feat(settings): move settings chrome into a single rounded card (#21131)

## What

Replaces `SubMenuTopBarContainer` with a settings-specific
`SettingsPageLayout` that puts the whole page chrome — breadcrumb,
centered title, actions, an optional secondary bar (tabs or wizard
step), and the 760px body — inside **one rounded card**, with
`SidePanelForDesktop` as a sibling. Title, tabs and body content share
one centered vertical axis at every card width.

Supersedes #21122. One PR, no feature flag.

## New components (`@/settings/components/layout/`)

- **SettingsPageLayout** — owns the rounded card + side-panel sibling,
`useCommandMenuHotKeys`, mobile command menu
- **SettingsPageHeader** — breadcrumb · centered title · actions in a
symmetric `1fr auto 1fr` grid (symmetric padding throughout)
- **SettingsSecondaryBar** — the secondary row, bracketed by top +
bottom borders
- **SettingsTabBar** — centered tabs reusing `activeTabIdComponentState`
+ `TabListFromUrlOptionalEffect` for URL-hash sync (does not touch the
shared `TabList`)
- **SettingsWizardStepBar** — back arrow · "N. Label" · optional
trailing slot

## Migrations

- Bulk rename across ~80 call sites (`SubMenuTopBarContainer` →
`SettingsPageLayout`); old component deleted.
- 5 tab pages (AI, APIs & Webhooks, Applications, Members, Role) + the
Data Model object-detail page render their tabs in `secondaryBar`
(object-detail keeps "See records" / "New Field" in the header actions).
- The 2 role object-level steps render the wizard step bar with working
back navigation.
- Accounts consolidated into **General / Emails / Calendars** tabs;
standalone `SettingsAccountsEmails` / `SettingsAccountsCalendars` pages
+ routes + stories removed. `SettingsPath.AccountsEmails` /
`AccountsCalendars` now resolve to `accounts#emails` /
`accounts#calendars`, so existing `getSettingsPath()` links deep-link to
the right tab via the existing hash sync — no call-site changes.

## Verification

- `nx typecheck twenty-front` and `nx lint twenty-front` both clean.
- Browser (logged-in workspace): title / tab / body / card centers align
on a single axis at multiple widths — width-invariant, so alignment
holds when the AI side panel (a sibling) shrinks the card. Rounded card
with even gaps on all four sides; tab row bracketed by two 1px lines;
no-tab pages render header → body with no lines; wizard back navigation
works; `…/accounts#emails` opens the Emails tab.

The shared `PageHeader` and `TabList` are untouched. The settings side
panel itself isn't wired to open yet — that's a follow-up PR.
This commit is contained in:
Félix Malfait
2026-06-02 14:22:57 +02:00
committed by GitHub
parent e721ebe300
commit 431f6ae98f
107 changed files with 1367 additions and 1008 deletions
@@ -0,0 +1,350 @@
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { Link } from 'react-router-dom';
import { useDebouncedCallback } from 'use-debounce';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { authProvidersState } from '@/client-config/states/authProvidersState';
import { isClickHouseConfiguredState } from '@/client-config/states/isClickHouseConfiguredState';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { Separator } from '@/settings/components/Separator';
import { SettingsEnterpriseFeatureGateCard } from '@/settings/components/SettingsEnterpriseFeatureGateCard';
import { SettingsOptionCardContentButton } from '@/settings/components/SettingsOptions/SettingsOptionCardContentButton';
import { SettingsOptionCardContentCounter } from '@/settings/components/SettingsOptions/SettingsOptionCardContentCounter';
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
import { SettingsRoleDefaultRole } from '@/settings/roles/components/SettingsRolesDefaultRole';
import { SettingsRolesQueryEffect } from '@/settings/roles/components/SettingsRolesQueryEffect';
import { useSettingsAllRoles } from '@/settings/roles/hooks/useSettingsAllRoles';
import { SettingsSSOIdentitiesProvidersListCard } from '@/settings/security/components/SSO/SettingsSSOIdentitiesProvidersListCard';
import { SettingsSecurityAuthBypassOptionsList } from '@/settings/security/components/SettingsSecurityAuthBypassOptionsList';
import { SettingsSecurityAuthProvidersOptionsList } from '@/settings/security/components/SettingsSecurityAuthProvidersOptionsList';
import { SettingsSecurityEditableProfileFields } from '@/settings/security/components/SettingsSecurityEditableProfileFields';
import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProvidersState';
import { ToggleImpersonate } from '@/settings/workspace/components/ToggleImpersonate';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { useMutation } from '@apollo/client/react';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { Tag } from 'twenty-ui/components';
import {
H2Title,
IconClockHour8,
IconHistory,
IconLock,
IconMail,
IconTrash,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Card, Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { UpdateWorkspaceDocument } from '~/generated-metadata/graphql';
const StyledContainer = styled.div`
width: 100%;
`;
const StyledMainContent = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[10]};
min-height: 200px;
`;
const StyledSectionContainer = styled.div`
flex-shrink: 0;
`;
const StyledLinkContainer = styled.div`
> a {
text-decoration: none;
&[data-disabled='true'] {
pointer-events: none;
}
}
`;
export const SettingsSecuritySettings = () => {
const { t } = useLingui();
const { enqueueErrorSnackBar } = useSnackBar();
const isMultiWorkspaceEnabled = useAtomStateValue(
isMultiWorkspaceEnabledState,
);
const isClickHouseConfigured = useAtomStateValue(isClickHouseConfiguredState);
const authProviders = useAtomStateValue(authProvidersState);
const SSOIdentitiesProviders = useAtomStateValue(SSOIdentitiesProvidersState);
const [currentWorkspace, setCurrentWorkspace] = useAtomState(
currentWorkspaceState,
);
const [updateWorkspace] = useMutation(UpdateWorkspaceDocument);
const saveTrashRetention = useDebouncedCallback(async (value: number) => {
try {
await updateWorkspace({
variables: {
input: {
trashRetentionDays: value,
},
},
});
} catch (err) {
enqueueErrorSnackBar({
apolloError: CombinedGraphQLErrors.is(err) ? err : undefined,
});
}
}, 500);
const saveEventLogRetention = useDebouncedCallback(async (value: number) => {
try {
await updateWorkspace({
variables: {
input: {
eventLogRetentionDays: value,
},
},
});
} catch (err) {
enqueueErrorSnackBar({
apolloError: CombinedGraphQLErrors.is(err) ? err : undefined,
});
}
}, 500);
const handleTrashRetentionDaysChange = (value: number) => {
if (!currentWorkspace) {
return;
}
if (value === currentWorkspace.trashRetentionDays) {
return;
}
setCurrentWorkspace({
...currentWorkspace,
trashRetentionDays: value,
});
saveTrashRetention(value);
};
const handleSyncInternalEmailsChange = (value: boolean) => {
if (!currentWorkspace) {
return;
}
if (value === currentWorkspace.isInternalMessagesImportEnabled) {
return;
}
setCurrentWorkspace({
...currentWorkspace,
isInternalMessagesImportEnabled: value,
});
updateWorkspace({
variables: {
input: {
isInternalMessagesImportEnabled: value,
},
},
}).catch((err) => {
enqueueErrorSnackBar({
apolloError: CombinedGraphQLErrors.is(err) ? err : undefined,
});
});
};
const handleEventLogRetentionDaysChange = (value: number) => {
if (!currentWorkspace) {
return;
}
if (value === currentWorkspace.eventLogRetentionDays) {
return;
}
setCurrentWorkspace({
...currentWorkspace,
eventLogRetentionDays: value,
});
saveEventLogRetention(value);
};
const roles = useSettingsAllRoles();
const hasSsoIdentityProviders = SSOIdentitiesProviders.length > 0;
const hasDirectAuthEnabled =
currentWorkspace?.isGoogleAuthEnabled ||
currentWorkspace?.isMicrosoftAuthEnabled ||
currentWorkspace?.isPasswordAuthEnabled;
const hasBypassProviderAvailable =
authProviders?.google ||
authProviders?.microsoft ||
authProviders?.password;
const shouldShowBypassSection =
hasSsoIdentityProviders &&
!hasDirectAuthEnabled &&
hasBypassProviderAvailable;
const hasEnterpriseAccess =
currentWorkspace?.hasValidSignedEnterpriseKey === true;
const isEventLogsEnabled = hasEnterpriseAccess && isClickHouseConfigured;
return (
<>
<SettingsRolesQueryEffect />
<StyledMainContent>
<StyledSectionContainer>
<Section>
<H2Title
title={t`SSO`}
description={t`Configure an SSO connection`}
adornment={
<Tag
text={t`Enterprise`}
color="transparent"
Icon={IconLock}
variant="border"
/>
}
/>
<SettingsSSOIdentitiesProvidersListCard />
</Section>
</StyledSectionContainer>
<Section>
<StyledContainer>
<H2Title
title={t`Authentication`}
description={t`Customize your workspace security`}
/>
<SettingsSecurityAuthProvidersOptionsList />
</StyledContainer>
</Section>
<Section>
<StyledContainer>
<H2Title
title={t`Editable Profile Fields`}
description={t`Choose which profile fields users with the Edit Profile permission can modify`}
/>
<SettingsSecurityEditableProfileFields />
</StyledContainer>
</Section>
<SettingsRoleDefaultRole roles={roles} />
{shouldShowBypassSection && (
<Section>
<StyledContainer>
<H2Title
title={t`SSO Bypass`}
description={t`Configure fallback login methods for users with SSO bypass permissions`}
/>
<SettingsSecurityAuthBypassOptionsList />
</StyledContainer>
</Section>
)}
{isMultiWorkspaceEnabled && (
<Section>
<H2Title
title={t`Support`}
description={t`Manage support access settings`}
/>
<ToggleImpersonate />
</Section>
)}
<Section>
<H2Title
title={t`Audit Logs`}
description={t`View workspace activity logs`}
adornment={
<Tag
text={t`Enterprise`}
color="transparent"
Icon={IconLock}
variant="border"
/>
}
/>
{hasEnterpriseAccess ? (
<Card rounded>
<SettingsOptionCardContentButton
Icon={IconHistory}
title={t`Workspace Events`}
description={
!isClickHouseConfigured
? t`ClickHouse is required for audit logs. Contact your administrator.`
: t`View and filter events, page views, object changes`
}
Button={
<StyledLinkContainer>
<Link
to={getSettingsPath(SettingsPath.EventLogs)}
data-disabled={!isEventLogsEnabled}
>
<Button
title={t`View Logs`}
variant="secondary"
size="small"
disabled={!isEventLogsEnabled}
/>
</Link>
</StyledLinkContainer>
}
/>
{isEventLogsEnabled && (
<>
<Separator />
<SettingsOptionCardContentCounter
Icon={IconClockHour8}
title={t`Log retention`}
description={t`Number of days to retain audit logs (30-1095 days)`}
value={currentWorkspace?.eventLogRetentionDays ?? 90}
onChange={handleEventLogRetentionDaysChange}
minValue={30}
maxValue={1095}
showButtons={false}
/>
</>
)}
</Card>
) : (
<SettingsEnterpriseFeatureGateCard
title={t`Enterprise feature`}
description={t`Upgrade to Enterprise to access audit logs.`}
buttonTitle={t`Activate`}
/>
)}
</Section>
<Section>
<H2Title title={t`Other`} description={t`Other security settings`} />
<Card rounded>
<SettingsOptionCardContentCounter
Icon={IconTrash}
title={t`Erasure of soft-deleted records`}
description={t`Permanent deletion. Enter the number of days.`}
value={currentWorkspace?.trashRetentionDays ?? 14}
onChange={handleTrashRetentionDaysChange}
minValue={0}
showButtons={false}
/>
<Separator />
<SettingsOptionCardContentToggle
Icon={IconMail}
title={t`Sync Internal Emails`}
description={t`Include emails where all participants share the same domain.`}
checked={
currentWorkspace?.isInternalMessagesImportEnabled ?? false
}
onChange={handleSyncInternalEmailsChange}
advancedMode
/>
</Card>
</Section>
</StyledMainContent>
</>
);
};