diff --git a/job-tracker-ui/src/components/GoogleAuthCard.tsx b/job-tracker-ui/src/components/GoogleAuthCard.tsx index 35e3d73..48dd685 100644 --- a/job-tracker-ui/src/components/GoogleAuthCard.tsx +++ b/job-tracker-ui/src/components/GoogleAuthCard.tsx @@ -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(null); const [working, setWorking] = useState(false); const [allowRegistration, setAllowRegistration] = useState(false); + const [pendingToken, setPendingToken] = useState(null); const hostRef = useRef(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 )} - {clientId && ( + {clientId && pendingToken && ( + setPendingToken(null)} + onSuccess={() => { + setPendingToken(null); + window.dispatchEvent(new Event("auth-changed")); + toast(t("googleSignedIn"), "success"); + onSignedIn?.(); + }} + /> + )} + + {clientId && !pendingToken && ( diff --git a/job-tracker-ui/src/components/MicrosoftAuthCard.tsx b/job-tracker-ui/src/components/MicrosoftAuthCard.tsx index 8722ea3..8e93cb4 100644 --- a/job-tracker-ui/src/components/MicrosoftAuthCard.tsx +++ b/job-tracker-ui/src/components/MicrosoftAuthCard.tsx @@ -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(null); const [working, setWorking] = useState(false); + const [pendingToken, setPendingToken] = useState(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 )} - {clientId && ( + {clientId && pendingToken && ( + setPendingToken(null)} + onSuccess={() => { + setPendingToken(null); + window.dispatchEvent(new Event("auth-changed")); + toast(t("microsoftSignedIn"), "success"); + onSignedIn?.(); + }} + /> + )} + + {clientId && !pendingToken && ( diff --git a/job-tracker-ui/src/components/TwoFactorChallenge.tsx b/job-tracker-ui/src/components/TwoFactorChallenge.tsx new file mode 100644 index 0000000..7ca302b --- /dev/null +++ b/job-tracker-ui/src/components/TwoFactorChallenge.tsx @@ -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(null); + + async function submit() { + setLoading(true); + setError(null); + try { + const res = await api.post("/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 ( + { e.preventDefault(); void submit(); }} + sx={{ display: "flex", flexDirection: "column", gap: 1.5 }} + role="group" + aria-label={t("twoFactorTitle")} + > + + {t("twoFactorTitle")} + + + {t("twoFactorHint")} + + + {error ? {error} : null} + + setCode(e.target.value)} + autoComplete="one-time-code" + autoFocus + fullWidth + /> + + + + + + + ); +} diff --git a/job-tracker-ui/src/components/TwoFactorSettingsCard.tsx b/job-tracker-ui/src/components/TwoFactorSettingsCard.tsx new file mode 100644 index 0000000..5da0ad9 --- /dev/null +++ b/job-tracker-ui/src/components/TwoFactorSettingsCard.tsx @@ -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(null); + const [flow, setFlow] = useState("closed"); + const [password, setPassword] = useState(""); + const [code, setCode] = useState(""); + const [setup, setSetup] = useState(null); + const [recoveryCodes, setRecoveryCodes] = useState([]); + const [savedConfirmed, setSavedConfirmed] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const loadStatus = () => { + api.get("/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("/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("/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("/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 ( + + + {t("twoFactorSectionTitle")} + + + {status ? ( + + {status.enabled + ? t("twoFactorStatusEnabled", { date: status.enabledAtUtc ? new Date(status.enabledAtUtc).toLocaleDateString() : "" }) + : t("twoFactorStatusDisabled")} + + ) : null} + + + {!status?.enabled ? ( + + ) : ( + <> + + + + )} + + + + {isPasswordStep && ( + { e.preventDefault(); void submitPassword(); }}> + {t("twoFactorPasswordPrompt")} + + {flow === "disable-password" ? {t("twoFactorDisableWarning")} : null} + {flow === "regenerate-password" ? {t("twoFactorRegenerateWarning")} : null} + {error ? {error} : null} + setPassword(e.target.value)} + autoComplete="current-password" + autoFocus + fullWidth + /> + + + + + + + )} + + {flow === "enable-qr" && setup && ( + { e.preventDefault(); void submitCode(); }}> + {t("twoFactorSetupTitle")} + + + {t("twoFactorSetupHint")} + + + {t("twoFactorSetupTitle")} + + { + void navigator.clipboard.writeText(setup.manualEntryKey); + toast(t("twoFactorKeyCopied"), "info"); + }} + > + {t("twoFactorCopyKey")} + + ), + }} + /> + + {t("twoFactorConfirmCodeHint")} + + {error ? {error} : null} + setCode(e.target.value)} + autoComplete="one-time-code" + fullWidth + /> + + + + + + + )} + + {isRecoveryStep && ( + <> + {t("twoFactorRecoveryTitle")} + + {t("twoFactorRecoveryHint")} + + {recoveryCodes.map((rc) => ( +
  • {rc}
  • + ))} +
    + + + + + setSavedConfirmed(e.target.checked)} />} + label={t("twoFactorSavedConfirm")} + /> +
    + + + + + )} +
    +
    + ); +} diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index e43bf39..0f48903 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -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", diff --git a/job-tracker-ui/src/login-page.test.tsx b/job-tracker-ui/src/login-page.test.tsx index 357f6a6..5ce1975 100644 --- a/job-tracker-ui/src/login-page.test.tsx +++ b/job-tracker-ui/src/login-page.test.tsx @@ -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.'); + }); }); diff --git a/job-tracker-ui/src/two-factor-settings.test.tsx b/job-tracker-ui/src/two-factor-settings.test.tsx new file mode 100644 index 0000000..b3d86d8 --- /dev/null +++ b/job-tracker-ui/src/two-factor-settings.test.tsx @@ -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; + +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( + + + + + , + ); +} + +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(); +}); diff --git a/job-tracker-ui/src/views/LoginPage.tsx b/job-tracker-ui/src/views/LoginPage.tsx index 6c2817e..b119c59 100644 --- a/job-tracker-ui/src/views/LoginPage.tsx +++ b/job-tracker-ui/src/views/LoginPage.tsx @@ -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(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")} - setTab(v)} sx={{ mb: 2 }}> - - - - + {pendingToken ? ( + setPendingToken(null)} + onSuccess={() => { setPendingToken(null); void completeLogin(); }} + /> + ) : ( + <> + setTab(v)} sx={{ mb: 2 }}> + + + + - {tab === 0 && ( - { e.preventDefault(); void submit("login"); }} sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}> - setEmail(e.target.value)} autoComplete="email" fullWidth /> - setPassword(e.target.value)} autoComplete={allowReg ? "new-password" : "current-password"} type="password" fullWidth /> + {tab === 0 && ( + { e.preventDefault(); void submit("login"); }} sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}> + setEmail(e.target.value)} autoComplete="email" fullWidth /> + setPassword(e.target.value)} autoComplete={allowReg ? "new-password" : "current-password"} type="password" fullWidth /> - - setRememberMe(e.target.checked)} />} - label={t("rememberMe")} - /> - - + + setRememberMe(e.target.checked)} />} + label={t("rememberMe")} + /> + + - - {rememberMe ? t("rememberMeHelpPersistent") : t("rememberMeHelpSession")} - + + {rememberMe ? t("rememberMeHelpPersistent") : t("rememberMeHelpSession")} + - - {allowReg && ( - - )} - - - + + {allowReg && ( + + )} + + + + )} + + {tab === 1 && { navigate(nextPath, { replace: true }); }} />} + {tab === 2 && { navigate(nextPath, { replace: true }); }} />} + )} - - {tab === 1 && { navigate(nextPath, { replace: true }); }} />} - {tab === 2 && { navigate(nextPath, { replace: true }); }} />}
    ); diff --git a/job-tracker-ui/src/views/ProfilePage.tsx b/job-tracker-ui/src/views/ProfilePage.tsx index 318ce45..1a4fbfe 100644 --- a/job-tracker-ui/src/views/ProfilePage.tsx +++ b/job-tracker-ui/src/views/ProfilePage.tsx @@ -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() {
    + + {isLocal ? : null} ); }