project-actions.tsx224 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
8type Tier = 'free' | 'pro' | 'team';
9const TIERS: readonly Tier[] = ['free', 'pro', 'team'];
10
11interface AdminProject {
12 id: string;
13 tier: Tier;
14 suspendedAt: string | null;
15}
16
17interface Props {
18 project: AdminProject;
19 apiOrigin: string;
20}
21
22/**
23 * Interactive controls for the admin project-detail page. Mirrors
24 * user-actions.tsx: same fetch-with-credentials pattern, same 403
25 * step_up_required inline re-auth, same router.refresh() on success.
26 * Adds a plan-tier <select> (free/pro/team → POST projects/:id/tier) on
27 * top of the suspend/unsuspend buttons the project endpoints already back.
28 */
29export function ProjectActions({ project, apiOrigin }: Props) {
30 const router = useRouter();
31 const [busy, setBusy] = useState(false);
32 const [tier, setTier] = useState<Tier>(project.tier);
33 const [pending, setPending] = useState<(() => Promise<void>) | null>(null);
34 const [error, setError] = useState<string | null>(null);
35 const [, startTransition] = useTransition();
36
37 // Runs a fetch, surfacing step-up re-auth inline (like user-actions).
38 // `retry` is stashed so the StepUpPrompt onSuccess can replay it.
39 async function call(
40 path: string,
41 body: Record<string, unknown>,
42 retry: () => Promise<void>,
43 ): Promise<boolean> {
44 setBusy(true);
45 setError(null);
46 try {
47 const res = await fetch(`${apiOrigin}${path}`, {
48 method: 'POST',
49 credentials: 'include',
50 headers: { 'content-type': 'application/json' },
51 body: JSON.stringify(body),
52 });
53 if (res.status === 403) {
54 const parsed = (await res.json().catch(() => null)) as { code?: string } | null;
55 if (parsed?.code === 'step_up_required') {
56 setPending(() => retry);
57 return false;
58 }
59 }
60 if (!res.ok) {
61 const text = await res.text().catch(() => '');
62 throw new Error(text || `request failed: ${res.status}`);
63 }
64 startTransition(() => router.refresh());
65 return true;
66 } catch (err) {
67 setError(err instanceof Error ? err.message : 'request failed');
68 return false;
69 } finally {
70 setBusy(false);
71 }
72 }
73
74 async function saveTier(next: Tier) {
75 const run = async () => {
76 await call(`/v1/admin/projects/${project.id}/tier`, { tier: next }, run);
77 };
78 await run();
79 }
80
81 function suspend(action: 'suspend' | 'unsuspend') {
82 if (!confirm(`confirm ${action} for ${project.id}?`)) return;
83 const run = async () => {
84 await call(`/v1/admin/projects/${action}`, { projectId: project.id }, run);
85 };
86 void run();
87 }
88
89 return (
90 <div className="flex flex-col items-end gap-2 font-mono text-xs">
91 <div className="flex items-center gap-2">
92 <label className="text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)]">
93 plan
94 </label>
95 <select
96 value={tier}
97 disabled={busy}
98 onChange={(e) => {
99 const next = e.target.value as Tier;
100 setTier(next);
101 void saveTier(next);
102 }}
103 className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1 text-[var(--color-text)] disabled:opacity-50"
104 >
105 {TIERS.map((t) => (
106 <option key={t} value={t}>
107 {t}
108 </option>
109 ))}
110 </select>
111 {project.suspendedAt ? (
112 <button
113 type="button"
114 disabled={busy}
115 onClick={() => suspend('unsuspend')}
116 className="rounded-md border border-[var(--color-border)] px-2 py-1 text-[var(--color-primary)] disabled:opacity-50"
117 >
118 unsuspend
119 </button>
120 ) : (
121 <button
122 type="button"
123 disabled={busy}
124 onClick={() => suspend('suspend')}
125 className="rounded-md border border-[var(--color-border)] px-2 py-1 text-red-400 disabled:opacity-50"
126 >
127 suspend
128 </button>
129 )}
130 </div>
131 {error ? <span className="text-[10px] text-[var(--color-error)]">{error}</span> : null}
132 {pending ? (
133 <StepUpPrompt
134 apiOrigin={apiOrigin}
135 reason="changing a project requires fresh step-up auth."
136 onSuccess={async () => {
137 const retry = pending;
138 setPending(null);
139 await retry();
140 }}
141 onCancel={() => setPending(null)}
142 />
143 ) : null}
144 </div>
145 );
146}
147
148/**
149 * "Set ALL my projects to pro" — the bulk self-service upgrade button on the
150 * projects LIST header. Hits POST projects/set-mine-tier, which the API
151 * scopes strictly to orgs the calling admin created, so it can only ever
152 * upgrade the admin's own projects. Shows the resulting count inline.
153 */
154export function SetMineToProButton({ apiOrigin }: { apiOrigin: string }) {
155 const router = useRouter();
156 const [busy, setBusy] = useState(false);
157 const [result, setResult] = useState<string | null>(null);
158 const [error, setError] = useState<string | null>(null);
159 const [pending, setPending] = useState(false);
160 const [, startTransition] = useTransition();
161
162 async function run() {
163 setBusy(true);
164 setError(null);
165 setResult(null);
166 try {
167 const res = await fetch(`${apiOrigin}/v1/admin/projects/set-mine-tier`, {
168 method: 'POST',
169 credentials: 'include',
170 headers: { 'content-type': 'application/json' },
171 body: JSON.stringify({ tier: 'pro' }),
172 });
173 if (res.status === 403) {
174 const parsed = (await res.json().catch(() => null)) as { code?: string } | null;
175 if (parsed?.code === 'step_up_required') {
176 setPending(true);
177 return;
178 }
179 }
180 if (!res.ok) {
181 const text = await res.text().catch(() => '');
182 throw new Error(text || `request failed: ${res.status}`);
183 }
184 const body = (await res.json()) as { updated: number };
185 setResult(`${body.updated} project${body.updated === 1 ? '' : 's'} set to pro`);
186 startTransition(() => router.refresh());
187 } catch (err) {
188 setError(err instanceof Error ? err.message : 'request failed');
189 } finally {
190 setBusy(false);
191 }
192 }
193
194 function trigger() {
195 if (!confirm('set every project you own to the pro plan?')) return;
196 void run();
197 }
198
199 return (
200 <div className="flex flex-col items-start gap-1 font-mono text-xs">
201 <button
202 type="button"
203 disabled={busy}
204 onClick={trigger}
205 className="rounded-md border border-[var(--color-border)] px-3 py-1.5 text-[var(--color-primary)] transition-colors hover:bg-[var(--color-surface-raised)] disabled:opacity-50"
206 >
207 set all my projects to pro
208 </button>
209 {result ? <span className="text-[10px] text-[var(--color-success)]">{result}</span> : null}
210 {error ? <span className="text-[10px] text-[var(--color-error)]">{error}</span> : null}
211 {pending ? (
212 <StepUpPrompt
213 apiOrigin={apiOrigin}
214 reason="bulk-upgrading your projects requires fresh step-up auth."
215 onSuccess={async () => {
216 setPending(false);
217 await run();
218 }}
219 onCancel={() => setPending(false)}
220 />
221 ) : null}
222 </div>
223 );
224}