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",
|
||||
"type-fest": "4.10.1",
|
||||
"typescript": "5.3.3",
|
||||
"prosemirror-model": "1.23.0"
|
||||
"prosemirror-model": "1.23.0",
|
||||
"yjs": "13.6.18"
|
||||
},
|
||||
"version": "0.2.1",
|
||||
"nx": {},
|
||||
|
||||
@ -37,7 +37,7 @@ const config: StorybookConfig = {
|
||||
'@storybook/addon-docs',
|
||||
'@storybook/addon-essentials/docs',
|
||||
],
|
||||
}
|
||||
},
|
||||
},
|
||||
addons: [
|
||||
'@storybook/addon-links',
|
||||
|
||||
@ -38,6 +38,7 @@
|
||||
"@nivo/core": "^0.87.0",
|
||||
"@nivo/line": "^0.87.0",
|
||||
"@react-pdf/renderer": "^4.1.6",
|
||||
"@scalar/api-reference-react": "^0.4.36",
|
||||
"@tiptap/core": "^2.10.4",
|
||||
"@tiptap/extension-document": "^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 { 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(() =>
|
||||
import('~/pages/settings/accounts/SettingsAccountsCalendars').then(
|
||||
(module) => ({
|
||||
@ -137,12 +169,6 @@ const SettingsBilling = lazy(() =>
|
||||
})),
|
||||
);
|
||||
|
||||
const SettingsDevelopers = lazy(() =>
|
||||
import('~/pages/settings/developers/SettingsDevelopers').then((module) => ({
|
||||
default: module.SettingsDevelopers,
|
||||
})),
|
||||
);
|
||||
|
||||
const SettingsIntegrations = lazy(() =>
|
||||
import('~/pages/settings/integrations/SettingsIntegrations').then(
|
||||
(module) => ({
|
||||
@ -376,9 +402,15 @@ export const SettingsRoutes = ({
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Route path={SettingsPath.APIs} element={<SettingsApiKeys />} />
|
||||
<Route path={SettingsPath.Webhooks} element={<SettingsWebhooks />} />
|
||||
<Route
|
||||
path={SettingsPath.Developers}
|
||||
element={<SettingsDevelopers />}
|
||||
path={`${SettingsPath.GraphQLPlayground}`}
|
||||
element={<SettingsGraphQLPlayground />}
|
||||
/>
|
||||
<Route
|
||||
path={`${SettingsPath.RestPlayground}/*`}
|
||||
element={<SettingsRestPlayground />}
|
||||
/>
|
||||
<Route
|
||||
path={SettingsPath.DevelopersNewApiKey}
|
||||
|
||||
@ -57,7 +57,9 @@ export const SettingsNavigationDrawerItems = () => {
|
||||
getSelectedIndexForSubItems(subItems);
|
||||
|
||||
return (
|
||||
<NavigationDrawerItemGroup key={item.path}>
|
||||
<NavigationDrawerItemGroup
|
||||
key={item.path || `group-${index}`}
|
||||
>
|
||||
<SettingsNavigationDrawerItem
|
||||
item={item}
|
||||
subItemState={
|
||||
@ -72,7 +74,7 @@ export const SettingsNavigationDrawerItems = () => {
|
||||
/>
|
||||
{subItems.map((subItem, subIndex) => (
|
||||
<SettingsNavigationDrawerItem
|
||||
key={subItem.path}
|
||||
key={subItem.path || `subitem-${subIndex}`}
|
||||
item={subItem}
|
||||
subItemState={
|
||||
subItem.indentationLevel
|
||||
@ -90,7 +92,7 @@ export const SettingsNavigationDrawerItems = () => {
|
||||
}
|
||||
return (
|
||||
<SettingsNavigationDrawerItem
|
||||
key={item.path}
|
||||
key={item.path || `item-${index}`}
|
||||
item={item}
|
||||
subItemState={
|
||||
item.indentationLevel
|
||||
|
||||
@ -4,13 +4,15 @@ import { SettingsApiKeysFieldItemTableRow } from '@/settings/developers/componen
|
||||
import { ApiFieldItem } from '@/settings/developers/types/api-key/ApiFieldItem';
|
||||
import { ApiKey } from '@/settings/developers/types/api-key/ApiKey';
|
||||
import { formatExpirations } from '@/settings/developers/utils/formatExpiration';
|
||||
import { SettingsPath } from '@/types/SettingsPath';
|
||||
import { Table } from '@/ui/layout/table/components/Table';
|
||||
import { TableBody } from '@/ui/layout/table/components/TableBody';
|
||||
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
|
||||
import { TableRow } from '@/ui/layout/table/components/TableRow';
|
||||
import styled from '@emotion/styled';
|
||||
import { MOBILE_VIEWPORT } from 'twenty-ui';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { MOBILE_VIEWPORT } from 'twenty-ui';
|
||||
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
|
||||
|
||||
const StyledTableBody = styled(TableBody)`
|
||||
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
|
||||
@ -53,7 +55,9 @@ export const SettingsApiKeysTable = () => {
|
||||
<SettingsApiKeysFieldItemTableRow
|
||||
key={fieldItem.id}
|
||||
fieldItem={fieldItem as ApiFieldItem}
|
||||
to={`/settings/developers/api-keys/${fieldItem.id}`}
|
||||
to={getSettingsPath(SettingsPath.DevelopersApiKeyDetail, {
|
||||
apiKeyId: fieldItem.id,
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</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 () => {
|
||||
await deleteOneWebhook(webhookId);
|
||||
navigate(SettingsPath.Developers);
|
||||
navigate(SettingsPath.Webhooks);
|
||||
};
|
||||
|
||||
useFindOneRecord({
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import {
|
||||
IconApi,
|
||||
IconApps,
|
||||
IconAt,
|
||||
IconCalendarEvent,
|
||||
IconCode,
|
||||
IconColorSwatch,
|
||||
IconComponent,
|
||||
IconCurrencyDollar,
|
||||
@ -18,6 +18,7 @@ import {
|
||||
IconSettings,
|
||||
IconUserCircle,
|
||||
IconUsers,
|
||||
IconWebhook,
|
||||
} from 'twenty-ui';
|
||||
|
||||
import { SettingsPath } from '@/types/SettingsPath';
|
||||
@ -159,9 +160,16 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
|
||||
isAdvanced: true,
|
||||
items: [
|
||||
{
|
||||
label: t`API & Webhooks`,
|
||||
path: SettingsPath.Developers,
|
||||
Icon: IconCode,
|
||||
label: t`APIs`,
|
||||
path: SettingsPath.APIs,
|
||||
Icon: IconApi,
|
||||
isAdvanced: true,
|
||||
isHidden: !permissionMap[SettingsPermissions.API_KEYS_AND_WEBHOOKS],
|
||||
},
|
||||
{
|
||||
label: t`Webhooks`,
|
||||
path: SettingsPath.Webhooks,
|
||||
Icon: IconWebhook,
|
||||
isAdvanced: true,
|
||||
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',
|
||||
Workspace = 'workspace',
|
||||
Domain = 'domain',
|
||||
Developers = 'developers',
|
||||
DevelopersNewApiKey = 'developers/api-keys/new',
|
||||
DevelopersApiKeyDetail = 'developers/api-keys/:apiKeyId',
|
||||
APIs = 'api-keys',
|
||||
RestPlayground = 'playground/rest/:schema',
|
||||
GraphQLPlayground = 'playground/graphql/:schema',
|
||||
DevelopersNewApiKey = 'api-keys/new',
|
||||
DevelopersApiKeyDetail = 'api-keys/:apiKeyId',
|
||||
Integrations = 'integrations',
|
||||
IntegrationDatabase = 'integrations/:databaseKey',
|
||||
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 { SignInAppNavigationDrawerMock } from '@/sign-in-background-mock/components/SignInAppNavigationDrawerMock';
|
||||
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 { NAV_DRAWER_WIDTHS } from '@/ui/navigation/navigation-drawer/constants/NavDrawerWidths';
|
||||
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
|
||||
@ -69,6 +70,7 @@ export const DefaultLayout = () => {
|
||||
const theme = useTheme();
|
||||
const windowsWidth = useScreenSize().width;
|
||||
const showAuthModal = useShowAuthModal();
|
||||
const useShowFullScreen = useShowFullscreen();
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -90,7 +92,7 @@ export const DefaultLayout = () => {
|
||||
<StyledPageContainer
|
||||
animate={{
|
||||
marginLeft:
|
||||
isSettingsPage && !isMobile
|
||||
isSettingsPage && !isMobile && !useShowFullScreen
|
||||
? (windowsWidth -
|
||||
(OBJECT_SETTINGS_WIDTH +
|
||||
NAV_DRAWER_WIDTHS.menu.desktop.expanded +
|
||||
@ -102,7 +104,7 @@ export const DefaultLayout = () => {
|
||||
>
|
||||
{showAuthModal ? (
|
||||
<StyledAppNavigationDrawerMock />
|
||||
) : (
|
||||
) : useShowFullScreen ? null : (
|
||||
<StyledAppNavigationDrawer />
|
||||
)}
|
||||
{showAuthModal ? (
|
||||
|
||||
@ -1,15 +1,44 @@
|
||||
import { ActivityRichTextEditor } from '@/activities/components/ActivityRichTextEditor';
|
||||
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
import { isNewViewableRecordLoadingState } from '@/object-record/record-right-drawer/states/isNewViewableRecordLoading';
|
||||
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
|
||||
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';
|
||||
|
||||
const ActivityRichTextEditor = lazy(() =>
|
||||
import('@/activities/components/ActivityRichTextEditor').then((module) => ({
|
||||
default: module.ActivityRichTextEditor,
|
||||
})),
|
||||
);
|
||||
|
||||
const StyledShowPageActivityContainer = styled.div`
|
||||
margin-top: ${({ theme }) => theme.spacing(6)};
|
||||
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 = ({
|
||||
targetableObject,
|
||||
}: {
|
||||
@ -28,14 +57,16 @@ export const ShowPageActivityContainer = ({
|
||||
componentInstanceId={`scroll-wrapper-tab-list-${targetableObject.id}`}
|
||||
>
|
||||
<StyledShowPageActivityContainer>
|
||||
<ActivityRichTextEditor
|
||||
activityId={targetableObject.id}
|
||||
activityObjectNameSingular={
|
||||
targetableObject.targetObjectNameSingular as
|
||||
| CoreObjectNameSingular.Note
|
||||
| CoreObjectNameSingular.Task
|
||||
}
|
||||
/>
|
||||
<Suspense fallback={<LoadingSkeleton />}>
|
||||
<ActivityRichTextEditor
|
||||
activityId={targetableObject.id}
|
||||
activityObjectNameSingular={
|
||||
targetableObject.targetObjectNameSingular as
|
||||
| CoreObjectNameSingular.Note
|
||||
| CoreObjectNameSingular.Task
|
||||
}
|
||||
/>
|
||||
</Suspense>
|
||||
</StyledShowPageActivityContainer>
|
||||
</ScrollWrapper>
|
||||
) : (
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
import { within } from '@storybook/test';
|
||||
|
||||
import { SettingsDevelopers } from '~/pages/settings/developers/SettingsDevelopers';
|
||||
import { SettingsApiKeys } from '~/pages/settings/developers/api-keys/SettingsApiKeys';
|
||||
import {
|
||||
PageDecorator,
|
||||
PageDecoratorArgs,
|
||||
@ -9,10 +9,10 @@ import {
|
||||
import { graphqlMocks } from '~/testing/graphqlMocks';
|
||||
|
||||
const meta: Meta<PageDecoratorArgs> = {
|
||||
title: 'Pages/Settings/Developers/SettingsDevelopers',
|
||||
component: SettingsDevelopers,
|
||||
title: 'Pages/Settings/ApiKeys',
|
||||
component: SettingsApiKeys,
|
||||
decorators: [PageDecorator],
|
||||
args: { routePath: '/settings/developers' },
|
||||
args: { routePath: '/settings/apis' },
|
||||
parameters: {
|
||||
msw: graphqlMocks,
|
||||
},
|
||||
@ -20,7 +20,7 @@ const meta: Meta<PageDecoratorArgs> = {
|
||||
|
||||
export default meta;
|
||||
|
||||
export type Story = StoryObj<typeof SettingsDevelopers>;
|
||||
export type Story = StoryObj<typeof SettingsApiKeys>;
|
||||
|
||||
export const Default: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
@ -11,7 +11,7 @@ import { graphqlMocks } from '~/testing/graphqlMocks';
|
||||
import { sleep } from '~/utils/sleep';
|
||||
|
||||
const meta: Meta<PageDecoratorArgs> = {
|
||||
title: 'Pages/Settings/Developers/ApiKeys/SettingsDevelopersApiKeyDetail',
|
||||
title: 'Pages/Settings/ApiKeys/SettingsDevelopersApiKeyDetail',
|
||||
component: SettingsDevelopersApiKeyDetail,
|
||||
decorators: [PageDecorator],
|
||||
args: {
|
||||
|
||||
@ -11,7 +11,7 @@ import { graphqlMocks } from '~/testing/graphqlMocks';
|
||||
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
|
||||
|
||||
const meta: Meta<PageDecoratorArgs> = {
|
||||
title: 'Pages/Settings/Developers/ApiKeys/SettingsDevelopersApiKeysNew',
|
||||
title: 'Pages/Settings/ApiKeys/SettingsDevelopersApiKeysNew',
|
||||
component: SettingsDevelopersApiKeysNew,
|
||||
decorators: [PageDecorator],
|
||||
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';
|
||||
|
||||
const meta: Meta<PageDecoratorArgs> = {
|
||||
title: 'Pages/Settings/Developers/Webhooks/SettingsDevelopersWebhooksDetail',
|
||||
title: 'Pages/Settings/Webhooks/SettingsDevelopersWebhooksDetail',
|
||||
component: SettingsDevelopersWebhooksDetail,
|
||||
decorators: [PageDecorator],
|
||||
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() },
|
||||
});
|
||||
if (redirect) {
|
||||
navigate(SettingsPath.Developers);
|
||||
navigate(SettingsPath.APIs);
|
||||
}
|
||||
} catch (err) {
|
||||
enqueueSnackBar(t`Error deleting api key: ${err}`, {
|
||||
@ -166,8 +166,8 @@ export const SettingsDevelopersApiKeyDetail = () => {
|
||||
href: getSettingsPath(SettingsPath.Workspace),
|
||||
},
|
||||
{
|
||||
children: t`Developers`,
|
||||
href: getSettingsPath(SettingsPath.Developers),
|
||||
children: t`APIs`,
|
||||
href: getSettingsPath(SettingsPath.APIs),
|
||||
},
|
||||
{ children: t`${apiKeyName} API Key` },
|
||||
]}
|
||||
|
||||
@ -86,8 +86,8 @@ export const SettingsDevelopersApiKeysNew = () => {
|
||||
href: getSettingsPath(SettingsPath.Workspace),
|
||||
},
|
||||
{
|
||||
children: t`Developers`,
|
||||
href: getSettingsPath(SettingsPath.Developers),
|
||||
children: t`APIs`,
|
||||
href: getSettingsPath(SettingsPath.APIs),
|
||||
},
|
||||
{ children: t`New Key` },
|
||||
]}
|
||||
@ -95,7 +95,7 @@ export const SettingsDevelopersApiKeysNew = () => {
|
||||
<SaveAndCancelButtons
|
||||
isSaveDisabled={!canSave}
|
||||
onCancel={() => {
|
||||
navigateSettings(SettingsPath.Developers);
|
||||
navigateSettings(SettingsPath.APIs);
|
||||
}}
|
||||
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`,
|
||||
href: getSettingsPath(SettingsPath.Workspace),
|
||||
},
|
||||
{
|
||||
children: t`Developers`,
|
||||
href: getSettingsPath(SettingsPath.Developers),
|
||||
},
|
||||
{ children: t`Webhook` },
|
||||
]}
|
||||
>
|
||||
|
||||
@ -1,7 +1,4 @@
|
||||
import { v4 } from 'uuid';
|
||||
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 { SettingsPath } from '@/types/SettingsPath';
|
||||
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 { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { Button, H2Title, IconPlus, MOBILE_VIEWPORT, Section } from 'twenty-ui';
|
||||
import { v4 } from 'uuid';
|
||||
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
|
||||
|
||||
const StyledButtonContainer = styled.div`
|
||||
@ -27,40 +25,23 @@ const StyledContainer = styled.div<{ isMobile: boolean }>`
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
export const SettingsDevelopers = () => {
|
||||
export const SettingsWebhooks = () => {
|
||||
const isMobile = useIsMobile();
|
||||
const { t } = useLingui();
|
||||
|
||||
return (
|
||||
<SubMenuTopBarContainer
|
||||
title={t`Developers`}
|
||||
actionButton={<SettingsReadDocumentationButton />}
|
||||
title={t`Webhooks`}
|
||||
links={[
|
||||
{
|
||||
children: <Trans>Workspace</Trans>,
|
||||
href: getSettingsPath(SettingsPath.Workspace),
|
||||
},
|
||||
{ children: <Trans>Developers</Trans> },
|
||||
{ children: <Trans>Webhooks</Trans> },
|
||||
]}
|
||||
>
|
||||
<SettingsPageContainer>
|
||||
<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>
|
||||
<H2Title
|
||||
title={t`Webhooks`}
|
||||
@ -3,15 +3,14 @@ import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { H2Title, IconLock, Section, Tag } from 'twenty-ui';
|
||||
|
||||
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||
import { SettingsReadDocumentationButton } from '@/settings/developers/components/SettingsReadDocumentationButton';
|
||||
import { SettingsSSOIdentitiesProvidersListCard } from '@/settings/security/components/SSO/SettingsSSOIdentitiesProvidersListCard';
|
||||
import { SettingsSecurityAuthProvidersOptionsList } from '@/settings/security/components/SettingsSecurityAuthProvidersOptionsList';
|
||||
import { SettingsApprovedAccessDomainsListCard } from '@/settings/security/components/approvedAccessDomains/SettingsApprovedAccessDomainsListCard';
|
||||
import { SettingsPath } from '@/types/SettingsPath';
|
||||
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 { FeatureFlagKey } from '~/generated/graphql';
|
||||
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
width: 100%;
|
||||
@ -38,7 +37,6 @@ export const SettingsSecurity = () => {
|
||||
return (
|
||||
<SubMenuTopBarContainer
|
||||
title={t`Security`}
|
||||
actionButton={<SettingsReadDocumentationButton />}
|
||||
links={[
|
||||
{
|
||||
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 = {
|
||||
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) ?? '', () => {
|
||||
return HttpResponse.json({
|
||||
data: {
|
||||
|
||||
@ -9,6 +9,8 @@ export enum SettingsPageTitles {
|
||||
Objects = 'Data model - Settings',
|
||||
Members = 'Members - Settings',
|
||||
Developers = 'Developers - Settings',
|
||||
Apis = 'API Keys - Settings',
|
||||
Webhooks = 'Webhooks - Settings',
|
||||
Integration = 'Integrations - Settings',
|
||||
ServerlessFunctions = 'Functions - Settings',
|
||||
General = 'General - Settings',
|
||||
@ -21,7 +23,8 @@ enum SettingsPathPrefixes {
|
||||
Profile = `${AppBasePath.Settings}/${SettingsPath.ProfilePage}`,
|
||||
Objects = `${AppBasePath.Settings}/${SettingsPath.Objects}`,
|
||||
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}`,
|
||||
Integration = `${AppBasePath.Settings}/${SettingsPath.Integrations}`,
|
||||
General = `${AppBasePath.Settings}/${SettingsPath.Workspace}`,
|
||||
@ -59,8 +62,10 @@ export const getPageTitleFromPath = (pathname: string): string => {
|
||||
return SettingsPageTitles.Members;
|
||||
case SettingsPathPrefixes.Objects:
|
||||
return SettingsPageTitles.Objects;
|
||||
case SettingsPathPrefixes.Developers:
|
||||
return SettingsPageTitles.Developers;
|
||||
case SettingsPathPrefixes.ApiKeys:
|
||||
return SettingsPageTitles.Apis;
|
||||
case SettingsPathPrefixes.Webhooks:
|
||||
return SettingsPageTitles.Webhooks;
|
||||
case SettingsPathPrefixes.ServerlessFunctions:
|
||||
return SettingsPageTitles.ServerlessFunctions;
|
||||
case SettingsPathPrefixes.Integration:
|
||||
|
||||
@ -16,6 +16,7 @@ describe('JwtAuthStrategy', () => {
|
||||
let userRepository: any;
|
||||
let dataSourceService: any;
|
||||
let typeORMService: any;
|
||||
|
||||
const jwt = {
|
||||
sub: 'sub-default',
|
||||
jti: 'jti-default',
|
||||
@ -33,6 +34,10 @@ describe('JwtAuthStrategy', () => {
|
||||
findOne: jest.fn(async () => new UserWorkspace()),
|
||||
};
|
||||
|
||||
const jwtWrapperService: any = {
|
||||
extractJwtFromRequest: jest.fn(() => () => 'token'),
|
||||
};
|
||||
|
||||
// first we test the API_KEY case
|
||||
it('should throw AuthException if type is API_KEY and workspace is not found', async () => {
|
||||
const payload = {
|
||||
@ -46,7 +51,7 @@ describe('JwtAuthStrategy', () => {
|
||||
|
||||
strategy = new JwtAuthStrategy(
|
||||
{} as any,
|
||||
{} as any,
|
||||
jwtWrapperService,
|
||||
typeORMService,
|
||||
dataSourceService,
|
||||
workspaceRepository,
|
||||
@ -82,7 +87,7 @@ describe('JwtAuthStrategy', () => {
|
||||
|
||||
strategy = new JwtAuthStrategy(
|
||||
{} as any,
|
||||
{} as any,
|
||||
jwtWrapperService,
|
||||
typeORMService,
|
||||
dataSourceService,
|
||||
workspaceRepository,
|
||||
@ -120,7 +125,7 @@ describe('JwtAuthStrategy', () => {
|
||||
|
||||
strategy = new JwtAuthStrategy(
|
||||
{} as any,
|
||||
{} as any,
|
||||
jwtWrapperService,
|
||||
typeORMService,
|
||||
dataSourceService,
|
||||
workspaceRepository,
|
||||
@ -152,7 +157,7 @@ describe('JwtAuthStrategy', () => {
|
||||
|
||||
strategy = new JwtAuthStrategy(
|
||||
{} as any,
|
||||
{} as any,
|
||||
jwtWrapperService,
|
||||
typeORMService,
|
||||
dataSourceService,
|
||||
workspaceRepository,
|
||||
@ -190,7 +195,7 @@ describe('JwtAuthStrategy', () => {
|
||||
|
||||
strategy = new JwtAuthStrategy(
|
||||
{} as any,
|
||||
{} as any,
|
||||
jwtWrapperService,
|
||||
typeORMService,
|
||||
dataSourceService,
|
||||
workspaceRepository,
|
||||
@ -231,7 +236,7 @@ describe('JwtAuthStrategy', () => {
|
||||
|
||||
strategy = new JwtAuthStrategy(
|
||||
{} as any,
|
||||
{} as any,
|
||||
jwtWrapperService,
|
||||
typeORMService,
|
||||
dataSourceService,
|
||||
workspaceRepository,
|
||||
|
||||
@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { Strategy } from 'passport-jwt';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
|
||||
@ -36,25 +36,28 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
@InjectRepository(UserWorkspace, 'core')
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKeyProvider: async (request, rawJwtToken, done) => {
|
||||
try {
|
||||
const decodedToken = this.jwtWrapperService.decode(
|
||||
rawJwtToken,
|
||||
) as JwtPayload;
|
||||
const workspaceId = decodedToken.workspaceId;
|
||||
const secret = this.jwtWrapperService.generateAppSecret(
|
||||
'ACCESS',
|
||||
workspaceId,
|
||||
);
|
||||
const jwtFromRequestFunction = jwtWrapperService.extractJwtFromRequest();
|
||||
const secretOrKeyProviderFunction = async (request, rawJwtToken, done) => {
|
||||
try {
|
||||
const decodedToken = jwtWrapperService.decode(
|
||||
rawJwtToken,
|
||||
) as JwtPayload;
|
||||
const workspaceId = decodedToken.workspaceId;
|
||||
const secret = jwtWrapperService.generateAppSecret(
|
||||
'ACCESS',
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
done(null, secret);
|
||||
} catch (error) {
|
||||
done(error, null);
|
||||
}
|
||||
},
|
||||
done(null, secret);
|
||||
} catch (error) {
|
||||
done(error, null);
|
||||
}
|
||||
};
|
||||
|
||||
super({
|
||||
jwtFromRequest: jwtFromRequestFunction,
|
||||
ignoreExpiration: false,
|
||||
secretOrKeyProvider: secretOrKeyProviderFunction,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -39,6 +39,7 @@ describe('AccessTokenService', () => {
|
||||
verifyWorkspaceToken: jest.fn(),
|
||||
decode: jest.fn(),
|
||||
generateAppSecret: jest.fn(),
|
||||
extractJwtFromRequest: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -179,6 +180,9 @@ describe('AccessTokenService', () => {
|
||||
workspaceMemberId: 'workspace-member-id',
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(jwtWrapperService, 'extractJwtFromRequest')
|
||||
.mockReturnValue(() => mockToken);
|
||||
jest
|
||||
.spyOn(jwtWrapperService, 'verifyWorkspaceToken')
|
||||
.mockResolvedValue(undefined);
|
||||
@ -207,6 +211,10 @@ describe('AccessTokenService', () => {
|
||||
headers: {},
|
||||
} as Request;
|
||||
|
||||
jest
|
||||
.spyOn(jwtWrapperService, 'extractJwtFromRequest')
|
||||
.mockReturnValue(() => null);
|
||||
|
||||
await expect(service.validateTokenByRequest(mockRequest)).rejects.toThrow(
|
||||
AuthException,
|
||||
);
|
||||
|
||||
@ -4,7 +4,6 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { addMilliseconds } from 'date-fns';
|
||||
import { Request } from 'express';
|
||||
import ms from 'ms';
|
||||
import { ExtractJwt } from 'passport-jwt';
|
||||
import { isWorkspaceActiveOrSuspended } from 'twenty-shared';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
@ -125,7 +124,7 @@ export class AccessTokenService {
|
||||
}
|
||||
|
||||
async validateTokenByRequest(request: Request): Promise<AuthContext> {
|
||||
const token = ExtractJwt.fromAuthHeaderAsBearerToken()(request);
|
||||
const token = this.jwtWrapperService.extractJwtFromRequest()(request);
|
||||
|
||||
if (!token) {
|
||||
throw new AuthException(
|
||||
|
||||
@ -3,7 +3,9 @@ import { JwtService, JwtSignOptions, JwtVerifyOptions } from '@nestjs/jwt';
|
||||
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
import { Request as ExpressRequest } from 'express';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
import { ExtractJwt, JwtFromRequestFunction } from 'passport-jwt';
|
||||
import { isDefined } from 'twenty-shared';
|
||||
|
||||
import {
|
||||
@ -122,4 +124,20 @@ export class JwtWrapperService {
|
||||
|
||||
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 { 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 { WorkspaceMetadataCacheModule } from 'src/engine/metadata-modules/workspace-metadata-cache/workspace-metadata-cache.module';
|
||||
import { MiddlewareService } from 'src/engine/middlewares/middleware.service';
|
||||
@ -12,6 +13,7 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
|
||||
WorkspaceCacheStorageModule,
|
||||
WorkspaceMetadataCacheModule,
|
||||
TokenModule,
|
||||
JwtModule,
|
||||
],
|
||||
providers: [MiddlewareService],
|
||||
exports: [MiddlewareService],
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import { ExtractJwt } from 'passport-jwt';
|
||||
import { isDefined } from 'twenty-shared';
|
||||
|
||||
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 { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
||||
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 { 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';
|
||||
@ -29,12 +29,13 @@ export class MiddlewareService {
|
||||
private readonly workspaceMetadataCacheService: WorkspaceMetadataCacheService,
|
||||
private readonly dataSourceService: DataSourceService,
|
||||
private readonly exceptionHandlerService: ExceptionHandlerService,
|
||||
private readonly jwtWrapperService: JwtWrapperService,
|
||||
) {}
|
||||
|
||||
private excludedOperations = EXCLUDED_MIDDLEWARE_OPERATIONS;
|
||||
|
||||
public isTokenPresent(request: Request): boolean {
|
||||
const token = ExtractJwt.fromAuthHeaderAsBearerToken()(request);
|
||||
const token = this.jwtWrapperService.extractJwtFromRequest()(request);
|
||||
|
||||
return !!token;
|
||||
}
|
||||
|
||||
@ -25,9 +25,11 @@ export {
|
||||
IconBookmarkPlus,
|
||||
IconBox,
|
||||
IconBrackets,
|
||||
IconBracketsAngle,
|
||||
IconBracketsContain,
|
||||
IconBrandGithub,
|
||||
IconBrandGoogle,
|
||||
IconBrandGraphql,
|
||||
IconBrandLinkedin,
|
||||
IconBrandX,
|
||||
IconBriefcase,
|
||||
@ -142,6 +144,7 @@ export {
|
||||
IconFolder,
|
||||
IconFolderOpen,
|
||||
IconFolderPlus,
|
||||
IconFolderRoot,
|
||||
IconForbid,
|
||||
IconFunction,
|
||||
IconGauge,
|
||||
@ -278,6 +281,7 @@ export {
|
||||
IconVariablePlus,
|
||||
IconVideo,
|
||||
IconWand,
|
||||
IconWebhook,
|
||||
IconWorld,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
Reference in New Issue
Block a user