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
@@ -19,11 +19,16 @@ const StyledCardsContainer = styled.div`
gap: ${themeCssVariables.spacing[4]};
margin-top: ${themeCssVariables.spacing[6]};
@media (max-width: ${MOBILE_VIEWPORT}pxF) {
@media (max-width: ${MOBILE_VIEWPORT}px) {
flex-direction: column;
}
`;
const StyledCardLinkSlot = styled.div`
flex: 1 1 0;
min-width: 0;
`;
export const SettingsAccountsSettingsSection = () => {
const { theme } = useContext(ThemeContext);
const { t } = useLingui();
@@ -34,30 +39,34 @@ export const SettingsAccountsSettingsSection = () => {
description={t`Configure your emails and calendar settings.`}
/>
<StyledCardsContainer>
<UndecoratedLink to={getSettingsPath(SettingsPath.AccountsEmails)}>
<SettingsCard
Icon={
<IconMailCog
size={theme.icon.size.lg}
stroke={theme.icon.stroke.sm}
/>
}
title={t`Emails`}
description={t`Set email visibility, manage your blocklist and more.`}
/>
</UndecoratedLink>
<UndecoratedLink to={getSettingsPath(SettingsPath.AccountsCalendars)}>
<SettingsCard
Icon={
<IconCalendarEvent
size={theme.icon.size.lg}
stroke={theme.icon.stroke.sm}
/>
}
title={t`Calendar`}
description={t`Configure and customize your calendar preferences.`}
/>
</UndecoratedLink>
<StyledCardLinkSlot>
<UndecoratedLink to={getSettingsPath(SettingsPath.AccountsEmails)}>
<SettingsCard
Icon={
<IconMailCog
size={theme.icon.size.lg}
stroke={theme.icon.stroke.sm}
/>
}
title={t`Emails`}
description={t`Set email visibility, manage your blocklist and more.`}
/>
</UndecoratedLink>
</StyledCardLinkSlot>
<StyledCardLinkSlot>
<UndecoratedLink to={getSettingsPath(SettingsPath.AccountsCalendars)}>
<SettingsCard
Icon={
<IconCalendarEvent
size={theme.icon.size.lg}
stroke={theme.icon.stroke.sm}
/>
}
title={t`Calendar`}
description={t`Configure and customize your calendar preferences.`}
/>
</UndecoratedLink>
</StyledCardLinkSlot>
</StyledCardsContainer>
</Section>
);
@@ -0,0 +1,103 @@
import { ModalStatefulWrapper } from '@/ui/layout/modal/components/ModalStatefulWrapper';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { styled } from '@linaria/react';
import { useState } from 'react';
import { type IconComponent, IconX } from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
export type SettingsCustomizeVideoModalTab = {
id: string;
title: string;
Icon: IconComponent;
vimeoId: string;
};
type SettingsCustomizeVideoModalProps = {
modalInstanceId: string;
tabsInstanceId: string;
tabs: SettingsCustomizeVideoModalTab[];
};
const StyledHeader = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[2]};
height: 48px;
justify-content: space-between;
padding-right: ${themeCssVariables.spacing[3]};
`;
const StyledTabsContainer = styled.div`
flex: 1 1 auto;
min-width: 0;
padding-left: ${themeCssVariables.spacing[3]};
`;
const StyledVideoContainer = styled.div`
display: flex;
justify-content: center;
padding: ${themeCssVariables.spacing[6]};
`;
const StyledVideoIframe = styled.iframe`
aspect-ratio: 1440 / 900;
border: 0;
border-radius: ${themeCssVariables.border.radius.md};
box-shadow: ${themeCssVariables.boxShadow.strong};
display: block;
height: auto;
max-width: 100%;
width: 960px;
`;
export const SettingsCustomizeVideoModal = ({
modalInstanceId,
tabsInstanceId,
tabs,
}: SettingsCustomizeVideoModalProps) => {
const { closeModal } = useModal();
const [activeTabId, setActiveTabId] = useState<string>(tabs[0]?.id ?? '');
if (tabs.length === 0) {
return null;
}
const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? tabs[0];
const handleClose = () => {
closeModal(modalInstanceId);
};
return (
<ModalStatefulWrapper
modalInstanceId={modalInstanceId}
size="large"
padding="none"
isClosable
onClose={handleClose}
renderInDocumentBody
>
<StyledHeader>
<StyledTabsContainer>
<TabList
tabs={tabs}
behaveAsLinks={false}
componentInstanceId={tabsInstanceId}
onChangeTab={(tabId) => setActiveTabId(tabId)}
/>
</StyledTabsContainer>
<IconButton Icon={IconX} onClick={handleClose} size="small" />
</StyledHeader>
<StyledVideoContainer>
<StyledVideoIframe
key={activeTab.id}
src={`https://player.vimeo.com/video/${activeTab.vimeoId}?autoplay=1&loop=1&autopause=0&background=1&muted=1`}
allow="autoplay; fullscreen; picture-in-picture"
title={activeTab.title}
/>
</StyledVideoContainer>
</ModalStatefulWrapper>
);
};
@@ -0,0 +1,85 @@
import {
SettingsCustomizeVideoModal,
type SettingsCustomizeVideoModalTab,
} from '@/settings/components/SettingsCustomizeVideoModal';
import { HeroPlayButton } from '@/ui/layout/hero/components/HeroPlayButton';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { useContext } from 'react';
import { Card } from 'twenty-ui/layout';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
const COVER_HEIGHT = 150;
const StyledCoverContainer = styled.div`
background: ${themeCssVariables.background.secondary};
box-sizing: border-box;
height: ${COVER_HEIGHT}px;
overflow: hidden;
position: relative;
`;
const StyledImage = styled.img`
display: block;
height: 100%;
inset: 0;
object-fit: cover;
object-position: center top;
position: absolute;
width: 100%;
`;
const StyledOverlay = styled.div`
align-items: center;
display: flex;
inset: 0;
justify-content: center;
position: absolute;
`;
type SettingsDiscoveryHeroCardProps = {
lightSrc: string;
darkSrc: string;
instanceIdPrefix: string;
tabs: SettingsCustomizeVideoModalTab[];
playButtonAriaLabel?: string;
};
export const SettingsDiscoveryHeroCard = ({
lightSrc,
darkSrc,
instanceIdPrefix,
tabs,
playButtonAriaLabel,
}: SettingsDiscoveryHeroCardProps) => {
const { t } = useLingui();
const { colorScheme } = useContext(ThemeContext);
const { openModal } = useModal();
const modalInstanceId = `${instanceIdPrefix}-modal`;
const tabsInstanceId = `${instanceIdPrefix}-tabs`;
const src = colorScheme === 'light' ? lightSrc : darkSrc;
return (
<>
<Card rounded>
<StyledCoverContainer>
<StyledImage src={src} alt="" aria-hidden />
<StyledOverlay>
<HeroPlayButton
onClick={() => openModal(modalInstanceId)}
ariaLabel={playButtonAriaLabel ?? t`Watch demo`}
/>
</StyledOverlay>
</StyledCoverContainer>
</Card>
<SettingsCustomizeVideoModal
modalInstanceId={modalInstanceId}
tabsInstanceId={tabsInstanceId}
tabs={tabs}
/>
</>
);
};
@@ -39,6 +39,7 @@ export const SettingsNavigationDrawerItem = ({
label={item.label}
to={href}
Icon={item.Icon}
withIconBackground
active={isActive}
modifier={item.modifier}
onClick={item.onClick}
@@ -54,6 +55,7 @@ export const SettingsNavigationDrawerItem = ({
label={item.label}
to={href || undefined}
Icon={item.Icon}
withIconBackground
active={isActive}
modifier={item.modifier}
onClick={item.onClick}
@@ -5,13 +5,81 @@ import {
type SettingsNavigationSection,
useSettingsNavigationItems,
} from '@/settings/hooks/useSettingsNavigationItems';
import { CollapsibleNavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/CollapsibleNavigationDrawerSection';
import { NavigationDrawerItemGroup } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItemGroup';
import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection';
import { NavigationDrawerSectionTitle } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle';
import { getNavigationSubItemLeftAdornment } from '@/ui/navigation/navigation-drawer/utils/getNavigationSubItemLeftAdornment';
import { styled } from '@linaria/react';
import { matchPath, resolvePath, useLocation } from 'react-router-dom';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { getSettingsPath } from 'twenty-shared/utils';
const StyledSectionsContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[3]};
`;
const renderSectionItem = (
item: SettingsNavigationItem,
index: number,
section: SettingsNavigationSection,
getSelectedIndexForSubItems: (subItems: SettingsNavigationItem[]) => number,
) => {
const subItems = item.subItems;
if (Array.isArray(subItems) && subItems.length > 0) {
const selectedSubItemIndex = getSelectedIndexForSubItems(subItems);
const hasActiveSubItem = selectedSubItemIndex !== -1;
return (
<NavigationDrawerItemGroup key={item.path || `group-${index}`}>
<SettingsNavigationDrawerItem
item={item}
hasActiveSubItem={hasActiveSubItem}
subItemState={
item.indentationLevel
? getNavigationSubItemLeftAdornment({
arrayLength: section.items.length,
index,
selectedIndex: selectedSubItemIndex,
})
: undefined
}
/>
{subItems.map((subItem, subIndex) => (
<SettingsNavigationDrawerItem
key={subItem.path || `subitem-${subIndex}`}
item={subItem}
subItemState={
subItem.indentationLevel
? getNavigationSubItemLeftAdornment({
arrayLength: subItems.length,
index: subIndex,
selectedIndex: selectedSubItemIndex,
})
: undefined
}
/>
))}
</NavigationDrawerItemGroup>
);
}
return (
<SettingsNavigationDrawerItem
key={item.path || `item-${index}`}
item={item}
subItemState={
item.indentationLevel
? getNavigationSubItemLeftAdornment({
arrayLength: section.items.length,
index,
selectedIndex: index,
})
: undefined
}
/>
);
};
export const SettingsNavigationDrawerItems = () => {
const settingsNavigationItems: SettingsNavigationSection[] =
useSettingsNavigationItems();
@@ -34,7 +102,7 @@ export const SettingsNavigationDrawerItems = () => {
};
return (
<>
<StyledSectionsContainer>
{settingsNavigationItems.map((section) => {
const allItemsHidden = section.items.every((item) => item.isHidden);
if (allItemsHidden) {
@@ -42,75 +110,31 @@ export const SettingsNavigationDrawerItems = () => {
}
return (
<NavigationDrawerSection key={section.label}>
{section.isAdvanced ? (
<AdvancedSettingsWrapper hideDot>
<NavigationDrawerSectionTitle label={section.label} />
</AdvancedSettingsWrapper>
) : (
<NavigationDrawerSectionTitle label={section.label} />
<CollapsibleNavigationDrawerSection
key={section.label}
sectionId={`settings/${section.label}`}
label={section.label}
wrapTitle={
section.isAdvanced
? (titleNode) => (
<AdvancedSettingsWrapper hideDot>
{titleNode}
</AdvancedSettingsWrapper>
)
: undefined
}
>
{section.items.map((item, index) =>
renderSectionItem(
item,
index,
section,
getSelectedIndexForSubItems,
),
)}
{section.items.map((item, index) => {
const subItems = item.subItems;
if (Array.isArray(subItems) && subItems.length > 0) {
const selectedSubItemIndex =
getSelectedIndexForSubItems(subItems);
const hasActiveSubItem = selectedSubItemIndex !== -1;
return (
<NavigationDrawerItemGroup
key={item.path || `group-${index}`}
>
<SettingsNavigationDrawerItem
item={item}
hasActiveSubItem={hasActiveSubItem}
subItemState={
item.indentationLevel
? getNavigationSubItemLeftAdornment({
arrayLength: section.items.length,
index,
selectedIndex: selectedSubItemIndex,
})
: undefined
}
/>
{subItems.map((subItem, subIndex) => (
<SettingsNavigationDrawerItem
key={subItem.path || `subitem-${subIndex}`}
item={subItem}
subItemState={
subItem.indentationLevel
? getNavigationSubItemLeftAdornment({
arrayLength: subItems.length,
index: subIndex,
selectedIndex: selectedSubItemIndex,
})
: undefined
}
/>
))}
</NavigationDrawerItemGroup>
);
}
return (
<SettingsNavigationDrawerItem
key={item.path || `item-${index}`}
item={item}
subItemState={
item.indentationLevel
? getNavigationSubItemLeftAdornment({
arrayLength: section.items.length,
index,
selectedIndex: index,
})
: undefined
}
/>
);
})}
</NavigationDrawerSection>
</CollapsibleNavigationDrawerSection>
);
})}
</>
</StyledSectionsContainer>
);
};
@@ -1,4 +1,3 @@
import { OBJECT_SETTINGS_WIDTH } from '@/settings/data-model/constants/ObjectSettings';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { useScrollRestoration } from '@/ui/utilities/scroll/hooks/useScrollRestoration';
@@ -13,6 +12,7 @@ const StyledSettingsPageContainer = styled.div<{
width?: number;
isMobile?: boolean;
}>`
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[8]};
@@ -27,7 +27,7 @@ const StyledSettingsPageContainer = styled.div<{
if (isMobile) {
return 'unset';
}
return OBJECT_SETTINGS_WIDTH + 'px';
return '100%';
}};
`;
@@ -0,0 +1,96 @@
import { styled } from '@linaria/react';
import { Fragment, useContext } from 'react';
import { type IconComponent } from 'twenty-ui/display';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
export type SettingsStatRow = {
Icon: IconComponent;
label: string;
// String so callers can render a placeholder (e.g. "—") while async counts
// are still loading. Layout stats just pass `count.toString()`.
value: string;
};
type SettingsStatsGridProps = {
// Each inner array is one column rendered top-to-bottom; columns are
// separated by a vertical divider. Pass [[a, b], [c, d]] for a 2x2 layout
// or [[a, b, c]] for a single column.
columns: SettingsStatRow[][];
};
const StyledContainer = styled.div`
background: ${themeCssVariables.background.secondary};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.md};
display: flex;
gap: ${themeCssVariables.spacing[3]};
padding: ${themeCssVariables.spacing[2]};
`;
const StyledColumn = styled.div`
display: flex;
flex: 1 1 0;
flex-direction: column;
gap: ${themeCssVariables.spacing[1]};
min-width: 0;
`;
const StyledDivider = styled.div`
align-self: stretch;
background: ${themeCssVariables.border.color.light};
width: 1px;
`;
const StyledRow = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[2]};
height: ${themeCssVariables.spacing[6]};
`;
const StyledLabel = styled.div`
color: ${themeCssVariables.font.color.tertiary};
flex: 1 1 0;
font-size: ${themeCssVariables.font.size.sm};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const StyledValue = styled.div`
color: ${themeCssVariables.font.color.primary};
padding: 0 ${themeCssVariables.spacing[1]};
`;
type StatRowProps = SettingsStatRow;
const StatRow = ({ Icon, label, value }: StatRowProps) => {
const { theme } = useContext(ThemeContext);
return (
<StyledRow>
<Icon size={theme.icon.size.md} color={theme.font.color.tertiary} />
<StyledLabel>{label}</StyledLabel>
<StyledValue>{value}</StyledValue>
</StyledRow>
);
};
export const SettingsStatsGrid = ({ columns }: SettingsStatsGridProps) => (
<StyledContainer>
{columns.map((column, index) => (
<Fragment key={index}>
{index > 0 && <StyledDivider />}
<StyledColumn>
{column.map((stat) => (
<StatRow
key={stat.label}
Icon={stat.Icon}
label={stat.label}
value={stat.value}
/>
))}
</StyledColumn>
</Fragment>
))}
</StyledContainer>
);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 78 KiB

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

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 290 KiB

@@ -1,4 +1,7 @@
import { playgroundApiKeyState } from '@/settings/playground/states/playgroundApiKeyState';
import {
isPlaygroundApiKeyFresh,
playgroundApiKeyState,
} from '@/settings/playground/states/playgroundApiKeyState';
import { PlaygroundSchemas } from '@/settings/playground/types/PlaygroundSchemas';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { styled } from '@linaria/react';
@@ -40,7 +43,7 @@ export const GraphQLPlayground = ({
const { colorScheme } = useContext(ThemeContext);
if (!playgroundApiKey) {
if (!isPlaygroundApiKeyFresh(playgroundApiKey)) {
onError();
return null;
}
@@ -60,7 +63,7 @@ export const GraphQLPlayground = ({
plugins={[explorer]}
fetcher={fetcher}
defaultHeaders={JSON.stringify({
Authorization: `Bearer ${playgroundApiKey}`,
Authorization: `Bearer ${playgroundApiKey.token}`,
})}
/>
</StyledGraphiQLContainer>
@@ -1,139 +1,61 @@
import { useOpenPlayground } from '@/settings/playground/hooks/useOpenPlayground';
import { SETTINGS_PLAYGROUND_FORM_SCHEMA_SELECT_OPTIONS } from '@/settings/playground/constants/SettingsPlaygroundFormSchemaSelectOptions';
import { playgroundApiKeyState } from '@/settings/playground/states/playgroundApiKeyState';
import { PlaygroundSchemas } from '@/settings/playground/types/PlaygroundSchemas';
import { PlaygroundTypes } from '@/settings/playground/types/PlaygroundTypes';
import { Select } from '@/ui/input/components/Select';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { styled } from '@linaria/react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Controller, useForm } from 'react-hook-form';
import { SettingsPath } from 'twenty-shared/types';
import { CustomError } from 'twenty-shared/utils';
import { IconApi, IconBrandGraphql } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { z } from 'zod';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
const playgroundSetupFormSchema = z.object({
apiKeyForPlayground: z.string(),
schema: z.enum(PlaygroundSchemas),
playgroundType: z.enum(PlaygroundTypes),
});
type PlaygroundSetupFormValues = z.infer<typeof playgroundSetupFormSchema>;
// Last column shrinks to the Launch button's content width so its right
// edge sits at the form's right edge. The two select columns share the
// remaining space equally.
const StyledForm = styled.form`
align-items: end;
display: grid;
gap: ${themeCssVariables.spacing[2]};
grid-template-columns: 1.5fr 1fr 1fr 0.5fr;
margin-bottom: ${themeCssVariables.spacing[2]};
grid-template-columns: 1fr 1fr auto;
width: 100%;
`;
export const PlaygroundSetupForm = () => {
const { t } = useLingui();
const navigateSettings = useNavigateSettings();
const [playgroundApiKey, setPlaygroundApiKey] = useAtomState(
playgroundApiKeyState,
);
const openPlayground = useOpenPlayground();
const {
control,
handleSubmit,
formState: { isSubmitting },
setError,
} = useForm<PlaygroundSetupFormValues>({
mode: 'onTouched',
resolver: zodResolver(playgroundSetupFormSchema),
defaultValues: {
schema: PlaygroundSchemas.CORE,
playgroundType: PlaygroundTypes.REST,
apiKeyForPlayground: playgroundApiKey || '',
},
});
const validateApiKey = async (values: PlaygroundSetupFormValues) => {
try {
const response = await fetch(
`${REACT_APP_SERVER_BASE_URL}/rest/open-api/${values.schema}`,
{
headers: { Authorization: `Bearer ${values.apiKeyForPlayground}` },
},
);
if (!response.ok) {
throw new CustomError(
`HTTP error! status: ${response.status}`,
'HTTP_ERROR',
);
}
const openAPIReference = await response.json();
if (!openAPIReference.tags) {
throw new Error('Invalid API Key');
}
return true;
} catch {
throw new Error(t`Invalid API key`);
}
};
const onSubmit = async (values: PlaygroundSetupFormValues) => {
try {
await validateApiKey(values);
setPlaygroundApiKey(values.apiKeyForPlayground);
const path =
values.playgroundType === PlaygroundTypes.GRAPHQL
? SettingsPath.GraphQLPlayground
: SettingsPath.RestPlayground;
navigateSettings(path, {
schema: values.schema.toLowerCase(),
});
} catch (error) {
setError('apiKeyForPlayground', {
type: 'manual',
message:
error instanceof Error
? error.message
: t`An unexpected error occurred`,
});
}
await openPlayground(values.playgroundType, values.schema);
};
return (
<StyledForm onSubmit={handleSubmit(onSubmit)}>
<Controller
name="apiKeyForPlayground"
control={control}
render={({ field: { onChange, value }, fieldState: { error } }) => (
<SettingsTextInput
instanceId="playground-api-key"
label={t`API Key`}
placeholder={t`Enter your API key`}
value={value}
onChange={(newValue) => {
onChange(newValue);
setPlaygroundApiKey(newValue);
}}
error={error?.message}
required
/>
)}
/>
<Controller
name="schema"
control={control}
defaultValue={PlaygroundSchemas.CORE}
render={({ field: { onChange, value } }) => (
<Select
dropdownId="schema"
@@ -152,17 +74,12 @@ export const PlaygroundSetupForm = () => {
<Controller
name="playgroundType"
control={control}
defaultValue={PlaygroundTypes.REST}
render={({ field: { onChange, value } }) => (
<Select
dropdownId="apiPlaygroundType"
label={t`API`}
options={[
{
value: PlaygroundTypes.REST,
label: t`REST`,
Icon: IconApi,
},
{ value: PlaygroundTypes.REST, label: t`REST`, Icon: IconApi },
{
value: PlaygroundTypes.GRAPHQL,
label: t`GraphQL`,
@@ -1,5 +1,8 @@
import { RestPlaygroundSchemaFetchEffect } from '@/settings/playground/components/RestPlaygroundSchemaFetchEffect';
import { playgroundApiKeyState } from '@/settings/playground/states/playgroundApiKeyState';
import {
isPlaygroundApiKeyFresh,
playgroundApiKeyState,
} from '@/settings/playground/states/playgroundApiKeyState';
import { type PlaygroundSchemas } from '@/settings/playground/types/PlaygroundSchemas';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useContext, useState, lazy, Suspense } from 'react';
@@ -55,7 +58,7 @@ export const RestPlayground = ({ onError, schema }: RestPlaygroundProps) => {
const playgroundApiKey = useAtomStateValue(playgroundApiKeyState);
const [specContent, setSpecContent] = useState<object | null>(null);
if (!playgroundApiKey) {
if (!isPlaygroundApiKeyFresh(playgroundApiKey)) {
onError();
return null;
}
@@ -74,7 +77,7 @@ export const RestPlayground = ({ onError, schema }: RestPlaygroundProps) => {
<StyledContainer>
<RestPlaygroundSchemaFetchEffect
schema={schema}
apiKey={playgroundApiKey}
apiKey={playgroundApiKey.token}
onSchemaLoaded={setSpecContent}
onError={onError}
/>
@@ -89,7 +92,7 @@ export const RestPlayground = ({ onError, schema }: RestPlaygroundProps) => {
},
authentication: {
http: {
bearer: { token: playgroundApiKey },
bearer: { token: playgroundApiKey.token },
},
},
baseServerURL: REACT_APP_SERVER_BASE_URL + '/' + schema,
@@ -0,0 +1,56 @@
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { H2Title, IconCopy } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Card, CardContent, Section } from 'twenty-ui/layout';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
const StyledPre = styled.pre`
font-family: monospace;
margin: 0;
white-space: pre;
`;
const buildMcpConfig = (serverUrl: string) =>
`{
"mcpServers": {
"twenty": {
"url": "${serverUrl}/mcp",
"headers": {
"Authorization": "Bearer <YOUR_API_KEY>"
}
}
}
}`;
export const SettingsMcpSetup = () => {
const { t } = useLingui();
const { copyToClipboard } = useCopyToClipboard();
const mcpConfig = buildMcpConfig(REACT_APP_SERVER_BASE_URL);
return (
<Section>
<H2Title
title={t`Connect your AI assistant`}
description={t`Add Twenty as a Model Context Protocol (MCP) server. Paste this config into Claude Desktop, Cursor, Cline, Continue, Zed, or any other MCP-aware client.`}
/>
<Card rounded>
<CardContent divider>
<StyledPre>{mcpConfig}</StyledPre>
</CardContent>
<CardContent>
<Button
title={t`Copy config`}
Icon={IconCopy}
size="small"
variant="secondary"
onClick={() =>
copyToClipboard(mcpConfig, t`MCP config copied to clipboard`)
}
/>
</CardContent>
</Card>
</Section>
);
};
@@ -1,44 +0,0 @@
import { styled } from '@linaria/react';
import { type ReactNode, useContext } from 'react';
import DarkCoverImage from '@/settings/playground/assets/cover-dark.png';
import LightCoverImage from '@/settings/playground/assets/cover-light.png';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
const StyledCoverContainer = styled.div`
align-items: center;
background-size: cover;
border-radius: ${themeCssVariables.border.radius.md};
box-sizing: border-box;
display: flex;
height: 153px;
justify-content: center;
margin-bottom: ${themeCssVariables.spacing[4]};
margin-top: ${themeCssVariables.spacing[4]};
position: relative;
`;
type StyledSettingsApiPlaygroundCoverImageProps = {
children?: ReactNode;
className?: string;
};
export const StyledSettingsApiPlaygroundCoverImage = ({
children,
className,
}: StyledSettingsApiPlaygroundCoverImageProps) => {
const { colorScheme } = useContext(ThemeContext);
const coverImage =
colorScheme === 'light'
? LightCoverImage.toString()
: DarkCoverImage.toString();
return (
<StyledCoverContainer
className={className}
style={{ backgroundImage: `url('${coverImage}')` }}
>
{children}
</StyledCoverContainer>
);
};
@@ -14,7 +14,10 @@ const PlaygroundApiKeySetterEffect = () => {
const setPlaygroundApiKey = useSetAtomState(playgroundApiKeyState);
useEffect(() => {
setPlaygroundApiKey('test-api-key-123');
setPlaygroundApiKey({
token: 'test-api-key-123',
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
});
}, [setPlaygroundApiKey]);
return null;
@@ -12,7 +12,10 @@ const PlaygroundApiKeySetterEffect = () => {
const setPlaygroundApiKey = useSetAtomState(playgroundApiKeyState);
useEffect(() => {
setPlaygroundApiKey('test-api-key-123');
setPlaygroundApiKey({
token: 'test-api-key-123',
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
});
}, [setPlaygroundApiKey]);
return null;
@@ -0,0 +1,62 @@
import { useMutation } from '@apollo/client/react';
import { useCallback } from 'react';
import { t } from '@lingui/core/macro';
import { SettingsPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
isPlaygroundApiKeyFresh,
playgroundApiKeyState,
} from '@/settings/playground/states/playgroundApiKeyState';
import { type PlaygroundSchemas } from '@/settings/playground/types/PlaygroundSchemas';
import { PlaygroundTypes } from '@/settings/playground/types/PlaygroundTypes';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { GeneratePlaygroundTokenDocument } from '~/generated-metadata/graphql';
// Re-mint when less than this remains so the user never lands on a token about to expire.
const TOKEN_FRESHNESS_BUFFER_MS = 5 * 60 * 1000;
export const useOpenPlayground = () => {
const navigateSettings = useNavigateSettings();
const [playgroundApiKey, setPlaygroundApiKey] = useAtomState(
playgroundApiKeyState,
);
const { enqueueErrorSnackBar } = useSnackBar();
const [generatePlaygroundToken] = useMutation(
GeneratePlaygroundTokenDocument,
{
onError: () => {
enqueueErrorSnackBar({
message: t`Could not open the API playground`,
});
},
},
);
return useCallback(
async (type: PlaygroundTypes, schema: PlaygroundSchemas) => {
if (
!isPlaygroundApiKeyFresh(playgroundApiKey, TOKEN_FRESHNESS_BUFFER_MS)
) {
const { data } = await generatePlaygroundToken();
const mintedToken = data?.generatePlaygroundToken;
if (!isDefined(mintedToken)) return;
setPlaygroundApiKey(mintedToken);
}
const path =
type === PlaygroundTypes.GRAPHQL
? SettingsPath.GraphQLPlayground
: SettingsPath.RestPlayground;
navigateSettings(path, { schema });
},
[
playgroundApiKey,
generatePlaygroundToken,
navigateSettings,
setPlaygroundApiKey,
],
);
};
@@ -1,7 +1,21 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
import { isDefined } from 'twenty-shared/utils';
import { type AuthToken } from '~/generated-metadata/graphql';
export const playgroundApiKeyState = createAtomState<string | null>({
// In-memory only: a short-lived, full-permission bearer token. Keeping it out of
// localStorage bounds the exfiltration window to the current tab and leaves no
// usable credential at rest after the tab closes.
export const playgroundApiKeyState = createAtomState<AuthToken | null>({
key: 'playgroundApiKeyState',
defaultValue: null,
useLocalStorage: true,
});
// Usable only while it stays valid for at least `bufferMs` longer. Consumers pass
// no buffer (reject the moment it expires); the launcher passes a buffer so it
// re-mints before a near-expired token can fail mid-session.
export const isPlaygroundApiKeyFresh = (
token: AuthToken | null,
bufferMs = 0,
): token is AuthToken =>
isDefined(token) &&
new Date(token.expiresAt).getTime() - Date.now() > bufferMs;