247 lines
9.3 KiB
TypeScript
247 lines
9.3 KiB
TypeScript
import React, { useCallback, useEffect, 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 { api, getApiErrorMessage } from "../api";
|
|
import { useI18n } from "../i18n/I18nProvider";
|
|
import { useToast } from "../toast";
|
|
import type { GmailStatus, ImapStatus, MicrosoftGraphStatus } from "../types";
|
|
|
|
// Settings > Account: connect/disconnect each linked-mailbox provider. Gmail and Microsoft use
|
|
// the same OAuth-popup + postMessage handshake (mirrored server-side in GmailController /
|
|
// MicrosoftGraphController's BuildPopupHtml); IMAP has no OAuth step, so it's a plain credential
|
|
// form submitted to POST /api/imap/connect, which verifies the connection before storing it.
|
|
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 loadGmailStatus = useCallback(async () => {
|
|
try {
|
|
const res = await api.get<GmailStatus>("/gmail/status");
|
|
setGmailStatus(res.data);
|
|
} catch {
|
|
setGmailStatus({ connected: 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 });
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void loadGmailStatus();
|
|
void loadMicrosoftStatus();
|
|
void loadImapStatus();
|
|
}, [loadGmailStatus, loadMicrosoftStatus, loadImapStatus]);
|
|
|
|
useEffect(() => {
|
|
const onMessage = (event: MessageEvent) => {
|
|
const data = event.data as { source?: string; status?: string; message?: string };
|
|
if (data?.source === "jobtracker-gmail-oauth") {
|
|
if (data.status === "connected") {
|
|
toast(data.message || t("emailConnectionsGmailConnected"), "success");
|
|
void loadGmailStatus();
|
|
} 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");
|
|
}
|
|
}
|
|
};
|
|
|
|
window.addEventListener("message", onMessage);
|
|
return () => window.removeEventListener("message", onMessage);
|
|
}, [loadGmailStatus, loadMicrosoftStatus, t, toast]);
|
|
|
|
const connectViaPopup = async (connectUrlPath: string, popupName: string, providerLabel: string) => {
|
|
try {
|
|
const res = await api.get<{ url: string }>(connectUrlPath);
|
|
const popup = window.open(res.data.url, popupName, "width=620,height=760,resizable=yes,scrollbars=yes");
|
|
if (!popup) toast(t("emailConnectionsPopupBlocked"), "error");
|
|
} catch (error) {
|
|
toast(getApiErrorMessage(error, t("emailConnectionsStartFailed", { provider: providerLabel })), "error");
|
|
}
|
|
};
|
|
|
|
const disconnect = async (path: string, reload: () => Promise<void>, providerLabel: string) => {
|
|
try {
|
|
await api.delete(path);
|
|
await reload();
|
|
toast(t("emailConnectionsDisconnected", { provider: providerLabel }), "success");
|
|
} catch (error) {
|
|
toast(getApiErrorMessage(error, t("emailConnectionsDisconnectFailed", { provider: providerLabel })), "error");
|
|
}
|
|
};
|
|
|
|
const connectImap = async () => {
|
|
if (!imapForm.host.trim() || !imapForm.username.trim() || !imapForm.password) {
|
|
toast(t("emailConnectionsRequired"), "error");
|
|
return;
|
|
}
|
|
setImapConnecting(true);
|
|
try {
|
|
await api.post("/imap/connect", imapForm);
|
|
setImapForm((prev) => ({ ...prev, password: "" }));
|
|
await loadImapStatus();
|
|
toast(t("emailConnectionsImapConnected"), "success");
|
|
} catch (error) {
|
|
toast(getApiErrorMessage(error, t("emailConnectionsImapFailed")), "error");
|
|
} finally {
|
|
setImapConnecting(false);
|
|
}
|
|
};
|
|
|
|
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>
|
|
|
|
<Stack spacing={2}>
|
|
<ProviderRow
|
|
label="Gmail"
|
|
connected={Boolean(gmailStatus?.connected)}
|
|
address={gmailStatus?.gmailAddress ?? null}
|
|
onConnect={() => void connectViaPopup("/gmail/connect-url", "jobtracker-gmail-connect", "Gmail")}
|
|
onDisconnect={() => void disconnect("/gmail/connection", loadGmailStatus, "Gmail")}
|
|
/>
|
|
|
|
<Divider />
|
|
|
|
<ProviderRow
|
|
label="Outlook / Microsoft 365"
|
|
connected={Boolean(microsoftStatus?.connected)}
|
|
address={microsoftStatus?.mailAddress ?? null}
|
|
onConnect={() => void connectViaPopup("/microsoft-graph/connect-url", "jobtracker-microsoft-connect", "Outlook")}
|
|
onDisconnect={() => void disconnect("/microsoft-graph/connection", loadMicrosoftStatus, "Outlook")}
|
|
/>
|
|
|
|
<Divider />
|
|
|
|
<Box>
|
|
<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 }}>
|
|
<TextField
|
|
size="small"
|
|
label={t("emailConnectionsImapHost")}
|
|
placeholder="imap.example.com"
|
|
value={imapForm.host}
|
|
onChange={(e) => setImapForm((prev) => ({ ...prev, host: e.target.value }))}
|
|
/>
|
|
<TextField
|
|
size="small"
|
|
label={t("emailConnectionsPort")}
|
|
type="number"
|
|
value={imapForm.port}
|
|
onChange={(e) => setImapForm((prev) => ({ ...prev, port: Number(e.target.value) || 993 }))}
|
|
/>
|
|
<TextField
|
|
size="small"
|
|
label={t("emailConnectionsUsername")}
|
|
value={imapForm.username}
|
|
onChange={(e) => setImapForm((prev) => ({ ...prev, username: e.target.value }))}
|
|
sx={{ gridColumn: "1 / -1" }}
|
|
/>
|
|
<TextField
|
|
size="small"
|
|
label={t("emailConnectionsPassword")}
|
|
type="password"
|
|
value={imapForm.password}
|
|
onChange={(e) => setImapForm((prev) => ({ ...prev, password: e.target.value }))}
|
|
sx={{ gridColumn: "1 / -1" }}
|
|
/>
|
|
<FormControlLabel
|
|
sx={{ gridColumn: "1 / -1" }}
|
|
control={<Checkbox checked={imapForm.useSsl} onChange={(e) => setImapForm((prev) => ({ ...prev, useSsl: e.target.checked }))} />}
|
|
label={t("emailConnectionsUseSsl")}
|
|
/>
|
|
<Button
|
|
variant="contained"
|
|
onClick={connectImap}
|
|
disabled={imapConnecting}
|
|
sx={{ gridColumn: "1 / -1", justifySelf: "start" }}
|
|
>
|
|
{imapConnecting ? t("emailConnectionsConnecting") : t("emailConnectionsConnectImap")}
|
|
</Button>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
</Stack>
|
|
</Paper>
|
|
);
|
|
}
|
|
|
|
function ProviderRow({
|
|
label,
|
|
connected,
|
|
address,
|
|
onConnect,
|
|
onDisconnect,
|
|
}: {
|
|
label: string;
|
|
connected: boolean;
|
|
address: string | null;
|
|
onConnect?: () => void;
|
|
onDisconnect: () => void;
|
|
}) {
|
|
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>
|
|
);
|
|
}
|