feat(auth): add 2FA setup UI and login challenge step
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { Alert, Box, Button, 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 [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 });
|
||||
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
|
||||
/>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user