Feat: API Playground (#10376)
/claim #10283 --------- Co-authored-by: Félix Malfait <felix@twenty.com> Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
@ -360,7 +360,8 @@
|
|||||||
"graphql": "16.8.0",
|
"graphql": "16.8.0",
|
||||||
"type-fest": "4.10.1",
|
"type-fest": "4.10.1",
|
||||||
"typescript": "5.3.3",
|
"typescript": "5.3.3",
|
||||||
"prosemirror-model": "1.23.0"
|
"prosemirror-model": "1.23.0",
|
||||||
|
"yjs": "13.6.18"
|
||||||
},
|
},
|
||||||
"version": "0.2.1",
|
"version": "0.2.1",
|
||||||
"nx": {},
|
"nx": {},
|
||||||
|
|||||||
@ -37,7 +37,7 @@ const config: StorybookConfig = {
|
|||||||
'@storybook/addon-docs',
|
'@storybook/addon-docs',
|
||||||
'@storybook/addon-essentials/docs',
|
'@storybook/addon-essentials/docs',
|
||||||
],
|
],
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
addons: [
|
addons: [
|
||||||
'@storybook/addon-links',
|
'@storybook/addon-links',
|
||||||
|
|||||||
@ -38,6 +38,7 @@
|
|||||||
"@nivo/core": "^0.87.0",
|
"@nivo/core": "^0.87.0",
|
||||||
"@nivo/line": "^0.87.0",
|
"@nivo/line": "^0.87.0",
|
||||||
"@react-pdf/renderer": "^4.1.6",
|
"@react-pdf/renderer": "^4.1.6",
|
||||||
|
"@scalar/api-reference-react": "^0.4.36",
|
||||||
"@tiptap/core": "^2.10.4",
|
"@tiptap/core": "^2.10.4",
|
||||||
"@tiptap/extension-document": "^2.10.4",
|
"@tiptap/extension-document": "^2.10.4",
|
||||||
"@tiptap/extension-hard-break": "^2.10.4",
|
"@tiptap/extension-hard-break": "^2.10.4",
|
||||||
|
|||||||
@ -7,6 +7,38 @@ import { SettingsPath } from '@/types/SettingsPath';
|
|||||||
import { FeatureFlagKey } from '~/generated-metadata/graphql';
|
import { FeatureFlagKey } from '~/generated-metadata/graphql';
|
||||||
import { SettingsPermissions } from '~/generated/graphql';
|
import { SettingsPermissions } from '~/generated/graphql';
|
||||||
|
|
||||||
|
const SettingsApiKeys = lazy(() =>
|
||||||
|
import('~/pages/settings/developers/api-keys/SettingsApiKeys').then(
|
||||||
|
(module) => ({
|
||||||
|
default: module.SettingsApiKeys,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const SettingsGraphQLPlayground = lazy(() =>
|
||||||
|
import(
|
||||||
|
'~/pages/settings/developers/playground/SettingsGraphQLPlayground'
|
||||||
|
).then((module) => ({
|
||||||
|
default: module.SettingsGraphQLPlayground,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const SettingsRestPlayground = lazy(() =>
|
||||||
|
import('~/pages/settings/developers/playground/SettingsRestPlayground').then(
|
||||||
|
(module) => ({
|
||||||
|
default: module.SettingsRestPlayground,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const SettingsWebhooks = lazy(() =>
|
||||||
|
import(
|
||||||
|
'~/pages/settings/developers/webhooks/components/SettingsWebhooks'
|
||||||
|
).then((module) => ({
|
||||||
|
default: module.SettingsWebhooks,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
const SettingsAccountsCalendars = lazy(() =>
|
const SettingsAccountsCalendars = lazy(() =>
|
||||||
import('~/pages/settings/accounts/SettingsAccountsCalendars').then(
|
import('~/pages/settings/accounts/SettingsAccountsCalendars').then(
|
||||||
(module) => ({
|
(module) => ({
|
||||||
@ -137,12 +169,6 @@ const SettingsBilling = lazy(() =>
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
const SettingsDevelopers = lazy(() =>
|
|
||||||
import('~/pages/settings/developers/SettingsDevelopers').then((module) => ({
|
|
||||||
default: module.SettingsDevelopers,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
const SettingsIntegrations = lazy(() =>
|
const SettingsIntegrations = lazy(() =>
|
||||||
import('~/pages/settings/integrations/SettingsIntegrations').then(
|
import('~/pages/settings/integrations/SettingsIntegrations').then(
|
||||||
(module) => ({
|
(module) => ({
|
||||||
@ -376,9 +402,15 @@ export const SettingsRoutes = ({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<Route path={SettingsPath.APIs} element={<SettingsApiKeys />} />
|
||||||
|
<Route path={SettingsPath.Webhooks} element={<SettingsWebhooks />} />
|
||||||
<Route
|
<Route
|
||||||
path={SettingsPath.Developers}
|
path={`${SettingsPath.GraphQLPlayground}`}
|
||||||
element={<SettingsDevelopers />}
|
element={<SettingsGraphQLPlayground />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={`${SettingsPath.RestPlayground}/*`}
|
||||||
|
element={<SettingsRestPlayground />}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path={SettingsPath.DevelopersNewApiKey}
|
path={SettingsPath.DevelopersNewApiKey}
|
||||||
|
|||||||
@ -57,7 +57,9 @@ export const SettingsNavigationDrawerItems = () => {
|
|||||||
getSelectedIndexForSubItems(subItems);
|
getSelectedIndexForSubItems(subItems);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavigationDrawerItemGroup key={item.path}>
|
<NavigationDrawerItemGroup
|
||||||
|
key={item.path || `group-${index}`}
|
||||||
|
>
|
||||||
<SettingsNavigationDrawerItem
|
<SettingsNavigationDrawerItem
|
||||||
item={item}
|
item={item}
|
||||||
subItemState={
|
subItemState={
|
||||||
@ -72,7 +74,7 @@ export const SettingsNavigationDrawerItems = () => {
|
|||||||
/>
|
/>
|
||||||
{subItems.map((subItem, subIndex) => (
|
{subItems.map((subItem, subIndex) => (
|
||||||
<SettingsNavigationDrawerItem
|
<SettingsNavigationDrawerItem
|
||||||
key={subItem.path}
|
key={subItem.path || `subitem-${subIndex}`}
|
||||||
item={subItem}
|
item={subItem}
|
||||||
subItemState={
|
subItemState={
|
||||||
subItem.indentationLevel
|
subItem.indentationLevel
|
||||||
@ -90,7 +92,7 @@ export const SettingsNavigationDrawerItems = () => {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<SettingsNavigationDrawerItem
|
<SettingsNavigationDrawerItem
|
||||||
key={item.path}
|
key={item.path || `item-${index}`}
|
||||||
item={item}
|
item={item}
|
||||||
subItemState={
|
subItemState={
|
||||||
item.indentationLevel
|
item.indentationLevel
|
||||||
|
|||||||
@ -4,13 +4,15 @@ import { SettingsApiKeysFieldItemTableRow } from '@/settings/developers/componen
|
|||||||
import { ApiFieldItem } from '@/settings/developers/types/api-key/ApiFieldItem';
|
import { ApiFieldItem } from '@/settings/developers/types/api-key/ApiFieldItem';
|
||||||
import { ApiKey } from '@/settings/developers/types/api-key/ApiKey';
|
import { ApiKey } from '@/settings/developers/types/api-key/ApiKey';
|
||||||
import { formatExpirations } from '@/settings/developers/utils/formatExpiration';
|
import { formatExpirations } from '@/settings/developers/utils/formatExpiration';
|
||||||
|
import { SettingsPath } from '@/types/SettingsPath';
|
||||||
import { Table } from '@/ui/layout/table/components/Table';
|
import { Table } from '@/ui/layout/table/components/Table';
|
||||||
import { TableBody } from '@/ui/layout/table/components/TableBody';
|
import { TableBody } from '@/ui/layout/table/components/TableBody';
|
||||||
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
|
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
|
||||||
import { TableRow } from '@/ui/layout/table/components/TableRow';
|
import { TableRow } from '@/ui/layout/table/components/TableRow';
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
import { MOBILE_VIEWPORT } from 'twenty-ui';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { MOBILE_VIEWPORT } from 'twenty-ui';
|
||||||
|
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
|
||||||
|
|
||||||
const StyledTableBody = styled(TableBody)`
|
const StyledTableBody = styled(TableBody)`
|
||||||
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
|
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
|
||||||
@ -53,7 +55,9 @@ export const SettingsApiKeysTable = () => {
|
|||||||
<SettingsApiKeysFieldItemTableRow
|
<SettingsApiKeysFieldItemTableRow
|
||||||
key={fieldItem.id}
|
key={fieldItem.id}
|
||||||
fieldItem={fieldItem as ApiFieldItem}
|
fieldItem={fieldItem as ApiFieldItem}
|
||||||
to={`/settings/developers/api-keys/${fieldItem.id}`}
|
to={getSettingsPath(SettingsPath.DevelopersApiKeyDetail, {
|
||||||
|
apiKeyId: fieldItem.id,
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</StyledTableBody>
|
</StyledTableBody>
|
||||||
|
|||||||
@ -1,18 +0,0 @@
|
|||||||
import { useLingui } from '@lingui/react/macro';
|
|
||||||
import { Button, IconBook2 } from 'twenty-ui';
|
|
||||||
|
|
||||||
export const SettingsReadDocumentationButton = () => {
|
|
||||||
const { t } = useLingui();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
title={t`Read documentation`}
|
|
||||||
variant="secondary"
|
|
||||||
accent="default"
|
|
||||||
size="small"
|
|
||||||
Icon={IconBook2}
|
|
||||||
to={'https://docs.twenty.com'}
|
|
||||||
target="_blank"
|
|
||||||
></Button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -145,7 +145,7 @@ export const useWebhookUpdateForm = ({
|
|||||||
|
|
||||||
const deleteWebhook = async () => {
|
const deleteWebhook = async () => {
|
||||||
await deleteOneWebhook(webhookId);
|
await deleteOneWebhook(webhookId);
|
||||||
navigate(SettingsPath.Developers);
|
navigate(SettingsPath.Webhooks);
|
||||||
};
|
};
|
||||||
|
|
||||||
useFindOneRecord({
|
useFindOneRecord({
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
|
IconApi,
|
||||||
IconApps,
|
IconApps,
|
||||||
IconAt,
|
IconAt,
|
||||||
IconCalendarEvent,
|
IconCalendarEvent,
|
||||||
IconCode,
|
|
||||||
IconColorSwatch,
|
IconColorSwatch,
|
||||||
IconComponent,
|
IconComponent,
|
||||||
IconCurrencyDollar,
|
IconCurrencyDollar,
|
||||||
@ -18,6 +18,7 @@ import {
|
|||||||
IconSettings,
|
IconSettings,
|
||||||
IconUserCircle,
|
IconUserCircle,
|
||||||
IconUsers,
|
IconUsers,
|
||||||
|
IconWebhook,
|
||||||
} from 'twenty-ui';
|
} from 'twenty-ui';
|
||||||
|
|
||||||
import { SettingsPath } from '@/types/SettingsPath';
|
import { SettingsPath } from '@/types/SettingsPath';
|
||||||
@ -159,9 +160,16 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
|
|||||||
isAdvanced: true,
|
isAdvanced: true,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
label: t`API & Webhooks`,
|
label: t`APIs`,
|
||||||
path: SettingsPath.Developers,
|
path: SettingsPath.APIs,
|
||||||
Icon: IconCode,
|
Icon: IconApi,
|
||||||
|
isAdvanced: true,
|
||||||
|
isHidden: !permissionMap[SettingsPermissions.API_KEYS_AND_WEBHOOKS],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t`Webhooks`,
|
||||||
|
path: SettingsPath.Webhooks,
|
||||||
|
Icon: IconWebhook,
|
||||||
isAdvanced: true,
|
isAdvanced: true,
|
||||||
isHidden: !permissionMap[SettingsPermissions.API_KEYS_AND_WEBHOOKS],
|
isHidden: !permissionMap[SettingsPermissions.API_KEYS_AND_WEBHOOKS],
|
||||||
},
|
},
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 113 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
@ -0,0 +1,55 @@
|
|||||||
|
import { playgroundApiKeyState } from '@/settings/playground/states/playgroundApiKeyState';
|
||||||
|
import { PlaygroundSchemas } from '@/settings/playground/types/PlaygroundSchemas';
|
||||||
|
import { explorerPlugin } from '@graphiql/plugin-explorer';
|
||||||
|
import '@graphiql/plugin-explorer/dist/style.css';
|
||||||
|
import { createGraphiQLFetcher } from '@graphiql/toolkit';
|
||||||
|
import { GraphiQL } from 'graphiql';
|
||||||
|
import 'graphiql/graphiql.css';
|
||||||
|
import { useContext } from 'react';
|
||||||
|
import { useRecoilValue } from 'recoil';
|
||||||
|
import { ThemeContext } from 'twenty-ui';
|
||||||
|
import { REACT_APP_SERVER_BASE_URL } from '~/config';
|
||||||
|
|
||||||
|
type GraphQLPlaygroundProps = {
|
||||||
|
onError(): void;
|
||||||
|
schema: PlaygroundSchemas;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const schemaToPath = {
|
||||||
|
[PlaygroundSchemas.CORE]: 'graphql',
|
||||||
|
[PlaygroundSchemas.METADATA]: 'metadata',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GraphQLPlayground = ({
|
||||||
|
onError,
|
||||||
|
schema,
|
||||||
|
}: GraphQLPlaygroundProps) => {
|
||||||
|
const playgroundApiKey = useRecoilValue(playgroundApiKeyState);
|
||||||
|
const baseUrl = REACT_APP_SERVER_BASE_URL + '/' + schemaToPath[schema];
|
||||||
|
|
||||||
|
const { theme } = useContext(ThemeContext);
|
||||||
|
|
||||||
|
if (!playgroundApiKey) {
|
||||||
|
onError();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const explorer = explorerPlugin({
|
||||||
|
showAttribution: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetcher = createGraphiQLFetcher({
|
||||||
|
url: baseUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GraphiQL
|
||||||
|
forcedTheme={theme.name as 'light' | 'dark'}
|
||||||
|
plugins={[explorer]}
|
||||||
|
fetcher={fetcher}
|
||||||
|
defaultHeaders={JSON.stringify({
|
||||||
|
Authorization: `Bearer ${playgroundApiKey}`,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,193 @@
|
|||||||
|
import { playgroundApiKeyState } from '@/settings/playground/states/playgroundApiKeyState';
|
||||||
|
import { PlaygroundSchemas } from '@/settings/playground/types/PlaygroundSchemas';
|
||||||
|
import { PlaygroundTypes } from '@/settings/playground/types/PlaygroundTypes';
|
||||||
|
import { SettingsPath } from '@/types/SettingsPath';
|
||||||
|
import { Select } from '@/ui/input/components/Select';
|
||||||
|
import { TextInput } from '@/ui/input/components/TextInput';
|
||||||
|
import styled from '@emotion/styled';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useLingui } from '@lingui/react/macro';
|
||||||
|
import { Controller, useForm } from 'react-hook-form';
|
||||||
|
import { useRecoilState } from 'recoil';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
IconApi,
|
||||||
|
IconBracketsAngle,
|
||||||
|
IconBrandGraphql,
|
||||||
|
IconFolderRoot,
|
||||||
|
} from 'twenty-ui';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { REACT_APP_SERVER_BASE_URL } from '~/config';
|
||||||
|
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
||||||
|
|
||||||
|
const playgroundSetupFormSchema = z.object({
|
||||||
|
apiKeyForPlayground: z.string(),
|
||||||
|
schema: z.nativeEnum(PlaygroundSchemas),
|
||||||
|
playgroundType: z.nativeEnum(PlaygroundTypes),
|
||||||
|
});
|
||||||
|
|
||||||
|
type PlaygroundSetupFormValues = z.infer<typeof playgroundSetupFormSchema>;
|
||||||
|
|
||||||
|
const StyledForm = styled.form`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.5fr 1fr 1fr 0.5fr;
|
||||||
|
align-items: end;
|
||||||
|
gap: ${({ theme }) => theme.spacing(2)};
|
||||||
|
margin-bottom: ${({ theme }) => theme.spacing(2)};
|
||||||
|
width: 100%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const PlaygroundSetupForm = () => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const navigateSettings = useNavigateSettings();
|
||||||
|
const [playgroundApiKey, setPlaygroundApiKey] = useRecoilState(
|
||||||
|
playgroundApiKeyState,
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { isSubmitting },
|
||||||
|
setError,
|
||||||
|
} = useForm<PlaygroundSetupFormValues>({
|
||||||
|
mode: 'onTouched',
|
||||||
|
resolver: zodResolver(playgroundSetupFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
schema: PlaygroundSchemas.CORE,
|
||||||
|
playgroundType: PlaygroundTypes.REST,
|
||||||
|
apiKeyForPlayground: playgroundApiKey || '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const validateApiKey = async (values: PlaygroundSetupFormValues) => {
|
||||||
|
try {
|
||||||
|
// Validate by fetching the schema (but not storing it)
|
||||||
|
const response = await fetch(
|
||||||
|
`${REACT_APP_SERVER_BASE_URL}/open-api/${values.schema}`,
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${values.apiKeyForPlayground}` },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const openAPIReference = await response.json();
|
||||||
|
|
||||||
|
if (!openAPIReference.tags) {
|
||||||
|
throw new Error('Invalid API Key');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
t`Failed to validate API key. Please check your API key and try again.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (values: PlaygroundSetupFormValues) => {
|
||||||
|
try {
|
||||||
|
await validateApiKey(values);
|
||||||
|
|
||||||
|
setPlaygroundApiKey(values.apiKeyForPlayground);
|
||||||
|
|
||||||
|
const path =
|
||||||
|
values.playgroundType === PlaygroundTypes.GRAPHQL
|
||||||
|
? SettingsPath.GraphQLPlayground
|
||||||
|
: SettingsPath.RestPlayground;
|
||||||
|
|
||||||
|
navigateSettings(path, {
|
||||||
|
schema: values.schema.toLowerCase(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
setError('apiKeyForPlayground', {
|
||||||
|
type: 'manual',
|
||||||
|
message:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: t`An unexpected error occurred`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledForm onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<Controller
|
||||||
|
name="apiKeyForPlayground"
|
||||||
|
control={control}
|
||||||
|
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||||
|
<TextInput
|
||||||
|
label={t`API Key`}
|
||||||
|
placeholder="Enter your API key"
|
||||||
|
value={value}
|
||||||
|
onChange={(newValue) => {
|
||||||
|
onChange(newValue);
|
||||||
|
setPlaygroundApiKey(newValue);
|
||||||
|
}}
|
||||||
|
error={error?.message}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="schema"
|
||||||
|
control={control}
|
||||||
|
defaultValue={PlaygroundSchemas.CORE}
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<Select
|
||||||
|
dropdownId="schema"
|
||||||
|
label={t`Schema`}
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
value: PlaygroundSchemas.CORE,
|
||||||
|
label: t`Core`,
|
||||||
|
Icon: IconFolderRoot,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: PlaygroundSchemas.METADATA,
|
||||||
|
label: t`Metadata`,
|
||||||
|
Icon: IconBracketsAngle,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="playgroundType"
|
||||||
|
control={control}
|
||||||
|
defaultValue={PlaygroundTypes.REST}
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<Select
|
||||||
|
dropdownId="apiPlaygroundType"
|
||||||
|
label={t`API`}
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
value: PlaygroundTypes.REST,
|
||||||
|
label: t`REST`,
|
||||||
|
Icon: IconApi,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: PlaygroundTypes.GRAPHQL,
|
||||||
|
label: t`GraphQL`,
|
||||||
|
Icon: IconBrandGraphql,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title={t`Launch`}
|
||||||
|
variant="primary"
|
||||||
|
accent="blue"
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</StyledForm>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,77 @@
|
|||||||
|
import { playgroundApiKeyState } from '@/settings/playground/states/playgroundApiKeyState';
|
||||||
|
import { PlaygroundSchemas } from '@/settings/playground/types/PlaygroundSchemas';
|
||||||
|
import { SettingsPath } from '@/types/SettingsPath';
|
||||||
|
import { useTheme } from '@emotion/react';
|
||||||
|
import styled from '@emotion/styled';
|
||||||
|
import { lazy, Suspense } from 'react';
|
||||||
|
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
|
||||||
|
import { useRecoilValue } from 'recoil';
|
||||||
|
import { REACT_APP_SERVER_BASE_URL } from '~/config';
|
||||||
|
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
|
||||||
|
|
||||||
|
const StyledContainer = styled.div`
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: scroll;
|
||||||
|
width: 100%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ApiReferenceReact = lazy(() =>
|
||||||
|
import('@scalar/api-reference-react').then((module) => ({
|
||||||
|
default: module.ApiReferenceReact,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
type RestPlaygroundProps = {
|
||||||
|
onError(): void;
|
||||||
|
schema: PlaygroundSchemas;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RestPlayground = ({ onError, schema }: RestPlaygroundProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const playgroundApiKey = useRecoilValue(playgroundApiKeyState);
|
||||||
|
|
||||||
|
if (!playgroundApiKey) {
|
||||||
|
onError();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledContainer>
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<SkeletonTheme
|
||||||
|
baseColor={theme.background.tertiary}
|
||||||
|
highlightColor={theme.background.transparent.lighter}
|
||||||
|
borderRadius={4}
|
||||||
|
>
|
||||||
|
<Skeleton width="100%" height="100%" />
|
||||||
|
</SkeletonTheme>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ApiReferenceReact
|
||||||
|
configuration={{
|
||||||
|
spec: {
|
||||||
|
url: `${REACT_APP_SERVER_BASE_URL}/open-api/${schema}?token=${playgroundApiKey}`,
|
||||||
|
},
|
||||||
|
authentication: {
|
||||||
|
http: {
|
||||||
|
bearer: playgroundApiKey
|
||||||
|
? { token: playgroundApiKey }
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
baseServerURL: REACT_APP_SERVER_BASE_URL + '/' + schema,
|
||||||
|
forceDarkModeState: theme.name === 'dark' ? 'dark' : 'light',
|
||||||
|
hideClientButton: true,
|
||||||
|
hideDarkModeToggle: true,
|
||||||
|
pathRouting: {
|
||||||
|
basePath: getSettingsPath(SettingsPath.RestPlayground, {
|
||||||
|
schema,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</StyledContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
import styled from '@emotion/styled';
|
||||||
|
import { Card } from 'twenty-ui';
|
||||||
|
|
||||||
|
import DarkCoverImage from '../assets/cover-dark.png';
|
||||||
|
import LightCoverImage from '../assets/cover-light.png';
|
||||||
|
|
||||||
|
export const StyledSettingsApiPlaygroundCoverImage = styled(Card)`
|
||||||
|
align-items: center;
|
||||||
|
background-image: ${({ theme }) =>
|
||||||
|
theme.name === 'light'
|
||||||
|
? `url('${LightCoverImage.toString()}')`
|
||||||
|
: `url('${DarkCoverImage.toString()}')`};
|
||||||
|
background-size: cover;
|
||||||
|
border-radius: ${({ theme }) => theme.border.radius.md};
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
height: 153px;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
margin-top: ${({ theme }) => theme.spacing(4)};
|
||||||
|
margin-bottom: ${({ theme }) => theme.spacing(4)};
|
||||||
|
`;
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
import { GraphQLPlayground } from '@/settings/playground/components/GraphQLPlayground';
|
||||||
|
import { playgroundApiKeyState } from '@/settings/playground/states/playgroundApiKeyState';
|
||||||
|
import { action } from '@storybook/addon-actions';
|
||||||
|
import { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useSetRecoilState } from 'recoil';
|
||||||
|
import { ComponentDecorator, ComponentWithRouterDecorator } from 'twenty-ui';
|
||||||
|
import { ComponentWithRecoilScopeDecorator } from '~/testing/decorators/ComponentWithRecoilScopeDecorator';
|
||||||
|
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
|
||||||
|
import { graphqlMocks } from '~/testing/graphqlMocks';
|
||||||
|
|
||||||
|
const PlaygroundApiKeySetterEffect = () => {
|
||||||
|
const setPlaygroundApiKey = useSetRecoilState(playgroundApiKeyState);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPlaygroundApiKey('test-api-key-123');
|
||||||
|
}, [setPlaygroundApiKey]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const meta: Meta<typeof GraphQLPlayground> = {
|
||||||
|
title: 'Modules/Settings/Playground/GraphQLPlayground',
|
||||||
|
component: GraphQLPlayground,
|
||||||
|
decorators: [
|
||||||
|
ComponentDecorator,
|
||||||
|
I18nFrontDecorator,
|
||||||
|
ComponentWithRouterDecorator,
|
||||||
|
ComponentWithRecoilScopeDecorator,
|
||||||
|
],
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component:
|
||||||
|
'GraphQLPlayground provides an interactive environment to test GraphQL queries with authentication.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
msw: graphqlMocks,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof GraphQLPlayground>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
onError: action('GraphQL Playground encountered unexpected error'),
|
||||||
|
},
|
||||||
|
decorators: [
|
||||||
|
(Story) => (
|
||||||
|
<>
|
||||||
|
<PlaygroundApiKeySetterEffect />
|
||||||
|
<Story />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
import { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import { ComponentDecorator, RouterDecorator } from 'twenty-ui';
|
||||||
|
import { ComponentWithRecoilScopeDecorator } from '~/testing/decorators/ComponentWithRecoilScopeDecorator';
|
||||||
|
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
|
||||||
|
import { PlaygroundSetupForm } from '../PlaygroundSetupForm';
|
||||||
|
|
||||||
|
const meta: Meta<typeof PlaygroundSetupForm> = {
|
||||||
|
title: 'Modules/Settings/Playground/PlaygroundSetupForm',
|
||||||
|
component: PlaygroundSetupForm,
|
||||||
|
decorators: [
|
||||||
|
ComponentDecorator,
|
||||||
|
RouterDecorator,
|
||||||
|
I18nFrontDecorator,
|
||||||
|
ComponentWithRecoilScopeDecorator,
|
||||||
|
],
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component:
|
||||||
|
'A form component for setting up the API playground with API key, schema selection, and playground type.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof PlaygroundSetupForm>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {},
|
||||||
|
};
|
||||||
@ -0,0 +1,132 @@
|
|||||||
|
import { playgroundApiKeyState } from '@/settings/playground/states/playgroundApiKeyState';
|
||||||
|
import { PlaygroundSchemas } from '@/settings/playground/types/PlaygroundSchemas';
|
||||||
|
import { action } from '@storybook/addon-actions';
|
||||||
|
import { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useSetRecoilState } from 'recoil';
|
||||||
|
import { ComponentDecorator } from 'twenty-ui';
|
||||||
|
import { ComponentWithRecoilScopeDecorator } from '~/testing/decorators/ComponentWithRecoilScopeDecorator';
|
||||||
|
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
|
||||||
|
import { RestPlayground } from '../RestPlayground';
|
||||||
|
|
||||||
|
const PlaygroundApiKeySetterEffect = () => {
|
||||||
|
const setPlaygroundApiKey = useSetRecoilState(playgroundApiKeyState);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPlaygroundApiKey('test-api-key-123');
|
||||||
|
}, [setPlaygroundApiKey]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openApiSpec = {
|
||||||
|
openapi: '3.1.1',
|
||||||
|
info: {
|
||||||
|
title: 'Twenty Api',
|
||||||
|
description:
|
||||||
|
'This is a **Twenty REST/API** playground based on the **OpenAPI 3.1 specification**.',
|
||||||
|
version: '1.0.0',
|
||||||
|
},
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
url: 'http://localhost:3000',
|
||||||
|
description: 'Local server',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
paths: {
|
||||||
|
'/companies': {
|
||||||
|
get: {
|
||||||
|
tags: ['Companies'],
|
||||||
|
summary: 'List companies',
|
||||||
|
operationId: 'listCompanies',
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
bearerAuth: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
'200': {
|
||||||
|
description: 'Successful response',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string' },
|
||||||
|
name: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
examples: {
|
||||||
|
'List of companies': {
|
||||||
|
value: [
|
||||||
|
{ id: '1', name: 'Acme Corp' },
|
||||||
|
{ id: '2', name: 'Globex Corporation' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
securitySchemes: {
|
||||||
|
bearerAuth: {
|
||||||
|
type: 'http',
|
||||||
|
scheme: 'bearer',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const meta: Meta<typeof RestPlayground> = {
|
||||||
|
title: 'Modules/Settings/Playground/RestPlayground',
|
||||||
|
component: RestPlayground,
|
||||||
|
decorators: [
|
||||||
|
ComponentDecorator,
|
||||||
|
I18nFrontDecorator,
|
||||||
|
ComponentWithRecoilScopeDecorator,
|
||||||
|
],
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component:
|
||||||
|
'RestPlayground provides an interactive environment to test REST API endpoints with authentication.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
msw: {
|
||||||
|
handlers: [
|
||||||
|
http.get('*/open-api/*', () => {
|
||||||
|
return HttpResponse.json(openApiSpec);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof RestPlayground>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
onError: (...args) => {
|
||||||
|
action('REST Playground encountered unexpected error')(...args);
|
||||||
|
},
|
||||||
|
schema: PlaygroundSchemas.CORE,
|
||||||
|
},
|
||||||
|
decorators: [
|
||||||
|
(Story) => (
|
||||||
|
<>
|
||||||
|
<PlaygroundApiKeySetterEffect />
|
||||||
|
<Story />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
import { createState } from 'twenty-ui';
|
||||||
|
|
||||||
|
export const openAPIReferenceState = createState<any>({
|
||||||
|
key: 'OpenAPIReference',
|
||||||
|
defaultValue: null,
|
||||||
|
});
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
import { atom } from 'recoil';
|
||||||
|
import { localStorageEffect } from '~/utils/recoil-effects';
|
||||||
|
|
||||||
|
export const playgroundApiKeyState = atom<string | null>({
|
||||||
|
key: 'playgroundApiKeyState',
|
||||||
|
default: null,
|
||||||
|
effects: [localStorageEffect()],
|
||||||
|
});
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
export enum PlaygroundSchemas {
|
||||||
|
METADATA = 'metadata',
|
||||||
|
CORE = 'core',
|
||||||
|
}
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
export enum PlaygroundTypes {
|
||||||
|
GRAPHQL = 'graphql',
|
||||||
|
REST = 'rest',
|
||||||
|
}
|
||||||
@ -19,9 +19,11 @@ export enum SettingsPath {
|
|||||||
WorkspaceMembersPage = 'workspace-members',
|
WorkspaceMembersPage = 'workspace-members',
|
||||||
Workspace = 'workspace',
|
Workspace = 'workspace',
|
||||||
Domain = 'domain',
|
Domain = 'domain',
|
||||||
Developers = 'developers',
|
APIs = 'api-keys',
|
||||||
DevelopersNewApiKey = 'developers/api-keys/new',
|
RestPlayground = 'playground/rest/:schema',
|
||||||
DevelopersApiKeyDetail = 'developers/api-keys/:apiKeyId',
|
GraphQLPlayground = 'playground/graphql/:schema',
|
||||||
|
DevelopersNewApiKey = 'api-keys/new',
|
||||||
|
DevelopersApiKeyDetail = 'api-keys/:apiKeyId',
|
||||||
Integrations = 'integrations',
|
Integrations = 'integrations',
|
||||||
IntegrationDatabase = 'integrations/:databaseKey',
|
IntegrationDatabase = 'integrations/:databaseKey',
|
||||||
IntegrationDatabaseConnection = 'integrations/:databaseKey/:connectionId',
|
IntegrationDatabaseConnection = 'integrations/:databaseKey/:connectionId',
|
||||||
|
|||||||
@ -0,0 +1,59 @@
|
|||||||
|
import { PageHeader } from '@/ui/layout/page/components/PageHeader';
|
||||||
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbProps,
|
||||||
|
} from '@/ui/navigation/bread-crumb/components/Breadcrumb';
|
||||||
|
import styled from '@emotion/styled';
|
||||||
|
import { useLingui } from '@lingui/react/macro';
|
||||||
|
import '@scalar/api-reference-react/style.css';
|
||||||
|
import { IconButton, IconX, useIsMobile } from 'twenty-ui';
|
||||||
|
|
||||||
|
type FullScreenContainerProps = {
|
||||||
|
children: JSX.Element | JSX.Element[];
|
||||||
|
links: BreadcrumbProps['links'];
|
||||||
|
exitFullScreen(): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const StyledFullScreen = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100dvw;
|
||||||
|
height: 100dvh;
|
||||||
|
background: ${({ theme }) => theme.background.noisy};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledMainContainer = styled.div`
|
||||||
|
border-top: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const FullScreenContainer = ({
|
||||||
|
children,
|
||||||
|
links,
|
||||||
|
exitFullScreen,
|
||||||
|
}: FullScreenContainerProps) => {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
const handleExitFullScreen = () => {
|
||||||
|
exitFullScreen();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledFullScreen>
|
||||||
|
<PageHeader title={<Breadcrumb links={links} />}>
|
||||||
|
<IconButton
|
||||||
|
Icon={IconX}
|
||||||
|
dataTestId="close-button"
|
||||||
|
size={isMobile ? 'medium' : 'small'}
|
||||||
|
variant="secondary"
|
||||||
|
accent="default"
|
||||||
|
onClick={handleExitFullScreen}
|
||||||
|
ariaLabel={t`Exit Full Screen`}
|
||||||
|
/>
|
||||||
|
</PageHeader>
|
||||||
|
<StyledMainContainer>{children}</StyledMainContainer>
|
||||||
|
</StyledFullScreen>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
import { FullScreenContainer } from '@/ui/layout/fullscreen/components/FullScreenContainer';
|
||||||
|
import styled from '@emotion/styled';
|
||||||
|
import { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import { ComponentDecorator, ComponentWithRouterDecorator } from 'twenty-ui';
|
||||||
|
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
|
||||||
|
|
||||||
|
const meta: Meta<typeof FullScreenContainer> = {
|
||||||
|
title: 'UI/Layout/FullScreenContainer',
|
||||||
|
component: FullScreenContainer,
|
||||||
|
decorators: [
|
||||||
|
ComponentDecorator,
|
||||||
|
I18nFrontDecorator,
|
||||||
|
ComponentWithRouterDecorator,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof FullScreenContainer>;
|
||||||
|
|
||||||
|
const StyledContainer = styled.div`
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: white;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
children: <StyledContainer>This is full-screen content</StyledContainer>,
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
children: 'Layout',
|
||||||
|
href: '/',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
children: 'FullScreen',
|
||||||
|
href: '/',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exitFullScreen: () => {},
|
||||||
|
},
|
||||||
|
decorators: [ComponentDecorator],
|
||||||
|
};
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { SettingsPath } from '@/types/SettingsPath';
|
||||||
|
import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation';
|
||||||
|
|
||||||
|
export const useShowFullscreen = () => {
|
||||||
|
const { isMatchingLocation } = useIsMatchingLocation();
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
if (
|
||||||
|
isMatchingLocation('settings/' + SettingsPath.RestPlayground + '/*') ||
|
||||||
|
isMatchingLocation('settings/' + SettingsPath.GraphQLPlayground)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}, [isMatchingLocation]);
|
||||||
|
};
|
||||||
@ -8,6 +8,7 @@ import { useIsSettingsPage } from '@/navigation/hooks/useIsSettingsPage';
|
|||||||
import { OBJECT_SETTINGS_WIDTH } from '@/settings/data-model/constants/ObjectSettings';
|
import { OBJECT_SETTINGS_WIDTH } from '@/settings/data-model/constants/ObjectSettings';
|
||||||
import { SignInAppNavigationDrawerMock } from '@/sign-in-background-mock/components/SignInAppNavigationDrawerMock';
|
import { SignInAppNavigationDrawerMock } from '@/sign-in-background-mock/components/SignInAppNavigationDrawerMock';
|
||||||
import { SignInBackgroundMockPage } from '@/sign-in-background-mock/components/SignInBackgroundMockPage';
|
import { SignInBackgroundMockPage } from '@/sign-in-background-mock/components/SignInBackgroundMockPage';
|
||||||
|
import { useShowFullscreen } from '@/ui/layout/fullscreen/hooks/useShowFullscreen';
|
||||||
import { useShowAuthModal } from '@/ui/layout/hooks/useShowAuthModal';
|
import { useShowAuthModal } from '@/ui/layout/hooks/useShowAuthModal';
|
||||||
import { NAV_DRAWER_WIDTHS } from '@/ui/navigation/navigation-drawer/constants/NavDrawerWidths';
|
import { NAV_DRAWER_WIDTHS } from '@/ui/navigation/navigation-drawer/constants/NavDrawerWidths';
|
||||||
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
|
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
|
||||||
@ -69,6 +70,7 @@ export const DefaultLayout = () => {
|
|||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const windowsWidth = useScreenSize().width;
|
const windowsWidth = useScreenSize().width;
|
||||||
const showAuthModal = useShowAuthModal();
|
const showAuthModal = useShowAuthModal();
|
||||||
|
const useShowFullScreen = useShowFullscreen();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -90,7 +92,7 @@ export const DefaultLayout = () => {
|
|||||||
<StyledPageContainer
|
<StyledPageContainer
|
||||||
animate={{
|
animate={{
|
||||||
marginLeft:
|
marginLeft:
|
||||||
isSettingsPage && !isMobile
|
isSettingsPage && !isMobile && !useShowFullScreen
|
||||||
? (windowsWidth -
|
? (windowsWidth -
|
||||||
(OBJECT_SETTINGS_WIDTH +
|
(OBJECT_SETTINGS_WIDTH +
|
||||||
NAV_DRAWER_WIDTHS.menu.desktop.expanded +
|
NAV_DRAWER_WIDTHS.menu.desktop.expanded +
|
||||||
@ -102,7 +104,7 @@ export const DefaultLayout = () => {
|
|||||||
>
|
>
|
||||||
{showAuthModal ? (
|
{showAuthModal ? (
|
||||||
<StyledAppNavigationDrawerMock />
|
<StyledAppNavigationDrawerMock />
|
||||||
) : (
|
) : useShowFullScreen ? null : (
|
||||||
<StyledAppNavigationDrawer />
|
<StyledAppNavigationDrawer />
|
||||||
)}
|
)}
|
||||||
{showAuthModal ? (
|
{showAuthModal ? (
|
||||||
|
|||||||
@ -1,15 +1,44 @@
|
|||||||
import { ActivityRichTextEditor } from '@/activities/components/ActivityRichTextEditor';
|
|
||||||
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
|
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
|
||||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||||
import { isNewViewableRecordLoadingState } from '@/object-record/record-right-drawer/states/isNewViewableRecordLoading';
|
import { isNewViewableRecordLoadingState } from '@/object-record/record-right-drawer/states/isNewViewableRecordLoading';
|
||||||
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
|
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
|
||||||
|
import { useTheme } from '@emotion/react';
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
|
import { lazy, Suspense } from 'react';
|
||||||
|
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
|
||||||
import { useRecoilValue } from 'recoil';
|
import { useRecoilValue } from 'recoil';
|
||||||
|
|
||||||
|
const ActivityRichTextEditor = lazy(() =>
|
||||||
|
import('@/activities/components/ActivityRichTextEditor').then((module) => ({
|
||||||
|
default: module.ActivityRichTextEditor,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
const StyledShowPageActivityContainer = styled.div`
|
const StyledShowPageActivityContainer = styled.div`
|
||||||
margin-top: ${({ theme }) => theme.spacing(6)};
|
margin-top: ${({ theme }) => theme.spacing(6)};
|
||||||
width: 100%;
|
width: 100%;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const StyledSkeletonContainer = styled.div`
|
||||||
|
width: 100%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const LoadingSkeleton = () => {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledSkeletonContainer>
|
||||||
|
<SkeletonTheme
|
||||||
|
baseColor={theme.background.tertiary}
|
||||||
|
highlightColor={theme.background.transparent.lighter}
|
||||||
|
borderRadius={theme.border.radius.sm}
|
||||||
|
>
|
||||||
|
<Skeleton height={200} />
|
||||||
|
</SkeletonTheme>
|
||||||
|
</StyledSkeletonContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const ShowPageActivityContainer = ({
|
export const ShowPageActivityContainer = ({
|
||||||
targetableObject,
|
targetableObject,
|
||||||
}: {
|
}: {
|
||||||
@ -28,14 +57,16 @@ export const ShowPageActivityContainer = ({
|
|||||||
componentInstanceId={`scroll-wrapper-tab-list-${targetableObject.id}`}
|
componentInstanceId={`scroll-wrapper-tab-list-${targetableObject.id}`}
|
||||||
>
|
>
|
||||||
<StyledShowPageActivityContainer>
|
<StyledShowPageActivityContainer>
|
||||||
<ActivityRichTextEditor
|
<Suspense fallback={<LoadingSkeleton />}>
|
||||||
activityId={targetableObject.id}
|
<ActivityRichTextEditor
|
||||||
activityObjectNameSingular={
|
activityId={targetableObject.id}
|
||||||
targetableObject.targetObjectNameSingular as
|
activityObjectNameSingular={
|
||||||
| CoreObjectNameSingular.Note
|
targetableObject.targetObjectNameSingular as
|
||||||
| CoreObjectNameSingular.Task
|
| CoreObjectNameSingular.Note
|
||||||
}
|
| CoreObjectNameSingular.Task
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
</StyledShowPageActivityContainer>
|
</StyledShowPageActivityContainer>
|
||||||
</ScrollWrapper>
|
</ScrollWrapper>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Meta, StoryObj } from '@storybook/react';
|
import { Meta, StoryObj } from '@storybook/react';
|
||||||
import { within } from '@storybook/test';
|
import { within } from '@storybook/test';
|
||||||
|
|
||||||
import { SettingsDevelopers } from '~/pages/settings/developers/SettingsDevelopers';
|
import { SettingsApiKeys } from '~/pages/settings/developers/api-keys/SettingsApiKeys';
|
||||||
import {
|
import {
|
||||||
PageDecorator,
|
PageDecorator,
|
||||||
PageDecoratorArgs,
|
PageDecoratorArgs,
|
||||||
@ -9,10 +9,10 @@ import {
|
|||||||
import { graphqlMocks } from '~/testing/graphqlMocks';
|
import { graphqlMocks } from '~/testing/graphqlMocks';
|
||||||
|
|
||||||
const meta: Meta<PageDecoratorArgs> = {
|
const meta: Meta<PageDecoratorArgs> = {
|
||||||
title: 'Pages/Settings/Developers/SettingsDevelopers',
|
title: 'Pages/Settings/ApiKeys',
|
||||||
component: SettingsDevelopers,
|
component: SettingsApiKeys,
|
||||||
decorators: [PageDecorator],
|
decorators: [PageDecorator],
|
||||||
args: { routePath: '/settings/developers' },
|
args: { routePath: '/settings/apis' },
|
||||||
parameters: {
|
parameters: {
|
||||||
msw: graphqlMocks,
|
msw: graphqlMocks,
|
||||||
},
|
},
|
||||||
@ -20,7 +20,7 @@ const meta: Meta<PageDecoratorArgs> = {
|
|||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
|
|
||||||
export type Story = StoryObj<typeof SettingsDevelopers>;
|
export type Story = StoryObj<typeof SettingsApiKeys>;
|
||||||
|
|
||||||
export const Default: Story = {
|
export const Default: Story = {
|
||||||
play: async ({ canvasElement }) => {
|
play: async ({ canvasElement }) => {
|
||||||
@ -11,7 +11,7 @@ import { graphqlMocks } from '~/testing/graphqlMocks';
|
|||||||
import { sleep } from '~/utils/sleep';
|
import { sleep } from '~/utils/sleep';
|
||||||
|
|
||||||
const meta: Meta<PageDecoratorArgs> = {
|
const meta: Meta<PageDecoratorArgs> = {
|
||||||
title: 'Pages/Settings/Developers/ApiKeys/SettingsDevelopersApiKeyDetail',
|
title: 'Pages/Settings/ApiKeys/SettingsDevelopersApiKeyDetail',
|
||||||
component: SettingsDevelopersApiKeyDetail,
|
component: SettingsDevelopersApiKeyDetail,
|
||||||
decorators: [PageDecorator],
|
decorators: [PageDecorator],
|
||||||
args: {
|
args: {
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import { graphqlMocks } from '~/testing/graphqlMocks';
|
|||||||
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
|
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
|
||||||
|
|
||||||
const meta: Meta<PageDecoratorArgs> = {
|
const meta: Meta<PageDecoratorArgs> = {
|
||||||
title: 'Pages/Settings/Developers/ApiKeys/SettingsDevelopersApiKeysNew',
|
title: 'Pages/Settings/ApiKeys/SettingsDevelopersApiKeysNew',
|
||||||
component: SettingsDevelopersApiKeysNew,
|
component: SettingsDevelopersApiKeysNew,
|
||||||
decorators: [PageDecorator],
|
decorators: [PageDecorator],
|
||||||
args: { routePath: getSettingsPath(SettingsPath.DevelopersNewApiKey) },
|
args: { routePath: getSettingsPath(SettingsPath.DevelopersNewApiKey) },
|
||||||
|
|||||||
@ -0,0 +1,34 @@
|
|||||||
|
import { action } from '@storybook/addon-actions';
|
||||||
|
import { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import { ComponentDecorator, ComponentWithRouterDecorator } from 'twenty-ui';
|
||||||
|
import { SettingsGraphQLPlayground } from '~/pages/settings/developers/playground/SettingsGraphQLPlayground';
|
||||||
|
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
|
||||||
|
import { graphqlMocks } from '~/testing/graphqlMocks';
|
||||||
|
|
||||||
|
const meta: Meta<any> = {
|
||||||
|
title: 'Pages/Settings/Playground/GraphQLPlayground',
|
||||||
|
component: SettingsGraphQLPlayground,
|
||||||
|
decorators: [
|
||||||
|
ComponentDecorator,
|
||||||
|
I18nFrontDecorator,
|
||||||
|
ComponentWithRouterDecorator,
|
||||||
|
],
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component:
|
||||||
|
'GraphQLPlayground provides an interactive environment to test GraphQL queries with authentication.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
msw: graphqlMocks,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<any>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
onError: action('GraphQL Playground encountered unexpected error'),
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
import { action } from '@storybook/addon-actions';
|
||||||
|
import { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import { ComponentDecorator, ComponentWithRouterDecorator } from 'twenty-ui';
|
||||||
|
import { SettingsRestPlayground } from '~/pages/settings/developers/playground/SettingsRestPlayground';
|
||||||
|
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
|
||||||
|
import { graphqlMocks } from '~/testing/graphqlMocks';
|
||||||
|
|
||||||
|
const meta: Meta<typeof SettingsRestPlayground> = {
|
||||||
|
title: 'Pages/Settings/Playground/RestPlayground',
|
||||||
|
component: SettingsRestPlayground,
|
||||||
|
decorators: [
|
||||||
|
ComponentDecorator,
|
||||||
|
I18nFrontDecorator,
|
||||||
|
ComponentWithRouterDecorator,
|
||||||
|
],
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component:
|
||||||
|
'RestPlayground provides an interactive environment to test Open API queries with authentication.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
msw: {
|
||||||
|
handlers: graphqlMocks,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof SettingsRestPlayground>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
onError: action('Rest Playground encountered unexpected error'),
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -10,7 +10,7 @@ import {
|
|||||||
import { graphqlMocks } from '~/testing/graphqlMocks';
|
import { graphqlMocks } from '~/testing/graphqlMocks';
|
||||||
|
|
||||||
const meta: Meta<PageDecoratorArgs> = {
|
const meta: Meta<PageDecoratorArgs> = {
|
||||||
title: 'Pages/Settings/Developers/Webhooks/SettingsDevelopersWebhooksDetail',
|
title: 'Pages/Settings/Webhooks/SettingsDevelopersWebhooksDetail',
|
||||||
component: SettingsDevelopersWebhooksDetail,
|
component: SettingsDevelopersWebhooksDetail,
|
||||||
decorators: [PageDecorator],
|
decorators: [PageDecorator],
|
||||||
args: {
|
args: {
|
||||||
|
|||||||
@ -0,0 +1,33 @@
|
|||||||
|
import { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import { within } from '@storybook/test';
|
||||||
|
import { SettingsWebhooks } from '~/pages/settings/developers/webhooks/components/SettingsWebhooks';
|
||||||
|
|
||||||
|
import {
|
||||||
|
PageDecorator,
|
||||||
|
PageDecoratorArgs,
|
||||||
|
} from '~/testing/decorators/PageDecorator';
|
||||||
|
import { graphqlMocks } from '~/testing/graphqlMocks';
|
||||||
|
|
||||||
|
const meta: Meta<PageDecoratorArgs> = {
|
||||||
|
title: 'Pages/Settings/Webhooks',
|
||||||
|
component: SettingsWebhooks,
|
||||||
|
decorators: [PageDecorator],
|
||||||
|
args: { routePath: '/settings/webhooks' },
|
||||||
|
parameters: {
|
||||||
|
msw: graphqlMocks,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
export type Story = StoryObj<typeof SettingsWebhooks>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
|
||||||
|
await canvas.findAllByText('Webhooks', undefined, {
|
||||||
|
timeout: 3000,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,74 @@
|
|||||||
|
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||||
|
import { SettingsApiKeysTable } from '@/settings/developers/components/SettingsApiKeysTable';
|
||||||
|
import { PlaygroundSetupForm } from '@/settings/playground/components/PlaygroundSetupForm';
|
||||||
|
import { StyledSettingsApiPlaygroundCoverImage } from '@/settings/playground/components/SettingsPlaygroundCoverImage';
|
||||||
|
import { SettingsPath } from '@/types/SettingsPath';
|
||||||
|
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
|
||||||
|
import styled from '@emotion/styled';
|
||||||
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
|
import { Button, H2Title, IconPlus, MOBILE_VIEWPORT, Section } from 'twenty-ui';
|
||||||
|
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
|
||||||
|
|
||||||
|
const StyledButtonContainer = styled.div`
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: ${({ theme }) => theme.spacing(2)};
|
||||||
|
@media (max-width: ${MOBILE_VIEWPORT}px) {
|
||||||
|
padding-top: ${({ theme }) => theme.spacing(5)};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledContainer = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
gap: ${({ theme }) => theme.spacing(2)};
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SettingsApiKeys = () => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SubMenuTopBarContainer
|
||||||
|
title={t`APIs`}
|
||||||
|
links={[
|
||||||
|
{
|
||||||
|
children: <Trans>Workspace</Trans>,
|
||||||
|
href: getSettingsPath(SettingsPath.Workspace),
|
||||||
|
},
|
||||||
|
{ children: <Trans>APIs</Trans> },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<SettingsPageContainer>
|
||||||
|
<StyledContainer>
|
||||||
|
<Section>
|
||||||
|
<H2Title
|
||||||
|
title={t`Playground`}
|
||||||
|
description={t`Try our REST or GraphQL API playgrounds.`}
|
||||||
|
/>
|
||||||
|
<StyledSettingsApiPlaygroundCoverImage />
|
||||||
|
<PlaygroundSetupForm />
|
||||||
|
</Section>
|
||||||
|
</StyledContainer>
|
||||||
|
<StyledContainer>
|
||||||
|
<Section>
|
||||||
|
<H2Title
|
||||||
|
title={t`API keys`}
|
||||||
|
description={t`Active API keys created by you or your team.`}
|
||||||
|
/>
|
||||||
|
<SettingsApiKeysTable />
|
||||||
|
<StyledButtonContainer>
|
||||||
|
<Button
|
||||||
|
Icon={IconPlus}
|
||||||
|
title={t`Create API key`}
|
||||||
|
size="small"
|
||||||
|
variant="secondary"
|
||||||
|
to={getSettingsPath(SettingsPath.DevelopersNewApiKey)}
|
||||||
|
/>
|
||||||
|
</StyledButtonContainer>
|
||||||
|
</Section>
|
||||||
|
</StyledContainer>
|
||||||
|
</SettingsPageContainer>
|
||||||
|
</SubMenuTopBarContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -90,7 +90,7 @@ export const SettingsDevelopersApiKeyDetail = () => {
|
|||||||
updateOneRecordInput: { revokedAt: DateTime.now().toString() },
|
updateOneRecordInput: { revokedAt: DateTime.now().toString() },
|
||||||
});
|
});
|
||||||
if (redirect) {
|
if (redirect) {
|
||||||
navigate(SettingsPath.Developers);
|
navigate(SettingsPath.APIs);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
enqueueSnackBar(t`Error deleting api key: ${err}`, {
|
enqueueSnackBar(t`Error deleting api key: ${err}`, {
|
||||||
@ -166,8 +166,8 @@ export const SettingsDevelopersApiKeyDetail = () => {
|
|||||||
href: getSettingsPath(SettingsPath.Workspace),
|
href: getSettingsPath(SettingsPath.Workspace),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
children: t`Developers`,
|
children: t`APIs`,
|
||||||
href: getSettingsPath(SettingsPath.Developers),
|
href: getSettingsPath(SettingsPath.APIs),
|
||||||
},
|
},
|
||||||
{ children: t`${apiKeyName} API Key` },
|
{ children: t`${apiKeyName} API Key` },
|
||||||
]}
|
]}
|
||||||
|
|||||||
@ -86,8 +86,8 @@ export const SettingsDevelopersApiKeysNew = () => {
|
|||||||
href: getSettingsPath(SettingsPath.Workspace),
|
href: getSettingsPath(SettingsPath.Workspace),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
children: t`Developers`,
|
children: t`APIs`,
|
||||||
href: getSettingsPath(SettingsPath.Developers),
|
href: getSettingsPath(SettingsPath.APIs),
|
||||||
},
|
},
|
||||||
{ children: t`New Key` },
|
{ children: t`New Key` },
|
||||||
]}
|
]}
|
||||||
@ -95,7 +95,7 @@ export const SettingsDevelopersApiKeysNew = () => {
|
|||||||
<SaveAndCancelButtons
|
<SaveAndCancelButtons
|
||||||
isSaveDisabled={!canSave}
|
isSaveDisabled={!canSave}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
navigateSettings(SettingsPath.Developers);
|
navigateSettings(SettingsPath.APIs);
|
||||||
}}
|
}}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -0,0 +1,47 @@
|
|||||||
|
import { GraphQLPlayground } from '@/settings/playground/components/GraphQLPlayground';
|
||||||
|
import { PlaygroundSchemas } from '@/settings/playground/types/PlaygroundSchemas';
|
||||||
|
import { SettingsPath } from '@/types/SettingsPath';
|
||||||
|
|
||||||
|
import { FullScreenContainer } from '@/ui/layout/fullscreen/components/FullScreenContainer';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
||||||
|
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
|
||||||
|
|
||||||
|
export const SettingsGraphQLPlayground = () => {
|
||||||
|
const navigateSettings = useNavigateSettings();
|
||||||
|
const { schema: urlSchema = 'core' } = useParams<{ schema: string }>();
|
||||||
|
|
||||||
|
// Convert lowercase URL parameter to PlaygroundSchemas enum
|
||||||
|
const schema =
|
||||||
|
urlSchema === 'metadata'
|
||||||
|
? PlaygroundSchemas.METADATA
|
||||||
|
: PlaygroundSchemas.CORE;
|
||||||
|
|
||||||
|
const handleExitFullScreen = () => {
|
||||||
|
navigateSettings(SettingsPath.APIs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOnError = () => {
|
||||||
|
handleExitFullScreen();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FullScreenContainer
|
||||||
|
exitFullScreen={handleExitFullScreen}
|
||||||
|
links={[
|
||||||
|
{
|
||||||
|
children: <Trans>Workspace</Trans>,
|
||||||
|
href: getSettingsPath(SettingsPath.Workspace),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
children: <Trans>APIs</Trans>,
|
||||||
|
href: getSettingsPath(SettingsPath.APIs),
|
||||||
|
},
|
||||||
|
{ children: <Trans>GraphQL API Playground</Trans> },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<GraphQLPlayground onError={handleOnError} schema={schema} />
|
||||||
|
</FullScreenContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
import { RestPlayground } from '@/settings/playground/components/RestPlayground';
|
||||||
|
import { PlaygroundSchemas } from '@/settings/playground/types/PlaygroundSchemas';
|
||||||
|
import { SettingsPath } from '@/types/SettingsPath';
|
||||||
|
import { FullScreenContainer } from '@/ui/layout/fullscreen/components/FullScreenContainer';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
||||||
|
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
|
||||||
|
|
||||||
|
export const SettingsRestPlayground = () => {
|
||||||
|
const navigateSettings = useNavigateSettings();
|
||||||
|
const { schema = PlaygroundSchemas.CORE } = useParams<{
|
||||||
|
schema: PlaygroundSchemas;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const handleExitFullScreen = () => {
|
||||||
|
navigateSettings(SettingsPath.APIs);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FullScreenContainer
|
||||||
|
exitFullScreen={handleExitFullScreen}
|
||||||
|
links={[
|
||||||
|
{
|
||||||
|
children: <Trans>Workspace</Trans>,
|
||||||
|
href: getSettingsPath(SettingsPath.Workspace),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
children: <Trans>APIs</Trans>,
|
||||||
|
href: getSettingsPath(SettingsPath.APIs),
|
||||||
|
},
|
||||||
|
{ children: <Trans>REST</Trans> },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<RestPlayground
|
||||||
|
schema={schema}
|
||||||
|
onError={() => navigateSettings(SettingsPath.APIs)}
|
||||||
|
/>
|
||||||
|
</FullScreenContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -128,10 +128,6 @@ export const SettingsDevelopersWebhooksDetail = () => {
|
|||||||
children: t`Workspace`,
|
children: t`Workspace`,
|
||||||
href: getSettingsPath(SettingsPath.Workspace),
|
href: getSettingsPath(SettingsPath.Workspace),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
children: t`Developers`,
|
|
||||||
href: getSettingsPath(SettingsPath.Developers),
|
|
||||||
},
|
|
||||||
{ children: t`Webhook` },
|
{ children: t`Webhook` },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,7 +1,4 @@
|
|||||||
import { v4 } from 'uuid';
|
|
||||||
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||||
import { SettingsApiKeysTable } from '@/settings/developers/components/SettingsApiKeysTable';
|
|
||||||
import { SettingsReadDocumentationButton } from '@/settings/developers/components/SettingsReadDocumentationButton';
|
|
||||||
import { SettingsWebhooksTable } from '@/settings/developers/components/SettingsWebhooksTable';
|
import { SettingsWebhooksTable } from '@/settings/developers/components/SettingsWebhooksTable';
|
||||||
import { SettingsPath } from '@/types/SettingsPath';
|
import { SettingsPath } from '@/types/SettingsPath';
|
||||||
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
|
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
|
||||||
@ -9,6 +6,7 @@ import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
|
|||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import { Button, H2Title, IconPlus, MOBILE_VIEWPORT, Section } from 'twenty-ui';
|
import { Button, H2Title, IconPlus, MOBILE_VIEWPORT, Section } from 'twenty-ui';
|
||||||
|
import { v4 } from 'uuid';
|
||||||
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
|
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
|
||||||
|
|
||||||
const StyledButtonContainer = styled.div`
|
const StyledButtonContainer = styled.div`
|
||||||
@ -27,40 +25,23 @@ const StyledContainer = styled.div<{ isMobile: boolean }>`
|
|||||||
gap: ${({ theme }) => theme.spacing(2)};
|
gap: ${({ theme }) => theme.spacing(2)};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const SettingsDevelopers = () => {
|
export const SettingsWebhooks = () => {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SubMenuTopBarContainer
|
<SubMenuTopBarContainer
|
||||||
title={t`Developers`}
|
title={t`Webhooks`}
|
||||||
actionButton={<SettingsReadDocumentationButton />}
|
|
||||||
links={[
|
links={[
|
||||||
{
|
{
|
||||||
children: <Trans>Workspace</Trans>,
|
children: <Trans>Workspace</Trans>,
|
||||||
href: getSettingsPath(SettingsPath.Workspace),
|
href: getSettingsPath(SettingsPath.Workspace),
|
||||||
},
|
},
|
||||||
{ children: <Trans>Developers</Trans> },
|
{ children: <Trans>Webhooks</Trans> },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<SettingsPageContainer>
|
<SettingsPageContainer>
|
||||||
<StyledContainer isMobile={isMobile}>
|
<StyledContainer isMobile={isMobile}>
|
||||||
<Section>
|
|
||||||
<H2Title
|
|
||||||
title={t`API keys`}
|
|
||||||
description={t`Active API keys created by you or your team.`}
|
|
||||||
/>
|
|
||||||
<SettingsApiKeysTable />
|
|
||||||
<StyledButtonContainer>
|
|
||||||
<Button
|
|
||||||
Icon={IconPlus}
|
|
||||||
title={t`Create API key`}
|
|
||||||
size="small"
|
|
||||||
variant="secondary"
|
|
||||||
to={getSettingsPath(SettingsPath.DevelopersNewApiKey)}
|
|
||||||
/>
|
|
||||||
</StyledButtonContainer>
|
|
||||||
</Section>
|
|
||||||
<Section>
|
<Section>
|
||||||
<H2Title
|
<H2Title
|
||||||
title={t`Webhooks`}
|
title={t`Webhooks`}
|
||||||
@ -3,15 +3,14 @@ import { Trans, useLingui } from '@lingui/react/macro';
|
|||||||
import { H2Title, IconLock, Section, Tag } from 'twenty-ui';
|
import { H2Title, IconLock, Section, Tag } from 'twenty-ui';
|
||||||
|
|
||||||
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||||
import { SettingsReadDocumentationButton } from '@/settings/developers/components/SettingsReadDocumentationButton';
|
|
||||||
import { SettingsSSOIdentitiesProvidersListCard } from '@/settings/security/components/SSO/SettingsSSOIdentitiesProvidersListCard';
|
import { SettingsSSOIdentitiesProvidersListCard } from '@/settings/security/components/SSO/SettingsSSOIdentitiesProvidersListCard';
|
||||||
import { SettingsSecurityAuthProvidersOptionsList } from '@/settings/security/components/SettingsSecurityAuthProvidersOptionsList';
|
import { SettingsSecurityAuthProvidersOptionsList } from '@/settings/security/components/SettingsSecurityAuthProvidersOptionsList';
|
||||||
|
import { SettingsApprovedAccessDomainsListCard } from '@/settings/security/components/approvedAccessDomains/SettingsApprovedAccessDomainsListCard';
|
||||||
import { SettingsPath } from '@/types/SettingsPath';
|
import { SettingsPath } from '@/types/SettingsPath';
|
||||||
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
|
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
|
||||||
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
|
|
||||||
import { SettingsApprovedAccessDomainsListCard } from '@/settings/security/components/approvedAccessDomains/SettingsApprovedAccessDomainsListCard';
|
|
||||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||||
import { FeatureFlagKey } from '~/generated/graphql';
|
import { FeatureFlagKey } from '~/generated/graphql';
|
||||||
|
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
|
||||||
|
|
||||||
const StyledContainer = styled.div`
|
const StyledContainer = styled.div`
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -38,7 +37,6 @@ export const SettingsSecurity = () => {
|
|||||||
return (
|
return (
|
||||||
<SubMenuTopBarContainer
|
<SubMenuTopBarContainer
|
||||||
title={t`Security`}
|
title={t`Security`}
|
||||||
actionButton={<SettingsReadDocumentationButton />}
|
|
||||||
links={[
|
links={[
|
||||||
{
|
{
|
||||||
children: <Trans>Workspace</Trans>,
|
children: <Trans>Workspace</Trans>,
|
||||||
|
|||||||
@ -0,0 +1,28 @@
|
|||||||
|
import { FullScreenContainer } from '@/ui/layout/fullscreen/components/FullScreenContainer';
|
||||||
|
import styled from '@emotion/styled';
|
||||||
|
import { action } from '@storybook/addon-actions';
|
||||||
|
import { Decorator } from '@storybook/react';
|
||||||
|
const StyledT = styled.div`
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const FullScreenDecorator: Decorator = (Story) => (
|
||||||
|
<FullScreenContainer
|
||||||
|
links={[
|
||||||
|
{
|
||||||
|
children: 'Layout',
|
||||||
|
href: '/',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
children: 'FullScreen',
|
||||||
|
href: '/',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
exitFullScreen={action('Full screen exit called.')}
|
||||||
|
>
|
||||||
|
<StyledT>
|
||||||
|
<Story />
|
||||||
|
</StyledT>
|
||||||
|
</FullScreenContainer>
|
||||||
|
);
|
||||||
@ -44,6 +44,39 @@ export const metadataGraphql = graphql.link(
|
|||||||
|
|
||||||
export const graphqlMocks = {
|
export const graphqlMocks = {
|
||||||
handlers: [
|
handlers: [
|
||||||
|
graphql.query('IntrospectionQuery', () => {
|
||||||
|
return HttpResponse.json({
|
||||||
|
data: {
|
||||||
|
__schema: {
|
||||||
|
queryType: { name: 'Query' },
|
||||||
|
types: [
|
||||||
|
{
|
||||||
|
kind: 'OBJECT',
|
||||||
|
name: 'Query',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
type: { kind: 'SCALAR', name: 'String' },
|
||||||
|
args: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
interfaces: [],
|
||||||
|
args: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'SCALAR',
|
||||||
|
name: 'String',
|
||||||
|
fields: [],
|
||||||
|
interfaces: [],
|
||||||
|
args: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
directives: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
graphql.query(getOperationName(GET_CURRENT_USER) ?? '', () => {
|
graphql.query(getOperationName(GET_CURRENT_USER) ?? '', () => {
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@ -9,6 +9,8 @@ export enum SettingsPageTitles {
|
|||||||
Objects = 'Data model - Settings',
|
Objects = 'Data model - Settings',
|
||||||
Members = 'Members - Settings',
|
Members = 'Members - Settings',
|
||||||
Developers = 'Developers - Settings',
|
Developers = 'Developers - Settings',
|
||||||
|
Apis = 'API Keys - Settings',
|
||||||
|
Webhooks = 'Webhooks - Settings',
|
||||||
Integration = 'Integrations - Settings',
|
Integration = 'Integrations - Settings',
|
||||||
ServerlessFunctions = 'Functions - Settings',
|
ServerlessFunctions = 'Functions - Settings',
|
||||||
General = 'General - Settings',
|
General = 'General - Settings',
|
||||||
@ -21,7 +23,8 @@ enum SettingsPathPrefixes {
|
|||||||
Profile = `${AppBasePath.Settings}/${SettingsPath.ProfilePage}`,
|
Profile = `${AppBasePath.Settings}/${SettingsPath.ProfilePage}`,
|
||||||
Objects = `${AppBasePath.Settings}/${SettingsPath.Objects}`,
|
Objects = `${AppBasePath.Settings}/${SettingsPath.Objects}`,
|
||||||
Members = `${AppBasePath.Settings}/${SettingsPath.WorkspaceMembersPage}`,
|
Members = `${AppBasePath.Settings}/${SettingsPath.WorkspaceMembersPage}`,
|
||||||
Developers = `${AppBasePath.Settings}/${SettingsPath.Developers}`,
|
ApiKeys = `${AppBasePath.Settings}/${SettingsPath.APIs}`,
|
||||||
|
Webhooks = `${AppBasePath.Settings}/${SettingsPath.Webhooks}`,
|
||||||
ServerlessFunctions = `${AppBasePath.Settings}/${SettingsPath.ServerlessFunctions}`,
|
ServerlessFunctions = `${AppBasePath.Settings}/${SettingsPath.ServerlessFunctions}`,
|
||||||
Integration = `${AppBasePath.Settings}/${SettingsPath.Integrations}`,
|
Integration = `${AppBasePath.Settings}/${SettingsPath.Integrations}`,
|
||||||
General = `${AppBasePath.Settings}/${SettingsPath.Workspace}`,
|
General = `${AppBasePath.Settings}/${SettingsPath.Workspace}`,
|
||||||
@ -59,8 +62,10 @@ export const getPageTitleFromPath = (pathname: string): string => {
|
|||||||
return SettingsPageTitles.Members;
|
return SettingsPageTitles.Members;
|
||||||
case SettingsPathPrefixes.Objects:
|
case SettingsPathPrefixes.Objects:
|
||||||
return SettingsPageTitles.Objects;
|
return SettingsPageTitles.Objects;
|
||||||
case SettingsPathPrefixes.Developers:
|
case SettingsPathPrefixes.ApiKeys:
|
||||||
return SettingsPageTitles.Developers;
|
return SettingsPageTitles.Apis;
|
||||||
|
case SettingsPathPrefixes.Webhooks:
|
||||||
|
return SettingsPageTitles.Webhooks;
|
||||||
case SettingsPathPrefixes.ServerlessFunctions:
|
case SettingsPathPrefixes.ServerlessFunctions:
|
||||||
return SettingsPageTitles.ServerlessFunctions;
|
return SettingsPageTitles.ServerlessFunctions;
|
||||||
case SettingsPathPrefixes.Integration:
|
case SettingsPathPrefixes.Integration:
|
||||||
|
|||||||
@ -16,6 +16,7 @@ describe('JwtAuthStrategy', () => {
|
|||||||
let userRepository: any;
|
let userRepository: any;
|
||||||
let dataSourceService: any;
|
let dataSourceService: any;
|
||||||
let typeORMService: any;
|
let typeORMService: any;
|
||||||
|
|
||||||
const jwt = {
|
const jwt = {
|
||||||
sub: 'sub-default',
|
sub: 'sub-default',
|
||||||
jti: 'jti-default',
|
jti: 'jti-default',
|
||||||
@ -33,6 +34,10 @@ describe('JwtAuthStrategy', () => {
|
|||||||
findOne: jest.fn(async () => new UserWorkspace()),
|
findOne: jest.fn(async () => new UserWorkspace()),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const jwtWrapperService: any = {
|
||||||
|
extractJwtFromRequest: jest.fn(() => () => 'token'),
|
||||||
|
};
|
||||||
|
|
||||||
// first we test the API_KEY case
|
// first we test the API_KEY case
|
||||||
it('should throw AuthException if type is API_KEY and workspace is not found', async () => {
|
it('should throw AuthException if type is API_KEY and workspace is not found', async () => {
|
||||||
const payload = {
|
const payload = {
|
||||||
@ -46,7 +51,7 @@ describe('JwtAuthStrategy', () => {
|
|||||||
|
|
||||||
strategy = new JwtAuthStrategy(
|
strategy = new JwtAuthStrategy(
|
||||||
{} as any,
|
{} as any,
|
||||||
{} as any,
|
jwtWrapperService,
|
||||||
typeORMService,
|
typeORMService,
|
||||||
dataSourceService,
|
dataSourceService,
|
||||||
workspaceRepository,
|
workspaceRepository,
|
||||||
@ -82,7 +87,7 @@ describe('JwtAuthStrategy', () => {
|
|||||||
|
|
||||||
strategy = new JwtAuthStrategy(
|
strategy = new JwtAuthStrategy(
|
||||||
{} as any,
|
{} as any,
|
||||||
{} as any,
|
jwtWrapperService,
|
||||||
typeORMService,
|
typeORMService,
|
||||||
dataSourceService,
|
dataSourceService,
|
||||||
workspaceRepository,
|
workspaceRepository,
|
||||||
@ -120,7 +125,7 @@ describe('JwtAuthStrategy', () => {
|
|||||||
|
|
||||||
strategy = new JwtAuthStrategy(
|
strategy = new JwtAuthStrategy(
|
||||||
{} as any,
|
{} as any,
|
||||||
{} as any,
|
jwtWrapperService,
|
||||||
typeORMService,
|
typeORMService,
|
||||||
dataSourceService,
|
dataSourceService,
|
||||||
workspaceRepository,
|
workspaceRepository,
|
||||||
@ -152,7 +157,7 @@ describe('JwtAuthStrategy', () => {
|
|||||||
|
|
||||||
strategy = new JwtAuthStrategy(
|
strategy = new JwtAuthStrategy(
|
||||||
{} as any,
|
{} as any,
|
||||||
{} as any,
|
jwtWrapperService,
|
||||||
typeORMService,
|
typeORMService,
|
||||||
dataSourceService,
|
dataSourceService,
|
||||||
workspaceRepository,
|
workspaceRepository,
|
||||||
@ -190,7 +195,7 @@ describe('JwtAuthStrategy', () => {
|
|||||||
|
|
||||||
strategy = new JwtAuthStrategy(
|
strategy = new JwtAuthStrategy(
|
||||||
{} as any,
|
{} as any,
|
||||||
{} as any,
|
jwtWrapperService,
|
||||||
typeORMService,
|
typeORMService,
|
||||||
dataSourceService,
|
dataSourceService,
|
||||||
workspaceRepository,
|
workspaceRepository,
|
||||||
@ -231,7 +236,7 @@ describe('JwtAuthStrategy', () => {
|
|||||||
|
|
||||||
strategy = new JwtAuthStrategy(
|
strategy = new JwtAuthStrategy(
|
||||||
{} as any,
|
{} as any,
|
||||||
{} as any,
|
jwtWrapperService,
|
||||||
typeORMService,
|
typeORMService,
|
||||||
dataSourceService,
|
dataSourceService,
|
||||||
workspaceRepository,
|
workspaceRepository,
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
|
||||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
import { Strategy } from 'passport-jwt';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
|
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
|
||||||
@ -36,25 +36,28 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
|||||||
@InjectRepository(UserWorkspace, 'core')
|
@InjectRepository(UserWorkspace, 'core')
|
||||||
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
|
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
|
||||||
) {
|
) {
|
||||||
super({
|
const jwtFromRequestFunction = jwtWrapperService.extractJwtFromRequest();
|
||||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
const secretOrKeyProviderFunction = async (request, rawJwtToken, done) => {
|
||||||
ignoreExpiration: false,
|
try {
|
||||||
secretOrKeyProvider: async (request, rawJwtToken, done) => {
|
const decodedToken = jwtWrapperService.decode(
|
||||||
try {
|
rawJwtToken,
|
||||||
const decodedToken = this.jwtWrapperService.decode(
|
) as JwtPayload;
|
||||||
rawJwtToken,
|
const workspaceId = decodedToken.workspaceId;
|
||||||
) as JwtPayload;
|
const secret = jwtWrapperService.generateAppSecret(
|
||||||
const workspaceId = decodedToken.workspaceId;
|
'ACCESS',
|
||||||
const secret = this.jwtWrapperService.generateAppSecret(
|
workspaceId,
|
||||||
'ACCESS',
|
);
|
||||||
workspaceId,
|
|
||||||
);
|
|
||||||
|
|
||||||
done(null, secret);
|
done(null, secret);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
done(error, null);
|
done(error, null);
|
||||||
}
|
}
|
||||||
},
|
};
|
||||||
|
|
||||||
|
super({
|
||||||
|
jwtFromRequest: jwtFromRequestFunction,
|
||||||
|
ignoreExpiration: false,
|
||||||
|
secretOrKeyProvider: secretOrKeyProviderFunction,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -39,6 +39,7 @@ describe('AccessTokenService', () => {
|
|||||||
verifyWorkspaceToken: jest.fn(),
|
verifyWorkspaceToken: jest.fn(),
|
||||||
decode: jest.fn(),
|
decode: jest.fn(),
|
||||||
generateAppSecret: jest.fn(),
|
generateAppSecret: jest.fn(),
|
||||||
|
extractJwtFromRequest: jest.fn(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -179,6 +180,9 @@ describe('AccessTokenService', () => {
|
|||||||
workspaceMemberId: 'workspace-member-id',
|
workspaceMemberId: 'workspace-member-id',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
jest
|
||||||
|
.spyOn(jwtWrapperService, 'extractJwtFromRequest')
|
||||||
|
.mockReturnValue(() => mockToken);
|
||||||
jest
|
jest
|
||||||
.spyOn(jwtWrapperService, 'verifyWorkspaceToken')
|
.spyOn(jwtWrapperService, 'verifyWorkspaceToken')
|
||||||
.mockResolvedValue(undefined);
|
.mockResolvedValue(undefined);
|
||||||
@ -207,6 +211,10 @@ describe('AccessTokenService', () => {
|
|||||||
headers: {},
|
headers: {},
|
||||||
} as Request;
|
} as Request;
|
||||||
|
|
||||||
|
jest
|
||||||
|
.spyOn(jwtWrapperService, 'extractJwtFromRequest')
|
||||||
|
.mockReturnValue(() => null);
|
||||||
|
|
||||||
await expect(service.validateTokenByRequest(mockRequest)).rejects.toThrow(
|
await expect(service.validateTokenByRequest(mockRequest)).rejects.toThrow(
|
||||||
AuthException,
|
AuthException,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import { InjectRepository } from '@nestjs/typeorm';
|
|||||||
import { addMilliseconds } from 'date-fns';
|
import { addMilliseconds } from 'date-fns';
|
||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
import ms from 'ms';
|
import ms from 'ms';
|
||||||
import { ExtractJwt } from 'passport-jwt';
|
|
||||||
import { isWorkspaceActiveOrSuspended } from 'twenty-shared';
|
import { isWorkspaceActiveOrSuspended } from 'twenty-shared';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
@ -125,7 +124,7 @@ export class AccessTokenService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async validateTokenByRequest(request: Request): Promise<AuthContext> {
|
async validateTokenByRequest(request: Request): Promise<AuthContext> {
|
||||||
const token = ExtractJwt.fromAuthHeaderAsBearerToken()(request);
|
const token = this.jwtWrapperService.extractJwtFromRequest()(request);
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new AuthException(
|
throw new AuthException(
|
||||||
|
|||||||
@ -3,7 +3,9 @@ import { JwtService, JwtSignOptions, JwtVerifyOptions } from '@nestjs/jwt';
|
|||||||
|
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
|
|
||||||
|
import { Request as ExpressRequest } from 'express';
|
||||||
import * as jwt from 'jsonwebtoken';
|
import * as jwt from 'jsonwebtoken';
|
||||||
|
import { ExtractJwt, JwtFromRequestFunction } from 'passport-jwt';
|
||||||
import { isDefined } from 'twenty-shared';
|
import { isDefined } from 'twenty-shared';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -122,4 +124,20 @@ export class JwtWrapperService {
|
|||||||
|
|
||||||
return accessTokenSecret;
|
return accessTokenSecret;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extractJwtFromRequest(): JwtFromRequestFunction {
|
||||||
|
return (request: ExpressRequest) => {
|
||||||
|
// First try to extract token from Authorization header
|
||||||
|
const tokenFromHeader = ExtractJwt.fromAuthHeaderAsBearerToken()(request);
|
||||||
|
|
||||||
|
if (tokenFromHeader) {
|
||||||
|
return tokenFromHeader;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not found in header, try to extract from URL query parameter
|
||||||
|
// This is for edge cases where we don't control the origin request
|
||||||
|
// (e.g. the REST API playground)
|
||||||
|
return ExtractJwt.fromUrlQueryParameter('token')(request);
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
||||||
|
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
|
||||||
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
||||||
import { WorkspaceMetadataCacheModule } from 'src/engine/metadata-modules/workspace-metadata-cache/workspace-metadata-cache.module';
|
import { WorkspaceMetadataCacheModule } from 'src/engine/metadata-modules/workspace-metadata-cache/workspace-metadata-cache.module';
|
||||||
import { MiddlewareService } from 'src/engine/middlewares/middleware.service';
|
import { MiddlewareService } from 'src/engine/middlewares/middleware.service';
|
||||||
@ -12,6 +13,7 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
|
|||||||
WorkspaceCacheStorageModule,
|
WorkspaceCacheStorageModule,
|
||||||
WorkspaceMetadataCacheModule,
|
WorkspaceMetadataCacheModule,
|
||||||
TokenModule,
|
TokenModule,
|
||||||
|
JwtModule,
|
||||||
],
|
],
|
||||||
providers: [MiddlewareService],
|
providers: [MiddlewareService],
|
||||||
exports: [MiddlewareService],
|
exports: [MiddlewareService],
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import { ExtractJwt } from 'passport-jwt';
|
|
||||||
import { isDefined } from 'twenty-shared';
|
import { isDefined } from 'twenty-shared';
|
||||||
|
|
||||||
import { AuthExceptionCode } from 'src/engine/core-modules/auth/auth.exception';
|
import { AuthExceptionCode } from 'src/engine/core-modules/auth/auth.exception';
|
||||||
@ -9,6 +8,7 @@ import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/
|
|||||||
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
|
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||||
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
||||||
import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||||
|
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||||
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
|
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
|
||||||
import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service';
|
import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service';
|
||||||
import { INTERNAL_SERVER_ERROR } from 'src/engine/middlewares/constants/default-error-message.constant';
|
import { INTERNAL_SERVER_ERROR } from 'src/engine/middlewares/constants/default-error-message.constant';
|
||||||
@ -29,12 +29,13 @@ export class MiddlewareService {
|
|||||||
private readonly workspaceMetadataCacheService: WorkspaceMetadataCacheService,
|
private readonly workspaceMetadataCacheService: WorkspaceMetadataCacheService,
|
||||||
private readonly dataSourceService: DataSourceService,
|
private readonly dataSourceService: DataSourceService,
|
||||||
private readonly exceptionHandlerService: ExceptionHandlerService,
|
private readonly exceptionHandlerService: ExceptionHandlerService,
|
||||||
|
private readonly jwtWrapperService: JwtWrapperService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private excludedOperations = EXCLUDED_MIDDLEWARE_OPERATIONS;
|
private excludedOperations = EXCLUDED_MIDDLEWARE_OPERATIONS;
|
||||||
|
|
||||||
public isTokenPresent(request: Request): boolean {
|
public isTokenPresent(request: Request): boolean {
|
||||||
const token = ExtractJwt.fromAuthHeaderAsBearerToken()(request);
|
const token = this.jwtWrapperService.extractJwtFromRequest()(request);
|
||||||
|
|
||||||
return !!token;
|
return !!token;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,9 +25,11 @@ export {
|
|||||||
IconBookmarkPlus,
|
IconBookmarkPlus,
|
||||||
IconBox,
|
IconBox,
|
||||||
IconBrackets,
|
IconBrackets,
|
||||||
|
IconBracketsAngle,
|
||||||
IconBracketsContain,
|
IconBracketsContain,
|
||||||
IconBrandGithub,
|
IconBrandGithub,
|
||||||
IconBrandGoogle,
|
IconBrandGoogle,
|
||||||
|
IconBrandGraphql,
|
||||||
IconBrandLinkedin,
|
IconBrandLinkedin,
|
||||||
IconBrandX,
|
IconBrandX,
|
||||||
IconBriefcase,
|
IconBriefcase,
|
||||||
@ -142,6 +144,7 @@ export {
|
|||||||
IconFolder,
|
IconFolder,
|
||||||
IconFolderOpen,
|
IconFolderOpen,
|
||||||
IconFolderPlus,
|
IconFolderPlus,
|
||||||
|
IconFolderRoot,
|
||||||
IconForbid,
|
IconForbid,
|
||||||
IconFunction,
|
IconFunction,
|
||||||
IconGauge,
|
IconGauge,
|
||||||
@ -278,6 +281,7 @@ export {
|
|||||||
IconVariablePlus,
|
IconVariablePlus,
|
||||||
IconVideo,
|
IconVideo,
|
||||||
IconWand,
|
IconWand,
|
||||||
|
IconWebhook,
|
||||||
IconWorld,
|
IconWorld,
|
||||||
IconX,
|
IconX,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
|
|||||||
Reference in New Issue
Block a user