33d899c243
Root cause of "Google authentication failed": appsettings.Development.json had Auth:GoogleClientId set to the literal placeholder "CHANGE_ME_GOOGLE_CLIENT_ID" while the frontend's .env.development had a real (already-public, already-committed) client ID -- every Google ID token's audience check failed against the backend's placeholder. Fixed by setting the same real client ID on both sides (a client ID is a public identifier, not a secret, safe to commit -- unlike a client secret). Also enabled Auth:AllowRegistration in dev so the existing Google-first self-serve-signup path (auto-create on unmatched verified email, auto-link on matching verified email -- built during Wave 7) is actually exercisable locally. Wired the previously-missing Auth__MicrosoftClientId / NEXT_PUBLIC_MICROSOFT_CLIENT_ID into docker-compose.yml/.env.example (distinct from the existing MICROSOFT_CLIENT_ID used for Outlook mail linking) -- Microsoft sign-in was never deployable, a leftover gap from when it was built. Fixed a stale env-var name in the Microsoft setup hint copy (still said REACT_APP_*, predates the Next.js migration). Removed the per-user accent color picker entirely: it was purely client-side (localStorage + theme.ts), never touched the backend/DB. theme.ts now hardcodes a single ACCENT constant; themePrefs.ts drops get/set/clearAccentColor; App.tsx and SettingsView.tsx drop the accentColor prop threading. Dead accent-related i18n keys removed from both locales. Consolidated Settings' "Account" tab (duplicated GoogleAuthCard, which already lives on the Profile page) into Profile: moved AuthStatusCard and EmailProviderConnections there alongside the existing Google/ Microsoft auth cards, so identity/account-linking lives in one place. Settings drops from 5 tabs to 4 and its General tab uses a consistent SectionCard layout instead of ad-hoc per-card styling. Verified: dotnet build/test (177/177) and npm build/test (57/57) both green; confirmed live against a running dev server that /auth/config now reports googleEnabled with the corrected client ID, Settings has no accent controls, and Profile shows the consolidated auth section.
240 lines
8.7 KiB
TypeScript
240 lines
8.7 KiB
TypeScript
import React, { useEffect, useState } from "react";
|
|
|
|
import {
|
|
Box,
|
|
Button,
|
|
Checkbox,
|
|
FormControl,
|
|
FormControlLabel,
|
|
InputLabel,
|
|
MenuItem,
|
|
Paper,
|
|
Select,
|
|
Tab,
|
|
Tabs,
|
|
Typography,
|
|
} from "@mui/material";
|
|
|
|
import { useNavigate } from "react-router-dom";
|
|
|
|
import { JobTableColumns } from "./JobTable";
|
|
import ImportExportJobs from "./ImportExportJobs";
|
|
import RulesSettingsCard from "./RulesSettingsCard";
|
|
import BackupCard from "./BackupCard";
|
|
import QuickCaptureCard from "./QuickCaptureCard";
|
|
import { ThemeModePref } from "../themePrefs";
|
|
import { useI18n } from "../i18n/I18nProvider";
|
|
|
|
interface Props {
|
|
pageSize: 15 | 20 | 25;
|
|
onPageSizeChange: (n: 15 | 20 | 25) => void;
|
|
columns: JobTableColumns;
|
|
onColumnsChange: (next: JobTableColumns) => void;
|
|
themeMode: ThemeModePref;
|
|
onThemeModeChange: (v: ThemeModePref) => void;
|
|
}
|
|
|
|
function TabPanel({ value, index, children }: { value: number; index: number; children: React.ReactNode }) {
|
|
if (value !== index) return null;
|
|
return <Box sx={{ mt: 2.5 }}>{children}</Box>;
|
|
}
|
|
|
|
function SectionCard({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
|
|
return (
|
|
<Paper sx={{ p: 2.5 }}>
|
|
<Typography variant="overline" sx={{ color: "text.secondary", fontWeight: 800 }}>{title}</Typography>
|
|
{subtitle ? <Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25, mb: 1.5 }}>{subtitle}</Typography> : <Box sx={{ mb: 1.5 }} />}
|
|
{children}
|
|
</Paper>
|
|
);
|
|
}
|
|
|
|
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,
|
|
onPageSizeChange,
|
|
columns,
|
|
onColumnsChange,
|
|
themeMode,
|
|
onThemeModeChange,
|
|
}: Props) {
|
|
const navigate = useNavigate();
|
|
const [tab, setTab] = useState(0);
|
|
const { language, setLanguage, t } = useI18n();
|
|
const [notificationPrefs, setNotificationPrefs] = useState<NotificationPrefs>(() => loadNotificationPrefs());
|
|
|
|
useEffect(() => {
|
|
window.localStorage.setItem(NOTIFICATION_PREFS_KEY, JSON.stringify(notificationPrefs));
|
|
}, [notificationPrefs]);
|
|
|
|
return (
|
|
<Paper sx={{ mt: 0, p: 2.5 }}>
|
|
<Typography variant="h5" sx={{ mb: 0.5, fontWeight: 900 }}>
|
|
{t("settingsTitle")}
|
|
</Typography>
|
|
<Typography sx={{ color: "text.secondary", mb: 2 }}>
|
|
{t("settingsSubtitle")}
|
|
</Typography>
|
|
|
|
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 1 }}>
|
|
<Tab label={t("settingsTabGeneral")} />
|
|
<Tab label={t("settingsTabFollowUps")} />
|
|
<Tab label={t("settingsTabNotifications")} />
|
|
<Tab label={t("settingsTabBackup")} />
|
|
</Tabs>
|
|
|
|
<TabPanel value={tab} index={0}>
|
|
<Box sx={{ display: "grid", gap: 2.5 }}>
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2.5 }}>
|
|
<SectionCard title={t("settingsAppearance")}>
|
|
<FormControl fullWidth>
|
|
<InputLabel id="theme-mode-label">{t("settingsTheme")}</InputLabel>
|
|
<Select
|
|
labelId="theme-mode-label"
|
|
value={themeMode}
|
|
label={t("settingsTheme")}
|
|
onChange={(e) => onThemeModeChange(e.target.value as ThemeModePref)}
|
|
>
|
|
<MenuItem value="system">{t("settingsThemeSystem")}</MenuItem>
|
|
<MenuItem value="dark">{t("settingsThemeDark")}</MenuItem>
|
|
<MenuItem value="light">{t("settingsThemeLight")}</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
</SectionCard>
|
|
|
|
<SectionCard title={t("settingsLanguageTitle")} subtitle={t("settingsLanguageBody")}>
|
|
<FormControl fullWidth>
|
|
<InputLabel id="language-label">{t("settingsPreferredLanguage")}</InputLabel>
|
|
<Select
|
|
labelId="language-label"
|
|
value={language}
|
|
label={t("settingsPreferredLanguage")}
|
|
onChange={(e) => setLanguage(e.target.value as "en" | "no")}
|
|
>
|
|
<MenuItem value="en">{t("settingsEnglish")}</MenuItem>
|
|
<MenuItem value="no">{t("settingsNorwegian")}</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
</SectionCard>
|
|
</Box>
|
|
|
|
<SectionCard title={t("settingsJobs")}>
|
|
<Box sx={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
|
|
<Box sx={{ minWidth: 240 }}>
|
|
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 700 }}>
|
|
{t("settingsPagination")}
|
|
</Typography>
|
|
<FormControl fullWidth>
|
|
<InputLabel id="page-size-label">{t("settingsRowsPerPage")}</InputLabel>
|
|
<Select
|
|
labelId="page-size-label"
|
|
value={pageSize}
|
|
label={t("settingsRowsPerPage")}
|
|
onChange={(e) => onPageSizeChange(e.target.value as 15 | 20 | 25)}
|
|
>
|
|
<MenuItem value={15}>15</MenuItem>
|
|
<MenuItem value={20}>20</MenuItem>
|
|
<MenuItem value={25}>25</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
</Box>
|
|
|
|
<Box sx={{ minWidth: 240 }}>
|
|
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 700 }}>
|
|
{t("settingsColumns")}
|
|
</Typography>
|
|
{(
|
|
[
|
|
["status", t("settingsColumnStatus")],
|
|
["dateApplied", t("settingsColumnDateApplied")],
|
|
["daysSince", t("settingsColumnDays")],
|
|
["jobUrl", t("settingsColumnJobUrl")],
|
|
] as const
|
|
).map(([key, label]) => (
|
|
<FormControlLabel
|
|
key={key}
|
|
control={<Checkbox checked={columns[key]} onChange={() => onColumnsChange({ ...columns, [key]: !columns[key] })} />}
|
|
label={label}
|
|
/>
|
|
))}
|
|
</Box>
|
|
</Box>
|
|
|
|
<Box sx={{ mt: 2 }}>
|
|
<ImportExportJobs />
|
|
</Box>
|
|
</SectionCard>
|
|
|
|
<QuickCaptureCard />
|
|
</Box>
|
|
</TabPanel>
|
|
|
|
<TabPanel value={tab} index={1}>
|
|
<RulesSettingsCard />
|
|
</TabPanel>
|
|
|
|
<TabPanel value={tab} index={2}>
|
|
<SectionCard title={t("settingsNotificationsTitle")} subtitle={t("settingsNotificationsBody")}>
|
|
<Box sx={{ display: "grid", gap: 1 }}>
|
|
<FormControlLabel
|
|
control={<Checkbox checked={notificationPrefs.emailFollowUpReminders} onChange={(e) => setNotificationPrefs((prev) => ({ ...prev, emailFollowUpReminders: e.target.checked }))} />}
|
|
label={t("settingsNotificationsFollowUpReminders")}
|
|
/>
|
|
<FormControlLabel
|
|
control={<Checkbox checked={notificationPrefs.emailGhostedJobAlerts} onChange={(e) => setNotificationPrefs((prev) => ({ ...prev, emailGhostedJobAlerts: e.target.checked }))} />}
|
|
label={t("settingsNotificationsGhostedJobs")}
|
|
/>
|
|
<FormControlLabel
|
|
control={<Checkbox checked={notificationPrefs.inAppReminderHighlights} onChange={(e) => setNotificationPrefs((prev) => ({ ...prev, inAppReminderHighlights: e.target.checked }))} />}
|
|
label={t("settingsNotificationsInAppReminders")}
|
|
/>
|
|
</Box>
|
|
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 1.5 }}>
|
|
{t("settingsNotificationsDelivery")}
|
|
</Typography>
|
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 1.5 }}>
|
|
<Button variant="outlined" onClick={() => navigate("/reminders")}>{t("settingsOpenReminderInbox")}</Button>
|
|
<Button variant="text" onClick={() => navigate("/admin/system")}>{t("settingsCheckSystemStatus")}</Button>
|
|
</Box>
|
|
</SectionCard>
|
|
</TabPanel>
|
|
|
|
<TabPanel value={tab} index={3}>
|
|
<BackupCard />
|
|
</TabPanel>
|
|
</Paper>
|
|
);
|
|
}
|