import React, { useEffect, useState } from "react"; import { Alert, Box, Button, Checkbox, Dialog, DialogActions, DialogContent, DialogTitle, Divider, FormControlLabel, IconButton, List, ListItem, ListItemSecondaryAction, ListItemText, Paper, TextField, Typography, } from "@mui/material"; import DeleteIcon from "@mui/icons-material/Delete"; import { api, getApiErrorMessage } from "../api"; import { useToast } from "../toast"; import { useI18n } from "../i18n/I18nProvider"; type Status = { enabled: boolean; enabledAtUtc: string | null }; type SetupResponse = { manualEntryKey: string; qrCodeDataUrl: string }; type RecoveryCodesResponse = { recoveryCodes: string[] }; type TrustedDevice = { id: number; deviceLabel: string | null; createdAtUtc: string; lastSeenAtUtc: string; expiresAtUtc: string; isCurrentDevice: boolean; }; type Flow = | "closed" | "enable-password" | "enable-qr" | "enable-recovery" | "disable-password" | "regenerate-password" | "regenerate-recovery" | "revoke-all-confirm"; function apiErrorMessage(e: any, t: (k: any) => string) { if (e?.response?.status === 429) return t("twoFactorRateLimited"); return getApiErrorMessage(e, t("twoFactorGenericError")); } export default function TwoFactorSettingsCard() { const { toast } = useToast(); const { t } = useI18n(); const [status, setStatus] = useState(null); const [flow, setFlow] = useState("closed"); const [password, setPassword] = useState(""); const [code, setCode] = useState(""); const [setup, setSetup] = useState(null); const [recoveryCodes, setRecoveryCodes] = useState([]); const [savedConfirmed, setSavedConfirmed] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [devices, setDevices] = useState([]); const [devicesLoading, setDevicesLoading] = useState(false); const [devicesError, setDevicesError] = useState(null); const loadStatus = () => { api.get("/auth/2fa/status").then((r) => setStatus(r.data)).catch(() => setStatus(null)); }; const loadDevices = () => { setDevicesLoading(true); setDevicesError(null); api .get("/auth/2fa/trusted-devices") .then((r) => setDevices(r.data)) .catch((e) => setDevicesError(apiErrorMessage(e, t))) .finally(() => setDevicesLoading(false)); }; useEffect(() => { loadStatus(); loadDevices(); }, []); async function revokeDevice(id: number) { try { await api.delete(`/auth/2fa/trusted-devices/${id}`); loadDevices(); } catch (e: any) { setDevicesError(apiErrorMessage(e, t)); } } async function revokeAllDevices() { try { await api.post("/auth/2fa/trusted-devices/revoke-all"); toast(t("twoFactorTrustedDevicesRevokedAll"), "success"); closeFlow(); loadDevices(); } catch (e: any) { setError(apiErrorMessage(e, t)); } } function closeFlow() { setFlow("closed"); setPassword(""); setCode(""); setSetup(null); setRecoveryCodes([]); setSavedConfirmed(false); setError(null); } async function submitPassword() { setLoading(true); setError(null); try { if (flow === "enable-password") { const res = await api.post("/auth/2fa/setup", { currentPassword: password }); setSetup(res.data); setPassword(""); setFlow("enable-qr"); } else if (flow === "disable-password") { await api.post("/auth/2fa/disable", { currentPassword: password }); toast(t("twoFactorDisabledSuccess"), "success"); closeFlow(); loadStatus(); } else if (flow === "regenerate-password") { const res = await api.post("/auth/2fa/recovery-codes/regenerate", { currentPassword: password }); setRecoveryCodes(res.data.recoveryCodes); setPassword(""); setFlow("regenerate-recovery"); } } catch (e: any) { setError(e?.response?.status === 400 || e?.response?.status === 401 ? t("twoFactorWrongPassword") : apiErrorMessage(e, t)); } finally { setLoading(false); } } async function submitCode() { setLoading(true); setError(null); try { const res = await api.post("/auth/2fa/verify-setup", { code }); setRecoveryCodes(res.data.recoveryCodes); setCode(""); setFlow("enable-recovery"); } catch (e: any) { setError(e?.response?.status === 401 ? t("twoFactorInvalidCode") : apiErrorMessage(e, t)); } finally { setLoading(false); } } function finishRecovery() { toast(flow === "enable-recovery" ? t("twoFactorEnabledSuccess") : t("twoFactorRegenerateSuccess"), "success"); closeFlow(); loadStatus(); } function copyRecoveryCodes() { void navigator.clipboard.writeText(recoveryCodes.join("\n")); toast(t("twoFactorCodesCopied"), "info"); } function downloadRecoveryCodes() { const blob = new Blob([recoveryCodes.join("\n") + "\n"], { type: "text/plain" }); const url = window.URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = "jobbjakt-recovery-codes.txt"; document.body.appendChild(link); link.click(); link.remove(); window.URL.revokeObjectURL(url); } const isPasswordStep = flow === "enable-password" || flow === "disable-password" || flow === "regenerate-password"; const isRecoveryStep = flow === "enable-recovery" || flow === "regenerate-recovery"; return ( {t("twoFactorSectionTitle")} {status ? ( {status.enabled ? t("twoFactorStatusEnabled", { date: status.enabledAtUtc ? new Date(status.enabledAtUtc).toLocaleDateString() : "" }) : t("twoFactorStatusDisabled")} ) : null} {!status?.enabled ? ( ) : ( <> )} {status?.enabled ? ( {t("twoFactorTrustedDevicesTitle")} {devicesError ? {devicesError} : null} {!devicesLoading && devices.length === 0 && !devicesError ? ( {t("twoFactorTrustedDevicesEmpty")} ) : null} {devices.length > 0 ? ( {devices.map((d) => ( {d.deviceLabel || t("twoFactorTrustedDeviceUnknown")} {d.isCurrentDevice ? ( {t("twoFactorTrustedDeviceCurrent")} ) : null} } secondary={t("twoFactorTrustedDeviceMeta", { lastSeen: new Date(d.lastSeenAtUtc).toLocaleDateString(), expires: new Date(d.expiresAtUtc).toLocaleDateString(), })} /> revokeDevice(d.id)}> ))} ) : null} {devices.length > 0 ? ( ) : null} ) : null} {isPasswordStep && ( { e.preventDefault(); void submitPassword(); }}> {t("twoFactorPasswordPrompt")} {flow === "disable-password" ? {t("twoFactorDisableWarning")} : null} {flow === "regenerate-password" ? {t("twoFactorRegenerateWarning")} : null} {error ? {error} : null} setPassword(e.target.value)} autoComplete="current-password" autoFocus fullWidth /> )} {flow === "enable-qr" && setup && ( { e.preventDefault(); void submitCode(); }}> {t("twoFactorSetupTitle")} {t("twoFactorSetupHint")} {t("twoFactorSetupTitle")} { void navigator.clipboard.writeText(setup.manualEntryKey); toast(t("twoFactorKeyCopied"), "info"); }} > {t("twoFactorCopyKey")} ), }} /> {t("twoFactorConfirmCodeHint")} {error ? {error} : null} setCode(e.target.value)} autoComplete="one-time-code" fullWidth /> )} {isRecoveryStep && ( <> {t("twoFactorRecoveryTitle")} {t("twoFactorRecoveryHint")} {recoveryCodes.map((rc) => (
  • {rc}
  • ))}
    setSavedConfirmed(e.target.checked)} />} label={t("twoFactorSavedConfirm")} />
    )} {flow === "revoke-all-confirm" && ( <> {t("twoFactorRevokeAllConfirmTitle")} {error ? {error} : null} {t("twoFactorRevokeAllConfirmBody")} )}
    ); }