fix(auth): bind mailbox callbacks to pending popup

This commit is contained in:
cesnimda
2026-10-17 10:27:45 +02:00
parent c5176e3251
commit dac29278db
19 changed files with 1403 additions and 38 deletions
@@ -44,6 +44,7 @@ import {
import { useDialogActions } from "../dialogs";
import { useI18n } from "../i18n/I18nProvider";
import ViewStateNotice from "./ViewStateNotice";
import { emailOAuthCallbackOrigin, emailOAuthMessage, type EmailOAuthAttempt } from "../emailOAuth";
function parseRawEmail(raw: string): { subject?: string; date?: string; from?: string; to?: string; body: string } {
const lines = raw.replace(/\r\n/g, "\n").split("\n");
@@ -155,6 +156,16 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
const [manageNote, setManageNote] = useState("");
const [manageSaving, setManageSaving] = useState(false);
const autoRefreshKeyRef = useRef<string | null>(null);
const gmailOAuth = useRef<{ active: boolean; generation: number; starting: boolean; attempt: EmailOAuthAttempt | null }>({ active: true, generation: 0, starting: false, attempt: null });
const startupTranslation = useRef(t);
useEffect(() => { startupTranslation.current = t; }, [t]);
useEffect(() => {
const session = gmailOAuth.current;
session.active = true;
return () => { session.active = false; session.generation += 1; session.starting = false; session.attempt = null; };
}, [jobId]);
const load = useCallback(async () => {
setMessagesLoading(true);
@@ -292,8 +303,11 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
useEffect(() => {
const onMessage = (event: MessageEvent) => {
const data = event.data as { source?: string; status?: string; message?: string };
if (data?.source !== "jobtracker-gmail-oauth") return;
const session = gmailOAuth.current;
if (!session.active || !session.attempt) return;
const data = emailOAuthMessage(event, session.attempt);
if (!data) return;
session.attempt = null;
if (data.status === "connected") {
toast(data.message || t("googleLinkedSuccess"), "success");
void loadGmailStatus();
@@ -356,12 +370,24 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
};
const connectGmail = async () => {
const session = gmailOAuth.current;
if (!session.active || session.starting) return;
if (session.attempt && !session.attempt.popup.closed) { session.attempt.popup.focus(); return; }
session.attempt = null;
session.starting = true;
const generation = session.generation;
try {
const res = await api.get<{ url: string }>("/gmail/connect-url");
if (!session.active || generation !== session.generation) return;
const origin = emailOAuthCallbackOrigin(res.data.url, "gmail");
if (!origin) { toast(startupTranslation.current("correspondenceStartGmailFailed"), "error"); return; }
const popup = window.open(res.data.url, "jobtracker-gmail-connect", "width=620,height=760,resizable=yes,scrollbars=yes");
if (!popup) toast(t("correspondenceBlockedPopup"), "error");
if (!popup) toast(startupTranslation.current("correspondenceBlockedPopup"), "error");
else session.attempt = { provider: "gmail", origin, popup };
} catch (error) {
toast(getApiErrorMessage(error, t("correspondenceStartGmailFailed")), "error");
if (session.active && generation === session.generation) toast(getApiErrorMessage(error, startupTranslation.current("correspondenceStartGmailFailed")), "error");
} finally {
if (generation === session.generation) session.starting = false;
}
};
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from "react";
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";
@@ -7,6 +7,7 @@ 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";
// Settings > Account: connect/disconnect each linked-mailbox provider. Gmail and Microsoft use
// the same OAuth-popup + postMessage handshake (mirrored server-side in GmailController /
@@ -22,6 +23,16 @@ export default function EmailProviderConnections() {
const [imapForm, setImapForm] = useState({ host: "", port: 993, useSsl: true, username: "", password: "" });
const [imapConnecting, setImapConnecting] = 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]);
useEffect(() => {
const session = oauth.current;
session.mounted = true;
return () => { session.mounted = false; session.generation += 1; session.starting.clear(); session.attempts.clear(); };
}, []);
const loadGmailStatus = useCallback(async () => {
try {
@@ -58,21 +69,18 @@ export default function EmailProviderConnections() {
useEffect(() => {
const onMessage = (event: MessageEvent) => {
const data = event.data as { source?: string; status?: string; message?: string };
if (data?.source === "jobtracker-gmail-oauth") {
if (!oauth.current.mounted) return;
for (const [provider, attempt] of oauth.current.attempts) {
const data = emailOAuthMessage(event, attempt);
if (!data) continue;
oauth.current.attempts.delete(provider);
if (data.status === "connected") {
toast(data.message || t("emailConnectionsGmailConnected"), "success");
void loadGmailStatus();
toast(data.message || t(provider === "gmail" ? "emailConnectionsGmailConnected" : "emailConnectionsOutlookConnected"), "success");
void (provider === "gmail" ? loadGmailStatus() : loadMicrosoftStatus());
} else {
toast(data.message || t("emailConnectionsGmailFailed"), "error");
}
} else if (data?.source === "jobtracker-microsoft-oauth") {
if (data.status === "connected") {
toast(data.message || t("emailConnectionsOutlookConnected"), "success");
void loadMicrosoftStatus();
} else {
toast(data.message || t("emailConnectionsOutlookFailed"), "error");
toast(data.message || t(provider === "gmail" ? "emailConnectionsGmailFailed" : "emailConnectionsOutlookFailed"), "error");
}
return;
}
};
@@ -80,13 +88,26 @@ export default function EmailProviderConnections() {
return () => window.removeEventListener("message", onMessage);
}, [loadGmailStatus, loadMicrosoftStatus, t, toast]);
const connectViaPopup = async (connectUrlPath: string, popupName: string, providerLabel: string) => {
const connectViaPopup = async (provider: EmailOAuthProvider, connectUrlPath: string, popupName: string, providerLabel: string) => {
const session = oauth.current;
if (!session.mounted || session.starting.has(provider)) return;
const previous = session.attempts.get(provider);
if (previous && !previous.popup.closed) { previous.popup.focus(); return; }
session.attempts.delete(provider);
session.starting.add(provider);
const generation = session.generation;
try {
const res = await api.get<{ url: string }>(connectUrlPath);
if (!session.mounted || generation !== session.generation) return;
const origin = emailOAuthCallbackOrigin(res.data.url, provider);
if (!origin) { toast(startupTranslation.current("emailConnectionsStartFailed", { provider: providerLabel }), "error"); return; }
const popup = window.open(res.data.url, popupName, "width=620,height=760,resizable=yes,scrollbars=yes");
if (!popup) toast(t("emailConnectionsPopupBlocked"), "error");
if (!popup) toast(startupTranslation.current("emailConnectionsPopupBlocked"), "error");
else session.attempts.set(provider, { provider, origin, popup });
} catch (error) {
toast(getApiErrorMessage(error, t("emailConnectionsStartFailed", { provider: providerLabel })), "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);
}
};
@@ -130,7 +151,7 @@ export default function EmailProviderConnections() {
label="Gmail"
connected={Boolean(gmailStatus?.connected)}
address={gmailStatus?.gmailAddress ?? null}
onConnect={() => void connectViaPopup("/gmail/connect-url", "jobtracker-gmail-connect", "Gmail")}
onConnect={() => void connectViaPopup("gmail", "/gmail/connect-url", "jobtracker-gmail-connect", "Gmail")}
onDisconnect={() => void disconnect("/gmail/connection", loadGmailStatus, "Gmail")}
/>
@@ -140,7 +161,7 @@ export default function EmailProviderConnections() {
label="Outlook / Microsoft 365"
connected={Boolean(microsoftStatus?.connected)}
address={microsoftStatus?.mailAddress ?? null}
onConnect={() => void connectViaPopup("/microsoft-graph/connect-url", "jobtracker-microsoft-connect", "Outlook")}
onConnect={() => void connectViaPopup("microsoft", "/microsoft-graph/connect-url", "jobtracker-microsoft-connect", "Outlook")}
onDisconnect={() => void disconnect("/microsoft-graph/connection", loadMicrosoftStatus, "Outlook")}
/>