fix(auth): Google Sign-In audience mismatch + remove per-user accent color #26
@@ -5,6 +5,9 @@ AUTH_JWT_KEY=CHANGE_ME_LONG_RANDOM_SECRET
|
||||
AUTH_ADMIN_EMAIL=admin@example.com
|
||||
AUTH_ADMIN_PASSWORD=CHANGE_ME_STRONG_PASSWORD
|
||||
AUTH_GOOGLE_CLIENT_ID=CHANGE_ME_GOOGLE_CLIENT_ID
|
||||
# Optional: enables the "Continue with Microsoft" sign-in tab (separate from the
|
||||
# MICROSOFT_CLIENT_ID below, which is for Outlook mail linking, not sign-in).
|
||||
AUTH_MICROSOFT_CLIENT_ID=
|
||||
GOOGLE_GMAIL_CLIENT_SECRET=CHANGE_ME_GOOGLE_OAUTH_CLIENT_SECRET
|
||||
# Optional. If omitted, the backend uses https://<your-domain>/api/gmail/oauth/callback
|
||||
GOOGLE_GMAIL_REDIRECT_URI=
|
||||
|
||||
@@ -19,14 +19,14 @@
|
||||
},
|
||||
"Auth": {
|
||||
"Require": true,
|
||||
"AllowRegistration": false,
|
||||
"AllowRegistration": true,
|
||||
"JwtKey": "CHANGE_ME_DEV_ONLY_LONG_RANDOM_SECRET",
|
||||
"JwtIssuer": "JobTrackerApi",
|
||||
"JwtAudience": "job-tracker-ui",
|
||||
"JwtExpiresMinutes": 720,
|
||||
"AdminEmail": "admin@example.com",
|
||||
"AdminPassword": "CHANGE_ME_STRONG_DEV_PASSWORD",
|
||||
"GoogleClientId": "CHANGE_ME_GOOGLE_CLIENT_ID",
|
||||
"GoogleClientId": "723556162227-llqucvpog2esn1dutmtvuul1lv374or6.apps.googleusercontent.com",
|
||||
"MicrosoftClientId": "CHANGE_ME_MICROSOFT_CLIENT_ID"
|
||||
},
|
||||
"App": {
|
||||
|
||||
+3
-1
@@ -19,8 +19,9 @@ services:
|
||||
- Auth__JwtKey=${AUTH_JWT_KEY}
|
||||
- Auth__AdminEmail=${AUTH_ADMIN_EMAIL}
|
||||
- Auth__AdminPassword=${AUTH_ADMIN_PASSWORD}
|
||||
# Optional: allow Google ID-token bearer auth
|
||||
# Optional: allow Google / Microsoft ID-token bearer auth (sign-in, not mail access)
|
||||
- Auth__GoogleClientId=${AUTH_GOOGLE_CLIENT_ID}
|
||||
- Auth__MicrosoftClientId=${AUTH_MICROSOFT_CLIENT_ID}
|
||||
- Google__GmailClientSecret=${GOOGLE_GMAIL_CLIENT_SECRET}
|
||||
- Google__GmailRedirectUri=${GOOGLE_GMAIL_REDIRECT_URI}
|
||||
# Optional: Outlook / Microsoft 365 mail linking via Microsoft Graph
|
||||
@@ -64,6 +65,7 @@ services:
|
||||
shm_size: '1gb'
|
||||
args:
|
||||
- NEXT_PUBLIC_GOOGLE_CLIENT_ID=${AUTH_GOOGLE_CLIENT_ID}
|
||||
- NEXT_PUBLIC_MICROSOFT_CLIENT_ID=${AUTH_MICROSOFT_CLIENT_ID}
|
||||
# Optional override; default in production is `/api`
|
||||
- NEXT_PUBLIC_API_BASE_URL=${REACT_APP_API_BASE_URL}
|
||||
ports:
|
||||
|
||||
@@ -3,9 +3,11 @@ FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID
|
||||
ARG NEXT_PUBLIC_MICROSOFT_CLIENT_ID
|
||||
ARG NEXT_PUBLIC_API_BASE_URL
|
||||
|
||||
ENV NEXT_PUBLIC_GOOGLE_CLIENT_ID=$NEXT_PUBLIC_GOOGLE_CLIENT_ID
|
||||
ENV NEXT_PUBLIC_MICROSOFT_CLIENT_ID=$NEXT_PUBLIC_MICROSOFT_CLIENT_ID
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||
|
||||
COPY package*.json .npmrc ./
|
||||
|
||||
@@ -36,7 +36,7 @@ import { api } from "./api";
|
||||
import { resolveCaptureUrl } from "./captureUrl";
|
||||
import { clearAuthClientState, setAuthUserKey } from "./auth";
|
||||
import AppShell, { NavItem } from "./layout/AppShell";
|
||||
import { clearAccentColor, getAccentColor, getThemeModePref, setAccentColor, setThemeModePref, ThemeModePref } from "./themePrefs";
|
||||
import { getThemeModePref, setThemeModePref, ThemeModePref } from "./themePrefs";
|
||||
|
||||
const AddJobModal = lazy(() => import("./components/AddJobModal"));
|
||||
const KanbanBoard = lazy(() => import("./components/KanbanBoard"));
|
||||
@@ -104,7 +104,7 @@ function PageLoader() {
|
||||
return <Box sx={{ p: 4 }}><Typography variant="h6">Loading...</Typography></Box>;
|
||||
}
|
||||
|
||||
function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMode, onThemeModeChange, accentColor, onAccentColorChange, onResetAccentColor }: { jobPageSize: 15 | 20 | 25; setJobPageSize: (n: 15 | 20 | 25) => void; jobColumns: JobTableColumns; setJobColumns: (c: JobTableColumns) => void; themeMode: ThemeModePref; onThemeModeChange: (v: ThemeModePref) => void; accentColor: string; onAccentColorChange: (v: string) => void; onResetAccentColor: () => void; }) {
|
||||
function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMode, onThemeModeChange }: { jobPageSize: 15 | 20 | 25; setJobPageSize: (n: 15 | 20 | 25) => void; jobColumns: JobTableColumns; setJobColumns: (c: JobTableColumns) => void; themeMode: ThemeModePref; onThemeModeChange: (v: ThemeModePref) => void; }) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useI18n();
|
||||
@@ -297,7 +297,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
<Route path="/admin/users" element={<AdminUsersPage />} />
|
||||
<Route path="/admin/system" element={<AdminSystemPage />} />
|
||||
<Route path="/trash" element={<JobTable refreshToken={refreshToken} pageSize={jobPageSize} onPageSizeChange={setAndPersistPageSize} columns={jobColumns} onColumnsChange={setAndPersistColumns} mode="trash" />} />
|
||||
<Route path="/settings" element={<SettingsView pageSize={jobPageSize} onPageSizeChange={setAndPersistPageSize} columns={jobColumns} onColumnsChange={setAndPersistColumns} themeMode={themeMode} onThemeModeChange={onThemeModeChange} accentColor={accentColor} onAccentColorChange={onAccentColorChange} onResetAccentColor={onResetAccentColor} />} />
|
||||
<Route path="/settings" element={<SettingsView pageSize={jobPageSize} onPageSizeChange={setAndPersistPageSize} columns={jobColumns} onColumnsChange={setAndPersistColumns} themeMode={themeMode} onThemeModeChange={onThemeModeChange} />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
@@ -314,19 +314,16 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
export default function App() {
|
||||
const systemPrefersDark = useMediaQuery("(prefers-color-scheme: dark)", { defaultMatches: true, noSsr: true });
|
||||
const [themeMode, setThemeMode] = useState<ThemeModePref>(() => getThemeModePref());
|
||||
const [accentColor, setAccentColorState] = useState<string>(() => getAccentColor());
|
||||
const effectiveMode: "light" | "dark" = themeMode === "light" ? "light" : themeMode === "dark" ? "dark" : systemPrefersDark ? "dark" : "light";
|
||||
const theme = useMemo(() => getTheme(effectiveMode, accentColor), [effectiveMode, accentColor]);
|
||||
const theme = useMemo(() => getTheme(effectiveMode), [effectiveMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const sync = () => { setThemeMode(getThemeModePref()); setAccentColorState(getAccentColor()); };
|
||||
const sync = () => { setThemeMode(getThemeModePref()); };
|
||||
window.addEventListener("auth-changed", sync);
|
||||
return () => window.removeEventListener("auth-changed", sync);
|
||||
}, []);
|
||||
|
||||
const onThemeModeChange = (v: ThemeModePref) => { setThemeModePref(v); setThemeMode(v); };
|
||||
const onAccentColorChange = (v: string) => { setAccentColor(v); setAccentColorState(getAccentColor()); };
|
||||
const onResetAccentColor = () => { clearAccentColor(); setAccentColorState(getAccentColor()); };
|
||||
|
||||
const [jobPageSize, setJobPageSize] = useState<15 | 20 | 25>(() => {
|
||||
const raw = window.localStorage.getItem("jobPageSize");
|
||||
@@ -349,14 +346,14 @@ export default function App() {
|
||||
{ path: "/login", element: <LoginPage />, errorElement: <RouteErrorPage /> },
|
||||
{ path: "/forgot-password", element: <ForgotPasswordPage />, errorElement: <RouteErrorPage /> },
|
||||
{ path: "/reset-password", element: <ResetPasswordPage />, errorElement: <RouteErrorPage /> },
|
||||
{ path: "/*", element: <Shell jobPageSize={jobPageSize} setJobPageSize={setJobPageSize} jobColumns={jobColumns} setJobColumns={setJobColumns} themeMode={themeMode} onThemeModeChange={onThemeModeChange} accentColor={accentColor} onAccentColorChange={onAccentColorChange} onResetAccentColor={onResetAccentColor} />, errorElement: <RouteErrorPage /> },
|
||||
], { future: { v7_relativeSplatPath: true } }), [jobColumns, jobPageSize, themeMode, accentColor]);
|
||||
{ path: "/*", element: <Shell jobPageSize={jobPageSize} setJobPageSize={setJobPageSize} jobColumns={jobColumns} setJobColumns={setJobColumns} themeMode={themeMode} onThemeModeChange={onThemeModeChange} />, errorElement: <RouteErrorPage /> },
|
||||
], { future: { v7_relativeSplatPath: true } }), [jobColumns, jobPageSize, themeMode]);
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
<ConfirmProvider>
|
||||
<PromptProvider>
|
||||
<CssVarsProvider key={`${effectiveMode}:${accentColor}`} theme={theme as any} defaultMode={effectiveMode} disableTransitionOnChange>
|
||||
<CssVarsProvider key={effectiveMode} theme={theme as any} defaultMode={effectiveMode} disableTransitionOnChange>
|
||||
<CssBaseline enableColorScheme />
|
||||
<I18nProvider>
|
||||
<RouterProvider router={router} future={{ v7_startTransition: true }} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
Box,
|
||||
@@ -9,11 +9,9 @@ import {
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Popover,
|
||||
Select,
|
||||
Tab,
|
||||
Tabs,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
|
||||
@@ -21,12 +19,9 @@ import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { JobTableColumns } from "./JobTable";
|
||||
import ImportExportJobs from "./ImportExportJobs";
|
||||
import GoogleAuthCard from "./GoogleAuthCard";
|
||||
import EmailProviderConnections from "./EmailProviderConnections";
|
||||
import RulesSettingsCard from "./RulesSettingsCard";
|
||||
import BackupCard from "./BackupCard";
|
||||
import QuickCaptureCard from "./QuickCaptureCard";
|
||||
import AuthStatusCard from "./AuthStatusCard";
|
||||
import { ThemeModePref } from "../themePrefs";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
@@ -37,17 +32,23 @@ interface Props {
|
||||
onColumnsChange: (next: JobTableColumns) => void;
|
||||
themeMode: ThemeModePref;
|
||||
onThemeModeChange: (v: ThemeModePref) => void;
|
||||
accentColor: string;
|
||||
onAccentColorChange: (v: string) => void;
|
||||
onResetAccentColor: () => void;
|
||||
}
|
||||
|
||||
function TabPanel({ value, index, children }: { value: number; index: number; children: React.ReactNode }) {
|
||||
if (value !== index) return null;
|
||||
return <Box sx={{ mt: 2 }}>{children}</Box>;
|
||||
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 ACCENTS = ["#6366f1", "#22d3ee", "#2563eb", "#8b5cf6", "#15803d", "#16a34a", "#0f766e", "#f97316"];
|
||||
const NOTIFICATION_PREFS_KEY = "settings.notificationPrefs";
|
||||
|
||||
type NotificationPrefs = {
|
||||
@@ -88,43 +89,19 @@ export default function SettingsView({
|
||||
onColumnsChange,
|
||||
themeMode,
|
||||
onThemeModeChange,
|
||||
accentColor,
|
||||
onAccentColorChange,
|
||||
onResetAccentColor,
|
||||
}: Props) {
|
||||
const navigate = useNavigate();
|
||||
const [tab, setTab] = useState(0);
|
||||
const { language, setLanguage, t } = useI18n();
|
||||
const [accentAnchor, setAccentAnchor] = useState<HTMLElement | null>(null);
|
||||
const [accentDraft, setAccentDraft] = useState(accentColor);
|
||||
const [notificationPrefs, setNotificationPrefs] = useState<NotificationPrefs>(() => 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 (
|
||||
<Paper sx={{ mt: 0, p: 2 }}>
|
||||
<Typography variant="h5" sx={{ mb: 1, fontWeight: 900 }}>
|
||||
<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 }}>
|
||||
@@ -135,16 +112,14 @@ export default function SettingsView({
|
||||
<Tab label={t("settingsTabGeneral")} />
|
||||
<Tab label={t("settingsTabFollowUps")} />
|
||||
<Tab label={t("settingsTabNotifications")} />
|
||||
<Tab label={t("settingsTabAccount")} />
|
||||
<Tab label={t("settingsTabBackup")} />
|
||||
</Tabs>
|
||||
|
||||
<TabPanel value={tab} index={0}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography sx={{ fontWeight: 950, mb: 1 }}>{t("settingsAppearance")}</Typography>
|
||||
|
||||
<FormControl fullWidth sx={{ mb: 2 }}>
|
||||
<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"
|
||||
@@ -157,85 +132,10 @@ export default function SettingsView({
|
||||
<MenuItem value="light">{t("settingsThemeLight")}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</SectionCard>
|
||||
|
||||
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ mb: 0.75, display: "block" }}>{t("settingsAccent")}</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={(e) => setAccentAnchor(e.currentTarget)}
|
||||
sx={{ gap: 1.25, justifyContent: "flex-start", minWidth: 180 }}
|
||||
>
|
||||
<Box sx={{ width: 20, height: 20, borderRadius: 999, bgcolor: accentOk ? accentColor : "#15803d", border: "1px solid", borderColor: "divider" }} />
|
||||
{accentOk ? accentColor.toUpperCase() : "#15803D"}
|
||||
</Button>
|
||||
</Box>
|
||||
<Button variant="outlined" onClick={resetAccent}>
|
||||
{t("settingsReset")}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Popover
|
||||
open={Boolean(accentAnchor)}
|
||||
anchorEl={accentAnchor}
|
||||
onClose={() => setAccentAnchor(null)}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
||||
>
|
||||
<Box sx={{ p: 2, width: 280, display: "grid", gap: 1.5 }}>
|
||||
<Typography sx={{ fontWeight: 900 }}>{t("settingsAccent")}</Typography>
|
||||
<input
|
||||
aria-label={t("settingsAccent")}
|
||||
type="color"
|
||||
value={accentDraftOk ? accentDraft : "#15803d"}
|
||||
onChange={(e) => setAccentDraft(e.target.value)}
|
||||
style={{ width: "100%", height: 52, border: "none", background: "transparent", padding: 0, cursor: "pointer" }}
|
||||
/>
|
||||
<TextField
|
||||
label={t("settingsAccent")}
|
||||
value={accentDraft}
|
||||
onChange={(e) => setAccentDraft(e.target.value)}
|
||||
error={!accentDraftOk}
|
||||
helperText={accentDraftOk ? t("settingsAccentHelp") : t("settingsAccentInvalid")}
|
||||
fullWidth
|
||||
/>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
{ACCENTS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setAccentDraft(c)}
|
||||
title={c}
|
||||
aria-label={`${t("settingsAccent")} ${c}`}
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 999,
|
||||
border: c.toLowerCase() === accentDraft.toLowerCase() ? "2px solid rgba(15,23,42,0.9)" : "1px solid rgba(148,163,184,0.35)",
|
||||
background: c,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 1 }}>
|
||||
<Button variant="text" onClick={() => setAccentAnchor(null)}>{t("cancel")}</Button>
|
||||
<Button variant="contained" onClick={applyAccent} disabled={!accentDraftOk}>{t("save")}</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Popover>
|
||||
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 1 }}>
|
||||
{t("settingsSavedPerUser")}
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography sx={{ fontWeight: 950, mb: 1 }}>{t("settingsLanguageTitle")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 2 }}>
|
||||
{t("settingsLanguageBody")}
|
||||
</Typography>
|
||||
|
||||
<FormControl fullWidth sx={{ mb: 2 }}>
|
||||
<SectionCard title={t("settingsLanguageTitle")} subtitle={t("settingsLanguageBody")}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="language-label">{t("settingsPreferredLanguage")}</InputLabel>
|
||||
<Select
|
||||
labelId="language-label"
|
||||
@@ -247,18 +147,13 @@ export default function SettingsView({
|
||||
<MenuItem value="no">{t("settingsNorwegian")}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</SectionCard>
|
||||
</Box>
|
||||
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{t("settingsMorePagesSoon")}
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 2, gridColumn: { xs: "1 / -1", md: "1 / -1" } }}>
|
||||
<Typography sx={{ fontWeight: 950, mb: 1 }}>{t("settingsJobs")}</Typography>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 3, flexWrap: "wrap" }}>
|
||||
<SectionCard title={t("settingsJobs")}>
|
||||
<Box sx={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
|
||||
<Box sx={{ minWidth: 240 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 700 }}>
|
||||
{t("settingsPagination")}
|
||||
</Typography>
|
||||
<FormControl fullWidth>
|
||||
@@ -277,7 +172,7 @@ export default function SettingsView({
|
||||
</Box>
|
||||
|
||||
<Box sx={{ minWidth: 240 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 700 }}>
|
||||
{t("settingsColumns")}
|
||||
</Typography>
|
||||
{(
|
||||
@@ -297,8 +192,10 @@ export default function SettingsView({
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<ImportExportJobs />
|
||||
</Paper>
|
||||
</Box>
|
||||
</SectionCard>
|
||||
|
||||
<QuickCaptureCard />
|
||||
</Box>
|
||||
@@ -309,9 +206,7 @@ export default function SettingsView({
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tab} index={2}>
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography sx={{ fontWeight: 950, mb: 0.5 }}>{t("settingsNotificationsTitle")}</Typography>
|
||||
<Typography sx={{ color: "text.secondary", mb: 2 }}>{t("settingsNotificationsBody")}</Typography>
|
||||
<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 }))} />}
|
||||
@@ -333,18 +228,10 @@ export default function SettingsView({
|
||||
<Button variant="outlined" onClick={() => navigate("/reminders")}>{t("settingsOpenReminderInbox")}</Button>
|
||||
<Button variant="text" onClick={() => navigate("/admin/system")}>{t("settingsCheckSystemStatus")}</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</SectionCard>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tab} index={3}>
|
||||
<AuthStatusCard />
|
||||
<GoogleAuthCard />
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<EmailProviderConnections />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tab} index={4}>
|
||||
<BackupCard />
|
||||
</TabPanel>
|
||||
</Paper>
|
||||
|
||||
@@ -128,22 +128,17 @@ export const translations = {
|
||||
settingsTabGeneral: "General",
|
||||
settingsTabFollowUps: "Follow-ups",
|
||||
settingsTabNotifications: "Notifications",
|
||||
settingsTabAccount: "Account",
|
||||
settingsTabBackup: "Backup",
|
||||
settingsAppearance: "Appearance",
|
||||
settingsTheme: "Theme",
|
||||
settingsThemeSystem: "System",
|
||||
settingsThemeDark: "Dark",
|
||||
settingsThemeLight: "Light",
|
||||
settingsAccent: "Accent",
|
||||
settingsReset: "Reset",
|
||||
settingsSavedPerUser: "Saved per user on this browser.",
|
||||
settingsLanguageTitle: "Language and localization",
|
||||
settingsLanguageBody: "Set your preferred app language. This is also the language used when deciding whether imported job descriptions should show translated text.",
|
||||
settingsPreferredLanguage: "Preferred language",
|
||||
settingsEnglish: "English",
|
||||
settingsNorwegian: "Norwegian Bokmål",
|
||||
settingsMorePagesSoon: "More pages will be moved onto this translation system as the UI cleanup continues.",
|
||||
settingsJobs: "Jobs",
|
||||
settingsPagination: "Pagination",
|
||||
settingsRowsPerPage: "Rows per page",
|
||||
@@ -167,8 +162,6 @@ export const translations = {
|
||||
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.",
|
||||
@@ -629,7 +622,7 @@ export const translations = {
|
||||
googleUnlinked: "Google account unlinked.",
|
||||
googleUnlinkFailed: "Failed to unlink Google account.",
|
||||
microsoftAccountTitle: "Microsoft account",
|
||||
microsoftSetupHint: "Set `REACT_APP_MICROSOFT_CLIENT_ID` in your UI environment to enable Microsoft sign-in and account linking.",
|
||||
microsoftSetupHint: "Set `NEXT_PUBLIC_MICROSOFT_CLIENT_ID` in your UI environment to enable Microsoft sign-in and account linking.",
|
||||
microsoftLinked: "Linked",
|
||||
microsoftAvailableToLink: "Available to link",
|
||||
microsoftLinkedDate: "Linked {date}",
|
||||
@@ -1093,22 +1086,17 @@ export const translations = {
|
||||
settingsTabGeneral: "Generelt",
|
||||
settingsTabFollowUps: "Oppfølging",
|
||||
settingsTabNotifications: "Varsler",
|
||||
settingsTabAccount: "Konto",
|
||||
settingsTabBackup: "Sikkerhetskopi",
|
||||
settingsAppearance: "Utseende",
|
||||
settingsTheme: "Tema",
|
||||
settingsThemeSystem: "System",
|
||||
settingsThemeDark: "Mørkt",
|
||||
settingsThemeLight: "Lyst",
|
||||
settingsAccent: "Aksent",
|
||||
settingsReset: "Tilbakestill",
|
||||
settingsSavedPerUser: "Lagres per bruker i denne nettleseren.",
|
||||
settingsLanguageTitle: "Språk og lokalisering",
|
||||
settingsLanguageBody: "Velg foretrukket språk i appen. Dette brukes også når appen avgjør om importerte stillingsbeskrivelser skal vise oversatt tekst.",
|
||||
settingsPreferredLanguage: "Foretrukket språk",
|
||||
settingsEnglish: "Engelsk",
|
||||
settingsNorwegian: "Norsk Bokmål",
|
||||
settingsMorePagesSoon: "Flere sider flyttes til dette oversettelsessystemet etter hvert som UI-oppryddingen fortsetter.",
|
||||
settingsJobs: "Jobber",
|
||||
settingsPagination: "Paginering",
|
||||
settingsRowsPerPage: "Rader per side",
|
||||
@@ -1132,8 +1120,6 @@ export const translations = {
|
||||
settingsNotificationsFollowUpReminders: "E-postpåminnelser for oppfølginger",
|
||||
settingsNotificationsGhostedJobs: "E-postvarsler for ghostede jobber",
|
||||
settingsNotificationsInAppReminders: "Fremhev påminnelser i appen",
|
||||
settingsAccentHelp: "Dra i fargevelgeren, og lagre når den ser riktig ut.",
|
||||
settingsAccentInvalid: "Bruk en full hex-farge som #15803D.",
|
||||
settingsCheckSystemStatus: "Sjekk systemstatus",
|
||||
profileTitle: "Profil",
|
||||
profileHeadlinePlaceholder: "Legg til en kort overskrift for å gjøre kontovisningen mer personlig.",
|
||||
@@ -1594,7 +1580,7 @@ export const translations = {
|
||||
googleUnlinked: "Google-konto koblet fra.",
|
||||
googleUnlinkFailed: "Kunne ikke koble fra Google-kontoen.",
|
||||
microsoftAccountTitle: "Microsoft-konto",
|
||||
microsoftSetupHint: "Sett `REACT_APP_MICROSOFT_CLIENT_ID` i UI-miljøet ditt for å aktivere Microsoft-innlogging og kontokobling.",
|
||||
microsoftSetupHint: "Sett `NEXT_PUBLIC_MICROSOFT_CLIENT_ID` i UI-miljøet ditt for å aktivere Microsoft-innlogging og kontokobling.",
|
||||
microsoftLinked: "Koblet",
|
||||
microsoftAvailableToLink: "Tilgjengelig for kobling",
|
||||
microsoftLinkedDate: "Koblet {date}",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
import SettingsView from './components/SettingsView';
|
||||
@@ -21,16 +21,12 @@ jest.mock('./api', () => ({
|
||||
}));
|
||||
|
||||
jest.mock('./components/ImportExportJobs', () => () => <div>Import Export Stub</div>);
|
||||
jest.mock('./components/GoogleAuthCard', () => () => <div>Google Auth Stub</div>);
|
||||
jest.mock('./components/BackupCard', () => () => <div>Backup Stub</div>);
|
||||
jest.mock('./components/AuthStatusCard', () => () => <div>Auth Status Stub</div>);
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
function renderView(onAccentColorChange = jest.fn()) {
|
||||
return {
|
||||
onAccentColorChange,
|
||||
...render(
|
||||
function renderView() {
|
||||
return render(
|
||||
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<ToastProvider>
|
||||
<I18nProvider>
|
||||
@@ -41,15 +37,11 @@ function renderView(onAccentColorChange = jest.fn()) {
|
||||
onColumnsChange={jest.fn()}
|
||||
themeMode="dark"
|
||||
onThemeModeChange={jest.fn()}
|
||||
accentColor="#15803d"
|
||||
onAccentColorChange={onAccentColorChange}
|
||||
onResetAccentColor={jest.fn()}
|
||||
/>
|
||||
</I18nProvider>
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
),
|
||||
};
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -76,15 +68,10 @@ afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('settings view uses one follow-up section, one notification section, and staged accent apply', async () => {
|
||||
const { onAccentColorChange } = renderView();
|
||||
test('settings view has no accent picker and uses one follow-up section, one notification section', async () => {
|
||||
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');
|
||||
expect(screen.queryByText(/accent/i)).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: /follow-ups/i }));
|
||||
expect(await screen.findByText(/follow-up rules by scenario/i)).toBeInTheDocument();
|
||||
|
||||
@@ -2,6 +2,10 @@ import { alpha, createTheme, darken, lighten } from "@mui/material/styles";
|
||||
|
||||
type PaletteLike = Record<string, any>;
|
||||
|
||||
// Single global brand accent -- matches the dark sidebar/landing page indigo used throughout
|
||||
// the app. Not user-configurable; see jobbjakt-nextjs-migration memory / UI rework notes.
|
||||
const ACCENT = "#6366F1";
|
||||
|
||||
function buildPrimary(main: string) {
|
||||
return {
|
||||
lighter: lighten(main, 0.82),
|
||||
@@ -12,7 +16,7 @@ function buildPrimary(main: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildLightPalette(accentColor: string): PaletteLike {
|
||||
function buildLightPalette(): PaletteLike {
|
||||
const textPrimary = "#1B1B1F";
|
||||
const textSecondary = "#46464F";
|
||||
|
||||
@@ -24,7 +28,7 @@ function buildLightPalette(accentColor: string): PaletteLike {
|
||||
const disabledBackground = "#E4E1E6";
|
||||
|
||||
return {
|
||||
primary: buildPrimary(accentColor || "#6366F1"),
|
||||
primary: buildPrimary(ACCENT),
|
||||
secondary: {
|
||||
lighter: "#E0E0FF",
|
||||
light: "#C3C4E4",
|
||||
@@ -82,14 +86,14 @@ function buildLightPalette(accentColor: string): PaletteLike {
|
||||
// from the product mockups; cards/inputs (paper) sit above it.
|
||||
background: { default: "#F4F6FB", paper: background },
|
||||
action: {
|
||||
hover: alpha(accentColor || "#6366F1", 0.05),
|
||||
hover: alpha(ACCENT, 0.05),
|
||||
disabled: alpha(disabled, 0.6),
|
||||
disabledBackground: alpha(disabledBackground, 0.9),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildDarkPalette(accentColor: string): PaletteLike {
|
||||
function buildDarkPalette(): PaletteLike {
|
||||
const bg = "#0B0B0E";
|
||||
const paper = "#111116";
|
||||
const divider = alpha("#FFFFFF", 0.10);
|
||||
@@ -101,7 +105,7 @@ function buildDarkPalette(accentColor: string): PaletteLike {
|
||||
const disabledBackground = alpha("#FFFFFF", 0.08);
|
||||
|
||||
return {
|
||||
primary: buildPrimary(accentColor || "#6366F1"),
|
||||
primary: buildPrimary(ACCENT),
|
||||
secondary: {
|
||||
lighter: alpha(secondaryMain, 0.22),
|
||||
light: alpha(secondaryMain, 0.14),
|
||||
@@ -157,7 +161,7 @@ function buildDarkPalette(accentColor: string): PaletteLike {
|
||||
divider,
|
||||
background: { default: bg, paper },
|
||||
action: {
|
||||
hover: alpha(accentColor || "#6366F1", 0.16),
|
||||
hover: alpha(ACCENT, 0.16),
|
||||
disabled: alpha("#FFFFFF", 0.5),
|
||||
disabledBackground,
|
||||
},
|
||||
@@ -196,9 +200,9 @@ function buildTypography() {
|
||||
};
|
||||
}
|
||||
|
||||
export const getTheme = (_mode: "light" | "dark", accentColor: string) => {
|
||||
const lightPalette = buildLightPalette(accentColor);
|
||||
const darkPalette = buildDarkPalette(accentColor);
|
||||
export const getTheme = (_mode: "light" | "dark") => {
|
||||
const lightPalette = buildLightPalette();
|
||||
const darkPalette = buildDarkPalette();
|
||||
|
||||
const theme = createTheme({
|
||||
breakpoints: {
|
||||
|
||||
@@ -19,17 +19,3 @@ export function getThemeModePref(): ThemeModePref {
|
||||
export function setThemeModePref(v: ThemeModePref) {
|
||||
window.localStorage.setItem(k("themeMode"), v);
|
||||
}
|
||||
|
||||
export function getAccentColor(): string {
|
||||
const raw = window.localStorage.getItem(k("accentColor"));
|
||||
if (raw && /^#[0-9a-fA-F]{6}$/.test(raw)) return raw;
|
||||
return "#6366f1";
|
||||
}
|
||||
|
||||
export function setAccentColor(v: string) {
|
||||
if (v && /^#[0-9a-fA-F]{6}$/.test(v)) window.localStorage.setItem(k("accentColor"), v);
|
||||
}
|
||||
|
||||
export function clearAccentColor() {
|
||||
window.localStorage.removeItem(k("accentColor"));
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import ZoomInOutlinedIcon from "@mui/icons-material/ZoomInOutlined";
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import GoogleAuthCard from "../components/GoogleAuthCard";
|
||||
import MicrosoftAuthCard from "../components/MicrosoftAuthCard";
|
||||
import AuthStatusCard from "../components/AuthStatusCard";
|
||||
import EmailProviderConnections from "../components/EmailProviderConnections";
|
||||
import CropImageDialog from "../components/CropImageDialog";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
@@ -562,8 +564,12 @@ export default function ProfilePage() {
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<AuthStatusCard />
|
||||
<GoogleAuthCard />
|
||||
<MicrosoftAuthCard />
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<EmailProviderConnections />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 3, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
||||
<Box sx={{ gridColumn: "1 / -1" }}>
|
||||
|
||||
Reference in New Issue
Block a user