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)}
+
+
+ );
+}