## 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:
+170
-168
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
+270
@@ -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>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+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>
|
||||
);
|
||||
};
|
||||
+24
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const GET_SERVER_ADMINS = gql`
|
||||
query GetServerAdmins {
|
||||
getServerAdmins {
|
||||
id
|
||||
email
|
||||
firstName
|
||||
lastName
|
||||
canAccessFullAdminPanel
|
||||
canImpersonate
|
||||
}
|
||||
}
|
||||
`;
|
||||
Reference in New Issue
Block a user