diff --git a/job-tracker-ui/src/components/TwoFactorChallenge.tsx b/job-tracker-ui/src/components/TwoFactorChallenge.tsx index 7ca302b..bdfe7b6 100644 --- a/job-tracker-ui/src/components/TwoFactorChallenge.tsx +++ b/job-tracker-ui/src/components/TwoFactorChallenge.tsx @@ -1,6 +1,6 @@ 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 { useI18n } from "../i18n/I18nProvider"; @@ -18,6 +18,7 @@ export default function TwoFactorChallenge({ }) { const { t } = useI18n(); const [code, setCode] = useState(""); + const [trustDevice, setTrustDevice] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -25,7 +26,7 @@ export default function TwoFactorChallenge({ setLoading(true); setError(null); try { - const res = await api.post("/auth/2fa/challenge", { pendingToken, code }); + const res = await api.post("/auth/2fa/challenge", { pendingToken, code, trustDevice }); onSuccess(res.data); } catch (e: any) { if (e?.response?.status === 429) { @@ -64,6 +65,11 @@ export default function TwoFactorChallenge({ fullWidth /> + setTrustDevice(e.target.checked)} />} + label={t("twoFactorTrustDevice")} + /> + + ) : null} + + ) : null} + {isPasswordStep && ( { e.preventDefault(); void submitPassword(); }}> @@ -272,6 +368,22 @@ export default function TwoFactorSettingsCard() { )} + + {flow === "revoke-all-confirm" && ( + <> + {t("twoFactorRevokeAllConfirmTitle")} + + {error ? {error} : null} + {t("twoFactorRevokeAllConfirmBody")} + + + + + + + )} ); diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 0f48903..efa0f89 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -345,6 +345,17 @@ export const translations = { twoFactorEnabledSuccess: "Two-factor authentication enabled.", twoFactorDisabledSuccess: "Two-factor authentication disabled.", 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", cropDialogBody: "Position and zoom your image. The saved avatar will be exported as a 512×512 square.", cropDialogZoom: "Zoom", @@ -1362,6 +1373,17 @@ export const translations = { twoFactorEnabledSuccess: "Topunkts autentisering aktivert.", twoFactorDisabledSuccess: "Topunkts autentisering deaktivert.", 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", cropDialogBody: "Plasser og zoom bildet. Det lagrede avataren eksporteres som en kvadratisk 512×512-fil.", cropDialogZoom: "Zoom", diff --git a/job-tracker-ui/src/login-page.test.tsx b/job-tracker-ui/src/login-page.test.tsx index 5ce1975..2973f34 100644 --- a/job-tracker-ui/src/login-page.test.tsx +++ b/job-tracker-ui/src/login-page.test.tsx @@ -89,7 +89,7 @@ describe('LoginPage', () => { return Promise.resolve({ data: { requiresTwoFactor: true, pendingToken: 'pending-abc' } } as any); } 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: {} } as any); @@ -109,7 +109,7 @@ describe('LoginPage', () => { 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.post).toHaveBeenCalledWith('/auth/2fa/challenge', { pendingToken: 'pending-abc', code: '123456', trustDevice: false })); await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/auth/me')); await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true })); });