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,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>
);
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;
@@ -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}