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:
Binary file not shown.
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 74 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 78 KiB |
+5
-4
@@ -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';
|
||||
|
||||
|
||||
+4
-1
@@ -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,
|
||||
|
||||
-71
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user