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
@@ -1,6 +1,7 @@
import { canManageFeatureFlagsState } from '@/client-config/states/canManageFeatureFlagsState';
import { useApolloAdminClient } from '@/settings/admin-panel/apollo/hooks/useApolloAdminClient';
import { SettingsSectionSkeletonLoader } from '@/settings/components/SettingsSectionSkeletonLoader';
import { SettingsAdminServerAdmins } from '@/settings/admin-panel/components/SettingsAdminServerAdmins';
import { SettingsAdminVersionContainer } from '@/settings/admin-panel/components/SettingsAdminVersionContainer';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { Table } from '@/ui/layout/table/components/Table';
@@ -61,7 +62,7 @@ export const SettingsAdminGeneral = () => {
{
client: apolloAdminClient,
variables: { searchTerm: debouncedUserSearchTerm },
skip: !canImpersonate,
skip: !canImpersonate && !canAccessFullAdminPanel,
},
);
@@ -80,184 +81,185 @@ export const SettingsAdminGeneral = () => {
return (
<>
{canAccessFullAdminPanel && (
<>
<Section>
<H2Title
title={t`About`}
description={t`Version of the application`}
/>
<SettingsAdminVersionContainer />
</Section>
<SettingsAdminServerAdmins />
</>
)}
{(canImpersonate || canAccessFullAdminPanel) && (
<Section>
<H2Title
title={t`About`}
description={t`Version of the application`}
title={t`Recent Users`}
description={
canManageFeatureFlags
? t`Last 10 users created. Click to manage feature flags or impersonate.`
: t`Last 10 users created. Click to impersonate.`
}
/>
<SettingsAdminVersionContainer />
<SettingsTextInput
instanceId="admin-panel-user-search"
value={userSearchTerm}
onChange={setUserSearchTerm}
placeholder={t`Search by name, email, or user ID...`}
fullWidth
/>
{isLoadingUsers ? (
<SettingsSectionSkeletonLoader />
) : recentUsers.length === 0 ? (
<StyledEmptyState>
{t`No users found matching your search criteria.`}
</StyledEmptyState>
) : (
<Table>
<TableBody>
<TableRow
gridTemplateColumns={RECENT_USERS_GRID_TEMPLATE_COLUMNS}
>
<TableHeader>{t`Name`}</TableHeader>
<TableHeader>{t`Email`}</TableHeader>
<TableHeader>{t`Workspace`}</TableHeader>
<TableHeader />
</TableRow>
{recentUsers.map((user) => (
<TableRow
key={user.id}
gridTemplateColumns={RECENT_USERS_GRID_TEMPLATE_COLUMNS}
to={getSettingsPath(SettingsPath.AdminPanelUserDetail, {
userId: user.id,
})}
>
<TableCell
color={themeCssVariables.font.color.primary}
gap={themeCssVariables.spacing[2]}
overflow="hidden"
>
<Avatar
avatarUrl={user.avatarUrl}
placeholder={
`${user.firstName || ''} ${user.lastName || ''}`.trim() ||
user.email
}
placeholderColorSeed={user.id}
size="md"
type="rounded"
/>
<OverflowingTextWithTooltip
text={
`${user.firstName || ''} ${user.lastName || ''}`.trim() ||
'\u2014'
}
/>
</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell
gap={themeCssVariables.spacing[2]}
overflow="hidden"
>
{user.workspaceId ? (
<>
<Avatar
avatarUrl={user.workspaceLogo}
placeholder={user.workspaceName || ''}
placeholderColorSeed={user.workspaceId}
size="sm"
/>
<OverflowingTextWithTooltip
text={user.workspaceName || '\u2014'}
/>
</>
) : (
'\u2014'
)}
</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>
)}
{canImpersonate && (
<>
<Section>
<H2Title
title={t`Recent Users`}
description={
canManageFeatureFlags
? t`Last 10 users created. Click to manage feature flags or impersonate.`
: t`Last 10 users created. Click to impersonate.`
}
/>
<SettingsTextInput
instanceId="admin-panel-user-search"
value={userSearchTerm}
onChange={setUserSearchTerm}
placeholder={t`Search by name, email, or user ID...`}
fullWidth
/>
{isLoadingUsers ? (
<SettingsSectionSkeletonLoader />
) : recentUsers.length === 0 ? (
<StyledEmptyState>
{t`No users found matching your search criteria.`}
</StyledEmptyState>
) : (
<Table>
<TableBody>
<TableRow
gridTemplateColumns={RECENT_USERS_GRID_TEMPLATE_COLUMNS}
>
<TableHeader>{t`Name`}</TableHeader>
<TableHeader>{t`Email`}</TableHeader>
<TableHeader>{t`Workspace`}</TableHeader>
<TableHeader />
</TableRow>
{recentUsers.map((user) => (
<TableRow
key={user.id}
gridTemplateColumns={RECENT_USERS_GRID_TEMPLATE_COLUMNS}
to={getSettingsPath(SettingsPath.AdminPanelUserDetail, {
userId: user.id,
})}
>
<TableCell
color={themeCssVariables.font.color.primary}
gap={themeCssVariables.spacing[2]}
overflow="hidden"
>
<Avatar
avatarUrl={user.avatarUrl}
placeholder={
`${user.firstName || ''} ${user.lastName || ''}`.trim() ||
user.email
}
placeholderColorSeed={user.id}
size="md"
type="rounded"
/>
<OverflowingTextWithTooltip
text={
`${user.firstName || ''} ${user.lastName || ''}`.trim() ||
'\u2014'
}
/>
</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell
gap={themeCssVariables.spacing[2]}
overflow="hidden"
>
{user.workspaceId ? (
<>
<Avatar
avatarUrl={user.workspaceLogo}
placeholder={user.workspaceName || ''}
placeholderColorSeed={user.workspaceId}
size="sm"
/>
<OverflowingTextWithTooltip
text={user.workspaceName || '\u2014'}
/>
</>
) : (
'\u2014'
)}
</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>
<Section>
<H2Title
title={t`Top Workspaces`}
description={t`Top 10 workspaces by number of users`}
/>
<SettingsTextInput
instanceId="admin-panel-workspace-search"
value={workspaceSearchTerm}
onChange={setWorkspaceSearchTerm}
placeholder={t`Search by workspace name, subdomain, or ID...`}
fullWidth
/>
{isLoadingWorkspaces ? (
<SettingsSectionSkeletonLoader />
) : topWorkspaces.length === 0 ? (
<StyledEmptyState>
{t`No workspaces found matching your search criteria.`}
</StyledEmptyState>
) : (
<Table>
<TableBody>
<Section>
<H2Title
title={t`Top Workspaces`}
description={t`Top 10 workspaces by number of users`}
/>
<SettingsTextInput
instanceId="admin-panel-workspace-search"
value={workspaceSearchTerm}
onChange={setWorkspaceSearchTerm}
placeholder={t`Search by workspace name, subdomain, or ID...`}
fullWidth
/>
{isLoadingWorkspaces ? (
<SettingsSectionSkeletonLoader />
) : topWorkspaces.length === 0 ? (
<StyledEmptyState>
{t`No workspaces found matching your search criteria.`}
</StyledEmptyState>
) : (
<Table>
<TableBody>
<TableRow
gridTemplateColumns={TOP_WORKSPACES_GRID_TEMPLATE_COLUMNS}
>
<TableHeader>{t`Workspace`}</TableHeader>
<TableHeader align="right">{t`Users`}</TableHeader>
<TableHeader />
</TableRow>
{topWorkspaces.map((workspace) => (
<TableRow
key={workspace.id}
gridTemplateColumns={TOP_WORKSPACES_GRID_TEMPLATE_COLUMNS}
to={getSettingsPath(
SettingsPath.AdminPanelWorkspaceDetail,
{ workspaceId: workspace.id },
)}
>
<TableHeader>{t`Workspace`}</TableHeader>
<TableHeader align="right">{t`Users`}</TableHeader>
<TableHeader />
</TableRow>
{topWorkspaces.map((workspace) => (
<TableRow
key={workspace.id}
gridTemplateColumns={TOP_WORKSPACES_GRID_TEMPLATE_COLUMNS}
to={getSettingsPath(
SettingsPath.AdminPanelWorkspaceDetail,
{ workspaceId: workspace.id },
)}
<TableCell
color={themeCssVariables.font.color.primary}
gap={themeCssVariables.spacing[2]}
overflow="hidden"
>
<TableCell
color={themeCssVariables.font.color.primary}
gap={themeCssVariables.spacing[2]}
overflow="hidden"
>
<Avatar
avatarUrl={workspace.logoUrl}
placeholder={workspace.name || ''}
placeholderColorSeed={workspace.id}
size="md"
/>
<OverflowingTextWithTooltip
text={workspace.name || '\u2014'}
/>
</TableCell>
<TableCell align="right">
{workspace.totalUsers}
</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>
</>
<Avatar
avatarUrl={workspace.logoUrl}
placeholder={workspace.name || ''}
placeholderColorSeed={workspace.id}
size="md"
/>
<OverflowingTextWithTooltip
text={workspace.name || '\u2014'}
/>
</TableCell>
<TableCell align="right">{workspace.totalUsers}</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>
)}
</>
);
@@ -0,0 +1,270 @@
import { useApolloAdminClient } from '@/settings/admin-panel/apollo/hooks/useApolloAdminClient';
import { TwoFactorAuthenticationVerificationCodeDash } from '@/settings/two-factor-authentication/components/TwoFactorAuthenticationVerificationCodeDash';
import { TwoFactorAuthenticationVerificationCodeSlot } from '@/settings/two-factor-authentication/components/TwoFactorAuthenticationVerificationCodeSlot';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { useMutation, useQuery } from '@apollo/client/react';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { OTPInput } from 'input-otp';
import { useState } from 'react';
import { IconDotsVertical, Status } from 'twenty-ui-deprecated/display';
import { LightIconButton } from 'twenty-ui-deprecated/input';
import { MenuItem } from 'twenty-ui-deprecated/navigation';
import { themeCssVariables } from 'twenty-ui-deprecated/theme-constants';
import {
GetServerAdminsDocument,
UpdateServerAdminAccessDocument,
} from '~/generated-admin/graphql';
type ServerAdminAccessUpdate = {
canAccessFullAdminPanel?: boolean;
canImpersonate?: boolean;
};
type PendingServerAdminChange = {
description: string;
isRevoking: boolean;
update: ServerAdminAccessUpdate;
};
const SERVER_ADMIN_ACCESS_CONFIRMATION_MODAL_ID =
'server-admin-access-confirmation';
const StyledValue = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[1]};
`;
const StyledChips = styled.div`
align-items: center;
display: flex;
flex: 1;
gap: ${themeCssVariables.spacing[2]};
`;
const StyledNoAccess = styled.span`
color: ${themeCssVariables.font.color.tertiary};
flex: 1;
`;
const StyledConfirmationContent = styled.div`
align-items: center;
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[4]};
`;
const StyledOTPContainer = styled.div`
display: flex;
gap: ${themeCssVariables.spacing[1]};
`;
export const SettingsAdminServerAdminAccess = ({
userId,
userLabel,
}: {
userId: string;
userLabel: string;
}) => {
const dropdownId = `server-admin-access-${userId}`;
const apolloAdminClient = useApolloAdminClient();
const { openModal } = useModal();
const { closeDropdown } = useCloseDropdown();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const [pendingChange, setPendingChange] =
useState<PendingServerAdminChange | null>(null);
const [otp, setOtp] = useState('');
const { data, refetch } = useQuery(GetServerAdminsDocument, {
client: apolloAdminClient,
});
const [updateServerAdminAccess] = useMutation(
UpdateServerAdminAccessDocument,
{ client: apolloAdminClient },
);
const serverAdmins = data?.getServerAdmins ?? [];
const currentAccess = serverAdmins.find((admin) => admin.id === userId);
const canAccessFullAdminPanel =
currentAccess?.canAccessFullAdminPanel ?? false;
const canImpersonate = currentAccess?.canImpersonate ?? false;
const fullAdminCount = serverAdmins.filter(
(admin) => admin.canAccessFullAdminPanel,
).length;
const isLastFullAdmin = canAccessFullAdminPanel && fullAdminCount <= 1;
const hasAnyAccess = canAccessFullAdminPanel || canImpersonate;
const hasFullAccess = canAccessFullAdminPanel && canImpersonate;
const requestChange = (change: PendingServerAdminChange) => {
closeDropdown(dropdownId);
setOtp('');
setPendingChange(change);
openModal(SERVER_ADMIN_ACCESS_CONFIRMATION_MODAL_ID);
};
const handleConfirm = async () => {
if (pendingChange === null) {
return;
}
try {
await updateServerAdminAccess({
variables: {
userId,
otp: otp.length > 0 ? otp : undefined,
...pendingChange.update,
},
});
await refetch();
enqueueSuccessSnackBar({
message: t`Server administrator access updated.`,
});
} catch (error) {
enqueueErrorSnackBar({
...(CombinedGraphQLErrors.is(error)
? { apolloError: error }
: { message: t`Failed to update server administrator access.` }),
});
} finally {
setOtp('');
setPendingChange(null);
}
};
return (
<>
<StyledValue>
{hasAnyAccess ? (
<StyledChips>
{canAccessFullAdminPanel && (
<Status color="green" text={t`Admin panel`} weight="medium" />
)}
{canImpersonate && (
<Status color="blue" text={t`Impersonation`} weight="medium" />
)}
</StyledChips>
) : (
<StyledNoAccess>{t`No access`}</StyledNoAccess>
)}
<Dropdown
dropdownId={dropdownId}
dropdownPlacement="right-start"
clickableComponent={
<LightIconButton Icon={IconDotsVertical} accent="tertiary" />
}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
<MenuItem
text={
canAccessFullAdminPanel
? t`Revoke admin panel access`
: t`Grant admin panel access`
}
disabled={isLastFullAdmin}
onClick={() =>
requestChange({
description: t`full admin panel access`,
isRevoking: canAccessFullAdminPanel,
update: {
canAccessFullAdminPanel: !canAccessFullAdminPanel,
},
})
}
/>
<MenuItem
text={
canImpersonate
? t`Disable impersonation`
: t`Enable impersonation`
}
onClick={() =>
requestChange({
description: t`impersonation`,
isRevoking: canImpersonate,
update: { canImpersonate: !canImpersonate },
})
}
/>
{!hasFullAccess && (
<MenuItem
text={t`Grant full access`}
onClick={() =>
requestChange({
description: t`full server access`,
isRevoking: false,
update: {
canAccessFullAdminPanel: true,
canImpersonate: true,
},
})
}
/>
)}
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
</StyledValue>
<ConfirmationModal
modalInstanceId={SERVER_ADMIN_ACCESS_CONFIRMATION_MODAL_ID}
title={pendingChange?.isRevoking ? t`Revoke access` : t`Grant access`}
confirmButtonAccent={pendingChange?.isRevoking ? 'danger' : 'blue'}
confirmButtonText={t`Confirm`}
onConfirmClick={handleConfirm}
onClose={() => {
setOtp('');
setPendingChange(null);
}}
subtitle={
<StyledConfirmationContent>
<div>
{pendingChange?.isRevoking
? t`This will revoke ${pendingChange?.description ?? ''} for ${userLabel}.`
: t`This will grant ${pendingChange?.description ?? ''} to ${userLabel}.`}
</div>
<div>{t`Enter your two-factor authentication code to confirm.`}</div>
<OTPInput
maxLength={6}
value={otp}
onChange={setOtp}
render={({ slots }) => (
<StyledOTPContainer>
{slots.slice(0, 3).map((slot, index) => (
<TwoFactorAuthenticationVerificationCodeSlot
key={index}
char={slot.char}
placeholderChar={slot.placeholderChar}
isActive={slot.isActive}
hasFakeCaret={slot.hasFakeCaret}
/>
))}
<TwoFactorAuthenticationVerificationCodeDash />
{slots.slice(3).map((slot, index) => (
<TwoFactorAuthenticationVerificationCodeSlot
key={index + 3}
char={slot.char}
placeholderChar={slot.placeholderChar}
isActive={slot.isActive}
hasFakeCaret={slot.hasFakeCaret}
/>
))}
</StyledOTPContainer>
)}
/>
</StyledConfirmationContent>
}
/>
</>
);
};
@@ -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>
);
};
@@ -0,0 +1,24 @@
import { gql } from '@apollo/client';
export const UPDATE_SERVER_ADMIN_ACCESS = gql`
mutation UpdateServerAdminAccess(
$userId: UUID!
$canAccessFullAdminPanel: Boolean
$canImpersonate: Boolean
$otp: String
) {
updateServerAdminAccess(
userId: $userId
canAccessFullAdminPanel: $canAccessFullAdminPanel
canImpersonate: $canImpersonate
otp: $otp
) {
id
email
firstName
lastName
canAccessFullAdminPanel
canImpersonate
}
}
`;
@@ -0,0 +1,14 @@
import { gql } from '@apollo/client';
export const GET_SERVER_ADMINS = gql`
query GetServerAdmins {
getServerAdmins {
id
email
firstName
lastName
canAccessFullAdminPanel
canImpersonate
}
}
`;