Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-18 01:00:08 +00:00
co-authored by renee-png
parent 41f2299fe2
commit c8f250715f
5 changed files with 740 additions and 1 deletions
+281
View File
@@ -0,0 +1,281 @@
import { createFileRoute } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/lib/auth";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Loader2, Inbox, KeyRound, RefreshCcw } from "lucide-react";
import { toast } from "sonner";
export const Route = createFileRoute("/settings/imap")({
component: ImapSettingsPage,
});
const EMPTY = {
host: "",
port: "993",
secure: true,
username: "",
folder: "INBOX",
enabled: true,
notes: "",
};
function ImapSettingsPage() {
const { user, isAdmin } = useAuth();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [polling, setPolling] = useState(false);
const [recordId, setRecordId] = useState<string | null>(null);
const [form, setForm] = useState({ ...EMPTY });
const [meta, setMeta] = useState<{
last_polled_at: string | null;
last_uid: number | null;
last_error: string | null;
}>({ last_polled_at: null, last_uid: null, last_error: null });
const load = async () => {
setLoading(true);
const { data } = await supabase
.from("imap_settings")
.select("*")
.order("updated_at", { ascending: false })
.limit(1)
.maybeSingle();
if (data) {
setRecordId(data.id);
setForm({
host: data.host ?? "",
port: String(data.port ?? 993),
secure: !!data.secure,
username: data.username ?? "",
folder: data.folder ?? "INBOX",
enabled: data.enabled ?? true,
notes: data.notes ?? "",
});
setMeta({
last_polled_at: data.last_polled_at,
last_uid: data.last_uid,
last_error: data.last_error,
});
}
setLoading(false);
};
useEffect(() => {
if (isAdmin) load();
else setLoading(false);
}, [isAdmin]);
const update = (k: keyof typeof EMPTY, v: string | boolean) =>
setForm((f) => ({ ...f, [k]: v }));
const save = async () => {
if (!form.host || !form.username) {
toast.error("Host and username are required");
return;
}
setSaving(true);
const payload = {
host: form.host.trim(),
port: parseInt(form.port, 10) || 993,
secure: form.secure,
username: form.username.trim(),
folder: form.folder.trim() || "INBOX",
enabled: form.enabled,
notes: form.notes || null,
updated_by: user?.id,
};
const { error } = recordId
? await supabase.from("imap_settings").update(payload).eq("id", recordId)
: await supabase.from("imap_settings").insert(payload);
setSaving(false);
if (error) {
toast.error("Could not save", { description: error.message });
return;
}
toast.success("IMAP settings saved");
load();
};
const pollNow = async () => {
setPolling(true);
try {
const res = await fetch("/hooks/poll-imap", { method: "POST" });
const body = await res.json();
if (!res.ok || body.error) {
toast.error("Poll failed", { description: body.error });
} else {
toast.success(`Imported ${body.imported ?? 0} new email(s)`);
}
} catch (e) {
toast.error("Poll failed", {
description: e instanceof Error ? e.message : String(e),
});
} finally {
setPolling(false);
load();
}
};
if (!isAdmin) {
return (
<div className="text-sm text-muted-foreground py-8">
Only administrators can manage IMAP settings.
</div>
);
}
if (loading) {
return (
<div className="flex justify-center py-16">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-6 max-w-3xl">
<Card className="border-border/60">
<CardHeader>
<CardTitle className="font-serif text-base flex items-center gap-2">
<Inbox className="h-4 w-4 text-muted-foreground" /> Incoming mail (IMAP)
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<Field label="IMAP host *">
<Input
value={form.host}
onChange={(e) => update("host", e.target.value)}
placeholder="imap.gmail.com"
/>
</Field>
<Field label="Port *">
<Input
type="number"
value={form.port}
onChange={(e) => update("port", e.target.value)}
placeholder="993"
/>
</Field>
<Field label="Username *">
<Input
value={form.username}
onChange={(e) => update("username", e.target.value)}
placeholder="inbox@yourfirm.com"
/>
</Field>
<Field label="Folder">
<Input
value={form.folder}
onChange={(e) => update("folder", e.target.value)}
placeholder="INBOX"
/>
</Field>
<div className="space-y-1.5 sm:col-span-2">
<Label className="text-xs">Password</Label>
<div className="flex items-center gap-2 h-9 px-3 rounded-md border bg-muted/30 text-xs text-muted-foreground">
<KeyRound className="h-3.5 w-3.5" />
Stored securely as the <code>IMAP_PASSWORD</code> secret. For Gmail/Workspace, use an App Password.
</div>
</div>
<div className="sm:col-span-2 flex items-center justify-between rounded-md border p-3">
<div>
<div className="text-sm font-medium">Use TLS / SSL</div>
<div className="text-xs text-muted-foreground">
Required for port 993. Off for 143 (STARTTLS).
</div>
</div>
<Switch
checked={form.secure}
onCheckedChange={(v) => update("secure", v)}
/>
</div>
<Field label="Notes" className="sm:col-span-2">
<Textarea
rows={2}
value={form.notes}
onChange={(e) => update("notes", e.target.value)}
/>
</Field>
<div className="sm:col-span-2 flex items-center justify-between rounded-md border p-3">
<div>
<div className="text-sm font-medium">Polling enabled</div>
<div className="text-xs text-muted-foreground">
When on, the system fetches new mail automatically every 5 minutes.
</div>
</div>
<Switch
checked={form.enabled}
onCheckedChange={(v) => update("enabled", v)}
/>
</div>
</CardContent>
</Card>
<div className="flex justify-between items-center gap-3">
<Button
variant="outline"
onClick={pollNow}
disabled={polling || !recordId}
>
{polling ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<RefreshCcw className="h-4 w-4 mr-2" />
)}
Poll now
</Button>
<Button onClick={save} disabled={saving}>
{saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Save IMAP settings
</Button>
</div>
{recordId && (
<Card className="border-border/60">
<CardHeader>
<CardTitle className="font-serif text-base">Last poll</CardTitle>
</CardHeader>
<CardContent className="text-sm space-y-1">
<div>
<span className="text-muted-foreground">Last polled at:</span>{" "}
{meta.last_polled_at
? new Date(meta.last_polled_at).toLocaleString()
: "never"}
</div>
<div>
<span className="text-muted-foreground">Last UID seen:</span>{" "}
{meta.last_uid ?? "—"}
</div>
{meta.last_error && (
<div className="text-destructive">
<span className="font-medium">Last error:</span> {meta.last_error}
</div>
)}
</CardContent>
</Card>
)}
</div>
);
}
function Field({
label,
className,
children,
}: {
label: string;
className?: string;
children: React.ReactNode;
}) {
return (
<div className={`space-y-1.5 ${className ?? ""}`}>
<Label className="text-xs">{label}</Label>
{children}
</div>
);
}