diff --git a/.gitea/workflows/ci-deploy.yml b/.gitea/workflows/ci-deploy.yml index b68df0a..2afdb4b 100644 --- a/.gitea/workflows/ci-deploy.yml +++ b/.gitea/workflows/ci-deploy.yml @@ -43,14 +43,14 @@ jobs: - name: Test frontend working-directory: job-tracker-ui - run: npm test -- --watchAll=false --runInBand App.test.tsx confirm.test.tsx prompt.test.tsx dialog-flow.test.tsx confirm-flow.test.tsx attachments.test.tsx job-details-generated-drafts.test.tsx admin-system-page.test.tsx profile-page.test.tsx + run: npm test -- --watchAll=false --runInBand App.test.tsx confirm.test.tsx prompt.test.tsx dialog-flow.test.tsx confirm-flow.test.tsx attachments.test.tsx job-details-generated-drafts.test.tsx admin-system-page.test.tsx profile-page.test.tsx login-page.test.tsx - name: Build frontend working-directory: job-tracker-ui env: CI: 'false' GENERATE_SOURCEMAP: 'false' - NODE_OPTIONS: --max-old-space-size=512 + NODE_OPTIONS: --max-old-space-size=4096 run: npm run build deploy: diff --git a/job-tracker-ui/package.json b/job-tracker-ui/package.json index d7fecee..e99ed4f 100644 --- a/job-tracker-ui/package.json +++ b/job-tracker-ui/package.json @@ -30,7 +30,7 @@ }, "scripts": { "start": "react-scripts start", - "build": "react-scripts build", + "build": "node --max-old-space-size=4096 ./node_modules/react-scripts/bin/react-scripts.js build", "test": "react-scripts test", "eject": "react-scripts eject" }, diff --git a/job-tracker-ui/src/auth.ts b/job-tracker-ui/src/auth.ts index f194870..a625b62 100644 --- a/job-tracker-ui/src/auth.ts +++ b/job-tracker-ui/src/auth.ts @@ -1,74 +1,106 @@ export const AUTH_TOKEN_KEY = "authToken"; export const AUTH_REMEMBER_ME_KEY = "authRememberMe"; const LEGACY_AUTH_TOKEN_KEY = "googleIdToken"; +const AUTH_TOKEN_PERSISTENCE_KEY = "authTokenPersistence"; -function getStoredToken(storage: Storage): string | null { +type AuthPersistence = "local" | "session"; + +function normalizePersistence(value: string | null | undefined): AuthPersistence { + return value === "session" ? "session" : "local"; +} + +function safeGet(storage: Storage, key: string): string | null { try { - return storage.getItem(AUTH_TOKEN_KEY); + return storage.getItem(key); } catch { return null; } } -export function getRememberMePref(): boolean { +function safeSet(storage: Storage, key: string, value: string) { try { - return window.localStorage.getItem(AUTH_REMEMBER_ME_KEY) === "1"; + storage.setItem(key, value); } catch { - return false; + // ignore storage failures } } +function safeRemove(storage: Storage, key: string) { + try { + storage.removeItem(key); + } catch { + // ignore storage failures + } +} + +function persistPreference(persistence: AuthPersistence) { + safeSet(window.localStorage, AUTH_TOKEN_PERSISTENCE_KEY, persistence); + safeSet(window.localStorage, AUTH_REMEMBER_ME_KEY, persistence === "local" ? "1" : "0"); +} + +function getStoredPersistence(): AuthPersistence { + const explicit = safeGet(window.localStorage, AUTH_TOKEN_PERSISTENCE_KEY); + if (explicit) return normalizePersistence(explicit); + const rememberMe = safeGet(window.localStorage, AUTH_REMEMBER_ME_KEY); + if (rememberMe === "0") return "session"; + return "local"; +} + +function migrateLegacyToken(): string | null { + const legacy = safeGet(window.localStorage, LEGACY_AUTH_TOKEN_KEY) ?? safeGet(window.sessionStorage, LEGACY_AUTH_TOKEN_KEY); + if (!legacy) return null; + safeRemove(window.localStorage, LEGACY_AUTH_TOKEN_KEY); + safeRemove(window.sessionStorage, LEGACY_AUTH_TOKEN_KEY); + setAuthToken(legacy, getStoredPersistence()); + return legacy; +} + +export function getRememberMePref(): boolean { + return getAuthPersistencePreference() === "local"; +} + export function setRememberMePref(value: boolean) { - try { - window.localStorage.setItem(AUTH_REMEMBER_ME_KEY, value ? "1" : "0"); - } catch { - // ignore storage failures - } + persistPreference(value ? "local" : "session"); } export function getAuthToken(): string | null { - const current = getStoredToken(window.localStorage) || getStoredToken(window.sessionStorage); - if (current) return current; - - // Backward compat for older builds that stored Google ID tokens under a different key. - const legacy = window.localStorage.getItem(LEGACY_AUTH_TOKEN_KEY) || window.sessionStorage.getItem(LEGACY_AUTH_TOKEN_KEY); - if (legacy) { - const remember = getRememberMePref(); - setAuthToken(legacy, { remember }); - try { - window.localStorage.removeItem(LEGACY_AUTH_TOKEN_KEY); - window.sessionStorage.removeItem(LEGACY_AUTH_TOKEN_KEY); - } catch { - // ignore storage failures - } - return legacy; + const localToken = safeGet(window.localStorage, AUTH_TOKEN_KEY); + if (localToken) { + persistPreference("local"); + return localToken; } - return null; + const sessionToken = safeGet(window.sessionStorage, AUTH_TOKEN_KEY); + if (sessionToken) { + persistPreference("session"); + return sessionToken; + } + + return migrateLegacyToken(); } -export function setAuthToken(token: string, options?: { remember?: boolean }) { - const remember = options?.remember ?? getRememberMePref(); - try { - if (remember) { - window.localStorage.setItem(AUTH_TOKEN_KEY, token); - window.sessionStorage.removeItem(AUTH_TOKEN_KEY); - } else { - window.sessionStorage.setItem(AUTH_TOKEN_KEY, token); - window.localStorage.removeItem(AUTH_TOKEN_KEY); - } - } catch { - window.localStorage.setItem(AUTH_TOKEN_KEY, token); +export function getAuthPersistencePreference(): AuthPersistence { + if (safeGet(window.sessionStorage, AUTH_TOKEN_KEY)) return "session"; + if (safeGet(window.localStorage, AUTH_TOKEN_KEY)) return "local"; + return getStoredPersistence(); +} + +export function setAuthToken(token: string, persistence: AuthPersistence = "local") { + safeRemove(window.localStorage, AUTH_TOKEN_KEY); + safeRemove(window.sessionStorage, AUTH_TOKEN_KEY); + + if (persistence === "session") { + safeSet(window.sessionStorage, AUTH_TOKEN_KEY, token); + } else { + safeSet(window.localStorage, AUTH_TOKEN_KEY, token); } + + persistPreference(persistence); } export function clearAuthToken() { - try { - window.localStorage.removeItem(AUTH_TOKEN_KEY); - window.sessionStorage.removeItem(AUTH_TOKEN_KEY); - } catch { - // ignore storage failures - } + safeRemove(window.localStorage, AUTH_TOKEN_KEY); + safeRemove(window.sessionStorage, AUTH_TOKEN_KEY); } export function decodeJwtPayload(token: string): any { diff --git a/job-tracker-ui/src/components/AddJobModal.tsx b/job-tracker-ui/src/components/AddJobModal.tsx index 045797c..9d6accf 100644 --- a/job-tracker-ui/src/components/AddJobModal.tsx +++ b/job-tracker-ui/src/components/AddJobModal.tsx @@ -62,6 +62,8 @@ type AttachmentBuckets = Record; const STATUS_OPTIONS = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const; const ACCEPTED_DOCUMENT_TYPES = ".pdf,.doc,.docx,.txt,.md,image/*,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,text/plain,text/markdown"; +const FIELD_SX = { "& .MuiInputBase-root": { minHeight: 56 } }; +const PICKER_TEXT_FIELD_PROPS = { fullWidth: true, sx: FIELD_SX }; function getTodayIso() { return new Date().toISOString().slice(0, 10); @@ -454,7 +456,7 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) { - setJobUrl(e.target.value)} sx={{ gridColumn: "1 / -1" }} /> + setJobUrl(e.target.value)} sx={{ ...FIELD_SX, gridColumn: "1 / -1" }} /> + + + + + - - {t("settingsNotificationsTitle")} - - {t("settingsNotificationsBody")} - + + + {t("settingsNotificationsTitle")} + {t("settingsNotificationsBody")} + {t("settingsNotificationsDelivery")} + - - - {t("settingsNotificationsFollowUpsTitle")} - {t("settingsNotificationsFollowUpsBody")} - - - {t("settingsNotificationsAccountTitle")} - {t("settingsNotificationsAccountBody")} - - - - - {t("settingsNotificationsDeliveryNote")} - - + + {t("settingsNotificationsWhatYouGetTitle")} + {t("settingsNotificationsWhatYouGetBody")} + + + + + + diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 3c2d76b..07f9c8a 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -145,13 +145,16 @@ export const translations = { settingsColumnDateApplied: "Date applied", settingsColumnDays: "Days", settingsColumnJobUrl: "Job URL", + settingsFollowUpsTitle: "Follow-up rules", + settingsFollowUpsBody: "Set when applied, offer, and feedback stages should surface follow-up work or be treated as ghosted. These rules drive the reminder inbox and the job table flags.", + settingsOpenReminderInbox: "Open reminders", + settingsReviewJobs: "Review jobs", settingsNotificationsTitle: "Email notifications", - settingsNotificationsBody: "Choose how follow-up and account emails are delivered.", - settingsNotificationsFollowUpsTitle: "Follow-up reminders", - settingsNotificationsFollowUpsBody: "Reminder and ghosting emails use the server SMTP configuration. Delivery follows the timing rules from the Follow-ups tab.", - settingsNotificationsAccountTitle: "Account and security emails", - settingsNotificationsAccountBody: "Password resets and other account notices are also sent from the system mailer so delivery stays reliable even if no personal mailbox is linked.", - settingsNotificationsDeliveryNote: "Per-user mailboxes are not selectable yet; the current behavior is one system sender for notifications and reset flows.", + settingsNotificationsBody: "Notifications are sent via SMTP. Configure the API with `Email:*` settings or env vars like `EMAIL_SMTP_HOST`, then Jobbjakt can deliver password reset and reminder emails.", + settingsNotificationsDelivery: "Use the system status page to confirm SMTP is configured before testing outbound email.", + settingsNotificationsWhatYouGetTitle: "What gets sent", + settingsNotificationsWhatYouGetBody: "Right now the app sends password reset mail and can surface reminder-driven follow-up workflows. Gmail OAuth stays separate from SMTP delivery.", + settingsCheckSystemStatus: "Check system status", profileTitle: "Profile", profileHeadlinePlaceholder: "Add a short headline to personalize your account view.", profileLocalAccount: "Local account", @@ -522,6 +525,9 @@ export const translations = { googleAvailableToLink: "Available to link", googleLinkedDate: "Linked {date}", googleSignInHint: "Sign in with a Google account that has already been linked to your Jobbjakt user.", + continueWithGoogle: "Continue with Google", + signInWithGoogle: "Sign in with Google", + linkWithGoogle: "Link with Google", googleLinkedTo: "Linked to {email}.", googleLinkedToYourAccount: "Linked to your Google account.", googleBindHint: "Bind a Google account to this user so you can sign in with Google and still keep your normal app roles and data.", @@ -571,13 +577,15 @@ export const translations = { google: "Google", createAccount: "Create account", signedIn: "Signed in.", - loginFailed: "Login failed.", - rememberMe: "Remember me on this device", + rememberMe: "Remember me", + rememberMeHelpPersistent: "Keeps you signed in on this device until you sign out.", + rememberMeHelpSession: "Keeps you signed in only for this browser session.", forgotPassword: "Forgot password?", - loginResetEmailRequired: "Enter your email first so we know where to send the reset link.", - loginRequestingReset: "Sending reset link…", - loginResetRequested: "If that account exists, a reset link has been sent.", - loginResetRequestFailed: "Could not request a password reset.", + passwordResetEnterEmail: "Enter your email first, then request a reset link.", + passwordResetRequestSending: "Sending reset link...", + passwordResetRequestSent: "If that account exists, a reset link has been sent.", + passwordResetRequestFailed: "Could not send the reset link.", + loginFailed: "Login failed.", resetPasswordTitle: "Reset password", resetPasswordBody: "Set a new password for your account.", missingResetLinkInfo: "Missing email/token in link.", @@ -969,13 +977,16 @@ export const translations = { settingsColumnDateApplied: "Søkt dato", settingsColumnDays: "Dager", settingsColumnJobUrl: "Jobb-URL", + settingsFollowUpsTitle: "Regler for oppfølging", + settingsFollowUpsBody: "Velg når søknader, tilbud og tilbakemeldinger skal dukke opp som oppfølgingsarbeid eller regnes som ghostet. Disse reglene styrer påminnelsesinnboksen og flaggene i jobblisten.", + settingsOpenReminderInbox: "Åpne påminnelser", + settingsReviewJobs: "Gå til jobber", settingsNotificationsTitle: "E-postvarsler", - settingsNotificationsBody: "Velg hvordan oppfølgings- og kontovarsler leveres.", - settingsNotificationsFollowUpsTitle: "Oppfølgingspåminnelser", - settingsNotificationsFollowUpsBody: "Påminnelser og ghosting-e-poster bruker serverens SMTP-oppsett. Leveringen følger tidsreglene på fanen Oppfølging.", - settingsNotificationsAccountTitle: "Konto- og sikkerhetsmailer", - settingsNotificationsAccountBody: "Tilbakestilling av passord og andre kontovarsler sendes også fra systemets avsender, slik at leveringen er stabil selv uten en personlig postkasse koblet til.", - settingsNotificationsDeliveryNote: "Per-bruker avsendere kan ikke velges ennå; i dag brukes én systemavsender for varsler og tilbakestilling av passord.", + settingsNotificationsBody: "Varsler sendes via SMTP. Konfigurer API-et med `Email:*`-innstillinger eller miljøvariabler som `EMAIL_SMTP_HOST`, så kan Jobbjakt sende passord-nullstilling og påminnelsesepost.", + settingsNotificationsDelivery: "Bruk siden for systemstatus for å bekrefte at SMTP er konfigurert før du tester utgående e-post.", + settingsNotificationsWhatYouGetTitle: "Hva som sendes", + settingsNotificationsWhatYouGetBody: "Akkurat nå sender appen passord-nullstilling og kan støtte oppfølgingsflyt drevet av påminnelser. Gmail OAuth er fortsatt separat fra SMTP-levering.", + settingsCheckSystemStatus: "Sjekk systemstatus", profileTitle: "Profil", profileHeadlinePlaceholder: "Legg til en kort overskrift for å gjøre kontovisningen mer personlig.", profileLocalAccount: "Lokal konto", @@ -1346,6 +1357,9 @@ export const translations = { googleAvailableToLink: "Tilgjengelig for kobling", googleLinkedDate: "Koblet {date}", googleSignInHint: "Logg inn med en Google-konto som allerede er koblet til Jobbjakt-brukeren din.", + continueWithGoogle: "Fortsett med Google", + signInWithGoogle: "Logg inn med Google", + linkWithGoogle: "Koble til med Google", googleLinkedTo: "Koblet til {email}.", googleLinkedToYourAccount: "Koblet til Google-kontoen din.", googleBindHint: "Koble en Google-konto til denne brukeren slik at du kan logge inn med Google og fortsatt beholde vanlige approller og data.", @@ -1395,13 +1409,15 @@ export const translations = { google: "Google", createAccount: "Opprett konto", signedIn: "Logget inn.", - loginFailed: "Innlogging mislyktes.", - rememberMe: "Husk meg på denne enheten", + rememberMe: "Husk meg", + rememberMeHelpPersistent: "Holder deg innlogget på denne enheten til du logger ut.", + rememberMeHelpSession: "Holder deg innlogget bare i denne nettleserøkten.", forgotPassword: "Glemt passord?", - loginResetEmailRequired: "Skriv inn e-postadressen først, så vet vi hvor vi skal sende lenken.", - loginRequestingReset: "Sender tilbakestillingslenke…", - loginResetRequested: "Hvis kontoen finnes, er en tilbakestillingslenke sendt.", - loginResetRequestFailed: "Kunne ikke be om tilbakestilling av passord.", + passwordResetEnterEmail: "Skriv inn e-post først, og be deretter om en nullstillingslenke.", + passwordResetRequestSending: "Sender nullstillingslenke...", + passwordResetRequestSent: "Hvis kontoen finnes, er en nullstillingslenke sendt.", + passwordResetRequestFailed: "Kunne ikke sende nullstillingslenken.", + loginFailed: "Innlogging mislyktes.", resetPasswordTitle: "Tilbakestill passord", resetPasswordBody: "Sett et nytt passord for kontoen din.", missingResetLinkInfo: "Mangler e-post/token i lenken.", diff --git a/job-tracker-ui/src/login-page.test.tsx b/job-tracker-ui/src/login-page.test.tsx new file mode 100644 index 0000000..494c341 --- /dev/null +++ b/job-tracker-ui/src/login-page.test.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; + +import LoginPage from './pages/LoginPage'; +import { ToastProvider } from './toast'; +import { I18nProvider } from './i18n/I18nProvider'; +import { api } from './api'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => jest.fn(), +})); + +const mockedApi = api as jest.Mocked; + +let consoleErrorSpy: jest.SpyInstance; + +function renderLoginPage() { + return render( + + + + + + + , + ); +} + +describe('LoginPage', () => { + beforeEach(() => { + const originalConsoleError = console.error.bind(console); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + const [first] = args; + const message = typeof first === 'string' ? first : ''; + if (message.includes('ForwardRef(TouchRipple) inside a test was not wrapped in act')) { + return; + } + originalConsoleError(...args); + }); + window.localStorage.clear(); + window.sessionStorage.clear(); + mockedApi.post.mockReset(); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + it('stores auth token in session storage when remember me is unchecked', async () => { + mockedApi.post.mockResolvedValueOnce({ data: { accessToken: 'header.payload.sig', tokenType: 'Bearer' } } 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.getByLabelText('Remember me')); + await userEvent.click(screen.getByRole('button', { name: 'Sign in' })); + + await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/auth/login', { email: 'person@example.com', password: 'hunter2' })); + + expect(window.sessionStorage.getItem('authToken')).toBe('header.payload.sig'); + expect(window.localStorage.getItem('authToken')).toBeNull(); + expect(window.localStorage.getItem('authTokenPersistence')).toBe('session'); + }); + + it('requests a password reset link for the entered email', async () => { + mockedApi.post.mockResolvedValueOnce({ data: {} } as any); + + renderLoginPage(); + await screen.findByLabelText('Email'); + + await userEvent.type(screen.getByLabelText('Email'), 'person@example.com'); + await userEvent.click(screen.getByRole('button', { name: 'Forgot password?' })); + + await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/auth/request-password-reset', { email: 'person@example.com' })); + expect(await screen.findByText('If that account exists, a reset link has been sent.')).toBeTruthy(); + }); +}); diff --git a/job-tracker-ui/src/pages/LoginPage.tsx b/job-tracker-ui/src/pages/LoginPage.tsx index 8388534..d24fe9e 100644 --- a/job-tracker-ui/src/pages/LoginPage.tsx +++ b/job-tracker-ui/src/pages/LoginPage.tsx @@ -1,11 +1,11 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { Box, Button, Checkbox, FormControlLabel, Paper, Tab, Tabs, TextField, Typography } from "@mui/material"; import { useLocation, useNavigate } from "react-router-dom"; -import { api } from "../api"; -import { setAuthToken, setRememberMePref, getRememberMePref } from "../auth"; +import { api, getApiErrorMessage } from "../api"; +import { getRememberMePref, setAuthToken, setRememberMePref } from "../auth"; import GoogleAuthCard from "../components/GoogleAuthCard"; import { useToast } from "../toast"; import { useI18n } from "../i18n/I18nProvider"; @@ -28,11 +28,12 @@ export default function LoginPage() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); - const [loading, setLoading] = useState(false); const [rememberMe, setRememberMe] = useState(() => getRememberMePref()); - const [requestingReset, setRequestingReset] = useState(false); + const [loading, setLoading] = useState(false); + const [resetLoading, setResetLoading] = useState(false); const nextPath = (location?.state?.from as string | undefined) ?? "/jobs"; + const canRequestPasswordReset = useMemo(() => email.trim().length > 0, [email]); useEffect(() => { api @@ -47,32 +48,30 @@ export default function LoginPage() { const url = mode === "register" ? "/auth/register" : "/auth/login"; const res = await api.post<{ accessToken: string; tokenType: string }>(url, { email, password }); setRememberMePref(rememberMe); - setAuthToken(res.data.accessToken, { remember: rememberMe }); + setAuthToken(res.data.accessToken, rememberMe ? "local" : "session"); toast(t("signedIn"), "success"); navigate(nextPath, { replace: true }); } catch (e: any) { - const msg = e?.response?.data || e?.message || t("loginFailed"); - toast(String(msg), "error"); + toast(getApiErrorMessage(e, t("loginFailed")), "error"); } finally { setLoading(false); } } async function requestPasswordReset() { - if (!email.trim()) { - toast(t("loginResetEmailRequired"), "error"); + if (!canRequestPasswordReset) { + toast(t("passwordResetEnterEmail"), "info"); return; } - setRequestingReset(true); + setResetLoading(true); try { await api.post("/auth/request-password-reset", { email: email.trim() }); - toast(t("loginResetRequested"), "success"); + toast(t("passwordResetRequestSent"), "success"); } catch (e: any) { - const msg = e?.response?.data || e?.message || t("loginResetRequestFailed"); - toast(String(msg), "error"); + toast(getApiErrorMessage(e, t("passwordResetRequestFailed")), "error"); } finally { - setRequestingReset(false); + setResetLoading(false); } } @@ -107,25 +106,37 @@ export default function LoginPage() { { 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")} - /> - - - - {allowReg && ( - - )} - - + )} + )}