feat(settings): preserve drafts and connection recovery
This commit is contained in:
@@ -1,13 +1,27 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { Box, Button, Checkbox, Chip, Divider, FormControlLabel, Paper, Stack, TextField, Typography } from "@mui/material";
|
||||
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
||||
import { Alert, Box, Button, Checkbox, Divider, FormControlLabel, Stack, TextField, Typography } from "@mui/material";
|
||||
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { useToast } from "../toast";
|
||||
import type { GmailStatus, ImapStatus, MicrosoftGraphStatus } from "../types";
|
||||
import { emailOAuthCallbackOrigin, emailOAuthMessage, type EmailOAuthAttempt, type EmailOAuthProvider } from "../emailOAuth";
|
||||
import { PageSection } from "../layout/PageLayout";
|
||||
|
||||
type Provider = EmailOAuthProvider | "imap";
|
||||
type ConnectionStatus = GmailStatus & MicrosoftGraphStatus & ImapStatus;
|
||||
type ProviderState = {
|
||||
status: ConnectionStatus | null;
|
||||
loading: boolean;
|
||||
readFailed: boolean;
|
||||
refreshRequired: boolean;
|
||||
busy: "connect" | "disconnect" | null;
|
||||
error: { kind: "disconnect" | "imap"; cause: unknown } | null;
|
||||
};
|
||||
const providerPaths = { gmail: "/gmail", microsoft: "/microsoft-graph", imap: "/imap" };
|
||||
const initialProvider = (): ProviderState => ({ status: null, loading: true, readFailed: false, refreshRequired: false, busy: null, error: null });
|
||||
const providerWork = () => ({ reading: false, writing: false, refreshRequired: false, sequence: 0, controller: null as AbortController | null, status: null as ConnectionStatus | null });
|
||||
|
||||
// Settings > Account: connect/disconnect each linked-mailbox provider. Gmail and Microsoft use
|
||||
// the same OAuth-popup + postMessage handshake (mirrored server-side in GmailController /
|
||||
@@ -17,49 +31,74 @@ export default function EmailProviderConnections() {
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
|
||||
const [gmailStatus, setGmailStatus] = useState<GmailStatus | null>(null);
|
||||
const [microsoftStatus, setMicrosoftStatus] = useState<MicrosoftGraphStatus | null>(null);
|
||||
const [imapStatus, setImapStatus] = useState<ImapStatus | null>(null);
|
||||
|
||||
const [imapForm, setImapForm] = useState({ host: "", port: 993, useSsl: true, username: "", password: "" });
|
||||
const [imapConnecting, setImapConnecting] = useState(false);
|
||||
const [providers, setProviders] = useState({ gmail: initialProvider(), microsoft: initialProvider(), imap: initialProvider() });
|
||||
const work = useRef({ gmail: providerWork(), microsoft: providerWork(), imap: providerWork() });
|
||||
const headings = useRef<Partial<Record<Provider, HTMLHeadingElement | null>>>({});
|
||||
const panels = useRef<Partial<Record<Provider, HTMLElement | null>>>({});
|
||||
const pendingFocus = useRef<{ provider: Provider; element: HTMLElement } | null>(null);
|
||||
const [imapForm, setImapForm] = useState({ host: "", port: "993", useSsl: true, username: "", password: "" });
|
||||
const [invalidPort, setInvalidPort] = useState(false);
|
||||
const oauth = useRef({ mounted: true, generation: 0, starting: new Set<EmailOAuthProvider>(), attempts: new Map<EmailOAuthProvider, EmailOAuthAttempt>() });
|
||||
const startupTranslation = useRef(t);
|
||||
|
||||
useEffect(() => { startupTranslation.current = t; }, [t]);
|
||||
|
||||
const updateProvider = useCallback((provider: Provider, patch: Partial<ProviderState>) => {
|
||||
setProviders(current => ({ ...current, [provider]: { ...current[provider], ...patch } }));
|
||||
}, []);
|
||||
|
||||
const rememberFocus = (provider: Provider) => {
|
||||
const element = document.activeElement;
|
||||
if (element instanceof HTMLElement && panels.current[provider]?.contains(element)) pendingFocus.current = { provider, element };
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const pending = pendingFocus.current;
|
||||
if (!pending) return;
|
||||
pendingFocus.current = null;
|
||||
const active = document.activeElement;
|
||||
if (active === document.body || active === pending.element && (pending.element.matches(":disabled") || !pending.element.isConnected)) headings.current[pending.provider]?.focus();
|
||||
}, [providers]);
|
||||
|
||||
useEffect(() => {
|
||||
const session = oauth.current;
|
||||
const providerSessions = work.current;
|
||||
session.mounted = true;
|
||||
return () => { session.mounted = false; session.generation += 1; session.starting.clear(); session.attempts.clear(); };
|
||||
return () => {
|
||||
session.mounted = false; session.generation += 1; session.starting.clear(); session.attempts.clear();
|
||||
for (const provider of Object.values(providerSessions)) { provider.sequence += 1; provider.controller?.abort(); provider.reading = false; }
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadGmailStatus = useCallback(async () => {
|
||||
const loadStatus = useCallback(async (provider: Provider, afterWrite = false): Promise<ConnectionStatus | null> => {
|
||||
const session = oauth.current;
|
||||
const current = work.current[provider];
|
||||
if (!session.mounted || current.reading || current.writing && !afterWrite) return null;
|
||||
current.reading = true;
|
||||
const sequence = ++current.sequence;
|
||||
const generation = session.generation;
|
||||
const controller = new AbortController();
|
||||
current.controller = controller;
|
||||
updateProvider(provider, { loading: true, readFailed: false });
|
||||
const active = () => session.mounted && generation === session.generation && sequence === current.sequence;
|
||||
try {
|
||||
const res = await api.get<GmailStatus>("/gmail/status");
|
||||
setGmailStatus(res.data);
|
||||
const { data } = await api.get<ConnectionStatus>(`${providerPaths[provider]}/status`, { signal: controller.signal });
|
||||
if (!active()) return null;
|
||||
if (typeof data?.connected !== "boolean") throw new Error("Invalid connection status");
|
||||
current.status = data;
|
||||
current.refreshRequired = false;
|
||||
updateProvider(provider, { status: data, readFailed: false, refreshRequired: false });
|
||||
return data;
|
||||
} catch {
|
||||
setGmailStatus({ connected: false });
|
||||
if (active()) updateProvider(provider, { readFailed: true });
|
||||
return null;
|
||||
} finally {
|
||||
if (active()) { current.reading = false; current.controller = null; updateProvider(provider, { loading: false }); }
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadMicrosoftStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get<MicrosoftGraphStatus>("/microsoft-graph/status");
|
||||
setMicrosoftStatus(res.data);
|
||||
} catch {
|
||||
setMicrosoftStatus({ connected: false });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadImapStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get<ImapStatus>("/imap/status");
|
||||
setImapStatus(res.data);
|
||||
} catch {
|
||||
setImapStatus({ connected: false });
|
||||
}
|
||||
}, []);
|
||||
}, [updateProvider]);
|
||||
const loadGmailStatus = useCallback(async () => { await loadStatus("gmail"); }, [loadStatus]);
|
||||
const loadMicrosoftStatus = useCallback(async () => { await loadStatus("microsoft"); }, [loadStatus]);
|
||||
const loadImapStatus = useCallback(async () => { await loadStatus("imap"); }, [loadStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadGmailStatus();
|
||||
@@ -90,11 +129,14 @@ export default function EmailProviderConnections() {
|
||||
|
||||
const connectViaPopup = async (provider: EmailOAuthProvider, connectUrlPath: string, popupName: string, providerLabel: string) => {
|
||||
const session = oauth.current;
|
||||
if (!session.mounted || session.starting.has(provider)) return;
|
||||
const current = work.current[provider];
|
||||
if (!session.mounted || session.starting.has(provider) || current.reading || current.writing || current.refreshRequired || !current.status || current.status.connected) return;
|
||||
const previous = session.attempts.get(provider);
|
||||
if (previous && !previous.popup.closed) { previous.popup.focus(); return; }
|
||||
session.attempts.delete(provider);
|
||||
session.starting.add(provider);
|
||||
rememberFocus(provider);
|
||||
updateProvider(provider, { busy: "connect", error: null });
|
||||
const generation = session.generation;
|
||||
try {
|
||||
const res = await api.get<{ url: string }>(connectUrlPath);
|
||||
@@ -107,75 +149,115 @@ export default function EmailProviderConnections() {
|
||||
} catch (error) {
|
||||
if (session.mounted && generation === session.generation) toast(getApiErrorMessage(error, startupTranslation.current("emailConnectionsStartFailed", { provider: providerLabel })), "error");
|
||||
} finally {
|
||||
if (generation === session.generation) session.starting.delete(provider);
|
||||
if (session.mounted && generation === session.generation) { session.starting.delete(provider); updateProvider(provider, { busy: null }); }
|
||||
}
|
||||
};
|
||||
|
||||
const disconnect = async (path: string, reload: () => Promise<void>, providerLabel: string) => {
|
||||
const disconnect = async (provider: Provider, providerLabel: string) => {
|
||||
const session = oauth.current;
|
||||
const current = work.current[provider];
|
||||
if (!session.mounted || current.reading || current.writing || current.refreshRequired || !current.status?.connected || provider !== "imap" && session.starting.has(provider)) return;
|
||||
current.writing = true;
|
||||
const generation = session.generation;
|
||||
rememberFocus(provider);
|
||||
updateProvider(provider, { busy: "disconnect", error: null });
|
||||
try {
|
||||
await api.delete(path);
|
||||
await reload();
|
||||
toast(t("emailConnectionsDisconnected", { provider: providerLabel }), "success");
|
||||
await api.delete(`${providerPaths[provider]}/connection`);
|
||||
if (!session.mounted || generation !== session.generation) return;
|
||||
current.status = null;
|
||||
current.refreshRequired = true;
|
||||
updateProvider(provider, { status: null, refreshRequired: true });
|
||||
const status = await loadStatus(provider, true);
|
||||
if (session.mounted && generation === session.generation && status && !status.connected) toast(startupTranslation.current("emailConnectionsDisconnected", { provider: providerLabel }), "success");
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, t("emailConnectionsDisconnectFailed", { provider: providerLabel })), "error");
|
||||
if (session.mounted && generation === session.generation) updateProvider(provider, { error: { kind: "disconnect", cause: error } });
|
||||
} finally {
|
||||
if (session.mounted && generation === session.generation) { current.writing = false; updateProvider(provider, { busy: null }); }
|
||||
}
|
||||
};
|
||||
|
||||
const connectImap = async () => {
|
||||
const session = oauth.current;
|
||||
const current = work.current.imap;
|
||||
if (!session.mounted || current.reading || current.writing || current.refreshRequired || !current.status || current.status.connected) return;
|
||||
if (!imapForm.host.trim() || !imapForm.username.trim() || !imapForm.password) {
|
||||
toast(t("emailConnectionsRequired"), "error");
|
||||
return;
|
||||
}
|
||||
setImapConnecting(true);
|
||||
const port = Number(imapForm.port);
|
||||
if (!imapForm.port.trim() || !Number.isInteger(port) || port < 1 || port > 65535) { setInvalidPort(true); return; }
|
||||
const submitted = { ...imapForm };
|
||||
current.writing = true;
|
||||
const generation = session.generation;
|
||||
rememberFocus("imap");
|
||||
updateProvider("imap", { busy: "connect", error: null });
|
||||
try {
|
||||
await api.post("/imap/connect", imapForm);
|
||||
setImapForm((prev) => ({ ...prev, password: "" }));
|
||||
await loadImapStatus();
|
||||
toast(t("emailConnectionsImapConnected"), "success");
|
||||
await api.post("/imap/connect", { ...submitted, port });
|
||||
if (!session.mounted || generation !== session.generation) return;
|
||||
rememberFocus("imap");
|
||||
// A completed submission must not clear credentials edited for another attempt.
|
||||
setImapForm(previous => Object.keys(submitted).every(key => previous[key as keyof typeof previous] === submitted[key as keyof typeof submitted]) ? { ...previous, password: "" } : previous);
|
||||
current.status = null;
|
||||
current.refreshRequired = true;
|
||||
updateProvider("imap", { status: null, refreshRequired: true });
|
||||
const status = await loadStatus("imap", true);
|
||||
if (session.mounted && generation === session.generation && status?.connected) toast(startupTranslation.current("emailConnectionsImapConnected"), "success");
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, t("emailConnectionsImapFailed")), "error");
|
||||
if (session.mounted && generation === session.generation) updateProvider("imap", { error: { kind: "imap", cause: error } });
|
||||
} finally {
|
||||
setImapConnecting(false);
|
||||
if (session.mounted && generation === session.generation) { current.writing = false; updateProvider("imap", { busy: null }); }
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography sx={{ fontWeight: 950, mb: 0.5 }}>{t("emailConnectionsTitle")}</Typography>
|
||||
<Typography sx={{ color: "text.secondary", mb: 2 }}>
|
||||
{t("emailConnectionsBody")}
|
||||
</Typography>
|
||||
const providerFeedback = (provider: Provider, providerLabel: string) => {
|
||||
const state = providers[provider];
|
||||
return <>
|
||||
{state.loading ? <Typography role="status" variant="body2" color="text.secondary">{t("emailConnectionsLoading")}</Typography> : null}
|
||||
{state.readFailed ? <Alert severity="error" action={<Button color="inherit" onClick={() => { rememberFocus(provider); void loadStatus(provider); }}>{t("retry")}</Button>}>
|
||||
{t(state.refreshRequired ? "emailConnectionsRefreshRequired" : "emailConnectionsStatusFailed")}
|
||||
</Alert> : null}
|
||||
{state.error ? <Alert severity="error">{getApiErrorMessage(state.error.cause, t(state.error.kind === "imap" ? "emailConnectionsImapFailed" : "emailConnectionsDisconnectFailed", { provider: providerLabel }))}</Alert> : null}
|
||||
</>;
|
||||
};
|
||||
|
||||
return (
|
||||
<PageSection surface title={t("emailConnectionsTitle")} description={t("emailConnectionsBody")}>
|
||||
<Stack spacing={2}>
|
||||
<ProviderRow
|
||||
label="Gmail"
|
||||
connected={Boolean(gmailStatus?.connected)}
|
||||
address={gmailStatus?.gmailAddress ?? null}
|
||||
state={providers.gmail}
|
||||
address={providers.gmail.status?.gmailAddress ?? null}
|
||||
panelRef={node => { panels.current.gmail = node; }}
|
||||
headingRef={node => { headings.current.gmail = node; }}
|
||||
onConnect={() => void connectViaPopup("gmail", "/gmail/connect-url", "jobtracker-gmail-connect", "Gmail")}
|
||||
onDisconnect={() => void disconnect("/gmail/connection", loadGmailStatus, "Gmail")}
|
||||
/>
|
||||
onDisconnect={() => void disconnect("gmail", "Gmail")}
|
||||
>{providerFeedback("gmail", "Gmail")}</ProviderRow>
|
||||
|
||||
<Divider />
|
||||
|
||||
<ProviderRow
|
||||
label="Outlook / Microsoft 365"
|
||||
connected={Boolean(microsoftStatus?.connected)}
|
||||
address={microsoftStatus?.mailAddress ?? null}
|
||||
state={providers.microsoft}
|
||||
address={providers.microsoft.status?.mailAddress ?? null}
|
||||
panelRef={node => { panels.current.microsoft = node; }}
|
||||
headingRef={node => { headings.current.microsoft = node; }}
|
||||
onConnect={() => void connectViaPopup("microsoft", "/microsoft-graph/connect-url", "jobtracker-microsoft-connect", "Outlook")}
|
||||
onDisconnect={() => void disconnect("/microsoft-graph/connection", loadMicrosoftStatus, "Outlook")}
|
||||
/>
|
||||
onDisconnect={() => void disconnect("microsoft", "Outlook")}
|
||||
>{providerFeedback("microsoft", "Outlook")}</ProviderRow>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<ProviderRow
|
||||
<ProviderRow
|
||||
label={t("emailConnectionsOtherImap")}
|
||||
connected={Boolean(imapStatus?.connected)}
|
||||
address={imapStatus?.username ?? null}
|
||||
onDisconnect={() => void disconnect("/imap/connection", loadImapStatus, "IMAP")}
|
||||
/>
|
||||
{!imapStatus?.connected && (
|
||||
<Box sx={{ mt: 1.5, display: "grid", gap: 1.25, gridTemplateColumns: { xs: "1fr", sm: "2fr 1fr" }, maxWidth: 520 }}>
|
||||
state={providers.imap}
|
||||
address={providers.imap.status?.username ?? null}
|
||||
panelRef={node => { panels.current.imap = node; }}
|
||||
headingRef={node => { headings.current.imap = node; }}
|
||||
onDisconnect={() => void disconnect("imap", "IMAP")}
|
||||
>
|
||||
{providerFeedback("imap", "IMAP")}
|
||||
{providers.imap.status && !providers.imap.status.connected && !providers.imap.readFailed && !providers.imap.refreshRequired && (
|
||||
<Box sx={{ display: "grid", gap: 1.5, gridTemplateColumns: { xs: "minmax(0, 1fr)", sm: "minmax(0, 2fr) minmax(0, 1fr)" }, maxWidth: 640 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
label={t("emailConnectionsImapHost")}
|
||||
@@ -188,7 +270,10 @@ export default function EmailProviderConnections() {
|
||||
label={t("emailConnectionsPort")}
|
||||
type="number"
|
||||
value={imapForm.port}
|
||||
onChange={(e) => setImapForm((prev) => ({ ...prev, port: Number(e.target.value) || 993 }))}
|
||||
error={invalidPort}
|
||||
helperText={invalidPort ? t("emailConnectionsPortInvalid") : undefined}
|
||||
slotProps={{ htmlInput: { min: 1, max: 65535, step: 1 } }}
|
||||
onChange={(e) => { setImapForm((prev) => ({ ...prev, port: e.target.value })); setInvalidPort(false); }}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
@@ -213,55 +298,57 @@ export default function EmailProviderConnections() {
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={connectImap}
|
||||
disabled={imapConnecting}
|
||||
sx={{ gridColumn: "1 / -1", justifySelf: "start" }}
|
||||
disabled={providers.imap.loading || providers.imap.busy !== null}
|
||||
sx={{ gridColumn: "1 / -1", justifySelf: "start", maxWidth: "100%", whiteSpace: "normal", minHeight: 40 }}
|
||||
>
|
||||
{imapConnecting ? t("emailConnectionsConnecting") : t("emailConnectionsConnectImap")}
|
||||
{providers.imap.busy === "connect" ? t("emailConnectionsConnecting") : t("emailConnectionsConnectImap")}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</ProviderRow>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderRow({
|
||||
label,
|
||||
connected,
|
||||
state,
|
||||
address,
|
||||
panelRef,
|
||||
headingRef,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
connected: boolean;
|
||||
state: ProviderState;
|
||||
address: string | null;
|
||||
panelRef: (node: HTMLElement | null) => void;
|
||||
headingRef: (node: HTMLHeadingElement | null) => void;
|
||||
onConnect?: () => void;
|
||||
onDisconnect: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" flexWrap="wrap" gap={1}>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 700 }}>{label}</Typography>
|
||||
{connected ? (
|
||||
<Chip
|
||||
size="small"
|
||||
icon={<CheckCircleIcon fontSize="small" />}
|
||||
color="success"
|
||||
variant="outlined"
|
||||
label={address || t("emailConnectionsConnected")}
|
||||
sx={{ mt: 0.5 }}
|
||||
/>
|
||||
) : (
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>{t("emailConnectionsNotConnected")}</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{connected ? (
|
||||
<Button size="small" variant="outlined" color="error" onClick={onDisconnect}>{t("emailConnectionsDisconnect")}</Button>
|
||||
) : (
|
||||
onConnect && <Button size="small" variant="outlined" onClick={onConnect}>{t("emailConnectionsConnect")}</Button>
|
||||
)}
|
||||
</Stack>
|
||||
<Box component="section" aria-label={label} ref={panelRef} aria-busy={state.loading || state.busy !== null} sx={{ display: "grid", gap: 1.5, minWidth: 0 }}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" flexWrap="wrap" gap={1} sx={{ minWidth: 0 }}>
|
||||
<Box sx={{ flex: "1 1 12rem", minWidth: 0 }}>
|
||||
<Typography component="h3" variant="subtitle1" ref={headingRef} tabIndex={-1} sx={{ fontWeight: 700, overflowWrap: "anywhere" }}>{label}</Typography>
|
||||
{state.status && !state.loading && !state.readFailed && !state.refreshRequired ? <Typography variant="body2" color="text.secondary" sx={{ mt: 0.5, overflowWrap: "anywhere" }}>
|
||||
{state.status.connected ? address || t("emailConnectionsConnected") : t("emailConnectionsNotConnected")}
|
||||
</Typography> : null}
|
||||
</Box>
|
||||
{state.status && !state.readFailed && !state.refreshRequired ? state.status.connected ? (
|
||||
<Button variant="outlined" color="error" disabled={state.loading || state.busy !== null} onClick={onDisconnect} sx={{ minHeight: 40, maxWidth: "100%", whiteSpace: "normal" }}>
|
||||
{state.busy === "disconnect" ? t("emailConnectionsDisconnecting") : t("emailConnectionsDisconnect")}
|
||||
</Button>
|
||||
) : onConnect ? <Button variant="outlined" disabled={state.loading || state.busy !== null} onClick={onConnect} sx={{ minHeight: 40, maxWidth: "100%", whiteSpace: "normal" }}>
|
||||
{state.busy === "connect" ? t("emailConnectionsConnecting") : t("emailConnectionsConnect")}
|
||||
</Button> : null : null}
|
||||
</Stack>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
Box,
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
Skeleton,
|
||||
Tab,
|
||||
Tabs,
|
||||
Typography,
|
||||
@@ -30,6 +29,7 @@ import { useI18n } from "../i18n/I18nProvider";
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { useToast } from "../toast";
|
||||
import { PageSection } from "../layout/PageLayout";
|
||||
import ViewStateNotice from "./ViewStateNotice";
|
||||
|
||||
interface Props {
|
||||
pageSize: 15 | 20 | 25;
|
||||
@@ -66,21 +66,38 @@ export default function SettingsView({
|
||||
const { language, setLanguage, t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const [notificationPrefs, setNotificationPrefs] = useState<NotificationPrefs | null>(null);
|
||||
const [notificationError, setNotificationError] = useState<string | null>(null);
|
||||
const [savedNotificationPrefs, setSavedNotificationPrefs] = useState<NotificationPrefs | null>(null);
|
||||
const [notificationError, setNotificationError] = useState<{ cause: unknown; action: boolean } | null>(null);
|
||||
const [loadingNotifications, setLoadingNotifications] = useState(true);
|
||||
const [savingNotifications, setSavingNotifications] = useState(false);
|
||||
const [savedRules, setSavedRules] = useState<RuleSettings | null>(null);
|
||||
const [rulesDraft, setRulesDraft] = useState<RuleDraft | null>(null);
|
||||
const [rulesLoadError, setRulesLoadError] = useState<string | null>(null);
|
||||
const [rulesLoadError, setRulesLoadError] = useState<{ cause: unknown } | null>(null);
|
||||
const [rulesSaveError, setRulesSaveError] = useState(false);
|
||||
const [invalidRules, setInvalidRules] = useState<RuleField[]>([]);
|
||||
const [savingRules, setSavingRules] = useState(false);
|
||||
const rulesSession = useRef({ mounted: true, loading: false, saving: false });
|
||||
const notificationSession = useRef({ mounted: true, loading: false, saving: false, generation: 0 });
|
||||
const notificationBody = useRef<HTMLDivElement>(null);
|
||||
const notificationRetryFocus = useRef<HTMLElement | null>(null);
|
||||
const currentTranslation = useRef(t);
|
||||
const rulesDirty = Boolean(rulesDraft && savedRules && RULE_FIELDS.some(field => rulesDraft[field] !== String(savedRules[field])));
|
||||
const notificationsDirty = Boolean(notificationPrefs && savedNotificationPrefs && notificationPrefs.emailFollowUpRemindersEnabled !== savedNotificationPrefs.emailFollowUpRemindersEnabled);
|
||||
|
||||
useEffect(() => { currentTranslation.current = t; }, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
const session = rulesSession.current;
|
||||
const notifications = notificationSession.current;
|
||||
session.mounted = true;
|
||||
return () => { session.mounted = false; };
|
||||
notifications.mounted = true;
|
||||
return () => {
|
||||
session.mounted = false;
|
||||
notifications.mounted = false;
|
||||
notifications.generation += 1;
|
||||
notifications.loading = false;
|
||||
notifications.saving = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadRules = async () => {
|
||||
@@ -94,7 +111,7 @@ export default function SettingsView({
|
||||
setSavedRules(data);
|
||||
setRulesDraft(ruleDraft(data));
|
||||
} catch (error) {
|
||||
if (session.mounted) setRulesLoadError(getApiErrorMessage(error, t("rulesLoadFailed")));
|
||||
if (session.mounted) setRulesLoadError({ cause: error });
|
||||
} finally { session.loading = false; }
|
||||
};
|
||||
|
||||
@@ -120,7 +137,7 @@ export default function SettingsView({
|
||||
setSavedRules(submitted);
|
||||
// A completed save acknowledges its snapshot, not edits made while it was pending.
|
||||
setRulesDraft(current => current && RULE_FIELDS.every(field => current[field] === submittedDraft[field]) ? ruleDraft(submitted) : current);
|
||||
toast(t("rulesSaved"), "success");
|
||||
toast(currentTranslation.current("rulesSaved"), "success");
|
||||
} catch {
|
||||
if (session.mounted) setRulesSaveError(true);
|
||||
} finally {
|
||||
@@ -136,27 +153,74 @@ export default function SettingsView({
|
||||
setRulesSaveError(false);
|
||||
};
|
||||
|
||||
const loadNotifications = useCallback(async () => {
|
||||
const session = notificationSession.current;
|
||||
if (!session.mounted || session.loading) return;
|
||||
const generation = session.generation;
|
||||
session.loading = true;
|
||||
setLoadingNotifications(true);
|
||||
setNotificationError(null);
|
||||
try {
|
||||
const { data } = await api.get<NotificationPrefs>("/notification-settings");
|
||||
if (!session.mounted || generation !== session.generation) return;
|
||||
setNotificationPrefs(data);
|
||||
setSavedNotificationPrefs(data);
|
||||
} catch (cause) {
|
||||
if (session.mounted && generation === session.generation) setNotificationError({ cause, action: false });
|
||||
} finally {
|
||||
if (generation === session.generation) {
|
||||
session.loading = false;
|
||||
if (session.mounted) setLoadingNotifications(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void loadNotifications(); }, [loadNotifications]);
|
||||
|
||||
const retryNotifications = () => {
|
||||
const active = document.activeElement;
|
||||
if (active instanceof HTMLElement && notificationBody.current?.contains(active)) notificationRetryFocus.current = active;
|
||||
void loadNotifications();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
api.get<NotificationPrefs>("/notification-settings")
|
||||
.then(({ data }) => { if (active) setNotificationPrefs(data); })
|
||||
.catch((error) => { if (active) setNotificationError(getApiErrorMessage(error, t("settingsNotificationsLoadFailed"))); });
|
||||
return () => { active = false; };
|
||||
}, [t]);
|
||||
const target = notificationRetryFocus.current;
|
||||
if (loadingNotifications || !target) return;
|
||||
notificationRetryFocus.current = null;
|
||||
if (!target.isConnected && document.activeElement === document.body) notificationBody.current?.focus();
|
||||
}, [loadingNotifications, notificationError, notificationPrefs]);
|
||||
|
||||
const saveNotifications = async () => {
|
||||
if (!notificationPrefs) return;
|
||||
const session = notificationSession.current;
|
||||
if (!session.mounted || !notificationPrefs || session.saving) return;
|
||||
const submitted = { emailFollowUpRemindersEnabled: notificationPrefs.emailFollowUpRemindersEnabled };
|
||||
const generation = session.generation;
|
||||
session.saving = true;
|
||||
setSavingNotifications(true);
|
||||
setNotificationError(null);
|
||||
try {
|
||||
const { data } = await api.put<NotificationPrefs>("/notification-settings", notificationPrefs);
|
||||
setNotificationPrefs(data);
|
||||
toast(t("settingsNotificationsSaved"), "success");
|
||||
} catch (error) {
|
||||
setNotificationError(getApiErrorMessage(error, t("settingsNotificationsSaveFailed")));
|
||||
} finally { setSavingNotifications(false); }
|
||||
const { data } = await api.put<NotificationPrefs>("/notification-settings", submitted);
|
||||
if (!session.mounted || generation !== session.generation) return;
|
||||
setSavedNotificationPrefs(data);
|
||||
setNotificationPrefs(current => current?.emailFollowUpRemindersEnabled === submitted.emailFollowUpRemindersEnabled ? data : current);
|
||||
toast(currentTranslation.current("settingsNotificationsSaved"), "success");
|
||||
} catch (cause) {
|
||||
if (session.mounted && generation === session.generation) setNotificationError({ cause, action: true });
|
||||
} finally {
|
||||
if (generation === session.generation) {
|
||||
session.saving = false;
|
||||
if (session.mounted) setSavingNotifications(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const discardNotifications = () => {
|
||||
if (!savedNotificationPrefs || notificationSession.current.saving) return;
|
||||
setNotificationPrefs(savedNotificationPrefs);
|
||||
setNotificationError(null);
|
||||
};
|
||||
const notificationReadStatus = (notificationError?.cause as { response?: { status?: number } } | undefined)?.response?.status;
|
||||
|
||||
return (
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<FormControl fullWidth sx={{ display: { xs: "flex", sm: "none" }, mb: 1 }}>
|
||||
@@ -274,7 +338,7 @@ export default function SettingsView({
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tab} index={1} label={t("settingsTabFollowUps")}>
|
||||
<RulesSettingsCard draft={rulesDraft} loadError={rulesLoadError} saveError={rulesSaveError} invalid={invalidRules}
|
||||
<RulesSettingsCard draft={rulesDraft} loadError={rulesLoadError ? getApiErrorMessage(rulesLoadError.cause, t("rulesLoadFailed")) : null} saveError={rulesSaveError} invalid={invalidRules}
|
||||
saving={savingRules} dirty={rulesDirty} onSave={() => void saveRules()} onRetry={() => void loadRules()} onDiscard={discardRules}
|
||||
onChange={(field, value) => {
|
||||
setRulesDraft(current => current ? { ...current, [field]: value } : current);
|
||||
@@ -284,15 +348,25 @@ export default function SettingsView({
|
||||
|
||||
<TabPanel value={tab} index={2} label={t("settingsTabNotifications")}>
|
||||
<PageSection surface title={t("settingsNotificationsTitle")} description={t("settingsNotificationsBody")}>
|
||||
{notificationError ? <Alert severity="error" sx={{ mb: 1.5 }}>{notificationError}</Alert> : null}
|
||||
{!notificationPrefs ? <Skeleton variant="rounded" height={70} /> : <Box sx={{ display: "grid", gap: 1 }}>
|
||||
<Box ref={notificationBody} role="group" aria-label={t("settingsNotificationsTitle")} tabIndex={-1} aria-busy={loadingNotifications || savingNotifications} sx={{ minWidth: 0 }}>
|
||||
{!notificationPrefs ? <ViewStateNotice compact loading={loadingNotifications} title={t("settingsNotificationsLoadFailed")} error={notificationError ? {
|
||||
kind: notificationReadStatus === 401 || notificationReadStatus === 403 ? "unauthorized" : "error",
|
||||
message: getApiErrorMessage(notificationError.cause, t("settingsNotificationsLoadFailed")),
|
||||
retryable: notificationReadStatus !== 401 && notificationReadStatus !== 403,
|
||||
} : null} onRetry={retryNotifications} /> : <Box sx={{ display: "grid", gap: 1 }}>
|
||||
{notificationError?.action ? <Alert severity="error" sx={{ mb: 1.5, overflowWrap: "anywhere" }}>{getApiErrorMessage(notificationError.cause, t("settingsNotificationsSaveFailed"))}</Alert> : null}
|
||||
{notificationsDirty ? <Typography role="status" variant="body2" color="text.secondary">{t("assetsUnsavedChanges")}. {t("settingsNotificationsDraftScope")}</Typography> : null}
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={notificationPrefs.emailFollowUpRemindersEnabled} onChange={(e) => setNotificationPrefs({ emailFollowUpRemindersEnabled: e.target.checked })} />}
|
||||
label={t("settingsNotificationsFollowUpReminders")}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">{t("settingsNotificationsEmailBody")}</Typography>
|
||||
<Box><Button variant="contained" disabled={savingNotifications} onClick={() => void saveNotifications()}>{savingNotifications ? t("settingsSaving") : t("settingsNotificationsSave")}</Button></Box>
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 1 }}>
|
||||
<Button disabled={!notificationsDirty || savingNotifications} onClick={discardNotifications}>{t("assetsDiscardChanges")}</Button>
|
||||
<Button variant="contained" disabled={savingNotifications} onClick={() => void saveNotifications()}>{savingNotifications ? t("settingsSaving") : t("settingsNotificationsSave")}</Button>
|
||||
</Box>
|
||||
</Box>}
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 1.5 }}>
|
||||
{t("settingsNotificationsDelivery")}
|
||||
</Typography>
|
||||
|
||||
Reference in New Issue
Block a user