From 190d4c2a002c6d3d240ff18480e37217fcbeae12 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Thu, 16 Jul 2026 18:07:27 +0800 Subject: [PATCH] fix(auth): close the MFA policy lockout and add self-service enrollment (#531) Fixes #529 (opened investigating #515). Setting MFA policy to "required"/"admins only" saved regardless of whether the mfa enterprise feature was licensed, and there was no enrollment UI at all, so any instance that flipped the toggle locked every unenrolled user out with no way back in. The login page and Settings save also both collapsed the resulting error into a generic message, hiding the real reason. - Reject saving mfaPolicy to admins_only/required server-side unless mfa is licensed - Surface the specific server error on login and on a failed settings save instead of a generic fallback - Add a self-service two-factor authentication enrollment flow (QR code, manual entry, recovery codes, verify, disable) so a licensed admin can actually satisfy the policy before it's enforced - Fix a pending-enrollment dead end, silent error swallowing in verify/disable, and a silent clipboard-copy failure on the recovery codes screen - Add the integration test that actually proves the fix: a real login attempt returns 403 MFA_ENROLLMENT_REQUIRED --- apps/api/src/plugins/auth.ts | 1 + apps/api/src/plugins/mfa.ts | 13 +- apps/api/src/routes/settings.ts | 19 ++ .../components/settings/settings-dialog.tsx | 22 +- .../settings/two-factor-settings.tsx | 322 ++++++++++++++++++ apps/web/src/hooks/use-auth.ts | 5 + apps/web/src/pages/login-page.tsx | 7 +- packages/shared/src/i18n/ar.ts | 22 ++ packages/shared/src/i18n/de.ts | 26 ++ packages/shared/src/i18n/en.ts | 25 ++ packages/shared/src/i18n/es.ts | 26 ++ packages/shared/src/i18n/fr.ts | 26 ++ packages/shared/src/i18n/hi.ts | 23 ++ packages/shared/src/i18n/id.ts | 25 ++ packages/shared/src/i18n/it.ts | 27 ++ packages/shared/src/i18n/ja.ts | 23 ++ packages/shared/src/i18n/ko.ts | 23 ++ packages/shared/src/i18n/nl.ts | 25 ++ packages/shared/src/i18n/pl.ts | 25 ++ packages/shared/src/i18n/pt-BR.ts | 26 ++ packages/shared/src/i18n/ru.ts | 25 ++ packages/shared/src/i18n/sv.ts | 26 ++ packages/shared/src/i18n/th.ts | 22 ++ packages/shared/src/i18n/tr.ts | 25 ++ packages/shared/src/i18n/uk.ts | 25 ++ packages/shared/src/i18n/vi.ts | 23 ++ packages/shared/src/i18n/zh-CN.ts | 22 ++ packages/shared/src/i18n/zh-TW.ts | 22 ++ .../platform/mfa-endpoints.test.ts | 185 +++++++++- .../platform/mfa-policy-license-gate.test.ts | 90 +++++ ...dmin-security-settings-save-error.test.tsx | 47 +++ tests/unit/web/anonymous-auth.test.ts | 51 +++ tests/unit/web/login-page-mfa-error.test.tsx | 81 +++++ tests/unit/web/two-factor-settings.test.tsx | 288 ++++++++++++++++ 34 files changed, 1624 insertions(+), 19 deletions(-) create mode 100644 apps/web/src/components/settings/two-factor-settings.tsx create mode 100644 tests/integration/platform/mfa-policy-license-gate.test.ts create mode 100644 tests/unit/web/admin-security-settings-save-error.test.tsx create mode 100644 tests/unit/web/login-page-mfa-error.test.tsx create mode 100644 tests/unit/web/two-factor-settings.test.tsx diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts index 918bbd4c..aa84516c 100644 --- a/apps/api/src/plugins/auth.ts +++ b/apps/api/src/plugins/auth.ts @@ -590,6 +590,7 @@ export async function authRoutes(app: FastifyInstance): Promise { email: user.email ?? null, hasLocalPassword: !!user.passwordHash, hasOidcLink: !!user.externalId, + totpEnabled: user.totpEnabled, }, expiresAt: session.expiresAt.toISOString(), }); diff --git a/apps/api/src/plugins/mfa.ts b/apps/api/src/plugins/mfa.ts index fbe9c305..0b1d2fcc 100644 --- a/apps/api/src/plugins/mfa.ts +++ b/apps/api/src/plugins/mfa.ts @@ -145,14 +145,11 @@ export async function registerMfa(app: FastifyInstance): Promise { }); } - // Check if there's already a pending (unverified) enrollment - if (dbUser.totpSecret && !dbUser.totpEnabled) { - return reply.status(409).send({ - error: - "MFA enrollment already pending. Complete verification first or contact an admin to reset.", - code: "MFA_ENROLLMENT_PENDING", - }); - } + // A pending (unverified) enrollment from an earlier attempt the user + // canceled or abandoned is intentionally overwritten below rather than + // blocked: it was never activated, so nothing depends on it, and + // starting fresh is strictly safer than leaving a stale, possibly + // already-screenshotted QR code valid. // Generate TOTP secret const totp = createTotp(user.username); diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts index b61959d3..ba48b0ec 100644 --- a/apps/api/src/routes/settings.ts +++ b/apps/api/src/routes/settings.ts @@ -139,6 +139,25 @@ export async function settingsRoutes(app: FastifyInstance): Promise { }); } + // Enforcing MFA requires a way to actually enroll, which is gated behind + // the "mfa" enterprise feature. Letting this save through on an unlicensed + // instance creates a login rule nobody can satisfy (snapotter-hq/SnapOtter#515). + if (key === "mfaPolicy" && (strValue === "admins_only" || strValue === "required")) { + let mfaLicensed = false; + try { + const { isFeatureEnabled } = await import("@snapotter/enterprise"); + mfaLicensed = isFeatureEnabled("mfa"); + } catch { + // Enterprise package not available + } + if (!mfaLicensed) { + return reply.status(403).send({ + error: "MFA requires an enterprise license", + code: "FEATURE_NOT_LICENSED", + }); + } + } + entries.push({ key, strValue }); } diff --git a/apps/web/src/components/settings/settings-dialog.tsx b/apps/web/src/components/settings/settings-dialog.tsx index 1fb3ab92..7b43a9be 100644 --- a/apps/web/src/components/settings/settings-dialog.tsx +++ b/apps/web/src/components/settings/settings-dialog.tsx @@ -45,6 +45,7 @@ import { OtterLogo } from "../common/otter-logo"; import { AdminInstallFeedbackCard } from "../feedback/admin-install-feedback-card"; import { FeedbackDialog } from "../feedback/feedback-dialog"; import { AiFeaturesSection } from "./ai-features-section"; +import { TwoFactorSettings } from "./two-factor-settings"; import { UsageSection } from "./usage-section"; interface SettingsDialogProps { @@ -1068,12 +1069,14 @@ function SecuritySection() {

{t.settings.security.loginAttemptLimitNote}

+ + {hasPermission("settings:write") && } ); } -function AdminSecuritySettings() { +export function AdminSecuritySettings() { const { t } = useTranslation(); const [settings, setSettings] = useState>({}); // Snapshot of the last server state; a save sends only the fields this tab changed @@ -1082,7 +1085,7 @@ function AdminSecuritySettings() { const originalSettingsRef = useRef>({}); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); - const [saveMsg, setSaveMsg] = useState(null); + const [saveMsg, setSaveMsg] = useState<{ type: "success" | "error"; text: string } | null>(null); useEffect(() => { apiGet<{ settings: Record }>("/v1/settings") @@ -1107,9 +1110,12 @@ function AdminSecuritySettings() { writableSettings(changedSettings(originalSettingsRef.current, settings)), ); originalSettingsRef.current = { ...settings }; - setSaveMsg(t.settings.security.securitySettingsSaved); - } catch { - setSaveMsg(t.settings.security.securitySettingsFailed); + setSaveMsg({ type: "success", text: t.settings.security.securitySettingsSaved }); + } catch (err) { + setSaveMsg({ + type: "error", + text: err instanceof Error ? err.message : t.settings.security.securitySettingsFailed, + }); } finally { setSaving(false); setTimeout(() => setSaveMsg(null), 3000); @@ -1341,12 +1347,10 @@ function AdminSecuritySettings() { - {saveMsg} + {saveMsg.text} )} diff --git a/apps/web/src/components/settings/two-factor-settings.tsx b/apps/web/src/components/settings/two-factor-settings.tsx new file mode 100644 index 00000000..6c7a2da8 --- /dev/null +++ b/apps/web/src/components/settings/two-factor-settings.tsx @@ -0,0 +1,322 @@ +import QRCodeStyling from "qr-code-styling"; +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useAuth } from "@/hooks/use-auth"; +import { apiPost } from "@/lib/api"; +import { cn, copyToClipboard } from "@/lib/utils"; + +type Step = "idle" | "enrolling" | "disabling"; + +interface EnrollResponse { + uri: string; + recoveryCodes: string[]; +} + +function parseManualSecret(uri: string): string { + try { + const params = new URL(uri).searchParams; + return params.get("secret") ?? ""; + } catch { + return ""; + } +} + +function QrCode({ uri }: { uri: string }) { + const containerRef = useRef(null); + + useEffect(() => { + const qr = new QRCodeStyling({ + width: 200, + height: 200, + data: uri, + margin: 8, + dotsOptions: { type: "square", color: "#000000" }, + backgroundOptions: { color: "#ffffff" }, + } as never); + const el = containerRef.current; + if (el) { + while (el.firstChild) el.removeChild(el.firstChild); + qr.append(el); + } + }, [uri]); + + return ( +
+ ); +} + +export function TwoFactorSettings() { + const { t } = useTranslation(); + const { totpEnabled: initialEnabled } = useAuth(); + const [enabled, setEnabled] = useState(initialEnabled); + const [step, setStep] = useState("idle"); + const [enrollment, setEnrollment] = useState(null); + const [code, setCode] = useState(""); + const [codesCopied, setCodesCopied] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); + + useEffect(() => { + setEnabled(initialEnabled); + }, [initialEnabled]); + + const resetToIdle = () => { + setStep("idle"); + setEnrollment(null); + setCode(""); + setCodesCopied(false); + setMessage(null); + }; + + const handleEnrollStart = async () => { + setSubmitting(true); + setMessage(null); + try { + const res = await apiPost("/auth/mfa/enroll"); + setEnrollment(res); + setStep("enrolling"); + } catch (err) { + setMessage({ + type: "error", + text: err instanceof Error ? err.message : t.settings.security.securitySettingsFailed, + }); + } finally { + setSubmitting(false); + } + }; + + const handleVerify = async () => { + setSubmitting(true); + setMessage(null); + try { + await apiPost("/auth/mfa/verify", { code }); + setEnabled(true); + setMessage({ type: "success", text: t.settings.security.twoFactorEnableSuccess }); + setStep("idle"); + setEnrollment(null); + setCode(""); + setCodesCopied(false); + } catch (err) { + setMessage({ + type: "error", + text: err instanceof Error ? err.message : t.auth.mfaInvalidCode, + }); + } finally { + setSubmitting(false); + } + }; + + const handleDisable = async () => { + setSubmitting(true); + setMessage(null); + try { + await apiPost("/auth/mfa/disable", { code }); + setEnabled(false); + setMessage({ type: "success", text: t.settings.security.twoFactorDisableSuccess }); + setStep("idle"); + setCode(""); + } catch (err) { + setMessage({ + type: "error", + text: err instanceof Error ? err.message : t.auth.mfaInvalidCode, + }); + } finally { + setSubmitting(false); + } + }; + + const handleCopyCodes = async () => { + if (!enrollment) return; + const ok = await copyToClipboard(enrollment.recoveryCodes.join("\n")); + if (ok) { + setCodesCopied(true); + setTimeout(() => setCodesCopied(false), 2000); + } else { + setMessage({ type: "error", text: t.settings.security.twoFactorCopyFailed }); + } + }; + + return ( +
+
+

+ {t.settings.security.twoFactorHeading} +

+

+ {t.settings.security.twoFactorDescription} +

+
+ + {step === "idle" && ( +
+

+ {enabled + ? t.settings.security.twoFactorEnabledStatus + : t.settings.security.twoFactorDisabledStatus} +

+ +
+ )} + + {step === "enrolling" && enrollment && ( +
+

{t.settings.security.twoFactorScanQr}

+ +
+

+ {t.settings.security.twoFactorManualEntry} +

+ + {parseManualSecret(enrollment.uri)} + +
+ +
+

+ {t.settings.security.twoFactorRecoveryCodesHeading} +

+

+ {t.settings.security.twoFactorRecoveryCodesDescription} +

+
+ {enrollment.recoveryCodes.map((rc) => ( + {rc} + ))} +
+ +
+ +
+ + setCode(e.target.value.replace(/[^0-9]/g, ""))} + placeholder={t.settings.security.twoFactorCodePlaceholder} + maxLength={6} + className="w-40 px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground tracking-widest" + /> +
+ + {message && ( +

+ {message.text} +

+ )} + +
+ + +
+
+ )} + + {step === "disabling" && ( +
+

{t.settings.security.twoFactorDisablePrompt}

+ setCode(e.target.value.replace(/[^0-9]/g, ""))} + placeholder={t.settings.security.twoFactorCodePlaceholder} + maxLength={6} + aria-label={t.settings.security.twoFactorEnterCode} + className="w-40 px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground tracking-widest" + /> + + {message && ( +

+ {message.text} +

+ )} + +
+ + +
+
+ )} + + {step === "idle" && message && ( +

+ {message.text} +

+ )} +
+ ); +} diff --git a/apps/web/src/hooks/use-auth.ts b/apps/web/src/hooks/use-auth.ts index 5a49a8b4..53b39dd6 100644 --- a/apps/web/src/hooks/use-auth.ts +++ b/apps/web/src/hooks/use-auth.ts @@ -17,6 +17,7 @@ interface AuthState { ssoEnforced: boolean; loginMethod: string | null; hasLocalPassword: boolean; + totpEnabled: boolean; } const ANON_ADMIN_PERMISSIONS = [ @@ -52,6 +53,7 @@ export function useAuth() { ssoEnforced: false, loginMethod: null, hasLocalPassword: false, + totpEnabled: false, }); useEffect(() => { @@ -79,6 +81,7 @@ export function useAuth() { ssoEnforced: false, loginMethod: null, hasLocalPassword: false, + totpEnabled: false, }); return; } @@ -108,6 +111,7 @@ export function useAuth() { ssoEnforced: config.ssoEnforced ?? false, loginMethod: session.user?.loginMethod ?? null, hasLocalPassword: session.user?.hasLocalPassword ?? false, + totpEnabled: session.user?.totpEnabled === true, }); } else { clearToken(); @@ -127,6 +131,7 @@ export function useAuth() { ssoEnforced: config.ssoEnforced ?? false, loginMethod: null, hasLocalPassword: false, + totpEnabled: false, }); } } catch { diff --git a/apps/web/src/pages/login-page.tsx b/apps/web/src/pages/login-page.tsx index d0ea11f8..26521fc1 100644 --- a/apps/web/src/pages/login-page.tsx +++ b/apps/web/src/pages/login-page.tsx @@ -168,7 +168,12 @@ export function LoginPage() { body: JSON.stringify({ username, password }), }); if (!res.ok) { - setError(t.auth.invalidCredentials); + const failure = await res.json().catch(() => null); + setError( + failure?.code === "MFA_ENROLLMENT_REQUIRED" + ? t.auth.mfaEnrollmentRequired + : t.auth.invalidCredentials, + ); return; } const data = await res.json(); diff --git a/packages/shared/src/i18n/ar.ts b/packages/shared/src/i18n/ar.ts index 3c001597..4dccfd82 100644 --- a/packages/shared/src/i18n/ar.ts +++ b/packages/shared/src/i18n/ar.ts @@ -3286,6 +3286,28 @@ export const ar: TranslationKeys = { currentPasswordIncorrect: "كلمة المرور الحالية غير صحيحة", changePasswordButton: "تغيير كلمة المرور", loginAttemptLimitNote: "يمكن ضبط حدود محاولات تسجيل الدخول في إعدادات النظام.", + twoFactorHeading: "المصادقة الثنائية", + twoFactorDescription: "أضِف طبقة حماية إضافية إلى حسابك باستخدام تطبيق مصادقة.", + twoFactorEnabledStatus: "المصادقة الثنائية مفعّلة.", + twoFactorDisabledStatus: "المصادقة الثنائية غير مفعّلة.", + enableTwoFactorButton: "تفعيل المصادقة الثنائية", + disableTwoFactorButton: "تعطيل المصادقة الثنائية", + twoFactorScanQr: + "امسح رمز QR هذا بتطبيق المصادقة الخاص بك (مثل Google Authenticator أو Authy أو 1Password).", + twoFactorManualEntry: "تعذّر مسح الرمز؟ أدخل هذا المفتاح يدويًا:", + twoFactorRecoveryCodesHeading: "رموز الاسترداد", + twoFactorRecoveryCodesDescription: + "احفظ هذه الرموز في مكان آمن. يمكن استخدام كل رمز مرة واحدة لتسجيل الدخول إذا فقدت الوصول إلى تطبيق المصادقة.", + twoFactorCopyRecoveryCodes: "نسخ الرموز", + twoFactorCodesCopied: "تم النسخ", + twoFactorCopyFailed: "تعذّر النسخ تلقائيًا. حدد الرموز أعلاه وانسخها يدويًا.", + twoFactorEnterCode: "أدخل الرمز المكوّن من 6 أرقام من تطبيقك لتأكيد الإعداد.", + twoFactorCodePlaceholder: "000000", + twoFactorConfirmButton: "تأكيد وتفعيل", + twoFactorCancelButton: "إلغاء", + twoFactorEnableSuccess: "المصادقة الثنائية مفعّلة الآن.", + twoFactorDisableSuccess: "تم تعطيل المصادقة الثنائية.", + twoFactorDisablePrompt: "أدخل رمز المصادقة الحالي لتعطيل المصادقة الثنائية.", adminHeading: "إعدادات أمان المسؤول", adminDescription: "إعدادات سياسة الأمان للمؤسسات. تنطبق على جميع المستخدمين.", sessionIdleTimeout: "مهلة الجلسة الخاملة (بالدقائق)", diff --git a/packages/shared/src/i18n/de.ts b/packages/shared/src/i18n/de.ts index 9354d121..2bfe4847 100644 --- a/packages/shared/src/i18n/de.ts +++ b/packages/shared/src/i18n/de.ts @@ -3316,6 +3316,32 @@ export const de: TranslationKeys = { changePasswordButton: "Passwort ändern", loginAttemptLimitNote: "Anmeldeversuchslimits können in den Systemeinstellungen konfiguriert werden.", + twoFactorHeading: "Zwei-Faktor-Authentifizierung", + twoFactorDescription: "Schützen Sie Ihr Konto zusätzlich mit einer Authentifizierungs-App.", + twoFactorEnabledStatus: "Zwei-Faktor-Authentifizierung ist aktiviert.", + twoFactorDisabledStatus: "Zwei-Faktor-Authentifizierung ist nicht aktiviert.", + enableTwoFactorButton: "Zwei-Faktor-Authentifizierung aktivieren", + disableTwoFactorButton: "Zwei-Faktor-Authentifizierung deaktivieren", + twoFactorScanQr: + "Scannen Sie diesen QR-Code mit Ihrer Authentifizierungs-App (zum Beispiel Google Authenticator, Authy oder 1Password).", + twoFactorManualEntry: + "Code lässt sich nicht scannen? Geben Sie diesen Schlüssel manuell ein:", + twoFactorRecoveryCodesHeading: "Wiederherstellungscodes", + twoFactorRecoveryCodesDescription: + "Bewahren Sie diese Codes sicher auf. Jeder Code lässt sich einmal verwenden, um sich anzumelden, falls Sie den Zugriff auf Ihre Authentifizierungs-App verlieren.", + twoFactorCopyRecoveryCodes: "Codes kopieren", + twoFactorCodesCopied: "Kopiert", + twoFactorCopyFailed: + "Automatisches Kopieren fehlgeschlagen. Wählen Sie die Codes oben aus und kopieren Sie sie manuell.", + twoFactorEnterCode: + "Geben Sie den 6-stelligen Code aus Ihrer App ein, um die Einrichtung zu bestätigen.", + twoFactorCodePlaceholder: "000000", + twoFactorConfirmButton: "Bestätigen und aktivieren", + twoFactorCancelButton: "Abbrechen", + twoFactorEnableSuccess: "Zwei-Faktor-Authentifizierung ist jetzt aktiviert.", + twoFactorDisableSuccess: "Zwei-Faktor-Authentifizierung wurde deaktiviert.", + twoFactorDisablePrompt: + "Geben Sie Ihren aktuellen Code aus der Authentifizierungs-App ein, um die Zwei-Faktor-Authentifizierung zu deaktivieren.", adminHeading: "Admin-Sicherheitseinstellungen", adminDescription: "Enterprise-Sicherheitsrichtlinien. Diese gelten für alle Benutzer.", sessionIdleTimeout: "Sitzungszeitlimit bei Inaktivität (Minuten)", diff --git a/packages/shared/src/i18n/en.ts b/packages/shared/src/i18n/en.ts index 06075fe6..64367feb 100644 --- a/packages/shared/src/i18n/en.ts +++ b/packages/shared/src/i18n/en.ts @@ -3252,6 +3252,31 @@ export const en = { currentPasswordIncorrect: "Current password is incorrect", changePasswordButton: "Change Password", loginAttemptLimitNote: "Login attempt limits can be configured in System Settings.", + twoFactorHeading: "Two-Factor Authentication", + twoFactorDescription: + "Add an extra layer of security to your account with an authenticator app.", + twoFactorEnabledStatus: "Two-factor authentication is enabled.", + twoFactorDisabledStatus: "Two-factor authentication is not enabled.", + enableTwoFactorButton: "Enable two-factor authentication", + disableTwoFactorButton: "Disable two-factor authentication", + twoFactorScanQr: + "Scan this QR code with your authenticator app (such as Google Authenticator, Authy, or 1Password).", + twoFactorManualEntry: "Can't scan the code? Enter this key manually:", + twoFactorRecoveryCodesHeading: "Recovery codes", + twoFactorRecoveryCodesDescription: + "Save these codes somewhere safe. Each one can be used once to sign in if you lose access to your authenticator app.", + twoFactorCopyRecoveryCodes: "Copy codes", + twoFactorCodesCopied: "Copied", + twoFactorCopyFailed: + "Couldn't copy automatically. Select the codes above and copy them manually.", + twoFactorEnterCode: "Enter the 6-digit code from your app to confirm setup.", + twoFactorCodePlaceholder: "000000", + twoFactorConfirmButton: "Confirm and enable", + twoFactorCancelButton: "Cancel", + twoFactorEnableSuccess: "Two-factor authentication is now enabled.", + twoFactorDisableSuccess: "Two-factor authentication has been disabled.", + twoFactorDisablePrompt: + "Enter your current authenticator code to disable two-factor authentication.", adminHeading: "Admin Security Settings", adminDescription: "Enterprise security policy settings. These apply to all users.", sessionIdleTimeout: "Session Idle Timeout (minutes)", diff --git a/packages/shared/src/i18n/es.ts b/packages/shared/src/i18n/es.ts index 46f444f6..5b66c4b2 100644 --- a/packages/shared/src/i18n/es.ts +++ b/packages/shared/src/i18n/es.ts @@ -3296,6 +3296,32 @@ export const es: TranslationKeys = { changePasswordButton: "Cambiar contraseña", loginAttemptLimitNote: "Los límites de intentos de inicio de sesión se pueden configurar en Configuración del sistema.", + twoFactorHeading: "Autenticación en dos pasos", + twoFactorDescription: + "Añade una capa extra de seguridad a tu cuenta con una app de autenticación.", + twoFactorEnabledStatus: "La autenticación en dos pasos está activada.", + twoFactorDisabledStatus: "La autenticación en dos pasos no está activada.", + enableTwoFactorButton: "Activar la autenticación en dos pasos", + disableTwoFactorButton: "Desactivar la autenticación en dos pasos", + twoFactorScanQr: + "Escanea este código QR con tu app de autenticación (como Google Authenticator, Authy o 1Password).", + twoFactorManualEntry: "¿No puedes escanear el código? Introduce esta clave manualmente:", + twoFactorRecoveryCodesHeading: "Códigos de recuperación", + twoFactorRecoveryCodesDescription: + "Guarda estos códigos en un lugar seguro. Cada uno se puede usar una vez para iniciar sesión si pierdes el acceso a tu app de autenticación.", + twoFactorCopyRecoveryCodes: "Copiar códigos", + twoFactorCodesCopied: "Copiado", + twoFactorCopyFailed: + "No se pudieron copiar automáticamente. Selecciona los códigos de arriba y cópialos manualmente.", + twoFactorEnterCode: + "Introduce el código de 6 dígitos de tu app para confirmar la configuración.", + twoFactorCodePlaceholder: "000000", + twoFactorConfirmButton: "Confirmar y activar", + twoFactorCancelButton: "Cancelar", + twoFactorEnableSuccess: "La autenticación en dos pasos ya está activada.", + twoFactorDisableSuccess: "La autenticación en dos pasos se ha desactivado.", + twoFactorDisablePrompt: + "Introduce tu código de autenticación actual para desactivar la autenticación en dos pasos.", adminHeading: "Configuración de seguridad del administrador", adminDescription: "Configuración de políticas de seguridad empresarial. Se aplica a todos los usuarios.", diff --git a/packages/shared/src/i18n/fr.ts b/packages/shared/src/i18n/fr.ts index 505ba4db..2e73f235 100644 --- a/packages/shared/src/i18n/fr.ts +++ b/packages/shared/src/i18n/fr.ts @@ -3322,6 +3322,32 @@ export const fr: TranslationKeys = { changePasswordButton: "Changer le mot de passe", loginAttemptLimitNote: "Les limites de tentatives de connexion peuvent être configurées dans les Paramètres système.", + twoFactorHeading: "Authentification à deux facteurs", + twoFactorDescription: + "Ajoutez une couche de sécurité supplémentaire à votre compte avec une application d'authentification.", + twoFactorEnabledStatus: "L'authentification à deux facteurs est activée.", + twoFactorDisabledStatus: "L'authentification à deux facteurs n'est pas activée.", + enableTwoFactorButton: "Activer l'authentification à deux facteurs", + disableTwoFactorButton: "Désactiver l'authentification à deux facteurs", + twoFactorScanQr: + "Scannez ce QR code avec votre application d'authentification (par exemple Google Authenticator, Authy ou 1Password).", + twoFactorManualEntry: "Impossible de scanner le code ? Saisissez cette clé manuellement :", + twoFactorRecoveryCodesHeading: "Codes de récupération", + twoFactorRecoveryCodesDescription: + "Conservez ces codes en lieu sûr. Chacun peut servir une fois à vous connecter si vous perdez l'accès à votre application d'authentification.", + twoFactorCopyRecoveryCodes: "Copier les codes", + twoFactorCodesCopied: "Copié", + twoFactorCopyFailed: + "Impossible de copier automatiquement. Sélectionnez les codes ci-dessus et copiez-les manuellement.", + twoFactorEnterCode: + "Saisissez le code à 6 chiffres de votre application pour confirmer la configuration.", + twoFactorCodePlaceholder: "000000", + twoFactorConfirmButton: "Confirmer et activer", + twoFactorCancelButton: "Annuler", + twoFactorEnableSuccess: "L'authentification à deux facteurs est maintenant activée.", + twoFactorDisableSuccess: "L'authentification à deux facteurs a été désactivée.", + twoFactorDisablePrompt: + "Saisissez votre code d'authentification actuel pour désactiver l'authentification à deux facteurs.", adminHeading: "Paramètres de sécurité admin", adminDescription: "Paramètres de politique de sécurité entreprise. S'appliquent à tous les utilisateurs.", diff --git a/packages/shared/src/i18n/hi.ts b/packages/shared/src/i18n/hi.ts index 5cc87a92..d5396ad1 100644 --- a/packages/shared/src/i18n/hi.ts +++ b/packages/shared/src/i18n/hi.ts @@ -3116,6 +3116,29 @@ export const hi: TranslationKeys = { currentPasswordIncorrect: "मौजूदा पासवर्ड गलत है", changePasswordButton: "पासवर्ड बदलें", loginAttemptLimitNote: "लॉगिन प्रयास सीमाएं सिस्टम सेटिंग्स में कॉन्फ़िगर की जा सकती हैं।", + twoFactorHeading: "द्वि-कारक प्रमाणीकरण", + twoFactorDescription: "ऑथेंटिकेटर ऐप के साथ अपने खाते में सुरक्षा की एक अतिरिक्त परत जोड़ें।", + twoFactorEnabledStatus: "द्वि-कारक प्रमाणीकरण सक्षम है।", + twoFactorDisabledStatus: "द्वि-कारक प्रमाणीकरण सक्षम नहीं है।", + enableTwoFactorButton: "द्वि-कारक प्रमाणीकरण सक्षम करें", + disableTwoFactorButton: "द्वि-कारक प्रमाणीकरण अक्षम करें", + twoFactorScanQr: + "इस QR कोड को अपने ऑथेंटिकेटर ऐप (जैसे Google Authenticator, Authy या 1Password) से स्कैन करें।", + twoFactorManualEntry: "कोड स्कैन नहीं कर पा रहे? यह कुंजी मैन्युअल रूप से दर्ज करें:", + twoFactorRecoveryCodesHeading: "रिकवरी कोड", + twoFactorRecoveryCodesDescription: + "इन कोड को कहीं सुरक्षित रूप से सहेजें। अपने ऑथेंटिकेटर ऐप तक पहुंच खोने पर हर कोड का उपयोग साइन इन करने के लिए एक बार किया जा सकता है।", + twoFactorCopyRecoveryCodes: "कोड कॉपी करें", + twoFactorCodesCopied: "कॉपी किया गया", + twoFactorCopyFailed: + "स्वचालित रूप से कॉपी नहीं हो सका। ऊपर दिए गए कोड चुनें और उन्हें मैन्युअल रूप से कॉपी करें।", + twoFactorEnterCode: "सेटअप की पुष्टि के लिए अपने ऐप से 6-अंकों का कोड दर्ज करें।", + twoFactorCodePlaceholder: "000000", + twoFactorConfirmButton: "पुष्टि करें और सक्षम करें", + twoFactorCancelButton: "रद्द करें", + twoFactorEnableSuccess: "द्वि-कारक प्रमाणीकरण अब सक्षम है।", + twoFactorDisableSuccess: "द्वि-कारक प्रमाणीकरण अक्षम कर दिया गया है।", + twoFactorDisablePrompt: "द्वि-कारक प्रमाणीकरण अक्षम करने के लिए अपना मौजूदा ऑथेंटिकेटर कोड दर्ज करें।", adminHeading: "एडमिन सुरक्षा सेटिंग्स", adminDescription: "एंटरप्राइज़ सुरक्षा नीति सेटिंग्स। ये सभी उपयोगकर्ताओं पर लागू होती हैं।", sessionIdleTimeout: "सत्र निष्क्रियता टाइमआउट (मिनट)", diff --git a/packages/shared/src/i18n/id.ts b/packages/shared/src/i18n/id.ts index 203bca53..3e401632 100644 --- a/packages/shared/src/i18n/id.ts +++ b/packages/shared/src/i18n/id.ts @@ -3301,6 +3301,31 @@ export const id: TranslationKeys = { currentPasswordIncorrect: "Kata sandi saat ini salah", changePasswordButton: "Ubah Kata Sandi", loginAttemptLimitNote: "Batas percobaan login dapat dikonfigurasi di Pengaturan Sistem.", + twoFactorHeading: "Autentikasi Dua Faktor", + twoFactorDescription: + "Tambahkan lapisan keamanan ekstra ke akun Anda dengan aplikasi autentikator.", + twoFactorEnabledStatus: "Autentikasi dua faktor aktif.", + twoFactorDisabledStatus: "Autentikasi dua faktor tidak aktif.", + enableTwoFactorButton: "Aktifkan autentikasi dua faktor", + disableTwoFactorButton: "Nonaktifkan autentikasi dua faktor", + twoFactorScanQr: + "Pindai kode QR ini dengan aplikasi autentikator Anda (seperti Google Authenticator, Authy, atau 1Password).", + twoFactorManualEntry: "Tidak bisa memindai kode? Masukkan kunci ini secara manual:", + twoFactorRecoveryCodesHeading: "Kode pemulihan", + twoFactorRecoveryCodesDescription: + "Simpan kode ini di tempat yang aman. Setiap kode dapat digunakan sekali untuk masuk jika Anda kehilangan akses ke aplikasi autentikator.", + twoFactorCopyRecoveryCodes: "Salin kode", + twoFactorCodesCopied: "Disalin", + twoFactorCopyFailed: + "Tidak dapat menyalin secara otomatis. Pilih kode di atas dan salin secara manual.", + twoFactorEnterCode: "Masukkan kode 6 digit dari aplikasi Anda untuk mengonfirmasi penyiapan.", + twoFactorCodePlaceholder: "000000", + twoFactorConfirmButton: "Konfirmasi dan aktifkan", + twoFactorCancelButton: "Batal", + twoFactorEnableSuccess: "Autentikasi dua faktor kini aktif.", + twoFactorDisableSuccess: "Autentikasi dua faktor telah dinonaktifkan.", + twoFactorDisablePrompt: + "Masukkan kode autentikator Anda saat ini untuk menonaktifkan autentikasi dua faktor.", adminHeading: "Pengaturan Keamanan Admin", adminDescription: "Pengaturan kebijakan keamanan enterprise. Berlaku untuk semua pengguna.", sessionIdleTimeout: "Batas Waktu Sesi Tidak Aktif (menit)", diff --git a/packages/shared/src/i18n/it.ts b/packages/shared/src/i18n/it.ts index 2cb705e1..2768f03e 100644 --- a/packages/shared/src/i18n/it.ts +++ b/packages/shared/src/i18n/it.ts @@ -3311,6 +3311,33 @@ export const it: TranslationKeys = { changePasswordButton: "Cambia password", loginAttemptLimitNote: "I limiti dei tentativi di accesso possono essere configurati nelle Impostazioni di sistema.", + twoFactorHeading: "Autenticazione a due fattori", + twoFactorDescription: + "Aggiungi un ulteriore livello di sicurezza al tuo account con un'app di autenticazione.", + twoFactorEnabledStatus: "L'autenticazione a due fattori è attiva.", + twoFactorDisabledStatus: "L'autenticazione a due fattori non è attiva.", + enableTwoFactorButton: "Attiva l'autenticazione a due fattori", + disableTwoFactorButton: "Disattiva l'autenticazione a due fattori", + twoFactorScanQr: + "Scansiona questo codice QR con la tua app di autenticazione (come Google Authenticator, Authy o 1Password).", + twoFactorManualEntry: + "Non riesci a scansionare il codice? Inserisci questa chiave manualmente:", + twoFactorRecoveryCodesHeading: "Codici di recupero", + twoFactorRecoveryCodesDescription: + "Conserva questi codici in un luogo sicuro. Ognuno può essere usato una sola volta per accedere se perdi l'accesso alla tua app di autenticazione.", + twoFactorCopyRecoveryCodes: "Copia codici", + twoFactorCodesCopied: "Copiati", + twoFactorCopyFailed: + "Impossibile copiare automaticamente. Seleziona i codici sopra e copiali manualmente.", + twoFactorEnterCode: + "Inserisci il codice a 6 cifre della tua app per confermare la configurazione.", + twoFactorCodePlaceholder: "000000", + twoFactorConfirmButton: "Conferma e attiva", + twoFactorCancelButton: "Annulla", + twoFactorEnableSuccess: "L'autenticazione a due fattori è ora attiva.", + twoFactorDisableSuccess: "L'autenticazione a due fattori è stata disattivata.", + twoFactorDisablePrompt: + "Inserisci il codice attuale della tua app di autenticazione per disattivare l'autenticazione a due fattori.", adminHeading: "Impostazioni di sicurezza amministratore", adminDescription: "Impostazioni delle policy di sicurezza aziendale. Si applicano a tutti gli utenti.", diff --git a/packages/shared/src/i18n/ja.ts b/packages/shared/src/i18n/ja.ts index 3ed863cd..33111010 100644 --- a/packages/shared/src/i18n/ja.ts +++ b/packages/shared/src/i18n/ja.ts @@ -3259,6 +3259,29 @@ export const ja: TranslationKeys = { currentPasswordIncorrect: "現在のパスワードが正しくありません", changePasswordButton: "パスワード変更", loginAttemptLimitNote: "ログイン試行制限はシステム設定で構成できます。", + twoFactorHeading: "2 要素認証", + twoFactorDescription: "認証アプリを使って、アカウントにセキュリティを追加します。", + twoFactorEnabledStatus: "2 要素認証は有効になっています。", + twoFactorDisabledStatus: "2 要素認証は有効になっていません。", + enableTwoFactorButton: "2 要素認証を有効にする", + disableTwoFactorButton: "2 要素認証を無効にする", + twoFactorScanQr: + "認証アプリ(Google Authenticator、Authy、1Password など)でこの QR コードをスキャンしてください。", + twoFactorManualEntry: "コードをスキャンできない場合は、このキーを手動で入力してください:", + twoFactorRecoveryCodesHeading: "リカバリーコード", + twoFactorRecoveryCodesDescription: + "これらのコードを安全な場所に保管してください。認証アプリにアクセスできなくなった場合、各コードを 1 回だけサインインに使用できます。", + twoFactorCopyRecoveryCodes: "コードをコピー", + twoFactorCodesCopied: "コピーしました", + twoFactorCopyFailed: + "自動的にコピーできませんでした。上のコードを選択して手動でコピーしてください。", + twoFactorEnterCode: "設定を確定するには、アプリに表示される 6 桁のコードを入力してください。", + twoFactorCodePlaceholder: "000000", + twoFactorConfirmButton: "確定して有効にする", + twoFactorCancelButton: "キャンセル", + twoFactorEnableSuccess: "2 要素認証が有効になりました。", + twoFactorDisableSuccess: "2 要素認証が無効になりました。", + twoFactorDisablePrompt: "2 要素認証を無効にするには、現在の認証コードを入力してください。", adminHeading: "管理者セキュリティ設定", adminDescription: "エンタープライズセキュリティポリシー設定。すべてのユーザーに適用されます。", diff --git a/packages/shared/src/i18n/ko.ts b/packages/shared/src/i18n/ko.ts index 89d870f5..67a0fcf5 100644 --- a/packages/shared/src/i18n/ko.ts +++ b/packages/shared/src/i18n/ko.ts @@ -3239,6 +3239,29 @@ export const ko: TranslationKeys = { currentPasswordIncorrect: "현재 비밀번호가 올바르지 않습니다", changePasswordButton: "비밀번호 변경", loginAttemptLimitNote: "로그인 시도 제한은 시스템 설정에서 구성할 수 있습니다.", + twoFactorHeading: "2단계 인증", + twoFactorDescription: "인증 앱으로 계정에 보안 단계를 추가하세요.", + twoFactorEnabledStatus: "2단계 인증이 활성화되어 있습니다.", + twoFactorDisabledStatus: "2단계 인증이 활성화되어 있지 않습니다.", + enableTwoFactorButton: "2단계 인증 활성화", + disableTwoFactorButton: "2단계 인증 비활성화", + twoFactorScanQr: + "인증 앱(Google Authenticator, Authy, 1Password 등)으로 이 QR 코드를 스캔하세요.", + twoFactorManualEntry: "코드를 스캔할 수 없나요? 이 키를 직접 입력하세요:", + twoFactorRecoveryCodesHeading: "복구 코드", + twoFactorRecoveryCodesDescription: + "이 코드를 안전한 곳에 보관하세요. 인증 앱에 접근할 수 없을 때 각 코드는 로그인에 한 번씩 사용할 수 있습니다.", + twoFactorCopyRecoveryCodes: "코드 복사", + twoFactorCodesCopied: "복사되었습니다", + twoFactorCopyFailed: + "자동으로 복사하지 못했습니다. 위의 코드를 선택하여 수동으로 복사하세요.", + twoFactorEnterCode: "설정을 확인하려면 앱에 표시된 6자리 코드를 입력하세요.", + twoFactorCodePlaceholder: "000000", + twoFactorConfirmButton: "확인 후 활성화", + twoFactorCancelButton: "취소", + twoFactorEnableSuccess: "2단계 인증이 활성화되었습니다.", + twoFactorDisableSuccess: "2단계 인증이 비활성화되었습니다.", + twoFactorDisablePrompt: "2단계 인증을 비활성화하려면 현재 인증 코드를 입력하세요.", adminHeading: "관리자 보안 설정", adminDescription: "엔터프라이즈 보안 정책 설정입니다. 모든 사용자에게 적용됩니다.", sessionIdleTimeout: "세션 유휴 시간 제한(분)", diff --git a/packages/shared/src/i18n/nl.ts b/packages/shared/src/i18n/nl.ts index 936ee5f1..96a50a13 100644 --- a/packages/shared/src/i18n/nl.ts +++ b/packages/shared/src/i18n/nl.ts @@ -3309,6 +3309,31 @@ export const nl: TranslationKeys = { currentPasswordIncorrect: "Huidig wachtwoord is onjuist", changePasswordButton: "Wachtwoord wijzigen", loginAttemptLimitNote: "Inlogpogingslimieten kun je instellen bij Systeeminstellingen.", + twoFactorHeading: "Tweefactorauthenticatie", + twoFactorDescription: + "Voeg een extra beveiligingslaag toe aan je account met een authenticator-app.", + twoFactorEnabledStatus: "Tweefactorauthenticatie is ingeschakeld.", + twoFactorDisabledStatus: "Tweefactorauthenticatie is niet ingeschakeld.", + enableTwoFactorButton: "Tweefactorauthenticatie inschakelen", + disableTwoFactorButton: "Tweefactorauthenticatie uitschakelen", + twoFactorScanQr: + "Scan deze QR-code met je authenticator-app (zoals Google Authenticator, Authy of 1Password).", + twoFactorManualEntry: "Kun je de code niet scannen? Voer deze sleutel handmatig in:", + twoFactorRecoveryCodesHeading: "Herstelcodes", + twoFactorRecoveryCodesDescription: + "Bewaar deze codes op een veilige plek. Elke code kun je eenmalig gebruiken om in te loggen als je geen toegang meer hebt tot je authenticator-app.", + twoFactorCopyRecoveryCodes: "Codes kopiëren", + twoFactorCodesCopied: "Gekopieerd", + twoFactorCopyFailed: + "Automatisch kopiëren is mislukt. Selecteer de codes hierboven en kopieer ze handmatig.", + twoFactorEnterCode: "Voer de 6-cijferige code uit je app in om de installatie te bevestigen.", + twoFactorCodePlaceholder: "000000", + twoFactorConfirmButton: "Bevestigen en inschakelen", + twoFactorCancelButton: "Annuleren", + twoFactorEnableSuccess: "Tweefactorauthenticatie is nu ingeschakeld.", + twoFactorDisableSuccess: "Tweefactorauthenticatie is uitgeschakeld.", + twoFactorDisablePrompt: + "Voer je huidige authenticator-code in om tweefactorauthenticatie uit te schakelen.", adminHeading: "Beveiligingsinstellingen voor beheerders", adminDescription: "Enterprise-beveiligingsbeleid. Deze instellingen gelden voor alle gebruikers.", diff --git a/packages/shared/src/i18n/pl.ts b/packages/shared/src/i18n/pl.ts index 8b57913e..5c58d7d1 100644 --- a/packages/shared/src/i18n/pl.ts +++ b/packages/shared/src/i18n/pl.ts @@ -3308,6 +3308,31 @@ export const pl: TranslationKeys = { changePasswordButton: "Zmień hasło", loginAttemptLimitNote: "Limity prób logowania można skonfigurować w Ustawieniach systemowych.", + twoFactorHeading: "Uwierzytelnianie dwuskładnikowe", + twoFactorDescription: + "Zwiększ bezpieczeństwo konta o dodatkową warstwę dzięki aplikacji uwierzytelniającej.", + twoFactorEnabledStatus: "Uwierzytelnianie dwuskładnikowe jest włączone.", + twoFactorDisabledStatus: "Uwierzytelnianie dwuskładnikowe jest wyłączone.", + enableTwoFactorButton: "Włącz uwierzytelnianie dwuskładnikowe", + disableTwoFactorButton: "Wyłącz uwierzytelnianie dwuskładnikowe", + twoFactorScanQr: + "Zeskanuj ten kod QR aplikacją uwierzytelniającą (np. Google Authenticator, Authy lub 1Password).", + twoFactorManualEntry: "Nie możesz zeskanować kodu? Wprowadź ten klucz ręcznie:", + twoFactorRecoveryCodesHeading: "Kody odzyskiwania", + twoFactorRecoveryCodesDescription: + "Zapisz te kody w bezpiecznym miejscu. Każdego z nich można użyć jednorazowo do zalogowania się, jeśli utracisz dostęp do aplikacji uwierzytelniającej.", + twoFactorCopyRecoveryCodes: "Kopiuj kody", + twoFactorCodesCopied: "Skopiowano", + twoFactorCopyFailed: + "Nie udało się skopiować automatycznie. Zaznacz kody powyżej i skopiuj je ręcznie.", + twoFactorEnterCode: "Wprowadź 6-cyfrowy kod z aplikacji, aby potwierdzić konfigurację.", + twoFactorCodePlaceholder: "000000", + twoFactorConfirmButton: "Potwierdź i włącz", + twoFactorCancelButton: "Anuluj", + twoFactorEnableSuccess: "Uwierzytelnianie dwuskładnikowe jest teraz włączone.", + twoFactorDisableSuccess: "Uwierzytelnianie dwuskładnikowe zostało wyłączone.", + twoFactorDisablePrompt: + "Wprowadź bieżący kod z aplikacji uwierzytelniającej, aby wyłączyć uwierzytelnianie dwuskładnikowe.", adminHeading: "Ustawienia zabezpieczeń administratora", adminDescription: "Ustawienia polityki bezpieczeństwa dla organizacji. Dotyczą wszystkich użytkowników.", diff --git a/packages/shared/src/i18n/pt-BR.ts b/packages/shared/src/i18n/pt-BR.ts index 2a055aec..6a8f323e 100644 --- a/packages/shared/src/i18n/pt-BR.ts +++ b/packages/shared/src/i18n/pt-BR.ts @@ -3305,6 +3305,32 @@ export const ptBR: TranslationKeys = { changePasswordButton: "Alterar senha", loginAttemptLimitNote: "Os limites de tentativas de login podem ser configurados nas Configurações do sistema.", + twoFactorHeading: "Autenticação de dois fatores", + twoFactorDescription: + "Adicione uma camada extra de segurança à sua conta com um aplicativo autenticador.", + twoFactorEnabledStatus: "A autenticação de dois fatores está ativada.", + twoFactorDisabledStatus: "A autenticação de dois fatores não está ativada.", + enableTwoFactorButton: "Ativar autenticação de dois fatores", + disableTwoFactorButton: "Desativar autenticação de dois fatores", + twoFactorScanQr: + "Escaneie este código QR com seu aplicativo autenticador (como Google Authenticator, Authy ou 1Password).", + twoFactorManualEntry: "Não consegue escanear o código? Digite esta chave manualmente:", + twoFactorRecoveryCodesHeading: "Códigos de recuperação", + twoFactorRecoveryCodesDescription: + "Guarde estes códigos em um local seguro. Cada um pode ser usado uma vez para entrar caso você perca o acesso ao seu aplicativo autenticador.", + twoFactorCopyRecoveryCodes: "Copiar códigos", + twoFactorCodesCopied: "Copiado", + twoFactorCopyFailed: + "Não foi possível copiar automaticamente. Selecione os códigos acima e copie-os manualmente.", + twoFactorEnterCode: + "Digite o código de 6 dígitos do seu aplicativo para confirmar a configuração.", + twoFactorCodePlaceholder: "000000", + twoFactorConfirmButton: "Confirmar e ativar", + twoFactorCancelButton: "Cancelar", + twoFactorEnableSuccess: "A autenticação de dois fatores agora está ativada.", + twoFactorDisableSuccess: "A autenticação de dois fatores foi desativada.", + twoFactorDisablePrompt: + "Digite seu código de autenticação atual para desativar a autenticação de dois fatores.", adminHeading: "Configurações de segurança do administrador", adminDescription: "Configurações de política de segurança empresarial. Aplicam-se a todos os usuários.", diff --git a/packages/shared/src/i18n/ru.ts b/packages/shared/src/i18n/ru.ts index 24f8acbe..a75d47c6 100644 --- a/packages/shared/src/i18n/ru.ts +++ b/packages/shared/src/i18n/ru.ts @@ -3304,6 +3304,31 @@ export const ru: TranslationKeys = { currentPasswordIncorrect: "Текущий пароль неверен", changePasswordButton: "Изменить пароль", loginAttemptLimitNote: "Лимиты попыток входа можно настроить в Системных настройках.", + twoFactorHeading: "Двухфакторная аутентификация", + twoFactorDescription: + "Добавьте дополнительный уровень защиты аккаунта с помощью приложения-аутентификатора.", + twoFactorEnabledStatus: "Двухфакторная аутентификация включена.", + twoFactorDisabledStatus: "Двухфакторная аутентификация отключена.", + enableTwoFactorButton: "Включить двухфакторную аутентификацию", + disableTwoFactorButton: "Отключить двухфакторную аутентификацию", + twoFactorScanQr: + "Отсканируйте этот QR-код приложением-аутентификатором (например, Google Authenticator, Authy или 1Password).", + twoFactorManualEntry: "Не удаётся отсканировать код? Введите этот ключ вручную:", + twoFactorRecoveryCodesHeading: "Коды восстановления", + twoFactorRecoveryCodesDescription: + "Сохраните эти коды в надёжном месте. Каждый из них можно использовать один раз для входа, если вы потеряете доступ к приложению-аутентификатору.", + twoFactorCopyRecoveryCodes: "Копировать коды", + twoFactorCodesCopied: "Скопировано", + twoFactorCopyFailed: + "Не удалось скопировать автоматически. Выделите коды выше и скопируйте их вручную.", + twoFactorEnterCode: "Введите 6-значный код из приложения, чтобы подтвердить настройку.", + twoFactorCodePlaceholder: "000000", + twoFactorConfirmButton: "Подтвердить и включить", + twoFactorCancelButton: "Отмена", + twoFactorEnableSuccess: "Двухфакторная аутентификация включена.", + twoFactorDisableSuccess: "Двухфакторная аутентификация отключена.", + twoFactorDisablePrompt: + "Введите текущий код из приложения-аутентификатора, чтобы отключить двухфакторную аутентификацию.", adminHeading: "Настройки безопасности администратора", adminDescription: "Корпоративные политики безопасности. Применяются ко всем пользователям.", sessionIdleTimeout: "Тайм-аут бездействия сессии (минуты)", diff --git a/packages/shared/src/i18n/sv.ts b/packages/shared/src/i18n/sv.ts index bf12de94..caa4aa45 100644 --- a/packages/shared/src/i18n/sv.ts +++ b/packages/shared/src/i18n/sv.ts @@ -3300,6 +3300,32 @@ export const sv: TranslationKeys = { currentPasswordIncorrect: "Nuvarande lösenord är felaktigt", changePasswordButton: "Byt lösenord", loginAttemptLimitNote: "Inloggningsförsöksgränser kan konfigureras i Systeminställningar.", + twoFactorHeading: "Tvåfaktorsautentisering", + twoFactorDescription: + "Lägg till ett extra säkerhetslager på ditt konto med en autentiseringsapp.", + twoFactorEnabledStatus: "Tvåfaktorsautentisering är aktiverad.", + twoFactorDisabledStatus: "Tvåfaktorsautentisering är inte aktiverad.", + enableTwoFactorButton: "Aktivera tvåfaktorsautentisering", + disableTwoFactorButton: "Inaktivera tvåfaktorsautentisering", + twoFactorScanQr: + "Skanna den här QR-koden med din autentiseringsapp (till exempel Google Authenticator, Authy eller 1Password).", + twoFactorManualEntry: "Kan du inte skanna koden? Ange den här nyckeln manuellt:", + twoFactorRecoveryCodesHeading: "Återställningskoder", + twoFactorRecoveryCodesDescription: + "Spara de här koderna på ett säkert ställe. Var och en kan användas en gång för att logga in om du förlorar åtkomsten till din autentiseringsapp.", + twoFactorCopyRecoveryCodes: "Kopiera koder", + twoFactorCodesCopied: "Kopierat", + twoFactorCopyFailed: + "Det gick inte att kopiera automatiskt. Markera koderna ovan och kopiera dem manuellt.", + twoFactorEnterCode: + "Ange den 6-siffriga koden från din app för att bekräfta konfigurationen.", + twoFactorCodePlaceholder: "000000", + twoFactorConfirmButton: "Bekräfta och aktivera", + twoFactorCancelButton: "Avbryt", + twoFactorEnableSuccess: "Tvåfaktorsautentisering är nu aktiverad.", + twoFactorDisableSuccess: "Tvåfaktorsautentisering har inaktiverats.", + twoFactorDisablePrompt: + "Ange din nuvarande autentiseringskod för att inaktivera tvåfaktorsautentisering.", adminHeading: "Säkerhetsinställningar för admin", adminDescription: "Säkerhetspolicyinställningar för företag. Gäller alla användare.", sessionIdleTimeout: "Tidsgräns för inaktiv session (minuter)", diff --git a/packages/shared/src/i18n/th.ts b/packages/shared/src/i18n/th.ts index c0ed0166..af641abc 100644 --- a/packages/shared/src/i18n/th.ts +++ b/packages/shared/src/i18n/th.ts @@ -3269,6 +3269,28 @@ export const th: TranslationKeys = { currentPasswordIncorrect: "รหัสผ่านปัจจุบันไม่ถูกต้อง", changePasswordButton: "เปลี่ยนรหัสผ่าน", loginAttemptLimitNote: "สามารถกำหนดจำนวนครั้งจำกัดการเข้าสู่ระบบได้ในตั้งค่าระบบ", + twoFactorHeading: "การยืนยันตัวตนสองปัจจัย", + twoFactorDescription: "เพิ่มความปลอดภัยอีกชั้นให้บัญชีของคุณด้วยแอปยืนยันตัวตน", + twoFactorEnabledStatus: "เปิดใช้งานการยืนยันตัวตนสองปัจจัยแล้ว", + twoFactorDisabledStatus: "ยังไม่ได้เปิดใช้งานการยืนยันตัวตนสองปัจจัย", + enableTwoFactorButton: "เปิดใช้งานการยืนยันตัวตนสองปัจจัย", + disableTwoFactorButton: "ปิดใช้งานการยืนยันตัวตนสองปัจจัย", + twoFactorScanQr: + "สแกน QR code นี้ด้วยแอปยืนยันตัวตนของคุณ (เช่น Google Authenticator, Authy หรือ 1Password)", + twoFactorManualEntry: "สแกนโค้ดไม่ได้ใช่ไหม กรอกคีย์นี้ด้วยตนเอง:", + twoFactorRecoveryCodesHeading: "รหัสกู้คืน", + twoFactorRecoveryCodesDescription: + "เก็บรหัสเหล่านี้ไว้ในที่ปลอดภัย แต่ละรหัสใช้เข้าสู่ระบบได้ครั้งเดียวหากคุณเข้าถึงแอปยืนยันตัวตนไม่ได้", + twoFactorCopyRecoveryCodes: "คัดลอกรหัส", + twoFactorCodesCopied: "คัดลอกแล้ว", + twoFactorCopyFailed: "คัดลอกอัตโนมัติไม่สำเร็จ กรุณาเลือกรหัสด้านบนแล้วคัดลอกด้วยตนเอง", + twoFactorEnterCode: "กรอกรหัส 6 หลักจากแอปของคุณเพื่อยืนยันการตั้งค่า", + twoFactorCodePlaceholder: "000000", + twoFactorConfirmButton: "ยืนยันและเปิดใช้งาน", + twoFactorCancelButton: "ยกเลิก", + twoFactorEnableSuccess: "เปิดใช้งานการยืนยันตัวตนสองปัจจัยแล้ว", + twoFactorDisableSuccess: "ปิดใช้งานการยืนยันตัวตนสองปัจจัยแล้ว", + twoFactorDisablePrompt: "กรอกรหัสจากแอปยืนยันตัวตนปัจจุบันของคุณเพื่อปิดใช้งานการยืนยันตัวตนสองปัจจัย", adminHeading: "การตั้งค่าความปลอดภัยของผู้ดูแล", adminDescription: "การตั้งค่านโยบายความปลอดภัยระดับองค์กร ใช้กับผู้ใช้ทุกคน", sessionIdleTimeout: "หมดเวลาเซสชันเมื่อไม่ใช้งาน (นาที)", diff --git a/packages/shared/src/i18n/tr.ts b/packages/shared/src/i18n/tr.ts index 1dca6d0b..b7e7c34b 100644 --- a/packages/shared/src/i18n/tr.ts +++ b/packages/shared/src/i18n/tr.ts @@ -3305,6 +3305,31 @@ export const tr: TranslationKeys = { currentPasswordIncorrect: "Mevcut parola yanlış", changePasswordButton: "Parolayı Değiştir", loginAttemptLimitNote: "Giriş deneme limitleri Sistem Ayarlarından yapılandırılabilir.", + twoFactorHeading: "İki Faktörlü Kimlik Doğrulama", + twoFactorDescription: + "Bir kimlik doğrulayıcı uygulamayla hesabınıza ek bir güvenlik katmanı ekleyin.", + twoFactorEnabledStatus: "İki faktörlü kimlik doğrulama etkin.", + twoFactorDisabledStatus: "İki faktörlü kimlik doğrulama etkin değil.", + enableTwoFactorButton: "İki faktörlü kimlik doğrulamayı etkinleştir", + disableTwoFactorButton: "İki faktörlü kimlik doğrulamayı devre dışı bırak", + twoFactorScanQr: + "Bu QR kodunu kimlik doğrulayıcı uygulamanızla (Google Authenticator, Authy veya 1Password gibi) tarayın.", + twoFactorManualEntry: "Kodu tarayamıyor musunuz? Bu anahtarı elle girin:", + twoFactorRecoveryCodesHeading: "Kurtarma kodları", + twoFactorRecoveryCodesDescription: + "Bu kodları güvenli bir yerde saklayın. Kimlik doğrulayıcı uygulamanıza erişimi kaybederseniz her biri giriş yapmak için bir kez kullanılabilir.", + twoFactorCopyRecoveryCodes: "Kodları kopyala", + twoFactorCodesCopied: "Kopyalandı", + twoFactorCopyFailed: + "Otomatik olarak kopyalanamadı. Yukarıdaki kodları seçip elle kopyalayın.", + twoFactorEnterCode: "Kurulumu onaylamak için uygulamanızdaki 6 haneli kodu girin.", + twoFactorCodePlaceholder: "000000", + twoFactorConfirmButton: "Onayla ve etkinleştir", + twoFactorCancelButton: "İptal", + twoFactorEnableSuccess: "İki faktörlü kimlik doğrulama artık etkin.", + twoFactorDisableSuccess: "İki faktörlü kimlik doğrulama devre dışı bırakıldı.", + twoFactorDisablePrompt: + "İki faktörlü kimlik doğrulamayı devre dışı bırakmak için mevcut kimlik doğrulayıcı kodunuzu girin.", adminHeading: "Yönetici Güvenlik Ayarları", adminDescription: "Kurumsal güvenlik politikası ayarları. Tüm kullanıcılar için geçerlidir.", sessionIdleTimeout: "Oturum Boşta Kalma Süresi (dakika)", diff --git a/packages/shared/src/i18n/uk.ts b/packages/shared/src/i18n/uk.ts index 4d021252..b8375c69 100644 --- a/packages/shared/src/i18n/uk.ts +++ b/packages/shared/src/i18n/uk.ts @@ -3303,6 +3303,31 @@ export const uk: TranslationKeys = { currentPasswordIncorrect: "Поточний пароль невірний", changePasswordButton: "Змінити пароль", loginAttemptLimitNote: "Ліміти спроб входу можна налаштувати в Системних налаштуваннях.", + twoFactorHeading: "Двофакторна автентифікація", + twoFactorDescription: + "Додайте додатковий рівень захисту облікового запису за допомогою застосунку-автентифікатора.", + twoFactorEnabledStatus: "Двофакторну автентифікацію ввімкнено.", + twoFactorDisabledStatus: "Двофакторну автентифікацію не ввімкнено.", + enableTwoFactorButton: "Увімкнути двофакторну автентифікацію", + disableTwoFactorButton: "Вимкнути двофакторну автентифікацію", + twoFactorScanQr: + "Відскануйте цей QR-код застосунком-автентифікатором (наприклад, Google Authenticator, Authy чи 1Password).", + twoFactorManualEntry: "Не вдається відсканувати код? Введіть цей ключ вручну:", + twoFactorRecoveryCodesHeading: "Коди відновлення", + twoFactorRecoveryCodesDescription: + "Збережіть ці коди в надійному місці. Кожен із них можна використати один раз для входу, якщо ви втратите доступ до застосунку-автентифікатора.", + twoFactorCopyRecoveryCodes: "Копіювати коди", + twoFactorCodesCopied: "Скопійовано", + twoFactorCopyFailed: + "Не вдалося скопіювати автоматично. Виділіть коди вище і скопіюйте їх вручну.", + twoFactorEnterCode: "Введіть 6-значний код із застосунку, щоб підтвердити налаштування.", + twoFactorCodePlaceholder: "000000", + twoFactorConfirmButton: "Підтвердити та ввімкнути", + twoFactorCancelButton: "Скасувати", + twoFactorEnableSuccess: "Двофакторну автентифікацію ввімкнено.", + twoFactorDisableSuccess: "Двофакторну автентифікацію вимкнено.", + twoFactorDisablePrompt: + "Введіть поточний код автентифікатора, щоб вимкнути двофакторну автентифікацію.", adminHeading: "Налаштування безпеки адміністратора", adminDescription: "Корпоративні налаштування політики безпеки. Застосовуються до всіх користувачів.", diff --git a/packages/shared/src/i18n/vi.ts b/packages/shared/src/i18n/vi.ts index c757e4f6..94a8f04e 100644 --- a/packages/shared/src/i18n/vi.ts +++ b/packages/shared/src/i18n/vi.ts @@ -3301,6 +3301,29 @@ export const vi: TranslationKeys = { currentPasswordIncorrect: "Mật khẩu hiện tại không đúng", changePasswordButton: "Đổi mật khẩu", loginAttemptLimitNote: "Giới hạn đăng nhập có thể được cấu hình trong Cài đặt hệ thống.", + twoFactorHeading: "Xác thực hai yếu tố", + twoFactorDescription: "Thêm một lớp bảo mật cho tài khoản của bạn bằng ứng dụng xác thực.", + twoFactorEnabledStatus: "Xác thực hai yếu tố đang được bật.", + twoFactorDisabledStatus: "Xác thực hai yếu tố chưa được bật.", + enableTwoFactorButton: "Bật xác thực hai yếu tố", + disableTwoFactorButton: "Tắt xác thực hai yếu tố", + twoFactorScanQr: + "Quét mã QR này bằng ứng dụng xác thực của bạn (chẳng hạn Google Authenticator, Authy hoặc 1Password).", + twoFactorManualEntry: "Không quét được mã? Nhập khóa này theo cách thủ công:", + twoFactorRecoveryCodesHeading: "Mã khôi phục", + twoFactorRecoveryCodesDescription: + "Hãy lưu các mã này ở nơi an toàn. Mỗi mã dùng được một lần để đăng nhập nếu bạn mất quyền truy cập vào ứng dụng xác thực.", + twoFactorCopyRecoveryCodes: "Sao chép mã", + twoFactorCodesCopied: "Đã sao chép", + twoFactorCopyFailed: + "Không thể sao chép tự động. Hãy chọn các mã ở trên và sao chép theo cách thủ công.", + twoFactorEnterCode: "Nhập mã 6 chữ số từ ứng dụng của bạn để xác nhận thiết lập.", + twoFactorCodePlaceholder: "000000", + twoFactorConfirmButton: "Xác nhận và bật", + twoFactorCancelButton: "Hủy", + twoFactorEnableSuccess: "Xác thực hai yếu tố hiện đã được bật.", + twoFactorDisableSuccess: "Xác thực hai yếu tố đã được tắt.", + twoFactorDisablePrompt: "Nhập mã xác thực hiện tại của bạn để tắt xác thực hai yếu tố.", adminHeading: "Cài đặt bảo mật quản trị", adminDescription: "Cài đặt chính sách bảo mật doanh nghiệp. Áp dụng cho tất cả người dùng.", sessionIdleTimeout: "Thời gian chờ phiên nhàn rỗi (phút)", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index d086a72f..b6b15a6f 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -3054,6 +3054,28 @@ export const zhCN: TranslationKeys = { currentPasswordIncorrect: "当前密码不正确", changePasswordButton: "修改密码", loginAttemptLimitNote: "登录尝试限制可在系统设置中配置。", + twoFactorHeading: "双重身份验证", + twoFactorDescription: "使用身份验证器应用为您的账户添加额外一层安全保护。", + twoFactorEnabledStatus: "双重身份验证已启用。", + twoFactorDisabledStatus: "双重身份验证尚未启用。", + enableTwoFactorButton: "启用双重身份验证", + disableTwoFactorButton: "禁用双重身份验证", + twoFactorScanQr: + "使用您的身份验证器应用(如 Google Authenticator、Authy 或 1Password)扫描此二维码。", + twoFactorManualEntry: "无法扫描二维码?请手动输入此密钥:", + twoFactorRecoveryCodesHeading: "恢复代码", + twoFactorRecoveryCodesDescription: + "请将这些代码妥善保存。如果您无法访问身份验证器应用,每个代码均可用于登录一次。", + twoFactorCopyRecoveryCodes: "复制代码", + twoFactorCodesCopied: "已复制", + twoFactorCopyFailed: "无法自动复制。请选择上方的代码并手动复制。", + twoFactorEnterCode: "输入应用中的 6 位数验证码以确认设置。", + twoFactorCodePlaceholder: "000000", + twoFactorConfirmButton: "确认并启用", + twoFactorCancelButton: "取消", + twoFactorEnableSuccess: "双重身份验证现已启用。", + twoFactorDisableSuccess: "双重身份验证已禁用。", + twoFactorDisablePrompt: "输入您当前的身份验证器验证码以禁用双重身份验证。", adminHeading: "管理员安全设置", adminDescription: "企业安全策略设置。适用于所有用户。", sessionIdleTimeout: "会话空闲超时(分钟)", diff --git a/packages/shared/src/i18n/zh-TW.ts b/packages/shared/src/i18n/zh-TW.ts index 9e60a783..32f290f0 100644 --- a/packages/shared/src/i18n/zh-TW.ts +++ b/packages/shared/src/i18n/zh-TW.ts @@ -3054,6 +3054,28 @@ export const zhTW: TranslationKeys = { currentPasswordIncorrect: "目前密碼不正確", changePasswordButton: "變更密碼", loginAttemptLimitNote: "登入嘗試限制可在系統設定中配置。", + twoFactorHeading: "兩步驟驗證", + twoFactorDescription: "使用驗證器應用程式為您的帳號增加一層額外的安全防護。", + twoFactorEnabledStatus: "兩步驟驗證已啟用。", + twoFactorDisabledStatus: "兩步驟驗證尚未啟用。", + enableTwoFactorButton: "啟用兩步驟驗證", + disableTwoFactorButton: "停用兩步驟驗證", + twoFactorScanQr: + "使用您的驗證器應用程式(例如 Google Authenticator、Authy 或 1Password)掃描此 QR 碼。", + twoFactorManualEntry: "無法掃描此代碼?請手動輸入這組金鑰:", + twoFactorRecoveryCodesHeading: "復原代碼", + twoFactorRecoveryCodesDescription: + "請將這些代碼妥善保存。若您無法存取驗證器應用程式,每組代碼可用於登入一次。", + twoFactorCopyRecoveryCodes: "複製代碼", + twoFactorCodesCopied: "已複製", + twoFactorCopyFailed: "無法自動複製。請選取上方的代碼並手動複製。", + twoFactorEnterCode: "請輸入應用程式中的 6 位數代碼以確認設定。", + twoFactorCodePlaceholder: "000000", + twoFactorConfirmButton: "確認並啟用", + twoFactorCancelButton: "取消", + twoFactorEnableSuccess: "兩步驟驗證已啟用。", + twoFactorDisableSuccess: "兩步驟驗證已停用。", + twoFactorDisablePrompt: "請輸入您目前的驗證器代碼以停用兩步驟驗證。", adminHeading: "管理員安全設定", adminDescription: "企業安全性政策設定,適用於所有使用者。", sessionIdleTimeout: "閒置逾時(分鐘)", diff --git a/tests/integration/platform/mfa-endpoints.test.ts b/tests/integration/platform/mfa-endpoints.test.ts index f74bbc69..ddda4691 100644 --- a/tests/integration/platform/mfa-endpoints.test.ts +++ b/tests/integration/platform/mfa-endpoints.test.ts @@ -6,7 +6,7 @@ vi.resetModules(); const { mockEnterpriseFeatures } = await import("../../helpers/enterprise-mock.js"); mockEnterpriseFeatures(["mfa"]); -const { buildTestApp, loginAsAdmin } = await import("../test-server.js"); +const { buildTestApp, loginAsAdmin, loginAsUser } = await import("../test-server.js"); const { db, schema } = await import("../../../apps/api/src/db/index.js"); import type { TestApp } from "../test-server.js"; @@ -112,6 +112,36 @@ describe("POST /api/auth/mfa/enroll", () => { const body = JSON.parse(res.body); expect(body.code).toBe("MFA_ALREADY_ENABLED"); }); + + it("restarting enrollment after canceling (no verify in between) issues a fresh, working secret instead of 409ing", async () => { + // First attempt: user clicks Enable, sees the QR, then cancels/abandons + // without verifying. This leaves a pending, unverified secret. + await testApp.app.inject({ + method: "POST", + url: "/api/auth/mfa/enroll", + headers: { authorization: `Bearer ${adminToken}` }, + }); + + // Second attempt: user clicks Enable again later. Must not be a dead + // end requiring an admin reset -- it should just issue a new secret. + const secondEnrollRes = await testApp.app.inject({ + method: "POST", + url: "/api/auth/mfa/enroll", + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(secondEnrollRes.statusCode).toBe(200); + const { uri: secondUri } = JSON.parse(secondEnrollRes.body); + + // The new secret is genuinely live: verifying with it actually works. + const code = generateTotpCode(secondUri); + const verifyRes = await testApp.app.inject({ + method: "POST", + url: "/api/auth/mfa/verify", + headers: { authorization: `Bearer ${adminToken}` }, + payload: { code }, + }); + expect(verifyRes.statusCode).toBe(200); + }); }); describe("POST /api/auth/mfa/verify", () => { @@ -244,6 +274,47 @@ describe("POST /api/auth/users/:id/mfa/reset", () => { }); }); +describe("GET /api/auth/session totpEnabled", () => { + afterEach(async () => { + await clearMfaState("admin"); + }); + + it("is false when the user has not enrolled", async () => { + const res = await testApp.app.inject({ + method: "GET", + url: "/api/auth/session", + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.user.totpEnabled).toBe(false); + }); + + it("is true once the user has completed enrollment", async () => { + const enrollRes = await testApp.app.inject({ + method: "POST", + url: "/api/auth/mfa/enroll", + headers: { authorization: `Bearer ${adminToken}` }, + }); + const { uri } = JSON.parse(enrollRes.body); + const code = generateTotpCode(uri); + await testApp.app.inject({ + method: "POST", + url: "/api/auth/mfa/verify", + headers: { authorization: `Bearer ${adminToken}` }, + payload: { code }, + }); + + const res = await testApp.app.inject({ + method: "GET", + url: "/api/auth/session", + headers: { authorization: `Bearer ${adminToken}` }, + }); + const body = JSON.parse(res.body); + expect(body.user.totpEnabled).toBe(true); + }); +}); + describe("MFA login flow", () => { let totpUri: string; @@ -307,3 +378,115 @@ describe("MFA login flow", () => { expect(body.expiresAt).toBeDefined(); }); }); + +async function setMfaPolicy(value: "optional" | "admins_only" | "required"): Promise { + await db + .insert(schema.settings) + .values({ key: "mfaPolicy", value }) + .onConflictDoUpdate({ target: schema.settings.key, set: { value } }); +} + +// This is the actual fix for #515/#529: exercises the real /api/auth/login +// route end to end, not a mocked response. Everything else in this repo that +// covers this bug (the license gate on saving the setting, the frontend's +// handling of a stubbed 403) would still pass if this exact backend branch +// were reverted or its response code were renamed. +describe("POST /api/auth/login with mfaPolicy enforcement", () => { + afterEach(async () => { + await setMfaPolicy("optional"); + await clearMfaState("admin"); + }); + + it("blocks an unenrolled admin with 403 MFA_ENROLLMENT_REQUIRED when policy is required", async () => { + await setMfaPolicy("required"); + + const res = await testApp.app.inject({ + method: "POST", + url: "/api/auth/login", + payload: { username: "admin", password: "Adminpass1" }, + }); + + expect(res.statusCode).toBe(403); + const body = JSON.parse(res.body); + expect(body.code).toBe("MFA_ENROLLMENT_REQUIRED"); + expect(body.token).toBeUndefined(); + }); + + it("blocks an unenrolled admin under an admins_only policy", async () => { + await setMfaPolicy("admins_only"); + + const res = await testApp.app.inject({ + method: "POST", + url: "/api/auth/login", + payload: { username: "admin", password: "Adminpass1" }, + }); + + expect(res.statusCode).toBe(403); + expect(JSON.parse(res.body).code).toBe("MFA_ENROLLMENT_REQUIRED"); + }); + + it("does not block a non-admin user under an admins_only policy", async () => { + // Ensure the shared test user exists while policy is still permissive. + await loginAsUser(testApp.app); + await setMfaPolicy("admins_only"); + + const res = await testApp.app.inject({ + method: "POST", + url: "/api/auth/login", + payload: { username: "plainuser", password: "Userpass1" }, + }); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).token).toBeDefined(); + }); + + it("does not block anyone when policy is optional", async () => { + await setMfaPolicy("optional"); + + const res = await testApp.app.inject({ + method: "POST", + url: "/api/auth/login", + payload: { username: "admin", password: "Adminpass1" }, + }); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).token).toBeDefined(); + }); +}); + +// The mirror image of tests/integration/platform/mfa-policy-license-gate.test.ts +// (which proves an UNlicensed instance can't save this policy). This file is +// already licensed (mockEnterpriseFeatures(["mfa"]) above), so it proves the +// gate doesn't also accidentally block a legitimately licensed instance. +describe("PUT /api/v1/settings mfaPolicy (licensed)", () => { + afterEach(async () => { + await setMfaPolicy("optional"); + }); + + it("allows saving required when mfa is licensed", async () => { + const res = await testApp.app.inject({ + method: "PUT", + url: "/api/v1/settings", + headers: { authorization: `Bearer ${adminToken}` }, + payload: { mfaPolicy: "required" }, + }); + expect(res.statusCode).toBe(200); + + const check = await testApp.app.inject({ + method: "GET", + url: "/api/v1/settings/mfaPolicy", + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(JSON.parse(check.body).value).toBe("required"); + }); + + it("allows saving admins_only when mfa is licensed", async () => { + const res = await testApp.app.inject({ + method: "PUT", + url: "/api/v1/settings", + headers: { authorization: `Bearer ${adminToken}` }, + payload: { mfaPolicy: "admins_only" }, + }); + expect(res.statusCode).toBe(200); + }); +}); diff --git a/tests/integration/platform/mfa-policy-license-gate.test.ts b/tests/integration/platform/mfa-policy-license-gate.test.ts new file mode 100644 index 00000000..5c451858 --- /dev/null +++ b/tests/integration/platform/mfa-policy-license-gate.test.ts @@ -0,0 +1,90 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +vi.resetModules(); +const { mockNoEnterprise } = await import("../../helpers/enterprise-mock.js"); +mockNoEnterprise(); + +const { buildTestApp, loginAsAdmin } = await import("../test-server.js"); + +import type { TestApp } from "../test-server.js"; + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +describe("PUT /api/v1/settings mfaPolicy (no mfa license)", () => { + it("rejects admins_only when mfa is not licensed", async () => { + const res = await testApp.app.inject({ + method: "PUT", + url: "/api/v1/settings", + headers: { authorization: `Bearer ${adminToken}` }, + payload: { mfaPolicy: "admins_only" }, + }); + expect(res.statusCode).toBe(403); + const body = JSON.parse(res.body); + expect(body.code).toBe("FEATURE_NOT_LICENSED"); + }); + + it("rejects required when mfa is not licensed", async () => { + const res = await testApp.app.inject({ + method: "PUT", + url: "/api/v1/settings", + headers: { authorization: `Bearer ${adminToken}` }, + payload: { mfaPolicy: "required" }, + }); + expect(res.statusCode).toBe(403); + const body = JSON.parse(res.body); + expect(body.code).toBe("FEATURE_NOT_LICENSED"); + }); + + it("does not persist the rejected value", async () => { + await testApp.app.inject({ + method: "PUT", + url: "/api/v1/settings", + headers: { authorization: `Bearer ${adminToken}` }, + payload: { mfaPolicy: "required" }, + }); + + const res = await testApp.app.inject({ + method: "GET", + url: "/api/v1/settings/mfaPolicy", + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(res.statusCode).toBe(404); + }); + + it("still allows setting mfaPolicy back to optional", async () => { + const res = await testApp.app.inject({ + method: "PUT", + url: "/api/v1/settings", + headers: { authorization: `Bearer ${adminToken}` }, + payload: { mfaPolicy: "optional" }, + }); + expect(res.statusCode).toBe(200); + }); + + it("does not block unrelated settings in the same request", async () => { + const res = await testApp.app.inject({ + method: "PUT", + url: "/api/v1/settings", + headers: { authorization: `Bearer ${adminToken}` }, + payload: { mfaPolicy: "required", sessionIdleTimeoutMinutes: "30" }, + }); + expect(res.statusCode).toBe(403); + + const check = await testApp.app.inject({ + method: "GET", + url: "/api/v1/settings/sessionIdleTimeoutMinutes", + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(check.statusCode).toBe(404); + }); +}); diff --git a/tests/unit/web/admin-security-settings-save-error.test.tsx b/tests/unit/web/admin-security-settings-save-error.test.tsx new file mode 100644 index 00000000..b5fa5824 --- /dev/null +++ b/tests/unit/web/admin-security-settings-save-error.test.tsx @@ -0,0 +1,47 @@ +// @vitest-environment jsdom + +import "@testing-library/jest-dom/vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const apiGet = vi.hoisted(() => vi.fn().mockResolvedValue({ settings: {} })); +const apiPut = vi.hoisted(() => vi.fn()); + +vi.mock("@/lib/api", async (importOriginal) => { + const actual: Record = await importOriginal(); + return { ...actual, apiGet, apiPut }; +}); + +import { AdminSecuritySettings } from "@/components/settings/settings-dialog"; + +afterEach(() => { + cleanup(); + apiGet.mockClear(); + apiPut.mockReset(); +}); + +describe("AdminSecuritySettings save errors", () => { + it("shows the server's specific error message when a save is rejected", async () => { + apiPut.mockRejectedValue(new Error("MFA requires an enterprise license")); + + render(); + await waitFor(() => expect(apiGet).toHaveBeenCalled()); + + fireEvent.click(await screen.findByRole("button", { name: /save/i })); + + const message = await screen.findByText("MFA requires an enterprise license"); + expect(message).toHaveClass("text-destructive"); + }); + + it("falls back to a generic message when the save rejects with a non-Error value", async () => { + apiPut.mockRejectedValue("network exploded"); + + render(); + await waitFor(() => expect(apiGet).toHaveBeenCalled()); + + fireEvent.click(await screen.findByRole("button", { name: /save/i })); + + const message = await screen.findByText("Failed to save security settings"); + expect(message).toHaveClass("text-destructive"); + }); +}); diff --git a/tests/unit/web/anonymous-auth.test.ts b/tests/unit/web/anonymous-auth.test.ts index 442b54f6..1bc042b8 100644 --- a/tests/unit/web/anonymous-auth.test.ts +++ b/tests/unit/web/anonymous-auth.test.ts @@ -131,6 +131,57 @@ describe("useAuth anonymous happy path", () => { }); }); +describe("useAuth totpEnabled", () => { + beforeEach(() => { + fetchMock.mockReset(); + vi.resetModules(); + }); + + it("reflects session.user.totpEnabled when authenticated", async () => { + fetchMock + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ authEnabled: true }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + user: { role: "admin", permissions: [], totpEnabled: true }, + }), + }); + + const { renderHook, act } = await import("@testing-library/react"); + const { useAuth } = await import("@/hooks/use-auth"); + + const { result } = renderHook(() => useAuth()); + + await act(async () => {}); + + expect(result.current.totpEnabled).toBe(true); + }); + + it("defaults to false when the session omits totpEnabled", async () => { + fetchMock + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ authEnabled: true }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ user: { role: "admin", permissions: [] } }), + }); + + const { renderHook, act } = await import("@testing-library/react"); + const { useAuth } = await import("@/hooks/use-auth"); + + const { result } = renderHook(() => useAuth()); + + await act(async () => {}); + + expect(result.current.totpEnabled).toBe(false); + }); +}); + describe("useAuth hasPermission", () => { beforeEach(() => { fetchMock.mockReset(); diff --git a/tests/unit/web/login-page-mfa-error.test.tsx b/tests/unit/web/login-page-mfa-error.test.tsx new file mode 100644 index 00000000..74286687 --- /dev/null +++ b/tests/unit/web/login-page-mfa-error.test.tsx @@ -0,0 +1,81 @@ +// @vitest-environment jsdom + +import "@testing-library/jest-dom/vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const useAuth = vi.hoisted(() => vi.fn()); + +vi.mock("@/hooks/use-auth", () => ({ useAuth })); + +import { LoginPage } from "@/pages/login-page"; + +afterEach(() => { + cleanup(); + useAuth.mockReset(); + vi.unstubAllGlobals(); +}); + +function renderLoginPage() { + useAuth.mockReturnValue({ + oidcEnabled: false, + oidcProviderName: null, + samlEnabled: false, + samlProviderName: null, + ssoEnforced: false, + }); + return render( + + + , + ); +} + +async function submitLogin() { + fireEvent.change(screen.getByLabelText(/username/i), { target: { value: "admin" } }); + fireEvent.change(screen.getByLabelText(/password/i), { target: { value: "correct-password" } }); + fireEvent.click(screen.getByRole("button", { name: /^login$/i })); +} + +describe("LoginPage error messages", () => { + it("shows the MFA enrollment message when the API returns MFA_ENROLLMENT_REQUIRED", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 403, + json: async () => ({ + error: "MFA enrollment is required before login", + code: "MFA_ENROLLMENT_REQUIRED", + }), + }), + ); + + renderLoginPage(); + await submitLogin(); + + await waitFor(() => { + expect(screen.getByText(/multi-factor authentication/i)).toBeInTheDocument(); + }); + expect(screen.queryByText(/invalid username or password/i)).not.toBeInTheDocument(); + }); + + it("still shows a generic message for a plain invalid-credentials response", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 401, + json: async () => ({ error: "Invalid credentials" }), + }), + ); + + renderLoginPage(); + await submitLogin(); + + await waitFor(() => { + expect(screen.getByText(/invalid username or password/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/tests/unit/web/two-factor-settings.test.tsx b/tests/unit/web/two-factor-settings.test.tsx new file mode 100644 index 00000000..e0d36b19 --- /dev/null +++ b/tests/unit/web/two-factor-settings.test.tsx @@ -0,0 +1,288 @@ +// @vitest-environment jsdom + +import "@testing-library/jest-dom/vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const useAuth = vi.hoisted(() => vi.fn()); +const apiPost = vi.hoisted(() => vi.fn()); +const copyToClipboard = vi.hoisted(() => vi.fn().mockResolvedValue(true)); + +vi.mock("@/hooks/use-auth", () => ({ useAuth })); + +vi.mock("@/lib/api", async (importOriginal) => { + const actual: Record = await importOriginal(); + return { ...actual, apiPost }; +}); + +vi.mock("@/lib/utils", async (importOriginal) => { + const actual: Record = await importOriginal(); + return { ...actual, copyToClipboard }; +}); + +vi.mock("qr-code-styling", () => ({ + default: class { + append() {} + update() {} + }, +})); + +import { TwoFactorSettings } from "@/components/settings/two-factor-settings"; + +const ENROLL_RESPONSE = { + uri: "otpauth://totp/SnapOtter:admin?secret=JBSWY3DPEHPK3PXP&issuer=SnapOtter", + recoveryCodes: ["aaaa1111", "bbbb2222"], +}; + +afterEach(() => { + cleanup(); + useAuth.mockReset(); + apiPost.mockReset(); + copyToClipboard.mockClear(); +}); + +describe("TwoFactorSettings", () => { + it("shows the enable button when not enrolled", () => { + useAuth.mockReturnValue({ totpEnabled: false }); + render(); + expect( + screen.getByRole("button", { name: /enable two-factor authentication/i }), + ).toBeInTheDocument(); + }); + + it("shows the disable button when already enrolled", () => { + useAuth.mockReturnValue({ totpEnabled: true }); + render(); + expect( + screen.getByRole("button", { name: /disable two-factor authentication/i }), + ).toBeInTheDocument(); + }); + + it("starts enrollment and shows the QR code, manual secret, and recovery codes", async () => { + useAuth.mockReturnValue({ totpEnabled: false }); + apiPost.mockResolvedValueOnce(ENROLL_RESPONSE); + + render(); + fireEvent.click(screen.getByRole("button", { name: /enable two-factor authentication/i })); + + await waitFor(() => { + expect(apiPost).toHaveBeenCalledWith("/auth/mfa/enroll"); + }); + expect(screen.getByText("JBSWY3DPEHPK3PXP")).toBeInTheDocument(); + expect(screen.getByText("aaaa1111")).toBeInTheDocument(); + expect(screen.getByText("bbbb2222")).toBeInTheDocument(); + }); + + it("surfaces the server's error when enrollment is rejected (e.g. unlicensed)", async () => { + useAuth.mockReturnValue({ totpEnabled: false }); + apiPost.mockRejectedValueOnce(new Error("MFA requires an enterprise license")); + + render(); + fireEvent.click(screen.getByRole("button", { name: /enable two-factor authentication/i })); + + expect(await screen.findByText("MFA requires an enterprise license")).toBeInTheDocument(); + }); + + it("falls back to a generic message when enrollment rejects with a non-Error value", async () => { + useAuth.mockReturnValue({ totpEnabled: false }); + apiPost.mockRejectedValueOnce("network exploded"); + + render(); + fireEvent.click(screen.getByRole("button", { name: /enable two-factor authentication/i })); + + // Must render a real message, not "undefined" or the raw non-Error value. + expect(await screen.findByText(/failed to save/i)).toBeInTheDocument(); + expect(screen.queryByText("network exploded")).not.toBeInTheDocument(); + expect(screen.queryByText(/undefined/i)).not.toBeInTheDocument(); + }); + + it("verifies the code and confirms enrollment", async () => { + useAuth.mockReturnValue({ totpEnabled: false }); + apiPost.mockResolvedValueOnce(ENROLL_RESPONSE); + apiPost.mockResolvedValueOnce({ ok: true }); + + render(); + fireEvent.click(screen.getByRole("button", { name: /enable two-factor authentication/i })); + await waitFor(() => expect(apiPost).toHaveBeenCalledWith("/auth/mfa/enroll")); + + fireEvent.change(screen.getByPlaceholderText("000000"), { target: { value: "123456" } }); + fireEvent.click(screen.getByRole("button", { name: /confirm and enable/i })); + + await waitFor(() => { + expect(apiPost).toHaveBeenCalledWith("/auth/mfa/verify", { code: "123456" }); + }); + expect(await screen.findByText(/is now enabled/i)).toBeInTheDocument(); + }); + + it("shows the server's specific error and stays on the verify step when the code is wrong", async () => { + useAuth.mockReturnValue({ totpEnabled: false }); + apiPost.mockResolvedValueOnce(ENROLL_RESPONSE); + apiPost.mockRejectedValueOnce(new Error("Invalid TOTP or recovery code")); + + render(); + fireEvent.click(screen.getByRole("button", { name: /enable two-factor authentication/i })); + await waitFor(() => expect(apiPost).toHaveBeenCalledWith("/auth/mfa/enroll")); + + fireEvent.change(screen.getByPlaceholderText("000000"), { target: { value: "000000" } }); + fireEvent.click(screen.getByRole("button", { name: /confirm and enable/i })); + + expect(await screen.findByText("Invalid TOTP or recovery code")).toBeInTheDocument(); + // Still on the verify step, not bounced back to the idle "Enable" button. + expect(screen.getByPlaceholderText("000000")).toBeInTheDocument(); + }); + + it("surfaces the server's specific error when verify fails for a reason other than a wrong code", async () => { + useAuth.mockReturnValue({ totpEnabled: false }); + apiPost.mockResolvedValueOnce(ENROLL_RESPONSE); + apiPost.mockRejectedValueOnce(new Error("Failed to decrypt TOTP secret")); + + render(); + fireEvent.click(screen.getByRole("button", { name: /enable two-factor authentication/i })); + await waitFor(() => expect(apiPost).toHaveBeenCalledWith("/auth/mfa/enroll")); + + fireEvent.change(screen.getByPlaceholderText("000000"), { target: { value: "123456" } }); + fireEvent.click(screen.getByRole("button", { name: /confirm and enable/i })); + + // Must not be mislabeled as a wrong code -- a decryption/config failure + // needs its own diagnosable message, not a generic "invalid code" that + // sends the user into an unwinnable retry loop. + expect(await screen.findByText("Failed to decrypt TOTP secret")).toBeInTheDocument(); + expect(screen.queryByText(/invalid code/i)).not.toBeInTheDocument(); + }); + + it("cancels enrollment and returns to the idle view without verifying", async () => { + useAuth.mockReturnValue({ totpEnabled: false }); + apiPost.mockResolvedValueOnce(ENROLL_RESPONSE); + + render(); + fireEvent.click(screen.getByRole("button", { name: /enable two-factor authentication/i })); + await waitFor(() => expect(apiPost).toHaveBeenCalledWith("/auth/mfa/enroll")); + + fireEvent.click(screen.getByRole("button", { name: /^cancel$/i })); + + expect( + screen.getByRole("button", { name: /enable two-factor authentication/i }), + ).toBeInTheDocument(); + expect(apiPost).toHaveBeenCalledTimes(1); + }); + + it("disables two-factor auth with a valid code", async () => { + useAuth.mockReturnValue({ totpEnabled: true }); + apiPost.mockResolvedValueOnce({ ok: true }); + + render(); + fireEvent.click(screen.getByRole("button", { name: /disable two-factor authentication/i })); + + // Confirm the disable form actually rendered before reusing the same + // accessible name for the submit button below -- otherwise a broken + // idle-to-disabling transition could resolve the second query to the + // wrong element instead of failing clearly. + const codeInput = await screen.findByPlaceholderText("000000"); + fireEvent.change(codeInput, { target: { value: "654321" } }); + fireEvent.click(screen.getByRole("button", { name: /disable two-factor authentication/i })); + + await waitFor(() => { + expect(apiPost).toHaveBeenCalledWith("/auth/mfa/disable", { code: "654321" }); + }); + expect(await screen.findByText(/has been disabled/i)).toBeInTheDocument(); + }); + + it("surfaces the server's specific error when disable fails for a reason other than a wrong code", async () => { + useAuth.mockReturnValue({ totpEnabled: true }); + apiPost.mockRejectedValueOnce(new Error("Failed to decrypt TOTP secret")); + + render(); + fireEvent.click(screen.getByRole("button", { name: /disable two-factor authentication/i })); + + const codeInput = await screen.findByPlaceholderText("000000"); + fireEvent.change(codeInput, { target: { value: "654321" } }); + fireEvent.click(screen.getByRole("button", { name: /disable two-factor authentication/i })); + + expect(await screen.findByText("Failed to decrypt TOTP secret")).toBeInTheDocument(); + expect(screen.queryByText(/invalid code/i)).not.toBeInTheDocument(); + }); + + it("copies recovery codes to the clipboard", async () => { + useAuth.mockReturnValue({ totpEnabled: false }); + apiPost.mockResolvedValueOnce(ENROLL_RESPONSE); + + render(); + fireEvent.click(screen.getByRole("button", { name: /enable two-factor authentication/i })); + await waitFor(() => expect(apiPost).toHaveBeenCalledWith("/auth/mfa/enroll")); + + fireEvent.click(screen.getByRole("button", { name: /copy codes/i })); + + await waitFor(() => { + expect(copyToClipboard).toHaveBeenCalledWith("aaaa1111\nbbbb2222"); + }); + expect(await screen.findByRole("button", { name: /^copied$/i })).toBeInTheDocument(); + }); + + it("shows an error instead of silently doing nothing when the clipboard write fails", async () => { + useAuth.mockReturnValue({ totpEnabled: false }); + apiPost.mockResolvedValueOnce(ENROLL_RESPONSE); + copyToClipboard.mockResolvedValueOnce(false); + + render(); + fireEvent.click(screen.getByRole("button", { name: /enable two-factor authentication/i })); + await waitFor(() => expect(apiPost).toHaveBeenCalledWith("/auth/mfa/enroll")); + + fireEvent.click(screen.getByRole("button", { name: /copy codes/i })); + + await waitFor(() => expect(copyToClipboard).toHaveBeenCalled()); + expect(await screen.findByText(/couldn't copy automatically/i)).toBeInTheDocument(); + // Button must not claim success it didn't achieve. + expect(screen.queryByRole("button", { name: /^copied$/i })).not.toBeInTheDocument(); + }); + + it("strips non-digit characters from the verify code as the user types", async () => { + useAuth.mockReturnValue({ totpEnabled: false }); + apiPost.mockResolvedValueOnce(ENROLL_RESPONSE); + + render(); + fireEvent.click(screen.getByRole("button", { name: /enable two-factor authentication/i })); + await waitFor(() => expect(apiPost).toHaveBeenCalledWith("/auth/mfa/enroll")); + + const codeInput = screen.getByPlaceholderText("000000") as HTMLInputElement; + fireEvent.change(codeInput, { target: { value: "12ab34" } }); + + expect(codeInput.value).toBe("1234"); + }); + + it("keeps the confirm button disabled until the code reaches 6 digits", async () => { + useAuth.mockReturnValue({ totpEnabled: false }); + apiPost.mockResolvedValueOnce(ENROLL_RESPONSE); + + render(); + fireEvent.click(screen.getByRole("button", { name: /enable two-factor authentication/i })); + await waitFor(() => expect(apiPost).toHaveBeenCalledWith("/auth/mfa/enroll")); + + const codeInput = screen.getByPlaceholderText("000000"); + const confirmButton = screen.getByRole("button", { name: /confirm and enable/i }); + + fireEvent.change(codeInput, { target: { value: "12345" } }); + expect(confirmButton).toBeDisabled(); + + fireEvent.change(codeInput, { target: { value: "123456" } }); + expect(confirmButton).not.toBeDisabled(); + }); + + it("keeps the disable button disabled until the code reaches 6 digits", async () => { + useAuth.mockReturnValue({ totpEnabled: true }); + + render(); + fireEvent.click(screen.getByRole("button", { name: /disable two-factor authentication/i })); + + const codeInput = await screen.findByPlaceholderText("000000"); + const submitButtons = screen.getAllByRole("button", { + name: /disable two-factor authentication/i, + }); + const submitButton = submitButtons[submitButtons.length - 1]; + + fireEvent.change(codeInput, { target: { value: "9999" } }); + expect(submitButton).toBeDisabled(); + + fireEvent.change(codeInput, { target: { value: "999999" } }); + expect(submitButton).not.toBeDisabled(); + }); +});