feat(auth): migrate account security presentation

This commit is contained in:
cesnimda
2026-10-11 21:16:05 +02:00
parent 8ad720da5d
commit ad7b4196af
22 changed files with 35532 additions and 143 deletions
@@ -1,11 +1,12 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Box, Button, Paper, Typography } from "@mui/material";
import { Alert, Box, Button, Typography } from "@mui/material";
import { api } from "../api";
import { api, getApiErrorMessage } from "../api";
import { clearAuthClientState } from "../auth";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
import { PageSection } from "../layout/PageLayout";
type MeResponse = {
provider?: string;
@@ -26,34 +27,40 @@ export default function AuthStatusCard() {
const { toast } = useToast();
const { t } = useI18n();
const [me, setMe] = useState<MeResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(() => {
setLoading(true);
setError(null);
api.get<MeResponse>("/auth/me")
.then((r) => setMe(r.data))
.catch((e) => {
setMe(null);
if (e?.response?.status !== 401) setError(getApiErrorMessage(e, t("profileLoadFailed")));
})
.finally(() => setLoading(false));
}, [t]);
useEffect(() => {
const refresh = () => {
api
.get<MeResponse>("/auth/me")
.then((r) => setMe(r.data))
.catch(() => setMe(null));
};
refresh();
window.addEventListener("auth-changed", refresh);
return () => window.removeEventListener("auth-changed", refresh);
}, []);
}, [refresh]);
const label = useMemo(() => me?.userName || me?.displayName || [me?.firstName, me?.lastName].filter(Boolean).join(" ") || me?.email, [me]);
return (
<Paper sx={{ mt: 2, p: 2 }}>
<Typography variant="h6" sx={{ mb: 1 }}>
{t("authStatusTitle")}
</Typography>
{!me ? (
<PageSection title={t("authStatusTitle")} surface>
{loading ? <Typography role="status" color="text.secondary">{t("loading")}</Typography> : error ? (
<Alert severity="error" action={<Button color="inherit" onClick={refresh}>{t("retry")}</Button>}>{error}</Alert>
) : !me ? (
<Typography sx={{ color: "text.secondary" }}>{t("authStatusNotSignedIn")}</Typography>
) : (
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.5, overflowWrap: "anywhere" }}>
<Typography sx={{ color: "text.secondary" }}>
Signed in{label ? ` as ${label}` : ""}{me?.provider ? ` (${me.provider})` : ""}.
{label ? t("signedInAs", { name: label }) : t("signedIn")}
{me.provider ? ` (${me.provider === "local" ? t("profileLocalAccount") : me.provider === "google" ? t("profileGoogleSession") : t("profileExternalSession")})` : ""}
</Typography>
{me?.roles && me.roles.length > 0 ? (
<Typography sx={{ color: "text.secondary" }}>
@@ -82,6 +89,6 @@ export default function AuthStatusCard() {
</Box>
</Box>
)}
</Paper>
</PageSection>
);
}
@@ -1,12 +1,13 @@
import React, { useEffect, useRef, useState } from "react";
import { Box, Button, Chip, Paper, Typography } from "@mui/material";
import { Box, Button, Chip, 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";
import { PageSection } from "../layout/PageLayout";
declare global {
interface Window {
@@ -77,7 +78,7 @@ function loadGoogleScript(): Promise<void> {
export default function GoogleAuthCard({ onSignedIn, presentation = "account" }: { onSignedIn?: () => void; presentation?: "account" | "sign-in" }) {
const { toast } = useToast();
const { t } = useI18n();
const { t, language } = useI18n();
const [me, setMe] = useState<MeResponse | null>(null);
const [working, setWorking] = useState(false);
const [allowRegistration, setAllowRegistration] = useState(false);
@@ -197,11 +198,7 @@ export default function GoogleAuthCard({ onSignedIn, presentation = "account" }:
}
return (
<Paper sx={{ mt: 2, p: 2 }}>
<Typography variant="h6" sx={{ mb: 1 }}>
{t("googleAccountTitle")}
</Typography>
<PageSection title={t("googleAccountTitle")} surface>
{!clientId && (
<Typography sx={{ color: "text.secondary" }}>
{t("googleSetupHint")}
@@ -225,7 +222,7 @@ export default function GoogleAuthCard({ onSignedIn, presentation = "account" }:
<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"} />
{me?.googleLink?.linkedAt ? <Chip size="small" variant="outlined" label={t("googleLinkedDate", { date: new Date(me.googleLink.linkedAt).toLocaleDateString() })} /> : null}
{me?.googleLink?.linkedAt ? <Chip size="small" variant="outlined" label={t("googleLinkedDate", { date: new Date(me.googleLink.linkedAt).toLocaleDateString(language === "nb" ? "nb-NO" : "en") })} /> : null}
</Box>
{!signedIn ? (
@@ -295,6 +292,6 @@ export default function GoogleAuthCard({ onSignedIn, presentation = "account" }:
) : null}
</Box>
)}
</Paper>
</PageSection>
);
}
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from "react";
import { Box, Button, Chip, Paper, TextField, Typography } from "@mui/material";
import { Box, Button, Chip, TextField, Typography } from "@mui/material";
import { PublicClientApplication } from "@azure/msal-browser";
import { api, getApiErrorMessage } from "../api";
@@ -8,6 +8,7 @@ import { clearAuthClientState, getAuthPersistencePreference } from "../auth";
import TwoFactorChallenge from "./TwoFactorChallenge";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
import { PageSection } from "../layout/PageLayout";
type MeResponse = {
provider?: "local" | "google" | "microsoft" | "external";
@@ -34,7 +35,7 @@ export function getMicrosoftMsalInstance(clientId: string): PublicClientApplicat
export default function MicrosoftAuthCard({ onSignedIn, presentation = "account" }: { onSignedIn?: () => void; presentation?: "account" | "sign-in" }) {
const { toast } = useToast();
const { t } = useI18n();
const { t, language } = useI18n();
const [me, setMe] = useState<MeResponse | null>(null);
const [working, setWorking] = useState(false);
const [pendingToken, setPendingToken] = useState<string | null>(null);
@@ -128,11 +129,7 @@ export default function MicrosoftAuthCard({ onSignedIn, presentation = "account"
}
return (
<Paper sx={{ mt: 2, p: 2 }}>
<Typography variant="h6" sx={{ mb: 1 }}>
{t("microsoftAccountTitle")}
</Typography>
<PageSection title={t("microsoftAccountTitle")} surface>
{!clientId && (
<Typography sx={{ color: "text.secondary" }}>
{t("microsoftSetupHint")}
@@ -156,7 +153,7 @@ export default function MicrosoftAuthCard({ onSignedIn, presentation = "account"
<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"} />
{me?.microsoftLink?.linkedAt ? <Chip size="small" variant="outlined" label={t("microsoftLinkedDate", { date: new Date(me.microsoftLink.linkedAt).toLocaleDateString() })} /> : null}
{me?.microsoftLink?.linkedAt ? <Chip size="small" variant="outlined" label={t("microsoftLinkedDate", { date: new Date(me.microsoftLink.linkedAt).toLocaleDateString(language === "nb" ? "nb-NO" : "en") })} /> : null}
</Box>
{!signedIn ? (
@@ -241,6 +238,6 @@ export default function MicrosoftAuthCard({ onSignedIn, presentation = "account"
) : null}
</Box>
)}
</Paper>
</PageSection>
);
}
@@ -12,7 +12,6 @@ import {
ListItem,
ListItemSecondaryAction,
ListItemText,
Paper,
Typography,
} from "@mui/material";
import DeleteIcon from "@mui/icons-material/Delete";
@@ -21,6 +20,7 @@ import { api, getApiErrorMessage } from "../api";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
import { clearAuthClientState } from "../auth";
import { PageSection } from "../layout/PageLayout";
type Session = {
id: string;
@@ -37,11 +37,14 @@ function apiErrorMessage(e: any, t: (k: any) => string) {
export default function SessionsSettingsCard() {
const { toast } = useToast();
const { t } = useI18n();
const { t, language } = useI18n();
const locale = language === "nb" ? "nb-NO" : "en";
const [sessions, setSessions] = useState<Session[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [confirmRevokeOthers, setConfirmRevokeOthers] = useState(false);
const [confirmError, setConfirmError] = useState<string | null>(null);
const [revokingOthers, setRevokingOthers] = useState(false);
const loadSessions = useCallback(() => {
setLoading(true);
@@ -73,23 +76,29 @@ export default function SessionsSettingsCard() {
}
async function revokeOthers() {
setRevokingOthers(true);
setConfirmError(null);
try {
await api.post("/auth/sessions/revoke-others");
toast(t("sessionsRevokedOthers"), "success");
setConfirmRevokeOthers(false);
loadSessions();
} catch (e: any) {
setError(apiErrorMessage(e, t));
setConfirmError(apiErrorMessage(e, t));
} finally {
setRevokingOthers(false);
}
}
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("sessionsSectionTitle")}
</Typography>
function closeConfirmation() {
setConfirmRevokeOthers(false);
setConfirmError(null);
}
{error ? <Alert severity="error" role="alert" sx={{ mb: 1 }}>{error}</Alert> : null}
return (
<PageSection title={t("sessionsSectionTitle")} surface>
{loading ? <Typography role="status" color="text.secondary">{t("loading")}</Typography> : null}
{error ? <Alert severity="error" role="alert" sx={{ mb: 1 }} action={<Button color="inherit" onClick={loadSessions}>{t("retry")}</Button>}>{error}</Alert> : null}
{!loading && sessions.length === 0 && !error ? (
<Typography sx={{ color: "text.secondary", mb: 1 }}>{t("sessionsEmpty")}</Typography>
) : null}
@@ -99,6 +108,7 @@ export default function SessionsSettingsCard() {
{sessions.map((s) => (
<ListItem key={s.id} divider>
<ListItemText
sx={{ minWidth: 0, overflowWrap: "anywhere" }}
primary={
<>
{s.deviceLabel || t("sessionsUnknownDevice")}
@@ -110,8 +120,8 @@ export default function SessionsSettingsCard() {
</>
}
secondary={t("sessionsMeta", {
lastSeen: new Date(s.lastSeenAtUtc).toLocaleString(),
expires: new Date(s.expiresAtUtc).toLocaleDateString(),
lastSeen: new Date(s.lastSeenAtUtc).toLocaleString(locale),
expires: new Date(s.expiresAtUtc).toLocaleDateString(locale),
})}
/>
<ListItemSecondaryAction>
@@ -125,23 +135,24 @@ export default function SessionsSettingsCard() {
) : null}
{sessions.length > 1 ? (
<Button variant="text" color="warning" size="small" sx={{ mt: 1 }} onClick={() => setConfirmRevokeOthers(true)}>
<Button variant="text" color="warning" size="small" sx={{ mt: 1 }} onClick={() => { setConfirmError(null); setConfirmRevokeOthers(true); }}>
{t("sessionsRevokeOthers")}
</Button>
) : null}
<Dialog open={confirmRevokeOthers} onClose={() => setConfirmRevokeOthers(false)} maxWidth="sm" fullWidth>
<Dialog open={confirmRevokeOthers} onClose={revokingOthers ? undefined : closeConfirmation} maxWidth="sm" fullWidth>
<DialogTitle>{t("sessionsRevokeOthersConfirmTitle")}</DialogTitle>
<DialogContent>
{confirmError ? <Alert severity="error" sx={{ mb: 2 }}>{confirmError}</Alert> : null}
<Typography>{t("sessionsRevokeOthersConfirmBody")}</Typography>
</DialogContent>
<DialogActions>
<Button type="button" onClick={() => setConfirmRevokeOthers(false)}>{t("cancel")}</Button>
<Button variant="contained" color="warning" onClick={revokeOthers}>
<Button type="button" onClick={closeConfirmation} disabled={revokingOthers}>{t("cancel")}</Button>
<Button variant="contained" color="warning" onClick={revokeOthers} disabled={revokingOthers}>
{t("sessionsRevokeOthers")}
</Button>
</DialogActions>
</Dialog>
</Paper>
</PageSection>
);
}
@@ -16,7 +16,6 @@ import {
ListItem,
ListItemSecondaryAction,
ListItemText,
Paper,
TextField,
Typography,
} from "@mui/material";
@@ -25,6 +24,7 @@ import DeleteIcon from "@mui/icons-material/Delete";
import { api, getApiErrorMessage } from "../api";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
import { PageSection } from "../layout/PageLayout";
type Status = { enabled: boolean; enabledAtUtc: string | null };
type SetupResponse = { manualEntryKey: string; qrCodeDataUrl: string };
@@ -55,8 +55,11 @@ function apiErrorMessage(e: any, t: (k: any) => string) {
export default function TwoFactorSettingsCard() {
const { toast } = useToast();
const { t } = useI18n();
const { t, language } = useI18n();
const locale = language === "nb" ? "nb-NO" : "en";
const [status, setStatus] = useState<Status | null>(null);
const [statusLoading, setStatusLoading] = useState(true);
const [statusError, setStatusError] = useState<string | null>(null);
const [flow, setFlow] = useState<Flow>("closed");
const [password, setPassword] = useState("");
const [code, setCode] = useState("");
@@ -70,8 +73,13 @@ export default function TwoFactorSettingsCard() {
const [devicesError, setDevicesError] = useState<string | null>(null);
const loadStatus = useCallback(() => {
api.get<Status>("/auth/2fa/status").then((r) => setStatus(r.data)).catch(() => setStatus(null));
}, []);
setStatusLoading(true);
setStatusError(null);
api.get<Status>("/auth/2fa/status")
.then((r) => setStatus(r.data))
.catch((e) => { setStatus(null); setStatusError(apiErrorMessage(e, t)); })
.finally(() => setStatusLoading(false));
}, [t]);
const loadDevices = useCallback(() => {
setDevicesLoading(true);
@@ -184,21 +192,19 @@ export default function TwoFactorSettingsCard() {
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 ? (
<PageSection title={t("twoFactorSectionTitle")} surface>
{statusLoading ? <Typography role="status" color="text.secondary">{t("loading")}</Typography> : null}
{statusError ? <Alert severity="error" sx={{ mb: 1.5 }} action={<Button color="inherit" onClick={loadStatus}>{t("retry")}</Button>}>{statusError}</Alert> : null}
{status && !statusLoading ? (
<Typography sx={{ color: "text.secondary", mb: 1.5 }}>
{status.enabled
? t("twoFactorStatusEnabled", { date: status.enabledAtUtc ? new Date(status.enabledAtUtc).toLocaleDateString() : "" })
? t("twoFactorStatusEnabled", { date: status.enabledAtUtc ? new Date(status.enabledAtUtc).toLocaleDateString(locale) : "" })
: t("twoFactorStatusDisabled")}
</Typography>
) : null}
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
{!status?.enabled ? (
{status && !statusLoading ? <Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
{!status.enabled ? (
<Button variant="contained" onClick={() => setFlow("enable-password")}>
{t("twoFactorEnableButton")}
</Button>
@@ -212,15 +218,16 @@ export default function TwoFactorSettingsCard() {
</Button>
</>
)}
</Box>
</Box> : null}
{status?.enabled ? (
<Box sx={{ mt: 2 }}>
<Divider sx={{ mb: 1.5 }} />
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 0.5 }}>
<Typography component="h3" variant="sectionTitle" sx={{ mb: 0.5 }}>
{t("twoFactorTrustedDevicesTitle")}
</Typography>
{devicesError ? <Alert severity="error" role="alert" sx={{ mb: 1 }}>{devicesError}</Alert> : null}
{devicesLoading ? <Typography role="status" color="text.secondary">{t("loading")}</Typography> : null}
{devicesError ? <Alert severity="error" role="alert" sx={{ mb: 1 }} action={<Button color="inherit" onClick={loadDevices}>{t("retry")}</Button>}>{devicesError}</Alert> : null}
{!devicesLoading && devices.length === 0 && !devicesError ? (
<Typography sx={{ color: "text.secondary", mb: 1 }}>{t("twoFactorTrustedDevicesEmpty")}</Typography>
) : null}
@@ -229,6 +236,7 @@ export default function TwoFactorSettingsCard() {
{devices.map((d) => (
<ListItem key={d.id} divider>
<ListItemText
sx={{ minWidth: 0, overflowWrap: "anywhere" }}
primary={
<>
{d.deviceLabel || t("twoFactorTrustedDeviceUnknown")}
@@ -240,8 +248,8 @@ export default function TwoFactorSettingsCard() {
</>
}
secondary={t("twoFactorTrustedDeviceMeta", {
lastSeen: new Date(d.lastSeenAtUtc).toLocaleDateString(),
expires: new Date(d.expiresAtUtc).toLocaleDateString(),
lastSeen: new Date(d.lastSeenAtUtc).toLocaleDateString(locale),
expires: new Date(d.expiresAtUtc).toLocaleDateString(locale),
})}
/>
<ListItemSecondaryAction>
@@ -385,6 +393,6 @@ export default function TwoFactorSettingsCard() {
</>
)}
</Dialog>
</Paper>
</PageSection>
);
}