diff --git a/job-tracker-ui/src/auth.ts b/job-tracker-ui/src/auth.ts index 5ed4cfc..f194870 100644 --- a/job-tracker-ui/src/auth.ts +++ b/job-tracker-ui/src/auth.ts @@ -1,27 +1,74 @@ export const AUTH_TOKEN_KEY = "authToken"; +export const AUTH_REMEMBER_ME_KEY = "authRememberMe"; const LEGACY_AUTH_TOKEN_KEY = "googleIdToken"; +function getStoredToken(storage: Storage): string | null { + try { + return storage.getItem(AUTH_TOKEN_KEY); + } catch { + return null; + } +} + +export function getRememberMePref(): boolean { + try { + return window.localStorage.getItem(AUTH_REMEMBER_ME_KEY) === "1"; + } catch { + return false; + } +} + +export function setRememberMePref(value: boolean) { + try { + window.localStorage.setItem(AUTH_REMEMBER_ME_KEY, value ? "1" : "0"); + } catch { + // ignore storage failures + } +} + export function getAuthToken(): string | null { - const current = window.localStorage.getItem(AUTH_TOKEN_KEY); + 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); + const legacy = window.localStorage.getItem(LEGACY_AUTH_TOKEN_KEY) || window.sessionStorage.getItem(LEGACY_AUTH_TOKEN_KEY); if (legacy) { - window.localStorage.setItem(AUTH_TOKEN_KEY, legacy); - window.localStorage.removeItem(LEGACY_AUTH_TOKEN_KEY); + 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; } return null; } -export function setAuthToken(token: string) { - window.localStorage.setItem(AUTH_TOKEN_KEY, token); +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 clearAuthToken() { - window.localStorage.removeItem(AUTH_TOKEN_KEY); + try { + window.localStorage.removeItem(AUTH_TOKEN_KEY); + window.sessionStorage.removeItem(AUTH_TOKEN_KEY); + } catch { + // ignore storage failures + } } export function decodeJwtPayload(token: string): any { diff --git a/job-tracker-ui/src/components/RulesSettingsCard.tsx b/job-tracker-ui/src/components/RulesSettingsCard.tsx index b2a276b..deceeb9 100644 --- a/job-tracker-ui/src/components/RulesSettingsCard.tsx +++ b/job-tracker-ui/src/components/RulesSettingsCard.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from "react"; -import { Box, Button, Paper, TextField, Typography } from "@mui/material"; +import { Alert, Box, Button, Paper, TextField, Typography } from "@mui/material"; import { api } from "../api"; import { useToast } from "../toast"; @@ -21,10 +21,19 @@ export default function RulesSettingsCard() { const { t } = useI18n(); const [s, setS] = useState(null); const [saving, setSaving] = useState(false); + const [loadError, setLoadError] = useState(null); useEffect(() => { - api.get("/rules").then((r) => setS(r.data)); - }, []); + api.get("/rules") + .then((r) => { + setS(r.data); + setLoadError(null); + }) + .catch((e: any) => { + setS(null); + setLoadError(String(e?.response?.data || e?.message || t("rulesLoadFailed"))); + }); + }, [t]); const save = async () => { if (!s) return; @@ -39,7 +48,16 @@ export default function RulesSettingsCard() { } }; - if (!s) return null; + if (!s) { + return ( + + + {t("rulesTitle")} + + {loadError ? {loadError} : {t("rulesLoading")}} + + ); + } const num = (k: keyof RuleSettings) => ({ value: s[k], diff --git a/job-tracker-ui/src/components/SettingsView.tsx b/job-tracker-ui/src/components/SettingsView.tsx index 225cebc..13ecb20 100644 --- a/job-tracker-ui/src/components/SettingsView.tsx +++ b/job-tracker-ui/src/components/SettingsView.tsx @@ -41,7 +41,7 @@ function TabPanel({ value, index, children }: { value: number; index: number; ch return {children}; } -const ACCENTS = ["#15803d", "#16a34a", "#22c55e", "#0f766e", "#0f766e", "#65a30d"]; +const ACCENTS = ["#15803d", "#16a34a", "#22c55e", "#0f766e", "#2563eb", "#65a30d"]; export default function SettingsView({ pageSize, @@ -221,9 +221,24 @@ export default function SettingsView({ {t("settingsNotificationsTitle")} - + {t("settingsNotificationsBody")} + + + + {t("settingsNotificationsFollowUpsTitle")} + {t("settingsNotificationsFollowUpsBody")} + + + {t("settingsNotificationsAccountTitle")} + {t("settingsNotificationsAccountBody")} + + + + + {t("settingsNotificationsDeliveryNote")} + diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 58ea451..3c2d76b 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -146,7 +146,12 @@ export const translations = { settingsColumnDays: "Days", settingsColumnJobUrl: "Job URL", settingsNotificationsTitle: "Email notifications", - settingsNotificationsBody: "Notifications are sent via SMTP (Gmail works). Configure SMTP in the API (`Email:*` settings or env vars like `EMAIL_SMTP_HOST`).", + 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.", profileTitle: "Profile", profileHeadlinePlaceholder: "Add a short headline to personalize your account view.", profileLocalAccount: "Local account", @@ -567,6 +572,12 @@ export const translations = { createAccount: "Create account", signedIn: "Signed in.", loginFailed: "Login failed.", + rememberMe: "Remember me on this device", + 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.", resetPasswordTitle: "Reset password", resetPasswordBody: "Set a new password for your account.", missingResetLinkInfo: "Missing email/token in link.", @@ -801,7 +812,9 @@ export const translations = { jobDetailsNoHistory: "No history yet.", jobDetailsNothingHighlighted: "Nothing highlighted yet.", rulesTitle: "Follow-up + Ghosting Rules", - rulesBody: "Jobs get a “Follow up” flag based on these thresholds. Ghosting is automatic.", + rulesBody: "Set how long to wait before a follow-up is due and when a thread should be treated as ghosted.", + rulesLoading: "Loading your follow-up settings…", + rulesLoadFailed: "Could not load your follow-up settings.", rulesAppliedFollowUpDays: "Applied: follow-up days", rulesAppliedGhostDays: "Applied: ghost days", rulesOfferFollowUpDays: "Offer: follow-up days", @@ -957,7 +970,12 @@ export const translations = { settingsColumnDays: "Dager", settingsColumnJobUrl: "Jobb-URL", settingsNotificationsTitle: "E-postvarsler", - settingsNotificationsBody: "Varsler sendes via SMTP (Gmail fungerer). Konfigurer SMTP i API-et (`Email:*`-innstillinger eller miljøvariabler som `EMAIL_SMTP_HOST`).", + 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.", profileTitle: "Profil", profileHeadlinePlaceholder: "Legg til en kort overskrift for å gjøre kontovisningen mer personlig.", profileLocalAccount: "Lokal konto", @@ -1378,6 +1396,12 @@ export const translations = { createAccount: "Opprett konto", signedIn: "Logget inn.", loginFailed: "Innlogging mislyktes.", + rememberMe: "Husk meg på denne enheten", + 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.", resetPasswordTitle: "Tilbakestill passord", resetPasswordBody: "Sett et nytt passord for kontoen din.", missingResetLinkInfo: "Mangler e-post/token i lenken.", @@ -1612,7 +1636,9 @@ export const translations = { jobDetailsNoHistory: "Ingen historikk ennå.", jobDetailsNothingHighlighted: "Ingenting fremhevet ennå.", rulesTitle: "Regler for oppfølging og ghosting", - rulesBody: "Jobber får et «Følg opp»-flagg basert på disse tersklene. Ghosting skjer automatisk.", + rulesBody: "Velg hvor lenge du vil vente før oppfølging forfaller, og når en tråd skal regnes som ghostet.", + rulesLoading: "Laster inn oppfølgingsinnstillingene dine…", + rulesLoadFailed: "Kunne ikke laste oppfølgingsinnstillingene dine.", rulesAppliedFollowUpDays: "Søkt: oppfølgingsdager", rulesAppliedGhostDays: "Søkt: ghostingdager", rulesOfferFollowUpDays: "Tilbud: oppfølgingsdager", diff --git a/job-tracker-ui/src/pages/LoginPage.tsx b/job-tracker-ui/src/pages/LoginPage.tsx index 9a1cfba..8388534 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 { Box, Button, Paper, Tab, Tabs, TextField, Typography } from "@mui/material"; +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 } from "../auth"; +import { setAuthToken, setRememberMePref, getRememberMePref } from "../auth"; import GoogleAuthCard from "../components/GoogleAuthCard"; import { useToast } from "../toast"; import { useI18n } from "../i18n/I18nProvider"; @@ -29,6 +29,8 @@ 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 nextPath = (location?.state?.from as string | undefined) ?? "/jobs"; @@ -44,7 +46,8 @@ export default function LoginPage() { try { const url = mode === "register" ? "/auth/register" : "/auth/login"; const res = await api.post<{ accessToken: string; tokenType: string }>(url, { email, password }); - setAuthToken(res.data.accessToken); + setRememberMePref(rememberMe); + setAuthToken(res.data.accessToken, { remember: rememberMe }); toast(t("signedIn"), "success"); navigate(nextPath, { replace: true }); } catch (e: any) { @@ -55,6 +58,24 @@ export default function LoginPage() { } } + async function requestPasswordReset() { + if (!email.trim()) { + toast(t("loginResetEmailRequired"), "error"); + return; + } + + setRequestingReset(true); + try { + await api.post("/auth/request-password-reset", { email: email.trim() }); + toast(t("loginResetRequested"), "success"); + } catch (e: any) { + const msg = e?.response?.data || e?.message || t("loginResetRequestFailed"); + toast(String(msg), "error"); + } finally { + setRequestingReset(false); + } + } + const allowReg = cfg?.allowRegistration ?? false; return ( @@ -86,16 +107,25 @@ 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 && ( - - )} - + + {allowReg && ( + + )} + + )} diff --git a/job-tracker-ui/src/pages/ResetPasswordPage.tsx b/job-tracker-ui/src/pages/ResetPasswordPage.tsx index 418efff..5526a5e 100644 --- a/job-tracker-ui/src/pages/ResetPasswordPage.tsx +++ b/job-tracker-ui/src/pages/ResetPasswordPage.tsx @@ -1,30 +1,31 @@ -import React, { useMemo, useState } from "react"; +import React, { useEffect, useState } from "react"; -import { Box, Button, Paper, TextField, Typography } from "@mui/material"; +import { Alert, Box, Button, Paper, TextField, Typography } from "@mui/material"; -import { useLocation, useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { api } from "../api"; import { useToast } from "../toast"; import { useI18n } from "../i18n/I18nProvider"; -function useQuery() { - const { search } = useLocation(); - return useMemo(() => new URLSearchParams(search), [search]); -} - export default function ResetPasswordPage() { const { toast } = useToast(); const { t } = useI18n(); const navigate = useNavigate(); - const q = useQuery(); - - const email = q.get("email") || ""; - const token = q.get("token") || ""; + const [email, setEmail] = useState(""); + const [token, setToken] = useState(""); const [newPassword, setNewPassword] = useState(""); const [loading, setLoading] = useState(false); + useEffect(() => { + const params = new URLSearchParams(window.location.search); + setEmail(params.get("email") || ""); + setToken(params.get("token") || ""); + }, []); + + const missingResetInfo = !email || !token; + return ( { e.preventDefault(); - if (!email || !token) { + if (missingResetInfo) { toast(t("missingResetLinkInfo"), "error"); return; } @@ -68,14 +69,15 @@ export default function ResetPasswordPage() { }} sx={{ display: "flex", flexDirection: "column", gap: 1.5 }} > - + {missingResetInfo ? {t("missingResetLinkInfo")} : null} + setEmail(e.target.value)} disabled={!missingResetInfo} fullWidth /> setNewPassword(e.target.value)} fullWidth /> - diff --git a/job-tracker-ui/src/theme.ts b/job-tracker-ui/src/theme.ts index 8495143..d3a85a5 100644 --- a/job-tracker-ui/src/theme.ts +++ b/job-tracker-ui/src/theme.ts @@ -260,6 +260,12 @@ export const getTheme = (_mode: "light" | "dark", accentColor: string) => { }), }, }, + MuiTextField: { + defaultProps: { + size: "small", + variant: "outlined", + }, + }, MuiOutlinedInput: { defaultProps: { size: "small" }, styleOverrides: { @@ -269,6 +275,9 @@ export const getTheme = (_mode: "light" | "dark", accentColor: string) => { background: theme.vars.palette.background.default, paddingLeft: 10, paddingRight: 10, + minHeight: 42, + display: "flex", + alignItems: "center", "&.Mui-disabled": { cursor: "not-allowed", input: { cursor: "not-allowed" }, @@ -279,13 +288,16 @@ export const getTheme = (_mode: "light" | "dark", accentColor: string) => { multiline: { padding: 10, alignItems: "flex-start", + minHeight: "unset", }, input: { paddingLeft: 0, paddingRight: 0, - paddingTop: 10, - paddingBottom: 10, + paddingTop: 9, + paddingBottom: 9, lineHeight: 1.45, + display: "flex", + alignItems: "center", }, inputMultiline: { paddingTop: 0,