feat(settings): discovery hero rollout + ephemeral playground token (#21072)
## Summary
Two intertwined streams of work:
### UI — discovery hero pattern, settings shell, AI/API redesign
- **Generalize `SettingsDiscoveryHeroCard`** and use it on Layout, Data
Model, Apps, AI, API/Webhooks, Members. Drops 4 per-page wrapper files
(`SettingsObjectCoverImage`, `SettingsLayoutCoverImage`,
`SettingsLayoutCustomizeVideoModal`,
`SettingsDataModelVisualizeVideoModal`). Each page now supplies cover
src, modal id, and tab list.
- **Modal**: swap `<video>` placeholder for the Vimeo iframe pattern
from `twenty-docs`, per-tab `vimeoId`. Drop the parallel border-bottom
on the header (TabList draws its own baseline) and the grey background
behind the video. Note: Vimeo's embed allowlist applies — the iframes
load with the correct URL on `localhost` but the player itself requires
the video owner to allow the dev/staging domains in Vimeo settings.
- **AI page** rebuilt into a Cockpit pattern (Overview / Models / Skills
/ Tools / Usage). New `SettingsAiOverviewTab` with default Smart/Fast
pickers, at-a-glance stats, and an MCP signpost that deep-links to
`/settings/api-webhooks#mcp`. System Prompt link moved under Models.
Advanced tab removed.
- **API & Webhooks** now has 4 tabs (Playground / MCP / API Keys /
Webhooks). Hero card above tabs. Playground tab inverted to "Core API" /
"Metadata API" sections, each containing REST + GraphQL cards — schema
is the meaningful axis, protocol is secondary. Hash deep-link sync
delegated to the shared `TabListFromUrlOptionalEffect`.
- **Settings shell**: unified drawer outer padding (kill `isSettings`
branch), extract `CollapsibleNavigationDrawerSection`, add `iconColor`
on settings nav items, fix Exit Settings button alignment, 880px content
cap.
### Backend — strategy C: ephemeral playground token
The legacy paste-your-API-key flow is replaced by an on-demand
short-lived token scoped to the calling user's permissions. No shared
"Playground" API key to manage or revoke.
- New `JwtTokenTypeEnum.PLAYGROUND`. `PlaygroundTokenJwtPayload =
Omit<AccessTokenJwtPayload, 'type' | impersonation fields>` so any
future ACCESS claim flows through automatically.
- `AccessTokenService.generatePlaygroundToken` signs an access-shaped
JWT with `type: PLAYGROUND` and a configurable short TTL. A shared
private `resolveTokenSubject` helper parallelizes the user / workspace /
userWorkspace lookups for both generators.
- `JwtAuthStrategy.validateAccessToken` widened to accept
`AccessTokenJwtPayload | PlaygroundTokenJwtPayload`; impersonation gated
on `payload.type === ACCESS` so the union narrows without `as unknown
as` casts. The two branches in `validate()` collapse into one.
- New `PLAYGROUND_TOKEN_EXPIRES_IN` config var (default `2h`).
- New `generatePlaygroundToken` mutation (`WorkspaceAuthGuard`, no args,
returns `AuthToken`).
- Frontend `useOpenPlayground` hook centralizes mint → atom write →
navigate, with Apollo `onError` snackbar and a "use cached PLAYGROUND
token if still fresh" short-circuit (decodes via `jwt-decode`, checks
both `type` AND `exp`). Old API_KEY tokens left in localStorage from the
prior paste-form flow are rejected on `type` alone and force a re-mint —
this is what was causing the "This API Key is revoked" symptom on stale
browsers.
### Drive-by cleanups
- `PlaygroundToken` DTO removed (identical shape to `AuthToken` already
in use).
- 5 `customize-sidebar.webm` imports and the dead placeholder pipeline
removed.
## Test plan
### Discovery hero
- [ ] `/settings/layout`, `/settings/data-model`,
`/settings/applications`, `/settings/ai`, `/settings/api-webhooks`,
`/settings/members` each render the discovery hero card with its
illustration + play button + tabbed modal
- [ ] Modal tabs show the correct Vimeo embed URL per tab; aspect ratio
stays at 1440/900; no parallel border-bottom jog at the tab baseline
- [ ] AI Overview tab shows Smart/Fast model pickers + stats grid + MCP
signpost card; the MCP card lands on `/settings/api-webhooks#mcp` with
the MCP tab active
### API playground (ephemeral token)
- [ ] With an empty `playgroundApiKeyState` in localStorage, clicking
REST or GraphQL playground card opens the playground and the cached
token has `type: "PLAYGROUND"` with ~2h exp
- [ ] Clicking the card again within the freshness window does **not**
re-mint (`iat` / fingerprint stable across visits)
- [ ] Planting a fake API_KEY-shaped JWT in localStorage and clicking
the card forces a fresh mint (old token rejected on `type`)
- [ ] `GET /rest/companies?limit=1` with the cached token returns 200 +
real data
- [ ] `POST /graphql { __typename }` returns 200
### Settings shell
- [ ] Settings nav matches main app drawer padding; sections collapse;
Exit Settings button aligns with the workspace links above
- [ ] Active nav items have a right-gap (cleaner active state)
- [ ] Content area capped at 880px
### Verify
- [ ] `npx nx typecheck twenty-front` passes
- [ ] `npx nx typecheck twenty-server` passes
- [ ] `npx nx lint:diff-with-main twenty-front` passes
- [ ] `npx nx lint:diff-with-main twenty-server` passes
This commit is contained in:
@@ -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,
|
||||
|
||||
+6
@@ -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,
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user