fix(auth): Google Sign-In audience mismatch + remove per-user accent color
CI and Deploy / test (pull_request) Successful in 2m8s
CI and Deploy / deploy (pull_request) Has been skipped

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.
This commit is contained in:
cesnimda
2026-07-12 02:43:10 +02:00
parent 86cdafb3ef
commit 33d899c243
11 changed files with 115 additions and 255 deletions
+3
View File
@@ -5,6 +5,9 @@ AUTH_JWT_KEY=CHANGE_ME_LONG_RANDOM_SECRET
AUTH_ADMIN_EMAIL=admin@example.com AUTH_ADMIN_EMAIL=admin@example.com
AUTH_ADMIN_PASSWORD=CHANGE_ME_STRONG_PASSWORD AUTH_ADMIN_PASSWORD=CHANGE_ME_STRONG_PASSWORD
AUTH_GOOGLE_CLIENT_ID=CHANGE_ME_GOOGLE_CLIENT_ID 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 GOOGLE_GMAIL_CLIENT_SECRET=CHANGE_ME_GOOGLE_OAUTH_CLIENT_SECRET
# Optional. If omitted, the backend uses https://<your-domain>/api/gmail/oauth/callback # Optional. If omitted, the backend uses https://<your-domain>/api/gmail/oauth/callback
GOOGLE_GMAIL_REDIRECT_URI= GOOGLE_GMAIL_REDIRECT_URI=
+2 -2
View File
@@ -19,14 +19,14 @@
}, },
"Auth": { "Auth": {
"Require": true, "Require": true,
"AllowRegistration": false, "AllowRegistration": true,
"JwtKey": "CHANGE_ME_DEV_ONLY_LONG_RANDOM_SECRET", "JwtKey": "CHANGE_ME_DEV_ONLY_LONG_RANDOM_SECRET",
"JwtIssuer": "JobTrackerApi", "JwtIssuer": "JobTrackerApi",
"JwtAudience": "job-tracker-ui", "JwtAudience": "job-tracker-ui",
"JwtExpiresMinutes": 720, "JwtExpiresMinutes": 720,
"AdminEmail": "admin@example.com", "AdminEmail": "admin@example.com",
"AdminPassword": "CHANGE_ME_STRONG_DEV_PASSWORD", "AdminPassword": "CHANGE_ME_STRONG_DEV_PASSWORD",
"GoogleClientId": "CHANGE_ME_GOOGLE_CLIENT_ID", "GoogleClientId": "723556162227-llqucvpog2esn1dutmtvuul1lv374or6.apps.googleusercontent.com",
"MicrosoftClientId": "CHANGE_ME_MICROSOFT_CLIENT_ID" "MicrosoftClientId": "CHANGE_ME_MICROSOFT_CLIENT_ID"
}, },
"App": { "App": {
+3 -1
View File
@@ -19,8 +19,9 @@ services:
- Auth__JwtKey=${AUTH_JWT_KEY} - Auth__JwtKey=${AUTH_JWT_KEY}
- Auth__AdminEmail=${AUTH_ADMIN_EMAIL} - Auth__AdminEmail=${AUTH_ADMIN_EMAIL}
- Auth__AdminPassword=${AUTH_ADMIN_PASSWORD} - 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__GoogleClientId=${AUTH_GOOGLE_CLIENT_ID}
- Auth__MicrosoftClientId=${AUTH_MICROSOFT_CLIENT_ID}
- Google__GmailClientSecret=${GOOGLE_GMAIL_CLIENT_SECRET} - Google__GmailClientSecret=${GOOGLE_GMAIL_CLIENT_SECRET}
- Google__GmailRedirectUri=${GOOGLE_GMAIL_REDIRECT_URI} - Google__GmailRedirectUri=${GOOGLE_GMAIL_REDIRECT_URI}
# Optional: Outlook / Microsoft 365 mail linking via Microsoft Graph # Optional: Outlook / Microsoft 365 mail linking via Microsoft Graph
@@ -64,6 +65,7 @@ services:
shm_size: '1gb' shm_size: '1gb'
args: args:
- NEXT_PUBLIC_GOOGLE_CLIENT_ID=${AUTH_GOOGLE_CLIENT_ID} - 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` # Optional override; default in production is `/api`
- NEXT_PUBLIC_API_BASE_URL=${REACT_APP_API_BASE_URL} - NEXT_PUBLIC_API_BASE_URL=${REACT_APP_API_BASE_URL}
ports: ports:
+2
View File
@@ -3,9 +3,11 @@ FROM node:20-alpine AS build
WORKDIR /app WORKDIR /app
ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID
ARG NEXT_PUBLIC_MICROSOFT_CLIENT_ID
ARG NEXT_PUBLIC_API_BASE_URL ARG NEXT_PUBLIC_API_BASE_URL
ENV NEXT_PUBLIC_GOOGLE_CLIENT_ID=$NEXT_PUBLIC_GOOGLE_CLIENT_ID 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 ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
COPY package*.json .npmrc ./ COPY package*.json .npmrc ./
+8 -11
View File
@@ -36,7 +36,7 @@ import { api } from "./api";
import { resolveCaptureUrl } from "./captureUrl"; import { resolveCaptureUrl } from "./captureUrl";
import { clearAuthClientState, setAuthUserKey } from "./auth"; import { clearAuthClientState, setAuthUserKey } from "./auth";
import AppShell, { NavItem } from "./layout/AppShell"; 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 AddJobModal = lazy(() => import("./components/AddJobModal"));
const KanbanBoard = lazy(() => import("./components/KanbanBoard")); const KanbanBoard = lazy(() => import("./components/KanbanBoard"));
@@ -104,7 +104,7 @@ function PageLoader() {
return <Box sx={{ p: 4 }}><Typography variant="h6">Loading...</Typography></Box>; 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 location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useI18n(); const { t } = useI18n();
@@ -297,7 +297,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
<Route path="/admin/users" element={<AdminUsersPage />} /> <Route path="/admin/users" element={<AdminUsersPage />} />
<Route path="/admin/system" element={<AdminSystemPage />} /> <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="/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 />} /> <Route path="*" element={<NotFoundPage />} />
</Routes> </Routes>
</Suspense> </Suspense>
@@ -314,19 +314,16 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
export default function App() { export default function App() {
const systemPrefersDark = useMediaQuery("(prefers-color-scheme: dark)", { defaultMatches: true, noSsr: true }); const systemPrefersDark = useMediaQuery("(prefers-color-scheme: dark)", { defaultMatches: true, noSsr: true });
const [themeMode, setThemeMode] = useState<ThemeModePref>(() => getThemeModePref()); 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 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(() => { useEffect(() => {
const sync = () => { setThemeMode(getThemeModePref()); setAccentColorState(getAccentColor()); }; const sync = () => { setThemeMode(getThemeModePref()); };
window.addEventListener("auth-changed", sync); window.addEventListener("auth-changed", sync);
return () => window.removeEventListener("auth-changed", sync); return () => window.removeEventListener("auth-changed", sync);
}, []); }, []);
const onThemeModeChange = (v: ThemeModePref) => { setThemeModePref(v); setThemeMode(v); }; 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 [jobPageSize, setJobPageSize] = useState<15 | 20 | 25>(() => {
const raw = window.localStorage.getItem("jobPageSize"); const raw = window.localStorage.getItem("jobPageSize");
@@ -349,14 +346,14 @@ export default function App() {
{ path: "/login", element: <LoginPage />, errorElement: <RouteErrorPage /> }, { path: "/login", element: <LoginPage />, errorElement: <RouteErrorPage /> },
{ path: "/forgot-password", element: <ForgotPasswordPage />, errorElement: <RouteErrorPage /> }, { path: "/forgot-password", element: <ForgotPasswordPage />, errorElement: <RouteErrorPage /> },
{ path: "/reset-password", element: <ResetPasswordPage />, 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 /> }, { path: "/*", element: <Shell jobPageSize={jobPageSize} setJobPageSize={setJobPageSize} jobColumns={jobColumns} setJobColumns={setJobColumns} themeMode={themeMode} onThemeModeChange={onThemeModeChange} />, errorElement: <RouteErrorPage /> },
], { future: { v7_relativeSplatPath: true } }), [jobColumns, jobPageSize, themeMode, accentColor]); ], { future: { v7_relativeSplatPath: true } }), [jobColumns, jobPageSize, themeMode]);
return ( return (
<ToastProvider> <ToastProvider>
<ConfirmProvider> <ConfirmProvider>
<PromptProvider> <PromptProvider>
<CssVarsProvider key={`${effectiveMode}:${accentColor}`} theme={theme as any} defaultMode={effectiveMode} disableTransitionOnChange> <CssVarsProvider key={effectiveMode} theme={theme as any} defaultMode={effectiveMode} disableTransitionOnChange>
<CssBaseline enableColorScheme /> <CssBaseline enableColorScheme />
<I18nProvider> <I18nProvider>
<RouterProvider router={router} future={{ v7_startTransition: true }} /> <RouterProvider router={router} future={{ v7_startTransition: true }} />
+55 -168
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useState } from "react";
import { import {
Box, Box,
@@ -9,11 +9,9 @@ import {
InputLabel, InputLabel,
MenuItem, MenuItem,
Paper, Paper,
Popover,
Select, Select,
Tab, Tab,
Tabs, Tabs,
TextField,
Typography, Typography,
} from "@mui/material"; } from "@mui/material";
@@ -21,12 +19,9 @@ import { useNavigate } from "react-router-dom";
import { JobTableColumns } from "./JobTable"; import { JobTableColumns } from "./JobTable";
import ImportExportJobs from "./ImportExportJobs"; import ImportExportJobs from "./ImportExportJobs";
import GoogleAuthCard from "./GoogleAuthCard";
import EmailProviderConnections from "./EmailProviderConnections";
import RulesSettingsCard from "./RulesSettingsCard"; import RulesSettingsCard from "./RulesSettingsCard";
import BackupCard from "./BackupCard"; import BackupCard from "./BackupCard";
import QuickCaptureCard from "./QuickCaptureCard"; import QuickCaptureCard from "./QuickCaptureCard";
import AuthStatusCard from "./AuthStatusCard";
import { ThemeModePref } from "../themePrefs"; import { ThemeModePref } from "../themePrefs";
import { useI18n } from "../i18n/I18nProvider"; import { useI18n } from "../i18n/I18nProvider";
@@ -37,17 +32,23 @@ interface Props {
onColumnsChange: (next: JobTableColumns) => void; onColumnsChange: (next: JobTableColumns) => void;
themeMode: ThemeModePref; themeMode: ThemeModePref;
onThemeModeChange: (v: ThemeModePref) => void; onThemeModeChange: (v: ThemeModePref) => void;
accentColor: string;
onAccentColorChange: (v: string) => void;
onResetAccentColor: () => void;
} }
function TabPanel({ value, index, children }: { value: number; index: number; children: React.ReactNode }) { function TabPanel({ value, index, children }: { value: number; index: number; children: React.ReactNode }) {
if (value !== index) return null; 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"; const NOTIFICATION_PREFS_KEY = "settings.notificationPrefs";
type NotificationPrefs = { type NotificationPrefs = {
@@ -88,43 +89,19 @@ export default function SettingsView({
onColumnsChange, onColumnsChange,
themeMode, themeMode,
onThemeModeChange, onThemeModeChange,
accentColor,
onAccentColorChange,
onResetAccentColor,
}: Props) { }: Props) {
const navigate = useNavigate(); const navigate = useNavigate();
const [tab, setTab] = useState(0); const [tab, setTab] = useState(0);
const { language, setLanguage, t } = useI18n(); const { language, setLanguage, t } = useI18n();
const [accentAnchor, setAccentAnchor] = useState<HTMLElement | null>(null);
const [accentDraft, setAccentDraft] = useState(accentColor);
const [notificationPrefs, setNotificationPrefs] = useState<NotificationPrefs>(() => loadNotificationPrefs()); 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(() => { useEffect(() => {
window.localStorage.setItem(NOTIFICATION_PREFS_KEY, JSON.stringify(notificationPrefs)); window.localStorage.setItem(NOTIFICATION_PREFS_KEY, JSON.stringify(notificationPrefs));
}, [notificationPrefs]); }, [notificationPrefs]);
const applyAccent = () => {
if (!accentDraftOk) return;
onAccentColorChange(accentDraft);
setAccentAnchor(null);
};
const resetAccent = () => {
onResetAccentColor();
setAccentDraft("#15803d");
setAccentAnchor(null);
};
return ( return (
<Paper sx={{ mt: 0, p: 2 }}> <Paper sx={{ mt: 0, p: 2.5 }}>
<Typography variant="h5" sx={{ mb: 1, fontWeight: 900 }}> <Typography variant="h5" sx={{ mb: 0.5, fontWeight: 900 }}>
{t("settingsTitle")} {t("settingsTitle")}
</Typography> </Typography>
<Typography sx={{ color: "text.secondary", mb: 2 }}> <Typography sx={{ color: "text.secondary", mb: 2 }}>
@@ -135,130 +112,48 @@ export default function SettingsView({
<Tab label={t("settingsTabGeneral")} /> <Tab label={t("settingsTabGeneral")} />
<Tab label={t("settingsTabFollowUps")} /> <Tab label={t("settingsTabFollowUps")} />
<Tab label={t("settingsTabNotifications")} /> <Tab label={t("settingsTabNotifications")} />
<Tab label={t("settingsTabAccount")} />
<Tab label={t("settingsTabBackup")} /> <Tab label={t("settingsTabBackup")} />
</Tabs> </Tabs>
<TabPanel value={tab} index={0}> <TabPanel value={tab} index={0}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}> <Box sx={{ display: "grid", gap: 2.5 }}>
<Paper sx={{ p: 2 }}> <Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2.5 }}>
<Typography sx={{ fontWeight: 950, mb: 1 }}>{t("settingsAppearance")}</Typography> <SectionCard title={t("settingsAppearance")}>
<FormControl fullWidth>
<FormControl fullWidth sx={{ mb: 2 }}> <InputLabel id="theme-mode-label">{t("settingsTheme")}</InputLabel>
<InputLabel id="theme-mode-label">{t("settingsTheme")}</InputLabel> <Select
<Select labelId="theme-mode-label"
labelId="theme-mode-label" value={themeMode}
value={themeMode} label={t("settingsTheme")}
label={t("settingsTheme")} onChange={(e) => onThemeModeChange(e.target.value as ThemeModePref)}
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>
<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" }} /> <MenuItem value="system">{t("settingsThemeSystem")}</MenuItem>
{accentOk ? accentColor.toUpperCase() : "#15803D"} <MenuItem value="dark">{t("settingsThemeDark")}</MenuItem>
</Button> <MenuItem value="light">{t("settingsThemeLight")}</MenuItem>
</Box> </Select>
<Button variant="outlined" onClick={resetAccent}> </FormControl>
{t("settingsReset")} </SectionCard>
</Button>
</Box>
<Popover <SectionCard title={t("settingsLanguageTitle")} subtitle={t("settingsLanguageBody")}>
open={Boolean(accentAnchor)} <FormControl fullWidth>
anchorEl={accentAnchor} <InputLabel id="language-label">{t("settingsPreferredLanguage")}</InputLabel>
onClose={() => setAccentAnchor(null)} <Select
anchorOrigin={{ vertical: "bottom", horizontal: "left" }} labelId="language-label"
> value={language}
<Box sx={{ p: 2, width: 280, display: "grid", gap: 1.5 }}> label={t("settingsPreferredLanguage")}
<Typography sx={{ fontWeight: 900 }}>{t("settingsAccent")}</Typography> onChange={(e) => setLanguage(e.target.value as "en" | "no")}
<input >
aria-label={t("settingsAccent")} <MenuItem value="en">{t("settingsEnglish")}</MenuItem>
type="color" <MenuItem value="no">{t("settingsNorwegian")}</MenuItem>
value={accentDraftOk ? accentDraft : "#15803d"} </Select>
onChange={(e) => setAccentDraft(e.target.value)} </FormControl>
style={{ width: "100%", height: 52, border: "none", background: "transparent", padding: 0, cursor: "pointer" }} </SectionCard>
/> </Box>
<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 }}> <SectionCard title={t("settingsJobs")}>
{t("settingsSavedPerUser")} <Box sx={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
</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 }}>
<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>
<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" }}>
<Box sx={{ minWidth: 240 }}> <Box sx={{ minWidth: 240 }}>
<Typography variant="h6" sx={{ mb: 1 }}> <Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 700 }}>
{t("settingsPagination")} {t("settingsPagination")}
</Typography> </Typography>
<FormControl fullWidth> <FormControl fullWidth>
@@ -277,7 +172,7 @@ export default function SettingsView({
</Box> </Box>
<Box sx={{ minWidth: 240 }}> <Box sx={{ minWidth: 240 }}>
<Typography variant="h6" sx={{ mb: 1 }}> <Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 700 }}>
{t("settingsColumns")} {t("settingsColumns")}
</Typography> </Typography>
{( {(
@@ -297,8 +192,10 @@ export default function SettingsView({
</Box> </Box>
</Box> </Box>
<ImportExportJobs /> <Box sx={{ mt: 2 }}>
</Paper> <ImportExportJobs />
</Box>
</SectionCard>
<QuickCaptureCard /> <QuickCaptureCard />
</Box> </Box>
@@ -309,9 +206,7 @@ export default function SettingsView({
</TabPanel> </TabPanel>
<TabPanel value={tab} index={2}> <TabPanel value={tab} index={2}>
<Paper sx={{ p: 2 }}> <SectionCard title={t("settingsNotificationsTitle")} subtitle={t("settingsNotificationsBody")}>
<Typography sx={{ fontWeight: 950, mb: 0.5 }}>{t("settingsNotificationsTitle")}</Typography>
<Typography sx={{ color: "text.secondary", mb: 2 }}>{t("settingsNotificationsBody")}</Typography>
<Box sx={{ display: "grid", gap: 1 }}> <Box sx={{ display: "grid", gap: 1 }}>
<FormControlLabel <FormControlLabel
control={<Checkbox checked={notificationPrefs.emailFollowUpReminders} onChange={(e) => setNotificationPrefs((prev) => ({ ...prev, emailFollowUpReminders: e.target.checked }))} />} 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="outlined" onClick={() => navigate("/reminders")}>{t("settingsOpenReminderInbox")}</Button>
<Button variant="text" onClick={() => navigate("/admin/system")}>{t("settingsCheckSystemStatus")}</Button> <Button variant="text" onClick={() => navigate("/admin/system")}>{t("settingsCheckSystemStatus")}</Button>
</Box> </Box>
</Paper> </SectionCard>
</TabPanel> </TabPanel>
<TabPanel value={tab} index={3}> <TabPanel value={tab} index={3}>
<AuthStatusCard />
<GoogleAuthCard />
<Box sx={{ mt: 2 }}>
<EmailProviderConnections />
</Box>
</TabPanel>
<TabPanel value={tab} index={4}>
<BackupCard /> <BackupCard />
</TabPanel> </TabPanel>
</Paper> </Paper>
+2 -16
View File
@@ -128,22 +128,17 @@ export const translations = {
settingsTabGeneral: "General", settingsTabGeneral: "General",
settingsTabFollowUps: "Follow-ups", settingsTabFollowUps: "Follow-ups",
settingsTabNotifications: "Notifications", settingsTabNotifications: "Notifications",
settingsTabAccount: "Account",
settingsTabBackup: "Backup", settingsTabBackup: "Backup",
settingsAppearance: "Appearance", settingsAppearance: "Appearance",
settingsTheme: "Theme", settingsTheme: "Theme",
settingsThemeSystem: "System", settingsThemeSystem: "System",
settingsThemeDark: "Dark", settingsThemeDark: "Dark",
settingsThemeLight: "Light", settingsThemeLight: "Light",
settingsAccent: "Accent",
settingsReset: "Reset",
settingsSavedPerUser: "Saved per user on this browser.",
settingsLanguageTitle: "Language and localization", 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.", 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", settingsPreferredLanguage: "Preferred language",
settingsEnglish: "English", settingsEnglish: "English",
settingsNorwegian: "Norwegian Bokmål", settingsNorwegian: "Norwegian Bokmål",
settingsMorePagesSoon: "More pages will be moved onto this translation system as the UI cleanup continues.",
settingsJobs: "Jobs", settingsJobs: "Jobs",
settingsPagination: "Pagination", settingsPagination: "Pagination",
settingsRowsPerPage: "Rows per page", settingsRowsPerPage: "Rows per page",
@@ -167,8 +162,6 @@ export const translations = {
settingsNotificationsFollowUpReminders: "Email reminders for follow-ups", settingsNotificationsFollowUpReminders: "Email reminders for follow-ups",
settingsNotificationsGhostedJobs: "Email alerts for ghosted jobs", settingsNotificationsGhostedJobs: "Email alerts for ghosted jobs",
settingsNotificationsInAppReminders: "Highlight reminders in the app", 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", settingsCheckSystemStatus: "Check system status",
profileTitle: "Profile", profileTitle: "Profile",
profileHeadlinePlaceholder: "Add a short headline to personalize your account view.", profileHeadlinePlaceholder: "Add a short headline to personalize your account view.",
@@ -629,7 +622,7 @@ export const translations = {
googleUnlinked: "Google account unlinked.", googleUnlinked: "Google account unlinked.",
googleUnlinkFailed: "Failed to unlink Google account.", googleUnlinkFailed: "Failed to unlink Google account.",
microsoftAccountTitle: "Microsoft 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", microsoftLinked: "Linked",
microsoftAvailableToLink: "Available to link", microsoftAvailableToLink: "Available to link",
microsoftLinkedDate: "Linked {date}", microsoftLinkedDate: "Linked {date}",
@@ -1093,22 +1086,17 @@ export const translations = {
settingsTabGeneral: "Generelt", settingsTabGeneral: "Generelt",
settingsTabFollowUps: "Oppfølging", settingsTabFollowUps: "Oppfølging",
settingsTabNotifications: "Varsler", settingsTabNotifications: "Varsler",
settingsTabAccount: "Konto",
settingsTabBackup: "Sikkerhetskopi", settingsTabBackup: "Sikkerhetskopi",
settingsAppearance: "Utseende", settingsAppearance: "Utseende",
settingsTheme: "Tema", settingsTheme: "Tema",
settingsThemeSystem: "System", settingsThemeSystem: "System",
settingsThemeDark: "Mørkt", settingsThemeDark: "Mørkt",
settingsThemeLight: "Lyst", settingsThemeLight: "Lyst",
settingsAccent: "Aksent",
settingsReset: "Tilbakestill",
settingsSavedPerUser: "Lagres per bruker i denne nettleseren.",
settingsLanguageTitle: "Språk og lokalisering", 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.", 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", settingsPreferredLanguage: "Foretrukket språk",
settingsEnglish: "Engelsk", settingsEnglish: "Engelsk",
settingsNorwegian: "Norsk Bokmål", settingsNorwegian: "Norsk Bokmål",
settingsMorePagesSoon: "Flere sider flyttes til dette oversettelsessystemet etter hvert som UI-oppryddingen fortsetter.",
settingsJobs: "Jobber", settingsJobs: "Jobber",
settingsPagination: "Paginering", settingsPagination: "Paginering",
settingsRowsPerPage: "Rader per side", settingsRowsPerPage: "Rader per side",
@@ -1132,8 +1120,6 @@ export const translations = {
settingsNotificationsFollowUpReminders: "E-postpåminnelser for oppfølginger", settingsNotificationsFollowUpReminders: "E-postpåminnelser for oppfølginger",
settingsNotificationsGhostedJobs: "E-postvarsler for ghostede jobber", settingsNotificationsGhostedJobs: "E-postvarsler for ghostede jobber",
settingsNotificationsInAppReminders: "Fremhev påminnelser i appen", 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", settingsCheckSystemStatus: "Sjekk systemstatus",
profileTitle: "Profil", profileTitle: "Profil",
profileHeadlinePlaceholder: "Legg til en kort overskrift for å gjøre kontovisningen mer personlig.", profileHeadlinePlaceholder: "Legg til en kort overskrift for å gjøre kontovisningen mer personlig.",
@@ -1594,7 +1580,7 @@ export const translations = {
googleUnlinked: "Google-konto koblet fra.", googleUnlinked: "Google-konto koblet fra.",
googleUnlinkFailed: "Kunne ikke koble fra Google-kontoen.", googleUnlinkFailed: "Kunne ikke koble fra Google-kontoen.",
microsoftAccountTitle: "Microsoft-konto", 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", microsoftLinked: "Koblet",
microsoftAvailableToLink: "Tilgjengelig for kobling", microsoftAvailableToLink: "Tilgjengelig for kobling",
microsoftLinkedDate: "Koblet {date}", microsoftLinkedDate: "Koblet {date}",
+21 -34
View File
@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import '@testing-library/jest-dom'; 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 { MemoryRouter } from 'react-router-dom';
import SettingsView from './components/SettingsView'; import SettingsView from './components/SettingsView';
@@ -21,35 +21,27 @@ jest.mock('./api', () => ({
})); }));
jest.mock('./components/ImportExportJobs', () => () => <div>Import Export Stub</div>); 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/BackupCard', () => () => <div>Backup Stub</div>);
jest.mock('./components/AuthStatusCard', () => () => <div>Auth Status Stub</div>);
const mockedApi = api as jest.Mocked<typeof api>; const mockedApi = api as jest.Mocked<typeof api>;
function renderView(onAccentColorChange = jest.fn()) { function renderView() {
return { return render(
onAccentColorChange, <MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
...render( <ToastProvider>
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}> <I18nProvider>
<ToastProvider> <SettingsView
<I18nProvider> pageSize={20}
<SettingsView onPageSizeChange={jest.fn()}
pageSize={20} columns={{ status: true, dateApplied: true, daysSince: true, jobUrl: false }}
onPageSizeChange={jest.fn()} onColumnsChange={jest.fn()}
columns={{ status: true, dateApplied: true, daysSince: true, jobUrl: false }} themeMode="dark"
onColumnsChange={jest.fn()} onThemeModeChange={jest.fn()}
themeMode="dark" />
onThemeModeChange={jest.fn()} </I18nProvider>
accentColor="#15803d" </ToastProvider>
onAccentColorChange={onAccentColorChange} </MemoryRouter>,
onResetAccentColor={jest.fn()} );
/>
</I18nProvider>
</ToastProvider>
</MemoryRouter>,
),
};
} }
beforeEach(() => { beforeEach(() => {
@@ -76,15 +68,10 @@ afterEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
}); });
test('settings view uses one follow-up section, one notification section, and staged accent apply', async () => { test('settings view has no accent picker and uses one follow-up section, one notification section', async () => {
const { onAccentColorChange } = renderView(); renderView();
fireEvent.click(screen.getByRole('button', { name: /#15803D/i })); expect(screen.queryByText(/accent/i)).not.toBeInTheDocument();
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 })); fireEvent.click(screen.getByRole('tab', { name: /follow-ups/i }));
expect(await screen.findByText(/follow-up rules by scenario/i)).toBeInTheDocument(); expect(await screen.findByText(/follow-up rules by scenario/i)).toBeInTheDocument();
+13 -9
View File
@@ -2,6 +2,10 @@ import { alpha, createTheme, darken, lighten } from "@mui/material/styles";
type PaletteLike = Record<string, any>; 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) { function buildPrimary(main: string) {
return { return {
lighter: lighten(main, 0.82), 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 textPrimary = "#1B1B1F";
const textSecondary = "#46464F"; const textSecondary = "#46464F";
@@ -24,7 +28,7 @@ function buildLightPalette(accentColor: string): PaletteLike {
const disabledBackground = "#E4E1E6"; const disabledBackground = "#E4E1E6";
return { return {
primary: buildPrimary(accentColor || "#6366F1"), primary: buildPrimary(ACCENT),
secondary: { secondary: {
lighter: "#E0E0FF", lighter: "#E0E0FF",
light: "#C3C4E4", light: "#C3C4E4",
@@ -82,14 +86,14 @@ function buildLightPalette(accentColor: string): PaletteLike {
// from the product mockups; cards/inputs (paper) sit above it. // from the product mockups; cards/inputs (paper) sit above it.
background: { default: "#F4F6FB", paper: background }, background: { default: "#F4F6FB", paper: background },
action: { action: {
hover: alpha(accentColor || "#6366F1", 0.05), hover: alpha(ACCENT, 0.05),
disabled: alpha(disabled, 0.6), disabled: alpha(disabled, 0.6),
disabledBackground: alpha(disabledBackground, 0.9), disabledBackground: alpha(disabledBackground, 0.9),
}, },
}; };
} }
function buildDarkPalette(accentColor: string): PaletteLike { function buildDarkPalette(): PaletteLike {
const bg = "#0B0B0E"; const bg = "#0B0B0E";
const paper = "#111116"; const paper = "#111116";
const divider = alpha("#FFFFFF", 0.10); const divider = alpha("#FFFFFF", 0.10);
@@ -101,7 +105,7 @@ function buildDarkPalette(accentColor: string): PaletteLike {
const disabledBackground = alpha("#FFFFFF", 0.08); const disabledBackground = alpha("#FFFFFF", 0.08);
return { return {
primary: buildPrimary(accentColor || "#6366F1"), primary: buildPrimary(ACCENT),
secondary: { secondary: {
lighter: alpha(secondaryMain, 0.22), lighter: alpha(secondaryMain, 0.22),
light: alpha(secondaryMain, 0.14), light: alpha(secondaryMain, 0.14),
@@ -157,7 +161,7 @@ function buildDarkPalette(accentColor: string): PaletteLike {
divider, divider,
background: { default: bg, paper }, background: { default: bg, paper },
action: { action: {
hover: alpha(accentColor || "#6366F1", 0.16), hover: alpha(ACCENT, 0.16),
disabled: alpha("#FFFFFF", 0.5), disabled: alpha("#FFFFFF", 0.5),
disabledBackground, disabledBackground,
}, },
@@ -196,9 +200,9 @@ function buildTypography() {
}; };
} }
export const getTheme = (_mode: "light" | "dark", accentColor: string) => { export const getTheme = (_mode: "light" | "dark") => {
const lightPalette = buildLightPalette(accentColor); const lightPalette = buildLightPalette();
const darkPalette = buildDarkPalette(accentColor); const darkPalette = buildDarkPalette();
const theme = createTheme({ const theme = createTheme({
breakpoints: { breakpoints: {
-14
View File
@@ -19,17 +19,3 @@ export function getThemeModePref(): ThemeModePref {
export function setThemeModePref(v: ThemeModePref) { export function setThemeModePref(v: ThemeModePref) {
window.localStorage.setItem(k("themeMode"), v); 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"));
}
+6
View File
@@ -10,6 +10,8 @@ import ZoomInOutlinedIcon from "@mui/icons-material/ZoomInOutlined";
import { api, getApiErrorMessage } from "../api"; import { api, getApiErrorMessage } from "../api";
import GoogleAuthCard from "../components/GoogleAuthCard"; import GoogleAuthCard from "../components/GoogleAuthCard";
import MicrosoftAuthCard from "../components/MicrosoftAuthCard"; import MicrosoftAuthCard from "../components/MicrosoftAuthCard";
import AuthStatusCard from "../components/AuthStatusCard";
import EmailProviderConnections from "../components/EmailProviderConnections";
import CropImageDialog from "../components/CropImageDialog"; import CropImageDialog from "../components/CropImageDialog";
import { useToast } from "../toast"; import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider"; import { useI18n } from "../i18n/I18nProvider";
@@ -562,8 +564,12 @@ export default function ProfilePage() {
</Box> </Box>
</Box> </Box>
<AuthStatusCard />
<GoogleAuthCard /> <GoogleAuthCard />
<MicrosoftAuthCard /> <MicrosoftAuthCard />
<Box sx={{ mt: 2 }}>
<EmailProviderConnections />
</Box>
<Box sx={{ mt: 3, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}> <Box sx={{ mt: 3, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
<Box sx={{ gridColumn: "1 / -1" }}> <Box sx={{ gridColumn: "1 / -1" }}>