0ca2f2b261
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.
84 lines
2.5 KiB
TypeScript
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>
|
|
);
|
|
}
|