feat(auth): add trusted-device 30-day 2FA skip (frontend)
Adds a "Trust this device for 30 days" checkbox to the 2FA challenge step, and a "Trusted devices" section to the 2FA settings card: list devices with a "this device" badge, per-row revoke, and a confirm-gated "sign out all other trusted devices" action. Both flows are opt-in and additive -- default unchecked, so nothing changes for a user who never uses them.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
|
||||||
import { Alert, Box, Button, TextField, Typography } from "@mui/material";
|
import { Alert, Box, Button, Checkbox, FormControlLabel, TextField, Typography } from "@mui/material";
|
||||||
|
|
||||||
import { api, getApiErrorMessage } from "../api";
|
import { api, getApiErrorMessage } from "../api";
|
||||||
import { useI18n } from "../i18n/I18nProvider";
|
import { useI18n } from "../i18n/I18nProvider";
|
||||||
@@ -18,6 +18,7 @@ export default function TwoFactorChallenge({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [code, setCode] = useState("");
|
const [code, setCode] = useState("");
|
||||||
|
const [trustDevice, setTrustDevice] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ export default function TwoFactorChallenge({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const res = await api.post<ChallengeResponse>("/auth/2fa/challenge", { pendingToken, code });
|
const res = await api.post<ChallengeResponse>("/auth/2fa/challenge", { pendingToken, code, trustDevice });
|
||||||
onSuccess(res.data);
|
onSuccess(res.data);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e?.response?.status === 429) {
|
if (e?.response?.status === 429) {
|
||||||
@@ -64,6 +65,11 @@ export default function TwoFactorChallenge({
|
|||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Checkbox checked={trustDevice} onChange={(e) => setTrustDevice(e.target.checked)} />}
|
||||||
|
label={t("twoFactorTrustDevice")}
|
||||||
|
/>
|
||||||
|
|
||||||
<Box sx={{ display: "flex", gap: 1, justifyContent: "flex-end" }}>
|
<Box sx={{ display: "flex", gap: 1, justifyContent: "flex-end" }}>
|
||||||
<Button type="button" variant="text" disabled={loading} onClick={onCancel}>
|
<Button type="button" variant="text" disabled={loading} onClick={onCancel}>
|
||||||
{t("twoFactorBack")}
|
{t("twoFactorBack")}
|
||||||
|
|||||||
@@ -9,11 +9,18 @@ import {
|
|||||||
DialogActions,
|
DialogActions,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
|
Divider,
|
||||||
FormControlLabel,
|
FormControlLabel,
|
||||||
|
IconButton,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemSecondaryAction,
|
||||||
|
ListItemText,
|
||||||
Paper,
|
Paper,
|
||||||
TextField,
|
TextField,
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
import DeleteIcon from "@mui/icons-material/Delete";
|
||||||
|
|
||||||
import { api, getApiErrorMessage } from "../api";
|
import { api, getApiErrorMessage } from "../api";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
@@ -22,6 +29,14 @@ import { useI18n } from "../i18n/I18nProvider";
|
|||||||
type Status = { enabled: boolean; enabledAtUtc: string | null };
|
type Status = { enabled: boolean; enabledAtUtc: string | null };
|
||||||
type SetupResponse = { manualEntryKey: string; qrCodeDataUrl: string };
|
type SetupResponse = { manualEntryKey: string; qrCodeDataUrl: string };
|
||||||
type RecoveryCodesResponse = { recoveryCodes: string[] };
|
type RecoveryCodesResponse = { recoveryCodes: string[] };
|
||||||
|
type TrustedDevice = {
|
||||||
|
id: number;
|
||||||
|
deviceLabel: string | null;
|
||||||
|
createdAtUtc: string;
|
||||||
|
lastSeenAtUtc: string;
|
||||||
|
expiresAtUtc: string;
|
||||||
|
isCurrentDevice: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
type Flow =
|
type Flow =
|
||||||
| "closed"
|
| "closed"
|
||||||
@@ -30,7 +45,8 @@ type Flow =
|
|||||||
| "enable-recovery"
|
| "enable-recovery"
|
||||||
| "disable-password"
|
| "disable-password"
|
||||||
| "regenerate-password"
|
| "regenerate-password"
|
||||||
| "regenerate-recovery";
|
| "regenerate-recovery"
|
||||||
|
| "revoke-all-confirm";
|
||||||
|
|
||||||
function apiErrorMessage(e: any, t: (k: any) => string) {
|
function apiErrorMessage(e: any, t: (k: any) => string) {
|
||||||
if (e?.response?.status === 429) return t("twoFactorRateLimited");
|
if (e?.response?.status === 429) return t("twoFactorRateLimited");
|
||||||
@@ -49,12 +65,45 @@ export default function TwoFactorSettingsCard() {
|
|||||||
const [savedConfirmed, setSavedConfirmed] = useState(false);
|
const [savedConfirmed, setSavedConfirmed] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 = () => {
|
const loadStatus = () => {
|
||||||
api.get<Status>("/auth/2fa/status").then((r) => setStatus(r.data)).catch(() => setStatus(null));
|
api.get<Status>("/auth/2fa/status").then((r) => setStatus(r.data)).catch(() => setStatus(null));
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => { loadStatus(); }, []);
|
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() {
|
function closeFlow() {
|
||||||
setFlow("closed");
|
setFlow("closed");
|
||||||
@@ -165,6 +214,53 @@ export default function TwoFactorSettingsCard() {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</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>
|
<Dialog open={flow !== "closed"} onClose={isRecoveryStep ? undefined : closeFlow} maxWidth="sm" fullWidth>
|
||||||
{isPasswordStep && (
|
{isPasswordStep && (
|
||||||
<Box component="form" onSubmit={(e) => { e.preventDefault(); void submitPassword(); }}>
|
<Box component="form" onSubmit={(e) => { e.preventDefault(); void submitPassword(); }}>
|
||||||
@@ -272,6 +368,22 @@ export default function TwoFactorSettingsCard() {
|
|||||||
</DialogActions>
|
</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>
|
</Dialog>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -345,6 +345,17 @@ export const translations = {
|
|||||||
twoFactorEnabledSuccess: "Two-factor authentication enabled.",
|
twoFactorEnabledSuccess: "Two-factor authentication enabled.",
|
||||||
twoFactorDisabledSuccess: "Two-factor authentication disabled.",
|
twoFactorDisabledSuccess: "Two-factor authentication disabled.",
|
||||||
twoFactorRegenerateSuccess: "Recovery codes regenerated.",
|
twoFactorRegenerateSuccess: "Recovery codes regenerated.",
|
||||||
|
twoFactorTrustDevice: "Trust this device for 30 days",
|
||||||
|
twoFactorTrustedDevicesTitle: "Trusted devices",
|
||||||
|
twoFactorTrustedDevicesEmpty: "No trusted devices yet.",
|
||||||
|
twoFactorTrustedDeviceUnknown: "Unknown device",
|
||||||
|
twoFactorTrustedDeviceCurrent: "This device",
|
||||||
|
twoFactorTrustedDeviceMeta: "Last used {lastSeen} · Expires {expires}",
|
||||||
|
twoFactorRevokeDevice: "Revoke",
|
||||||
|
twoFactorRevokeAllDevices: "Sign out all other trusted devices",
|
||||||
|
twoFactorTrustedDevicesRevokedAll: "All trusted devices have been signed out.",
|
||||||
|
twoFactorRevokeAllConfirmTitle: "Sign out all trusted devices?",
|
||||||
|
twoFactorRevokeAllConfirmBody: "You'll be asked for a 2FA code the next time you sign in on any device, including this one.",
|
||||||
cropDialogTitle: "Crop profile image",
|
cropDialogTitle: "Crop profile image",
|
||||||
cropDialogBody: "Position and zoom your image. The saved avatar will be exported as a 512×512 square.",
|
cropDialogBody: "Position and zoom your image. The saved avatar will be exported as a 512×512 square.",
|
||||||
cropDialogZoom: "Zoom",
|
cropDialogZoom: "Zoom",
|
||||||
@@ -1362,6 +1373,17 @@ export const translations = {
|
|||||||
twoFactorEnabledSuccess: "Topunkts autentisering aktivert.",
|
twoFactorEnabledSuccess: "Topunkts autentisering aktivert.",
|
||||||
twoFactorDisabledSuccess: "Topunkts autentisering deaktivert.",
|
twoFactorDisabledSuccess: "Topunkts autentisering deaktivert.",
|
||||||
twoFactorRegenerateSuccess: "Gjenopprettingskoder generert på nytt.",
|
twoFactorRegenerateSuccess: "Gjenopprettingskoder generert på nytt.",
|
||||||
|
twoFactorTrustDevice: "Stol på denne enheten i 30 dager",
|
||||||
|
twoFactorTrustedDevicesTitle: "Betrodde enheter",
|
||||||
|
twoFactorTrustedDevicesEmpty: "Ingen betrodde enheter ennå.",
|
||||||
|
twoFactorTrustedDeviceUnknown: "Ukjent enhet",
|
||||||
|
twoFactorTrustedDeviceCurrent: "Denne enheten",
|
||||||
|
twoFactorTrustedDeviceMeta: "Sist brukt {lastSeen} · Utløper {expires}",
|
||||||
|
twoFactorRevokeDevice: "Fjern tilgang",
|
||||||
|
twoFactorRevokeAllDevices: "Logg ut alle andre betrodde enheter",
|
||||||
|
twoFactorTrustedDevicesRevokedAll: "Alle betrodde enheter er logget ut.",
|
||||||
|
twoFactorRevokeAllConfirmTitle: "Logg ut alle betrodde enheter?",
|
||||||
|
twoFactorRevokeAllConfirmBody: "Du vil bli bedt om en 2FA-kode neste gang du logger inn på en enhet, inkludert denne.",
|
||||||
cropDialogTitle: "Beskjær profilbilde",
|
cropDialogTitle: "Beskjær profilbilde",
|
||||||
cropDialogBody: "Plasser og zoom bildet. Det lagrede avataren eksporteres som en kvadratisk 512×512-fil.",
|
cropDialogBody: "Plasser og zoom bildet. Det lagrede avataren eksporteres som en kvadratisk 512×512-fil.",
|
||||||
cropDialogZoom: "Zoom",
|
cropDialogZoom: "Zoom",
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ describe('LoginPage', () => {
|
|||||||
return Promise.resolve({ data: { requiresTwoFactor: true, pendingToken: 'pending-abc' } } as any);
|
return Promise.resolve({ data: { requiresTwoFactor: true, pendingToken: 'pending-abc' } } as any);
|
||||||
}
|
}
|
||||||
if (url === '/auth/2fa/challenge') {
|
if (url === '/auth/2fa/challenge') {
|
||||||
expect(payload).toEqual({ pendingToken: 'pending-abc', code: '123456' });
|
expect(payload).toEqual({ pendingToken: 'pending-abc', code: '123456', trustDevice: false });
|
||||||
return Promise.resolve({ data: { authenticated: true, provider: 'local' } } as any);
|
return Promise.resolve({ data: { authenticated: true, provider: 'local' } } as any);
|
||||||
}
|
}
|
||||||
return Promise.resolve({ data: {} } as any);
|
return Promise.resolve({ data: {} } as any);
|
||||||
@@ -109,7 +109,7 @@ describe('LoginPage', () => {
|
|||||||
await userEvent.type(screen.getByLabelText('Code'), '123456');
|
await userEvent.type(screen.getByLabelText('Code'), '123456');
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Verify' }));
|
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.post).toHaveBeenCalledWith('/auth/2fa/challenge', { pendingToken: 'pending-abc', code: '123456', trustDevice: false }));
|
||||||
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/auth/me'));
|
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/auth/me'));
|
||||||
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true }));
|
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true }));
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user