tier-caps-form.tsx179 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
8export type Tier = 'free' | 'pro' | 'team';
9
10export interface TierCap {
11 maxRows: number;
12 maxTables: number;
13}
14
15interface Props {
16 apiOrigin: string;
17 caps: Record<Tier, TierCap>;
18}
19
20const TIERS: Tier[] = ['free', 'pro', 'team'];
21
22interface Draft {
23 maxRows: string;
24 maxTables: string;
25}
26
27function toDrafts(caps: Record<Tier, TierCap>): Record<Tier, Draft> {
28 return {
29 free: { maxRows: String(caps.free.maxRows), maxTables: String(caps.free.maxTables) },
30 pro: { maxRows: String(caps.pro.maxRows), maxTables: String(caps.pro.maxTables) },
31 team: { maxRows: String(caps.team.maxRows), maxTables: String(caps.team.maxTables) },
32 };
33}
34
35/**
36 * Tier-cap editor. One PATCH per tier against
37 * /v1/admin/storage/tier-caps/:tier. Edits take effect immediately on the
38 * api — no redeploy — so the only feedback the operator needs is the row
39 * snapping back to the saved value after router.refresh(). Step-up gated:
40 * the api answers 403 step_up_required on stale auth and we surface the
41 * password prompt inline, then retry the same tier.
42 */
43export function TierCapsForm({ apiOrigin, caps }: Props) {
44 const router = useRouter();
45 const [drafts, setDrafts] = useState<Record<Tier, Draft>>(() => toDrafts(caps));
46 const [busy, setBusy] = useState<Tier | null>(null);
47 const [error, setError] = useState<Partial<Record<Tier, string>>>({});
48 const [pending, setPending] = useState<Tier | null>(null);
49 const [, startTransition] = useTransition();
50
51 function setField(tier: Tier, field: keyof Draft, value: string) {
52 setDrafts((d) => ({ ...d, [tier]: { ...d[tier], [field]: value } }));
53 }
54
55 async function save(tier: Tier) {
56 const draft = drafts[tier];
57 const maxRows = Number(draft.maxRows);
58 const maxTables = Number(draft.maxTables);
59 if (
60 !Number.isInteger(maxRows) ||
61 maxRows < 0 ||
62 !Number.isInteger(maxTables) ||
63 maxTables < 0
64 ) {
65 setError((e) => ({ ...e, [tier]: 'whole non-negative numbers only' }));
66 return;
67 }
68 setBusy(tier);
69 setError((e) => ({ ...e, [tier]: undefined }));
70 try {
71 const res = await fetch(
72 `${apiOrigin}/v1/admin/storage/tier-caps/${encodeURIComponent(tier)}`,
73 {
74 method: 'PATCH',
75 credentials: 'include',
76 headers: { 'content-type': 'application/json' },
77 body: JSON.stringify({ maxRows, maxTables }),
78 },
79 );
80 if (res.status === 403) {
81 const body = (await res.json().catch(() => null)) as { code?: string } | null;
82 if (body?.code === 'step_up_required') {
83 setPending(tier);
84 return;
85 }
86 }
87 if (!res.ok) {
88 const body = await res.text().catch(() => '');
89 throw new Error(body || `save failed: ${res.status}`);
90 }
91 startTransition(() => router.refresh());
92 } catch (err) {
93 setError((e) => ({
94 ...e,
95 [tier]: err instanceof Error ? err.message : 'save failed',
96 }));
97 } finally {
98 setBusy(null);
99 }
100 }
101
102 return (
103 <div className="flex flex-col gap-3">
104 <div className="overflow-x-auto rounded-md border border-[var(--color-border-subtle)]">
105 <table className="w-full font-mono text-xs">
106 <thead className="bg-[var(--color-surface)] text-left text-[var(--color-text-muted)]">
107 <tr>
108 <th className="px-3 py-2 font-medium">tier</th>
109 <th className="px-3 py-2 font-medium">max rows</th>
110 <th className="px-3 py-2 font-medium">max tables</th>
111 <th className="px-3 py-2 font-medium" />
112 </tr>
113 </thead>
114 <tbody>
115 {TIERS.map((tier) => (
116 <tr
117 key={tier}
118 className="border-t border-[var(--color-border-subtle)] align-middle"
119 >
120 <td className="px-3 py-2 text-[var(--color-text)]">{tier}</td>
121 <td className="px-3 py-2">
122 <input
123 type="number"
124 min={0}
125 step={1}
126 value={drafts[tier].maxRows}
127 onChange={(e) => setField(tier, 'maxRows', e.target.value)}
128 disabled={busy === tier}
129 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"
130 />
131 </td>
132 <td className="px-3 py-2">
133 <input
134 type="number"
135 min={0}
136 step={1}
137 value={drafts[tier].maxTables}
138 onChange={(e) => setField(tier, 'maxTables', e.target.value)}
139 disabled={busy === tier}
140 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"
141 />
142 </td>
143 <td className="px-3 py-2">
144 <div className="flex items-center gap-2">
145 <button
146 type="button"
147 onClick={() => void save(tier)}
148 disabled={busy === tier}
149 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"
150 >
151 {busy === tier ? 'saving…' : 'save'}
152 </button>
153 {error[tier] ? (
154 <span className="font-mono text-[10px] text-[var(--color-error)]">
155 {error[tier]}
156 </span>
157 ) : null}
158 </div>
159 </td>
160 </tr>
161 ))}
162 </tbody>
163 </table>
164 </div>
165 {pending != null ? (
166 <StepUpPrompt
167 apiOrigin={apiOrigin}
168 reason={`editing the ${pending} tier storage caps requires fresh step-up auth.`}
169 onSuccess={async () => {
170 const t = pending;
171 setPending(null);
172 await save(t);
173 }}
174 onCancel={() => setPending(null)}
175 />
176 ) : null}
177 </div>
178 );
179}