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

## Summary

Two intertwined streams of work:

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

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

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

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

## Test plan

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

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

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

### Verify
- [ ] `npx nx typecheck twenty-front` passes
- [ ] `npx nx typecheck twenty-server` passes
- [ ] `npx nx lint:diff-with-main twenty-front` passes
- [ ] `npx nx lint:diff-with-main twenty-server` passes
This commit is contained in:
Félix Malfait
2026-06-01 14:16:02 +02:00
committed by GitHub
parent 6e00a122c6
commit b338a7a1d2
148 changed files with 3092 additions and 1694 deletions
@@ -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}