feat(server): in-app server-level admin management (#19785) (#21321)

## Closes #19785

In-app management of **server-level admin rights**
(`canAccessFullAdminPanel`, `canImpersonate`) so self-hosters no longer
need raw SQL + a Redis flush + restart to grant access.

> **Draft** — feature complete; `/code-review` + `/security-review` run
and addressed.

### Background
`AdminPanelGuard` / `ServerLevelImpersonateGuard` read
`request.user.{canAccessFullAdminPanel,canImpersonate}`, hydrated each
request from `CoreEntityCacheService.get('user', …)` (local 30-min +
Redis no-TTL). The cache was only invalidated on soft-delete, so a raw
`UPDATE core."user"` never took effect. The **first** signup auto-gets
both flags; every subsequent admin previously needed raw SQL.

### UX
- **Admin Panel → General → Administrators**: a read-only overview of
every user with server-level access; each row links to that user's admin
page.
- **Find anyone** via the user search (Recent Users) — available to full
admins and impersonators — then open their **admin user page**.
- On the user page, an **"Administrator access"** card (gated on
`canAccessFullAdminPanel`) has two toggles — *Full admin panel access*
and *Impersonation* — that work for **any** user (a user with no access
shows both off). Mirrors how **Impersonate** already works (find user →
user page → act). Each change opens a confirm dialog with a **2FA code**
field; the last full admin's toggle is disabled.

### Backend / security
- **Cache fix** — invalidate the user entity cache on committed user
updates (not just soft-delete) so privilege changes propagate (~100 ms,
cluster-wide) with no restart.
- `getServerAdmins` query + `updateServerAdminAccess` mutation (any
`targetUserId`), gated on `canAccessFullAdminPanel`.
- `NoImpersonationGuard` on both — an impersonated full-admin session
can't be used to escalate an impersonator.
- Fresh **2FA TOTP step-up** (enrolled+verified method **and** a fresh
code; genuine 2FA errors surface; dev-skip on trusted `NODE_ENV`).
- **Last-admin lockout** in a transaction with a pessimistic row lock
(no TOCTOU).
- **Email-to-all-admins + affected user** (rendered once per locale),
structured log, audit event-log emit.
- **Authorization**: the read-only `userLookupAdminPanel` +
`adminPanelRecentUsers` lookups now accept `canAccessFullAdminPanel OR
canImpersonate` (new `AdminPanelOrImpersonateGuard`), so a full admin
without impersonate can still find users to manage.
Workspace/impersonation queries stay impersonate-gated.

### Reviews
- `/code-review` (max effort): 3 security findings
(impersonation-escalation sink, lockout TOCTOU, step-up accepting
PENDING 2FA) — **all fixed**. `/simplify`: applied. `/security-review`:
**no high/medium vulnerabilities**.

### Follow-ups (not in this PR)
- Unit tests for `AdminPanelServerAdminService` + a frontend test.
- Point the self-host troubleshooting docs at the new UI.
- OTP retry UX: `ConfirmationModal` closes on confirm, so a wrong code
needs a reopen (kept to reuse the existing modal; no new pattern).

### Notes for reviewers
- `generated-admin/graphql.ts` entries were hand-added to match codegen
output (admin codegen needs a running server); re-run `nx
graphql:generate twenty-front --configuration=admin` to confirm parity.
- First-admin bootstrap (first signup) is unchanged.

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
This commit is contained in:
Félix Malfait
2026-06-10 06:50:25 +02:00
committed by GitHub
parent 6c65ae8257
commit ce2d77be2a
18 changed files with 1193 additions and 174 deletions
@@ -0,0 +1,102 @@
import { useApolloAdminClient } from '@/settings/admin-panel/apollo/hooks/useApolloAdminClient';
import { SettingsSectionSkeletonLoader } from '@/settings/components/SettingsSectionSkeletonLoader';
import { Table } from '@/ui/layout/table/components/Table';
import { TableBody } from '@/ui/layout/table/components/TableBody';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { useQuery } from '@apollo/client/react';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { useContext } from 'react';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import {
H2Title,
IconChevronRight,
OverflowingTextWithTooltip,
} from 'twenty-ui-deprecated/display';
import { Section } from 'twenty-ui-deprecated/layout';
import {
ThemeContext,
themeCssVariables,
} from 'twenty-ui-deprecated/theme-constants';
import { GetServerAdminsDocument } from '~/generated-admin/graphql';
const SERVER_ADMINS_GRID_TEMPLATE_COLUMNS = '2fr 1fr 1fr 36px';
const StyledEmptyState = styled.div`
color: ${themeCssVariables.font.color.tertiary};
padding: ${themeCssVariables.spacing[4]} 0;
`;
export const SettingsAdminServerAdmins = () => {
const { theme } = useContext(ThemeContext);
const apolloAdminClient = useApolloAdminClient();
const { data, loading, error } = useQuery(GetServerAdminsDocument, {
client: apolloAdminClient,
});
const serverAdmins = data?.getServerAdmins ?? [];
return (
<Section>
<H2Title
title={t`Administrators`}
description={t`Users with server-level access. Open a user to grant or revoke access; use the search below to find anyone.`}
/>
{loading ? (
<SettingsSectionSkeletonLoader />
) : error ? (
<StyledEmptyState>{t`Failed to load server administrators.`}</StyledEmptyState>
) : serverAdmins.length === 0 ? (
<StyledEmptyState>{t`No server administrators found.`}</StyledEmptyState>
) : (
<Table>
<TableBody>
<TableRow gridTemplateColumns={SERVER_ADMINS_GRID_TEMPLATE_COLUMNS}>
<TableHeader>{t`Administrator`}</TableHeader>
<TableHeader>{t`Admin panel`}</TableHeader>
<TableHeader>{t`Impersonation`}</TableHeader>
<TableHeader />
</TableRow>
{serverAdmins.map((admin) => {
const adminLabel =
`${admin.firstName || ''} ${admin.lastName || ''}`.trim() ||
admin.email;
return (
<TableRow
key={admin.id}
gridTemplateColumns={SERVER_ADMINS_GRID_TEMPLATE_COLUMNS}
to={getSettingsPath(SettingsPath.AdminPanelUserDetail, {
userId: admin.id,
})}
>
<TableCell
color={themeCssVariables.font.color.primary}
overflow="hidden"
>
<OverflowingTextWithTooltip text={adminLabel} />
</TableCell>
<TableCell>
{admin.canAccessFullAdminPanel ? t`Yes` : '—'}
</TableCell>
<TableCell>{admin.canImpersonate ? t`Yes` : '—'}</TableCell>
<TableCell align="center">
<IconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
color={theme.font.color.tertiary}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</Section>
);
};