125235c293
Login/register, forgot-password, reset-password, verify-email, and the
2FA/sessions settings cards all used a bare MuiPaper (1px border, no
shadow) predating this session's theme foundation. MuiPaper itself
stays untouched (it's a lower-level primitive used too broadly across
the app -- menus, popovers -- to safely restyle globally), so these
specific card instances get the same explicit no-border/floating-shadow
treatment already applied screen-by-screen elsewhere this session.
Static shadow value again, not theme.vars.customShadows -- inline sx
callbacks execute against whatever theme is in context, and none of
this repo's tests wrap components in a ThemeProvider (see fc56f94).
391 lines
14 KiB
TypeScript
391 lines
14 KiB
TypeScript
import React, { useEffect, useState } from "react";
|
|
|
|
import {
|
|
Alert,
|
|
Box,
|
|
Button,
|
|
Checkbox,
|
|
Dialog,
|
|
DialogActions,
|
|
DialogContent,
|
|
DialogTitle,
|
|
Divider,
|
|
FormControlLabel,
|
|
IconButton,
|
|
List,
|
|
ListItem,
|
|
ListItemSecondaryAction,
|
|
ListItemText,
|
|
Paper,
|
|
TextField,
|
|
Typography,
|
|
} from "@mui/material";
|
|
import DeleteIcon from "@mui/icons-material/Delete";
|
|
|
|
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 TrustedDevice = {
|
|
id: number;
|
|
deviceLabel: string | null;
|
|
createdAtUtc: string;
|
|
lastSeenAtUtc: string;
|
|
expiresAtUtc: string;
|
|
isCurrentDevice: boolean;
|
|
};
|
|
|
|
type Flow =
|
|
| "closed"
|
|
| "enable-password"
|
|
| "enable-qr"
|
|
| "enable-recovery"
|
|
| "disable-password"
|
|
| "regenerate-password"
|
|
| "regenerate-recovery"
|
|
| "revoke-all-confirm";
|
|
|
|
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 [devices, setDevices] = useState<TrustedDevice[]>([]);
|
|
const [devicesLoading, setDevicesLoading] = useState(false);
|
|
const [devicesError, setDevicesError] = useState<string | null>(null);
|
|
|
|
const loadStatus = () => {
|
|
api.get<Status>("/auth/2fa/status").then((r) => setStatus(r.data)).catch(() => setStatus(null));
|
|
};
|
|
|
|
const loadDevices = () => {
|
|
setDevicesLoading(true);
|
|
setDevicesError(null);
|
|
api
|
|
.get<TrustedDevice[]>("/auth/2fa/trusted-devices")
|
|
.then((r) => setDevices(r.data))
|
|
.catch((e) => setDevicesError(apiErrorMessage(e, t)))
|
|
.finally(() => setDevicesLoading(false));
|
|
};
|
|
|
|
useEffect(() => { loadStatus(); loadDevices(); }, []);
|
|
|
|
async function revokeDevice(id: number) {
|
|
try {
|
|
await api.delete(`/auth/2fa/trusted-devices/${id}`);
|
|
loadDevices();
|
|
} catch (e: any) {
|
|
setDevicesError(apiErrorMessage(e, t));
|
|
}
|
|
}
|
|
|
|
async function revokeAllDevices() {
|
|
try {
|
|
await api.post("/auth/2fa/trusted-devices/revoke-all");
|
|
toast(t("twoFactorTrustedDevicesRevokedAll"), "success");
|
|
closeFlow();
|
|
loadDevices();
|
|
} catch (e: any) {
|
|
setError(apiErrorMessage(e, t));
|
|
}
|
|
}
|
|
|
|
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.5, borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
|
|
<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>
|
|
|
|
{status?.enabled ? (
|
|
<Box sx={{ mt: 2 }}>
|
|
<Divider sx={{ mb: 1.5 }} />
|
|
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 0.5 }}>
|
|
{t("twoFactorTrustedDevicesTitle")}
|
|
</Typography>
|
|
{devicesError ? <Alert severity="error" role="alert" sx={{ mb: 1 }}>{devicesError}</Alert> : null}
|
|
{!devicesLoading && devices.length === 0 && !devicesError ? (
|
|
<Typography sx={{ color: "text.secondary", mb: 1 }}>{t("twoFactorTrustedDevicesEmpty")}</Typography>
|
|
) : null}
|
|
{devices.length > 0 ? (
|
|
<List dense disablePadding>
|
|
{devices.map((d) => (
|
|
<ListItem key={d.id} divider>
|
|
<ListItemText
|
|
primary={
|
|
<>
|
|
{d.deviceLabel || t("twoFactorTrustedDeviceUnknown")}
|
|
{d.isCurrentDevice ? (
|
|
<Typography component="span" variant="caption" sx={{ ml: 1, color: "primary.main", fontWeight: 700 }}>
|
|
{t("twoFactorTrustedDeviceCurrent")}
|
|
</Typography>
|
|
) : null}
|
|
</>
|
|
}
|
|
secondary={t("twoFactorTrustedDeviceMeta", {
|
|
lastSeen: new Date(d.lastSeenAtUtc).toLocaleDateString(),
|
|
expires: new Date(d.expiresAtUtc).toLocaleDateString(),
|
|
})}
|
|
/>
|
|
<ListItemSecondaryAction>
|
|
<IconButton edge="end" aria-label={t("twoFactorRevokeDevice")} onClick={() => revokeDevice(d.id)}>
|
|
<DeleteIcon fontSize="small" />
|
|
</IconButton>
|
|
</ListItemSecondaryAction>
|
|
</ListItem>
|
|
))}
|
|
</List>
|
|
) : null}
|
|
{devices.length > 0 ? (
|
|
<Button variant="text" color="warning" size="small" sx={{ mt: 1 }} onClick={() => setFlow("revoke-all-confirm")}>
|
|
{t("twoFactorRevokeAllDevices")}
|
|
</Button>
|
|
) : null}
|
|
</Box>
|
|
) : null}
|
|
|
|
<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>
|
|
</>
|
|
)}
|
|
|
|
{flow === "revoke-all-confirm" && (
|
|
<>
|
|
<DialogTitle>{t("twoFactorRevokeAllConfirmTitle")}</DialogTitle>
|
|
<DialogContent>
|
|
{error ? <Alert severity="error" role="alert" sx={{ mb: 2 }}>{error}</Alert> : null}
|
|
<Typography>{t("twoFactorRevokeAllConfirmBody")}</Typography>
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button type="button" onClick={closeFlow}>{t("cancel")}</Button>
|
|
<Button variant="contained" color="warning" onClick={revokeAllDevices}>
|
|
{t("twoFactorRevokeAllDevices")}
|
|
</Button>
|
|
</DialogActions>
|
|
</>
|
|
)}
|
|
</Dialog>
|
|
</Paper>
|
|
);
|
|
}
|