## 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:
+102
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user