+ {digits.map((digit, i) => (
+ {
+ refs.current[i] = el;
+ }}
+ >
+ handleInput(i, char)}
+ onKeyDown={e => handleKeyDown(i, e)}
+ onPaste={handlePaste}
+ isDisabled={disabled}
+ inputMode="numeric"
+ maxLength={1}
+ autoComplete="one-time-code"
+ aria-label={`Digit ${i + 1}`}
+ placeholder="0"
+ style={{ width: 32 }}
+ />
+
+ ))}
+
+ );
+}
diff --git a/src/components/hooks/index.ts b/src/components/hooks/index.ts
index 4f3824001..bff1d0f95 100644
--- a/src/components/hooks/index.ts
+++ b/src/components/hooks/index.ts
@@ -24,6 +24,9 @@ export * from './queries/useLinkQuery';
export * from './queries/useLinkSharesQuery';
export * from './queries/useLinksQuery';
export * from './queries/useLoginQuery';
+export * from './queries/useTwoFactorStatusQuery';
+export * from './queries/useTwoFactorUserStatus';
+export * from './queries/useTwoFactorVerifyMutation';
export * from './queries/usePixelQuery';
export * from './queries/usePixelSharesQuery';
export * from './queries/usePixelsQuery';
diff --git a/src/components/hooks/queries/useTwoFactorStatusQuery.ts b/src/components/hooks/queries/useTwoFactorStatusQuery.ts
new file mode 100644
index 000000000..17720fece
--- /dev/null
+++ b/src/components/hooks/queries/useTwoFactorStatusQuery.ts
@@ -0,0 +1,13 @@
+import { useApi } from '../useApi';
+
+export function useTwoFactorStatusQuery(enabled: boolean) {
+ const { get, useQuery } = useApi();
+
+ return useQuery({
+ queryKey: ['2fa-status'],
+ queryFn: () => get('/2fa/status'),
+ enabled,
+ refetchOnWindowFocus: true,
+ refetchOnReconnect: true,
+ });
+}
diff --git a/src/components/hooks/queries/useTwoFactorUserStatus.ts b/src/components/hooks/queries/useTwoFactorUserStatus.ts
new file mode 100644
index 000000000..96de5edfb
--- /dev/null
+++ b/src/components/hooks/queries/useTwoFactorUserStatus.ts
@@ -0,0 +1,12 @@
+import { useApi } from '../useApi';
+
+export function useTwoFactorUserStatusQuery(userId: string) {
+ const { get, useQuery } = useApi();
+
+ return useQuery({
+ queryKey: ['user-2fa-status', userId],
+ queryFn: () => get(`/admin/users/${userId}/2fa`),
+ refetchOnWindowFocus: true,
+ refetchOnReconnect: true,
+ });
+}
diff --git a/src/components/hooks/queries/useTwoFactorVerifyMutation.ts b/src/components/hooks/queries/useTwoFactorVerifyMutation.ts
new file mode 100644
index 000000000..b869a26dd
--- /dev/null
+++ b/src/components/hooks/queries/useTwoFactorVerifyMutation.ts
@@ -0,0 +1,29 @@
+import { httpPost } from '@/lib/fetch';
+import { useApi } from '../useApi';
+
+interface TwoFactorVerifyParams {
+ partialToken: string;
+ token?: string;
+ backupCode?: string;
+}
+
+export function useTwoFactorVerifyMutation() {
+ const { useMutation } = useApi();
+ const basePath = process.env.basePath || '';
+
+ return useMutation({
+ mutationFn: async ({ partialToken, token, backupCode }: TwoFactorVerifyParams) => {
+ const body = backupCode ? { backupCode } : { token };
+ const res = await httpPost(`${basePath}/api/2fa/verify`, body, {
+ Authorization: `Bearer ${partialToken}`,
+ });
+
+ if (!res.ok) {
+ const { message, code, lockedUntil } = res?.data?.error || {};
+ throw Object.assign(new Error(message), { code, status: res.status, lockedUntil });
+ }
+
+ return res.data as { token: string; user: any };
+ },
+ });
+}
diff --git a/src/components/messages.ts b/src/components/messages.ts
index 63dca3bb2..2016506db 100644
--- a/src/components/messages.ts
+++ b/src/components/messages.ts
@@ -395,6 +395,36 @@ export const labels: Record