Skip to content

Commit 1d06a1c

Browse files
feat: workspace members — invite, roles, management (#32)
- Add members settings page at /[workspaceSlug]/settings/members - Invite form: email + role, creates workspace_invites row with token and 7-day expiry - Invite accept page at /invite/[token] with states for invalid, expired, already accepted, unauthenticated, email mismatch, and ready-to-accept - Member list with role badges, role change (Select), and remove (with confirmation) - Pending invites list with revoke action - RLS migration: policies for invite token lookup, invite acceptance, self-join via invite, and self-removal from workspace - Personal workspace owner cannot be removed - Navigation: Members link in sidebar user menu and workspace settings page - shadcn/ui components added: Badge, Select, Table Co-authored-by: Ona <no-reply@ona.com>
1 parent a2007bd commit 1d06a1c

16 files changed

Lines changed: 1364 additions & 4 deletions

File tree

.agents/architecture.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,16 +171,26 @@ src/
171171
│ │ ├── page-tree.tsx # Page list placeholder (functional in #28)
172172
│ │ └── user-menu.tsx # User dropdown with settings link + sign-out
173173
│ ├── workspace-settings-form.tsx # Edit workspace name/slug, delete workspace
174+
│ ├── members/ # Workspace member management components
175+
│ │ ├── members-page.tsx # Client orchestrator: member list + invite form + pending invites
176+
│ │ ├── member-list.tsx # Table of members with role badges, role change, remove
177+
│ │ ├── invite-form.tsx # Email + role invite form (admin/owner only)
178+
│ │ ├── invite-accept.tsx # Client component for accepting an invite token
179+
│ │ ├── pending-invite-list.tsx # Table of pending invites with revoke action
180+
│ │ └── role-select.tsx # Role picker dropdown (owner/admin/member)
174181
│ └── ui/ # shadcn/ui components (base-nova style, base-ui primitives)
175182
│ ├── alert-dialog.tsx
183+
│ ├── badge.tsx
176184
│ ├── button.tsx
177185
│ ├── card.tsx
178186
│ ├── dialog.tsx
179187
│ ├── dropdown-menu.tsx
180188
│ ├── input.tsx
181189
│ ├── label.tsx
190+
│ ├── select.tsx
182191
│ ├── separator.tsx
183192
│ ├── sheet.tsx
193+
│ ├── table.tsx
184194
│ └── tooltip.tsx
185195
├── lib/
186196
│ ├── utils.ts # cn() utility (clsx + tailwind-merge)
@@ -209,15 +219,15 @@ src/
209219
│ ├── (auth)/ # Unauthenticated routes
210220
│ │ ├── sign-in/page.tsx # /sign-in
211221
│ │ ├── sign-up/page.tsx # /sign-up
212-
│ │ └── invite/[token]/page.tsx # /invite/[token]
222+
│ │ └── invite/[token]/page.tsx # /invite/[token] — invite accept flow
213223
│ ├── (app)/ # Authenticated routes
214224
│ │ ├── layout.tsx # App shell (sidebar + main content), passes userId
215225
│ │ └── [workspaceSlug]/
216226
│ │ ├── page.tsx # /[workspaceSlug] (workspace home)
217227
│ │ ├── [pageId]/page.tsx # /[workspaceSlug]/[pageId] (editor) — planned
218228
│ │ └── settings/
219229
│ │ ├── page.tsx # /[workspaceSlug]/settings (name, slug, delete)
220-
│ │ └── members/page.tsx # /[workspaceSlug]/settings/members — planned
230+
│ │ └── members/page.tsx # /[workspaceSlug]/settings/members
221231
│ └── api/
222232
│ ├── health/ # Existing
223233
│ └── ... # Additional API routes as needed
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { notFound, redirect } from "next/navigation";
2+
import { createClient } from "@/lib/supabase/server";
3+
import { MembersPage } from "@/components/members/members-page";
4+
import type { MemberWithProfile, WorkspaceInviteWithInviter } from "@/lib/types";
5+
6+
export default async function WorkspaceMembersPage({
7+
params,
8+
}: {
9+
params: Promise<{ workspaceSlug: string }>;
10+
}) {
11+
const { workspaceSlug } = await params;
12+
const supabase = await createClient();
13+
14+
const {
15+
data: { user },
16+
} = await supabase.auth.getUser();
17+
18+
if (!user) {
19+
redirect("/sign-in");
20+
}
21+
22+
const { data: workspace } = await supabase
23+
.from("workspaces")
24+
.select("*")
25+
.eq("slug", workspaceSlug)
26+
.maybeSingle();
27+
28+
if (!workspace) {
29+
notFound();
30+
}
31+
32+
// Fetch current user's membership to determine their role
33+
const { data: currentMember } = await supabase
34+
.from("members")
35+
.select("id, role")
36+
.eq("workspace_id", workspace.id)
37+
.eq("user_id", user.id)
38+
.maybeSingle();
39+
40+
if (!currentMember) {
41+
notFound();
42+
}
43+
44+
// Fetch all members with profile info
45+
const { data: membersRaw } = await supabase
46+
.from("members")
47+
.select("*, profiles(email, display_name, avatar_url)")
48+
.eq("workspace_id", workspace.id)
49+
.order("created_at", { ascending: true });
50+
51+
// Supabase join returns the relation as an opaque type; cast is unavoidable
52+
const members = (membersRaw ?? []) as unknown as MemberWithProfile[];
53+
54+
// Fetch pending invites (only visible to admins/owners)
55+
const isAdmin = currentMember.role === "owner" || currentMember.role === "admin";
56+
let pendingInvites: WorkspaceInviteWithInviter[] = [];
57+
58+
if (isAdmin) {
59+
const { data: invites } = await supabase
60+
.from("workspace_invites")
61+
.select("*, profiles:invited_by(display_name)")
62+
.eq("workspace_id", workspace.id)
63+
.is("accepted_at", null)
64+
.order("created_at", { ascending: false });
65+
66+
// Supabase join returns the relation as an opaque type; cast is unavoidable
67+
pendingInvites = (invites ?? []) as unknown as WorkspaceInviteWithInviter[];
68+
}
69+
70+
return (
71+
<div className="mx-auto max-w-xl p-6">
72+
<h1 className="text-2xl font-semibold">Members</h1>
73+
<p className="mt-1 text-sm text-muted-foreground">
74+
Manage who has access to this workspace.
75+
</p>
76+
<div className="mt-6">
77+
<MembersPage
78+
workspace={workspace}
79+
members={members}
80+
pendingInvites={pendingInvites}
81+
currentUserId={user.id}
82+
currentUserRole={currentMember.role}
83+
/>
84+
</div>
85+
</div>
86+
);
87+
}

src/app/(app)/[workspaceSlug]/settings/page.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import Link from "next/link";
12
import { notFound, redirect } from "next/navigation";
23
import { createClient } from "@/lib/supabase/server";
34
import { WorkspaceSettingsForm } from "@/components/workspace-settings-form";
@@ -30,7 +31,15 @@ export default async function WorkspaceSettingsPage({
3031

3132
return (
3233
<div className="mx-auto max-w-xl p-6">
33-
<h1 className="text-2xl font-semibold">Workspace settings</h1>
34+
<div className="flex items-center gap-4">
35+
<h1 className="text-2xl font-semibold">Workspace settings</h1>
36+
<Link
37+
href={`/${workspaceSlug}/settings/members`}
38+
className="text-sm text-accent underline-offset-4 hover:underline"
39+
>
40+
Members
41+
</Link>
42+
</div>
3443
<p className="mt-1 text-sm text-muted-foreground">
3544
Manage your workspace name, URL, and other settings.
3645
</p>
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { createClient } from "@/lib/supabase/server";
2+
import {
3+
Card,
4+
CardContent,
5+
CardDescription,
6+
CardHeader,
7+
CardTitle,
8+
} from "@/components/ui/card";
9+
import { InviteAccept } from "@/components/members/invite-accept";
10+
11+
export default async function InviteAcceptPage({
12+
params,
13+
}: {
14+
params: Promise<{ token: string }>;
15+
}) {
16+
const { token } = await params;
17+
const supabase = await createClient();
18+
19+
// Look up the invite by token
20+
const { data: invite } = await supabase
21+
.from("workspace_invites")
22+
.select("*, workspaces(name, slug)")
23+
.eq("token", token)
24+
.maybeSingle();
25+
26+
if (!invite) {
27+
return (
28+
<Card>
29+
<CardHeader>
30+
<CardTitle className="text-2xl font-semibold">
31+
Invalid invite
32+
</CardTitle>
33+
<CardDescription>
34+
This invite link is invalid or has been revoked.
35+
</CardDescription>
36+
</CardHeader>
37+
</Card>
38+
);
39+
}
40+
41+
if (invite.accepted_at) {
42+
// Supabase join returns the relation as an opaque type; cast is unavoidable
43+
const ws = invite.workspaces as unknown as { name: string; slug: string };
44+
return (
45+
<Card>
46+
<CardHeader>
47+
<CardTitle className="text-2xl font-semibold">
48+
Already accepted
49+
</CardTitle>
50+
<CardDescription>
51+
This invite to {ws?.name} has already been accepted.
52+
</CardDescription>
53+
</CardHeader>
54+
</Card>
55+
);
56+
}
57+
58+
if (new Date(invite.expires_at) < new Date()) {
59+
return (
60+
<Card>
61+
<CardHeader>
62+
<CardTitle className="text-2xl font-semibold">
63+
Invite expired
64+
</CardTitle>
65+
<CardDescription>
66+
This invite has expired. Ask the workspace admin to send a new one.
67+
</CardDescription>
68+
</CardHeader>
69+
</Card>
70+
);
71+
}
72+
73+
// Supabase join returns the relation as an opaque type; cast is unavoidable
74+
const ws = invite.workspaces as unknown as { name: string; slug: string };
75+
76+
// Check if the current user is authenticated
77+
const {
78+
data: { user },
79+
} = await supabase.auth.getUser();
80+
81+
return (
82+
<Card>
83+
<CardHeader>
84+
<CardTitle className="text-2xl font-semibold">
85+
Join {ws?.name}
86+
</CardTitle>
87+
<CardDescription>
88+
You&apos;ve been invited to join as {invite.role === "admin" ? "an" : "a"}{" "}
89+
<span className="font-medium text-foreground">{invite.role}</span>.
90+
</CardDescription>
91+
</CardHeader>
92+
<CardContent>
93+
<InviteAccept
94+
token={token}
95+
inviteId={invite.id}
96+
workspaceId={invite.workspace_id}
97+
workspaceSlug={ws?.slug ?? ""}
98+
email={invite.email}
99+
role={invite.role}
100+
isAuthenticated={!!user}
101+
userEmail={user?.email ?? null}
102+
/>
103+
</CardContent>
104+
</Card>
105+
);
106+
}

0 commit comments

Comments
 (0)