project-limit-form.tsx198 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 projectId: string;
11 projectName: string;
12 hasOverride: boolean;
13 /** Effective limits in force now (override value if set, else the tier cap). */
14 effectiveMaxRows: number;
15 effectiveMaxTables: number;
16 /** Tier cap the project inherits when no override is set — shown as placeholder. */
17 tierMaxRows: number;
18 tierMaxTables: number;
19}
20
21type Body = { maxRows: number | null; maxTables: number | null };
22
23/**
24 * Per-project storage-limit editor. PATCHes
25 * /v1/admin/storage/projects/:id with an override, or clears it (null,null)
26 * so the project inherits its tier cap again. Collapsed by default to keep
27 * the usage table scannable; the override badge in the row already shows
28 * which projects carry a custom limit. Step-up gated like the tier-cap
29 * editor — 403 step_up_required surfaces the inline password prompt and we
30 * retry the same intent (set or clear).
31 */
32export function ProjectLimitForm({
33 apiOrigin,
34 projectId,
35 projectName,
36 hasOverride,
37 effectiveMaxRows,
38 effectiveMaxTables,
39 tierMaxRows,
40 tierMaxTables,
41}: Props) {
42 const router = useRouter();
43 const [open, setOpen] = useState(false);
44 // Prefill with the current override when one exists; otherwise leave blank
45 // so empty inputs read as "inherit the tier cap".
46 const [rows, setRows] = useState(hasOverride ? String(effectiveMaxRows) : '');
47 const [tables, setTables] = useState(hasOverride ? String(effectiveMaxTables) : '');
48 const [busy, setBusy] = useState(false);
49 const [error, setError] = useState<string | null>(null);
50 const [pending, setPending] = useState<Body | null>(null);
51 const [, startTransition] = useTransition();
52
53 function parseField(value: string): number | null | 'invalid' {
54 const trimmed = value.trim();
55 if (trimmed === '') return null;
56 const n = Number(trimmed);
57 if (!Number.isInteger(n) || n < 0) return 'invalid';
58 return n;
59 }
60
61 async function send(body: Body) {
62 setBusy(true);
63 setError(null);
64 try {
65 const res = await fetch(
66 `${apiOrigin}/v1/admin/storage/projects/${encodeURIComponent(projectId)}`,
67 {
68 method: 'PATCH',
69 credentials: 'include',
70 headers: { 'content-type': 'application/json' },
71 body: JSON.stringify(body),
72 },
73 );
74 if (res.status === 403) {
75 const parsed = (await res.json().catch(() => null)) as { code?: string } | null;
76 if (parsed?.code === 'step_up_required') {
77 setPending(body);
78 return;
79 }
80 }
81 if (!res.ok) {
82 const text = await res.text().catch(() => '');
83 throw new Error(text || `save failed: ${res.status}`);
84 }
85 setOpen(false);
86 startTransition(() => router.refresh());
87 } catch (err) {
88 setError(err instanceof Error ? err.message : 'save failed');
89 } finally {
90 setBusy(false);
91 }
92 }
93
94 function onSave() {
95 const maxRows = parseField(rows);
96 const maxTables = parseField(tables);
97 if (maxRows === 'invalid' || maxTables === 'invalid') {
98 setError('whole non-negative numbers, or blank to inherit');
99 return;
100 }
101 void send({ maxRows, maxTables });
102 }
103
104 function onClear() {
105 setRows('');
106 setTables('');
107 void send({ maxRows: null, maxTables: null });
108 }
109
110 if (!open) {
111 return (
112 <button
113 type="button"
114 onClick={() => setOpen(true)}
115 className="rounded-md border border-[var(--color-border)] px-2 py-1 font-mono text-[10px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
116 >
117 set limit
118 </button>
119 );
120 }
121
122 return (
123 <div className="flex flex-col gap-2">
124 <div className="flex flex-wrap items-end gap-2">
125 <label className="flex flex-col gap-0.5">
126 <span className="font-mono text-[10px] text-[var(--color-text-muted)]">rows</span>
127 <input
128 type="number"
129 min={0}
130 step={1}
131 value={rows}
132 onChange={(e) => setRows(e.target.value)}
133 placeholder={`tier: ${tierMaxRows.toLocaleString()}`}
134 disabled={busy}
135 className="w-32 rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-2 py-1 font-mono text-xs text-[var(--color-text)] focus:border-[var(--color-primary)] focus:outline-none disabled:opacity-50"
136 />
137 </label>
138 <label className="flex flex-col gap-0.5">
139 <span className="font-mono text-[10px] text-[var(--color-text-muted)]">tables</span>
140 <input
141 type="number"
142 min={0}
143 step={1}
144 value={tables}
145 onChange={(e) => setTables(e.target.value)}
146 placeholder={`tier: ${tierMaxTables.toLocaleString()}`}
147 disabled={busy}
148 className="w-24 rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-2 py-1 font-mono text-xs text-[var(--color-text)] focus:border-[var(--color-primary)] focus:outline-none disabled:opacity-50"
149 />
150 </label>
151 <button
152 type="button"
153 onClick={onSave}
154 disabled={busy}
155 className="rounded-md border border-[var(--color-primary)] bg-[var(--color-primary-subtle)] px-3 py-1 font-mono text-xs text-[var(--color-primary)] hover:bg-[var(--color-primary)]/15 disabled:opacity-50"
156 >
157 {busy ? 'saving…' : 'save'}
158 </button>
159 {hasOverride ? (
160 <button
161 type="button"
162 onClick={onClear}
163 disabled={busy}
164 className="rounded-md border border-[var(--color-border)] px-3 py-1 font-mono text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)] disabled:opacity-50"
165 >
166 clear override
167 </button>
168 ) : null}
169 <button
170 type="button"
171 onClick={() => setOpen(false)}
172 disabled={busy}
173 className="font-mono text-[10px] text-[var(--color-text-muted)] hover:text-[var(--color-text)] disabled:opacity-50"
174 >
175 cancel
176 </button>
177 </div>
178 <p className="font-mono text-[10px] text-[var(--color-text-subtle)]">
179 blank inherits the tier cap. both fields are sent together.
180 </p>
181 {error ? (
182 <p className="font-mono text-[10px] text-[var(--color-error)]">{error}</p>
183 ) : null}
184 {pending != null ? (
185 <StepUpPrompt
186 apiOrigin={apiOrigin}
187 reason={`changing the storage limit for ${projectName} requires fresh step-up auth.`}
188 onSuccess={async () => {
189 const body = pending;
190 setPending(null);
191 await send(body);
192 }}
193 onCancel={() => setPending(null)}
194 />
195 ) : null}
196 </div>
197 );
198}