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:
oliver
2025-03-07 09:03:57 -08:00
committed by GitHub
parent d1518764a8
commit fc287dac78
55 changed files with 2915 additions and 163 deletions

View File

@ -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": {},

View File

@ -37,7 +37,7 @@ const config: StorybookConfig = {
'@storybook/addon-docs',
'@storybook/addon-essentials/docs',
],
}
},
},
addons: [
'@storybook/addon-links',

View File

@ -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",

View File

@ -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}

View File

@ -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

View File

@ -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>

View File

@ -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>
);
};

View File

@ -145,7 +145,7 @@ export const useWebhookUpdateForm = ({
const deleteWebhook = async () => {
await deleteOneWebhook(webhookId);
navigate(SettingsPath.Developers);
navigate(SettingsPath.Webhooks);
};
useFindOneRecord({

View File

@ -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

View File

@ -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}`,
})}
/>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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)};
`;

View File

@ -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 />
</>
),
],
};

View File

@ -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: {},
};

View File

@ -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 />
</>
),
],
};

View File

@ -0,0 +1,6 @@
import { createState } from 'twenty-ui';
export const openAPIReferenceState = createState<any>({
key: 'OpenAPIReference',
defaultValue: null,
});

View File

@ -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()],
});

View File

@ -0,0 +1,4 @@
export enum PlaygroundSchemas {
METADATA = 'metadata',
CORE = 'core',
}

View File

@ -0,0 +1,4 @@
export enum PlaygroundTypes {
GRAPHQL = 'graphql',
REST = 'rest',
}

View File

@ -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',

View File

@ -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>
);
};

View File

@ -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],
};

View File

@ -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]);
};

View File

@ -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 ? (

View File

@ -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>
) : (

View File

@ -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 }) => {

View File

@ -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: {

View File

@ -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) },

View File

@ -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'),
},
};

View File

@ -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'),
},
};

View File

@ -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: {

View File

@ -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,
});
},
};

View File

@ -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>
);
};

View File

@ -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` },
]}

View File

@ -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}
/>

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -128,10 +128,6 @@ export const SettingsDevelopersWebhooksDetail = () => {
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: t`Developers`,
href: getSettingsPath(SettingsPath.Developers),
},
{ children: t`Webhook` },
]}
>

View File

@ -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`}

View File

@ -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>,

View File

@ -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>
);

View File

@ -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: {

View File

@ -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:

View File

@ -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,

View File

@ -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,
});
}

View File

@ -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,
);

View File

@ -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(

View File

@ -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);
};
}
}

View File

@ -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],

View File

@ -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;
}

View File

@ -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';

1696
yarn.lock

File diff suppressed because it is too large Load Diff