page.tsx176 lines · main
1import { revalidatePath } from 'next/cache';
2
3import { apiFetch, apiJson } from '../../../../../../lib/api';
4import { AddMemberForm } from './add-member-form';
5import { InvitationRow } from './invitation-row';
6import { InviteForm } from './invite-form';
7import { MemberActions } from './member-actions';
8
9type Role = 'owner' | 'admin' | 'developer' | 'viewer';
10
11interface Member {
12 userId: string;
13 name: string | null;
14 role: Role;
15 createdAt: string;
16 email?: string;
17}
18
19interface Invitation {
20 id: string;
21 email: string;
22 role: Role;
23 expiresAt: string;
24 acceptedAt: string | null;
25 revokedAt: string | null;
26 createdAt: string;
27}
28
29export const dynamic = 'force-dynamic';
30
31export default async function MembersPage({ params }: { params: Promise<{ id: string }> }) {
32 const { id } = await params;
33 const [{ members }, invitesRes] = await Promise.all([
34 apiJson<{ members: Member[] }>(`/v1/projects/${id}/members`),
35 apiJson<{ invitations: Invitation[] }>(`/v1/projects/${id}/invitations`).catch(() => ({
36 invitations: [] as Invitation[],
37 })),
38 ]);
39
40 const pendingInvites = invitesRes.invitations.filter((i) => !i.acceptedAt && !i.revokedAt);
41
42 async function addMember(formData: FormData) {
43 'use server';
44 const { id } = await params;
45 const email = String(formData.get('email') ?? '').trim();
46 const role = String(formData.get('role') ?? 'developer') as Role;
47 const res = await apiFetch(`/v1/projects/${id}/members`, {
48 method: 'POST',
49 headers: { 'content-type': 'application/json' },
50 body: JSON.stringify({ email, role }),
51 });
52 if (!res.ok) {
53 const body = await res.text().catch(() => '');
54 throw new Error(body || `add member failed: ${res.status}`);
55 }
56 revalidatePath(`/dashboard/projects/${id}/members`);
57 }
58
59 async function invite(formData: FormData) {
60 'use server';
61 const { id } = await params;
62 const email = String(formData.get('email') ?? '').trim();
63 const role = String(formData.get('role') ?? 'developer') as Role;
64 const res = await apiFetch(`/v1/projects/${id}/invitations`, {
65 method: 'POST',
66 headers: { 'content-type': 'application/json' },
67 body: JSON.stringify({
68 email,
69 role,
70 callbackURL: `https://briven.tech/dashboard/invitations/accept`,
71 }),
72 });
73 if (!res.ok) {
74 const body = await res.text().catch(() => '');
75 throw new Error(body || `invite failed: ${res.status}`);
76 }
77 revalidatePath(`/dashboard/projects/${id}/members`);
78 }
79
80 async function revokeInvite(invitationId: string) {
81 'use server';
82 const { id } = await params;
83 const res = await apiFetch(`/v1/projects/${id}/invitations/${invitationId}`, {
84 method: 'DELETE',
85 });
86 if (!res.ok) throw new Error(`revoke failed: ${res.status}`);
87 revalidatePath(`/dashboard/projects/${id}/members`);
88 }
89
90 async function updateRole(userId: string, role: Role) {
91 'use server';
92 const { id } = await params;
93 const res = await apiFetch(`/v1/projects/${id}/members/${userId}`, {
94 method: 'PATCH',
95 headers: { 'content-type': 'application/json' },
96 body: JSON.stringify({ role }),
97 });
98 if (!res.ok) throw new Error(`update failed: ${res.status}`);
99 revalidatePath(`/dashboard/projects/${id}/members`);
100 }
101
102 async function remove(userId: string) {
103 'use server';
104 const { id } = await params;
105 const res = await apiFetch(`/v1/projects/${id}/members/${userId}`, { method: 'DELETE' });
106 if (!res.ok) throw new Error(`remove failed: ${res.status}`);
107 revalidatePath(`/dashboard/projects/${id}/members`);
108 }
109
110 return (
111 <div className="flex flex-col gap-6">
112 <header>
113 <h2 className="font-mono text-sm text-[var(--color-text)]">members</h2>
114 <p className="mt-1 font-mono text-xs text-[var(--color-text-muted)]">
115 owners have full control. admins manage members and keys. developers can deploy. viewers
116 are read-only.
117 </p>
118 </header>
119
120 <section>
121 <h3 className="font-mono text-xs text-[var(--color-text-muted)]">invite by email</h3>
122 <p className="mt-1 font-mono text-xs text-[var(--color-text-subtle)]">
123 sends a single-use link. valid for 7 days; revoke any time before accept.
124 </p>
125 <div className="mt-3">
126 <InviteForm action={invite} />
127 </div>
128
129 {pendingInvites.length > 0 ? (
130 <ul className="mt-3 flex flex-col gap-2">
131 {pendingInvites.map((inv) => (
132 <InvitationRow key={inv.id} invitation={inv} onRevoke={revokeInvite} />
133 ))}
134 </ul>
135 ) : null}
136 </section>
137
138 <section>
139 <h3 className="font-mono text-xs text-[var(--color-text-muted)]">
140 add existing user directly
141 </h3>
142 <p className="mt-1 font-mono text-xs text-[var(--color-text-subtle)]">
143 skips the invite email — the user must already exist in briven.
144 </p>
145 <div className="mt-3">
146 <AddMemberForm action={addMember} />
147 </div>
148 </section>
149
150 <section>
151 <h3 className="font-mono text-xs text-[var(--color-text-muted)]">current members</h3>
152 <ul className="mt-2 flex flex-col gap-2">
153 {members.map((m) => (
154 <li
155 key={m.userId}
156 className="flex items-center justify-between rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] px-4 py-3"
157 >
158 <div>
159 <p className="font-mono text-sm">{m.name ?? m.userId}</p>
160 <p className="mt-0.5 font-mono text-xs text-[var(--color-text-subtle)]">
161 {m.userId} · joined {new Date(m.createdAt).toISOString().slice(0, 10)}
162 </p>
163 </div>
164 <MemberActions
165 userId={m.userId}
166 role={m.role}
167 onUpdateRole={updateRole}
168 onRemove={remove}
169 />
170 </li>
171 ))}
172 </ul>
173 </section>
174 </div>
175 );
176}