feat(settings): discovery hero rollout + ephemeral playground token (#21072)

## Summary

Two intertwined streams of work:

### UI — discovery hero pattern, settings shell, AI/API redesign
- **Generalize `SettingsDiscoveryHeroCard`** and use it on Layout, Data
Model, Apps, AI, API/Webhooks, Members. Drops 4 per-page wrapper files
(`SettingsObjectCoverImage`, `SettingsLayoutCoverImage`,
`SettingsLayoutCustomizeVideoModal`,
`SettingsDataModelVisualizeVideoModal`). Each page now supplies cover
src, modal id, and tab list.
- **Modal**: swap `<video>` placeholder for the Vimeo iframe pattern
from `twenty-docs`, per-tab `vimeoId`. Drop the parallel border-bottom
on the header (TabList draws its own baseline) and the grey background
behind the video. Note: Vimeo's embed allowlist applies — the iframes
load with the correct URL on `localhost` but the player itself requires
the video owner to allow the dev/staging domains in Vimeo settings.
- **AI page** rebuilt into a Cockpit pattern (Overview / Models / Skills
/ Tools / Usage). New `SettingsAiOverviewTab` with default Smart/Fast
pickers, at-a-glance stats, and an MCP signpost that deep-links to
`/settings/api-webhooks#mcp`. System Prompt link moved under Models.
Advanced tab removed.
- **API & Webhooks** now has 4 tabs (Playground / MCP / API Keys /
Webhooks). Hero card above tabs. Playground tab inverted to "Core API" /
"Metadata API" sections, each containing REST + GraphQL cards — schema
is the meaningful axis, protocol is secondary. Hash deep-link sync
delegated to the shared `TabListFromUrlOptionalEffect`.
- **Settings shell**: unified drawer outer padding (kill `isSettings`
branch), extract `CollapsibleNavigationDrawerSection`, add `iconColor`
on settings nav items, fix Exit Settings button alignment, 880px content
cap.

### Backend — strategy C: ephemeral playground token
The legacy paste-your-API-key flow is replaced by an on-demand
short-lived token scoped to the calling user's permissions. No shared
"Playground" API key to manage or revoke.

- New `JwtTokenTypeEnum.PLAYGROUND`. `PlaygroundTokenJwtPayload =
Omit<AccessTokenJwtPayload, 'type' | impersonation fields>` so any
future ACCESS claim flows through automatically.
- `AccessTokenService.generatePlaygroundToken` signs an access-shaped
JWT with `type: PLAYGROUND` and a configurable short TTL. A shared
private `resolveTokenSubject` helper parallelizes the user / workspace /
userWorkspace lookups for both generators.
- `JwtAuthStrategy.validateAccessToken` widened to accept
`AccessTokenJwtPayload | PlaygroundTokenJwtPayload`; impersonation gated
on `payload.type === ACCESS` so the union narrows without `as unknown
as` casts. The two branches in `validate()` collapse into one.
- New `PLAYGROUND_TOKEN_EXPIRES_IN` config var (default `2h`).
- New `generatePlaygroundToken` mutation (`WorkspaceAuthGuard`, no args,
returns `AuthToken`).
- Frontend `useOpenPlayground` hook centralizes mint → atom write →
navigate, with Apollo `onError` snackbar and a "use cached PLAYGROUND
token if still fresh" short-circuit (decodes via `jwt-decode`, checks
both `type` AND `exp`). Old API_KEY tokens left in localStorage from the
prior paste-form flow are rejected on `type` alone and force a re-mint —
this is what was causing the "This API Key is revoked" symptom on stale
browsers.

### Drive-by cleanups
- `PlaygroundToken` DTO removed (identical shape to `AuthToken` already
in use).
- 5 `customize-sidebar.webm` imports and the dead placeholder pipeline
removed.

## Test plan

### Discovery hero
- [ ] `/settings/layout`, `/settings/data-model`,
`/settings/applications`, `/settings/ai`, `/settings/api-webhooks`,
`/settings/members` each render the discovery hero card with its
illustration + play button + tabbed modal
- [ ] Modal tabs show the correct Vimeo embed URL per tab; aspect ratio
stays at 1440/900; no parallel border-bottom jog at the tab baseline
- [ ] AI Overview tab shows Smart/Fast model pickers + stats grid + MCP
signpost card; the MCP card lands on `/settings/api-webhooks#mcp` with
the MCP tab active

### API playground (ephemeral token)
- [ ] With an empty `playgroundApiKeyState` in localStorage, clicking
REST or GraphQL playground card opens the playground and the cached
token has `type: "PLAYGROUND"` with ~2h exp
- [ ] Clicking the card again within the freshness window does **not**
re-mint (`iat` / fingerprint stable across visits)
- [ ] Planting a fake API_KEY-shaped JWT in localStorage and clicking
the card forces a fresh mint (old token rejected on `type`)
- [ ] `GET /rest/companies?limit=1` with the cached token returns 200 +
real data
- [ ] `POST /graphql { __typename }` returns 200

### Settings shell
- [ ] Settings nav matches main app drawer padding; sections collapse;
Exit Settings button aligns with the workspace links above
- [ ] Active nav items have a right-gap (cleaner active state)
- [ ] Content area capped at 880px

### Verify
- [ ] `npx nx typecheck twenty-front` passes
- [ ] `npx nx typecheck twenty-server` passes
- [ ] `npx nx lint:diff-with-main twenty-front` passes
- [ ] `npx nx lint:diff-with-main twenty-server` passes
This commit is contained in:
Félix Malfait
2026-06-01 14:16:02 +02:00
committed by GitHub
parent 6e00a122c6
commit b338a7a1d2
148 changed files with 3092 additions and 1694 deletions
@@ -2699,6 +2699,12 @@ type AgentTurn {
createdAt: DateTime!
}
type WorkspaceAiStats {
conversationsCount: Int!
skillsCount: Int!
toolsCount: Int!
}
type CalendarChannel {
id: UUID!
handle: String!
@@ -2996,6 +3002,7 @@ type Query {
myConnectedAccounts: [ConnectedAccountPublicDTO!]!
myCalendarChannels(connectedAccountId: UUID): [CalendarChannel!]!
minimalMetadata: MinimalMetadata!
findWorkspaceAiStats: WorkspaceAiStats!
chatThreads: [AgentChatThread!]!
chatThread(id: UUID!): AgentChatThread!
chatMessages(threadId: UUID!): [AgentMessage!]!
@@ -3269,6 +3276,7 @@ type Mutation {
authorizeApp(clientId: String!, codeChallenge: String, redirectUrl: String!, state: String, scope: String): AuthorizeApp!
renewToken(appToken: String!): AuthTokens!
generateApiKeyToken(apiKeyId: UUID!, expiresAt: String!): ApiKeyToken!
generatePlaygroundToken: AuthToken!
emailPasswordResetLink(email: String!, workspaceId: UUID): EmailPasswordResetLink!
updatePasswordViaResetToken(passwordResetToken: String!, newPassword: String!): InvalidatePassword!
createApplicationRegistration(input: CreateApplicationRegistrationInput!): CreateApplicationRegistration!
@@ -2410,6 +2410,13 @@ export interface AgentTurn {
__typename: 'AgentTurn'
}
export interface WorkspaceAiStats {
conversationsCount: Scalars['Int']
skillsCount: Scalars['Int']
toolsCount: Scalars['Int']
__typename: 'WorkspaceAiStats'
}
export interface CalendarChannel {
id: Scalars['UUID']
handle: Scalars['String']
@@ -2597,6 +2604,7 @@ export interface Query {
myConnectedAccounts: ConnectedAccountPublicDTO[]
myCalendarChannels: CalendarChannel[]
minimalMetadata: MinimalMetadata
findWorkspaceAiStats: WorkspaceAiStats
chatThreads: AgentChatThread[]
chatThread: AgentChatThread
chatMessages: AgentMessage[]
@@ -2803,6 +2811,7 @@ export interface Mutation {
authorizeApp: AuthorizeApp
renewToken: AuthTokens
generateApiKeyToken: ApiKeyToken
generatePlaygroundToken: AuthToken
emailPasswordResetLink: EmailPasswordResetLink
updatePasswordViaResetToken: InvalidatePassword
createApplicationRegistration: CreateApplicationRegistration
@@ -5438,6 +5447,14 @@ export interface AgentTurnGenqlSelection{
__scalar?: boolean | number
}
export interface WorkspaceAiStatsGenqlSelection{
conversationsCount?: boolean | number
skillsCount?: boolean | number
toolsCount?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface CalendarChannelGenqlSelection{
id?: boolean | number
handle?: boolean | number
@@ -5625,6 +5642,7 @@ export interface QueryGenqlSelection{
myConnectedAccounts?: ConnectedAccountPublicDTOGenqlSelection
myCalendarChannels?: (CalendarChannelGenqlSelection & { __args?: {connectedAccountId?: (Scalars['UUID'] | null)} })
minimalMetadata?: MinimalMetadataGenqlSelection
findWorkspaceAiStats?: WorkspaceAiStatsGenqlSelection
chatThreads?: AgentChatThreadGenqlSelection
chatThread?: (AgentChatThreadGenqlSelection & { __args: {id: Scalars['UUID']} })
chatMessages?: (AgentMessageGenqlSelection & { __args: {threadId: Scalars['UUID']} })
@@ -5852,6 +5870,7 @@ export interface MutationGenqlSelection{
authorizeApp?: (AuthorizeAppGenqlSelection & { __args: {clientId: Scalars['String'], codeChallenge?: (Scalars['String'] | null), redirectUrl: Scalars['String'], state?: (Scalars['String'] | null), scope?: (Scalars['String'] | null)} })
renewToken?: (AuthTokensGenqlSelection & { __args: {appToken: Scalars['String']} })
generateApiKeyToken?: (ApiKeyTokenGenqlSelection & { __args: {apiKeyId: Scalars['UUID'], expiresAt: Scalars['String']} })
generatePlaygroundToken?: AuthTokenGenqlSelection
emailPasswordResetLink?: (EmailPasswordResetLinkGenqlSelection & { __args: {email: Scalars['String'], workspaceId?: (Scalars['UUID'] | null)} })
updatePasswordViaResetToken?: (InvalidatePasswordGenqlSelection & { __args: {passwordResetToken: Scalars['String'], newPassword: Scalars['String']} })
createApplicationRegistration?: (CreateApplicationRegistrationGenqlSelection & { __args: {input: CreateApplicationRegistrationInput} })
@@ -8152,6 +8171,14 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const WorkspaceAiStats_possibleTypes: string[] = ['WorkspaceAiStats']
export const isWorkspaceAiStats = (obj?: { __typename?: any } | null): obj is WorkspaceAiStats => {
if (!obj?.__typename) throw new Error('__typename is missing in "isWorkspaceAiStats"')
return WorkspaceAiStats_possibleTypes.includes(obj.__typename)
}
const CalendarChannel_possibleTypes: string[] = ['CalendarChannel']
export const isCalendarChannel = (obj?: { __typename?: any } | null): obj is CalendarChannel => {
if (!obj?.__typename) throw new Error('__typename is missing in "isCalendarChannel"')
File diff suppressed because it is too large Load Diff
@@ -2461,6 +2461,7 @@ export type Mutation = {
executeOneLogicFunction: LogicFunctionExecutionResult;
generateApiKeyToken: ApiKeyToken;
generateApplicationToken: ApplicationTokenPair;
generatePlaygroundToken: AuthToken;
generateTransientToken: TransientToken;
getAuthTokensFromLoginToken: AuthTokens;
getAuthTokensFromOTP: AuthTokens;
@@ -4139,6 +4140,7 @@ export type Query = {
findOneApplication: Application;
findOneApplicationRegistration: ApplicationRegistration;
findOneLogicFunction: LogicFunction;
findWorkspaceAiStats: WorkspaceAiStats;
findWorkspaceFromInviteHash: Workspace;
findWorkspaceInvitations: Array<WorkspaceInvitation>;
frontComponent?: Maybe<FrontComponent>;
@@ -5953,6 +5955,13 @@ export enum WorkspaceActivationStatus {
SUSPENDED = 'SUSPENDED'
}
export type WorkspaceAiStats = {
__typename?: 'WorkspaceAiStats';
conversationsCount: Scalars['Int'];
skillsCount: Scalars['Int'];
toolsCount: Scalars['Int'];
};
export type WorkspaceInvitation = {
__typename?: 'WorkspaceInvitation';
email: Scalars['String'];
@@ -6235,6 +6244,11 @@ export type FindOneSkillQueryVariables = Exact<{
export type FindOneSkillQuery = { __typename?: 'Query', skill?: { __typename?: 'Skill', id: string, name: string, label: string, description?: string | null, icon?: string | null, content: string, isCustom: boolean, isActive: boolean, createdAt: string, updatedAt: string } | null };
export type FindWorkspaceAiStatsQueryVariables = Exact<{ [key: string]: never; }>;
export type FindWorkspaceAiStatsQuery = { __typename?: 'Query', findWorkspaceAiStats: { __typename?: 'WorkspaceAiStats', conversationsCount: number, skillsCount: number, toolsCount: number } };
export type GetAgentTurnsQueryVariables = Exact<{
agentId: Scalars['UUID'];
}>;
@@ -6347,6 +6361,11 @@ export type GenerateApiKeyTokenMutationVariables = Exact<{
export type GenerateApiKeyTokenMutation = { __typename?: 'Mutation', generateApiKeyToken: { __typename?: 'ApiKeyToken', token: string } };
export type GeneratePlaygroundTokenMutationVariables = Exact<{ [key: string]: never; }>;
export type GeneratePlaygroundTokenMutation = { __typename?: 'Mutation', generatePlaygroundToken: { __typename?: 'AuthToken', token: string, expiresAt: string } };
export type GenerateTransientTokenMutationVariables = Exact<{ [key: string]: never; }>;
@@ -8017,6 +8036,7 @@ export const FindManyAgentsDocument = {"kind":"Document","definitions":[{"kind":
export const FindManySkillsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindManySkills"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"skills"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SkillFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SkillFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Skill"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"content"}},{"kind":"Field","name":{"kind":"Name","value":"isCustom"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<FindManySkillsQuery, FindManySkillsQueryVariables>;
export const FindOneAgentDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindOneAgent"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findOneAgent"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AgentFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AgentFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Agent"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"prompt"}},{"kind":"Field","name":{"kind":"Name","value":"modelId"}},{"kind":"Field","name":{"kind":"Name","value":"responseFormat"}},{"kind":"Field","name":{"kind":"Name","value":"roleId"}},{"kind":"Field","name":{"kind":"Name","value":"isCustom"}},{"kind":"Field","name":{"kind":"Name","value":"modelConfiguration"}},{"kind":"Field","name":{"kind":"Name","value":"evaluationInputs"}},{"kind":"Field","name":{"kind":"Name","value":"applicationId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<FindOneAgentQuery, FindOneAgentQueryVariables>;
export const FindOneSkillDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindOneSkill"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"skill"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SkillFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SkillFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Skill"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"content"}},{"kind":"Field","name":{"kind":"Name","value":"isCustom"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<FindOneSkillQuery, FindOneSkillQueryVariables>;
export const FindWorkspaceAiStatsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindWorkspaceAiStats"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findWorkspaceAiStats"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"conversationsCount"}},{"kind":"Field","name":{"kind":"Name","value":"skillsCount"}},{"kind":"Field","name":{"kind":"Name","value":"toolsCount"}}]}}]}}]} as unknown as DocumentNode<FindWorkspaceAiStatsQuery, FindWorkspaceAiStatsQueryVariables>;
export const GetAgentTurnsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAgentTurns"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"agentId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"agentTurns"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"agentId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"agentId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"threadId"}},{"kind":"Field","name":{"kind":"Name","value":"agentId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"evaluations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"score"}},{"kind":"Field","name":{"kind":"Name","value":"comment"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"messages"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"parts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"textContent"}},{"kind":"Field","name":{"kind":"Name","value":"reasoningContent"}},{"kind":"Field","name":{"kind":"Name","value":"toolName"}},{"kind":"Field","name":{"kind":"Name","value":"toolCallId"}},{"kind":"Field","name":{"kind":"Name","value":"toolInput"}},{"kind":"Field","name":{"kind":"Name","value":"toolOutput"}},{"kind":"Field","name":{"kind":"Name","value":"errorMessage"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"providerExecuted"}},{"kind":"Field","name":{"kind":"Name","value":"errorDetails"}},{"kind":"Field","name":{"kind":"Name","value":"sourceUrlSourceId"}},{"kind":"Field","name":{"kind":"Name","value":"sourceUrlUrl"}},{"kind":"Field","name":{"kind":"Name","value":"sourceUrlTitle"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentSourceId"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentMediaType"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentTitle"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentFilename"}},{"kind":"Field","name":{"kind":"Name","value":"fileMediaType"}},{"kind":"Field","name":{"kind":"Name","value":"fileFilename"}},{"kind":"Field","name":{"kind":"Name","value":"fileUrl"}},{"kind":"Field","name":{"kind":"Name","value":"providerMetadata"}}]}}]}}]}}]}}]} as unknown as DocumentNode<GetAgentTurnsQuery, GetAgentTurnsQueryVariables>;
export const GetChatMessagesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetChatMessages"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"chatMessages"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"threadId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"threadId"}},{"kind":"Field","name":{"kind":"Name","value":"turnId"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"parts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"messageId"}},{"kind":"Field","name":{"kind":"Name","value":"orderIndex"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"textContent"}},{"kind":"Field","name":{"kind":"Name","value":"reasoningContent"}},{"kind":"Field","name":{"kind":"Name","value":"toolName"}},{"kind":"Field","name":{"kind":"Name","value":"toolCallId"}},{"kind":"Field","name":{"kind":"Name","value":"toolInput"}},{"kind":"Field","name":{"kind":"Name","value":"toolOutput"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"providerExecuted"}},{"kind":"Field","name":{"kind":"Name","value":"errorMessage"}},{"kind":"Field","name":{"kind":"Name","value":"errorDetails"}},{"kind":"Field","name":{"kind":"Name","value":"sourceUrlSourceId"}},{"kind":"Field","name":{"kind":"Name","value":"sourceUrlUrl"}},{"kind":"Field","name":{"kind":"Name","value":"sourceUrlTitle"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentSourceId"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentMediaType"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentTitle"}},{"kind":"Field","name":{"kind":"Name","value":"sourceDocumentFilename"}},{"kind":"Field","name":{"kind":"Name","value":"fileMediaType"}},{"kind":"Field","name":{"kind":"Name","value":"fileFilename"}},{"kind":"Field","name":{"kind":"Name","value":"fileUrl"}},{"kind":"Field","name":{"kind":"Name","value":"fileId"}},{"kind":"Field","name":{"kind":"Name","value":"providerMetadata"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"chatStreamCatchupChunks"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"threadId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"chunks"}},{"kind":"Field","name":{"kind":"Name","value":"maxSeq"}}]}}]}}]} as unknown as DocumentNode<GetChatMessagesQuery, GetChatMessagesQueryVariables>;
export const GetChatThreadsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetChatThreads"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"chatThreads"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"totalInputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"totalOutputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"contextWindowTokens"}},{"kind":"Field","name":{"kind":"Name","value":"conversationSize"}},{"kind":"Field","name":{"kind":"Name","value":"totalInputCredits"}},{"kind":"Field","name":{"kind":"Name","value":"totalOutputCredits"}},{"kind":"Field","name":{"kind":"Name","value":"deletedAt"}},{"kind":"Field","name":{"kind":"Name","value":"lastMessageAt"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<GetChatThreadsQuery, GetChatThreadsQueryVariables>;
@@ -8031,6 +8051,7 @@ export const FindOneApplicationSummaryDocument = {"kind":"Document","definitions
export const AuthorizeAppDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"authorizeApp"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"clientId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"codeChallenge"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"redirectUrl"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"state"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"authorizeApp"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"clientId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"clientId"}}},{"kind":"Argument","name":{"kind":"Name","value":"codeChallenge"},"value":{"kind":"Variable","name":{"kind":"Name","value":"codeChallenge"}}},{"kind":"Argument","name":{"kind":"Name","value":"redirectUrl"},"value":{"kind":"Variable","name":{"kind":"Name","value":"redirectUrl"}}},{"kind":"Argument","name":{"kind":"Name","value":"state"},"value":{"kind":"Variable","name":{"kind":"Name","value":"state"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"redirectUrl"}}]}}]}}]} as unknown as DocumentNode<AuthorizeAppMutation, AuthorizeAppMutationVariables>;
export const EmailPasswordResetLinkDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"EmailPasswordResetLink"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"email"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"emailPasswordResetLink"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"email"},"value":{"kind":"Variable","name":{"kind":"Name","value":"email"}}},{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}}]}}]}}]} as unknown as DocumentNode<EmailPasswordResetLinkMutation, EmailPasswordResetLinkMutationVariables>;
export const GenerateApiKeyTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"GenerateApiKeyToken"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"apiKeyId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"expiresAt"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"generateApiKeyToken"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"apiKeyId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"apiKeyId"}}},{"kind":"Argument","name":{"kind":"Name","value":"expiresAt"},"value":{"kind":"Variable","name":{"kind":"Name","value":"expiresAt"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"token"}}]}}]}}]} as unknown as DocumentNode<GenerateApiKeyTokenMutation, GenerateApiKeyTokenMutationVariables>;
export const GeneratePlaygroundTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"GeneratePlaygroundToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"generatePlaygroundToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"token"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}}]}}]}}]} as unknown as DocumentNode<GeneratePlaygroundTokenMutation, GeneratePlaygroundTokenMutationVariables>;
export const GenerateTransientTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"generateTransientToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"generateTransientToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"transientToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"token"}}]}}]}}]}}]} as unknown as DocumentNode<GenerateTransientTokenMutation, GenerateTransientTokenMutationVariables>;
export const GetAuthTokensFromLoginTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"getAuthTokensFromLoginToken"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"loginToken"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"origin"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getAuthTokensFromLoginToken"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"loginToken"},"value":{"kind":"Variable","name":{"kind":"Name","value":"loginToken"}}},{"kind":"Argument","name":{"kind":"Name","value":"origin"},"value":{"kind":"Variable","name":{"kind":"Name","value":"origin"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"tokens"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AuthTokenPairFragment"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AuthTokenFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AuthToken"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"token"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AuthTokenPairFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AuthTokenPair"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"accessOrWorkspaceAgnosticToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AuthTokenFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"refreshToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AuthTokenFragment"}}]}}]}}]} as unknown as DocumentNode<GetAuthTokensFromLoginTokenMutation, GetAuthTokensFromLoginTokenMutationVariables>;
export const GetAuthTokensFromOtpDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"getAuthTokensFromOTP"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"loginToken"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"otp"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"captchaToken"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"origin"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getAuthTokensFromOTP"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"loginToken"},"value":{"kind":"Variable","name":{"kind":"Name","value":"loginToken"}}},{"kind":"Argument","name":{"kind":"Name","value":"otp"},"value":{"kind":"Variable","name":{"kind":"Name","value":"otp"}}},{"kind":"Argument","name":{"kind":"Name","value":"captchaToken"},"value":{"kind":"Variable","name":{"kind":"Name","value":"captchaToken"}}},{"kind":"Argument","name":{"kind":"Name","value":"origin"},"value":{"kind":"Variable","name":{"kind":"Name","value":"origin"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"tokens"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AuthTokenPairFragment"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AuthTokenFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AuthToken"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"token"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AuthTokenPairFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AuthTokenPair"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"accessOrWorkspaceAgnosticToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AuthTokenFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"refreshToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AuthTokenFragment"}}]}}]}}]} as unknown as DocumentNode<GetAuthTokensFromOtpMutation, GetAuthTokensFromOtpMutationVariables>;
@@ -0,0 +1,11 @@
import { gql } from '@apollo/client';
export const FIND_WORKSPACE_AI_STATS = gql`
query FindWorkspaceAiStats {
findWorkspaceAiStats {
conversationsCount
skillsCount
toolsCount
}
}
`;
@@ -220,6 +220,12 @@ const SettingsApplicationCommandMenuItemDetail = lazy(() =>
),
);
const SettingsLayout = lazy(() =>
import('~/pages/settings/layout/SettingsLayout').then((module) => ({
default: module.SettingsLayout,
})),
);
const SettingsLayoutViewDetail = lazy(() =>
import('~/pages/settings/layout/SettingsLayoutViewDetail').then((module) => ({
default: module.SettingsLayoutViewDetail,
@@ -666,6 +672,40 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
path={SettingsPath.ApiWebhooks}
element={<SettingsApiWebhooks />}
/>
<Route path={SettingsPath.Billing} element={<SettingsBilling />} />
<Route path={SettingsPath.Usage} element={<SettingsUsage />} />
<Route
path={SettingsPath.UsageUserDetail}
element={<SettingsUsageUserDetail />}
/>
<Route
path={SettingsPath.Subdomain}
element={<SettingsSubdomainPage />}
/>
<Route
path={SettingsPath.CustomDomain}
element={<SettingsCustomDomainPage />}
/>
<Route
path={SettingsPath.NewEmailingDomain}
element={<SettingsNewEmailingDomain />}
/>
<Route
path={SettingsPath.EmailingDomainDetail}
element={<SettingsEmailingDomainDetail />}
/>
<Route
path={SettingsPath.PublicDomain}
element={<SettingPublicDomain />}
/>
</Route>
<Route
element={
<SettingsProtectedRouteWrapper
settingsPermission={PermissionFlagType.AI}
/>
}
>
<Route path={SettingsPath.AI} element={<SettingsAI />} />
<Route path={SettingsPath.AiPrompts} element={<SettingsAiPrompts />} />
<Route
@@ -700,32 +740,15 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
path={SettingsPath.LogicFunctionDetail}
element={<SettingsLogicFunctionDetail />}
/>
<Route path={SettingsPath.Billing} element={<SettingsBilling />} />
<Route path={SettingsPath.Usage} element={<SettingsUsage />} />
<Route
path={SettingsPath.UsageUserDetail}
element={<SettingsUsageUserDetail />}
/>
<Route
path={SettingsPath.Subdomain}
element={<SettingsSubdomainPage />}
/>
<Route
path={SettingsPath.CustomDomain}
element={<SettingsCustomDomainPage />}
/>
<Route
path={SettingsPath.NewEmailingDomain}
element={<SettingsNewEmailingDomain />}
/>
<Route
path={SettingsPath.EmailingDomainDetail}
element={<SettingsEmailingDomainDetail />}
/>
<Route
path={SettingsPath.PublicDomain}
element={<SettingPublicDomain />}
/>
</Route>
<Route
element={
<SettingsProtectedRouteWrapper
settingsPermission={PermissionFlagType.LAYOUTS}
/>
}
>
<Route path={SettingsPath.Layout} element={<SettingsLayout />} />
</Route>
<Route
element={
@@ -0,0 +1,10 @@
import { gql } from '@apollo/client';
export const GENERATE_PLAYGROUND_TOKEN = gql`
mutation GeneratePlaygroundToken {
generatePlaygroundToken {
token
expiresAt
}
}
`;
@@ -3,7 +3,6 @@ import { safeRemoveLocalStorageItems } from '@/auth/utils/safeRemoveLocalStorage
const SESSION_KEYS_TO_CLEAR = [
'lastVisitedObjectMetadataItemIdState',
'lastVisitedViewPerObjectMetadataItemState',
'playgroundApiKeyState',
'ai/agentChatDraftsByThreadIdState',
'locale',
];
@@ -0,0 +1,14 @@
import { metadataStoreState } from '@/metadata-store/states/metadataStoreState';
import { type FlatFrontComponent } from '@/metadata-store/types/FlatFrontComponent';
import { createAtomSelector } from '@/ui/utilities/state/jotai/utils/createAtomSelector';
export const frontComponentsSelector = createAtomSelector<FlatFrontComponent[]>(
{
key: 'frontComponentsSelector',
get: ({ get }) => {
const storeItem = get(metadataStoreState, 'frontComponents');
return storeItem.current as FlatFrontComponent[];
},
},
);
@@ -24,13 +24,16 @@ export const useEnterLayoutCustomizationMode = () => {
const { navigateSidePanel } = useNavigateSidePanel();
const { enqueueWarningSnackBar } = useSnackBar();
const enterLayoutCustomizationMode = useCallback(() => {
// Returns whether customization mode is active afterward, so callers that
// navigate on entry can skip navigation when entry was blocked (e.g. a
// dashboard is mid-edit).
const enterLayoutCustomizationMode = useCallback((): boolean => {
const isLayoutCustomizationModeAlreadyEnabled = store.get(
isLayoutCustomizationModeEnabledState.atom,
);
if (isLayoutCustomizationModeAlreadyEnabled) {
return;
return true;
}
const dashboardPageLayoutIdInEditMode = store.get(
@@ -49,7 +52,7 @@ export const useEnterLayoutCustomizationMode = () => {
message: t`Save or cancel dashboard changes before editing the layout.`,
});
return;
return false;
}
}
@@ -82,6 +85,8 @@ export const useEnterLayoutCustomizationMode = () => {
resetNavigationStack: true,
});
}
return true;
}, [enqueueWarningSnackBar, navigateSidePanel, store]);
return { enterLayoutCustomizationMode };
@@ -1,11 +1,28 @@
import { NavigationDrawerAiChatContent } from '@/ai/components/NavigationDrawerAiChatContent';
import { MainNavigationDrawerTabsRow } from '@/navigation/components/MainNavigationDrawerTabsRow';
import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
import { SettingsNavigationDrawerItems } from '@/settings/components/SettingsNavigationDrawerItems';
import { NavigationDrawer } from '@/ui/navigation/navigation-drawer/components/NavigationDrawer';
import { NavigationDrawerFixedContent } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerFixedContent';
import { NavigationDrawerScrollableContent } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerScrollableContent';
import { isAdvancedModeEnabledState } from '@/ui/navigation/navigation-drawer/states/isAdvancedModeEnabledState';
import { navigationDrawerActiveTabState } from '@/ui/navigation/states/navigationDrawerActiveTabState';
import { NAVIGATION_DRAWER_TABS } from '@/ui/navigation/states/navigationDrawerTabs';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { useIsMobile } from 'twenty-ui/utilities';
import { AdvancedSettingsToggle } from 'twenty-ui/navigation';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { PermissionFlagType } from '~/generated-metadata/graphql';
const StyledAdvancedToggleWrapper = styled.div<{ isMobile: boolean }>`
padding-left: ${({ isMobile }) =>
isMobile ? '0' : themeCssVariables.spacing[5]};
padding-right: ${({ isMobile }) =>
isMobile ? '0' : themeCssVariables.spacing[8]};
`;
export const SettingsNavigationDrawer = ({
className,
@@ -13,23 +30,46 @@ export const SettingsNavigationDrawer = ({
className?: string;
}) => {
const { t } = useLingui();
const isMobile = useIsMobile();
const [isAdvancedModeEnabled, setIsAdvancedModeEnabled] = useAtomState(
isAdvancedModeEnabledState,
);
const navigationDrawerActiveTab = useAtomStateValue(
navigationDrawerActiveTabState,
);
const hasAiPermission = useHasPermissionFlag(PermissionFlagType.AI);
const showAiChatContent =
hasAiPermission &&
navigationDrawerActiveTab === NAVIGATION_DRAWER_TABS.AI_CHAT_HISTORY;
return (
<NavigationDrawer className={className} title={t`Exit Settings`}>
{hasAiPermission && (
<NavigationDrawerFixedContent>
<MainNavigationDrawerTabsRow />
</NavigationDrawerFixedContent>
)}
<NavigationDrawerScrollableContent>
<SettingsNavigationDrawerItems />
{showAiChatContent ? (
<NavigationDrawerAiChatContent />
) : (
<SettingsNavigationDrawerItems />
)}
</NavigationDrawerScrollableContent>
<NavigationDrawerFixedContent>
<AdvancedSettingsToggle
isAdvancedModeEnabled={isAdvancedModeEnabled}
setIsAdvancedModeEnabled={setIsAdvancedModeEnabled}
label={t`Advanced:`}
/>
</NavigationDrawerFixedContent>
{!showAiChatContent && (
<NavigationDrawerFixedContent>
<StyledAdvancedToggleWrapper isMobile={isMobile}>
<AdvancedSettingsToggle
isAdvancedModeEnabled={isAdvancedModeEnabled}
setIsAdvancedModeEnabled={setIsAdvancedModeEnabled}
label={t`Advanced:`}
/>
</StyledAdvancedToggleWrapper>
</NavigationDrawerFixedContent>
)}
</NavigationDrawer>
);
};
@@ -19,11 +19,16 @@ const StyledCardsContainer = styled.div`
gap: ${themeCssVariables.spacing[4]};
margin-top: ${themeCssVariables.spacing[6]};
@media (max-width: ${MOBILE_VIEWPORT}pxF) {
@media (max-width: ${MOBILE_VIEWPORT}px) {
flex-direction: column;
}
`;
const StyledCardLinkSlot = styled.div`
flex: 1 1 0;
min-width: 0;
`;
export const SettingsAccountsSettingsSection = () => {
const { theme } = useContext(ThemeContext);
const { t } = useLingui();
@@ -34,30 +39,34 @@ export const SettingsAccountsSettingsSection = () => {
description={t`Configure your emails and calendar settings.`}
/>
<StyledCardsContainer>
<UndecoratedLink to={getSettingsPath(SettingsPath.AccountsEmails)}>
<SettingsCard
Icon={
<IconMailCog
size={theme.icon.size.lg}
stroke={theme.icon.stroke.sm}
/>
}
title={t`Emails`}
description={t`Set email visibility, manage your blocklist and more.`}
/>
</UndecoratedLink>
<UndecoratedLink to={getSettingsPath(SettingsPath.AccountsCalendars)}>
<SettingsCard
Icon={
<IconCalendarEvent
size={theme.icon.size.lg}
stroke={theme.icon.stroke.sm}
/>
}
title={t`Calendar`}
description={t`Configure and customize your calendar preferences.`}
/>
</UndecoratedLink>
<StyledCardLinkSlot>
<UndecoratedLink to={getSettingsPath(SettingsPath.AccountsEmails)}>
<SettingsCard
Icon={
<IconMailCog
size={theme.icon.size.lg}
stroke={theme.icon.stroke.sm}
/>
}
title={t`Emails`}
description={t`Set email visibility, manage your blocklist and more.`}
/>
</UndecoratedLink>
</StyledCardLinkSlot>
<StyledCardLinkSlot>
<UndecoratedLink to={getSettingsPath(SettingsPath.AccountsCalendars)}>
<SettingsCard
Icon={
<IconCalendarEvent
size={theme.icon.size.lg}
stroke={theme.icon.stroke.sm}
/>
}
title={t`Calendar`}
description={t`Configure and customize your calendar preferences.`}
/>
</UndecoratedLink>
</StyledCardLinkSlot>
</StyledCardsContainer>
</Section>
);
@@ -0,0 +1,103 @@
import { ModalStatefulWrapper } from '@/ui/layout/modal/components/ModalStatefulWrapper';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { styled } from '@linaria/react';
import { useState } from 'react';
import { type IconComponent, IconX } from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
export type SettingsCustomizeVideoModalTab = {
id: string;
title: string;
Icon: IconComponent;
vimeoId: string;
};
type SettingsCustomizeVideoModalProps = {
modalInstanceId: string;
tabsInstanceId: string;
tabs: SettingsCustomizeVideoModalTab[];
};
const StyledHeader = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[2]};
height: 48px;
justify-content: space-between;
padding-right: ${themeCssVariables.spacing[3]};
`;
const StyledTabsContainer = styled.div`
flex: 1 1 auto;
min-width: 0;
padding-left: ${themeCssVariables.spacing[3]};
`;
const StyledVideoContainer = styled.div`
display: flex;
justify-content: center;
padding: ${themeCssVariables.spacing[6]};
`;
const StyledVideoIframe = styled.iframe`
aspect-ratio: 1440 / 900;
border: 0;
border-radius: ${themeCssVariables.border.radius.md};
box-shadow: ${themeCssVariables.boxShadow.strong};
display: block;
height: auto;
max-width: 100%;
width: 960px;
`;
export const SettingsCustomizeVideoModal = ({
modalInstanceId,
tabsInstanceId,
tabs,
}: SettingsCustomizeVideoModalProps) => {
const { closeModal } = useModal();
const [activeTabId, setActiveTabId] = useState<string>(tabs[0]?.id ?? '');
if (tabs.length === 0) {
return null;
}
const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? tabs[0];
const handleClose = () => {
closeModal(modalInstanceId);
};
return (
<ModalStatefulWrapper
modalInstanceId={modalInstanceId}
size="large"
padding="none"
isClosable
onClose={handleClose}
renderInDocumentBody
>
<StyledHeader>
<StyledTabsContainer>
<TabList
tabs={tabs}
behaveAsLinks={false}
componentInstanceId={tabsInstanceId}
onChangeTab={(tabId) => setActiveTabId(tabId)}
/>
</StyledTabsContainer>
<IconButton Icon={IconX} onClick={handleClose} size="small" />
</StyledHeader>
<StyledVideoContainer>
<StyledVideoIframe
key={activeTab.id}
src={`https://player.vimeo.com/video/${activeTab.vimeoId}?autoplay=1&loop=1&autopause=0&background=1&muted=1`}
allow="autoplay; fullscreen; picture-in-picture"
title={activeTab.title}
/>
</StyledVideoContainer>
</ModalStatefulWrapper>
);
};
@@ -0,0 +1,85 @@
import {
SettingsCustomizeVideoModal,
type SettingsCustomizeVideoModalTab,
} from '@/settings/components/SettingsCustomizeVideoModal';
import { HeroPlayButton } from '@/ui/layout/hero/components/HeroPlayButton';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { useContext } from 'react';
import { Card } from 'twenty-ui/layout';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
const COVER_HEIGHT = 150;
const StyledCoverContainer = styled.div`
background: ${themeCssVariables.background.secondary};
box-sizing: border-box;
height: ${COVER_HEIGHT}px;
overflow: hidden;
position: relative;
`;
const StyledImage = styled.img`
display: block;
height: 100%;
inset: 0;
object-fit: cover;
object-position: center top;
position: absolute;
width: 100%;
`;
const StyledOverlay = styled.div`
align-items: center;
display: flex;
inset: 0;
justify-content: center;
position: absolute;
`;
type SettingsDiscoveryHeroCardProps = {
lightSrc: string;
darkSrc: string;
instanceIdPrefix: string;
tabs: SettingsCustomizeVideoModalTab[];
playButtonAriaLabel?: string;
};
export const SettingsDiscoveryHeroCard = ({
lightSrc,
darkSrc,
instanceIdPrefix,
tabs,
playButtonAriaLabel,
}: SettingsDiscoveryHeroCardProps) => {
const { t } = useLingui();
const { colorScheme } = useContext(ThemeContext);
const { openModal } = useModal();
const modalInstanceId = `${instanceIdPrefix}-modal`;
const tabsInstanceId = `${instanceIdPrefix}-tabs`;
const src = colorScheme === 'light' ? lightSrc : darkSrc;
return (
<>
<Card rounded>
<StyledCoverContainer>
<StyledImage src={src} alt="" aria-hidden />
<StyledOverlay>
<HeroPlayButton
onClick={() => openModal(modalInstanceId)}
ariaLabel={playButtonAriaLabel ?? t`Watch demo`}
/>
</StyledOverlay>
</StyledCoverContainer>
</Card>
<SettingsCustomizeVideoModal
modalInstanceId={modalInstanceId}
tabsInstanceId={tabsInstanceId}
tabs={tabs}
/>
</>
);
};
@@ -39,6 +39,7 @@ export const SettingsNavigationDrawerItem = ({
label={item.label}
to={href}
Icon={item.Icon}
withIconBackground
active={isActive}
modifier={item.modifier}
onClick={item.onClick}
@@ -54,6 +55,7 @@ export const SettingsNavigationDrawerItem = ({
label={item.label}
to={href || undefined}
Icon={item.Icon}
withIconBackground
active={isActive}
modifier={item.modifier}
onClick={item.onClick}
@@ -5,13 +5,81 @@ import {
type SettingsNavigationSection,
useSettingsNavigationItems,
} from '@/settings/hooks/useSettingsNavigationItems';
import { CollapsibleNavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/CollapsibleNavigationDrawerSection';
import { NavigationDrawerItemGroup } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItemGroup';
import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection';
import { NavigationDrawerSectionTitle } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle';
import { getNavigationSubItemLeftAdornment } from '@/ui/navigation/navigation-drawer/utils/getNavigationSubItemLeftAdornment';
import { styled } from '@linaria/react';
import { matchPath, resolvePath, useLocation } from 'react-router-dom';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { getSettingsPath } from 'twenty-shared/utils';
const StyledSectionsContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[3]};
`;
const renderSectionItem = (
item: SettingsNavigationItem,
index: number,
section: SettingsNavigationSection,
getSelectedIndexForSubItems: (subItems: SettingsNavigationItem[]) => number,
) => {
const subItems = item.subItems;
if (Array.isArray(subItems) && subItems.length > 0) {
const selectedSubItemIndex = getSelectedIndexForSubItems(subItems);
const hasActiveSubItem = selectedSubItemIndex !== -1;
return (
<NavigationDrawerItemGroup key={item.path || `group-${index}`}>
<SettingsNavigationDrawerItem
item={item}
hasActiveSubItem={hasActiveSubItem}
subItemState={
item.indentationLevel
? getNavigationSubItemLeftAdornment({
arrayLength: section.items.length,
index,
selectedIndex: selectedSubItemIndex,
})
: undefined
}
/>
{subItems.map((subItem, subIndex) => (
<SettingsNavigationDrawerItem
key={subItem.path || `subitem-${subIndex}`}
item={subItem}
subItemState={
subItem.indentationLevel
? getNavigationSubItemLeftAdornment({
arrayLength: subItems.length,
index: subIndex,
selectedIndex: selectedSubItemIndex,
})
: undefined
}
/>
))}
</NavigationDrawerItemGroup>
);
}
return (
<SettingsNavigationDrawerItem
key={item.path || `item-${index}`}
item={item}
subItemState={
item.indentationLevel
? getNavigationSubItemLeftAdornment({
arrayLength: section.items.length,
index,
selectedIndex: index,
})
: undefined
}
/>
);
};
export const SettingsNavigationDrawerItems = () => {
const settingsNavigationItems: SettingsNavigationSection[] =
useSettingsNavigationItems();
@@ -34,7 +102,7 @@ export const SettingsNavigationDrawerItems = () => {
};
return (
<>
<StyledSectionsContainer>
{settingsNavigationItems.map((section) => {
const allItemsHidden = section.items.every((item) => item.isHidden);
if (allItemsHidden) {
@@ -42,75 +110,31 @@ export const SettingsNavigationDrawerItems = () => {
}
return (
<NavigationDrawerSection key={section.label}>
{section.isAdvanced ? (
<AdvancedSettingsWrapper hideDot>
<NavigationDrawerSectionTitle label={section.label} />
</AdvancedSettingsWrapper>
) : (
<NavigationDrawerSectionTitle label={section.label} />
<CollapsibleNavigationDrawerSection
key={section.label}
sectionId={`settings/${section.label}`}
label={section.label}
wrapTitle={
section.isAdvanced
? (titleNode) => (
<AdvancedSettingsWrapper hideDot>
{titleNode}
</AdvancedSettingsWrapper>
)
: undefined
}
>
{section.items.map((item, index) =>
renderSectionItem(
item,
index,
section,
getSelectedIndexForSubItems,
),
)}
{section.items.map((item, index) => {
const subItems = item.subItems;
if (Array.isArray(subItems) && subItems.length > 0) {
const selectedSubItemIndex =
getSelectedIndexForSubItems(subItems);
const hasActiveSubItem = selectedSubItemIndex !== -1;
return (
<NavigationDrawerItemGroup
key={item.path || `group-${index}`}
>
<SettingsNavigationDrawerItem
item={item}
hasActiveSubItem={hasActiveSubItem}
subItemState={
item.indentationLevel
? getNavigationSubItemLeftAdornment({
arrayLength: section.items.length,
index,
selectedIndex: selectedSubItemIndex,
})
: undefined
}
/>
{subItems.map((subItem, subIndex) => (
<SettingsNavigationDrawerItem
key={subItem.path || `subitem-${subIndex}`}
item={subItem}
subItemState={
subItem.indentationLevel
? getNavigationSubItemLeftAdornment({
arrayLength: subItems.length,
index: subIndex,
selectedIndex: selectedSubItemIndex,
})
: undefined
}
/>
))}
</NavigationDrawerItemGroup>
);
}
return (
<SettingsNavigationDrawerItem
key={item.path || `item-${index}`}
item={item}
subItemState={
item.indentationLevel
? getNavigationSubItemLeftAdornment({
arrayLength: section.items.length,
index,
selectedIndex: index,
})
: undefined
}
/>
);
})}
</NavigationDrawerSection>
</CollapsibleNavigationDrawerSection>
);
})}
</>
</StyledSectionsContainer>
);
};
@@ -1,4 +1,3 @@
import { OBJECT_SETTINGS_WIDTH } from '@/settings/data-model/constants/ObjectSettings';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { useScrollRestoration } from '@/ui/utilities/scroll/hooks/useScrollRestoration';
@@ -13,6 +12,7 @@ const StyledSettingsPageContainer = styled.div<{
width?: number;
isMobile?: boolean;
}>`
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[8]};
@@ -27,7 +27,7 @@ const StyledSettingsPageContainer = styled.div<{
if (isMobile) {
return 'unset';
}
return OBJECT_SETTINGS_WIDTH + 'px';
return '100%';
}};
`;
@@ -0,0 +1,96 @@
import { styled } from '@linaria/react';
import { Fragment, useContext } from 'react';
import { type IconComponent } from 'twenty-ui/display';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
export type SettingsStatRow = {
Icon: IconComponent;
label: string;
// String so callers can render a placeholder (e.g. "—") while async counts
// are still loading. Layout stats just pass `count.toString()`.
value: string;
};
type SettingsStatsGridProps = {
// Each inner array is one column rendered top-to-bottom; columns are
// separated by a vertical divider. Pass [[a, b], [c, d]] for a 2x2 layout
// or [[a, b, c]] for a single column.
columns: SettingsStatRow[][];
};
const StyledContainer = styled.div`
background: ${themeCssVariables.background.secondary};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.md};
display: flex;
gap: ${themeCssVariables.spacing[3]};
padding: ${themeCssVariables.spacing[2]};
`;
const StyledColumn = styled.div`
display: flex;
flex: 1 1 0;
flex-direction: column;
gap: ${themeCssVariables.spacing[1]};
min-width: 0;
`;
const StyledDivider = styled.div`
align-self: stretch;
background: ${themeCssVariables.border.color.light};
width: 1px;
`;
const StyledRow = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[2]};
height: ${themeCssVariables.spacing[6]};
`;
const StyledLabel = styled.div`
color: ${themeCssVariables.font.color.tertiary};
flex: 1 1 0;
font-size: ${themeCssVariables.font.size.sm};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const StyledValue = styled.div`
color: ${themeCssVariables.font.color.primary};
padding: 0 ${themeCssVariables.spacing[1]};
`;
type StatRowProps = SettingsStatRow;
const StatRow = ({ Icon, label, value }: StatRowProps) => {
const { theme } = useContext(ThemeContext);
return (
<StyledRow>
<Icon size={theme.icon.size.md} color={theme.font.color.tertiary} />
<StyledLabel>{label}</StyledLabel>
<StyledValue>{value}</StyledValue>
</StyledRow>
);
};
export const SettingsStatsGrid = ({ columns }: SettingsStatsGridProps) => (
<StyledContainer>
{columns.map((column, index) => (
<Fragment key={index}>
{index > 0 && <StyledDivider />}
<StyledColumn>
{column.map((stat) => (
<StatRow
key={stat.label}
Icon={stat.Icon}
label={stat.label}
value={stat.value}
/>
))}
</StyledColumn>
</Fragment>
))}
</StyledContainer>
);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 78 KiB

@@ -3,12 +3,13 @@ import { styled } from '@linaria/react';
import { MOBILE_VIEWPORT, themeCssVariables } from 'twenty-ui/theme-constants';
import React from 'react';
// Column width used by the Applications data tables (Instances column).
export const SETTINGS_OBJECT_TABLE_COLUMN_WIDTH = '98.7px';
const SETTINGS_OBJECT_TABLE_APP_COLUMN_WIDTH = '140px';
const SETTINGS_OBJECT_TABLE_FIELDS_COLUMN_WIDTH = '72px';
export const SETTINGS_OBJECT_TABLE_ROW_GRID_TEMPLATE_COLUMNS = `180px ${SETTINGS_OBJECT_TABLE_APP_COLUMN_WIDTH} ${SETTINGS_OBJECT_TABLE_FIELDS_COLUMN_WIDTH} ${SETTINGS_OBJECT_TABLE_COLUMN_WIDTH} 36px`;
// Relative grid: Name takes all remaining space (with a floor); App / Fields /
// Instances get fixed minimums so short text columns don't collapse; trailing
// 36 px holds the chevron / action cell.
export const SETTINGS_OBJECT_TABLE_ROW_GRID_TEMPLATE_COLUMNS = `minmax(180px, 1fr) 140px 80px 100px 36px`;
export const SETTINGS_OBJECT_TABLE_ROW_MOBILE_MIN_WIDTH = '520px';
@@ -58,7 +58,10 @@ export const ObjectLayout = ({ objectMetadataItem }: ObjectLayoutProps) => {
return;
}
enterLayoutCustomizationMode();
// Skip navigation when entry was blocked (e.g. a dashboard is mid-edit).
if (!enterLayoutCustomizationMode()) {
return;
}
navigateApp(AppPath.RecordShowPage, {
objectNameSingular: objectMetadataItem.nameSingular,
@@ -1,71 +0,0 @@
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { useContext } from 'react';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { IconEye } from 'twenty-ui/display';
import { FloatingButton } from 'twenty-ui/input';
import DarkCoverImage from '@/settings/data-model/assets/cover-dark.png';
import LightCoverImage from '@/settings/data-model/assets/cover-light.png';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
const StyledCoverImageContainer = styled.div`
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.md};
box-sizing: border-box;
margin-bottom: ${themeCssVariables.spacing[8]};
min-height: 153px;
overflow: hidden;
position: relative;
`;
const StyledCoverImage = styled.img`
display: block;
height: 100%;
inset: 0;
object-fit: cover;
object-position: center;
position: absolute;
width: 100%;
`;
const StyledButtonOverlay = styled.div`
align-items: center;
display: flex;
inset: 0;
justify-content: center;
pointer-events: none;
position: absolute;
& > * {
pointer-events: auto;
}
`;
export const SettingsObjectCoverImage = () => {
const { colorScheme } = useContext(ThemeContext);
const { t } = useLingui();
return (
<StyledCoverImageContainer>
<StyledCoverImage
src={
colorScheme === 'light'
? LightCoverImage.toString()
: DarkCoverImage.toString()
}
alt=""
aria-hidden
/>
<StyledButtonOverlay>
<FloatingButton
Icon={IconEye}
title={t`Visualize`}
size="small"
to={getSettingsPath(SettingsPath.ObjectOverview)}
/>
</StyledButtonOverlay>
</StyledCoverImageContainer>
);
};
@@ -11,7 +11,7 @@ import { useContext } from 'react';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { type Webhook } from '~/generated-metadata/graphql';
const WEBHOOK_TABLE_ROW_GRID_TEMPLATE_COLUMNS = '1fr 28px';
export const WEBHOOK_TABLE_ROW_GRID_TEMPLATE_COLUMNS = '1fr 28px';
const StyledIconChevronRightContainer = styled.span`
align-items: center;
@@ -1,6 +1,9 @@
import { styled } from '@linaria/react';
import { SettingsDevelopersWebhookTableRow } from '@/settings/developers/components/SettingsDevelopersWebhookTableRow';
import {
SettingsDevelopersWebhookTableRow,
WEBHOOK_TABLE_ROW_GRID_TEMPLATE_COLUMNS,
} from '@/settings/developers/components/SettingsDevelopersWebhookTableRow';
import { Table } from '@/ui/layout/table/components/Table';
import { TableBody } from '@/ui/layout/table/components/TableBody';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
@@ -24,7 +27,7 @@ export const SettingsWebhooksTable = () => {
return (
<Table>
<TableRow gridTemplateColumns="444px 68px">
<TableRow gridTemplateColumns={WEBHOOK_TABLE_ROW_GRID_TEMPLATE_COLUMNS}>
<TableHeader>URL</TableHeader>
<TableHeader></TableHeader>
</TableRow>
@@ -27,6 +27,7 @@ import {
IconHelpCircle,
IconHierarchy2,
IconKey,
IconLayout,
IconMail,
IconMessage,
IconPlug,
@@ -124,20 +125,18 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
Icon: IconSettings,
isHidden: !permissionMap[PermissionFlagType.WORKSPACE],
},
{
label: t`Email`,
path: SettingsPath.WorkspaceEmail,
Icon: IconMail,
isHidden:
!isEmailGroupFeatureEnabled ||
!permissionMap[PermissionFlagType.WORKSPACE],
},
{
label: t`Data model`,
path: SettingsPath.Objects,
Icon: IconHierarchy2,
isHidden: !permissionMap[PermissionFlagType.DATA_MODEL],
},
{
label: t`Layout`,
path: SettingsPath.Layout,
Icon: IconLayout,
isHidden: !permissionMap[PermissionFlagType.LAYOUTS],
},
{
label: t`Members`,
path: SettingsPath.WorkspaceMembersPage,
@@ -175,9 +174,17 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
label: t`AI`,
path: SettingsPath.AI,
Icon: IconSparkles,
isHidden: !permissionMap[PermissionFlagType.WORKSPACE],
isHidden: !permissionMap[PermissionFlagType.AI],
modifier: 'new',
},
{
label: t`Email`,
path: SettingsPath.WorkspaceEmail,
Icon: IconMail,
isHidden:
!isEmailGroupFeatureEnabled ||
!permissionMap[PermissionFlagType.WORKSPACE],
},
{
label: t`Security`,
path: SettingsPath.Security,
@@ -0,0 +1,65 @@
import { commandMenuItemsSelector } from '@/command-menu-item/states/commandMenuItemsSelector';
import { frontComponentsSelector } from '@/front-components/states/frontComponentsSelector';
import { navigationMenuItemsSelector } from '@/navigation-menu-item/common/states/navigationMenuItemsSelector';
import { pageLayoutsWithRelationsSelector } from '@/page-layout/states/pageLayoutsWithRelationsSelector';
import { SettingsStatsGrid } from '@/settings/components/SettingsStatsGrid';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { viewsSelector } from '@/views/states/selectors/viewsSelector';
import { useLingui } from '@lingui/react/macro';
import {
IconAppWindow,
IconCommand,
IconLayoutSidebarLeftExpand,
IconPuzzle,
IconTable,
} from 'twenty-ui/display';
export const SettingsLayoutItemsStats = () => {
const { t } = useLingui();
const commandMenuItems = useAtomStateValue(commandMenuItemsSelector);
const navigationMenuItems = useAtomStateValue(navigationMenuItemsSelector);
const views = useAtomStateValue(viewsSelector);
const pageLayoutsWithRelations = useAtomStateValue(
pageLayoutsWithRelationsSelector,
);
const frontComponents = useAtomStateValue(frontComponentsSelector);
return (
<SettingsStatsGrid
columns={[
[
{
Icon: IconCommand,
label: t`Commands`,
value: commandMenuItems.length.toString(),
},
{
Icon: IconLayoutSidebarLeftExpand,
label: t`Sidebar items`,
value: navigationMenuItems.length.toString(),
},
],
[
{
Icon: IconTable,
label: t`Views`,
value: views.length.toString(),
},
{
Icon: IconAppWindow,
label: t`Pages`,
value: pageLayoutsWithRelations.length.toString(),
},
],
[
{
Icon: IconPuzzle,
label: t`Widgets`,
value: frontComponents.length.toString(),
},
],
]}
/>
);
};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 290 KiB

@@ -1,4 +1,7 @@
import { playgroundApiKeyState } from '@/settings/playground/states/playgroundApiKeyState';
import {
isPlaygroundApiKeyFresh,
playgroundApiKeyState,
} from '@/settings/playground/states/playgroundApiKeyState';
import { PlaygroundSchemas } from '@/settings/playground/types/PlaygroundSchemas';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { styled } from '@linaria/react';
@@ -40,7 +43,7 @@ export const GraphQLPlayground = ({
const { colorScheme } = useContext(ThemeContext);
if (!playgroundApiKey) {
if (!isPlaygroundApiKeyFresh(playgroundApiKey)) {
onError();
return null;
}
@@ -60,7 +63,7 @@ export const GraphQLPlayground = ({
plugins={[explorer]}
fetcher={fetcher}
defaultHeaders={JSON.stringify({
Authorization: `Bearer ${playgroundApiKey}`,
Authorization: `Bearer ${playgroundApiKey.token}`,
})}
/>
</StyledGraphiQLContainer>
@@ -1,139 +1,61 @@
import { useOpenPlayground } from '@/settings/playground/hooks/useOpenPlayground';
import { SETTINGS_PLAYGROUND_FORM_SCHEMA_SELECT_OPTIONS } from '@/settings/playground/constants/SettingsPlaygroundFormSchemaSelectOptions';
import { playgroundApiKeyState } from '@/settings/playground/states/playgroundApiKeyState';
import { PlaygroundSchemas } from '@/settings/playground/types/PlaygroundSchemas';
import { PlaygroundTypes } from '@/settings/playground/types/PlaygroundTypes';
import { Select } from '@/ui/input/components/Select';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { styled } from '@linaria/react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Controller, useForm } from 'react-hook-form';
import { SettingsPath } from 'twenty-shared/types';
import { CustomError } from 'twenty-shared/utils';
import { IconApi, IconBrandGraphql } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
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.enum(PlaygroundSchemas),
playgroundType: z.enum(PlaygroundTypes),
});
type PlaygroundSetupFormValues = z.infer<typeof playgroundSetupFormSchema>;
// Last column shrinks to the Launch button's content width so its right
// edge sits at the form's right edge. The two select columns share the
// remaining space equally.
const StyledForm = styled.form`
align-items: end;
display: grid;
gap: ${themeCssVariables.spacing[2]};
grid-template-columns: 1.5fr 1fr 1fr 0.5fr;
margin-bottom: ${themeCssVariables.spacing[2]};
grid-template-columns: 1fr 1fr auto;
width: 100%;
`;
export const PlaygroundSetupForm = () => {
const { t } = useLingui();
const navigateSettings = useNavigateSettings();
const [playgroundApiKey, setPlaygroundApiKey] = useAtomState(
playgroundApiKeyState,
);
const openPlayground = useOpenPlayground();
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 {
const response = await fetch(
`${REACT_APP_SERVER_BASE_URL}/rest/open-api/${values.schema}`,
{
headers: { Authorization: `Bearer ${values.apiKeyForPlayground}` },
},
);
if (!response.ok) {
throw new CustomError(
`HTTP error! status: ${response.status}`,
'HTTP_ERROR',
);
}
const openAPIReference = await response.json();
if (!openAPIReference.tags) {
throw new Error('Invalid API Key');
}
return true;
} catch {
throw new Error(t`Invalid API key`);
}
};
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`,
});
}
await openPlayground(values.playgroundType, values.schema);
};
return (
<StyledForm onSubmit={handleSubmit(onSubmit)}>
<Controller
name="apiKeyForPlayground"
control={control}
render={({ field: { onChange, value }, fieldState: { error } }) => (
<SettingsTextInput
instanceId="playground-api-key"
label={t`API Key`}
placeholder={t`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"
@@ -152,17 +74,12 @@ export const PlaygroundSetupForm = () => {
<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.REST, label: t`REST`, Icon: IconApi },
{
value: PlaygroundTypes.GRAPHQL,
label: t`GraphQL`,
@@ -1,5 +1,8 @@
import { RestPlaygroundSchemaFetchEffect } from '@/settings/playground/components/RestPlaygroundSchemaFetchEffect';
import { playgroundApiKeyState } from '@/settings/playground/states/playgroundApiKeyState';
import {
isPlaygroundApiKeyFresh,
playgroundApiKeyState,
} from '@/settings/playground/states/playgroundApiKeyState';
import { type PlaygroundSchemas } from '@/settings/playground/types/PlaygroundSchemas';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useContext, useState, lazy, Suspense } from 'react';
@@ -55,7 +58,7 @@ export const RestPlayground = ({ onError, schema }: RestPlaygroundProps) => {
const playgroundApiKey = useAtomStateValue(playgroundApiKeyState);
const [specContent, setSpecContent] = useState<object | null>(null);
if (!playgroundApiKey) {
if (!isPlaygroundApiKeyFresh(playgroundApiKey)) {
onError();
return null;
}
@@ -74,7 +77,7 @@ export const RestPlayground = ({ onError, schema }: RestPlaygroundProps) => {
<StyledContainer>
<RestPlaygroundSchemaFetchEffect
schema={schema}
apiKey={playgroundApiKey}
apiKey={playgroundApiKey.token}
onSchemaLoaded={setSpecContent}
onError={onError}
/>
@@ -89,7 +92,7 @@ export const RestPlayground = ({ onError, schema }: RestPlaygroundProps) => {
},
authentication: {
http: {
bearer: { token: playgroundApiKey },
bearer: { token: playgroundApiKey.token },
},
},
baseServerURL: REACT_APP_SERVER_BASE_URL + '/' + schema,
@@ -0,0 +1,56 @@
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { H2Title, IconCopy } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Card, CardContent, Section } from 'twenty-ui/layout';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
const StyledPre = styled.pre`
font-family: monospace;
margin: 0;
white-space: pre;
`;
const buildMcpConfig = (serverUrl: string) =>
`{
"mcpServers": {
"twenty": {
"url": "${serverUrl}/mcp",
"headers": {
"Authorization": "Bearer <YOUR_API_KEY>"
}
}
}
}`;
export const SettingsMcpSetup = () => {
const { t } = useLingui();
const { copyToClipboard } = useCopyToClipboard();
const mcpConfig = buildMcpConfig(REACT_APP_SERVER_BASE_URL);
return (
<Section>
<H2Title
title={t`Connect your AI assistant`}
description={t`Add Twenty as a Model Context Protocol (MCP) server. Paste this config into Claude Desktop, Cursor, Cline, Continue, Zed, or any other MCP-aware client.`}
/>
<Card rounded>
<CardContent divider>
<StyledPre>{mcpConfig}</StyledPre>
</CardContent>
<CardContent>
<Button
title={t`Copy config`}
Icon={IconCopy}
size="small"
variant="secondary"
onClick={() =>
copyToClipboard(mcpConfig, t`MCP config copied to clipboard`)
}
/>
</CardContent>
</Card>
</Section>
);
};
@@ -1,44 +0,0 @@
import { styled } from '@linaria/react';
import { type ReactNode, useContext } from 'react';
import DarkCoverImage from '@/settings/playground/assets/cover-dark.png';
import LightCoverImage from '@/settings/playground/assets/cover-light.png';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
const StyledCoverContainer = styled.div`
align-items: center;
background-size: cover;
border-radius: ${themeCssVariables.border.radius.md};
box-sizing: border-box;
display: flex;
height: 153px;
justify-content: center;
margin-bottom: ${themeCssVariables.spacing[4]};
margin-top: ${themeCssVariables.spacing[4]};
position: relative;
`;
type StyledSettingsApiPlaygroundCoverImageProps = {
children?: ReactNode;
className?: string;
};
export const StyledSettingsApiPlaygroundCoverImage = ({
children,
className,
}: StyledSettingsApiPlaygroundCoverImageProps) => {
const { colorScheme } = useContext(ThemeContext);
const coverImage =
colorScheme === 'light'
? LightCoverImage.toString()
: DarkCoverImage.toString();
return (
<StyledCoverContainer
className={className}
style={{ backgroundImage: `url('${coverImage}')` }}
>
{children}
</StyledCoverContainer>
);
};
@@ -14,7 +14,10 @@ const PlaygroundApiKeySetterEffect = () => {
const setPlaygroundApiKey = useSetAtomState(playgroundApiKeyState);
useEffect(() => {
setPlaygroundApiKey('test-api-key-123');
setPlaygroundApiKey({
token: 'test-api-key-123',
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
});
}, [setPlaygroundApiKey]);
return null;
@@ -12,7 +12,10 @@ const PlaygroundApiKeySetterEffect = () => {
const setPlaygroundApiKey = useSetAtomState(playgroundApiKeyState);
useEffect(() => {
setPlaygroundApiKey('test-api-key-123');
setPlaygroundApiKey({
token: 'test-api-key-123',
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
});
}, [setPlaygroundApiKey]);
return null;
@@ -0,0 +1,62 @@
import { useMutation } from '@apollo/client/react';
import { useCallback } from 'react';
import { t } from '@lingui/core/macro';
import { SettingsPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
isPlaygroundApiKeyFresh,
playgroundApiKeyState,
} from '@/settings/playground/states/playgroundApiKeyState';
import { type PlaygroundSchemas } from '@/settings/playground/types/PlaygroundSchemas';
import { PlaygroundTypes } from '@/settings/playground/types/PlaygroundTypes';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { GeneratePlaygroundTokenDocument } from '~/generated-metadata/graphql';
// Re-mint when less than this remains so the user never lands on a token about to expire.
const TOKEN_FRESHNESS_BUFFER_MS = 5 * 60 * 1000;
export const useOpenPlayground = () => {
const navigateSettings = useNavigateSettings();
const [playgroundApiKey, setPlaygroundApiKey] = useAtomState(
playgroundApiKeyState,
);
const { enqueueErrorSnackBar } = useSnackBar();
const [generatePlaygroundToken] = useMutation(
GeneratePlaygroundTokenDocument,
{
onError: () => {
enqueueErrorSnackBar({
message: t`Could not open the API playground`,
});
},
},
);
return useCallback(
async (type: PlaygroundTypes, schema: PlaygroundSchemas) => {
if (
!isPlaygroundApiKeyFresh(playgroundApiKey, TOKEN_FRESHNESS_BUFFER_MS)
) {
const { data } = await generatePlaygroundToken();
const mintedToken = data?.generatePlaygroundToken;
if (!isDefined(mintedToken)) return;
setPlaygroundApiKey(mintedToken);
}
const path =
type === PlaygroundTypes.GRAPHQL
? SettingsPath.GraphQLPlayground
: SettingsPath.RestPlayground;
navigateSettings(path, { schema });
},
[
playgroundApiKey,
generatePlaygroundToken,
navigateSettings,
setPlaygroundApiKey,
],
);
};
@@ -1,7 +1,21 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
import { isDefined } from 'twenty-shared/utils';
import { type AuthToken } from '~/generated-metadata/graphql';
export const playgroundApiKeyState = createAtomState<string | null>({
// In-memory only: a short-lived, full-permission bearer token. Keeping it out of
// localStorage bounds the exfiltration window to the current tab and leaves no
// usable credential at rest after the tab closes.
export const playgroundApiKeyState = createAtomState<AuthToken | null>({
key: 'playgroundApiKeyState',
defaultValue: null,
useLocalStorage: true,
});
// Usable only while it stays valid for at least `bufferMs` longer. Consumers pass
// no buffer (reject the moment it expires); the launcher passes a buffer so it
// re-mints before a near-expired token can fail mid-session.
export const isPlaygroundApiKeyFresh = (
token: AuthToken | null,
bufferMs = 0,
): token is AuthToken =>
isDefined(token) &&
new Date(token.expiresAt).getTime() - Date.now() > bufferMs;
@@ -0,0 +1,61 @@
import { styled } from '@linaria/react';
import { useContext } from 'react';
import { IconPlayerPlay } from 'twenty-ui/display';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
type HeroPlayButtonProps = {
onClick?: () => void;
ariaLabel?: string;
className?: string;
};
const StyledButton = styled.button`
align-items: center;
background: ${themeCssVariables.background.primary};
border: none;
border-radius: 50%;
box-shadow: ${themeCssVariables.boxShadow.strong};
color: ${themeCssVariables.font.color.tertiary};
cursor: pointer;
display: inline-flex;
height: 44px;
justify-content: center;
padding: 0;
transition:
transform 120ms ease-out,
background-color 120ms ease-out;
width: 44px;
&:hover {
background: ${themeCssVariables.background.secondary};
transform: scale(1.04);
}
&:active {
transform: scale(0.98);
}
&:focus-visible {
outline: 2px solid ${themeCssVariables.border.color.blue};
outline-offset: 2px;
}
`;
export const HeroPlayButton = ({
onClick,
ariaLabel = 'Play video',
className,
}: HeroPlayButtonProps) => {
const { theme } = useContext(ThemeContext);
return (
<StyledButton
type="button"
onClick={onClick}
aria-label={ariaLabel}
className={className}
>
<IconPlayerPlay size={theme.icon.size.md} stroke={theme.icon.stroke.md} />
</StyledButton>
);
};
@@ -9,10 +9,8 @@ import { LayoutCustomizationBar } from '@/layout-customization/components/Layout
import { AppNavigationDrawer } from '@/navigation/components/AppNavigationDrawer';
import { MobileNavigationBar } from '@/navigation/components/MobileNavigationBar';
import { PageDragDropProvider } from '@/navigation-menu-item/display/dnd/providers/PageDragDropProvider';
import { useIsSettingsPage } from '@/navigation/hooks/useIsSettingsPage';
import { OBJECT_SETTINGS_WIDTH } from '@/settings/data-model/constants/ObjectSettings';
import { BackgroundMockNavigationDrawer } from '@/sign-in-background-mock/components/BackgroundMockNavigationDrawer';
import { Suspense, lazy, useContext } from 'react';
import { Suspense, lazy } from 'react';
const BackgroundMockPage = lazy(() =>
import('@/sign-in-background-mock/components/BackgroundMockPage').then(
@@ -21,13 +19,11 @@ const BackgroundMockPage = lazy(() =>
);
import { useShowFullscreen } from '@/ui/layout/fullscreen/hooks/useShowFullscreen';
import { useShowAuthModal } from '@/ui/layout/hooks/useShowAuthModal';
import { NAVIGATION_DRAWER_CONSTRAINTS } from '@/ui/layout/resizable-panel/constants/NavigationDrawerConstraints';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { styled } from '@linaria/react';
import { AnimatePresence, LayoutGroup, motion } from 'framer-motion';
import { AnimatePresence, LayoutGroup } from 'framer-motion';
import { Outlet } from 'react-router-dom';
import { useScreenSize } from 'twenty-ui/utilities';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledLayout = styled.div`
background: ${themeCssVariables.background.noisy};
display: flex;
@@ -43,14 +39,13 @@ const StyledLayout = styled.div`
}
`;
const StyledPageContainerBase = styled.div`
const StyledPageContainer = styled.div`
display: flex;
flex: 1 1 auto;
flex-direction: row;
min-height: 0;
min-width: 0;
`;
const StyledPageContainer = motion.create(StyledPageContainerBase);
const StyledNavigationDrawerWrapper = styled.div`
flex-shrink: 0;
@@ -65,11 +60,8 @@ const StyledMainContainer = styled.div`
export const DefaultLayout = () => {
const isMobile = useIsMobile();
const isSettingsPage = useIsSettingsPage();
const windowsWidth = useScreenSize().width;
const showAuthModal = useShowAuthModal();
const useShowFullScreen = useShowFullscreen();
const { theme } = useContext(ThemeContext);
return (
<>
@@ -78,21 +70,7 @@ export const DefaultLayout = () => {
<AppErrorBoundary FallbackComponent={AppFullScreenErrorFallback}>
<InformationBannerIsImpersonating />
<LayoutCustomizationBar />
<StyledPageContainer
animate={{
marginLeft:
isSettingsPage && !isMobile && !useShowFullScreen
? (windowsWidth -
(OBJECT_SETTINGS_WIDTH +
NAVIGATION_DRAWER_CONSTRAINTS.default +
76)) /
2
: 0,
}}
transition={{
duration: theme.animation.duration.normal,
}}
>
<StyledPageContainer>
<PageDragDropProvider>
{!showAuthModal && <KeyboardShortcutMenu />}
{showAuthModal ? (
@@ -1,4 +1,5 @@
import { InformationBannerWrapper } from '@/information-banner/components/InformationBannerWrapper';
import { MainContainerLayoutWithSidePanel } from '@/object-record/components/MainContainerLayoutWithSidePanel';
import {
Breadcrumb,
type BreadcrumbProps,
@@ -6,7 +7,6 @@ import {
import { isDefined } from 'twenty-shared/utils';
import { styled } from '@linaria/react';
import { type JSX, type ReactNode } from 'react';
import { PageBody } from './PageBody';
import { PageHeader } from './PageHeader';
import { themeCssVariables } from 'twenty-ui/theme-constants';
@@ -20,12 +20,31 @@ type SubMenuTopBarContainerProps = {
tag?: JSX.Element;
};
// Cards, forms, and tables inside the white panel are centered in a fixed
// max-width column so they don't sprawl on large displays. The white panel
// itself spans edge-to-edge; only the content is constrained.
const SETTINGS_CONTENT_MAX_WIDTH = 760;
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
width: 100%;
`;
// flex: 1 + min-height: 0 keep the vertical-scroll chain intact: PagePanel's
// own overflow handling sits one level up and depends on its children
// participating in the flex height calculation rather than collapsing to
// content height.
const StyledBodyContentWrapper = styled.div`
display: flex;
flex: 1;
flex-direction: column;
margin: 0 auto;
max-width: ${SETTINGS_CONTENT_MAX_WIDTH}px;
min-height: 0;
width: 100%;
`;
const StyledTitle = styled.span<{ reserveTitleSpace?: boolean }>`
color: ${themeCssVariables.font.color.primary};
display: flex;
@@ -53,16 +72,24 @@ export const SubMenuTopBarContainer = ({
<PageHeader title={<Breadcrumb links={links} />}>
{actionButton}
</PageHeader>
<PageBody>
<InformationBannerWrapper />
{(isDefined(title) || reserveTitleSpace === true) && (
<StyledTitle reserveTitleSpace={reserveTitleSpace}>
{title}
{tag}
</StyledTitle>
)}
{children}
</PageBody>
{/*
MainContainerLayoutWithSidePanel is the same wrapper the App's record
pages use: it renders the page body on the left and SidePanelForDesktop
on the right. Hosting it here lets the AI chat side panel (and any
other side-panel page) open in settings exactly as it does in the App.
*/}
<MainContainerLayoutWithSidePanel>
<StyledBodyContentWrapper>
<InformationBannerWrapper />
{(isDefined(title) || reserveTitleSpace === true) && (
<StyledTitle reserveTitleSpace={reserveTitleSpace}>
{title}
{tag}
</StyledTitle>
)}
{children}
</StyledBodyContentWrapper>
</MainContainerLayoutWithSidePanel>
</StyledContainer>
);
};
@@ -0,0 +1,57 @@
import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection';
import { NavigationDrawerSectionTitle } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle';
import { useNavigationSection } from '@/ui/navigation/navigation-drawer/hooks/useNavigationSection';
import { type ReactNode } from 'react';
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
type CollapsibleNavigationDrawerSectionProps = {
// Unique id used to persist the open/closed state in localStorage. Pass
// a namespaced value (e.g. 'settings/User') so unrelated sections in
// different drawers don't share state.
sectionId: string;
label: string;
children: ReactNode;
// Optional wrapper around the section title (e.g. AdvancedSettingsWrapper
// for advanced-mode-only sections). Receives the title node and returns
// the wrapped node.
wrapTitle?: (titleNode: ReactNode) => ReactNode;
};
// One-stop section component for any drawer that wants the main-app's
// collapsible section behavior: click the title to collapse / expand,
// animated height transition, persisted open state, chevron-on-hover.
// Use this instead of stitching together NavigationDrawerSection +
// NavigationDrawerSectionTitle + AnimatedExpandableContainer by hand at
// every call site.
export const CollapsibleNavigationDrawerSection = ({
sectionId,
label,
children,
wrapTitle,
}: CollapsibleNavigationDrawerSectionProps) => {
const { toggleNavigationSection, isNavigationSectionOpen } =
useNavigationSection(sectionId);
const titleNode = (
<NavigationDrawerSectionTitle
label={label}
onClick={toggleNavigationSection}
isOpen={isNavigationSectionOpen}
/>
);
return (
<NavigationDrawerSection>
{wrapTitle ? wrapTitle(titleNode) : titleNode}
<AnimatedExpandableContainer
isExpanded={isNavigationSectionOpen}
dimension="height"
mode="fit-content"
containAnimation
initial={false}
>
{children}
</AnimatedExpandableContainer>
</NavigationDrawerSection>
);
};
@@ -52,7 +52,6 @@ const StyledAnimatedContainer = styled.div<{
`;
const StyledContainer = styled.div<{
isSettings?: boolean;
isExpanded?: boolean;
}>`
box-sizing: border-box;
@@ -60,10 +59,8 @@ const StyledContainer = styled.div<{
flex-direction: column;
gap: ${themeCssVariables.spacing[3]};
height: 100%;
padding: ${({ isSettings }) =>
isSettings
? `${themeCssVariables.spacing[3]} 0 ${themeCssVariables.spacing[4]} 0`
: `${themeCssVariables.spacing[3]} 0 ${themeCssVariables.spacing[4]} ${themeCssVariables.spacing[2]}`};
padding: ${themeCssVariables.spacing[3]} 0 ${themeCssVariables.spacing[4]}
${themeCssVariables.spacing[2]};
width: ${({ isExpanded }) =>
isExpanded ? `var(${NAVIGATION_DRAWER_WIDTH_VAR})` : '100%'};
@media (max-width: ${MOBILE_VIEWPORT}px) {
@@ -123,7 +120,7 @@ export const NavigationDrawer = ({
isExpanded={isExpanded}
isResizing={isResizing}
>
<StyledContainer isSettings={isSettingsDrawer} isExpanded={isExpanded}>
<StyledContainer isExpanded={isExpanded}>
{!isMobile && isSettingsDrawer && title ? (
<NavigationDrawerBackButton title={title} />
) : (
@@ -42,7 +42,6 @@ const StyledContainer = styled.div`
flex-direction: row;
height: ${themeCssVariables.spacing[8]};
justify-content: space-between;
padding-left: ${themeCssVariables.spacing[5]};
`;
export const NavigationDrawerBackButton = ({
@@ -1,34 +1,28 @@
import { type ReactNode } from 'react';
import { useIsSettingsDrawer } from '@/navigation/hooks/useIsSettingsDrawer';
import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection';
import { styled } from '@linaria/react';
import { useIsMobile } from 'twenty-ui/utilities';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledFixedContainer = styled.div<{
isSettings?: boolean;
isMobile?: boolean;
}>`
padding-left: ${({ isSettings, isMobile }) =>
isSettings || isMobile ? themeCssVariables.spacing[5] : '0'};
padding-right: ${({ isSettings, isMobile }) =>
isMobile
? themeCssVariables.spacing[5]
: isSettings
? themeCssVariables.spacing[8]
: '0'};
// Mobile keeps the touch-friendly horizontal padding; on desktop the container
// is edge-to-edge and the child supplies its own padding.
const StyledFixedContainer = styled.div<{ isMobile?: boolean }>`
padding-left: ${({ isMobile }) =>
isMobile ? themeCssVariables.spacing[5] : '0'};
padding-right: ${({ isMobile }) =>
isMobile ? themeCssVariables.spacing[5] : '0'};
`;
export const NavigationDrawerFixedContent = ({
children,
}: {
children: ReactNode;
}) => {
const isSettingsDrawer = useIsSettingsDrawer();
const isMobile = useIsMobile();
return (
<StyledFixedContainer isSettings={isSettingsDrawer} isMobile={isMobile}>
<StyledFixedContainer isMobile={isMobile}>
<NavigationDrawerSection>{children}</NavigationDrawerSection>
</StyledFixedContainer>
);
@@ -52,6 +52,10 @@ export type NavigationDrawerItemProps = {
onClick?: () => void;
Icon?: IconComponent | ((props: TablerIconsProps) => JSX.Element);
iconColor?: string | null;
// Wrap the plain icon in a soft grey tile (no border) — used by the
// settings drawer so its icons read as a uniform group without picking
// up TintedIconTile's bordered colored treatment.
withIconBackground?: boolean;
active?: boolean;
modifier?: NavigationDrawerItemModifier;
rightOptions?: ReactNode;
@@ -202,6 +206,21 @@ const StyledIcon = styled.div`
margin-right: ${themeCssVariables.spacing[2]};
`;
// Soft grey background-only tile (no border) used by the settings drawer.
// Sized one step larger than the icon so the icon sits with a couple of
// pixels of breathing room on every side. radius.md matches the rest of
// the App's small-card / tile language; radius.sm read as sharp squares.
const StyledIconBackgroundTile = styled.div`
align-items: center;
background-color: ${themeCssVariables.background.tertiary};
border-radius: ${themeCssVariables.border.radius.md};
display: flex;
flex-shrink: 0;
height: ${themeCssVariables.spacing[6]};
justify-content: center;
width: ${themeCssVariables.spacing[6]};
`;
const StyledRightOptionsContainer = styled.div`
align-items: center;
border-radius: ${themeCssVariables.border.radius.sm};
@@ -243,6 +262,7 @@ export const NavigationDrawerItem = ({
indentationLevel = DEFAULT_INDENTATION_LEVEL,
Icon,
iconColor,
withIconBackground = false,
to,
onClick,
active,
@@ -347,6 +367,20 @@ export const NavigationDrawerItem = ({
<StyledIcon>
<TintedIconTile Icon={Icon} color={iconColor} />
</StyledIcon>
) : withIconBackground ? (
<StyledIcon>
<StyledIconBackgroundTile>
<Icon
size={theme.icon.size.md}
stroke={theme.icon.stroke.md}
color={
showBreadcrumb && !isExpanded
? theme.font.color.light
: 'currentColor'
}
/>
</StyledIconBackgroundTile>
</StyledIcon>
) : (
<StyledIcon>
<Icon
@@ -11,11 +11,10 @@ const StyledItemsContainer = styled.div`
height: 100%;
`;
const StyledScrollableInnerContainer = styled.div<{ isMobile?: boolean }>`
const StyledScrollableMobileInnerContainer = styled.div`
height: 100%;
padding-left: ${themeCssVariables.spacing[5]};
padding-right: ${({ isMobile }) =>
isMobile ? themeCssVariables.spacing[5] : themeCssVariables.spacing[8]};
padding-right: ${themeCssVariables.spacing[5]};
`;
export const NavigationDrawerScrollableContent = ({
@@ -34,10 +33,10 @@ export const NavigationDrawerScrollableContent = ({
defaultEnableXScroll={false}
>
<StyledItemsContainer>
{isSettingsDrawer || isMobile ? (
<StyledScrollableInnerContainer isMobile={isMobile}>
{isMobile ? (
<StyledScrollableMobileInnerContainer>
{children}
</StyledScrollableInnerContainer>
</StyledScrollableMobileInnerContainer>
) : (
<>{children}</>
)}
@@ -5,22 +5,19 @@ import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useIsMobile } from 'twenty-ui/utilities';
const StyledSection = styled.div<{ isSettingsDrawer?: boolean }>`
margin-bottom: ${({ isSettingsDrawer }) =>
isSettingsDrawer ? themeCssVariables.spacing[3] : '0'};
const StyledSection = styled.div`
width: 100%;
`;
const StyledSectionInnerContainerMinusScrollPadding = styled.div<{
isMobile: boolean;
isSettingsDrawer: boolean;
isMainNavCollapsed: boolean;
}>`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.betweenSiblingsGap};
width: ${({ isMobile, isSettingsDrawer, isMainNavCollapsed }) =>
isMobile || isSettingsDrawer || isMainNavCollapsed
width: ${({ isMobile, isMainNavCollapsed }) =>
isMobile || isMainNavCollapsed
? '100%'
: `calc(100% - ${themeCssVariables.spacing[2]})`};
`;
@@ -41,10 +38,9 @@ export const NavigationDrawerSection = ({
!isSettingsDrawer && !isMobile && !isNavigationDrawerExpanded;
return (
<StyledSection isSettingsDrawer={isSettingsDrawer} className={className}>
<StyledSection className={className}>
<StyledSectionInnerContainerMinusScrollPadding
isMobile={isMobile}
isSettingsDrawer={isSettingsDrawer}
isMainNavCollapsed={isMainNavCollapsed}
>
{children}
@@ -2,6 +2,7 @@ import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { usePersistLogicFunction } from '@/logic-functions/hooks/usePersistLogicFunction';
import { SettingsDiscoveryHeroCard } from '@/settings/components/SettingsDiscoveryHeroCard';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
@@ -15,19 +16,25 @@ import { t } from '@lingui/core/macro';
import {
IconChartBar,
IconCpu,
IconLayoutDashboard,
IconPlus,
IconSettingsBolt,
IconSparkles,
IconTool,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { UndecoratedLink } from 'twenty-ui/navigation';
import { SettingsAiMoreTab } from '~/pages/settings/ai/components/SettingsAiMoreTab';
import { SettingsAgentSkillsTab } from '~/pages/settings/ai/components/SettingsAgentSkillsTab';
import { SettingsAgentToolsTab } from '~/pages/settings/ai/components/SettingsAgentToolsTab';
import { SettingsAiModelsTab } from './components/SettingsAiModelsTab';
import { SettingsAiUsageTab } from './components/SettingsAiUsageTab';
import { SettingsAgentSkills } from './components/SettingsAgentSkills';
import { SETTINGS_AI_TABS } from './constants/SettingsAiTabs';
import { SettingsAiModelsTab } from '~/pages/settings/ai/components/SettingsAiModelsTab';
import { SettingsAiOverviewTab } from '~/pages/settings/ai/components/SettingsAiOverviewTab';
import { SettingsAiUsageTab } from '~/pages/settings/ai/components/SettingsAiUsageTab';
import { SETTINGS_AI_TABS } from '~/pages/settings/ai/constants/SettingsAiTabs';
const AI_HERO_LIGHT = '/images/ai/ai-tools-cover-light.png';
const AI_HERO_DARK = '/images/ai/ai-tools-cover-dark.png';
const SETTINGS_AI_HERO_INSTANCE_ID_PREFIX = 'settings-ai-hero';
export const SettingsAI = () => {
const navigate = useNavigate();
@@ -56,8 +63,7 @@ export const SettingsAI = () => {
const newLogicFunction = result.response.data.createOneLogicFunction;
enqueueSuccessSnackBar({ message: t`Tool created` });
const applicationId = (newLogicFunction as { applicationId?: string })
.applicationId;
const applicationId = newLogicFunction.applicationId;
if (isDefined(applicationId)) {
navigate(
getSettingsPath(SettingsPath.ApplicationLogicFunctionDetail, {
@@ -81,6 +87,11 @@ export const SettingsAI = () => {
};
const tabs = [
{
id: SETTINGS_AI_TABS.TABS_IDS.OVERVIEW,
title: t`Overview`,
Icon: IconLayoutDashboard,
},
{
id: SETTINGS_AI_TABS.TABS_IDS.MODELS,
title: t`Models`,
@@ -101,18 +112,14 @@ export const SettingsAI = () => {
title: t`Usage`,
Icon: IconChartBar,
},
{
id: SETTINGS_AI_TABS.TABS_IDS.MORE,
title: t`More`,
Icon: IconSettingsBolt,
},
];
const isModelsTab = activeTabId === SETTINGS_AI_TABS.TABS_IDS.MODELS;
const isSkillsTab = activeTabId === SETTINGS_AI_TABS.TABS_IDS.SKILLS;
const isToolsTab = activeTabId === SETTINGS_AI_TABS.TABS_IDS.TOOLS;
const isUsageTab = activeTabId === SETTINGS_AI_TABS.TABS_IDS.USAGE;
const isMoreTab = activeTabId === SETTINGS_AI_TABS.TABS_IDS.MORE;
const resolvedTabId = activeTabId ?? SETTINGS_AI_TABS.TABS_IDS.OVERVIEW;
const isOverviewTab = resolvedTabId === SETTINGS_AI_TABS.TABS_IDS.OVERVIEW;
const isModelsTab = resolvedTabId === SETTINGS_AI_TABS.TABS_IDS.MODELS;
const isSkillsTab = resolvedTabId === SETTINGS_AI_TABS.TABS_IDS.SKILLS;
const isToolsTab = resolvedTabId === SETTINGS_AI_TABS.TABS_IDS.TOOLS;
const isUsageTab = resolvedTabId === SETTINGS_AI_TABS.TABS_IDS.USAGE;
return (
<SubMenuTopBarContainer
@@ -147,15 +154,43 @@ export const SettingsAI = () => {
]}
>
<SettingsPageContainer>
<Section>
<SettingsDiscoveryHeroCard
lightSrc={AI_HERO_LIGHT}
darkSrc={AI_HERO_DARK}
instanceIdPrefix={SETTINGS_AI_HERO_INSTANCE_ID_PREFIX}
tabs={[
{
id: 'skills',
title: t`Skills`,
Icon: IconSparkles,
vimeoId: '1185511734',
},
{
id: 'tools',
title: t`Tools`,
Icon: IconTool,
vimeoId: '1185511734',
},
{
id: 'models',
title: t`Models`,
Icon: IconCpu,
vimeoId: '1185511734',
},
]}
playButtonAriaLabel={t`Watch AI demo`}
/>
</Section>
<TabList
tabs={tabs}
componentInstanceId={SETTINGS_AI_TABS.COMPONENT_INSTANCE_ID}
/>
{isOverviewTab && <SettingsAiOverviewTab />}
{isModelsTab && <SettingsAiModelsTab />}
{isSkillsTab && <SettingsAgentSkills />}
{isSkillsTab && <SettingsAgentSkillsTab />}
{isToolsTab && <SettingsAgentToolsTab />}
{isUsageTab && <SettingsAiUsageTab />}
{isMoreTab && <SettingsAiMoreTab />}
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
@@ -5,11 +5,11 @@ import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/Drop
import { useSortedArray } from '@/ui/layout/table/hooks/useSortedArray';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { useContext, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { H2Title, IconArchive } from 'twenty-ui/display';
import { SearchInput } from 'twenty-ui/input';
import { MenuItemToggle } from 'twenty-ui/navigation';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useMutation, useQuery } from '@apollo/client/react';
import { Section } from 'twenty-ui/layout';
@@ -22,20 +22,12 @@ import { SETTINGS_SKILL_TABLE_METADATA } from '~/pages/settings/ai/constants/Set
import { normalizeSearchText } from '~/utils/normalizeSearchText';
import { SettingsAgentSkillsTable } from './SettingsAgentSkillsTable';
const StyledCoverImage = styled.div`
background-position: center;
background-size: cover;
height: 160px;
overflow: hidden;
`;
const StyledSearchInput = styled(SearchInput)`
margin-bottom: ${themeCssVariables.spacing[4]};
`;
export const SettingsAgentSkills = () => {
export const SettingsAgentSkillsTab = () => {
const { t } = useLingui();
const { colorScheme } = useContext(ThemeContext);
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const { data, loading, refetch } = useQuery(FindManySkillsDocument);
@@ -90,55 +82,45 @@ export const SettingsAgentSkills = () => {
}
};
const coverImage =
colorScheme === 'light'
? '/images/ai/ai-skills-cover-light.png'
: '/images/ai/ai-skills-cover-dark.png';
return (
<>
<StyledCoverImage style={{ backgroundImage: `url('${coverImage}')` }} />
<Section>
<H2Title
title={t`Skills`}
description={t`Use filter to see existing tools or create your own`}
/>
<Section>
<H2Title
title={t`Skills`}
description={t`Use filter to see existing skills or create your own`}
/>
<StyledSearchInput
placeholder={t`Search a skill...`}
value={searchTerm}
onChange={setSearchTerm}
filterDropdown={(filterButton) => (
<Dropdown
dropdownId="settings-skills-filter-dropdown"
dropdownPlacement="bottom-end"
dropdownOffset={{ x: 0, y: 8 }}
clickableComponent={filterButton}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
<MenuItemToggle
LeftIcon={IconArchive}
onToggleChange={() =>
setShowDeactivated(!showDeactivated)
}
toggled={showDeactivated}
text={t`Deactivated`}
toggleSize="small"
/>
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
)}
/>
<SettingsAgentSkillsTable
skills={filteredSkills}
loading={loading}
onActivate={handleActivate}
onDelete={handleDelete}
/>
</Section>
</>
<StyledSearchInput
placeholder={t`Search a skill...`}
value={searchTerm}
onChange={setSearchTerm}
filterDropdown={(filterButton) => (
<Dropdown
dropdownId="settings-skills-filter-dropdown"
dropdownPlacement="bottom-end"
dropdownOffset={{ x: 0, y: 8 }}
clickableComponent={filterButton}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
<MenuItemToggle
LeftIcon={IconArchive}
onToggleChange={() => setShowDeactivated(!showDeactivated)}
toggled={showDeactivated}
text={t`Deactivated`}
toggleSize="small"
/>
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
)}
/>
<SettingsAgentSkillsTable
skills={filteredSkills}
loading={loading}
onActivate={handleActivate}
onDelete={handleDelete}
/>
</Section>
);
};
@@ -1,28 +1,219 @@
import { gql } from '@apollo/client';
import { useQuery } from '@apollo/client/react';
import { styled } from '@linaria/react';
import { useContext } from 'react';
import { Section } from 'twenty-ui/layout';
import { ThemeContext } from 'twenty-ui/theme-constants';
import { SettingsToolsTable } from '~/pages/settings/ai/components/SettingsToolsTable';
import { useLingui } from '@lingui/react/macro';
import { type ReactNode, useMemo, useState } from 'react';
const StyledCoverImage = styled.div`
background-position: center;
background-size: cover;
height: 160px;
overflow: hidden;
import { useGetToolIndex } from '@/ai/hooks/useGetToolIndex';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { logicFunctionsSelector } from '@/logic-functions/states/logicFunctionsSelector';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { ToolCategory } from 'twenty-shared/ai';
import { isDefined } from 'twenty-shared/utils';
import { H2Title, IconLock, IconPuzzle, IconTool } from 'twenty-ui/display';
import { SearchInput } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { MenuItemToggle } from 'twenty-ui/navigation';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import {
type SettingsAgentToolItem,
SettingsAgentToolsTable,
} from '~/pages/settings/ai/components/SettingsAgentToolsTable';
import { normalizeSearchText } from '~/utils/normalizeSearchText';
const FIND_MANY_APPLICATIONS_FOR_TOOL_TABLE = gql`
query FindManyApplicationsForToolTable {
findManyApplications {
id
name
universalIdentifier
logo
}
}
`;
const FIND_MANY_MARKETPLACE_APPS_FOR_TOOL_TABLE = gql`
query FindManyMarketplaceAppsForToolTable {
findManyMarketplaceApps {
id
universalIdentifier
icon
logo
}
}
`;
const StyledSearchContainer = styled.div`
padding-bottom: ${themeCssVariables.spacing[2]};
`;
export const SettingsAgentToolsTab = () => {
const { colorScheme } = useContext(ThemeContext);
const coverImage =
colorScheme === 'light'
? '/images/ai/ai-tools-cover-light.png'
: '/images/ai/ai-tools-cover-dark.png';
const logicFunctions = useAtomStateValue(logicFunctionsSelector);
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const {
toolIndex,
loading: toolIndexLoading,
error: toolIndexError,
} = useGetToolIndex();
const { data: applicationsData } = useQuery<{
findManyApplications: Array<{
id: string;
name: string;
universalIdentifier: string;
logo?: string | null;
}>;
}>(FIND_MANY_APPLICATIONS_FOR_TOOL_TABLE);
const { data: marketplaceAppsData } = useQuery<{
findManyMarketplaceApps: Array<{
id: string;
universalIdentifier: string;
icon: string;
logo?: string | null;
}>;
}>(FIND_MANY_MARKETPLACE_APPS_FOR_TOOL_TABLE);
const { t } = useLingui();
const [searchTerm, setSearchTerm] = useState('');
const [showCustomTools, setShowCustomTools] = useState(true);
const [showManagedTools, setShowManagedTools] = useState(true);
const [showStandardTools, setShowStandardTools] = useState(true);
const workspaceCustomApplicationId =
currentWorkspace?.workspaceCustomApplication?.id;
const isManaged = (applicationId?: string | null) =>
isDefined(applicationId) && applicationId !== workspaceCustomApplicationId;
const isCustom = (tool: SettingsAgentToolItem) =>
isDefined(tool.applicationId);
const allTools: SettingsAgentToolItem[] = useMemo(
() => [
...logicFunctions
.filter((fn) => isDefined(fn.toolTriggerSettings))
.map((fn) => ({
identifier: fn.id,
name: fn.name,
description: fn.description,
applicationId: fn.applicationId,
})),
...toolIndex
.filter((tool) => tool.category !== ToolCategory.LOGIC_FUNCTION)
.map((tool) => ({
identifier: tool.name,
name: tool.name,
description: tool.description,
category: tool.category,
objectName: tool.objectName,
icon: tool.icon,
})),
],
[logicFunctions, toolIndex],
);
const applicationById = new Map(
(applicationsData?.findManyApplications ?? []).map((application) => [
application.id,
application,
]),
);
const marketplaceAppByUniversalIdentifier = new Map(
(marketplaceAppsData?.findManyMarketplaceApps ?? []).map(
(marketplaceApp) => [marketplaceApp.universalIdentifier, marketplaceApp],
),
);
const filteredTools = allTools
.filter((tool) => {
const searchNormalized = normalizeSearchText(searchTerm);
const matchesSearch =
normalizeSearchText(tool.name).includes(searchNormalized) ||
normalizeSearchText(tool.description ?? '').includes(searchNormalized);
if (!matchesSearch) {
return false;
}
if (!isCustom(tool)) {
return showStandardTools;
}
if (isManaged(tool.applicationId)) {
return showManagedTools;
}
return showCustomTools;
})
.sort((a, b) => a.name.localeCompare(b.name));
const isLoading = toolIndexLoading && !toolIndexError;
return (
<>
<Section>
<StyledCoverImage style={{ backgroundImage: `url('${coverImage}')` }} />
</Section>
<SettingsToolsTable />
</>
<Section>
<H2Title
title={t`Tools`}
description={t`Use filter to see existing tools or create your own`}
/>
<StyledSearchContainer>
<SearchInput
placeholder={t`Search a tool...`}
value={searchTerm}
onChange={setSearchTerm}
filterDropdown={(filterButton: ReactNode) => (
<Dropdown
dropdownId="settings-tools-filter-dropdown"
dropdownPlacement="bottom-end"
dropdownOffset={{ x: 0, y: 8 }}
clickableComponent={filterButton}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
<MenuItemToggle
LeftIcon={IconTool}
onToggleChange={() =>
setShowCustomTools(!showCustomTools)
}
toggled={showCustomTools}
text={t`Custom`}
toggleSize="small"
/>
<MenuItemToggle
LeftIcon={IconLock}
onToggleChange={() =>
setShowManagedTools(!showManagedTools)
}
toggled={showManagedTools}
text={t`Managed`}
toggleSize="small"
/>
<MenuItemToggle
LeftIcon={IconPuzzle}
onToggleChange={() =>
setShowStandardTools(!showStandardTools)
}
toggled={showStandardTools}
text={t`Standard`}
toggleSize="small"
/>
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
)}
/>
</StyledSearchContainer>
<SettingsAgentToolsTable
tools={filteredTools}
isLoading={isLoading}
applicationById={applicationById}
marketplaceAppByUniversalIdentifier={
marketplaceAppByUniversalIdentifier
}
currentWorkspace={currentWorkspace}
/>
</Section>
);
};
@@ -0,0 +1,146 @@
import { useContext } from 'react';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { Table } from '@/ui/layout/table/components/Table';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER } from 'twenty-shared/application';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { IconChevronRight } from 'twenty-ui/display';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { type CurrentWorkspace } from '@/auth/states/currentWorkspaceState';
import { SettingsToolIcon } from '~/pages/settings/ai/components/SettingsToolIcon';
import {
SettingsToolTableRow,
TOOL_TABLE_ROW_GRID_TEMPLATE_COLUMNS,
} from '~/pages/settings/ai/components/SettingsToolTableRow';
export type SettingsAgentToolItem = {
identifier: string;
name: string;
description?: string | null;
category?: string;
objectName?: string | null;
icon?: string | null;
applicationId?: string | null;
};
type Application = {
id: string;
name: string;
universalIdentifier: string;
logo?: string | null;
};
type MarketplaceApp = {
id: string;
universalIdentifier: string;
icon: string;
logo?: string | null;
};
type SettingsAgentToolsTableProps = {
tools: SettingsAgentToolItem[];
isLoading: boolean;
applicationById: Map<string, Application>;
marketplaceAppByUniversalIdentifier: Map<string, MarketplaceApp>;
currentWorkspace: CurrentWorkspace | null;
};
const StyledTableHeaderRowContainer = styled.div`
margin-bottom: ${themeCssVariables.spacing[2]};
`;
const getToolApplicationId = (
tool: SettingsAgentToolItem,
currentWorkspace: CurrentWorkspace | null,
): string => {
if (isDefined(tool.applicationId)) {
return tool.applicationId;
}
return (
currentWorkspace?.installedApplications?.find(
(app) =>
app.universalIdentifier ===
TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER,
)?.id ?? ''
);
};
const getToolLink = (tool: SettingsAgentToolItem): string =>
getSettingsPath(SettingsPath.AiToolDetail, {
toolIdentifier: tool.identifier,
});
export const SettingsAgentToolsTable = ({
tools,
isLoading,
applicationById,
marketplaceAppByUniversalIdentifier,
currentWorkspace,
}: SettingsAgentToolsTableProps) => {
const { theme } = useContext(ThemeContext);
const { t } = useLingui();
return (
<Table>
<StyledTableHeaderRowContainer>
<TableRow gridTemplateColumns={TOOL_TABLE_ROW_GRID_TEMPLATE_COLUMNS}>
<TableHeader>{t`Name`}</TableHeader>
<TableHeader>{t`App`}</TableHeader>
<TableHeader />
</TableRow>
</StyledTableHeaderRowContainer>
{isLoading
? Array.from({ length: 3 }).map((_, index) => (
<SkeletonTheme
key={index}
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.lighter}
borderRadius={4}
>
<Skeleton height={32} borderRadius={4} />
</SkeletonTheme>
))
: tools.map((tool) => {
const application = isDefined(tool.applicationId)
? applicationById.get(tool.applicationId)
: undefined;
const marketplaceApp = isDefined(application)
? marketplaceAppByUniversalIdentifier.get(
application.universalIdentifier,
)
: undefined;
return (
<SettingsToolTableRow
key={tool.identifier}
leftIcon={
<SettingsToolIcon
icon={tool.icon}
toolName={tool.name}
objectName={tool.objectName ?? undefined}
application={application}
marketplaceApp={marketplaceApp}
/>
}
name={tool.name}
applicationId={getToolApplicationId(tool, currentWorkspace)}
action={
<IconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
}
link={getToolLink(tool)}
/>
);
})}
</Table>
);
};
@@ -1,10 +1,5 @@
import { styled } from '@linaria/react';
import { useState } from 'react';
import {
AUTO_SELECT_FAST_MODEL_ID,
AUTO_SELECT_SMART_MODEL_ID,
} from 'twenty-shared/constants';
import { useContext, useState } from 'react';
import { useWorkspaceAiModelAvailability } from '@/ai/hooks/useWorkspaceAiModelAvailability';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
@@ -12,6 +7,7 @@ import { aiModelsState } from '@/client-config/states/aiModelsState';
import { SettingsAiModelsTable } from '@/settings/ai/components/SettingsAiModelsTable';
import { getDataResidencyDisplay } from '@/settings/ai/utils/getDataResidencyDisplay';
import { getModelIcon } from '@/settings/ai/utils/getModelIcon';
import { SettingsCard } from '@/settings/components/SettingsCard';
import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect';
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
@@ -19,13 +15,30 @@ import { Select } from '@/ui/input/components/Select';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useMutation } from '@apollo/client/react';
import { useMutation, useQuery } from '@apollo/client/react';
import { t } from '@lingui/core/macro';
import { H2Title, IconBolt, IconBrain, IconStar } from 'twenty-ui/display';
import {
AUTO_SELECT_FAST_MODEL_ID,
AUTO_SELECT_SMART_MODEL_ID,
} from 'twenty-shared/constants';
import {
H2Title,
IconBolt,
IconBrain,
IconPrompt,
IconStar,
} from 'twenty-ui/display';
import { SearchInput } from 'twenty-ui/input';
import { Card, Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { UpdateWorkspaceDocument } from '~/generated-metadata/graphql';
import { UndecoratedLink } from 'twenty-ui/navigation';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import {
GetAiSystemPromptPreviewDocument,
UpdateWorkspaceDocument,
} from '~/generated-metadata/graphql';
import { formatNumber } from '~/utils/format/formatNumber';
const StyledCustomModelsContainer = styled.div`
display: flex;
@@ -35,18 +48,27 @@ const StyledCustomModelsContainer = styled.div`
`;
export const SettingsAiModelsTab = () => {
const { theme } = useContext(ThemeContext);
const { enqueueErrorSnackBar } = useSnackBar();
const [currentWorkspace, setCurrentWorkspace] = useAtomState(
currentWorkspaceState,
);
const [updateWorkspace] = useMutation(UpdateWorkspaceDocument);
const [searchQuery, setSearchQuery] = useState('');
const { data: previewData } = useQuery(GetAiSystemPromptPreviewDocument);
const aiModels = useAtomStateValue(aiModelsState);
const { enabledModels, useRecommendedModels, realModels } =
useWorkspaceAiModelAvailability();
const systemPromptTokenCount =
previewData?.getAiSystemPromptPreview.estimatedTokenCount;
const systemPromptDescription = isDefined(systemPromptTokenCount)
? t`Read the system prompts to understand how the AI works (~${formatNumber(
systemPromptTokenCount,
{ abbreviate: true, decimals: 1 },
)} tokens)`
: t`Read the system prompts to understand how the AI works`;
const enabledModelIdSet = new Set(currentWorkspace?.enabledAiModelIds ?? []);
const { useRecommendedModels, realModels, enabledModels } =
useWorkspaceAiModelAvailability();
const currentSmartModel = currentWorkspace?.smartModel;
const currentFastModel = currentWorkspace?.fastModel;
@@ -55,11 +77,7 @@ export const SettingsAiModelsTab = () => {
const autoSelectEntry = aiModels.find(
(model) => model.modelId === autoSelectModelId,
);
if (!autoSelectEntry) {
return undefined;
}
if (!autoSelectEntry) return undefined;
return {
value: autoSelectModelId,
label: autoSelectEntry.label,
@@ -74,57 +92,34 @@ export const SettingsAiModelsTab = () => {
const smartPinnedOption = buildPinnedOption(AUTO_SELECT_SMART_MODEL_ID);
const fastPinnedOption = buildPinnedOption(AUTO_SELECT_FAST_MODEL_ID);
const buildModelOptions = () =>
enabledModels.map((model) => {
const residencyFlag = model.dataResidency
? ` ${getDataResidencyDisplay(model.dataResidency)}`
: '';
return {
value: model.modelId,
label: `${model.label}${residencyFlag}`,
Icon: getModelIcon(model.modelFamily, model.providerName),
};
});
const smartModelOptions = buildModelOptions();
const fastModelOptions = buildModelOptions();
const modelOptions = enabledModels.map((model) => {
const residencyFlag = model.dataResidency
? ` ${getDataResidencyDisplay(model.dataResidency)}`
: '';
return {
value: model.modelId,
label: `${model.label}${residencyFlag}`,
Icon: getModelIcon(model.modelFamily, model.providerName),
};
});
const handleModelFieldChange = async (
field: 'smartModel' | 'fastModel',
value: string,
) => {
if (!currentWorkspace?.id) {
return;
}
if (!currentWorkspace?.id) return;
const previousValue = currentWorkspace[field];
try {
setCurrentWorkspace({
...currentWorkspace,
[field]: value,
});
await updateWorkspace({
variables: {
input: {
[field]: value,
},
},
});
setCurrentWorkspace({ ...currentWorkspace, [field]: value });
await updateWorkspace({ variables: { input: { [field]: value } } });
} catch {
setCurrentWorkspace({
...currentWorkspace,
[field]: previousValue,
});
enqueueErrorSnackBar({
message: t`Failed to update model`,
});
setCurrentWorkspace({ ...currentWorkspace, [field]: previousValue });
enqueueErrorSnackBar({ message: t`Failed to update model` });
}
};
const enabledModelIdSet = new Set(currentWorkspace?.enabledAiModelIds ?? []);
const handleUseRecommendedToggle = async (checked: boolean) => {
if (!currentWorkspace?.id) {
return;
@@ -224,10 +219,9 @@ export const SettingsAiModelsTab = () => {
<>
<Section>
<H2Title
title={t`Default`}
description={t`Configure your default AI model`}
title={t`Default model`}
description={t`The default AI model used for chats, agents, and workflows`}
/>
<Card rounded>
<SettingsOptionCardContentSelect
Icon={IconBrain}
@@ -235,10 +229,10 @@ export const SettingsAiModelsTab = () => {
description={t`Used for chats, agents, and complex reasoning`}
>
<Select
dropdownId="smart-model-select"
dropdownId="models-tab-smart-model-select"
value={currentSmartModel}
onChange={(value) => handleModelFieldChange('smartModel', value)}
options={smartModelOptions}
options={modelOptions}
pinnedOption={smartPinnedOption}
selectSizeVariant="small"
dropdownWidth={GenericDropdownContentWidth.ExtraLarge}
@@ -250,10 +244,10 @@ export const SettingsAiModelsTab = () => {
description={t`Used for lightweight tasks like title generation`}
>
<Select
dropdownId="fast-model-select"
dropdownId="models-tab-fast-model-select"
value={currentFastModel}
onChange={(value) => handleModelFieldChange('fastModel', value)}
options={fastModelOptions}
options={modelOptions}
pinnedOption={fastPinnedOption}
selectSizeVariant="small"
dropdownWidth={GenericDropdownContentWidth.ExtraLarge}
@@ -264,7 +258,7 @@ export const SettingsAiModelsTab = () => {
<Section>
<H2Title
title={t`Available`}
title={t`Available models`}
description={t`Models available in the chat model picker`}
/>
<Card rounded>
@@ -323,6 +317,19 @@ export const SettingsAiModelsTab = () => {
</StyledCustomModelsContainer>
)}
</Section>
<Section>
<H2Title
title={t`System Prompt`}
description={systemPromptDescription}
/>
<UndecoratedLink to={getSettingsPath(SettingsPath.AiPrompts)}>
<SettingsCard
Icon={<IconPrompt size={theme.icon.size.md} />}
title={t`Read system prompts`}
/>
</UndecoratedLink>
</Section>
</>
);
};
@@ -1,41 +1,50 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { FormAdvancedTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormAdvancedTextFieldInput';
import { SettingsCard } from '@/settings/components/SettingsCard';
import { SettingsStatsGrid } from '@/settings/components/SettingsStatsGrid';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { CombinedGraphQLErrors } from '@apollo/client';
import { useMutation, useQuery } from '@apollo/client/react';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { useContext, useState } from 'react';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { H2Title, IconPrompt } from 'twenty-ui/display';
import { Fragment, useContext, useState } from 'react';
import {
H2Title,
IconMessage,
IconRobot,
IconSparkles,
IconTool,
} from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { UndecoratedLink } from 'twenty-ui/navigation';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { useDebouncedCallback } from 'use-debounce';
import {
GetAiSystemPromptPreviewDocument,
FindWorkspaceAiStatsDocument,
UpdateWorkspaceDocument,
} from '~/generated-metadata/graphql';
import { SettingsAiMCP } from '~/pages/settings/ai/components/SettingsAiMCP';
import { formatNumber } from '~/utils/format/formatNumber';
const StyledFormContainer = styled.div`
const StyledInstructionsContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[4]};
`;
export const SettingsAiMoreTab = () => {
const MCP_DEEP_LINK = `${getSettingsPath(SettingsPath.ApiWebhooks)}#mcp`;
export const SettingsAiOverviewTab = () => {
const { theme } = useContext(ThemeContext);
const { enqueueErrorSnackBar } = useSnackBar();
const [currentWorkspace, setCurrentWorkspace] = useAtomState(
currentWorkspaceState,
);
const [updateWorkspace] = useMutation(UpdateWorkspaceDocument);
const { data: previewData } = useQuery(GetAiSystemPromptPreviewDocument);
const { data: aiStatsData } = useQuery(FindWorkspaceAiStatsDocument);
const stats = aiStatsData?.findWorkspaceAiStats;
const initialInstructions = currentWorkspace?.aiAdditionalInstructions ?? '';
const [workspaceInstructions, setWorkspaceInstructions] =
@@ -44,35 +53,25 @@ export const SettingsAiMoreTab = () => {
useState(initialInstructions);
const autoSave = useDebouncedCallback(async (newValue: string) => {
if (!currentWorkspace?.id || newValue === originalInstructions) {
return;
}
if (!currentWorkspace?.id || newValue === originalInstructions) return;
try {
setCurrentWorkspace({
...currentWorkspace,
aiAdditionalInstructions: newValue || null,
});
await updateWorkspace({
variables: {
input: {
aiAdditionalInstructions: newValue || null,
},
input: { aiAdditionalInstructions: newValue || null },
},
});
setOriginalInstructions(newValue);
} catch (error) {
setCurrentWorkspace({
...currentWorkspace,
aiAdditionalInstructions: originalInstructions || null,
});
if (CombinedGraphQLErrors.is(error)) {
enqueueErrorSnackBar({
apolloError: error,
});
enqueueErrorSnackBar({ apolloError: error });
} else {
enqueueErrorSnackBar({
message: t`Failed to save workspace instructions`,
@@ -81,68 +80,75 @@ export const SettingsAiMoreTab = () => {
}
}, 1000);
const handleWorkspaceInstructionsChange = (value: string) => {
setWorkspaceInstructions(value);
autoSave(value);
};
const systemPromptTokenCount =
previewData?.getAiSystemPromptPreview.estimatedTokenCount;
const systemPromptDescription = isDefined(systemPromptTokenCount)
? t`Read the system prompts to understand how the AI works (~${formatNumber(
systemPromptTokenCount,
{
abbreviate: true,
decimals: 1,
},
)} tokens)`
: t`Read the system prompts to understand how the AI works`;
return (
<>
<Fragment>
<Section>
<H2Title
title={t`At a glance`}
description={t`What's installed and being used in your workspace`}
/>
<SettingsStatsGrid
columns={[
[
{
Icon: IconMessage,
label: t`Conversations`,
value: stats ? stats.conversationsCount.toString() : '—',
},
{
Icon: IconSparkles,
label: t`Skills`,
value: stats ? stats.skillsCount.toString() : '—',
},
{
Icon: IconTool,
label: t`Tools`,
value: stats ? stats.toolsCount.toString() : '—',
},
],
]}
/>
</Section>
<Section>
<H2Title
title={t`MCP Server`}
description={t`Connect AI assistants like Claude or Cursor to your workspace via the Model Context Protocol`}
/>
<UndecoratedLink to={MCP_DEEP_LINK}>
<SettingsCard
Icon={<IconRobot size={theme.icon.size.md} />}
title={t`Set up MCP`}
/>
</UndecoratedLink>
</Section>
<Section>
<H2Title
title={t`Workspace Instructions`}
description={t`Add custom instructions specific to your workspace (appended to system prompt)`}
description={t`Custom instructions appended to every system prompt`}
/>
<StyledFormContainer>
<StyledInstructionsContainer>
<FormAdvancedTextFieldInput
key={originalInstructions}
readonly={false}
defaultValue={workspaceInstructions}
contentType="markdown"
onChange={handleWorkspaceInstructionsChange}
onChange={(value) => {
setWorkspaceInstructions(value);
autoSave(value);
}}
enableFullScreen={true}
fullScreenBreadcrumbs={[
{
children: t`System Prompt`,
href: '#',
},
{
children: t`Workspace Instructions`,
},
{ children: t`System Prompt`, href: '#' },
{ children: t`Workspace Instructions` },
]}
placeholder={t`E.g., "We are a B2B SaaS company. Always use formal language..."`}
minHeight={150}
maxWidth={700}
/>
</StyledFormContainer>
</StyledInstructionsContainer>
</Section>
<SettingsAiMCP />
<Section>
<H2Title
title={t`System Prompt`}
description={systemPromptDescription}
/>
<UndecoratedLink to={getSettingsPath(SettingsPath.AiPrompts)}>
<SettingsCard
Icon={<IconPrompt size={theme.icon.size.md} />}
title={t`Read system prompts`}
/>
</UndecoratedLink>
</Section>
</>
</Fragment>
);
};
@@ -1,311 +0,0 @@
import { styled } from '@linaria/react';
import { gql } from '@apollo/client';
import { useQuery } from '@apollo/client/react';
import { useLingui } from '@lingui/react/macro';
import { type ReactNode, useContext, useMemo, useState } from 'react';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { useGetToolIndex } from '@/ai/hooks/useGetToolIndex';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { logicFunctionsSelector } from '@/logic-functions/states/logicFunctionsSelector';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { Table } from '@/ui/layout/table/components/Table';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import {
H2Title,
IconChevronRight,
IconLock,
IconPuzzle,
IconTool,
} from 'twenty-ui/display';
import { SearchInput } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { MenuItemToggle } from 'twenty-ui/navigation';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { ToolCategory } from 'twenty-shared/ai';
import { normalizeSearchText } from '~/utils/normalizeSearchText';
import { SettingsToolIcon } from './SettingsToolIcon';
import {
SettingsToolTableRow,
TOOL_TABLE_ROW_GRID_TEMPLATE_COLUMNS,
} from './SettingsToolTableRow';
import { TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER } from 'twenty-shared/application';
type ToolItem = {
identifier: string;
name: string;
description?: string | null;
category?: string;
objectName?: string | null;
icon?: string | null;
applicationId?: string | null;
};
const FIND_MANY_APPLICATIONS_FOR_TOOL_TABLE = gql`
query FindManyApplicationsForToolTable {
findManyApplications {
id
name
universalIdentifier
logo
}
}
`;
const FIND_MANY_MARKETPLACE_APPS_FOR_TOOL_TABLE = gql`
query FindManyMarketplaceAppsForToolTable {
findManyMarketplaceApps {
id
universalIdentifier
icon
logo
}
}
`;
const StyledSearchContainer = styled.div`
padding-bottom: ${themeCssVariables.spacing[2]};
`;
const StyledTableHeaderRowContainer = styled.div`
margin-bottom: ${themeCssVariables.spacing[2]};
`;
export const SettingsToolsTable = () => {
const { theme } = useContext(ThemeContext);
const logicFunctions = useAtomStateValue(logicFunctionsSelector);
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const {
toolIndex,
loading: toolIndexLoading,
error: toolIndexError,
} = useGetToolIndex();
const { data: applicationsData } = useQuery<{
findManyApplications: Array<{
id: string;
name: string;
universalIdentifier: string;
logo?: string | null;
}>;
}>(FIND_MANY_APPLICATIONS_FOR_TOOL_TABLE);
const { data: marketplaceAppsData } = useQuery<{
findManyMarketplaceApps: Array<{
id: string;
universalIdentifier: string;
icon: string;
logo?: string | null;
}>;
}>(FIND_MANY_MARKETPLACE_APPS_FOR_TOOL_TABLE);
const { t } = useLingui();
const [searchTerm, setSearchTerm] = useState('');
const [showCustomTools, setShowCustomTools] = useState(true);
const [showManagedTools, setShowManagedTools] = useState(true);
const [showStandardTools, setShowStandardTools] = useState(true);
const workspaceCustomApplicationId =
currentWorkspace?.workspaceCustomApplication?.id;
const isManaged = (applicationId?: string | null) =>
isDefined(applicationId) && applicationId !== workspaceCustomApplicationId;
const isCustom = (item: ToolItem) => isDefined(item.applicationId);
const getToolLink = (item: ToolItem) =>
getSettingsPath(SettingsPath.AiToolDetail, {
toolIdentifier: item.identifier,
});
const getToolApplicationId = (item: ToolItem) => {
if (isDefined(item.applicationId)) {
return item.applicationId;
}
return (
currentWorkspace?.installedApplications?.find(
(app) =>
app.universalIdentifier ===
TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER,
)?.id ?? ''
);
};
const allTools: ToolItem[] = useMemo(
() => [
...logicFunctions
.filter((fn) => isDefined(fn.toolTriggerSettings))
.map((fn) => ({
identifier: fn.id,
name: fn.name,
description: fn.description,
applicationId: fn.applicationId,
})),
...toolIndex
.filter((tool) => tool.category !== ToolCategory.LOGIC_FUNCTION)
.map((tool) => ({
identifier: tool.name,
name: tool.name,
description: tool.description,
category: tool.category,
objectName: tool.objectName,
icon: tool.icon,
})),
],
[logicFunctions, toolIndex],
);
const applicationById = new Map(
(applicationsData?.findManyApplications ?? []).map((application) => [
application.id,
application,
]),
);
const marketplaceAppByUniversalIdentifier = new Map(
(marketplaceAppsData?.findManyMarketplaceApps ?? []).map(
(marketplaceApp) => [marketplaceApp.universalIdentifier, marketplaceApp],
),
);
const filteredTools = allTools
.filter((item) => {
const searchNormalized = normalizeSearchText(searchTerm);
const matchesSearch =
normalizeSearchText(item.name).includes(searchNormalized) ||
normalizeSearchText(item.description ?? '').includes(searchNormalized);
if (!matchesSearch) {
return false;
}
if (!isCustom(item)) {
return showStandardTools;
}
if (isManaged(item.applicationId)) {
return showManagedTools;
}
return showCustomTools;
})
.sort((a, b) => a.name.localeCompare(b.name));
const showSkeleton = toolIndexLoading && !toolIndexError;
return (
<Section>
<H2Title
title={t`Tools`}
description={t`Use filter to see existing tools or create your own`}
/>
<StyledSearchContainer>
<SearchInput
placeholder={t`Search a tool...`}
value={searchTerm}
onChange={setSearchTerm}
filterDropdown={(filterButton: ReactNode) => (
<Dropdown
dropdownId="settings-tools-filter-dropdown"
dropdownPlacement="bottom-end"
dropdownOffset={{ x: 0, y: 8 }}
clickableComponent={filterButton}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
<MenuItemToggle
LeftIcon={IconTool}
onToggleChange={() =>
setShowCustomTools(!showCustomTools)
}
toggled={showCustomTools}
text={t`Custom`}
toggleSize="small"
/>
<MenuItemToggle
LeftIcon={IconLock}
onToggleChange={() =>
setShowManagedTools(!showManagedTools)
}
toggled={showManagedTools}
text={t`Managed`}
toggleSize="small"
/>
<MenuItemToggle
LeftIcon={IconPuzzle}
onToggleChange={() =>
setShowStandardTools(!showStandardTools)
}
toggled={showStandardTools}
text={t`Standard`}
toggleSize="small"
/>
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
)}
/>
</StyledSearchContainer>
<Table>
<StyledTableHeaderRowContainer>
<TableRow gridTemplateColumns={TOOL_TABLE_ROW_GRID_TEMPLATE_COLUMNS}>
<TableHeader>{t`Name`}</TableHeader>
<TableHeader>{t`App`}</TableHeader>
<TableHeader />
</TableRow>
</StyledTableHeaderRowContainer>
{showSkeleton
? Array.from({ length: 3 }).map((_, index) => (
<SkeletonTheme
key={index}
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.lighter}
borderRadius={4}
>
<Skeleton height={32} borderRadius={4} />
</SkeletonTheme>
))
: filteredTools.map((item) => {
const application = isDefined(item.applicationId)
? applicationById.get(item.applicationId)
: undefined;
const marketplaceApp = isDefined(application)
? marketplaceAppByUniversalIdentifier.get(
application.universalIdentifier,
)
: undefined;
return (
<SettingsToolTableRow
key={item.identifier}
leftIcon={
<SettingsToolIcon
icon={item.icon}
toolName={item.name}
objectName={item.objectName ?? undefined}
application={application}
marketplaceApp={marketplaceApp}
/>
}
name={item.name}
applicationId={getToolApplicationId(item)}
action={
<IconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
}
link={getToolLink(item)}
/>
);
})}
</Table>
</Section>
);
};
@@ -1,10 +1,10 @@
export const SETTINGS_AI_TABS = {
COMPONENT_INSTANCE_ID: 'settings-ai-tab-list',
TABS_IDS: {
OVERVIEW: 'overview',
MODELS: 'models',
SKILLS: 'skills',
TOOLS: 'tools',
USAGE: 'usage',
MORE: 'more',
},
} as const;
@@ -1,3 +1,4 @@
import { SettingsDiscoveryHeroCard } from '@/settings/components/SettingsDiscoveryHeroCard';
import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
@@ -8,7 +9,11 @@ import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useLingui } from '@lingui/react/macro';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { IconApps, IconCode, IconDownload } from 'twenty-ui/display';
import { IconApps, IconCode, IconDownload, IconPlug } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
// TODO: replace with apps-specific illustrations + recordings when designed.
import placeholderHeroDark from '~/pages/settings/layout/assets/customize-illustration-dark.png';
import placeholderHeroLight from '~/pages/settings/layout/assets/customize-illustration-light.png';
import {
FeatureFlagKey,
PermissionFlagType,
@@ -18,6 +23,7 @@ import { SettingsApplicationsDeveloperTab } from '~/pages/settings/applications/
import { SettingsApplicationsInstalledTab } from '~/pages/settings/applications/tabs/SettingsApplicationsInstalledTab';
const APPLICATIONS_TAB_LIST_ID = 'applications-tab-list';
const APPLICATIONS_HERO_INSTANCE_ID_PREFIX = 'settings-applications-hero';
export const SettingsApplications = () => {
const { t } = useLingui();
@@ -74,6 +80,34 @@ export const SettingsApplications = () => {
]}
>
<SettingsPageContainer>
<Section>
<SettingsDiscoveryHeroCard
lightSrc={placeholderHeroLight}
darkSrc={placeholderHeroDark}
instanceIdPrefix={APPLICATIONS_HERO_INSTANCE_ID_PREFIX}
tabs={[
{
id: 'browse',
title: t`Browse`,
Icon: IconDownload,
vimeoId: '1185416793',
},
{
id: 'install',
title: t`Install`,
Icon: IconApps,
vimeoId: '1185416793',
},
{
id: 'develop',
title: t`Develop`,
Icon: IconPlug,
vimeoId: '1185416793',
},
]}
playButtonAriaLabel={t`Watch apps demo`}
/>
</Section>
<TabList tabs={tabs} componentInstanceId={APPLICATIONS_TAB_LIST_ID} />
{renderActiveTabContent()}
</SettingsPageContainer>
@@ -1,25 +1,60 @@
import { isDDLLockedState } from '@/client-config/states/isDDLLockedState';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { SettingsDiscoveryHeroCard } from '@/settings/components/SettingsDiscoveryHeroCard';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsObjectCoverImage } from '@/settings/data-model/objects/components/SettingsObjectCoverImage';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { Trans, useLingui } from '@lingui/react/macro';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { useLingui } from '@lingui/react/macro';
import { useMemo } from 'react';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { H2Title, IconPlus } from 'twenty-ui/display';
import {
H2Title,
IconEye,
IconHierarchy2,
IconLink,
IconList,
IconPlus,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { UndecoratedLink } from 'twenty-ui/navigation';
import DarkCoverImage from '@/settings/data-model/assets/cover-dark.png';
import LightCoverImage from '@/settings/data-model/assets/cover-light.png';
import { SettingsObjectTable } from '~/pages/settings/data-model/SettingsObjectTable';
const SETTINGS_DATA_MODEL_HERO_INSTANCE_ID_PREFIX = 'settings-data-model-hero';
export const SettingsObjects = () => {
const { t } = useLingui();
const { objectMetadataItems } = useFilteredObjectMetadataItems();
const isDDLLocked = useAtomStateValue(isDDLLockedState);
const heroTabs = useMemo(
() => [
{
id: 'objects',
title: t`Objects`,
Icon: IconHierarchy2,
vimeoId: '926288174',
},
{
id: 'fields',
title: t`Fields`,
Icon: IconList,
vimeoId: '927628219',
},
{
id: 'relations',
title: t`Relations`,
Icon: IconLink,
vimeoId: '1185511827',
},
],
[t],
);
return (
<SubMenuTopBarContainer
title={t`Data model`}
@@ -45,21 +80,43 @@ export const SettingsObjects = () => {
}
links={[
{
children: <Trans>Workspace</Trans>,
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
{ children: <Trans>Objects</Trans> },
{ children: t`Objects` },
]}
>
<SettingsPageContainer>
<>
<SettingsObjectCoverImage />
<Section>
<H2Title title={t`Existing objects`} />
<SettingsObjectTable objectMetadataItems={objectMetadataItems} />
</Section>
</>
<Section>
<SettingsDiscoveryHeroCard
lightSrc={LightCoverImage}
darkSrc={DarkCoverImage}
instanceIdPrefix={SETTINGS_DATA_MODEL_HERO_INSTANCE_ID_PREFIX}
tabs={heroTabs}
playButtonAriaLabel={t`Watch data model demo`}
/>
</Section>
<Section>
<H2Title
title={t`Existing objects`}
description={t`Manage objects, fields and relationships`}
/>
<SettingsObjectTable objectMetadataItems={objectMetadataItems} />
</Section>
<Section>
<H2Title
title={t`Visualize data model`}
description={t`See your data structure as an interactive diagram`}
/>
<UndecoratedLink to={getSettingsPath(SettingsPath.ObjectOverview)}>
<Button
title={t`Visualize`}
variant="secondary"
size="small"
Icon={IconEye}
/>
</UndecoratedLink>
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
@@ -1,33 +0,0 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { within } from 'storybook/test';
import { SettingsApiKeys } from '~/pages/settings/developers/api-keys/SettingsApiKeys';
import {
PageDecorator,
type PageDecoratorArgs,
} from '~/testing/decorators/PageDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Settings/ApiKeys',
component: SettingsApiKeys,
decorators: [PageDecorator],
args: { routePath: '/settings/apis' },
parameters: {
msw: graphqlMocks,
},
};
export default meta;
export type Story = StoryObj<typeof SettingsApiKeys>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText('API keys', undefined, {
timeout: 3000,
});
},
};
@@ -9,12 +9,15 @@ import {
import { SettingsGraphQLPlayground } from '~/pages/settings/developers/playground/SettingsGraphQLPlayground';
import { graphqlMocks } from '~/testing/graphqlMocks';
const meta: Meta<any> = {
const meta: Meta<typeof SettingsGraphQLPlayground> = {
title: 'Pages/Settings/Playground/GraphQLPlayground',
component: SettingsGraphQLPlayground,
decorators: [
(Story) => {
jotaiStore.set(playgroundApiKeyState.atom, 'test-api-key');
jotaiStore.set(playgroundApiKeyState.atom, {
token: 'test-api-key',
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
});
return <Story />;
},
ComponentDecorator,
@@ -32,7 +35,7 @@ const meta: Meta<any> = {
};
export default meta;
type Story = StoryObj<any>;
type Story = StoryObj<typeof SettingsGraphQLPlayground>;
export const Default: Story = {
args: {
@@ -14,7 +14,10 @@ const meta: Meta<typeof SettingsRestPlayground> = {
component: SettingsRestPlayground,
decorators: [
(Story) => {
jotaiStore.set(playgroundApiKeyState.atom, 'test-api-key');
jotaiStore.set(playgroundApiKeyState.atom, {
token: 'test-api-key',
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
});
return <Story />;
},
ComponentDecorator,
@@ -1,77 +0,0 @@
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 { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { styled } from '@linaria/react';
import { Trans, useLingui } from '@lingui/react/macro';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { H2Title, IconPlus } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { MOBILE_VIEWPORT, themeCssVariables } from 'twenty-ui/theme-constants';
const StyledButtonContainer = styled.div`
display: flex;
justify-content: flex-end;
padding-top: ${themeCssVariables.spacing[2]};
@media (max-width: ${MOBILE_VIEWPORT}px) {
padding-top: ${themeCssVariables.spacing[5]};
}
`;
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
overflow: visible;
`;
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`Documentation`}
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.NewApiKey)}
/>
</StyledButtonContainer>
</Section>
</StyledContainer>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};
@@ -0,0 +1,120 @@
import { useEnterLayoutCustomizationMode } from '@/layout-customization/hooks/useEnterLayoutCustomizationMode';
import { SettingsDiscoveryHeroCard } from '@/settings/components/SettingsDiscoveryHeroCard';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsLayoutItemsStats } from '@/settings/layout/components/SettingsLayoutItemsStats';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { useLingui } from '@lingui/react/macro';
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { AppPath, SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import {
H2Title,
IconAppWindow,
IconCommand,
IconLayoutDashboard,
IconLayoutSidebarLeftExpand,
IconPencil,
IconTable,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import customizeIllustrationDark from '~/pages/settings/layout/assets/customize-illustration-dark.png';
import customizeIllustrationLight from '~/pages/settings/layout/assets/customize-illustration-light.png';
const SETTINGS_LAYOUT_HERO_INSTANCE_ID_PREFIX = 'settings-layout-hero';
export const SettingsLayout = () => {
const { t } = useLingui();
const navigate = useNavigate();
const { enterLayoutCustomizationMode } = useEnterLayoutCustomizationMode();
// Match the in-app customization entry points: arm customization mode before
// landing in the app, otherwise the user just drops into the app normally.
// Skip navigation when entry was blocked (e.g. a dashboard is mid-edit) so we
// don't yank the user away after the warning.
const handleCustomize = () => {
if (enterLayoutCustomizationMode()) {
navigate(AppPath.Index);
}
};
// TODO: replace placeholder demo videos per tab
const heroTabs = useMemo(
() => [
{
id: 'sidebar',
title: t`Sidebar`,
Icon: IconLayoutSidebarLeftExpand,
vimeoId: '1185511790',
},
{
id: 'record-page',
title: t`Record page`,
Icon: IconAppWindow,
vimeoId: '1185511790',
},
{
id: 'command-menu',
title: t`Command menu`,
Icon: IconCommand,
vimeoId: '1185416775',
},
{
id: 'views',
title: t`Views`,
Icon: IconTable,
vimeoId: '1145648745',
},
{
id: 'dashboards',
title: t`Dashboards`,
Icon: IconLayoutDashboard,
vimeoId: '1185511768',
},
],
[t],
);
return (
<SubMenuTopBarContainer
title={t`Layout`}
actionButton={
<Button
title={t`Customize`}
variant="primary"
accent="blue"
size="small"
Icon={IconPencil}
onClick={handleCustomize}
/>
}
links={[
{
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
{ children: t`Layout` },
]}
>
<SettingsPageContainer>
<Section>
<SettingsDiscoveryHeroCard
lightSrc={customizeIllustrationLight}
darkSrc={customizeIllustrationDark}
instanceIdPrefix={SETTINGS_LAYOUT_HERO_INSTANCE_ID_PREFIX}
tabs={heroTabs}
playButtonAriaLabel={t`Watch customization demo`}
/>
</Section>
<Section>
<H2Title
title={t`Overview`}
description={t`All the layout items declared on your workspace`}
/>
<SettingsLayoutItemsStats />
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

@@ -3,16 +3,21 @@ import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { IconLock, IconUserPlus, IconUsers } from 'twenty-ui/display';
import { SettingsDiscoveryHeroCard } from '@/settings/components/SettingsDiscoveryHeroCard';
import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { Section } from 'twenty-ui/layout';
import { PermissionFlagType } from '~/generated-metadata/graphql';
import { SettingsWorkspaceMembersInviteTab } from '~/pages/settings/members/tabs/SettingsWorkspaceMembersInviteTab';
import { SettingsWorkspaceMembersRolesTab } from '~/pages/settings/members/tabs/SettingsWorkspaceMembersRolesTab';
import { SettingsWorkspaceMembersTeamTab } from '~/pages/settings/members/tabs/SettingsWorkspaceMembersTeamTab';
// TODO: replace with members-specific illustrations + recordings when designed.
import placeholderHeroDark from '~/pages/settings/layout/assets/customize-illustration-dark.png';
import placeholderHeroLight from '~/pages/settings/layout/assets/customize-illustration-light.png';
const MEMBERS_TAB_LIST_ID = 'members-tab-list';
@@ -20,6 +25,8 @@ const MEMBERS_TAB_TEAM_ID = 'team';
const MEMBERS_TAB_INVITE_ID = 'invite';
const MEMBERS_TAB_ROLES_ID = 'roles';
const SETTINGS_MEMBERS_HERO_INSTANCE_ID_PREFIX = 'settings-members-hero';
export const SettingsWorkspaceMembers = () => {
const { t } = useLingui();
@@ -65,6 +72,38 @@ export const SettingsWorkspaceMembers = () => {
]}
>
<SettingsPageContainer>
<Section>
<SettingsDiscoveryHeroCard
lightSrc={placeholderHeroLight}
darkSrc={placeholderHeroDark}
instanceIdPrefix={SETTINGS_MEMBERS_HERO_INSTANCE_ID_PREFIX}
tabs={[
{
id: 'team',
title: t`Team`,
Icon: IconUsers,
vimeoId: '1185227242',
},
{
id: 'invite',
title: t`Invite`,
Icon: IconUserPlus,
vimeoId: '1185227242',
},
...(hasRolesPermission
? [
{
id: 'roles',
title: t`Roles`,
Icon: IconLock,
vimeoId: '1185227242',
},
]
: []),
]}
playButtonAriaLabel={t`Watch members demo`}
/>
</Section>
<TabList tabs={tabs} componentInstanceId={MEMBERS_TAB_LIST_ID} />
{renderActiveTabContent()}
</SettingsPageContainer>
@@ -1,18 +1,37 @@
import { SettingsDiscoveryHeroCard } from '@/settings/components/SettingsDiscoveryHeroCard';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsApiKeysTable } from '@/settings/developers/components/SettingsApiKeysTable';
import { SettingsWebhooksTable } from '@/settings/developers/components/SettingsWebhooksTable';
import { PlaygroundSetupForm } from '@/settings/playground/components/PlaygroundSetupForm';
import { StyledSettingsApiPlaygroundCoverImage } from '@/settings/playground/components/SettingsPlaygroundCoverImage';
import { SettingsMcpSetup } from '@/settings/playground/components/SettingsMcpSetup';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import PlaygroundCoverDark from '@/settings/playground/assets/cover-dark.png';
import PlaygroundCoverLight from '@/settings/playground/assets/cover-light.png';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { styled } from '@linaria/react';
import { Trans, useLingui } from '@lingui/react/macro';
import { useLingui } from '@lingui/react/macro';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { H2Title, IconPlus } from 'twenty-ui/display';
import {
H2Title,
IconBrandGraphql,
IconCode,
IconPlus,
IconRobot,
IconWebhook,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { MOBILE_VIEWPORT, themeCssVariables } from 'twenty-ui/theme-constants';
import { SETTINGS_API_WEBHOOKS_TABS } from '~/pages/settings/workspace/constants/SettingsApiWebhooksTabs';
type TabKey =
(typeof SETTINGS_API_WEBHOOKS_TABS.TABS_IDS)[keyof typeof SETTINGS_API_WEBHOOKS_TABS.TABS_IDS];
const SETTINGS_API_HERO_INSTANCE_ID_PREFIX = 'settings-api-hero';
const StyledButtonContainer = styled.div`
display: flex;
@@ -23,18 +42,14 @@ const StyledButtonContainer = styled.div`
}
`;
const StyledMainContent = styled.div`
const StyledTabContent = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[10]};
min-height: 200px;
padding-top: ${themeCssVariables.spacing[6]};
`;
const StyledSectionContainer = styled.div`
flex-shrink: 0;
`;
const StyledContainer = styled.div<{ isMobile?: boolean }>`
const StyledTableContainer = styled.div<{ isMobile?: boolean }>`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
@@ -45,39 +60,87 @@ export const SettingsApiWebhooks = () => {
const isMobile = useIsMobile();
const { t } = useLingui();
const activeTabId = useAtomComponentStateValue(
activeTabIdComponentState,
SETTINGS_API_WEBHOOKS_TABS.COMPONENT_INSTANCE_ID,
);
const activeTab: TabKey =
(activeTabId as TabKey) ?? SETTINGS_API_WEBHOOKS_TABS.TABS_IDS.API;
const tabs = [
{
id: SETTINGS_API_WEBHOOKS_TABS.TABS_IDS.API,
title: t`API`,
Icon: IconCode,
},
{
id: SETTINGS_API_WEBHOOKS_TABS.TABS_IDS.MCP,
title: t`MCP`,
Icon: IconRobot,
},
{
id: SETTINGS_API_WEBHOOKS_TABS.TABS_IDS.WEBHOOKS,
title: t`Webhooks`,
Icon: IconWebhook,
},
];
return (
<SubMenuTopBarContainer
title={t`APIs & Webhooks`}
links={[
{
children: <Trans>Workspace</Trans>,
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
{ children: <Trans>APIs & Webhooks</Trans> },
{ children: t`APIs & Webhooks` },
]}
>
<SettingsPageContainer>
<StyledMainContent>
<StyledSectionContainer>
<Section>
<SettingsDiscoveryHeroCard
lightSrc={PlaygroundCoverLight}
darkSrc={PlaygroundCoverDark}
instanceIdPrefix={SETTINGS_API_HERO_INSTANCE_ID_PREFIX}
tabs={[
{
id: 'rest',
title: t`REST`,
Icon: IconCode,
vimeoId: '928786722',
},
{
id: 'graphql',
title: t`GraphQL`,
Icon: IconBrandGraphql,
vimeoId: '928786722',
},
]}
playButtonAriaLabel={t`Watch API demo`}
/>
</Section>
<TabList
tabs={tabs}
componentInstanceId={SETTINGS_API_WEBHOOKS_TABS.COMPONENT_INSTANCE_ID}
/>
{activeTab === SETTINGS_API_WEBHOOKS_TABS.TABS_IDS.API && (
<StyledTabContent>
<Section>
<H2Title
title={t`Documentation`}
description={t`Try our REST or GraphQL API playgrounds.`}
description={t`Try our REST or GraphQL API playgrounds`}
/>
<StyledContainer>
<StyledSettingsApiPlaygroundCoverImage />
<PlaygroundSetupForm />
</StyledContainer>
<PlaygroundSetupForm />
</Section>
</StyledSectionContainer>
<StyledSectionContainer>
<Section>
<H2Title
title={t`API Keys`}
description={t`Active API keys created by you or your team.`}
/>
<StyledContainer isMobile={isMobile}>
<StyledTableContainer isMobile={isMobile}>
<SettingsApiKeysTable />
<StyledButtonContainer>
<Button
@@ -88,17 +151,25 @@ export const SettingsApiWebhooks = () => {
to={getSettingsPath(SettingsPath.NewApiKey)}
/>
</StyledButtonContainer>
</StyledContainer>
</StyledTableContainer>
</Section>
</StyledSectionContainer>
</StyledTabContent>
)}
<StyledSectionContainer>
{activeTab === SETTINGS_API_WEBHOOKS_TABS.TABS_IDS.MCP && (
<StyledTabContent>
<SettingsMcpSetup />
</StyledTabContent>
)}
{activeTab === SETTINGS_API_WEBHOOKS_TABS.TABS_IDS.WEBHOOKS && (
<StyledTabContent>
<Section>
<H2Title
title={t`Webhooks`}
description={t`Establish Webhook endpoints for notifications on asynchronous events.`}
/>
<StyledContainer isMobile={isMobile}>
<StyledTableContainer isMobile={isMobile}>
<SettingsWebhooksTable />
<StyledButtonContainer>
<Button
@@ -109,10 +180,10 @@ export const SettingsApiWebhooks = () => {
to={getSettingsPath(SettingsPath.NewWebhook)}
/>
</StyledButtonContainer>
</StyledContainer>
</StyledTableContainer>
</Section>
</StyledSectionContainer>
</StyledMainContent>
</StyledTabContent>
)}
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
@@ -0,0 +1,8 @@
export const SETTINGS_API_WEBHOOKS_TABS = {
COMPONENT_INSTANCE_ID: 'settings-api-webhooks-tabs',
TABS_IDS: {
API: 'api',
MCP: 'mcp',
WEBHOOKS: 'webhooks',
},
} as const;
+2
View File
@@ -4,6 +4,7 @@ import { type FlatApiKey } from 'src/engine/core-modules/api-key/types/flat-api-
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { type RawAuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { type FlatAuthContextUser } from 'src/engine/core-modules/auth/types/flat-auth-context-user.type';
import { type JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { type FlatUserWorkspace } from 'src/engine/core-modules/user-workspace/types/flat-user-workspace.type';
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
import { type AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
@@ -24,5 +25,6 @@ declare module 'express-serve-static-core' {
userWorkspaceId?: string;
authProvider?: AuthProviderEnum | null;
impersonationContext?: RawAuthContext['impersonationContext'];
tokenType?: JwtTokenTypeEnum;
}
}
@@ -14,6 +14,7 @@ import { UpdateApiKeyInput } from 'src/engine/core-modules/api-key/dtos/update-a
import { apiKeyGraphqlApiExceptionHandler } from 'src/engine/core-modules/api-key/utils/api-key-graphql-api-exception-handler.util';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { RequireAccessTokenGuard } from 'src/engine/guards/require-access-token.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
@@ -58,6 +59,10 @@ export class ApiKeyResolver {
}
}
// A long-lived API key is durable credential management: only a first-person
// session (ACCESS) may mint or alter one, so a short-lived derived token
// (PLAYGROUND) or an API key itself cannot escalate into another.
@UseGuards(RequireAccessTokenGuard)
@Mutation(() => ApiKeyEntity)
async createApiKey(
@AuthWorkspace() workspace: WorkspaceEntity,
@@ -72,6 +77,7 @@ export class ApiKeyResolver {
});
}
@UseGuards(RequireAccessTokenGuard)
@Mutation(() => ApiKeyEntity, { nullable: true })
async updateApiKey(
@AuthWorkspace() workspace: WorkspaceEntity,
@@ -89,6 +95,7 @@ export class ApiKeyResolver {
return this.apiKeyService.update(input.id, workspace.id, updateData);
}
@UseGuards(RequireAccessTokenGuard)
@Mutation(() => ApiKeyEntity, { nullable: true })
async revokeApiKey(
@AuthWorkspace() workspace: WorkspaceEntity,
@@ -97,6 +104,7 @@ export class ApiKeyResolver {
return this.apiKeyService.revoke(input.id, workspace.id);
}
@UseGuards(RequireAccessTokenGuard)
@Mutation(() => Boolean)
async assignRoleToApiKey(
@AuthWorkspace() workspace: WorkspaceEntity,
@@ -21,6 +21,7 @@ import { ApiKeyService } from 'src/engine/core-modules/api-key/services/api-key.
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard';
import { RequireAccessTokenGuard } from 'src/engine/guards/require-access-token.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { PermissionsRestApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-rest-api-exception.filter';
@@ -54,6 +55,9 @@ export class ApiKeyController {
return this.apiKeyService.findById(id, workspace.id);
}
// Minting/altering a long-lived API key requires a first-person session
// (ACCESS); a derived PLAYGROUND token or an API key cannot escalate here.
@UseGuards(RequireAccessTokenGuard)
@Post()
async create(
@Body() createApiKeyDto: CreateApiKeyInput,
@@ -70,6 +74,7 @@ export class ApiKeyController {
});
}
@UseGuards(RequireAccessTokenGuard)
@Patch(':id')
async update(
@Param('id') id: string,
@@ -91,6 +96,7 @@ export class ApiKeyController {
return this.apiKeyService.update(id, workspace.id, updateData);
}
@UseGuards(RequireAccessTokenGuard)
@Delete(':id')
async remove(
@Param('id') id: string,
@@ -10,7 +10,7 @@ import {
} from 'src/engine/core-modules/api-key/exceptions/api-key.exception';
import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-key-role.service';
import { ApiKeyService } from 'src/engine/core-modules/api-key/services/api-key.service';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity';
import { RoleTargetService } from 'src/engine/metadata-modules/role-target/services/role-target.service';
@@ -10,7 +10,7 @@ import {
ApiKeyExceptionCode,
} from 'src/engine/core-modules/api-key/exceptions/api-key.exception';
import { type ApiKeyToken } from 'src/engine/core-modules/auth/dto/api-key-token.dto';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { RoleTargetService } from 'src/engine/metadata-modules/role-target/services/role-target.service';
import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator';
@@ -18,7 +18,7 @@ import { isDefined } from 'twenty-shared/utils';
import { type ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity';
import { ConnectionProviderOAuthFlowService } from 'src/engine/core-modules/application/connection-provider/connection-provider-oauth-flow.service';
import { ConnectionProviderService } from 'src/engine/core-modules/application/connection-provider/connection-provider.service';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@@ -19,10 +19,8 @@ import { buildAppOAuthCallbackUrl } from 'src/engine/core-modules/application/co
import { computePkceChallenge } from 'src/engine/core-modules/application/connection-provider/utils/compute-pkce-challenge.util';
import { exchangeCodeForToken } from 'src/engine/core-modules/application/connection-provider/utils/exchange-code-for-token.util';
import { generatePkceVerifier } from 'src/engine/core-modules/application/connection-provider/utils/generate-pkce-verifier.util';
import {
type AppOAuthStateJwtPayload,
JwtTokenTypeEnum,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { type AppOAuthStateJwtPayload } from 'src/engine/core-modules/auth/types/app-oauth-state-jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@@ -16,10 +16,8 @@ import {
ApprovedAccessDomainExceptionCode,
} from 'src/engine/core-modules/approved-access-domain/approved-access-domain.exception';
import { approvedAccessDomainValidator } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.validate';
import {
type ApprovedAccessDomainJwtPayload,
JwtTokenTypeEnum,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { type ApprovedAccessDomainJwtPayload } from 'src/engine/core-modules/auth/types/approved-access-domain-jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
@@ -10,7 +10,7 @@ import {
ApprovedAccessDomainException,
ApprovedAccessDomainExceptionCode,
} from 'src/engine/core-modules/approved-access-domain/approved-access-domain.exception';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
@@ -6,6 +6,7 @@ import { ApiKeyService } from 'src/engine/core-modules/api-key/services/api-key.
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service';
import { CaptchaGuard } from 'src/engine/core-modules/captcha/captcha.guard';
@@ -86,6 +87,10 @@ describe('AuthResolver', () => {
provide: ApiKeyService,
useValue: {},
},
{
provide: AccessTokenService,
useValue: {},
},
{
provide: ResetPasswordService,
useValue: {},
@@ -38,17 +38,16 @@ import { VerifyEmailAndGetLoginTokenDTO } from 'src/engine/core-modules/auth/dto
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { ResetPasswordService } from 'src/engine/core-modules/auth/services/reset-password.service';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { EmailVerificationTokenService } from 'src/engine/core-modules/auth/token/services/email-verification-token.service';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
import { RenewTokenService } from 'src/engine/core-modules/auth/token/services/renew-token.service';
import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service';
import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service';
import {
AuthContextUser,
JwtTokenTypeEnum,
LoginTokenJwtPayload,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { LoginTokenJwtPayload } from 'src/engine/core-modules/auth/types/login-token-jwt-payload.type';
import { CaptchaGuard } from 'src/engine/core-modules/captcha/captcha.guard';
import { CaptchaGraphqlApiExceptionFilter } from 'src/engine/core-modules/captcha/filters/captcha-graphql-api-exception.filter';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
@@ -73,6 +72,7 @@ import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
import { RequireAccessTokenGuard } from 'src/engine/guards/require-access-token.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
@@ -80,6 +80,7 @@ import { PermissionsService } from 'src/engine/metadata-modules/permissions/perm
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
import { ApiKeyToken } from './dto/api-key-token.dto';
import { AuthToken } from './dto/auth-token.dto';
import { AuthTokens } from './dto/auth-tokens.dto';
import { GetAuthTokensFromLoginTokenInput } from './dto/get-auth-tokens-from-login-token.input';
import { LoginTokenDTO } from './dto/login-token.dto';
@@ -112,6 +113,7 @@ export class AuthResolver {
private renewTokenService: RenewTokenService,
private userService: UserService,
private apiKeyService: ApiKeyService,
private accessTokenService: AccessTokenService,
private resetPasswordService: ResetPasswordService,
private loginTokenService: LoginTokenService,
private workspaceAgnosticTokenService: WorkspaceAgnosticTokenService,
@@ -805,6 +807,7 @@ export class AuthResolver {
@UseGuards(
WorkspaceAuthGuard,
RequireAccessTokenGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Mutation(() => ApiKeyToken)
@@ -819,6 +822,24 @@ export class AuthResolver {
);
}
@UseGuards(
WorkspaceAuthGuard,
RequireAccessTokenGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Mutation(() => AuthToken)
async generatePlaygroundToken(
@AuthUser() user: UserEntity,
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthProvider() authProvider: AuthProviderEnum,
): Promise<AuthToken> {
return await this.accessTokenService.generatePlaygroundToken({
userId: user.id,
workspaceId: workspace.id,
authProvider,
});
}
@Mutation(() => EmailPasswordResetLinkDTO)
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
async emailPasswordResetLink(
@@ -45,10 +45,8 @@ import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service';
import {
AuthContextUser,
JwtTokenTypeEnum,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import {
type AuthProviderWithPasswordType,
type ExistingUserOrNewUser,
@@ -6,10 +6,8 @@ import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import {
type JwtPayload,
JwtTokenTypeEnum,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { type JwtPayload } from 'src/engine/core-modules/auth/types/jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { JwtAuthStrategy } from './jwt.auth.strategy';
@@ -944,4 +942,58 @@ describe('JwtAuthStrategy', () => {
);
});
});
describe('PLAYGROUND token validation', () => {
// A PLAYGROUND token is access-shaped but must never impersonate. This is the
// same payload that, as an ACCESS token with isImpersonating set but without
// impersonation ids, throws 'Invalid or missing user workspace ID in
// impersonation token' above; as a PLAYGROUND token it must skip the
// impersonation path entirely and resolve to the first-person context.
it('ignores isImpersonating and resolves first-person', async () => {
const validUserId = 'valid-user-id';
const validUserWorkspaceId = randomUUID();
const validWorkspaceId = randomUUID();
const payload = {
sub: validUserId,
type: JwtTokenTypeEnum.PLAYGROUND,
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
isImpersonating: true,
};
workspaceStore[validWorkspaceId] = new WorkspaceEntity();
userStore[validUserId] = { id: validUserId, lastName: 'lastNameDefault' };
coreEntityCacheService.get.mockImplementation(
async (keyName: string, entityId: string) => {
if (keyName === 'workspaceEntity') {
return workspaceStore[entityId] ?? null;
}
if (keyName === 'user') {
return userStore[entityId] ?? null;
}
if (keyName === 'userWorkspaceEntity') {
return {
id: validUserWorkspaceId,
user: { id: validUserId, lastName: 'lastNameDefault' },
workspace: { id: validWorkspaceId },
};
}
return null;
},
);
strategy = createStrategy();
const result = await strategy.validate(payload as JwtPayload);
expect(result.impersonationContext).toBeUndefined();
expect(result.tokenType).toBe(JwtTokenTypeEnum.PLAYGROUND);
expect(result.userWorkspaceId).toBe(validUserWorkspaceId);
});
});
});
@@ -13,15 +13,16 @@ import {
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import {
type AccessTokenJwtPayload,
type ApiKeyTokenJwtPayload,
ApplicationAccessTokenJwtPayload,
type AuthContext,
type AuthContextUser,
type JwtPayload,
JwtTokenTypeEnum,
type WorkspaceAgnosticTokenJwtPayload,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { type AccessTokenJwtPayload } from 'src/engine/core-modules/auth/types/access-token-jwt-payload.type';
import { type ApiKeyTokenJwtPayload } from 'src/engine/core-modules/auth/types/api-key-token-jwt-payload.type';
import { ApplicationAccessTokenJwtPayload } from 'src/engine/core-modules/auth/types/application-access-token-jwt-payload.type';
import { type JwtPayload } from 'src/engine/core-modules/auth/types/jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { type PlaygroundTokenJwtPayload } from 'src/engine/core-modules/auth/types/playground-token-jwt-payload.type';
import { type WorkspaceAgnosticTokenJwtPayload } from 'src/engine/core-modules/auth/types/workspace-agnostic-token-jwt-payload.type';
import { type FlatUserWorkspace } from 'src/engine/core-modules/user-workspace/types/flat-user-workspace.type';
import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service';
import { JWT_SUPPORTED_VERIFY_ALGORITHMS } from 'src/engine/core-modules/jwt/constants/jwt-algorithm.constant';
@@ -100,7 +101,7 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
}
private async validateAccessToken(
payload: AccessTokenJwtPayload,
payload: AccessTokenJwtPayload | PlaygroundTokenJwtPayload,
): Promise<AuthContext> {
let user: AuthContextUser | null = null;
let context: AuthContext = {};
@@ -117,7 +118,11 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
);
}
if (payload.isImpersonating === true) {
// Only ACCESS tokens can carry impersonation; PLAYGROUND is always first-person.
if (
payload.type === JwtTokenTypeEnum.ACCESS &&
payload.isImpersonating === true
) {
context.impersonationContext = await this.validateImpersonation(payload);
}
@@ -425,6 +430,17 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
}
async validate(payload: JwtPayload): Promise<AuthContext> {
const context = await this.dispatch(payload);
return {
...context,
tokenType: this.isLegacyApiKeyPayload(payload)
? JwtTokenTypeEnum.API_KEY
: payload.type,
};
}
private async dispatch(payload: JwtPayload): Promise<AuthContext> {
// Support legacy api keys
if (
payload.type === JwtTokenTypeEnum.API_KEY ||
@@ -437,7 +453,10 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
return await this.validateWorkspaceAgnosticToken(payload);
}
if (payload.type === JwtTokenTypeEnum.ACCESS) {
if (
payload.type === JwtTokenTypeEnum.ACCESS ||
payload.type === JwtTokenTypeEnum.PLAYGROUND
) {
return await this.validateAccessToken(payload);
}
@@ -224,6 +224,12 @@ describe('AccessTokenService', () => {
it('should throw an error if user is not found', async () => {
jest.spyOn(twentyConfigService, 'get').mockReturnValue('1h');
jest.spyOn(userRepository, 'findOne').mockResolvedValue(null);
jest
.spyOn(workspaceRepository, 'findOne')
.mockResolvedValue({} as WorkspaceEntity);
jest
.spyOn(userWorkspaceRepository, 'findOne')
.mockResolvedValue({} as UserWorkspaceEntity);
await expect(
service.generateAccessToken({
@@ -15,11 +15,10 @@ import {
} from 'src/engine/core-modules/auth/auth.exception';
import { type AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto';
import { JwtAuthStrategy } from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy';
import {
type AccessTokenJwtPayload,
type AuthContext,
JwtTokenTypeEnum,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { type AccessTokenJwtPayload } from 'src/engine/core-modules/auth/types/access-token-jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { type PlaygroundTokenJwtPayload } from 'src/engine/core-modules/auth/types/playground-token-jwt-payload.type';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
@@ -47,42 +46,36 @@ export class AccessTokenService {
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
) {}
async generateAccessToken({
userId,
workspaceId,
authProvider,
isImpersonating,
impersonatorUserWorkspaceId,
impersonatedUserWorkspaceId,
}: Omit<
AccessTokenJwtPayload,
'type' | 'workspaceMemberId' | 'userWorkspaceId' | 'sub'
>): Promise<AuthToken> {
const expiresIn = this.twentyConfigService.get('ACCESS_TOKEN_EXPIRES_IN');
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
const user = await this.userRepository.findOne({
where: { id: userId },
});
private async resolveTokenSubject(
userId: string,
workspaceId: string,
): Promise<{
user: UserEntity;
workspace: WorkspaceEntity;
userWorkspace: UserWorkspaceEntity;
workspaceMemberId: string | undefined;
}> {
const [user, workspace, userWorkspace] = await Promise.all([
this.userRepository.findOne({ where: { id: userId } }),
this.workspaceRepository.findOne({ where: { id: workspaceId } }),
this.userWorkspaceRepository.findOne({
where: { userId, workspaceId },
}),
]);
userValidator.assertIsDefinedOrThrow(
user,
new AuthException('User is not found', AuthExceptionCode.INVALID_INPUT),
);
let tokenWorkspaceMemberId: string | undefined;
const workspace = await this.workspaceRepository.findOne({
where: { id: workspaceId },
});
assertIsDefinedOrThrow(workspace, WorkspaceNotFoundDefaultError);
assertIsDefinedOrThrow(userWorkspace, UserWorkspaceNotFoundDefaultError);
let workspaceMemberId: string | undefined;
if (isWorkspaceActiveOrSuspended(workspace)) {
const authContext = buildSystemAuthContext(workspaceId);
tokenWorkspaceMemberId =
workspaceMemberId =
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const workspaceMemberRepository =
@@ -93,9 +86,7 @@ export class AccessTokenService {
);
const workspaceMember = await workspaceMemberRepository.findOne({
where: {
userId: user.id,
},
where: { userId: user.id },
});
assertIsDefinedOrThrow(
@@ -114,31 +105,73 @@ export class AccessTokenService {
authContext,
);
}
const userWorkspace = await this.userWorkspaceRepository.findOne({
where: {
userId: user.id,
workspaceId,
},
});
assertIsDefinedOrThrow(userWorkspace, UserWorkspaceNotFoundDefaultError);
return { user, workspace, userWorkspace, workspaceMemberId };
}
const payloadImpersonatorUserWorkspaceId =
isImpersonating === true ? impersonatorUserWorkspaceId : undefined;
const payloadOriginalUserWorkspaceId =
isImpersonating === true ? impersonatedUserWorkspaceId : undefined;
async generateAccessToken({
userId,
workspaceId,
authProvider,
isImpersonating,
impersonatorUserWorkspaceId,
impersonatedUserWorkspaceId,
}: Omit<
AccessTokenJwtPayload,
'type' | 'workspaceMemberId' | 'userWorkspaceId' | 'sub'
>): Promise<AuthToken> {
const expiresIn = this.twentyConfigService.get('ACCESS_TOKEN_EXPIRES_IN');
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
const { user, userWorkspace, workspaceMemberId } =
await this.resolveTokenSubject(userId, workspaceId);
const jwtPayload: AccessTokenJwtPayload = {
sub: user.id,
userId: user.id,
workspaceId,
workspaceMemberId: tokenWorkspaceMemberId,
workspaceMemberId,
userWorkspaceId: userWorkspace.id,
type: JwtTokenTypeEnum.ACCESS,
authProvider,
isImpersonating: isImpersonating === true,
impersonatorUserWorkspaceId: payloadImpersonatorUserWorkspaceId,
impersonatedUserWorkspaceId: payloadOriginalUserWorkspaceId,
impersonatorUserWorkspaceId:
isImpersonating === true ? impersonatorUserWorkspaceId : undefined,
impersonatedUserWorkspaceId:
isImpersonating === true ? impersonatedUserWorkspaceId : undefined,
};
const token = await this.jwtWrapperService.signAsyncOrThrow(jwtPayload, {
expiresIn,
});
return { token, expiresAt };
}
async generatePlaygroundToken({
userId,
workspaceId,
authProvider,
}: Pick<
PlaygroundTokenJwtPayload,
'userId' | 'workspaceId' | 'authProvider'
>): Promise<AuthToken> {
const expiresIn = this.twentyConfigService.get(
'PLAYGROUND_TOKEN_EXPIRES_IN',
);
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
const { user, userWorkspace, workspaceMemberId } =
await this.resolveTokenSubject(userId, workspaceId);
const jwtPayload: PlaygroundTokenJwtPayload = {
sub: user.id,
userId: user.id,
workspaceId,
workspaceMemberId,
userWorkspaceId: userWorkspace.id,
type: JwtTokenTypeEnum.PLAYGROUND,
authProvider,
};
const token = await this.jwtWrapperService.signAsyncOrThrow(jwtPayload, {
@@ -10,7 +10,7 @@ import {
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -8,11 +8,9 @@ import ms from 'ms';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import {
type ApplicationAccessTokenJwtPayload,
type ApplicationRefreshTokenJwtPayload,
JwtTokenTypeEnum,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { type ApplicationAccessTokenJwtPayload } from 'src/engine/core-modules/auth/types/application-access-token-jwt-payload.type';
import { type ApplicationRefreshTokenJwtPayload } from 'src/engine/core-modules/auth/types/application-refresh-token-jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { type AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto';
import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
@@ -3,7 +3,7 @@ import { Test, type TestingModule } from '@nestjs/testing';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { LoginTokenService } from './login-token.service';
@@ -8,10 +8,8 @@ import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import {
type LoginTokenJwtPayload,
JwtTokenTypeEnum,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { type LoginTokenJwtPayload } from 'src/engine/core-modules/auth/types/login-token-jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { type AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
@@ -8,7 +8,7 @@ import {
AppTokenType,
} from 'src/engine/core-modules/app-token/app-token.entity';
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
@@ -14,10 +14,8 @@ import {
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { type AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto';
import {
type RefreshTokenJwtPayload,
JwtTokenTypeEnum,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { type RefreshTokenJwtPayload } from 'src/engine/core-modules/auth/types/refresh-token-jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
@@ -8,7 +8,7 @@ import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { type UserEntity } from 'src/engine/core-modules/user/user.entity';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
@@ -13,7 +13,7 @@ import { type AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto'
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
@Injectable()
@@ -2,7 +2,7 @@ import { Test, type TestingModule } from '@nestjs/testing';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { TransientTokenService } from './transient-token.service';
@@ -10,10 +10,8 @@ import {
} from 'src/engine/core-modules/auth/auth.exception';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import {
type TransientTokenJwtPayload,
JwtTokenTypeEnum,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { type TransientTokenJwtPayload } from 'src/engine/core-modules/auth/types/transient-token-jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
@Injectable()
export class TransientTokenService {
@@ -9,7 +9,7 @@ import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twent
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
describe('WorkspaceAgnosticToken', () => {
let service: WorkspaceAgnosticTokenService;
@@ -11,11 +11,9 @@ import {
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { type AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto';
import {
type AuthContext,
JwtTokenTypeEnum,
type WorkspaceAgnosticTokenJwtPayload,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { type WorkspaceAgnosticTokenJwtPayload } from 'src/engine/core-modules/auth/types/workspace-agnostic-token-jwt-payload.type';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
@@ -0,0 +1,15 @@
import { type CommonPropertiesJwtPayload } from 'src/engine/core-modules/auth/types/common-properties-jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { type AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
export type AccessTokenJwtPayload = CommonPropertiesJwtPayload & {
type: JwtTokenTypeEnum.ACCESS;
workspaceId: string;
userId: string;
workspaceMemberId?: string;
userWorkspaceId: string;
authProvider: AuthProviderEnum;
isImpersonating?: boolean;
impersonatorUserWorkspaceId?: string;
impersonatedUserWorkspaceId?: string;
};
@@ -0,0 +1,9 @@
import { type CommonPropertiesJwtPayload } from 'src/engine/core-modules/auth/types/common-properties-jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
export type ApiKeyTokenJwtPayload = CommonPropertiesJwtPayload & {
type: JwtTokenTypeEnum.API_KEY;
workspaceId: string;
workspaceMemberId?: string;
jti?: string;
};
@@ -0,0 +1,19 @@
import { type CommonPropertiesJwtPayload } from 'src/engine/core-modules/auth/types/common-properties-jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
export type AppOAuthStateJwtPayload = CommonPropertiesJwtPayload & {
type: JwtTokenTypeEnum.APP_OAUTH_STATE;
workspaceId: string;
connectionProviderId: string;
userId: string;
userWorkspaceId: string;
// 'user' = the resulting credential is private to userWorkspaceId.
// 'workspace' = visible to anyone in the workspace.
// Named `visibility` to disambiguate from OAuth `scopes` on the row.
visibility: 'user' | 'workspace';
// If set, the callback updates this existing connectedAccount row instead
// of creating a new one (used by the UI's "Reconnect" action).
reconnectingConnectedAccountId: string | null;
redirectLocation: string | null;
codeVerifier: string | null;
};
@@ -0,0 +1,10 @@
import { type CommonPropertiesJwtPayload } from 'src/engine/core-modules/auth/types/common-properties-jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
export type ApplicationAccessTokenJwtPayload = CommonPropertiesJwtPayload & {
type: JwtTokenTypeEnum.APPLICATION_ACCESS;
workspaceId: string;
applicationId: string;
userWorkspaceId?: string;
userId?: string;
};

Some files were not shown because too many files have changed in this diff Show More