allowlist-controls.tsx187 lines · main
| 1 | 'use client'; |
| 2 | |
| 3 | import { useRouter } from 'next/navigation'; |
| 4 | import { useState, useTransition } from 'react'; |
| 5 | |
| 6 | import { StepUpPrompt } from '@/components/step-up-prompt'; |
| 7 | |
| 8 | interface Props { |
| 9 | apiOrigin: string; |
| 10 | } |
| 11 | |
| 12 | type Pending = { kind: 'add'; email: string; notes: string } | { kind: 'remove'; email: string }; |
| 13 | |
| 14 | export 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 | |
| 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 | |
| 124 | export 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 | } |