suppression-controls.tsx196 lines · main
1'use client';
2
3import { useRouter } from 'next/navigation';
4import { useState, useTransition } from 'react';
5
6import { StepUpPrompt } from '@/components/step-up-prompt';
7
8interface Props {
9 apiOrigin: string;
10}
11
12type PendingAction =
13 | { kind: 'suppress'; email: string }
14 | { kind: 'unsuppress'; email: string };
15
16/**
17 * Holds the add-suppression form and surfaces a global step-up prompt
18 * shared across the page's two write surfaces (add new, remove
19 * existing). Per-row remove buttons live in the sibling table inside
20 * a sub-component that calls into this controller via custom event,
21 * but for the launch slice we keep it inline: each row renders its
22 * own remove button via the same patch handler exposed below.
23 *
24 * The single hidden form pattern means the operator types their
25 * password once for a burst of suppressions instead of per-row.
26 */
27export function SuppressionControls({ apiOrigin }: Props) {
28 const router = useRouter();
29 const [email, setEmail] = useState('');
30 const [busy, setBusy] = useState(false);
31 const [error, setError] = useState<string | null>(null);
32 const [pending, setPending] = useState<PendingAction | null>(null);
33 const [, startTransition] = useTransition();
34
35 async function run(action: PendingAction) {
36 setBusy(true);
37 setError(null);
38 try {
39 const res =
40 action.kind === 'suppress'
41 ? await fetch(`${apiOrigin}/v1/admin/email-suppressions`, {
42 method: 'POST',
43 credentials: 'include',
44 headers: { 'content-type': 'application/json' },
45 body: JSON.stringify({ email: action.email, reason: 'manual' }),
46 })
47 : await fetch(
48 `${apiOrigin}/v1/admin/email-suppressions/${encodeURIComponent(action.email)}`,
49 { method: 'DELETE', credentials: 'include' },
50 );
51 if (res.status === 403) {
52 const body = (await res.json().catch(() => null)) as { code?: string } | null;
53 if (body?.code === 'step_up_required') {
54 setPending(action);
55 return;
56 }
57 }
58 if (!res.ok) {
59 const body = await res.text().catch(() => '');
60 throw new Error(body || `${action.kind} failed: ${res.status}`);
61 }
62 if (action.kind === 'suppress') setEmail('');
63 startTransition(() => router.refresh());
64 } catch (err) {
65 setError(err instanceof Error ? err.message : `${action.kind} failed`);
66 } finally {
67 setBusy(false);
68 }
69 }
70
71 function submitAdd(e: React.FormEvent) {
72 e.preventDefault();
73 const trimmed = email.trim();
74 if (!trimmed) return;
75 void run({ kind: 'suppress', email: trimmed });
76 }
77
78 return (
79 <>
80 <form
81 onSubmit={submitAdd}
82 className="flex flex-wrap items-center gap-3 rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6 font-mono text-xs"
83 >
84 <span className="text-[var(--color-text-muted)]">add manual:</span>
85 <input
86 type="email"
87 required
88 placeholder="user@example.com"
89 value={email}
90 onChange={(e) => setEmail(e.target.value)}
91 disabled={busy}
92 className="flex-1 rounded-sm border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-2 py-1 outline-none focus:border-[var(--color-primary)] disabled:opacity-50"
93 />
94 <button
95 type="submit"
96 disabled={busy || !email.trim()}
97 className="rounded-md bg-[var(--color-primary)] px-3 py-1 font-medium text-[var(--color-text-inverse)] hover:bg-[var(--color-primary-hover)] disabled:opacity-50"
98 >
99 {busy && pending?.kind === 'suppress' ? 'adding…' : 'suppress'}
100 </button>
101 </form>
102
103 {error ? (
104 <p className="font-mono text-xs text-[var(--color-error)]">{error}</p>
105 ) : null}
106
107 {pending ? (
108 <StepUpPrompt
109 apiOrigin={apiOrigin}
110 reason={
111 pending.kind === 'suppress'
112 ? 'adding a manual suppression blocks email delivery to this address. confirm with your password.'
113 : 'removing a suppression re-enables delivery to a previously bounced address. confirm with your password.'
114 }
115 onSuccess={async () => {
116 const action = pending;
117 setPending(null);
118 await run(action);
119 }}
120 onCancel={() => setPending(null)}
121 />
122 ) : null}
123 </>
124 );
125}
126
127/**
128 * Per-row remove button. Kept simple — fires a fetch against the
129 * delete endpoint, surfaces step-up via a row-local prompt. The
130 * trade-off: typing your password twice within 10 min of each other
131 * is fine because the auth window is bumped on the first success.
132 */
133export function RemoveSuppressionButton({
134 email,
135 apiOrigin,
136}: {
137 email: string;
138 apiOrigin: string;
139}) {
140 const router = useRouter();
141 const [busy, setBusy] = useState(false);
142 const [pending, setPending] = useState(false);
143 const [error, setError] = useState<string | null>(null);
144 const [, startTransition] = useTransition();
145
146 async function run() {
147 setBusy(true);
148 setError(null);
149 try {
150 const res = await fetch(
151 `${apiOrigin}/v1/admin/email-suppressions/${encodeURIComponent(email)}`,
152 { method: 'DELETE', credentials: 'include' },
153 );
154 if (res.status === 403) {
155 const body = (await res.json().catch(() => null)) as { code?: string } | null;
156 if (body?.code === 'step_up_required') {
157 setPending(true);
158 return;
159 }
160 }
161 if (!res.ok) throw new Error(`remove failed: ${res.status}`);
162 startTransition(() => router.refresh());
163 } catch (err) {
164 setError(err instanceof Error ? err.message : 'remove failed');
165 } finally {
166 setBusy(false);
167 }
168 }
169
170 return (
171 <>
172 <button
173 type="button"
174 onClick={() => void run()}
175 disabled={busy}
176 className="rounded-md border border-[var(--color-border)] px-2 py-1 font-mono text-xs text-[var(--color-text-muted)] hover:border-[var(--color-border-strong)] hover:text-[var(--color-text)] disabled:opacity-50"
177 >
178 {busy ? '…' : 'remove'}
179 </button>
180 {error ? (
181 <p className="mt-1 font-mono text-[10px] text-[var(--color-error)]">{error}</p>
182 ) : null}
183 {pending ? (
184 <StepUpPrompt
185 apiOrigin={apiOrigin}
186 reason="removing a suppression re-enables delivery. confirm with your password."
187 onSuccess={async () => {
188 setPending(false);
189 await run();
190 }}
191 onCancel={() => setPending(false)}
192 />
193 ) : null}
194 </>
195 );
196}