feat(auth): add 2FA setup UI and login challenge step
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -306,6 +306,45 @@ export const translations = {
|
||||
profileUpdatePassword: "Update password",
|
||||
profilePasswordUpdated: "Password updated.",
|
||||
profilePasswordUpdateFailed: "Failed to change password.",
|
||||
twoFactorTitle: "Two-factor verification",
|
||||
twoFactorHint: "Enter the 6-digit code from your authenticator app, or a recovery code.",
|
||||
twoFactorCodeLabel: "Code",
|
||||
twoFactorVerify: "Verify",
|
||||
twoFactorVerifying: "Verifying...",
|
||||
twoFactorBack: "Back",
|
||||
twoFactorInvalidCode: "Invalid code. Please try again.",
|
||||
twoFactorRateLimited: "Too many attempts. Please wait a few minutes and try again.",
|
||||
twoFactorGenericError: "Something went wrong. Please try again.",
|
||||
twoFactorSectionTitle: "Two-factor authentication",
|
||||
twoFactorStatusEnabled: "Enabled since {date}",
|
||||
twoFactorStatusDisabled: "Not enabled",
|
||||
twoFactorEnableButton: "Enable 2FA",
|
||||
twoFactorDisableButton: "Disable 2FA",
|
||||
twoFactorRegenerateButton: "Regenerate recovery codes",
|
||||
twoFactorPasswordPrompt: "Confirm your password to continue",
|
||||
twoFactorPasswordLabel: "Current password",
|
||||
twoFactorContinue: "Continue",
|
||||
twoFactorWrongPassword: "Incorrect password.",
|
||||
twoFactorSetupTitle: "Scan this QR code",
|
||||
twoFactorSetupHint: "Scan with your authenticator app, or enter the key manually.",
|
||||
twoFactorManualKeyLabel: "Manual entry key",
|
||||
twoFactorCopyKey: "Copy key",
|
||||
twoFactorKeyCopied: "Key copied to clipboard.",
|
||||
twoFactorConfirmCodeLabel: "6-digit code",
|
||||
twoFactorConfirmCodeHint: "Enter the code shown by your authenticator app to confirm setup.",
|
||||
twoFactorConfirmButton: "Confirm",
|
||||
twoFactorRecoveryTitle: "Save your recovery codes",
|
||||
twoFactorRecoveryHint: "Each code can be used once if you lose access to your authenticator app. This is the only time these codes will be shown.",
|
||||
twoFactorCopyAll: "Copy all codes",
|
||||
twoFactorCodesCopied: "Recovery codes copied.",
|
||||
twoFactorDownload: "Download as .txt",
|
||||
twoFactorSavedConfirm: "I've saved my recovery codes",
|
||||
twoFactorDone: "Done",
|
||||
twoFactorDisableWarning: "Disabling 2FA will also invalidate your recovery codes.",
|
||||
twoFactorRegenerateWarning: "This will invalidate your existing recovery codes.",
|
||||
twoFactorEnabledSuccess: "Two-factor authentication enabled.",
|
||||
twoFactorDisabledSuccess: "Two-factor authentication disabled.",
|
||||
twoFactorRegenerateSuccess: "Recovery codes regenerated.",
|
||||
cropDialogTitle: "Crop profile image",
|
||||
cropDialogBody: "Position and zoom your image. The saved avatar will be exported as a 512×512 square.",
|
||||
cropDialogZoom: "Zoom",
|
||||
@@ -1284,6 +1323,45 @@ export const translations = {
|
||||
profileUpdatePassword: "Oppdater passord",
|
||||
profilePasswordUpdated: "Passord oppdatert.",
|
||||
profilePasswordUpdateFailed: "Kunne ikke endre passord.",
|
||||
twoFactorTitle: "Topunkts bekreftelse",
|
||||
twoFactorHint: "Skriv inn 6-sifret kode fra autentiseringsappen din, eller en gjenopprettingskode.",
|
||||
twoFactorCodeLabel: "Kode",
|
||||
twoFactorVerify: "Bekreft",
|
||||
twoFactorVerifying: "Bekrefter...",
|
||||
twoFactorBack: "Tilbake",
|
||||
twoFactorInvalidCode: "Ugyldig kode. Prøv igjen.",
|
||||
twoFactorRateLimited: "For mange forsøk. Vent noen minutter og prøv igjen.",
|
||||
twoFactorGenericError: "Noe gikk galt. Prøv igjen.",
|
||||
twoFactorSectionTitle: "Topunkts autentisering",
|
||||
twoFactorStatusEnabled: "Aktivert siden {date}",
|
||||
twoFactorStatusDisabled: "Ikke aktivert",
|
||||
twoFactorEnableButton: "Aktiver 2FA",
|
||||
twoFactorDisableButton: "Deaktiver 2FA",
|
||||
twoFactorRegenerateButton: "Generer nye gjenopprettingskoder",
|
||||
twoFactorPasswordPrompt: "Bekreft passordet ditt for å fortsette",
|
||||
twoFactorPasswordLabel: "Nåværende passord",
|
||||
twoFactorContinue: "Fortsett",
|
||||
twoFactorWrongPassword: "Feil passord.",
|
||||
twoFactorSetupTitle: "Skann denne QR-koden",
|
||||
twoFactorSetupHint: "Skann med autentiseringsappen din, eller skriv inn nøkkelen manuelt.",
|
||||
twoFactorManualKeyLabel: "Manuell registreringsnøkkel",
|
||||
twoFactorCopyKey: "Kopier nøkkel",
|
||||
twoFactorKeyCopied: "Nøkkel kopiert til utklippstavlen.",
|
||||
twoFactorConfirmCodeLabel: "6-sifret kode",
|
||||
twoFactorConfirmCodeHint: "Skriv inn koden som vises i autentiseringsappen din for å bekrefte oppsettet.",
|
||||
twoFactorConfirmButton: "Bekreft",
|
||||
twoFactorRecoveryTitle: "Lagre gjenopprettingskodene dine",
|
||||
twoFactorRecoveryHint: "Hver kode kan brukes én gang hvis du mister tilgang til autentiseringsappen din. Dette er eneste gang disse kodene vises.",
|
||||
twoFactorCopyAll: "Kopier alle koder",
|
||||
twoFactorCodesCopied: "Gjenopprettingskoder kopiert.",
|
||||
twoFactorDownload: "Last ned som .txt",
|
||||
twoFactorSavedConfirm: "Jeg har lagret gjenopprettingskodene mine",
|
||||
twoFactorDone: "Ferdig",
|
||||
twoFactorDisableWarning: "Deaktivering av 2FA vil også ugyldiggjøre gjenopprettingskodene dine.",
|
||||
twoFactorRegenerateWarning: "Dette vil ugyldiggjøre eksisterende gjenopprettingskoder.",
|
||||
twoFactorEnabledSuccess: "Topunkts autentisering aktivert.",
|
||||
twoFactorDisabledSuccess: "Topunkts autentisering deaktivert.",
|
||||
twoFactorRegenerateSuccess: "Gjenopprettingskoder generert på nytt.",
|
||||
cropDialogTitle: "Beskjær profilbilde",
|
||||
cropDialogBody: "Plasser og zoom bildet. Det lagrede avataren eksporteres som en kvadratisk 512×512-fil.",
|
||||
cropDialogZoom: "Zoom",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
@@ -81,4 +82,59 @@ describe('LoginPage', () => {
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/forgot-password?email=person%40example.com');
|
||||
});
|
||||
|
||||
it('shows the 2FA code step when login requires two-factor, then proceeds like a normal login on success', async () => {
|
||||
mockedApi.post.mockImplementation((url: string, payload?: any) => {
|
||||
if (url === '/auth/login') {
|
||||
return Promise.resolve({ data: { requiresTwoFactor: true, pendingToken: 'pending-abc' } } as any);
|
||||
}
|
||||
if (url === '/auth/2fa/challenge') {
|
||||
expect(payload).toEqual({ pendingToken: 'pending-abc', code: '123456' });
|
||||
return Promise.resolve({ data: { authenticated: true, provider: 'local' } } as any);
|
||||
}
|
||||
return Promise.resolve({ data: {} } as any);
|
||||
});
|
||||
mockedApi.get.mockResolvedValueOnce({ data: { roles: [], email: 'person@example.com', userName: 'person' } } as any);
|
||||
|
||||
renderLoginPage();
|
||||
await screen.findByLabelText('Email');
|
||||
|
||||
await userEvent.type(screen.getByLabelText('Email'), 'person@example.com');
|
||||
await userEvent.type(screen.getByLabelText('Current password'), 'hunter2');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Sign in' }));
|
||||
|
||||
await screen.findByText('Two-factor verification');
|
||||
expect(screen.queryByLabelText('Email')).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.type(screen.getByLabelText('Code'), '123456');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Verify' }));
|
||||
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/auth/2fa/challenge', { pendingToken: 'pending-abc', code: '123456' }));
|
||||
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/auth/me'));
|
||||
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true }));
|
||||
});
|
||||
|
||||
it('shows a clear message when the 2FA challenge is rate-limited', async () => {
|
||||
mockedApi.post.mockImplementation((url: string) => {
|
||||
if (url === '/auth/login') {
|
||||
return Promise.resolve({ data: { requiresTwoFactor: true, pendingToken: 'pending-abc' } } as any);
|
||||
}
|
||||
if (url === '/auth/2fa/challenge') {
|
||||
return Promise.reject({ response: { status: 429 } });
|
||||
}
|
||||
return Promise.resolve({ data: {} } as any);
|
||||
});
|
||||
|
||||
renderLoginPage();
|
||||
await screen.findByLabelText('Email');
|
||||
await userEvent.type(screen.getByLabelText('Email'), 'person@example.com');
|
||||
await userEvent.type(screen.getByLabelText('Current password'), 'hunter2');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Sign in' }));
|
||||
|
||||
await screen.findByText('Two-factor verification');
|
||||
await userEvent.type(screen.getByLabelText('Code'), '123456');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Verify' }));
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Too many attempts. Please wait a few minutes and try again.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ToastProvider } from './toast';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
import TwoFactorSettingsCard from './components/TwoFactorSettingsCard';
|
||||
import { api } from './api';
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
const writeTextMock = jest.fn(() => Promise.resolve());
|
||||
Object.assign(navigator, { clipboard: { writeText: writeTextMock } });
|
||||
Object.defineProperty(window.URL, 'createObjectURL', { writable: true, value: jest.fn(() => 'blob:mock') });
|
||||
Object.defineProperty(window.URL, 'revokeObjectURL', { writable: true, value: jest.fn() });
|
||||
|
||||
function renderCard() {
|
||||
return render(
|
||||
<ToastProvider>
|
||||
<I18nProvider>
|
||||
<TwoFactorSettingsCard />
|
||||
</I18nProvider>
|
||||
</ToastProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockedApi.get.mockImplementation((url: string) => {
|
||||
if (url === '/auth/2fa/status') {
|
||||
return Promise.resolve({ data: { enabled: false, enabledAtUtc: null } } as any);
|
||||
}
|
||||
return Promise.resolve({ data: {} } as any);
|
||||
});
|
||||
});
|
||||
|
||||
test('shows not-enabled status and walks through the full enable flow to recovery codes', async () => {
|
||||
mockedApi.post.mockImplementation((url: string) => {
|
||||
if (url === '/auth/2fa/setup') {
|
||||
return Promise.resolve({ data: { manualEntryKey: 'ABCD1234', qrCodeDataUrl: 'data:image/png;base64,abc' } } as any);
|
||||
}
|
||||
if (url === '/auth/2fa/verify-setup') {
|
||||
return Promise.resolve({ data: { enabled: true, recoveryCodes: ['aaaaa-11111', 'bbbbb-22222'] } } as any);
|
||||
}
|
||||
return Promise.resolve({ data: {} } as any);
|
||||
});
|
||||
|
||||
renderCard();
|
||||
expect(await screen.findByText('Not enabled')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Enable 2FA' }));
|
||||
await userEvent.type(await screen.findByLabelText('Current password'), 'hunter2');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Continue' }));
|
||||
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/auth/2fa/setup', { currentPassword: 'hunter2' }));
|
||||
expect(await screen.findByAltText('Scan this QR code')).toHaveAttribute('src', 'data:image/png;base64,abc');
|
||||
expect(screen.getByDisplayValue('ABCD1234')).toBeInTheDocument();
|
||||
|
||||
await userEvent.type(screen.getByLabelText('6-digit code'), '654321');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Confirm' }));
|
||||
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/auth/2fa/verify-setup', { code: '654321' }));
|
||||
expect(await screen.findByText('Save your recovery codes')).toBeInTheDocument();
|
||||
expect(screen.getByText('aaaaa-11111')).toBeInTheDocument();
|
||||
expect(screen.getByText('bbbbb-22222')).toBeInTheDocument();
|
||||
|
||||
const doneButton = screen.getByRole('button', { name: 'Done' });
|
||||
expect(doneButton).toBeDisabled();
|
||||
|
||||
await userEvent.click(screen.getByLabelText("I've saved my recovery codes"));
|
||||
expect(doneButton).toBeEnabled();
|
||||
await userEvent.click(doneButton);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Save your recovery codes')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
test('shows wrong-password error on disable and lets the user retry', async () => {
|
||||
mockedApi.get.mockImplementation((url: string) => {
|
||||
if (url === '/auth/2fa/status') {
|
||||
return Promise.resolve({ data: { enabled: true, enabledAtUtc: '2026-01-01T00:00:00Z' } } as any);
|
||||
}
|
||||
return Promise.resolve({ data: {} } as any);
|
||||
});
|
||||
mockedApi.post.mockRejectedValueOnce({ response: { status: 401 } });
|
||||
|
||||
renderCard();
|
||||
expect(await screen.findByText(/enabled since/i)).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Disable 2FA' }));
|
||||
await userEvent.type(await screen.findByLabelText('Current password'), 'wrong');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Continue' }));
|
||||
|
||||
expect(await screen.findByText('Incorrect password.')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Current password')).toBeInTheDocument();
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { api, getApiErrorMessage } from "../api";
|
||||
import { getRememberMePref, setAuthPersistencePreference } from "../auth";
|
||||
import GoogleAuthCard from "../components/GoogleAuthCard";
|
||||
import MicrosoftAuthCard from "../components/MicrosoftAuthCard";
|
||||
import TwoFactorChallenge from "../components/TwoFactorChallenge";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
@@ -32,6 +33,7 @@ export default function LoginPage() {
|
||||
const [password, setPassword] = useState("");
|
||||
const [rememberMe, setRememberMe] = useState(() => getRememberMePref());
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pendingToken, setPendingToken] = useState<string | null>(null);
|
||||
|
||||
const nextPath = (location?.state?.from as string | undefined) ?? "/dashboard";
|
||||
|
||||
@@ -42,15 +44,23 @@ export default function LoginPage() {
|
||||
.catch(() => setCfg(null));
|
||||
}, []);
|
||||
|
||||
async function completeLogin() {
|
||||
setAuthPersistencePreference(rememberMe ? "local" : "session");
|
||||
await api.get("/auth/me");
|
||||
toast(t("signedIn"), "success");
|
||||
navigate(nextPath, { replace: true });
|
||||
}
|
||||
|
||||
async function submit(mode: "login" | "register") {
|
||||
setLoading(true);
|
||||
try {
|
||||
const url = mode === "register" ? "/auth/register" : "/auth/login";
|
||||
await api.post(url, { email, password, rememberMe });
|
||||
setAuthPersistencePreference(rememberMe ? "local" : "session");
|
||||
await api.get("/auth/me");
|
||||
toast(t("signedIn"), "success");
|
||||
navigate(nextPath, { replace: true });
|
||||
const res = await api.post<{ requiresTwoFactor?: boolean; pendingToken?: string }>(url, { email, password, rememberMe });
|
||||
if (res.data?.requiresTwoFactor && res.data.pendingToken) {
|
||||
setPendingToken(res.data.pendingToken);
|
||||
return;
|
||||
}
|
||||
await completeLogin();
|
||||
} catch (e: any) {
|
||||
toast(getApiErrorMessage(e, t("loginFailed")), "error");
|
||||
} finally {
|
||||
@@ -80,53 +90,63 @@ export default function LoginPage() {
|
||||
{cfg?.requireAuth ? t("authRequired") : t("authOptional")}
|
||||
</Typography>
|
||||
|
||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
|
||||
<Tab label={t("emailAndPassword")} />
|
||||
<Tab label={t("google")} />
|
||||
<Tab label={t("microsoft")} />
|
||||
</Tabs>
|
||||
{pendingToken ? (
|
||||
<TwoFactorChallenge
|
||||
pendingToken={pendingToken}
|
||||
onCancel={() => setPendingToken(null)}
|
||||
onSuccess={() => { setPendingToken(null); void completeLogin(); }}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
|
||||
<Tab label={t("emailAndPassword")} />
|
||||
<Tab label={t("google")} />
|
||||
<Tab label={t("microsoft")} />
|
||||
</Tabs>
|
||||
|
||||
{tab === 0 && (
|
||||
<Box component="form" onSubmit={(e) => { e.preventDefault(); void submit("login"); }} sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
||||
<TextField label={t("profileEmail")} value={email} onChange={(e) => setEmail(e.target.value)} autoComplete="email" fullWidth />
|
||||
<TextField label={t("profileCurrentPassword")} value={password} onChange={(e) => setPassword(e.target.value)} autoComplete={allowReg ? "new-password" : "current-password"} type="password" fullWidth />
|
||||
{tab === 0 && (
|
||||
<Box component="form" onSubmit={(e) => { e.preventDefault(); void submit("login"); }} sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
||||
<TextField label={t("profileEmail")} value={email} onChange={(e) => setEmail(e.target.value)} autoComplete="email" fullWidth />
|
||||
<TextField label={t("profileCurrentPassword")} value={password} onChange={(e) => setPassword(e.target.value)} autoComplete={allowReg ? "new-password" : "current-password"} type="password" fullWidth />
|
||||
|
||||
<Box sx={{ display: "flex", alignItems: { xs: "flex-start", sm: "center" }, justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
|
||||
<FormControlLabel
|
||||
control={<Checkbox disableRipple checked={rememberMe} onChange={(e) => setRememberMe(e.target.checked)} />}
|
||||
label={t("rememberMe")}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="text"
|
||||
size="small"
|
||||
disableRipple
|
||||
onClick={() => navigate(`/forgot-password${email.trim() ? `?email=${encodeURIComponent(email.trim())}` : ""}`)}
|
||||
sx={{ px: 0, minWidth: 0, fontWeight: 700, alignSelf: { xs: "stretch", sm: "auto" } }}
|
||||
>
|
||||
{t("forgotPassword")}
|
||||
</Button>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", alignItems: { xs: "flex-start", sm: "center" }, justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
|
||||
<FormControlLabel
|
||||
control={<Checkbox disableRipple checked={rememberMe} onChange={(e) => setRememberMe(e.target.checked)} />}
|
||||
label={t("rememberMe")}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="text"
|
||||
size="small"
|
||||
disableRipple
|
||||
onClick={() => navigate(`/forgot-password${email.trim() ? `?email=${encodeURIComponent(email.trim())}` : ""}`)}
|
||||
sx={{ px: 0, minWidth: 0, fontWeight: 700, alignSelf: { xs: "stretch", sm: "auto" } }}
|
||||
>
|
||||
{t("forgotPassword")}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: -0.5 }}>
|
||||
{rememberMe ? t("rememberMeHelpPersistent") : t("rememberMeHelpSession")}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: -0.5 }}>
|
||||
{rememberMe ? t("rememberMeHelpPersistent") : t("rememberMeHelpSession")}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 1, justifyContent: "flex-end", mt: 1 }}>
|
||||
{allowReg && (
|
||||
<Button type="button" variant="outlined" disableRipple disabled={loading} onClick={() => void submit("register")}>
|
||||
{t("createAccount")}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" variant="contained" disableRipple disabled={loading}>
|
||||
{t("signInTitle")}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, justifyContent: "flex-end", mt: 1 }}>
|
||||
{allowReg && (
|
||||
<Button type="button" variant="outlined" disableRipple disabled={loading} onClick={() => void submit("register")}>
|
||||
{t("createAccount")}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" variant="contained" disableRipple disabled={loading}>
|
||||
{t("signInTitle")}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{tab === 1 && <GoogleAuthCard onSignedIn={() => { navigate(nextPath, { replace: true }); }} />}
|
||||
{tab === 2 && <MicrosoftAuthCard onSignedIn={() => { navigate(nextPath, { replace: true }); }} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 1 && <GoogleAuthCard onSignedIn={() => { navigate(nextPath, { replace: true }); }} />}
|
||||
{tab === 2 && <MicrosoftAuthCard onSignedIn={() => { navigate(nextPath, { replace: true }); }} />}
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { api, getApiErrorMessage } from "../api";
|
||||
import GoogleAuthCard from "../components/GoogleAuthCard";
|
||||
import MicrosoftAuthCard from "../components/MicrosoftAuthCard";
|
||||
import AuthStatusCard from "../components/AuthStatusCard";
|
||||
import TwoFactorSettingsCard from "../components/TwoFactorSettingsCard";
|
||||
import EmailProviderConnections from "../components/EmailProviderConnections";
|
||||
import CropImageDialog from "../components/CropImageDialog";
|
||||
import { useToast } from "../toast";
|
||||
@@ -1348,6 +1349,8 @@ export default function ProfilePage() {
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{isLocal ? <TwoFactorSettingsCard /> : null}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user