Polish settings and auth flows

This commit is contained in:
2026-03-27 21:51:15 +01:00
parent b46a0c121d
commit b53b2b5a35
10 changed files with 315 additions and 156 deletions
+2 -2
View File
@@ -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:
+1 -1
View File
@@ -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"
},
+77 -45
View File
@@ -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;
}
}
function safeSet(storage: Storage, key: string, value: string) {
try {
storage.setItem(key, value);
} catch {
// 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 {
try {
return window.localStorage.getItem(AUTH_REMEMBER_ME_KEY) === "1";
} catch {
return false;
}
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);
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 {
window.sessionStorage.setItem(AUTH_TOKEN_KEY, token);
window.localStorage.removeItem(AUTH_TOKEN_KEY);
}
} catch {
window.localStorage.setItem(AUTH_TOKEN_KEY, token);
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 {
@@ -62,6 +62,8 @@ type AttachmentBuckets = Record<AttachmentBucketKey, File[]>;
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) {
</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2, mt: 1 }}>
<TextField label={t("addJobModalJobUrl")} value={jobUrl} onChange={(e) => setJobUrl(e.target.value)} sx={{ gridColumn: "1 / -1" }} />
<TextField label={t("addJobModalJobUrl")} value={jobUrl} onChange={(e) => setJobUrl(e.target.value)} sx={{ ...FIELD_SX, gridColumn: "1 / -1" }} />
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end" }}>
<Button onClick={() => void importFromUrl()} disabled={importing || !jobUrl.trim()}>
{importing ? t("addJobModalImporting") : t("addJobModalImportFromUrl")}
@@ -465,10 +467,10 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
label={t("addJobModalDateApplied")}
value={parsePickerDate(dateApplied)}
onChange={(value) => setDateApplied(toPickerIso(value))}
slotProps={{ textField: { fullWidth: true } }}
slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }}
/>
<TextField select label={t("addJobModalStatus")} value={status} onChange={(e) => setStatus(e.target.value as any)}>
<TextField select label={t("addJobModalStatus")} value={status} onChange={(e) => setStatus(e.target.value as any)} sx={FIELD_SX}>
{STATUS_OPTIONS.map((s) => (
<MenuItem key={s} value={s}>
{statusLabel(s)}
@@ -476,15 +478,15 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
))}
</TextField>
<TextField label={t("addJobModalJobTitle")} value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} />
<TextField label={t("location")} value={location} onChange={(e) => setLocation(e.target.value)} />
<TextField label={t("addJobModalJobTitle")} value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} sx={FIELD_SX} />
<TextField label={t("location")} value={location} onChange={(e) => setLocation(e.target.value)} sx={FIELD_SX} />
<TextField label={t("addJobModalSalary")} value={salary} onChange={(e) => setSalary(e.target.value)} />
<TextField label={t("addJobModalSalary")} value={salary} onChange={(e) => setSalary(e.target.value)} sx={FIELD_SX} />
<DatePicker
label={t("addJobModalDeadline")}
value={parsePickerDate(deadline)}
onChange={(value) => setDeadline(toPickerIso(value))}
slotProps={{ textField: { fullWidth: true } }}
slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }}
/>
<Box sx={{ gridColumn: "1 / -1" }}>
+15 -13
View File
@@ -33,6 +33,8 @@ interface Props {
}
const STATUS_OPTIONS = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const;
const FIELD_SX = { "& .MuiInputBase-root": { minHeight: 56 } };
const PICKER_TEXT_FIELD_PROPS = { fullWidth: true, sx: FIELD_SX };
function toDateInputValue(isoLike?: string): string {
if (!isoLike) return new Date().toISOString().slice(0, 10);
@@ -182,34 +184,34 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
<Paper variant="outlined" sx={{ p: 2 }}>
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("editJobApplicationDetails")}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2, mt: 1 }}>
<Autocomplete options={companies} getOptionLabel={(c) => c.name} value={company} onChange={(_, v) => setCompany(v)} renderInput={(params) => <TextField {...params} label={t("company")} />} />
<TextField label={t("editJobJobTitle")} value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} />
<DatePicker label={t("editJobAppliedOn")} value={parsePickerDate(dateApplied)} onChange={(value) => setDateApplied(toPickerIso(value))} slotProps={{ textField: { fullWidth: true } }} />
<TextField label={t("addJobModalJobUrl")} value={jobUrl} onChange={(e) => setJobUrl(e.target.value)} />
<Autocomplete options={companies} getOptionLabel={(c) => c.name} value={company} onChange={(_, v) => setCompany(v)} renderInput={(params) => <TextField {...params} label={t("company")} sx={FIELD_SX} />} />
<TextField label={t("editJobJobTitle")} value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} sx={FIELD_SX} />
<DatePicker label={t("editJobAppliedOn")} value={parsePickerDate(dateApplied)} onChange={(value) => setDateApplied(toPickerIso(value))} slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }} />
<TextField label={t("addJobModalJobUrl")} value={jobUrl} onChange={(e) => setJobUrl(e.target.value)} sx={FIELD_SX} />
</Box>
</Paper>
<Paper variant="outlined" sx={{ p: 2 }}>
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("editJobStatusUpdate")}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr" }, gap: 2, mt: 1 }}>
<TextField select label={t("editJobCurrentStatus")} value={status} onChange={(e) => setStatus(e.target.value)}>
<TextField select label={t("editJobCurrentStatus")} value={status} onChange={(e) => setStatus(e.target.value)} sx={FIELD_SX}>
{STATUS_OPTIONS.map((s) => <MenuItem key={s} value={s}>{s}</MenuItem>)}
</TextField>
<DatePicker label={t("editJobStatusChangedOn")} value={parsePickerDate(statusChangedAt)} onChange={(value) => setStatusChangedAt(toPickerIso(value))} slotProps={{ textField: { fullWidth: true, helperText: status === initialStatus ? t("editJobStatusChangedHelpIdle") : t("editJobStatusChangedHelpActive") } }} />
<DatePicker label={t("editJobStatusChangedOn")} value={parsePickerDate(statusChangedAt)} onChange={(value) => setStatusChangedAt(toPickerIso(value))} slotProps={{ textField: { ...PICKER_TEXT_FIELD_PROPS, helperText: status === initialStatus ? t("editJobStatusChangedHelpIdle") : t("editJobStatusChangedHelpActive") } }} />
<Box sx={{ display: "flex", alignItems: "center" }}><FormControlLabel control={<Checkbox checked={responseReceived} onChange={(e) => setResponseReceived(e.target.checked)} />} label={t("editJobReplyReceived")} /></Box>
<DatePicker label={t("editJobReplyReceivedOn")} disabled={!responseReceived} value={parsePickerDate(responseDate)} onChange={(value) => setResponseDate(toPickerIso(value))} slotProps={{ textField: { fullWidth: true } }} />
<TextField label={t("editJobNextAction")} value={nextAction} onChange={(e) => setNextAction(e.target.value)} />
<DatePicker label={t("editJobFollowUpOn")} value={parsePickerDate(followUpAt)} onChange={(value) => setFollowUpAt(toPickerIso(value))} slotProps={{ textField: { fullWidth: true } }} />
<DatePicker label={t("editJobReplyReceivedOn")} disabled={!responseReceived} value={parsePickerDate(responseDate)} onChange={(value) => setResponseDate(toPickerIso(value))} slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }} />
<TextField label={t("editJobNextAction")} value={nextAction} onChange={(e) => setNextAction(e.target.value)} sx={FIELD_SX} />
<DatePicker label={t("editJobFollowUpOn")} value={parsePickerDate(followUpAt)} onChange={(value) => setFollowUpAt(toPickerIso(value))} slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }} />
</Box>
</Paper>
<Paper variant="outlined" sx={{ p: 2 }}>
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("editJobRoleDetails")}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2, mt: 1 }}>
<TextField label={t("location")} value={location} onChange={(e) => setLocation(e.target.value)} />
<TextField label={t("addJobModalSalary")} value={salary} onChange={(e) => setSalary(e.target.value)} />
<DatePicker label={t("editJobDeadline")} value={parsePickerDate(deadline)} onChange={(value) => setDeadline(toPickerIso(value))} slotProps={{ textField: { fullWidth: true } }} />
<TextField label={t("editJobDescriptionLanguage")} value={descriptionLanguage} onChange={(e) => setDescriptionLanguage(e.target.value)} />
<TextField label={t("location")} value={location} onChange={(e) => setLocation(e.target.value)} sx={FIELD_SX} />
<TextField label={t("addJobModalSalary")} value={salary} onChange={(e) => setSalary(e.target.value)} sx={FIELD_SX} />
<DatePicker label={t("editJobDeadline")} value={parsePickerDate(deadline)} onChange={(value) => setDeadline(toPickerIso(value))} slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }} />
<TextField label={t("editJobDescriptionLanguage")} value={descriptionLanguage} onChange={(e) => setDescriptionLanguage(e.target.value)} sx={FIELD_SX} />
<Box sx={{ gridColumn: "1 / -1" }}><TagsInput value={tags} onChange={setTags} /></Box>
<TextField label={t("editJobNotes")} value={notes} onChange={(e) => setNotes(e.target.value)} multiline rows={4} helperText={t("correspondenceCharacters", { count: notes.length })} sx={{ gridColumn: "1 / -1" }} />
<TextField label={t("editJobDescriptionOriginal")} value={description} onChange={(e) => setDescription(e.target.value)} multiline rows={6} helperText={t("correspondenceCharacters", { count: description.length })} sx={{ gridColumn: "1 / -1" }} />
@@ -3,7 +3,7 @@ import React, { useEffect, useMemo, useRef, useState } from "react";
import { Box, Button, Chip, Paper, Typography } from "@mui/material";
import { api } from "../api";
import { clearAuthToken, decodeJwtPayload, getAuthToken, setAuthToken } from "../auth";
import { clearAuthToken, decodeJwtPayload, getAuthPersistencePreference, getAuthToken, setAuthToken } from "../auth";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
@@ -58,6 +58,12 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
const payload = useMemo(() => (token ? decodeJwtPayload(token) : null), [token]);
const isRawGoogleToken = payload?.iss === "accounts.google.com" || payload?.iss === "https://accounts.google.com";
const actionLabel = !token
? t("continueWithGoogle")
: me?.provider === "local" && !me?.googleLink?.linked
? t("linkWithGoogle")
: t("signInWithGoogle");
async function refreshMe() {
if (!getAuthToken()) {
setMe(null);
@@ -82,7 +88,7 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
try {
const res = await api.post<{ accessToken: string }>("/auth/google/exchange", { token });
if (cancelled) return;
setAuthToken(res.data.accessToken);
setAuthToken(res.data.accessToken, getAuthPersistencePreference());
setToken(res.data.accessToken);
toast(t("googleSignedIn"), "success");
onSignedIn?.();
@@ -125,7 +131,7 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
await refreshMe();
} else {
const res = await api.post<{ accessToken: string }>("/auth/google/exchange", { token: credential });
setAuthToken(res.data.accessToken);
setAuthToken(res.data.accessToken, getAuthPersistencePreference());
setToken(res.data.accessToken);
toast(t("googleSignedIn"), "success");
onSignedIn?.();
@@ -191,9 +197,14 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
</Typography>
)}
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "flex-start", gap: 1 }}>
<Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 700, letterSpacing: 0.4, textTransform: "uppercase" }}>
{actionLabel}
</Typography>
<div ref={hostRef} />
</Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
{token ? (
<Button
variant="outlined"
+29 -26
View File
@@ -15,6 +15,8 @@ import {
Typography,
} from "@mui/material";
import { useNavigate } from "react-router-dom";
import { JobTableColumns } from "./JobTable";
import ImportExportJobs from "./ImportExportJobs";
import GoogleAuthCard from "./GoogleAuthCard";
@@ -54,6 +56,7 @@ export default function SettingsView({
onAccentColorChange,
onResetAccentColor,
}: Props) {
const navigate = useNavigate();
const [tab, setTab] = useState(0);
const { language, setLanguage, t } = useI18n();
@@ -96,15 +99,14 @@ export default function SettingsView({
</FormControl>
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
<input type="hidden" />
<FormControl sx={{ width: 160 }}>
<Typography variant="caption" sx={{ mb: 0.5 }}>{t("settingsAccent")}</Typography>
<Typography variant="caption" sx={{ mb: 0.75 }}>{t("settingsAccent")}</Typography>
<input
aria-label={t("settingsAccent")}
type="color"
value={accentOk ? accentColor : "#15803d"}
onChange={(e) => onAccentColorChange(e.target.value)}
style={{ width: 160, height: 40, border: "none", background: "transparent", padding: 0 }}
style={{ width: 160, height: 56, border: "none", background: "transparent", padding: 0 }}
/>
</FormControl>
<Button variant="outlined" onClick={onResetAccentColor}>
@@ -119,6 +121,7 @@ export default function SettingsView({
type="button"
onClick={() => onAccentColorChange(c)}
title={c}
aria-label={`${t("settingsAccent")} ${c}`}
style={{
width: 28,
height: 28,
@@ -197,12 +200,7 @@ export default function SettingsView({
).map(([key, label]) => (
<FormControlLabel
key={key}
control={
<Checkbox
checked={columns[key]}
onChange={() => onColumnsChange({ ...columns, [key]: !columns[key] })}
/>
}
control={<Checkbox checked={columns[key]} onChange={() => onColumnsChange({ ...columns, [key]: !columns[key] })} />}
label={label}
/>
))}
@@ -215,31 +213,36 @@ export default function SettingsView({
</TabPanel>
<TabPanel value={tab} index={1}>
<Box sx={{ display: "grid", gap: 2 }}>
<Paper sx={{ p: 2 }}>
<Typography sx={{ fontWeight: 950, mb: 0.5 }}>{t("settingsFollowUpsTitle")}</Typography>
<Typography sx={{ color: "text.secondary", mb: 1.5 }}>{t("settingsFollowUpsBody")}</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Button variant="outlined" onClick={() => navigate("/reminders")}>{t("settingsOpenReminderInbox")}</Button>
<Button variant="text" onClick={() => navigate("/jobs")}>{t("settingsReviewJobs")}</Button>
</Box>
</Paper>
<RulesSettingsCard />
</Box>
</TabPanel>
<TabPanel value={tab} index={2}>
<Paper sx={{ p: 2, mt: 2 }}>
<Typography sx={{ fontWeight: 950, mb: 0.5 }}>{t("settingsNotificationsTitle")}</Typography>
<Typography sx={{ color: "text.secondary", mb: 2 }}>
{t("settingsNotificationsBody")}
</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
<Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography sx={{ fontWeight: 800, mb: 0.5 }}>{t("settingsNotificationsFollowUpsTitle")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("settingsNotificationsFollowUpsBody")}</Typography>
<Paper sx={{ p: 2 }}>
<Typography sx={{ fontWeight: 950, mb: 0.5 }}>{t("settingsNotificationsTitle")}</Typography>
<Typography sx={{ color: "text.secondary", mb: 1.5 }}>{t("settingsNotificationsBody")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("settingsNotificationsDelivery")}</Typography>
</Paper>
<Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography sx={{ fontWeight: 800, mb: 0.5 }}>{t("settingsNotificationsAccountTitle")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("settingsNotificationsAccountBody")}</Typography>
<Paper sx={{ p: 2 }}>
<Typography sx={{ fontWeight: 950, mb: 0.5 }}>{t("settingsNotificationsWhatYouGetTitle")}</Typography>
<Typography sx={{ color: "text.secondary", mb: 1.5 }}>{t("settingsNotificationsWhatYouGetBody")}</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Button variant="outlined" onClick={() => navigate("/reminders")}>{t("settingsOpenReminderInbox")}</Button>
<Button variant="text" onClick={() => navigate("/admin/system")}>{t("settingsCheckSystemStatus")}</Button>
</Box>
</Paper>
</Box>
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 2 }}>
{t("settingsNotificationsDeliveryNote")}
</Typography>
</Paper>
</TabPanel>
<TabPanel value={tab} index={3}>
+40 -24
View File
@@ -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.",
+82
View File
@@ -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<typeof api>;
let consoleErrorSpy: jest.SpyInstance;
function renderLoginPage() {
return render(
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<I18nProvider>
<ToastProvider>
<LoginPage />
</ToastProvider>
</I18nProvider>
</MemoryRouter>,
);
}
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();
});
});
+32 -21
View File
@@ -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,16 +106,29 @@ export default function LoginPage() {
<Box component="form" onSubmit={(e) => { e.preventDefault(); void submit("login"); }} sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
<TextField label={t("profileEmail")} value={email} onChange={(e) => setEmail(e.target.value)} autoComplete="email" fullWidth />
<TextField label={t("profileCurrentPassword")} value={password} onChange={(e) => setPassword(e.target.value)} autoComplete={allowReg ? "new-password" : "current-password"} type="password" fullWidth />
<Box sx={{ display: "flex", alignItems: { xs: "flex-start", sm: "center" }, justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
<FormControlLabel
control={<Checkbox checked={rememberMe} onChange={(e) => setRememberMe(e.target.checked)} />}
label={t("rememberMe")}
/>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mt: 0.5 }}>
<Button type="button" variant="text" onClick={() => void requestPasswordReset()} disabled={loading || requestingReset}>
{requestingReset ? t("loginRequestingReset") : t("forgotPassword")}
<Button
type="button"
variant="text"
size="small"
onClick={() => void requestPasswordReset()}
disabled={resetLoading}
sx={{ px: 0, minWidth: 0, fontWeight: 700, alignSelf: { xs: "stretch", sm: "auto" } }}
>
{resetLoading ? t("passwordResetRequestSending") : t("forgotPassword")}
</Button>
<Box sx={{ display: "flex", gap: 1, justifyContent: "flex-end" }}>
</Box>
<Typography variant="body2" sx={{ color: "text.secondary", mt: -0.5 }}>
{rememberMe ? t("rememberMeHelpPersistent") : t("rememberMeHelpSession")}
</Typography>
<Box sx={{ display: "flex", gap: 1, justifyContent: "flex-end", mt: 1 }}>
{allowReg && (
<Button type="button" variant="outlined" disabled={loading} onClick={() => void submit("register")}>
{t("createAccount")}
@@ -127,7 +139,6 @@ export default function LoginPage() {
</Button>
</Box>
</Box>
</Box>
)}
{tab === 1 && <GoogleAuthCard onSignedIn={() => { navigate(nextPath, { replace: true }); }} />}