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
@@ -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"')
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 74 KiB |
|
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(),
|
||||
},
|
||||
],
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 288 KiB |
|
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>
|
||||
);
|
||||
};
|
||||
|
After Width: | Height: | Size: 206 KiB |
|
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;
|
||||
@@ -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;
|
||||
};
|
||||