feat(auth): migrate account security presentation

This commit is contained in:
cesnimda
2026-10-11 21:16:05 +02:00
parent 8ad720da5d
commit ad7b4196af
22 changed files with 35532 additions and 143 deletions
@@ -16,7 +16,6 @@ import {
ListItem,
ListItemSecondaryAction,
ListItemText,
Paper,
TextField,
Typography,
} from "@mui/material";
@@ -25,6 +24,7 @@ import DeleteIcon from "@mui/icons-material/Delete";
import { api, getApiErrorMessage } from "../api";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
import { PageSection } from "../layout/PageLayout";
type Status = { enabled: boolean; enabledAtUtc: string | null };
type SetupResponse = { manualEntryKey: string; qrCodeDataUrl: string };
@@ -55,8 +55,11 @@ function apiErrorMessage(e: any, t: (k: any) => string) {
export default function TwoFactorSettingsCard() {
const { toast } = useToast();
const { t } = useI18n();
const { t, language } = useI18n();
const locale = language === "nb" ? "nb-NO" : "en";
const [status, setStatus] = useState<Status | null>(null);
const [statusLoading, setStatusLoading] = useState(true);
const [statusError, setStatusError] = useState<string | null>(null);
const [flow, setFlow] = useState<Flow>("closed");
const [password, setPassword] = useState("");
const [code, setCode] = useState("");
@@ -70,8 +73,13 @@ export default function TwoFactorSettingsCard() {
const [devicesError, setDevicesError] = useState<string | null>(null);
const loadStatus = useCallback(() => {
api.get<Status>("/auth/2fa/status").then((r) => setStatus(r.data)).catch(() => setStatus(null));
}, []);
setStatusLoading(true);
setStatusError(null);
api.get<Status>("/auth/2fa/status")
.then((r) => setStatus(r.data))
.catch((e) => { setStatus(null); setStatusError(apiErrorMessage(e, t)); })
.finally(() => setStatusLoading(false));
}, [t]);
const loadDevices = useCallback(() => {
setDevicesLoading(true);
@@ -184,21 +192,19 @@ export default function TwoFactorSettingsCard() {
const isRecoveryStep = flow === "enable-recovery" || flow === "regenerate-recovery";
return (
<Paper sx={{ mt: 2, p: 2.5, borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
<Typography variant="h6" sx={{ mb: 1 }}>
{t("twoFactorSectionTitle")}
</Typography>
{status ? (
<PageSection title={t("twoFactorSectionTitle")} surface>
{statusLoading ? <Typography role="status" color="text.secondary">{t("loading")}</Typography> : null}
{statusError ? <Alert severity="error" sx={{ mb: 1.5 }} action={<Button color="inherit" onClick={loadStatus}>{t("retry")}</Button>}>{statusError}</Alert> : null}
{status && !statusLoading ? (
<Typography sx={{ color: "text.secondary", mb: 1.5 }}>
{status.enabled
? t("twoFactorStatusEnabled", { date: status.enabledAtUtc ? new Date(status.enabledAtUtc).toLocaleDateString() : "" })
? t("twoFactorStatusEnabled", { date: status.enabledAtUtc ? new Date(status.enabledAtUtc).toLocaleDateString(locale) : "" })
: t("twoFactorStatusDisabled")}
</Typography>
) : null}
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
{!status?.enabled ? (
{status && !statusLoading ? <Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
{!status.enabled ? (
<Button variant="contained" onClick={() => setFlow("enable-password")}>
{t("twoFactorEnableButton")}
</Button>
@@ -212,15 +218,16 @@ export default function TwoFactorSettingsCard() {
</Button>
</>
)}
</Box>
</Box> : null}
{status?.enabled ? (
<Box sx={{ mt: 2 }}>
<Divider sx={{ mb: 1.5 }} />
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 0.5 }}>
<Typography component="h3" variant="sectionTitle" sx={{ mb: 0.5 }}>
{t("twoFactorTrustedDevicesTitle")}
</Typography>
{devicesError ? <Alert severity="error" role="alert" sx={{ mb: 1 }}>{devicesError}</Alert> : null}
{devicesLoading ? <Typography role="status" color="text.secondary">{t("loading")}</Typography> : null}
{devicesError ? <Alert severity="error" role="alert" sx={{ mb: 1 }} action={<Button color="inherit" onClick={loadDevices}>{t("retry")}</Button>}>{devicesError}</Alert> : null}
{!devicesLoading && devices.length === 0 && !devicesError ? (
<Typography sx={{ color: "text.secondary", mb: 1 }}>{t("twoFactorTrustedDevicesEmpty")}</Typography>
) : null}
@@ -229,6 +236,7 @@ export default function TwoFactorSettingsCard() {
{devices.map((d) => (
<ListItem key={d.id} divider>
<ListItemText
sx={{ minWidth: 0, overflowWrap: "anywhere" }}
primary={
<>
{d.deviceLabel || t("twoFactorTrustedDeviceUnknown")}
@@ -240,8 +248,8 @@ export default function TwoFactorSettingsCard() {
</>
}
secondary={t("twoFactorTrustedDeviceMeta", {
lastSeen: new Date(d.lastSeenAtUtc).toLocaleDateString(),
expires: new Date(d.expiresAtUtc).toLocaleDateString(),
lastSeen: new Date(d.lastSeenAtUtc).toLocaleDateString(locale),
expires: new Date(d.expiresAtUtc).toLocaleDateString(locale),
})}
/>
<ListItemSecondaryAction>
@@ -385,6 +393,6 @@ export default function TwoFactorSettingsCard() {
</>
)}
</Dialog>
</Paper>
</PageSection>
);
}