From 0694cba722f4b2d2421d5af797d67b6f53986705 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sat, 28 Mar 2026 15:30:21 +0100 Subject: [PATCH] Polish settings and admin system pages --- job-tracker-ui/src/admin-system-page.test.tsx | 77 +++- job-tracker-ui/src/components/BackupCard.tsx | 4 +- .../src/components/DashboardView.tsx | 23 +- .../src/components/GoogleAuthCard.tsx | 10 +- .../src/components/RulesSettingsCard.tsx | 4 +- .../src/components/SettingsView.tsx | 203 +++++++--- job-tracker-ui/src/i18n/translations.ts | 37 +- job-tracker-ui/src/pages/AdminSystemPage.tsx | 356 +++++++++++------- job-tracker-ui/src/pages/AdminUsersPage.tsx | 9 +- job-tracker-ui/src/settings-view.test.tsx | 98 +++++ 10 files changed, 604 insertions(+), 217 deletions(-) create mode 100644 job-tracker-ui/src/settings-view.test.tsx diff --git a/job-tracker-ui/src/admin-system-page.test.tsx b/job-tracker-ui/src/admin-system-page.test.tsx index 891a634..8fbec06 100644 --- a/job-tracker-ui/src/admin-system-page.test.tsx +++ b/job-tracker-ui/src/admin-system-page.test.tsx @@ -1,14 +1,26 @@ import React from 'react'; -import { render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import AdminSystemPage from './pages/AdminSystemPage'; import { I18nProvider } from './i18n/I18nProvider'; import { api } from './api'; +jest.mock('./api', () => ({ + api: { + get: jest.fn(), + post: jest.fn(), + put: jest.fn(), + patch: jest.fn(), + delete: jest.fn(), + interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } }, + }, + getApiErrorMessage: (error: any, fallback?: string) => fallback || 'Request failed.', +})); + const mockedApi = api as jest.Mocked; describe('AdminSystemPage', () => { - it('renders AI service health, latency, and OCR readiness', async () => { + beforeEach(() => { mockedApi.get.mockImplementation((url: string) => { if (url === '/admin/system') { return Promise.resolve({ @@ -54,9 +66,45 @@ describe('AdminSystemPage', () => { }, } as any); } + if (url === '/admin/system/email-settings') { + return Promise.resolve({ + data: { + enabled: true, + host: 'smtp.example.test', + port: 587, + user: 'mailer@example.test', + from: 'noreply@example.test', + fromName: 'Jobbjakt', + enableSsl: true, + timeoutMs: 15000, + usesOverrides: false, + hasPassword: true, + }, + } as any); + } return Promise.resolve({ data: {} } as any); }); + mockedApi.put.mockResolvedValue({ + data: { + enabled: true, + host: 'smtp.changed.test', + port: 2525, + user: 'mailer@example.test', + from: 'noreply@example.test', + fromName: 'Jobbjakt', + enableSsl: true, + timeoutMs: 15000, + usesOverrides: true, + hasPassword: true, + }, + } as any); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders AI service health, latency, and OCR readiness', async () => { render( @@ -64,7 +112,7 @@ describe('AdminSystemPage', () => { ); await waitFor(() => { - expect(screen.getByText('AI service')).toBeTruthy(); + expect(screen.getByText('Production')).toBeTruthy(); }); expect(screen.getByText(/25.8 ms probe/i)).toBeTruthy(); @@ -72,4 +120,27 @@ describe('AdminSystemPage', () => { expect(screen.getByText('OCR avg latency')).toBeTruthy(); expect(screen.getByText('88.4 ms')).toBeTruthy(); }); + + it('loads and saves smtp settings from the settings tab', async () => { + render( + + + , + ); + + expect(await screen.findByText('AI service')).toBeTruthy(); + fireEvent.click(screen.getByRole('tab', { name: /settings/i })); + + const hostInput = await screen.findByLabelText(/host/i); + fireEvent.change(hostInput, { target: { value: 'smtp.changed.test' } }); + fireEvent.change(screen.getByLabelText(/port/i), { target: { value: '2525' } }); + fireEvent.click(screen.getByRole('button', { name: /save settings/i })); + + await waitFor(() => { + expect(mockedApi.put).toHaveBeenCalledWith('/admin/system/email-settings', expect.objectContaining({ + host: 'smtp.changed.test', + port: 2525, + })); + }); + }); }); diff --git a/job-tracker-ui/src/components/BackupCard.tsx b/job-tracker-ui/src/components/BackupCard.tsx index 59bcbf3..787fb29 100644 --- a/job-tracker-ui/src/components/BackupCard.tsx +++ b/job-tracker-ui/src/components/BackupCard.tsx @@ -28,8 +28,8 @@ export default function BackupCard() { link.remove(); window.setTimeout(() => URL.revokeObjectURL(url), 5000); toast(t("backupDownloaded"), "success"); - } catch { - toast(t("backupFailed"), "error"); + } catch (error: any) { + toast(getApiErrorMessage(error, t("backupFailed")), "error"); } finally { setDownloading(false); } diff --git a/job-tracker-ui/src/components/DashboardView.tsx b/job-tracker-ui/src/components/DashboardView.tsx index 0ad91b1..b9c8777 100644 --- a/job-tracker-ui/src/components/DashboardView.tsx +++ b/job-tracker-ui/src/components/DashboardView.tsx @@ -222,22 +222,33 @@ export default function DashboardView() { - + {t("dashboardHeroLabel")} - + {t("dashboardOverviewTitle")} - + {t("dashboardOverviewBody")} diff --git a/job-tracker-ui/src/components/GoogleAuthCard.tsx b/job-tracker-ui/src/components/GoogleAuthCard.tsx index 0e0100f..a6cc25b 100644 --- a/job-tracker-ui/src/components/GoogleAuthCard.tsx +++ b/job-tracker-ui/src/components/GoogleAuthCard.tsx @@ -137,8 +137,7 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void onSignedIn?.(); } } catch (e: any) { - const msg = e?.response?.data || e?.message || t("googleAuthFailed"); - toast(String(msg), "error"); + toast(getApiErrorMessage(e, t("googleAuthFailed")), "error"); } finally { setWorking(false); } @@ -250,3 +249,10 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void ); } +raphy> + ) : null} + + )} + + ); +} diff --git a/job-tracker-ui/src/components/RulesSettingsCard.tsx b/job-tracker-ui/src/components/RulesSettingsCard.tsx index deceeb9..806b2de 100644 --- a/job-tracker-ui/src/components/RulesSettingsCard.tsx +++ b/job-tracker-ui/src/components/RulesSettingsCard.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react"; import { Alert, Box, Button, Paper, TextField, Typography } from "@mui/material"; -import { api } from "../api"; +import { api, getApiErrorMessage } from "../api"; import { useToast } from "../toast"; import { useI18n } from "../i18n/I18nProvider"; @@ -31,7 +31,7 @@ export default function RulesSettingsCard() { }) .catch((e: any) => { setS(null); - setLoadError(String(e?.response?.data || e?.message || t("rulesLoadFailed"))); + setLoadError(getApiErrorMessage(e, t("rulesLoadFailed"))); }); }, [t]); diff --git a/job-tracker-ui/src/components/SettingsView.tsx b/job-tracker-ui/src/components/SettingsView.tsx index 61649b4..30ee4d9 100644 --- a/job-tracker-ui/src/components/SettingsView.tsx +++ b/job-tracker-ui/src/components/SettingsView.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { Box, @@ -9,9 +9,11 @@ import { InputLabel, MenuItem, Paper, + Popover, Select, Tab, Tabs, + TextField, Typography, } from "@mui/material"; @@ -43,7 +45,39 @@ function TabPanel({ value, index, children }: { value: number; index: number; ch return {children}; } -const ACCENTS = ["#15803d", "#16a34a", "#22c55e", "#0f766e", "#2563eb", "#65a30d"]; +const ACCENTS = ["#15803d", "#16a34a", "#22c55e", "#0f766e", "#2563eb", "#65a30d", "#8b5cf6", "#f97316"]; +const NOTIFICATION_PREFS_KEY = "settings.notificationPrefs"; + +type NotificationPrefs = { + emailFollowUpReminders: boolean; + emailGhostedJobAlerts: boolean; + inAppReminderHighlights: boolean; +}; + +function loadNotificationPrefs(): NotificationPrefs { + try { + const raw = window.localStorage.getItem(NOTIFICATION_PREFS_KEY); + if (!raw) { + return { + emailFollowUpReminders: true, + emailGhostedJobAlerts: true, + inAppReminderHighlights: true, + }; + } + return { + emailFollowUpReminders: true, + emailGhostedJobAlerts: true, + inAppReminderHighlights: true, + ...JSON.parse(raw), + }; + } catch { + return { + emailFollowUpReminders: true, + emailGhostedJobAlerts: true, + inAppReminderHighlights: true, + }; + } +} export default function SettingsView({ pageSize, @@ -59,8 +93,32 @@ export default function SettingsView({ const navigate = useNavigate(); const [tab, setTab] = useState(0); const { language, setLanguage, t } = useI18n(); + const [accentAnchor, setAccentAnchor] = useState(null); + const [accentDraft, setAccentDraft] = useState(accentColor); + const [notificationPrefs, setNotificationPrefs] = useState(() => loadNotificationPrefs()); const accentOk = useMemo(() => /^#[0-9a-fA-F]{6}$/.test(accentColor), [accentColor]); + const accentDraftOk = useMemo(() => /^#[0-9a-fA-F]{6}$/.test(accentDraft), [accentDraft]); + + useEffect(() => { + setAccentDraft(accentOk ? accentColor : "#15803d"); + }, [accentColor, accentOk]); + + useEffect(() => { + window.localStorage.setItem(NOTIFICATION_PREFS_KEY, JSON.stringify(notificationPrefs)); + }, [notificationPrefs]); + + const applyAccent = () => { + if (!accentDraftOk) return; + onAccentColorChange(accentDraft); + setAccentAnchor(null); + }; + + const resetAccent = () => { + onResetAccentColor(); + setAccentDraft("#15803d"); + setAccentAnchor(null); + }; return ( @@ -99,40 +157,70 @@ export default function SettingsView({ - - {t("settingsAccent")} - onAccentColorChange(e.target.value)} - style={{ width: 160, height: 56, border: "none", background: "transparent", padding: 0 }} - /> - - + + - - {ACCENTS.map((c) => ( - + + + + {t("settingsSavedPerUser")} @@ -213,36 +301,35 @@ export default function SettingsView({ - - - {t("settingsFollowUpsTitle")} - {t("settingsFollowUpsBody")} - - - - - - - + - - - {t("settingsNotificationsTitle")} - {t("settingsNotificationsBody")} - {t("settingsNotificationsDelivery")} - - - - {t("settingsNotificationsWhatYouGetTitle")} - {t("settingsNotificationsWhatYouGetBody")} - - - - - - + + {t("settingsNotificationsTitle")} + {t("settingsNotificationsBody")} + + setNotificationPrefs((prev) => ({ ...prev, emailFollowUpReminders: e.target.checked }))} />} + label={t("settingsNotificationsFollowUpReminders")} + /> + setNotificationPrefs((prev) => ({ ...prev, emailGhostedJobAlerts: e.target.checked }))} />} + label={t("settingsNotificationsGhostedJobs")} + /> + setNotificationPrefs((prev) => ({ ...prev, inAppReminderHighlights: e.target.checked }))} />} + label={t("settingsNotificationsInAppReminders")} + /> + + + {t("settingsNotificationsDelivery")} + + + + + + diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 066e64e..e82e5ac 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -145,15 +145,18 @@ 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.", + settingsFollowUpsTitle: "Follow-up rules by scenario", + settingsFollowUpsBody: "Set when applied, offer, and feedback scenarios should surface follow-up work or be treated as ghosted.", settingsOpenReminderInbox: "Open reminders", settingsReviewJobs: "Review jobs", - settingsNotificationsTitle: "Email notifications", - 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.", + settingsNotificationsTitle: "Notification settings", + settingsNotificationsBody: "Choose which reminders should show up in your workflow. SMTP delivery can be checked from the system page.", + settingsNotificationsDelivery: "SMTP delivery and test mail live under Admin → System → Settings.", + settingsNotificationsFollowUpReminders: "Email reminders for follow-ups", + settingsNotificationsGhostedJobs: "Email alerts for ghosted jobs", + settingsNotificationsInAppReminders: "Highlight reminders in the app", + settingsAccentHelp: "Drag in the color picker, then save when it looks right.", + settingsAccentInvalid: "Use a full hex color like #15803D.", settingsCheckSystemStatus: "Check system status", profileTitle: "Profile", profileHeadlinePlaceholder: "Add a short headline to personalize your account view.", @@ -527,6 +530,8 @@ export const translations = { adminAuditActions: "Actions", adminAuditNoEvents: "No events.", adminSystemEnvironment: "Environment", + adminSystemStatusTab: "Status", + adminSystemSettingsTab: "Settings", adminSystemDatabase: "Database", adminSystemConnected: "Connected", adminSystemOffline: "Offline", @@ -539,6 +544,16 @@ export const translations = { adminSystemDatabaseStorage: "Database and storage", adminSystemRuntimeAuth: "Runtime and auth", adminSystemEmailConfig: "Email configuration", + adminSystemEmailSettingsTitle: "SMTP settings", + adminSystemEmailSettingsBody: "Update the effective SMTP settings used for password resets, reminders, and admin test email delivery.", + adminSystemUsername: "SMTP username", + adminSystemPassword: "SMTP password", + adminSystemPasswordStored: "A password is already stored. Leave this blank to keep it.", + adminSystemPasswordMissing: "No SMTP password stored yet.", + adminSystemTimeoutMs: "Timeout (ms)", + adminSystemClearStoredPassword: "Clear stored password", + adminSystemSaveSettings: "Save settings", + adminSystemSaving: "Saving...", adminSystemSummarizerRuntime: "AI runtime", adminSystemSmtpTest: "SMTP test email", adminSystemSmtpTestBody: "Send a quick delivery check using the configured SMTP settings. Leave the recipient blank to use your admin email.", @@ -585,7 +600,7 @@ export const translations = { signedInAs: "Signed in as {name}.", unlinkGoogle: "Unlink Google", backupTitle: "Data safety", - backupBody: "One-click encrypted backup (Windows DPAPI).", + backupBody: "One-click encrypted backup of your current data.", backupPreparing: "Preparing...", backupDownload: "Download encrypted backup", backupDownloaded: "Backup downloaded.", @@ -863,7 +878,7 @@ export const translations = { jobDetailsNoReadiness: "No readiness analysis available yet.", jobDetailsNoHistory: "No history yet.", jobDetailsNothingHighlighted: "Nothing highlighted yet.", - rulesTitle: "Follow-up + Ghosting Rules", + rulesTitle: "Follow-up rules by scenario", 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.", @@ -1420,7 +1435,7 @@ export const translations = { signedInAs: "Logget inn som {name}.", unlinkGoogle: "Koble fra Google", backupTitle: "Datasikkerhet", - backupBody: "Kryptert sikkerhetskopi med ett klikk (Windows DPAPI).", + backupBody: "Kryptert sikkerhetskopi av gjeldende data med ett klikk.", backupPreparing: "Forbereder...", backupDownload: "Last ned kryptert sikkerhetskopi", backupDownloaded: "Sikkerhetskopi lastet ned.", @@ -1698,7 +1713,7 @@ export const translations = { jobDetailsNoReadiness: "Ingen beredskapsanalyse tilgjengelig ennå.", jobDetailsNoHistory: "Ingen historikk ennå.", jobDetailsNothingHighlighted: "Ingenting fremhevet ennå.", - rulesTitle: "Regler for oppfølging og ghosting", + rulesTitle: "Oppfølgingsregler per scenario", 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.", diff --git a/job-tracker-ui/src/pages/AdminSystemPage.tsx b/job-tracker-ui/src/pages/AdminSystemPage.tsx index f10fce0..15b418c 100644 --- a/job-tracker-ui/src/pages/AdminSystemPage.tsx +++ b/job-tracker-ui/src/pages/AdminSystemPage.tsx @@ -7,8 +7,12 @@ import { Chip, Paper, Stack, + Tab, + Tabs, TextField, Typography, + Checkbox, + FormControlLabel, } from "@mui/material"; import { api, getApiErrorMessage } from "../api"; @@ -43,6 +47,19 @@ type AiServiceMetrics = { lastError?: string | null; }; +type EditableEmailSettings = { + enabled: boolean; + host: string; + port: number; + user: string; + from: string; + fromName: string; + enableSsl: boolean; + timeoutMs: number; + usesOverrides: boolean; + hasPassword: boolean; +}; + type SystemStatus = { environment: string; contentRoot: string; @@ -121,9 +138,14 @@ function DetailRow({ label, value }: { label: string; value: React.ReactNode }) export default function AdminSystemPage() { const { t } = useI18n(); + const [tab, setTab] = useState(0); const [status, setStatus] = useState(null); + const [emailSettings, setEmailSettings] = useState(null); + const [smtpPassword, setSmtpPassword] = useState(""); + const [clearPassword, setClearPassword] = useState(false); const [loading, setLoading] = useState(false); const [runningProbe, setRunningProbe] = useState(false); + const [savingSettings, setSavingSettings] = useState(false); const [error, setError] = useState(null); const [testEmailTo, setTestEmailTo] = useState(""); const [testEmailSubject, setTestEmailSubject] = useState("Jobbjakt SMTP test"); @@ -134,11 +156,18 @@ export default function AdminSystemPage() { setLoading(true); setError(null); try { - const res = await api.get("/admin/system"); - setStatus(res.data); + const [statusRes, emailRes] = await Promise.all([ + api.get("/admin/system"), + api.get("/admin/system/email-settings"), + ]); + setStatus(statusRes.data); + setEmailSettings(emailRes.data); + setSmtpPassword(""); + setClearPassword(false); } catch (e: any) { setError(getApiErrorMessage(e, "Failed to load system status.")); setStatus(null); + setEmailSettings(null); } finally { setLoading(false); } @@ -170,6 +199,7 @@ export default function AdminSystemPage() { subject: testEmailSubject.trim() || null, message: testEmailMessage.trim() || null, }); + setError(null); } catch (e: any) { setError(getApiErrorMessage(e, "Failed to send test email.")); } finally { @@ -177,6 +207,33 @@ export default function AdminSystemPage() { } }; + const saveEmailSettings = async () => { + if (!emailSettings) return; + setSavingSettings(true); + try { + const res = await api.put("/admin/system/email-settings", { + enabled: emailSettings.enabled, + host: emailSettings.host, + port: Number(emailSettings.port) || 587, + user: emailSettings.user, + password: smtpPassword.trim() || null, + clearPassword, + from: emailSettings.from, + fromName: emailSettings.fromName, + enableSsl: emailSettings.enableSsl, + timeoutMs: Number(emailSettings.timeoutMs) || 15000, + }); + setEmailSettings(res.data); + setSmtpPassword(""); + setClearPassword(false); + await load(); + } catch (e: any) { + setError(getApiErrorMessage(e, "Failed to save email settings.")); + } finally { + setSavingSettings(false); + } + }; + return ( @@ -209,143 +266,180 @@ export default function AdminSystemPage() { + setTab(value)}> + + + + {error ? {error} : null} - {status?.database.warning ? {status.database.warning} : null} - {status?.ai.lastError ? {status.ai.lastError} : null} + {status?.database.warning && tab === 0 ? {status.database.warning} : null} + {status?.ai.lastError && tab === 0 ? {status.ai.lastError} : null} - - - - - - + {tab === 0 ? ( + <> + + + + + + - - - {t("adminSystemDatabaseStorage")} - - - - - - - - - - - - - - - + + + {t("adminSystemDatabaseStorage")} + + + + + + + + + + + + + + + - - {t("adminSystemRuntimeAuth")} - - - - - - - - - - - - - - + + {t("adminSystemRuntimeAuth")} + + + + + + + + + + + + + + - - - {t("adminSystemEmailConfig")} - - - - - - - - - + + + {t("adminSystemEmailConfig")} + + + + + + + + + - - {t("adminSystemSummarizerRuntime")} - - - - - - - - - - - - - + + {t("adminSystemSummarizerRuntime")} + + + + + + + + + + + + + - - {t("adminSystemSmtpTest")} - - {t("adminSystemSmtpTestBody")} - - - setTestEmailTo(e.target.value)} placeholder={t("adminSystemRecipientPlaceholder")} /> - setTestEmailSubject(e.target.value)} /> - setTestEmailMessage(e.target.value)} sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} /> + + {t("adminSystemSummarizerTelemetry")} + + {t("adminSystemRequests")}{status?.ai.requests ?? 0} + {t("adminSystemCacheHits")}{status?.ai.cacheHits ?? 0} + {t("adminSystemCacheMisses")}{status?.ai.cacheMisses ?? 0} + {t("adminSystemFailures")}{status?.ai.failures ?? 0} + {t("adminSystemProbeFailures")}{status?.ai.probeFailures ?? 0} + {t("adminSystemAvgLatency")}{status?.ai.averageLatencyMs != null ? `${status.ai.averageLatencyMs} ms` : "-"} + {t("adminSystemOcrRequests")}{status?.ai.ocrRequests ?? 0} + {t("adminSystemOcrAvgLatency")}{status?.ai.averageOcrLatencyMs != null ? `${status.ai.averageOcrLatencyMs} ms` : "-"} + + + + + + + + + + + + ) : ( + + + {t("adminSystemEmailSettingsTitle")} + {t("adminSystemEmailSettingsBody")} + + setEmailSettings((prev) => prev ? { ...prev, enabled: e.target.checked } : prev)} />} + label={t("adminSystemEnabled")} + sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} + /> + setEmailSettings((prev) => prev ? { ...prev, host: e.target.value } : prev)} fullWidth /> + setEmailSettings((prev) => prev ? { ...prev, port: Number(e.target.value) } : prev)} fullWidth /> + setEmailSettings((prev) => prev ? { ...prev, user: e.target.value } : prev)} fullWidth /> + { setSmtpPassword(e.target.value); if (e.target.value.trim()) setClearPassword(false); }} helperText={emailSettings?.hasPassword ? t("adminSystemPasswordStored") : t("adminSystemPasswordMissing")} fullWidth /> + setEmailSettings((prev) => prev ? { ...prev, from: e.target.value } : prev)} fullWidth /> + setEmailSettings((prev) => prev ? { ...prev, fromName: e.target.value } : prev)} fullWidth /> + setEmailSettings((prev) => prev ? { ...prev, timeoutMs: Number(e.target.value) } : prev)} fullWidth /> + setEmailSettings((prev) => prev ? { ...prev, enableSsl: e.target.checked } : prev)} />} label={t("adminSystemSsl")} /> + { setClearPassword(e.target.checked); if (e.target.checked) setSmtpPassword(""); }} />} label={t("adminSystemClearStoredPassword")} sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} /> + + + + + + + + {t("adminSystemSmtpTest")} + + {t("adminSystemSmtpTestBody")} + + + setTestEmailTo(e.target.value)} placeholder={t("adminSystemRecipientPlaceholder")} /> + setTestEmailSubject(e.target.value)} /> + setTestEmailMessage(e.target.value)} sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} /> + + + + + - - - - - - - {t("adminSystemSummarizerTelemetry")} - - {t("adminSystemRequests")}{status?.ai.requests ?? 0} - {t("adminSystemCacheHits")}{status?.ai.cacheHits ?? 0} - {t("adminSystemCacheMisses")}{status?.ai.cacheMisses ?? 0} - {t("adminSystemFailures")}{status?.ai.failures ?? 0} - {t("adminSystemProbeFailures")}{status?.ai.probeFailures ?? 0} - {t("adminSystemAvgLatency")}{status?.ai.averageLatencyMs != null ? `${status.ai.averageLatencyMs} ms` : "-"} - {t("adminSystemOcrRequests")}{status?.ai.ocrRequests ?? 0} - {t("adminSystemOcrAvgLatency")}{status?.ai.averageOcrLatencyMs != null ? `${status.ai.averageOcrLatencyMs} ms` : "-"} - - - - - - - - - - + )} ); } diff --git a/job-tracker-ui/src/pages/AdminUsersPage.tsx b/job-tracker-ui/src/pages/AdminUsersPage.tsx index 523f85d..4e96372 100644 --- a/job-tracker-ui/src/pages/AdminUsersPage.tsx +++ b/job-tracker-ui/src/pages/AdminUsersPage.tsx @@ -79,8 +79,7 @@ export default function AdminUsersPage() { await api.post(`/users/${u.id}/send-password-reset`); toast(t("adminUsersResetSent"), "success"); } catch (e: any) { - const msg = e?.response?.data || e?.message || t("adminUsersResetFailed"); - toast(String(msg), "error"); + toast(getApiErrorMessage(e, t("adminUsersResetFailed")), "error"); } }, [t, toast]); @@ -216,3 +215,9 @@ export default function AdminUsersPage() { ); } +pography> + ) : null} + + + ); +} diff --git a/job-tracker-ui/src/settings-view.test.tsx b/job-tracker-ui/src/settings-view.test.tsx new file mode 100644 index 0000000..24cd577 --- /dev/null +++ b/job-tracker-ui/src/settings-view.test.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +import SettingsView from './components/SettingsView'; +import { I18nProvider } from './i18n/I18nProvider'; +import { ToastProvider } from './toast'; +import { api } from './api'; + +jest.mock('./api', () => ({ + api: { + get: jest.fn(), + post: jest.fn(), + put: jest.fn(), + patch: jest.fn(), + delete: jest.fn(), + interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } }, + }, + getApiErrorMessage: (error: any, fallback?: string) => fallback || 'Request failed.', +})); + +jest.mock('./components/ImportExportJobs', () => () =>
Import Export Stub
); +jest.mock('./components/GoogleAuthCard', () => () =>
Google Auth Stub
); +jest.mock('./components/BackupCard', () => () =>
Backup Stub
); +jest.mock('./components/AuthStatusCard', () => () =>
Auth Status Stub
); + +const mockedApi = api as jest.Mocked; + +function renderView(onAccentColorChange = jest.fn()) { + return { + onAccentColorChange, + ...render( + + + + + + + , + ), + }; +} + +beforeEach(() => { + mockedApi.get.mockImplementation((url: string) => { + if (url === '/rules') { + return Promise.resolve({ + data: { + id: 1, + appliedFollowUpDays: 14, + appliedGhostDays: 30, + offerFollowUpDays: 7, + offerGhostDays: 14, + feedbackFollowUpDays: 7, + feedbackGhostDays: 14, + }, + } as any); + } + return Promise.resolve({ data: {} } as any); + }); + window.localStorage.clear(); +}); + +afterEach(() => { + jest.clearAllMocks(); +}); + +test('settings view uses one follow-up section, one notification section, and staged accent apply', async () => { + const { onAccentColorChange } = renderView(); + + fireEvent.click(screen.getByRole('button', { name: /#15803D/i })); + const accentInput = (await screen.findAllByLabelText('Accent'))[1] as HTMLInputElement; + fireEvent.change(accentInput, { target: { value: '#2563eb' } }); + expect(onAccentColorChange).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: /^save$/i })); + expect(onAccentColorChange).toHaveBeenCalledWith('#2563eb'); + + fireEvent.click(screen.getByRole('tab', { name: /follow-ups/i })); + expect(await screen.findByText(/follow-up rules by scenario/i)).toBeInTheDocument(); + expect(screen.queryAllByText(/open reminders/i)).toHaveLength(0); + + fireEvent.click(screen.getByRole('tab', { name: /notifications/i })); + expect(screen.getByText(/notification settings/i)).toBeInTheDocument(); + expect(screen.getAllByText(/check system status/i).length).toBe(1); + expect(screen.getByLabelText(/email reminders for follow-ups/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/email alerts for ghosted jobs/i)).toBeInTheDocument(); +});