allowlist-controls.tsx187 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 Pending = { kind: 'add'; email: string; notes: string } | { kind: 'remove'; email: string };
13
14export function AllowlistAddForm({ apiOrigin }: Props) {
15 const router = useRouter();
16 const [email, setEmail] = useState('');
17 const [notes, setNotes] = useState('');
18 const [busy, setBusy] = useState(false);
19 const [error, setError] = useState<string | null>(null);
20 const [pending, setPending] = useState<Pending | null>(null);
21 const [, startTransition] = useTransition();
22
23 async function add() {
24 setBusy(true);
25 setError(null);
26 try {
27 const res = await fetch(`${apiOrigin}/v1/admin/signup-allowlist`, {
28 method: 'POST',
29 credentials: 'include',
30 headers: { 'content-type': 'application/json' },
31 body: JSON.stringify({
32 email: email.trim(),
33 ...(notes.trim() ? { notes: notes.trim() } : {}),
34 }),
35 });
36 if (res.status === 403) {
37 const body = (await res.json().catch(() => null)) as { code?: string } | null;
38 if (body?.code === 'step_up_required') {
39 setPending({ kind: 'add', email: email.trim(), notes: notes.trim() });
40 return;
41 }
42 }
43 if (!res.ok) {
44 const body = await res.text().catch(() => '');
45 throw new Error(body || `add failed: ${res.status}`);
46 }
47 setEmail('');
48 setNotes('');
49 startTransition(() => router.refresh());
50 } catch (err) {
51 setError(err instanceof Error ? err.message : 'add failed');
52 } finally {
53 setBusy(false);
54 }
55 }
56
57 function onSubmit(e: React.FormEvent) {
58 e.preventDefault();
59 if (!email.trim()) return;
60 void add();
61 }
62
63 return (
64 <>
65 <form
66 onSubmit={onSubmit}
67 className="grid grid-cols-1 gap-4 rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6 md:grid-cols-[2fr_2fr_auto] md:items-end"
68 >
69 <label className="flex flex-col gap-1">
70 <span className="font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)]">
71 email
72 </span>
73 <input
74 required
75 type="email"
76 value={email}
77 onChange={(e) => setEmail(e.target.value)}
78 placeholder="alice@example.com"
79 disabled={busy}
80 className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 font-mono text-sm text-[var(--color-text)] focus:border-[var(--color-primary)] focus:outline-none disabled:opacity-50"
81 />
82 </label>
83 <label className="flex flex-col gap-1">
84 <span className="font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)]">
85 notes (optional)
86 </span>
87 <input
88 value={notes}
89 onChange={(e) => setNotes(e.target.value)}
90 placeholder="founder of acme.dev — met at handlr launch"
91 maxLength={500}
92 disabled={busy}
93 className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 font-mono text-sm text-[var(--color-text)] focus:border-[var(--color-primary)] focus:outline-none disabled:opacity-50"
94 />
95 </label>
96 <button
97 type="submit"
98 disabled={busy || !email.trim()}
99 className="inline-flex h-10 items-center justify-center rounded-md bg-[var(--color-primary)] px-4 font-sans text-sm text-[var(--color-text-inverse)] transition-colors hover:bg-[var(--color-primary-hover)] disabled:opacity-50"
100 >
101 {busy ? 'adding…' : 'add'}
102 </button>
103 </form>
104
105 {error ? (
106 <p className="font-mono text-xs text-[var(--color-error)]">{error}</p>
107 ) : null}
108
109 {pending?.kind === 'add' ? (
110 <StepUpPrompt
111 apiOrigin={apiOrigin}
112 reason="adding an allowlist entry lets a new email sign up to the beta. confirm with your password."
113 onSuccess={async () => {
114 setPending(null);
115 await add();
116 }}
117 onCancel={() => setPending(null)}
118 />
119 ) : null}
120 </>
121 );
122}
123
124export function AllowlistRemoveButton({
125 email,
126 apiOrigin,
127}: {
128 email: string;
129 apiOrigin: string;
130}) {
131 const router = useRouter();
132 const [busy, setBusy] = useState(false);
133 const [pending, setPending] = useState(false);
134 const [error, setError] = useState<string | null>(null);
135 const [, startTransition] = useTransition();
136
137 async function remove() {
138 setBusy(true);
139 setError(null);
140 try {
141 const res = await fetch(
142 `${apiOrigin}/v1/admin/signup-allowlist/${encodeURIComponent(email)}`,
143 { method: 'DELETE', credentials: 'include' },
144 );
145 if (res.status === 403) {
146 const body = (await res.json().catch(() => null)) as { code?: string } | null;
147 if (body?.code === 'step_up_required') {
148 setPending(true);
149 return;
150 }
151 }
152 if (!res.ok && res.status !== 404) throw new Error(`remove failed: ${res.status}`);
153 startTransition(() => router.refresh());
154 } catch (err) {
155 setError(err instanceof Error ? err.message : 'remove failed');
156 } finally {
157 setBusy(false);
158 }
159 }
160
161 return (
162 <>
163 <button
164 type="button"
165 onClick={() => void remove()}
166 disabled={busy}
167 className="rounded-md border border-[var(--color-border-subtle)] px-3 py-1.5 font-mono text-xs text-[var(--color-text-muted)] hover:border-[var(--color-error)] hover:text-[var(--color-error)] disabled:opacity-50"
168 >
169 {busy ? '…' : 'remove'}
170 </button>
171 {error ? (
172 <p className="mt-1 font-mono text-[10px] text-[var(--color-error)]">{error}</p>
173 ) : null}
174 {pending ? (
175 <StepUpPrompt
176 apiOrigin={apiOrigin}
177 reason="removing an allowlist entry blocks future signups for this email. confirm with your password."
178 onSuccess={async () => {
179 setPending(false);
180 await remove();
181 }}
182 onCancel={() => setPending(false)}
183 />
184 ) : null}
185 </>
186 );
187}