feat(auth): add trusted-device 30-day 2FA skip (frontend)

Adds a "Trust this device for 30 days" checkbox to the 2FA challenge step,
and a "Trusted devices" section to the 2FA settings card: list devices with
a "this device" badge, per-row revoke, and a confirm-gated "sign out all
other trusted devices" action. Both flows are opt-in and additive -- default
unchecked, so nothing changes for a user who never uses them.
This commit is contained in:
cesnimda
2026-07-13 01:02:43 +02:00
parent b914630657
commit 0ca2f2b261
4 changed files with 146 additions and 6 deletions
@@ -1,6 +1,6 @@
import React, { useState } from "react";
import { Alert, Box, Button, TextField, Typography } from "@mui/material";
import { Alert, Box, Button, Checkbox, FormControlLabel, TextField, Typography } from "@mui/material";
import { api, getApiErrorMessage } from "../api";
import { useI18n } from "../i18n/I18nProvider";
@@ -18,6 +18,7 @@ export default function TwoFactorChallenge({
}) {
const { t } = useI18n();
const [code, setCode] = useState("");
const [trustDevice, setTrustDevice] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -25,7 +26,7 @@ export default function TwoFactorChallenge({
setLoading(true);
setError(null);
try {
const res = await api.post<ChallengeResponse>("/auth/2fa/challenge", { pendingToken, code });
const res = await api.post<ChallengeResponse>("/auth/2fa/challenge", { pendingToken, code, trustDevice });
onSuccess(res.data);
} catch (e: any) {
if (e?.response?.status === 429) {
@@ -64,6 +65,11 @@ export default function TwoFactorChallenge({
fullWidth
/>
<FormControlLabel
control={<Checkbox checked={trustDevice} onChange={(e) => setTrustDevice(e.target.checked)} />}
label={t("twoFactorTrustDevice")}
/>
<Box sx={{ display: "flex", gap: 1, justifyContent: "flex-end" }}>
<Button type="button" variant="text" disabled={loading} onClick={onCancel}>
{t("twoFactorBack")}
@@ -9,11 +9,18 @@ import {
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";
@@ -22,6 +29,14 @@ 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"
@@ -30,7 +45,8 @@ type Flow =
| "enable-recovery"
| "disable-password"
| "regenerate-password"
| "regenerate-recovery";
| "regenerate-recovery"
| "revoke-all-confirm";
function apiErrorMessage(e: any, t: (k: any) => string) {
if (e?.response?.status === 429) return t("twoFactorRateLimited");
@@ -49,12 +65,45 @@ export default function TwoFactorSettingsCard() {
const [savedConfirmed, setSavedConfirmed] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [devices, setDevices] = useState<TrustedDevice[]>([]);
const [devicesLoading, setDevicesLoading] = useState(false);
const [devicesError, setDevicesError] = useState<string | null>(null);
const loadStatus = () => {
api.get<Status>("/auth/2fa/status").then((r) => setStatus(r.data)).catch(() => setStatus(null));
};
useEffect(() => { loadStatus(); }, []);
const loadDevices = () => {
setDevicesLoading(true);
setDevicesError(null);
api
.get<TrustedDevice[]>("/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");
@@ -165,6 +214,53 @@ export default function TwoFactorSettingsCard() {
)}
</Box>
{status?.enabled ? (
<Box sx={{ mt: 2 }}>
<Divider sx={{ mb: 1.5 }} />
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 0.5 }}>
{t("twoFactorTrustedDevicesTitle")}
</Typography>
{devicesError ? <Alert severity="error" role="alert" sx={{ mb: 1 }}>{devicesError}</Alert> : null}
{!devicesLoading && devices.length === 0 && !devicesError ? (
<Typography sx={{ color: "text.secondary", mb: 1 }}>{t("twoFactorTrustedDevicesEmpty")}</Typography>
) : null}
{devices.length > 0 ? (
<List dense disablePadding>
{devices.map((d) => (
<ListItem key={d.id} divider>
<ListItemText
primary={
<>
{d.deviceLabel || t("twoFactorTrustedDeviceUnknown")}
{d.isCurrentDevice ? (
<Typography component="span" variant="caption" sx={{ ml: 1, color: "primary.main", fontWeight: 700 }}>
{t("twoFactorTrustedDeviceCurrent")}
</Typography>
) : null}
</>
}
secondary={t("twoFactorTrustedDeviceMeta", {
lastSeen: new Date(d.lastSeenAtUtc).toLocaleDateString(),
expires: new Date(d.expiresAtUtc).toLocaleDateString(),
})}
/>
<ListItemSecondaryAction>
<IconButton edge="end" aria-label={t("twoFactorRevokeDevice")} onClick={() => revokeDevice(d.id)}>
<DeleteIcon fontSize="small" />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
))}
</List>
) : null}
{devices.length > 0 ? (
<Button variant="text" color="warning" size="small" sx={{ mt: 1 }} onClick={() => setFlow("revoke-all-confirm")}>
{t("twoFactorRevokeAllDevices")}
</Button>
) : null}
</Box>
) : null}
<Dialog open={flow !== "closed"} onClose={isRecoveryStep ? undefined : closeFlow} maxWidth="sm" fullWidth>
{isPasswordStep && (
<Box component="form" onSubmit={(e) => { e.preventDefault(); void submitPassword(); }}>
@@ -272,6 +368,22 @@ export default function TwoFactorSettingsCard() {
</DialogActions>
</>
)}
{flow === "revoke-all-confirm" && (
<>
<DialogTitle>{t("twoFactorRevokeAllConfirmTitle")}</DialogTitle>
<DialogContent>
{error ? <Alert severity="error" role="alert" sx={{ mb: 2 }}>{error}</Alert> : null}
<Typography>{t("twoFactorRevokeAllConfirmBody")}</Typography>
</DialogContent>
<DialogActions>
<Button type="button" onClick={closeFlow}>{t("cancel")}</Button>
<Button variant="contained" color="warning" onClick={revokeAllDevices}>
{t("twoFactorRevokeAllDevices")}
</Button>
</DialogActions>
</>
)}
</Dialog>
</Paper>
);