Skip to content
File

Blob: src/client/components/admin/domain-manager.tsx

typescript180 lines
1import { useState } from "react";
2import { Globe, Loader2, Plus, Power, PowerOff, Trash2 } from "lucide-react";
3import { toast } from "@/client/components/toast";
4import { Badge, Button, Card, PageHeader } from "@/client/components/ui";
5import { useAdminSessionGuard } from "@/client/hooks/useAdminSessionGuard";
6import { addAdminDomain, deleteAdminDomain, updateAdminDomain, type AdminDomain } from "@/client/lib/api";
7import { fullDate } from "@/client/lib/time";
8 
9interface DomainManagerProps {
10 domains: AdminDomain[];
11 loading: boolean;
12 onAdminSessionError: (message?: string) => void;
13 onReload: () => Promise<void>;
14}
15 
16export function DomainManager({ domains, loading, onAdminSessionError, onReload }: DomainManagerProps) {
17 const [newDomain, setNewDomain] = useState("");
18 const [newDomainActive, setNewDomainActive] = useState(true);
19 const [busy, setBusy] = useState<string | null>(null);
20 const handleAdminError = useAdminSessionGuard(onAdminSessionError);
21 
22 const handleAdd = async (event: React.FormEvent<HTMLFormElement>) => {
23 event.preventDefault();
24 setBusy("add");
25 
26 try {
27 await addAdminDomain(newDomain, newDomainActive);
28 setNewDomain("");
29 setNewDomainActive(true);
30 await onReload();
31 toast.success(`Domain ${newDomain} added`);
32 } catch (error) {
33 handleAdminError(error, toast.error);
34 } finally {
35 setBusy(null);
36 }
37 };
38 
39 const handleToggle = async (domain: AdminDomain) => {
40 setBusy(domain.domain);
41 
42 try {
43 await updateAdminDomain(domain.domain, !domain.isActive);
44 await onReload();
45 toast.success(`${domain.domain} ${domain.isActive ? "disabled" : "enabled"}`);
46 } catch (error) {
47 handleAdminError(error, toast.error);
48 } finally {
49 setBusy(null);
50 }
51 };
52 
53 const handleDelete = async (domain: AdminDomain) => {
54 if (!domain.canDelete) {
55 return;
56 }
57 
58 const confirmed = window.confirm(`Delete ${domain.domain} from the domain pool?`);
59 if (!confirmed) {
60 return;
61 }
62 
63 setBusy(domain.domain);
64 
65 try {
66 await deleteAdminDomain(domain.domain);
67 await onReload();
68 toast.success(`${domain.domain} deleted`);
69 } catch (error) {
70 handleAdminError(error, toast.error);
71 } finally {
72 setBusy(null);
73 }
74 };
75 
76 return (
77 <Card>
78 <div className="flex items-start justify-between gap-4">
79 <PageHeader
80 caption="Domain Pool"
81 captionIcon={<Globe className="h-3.5 w-3.5 text-zinc-500" />}
82 heading="Manage inbound domains"
83 description="Active domains are available for public inbox creation. Disabled domains no longer accept new temporary inboxes or inbound mail."
84 />
85 <Badge>
86 {domains.length} domain{domains.length !== 1 ? "s" : ""}
87 </Badge>
88 </div>
89
90 <form className="mt-5 grid gap-3 md:grid-cols-[minmax(0,1fr)_auto_auto]" onSubmit={handleAdd}>
91 <input
92 type="text"
93 value={newDomain}
94 onChange={(event) => setNewDomain(event.target.value)}
95 placeholder="example.com"
96 disabled={busy === "add"}
97 className="w-full rounded-lg border border-zinc-700/60 bg-zinc-800 px-4 py-2.5 text-sm text-zinc-100 placeholder-zinc-500 outline-none transition-colors focus:border-accent-500/50 focus:ring-1 focus:ring-accent-500/30 disabled:opacity-50"
98 />
99
100 <label className="flex items-center gap-2 rounded-lg border border-zinc-700/60 bg-zinc-800 px-3 py-2.5 text-sm text-zinc-300">
101 <input
102 type="checkbox"
103 checked={newDomainActive}
104 onChange={(event) => setNewDomainActive(event.target.checked)}
105 disabled={busy === "add"}
106 className="h-4 w-4 rounded border-zinc-600 bg-zinc-900 text-accent-500 focus:ring-accent-500/40"
107 />
108 Start active
109 </label>
110
111 <Button
112 variant="primary"
113 size="md"
114 type="submit"
115 loading={busy === "add"}
116 icon={busy !== "add" ? <Plus className="h-3.5 w-3.5" /> : undefined}
117 disabled={newDomain.trim().length === 0}
118 >
119 {busy === "add" ? "Adding..." : "Add domain"}
120 </Button>
121 </form>
122
123 <div className="mt-5 space-y-3">
124 {loading ? (
125 <p className="flex items-center gap-2 text-sm text-zinc-400">
126 <Loader2 className="h-3.5 w-3.5 animate-spin" />
127 Loading domains...
128 </p>
129 ) : null}
130 {!loading && domains.length === 0 ? <p className="text-sm text-zinc-400">No domains configured yet.</p> : null}
131
132 {domains.map((domain) => {
133 const isBusy = busy === domain.domain;
134
135 return (
136 <div key={domain.domain} className="rounded-lg border border-zinc-800/50 bg-zinc-800/30 p-4">
137 <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
138 <div className="min-w-0">
139 <div className="flex flex-wrap items-center gap-2">
140 <strong className="truncate text-sm font-semibold text-zinc-200">{domain.domain}</strong>
141 <Badge
142 variant={domain.isActive ? "success" : "muted"}
143 className="text-[11px] uppercase tracking-wider"
144 >
145 {domain.isActive ? "active" : "disabled"}
146 </Badge>
147 </div>
148 <p className="mt-1 text-xs text-zinc-400">
149 Added {fullDate(domain.createdAt)} · {domain.inboxCount} inbox
150 {domain.inboxCount !== 1 ? "es" : ""}
151 </p>
152 </div>
153
154 <div className="flex flex-wrap gap-2">
155 <Button
156 icon={domain.isActive ? <PowerOff className="h-3 w-3" /> : <Power className="h-3 w-3" />}
157 disabled={isBusy}
158 onClick={() => void handleToggle(domain)}
159 >
160 {isBusy ? "Saving..." : domain.isActive ? "Disable" : "Enable"}
161 </Button>
162 <Button
163 variant="danger"
164 icon={<Trash2 className="h-3 w-3" />}
165 disabled={!domain.canDelete || isBusy}
166 onClick={() => void handleDelete(domain)}
167 title={domain.canDelete ? "Delete domain" : "Cannot delete a domain that still has inboxes"}
168 >
169 Delete
170 </Button>
171 </div>
172 </div>
173 </div>
174 );
175 })}
176 </div>
177 </Card>
178 );
179}