diff --git a/prisma/migrations/22_add_2fa/migration.sql b/prisma/migrations/22_add_2fa/migration.sql
index 04bb955e4..1bffd9d16 100644
--- a/prisma/migrations/22_add_2fa/migration.sql
+++ b/prisma/migrations/22_add_2fa/migration.sql
@@ -66,4 +66,8 @@ CREATE INDEX "two_factor_backup_code_user_id_idx" ON "two_factor_backup_code"("u
CREATE UNIQUE INDEX "two_factor_otp_used_user_id_otp_key" ON "two_factor_otp_used"("user_id", "otp");
-- CreateIndex
-CREATE UNIQUE INDEX "two_factor_rate_limit_user_id_key" ON "two_factor_rate_limit"("user_id");
\ No newline at end of file
+CREATE UNIQUE INDEX "two_factor_rate_limit_user_id_key" ON "two_factor_rate_limit"("user_id");
+
+-- Historical catch-up: some environments already have this replay index,
+-- but the current schema expects it everywhere.
+CREATE INDEX IF NOT EXISTS "session_replay_visit_id_idx" ON "session_replay"("visit_id");
\ No newline at end of file
diff --git a/public/intl/messages/en-US.json b/public/intl/messages/en-US.json
index f35cf4522..97078ba27 100644
--- a/public/intl/messages/en-US.json
+++ b/public/intl/messages/en-US.json
@@ -419,10 +419,10 @@
"two-factor-require-global": "Require 2FA for all users",
"two-factor-require-user": "Require 2FA for this user",
"two-factor-require-team": "Require 2FA for all members of this team",
+ "two-factor-reset": "Reset 2FA",
"two-factor-global-active-tooltip": "Global 2FA is enabled",
"two-factor-verify": "Verify",
- "two-factor-login": "Two-factor authentication",
- "close": "Close"
+ "two-factor-login": "Two-factor authentication"
},
"message": {
"action-confirmation": "Type {confirmation} in the box below to confirm.",
@@ -492,6 +492,7 @@
"two-factor-require-user-description": "When enabled, this user will be prompted to set up 2FA on their next login if they haven't already.",
"two-factor-require-team-description": "When enabled, all members of this team will be prompted to set up 2FA on their next login if they haven't already.",
"two-factor-user-description": "Add an extra layer of security to your account. Each time you log in, you will need your password and a code from your authenticator app.",
+ "two-factor-reset-description": "Remove this user's current two-factor configuration. If 2FA is still required by policy, they will be asked to scan a new QR code on their next login.",
"two-factor-error-invalid-code": "Invalid verification code. Please try again.",
"two-factor-error-missing-token": "Authentication token is missing.",
"two-factor-error-invalid-partial-token": "Invalid or expired authentication token.",
diff --git a/public/intl/messages/fr-FR.json b/public/intl/messages/fr-FR.json
index 7431e9c84..42de24570 100644
--- a/public/intl/messages/fr-FR.json
+++ b/public/intl/messages/fr-FR.json
@@ -410,10 +410,10 @@
"two-factor-require-global": "Rendre l’authentification à deux facteurs obligatoire pour tous les utilisateurs",
"two-factor-require-user": "Rendre l’authentification à deux facteurs obligatoire pour cet utilisateur",
"two-factor-require-team": "Rendre l’authentification à deux facteurs oblitatoire pour tous les membres de cette équipe",
+ "two-factor-reset": "Reinitialiser la 2FA",
"two-factor-global-active-tooltip": "L’authentification à deux facteurs est obligatoire pour tous les utilisateurs",
"two-factor-verify": "Vérifier",
- "two-factor-login": "Authentification à deux facteurs",
- "close": "Fermer"
+ "two-factor-login": "Authentification à deux facteurs"
},
"message": {
"action-confirmation": "Taper {confirmation} ci-dessous pour confirmer.",
@@ -483,6 +483,7 @@
"two-factor-require-user-description": "Lorsque cette option est activée, cet utilisateur sera invité à configurer l’authentification à deux facteurs lors de sa prochaine connexion s’il ne l'a pas déjà fait.",
"two-factor-require-team-description": "Lorsque cette option est activée, tous les membres de cette équipe seront invités à configurer l’authentification à deux facteurs lors de leur prochaine connexion s’ils ne l’ont pas déjà fait.",
"two-factor-user-description": "Ajoutez une couche de sécurité supplémentaire à votre compte. À chaque connexion, vous aurez besoin de votre mot de passe et d'un code provenant de votre application d’authentification.",
+ "two-factor-reset-description": "Supprime la configuration 2FA actuelle de cet utilisateur. Si la 2FA reste obligatoire par politique, il devra scanner un nouveau QR code lors de sa prochaine connexion.",
"two-factor-error-invalid-code": "Code de vérification invalide. Veuillez réessayer.",
"two-factor-error-missing-token": "Le jeton d'authentification est manquant.",
"two-factor-error-invalid-partial-token": "Jeton d'authentification invalide ou expiré.",
diff --git a/src/app/(main)/admin/users/[userId]/UserTwoFactorSettings.tsx b/src/app/(main)/admin/users/[userId]/UserTwoFactorSettings.tsx
index f2cecfe58..a577bab7a 100644
--- a/src/app/(main)/admin/users/[userId]/UserTwoFactorSettings.tsx
+++ b/src/app/(main)/admin/users/[userId]/UserTwoFactorSettings.tsx
@@ -1,7 +1,10 @@
'use client';
-import { Column, Row, Switch, Text, Tooltip, TooltipTrigger } from '@umami/react-zen';
+import { Button, Column, Dialog, Modal, Row, Switch, Text, Tooltip, TooltipTrigger } from '@umami/react-zen';
+import { useState } from 'react';
import { Badge } from '@/components/common/Badge';
+import { TypeConfirmationForm } from '@/components/common/TypeConfirmationForm';
import {
+ useDeleteQuery,
useMessages,
useUpdateQuery,
useTwoFactorStatusQuery,
@@ -10,9 +13,12 @@ import {
} from '@/components/hooks';
import { useQueryClient } from '@tanstack/react-query';
+const CONFIRM_VALUE = 'RESET';
+
export function UserTwoFactorSettings({ userId }: { userId: string }) {
const { t, labels, messages } = useMessages();
const queryClient = useQueryClient();
+ const [showReset, setShowReset] = useState(false);
const { data: globalStatus } = useTwoFactorStatusQuery(true);
const isGlobalRequired = globalStatus?.requiredReason === 'global';
@@ -21,6 +27,9 @@ export function UserTwoFactorSettings({ userId }: { userId: string }) {
const { data: userData } = useUserQuery(userId);
const { mutateAsync: setUserRequired } = useUpdateQuery(`/admin/users/${userId}/2fa`);
+ const { mutateAsync: resetUserTwoFactor, isPending: isResetting, error: resetError } = useDeleteQuery(
+ `/admin/users/${userId}/2fa`,
+ );
const twoFactorEnabled = !!(userTfaData as any)?.isEnabled;
const twoFactorRequired = !!(userData as any)?.twoFactorRequired;
@@ -30,30 +39,69 @@ export function UserTwoFactorSettings({ userId }: { userId: string }) {
queryClient.invalidateQueries({ queryKey: ['users', { userId }] });
};
+ const handleReset = async () => {
+ await resetUserTwoFactor(null, {
+ onSuccess: async () => {
+ await queryClient.invalidateQueries({ queryKey: ['user-2fa-status', userId] });
+ setShowReset(false);
+ },
+ });
+ };
+
return (
-
-
- {t(labels.twoFactorAuth)}
-
- {twoFactorEnabled ? t(labels.twoFactorActive) : t(labels.twoFactorStatusNotConfigured)}
-
-
-
-
- {t(messages.twoFactorRequireUserDescription)}
-
-
-
+ <>
+
-
- {t(labels.twoFactorRequireUser)}
+ {t(labels.twoFactorAuth)}
+
+ {twoFactorEnabled ? t(labels.twoFactorActive) : t(labels.twoFactorStatusNotConfigured)}
+
- {t(labels.twoFactorGlobalActiveTooltip)}
-
-
+
+
+ {t(messages.twoFactorRequireUserDescription)}
+
+
+
+
+
+ {t(labels.twoFactorRequireUser)}
+
+ {t(labels.twoFactorGlobalActiveTooltip)}
+
+
+ {twoFactorEnabled && (
+
+ {t(labels.twoFactorReset)}
+
+ {t(messages.twoFactorResetDescription)}
+
+
+
+
+
+ )}
+
+
+ !open && setShowReset(false)}>
+
+
+ >
);
}
diff --git a/src/app/api/admin/users/[userId]/2fa/route.test.ts b/src/app/api/admin/users/[userId]/2fa/route.test.ts
new file mode 100644
index 000000000..763e6e0fa
--- /dev/null
+++ b/src/app/api/admin/users/[userId]/2fa/route.test.ts
@@ -0,0 +1,146 @@
+import { beforeEach, expect, test, vi } from 'vitest';
+import { canEnforceTwoFactorAuthForUser } from '@/permissions';
+import prisma from '@/lib/prisma';
+import { parseRequest } from '@/lib/request';
+import { updateUser } from '@/queries/prisma/user';
+import { DELETE, GET, POST } from './route';
+
+vi.mock('@/lib/request', () => ({
+ parseRequest: vi.fn(),
+}));
+
+vi.mock('@/permissions', () => ({
+ canEnforceTwoFactorAuthForUser: vi.fn(),
+}));
+
+vi.mock('@/queries/prisma/user', () => ({
+ updateUser: vi.fn(),
+}));
+
+vi.mock('@/lib/prisma', () => ({
+ default: {
+ client: {
+ twoFactorAuth: {
+ findUnique: vi.fn(),
+ deleteMany: vi.fn(),
+ },
+ twoFactorBackupCode: {
+ deleteMany: vi.fn(),
+ },
+ twoFactorOtpUsed: {
+ deleteMany: vi.fn(),
+ },
+ twoFactorRateLimit: {
+ deleteMany: vi.fn(),
+ },
+ },
+ transaction: vi.fn(),
+ },
+}));
+
+const parseRequestMock = vi.mocked(parseRequest);
+const canEnforceTwoFactorAuthForUserMock = vi.mocked(canEnforceTwoFactorAuthForUser);
+const updateUserMock = vi.mocked(updateUser);
+const prismaMock = vi.mocked(prisma, true);
+
+beforeEach(() => {
+ parseRequestMock.mockReset();
+ canEnforceTwoFactorAuthForUserMock.mockReset();
+ updateUserMock.mockReset();
+ prismaMock.client.twoFactorAuth.findUnique.mockReset();
+ prismaMock.client.twoFactorAuth.deleteMany.mockReset();
+ prismaMock.client.twoFactorBackupCode.deleteMany.mockReset();
+ prismaMock.client.twoFactorOtpUsed.deleteMany.mockReset();
+ prismaMock.client.twoFactorRateLimit.deleteMany.mockReset();
+ prismaMock.transaction.mockReset();
+
+ parseRequestMock.mockResolvedValue({
+ auth: {
+ user: {
+ id: 'admin-1',
+ isAdmin: true,
+ },
+ },
+ error: undefined,
+ });
+ canEnforceTwoFactorAuthForUserMock.mockResolvedValue(true);
+});
+
+test('GET returns whether 2FA is enabled for the target user', async () => {
+ prismaMock.client.twoFactorAuth.findUnique.mockResolvedValue({
+ userId: 'user-1',
+ isEnabled: true,
+ } as any);
+
+ const response = await GET(new Request('http://localhost/api/admin/users/user-1/2fa'), {
+ params: Promise.resolve({ userId: 'user-1' }),
+ });
+
+ await expect(response.json()).resolves.toEqual({ isEnabled: true });
+ expect(response.status).toBe(200);
+});
+
+test('POST updates the user-level 2FA requirement flag', async () => {
+ parseRequestMock.mockResolvedValue({
+ auth: {
+ user: {
+ id: 'admin-1',
+ isAdmin: true,
+ },
+ },
+ body: {
+ required: true,
+ },
+ error: undefined,
+ });
+ updateUserMock.mockResolvedValue({ id: 'user-1' } as any);
+
+ const response = await POST(new Request('http://localhost/api/admin/users/user-1/2fa', { method: 'POST' }), {
+ params: Promise.resolve({ userId: 'user-1' }),
+ });
+
+ expect(updateUserMock).toHaveBeenCalledWith('user-1', { twoFactorRequired: true });
+ await expect(response.json()).resolves.toEqual({
+ ok: true,
+ userId: 'user-1',
+ twoFactorRequired: true,
+ });
+ expect(response.status).toBe(200);
+});
+
+test('DELETE clears the user 2FA configuration and related support tables', async () => {
+ prismaMock.client.twoFactorAuth.deleteMany.mockResolvedValue({ count: 1 } as any);
+ prismaMock.client.twoFactorBackupCode.deleteMany.mockResolvedValue({ count: 8 } as any);
+ prismaMock.client.twoFactorOtpUsed.deleteMany.mockResolvedValue({ count: 2 } as any);
+ prismaMock.client.twoFactorRateLimit.deleteMany.mockResolvedValue({ count: 1 } as any);
+ prismaMock.transaction.mockImplementation(async operations => Promise.all(operations));
+
+ const response = await DELETE(
+ new Request('http://localhost/api/admin/users/user-1/2fa', { method: 'DELETE' }),
+ {
+ params: Promise.resolve({ userId: 'user-1' }),
+ },
+ );
+
+ expect(prismaMock.client.twoFactorAuth.deleteMany).toHaveBeenCalledWith({ where: { userId: 'user-1' } });
+ expect(prismaMock.client.twoFactorBackupCode.deleteMany).toHaveBeenCalledWith({
+ where: { userId: 'user-1' },
+ });
+ expect(prismaMock.client.twoFactorOtpUsed.deleteMany).toHaveBeenCalledWith({
+ where: { userId: 'user-1' },
+ });
+ expect(prismaMock.client.twoFactorRateLimit.deleteMany).toHaveBeenCalledWith({
+ where: { userId: 'user-1' },
+ });
+ await expect(response.json()).resolves.toEqual({
+ ok: true,
+ userId: 'user-1',
+ reset: {
+ twoFactorAuth: 1,
+ backupCodes: 8,
+ otpUsed: 2,
+ rateLimit: 1,
+ },
+ });
+ expect(response.status).toBe(200);
+});
diff --git a/src/app/api/admin/users/[userId]/2fa/route.ts b/src/app/api/admin/users/[userId]/2fa/route.ts
index 7cb09cd64..b110a9e00 100644
--- a/src/app/api/admin/users/[userId]/2fa/route.ts
+++ b/src/app/api/admin/users/[userId]/2fa/route.ts
@@ -43,3 +43,35 @@ export async function POST(request: Request, { params }: { params: Promise<{ use
return json({ ok: true, userId: user.id, twoFactorRequired: required });
}
+
+export async function DELETE(request: Request, { params }: { params: Promise<{ userId: string }> }) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ if (!(await canEnforceTwoFactorAuthForUser(auth))) {
+ return unauthorized();
+ }
+
+ const { userId } = await params;
+
+ const [twoFactorAuth, backupCodes, otpUsed, rateLimit] = await prisma.transaction([
+ prisma.client.twoFactorAuth.deleteMany({ where: { userId } }),
+ prisma.client.twoFactorBackupCode.deleteMany({ where: { userId } }),
+ prisma.client.twoFactorOtpUsed.deleteMany({ where: { userId } }),
+ prisma.client.twoFactorRateLimit.deleteMany({ where: { userId } }),
+ ]);
+
+ return json({
+ ok: true,
+ userId,
+ reset: {
+ twoFactorAuth: twoFactorAuth.count,
+ backupCodes: backupCodes.count,
+ otpUsed: otpUsed.count,
+ rateLimit: rateLimit.count,
+ },
+ });
+}
diff --git a/src/components/messages.ts b/src/components/messages.ts
index 4e474e56a..14feeafdd 100644
--- a/src/components/messages.ts
+++ b/src/components/messages.ts
@@ -422,6 +422,7 @@ export const labels: Record = {
twoFactorRequireGlobal: 'label.two-factor-require-global',
twoFactorRequireUser: 'label.two-factor-require-user',
twoFactorRequireTeam: 'label.two-factor-require-team',
+ twoFactorReset: 'label.two-factor-reset',
twoFactorGlobalActiveTooltip: 'label.two-factor-global-active-tooltip',
twoFactorVerify: 'label.two-factor-verify',
twoFactorLogin: 'label.two-factor-login',
@@ -492,6 +493,7 @@ export const messages: Record = {
twoFactorUserDescription: 'message.two-factor-user-description',
twoFactorLockedMessage: 'message.two-factor-locked-message',
twoFactorActiveDescription: 'message.two-factor-active-description',
+ twoFactorResetDescription: 'message.two-factor-reset-description',
twoFactorErrorInvalidCode: 'message.two-factor-error-invalid-code',
twoFactorErrorMissingToken: 'message.two-factor-error-missing-token',
twoFactorErrorInvalidPartialToken: 'message.two-factor-error-invalid-partial-token',