Add OAth flow for Gmail and update tables and UI

This commit is contained in:
cesnimda
2026-03-21 14:02:19 +01:00
parent 51a539068f
commit ed68e44eaf
17 changed files with 1180 additions and 53 deletions
+249 -31
View File
@@ -3,11 +3,20 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import {
Box,
Button,
Chip,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
List,
ListItemButton,
ListItemText,
Paper,
Tab,
Tabs,
TextField,
ToggleButton,
ToggleButtonGroup,
Typography,
@@ -16,7 +25,7 @@ import { alpha, useTheme } from "@mui/material/styles";
import { api } from "../api";
import { useToast } from "../toast";
import { CorrespondenceMessage } from "../types";
import { CorrespondenceMessage, GmailMessageSummary, GmailStatus } from "../types";
function parseRawEmail(raw: string): {
subject?: string;
@@ -68,13 +77,50 @@ export default function Correspondence({ jobId }: { jobId: number }) {
const scrollRef = useRef<HTMLDivElement | null>(null);
const [importOpen, setImportOpen] = useState(false);
const [importTab, setImportTab] = useState(0);
const [rawEmail, setRawEmail] = useState("");
const [gmailStatus, setGmailStatus] = useState<GmailStatus | null>(null);
const [gmailLoading, setGmailLoading] = useState(false);
const [gmailQuery, setGmailQuery] = useState("");
const [gmailMessages, setGmailMessages] = useState<GmailMessageSummary[]>([]);
const [gmailMessagesLoading, setGmailMessagesLoading] = useState(false);
const [importingMessageId, setImportingMessageId] = useState<string | null>(null);
const load = useCallback(async () => {
const res = await api.get<CorrespondenceMessage[]>(`/correspondence/${jobId}`);
setMessages(res.data);
}, [jobId]);
const loadGmailStatus = useCallback(async () => {
try {
setGmailLoading(true);
const res = await api.get<GmailStatus>("/gmail/status");
setGmailStatus(res.data);
} catch {
setGmailStatus({ connected: false });
} finally {
setGmailLoading(false);
}
}, []);
const loadGmailMessages = useCallback(async () => {
try {
setGmailMessagesLoading(true);
const res = await api.get<GmailMessageSummary[]>("/gmail/messages", {
params: {
query: gmailQuery.trim() || undefined,
maxResults: 12,
},
});
setGmailMessages(res.data);
} catch {
toast("Failed to load Gmail messages.", "error");
} finally {
setGmailMessagesLoading(false);
}
}, [gmailQuery, toast]);
useEffect(() => {
void load();
}, [load]);
@@ -85,6 +131,34 @@ export default function Correspondence({ jobId }: { jobId: number }) {
el.scrollTop = el.scrollHeight;
}, [messages.length]);
useEffect(() => {
if (!importOpen) return;
void loadGmailStatus();
}, [importOpen, loadGmailStatus]);
useEffect(() => {
if (!importOpen || importTab !== 1 || !gmailStatus?.connected) return;
void loadGmailMessages();
}, [importOpen, importTab, gmailStatus?.connected, loadGmailMessages]);
useEffect(() => {
const onMessage = (event: MessageEvent) => {
const data = event.data as { source?: string; status?: string; message?: string };
if (data?.source !== "jobtracker-gmail-oauth") return;
if (data.status === "connected") {
toast(data.message || "Gmail connected.", "success");
void loadGmailStatus();
setImportTab(1);
void loadGmailMessages();
} else {
toast(data.message || "Gmail connection failed.", "error");
}
};
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, [loadGmailMessages, loadGmailStatus, toast]);
const canSend = useMemo(() => text.trim().length > 0, [text]);
const send = async () => {
@@ -130,6 +204,45 @@ export default function Correspondence({ jobId }: { jobId: number }) {
}
};
const connectGmail = async () => {
try {
const res = await api.get<{ url: string }>("/gmail/connect-url");
const popup = window.open(res.data.url, "jobtracker-gmail-connect", "width=620,height=760,resizable=yes,scrollbars=yes");
if (!popup) {
toast("Your browser blocked the Gmail popup.", "error");
}
} catch {
toast("Failed to start Gmail connection.", "error");
}
};
const disconnectGmail = async () => {
try {
await api.delete("/gmail/connection");
setGmailStatus({ connected: false });
setGmailMessages([]);
toast("Gmail disconnected.", "success");
} catch {
toast("Failed to disconnect Gmail.", "error");
}
};
const importGmailMessage = async (messageId: string) => {
try {
setImportingMessageId(messageId);
await api.post("/gmail/import", {
jobApplicationId: jobId,
messageId,
});
await load();
toast("Email imported from Gmail.", "success");
} catch {
toast("Failed to import Gmail message.", "error");
} finally {
setImportingMessageId(null);
}
};
return (
<Box>
<Paper
@@ -190,8 +303,8 @@ export default function Correspondence({ jobId }: { jobId: number }) {
sx={{ display: "block", mt: 0.75, color: "text.secondary" }}
>
{isMe ? "Me" : "Company"}
{m.channel ? ` · ${m.channel}` : ""}
{m.date ? ` · ${new Date(m.date).toLocaleString()}` : ""}
{m.channel ? ` · ${m.channel}` : ""}
{m.date ? ` · ${new Date(m.date).toLocaleString()}` : ""}
</Typography>
</Box>
</Box>
@@ -249,38 +362,143 @@ export default function Correspondence({ jobId }: { jobId: number }) {
<Dialog open={importOpen} onClose={() => setImportOpen(false)} fullWidth maxWidth="md">
<DialogTitle>Import Email</DialogTitle>
<DialogContent>
<Typography sx={{ color: "text.secondary", mb: 1 }}>
Paste raw email text (headers optional). We parse Subject and Date when present.
</Typography>
<textarea
value={rawEmail}
onChange={(e) => setRawEmail(e.target.value)}
placeholder={"Subject: ...\nDate: ...\nFrom: ...\nTo: ...\n\nBody..."}
style={{
width: "100%",
minHeight: 220,
resize: "vertical",
padding: 12,
borderRadius: 12,
border:
theme.palette.mode === "dark"
? "1px solid rgba(148,163,184,0.25)"
: "1px solid rgba(15,23,42,0.12)",
background: theme.palette.mode === "dark" ? "rgba(2,6,23,0.25)" : "white",
color: theme.palette.text.primary,
fontFamily: "inherit",
fontSize: 14,
}}
/>
<Tabs value={importTab} onChange={(_, v) => setImportTab(v)} sx={{ mb: 2 }}>
<Tab label="Paste email" />
<Tab label="Gmail" />
</Tabs>
{importTab === 0 ? (
<>
<Typography sx={{ color: "text.secondary", mb: 1 }}>
Paste raw email text (headers optional). We parse Subject and Date when present.
</Typography>
<textarea
value={rawEmail}
onChange={(e) => setRawEmail(e.target.value)}
placeholder={"Subject: ...\nDate: ...\nFrom: ...\nTo: ...\n\nBody..."}
style={{
width: "100%",
minHeight: 220,
resize: "vertical",
padding: 12,
borderRadius: 12,
border:
theme.palette.mode === "dark"
? "1px solid rgba(148,163,184,0.25)"
: "1px solid rgba(15,23,42,0.12)",
background: theme.palette.mode === "dark" ? "rgba(2,6,23,0.25)" : "white",
color: theme.palette.text.primary,
fontFamily: "inherit",
fontSize: 14,
}}
/>
</>
) : (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
<Box>
<Typography sx={{ fontWeight: 800 }}>Google Gmail</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{gmailLoading ? "Checking connection..." : gmailStatus?.connected ? `Connected as ${gmailStatus.gmailAddress}` : "Connect your Gmail account to browse recent emails."}
</Typography>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
{gmailStatus?.connected ? (
<>
<Button variant="outlined" onClick={() => void loadGmailMessages()} disabled={gmailMessagesLoading}>
Refresh
</Button>
<Button variant="outlined" color="error" onClick={() => void disconnectGmail()}>
Disconnect
</Button>
</>
) : (
<Button variant="contained" onClick={() => void connectGmail()}>
Connect Gmail
</Button>
)}
</Box>
</Box>
{gmailStatus?.connected ? (
<>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
<TextField
label="Search Gmail"
value={gmailQuery}
onChange={(e) => setGmailQuery(e.target.value)}
placeholder="from:company@example.com OR interview"
size="small"
fullWidth
/>
<Button variant="outlined" onClick={() => void loadGmailMessages()} disabled={gmailMessagesLoading}>
Search
</Button>
</Box>
{gmailStatus.lastSyncedAt ? (
<Chip label={`Last synced ${new Date(gmailStatus.lastSyncedAt).toLocaleString()}`} size="small" />
) : null}
<Paper variant="outlined" sx={{ maxHeight: 360, overflowY: "auto" }}>
{gmailMessagesLoading ? (
<Box sx={{ py: 5, display: "flex", justifyContent: "center" }}>
<CircularProgress size={28} />
</Box>
) : gmailMessages.length === 0 ? (
<Typography sx={{ color: "text.secondary", p: 2 }}>No Gmail messages found.</Typography>
) : (
<List disablePadding>
{gmailMessages.map((message, index) => (
<React.Fragment key={message.id}>
{index > 0 ? <Divider /> : null}
<ListItemButton sx={{ alignItems: "flex-start", py: 1.5 }}>
<ListItemText
primary={
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
<Typography sx={{ fontWeight: 800 }}>{message.subject || "(No subject)"}</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{message.date ? new Date(message.date).toLocaleString() : ""}
</Typography>
</Box>
}
secondary={
<Box sx={{ mt: 0.5 }}>
<Typography variant="body2" sx={{ color: "text.primary" }}>
From: {message.from || "Unknown"}
</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25 }}>
{message.snippet}
</Typography>
</Box>
}
/>
<Button
variant="contained"
size="small"
disabled={importingMessageId === message.id}
onClick={() => void importGmailMessage(message.id)}
>
{importingMessageId === message.id ? "Importing..." : "Import"}
</Button>
</ListItemButton>
</React.Fragment>
))}
</List>
)}
</Paper>
</>
) : null}
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setImportOpen(false)}>Cancel</Button>
<Button variant="contained" onClick={importEmail}>
Log Email
</Button>
<Button onClick={() => setImportOpen(false)}>Close</Button>
{importTab === 0 ? (
<Button variant="contained" onClick={importEmail}>
Log Email
</Button>
) : null}
</DialogActions>
</Dialog>
</Box>
);
}