create-form.tsx164 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
8const SEVERITIES = ['critical', 'major', 'minor', 'maintenance'] as const;
9const SERVICES = ['api', 'realtime', 'runtime', 'web', 'docs', 'all'] as const;
10
11type Severity = (typeof SEVERITIES)[number];
12
13export function IncidentCreateForm({ apiOrigin }: { apiOrigin: string }) {
14 const router = useRouter();
15 const [severity, setSeverity] = useState<Severity>('minor');
16 const [services, setServices] = useState<Set<string>>(new Set(['api']));
17 const [summary, setSummary] = useState('');
18 const [busy, setBusy] = useState(false);
19 const [error, setError] = useState<string | null>(null);
20 const [pendingRetry, setPendingRetry] = useState(false);
21 const [, startTransition] = useTransition();
22
23 function toggleService(s: string) {
24 setServices((prev) => {
25 const next = new Set(prev);
26 if (next.has(s)) next.delete(s);
27 else next.add(s);
28 return next;
29 });
30 }
31
32 async function submit(e?: React.FormEvent) {
33 e?.preventDefault();
34 setBusy(true);
35 setError(null);
36 try {
37 const res = await fetch(`${apiOrigin}/v1/admin/incidents`, {
38 method: 'POST',
39 credentials: 'include',
40 headers: { 'content-type': 'application/json' },
41 body: JSON.stringify({
42 severity,
43 services: Array.from(services),
44 summary,
45 }),
46 });
47 if (res.status === 403) {
48 const body = (await res.json().catch(() => null)) as { code?: string } | null;
49 if (body?.code === 'step_up_required') {
50 setPendingRetry(true);
51 return;
52 }
53 }
54 if (!res.ok) {
55 const body = await res.text().catch(() => '');
56 throw new Error(body || `create failed: ${res.status}`);
57 }
58 setSummary('');
59 setServices(new Set(['api']));
60 setSeverity('minor');
61 startTransition(() => router.refresh());
62 } catch (err) {
63 setError(err instanceof Error ? err.message : 'create failed');
64 } finally {
65 setBusy(false);
66 }
67 }
68
69 return (
70 <form
71 onSubmit={submit}
72 className="flex flex-col gap-4 rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6"
73 >
74 <h3 className="font-mono text-[11px] uppercase tracking-wider text-[var(--color-text-subtle)]">
75 new incident
76 </h3>
77
78 <label className="flex flex-col gap-1">
79 <span className="font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)]">
80 severity
81 </span>
82 <div className="flex flex-wrap gap-2">
83 {SEVERITIES.map((s) => (
84 <button
85 type="button"
86 key={s}
87 onClick={() => setSeverity(s)}
88 className={`rounded-full border px-3 py-1 font-mono text-[10px] uppercase tracking-wider transition-colors ${
89 severity === s
90 ? 'border-[var(--color-primary)] text-[var(--color-primary)]'
91 : 'border-[var(--color-border-subtle)] text-[var(--color-text-muted)] hover:text-[var(--color-text)]'
92 }`}
93 >
94 {s}
95 </button>
96 ))}
97 </div>
98 </label>
99
100 <label className="flex flex-col gap-1">
101 <span className="font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)]">
102 affected services
103 </span>
104 <div className="flex flex-wrap gap-2">
105 {SERVICES.map((s) => (
106 <button
107 type="button"
108 key={s}
109 onClick={() => toggleService(s)}
110 className={`rounded-full border px-3 py-1 font-mono text-[10px] transition-colors ${
111 services.has(s)
112 ? 'border-[var(--color-primary)] text-[var(--color-primary)]'
113 : 'border-[var(--color-border-subtle)] text-[var(--color-text-muted)] hover:text-[var(--color-text)]'
114 }`}
115 >
116 {s}
117 </button>
118 ))}
119 </div>
120 </label>
121
122 <label className="flex flex-col gap-1">
123 <span className="font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)]">
124 summary
125 </span>
126 <textarea
127 required
128 value={summary}
129 onChange={(e) => setSummary(e.target.value)}
130 maxLength={2000}
131 rows={3}
132 placeholder="briefly: what's happening, who's affected, what's the workaround if any."
133 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"
134 />
135 </label>
136
137 <div>
138 <button
139 type="submit"
140 disabled={busy || summary.length === 0 || services.size === 0}
141 className="inline-flex h-9 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"
142 >
143 {busy ? 'publishing…' : 'publish incident'}
144 </button>
145 </div>
146
147 {error ? (
148 <p className="font-mono text-[10px] text-[var(--color-error)]">{error}</p>
149 ) : null}
150
151 {pendingRetry ? (
152 <StepUpPrompt
153 apiOrigin={apiOrigin}
154 reason="publishing an incident updates the public status page. confirm with your password."
155 onSuccess={async () => {
156 setPendingRetry(false);
157 await submit();
158 }}
159 onCancel={() => setPendingRetry(false)}
160 />
161 ) : null}
162 </form>
163 );
164}