Merge remote-tracking branch 'origin/email-imap'
This commit is contained in:
@@ -6,14 +6,13 @@ import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
import { useJobs } from 'hooks/useJobs';
|
||||
import type { EmailSummary } from 'types';
|
||||
|
||||
type GoogleStatus = {
|
||||
configured: boolean;
|
||||
connected: boolean;
|
||||
email: string | null;
|
||||
picture: string | null;
|
||||
type EmailAccountRow = {
|
||||
id: number;
|
||||
provider: string;
|
||||
email: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
const LIMIT = 50;
|
||||
@@ -42,13 +41,13 @@ export const EmailList = () => {
|
||||
const [selectedId, setSelectedId] = useGlobal<string | null>('EMAIL_SELECTED', null);
|
||||
const [folder, setFolder] = useGlobal<string>('EMAIL_FOLDER', 'inbox');
|
||||
const [page, setPage] = useState(1);
|
||||
const { jobs, createJob } = useJobs({ type: 'gmail-sync' });
|
||||
const isSyncing = jobs.some((j) => j.status === 'queued' || j.status === 'running');
|
||||
|
||||
const { data: googleStatus } = useQuery({
|
||||
queryKey: ['google-status'],
|
||||
queryFn: () => client.get<GoogleStatus>('/integrations/google/status'),
|
||||
const { data: emailAccounts = [], refetch: refetchAccounts } = useQuery({
|
||||
queryKey: ['email-accounts'],
|
||||
queryFn: () => client.get<EmailAccountRow[]>('/email/accounts'),
|
||||
});
|
||||
const syncableAccount = emailAccounts.find((a) => a.status === 'connected' || a.status === 'synced');
|
||||
const isSyncing = emailAccounts.some((a) => a.status === 'syncing' || a.status === 'queued');
|
||||
const hasAccounts = emailAccounts.length > 0;
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['email-messages', page, folder],
|
||||
@@ -75,17 +74,23 @@ export const EmailList = () => {
|
||||
};
|
||||
|
||||
const handleSync = async () => {
|
||||
if (!syncableAccount) return;
|
||||
try {
|
||||
await createJob({ lane: 'google-api', type: 'gmail-sync', notify: false });
|
||||
toast.success('Gmail sync started');
|
||||
} catch {
|
||||
toast.error('Failed to start sync');
|
||||
await client.post(`/email/accounts/${syncableAccount.id}/sync`, {});
|
||||
toast.success('Sync started');
|
||||
refetchAccounts();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to start sync');
|
||||
}
|
||||
};
|
||||
|
||||
// Refresh email list when a sync job completes
|
||||
// Poll accounts while syncing, refresh email list when done
|
||||
const prevSyncing = useRef(false);
|
||||
useEffect(() => {
|
||||
if (isSyncing) {
|
||||
const interval = setInterval(refetchAccounts, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
if (prevSyncing.current && !isSyncing) {
|
||||
queryClient.invalidateQueries({ queryKey: ['email-messages'] });
|
||||
}
|
||||
@@ -134,16 +139,14 @@ export const EmailList = () => {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-sm opacity-50">
|
||||
<Mail className="h-8 w-8" />
|
||||
{googleStatus && !googleStatus.configured ? (
|
||||
<span>Google integration not configured. Contact your administrator.</span>
|
||||
) : googleStatus && !googleStatus.connected ? (
|
||||
{!hasAccounts ? (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span>Connect your Google account to sync emails</span>
|
||||
<span>Add an email account to get started</span>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to="/settings/integrations">Connect Google</Link>
|
||||
<Link to="/settings/integrations">Add Account</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
) : syncableAccount ? (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span>No emails synced yet</span>
|
||||
<Button variant="outline" size="sm" onClick={handleSync} disabled={isSyncing}>
|
||||
@@ -151,6 +154,13 @@ export const EmailList = () => {
|
||||
Sync Now
|
||||
</Button>
|
||||
</div>
|
||||
) : isSyncing ? (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<span>Syncing emails...</span>
|
||||
</div>
|
||||
) : (
|
||||
<span>No emails synced yet</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -179,18 +189,17 @@ export const EmailList = () => {
|
||||
})}
|
||||
</div>
|
||||
<span className="text-xs opacity-50">{total}</span>
|
||||
<button
|
||||
onClick={handleSync}
|
||||
disabled={isSyncing}
|
||||
className="p-0.5 rounded hover:bg-black/10 transition-colors cursor-pointer disabled:cursor-default disabled:opacity-50 shrink-0"
|
||||
title={isSyncing ? 'Syncing...' : 'Sync emails'}
|
||||
>
|
||||
{isSyncing ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
{isSyncing ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin shrink-0 opacity-50" title="Syncing..." />
|
||||
) : syncableAccount ? (
|
||||
<button
|
||||
onClick={handleSync}
|
||||
className="p-0.5 rounded hover:bg-black/10 transition-colors cursor-pointer shrink-0"
|
||||
title="Sync emails"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</button>
|
||||
) : null}
|
||||
{totalPages > 1 && (
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
|
||||
+508
@@ -0,0 +1,508 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Plus, Trash2, Loader2, Mail, Server, KeyRound, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useJobs } from 'hooks/useJobs';
|
||||
|
||||
type EmailAccountRow = {
|
||||
id: number;
|
||||
provider: string;
|
||||
email: string;
|
||||
displayName: string | null;
|
||||
enabled: boolean;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type ProviderChoice = 'gmail-oauth' | 'gmail-password' | 'imap';
|
||||
|
||||
type FormState = {
|
||||
email: string;
|
||||
displayName: string;
|
||||
password: string;
|
||||
imapHost: string;
|
||||
imapPort: string;
|
||||
imapSecure: boolean;
|
||||
};
|
||||
|
||||
const INITIAL_FORM: FormState = {
|
||||
email: '',
|
||||
displayName: '',
|
||||
password: '',
|
||||
imapHost: '',
|
||||
imapPort: '993',
|
||||
imapSecure: true,
|
||||
};
|
||||
|
||||
export const EmailAccounts = () => {
|
||||
const client = useClient();
|
||||
const { user } = useAuth();
|
||||
const { jobs } = useJobs({ type: 'email-sync' });
|
||||
const [accounts, setAccounts] = useState<EmailAccountRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
const [activeForm, setActiveForm] = useState<ProviderChoice | null>(null);
|
||||
const [form, setForm] = useState<FormState>(INITIAL_FORM);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleting, setDeleting] = useState<number | null>(null);
|
||||
const [syncing, setSyncing] = useState<number | null>(null);
|
||||
|
||||
const fetchAccounts = () => {
|
||||
client
|
||||
.get<EmailAccountRow[]>('/email/accounts')
|
||||
.then(setAccounts)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAccounts();
|
||||
}, []);
|
||||
|
||||
// Poll accounts while any are queued/syncing to pick up status changes
|
||||
const hasSyncingAccount = accounts.some((a) => a.status === 'syncing' || a.status === 'queued');
|
||||
useEffect(() => {
|
||||
if (!hasSyncingAccount) return;
|
||||
const interval = setInterval(fetchAccounts, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [hasSyncingAccount]);
|
||||
|
||||
const resetForm = () => {
|
||||
setActiveForm(null);
|
||||
setShowPicker(false);
|
||||
setForm(INITIAL_FORM);
|
||||
};
|
||||
|
||||
const handlePickProvider = (choice: ProviderChoice) => {
|
||||
setShowPicker(false);
|
||||
setActiveForm(choice);
|
||||
if (choice === 'gmail-oauth' || choice === 'gmail-password') {
|
||||
setForm({ ...INITIAL_FORM, imapHost: 'imap.gmail.com', imapPort: '993', imapSecure: true });
|
||||
} else {
|
||||
setForm(INITIAL_FORM);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGmailOAuth = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const status = await client.get<{ connected: boolean; email: string | null }>('/integrations/google/status');
|
||||
if (!status.connected) {
|
||||
const params = new URLSearchParams({ token: client.token ?? '', origin: window.location.origin });
|
||||
window.location.href = `/api/integrations/google/authorize?${params.toString()}`;
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await client.post<{ id: number; provider: string; email: string }>('/email/accounts', {
|
||||
provider: 'gmail',
|
||||
email: status.email,
|
||||
displayName: form.displayName || undefined,
|
||||
imapHost: 'imap.gmail.com',
|
||||
imapPort: 993,
|
||||
imapSecure: true,
|
||||
authType: 'oauth',
|
||||
credentials: { userIntegrationId: true },
|
||||
});
|
||||
|
||||
toast.success(`Added ${result.email}`);
|
||||
resetForm();
|
||||
fetchAccounts();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to add Gmail account');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordSubmit = async () => {
|
||||
if (!form.email.trim() || !form.password.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const provider = form.imapHost === 'imap.gmail.com' ? 'gmail' : 'imap';
|
||||
const result = await client.post<{ id: number; provider: string; email: string }>('/email/accounts', {
|
||||
provider,
|
||||
email: form.email.trim(),
|
||||
displayName: form.displayName.trim() || undefined,
|
||||
imapHost: form.imapHost,
|
||||
imapPort: Number(form.imapPort),
|
||||
imapSecure: form.imapSecure,
|
||||
authType: 'password',
|
||||
credentials: { password: form.password },
|
||||
});
|
||||
|
||||
toast.success(`Added ${result.email}`);
|
||||
resetForm();
|
||||
fetchAccounts();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to add account');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
setDeleting(id);
|
||||
try {
|
||||
await client.delete(`/email/accounts/${id}`);
|
||||
setAccounts((prev) => prev.filter((a) => a.id !== id));
|
||||
toast.success('Account removed');
|
||||
} catch {
|
||||
toast.error('Failed to remove account');
|
||||
} finally {
|
||||
setDeleting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSync = async (id: number) => {
|
||||
setSyncing(id);
|
||||
try {
|
||||
await client.post(`/email/accounts/${id}/sync`, {});
|
||||
toast.success('Sync started');
|
||||
// Update local state immediately
|
||||
setAccounts((prev) => prev.map((a) => (a.id === id ? { ...a, status: 'queued' } : a)));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to start sync');
|
||||
} finally {
|
||||
setSyncing(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return null;
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Email Accounts</p>
|
||||
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 mt-1">
|
||||
Connect email accounts for syncing your inbox. Supports Gmail and any IMAP provider.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Account list */}
|
||||
{accounts.length > 0 && (
|
||||
<div className="grid gap-2">
|
||||
{accounts.map((account) => {
|
||||
const accountJob = jobs.find(
|
||||
(j) =>
|
||||
(j.status === 'queued' || j.status === 'running') &&
|
||||
(j.meta as Record<string, unknown> | undefined)?.emailAccountId === account.id,
|
||||
);
|
||||
const progress = accountJob?.steps[accountJob.currentStep]?.progress;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={account.id}
|
||||
className="rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<ProviderIcon provider={account.provider} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-duck-dark dark:text-foreground truncate">
|
||||
{account.displayName ?? account.email}
|
||||
</p>
|
||||
{account.displayName && (
|
||||
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 truncate">{account.email}</p>
|
||||
)}
|
||||
</div>
|
||||
<StatusBadge status={account.status} />
|
||||
{account.status === 'queued' && (
|
||||
<span className="text-xs text-duck-dark/50 dark:text-foreground/50">Waiting...</span>
|
||||
)}
|
||||
{account.status === 'connected' && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleSync(account.id)}
|
||||
disabled={syncing === account.id}
|
||||
className="h-7 text-xs cursor-pointer gap-1.5"
|
||||
>
|
||||
{syncing === account.id ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
)}
|
||||
Initial Sync
|
||||
</Button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDelete(account.id)}
|
||||
disabled={deleting === account.id || account.status === 'syncing' || account.status === 'queued'}
|
||||
className="text-duck-dark/40 dark:text-foreground/40 hover:text-red-500 transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
{deleting === account.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{/* Sync progress */}
|
||||
{account.status === 'syncing' && progress && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Loader2 className="h-3 w-3 animate-spin text-blue-500 shrink-0" />
|
||||
<p className="text-xs text-duck-dark/60 dark:text-foreground/60 truncate">
|
||||
{progress.label ?? 'Syncing...'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{account.status === 'syncing' && !progress && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Loader2 className="h-3 w-3 animate-spin text-blue-500 shrink-0" />
|
||||
<p className="text-xs text-duck-dark/60 dark:text-foreground/60">Syncing...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Provider picker */}
|
||||
{showPicker && !activeForm && (
|
||||
<div className="grid gap-2">
|
||||
<button
|
||||
onClick={() => handlePickProvider('gmail-oauth')}
|
||||
className="flex items-center gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors cursor-pointer text-left"
|
||||
>
|
||||
<Mail className="h-4 w-4 text-duck-dark/60 dark:text-foreground/60" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Gmail (OAuth)</p>
|
||||
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">Uses your existing Google connection</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePickProvider('gmail-password')}
|
||||
className="flex items-center gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors cursor-pointer text-left"
|
||||
>
|
||||
<KeyRound className="h-4 w-4 text-duck-dark/60 dark:text-foreground/60" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Gmail (App Password)</p>
|
||||
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
Direct IMAP with a Google App Password
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePickProvider('imap')}
|
||||
className="flex items-center gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors cursor-pointer text-left"
|
||||
>
|
||||
<Server className="h-4 w-4 text-duck-dark/60 dark:text-foreground/60" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-duck-dark dark:text-foreground">IMAP Account</p>
|
||||
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">Any email provider with IMAP access</p>
|
||||
</div>
|
||||
</button>
|
||||
<Button type="button" variant="ghost" onClick={resetForm} className="h-9 cursor-pointer">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gmail OAuth form */}
|
||||
{activeForm === 'gmail-oauth' && (
|
||||
<div className="grid gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4">
|
||||
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Add Gmail (OAuth)</p>
|
||||
<input
|
||||
type="text"
|
||||
value={form.displayName}
|
||||
onChange={(ev) => setForm({ ...form, displayName: ev.target.value })}
|
||||
placeholder="Display name (optional)"
|
||||
className="h-9 rounded-md border border-duck-dark/10 dark:border-foreground/10 bg-transparent px-3 text-sm"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="ghost" onClick={resetForm} className="h-9 cursor-pointer">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" onClick={handleGmailOAuth} disabled={saving} className="h-9 flex-1 cursor-pointer">
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Connect & Add'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gmail App Password form */}
|
||||
{activeForm === 'gmail-password' && (
|
||||
<div className="grid gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4">
|
||||
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Add Gmail (App Password)</p>
|
||||
<div className="text-xs text-duck-dark/50 dark:text-foreground/50 grid gap-1.5">
|
||||
<p>To create an App Password:</p>
|
||||
<ol className="list-decimal ml-4 grid gap-0.5">
|
||||
<li>
|
||||
Go to{' '}
|
||||
<a
|
||||
href="https://myaccount.google.com/apppasswords"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:opacity-70"
|
||||
>
|
||||
Google App Passwords
|
||||
</a>
|
||||
</li>
|
||||
<li>You may need to enable 2-Step Verification first</li>
|
||||
<li>Enter a name (e.g. "Officer") and click Create</li>
|
||||
<li>Copy the 16-character password and paste it below</li>
|
||||
</ol>
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(ev) => setForm({ ...form, email: ev.target.value })}
|
||||
placeholder="your@gmail.com"
|
||||
className="h-9 rounded-md border border-duck-dark/10 dark:border-foreground/10 bg-transparent px-3 text-sm"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={form.displayName}
|
||||
onChange={(ev) => setForm({ ...form, displayName: ev.target.value })}
|
||||
placeholder="Display name (optional)"
|
||||
className="h-9 rounded-md border border-duck-dark/10 dark:border-foreground/10 bg-transparent px-3 text-sm"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(ev) => setForm({ ...form, password: ev.target.value })}
|
||||
placeholder="xxxx xxxx xxxx xxxx"
|
||||
className="h-9 rounded-md border border-duck-dark/10 dark:border-foreground/10 bg-transparent px-3 text-sm"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="ghost" onClick={resetForm} className="h-9 cursor-pointer">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handlePasswordSubmit}
|
||||
disabled={!form.email.trim() || !form.password.trim() || saving}
|
||||
className="h-9 flex-1 cursor-pointer"
|
||||
>
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Test & Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generic IMAP form */}
|
||||
{activeForm === 'imap' && (
|
||||
<div className="grid gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4">
|
||||
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Add IMAP Account</p>
|
||||
<input
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(ev) => setForm({ ...form, email: ev.target.value })}
|
||||
placeholder="you@example.com"
|
||||
className="h-9 rounded-md border border-duck-dark/10 dark:border-foreground/10 bg-transparent px-3 text-sm"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={form.displayName}
|
||||
onChange={(ev) => setForm({ ...form, displayName: ev.target.value })}
|
||||
placeholder="Display name (optional)"
|
||||
className="h-9 rounded-md border border-duck-dark/10 dark:border-foreground/10 bg-transparent px-3 text-sm"
|
||||
/>
|
||||
<div className="grid grid-cols-[1fr_auto_auto] gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={form.imapHost}
|
||||
onChange={(ev) => setForm({ ...form, imapHost: ev.target.value })}
|
||||
placeholder="imap.example.com"
|
||||
className="h-9 rounded-md border border-duck-dark/10 dark:border-foreground/10 bg-transparent px-3 text-sm"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
value={form.imapPort}
|
||||
onChange={(ev) => setForm({ ...form, imapPort: ev.target.value })}
|
||||
placeholder="993"
|
||||
className="h-9 w-20 rounded-md border border-duck-dark/10 dark:border-foreground/10 bg-transparent px-3 text-sm"
|
||||
/>
|
||||
<label className="flex items-center gap-1.5 text-xs text-duck-dark/60 dark:text-foreground/60 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.imapSecure}
|
||||
onChange={(ev) => setForm({ ...form, imapSecure: ev.target.checked })}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
SSL
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(ev) => setForm({ ...form, password: ev.target.value })}
|
||||
placeholder="Password"
|
||||
className="h-9 rounded-md border border-duck-dark/10 dark:border-foreground/10 bg-transparent px-3 text-sm"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="ghost" onClick={resetForm} className="h-9 cursor-pointer">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handlePasswordSubmit}
|
||||
disabled={!form.email.trim() || !form.password.trim() || !form.imapHost.trim() || saving}
|
||||
className="h-9 flex-1 cursor-pointer"
|
||||
>
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Test & Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add account button */}
|
||||
{!showPicker && !activeForm && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setShowPicker(true)}
|
||||
className="w-full h-11 cursor-pointer gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Account
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const StatusBadge = ({ status }: { status: string }) => {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return (
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-yellow-500/10 text-yellow-600 dark:text-yellow-400">
|
||||
Connected
|
||||
</span>
|
||||
);
|
||||
case 'queued':
|
||||
return (
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-orange-500/10 text-orange-600 dark:text-orange-400">
|
||||
Queued
|
||||
</span>
|
||||
);
|
||||
case 'syncing':
|
||||
return (
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-blue-500/10 text-blue-600 dark:text-blue-400">
|
||||
Syncing
|
||||
</span>
|
||||
);
|
||||
case 'synced':
|
||||
return (
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-green-500/10 text-green-600 dark:text-green-400">
|
||||
Synced
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const ProviderIcon = ({ provider }: { provider: string }) => {
|
||||
switch (provider) {
|
||||
case 'gmail':
|
||||
return <Mail className="h-4 w-4 text-red-500 shrink-0" />;
|
||||
case 'outlook':
|
||||
return <Mail className="h-4 w-4 text-blue-500 shrink-0" />;
|
||||
default:
|
||||
return <Server className="h-4 w-4 text-duck-dark/60 dark:text-foreground/60 shrink-0" />;
|
||||
}
|
||||
};
|
||||
+31
-244
@@ -1,64 +1,32 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { RefreshCw, Loader2, CheckCircle2, XCircle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useJobs } from 'hooks/useJobs';
|
||||
|
||||
type GoogleStatus = {
|
||||
connected: boolean;
|
||||
email: string | null;
|
||||
picture: string | null;
|
||||
configured: boolean;
|
||||
hasAppPassword: boolean;
|
||||
};
|
||||
|
||||
const formatTime = (ts: number | string) => {
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
|
||||
};
|
||||
|
||||
export const GoogleAccount = () => {
|
||||
const client = useClient();
|
||||
const { user } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [status, setStatus] = useState<GoogleStatus>({
|
||||
connected: false,
|
||||
email: null,
|
||||
picture: null,
|
||||
configured: false,
|
||||
hasAppPassword: false,
|
||||
});
|
||||
const [gmailEmail, setGmailEmail] = useState('');
|
||||
const [appPassword, setAppPassword] = useState('');
|
||||
const [showPasswordInput, setShowPasswordInput] = useState(false);
|
||||
const [savingPassword, setSavingPassword] = useState(false);
|
||||
const [lastSyncAt, setLastSyncAt] = useState<string | null>(null);
|
||||
const [dismissedError, setDismissedError] = useState(false);
|
||||
const { jobs, createJob, refetch } = useJobs({ type: 'gmail-sync' });
|
||||
const activeJob = jobs.find((j) => j.status === 'queued' || j.status === 'running');
|
||||
const lastJob = jobs[0];
|
||||
|
||||
const fetchStatus = () => {
|
||||
client
|
||||
.get<GoogleStatus>('/integrations/google/status')
|
||||
.then((s) => {
|
||||
setStatus(s);
|
||||
setGmailEmail(s.email ?? user?.email ?? '');
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setIsLoading(false));
|
||||
client
|
||||
.get<{ lastSyncAt: string | null }>('/email/sync-status')
|
||||
.then((res) => setLastSyncAt(res.lastSyncAt))
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
client
|
||||
.get<GoogleStatus>('/integrations/google/status')
|
||||
.then(setStatus)
|
||||
.catch(() => {})
|
||||
.finally(() => setIsLoading(false));
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const result = params.get('google');
|
||||
if (result === 'success') {
|
||||
@@ -71,27 +39,6 @@ export const GoogleAccount = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Refresh sync status and dock when a job finishes
|
||||
useEffect(() => {
|
||||
if (!activeJob && lastJob?.status === 'completed') {
|
||||
client
|
||||
.get<{ lastSyncAt: string | null }>('/email/sync-status')
|
||||
.then((res) => setLastSyncAt(res.lastSyncAt))
|
||||
.catch(() => {});
|
||||
queryClient.invalidateQueries({ queryKey: ['DOCK'] });
|
||||
}
|
||||
}, [activeJob, lastJob?.status]);
|
||||
|
||||
const handleSync = async (year?: number) => {
|
||||
try {
|
||||
await createJob({ lane: 'google-api', type: 'gmail-sync', meta: year ? { year } : undefined });
|
||||
await refetch();
|
||||
toast.success("Gmail sync started — you'll receive an email when it's done");
|
||||
} catch {
|
||||
toast.error('Failed to start Gmail sync');
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnect = () => {
|
||||
const params = new URLSearchParams({
|
||||
token: client.token ?? '',
|
||||
@@ -110,201 +57,41 @@ export const GoogleAccount = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAppPassword = async () => {
|
||||
if (!appPassword.trim() || !gmailEmail.trim()) return;
|
||||
setSavingPassword(true);
|
||||
try {
|
||||
await client.put('/integrations/google/app-password', {
|
||||
appPassword: appPassword.trim(),
|
||||
email: gmailEmail.trim(),
|
||||
});
|
||||
setStatus({ ...status, hasAppPassword: true, email: gmailEmail.trim() });
|
||||
setAppPassword('');
|
||||
setShowPasswordInput(false);
|
||||
toast.success('App password saved');
|
||||
} catch {
|
||||
toast.error('Failed to save app password');
|
||||
} finally {
|
||||
setSavingPassword(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
const currentStep = activeJob?.steps[activeJob.currentStep];
|
||||
const progress = currentStep?.progress;
|
||||
if (!status.configured) return null;
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
{/* Gmail Sync — independent of OAuth */}
|
||||
<div className="grid gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Gmail Sync</p>
|
||||
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 mt-1">
|
||||
Import and sync your Gmail emails with your Officer inbox. This uses a Google App Password for a direct IMAP
|
||||
connection — the only thing it can do is download your emails. It cannot send, delete, or modify anything in
|
||||
your account.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4 grid gap-2">
|
||||
<p className="text-sm font-medium text-duck-dark dark:text-foreground">App Password</p>
|
||||
{status.hasAppPassword && !showPasswordInput ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-green-600 dark:text-green-400">Configured</span>
|
||||
{status.email && (
|
||||
<span className="text-xs text-duck-dark/50 dark:text-foreground/50">({status.email})</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowPasswordInput(true)}
|
||||
className="text-xs text-duck-dark/50 dark:text-foreground/50 underline hover:opacity-70 cursor-pointer"
|
||||
>
|
||||
Replace
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-xs text-duck-dark/50 dark:text-foreground/50 grid gap-1.5">
|
||||
<p>To create an App Password:</p>
|
||||
<ol className="list-decimal ml-4 grid gap-0.5">
|
||||
<li>
|
||||
Go to{' '}
|
||||
<a
|
||||
href="https://myaccount.google.com/apppasswords"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:opacity-70"
|
||||
>
|
||||
Google App Passwords
|
||||
</a>
|
||||
</li>
|
||||
<li>You may need to enable 2-Step Verification first</li>
|
||||
<li>Enter a name (e.g. "Officer") and click Create</li>
|
||||
<li>Copy the 16-character password and paste it below</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<input
|
||||
type="email"
|
||||
value={gmailEmail}
|
||||
onChange={(ev) => setGmailEmail(ev.target.value)}
|
||||
placeholder="your@gmail.com"
|
||||
className="h-9 rounded-md border border-duck-dark/10 dark:border-foreground/10 bg-transparent px-3 text-sm"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="password"
|
||||
value={appPassword}
|
||||
onChange={(ev) => setAppPassword(ev.target.value)}
|
||||
placeholder="xxxx xxxx xxxx xxxx"
|
||||
className="flex-1 h-9 rounded-md border border-duck-dark/10 dark:border-foreground/10 bg-transparent px-3 text-sm"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!appPassword.trim() || !gmailEmail.trim() || savingPassword}
|
||||
onClick={handleSaveAppPassword}
|
||||
className="h-9 cursor-pointer"
|
||||
>
|
||||
{savingPassword ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{activeJob && (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-duck-dark/60 dark:text-foreground/60 shrink-0" />
|
||||
<div className="grid gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Google Account</p>
|
||||
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 mt-1">
|
||||
Connect your Google account for Calendar, Contacts, and other Google services.
|
||||
</p>
|
||||
</div>
|
||||
{status.connected ? (
|
||||
<>
|
||||
<div className="flex items-center gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4">
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-green-500 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-duck-dark dark:text-foreground">
|
||||
{activeJob.status === 'queued' ? 'Queued' : 'Syncing'}
|
||||
{progress?.label ? ` — ${progress.label}` : ''}
|
||||
</p>
|
||||
{progress && progress.total > 0 && (
|
||||
<div className="mt-1.5 h-1.5 rounded-full bg-duck-dark/10 dark:bg-foreground/10 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-duck-yellow transition-all duration-300"
|
||||
style={{ width: `${Math.round((progress.current / progress.total) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{progress && progress.total === 0 && progress.current > 0 && (
|
||||
<p className="mt-1 text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
{progress.current.toLocaleString()} emails processed
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Connected</p>
|
||||
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 truncate">{status.email}</p>
|
||||
</div>
|
||||
{status.picture && (
|
||||
<img src={status.picture} alt="" className="h-9 w-9 rounded-full shrink-0" referrerPolicy="no-referrer" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!activeJob && lastJob?.status === 'failed' && !dismissedError && (
|
||||
<button
|
||||
onClick={() => setDismissedError(true)}
|
||||
className="flex items-center gap-2 text-xs text-red-500 hover:opacity-70 transition-opacity cursor-pointer text-left"
|
||||
title="Click to dismiss"
|
||||
>
|
||||
<XCircle className="h-3.5 w-3.5 shrink-0" />
|
||||
Last sync failed{lastJob.error ? `: ${lastJob.error}` : ''}
|
||||
</button>
|
||||
)}
|
||||
{!activeJob && lastSyncAt && (
|
||||
<div className="flex items-center gap-2 text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />
|
||||
Last sync completed {formatTime(lastSyncAt)}
|
||||
</div>
|
||||
)}
|
||||
<Button type="button" variant="outline" onClick={handleDisconnect} className="w-full h-11 cursor-pointer">
|
||||
Disconnect
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!!activeJob || !status.hasAppPassword}
|
||||
onClick={() => handleSync()}
|
||||
className="w-full h-11 cursor-pointer gap-2"
|
||||
onClick={handleConnect}
|
||||
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Sync Gmail Inbox
|
||||
Connect Google Account
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* OAuth — for Calendar and other Google services */}
|
||||
{status.configured && (
|
||||
<div className="grid gap-4 border-t border-duck-dark/10 dark:border-foreground/10 pt-6">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Google Account</p>
|
||||
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 mt-1">
|
||||
Connect your Google account for Calendar, Contacts, and other Google services. This is separate from Gmail
|
||||
sync above.
|
||||
</p>
|
||||
</div>
|
||||
{status.connected ? (
|
||||
<>
|
||||
<div className="flex items-center gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4">
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-green-500 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Connected</p>
|
||||
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 truncate">{status.email}</p>
|
||||
</div>
|
||||
{status.picture && (
|
||||
<img
|
||||
src={status.picture}
|
||||
alt=""
|
||||
className="h-9 w-9 rounded-full shrink-0"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Button type="button" variant="outline" onClick={handleDisconnect} className="w-full h-11 cursor-pointer">
|
||||
Disconnect
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleConnect}
|
||||
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer"
|
||||
>
|
||||
Connect Google Account
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Puzzle, KeyRound, UserCircle, MessageCircle, Globe, Wrench } from 'lucide-react';
|
||||
import { Puzzle, KeyRound, UserCircle, MessageCircle, Globe, Wrench, Mail } from 'lucide-react';
|
||||
import type { LayoutNode, PanelComponents } from 'officerdev';
|
||||
import { WorkspaceLayout } from 'officerdev';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
@@ -17,6 +17,7 @@ import { WhatsAppBotConfig } from './WhatsAppBotConfig';
|
||||
import { WhatsAppAccount } from './WhatsAppAccount';
|
||||
import { BrowserRelay } from './BrowserRelay';
|
||||
import { ApifyConfig } from './ApifyConfig';
|
||||
import { EmailAccounts } from './EmailAccounts';
|
||||
|
||||
const GLOBAL_KEY = 'INTEGRATIONS_SETTINGS_SELECTED';
|
||||
const TAB_KEY = 'INTEGRATIONS_SETTINGS_TAB';
|
||||
@@ -60,6 +61,13 @@ const enterpriseSections: SettingsSection[] = [
|
||||
];
|
||||
|
||||
const personalSections: SettingsSection[] = [
|
||||
{
|
||||
key: 'email-accounts',
|
||||
icon: Mail,
|
||||
title: 'Email',
|
||||
description: 'Connect email accounts for inbox sync',
|
||||
content: <EmailAccounts />,
|
||||
},
|
||||
{
|
||||
key: 'google-account',
|
||||
icon: UserCircle,
|
||||
|
||||
Reference in New Issue
Block a user