feat(auth): add 2FA setup UI and login challenge step

This commit is contained in:
cesnimda
2026-07-12 21:17:09 +02:00
parent c68b49eda0
commit b85dc1ffb7
9 changed files with 702 additions and 57 deletions
@@ -4,6 +4,7 @@ import { Box, Button, Chip, Paper, Typography } from "@mui/material";
import { api, getApiErrorMessage } from "../api";
import { clearAuthClientState, getAuthPersistencePreference } from "../auth";
import TwoFactorChallenge from "./TwoFactorChallenge";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
@@ -52,6 +53,7 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
const [me, setMe] = useState<MeResponse | null>(null);
const [working, setWorking] = useState(false);
const [allowRegistration, setAllowRegistration] = useState(false);
const [pendingToken, setPendingToken] = useState<string | null>(null);
const hostRef = useRef<HTMLDivElement | null>(null);
const clientId = (process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "").trim();
@@ -109,10 +111,14 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
toast(res.data?.email ? t("googleLinkedSuccessWithEmail", { email: res.data.email }) : t("googleLinkedSuccess"), "success");
await refreshMe();
} else {
await api.post("/auth/google/exchange", { token: credential, rememberMe: getAuthPersistencePreference() === "local" });
window.dispatchEvent(new Event("auth-changed"));
toast(t("googleSignedIn"), "success");
onSignedIn?.();
const res = await api.post<{ requiresTwoFactor?: boolean; pendingToken?: string }>("/auth/google/exchange", { token: credential, rememberMe: getAuthPersistencePreference() === "local" });
if (res.data?.requiresTwoFactor && res.data.pendingToken) {
setPendingToken(res.data.pendingToken);
} else {
window.dispatchEvent(new Event("auth-changed"));
toast(t("googleSignedIn"), "success");
onSignedIn?.();
}
}
} catch (e: any) {
toast(getApiErrorMessage(e, t("googleAuthFailed")), "error");
@@ -151,7 +157,20 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
</Typography>
)}
{clientId && (
{clientId && pendingToken && (
<TwoFactorChallenge
pendingToken={pendingToken}
onCancel={() => setPendingToken(null)}
onSuccess={() => {
setPendingToken(null);
window.dispatchEvent(new Event("auth-changed"));
toast(t("googleSignedIn"), "success");
onSignedIn?.();
}}
/>
)}
{clientId && !pendingToken && (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Chip size="small" label={me?.googleLink?.linked ? t("googleLinked") : t("googleAvailableToLink")} color={me?.googleLink?.linked ? "success" : "default"} variant={me?.googleLink?.linked ? "filled" : "outlined"} />
@@ -5,6 +5,7 @@ import { PublicClientApplication } from "@azure/msal-browser";
import { api, getApiErrorMessage } from "../api";
import { clearAuthClientState, getAuthPersistencePreference } from "../auth";
import TwoFactorChallenge from "./TwoFactorChallenge";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
@@ -35,6 +36,7 @@ export default function MicrosoftAuthCard({ onSignedIn }: { onSignedIn?: () => v
const { t } = useI18n();
const [me, setMe] = useState<MeResponse | null>(null);
const [working, setWorking] = useState(false);
const [pendingToken, setPendingToken] = useState<string | null>(null);
const clientId = (process.env.REACT_APP_MICROSOFT_CLIENT_ID || "").trim();
const signedIn = Boolean(me?.provider);
@@ -78,10 +80,14 @@ export default function MicrosoftAuthCard({ onSignedIn }: { onSignedIn?: () => v
toast(res.data?.email ? t("microsoftLinkedSuccessWithEmail", { email: res.data.email }) : t("microsoftLinkedSuccess"), "success");
await refreshMe();
} else {
await api.post("/auth/microsoft/exchange", { token: idToken, rememberMe: getAuthPersistencePreference() === "local" });
window.dispatchEvent(new Event("auth-changed"));
toast(t("microsoftSignedIn"), "success");
onSignedIn?.();
const res = await api.post<{ requiresTwoFactor?: boolean; pendingToken?: string }>("/auth/microsoft/exchange", { token: idToken, rememberMe: getAuthPersistencePreference() === "local" });
if (res.data?.requiresTwoFactor && res.data.pendingToken) {
setPendingToken(res.data.pendingToken);
} else {
window.dispatchEvent(new Event("auth-changed"));
toast(t("microsoftSignedIn"), "success");
onSignedIn?.();
}
}
} catch (e: any) {
toast(getApiErrorMessage(e, t("microsoftAuthFailed")), "error");
@@ -104,7 +110,20 @@ export default function MicrosoftAuthCard({ onSignedIn }: { onSignedIn?: () => v
</Typography>
)}
{clientId && (
{clientId && pendingToken && (
<TwoFactorChallenge
pendingToken={pendingToken}
onCancel={() => setPendingToken(null)}
onSuccess={() => {
setPendingToken(null);
window.dispatchEvent(new Event("auth-changed"));
toast(t("microsoftSignedIn"), "success");
onSignedIn?.();
}}
/>
)}
{clientId && !pendingToken && (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Chip size="small" label={me?.microsoftLink?.linked ? t("microsoftLinked") : t("microsoftAvailableToLink")} color={me?.microsoftLink?.linked ? "success" : "default"} variant={me?.microsoftLink?.linked ? "filled" : "outlined"} />
@@ -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>
);
}
@@ -0,0 +1,278 @@
import React, { useEffect, useState } from "react";
import {
Alert,
Box,
Button,
Checkbox,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControlLabel,
Paper,
TextField,
Typography,
} from "@mui/material";
import { api, getApiErrorMessage } from "../api";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
type Status = { enabled: boolean; enabledAtUtc: string | null };
type SetupResponse = { manualEntryKey: string; qrCodeDataUrl: string };
type RecoveryCodesResponse = { recoveryCodes: string[] };
type Flow =
| "closed"
| "enable-password"
| "enable-qr"
| "enable-recovery"
| "disable-password"
| "regenerate-password"
| "regenerate-recovery";
function apiErrorMessage(e: any, t: (k: any) => string) {
if (e?.response?.status === 429) return t("twoFactorRateLimited");
return getApiErrorMessage(e, t("twoFactorGenericError"));
}
export default function TwoFactorSettingsCard() {
const { toast } = useToast();
const { t } = useI18n();
const [status, setStatus] = useState<Status | null>(null);
const [flow, setFlow] = useState<Flow>("closed");
const [password, setPassword] = useState("");
const [code, setCode] = useState("");
const [setup, setSetup] = useState<SetupResponse | null>(null);
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([]);
const [savedConfirmed, setSavedConfirmed] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadStatus = () => {
api.get<Status>("/auth/2fa/status").then((r) => setStatus(r.data)).catch(() => setStatus(null));
};
useEffect(() => { loadStatus(); }, []);
function closeFlow() {
setFlow("closed");
setPassword("");
setCode("");
setSetup(null);
setRecoveryCodes([]);
setSavedConfirmed(false);
setError(null);
}
async function submitPassword() {
setLoading(true);
setError(null);
try {
if (flow === "enable-password") {
const res = await api.post<SetupResponse>("/auth/2fa/setup", { currentPassword: password });
setSetup(res.data);
setPassword("");
setFlow("enable-qr");
} else if (flow === "disable-password") {
await api.post("/auth/2fa/disable", { currentPassword: password });
toast(t("twoFactorDisabledSuccess"), "success");
closeFlow();
loadStatus();
} else if (flow === "regenerate-password") {
const res = await api.post<RecoveryCodesResponse>("/auth/2fa/recovery-codes/regenerate", { currentPassword: password });
setRecoveryCodes(res.data.recoveryCodes);
setPassword("");
setFlow("regenerate-recovery");
}
} catch (e: any) {
setError(e?.response?.status === 400 || e?.response?.status === 401 ? t("twoFactorWrongPassword") : apiErrorMessage(e, t));
} finally {
setLoading(false);
}
}
async function submitCode() {
setLoading(true);
setError(null);
try {
const res = await api.post<RecoveryCodesResponse>("/auth/2fa/verify-setup", { code });
setRecoveryCodes(res.data.recoveryCodes);
setCode("");
setFlow("enable-recovery");
} catch (e: any) {
setError(e?.response?.status === 401 ? t("twoFactorInvalidCode") : apiErrorMessage(e, t));
} finally {
setLoading(false);
}
}
function finishRecovery() {
toast(flow === "enable-recovery" ? t("twoFactorEnabledSuccess") : t("twoFactorRegenerateSuccess"), "success");
closeFlow();
loadStatus();
}
function copyRecoveryCodes() {
void navigator.clipboard.writeText(recoveryCodes.join("\n"));
toast(t("twoFactorCodesCopied"), "info");
}
function downloadRecoveryCodes() {
const blob = new Blob([recoveryCodes.join("\n") + "\n"], { type: "text/plain" });
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "jobbjakt-recovery-codes.txt";
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
}
const isPasswordStep = flow === "enable-password" || flow === "disable-password" || flow === "regenerate-password";
const isRecoveryStep = flow === "enable-recovery" || flow === "regenerate-recovery";
return (
<Paper sx={{ mt: 2, p: 2 }}>
<Typography variant="h6" sx={{ mb: 1 }}>
{t("twoFactorSectionTitle")}
</Typography>
{status ? (
<Typography sx={{ color: "text.secondary", mb: 1.5 }}>
{status.enabled
? t("twoFactorStatusEnabled", { date: status.enabledAtUtc ? new Date(status.enabledAtUtc).toLocaleDateString() : "" })
: t("twoFactorStatusDisabled")}
</Typography>
) : null}
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
{!status?.enabled ? (
<Button variant="contained" onClick={() => setFlow("enable-password")}>
{t("twoFactorEnableButton")}
</Button>
) : (
<>
<Button variant="outlined" color="warning" onClick={() => setFlow("disable-password")}>
{t("twoFactorDisableButton")}
</Button>
<Button variant="outlined" onClick={() => setFlow("regenerate-password")}>
{t("twoFactorRegenerateButton")}
</Button>
</>
)}
</Box>
<Dialog open={flow !== "closed"} onClose={isRecoveryStep ? undefined : closeFlow} maxWidth="sm" fullWidth>
{isPasswordStep && (
<Box component="form" onSubmit={(e) => { e.preventDefault(); void submitPassword(); }}>
<DialogTitle>{t("twoFactorPasswordPrompt")}</DialogTitle>
<DialogContent>
{flow === "disable-password" ? <Alert severity="warning" sx={{ mb: 2 }}>{t("twoFactorDisableWarning")}</Alert> : null}
{flow === "regenerate-password" ? <Alert severity="warning" sx={{ mb: 2 }}>{t("twoFactorRegenerateWarning")}</Alert> : null}
{error ? <Alert severity="error" role="alert" sx={{ mb: 2 }}>{error}</Alert> : null}
<TextField
label={t("twoFactorPasswordLabel")}
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
autoFocus
fullWidth
/>
</DialogContent>
<DialogActions>
<Button type="button" onClick={closeFlow} disabled={loading}>{t("cancel")}</Button>
<Button type="submit" variant="contained" disabled={loading || !password}>
{t("twoFactorContinue")}
</Button>
</DialogActions>
</Box>
)}
{flow === "enable-qr" && setup && (
<Box component="form" onSubmit={(e) => { e.preventDefault(); void submitCode(); }}>
<DialogTitle>{t("twoFactorSetupTitle")}</DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 2 }}>
{t("twoFactorSetupHint")}
</Typography>
<Box sx={{ display: "flex", justifyContent: "center", mb: 2 }}>
<img src={setup.qrCodeDataUrl} alt={t("twoFactorSetupTitle")} width={200} height={200} />
</Box>
<TextField
label={t("twoFactorManualKeyLabel")}
value={setup.manualEntryKey}
fullWidth
sx={{ mb: 1 }}
InputProps={{
readOnly: true,
endAdornment: (
<Button
size="small"
onClick={() => {
void navigator.clipboard.writeText(setup.manualEntryKey);
toast(t("twoFactorKeyCopied"), "info");
}}
>
{t("twoFactorCopyKey")}
</Button>
),
}}
/>
<Typography variant="body2" sx={{ color: "text.secondary", mt: 2, mb: 1 }}>
{t("twoFactorConfirmCodeHint")}
</Typography>
{error ? <Alert severity="error" role="alert" sx={{ mb: 2 }}>{error}</Alert> : null}
<TextField
label={t("twoFactorConfirmCodeLabel")}
value={code}
onChange={(e) => setCode(e.target.value)}
autoComplete="one-time-code"
fullWidth
/>
</DialogContent>
<DialogActions>
<Button type="button" onClick={closeFlow} disabled={loading}>{t("cancel")}</Button>
<Button type="submit" variant="contained" disabled={loading || !code.trim()}>
{t("twoFactorConfirmButton")}
</Button>
</DialogActions>
</Box>
)}
{isRecoveryStep && (
<>
<DialogTitle role="alert">{t("twoFactorRecoveryTitle")}</DialogTitle>
<DialogContent>
<Alert severity="warning" sx={{ mb: 2 }}>{t("twoFactorRecoveryHint")}</Alert>
<Box
component="ul"
sx={{ fontFamily: "monospace", fontSize: 16, p: 1.5, borderRadius: 2, backgroundColor: "background.default", border: "1px solid", borderColor: "divider", listStyle: "none", m: 0, mb: 2 }}
>
{recoveryCodes.map((rc) => (
<li key={rc}>{rc}</li>
))}
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 2 }}>
<Button variant="outlined" onClick={copyRecoveryCodes}>{t("twoFactorCopyAll")}</Button>
<Button variant="outlined" onClick={downloadRecoveryCodes}>{t("twoFactorDownload")}</Button>
</Box>
<FormControlLabel
control={<Checkbox checked={savedConfirmed} onChange={(e) => setSavedConfirmed(e.target.checked)} />}
label={t("twoFactorSavedConfirm")}
/>
</DialogContent>
<DialogActions>
<Button variant="contained" disabled={!savedConfirmed} onClick={finishRecovery}>
{t("twoFactorDone")}
</Button>
</DialogActions>
</>
)}
</Dialog>
</Paper>
);
}