Files
jobtrackingapp/job-tracker-ui/src/components/TwoFactorChallenge.tsx
T
cesnimda 0ca2f2b261 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.
2026-07-13 01:02:43 +02:00

84 lines
2.5 KiB
TypeScript

import React, { useState } from "react";
import { Alert, Box, Button, Checkbox, FormControlLabel, TextField, Typography } from "@mui/material";
import { api, getApiErrorMessage } from "../api";
import { useI18n } from "../i18n/I18nProvider";
type ChallengeResponse = { authenticated: true; provider: "local" };
export default function TwoFactorChallenge({
pendingToken,
onSuccess,
onCancel,
}: {
pendingToken: string;
onSuccess: (data: ChallengeResponse) => void;
onCancel: () => void;
}) {
const { t } = useI18n();
const [code, setCode] = useState("");
const [trustDevice, setTrustDevice] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit() {
setLoading(true);
setError(null);
try {
const res = await api.post<ChallengeResponse>("/auth/2fa/challenge", { pendingToken, code, trustDevice });
onSuccess(res.data);
} catch (e: any) {
if (e?.response?.status === 429) {
setError(t("twoFactorRateLimited"));
} else {
setError(getApiErrorMessage(e, t("twoFactorInvalidCode")));
}
} finally {
setLoading(false);
}
}
return (
<Box
component="form"
onSubmit={(e) => { e.preventDefault(); void submit(); }}
sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}
role="group"
aria-label={t("twoFactorTitle")}
>
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>
{t("twoFactorTitle")}
</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{t("twoFactorHint")}
</Typography>
{error ? <Alert severity="error" role="alert">{error}</Alert> : null}
<TextField
label={t("twoFactorCodeLabel")}
value={code}
onChange={(e) => setCode(e.target.value)}
autoComplete="one-time-code"
autoFocus
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")}
</Button>
<Button type="submit" variant="contained" disabled={loading || !code.trim()}>
{loading ? t("twoFactorVerifying") : t("twoFactorVerify")}
</Button>
</Box>
</Box>
);
}