feat(2FA): add admin security settings pages

This commit is contained in:
Rudy B. committed 2026-04-25 01:40:06 +02:00
1 parent 1b78192253
commit 2359dbad64
5 files changed
+131 -1

No files matched your search

+7 -1
View File
@@ -1,7 +1,7 @@
import { IconLabel } from '@/components/common/IconLabel';
import { NavMenu } from '@/components/common/NavMenu';
import { useMessages, useNavigation } from '@/components/hooks';
import { ArrowLeft, Globe, User, Users } from '@/components/icons';
import { ArrowLeft, Globe, ShieldCheck, User, Users } from '@/components/icons';
import { Column, Focusable, Row, Tooltip, TooltipTrigger } from '@umami/react-zen';
import Link from '@/components/common/Link';
@@ -31,6 +31,12 @@ export function AdminNav({ onItemClick }: { onItemClick?: () => void }) {
path: '/admin/teams',
icon: <Users />,
},
{
id: 'security',
label: t(labels.security),
path: '/admin/security',
icon: <ShieldCheck />,
},
],
},
];
@@ -0,0 +1,47 @@
'use client';
import { Column, Row, Switch, Text } from '@umami/react-zen';
import { PageHeader } from '@/components/common/PageHeader';
import { Panel } from '@/components/common/Panel';
import { useMessages, useUpdateQuery, useTwoFactorStatusQuery } from '@/components/hooks';
import { useQueryClient } from '@tanstack/react-query';
export function AdminSecurityPage() {
const { t, labels, messages } = useMessages();
const { data: status, isLoading } = useTwoFactorStatusQuery(true);
const { mutateAsync: setGlobal } = useUpdateQuery('/admin/2fa/global');
const queryClient = useQueryClient();
const isGlobalRequired = status?.requiredReason === 'global' || false;
const handleToggle = async (value: boolean) => {
await setGlobal({ required: value });
queryClient.invalidateQueries({ queryKey: ['2fa-status'] });
};
if (isLoading) return null;
return (
<Column gap="6" margin="2">
<PageHeader title={t(labels.security)} />
<Panel>
<Column gap="4">
<Column gap="1">
<Text weight="bold">{t(labels.twoFactorAuth)}</Text>
<Text>{t(messages.twoFactorAdminGlobalDescription)}</Text>
</Column>
<Row alignItems="center" gap="3">
<Switch isSelected={isGlobalRequired} onChange={handleToggle} />
<Text>{t(labels.twoFactorRequireGlobal)}</Text>
</Row>
{isGlobalRequired && (
<Text size="sm" color="muted">
{t(messages.twoFactorOverrideNote)}
</Text>
)}
</Column>
</Panel>
</Column>
);
}
+10
View File
@@ -0,0 +1,10 @@
import type { Metadata } from 'next';
import { AdminSecurityPage } from './AdminSecurityPage';
export default function () {
return <AdminSecurityPage />;
}
export const metadata: Metadata = {
title: 'Security',
};
@@ -1,6 +1,7 @@
import { Column, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen';
import { useMessages } from '@/components/hooks';
import { UserEditForm } from './UserEditForm';
import { UserTwoFactorSettings } from './UserTwoFactorSettings';
import { UserWebsites } from './UserWebsites';
export function UserSettings({ userId }: { userId: string }) {
@@ -12,6 +13,7 @@ export function UserSettings({ userId }: { userId: string }) {
<TabList>
<Tab id="details">{t(labels.details)}</Tab>
<Tab id="websites">{t(labels.websites)}</Tab>
<Tab id="security">{t(labels.security)}</Tab>
</TabList>
<TabPanel id="details" style={{ width: 500 }}>
<UserEditForm userId={userId} />
@@ -19,6 +21,9 @@ export function UserSettings({ userId }: { userId: string }) {
<TabPanel id="websites">
<UserWebsites userId={userId} />
</TabPanel>
<TabPanel id="security" style={{ width: 500 }}>
<UserTwoFactorSettings userId={userId} />
</TabPanel>
</Tabs>
</Column>
);
@@ -0,0 +1,62 @@
'use client';
import { Column, Row, Switch, Text, Tooltip, TooltipTrigger } from '@umami/react-zen';
import { Badge } from '@/components/common/Badge';
import { useMessages, useUpdateQuery, useTwoFactorStatusQuery } from '@/components/hooks';
import { useApi } from '@/components/hooks/useApi';
import { useQuery, useQueryClient } from '@tanstack/react-query';
export function UserTwoFactorSettings({ userId }: { userId: string }) {
const { t, labels, messages } = useMessages();
const { get } = useApi();
const queryClient = useQueryClient();
const { data: globalStatus } = useTwoFactorStatusQuery(true);
const isGlobalRequired = globalStatus?.requiredReason === 'global';
const { data: userTfaData } = useQuery({
queryKey: ['user-2fa-status', userId],
queryFn: () => get(`/admin/users/${userId}/2fa`),
});
const { data: userData } = useQuery({
queryKey: ['user', userId],
queryFn: () => get(`/users/${userId}`),
});
const { mutateAsync: setUserRequired } = useUpdateQuery(`/admin/users/${userId}/2fa`);
const twoFactorEnabled = !!(userTfaData as any)?.isEnabled;
const twoFactorRequired = !!(userData as any)?.twoFactorRequired;
const handleToggle = async (value: boolean) => {
await setUserRequired({ required: value });
queryClient.invalidateQueries({ queryKey: ['user', userId] });
};
return (
<Column gap="4">
<Row alignItems="center" gap="3">
<Text weight="bold">{t(labels.twoFactorAuth)}</Text>
<Badge variant={twoFactorEnabled ? 'good' : 'gray'}>
{twoFactorEnabled ? t(labels.twoFactorActive) : t(labels.twoFactorStatusNotConfigured)}
</Badge>
</Row>
<Text size="sm" color="muted">
{t(messages.twoFactorRequireUserDescription)}
</Text>
<TooltipTrigger isDisabled={!isGlobalRequired}>
<Row alignItems="center" gap="3">
<Switch
isSelected={isGlobalRequired || twoFactorRequired}
isDisabled={isGlobalRequired}
onChange={handleToggle}
/>
<Text>{t(labels.twoFactorRequireUser)}</Text>
</Row>
<Tooltip>{t(labels.twoFactorGlobalActiveTooltip)}</Tooltip>
</TooltipTrigger>
</Column>
);
}