launch-controls.tsx586 lines · main
1'use client';
2
3import { motion } from 'motion/react';
4import { useRouter } from 'next/navigation';
5import { useState, useTransition } from 'react';
6
7import { StepUpPrompt } from '@/components/step-up-prompt';
8
9/* ─── pulsing status dot ─────────────────────────────────────────────────── */
10
11/**
12 * Big clear on/off state for a hero control. `tone` picks the color of the
13 * "active" state — maintenance being ON is red (customers see 503), signups
14 * being OPEN is green (healthy growth state). Pulses only when active.
15 */
16function PulseDot({ active, activeColor }: { active: boolean; activeColor: 'red' | 'green' }) {
17 const color = active
18 ? activeColor === 'red'
19 ? 'bg-[var(--color-error)]'
20 : 'bg-[var(--color-success)]'
21 : 'bg-[var(--color-text-subtle)]';
22 return (
23 <span className="relative flex h-3.5 w-3.5 shrink-0" aria-hidden>
24 {active ? (
25 <span
26 className={`absolute inline-flex h-full w-full animate-ping rounded-full opacity-60 ${color}`}
27 />
28 ) : null}
29 <span className={`relative inline-flex h-3.5 w-3.5 rounded-full ${color}`} />
30 </span>
31 );
32}
33
34/* ─── shared hero-card shell ─────────────────────────────────────────────── */
35
36function HeroCard({ children }: { children: React.ReactNode }) {
37 return (
38 <motion.div
39 initial={{ opacity: 0, y: 8 }}
40 animate={{ opacity: 1, y: 0 }}
41 transition={{ duration: 0.4, ease: 'easeOut' }}
42 className="flex flex-col gap-6 rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6 sm:p-8"
43 >
44 {children}
45 </motion.div>
46 );
47}
48
49/* ─── maintenance mode (hero control) ────────────────────────────────────── */
50
51/**
52 * The maintenance object as it arrives on /v1/admin/launch-status. Carries
53 * both the live flag (`active`) and the scheduled window so the schedule
54 * sub-panel can show the current plan.
55 */
56export interface MaintenanceState {
57 active: boolean;
58 scheduled: boolean;
59 upcoming: boolean;
60 startsAt: string | null;
61 endsAt: string | null;
62 message: string | null;
63 manualOverride: boolean;
64}
65
66/**
67 * Turn an ISO string into the value a <input type="datetime-local"> expects
68 * (`YYYY-MM-DDTHH:mm`, in the browser's local time). Returns '' for null /
69 * unparseable input so the field renders empty.
70 */
71function isoToLocalInput(iso: string | null): string {
72 if (!iso) return '';
73 const d = new Date(iso);
74 if (Number.isNaN(d.getTime())) return '';
75 // Shift by the local offset so toISOString's slice reads as local wall time.
76 const local = new Date(d.getTime() - d.getTimezoneOffset() * 60_000);
77 return local.toISOString().slice(0, 16);
78}
79
80/** "3 Jul, 14:30" — a compact, readable local timestamp for the summary. */
81function formatLocal(iso: string | null): string {
82 if (!iso) return '';
83 const d = new Date(iso);
84 if (Number.isNaN(d.getTime())) return '';
85 return new Intl.DateTimeFormat('en-GB', {
86 day: 'numeric',
87 month: 'short',
88 hour: '2-digit',
89 minute: '2-digit',
90 }).format(d);
91}
92
93/**
94 * Maintenance-mode switch. Two-step confirm — entering maintenance mode
95 * returns 503 across the customer-facing surface, so a stray click would
96 * cause an outage. Posts to /v1/admin/launch-status/maintenance-mode and
97 * retries through the shared StepUpPrompt when the api asks for fresh auth.
98 * The schedule sub-panel below sets a future window instead of flipping now.
99 */
100export function MaintenanceControl({
101 initial,
102 schedule,
103 apiOrigin,
104}: {
105 initial: boolean;
106 schedule: MaintenanceState;
107 apiOrigin: string;
108}) {
109 const router = useRouter();
110 const [enabled, setEnabled] = useState(initial);
111 const [phase, setPhase] = useState<'idle' | 'confirming' | 'flipping'>('idle');
112 const [error, setError] = useState<string | null>(null);
113 const [, startTransition] = useTransition();
114 const [pendingFlip, setPendingFlip] = useState<boolean | null>(null);
115
116 async function flip(next: boolean) {
117 setPhase('flipping');
118 setError(null);
119 try {
120 const res = await fetch(`${apiOrigin}/v1/admin/launch-status/maintenance-mode`, {
121 method: 'POST',
122 credentials: 'include',
123 headers: { 'content-type': 'application/json' },
124 body: JSON.stringify({ enabled: next }),
125 });
126 if (res.status === 403) {
127 const body = (await res.json().catch(() => null)) as { code?: string } | null;
128 if (body?.code === 'step_up_required') {
129 setPendingFlip(next);
130 setPhase('idle');
131 return;
132 }
133 }
134 if (!res.ok) {
135 const body = await res.text().catch(() => '');
136 throw new Error(body || `flip failed: ${res.status}`);
137 }
138 setEnabled(next);
139 setPhase('idle');
140 startTransition(() => router.refresh());
141 } catch (err) {
142 setError(err instanceof Error ? err.message : 'flip failed');
143 setPhase('idle');
144 }
145 }
146
147 return (
148 <HeroCard>
149 <div className="flex flex-wrap items-center justify-between gap-6">
150 <div className="flex items-start gap-5">
151 <span className="mt-2.5">
152 <PulseDot active={enabled} activeColor="red" />
153 </span>
154 <div className="flex flex-col gap-1.5">
155 <span className="font-mono text-[11px] uppercase tracking-wider text-[var(--color-text-subtle)]">
156 maintenance mode
157 </span>
158 <span
159 className={`font-mono text-3xl tracking-tight ${
160 enabled ? 'text-[var(--color-error)]' : 'text-[var(--color-text)]'
161 }`}
162 >
163 {enabled ? 'on · 503 to customers' : 'off'}
164 </span>
165 <span className="max-w-prose font-mono text-xs leading-relaxed text-[var(--color-text-muted)]">
166 {enabled
167 ? 'every non-admin route is returning 503 right now. /v1/auth, /v1/me, /v1/admin, /health and /ready stay open so you can flip back.'
168 : 'the emergency brake. turning this on makes the whole platform answer "503 — down for maintenance" to customers. auth, health checks and this admin cockpit stay open so you can flip it back.'}
169 </span>
170 </div>
171 </div>
172
173 {phase !== 'confirming' ? (
174 <button
175 type="button"
176 disabled={phase === 'flipping'}
177 onClick={() => setPhase('confirming')}
178 className={
179 enabled
180 ? 'rounded-md bg-[var(--color-primary)] px-5 py-2.5 font-mono text-xs text-[var(--color-text-inverse)] transition hover:bg-[var(--color-primary-hover)] disabled:opacity-50'
181 : 'rounded-md border border-[var(--color-error)] px-5 py-2.5 font-mono text-xs text-[var(--color-error)] transition hover:bg-[var(--color-error)] hover:text-[var(--color-text-inverse)] disabled:opacity-50'
182 }
183 >
184 {phase === 'flipping' ? 'flipping…' : enabled ? 'exit maintenance' : 'enter maintenance'}
185 </button>
186 ) : null}
187 </div>
188
189 {phase === 'confirming' ? (
190 <div
191 className={`flex flex-col gap-3 rounded-lg border p-4 font-mono text-xs ${
192 enabled ? 'border-[var(--color-border)]' : 'border-[var(--color-error)]'
193 }`}
194 >
195 <p className="text-[var(--color-text)]">
196 {enabled
197 ? 'flip out of maintenance? customer traffic resumes immediately.'
198 : 'put the platform into maintenance? every customer-facing page and api call starts returning 503 the moment you confirm.'}
199 </p>
200 <div className="flex items-center gap-3">
201 <button
202 type="button"
203 onClick={() => void flip(!enabled)}
204 className={`rounded-md px-4 py-2 font-mono text-xs text-[var(--color-text-inverse)] ${
205 enabled ? 'bg-[var(--color-primary)]' : 'bg-[var(--color-error)]'
206 }`}
207 >
208 {enabled ? 'yes, resume traffic' : 'yes, take it down'}
209 </button>
210 <button
211 type="button"
212 onClick={() => setPhase('idle')}
213 className="font-mono text-[10px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
214 >
215 cancel
216 </button>
217 </div>
218 </div>
219 ) : null}
220
221 {error ? <p className="font-mono text-[10px] text-[var(--color-error)]">{error}</p> : null}
222
223 {pendingFlip !== null ? (
224 <StepUpPrompt
225 apiOrigin={apiOrigin}
226 reason={
227 pendingFlip
228 ? 'entering maintenance returns 503 across the customer-facing surface. confirm with your password.'
229 : 'exiting maintenance resumes customer traffic. confirm with your password.'
230 }
231 onSuccess={async () => {
232 const next = pendingFlip;
233 setPendingFlip(null);
234 if (next !== null) await flip(next);
235 }}
236 onCancel={() => setPendingFlip(null)}
237 />
238 ) : null}
239
240 <MaintenanceSchedulePanel schedule={schedule} apiOrigin={apiOrigin} />
241 </HeroCard>
242 );
243}
244
245/* ─── maintenance schedule (sub-panel) ───────────────────────────────────── */
246
247/**
248 * Plan a maintenance window ahead of time instead of flipping the brake now.
249 * Posts { startsAt, endsAt, message } (ISO) to the maintenance-mode endpoint;
250 * when the window opens the api starts serving the splash on its own. Shows
251 * the current scheduled window if one is set, with a one-click clear.
252 * Step-up is handled the same way the immediate flip does — a 403 with
253 * `step_up_required` re-runs the pending action through StepUpPrompt.
254 */
255function MaintenanceSchedulePanel({
256 schedule,
257 apiOrigin,
258}: {
259 schedule: MaintenanceState;
260 apiOrigin: string;
261}) {
262 const router = useRouter();
263 const [, startTransition] = useTransition();
264 const [startsAt, setStartsAt] = useState(isoToLocalInput(schedule.startsAt));
265 const [endsAt, setEndsAt] = useState(isoToLocalInput(schedule.endsAt));
266 const [message, setMessage] = useState(schedule.message ?? '');
267 const [busy, setBusy] = useState(false);
268 const [error, setError] = useState<string | null>(null);
269 // A queued POST body to replay verbatim after a step-up challenge succeeds.
270 const [pending, setPending] = useState<Record<string, string | null> | null>(null);
271
272 const hasSchedule = Boolean(schedule.startsAt && schedule.endsAt);
273
274 async function post(body: Record<string, string | null>) {
275 setBusy(true);
276 setError(null);
277 try {
278 const res = await fetch(`${apiOrigin}/v1/admin/launch-status/maintenance-mode`, {
279 method: 'POST',
280 credentials: 'include',
281 headers: { 'content-type': 'application/json' },
282 body: JSON.stringify(body),
283 });
284 if (res.status === 403) {
285 const b = (await res.json().catch(() => null)) as { code?: string } | null;
286 if (b?.code === 'step_up_required') {
287 setPending(body);
288 setBusy(false);
289 return;
290 }
291 }
292 if (!res.ok) {
293 const t = await res.text().catch(() => '');
294 throw new Error(t || `request failed: ${res.status}`);
295 }
296 startTransition(() => router.refresh());
297 } catch (err) {
298 setError(err instanceof Error ? err.message : 'request failed');
299 } finally {
300 setBusy(false);
301 }
302 }
303
304 function submitSchedule() {
305 setError(null);
306 if (!startsAt || !endsAt) {
307 setError('pick both a start and an end time.');
308 return;
309 }
310 const start = new Date(startsAt);
311 const end = new Date(endsAt);
312 if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
313 setError('those dates don’t look valid — check the start and end.');
314 return;
315 }
316 if (start >= end) {
317 setError('the start must be before the end.');
318 return;
319 }
320 void post({
321 startsAt: start.toISOString(),
322 endsAt: end.toISOString(),
323 message: message.trim() ? message.trim() : null,
324 });
325 }
326
327 function clearSchedule() {
328 setError(null);
329 setStartsAt('');
330 setEndsAt('');
331 setMessage('');
332 void post({ startsAt: null, endsAt: null, message: null });
333 }
334
335 return (
336 <div className="flex flex-col gap-4 rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-bg)] p-4">
337 <div className="flex flex-col gap-1">
338 <span className="font-mono text-[11px] uppercase tracking-wider text-[var(--color-text-subtle)]">
339 schedule maintenance
340 </span>
341 <span className="font-mono text-xs leading-relaxed text-[var(--color-text-muted)]">
342 plan a window ahead of time. when it opens the platform switches itself into maintenance —
343 no need to be at the keyboard. clear it any time before it starts.
344 </span>
345 </div>
346
347 {hasSchedule ? (
348 <div className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-[var(--color-border)] px-3 py-2 font-mono text-xs">
349 <span className="text-[var(--color-text)]">
350 scheduled: {formatLocal(schedule.startsAt)} → {formatLocal(schedule.endsAt)}
351 {schedule.upcoming ? (
352 <span className="ml-2 text-[var(--color-text-subtle)]">(upcoming)</span>
353 ) : null}
354 </span>
355 <button
356 type="button"
357 disabled={busy}
358 onClick={clearSchedule}
359 className="font-mono text-[10px] text-[var(--color-text-muted)] underline-offset-2 hover:text-[var(--color-error)] hover:underline disabled:opacity-50"
360 >
361 clear schedule
362 </button>
363 </div>
364 ) : null}
365
366 <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
367 <label className="flex flex-col gap-1.5">
368 <span className="font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)]">
369 start
370 </span>
371 <input
372 type="datetime-local"
373 value={startsAt}
374 onChange={(e) => setStartsAt(e.target.value)}
375 className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 font-mono text-xs text-[var(--color-text)] outline-none focus:border-[var(--color-border-strong)]"
376 />
377 </label>
378 <label className="flex flex-col gap-1.5">
379 <span className="font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)]">
380 end
381 </span>
382 <input
383 type="datetime-local"
384 value={endsAt}
385 onChange={(e) => setEndsAt(e.target.value)}
386 className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 font-mono text-xs text-[var(--color-text)] outline-none focus:border-[var(--color-border-strong)]"
387 />
388 </label>
389 </div>
390
391 <label className="flex flex-col gap-1.5">
392 <span className="font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)]">
393 message
394 </span>
395 <textarea
396 value={message}
397 onChange={(e) => setMessage(e.target.value)}
398 rows={2}
399 placeholder="shown to visitors on the maintenance page"
400 className="resize-y rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 font-mono text-xs text-[var(--color-text)] outline-none placeholder:text-[var(--color-text-subtle)] focus:border-[var(--color-border-strong)]"
401 />
402 </label>
403
404 {error ? (
405 <p className="font-mono text-[10px] text-[var(--color-error)]">{error}</p>
406 ) : null}
407
408 <div className="flex items-center gap-3">
409 <button
410 type="button"
411 disabled={busy}
412 onClick={submitSchedule}
413 className="rounded-md border border-[var(--color-border)] px-4 py-2 font-mono text-xs text-[var(--color-text-muted)] transition hover:border-[var(--color-border-strong)] hover:text-[var(--color-text)] disabled:opacity-50"
414 >
415 {busy ? 'saving…' : 'schedule maintenance'}
416 </button>
417 </div>
418
419 {pending !== null ? (
420 <StepUpPrompt
421 apiOrigin={apiOrigin}
422 reason="scheduling maintenance changes the customer-facing platform. confirm with your password."
423 onSuccess={async () => {
424 const body = pending;
425 setPending(null);
426 if (body) await post(body);
427 }}
428 onCancel={() => setPending(null)}
429 />
430 ) : null}
431 </div>
432 );
433}
434
435/* ─── open signups (hero control) ────────────────────────────────────────── */
436
437/**
438 * Open-signups switch. Posts to /v1/admin/launch-status/open-signups and
439 * refreshes the server component so peer-rendered state catches up. Shows
440 * the env default underneath when the DB override differs from it so the
441 * operator can see they've drifted.
442 */
443export function OpenSignupsControl({
444 initialOpen,
445 envDefault,
446 apiOrigin,
447}: {
448 initialOpen: boolean;
449 envDefault: boolean;
450 apiOrigin: string;
451}) {
452 const router = useRouter();
453 const [open, setOpen] = useState(initialOpen);
454 const [error, setError] = useState<string | null>(null);
455 const [pending, startTransition] = useTransition();
456 const [busy, setBusy] = useState(false);
457 const [confirming, setConfirming] = useState(false);
458 const [pendingFlip, setPendingFlip] = useState<boolean | null>(null);
459
460 async function flip(next: boolean) {
461 setBusy(true);
462 setError(null);
463 try {
464 const res = await fetch(`${apiOrigin}/v1/admin/launch-status/open-signups`, {
465 method: 'POST',
466 credentials: 'include',
467 headers: { 'content-type': 'application/json' },
468 body: JSON.stringify({ openSignups: next }),
469 });
470 if (res.status === 403) {
471 const body = (await res.json().catch(() => null)) as { code?: string } | null;
472 if (body?.code === 'step_up_required') {
473 // Stash the requested state so the post-step-up retry knows
474 // which direction to flip.
475 setPendingFlip(next);
476 return;
477 }
478 }
479 if (!res.ok) {
480 const body = await res.text().catch(() => '');
481 throw new Error(body || `flip failed: ${res.status}`);
482 }
483 setOpen(next);
484 setConfirming(false);
485 startTransition(() => router.refresh());
486 } catch (err) {
487 setError(err instanceof Error ? err.message : 'flip failed');
488 } finally {
489 setBusy(false);
490 }
491 }
492
493 const drift = open !== envDefault;
494
495 return (
496 <HeroCard>
497 <div className="flex flex-wrap items-center justify-between gap-6">
498 <div className="flex items-start gap-5">
499 <span className="mt-2.5">
500 <PulseDot active={open} activeColor="green" />
501 </span>
502 <div className="flex flex-col gap-1.5">
503 <span className="font-mono text-[11px] uppercase tracking-wider text-[var(--color-text-subtle)]">
504 signups
505 </span>
506 <span
507 className={`font-mono text-3xl tracking-tight ${
508 open ? 'text-[var(--color-success)]' : 'text-[var(--color-text)]'
509 }`}
510 >
511 {open ? 'open' : 'invite-only'}
512 </span>
513 <span className="max-w-prose font-mono text-xs leading-relaxed text-[var(--color-text-muted)]">
514 {open
515 ? 'anyone with the URL can create an account at /signin right now. close it to go back to the invite-only allowlist.'
516 : 'only emails on the allowlist can sign up. existing users keep working either way — this gate only affects brand-new registrations.'}
517 </span>
518 {drift ? (
519 <span className="font-mono text-[10px] text-[var(--color-text-subtle)]">
520 env default is <code>BRIVEN_OPEN_SIGNUPS={envDefault ? 'true' : 'false'}</code> —
521 this dashboard override wins.
522 </span>
523 ) : null}
524 </div>
525 </div>
526
527 {!confirming ? (
528 <button
529 type="button"
530 disabled={busy || pending}
531 onClick={() => setConfirming(true)}
532 className="rounded-md border border-[var(--color-border)] px-5 py-2.5 font-mono text-xs text-[var(--color-text-muted)] transition hover:border-[var(--color-border-strong)] hover:text-[var(--color-text)] disabled:opacity-50"
533 >
534 {open ? 'close signups' : 'open signups'}
535 </button>
536 ) : null}
537 </div>
538
539 {confirming ? (
540 <div className="flex flex-col gap-3 rounded-lg border border-[var(--color-warning)] p-4 font-mono text-xs">
541 <p className="text-[var(--color-text)]">
542 {open
543 ? 'pause new signups? existing users keep working — only new registrations are blocked.'
544 : 'open signups to the public? anyone with the URL can register the moment you confirm.'}
545 </p>
546 <div className="flex items-center gap-3">
547 <button
548 type="button"
549 disabled={busy || pending}
550 onClick={() => void flip(!open)}
551 className="rounded-md border border-[var(--color-warning)] px-4 py-2 font-mono text-xs text-[var(--color-warning)] transition hover:bg-[var(--color-warning)] hover:text-[var(--color-text-inverse)] disabled:opacity-50"
552 >
553 {busy ? 'flipping…' : 'confirm'}
554 </button>
555 <button
556 type="button"
557 onClick={() => setConfirming(false)}
558 className="font-mono text-[10px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
559 >
560 cancel
561 </button>
562 </div>
563 </div>
564 ) : null}
565
566 {error ? <p className="font-mono text-[10px] text-[var(--color-error)]">{error}</p> : null}
567
568 {pendingFlip !== null ? (
569 <StepUpPrompt
570 apiOrigin={apiOrigin}
571 reason={
572 pendingFlip
573 ? 'flipping open-signups to true makes the platform publicly registrable. confirm with your password.'
574 : 'closing public signups reverts to the invite-only allowlist. confirm with your password.'
575 }
576 onSuccess={async () => {
577 const next = pendingFlip;
578 setPendingFlip(null);
579 if (next !== null) await flip(next);
580 }}
581 onCancel={() => setPendingFlip(null)}
582 />
583 ) : null}
584 </HeroCard>
585 );
586}