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", "graphql": "16.8.0",
"type-fest": "4.10.1", "type-fest": "4.10.1",
"typescript": "5.3.3", "typescript": "5.3.3",
"prosemirror-model": "1.23.0" "prosemirror-model": "1.23.0",
"yjs": "13.6.18"
}, },
"version": "0.2.1", "version": "0.2.1",
"nx": {}, "nx": {},

View File

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

View File

@ -38,6 +38,7 @@
"@nivo/core": "^0.87.0", "@nivo/core": "^0.87.0",
"@nivo/line": "^0.87.0", "@nivo/line": "^0.87.0",
"@react-pdf/renderer": "^4.1.6", "@react-pdf/renderer": "^4.1.6",
"@scalar/api-reference-react": "^0.4.36",
"@tiptap/core": "^2.10.4", "@tiptap/core": "^2.10.4",
"@tiptap/extension-document": "^2.10.4", "@tiptap/extension-document": "^2.10.4",
"@tiptap/extension-hard-break": "^2.10.4", "@tiptap/extension-hard-break": "^2.10.4",

View File

@ -7,6 +7,38 @@ import { SettingsPath } from '@/types/SettingsPath';
import { FeatureFlagKey } from '~/generated-metadata/graphql'; import { FeatureFlagKey } from '~/generated-metadata/graphql';
import { SettingsPermissions } from '~/generated/graphql'; import { SettingsPermissions } from '~/generated/graphql';
const SettingsApiKeys = lazy(() =>
import('~/pages/settings/developers/api-keys/SettingsApiKeys').then(
(module) => ({
default: module.SettingsApiKeys,
}),
),
);
const SettingsGraphQLPlayground = lazy(() =>
import(
'~/pages/settings/developers/playground/SettingsGraphQLPlayground'
).then((module) => ({
default: module.SettingsGraphQLPlayground,
})),
);
const SettingsRestPlayground = lazy(() =>
import('~/pages/settings/developers/playground/SettingsRestPlayground').then(
(module) => ({
default: module.SettingsRestPlayground,
}),
),
);
const SettingsWebhooks = lazy(() =>
import(
'~/pages/settings/developers/webhooks/components/SettingsWebhooks'
).then((module) => ({
default: module.SettingsWebhooks,
})),
);
const SettingsAccountsCalendars = lazy(() => const SettingsAccountsCalendars = lazy(() =>
import('~/pages/settings/accounts/SettingsAccountsCalendars').then( import('~/pages/settings/accounts/SettingsAccountsCalendars').then(
(module) => ({ (module) => ({
@ -137,12 +169,6 @@ const SettingsBilling = lazy(() =>
})), })),
); );
const SettingsDevelopers = lazy(() =>
import('~/pages/settings/developers/SettingsDevelopers').then((module) => ({
default: module.SettingsDevelopers,
})),
);
const SettingsIntegrations = lazy(() => const SettingsIntegrations = lazy(() =>
import('~/pages/settings/integrations/SettingsIntegrations').then( import('~/pages/settings/integrations/SettingsIntegrations').then(
(module) => ({ (module) => ({
@ -376,9 +402,15 @@ export const SettingsRoutes = ({
/> />
} }
> >
<Route path={SettingsPath.APIs} element={<SettingsApiKeys />} />
<Route path={SettingsPath.Webhooks} element={<SettingsWebhooks />} />
<Route <Route
path={SettingsPath.Developers} path={`${SettingsPath.GraphQLPlayground}`}
element={<SettingsDevelopers />} element={<SettingsGraphQLPlayground />}
/>
<Route
path={`${SettingsPath.RestPlayground}/*`}
element={<SettingsRestPlayground />}
/> />
<Route <Route
path={SettingsPath.DevelopersNewApiKey} path={SettingsPath.DevelopersNewApiKey}

View File

@ -57,7 +57,9 @@ export const SettingsNavigationDrawerItems = () => {
getSelectedIndexForSubItems(subItems); getSelectedIndexForSubItems(subItems);
return ( return (
<NavigationDrawerItemGroup key={item.path}> <NavigationDrawerItemGroup
key={item.path || `group-${index}`}
>
<SettingsNavigationDrawerItem <SettingsNavigationDrawerItem
item={item} item={item}
subItemState={ subItemState={
@ -72,7 +74,7 @@ export const SettingsNavigationDrawerItems = () => {
/> />
{subItems.map((subItem, subIndex) => ( {subItems.map((subItem, subIndex) => (
<SettingsNavigationDrawerItem <SettingsNavigationDrawerItem
key={subItem.path} key={subItem.path || `subitem-${subIndex}`}
item={subItem} item={subItem}
subItemState={ subItemState={
subItem.indentationLevel subItem.indentationLevel
@ -90,7 +92,7 @@ export const SettingsNavigationDrawerItems = () => {
} }
return ( return (
<SettingsNavigationDrawerItem <SettingsNavigationDrawerItem
key={item.path} key={item.path || `item-${index}`}
item={item} item={item}
subItemState={ subItemState={
item.indentationLevel item.indentationLevel

View File

@ -4,13 +4,15 @@ import { SettingsApiKeysFieldItemTableRow } from '@/settings/developers/componen
import { ApiFieldItem } from '@/settings/developers/types/api-key/ApiFieldItem'; import { ApiFieldItem } from '@/settings/developers/types/api-key/ApiFieldItem';
import { ApiKey } from '@/settings/developers/types/api-key/ApiKey'; import { ApiKey } from '@/settings/developers/types/api-key/ApiKey';
import { formatExpirations } from '@/settings/developers/utils/formatExpiration'; import { formatExpirations } from '@/settings/developers/utils/formatExpiration';
import { SettingsPath } from '@/types/SettingsPath';
import { Table } from '@/ui/layout/table/components/Table'; import { Table } from '@/ui/layout/table/components/Table';
import { TableBody } from '@/ui/layout/table/components/TableBody'; import { TableBody } from '@/ui/layout/table/components/TableBody';
import { TableHeader } from '@/ui/layout/table/components/TableHeader'; import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow'; import { TableRow } from '@/ui/layout/table/components/TableRow';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { MOBILE_VIEWPORT } from 'twenty-ui';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { MOBILE_VIEWPORT } from 'twenty-ui';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
const StyledTableBody = styled(TableBody)` const StyledTableBody = styled(TableBody)`
border-bottom: 1px solid ${({ theme }) => theme.border.color.light}; border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
@ -53,7 +55,9 @@ export const SettingsApiKeysTable = () => {
<SettingsApiKeysFieldItemTableRow <SettingsApiKeysFieldItemTableRow
key={fieldItem.id} key={fieldItem.id}
fieldItem={fieldItem as ApiFieldItem} fieldItem={fieldItem as ApiFieldItem}
to={`/settings/developers/api-keys/${fieldItem.id}`} to={getSettingsPath(SettingsPath.DevelopersApiKeyDetail, {
apiKeyId: fieldItem.id,
})}
/> />
))} ))}
</StyledTableBody> </StyledTableBody>

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 () => { const deleteWebhook = async () => {
await deleteOneWebhook(webhookId); await deleteOneWebhook(webhookId);
navigate(SettingsPath.Developers); navigate(SettingsPath.Webhooks);
}; };
useFindOneRecord({ useFindOneRecord({

View File

@ -1,8 +1,8 @@
import { import {
IconApi,
IconApps, IconApps,
IconAt, IconAt,
IconCalendarEvent, IconCalendarEvent,
IconCode,
IconColorSwatch, IconColorSwatch,
IconComponent, IconComponent,
IconCurrencyDollar, IconCurrencyDollar,
@ -18,6 +18,7 @@ import {
IconSettings, IconSettings,
IconUserCircle, IconUserCircle,
IconUsers, IconUsers,
IconWebhook,
} from 'twenty-ui'; } from 'twenty-ui';
import { SettingsPath } from '@/types/SettingsPath'; import { SettingsPath } from '@/types/SettingsPath';
@ -159,9 +160,16 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
isAdvanced: true, isAdvanced: true,
items: [ items: [
{ {
label: t`API & Webhooks`, label: t`APIs`,
path: SettingsPath.Developers, path: SettingsPath.APIs,
Icon: IconCode, Icon: IconApi,
isAdvanced: true,
isHidden: !permissionMap[SettingsPermissions.API_KEYS_AND_WEBHOOKS],
},
{
label: t`Webhooks`,
path: SettingsPath.Webhooks,
Icon: IconWebhook,
isAdvanced: true, isAdvanced: true,
isHidden: !permissionMap[SettingsPermissions.API_KEYS_AND_WEBHOOKS], isHidden: !permissionMap[SettingsPermissions.API_KEYS_AND_WEBHOOKS],
}, },

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

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', WorkspaceMembersPage = 'workspace-members',
Workspace = 'workspace', Workspace = 'workspace',
Domain = 'domain', Domain = 'domain',
Developers = 'developers', APIs = 'api-keys',
DevelopersNewApiKey = 'developers/api-keys/new', RestPlayground = 'playground/rest/:schema',
DevelopersApiKeyDetail = 'developers/api-keys/:apiKeyId', GraphQLPlayground = 'playground/graphql/:schema',
DevelopersNewApiKey = 'api-keys/new',
DevelopersApiKeyDetail = 'api-keys/:apiKeyId',
Integrations = 'integrations', Integrations = 'integrations',
IntegrationDatabase = 'integrations/:databaseKey', IntegrationDatabase = 'integrations/:databaseKey',
IntegrationDatabaseConnection = 'integrations/:databaseKey/:connectionId', IntegrationDatabaseConnection = 'integrations/:databaseKey/:connectionId',

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 { OBJECT_SETTINGS_WIDTH } from '@/settings/data-model/constants/ObjectSettings';
import { SignInAppNavigationDrawerMock } from '@/sign-in-background-mock/components/SignInAppNavigationDrawerMock'; import { SignInAppNavigationDrawerMock } from '@/sign-in-background-mock/components/SignInAppNavigationDrawerMock';
import { SignInBackgroundMockPage } from '@/sign-in-background-mock/components/SignInBackgroundMockPage'; import { SignInBackgroundMockPage } from '@/sign-in-background-mock/components/SignInBackgroundMockPage';
import { useShowFullscreen } from '@/ui/layout/fullscreen/hooks/useShowFullscreen';
import { useShowAuthModal } from '@/ui/layout/hooks/useShowAuthModal'; import { useShowAuthModal } from '@/ui/layout/hooks/useShowAuthModal';
import { NAV_DRAWER_WIDTHS } from '@/ui/navigation/navigation-drawer/constants/NavDrawerWidths'; import { NAV_DRAWER_WIDTHS } from '@/ui/navigation/navigation-drawer/constants/NavDrawerWidths';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
@ -69,6 +70,7 @@ export const DefaultLayout = () => {
const theme = useTheme(); const theme = useTheme();
const windowsWidth = useScreenSize().width; const windowsWidth = useScreenSize().width;
const showAuthModal = useShowAuthModal(); const showAuthModal = useShowAuthModal();
const useShowFullScreen = useShowFullscreen();
return ( return (
<> <>
@ -90,7 +92,7 @@ export const DefaultLayout = () => {
<StyledPageContainer <StyledPageContainer
animate={{ animate={{
marginLeft: marginLeft:
isSettingsPage && !isMobile isSettingsPage && !isMobile && !useShowFullScreen
? (windowsWidth - ? (windowsWidth -
(OBJECT_SETTINGS_WIDTH + (OBJECT_SETTINGS_WIDTH +
NAV_DRAWER_WIDTHS.menu.desktop.expanded + NAV_DRAWER_WIDTHS.menu.desktop.expanded +
@ -102,7 +104,7 @@ export const DefaultLayout = () => {
> >
{showAuthModal ? ( {showAuthModal ? (
<StyledAppNavigationDrawerMock /> <StyledAppNavigationDrawerMock />
) : ( ) : useShowFullScreen ? null : (
<StyledAppNavigationDrawer /> <StyledAppNavigationDrawer />
)} )}
{showAuthModal ? ( {showAuthModal ? (

View File

@ -1,15 +1,44 @@
import { ActivityRichTextEditor } from '@/activities/components/ActivityRichTextEditor';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { isNewViewableRecordLoadingState } from '@/object-record/record-right-drawer/states/isNewViewableRecordLoading'; import { isNewViewableRecordLoadingState } from '@/object-record/record-right-drawer/states/isNewViewableRecordLoading';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { lazy, Suspense } from 'react';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
const ActivityRichTextEditor = lazy(() =>
import('@/activities/components/ActivityRichTextEditor').then((module) => ({
default: module.ActivityRichTextEditor,
})),
);
const StyledShowPageActivityContainer = styled.div` const StyledShowPageActivityContainer = styled.div`
margin-top: ${({ theme }) => theme.spacing(6)}; margin-top: ${({ theme }) => theme.spacing(6)};
width: 100%; width: 100%;
`; `;
const StyledSkeletonContainer = styled.div`
width: 100%;
`;
const LoadingSkeleton = () => {
const theme = useTheme();
return (
<StyledSkeletonContainer>
<SkeletonTheme
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.lighter}
borderRadius={theme.border.radius.sm}
>
<Skeleton height={200} />
</SkeletonTheme>
</StyledSkeletonContainer>
);
};
export const ShowPageActivityContainer = ({ export const ShowPageActivityContainer = ({
targetableObject, targetableObject,
}: { }: {
@ -28,14 +57,16 @@ export const ShowPageActivityContainer = ({
componentInstanceId={`scroll-wrapper-tab-list-${targetableObject.id}`} componentInstanceId={`scroll-wrapper-tab-list-${targetableObject.id}`}
> >
<StyledShowPageActivityContainer> <StyledShowPageActivityContainer>
<ActivityRichTextEditor <Suspense fallback={<LoadingSkeleton />}>
activityId={targetableObject.id} <ActivityRichTextEditor
activityObjectNameSingular={ activityId={targetableObject.id}
targetableObject.targetObjectNameSingular as activityObjectNameSingular={
| CoreObjectNameSingular.Note targetableObject.targetObjectNameSingular as
| CoreObjectNameSingular.Task | CoreObjectNameSingular.Note
} | CoreObjectNameSingular.Task
/> }
/>
</Suspense>
</StyledShowPageActivityContainer> </StyledShowPageActivityContainer>
</ScrollWrapper> </ScrollWrapper>
) : ( ) : (

View File

@ -1,7 +1,7 @@
import { Meta, StoryObj } from '@storybook/react'; import { Meta, StoryObj } from '@storybook/react';
import { within } from '@storybook/test'; import { within } from '@storybook/test';
import { SettingsDevelopers } from '~/pages/settings/developers/SettingsDevelopers'; import { SettingsApiKeys } from '~/pages/settings/developers/api-keys/SettingsApiKeys';
import { import {
PageDecorator, PageDecorator,
PageDecoratorArgs, PageDecoratorArgs,
@ -9,10 +9,10 @@ import {
import { graphqlMocks } from '~/testing/graphqlMocks'; import { graphqlMocks } from '~/testing/graphqlMocks';
const meta: Meta<PageDecoratorArgs> = { const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Settings/Developers/SettingsDevelopers', title: 'Pages/Settings/ApiKeys',
component: SettingsDevelopers, component: SettingsApiKeys,
decorators: [PageDecorator], decorators: [PageDecorator],
args: { routePath: '/settings/developers' }, args: { routePath: '/settings/apis' },
parameters: { parameters: {
msw: graphqlMocks, msw: graphqlMocks,
}, },
@ -20,7 +20,7 @@ const meta: Meta<PageDecoratorArgs> = {
export default meta; export default meta;
export type Story = StoryObj<typeof SettingsDevelopers>; export type Story = StoryObj<typeof SettingsApiKeys>;
export const Default: Story = { export const Default: Story = {
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {

View File

@ -11,7 +11,7 @@ import { graphqlMocks } from '~/testing/graphqlMocks';
import { sleep } from '~/utils/sleep'; import { sleep } from '~/utils/sleep';
const meta: Meta<PageDecoratorArgs> = { const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Settings/Developers/ApiKeys/SettingsDevelopersApiKeyDetail', title: 'Pages/Settings/ApiKeys/SettingsDevelopersApiKeyDetail',
component: SettingsDevelopersApiKeyDetail, component: SettingsDevelopersApiKeyDetail,
decorators: [PageDecorator], decorators: [PageDecorator],
args: { args: {

View File

@ -11,7 +11,7 @@ import { graphqlMocks } from '~/testing/graphqlMocks';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
const meta: Meta<PageDecoratorArgs> = { const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Settings/Developers/ApiKeys/SettingsDevelopersApiKeysNew', title: 'Pages/Settings/ApiKeys/SettingsDevelopersApiKeysNew',
component: SettingsDevelopersApiKeysNew, component: SettingsDevelopersApiKeysNew,
decorators: [PageDecorator], decorators: [PageDecorator],
args: { routePath: getSettingsPath(SettingsPath.DevelopersNewApiKey) }, args: { routePath: getSettingsPath(SettingsPath.DevelopersNewApiKey) },

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'; import { graphqlMocks } from '~/testing/graphqlMocks';
const meta: Meta<PageDecoratorArgs> = { const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Settings/Developers/Webhooks/SettingsDevelopersWebhooksDetail', title: 'Pages/Settings/Webhooks/SettingsDevelopersWebhooksDetail',
component: SettingsDevelopersWebhooksDetail, component: SettingsDevelopersWebhooksDetail,
decorators: [PageDecorator], decorators: [PageDecorator],
args: { args: {

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() }, updateOneRecordInput: { revokedAt: DateTime.now().toString() },
}); });
if (redirect) { if (redirect) {
navigate(SettingsPath.Developers); navigate(SettingsPath.APIs);
} }
} catch (err) { } catch (err) {
enqueueSnackBar(t`Error deleting api key: ${err}`, { enqueueSnackBar(t`Error deleting api key: ${err}`, {
@ -166,8 +166,8 @@ export const SettingsDevelopersApiKeyDetail = () => {
href: getSettingsPath(SettingsPath.Workspace), href: getSettingsPath(SettingsPath.Workspace),
}, },
{ {
children: t`Developers`, children: t`APIs`,
href: getSettingsPath(SettingsPath.Developers), href: getSettingsPath(SettingsPath.APIs),
}, },
{ children: t`${apiKeyName} API Key` }, { children: t`${apiKeyName} API Key` },
]} ]}

View File

@ -86,8 +86,8 @@ export const SettingsDevelopersApiKeysNew = () => {
href: getSettingsPath(SettingsPath.Workspace), href: getSettingsPath(SettingsPath.Workspace),
}, },
{ {
children: t`Developers`, children: t`APIs`,
href: getSettingsPath(SettingsPath.Developers), href: getSettingsPath(SettingsPath.APIs),
}, },
{ children: t`New Key` }, { children: t`New Key` },
]} ]}
@ -95,7 +95,7 @@ export const SettingsDevelopersApiKeysNew = () => {
<SaveAndCancelButtons <SaveAndCancelButtons
isSaveDisabled={!canSave} isSaveDisabled={!canSave}
onCancel={() => { onCancel={() => {
navigateSettings(SettingsPath.Developers); navigateSettings(SettingsPath.APIs);
}} }}
onSave={handleSave} onSave={handleSave}
/> />

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`, children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace), href: getSettingsPath(SettingsPath.Workspace),
}, },
{
children: t`Developers`,
href: getSettingsPath(SettingsPath.Developers),
},
{ children: t`Webhook` }, { children: t`Webhook` },
]} ]}
> >

View File

@ -1,7 +1,4 @@
import { v4 } from 'uuid';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsApiKeysTable } from '@/settings/developers/components/SettingsApiKeysTable';
import { SettingsReadDocumentationButton } from '@/settings/developers/components/SettingsReadDocumentationButton';
import { SettingsWebhooksTable } from '@/settings/developers/components/SettingsWebhooksTable'; import { SettingsWebhooksTable } from '@/settings/developers/components/SettingsWebhooksTable';
import { SettingsPath } from '@/types/SettingsPath'; import { SettingsPath } from '@/types/SettingsPath';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
@ -9,6 +6,7 @@ import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { Trans, useLingui } from '@lingui/react/macro'; import { Trans, useLingui } from '@lingui/react/macro';
import { Button, H2Title, IconPlus, MOBILE_VIEWPORT, Section } from 'twenty-ui'; import { Button, H2Title, IconPlus, MOBILE_VIEWPORT, Section } from 'twenty-ui';
import { v4 } from 'uuid';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
const StyledButtonContainer = styled.div` const StyledButtonContainer = styled.div`
@ -27,40 +25,23 @@ const StyledContainer = styled.div<{ isMobile: boolean }>`
gap: ${({ theme }) => theme.spacing(2)}; gap: ${({ theme }) => theme.spacing(2)};
`; `;
export const SettingsDevelopers = () => { export const SettingsWebhooks = () => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const { t } = useLingui(); const { t } = useLingui();
return ( return (
<SubMenuTopBarContainer <SubMenuTopBarContainer
title={t`Developers`} title={t`Webhooks`}
actionButton={<SettingsReadDocumentationButton />}
links={[ links={[
{ {
children: <Trans>Workspace</Trans>, children: <Trans>Workspace</Trans>,
href: getSettingsPath(SettingsPath.Workspace), href: getSettingsPath(SettingsPath.Workspace),
}, },
{ children: <Trans>Developers</Trans> }, { children: <Trans>Webhooks</Trans> },
]} ]}
> >
<SettingsPageContainer> <SettingsPageContainer>
<StyledContainer isMobile={isMobile}> <StyledContainer isMobile={isMobile}>
<Section>
<H2Title
title={t`API keys`}
description={t`Active API keys created by you or your team.`}
/>
<SettingsApiKeysTable />
<StyledButtonContainer>
<Button
Icon={IconPlus}
title={t`Create API key`}
size="small"
variant="secondary"
to={getSettingsPath(SettingsPath.DevelopersNewApiKey)}
/>
</StyledButtonContainer>
</Section>
<Section> <Section>
<H2Title <H2Title
title={t`Webhooks`} title={t`Webhooks`}

View File

@ -3,15 +3,14 @@ import { Trans, useLingui } from '@lingui/react/macro';
import { H2Title, IconLock, Section, Tag } from 'twenty-ui'; import { H2Title, IconLock, Section, Tag } from 'twenty-ui';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsReadDocumentationButton } from '@/settings/developers/components/SettingsReadDocumentationButton';
import { SettingsSSOIdentitiesProvidersListCard } from '@/settings/security/components/SSO/SettingsSSOIdentitiesProvidersListCard'; import { SettingsSSOIdentitiesProvidersListCard } from '@/settings/security/components/SSO/SettingsSSOIdentitiesProvidersListCard';
import { SettingsSecurityAuthProvidersOptionsList } from '@/settings/security/components/SettingsSecurityAuthProvidersOptionsList'; import { SettingsSecurityAuthProvidersOptionsList } from '@/settings/security/components/SettingsSecurityAuthProvidersOptionsList';
import { SettingsApprovedAccessDomainsListCard } from '@/settings/security/components/approvedAccessDomains/SettingsApprovedAccessDomainsListCard';
import { SettingsPath } from '@/types/SettingsPath'; import { SettingsPath } from '@/types/SettingsPath';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import { SettingsApprovedAccessDomainsListCard } from '@/settings/security/components/approvedAccessDomains/SettingsApprovedAccessDomainsListCard';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { FeatureFlagKey } from '~/generated/graphql'; import { FeatureFlagKey } from '~/generated/graphql';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
const StyledContainer = styled.div` const StyledContainer = styled.div`
width: 100%; width: 100%;
@ -38,7 +37,6 @@ export const SettingsSecurity = () => {
return ( return (
<SubMenuTopBarContainer <SubMenuTopBarContainer
title={t`Security`} title={t`Security`}
actionButton={<SettingsReadDocumentationButton />}
links={[ links={[
{ {
children: <Trans>Workspace</Trans>, children: <Trans>Workspace</Trans>,

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 = { export const graphqlMocks = {
handlers: [ handlers: [
graphql.query('IntrospectionQuery', () => {
return HttpResponse.json({
data: {
__schema: {
queryType: { name: 'Query' },
types: [
{
kind: 'OBJECT',
name: 'Query',
fields: [
{
name: 'name',
type: { kind: 'SCALAR', name: 'String' },
args: [],
},
],
interfaces: [],
args: [],
},
{
kind: 'SCALAR',
name: 'String',
fields: [],
interfaces: [],
args: [],
},
],
directives: [],
},
},
});
}),
graphql.query(getOperationName(GET_CURRENT_USER) ?? '', () => { graphql.query(getOperationName(GET_CURRENT_USER) ?? '', () => {
return HttpResponse.json({ return HttpResponse.json({
data: { data: {

View File

@ -9,6 +9,8 @@ export enum SettingsPageTitles {
Objects = 'Data model - Settings', Objects = 'Data model - Settings',
Members = 'Members - Settings', Members = 'Members - Settings',
Developers = 'Developers - Settings', Developers = 'Developers - Settings',
Apis = 'API Keys - Settings',
Webhooks = 'Webhooks - Settings',
Integration = 'Integrations - Settings', Integration = 'Integrations - Settings',
ServerlessFunctions = 'Functions - Settings', ServerlessFunctions = 'Functions - Settings',
General = 'General - Settings', General = 'General - Settings',
@ -21,7 +23,8 @@ enum SettingsPathPrefixes {
Profile = `${AppBasePath.Settings}/${SettingsPath.ProfilePage}`, Profile = `${AppBasePath.Settings}/${SettingsPath.ProfilePage}`,
Objects = `${AppBasePath.Settings}/${SettingsPath.Objects}`, Objects = `${AppBasePath.Settings}/${SettingsPath.Objects}`,
Members = `${AppBasePath.Settings}/${SettingsPath.WorkspaceMembersPage}`, Members = `${AppBasePath.Settings}/${SettingsPath.WorkspaceMembersPage}`,
Developers = `${AppBasePath.Settings}/${SettingsPath.Developers}`, ApiKeys = `${AppBasePath.Settings}/${SettingsPath.APIs}`,
Webhooks = `${AppBasePath.Settings}/${SettingsPath.Webhooks}`,
ServerlessFunctions = `${AppBasePath.Settings}/${SettingsPath.ServerlessFunctions}`, ServerlessFunctions = `${AppBasePath.Settings}/${SettingsPath.ServerlessFunctions}`,
Integration = `${AppBasePath.Settings}/${SettingsPath.Integrations}`, Integration = `${AppBasePath.Settings}/${SettingsPath.Integrations}`,
General = `${AppBasePath.Settings}/${SettingsPath.Workspace}`, General = `${AppBasePath.Settings}/${SettingsPath.Workspace}`,
@ -59,8 +62,10 @@ export const getPageTitleFromPath = (pathname: string): string => {
return SettingsPageTitles.Members; return SettingsPageTitles.Members;
case SettingsPathPrefixes.Objects: case SettingsPathPrefixes.Objects:
return SettingsPageTitles.Objects; return SettingsPageTitles.Objects;
case SettingsPathPrefixes.Developers: case SettingsPathPrefixes.ApiKeys:
return SettingsPageTitles.Developers; return SettingsPageTitles.Apis;
case SettingsPathPrefixes.Webhooks:
return SettingsPageTitles.Webhooks;
case SettingsPathPrefixes.ServerlessFunctions: case SettingsPathPrefixes.ServerlessFunctions:
return SettingsPageTitles.ServerlessFunctions; return SettingsPageTitles.ServerlessFunctions;
case SettingsPathPrefixes.Integration: case SettingsPathPrefixes.Integration:

View File

@ -16,6 +16,7 @@ describe('JwtAuthStrategy', () => {
let userRepository: any; let userRepository: any;
let dataSourceService: any; let dataSourceService: any;
let typeORMService: any; let typeORMService: any;
const jwt = { const jwt = {
sub: 'sub-default', sub: 'sub-default',
jti: 'jti-default', jti: 'jti-default',
@ -33,6 +34,10 @@ describe('JwtAuthStrategy', () => {
findOne: jest.fn(async () => new UserWorkspace()), findOne: jest.fn(async () => new UserWorkspace()),
}; };
const jwtWrapperService: any = {
extractJwtFromRequest: jest.fn(() => () => 'token'),
};
// first we test the API_KEY case // first we test the API_KEY case
it('should throw AuthException if type is API_KEY and workspace is not found', async () => { it('should throw AuthException if type is API_KEY and workspace is not found', async () => {
const payload = { const payload = {
@ -46,7 +51,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy( strategy = new JwtAuthStrategy(
{} as any, {} as any,
{} as any, jwtWrapperService,
typeORMService, typeORMService,
dataSourceService, dataSourceService,
workspaceRepository, workspaceRepository,
@ -82,7 +87,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy( strategy = new JwtAuthStrategy(
{} as any, {} as any,
{} as any, jwtWrapperService,
typeORMService, typeORMService,
dataSourceService, dataSourceService,
workspaceRepository, workspaceRepository,
@ -120,7 +125,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy( strategy = new JwtAuthStrategy(
{} as any, {} as any,
{} as any, jwtWrapperService,
typeORMService, typeORMService,
dataSourceService, dataSourceService,
workspaceRepository, workspaceRepository,
@ -152,7 +157,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy( strategy = new JwtAuthStrategy(
{} as any, {} as any,
{} as any, jwtWrapperService,
typeORMService, typeORMService,
dataSourceService, dataSourceService,
workspaceRepository, workspaceRepository,
@ -190,7 +195,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy( strategy = new JwtAuthStrategy(
{} as any, {} as any,
{} as any, jwtWrapperService,
typeORMService, typeORMService,
dataSourceService, dataSourceService,
workspaceRepository, workspaceRepository,
@ -231,7 +236,7 @@ describe('JwtAuthStrategy', () => {
strategy = new JwtAuthStrategy( strategy = new JwtAuthStrategy(
{} as any, {} as any,
{} as any, jwtWrapperService,
typeORMService, typeORMService,
dataSourceService, dataSourceService,
workspaceRepository, workspaceRepository,

View File

@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { ExtractJwt, Strategy } from 'passport-jwt'; import { Strategy } from 'passport-jwt';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { TypeORMService } from 'src/database/typeorm/typeorm.service'; import { TypeORMService } from 'src/database/typeorm/typeorm.service';
@ -36,25 +36,28 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
@InjectRepository(UserWorkspace, 'core') @InjectRepository(UserWorkspace, 'core')
private readonly userWorkspaceRepository: Repository<UserWorkspace>, private readonly userWorkspaceRepository: Repository<UserWorkspace>,
) { ) {
super({ const jwtFromRequestFunction = jwtWrapperService.extractJwtFromRequest();
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), const secretOrKeyProviderFunction = async (request, rawJwtToken, done) => {
ignoreExpiration: false, try {
secretOrKeyProvider: async (request, rawJwtToken, done) => { const decodedToken = jwtWrapperService.decode(
try { rawJwtToken,
const decodedToken = this.jwtWrapperService.decode( ) as JwtPayload;
rawJwtToken, const workspaceId = decodedToken.workspaceId;
) as JwtPayload; const secret = jwtWrapperService.generateAppSecret(
const workspaceId = decodedToken.workspaceId; 'ACCESS',
const secret = this.jwtWrapperService.generateAppSecret( workspaceId,
'ACCESS', );
workspaceId,
);
done(null, secret); done(null, secret);
} catch (error) { } catch (error) {
done(error, null); done(error, null);
} }
}, };
super({
jwtFromRequest: jwtFromRequestFunction,
ignoreExpiration: false,
secretOrKeyProvider: secretOrKeyProviderFunction,
}); });
} }

View File

@ -39,6 +39,7 @@ describe('AccessTokenService', () => {
verifyWorkspaceToken: jest.fn(), verifyWorkspaceToken: jest.fn(),
decode: jest.fn(), decode: jest.fn(),
generateAppSecret: jest.fn(), generateAppSecret: jest.fn(),
extractJwtFromRequest: jest.fn(),
}, },
}, },
{ {
@ -179,6 +180,9 @@ describe('AccessTokenService', () => {
workspaceMemberId: 'workspace-member-id', workspaceMemberId: 'workspace-member-id',
}; };
jest
.spyOn(jwtWrapperService, 'extractJwtFromRequest')
.mockReturnValue(() => mockToken);
jest jest
.spyOn(jwtWrapperService, 'verifyWorkspaceToken') .spyOn(jwtWrapperService, 'verifyWorkspaceToken')
.mockResolvedValue(undefined); .mockResolvedValue(undefined);
@ -207,6 +211,10 @@ describe('AccessTokenService', () => {
headers: {}, headers: {},
} as Request; } as Request;
jest
.spyOn(jwtWrapperService, 'extractJwtFromRequest')
.mockReturnValue(() => null);
await expect(service.validateTokenByRequest(mockRequest)).rejects.toThrow( await expect(service.validateTokenByRequest(mockRequest)).rejects.toThrow(
AuthException, AuthException,
); );

View File

@ -4,7 +4,6 @@ import { InjectRepository } from '@nestjs/typeorm';
import { addMilliseconds } from 'date-fns'; import { addMilliseconds } from 'date-fns';
import { Request } from 'express'; import { Request } from 'express';
import ms from 'ms'; import ms from 'ms';
import { ExtractJwt } from 'passport-jwt';
import { isWorkspaceActiveOrSuspended } from 'twenty-shared'; import { isWorkspaceActiveOrSuspended } from 'twenty-shared';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
@ -125,7 +124,7 @@ export class AccessTokenService {
} }
async validateTokenByRequest(request: Request): Promise<AuthContext> { async validateTokenByRequest(request: Request): Promise<AuthContext> {
const token = ExtractJwt.fromAuthHeaderAsBearerToken()(request); const token = this.jwtWrapperService.extractJwtFromRequest()(request);
if (!token) { if (!token) {
throw new AuthException( throw new AuthException(

View File

@ -3,7 +3,9 @@ import { JwtService, JwtSignOptions, JwtVerifyOptions } from '@nestjs/jwt';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import { Request as ExpressRequest } from 'express';
import * as jwt from 'jsonwebtoken'; import * as jwt from 'jsonwebtoken';
import { ExtractJwt, JwtFromRequestFunction } from 'passport-jwt';
import { isDefined } from 'twenty-shared'; import { isDefined } from 'twenty-shared';
import { import {
@ -122,4 +124,20 @@ export class JwtWrapperService {
return accessTokenSecret; return accessTokenSecret;
} }
extractJwtFromRequest(): JwtFromRequestFunction {
return (request: ExpressRequest) => {
// First try to extract token from Authorization header
const tokenFromHeader = ExtractJwt.fromAuthHeaderAsBearerToken()(request);
if (tokenFromHeader) {
return tokenFromHeader;
}
// If not found in header, try to extract from URL query parameter
// This is for edge cases where we don't control the origin request
// (e.g. the REST API playground)
return ExtractJwt.fromUrlQueryParameter('token')(request);
};
}
} }

View File

@ -1,6 +1,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module'; import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { WorkspaceMetadataCacheModule } from 'src/engine/metadata-modules/workspace-metadata-cache/workspace-metadata-cache.module'; import { WorkspaceMetadataCacheModule } from 'src/engine/metadata-modules/workspace-metadata-cache/workspace-metadata-cache.module';
import { MiddlewareService } from 'src/engine/middlewares/middleware.service'; import { MiddlewareService } from 'src/engine/middlewares/middleware.service';
@ -12,6 +13,7 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
WorkspaceCacheStorageModule, WorkspaceCacheStorageModule,
WorkspaceMetadataCacheModule, WorkspaceMetadataCacheModule,
TokenModule, TokenModule,
JwtModule,
], ],
providers: [MiddlewareService], providers: [MiddlewareService],
exports: [MiddlewareService], exports: [MiddlewareService],

View File

@ -1,7 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { ExtractJwt } from 'passport-jwt';
import { isDefined } from 'twenty-shared'; import { isDefined } from 'twenty-shared';
import { AuthExceptionCode } from 'src/engine/core-modules/auth/auth.exception'; import { AuthExceptionCode } from 'src/engine/core-modules/auth/auth.exception';
@ -9,6 +8,7 @@ import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service'; import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service'; import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service';
import { INTERNAL_SERVER_ERROR } from 'src/engine/middlewares/constants/default-error-message.constant'; import { INTERNAL_SERVER_ERROR } from 'src/engine/middlewares/constants/default-error-message.constant';
@ -29,12 +29,13 @@ export class MiddlewareService {
private readonly workspaceMetadataCacheService: WorkspaceMetadataCacheService, private readonly workspaceMetadataCacheService: WorkspaceMetadataCacheService,
private readonly dataSourceService: DataSourceService, private readonly dataSourceService: DataSourceService,
private readonly exceptionHandlerService: ExceptionHandlerService, private readonly exceptionHandlerService: ExceptionHandlerService,
private readonly jwtWrapperService: JwtWrapperService,
) {} ) {}
private excludedOperations = EXCLUDED_MIDDLEWARE_OPERATIONS; private excludedOperations = EXCLUDED_MIDDLEWARE_OPERATIONS;
public isTokenPresent(request: Request): boolean { public isTokenPresent(request: Request): boolean {
const token = ExtractJwt.fromAuthHeaderAsBearerToken()(request); const token = this.jwtWrapperService.extractJwtFromRequest()(request);
return !!token; return !!token;
} }

View File

@ -25,9 +25,11 @@ export {
IconBookmarkPlus, IconBookmarkPlus,
IconBox, IconBox,
IconBrackets, IconBrackets,
IconBracketsAngle,
IconBracketsContain, IconBracketsContain,
IconBrandGithub, IconBrandGithub,
IconBrandGoogle, IconBrandGoogle,
IconBrandGraphql,
IconBrandLinkedin, IconBrandLinkedin,
IconBrandX, IconBrandX,
IconBriefcase, IconBriefcase,
@ -142,6 +144,7 @@ export {
IconFolder, IconFolder,
IconFolderOpen, IconFolderOpen,
IconFolderPlus, IconFolderPlus,
IconFolderRoot,
IconForbid, IconForbid,
IconFunction, IconFunction,
IconGauge, IconGauge,
@ -278,6 +281,7 @@ export {
IconVariablePlus, IconVariablePlus,
IconVideo, IconVideo,
IconWand, IconWand,
IconWebhook,
IconWorld, IconWorld,
IconX, IconX,
} from '@tabler/icons-react'; } from '@tabler/icons-react';

1696
yarn.lock

File diff suppressed because it is too large Load Diff