page.tsx406 lines · main
1import Link from 'next/link';
2import { redirect } from 'next/navigation';
3
4import { apiFetch, apiJson } from '../../../../../lib/api';
5
6interface Org {
7 id: string;
8 slug: string;
9 name: string;
10 personal: boolean;
11 createdAt: string | Date;
12}
13
14interface Project {
15 id: string;
16 slug: string;
17 name: string;
18 region: string;
19 tier: 'free' | 'pro' | 'team';
20 orgId: string;
21 orgName: string | null;
22}
23
24export const dynamic = 'force-dynamic';
25export const metadata = { title: 'team · settings' };
26
27export default async function TeamDetailPage({ params }: { params: Promise<{ id: string }> }) {
28 const { id } = await params;
29 const [{ orgs }, { projects }] = await Promise.all([
30 apiJson<{ orgs: Org[] }>('/v1/me/orgs'),
31 apiJson<{ projects: Project[] }>('/v1/projects'),
32 ]);
33 const org = orgs.find((o) => o.id === id);
34 if (!org) {
35 // Either it doesn't exist or the user isn't a member — same UX
36 // either way: bounce to the teams list.
37 redirect('/dashboard/teams');
38 }
39 // Soft cast — redirect throws but TS doesn't model it.
40 const team = org as Org;
41 const teamProjects = projects.filter((p) => p.orgId === team.id);
42
43 async function rename(formData: FormData) {
44 'use server';
45 const name = String(formData.get('name') ?? '').trim();
46 if (!name) throw new Error('team name is required');
47 const res = await apiFetch(`/v1/orgs/${id}`, {
48 method: 'PATCH',
49 headers: { 'content-type': 'application/json' },
50 body: JSON.stringify({ name }),
51 });
52 if (!res.ok) {
53 const body = await res.text().catch(() => '');
54 throw new Error(`rename failed (${res.status}): ${body}`);
55 }
56 redirect(`/dashboard/teams/${id}`);
57 }
58
59 async function promote(formData: FormData) {
60 'use server';
61 const name = String(formData.get('name') ?? '').trim();
62 if (!name) throw new Error('team name is required');
63 const res = await apiFetch(`/v1/orgs/${id}/promote`, {
64 method: 'POST',
65 headers: { 'content-type': 'application/json' },
66 body: JSON.stringify({ name }),
67 });
68 if (!res.ok) {
69 const body = await res.text().catch(() => '');
70 throw new Error(`promote failed (${res.status}): ${body}`);
71 }
72 redirect(`/dashboard/teams/${id}`);
73 }
74
75 async function deleteTeam() {
76 'use server';
77 const res = await apiFetch(`/v1/orgs/${id}`, { method: 'DELETE' });
78 if (!res.ok) {
79 const body = (await res.json().catch(() => ({}))) as { message?: string };
80 throw new Error(body.message ?? `delete failed: ${res.status}`);
81 }
82 redirect('/dashboard/teams');
83 }
84
85 return (
86 <section className="flex flex-col gap-8">
87 <header>
88 <p className="font-mono text-xs text-[var(--color-text-muted)]">
89 <Link href="/dashboard/teams" className="hover:text-[var(--color-text)]">
90 ← all teams
91 </Link>
92 </p>
93 <h1 className="mt-2 font-mono text-xl tracking-tight">{team.name}</h1>
94 <p className="mt-1 font-mono text-sm text-[var(--color-text-muted)]">
95 {team.personal ? 'personal org' : 'team org'} · {team.slug}
96 </p>
97 </header>
98
99 {team.personal ? (
100 <section className="max-w-lg rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-5">
101 <h2 className="font-mono text-sm text-[var(--color-text)]">promote to team</h2>
102 <p className="mt-1 font-mono text-xs text-[var(--color-text-muted)]">
103 this is your personal org — the single-member default that gets auto-created when you
104 sign in. promoting it unlocks team rename, member invites, and the rest of the team
105 affordances. projects in this org keep working without interruption.
106 </p>
107 <form action={promote} className="mt-4 flex flex-col gap-3">
108 <label className="flex flex-col gap-1">
109 <span className="font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)]">
110 new team name
111 </span>
112 <input
113 name="name"
114 type="text"
115 required
116 maxLength={120}
117 defaultValue={team.name}
118 placeholder="flndrn"
119 className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 font-mono text-sm outline-none focus:border-[var(--color-primary)]"
120 />
121 </label>
122 <button
123 type="submit"
124 className="self-start rounded-md bg-[var(--color-primary)] px-4 py-2 font-mono text-sm font-medium text-[var(--color-text-inverse)] transition hover:bg-[var(--color-primary-hover)]"
125 >
126 promote to team
127 </button>
128 </form>
129 </section>
130 ) : (
131 <section className="max-w-lg">
132 <h2 className="mb-3 font-mono text-sm text-[var(--color-text-muted)]">rename</h2>
133 <form action={rename} className="flex flex-col gap-3">
134 <input
135 name="name"
136 type="text"
137 required
138 maxLength={200}
139 defaultValue={team.name}
140 className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 font-mono text-sm outline-none focus:border-[var(--color-primary)]"
141 />
142 <button
143 type="submit"
144 className="self-start rounded-md bg-[var(--color-primary)] px-4 py-2 font-mono text-sm font-medium text-[var(--color-text-inverse)] transition hover:bg-[var(--color-primary-hover)]"
145 >
146 rename team
147 </button>
148 </form>
149 </section>
150 )}
151
152 <section>
153 <h2 className="mb-3 font-mono text-sm text-[var(--color-text-muted)]">
154 projects in this {team.personal ? 'personal org' : 'team'}
155 </h2>
156 {teamProjects.length === 0 ? (
157 <div className="rounded-md border border-dashed border-[var(--color-border)] p-6 font-mono text-sm text-[var(--color-text-muted)]">
158 no projects yet.{' '}
159 <Link href="/dashboard/projects/new" className="underline">
160 create one
161 </Link>
162 {' '}and pick this {team.personal ? 'org' : 'team'} in the dropdown.
163 </div>
164 ) : (
165 <ul className="flex flex-col gap-2">
166 {teamProjects.map((p) => (
167 <li key={p.id}>
168 <Link
169 href={`/dashboard/projects/${p.id}`}
170 className="flex items-center justify-between rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] px-4 py-3 transition hover:border-[var(--color-border)]"
171 >
172 <span className="font-mono text-sm">{p.name}</span>
173 <span className="font-mono text-xs text-[var(--color-text-subtle)]">
174 {p.region} · {p.tier}
175 </span>
176 </Link>
177 </li>
178 ))}
179 </ul>
180 )}
181 </section>
182
183 {!team.personal ? <TeamInvites teamId={id} /> : null}
184
185 {!team.personal ? (
186 <section className="max-w-lg border-t border-[var(--color-border-subtle)] pt-6">
187 <h2 className="mb-2 font-mono text-sm text-red-400">danger zone</h2>
188 <p className="mb-3 font-mono text-xs text-[var(--color-text-muted)]">
189 deleting a team is soft — members lose access and billing rolls back to each
190 user&apos;s personal tier. you can&apos;t delete a team that still owns projects;
191 delete or move them first.
192 </p>
193 <form action={deleteTeam}>
194 <button
195 type="submit"
196 className="rounded-md border border-red-500/40 px-3 py-1.5 font-mono text-xs text-red-400 transition hover:bg-red-500/10"
197 >
198 delete this team
199 </button>
200 </form>
201 </section>
202 ) : null}
203 </section>
204 );
205}
206
207async function TeamInvites({ teamId }: { teamId: string }) {
208 const [{ invitations }, { members }] = await Promise.all([
209 apiJson<{
210 invitations: Array<{
211 id: string;
212 email: string;
213 role: 'owner' | 'admin' | 'developer' | 'viewer';
214 expiresAt: string;
215 acceptedAt: string | null;
216 revokedAt: string | null;
217 }>;
218 }>(`/v1/orgs/${teamId}/invitations`),
219 apiJson<{
220 members: Array<{
221 userId: string;
222 email: string;
223 name: string | null;
224 role: 'owner' | 'admin' | 'developer' | 'viewer';
225 joinedAt: string;
226 }>;
227 }>(`/v1/orgs/${teamId}/members`),
228 ]);
229
230 const pending = invitations.filter((i) => !i.acceptedAt && !i.revokedAt);
231
232 async function removeMember(userId: string) {
233 'use server';
234 const res = await apiFetch(`/v1/orgs/${teamId}/members/${userId}`, {
235 method: 'DELETE',
236 });
237 if (!res.ok) {
238 const body = (await res.json().catch(() => ({}))) as { message?: string };
239 throw new Error(body.message ?? `remove failed: ${res.status}`);
240 }
241 redirect(`/dashboard/teams/${teamId}`);
242 }
243
244 async function changeRole(userId: string, formData: FormData) {
245 'use server';
246 const role = String(formData.get('role') ?? '').trim();
247 if (!role) throw new Error('role is required');
248 const res = await apiFetch(`/v1/orgs/${teamId}/members/${userId}`, {
249 method: 'PATCH',
250 headers: { 'content-type': 'application/json' },
251 body: JSON.stringify({ role }),
252 });
253 if (!res.ok) {
254 const body = (await res.json().catch(() => ({}))) as { message?: string };
255 throw new Error(body.message ?? `role change failed: ${res.status}`);
256 }
257 redirect(`/dashboard/teams/${teamId}`);
258 }
259
260 async function invite(formData: FormData) {
261 'use server';
262 const email = String(formData.get('email') ?? '').trim();
263 const role = String(formData.get('role') ?? 'developer');
264 if (!email) throw new Error('email is required');
265 const res = await apiFetch(`/v1/orgs/${teamId}/invitations`, {
266 method: 'POST',
267 headers: { 'content-type': 'application/json' },
268 body: JSON.stringify({
269 email,
270 role,
271 callbackURL: `https://briven.tech/dashboard/org-invitations/accept`,
272 }),
273 });
274 if (!res.ok) {
275 const body = await res.text().catch(() => '');
276 throw new Error(`invite failed (${res.status}): ${body}`);
277 }
278 redirect(`/dashboard/teams/${teamId}`);
279 }
280
281 async function revoke(invId: string) {
282 'use server';
283 const res = await apiFetch(`/v1/orgs/${teamId}/invitations/${invId}`, {
284 method: 'DELETE',
285 });
286 if (!res.ok) throw new Error(`revoke failed: ${res.status}`);
287 redirect(`/dashboard/teams/${teamId}`);
288 }
289
290 return (
291 <section>
292 <h2 className="mb-3 font-mono text-sm text-[var(--color-text-muted)]">members</h2>
293
294 <ul className="mb-4 flex flex-col gap-2">
295 {members.map((m) => (
296 <li
297 key={m.userId}
298 className="flex items-center justify-between rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-3"
299 >
300 <div className="min-w-0">
301 <p className="font-mono text-xs text-[var(--color-text)]">
302 {m.name ?? m.email}
303 </p>
304 <p className="mt-0.5 font-mono text-[10px] text-[var(--color-text-subtle)]">
305 {m.role} · joined {new Date(m.joinedAt).toISOString().slice(0, 10)}
306 </p>
307 </div>
308 <div className="flex items-center gap-2">
309 <form action={changeRole.bind(null, m.userId)} className="flex items-center gap-1">
310 <select
311 name="role"
312 defaultValue={m.role}
313 aria-label={`role for ${m.email}`}
314 className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1 font-mono text-[10px] outline-none focus:border-[var(--color-primary)]"
315 >
316 <option value="owner">owner</option>
317 <option value="admin">admin</option>
318 <option value="developer">developer</option>
319 <option value="viewer">viewer</option>
320 </select>
321 <button
322 type="submit"
323 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)]"
324 >
325 save
326 </button>
327 </form>
328 {m.role !== 'owner' ? (
329 <form action={removeMember.bind(null, m.userId)}>
330 <button
331 type="submit"
332 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-error)]"
333 >
334 remove
335 </button>
336 </form>
337 ) : null}
338 </div>
339 </li>
340 ))}
341 </ul>
342
343 <form action={invite} className="mb-4 flex flex-wrap items-end gap-3">
344 <label className="flex flex-1 flex-col gap-1">
345 <span className="font-mono text-xs text-[var(--color-text-muted)]">email</span>
346 <input
347 name="email"
348 type="email"
349 required
350 placeholder="teammate@example.com"
351 className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 font-mono text-sm outline-none focus:border-[var(--color-primary)]"
352 />
353 </label>
354 <label className="flex flex-col gap-1">
355 <span className="font-mono text-xs text-[var(--color-text-muted)]">role</span>
356 <select
357 name="role"
358 defaultValue="developer"
359 className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 font-mono text-sm outline-none focus:border-[var(--color-primary)]"
360 >
361 <option value="admin">admin</option>
362 <option value="developer">developer</option>
363 <option value="viewer">viewer</option>
364 </select>
365 </label>
366 <button
367 type="submit"
368 className="rounded-md bg-[var(--color-primary)] px-4 py-2 font-mono text-sm font-medium text-[var(--color-text-inverse)] transition hover:bg-[var(--color-primary-hover)]"
369 >
370 send invite
371 </button>
372 </form>
373
374 {pending.length === 0 ? (
375 <p className="font-mono text-xs text-[var(--color-text-subtle)]">
376 no pending invites. the invitee gets an email with a one-time accept link; the link
377 expires in 7 days.
378 </p>
379 ) : (
380 <ul className="flex flex-col gap-2">
381 {pending.map((inv) => (
382 <li
383 key={inv.id}
384 className="flex items-center justify-between rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-3"
385 >
386 <div className="min-w-0">
387 <p className="font-mono text-xs text-[var(--color-text)]">{inv.email}</p>
388 <p className="mt-0.5 font-mono text-[10px] text-[var(--color-text-subtle)]">
389 {inv.role} · expires {new Date(inv.expiresAt).toISOString().slice(0, 10)}
390 </p>
391 </div>
392 <form action={revoke.bind(null, inv.id)}>
393 <button
394 type="submit"
395 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-error)]"
396 >
397 revoke
398 </button>
399 </form>
400 </li>
401 ))}
402 </ul>
403 )}
404 </section>
405 );
406}