From 93b869259edbacee98dc03735875477b47a34111 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 9 Aug 2026 16:07:33 +0200 Subject: [PATCH] feat(auth): unify sign-in options --- .../src/components/GoogleAuthCard.tsx | 38 ++++-- .../src/components/MicrosoftAuthCard.tsx | 30 +++- job-tracker-ui/src/i18n/translations.ts | 6 + job-tracker-ui/src/login-page.test.tsx | 129 ++++++++++++++++-- job-tracker-ui/src/views/LoginPage.tsx | 46 +++---- 5 files changed, 200 insertions(+), 49 deletions(-) diff --git a/job-tracker-ui/src/components/GoogleAuthCard.tsx b/job-tracker-ui/src/components/GoogleAuthCard.tsx index 48dd685..b812b3b 100644 --- a/job-tracker-ui/src/components/GoogleAuthCard.tsx +++ b/job-tracker-ui/src/components/GoogleAuthCard.tsx @@ -47,7 +47,7 @@ function loadGoogleScript(): Promise { }); } -export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void }) { +export default function GoogleAuthCard({ onSignedIn, presentation = "account" }: { onSignedIn?: () => void; presentation?: "account" | "sign-in" }) { const { toast } = useToast(); const { t } = useI18n(); const [me, setMe] = useState(null); @@ -57,8 +57,9 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void const hostRef = useRef(null); const clientId = (process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "").trim(); + const signInOnly = presentation === "sign-in"; const signedIn = Boolean(me?.provider); - const actionLabel = !signedIn + const actionLabel = signInOnly || !signedIn ? t("continueWithGoogle") : me?.provider === "local" && !me?.googleLink?.linked ? t("linkWithGoogle") @@ -74,23 +75,25 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void } useEffect(() => { + if (signInOnly) return; void refreshMe(); api.get<{ allowRegistration: boolean }>("/auth/config").then((res) => { setAllowRegistration(Boolean(res.data?.allowRegistration)); }).catch(() => setAllowRegistration(false)); - }, []); + }, [signInOnly]); useEffect(() => { + if (signInOnly) return; const onAuthChanged = () => { void refreshMe(); }; window.addEventListener("auth-changed", onAuthChanged); return () => window.removeEventListener("auth-changed", onAuthChanged); - }, []); + }, [signInOnly]); useEffect(() => { const host = hostRef.current; if (!clientId || !host) return; - const shouldRenderButton = !signedIn || (me?.provider === "local" && !me?.googleLink?.linked); + const shouldRenderButton = signInOnly || !signedIn || (me?.provider === "local" && !me?.googleLink?.linked); host.replaceChildren(); if (!shouldRenderButton) return; @@ -106,7 +109,7 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void if (!credential) return; setWorking(true); try { - if (me?.provider === "local") { + if (!signInOnly && me?.provider === "local") { const res = await api.post<{ linked: boolean; email?: string | null }>("/auth/google/link", { token: credential, rememberMe: getAuthPersistencePreference() === "local" }); toast(res.data?.email ? t("googleLinkedSuccessWithEmail", { email: res.data.email }) : t("googleLinkedSuccess"), "success"); await refreshMe(); @@ -132,7 +135,7 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void size: "large", type: "standard", shape: "pill", - text: me?.provider === "local" ? "continue_with" : "signin_with", + text: !signInOnly && me?.provider === "local" ? "continue_with" : "signin_with", }); }) .catch(() => toast(t("googleScriptLoadFailed"), "error")); @@ -141,10 +144,29 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void active = false; host.replaceChildren(); }; - }, [clientId, me?.provider, me?.googleLink?.linked, onSignedIn, signedIn, toast, t]); + }, [clientId, me?.provider, me?.googleLink?.linked, onSignedIn, signInOnly, signedIn, toast, t]); const signedInName = me?.userName || me?.displayName || [me?.firstName, me?.lastName].filter(Boolean).join(" ") || me?.email || ""; + if (signInOnly) { + if (pendingToken) { + return setPendingToken(null)} + onSuccess={() => { + setPendingToken(null); + window.dispatchEvent(new Event("auth-changed")); + toast(t("googleSignedIn"), "success"); + onSignedIn?.(); + }} + />; + } + + return clientId + ?
+ : ; + } + return ( diff --git a/job-tracker-ui/src/components/MicrosoftAuthCard.tsx b/job-tracker-ui/src/components/MicrosoftAuthCard.tsx index cc2f689..4428e78 100644 --- a/job-tracker-ui/src/components/MicrosoftAuthCard.tsx +++ b/job-tracker-ui/src/components/MicrosoftAuthCard.tsx @@ -32,7 +32,7 @@ export function getMicrosoftMsalInstance(clientId: string): PublicClientApplicat return msalInstance; } -export default function MicrosoftAuthCard({ onSignedIn }: { onSignedIn?: () => void }) { +export default function MicrosoftAuthCard({ onSignedIn, presentation = "account" }: { onSignedIn?: () => void; presentation?: "account" | "sign-in" }) { const { toast } = useToast(); const { t } = useI18n(); const [me, setMe] = useState(null); @@ -41,8 +41,9 @@ export default function MicrosoftAuthCard({ onSignedIn }: { onSignedIn?: () => v const [currentPassword, setCurrentPassword] = useState(""); const clientId = (process.env.NEXT_PUBLIC_MICROSOFT_CLIENT_ID || "").trim(); + const signInOnly = presentation === "sign-in"; const signedIn = Boolean(me?.provider); - const actionLabel = !signedIn + const actionLabel = signInOnly || !signedIn ? t("continueWithMicrosoft") : me?.provider === "local" && !me?.microsoftLink?.linked ? t("linkWithMicrosoft") @@ -58,14 +59,16 @@ export default function MicrosoftAuthCard({ onSignedIn }: { onSignedIn?: () => v } useEffect(() => { + if (signInOnly) return; void refreshMe(); - }, []); + }, [signInOnly]); useEffect(() => { + if (signInOnly) return; const onAuthChanged = () => { void refreshMe(); }; window.addEventListener("auth-changed", onAuthChanged); return () => window.removeEventListener("auth-changed", onAuthChanged); - }, []); + }, [signInOnly]); async function handleSignIn() { if (!clientId) return; @@ -77,7 +80,7 @@ export default function MicrosoftAuthCard({ onSignedIn }: { onSignedIn?: () => v const idToken = result.idToken; if (!idToken) throw new Error(t("microsoftAuthFailed")); - if (me?.provider === "local") { + if (!signInOnly && me?.provider === "local") { const res = await api.post<{ linked: boolean; email?: string | null }>("/auth/microsoft/link", { token: idToken, currentPassword }); toast(res.data?.email ? t("microsoftLinkedSuccessWithEmail", { email: res.data.email }) : t("microsoftLinkedSuccess"), "success"); clearAuthClientState(); @@ -107,6 +110,23 @@ export default function MicrosoftAuthCard({ onSignedIn }: { onSignedIn?: () => v const signedInName = me?.userName || me?.displayName || [me?.firstName, me?.lastName].filter(Boolean).join(" ") || me?.email || ""; + if (signInOnly) { + if (pendingToken) { + return setPendingToken(null)} + onSuccess={() => { + setPendingToken(null); + window.dispatchEvent(new Event("auth-changed")); + toast(t("microsoftSignedIn"), "success"); + onSignedIn?.(); + }} + />; + } + + return ; + } + return ( diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 0eb8b12..4cfc8ce 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -219,6 +219,9 @@ export const translations = { profileFirstName: "First name", profileLastName: "Last name", profileEmail: "Email", + usernameOrEmail: "Username or email", + usernameOrEmailRequired: "Username or email is required", + or: "or", profileNewEmail: "New email", profileCurrentEmail: "Current email: {email}", profileEmailChangePassword: "Password to confirm email change", @@ -1346,6 +1349,9 @@ export const translations = { profileFirstName: "Fornavn", profileLastName: "Etternavn", profileEmail: "E-post", + usernameOrEmail: "Brukernavn eller e-post", + usernameOrEmailRequired: "Brukernavn eller e-post er påkrevd", + or: "eller", profileNewEmail: "Ny e-post", profileCurrentEmail: "NÃ¥værende e-post: {email}", profileEmailChangePassword: "Passord for Ã¥ bekrefte e-postendring", diff --git a/job-tracker-ui/src/login-page.test.tsx b/job-tracker-ui/src/login-page.test.tsx index 8233fc5..4b8462b 100644 --- a/job-tracker-ui/src/login-page.test.tsx +++ b/job-tracker-ui/src/login-page.test.tsx @@ -1,5 +1,7 @@ import React from 'react'; import '@testing-library/jest-dom'; +jest.unmock('./components/GoogleAuthCard'); +jest.unmock('./components/MicrosoftAuthCard'); import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MemoryRouter } from 'react-router-dom'; @@ -10,6 +12,15 @@ import { I18nProvider } from './i18n/I18nProvider'; import { api } from './api'; const mockNavigate = jest.fn(); +const mockMsalInitialize = jest.fn(); +const mockMsalLoginPopup = jest.fn(); + +jest.mock('@azure/msal-browser', () => ({ + PublicClientApplication: jest.fn().mockImplementation(() => ({ + initialize: mockMsalInitialize, + loginPopup: mockMsalLoginPopup, + })), +})); jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), @@ -62,22 +73,116 @@ describe('LoginPage', () => { }); window.localStorage.clear(); window.sessionStorage.clear(); + mockedApi.get.mockReset(); + mockedApi.get.mockImplementation((url: string) => { + if (url === '/auth/config') return Promise.resolve({ data: { requireAuth: true, googleEnabled: false, microsoftEnabled: false, localEnabled: true, allowRegistration: true, requireEmailVerification: true } } as any); + if (url === '/auth/me') return Promise.resolve({ data: { roles: [], email: 'person@example.com', userName: 'person' } } as any); + return Promise.resolve({ data: {} } as any); + }); mockedApi.post.mockReset(); mockNavigate.mockReset(); + mockMsalInitialize.mockReset().mockResolvedValue(undefined); + mockMsalLoginPopup.mockReset(); + delete process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID; + delete process.env.NEXT_PUBLIC_MICROSOFT_CLIENT_ID; + delete (window as any).google; }); afterEach(() => { consoleErrorSpy.mockRestore(); }); + it('renders one conventional sign-in card with enabled provider alternatives', async () => { + mockedApi.get.mockImplementation((url: string) => url === '/auth/config' + ? Promise.resolve({ data: { requireAuth: true, googleEnabled: true, microsoftEnabled: true, localEnabled: true, allowRegistration: true, requireEmailVerification: true } } as any) + : Promise.reject({ response: { status: 401 } })); + + renderLoginPage(); + + expect(await screen.findByLabelText('Username or email')).toBeInTheDocument(); + expect(screen.getByLabelText('Current password')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Sign in' })).toBeInTheDocument(); + expect(await screen.findByText('or')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Continue with Google' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Continue with Microsoft' })).toBeInTheDocument(); + expect(screen.queryByRole('tab')).not.toBeInTheDocument(); + expect(screen.queryByText('Google account')).not.toBeInTheDocument(); + expect(screen.queryByText('Available to link')).not.toBeInTheDocument(); + }); + + it('accepts a username and reports invalid credentials without changing routes', async () => { + mockedApi.get.mockImplementation((url: string) => url === '/auth/config' + ? Promise.resolve({ data: { requireAuth: true, googleEnabled: false, microsoftEnabled: false, localEnabled: true, allowRegistration: true, requireEmailVerification: true } } as any) + : Promise.reject({ response: { status: 401 } })); + mockedApi.post.mockRejectedValueOnce({ response: { status: 401, data: 'Invalid credentials.' } }); + + renderLoginPage(); + await userEvent.type(await screen.findByLabelText('Username or email'), 'demo-user'); + await userEvent.type(screen.getByLabelText('Current password'), 'wrong-password'); + await click(screen.getByRole('button', { name: 'Sign in' })); + + await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/auth/login', { email: 'demo-user', password: 'wrong-password', rememberMe: true })); + expect(await screen.findByRole('alert')).toHaveTextContent('Invalid credentials.'); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('handles Microsoft cancellation without exchanging or navigating', async () => { + process.env.NEXT_PUBLIC_MICROSOFT_CLIENT_ID = 'microsoft-client'; + mockedApi.get.mockResolvedValue({ data: { requireAuth: true, googleEnabled: false, microsoftEnabled: true, localEnabled: true, allowRegistration: true, requireEmailVerification: true } } as any); + mockMsalLoginPopup.mockRejectedValueOnce({ errorCode: 'user_cancelled', message: 'Microsoft sign-in was cancelled.' }); + + renderLoginPage(); + await click(await screen.findByRole('button', { name: 'Continue with Microsoft' })); + + expect(await screen.findByRole('alert')).toHaveTextContent('Microsoft sign-in was cancelled.'); + expect(mockedApi.post).not.toHaveBeenCalledWith('/auth/microsoft/exchange', expect.anything()); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('completes the Microsoft provider return through the hardened exchange endpoint', async () => { + process.env.NEXT_PUBLIC_MICROSOFT_CLIENT_ID = 'microsoft-client'; + mockedApi.get.mockResolvedValue({ data: { requireAuth: true, googleEnabled: false, microsoftEnabled: true, localEnabled: true, allowRegistration: true, requireEmailVerification: true } } as any); + mockMsalLoginPopup.mockResolvedValueOnce({ idToken: 'synthetic-microsoft-token' }); + mockedApi.post.mockResolvedValueOnce({ data: { authenticated: true, provider: 'microsoft' } } as any); + + renderLoginPage('login', '/jobs'); + await click(await screen.findByRole('button', { name: 'Continue with Microsoft' })); + + await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/auth/microsoft/exchange', { token: 'synthetic-microsoft-token', rememberMe: true })); + await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/jobs', { replace: true })); + }); + + it('completes a Google credential return without account-linking copy', async () => { + process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID = 'google-client'; + let callback: ((response: { credential: string }) => void) | undefined; + (window as any).google = { accounts: { id: { + initialize: jest.fn((options: any) => { callback = options.callback; }), + renderButton: jest.fn((host: HTMLElement) => { + const button = document.createElement('button'); + button.textContent = 'Continue with Google'; + button.onclick = () => callback?.({ credential: 'synthetic-google-token' }); + host.appendChild(button); + }), + } } }; + mockedApi.get.mockResolvedValue({ data: { requireAuth: true, googleEnabled: true, microsoftEnabled: false, localEnabled: true, allowRegistration: true, requireEmailVerification: true } } as any); + mockedApi.post.mockResolvedValueOnce({ data: { authenticated: true, provider: 'google' } } as any); + + renderLoginPage(); + await click(await screen.findByRole('button', { name: 'Continue with Google' })); + + await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/auth/google/exchange', { token: 'synthetic-google-token', rememberMe: true })); + await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true })); + expect(screen.queryByText(/create your account automatically/i)).not.toBeInTheDocument(); + }); + it('posts remember-me preference without storing an auth token in browser storage', async () => { mockedApi.post.mockResolvedValueOnce({ data: { authenticated: true, provider: 'local' } } as any); mockedApi.get.mockResolvedValueOnce({ data: { roles: [], email: 'person@example.com', userName: 'person' } } as any); renderLoginPage(); - await screen.findByLabelText('Email'); + await screen.findByLabelText('Username or email'); - await userEvent.type(screen.getByLabelText('Email'), 'person@example.com'); + await userEvent.type(screen.getByLabelText('Username or email'), 'person@example.com'); await userEvent.type(screen.getByLabelText('Current password'), 'hunter2'); await click(screen.getByLabelText('Remember me')); await click(screen.getByRole('button', { name: 'Sign in' })); @@ -113,7 +218,7 @@ describe('LoginPage', () => { mockedApi.get.mockResolvedValueOnce({ data: { roles: [], email: 'person@example.com', userName: 'person' } } as any); renderLoginPage("login", "/\\evil.example"); - await userEvent.type(await screen.findByLabelText('Email'), 'person@example.com'); + await userEvent.type(await screen.findByLabelText('Username or email'), 'person@example.com'); await userEvent.type(screen.getByLabelText('Current password'), 'hunter2'); await click(screen.getByRole('button', { name: 'Sign in' })); @@ -122,9 +227,9 @@ describe('LoginPage', () => { it('opens the separate forgot-password page with the typed email prefilled', async () => { renderLoginPage(); - await screen.findByLabelText('Email'); + await screen.findByLabelText('Username or email'); - await userEvent.type(screen.getByLabelText('Email'), 'person@example.com'); + await userEvent.type(screen.getByLabelText('Username or email'), 'person@example.com'); await click(screen.getByRole('button', { name: 'Forgot password?' })); expect(mockNavigate).toHaveBeenCalledWith('/forgot-password?email=person%40example.com'); @@ -144,14 +249,14 @@ describe('LoginPage', () => { mockedApi.get.mockResolvedValueOnce({ data: { roles: [], email: 'person@example.com', userName: 'person' } } as any); renderLoginPage(); - await screen.findByLabelText('Email'); + await screen.findByLabelText('Username or email'); - await userEvent.type(screen.getByLabelText('Email'), 'person@example.com'); + await userEvent.type(screen.getByLabelText('Username or email'), 'person@example.com'); await userEvent.type(screen.getByLabelText('Current password'), 'hunter2'); await click(screen.getByRole('button', { name: 'Sign in' })); await screen.findByText('Two-factor verification'); - expect(screen.queryByLabelText('Email')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Username or email')).not.toBeInTheDocument(); await userEvent.type(screen.getByLabelText('Code'), '123456'); await click(screen.getByRole('button', { name: 'Verify' })); @@ -173,8 +278,8 @@ describe('LoginPage', () => { }); renderLoginPage(); - await screen.findByLabelText('Email'); - await userEvent.type(screen.getByLabelText('Email'), 'person@example.com'); + await screen.findByLabelText('Username or email'); + await userEvent.type(screen.getByLabelText('Username or email'), 'person@example.com'); await userEvent.type(screen.getByLabelText('Current password'), 'hunter2'); await click(screen.getByRole('button', { name: 'Sign in' })); @@ -200,9 +305,9 @@ describe('LoginPage', () => { }); renderLoginPage(); - await screen.findByLabelText('Email'); + await screen.findByLabelText('Username or email'); - await userEvent.type(screen.getByLabelText('Email'), 'unverified@example.com'); + await userEvent.type(screen.getByLabelText('Username or email'), 'unverified@example.com'); await userEvent.type(screen.getByLabelText('Current password'), 'hunter2'); await click(screen.getByRole('button', { name: 'Sign in' })); diff --git a/job-tracker-ui/src/views/LoginPage.tsx b/job-tracker-ui/src/views/LoginPage.tsx index ef6c650..99094ac 100644 --- a/job-tracker-ui/src/views/LoginPage.tsx +++ b/job-tracker-ui/src/views/LoginPage.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from "react"; -import { Alert, Box, Button, Checkbox, FormControlLabel, Paper, Tab, Tabs, TextField, Typography } from "@mui/material"; +import { Alert, Box, Button, Checkbox, Divider, FormControlLabel, Paper, TextField, Typography } from "@mui/material"; import { useLocation, useNavigate } from "react-router-dom"; @@ -30,7 +30,6 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo const navigate = useNavigate(); const location = useLocation() as any; - const [tab, setTab] = useState(0); const [cfg, setCfg] = useState(null); const [email, setEmail] = useState(""); @@ -72,8 +71,8 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo function validate(mode: "login" | "register") { const errors: { email?: string; password?: string; confirmPassword?: string } = {}; - if (!email.trim()) errors.email = t("emailRequired"); - else if (!EMAIL_PATTERN.test(email.trim())) errors.email = t("invalidEmail"); + if (!email.trim()) errors.email = mode === "login" ? t("usernameOrEmailRequired") : t("emailRequired"); + else if (mode === "register" && !EMAIL_PATTERN.test(email.trim())) errors.email = t("invalidEmail"); if (!password) errors.password = t("passwordRequired"); else if (mode === "register" && password.length < 8) errors.password = t("passwordTooShort"); @@ -159,18 +158,11 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo /> ) : ( <> - {initialMode === "login" ? setTab(v)} sx={{ mb: 2 }}> - - - - : null} - - {tab === 0 && ( - { e.preventDefault(); void submit(registerMode ? "register" : "login"); }} - sx={{ display: "flex", flexDirection: "column", gap: 1.5 }} - > + { e.preventDefault(); void submit(registerMode ? "register" : "login"); }} + sx={{ display: "flex", flexDirection: "column", gap: 1.5 }} + > {registerMode && cfg && !allowReg ? Registration is currently unavailable. : null} {cfg?.requireEmailVerification && emailNotVerified && ( )} - { setEmail(e.target.value); setFieldErrors((f) => ({ ...f, email: undefined })); }} - autoComplete="email" + autoComplete={registerMode ? "email" : "username"} autoFocus error={Boolean(fieldErrors.email)} helperText={fieldErrors.email} @@ -265,11 +257,17 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo {registerMode ? t("createAccount") : t("signInTitle")} - - )} + - {tab === 1 && { navigate(nextPath, { replace: true }); }} />} - {tab === 2 && { navigate(nextPath, { replace: true }); }} />} + {!registerMode && (cfg?.googleEnabled || cfg?.microsoftEnabled) ? ( + + {t("or")} + + {cfg?.googleEnabled ? { navigate(nextPath, { replace: true }); }} /> : null} + {cfg?.microsoftEnabled ? { navigate(nextPath, { replace: true }); }} /> : null} + + + ) : null} )}