From 2359dbad644ea44b1b37ab2368bddb33f7be0219 Mon Sep 17 00:00:00 2001 From: "Rudy B." Date: Fri, 24 Apr 2026 19:33:29 +0200 Subject: [PATCH] feat(2FA): add admin security settings pages --- src/app/(main)/admin/AdminNav.tsx | 8 ++- .../admin/security/AdminSecurityPage.tsx | 47 ++++++++++++++ src/app/(main)/admin/security/page.tsx | 10 +++ .../admin/users/[userId]/UserSettings.tsx | 5 ++ .../users/[userId]/UserTwoFactorSettings.tsx | 62 +++++++++++++++++++ 5 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 src/app/(main)/admin/security/AdminSecurityPage.tsx create mode 100644 src/app/(main)/admin/security/page.tsx create mode 100644 src/app/(main)/admin/users/[userId]/UserTwoFactorSettings.tsx diff --git a/src/app/(main)/admin/AdminNav.tsx b/src/app/(main)/admin/AdminNav.tsx index 7df3ff546..511f9c8b1 100644 --- a/src/app/(main)/admin/AdminNav.tsx +++ b/src/app/(main)/admin/AdminNav.tsx @@ -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: , }, + { + id: 'security', + label: t(labels.security), + path: '/admin/security', + icon: , + }, ], }, ]; diff --git a/src/app/(main)/admin/security/AdminSecurityPage.tsx b/src/app/(main)/admin/security/AdminSecurityPage.tsx new file mode 100644 index 000000000..a962787ae --- /dev/null +++ b/src/app/(main)/admin/security/AdminSecurityPage.tsx @@ -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 ( + + + + + + {t(labels.twoFactorAuth)} + {t(messages.twoFactorAdminGlobalDescription)} + + + + + {t(labels.twoFactorRequireGlobal)} + + + {isGlobalRequired && ( + + {t(messages.twoFactorOverrideNote)} + + )} + + + + ); +} diff --git a/src/app/(main)/admin/security/page.tsx b/src/app/(main)/admin/security/page.tsx new file mode 100644 index 000000000..db24cca14 --- /dev/null +++ b/src/app/(main)/admin/security/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from 'next'; +import { AdminSecurityPage } from './AdminSecurityPage'; + +export default function () { + return ; +} + +export const metadata: Metadata = { + title: 'Security', +}; diff --git a/src/app/(main)/admin/users/[userId]/UserSettings.tsx b/src/app/(main)/admin/users/[userId]/UserSettings.tsx index e2119bb59..3e461fd5a 100644 --- a/src/app/(main)/admin/users/[userId]/UserSettings.tsx +++ b/src/app/(main)/admin/users/[userId]/UserSettings.tsx @@ -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 }) { {t(labels.details)} {t(labels.websites)} + {t(labels.security)} @@ -19,6 +21,9 @@ export function UserSettings({ userId }: { userId: string }) { + + + ); diff --git a/src/app/(main)/admin/users/[userId]/UserTwoFactorSettings.tsx b/src/app/(main)/admin/users/[userId]/UserTwoFactorSettings.tsx new file mode 100644 index 000000000..864410f4c --- /dev/null +++ b/src/app/(main)/admin/users/[userId]/UserTwoFactorSettings.tsx @@ -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 ( + + + {t(labels.twoFactorAuth)} + + {twoFactorEnabled ? t(labels.twoFactorActive) : t(labels.twoFactorStatusNotConfigured)} + + + + + {t(messages.twoFactorRequireUserDescription)} + + + + + + {t(labels.twoFactorRequireUser)} + + {t(labels.twoFactorGlobalActiveTooltip)} + + + ); +}