Skip to content

Commit 22a8348

Browse files
feat: workspace members — invite, roles, management (#32) (#51)
* 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> * chore: re-trigger PR review Co-authored-by: Ona <no-reply@ona.com> * fix: address review security feedback — use security definer RPCs for invite flow Replace overly permissive RLS policies with security definer functions: - get_invite_by_token: bypasses RLS for token lookup, works for anon+authenticated - accept_invite: atomically marks invite accepted and inserts member with the invite's role, preventing role escalation and column tampering Remove unused props (token, workspaceId, role) from InviteAccept component. Co-authored-by: Ona <no-reply@ona.com> --------- Co-authored-by: Ona <no-reply@ona.com>
1 parent 585dd26 commit 22a8348

16 files changed

Lines changed: 1366 additions & 4 deletions

File tree

.agents/architecture.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,16 +181,26 @@ src/
181181
│ ├── page-title.tsx # Inline-editable page title (saves on blur/Enter)
182182
│ ├── workspace-home.tsx # Workspace home: page list or empty state with create CTA
183183
│ ├── workspace-settings-form.tsx # Edit workspace name/slug, delete workspace
184+
│ ├── members/ # Workspace member management components
185+
│ │ ├── members-page.tsx # Client orchestrator: member list + invite form + pending invites
186+
│ │ ├── member-list.tsx # Table of members with role badges, role change, remove
187+
│ │ ├── invite-form.tsx # Email + role invite form (admin/owner only)
188+
│ │ ├── invite-accept.tsx # Client component for accepting an invite token
189+
│ │ ├── pending-invite-list.tsx # Table of pending invites with revoke action
190+
│ │ └── role-select.tsx # Role picker dropdown (owner/admin/member)
184191
│ └── ui/ # shadcn/ui components (base-nova style, base-ui primitives)
185192
│ ├── alert-dialog.tsx
193+
│ ├── badge.tsx
186194
│ ├── button.tsx
187195
│ ├── card.tsx
188196
│ ├── dialog.tsx
189197
│ ├── dropdown-menu.tsx
190198
│ ├── input.tsx
191199
│ ├── label.tsx
200+
│ ├── select.tsx
192201
│ ├── separator.tsx
193202
│ ├── sheet.tsx
203+
│ ├── table.tsx
194204
│ └── tooltip.tsx
195205
├── lib/
196206
│ ├── utils.ts # cn() utility (clsx + tailwind-merge)
@@ -219,15 +229,15 @@ src/
219229
│ ├── (auth)/ # Unauthenticated routes
220230
│ │ ├── sign-in/page.tsx # /sign-in
221231
│ │ ├── sign-up/page.tsx # /sign-up
222-
│ │ └── invite/[token]/page.tsx # /invite/[token]
232+
│ │ └── invite/[token]/page.tsx # /invite/[token] — invite accept flow
223233
│ ├── (app)/ # Authenticated routes
224234
│ │ ├── layout.tsx # App shell (sidebar + main content), passes userId
225235
│ │ └── [workspaceSlug]/
226236
│ │ ├── page.tsx # /[workspaceSlug] (workspace home)
227237
│ │ ├── [pageId]/page.tsx # /[workspaceSlug]/[pageId] (page view + Lexical editor)
228238
│ │ └── settings/
229239
│ │ ├── page.tsx # /[workspaceSlug]/settings (name, slug, delete)
230-
│ │ └── members/page.tsx # /[workspaceSlug]/settings/members — planned
240+
│ │ └── members/page.tsx # /[workspaceSlug]/settings/members
231241
│ └── api/
232242
│ ├── health/ # Existing
233243
│ └── ... # 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: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
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 via security definer function.
20+
// Works for both anon and authenticated users.
21+
const { data: invites } = await supabase.rpc("get_invite_by_token", {
22+
invite_token: token,
23+
});
24+
25+
const invite = invites?.[0] ?? null;
26+
27+
if (!invite) {
28+
return (
29+
<Card>
30+
<CardHeader>
31+
<CardTitle className="text-2xl font-semibold">
32+
Invalid invite
33+
</CardTitle>
34+
<CardDescription>
35+
This invite link is invalid or has been revoked.
36+
</CardDescription>
37+
</CardHeader>
38+
</Card>
39+
);
40+
}
41+
42+
if (invite.accepted_at) {
43+
return (
44+
<Card>
45+
<CardHeader>
46+
<CardTitle className="text-2xl font-semibold">
47+
Already accepted
48+
</CardTitle>
49+
<CardDescription>
50+
This invite to {invite.workspace_name} has already been accepted.
51+
</CardDescription>
52+
</CardHeader>
53+
</Card>
54+
);
55+
}
56+
57+
if (new Date(invite.expires_at) < new Date()) {
58+
return (
59+
<Card>
60+
<CardHeader>
61+
<CardTitle className="text-2xl font-semibold">
62+
Invite expired
63+
</CardTitle>
64+
<CardDescription>
65+
This invite has expired. Ask the workspace admin to send a new one.
66+
</CardDescription>
67+
</CardHeader>
68+
</Card>
69+
);
70+
}
71+
72+
// Check if the current user is authenticated
73+
const {
74+
data: { user },
75+
} = await supabase.auth.getUser();
76+
77+
return (
78+
<Card>
79+
<CardHeader>
80+
<CardTitle className="text-2xl font-semibold">
81+
Join {invite.workspace_name}
82+
</CardTitle>
83+
<CardDescription>
84+
You&apos;ve been invited to join as {invite.role === "admin" ? "an" : "a"}{" "}
85+
<span className="font-medium text-foreground">{invite.role}</span>.
86+
</CardDescription>
87+
</CardHeader>
88+
<CardContent>
89+
<InviteAccept
90+
inviteId={invite.id}
91+
workspaceSlug={invite.workspace_slug ?? ""}
92+
email={invite.email}
93+
isAuthenticated={!!user}
94+
userEmail={user?.email ?? null}
95+
/>
96+
</CardContent>
97+
</Card>
98+
);
99+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
import { useRouter } from "next/navigation";
5+
import Link from "next/link";
6+
import { createClient } from "@/lib/supabase/client";
7+
import { Button } from "@/components/ui/button";
8+
9+
interface InviteAcceptProps {
10+
inviteId: string;
11+
workspaceSlug: string;
12+
email: string;
13+
isAuthenticated: boolean;
14+
userEmail: string | null;
15+
}
16+
17+
export function InviteAccept({
18+
inviteId,
19+
workspaceSlug,
20+
email,
21+
isAuthenticated,
22+
userEmail,
23+
}: InviteAcceptProps) {
24+
const router = useRouter();
25+
const [accepting, setAccepting] = useState(false);
26+
const [error, setError] = useState<string | null>(null);
27+
28+
// Not authenticated — prompt to sign in or sign up
29+
if (!isAuthenticated) {
30+
return (
31+
<div className="flex flex-col gap-3">
32+
<p className="text-sm text-muted-foreground">
33+
Sign in or create an account with{" "}
34+
<span className="font-medium text-foreground">{email}</span> to
35+
accept this invite.
36+
</p>
37+
<div className="flex gap-2">
38+
<Button render={<Link href="/sign-in" />}>Sign in</Button>
39+
<Button variant="outline" render={<Link href="/sign-up" />}>
40+
Sign up
41+
</Button>
42+
</div>
43+
</div>
44+
);
45+
}
46+
47+
// Authenticated but with a different email
48+
const emailMismatch =
49+
userEmail && userEmail.toLowerCase() !== email.toLowerCase();
50+
51+
if (emailMismatch) {
52+
return (
53+
<div className="flex flex-col gap-3">
54+
<p className="text-sm text-muted-foreground">
55+
This invite was sent to{" "}
56+
<span className="font-medium text-foreground">{email}</span>, but
57+
you&apos;re signed in as{" "}
58+
<span className="font-medium text-foreground">{userEmail}</span>.
59+
</p>
60+
<p className="text-sm text-muted-foreground">
61+
Sign in with the invited email to accept.
62+
</p>
63+
</div>
64+
);
65+
}
66+
67+
async function handleAccept() {
68+
setAccepting(true);
69+
setError(null);
70+
71+
const supabase = createClient();
72+
73+
// Use the security definer RPC which atomically marks the invite as
74+
// accepted and inserts the member with the correct role from the invite.
75+
const { error: acceptError } = await supabase.rpc("accept_invite", {
76+
invite_id: inviteId,
77+
});
78+
79+
if (acceptError) {
80+
// Duplicate key means already a member — treat as success
81+
if (acceptError.message.includes("duplicate key")) {
82+
router.push(`/${workspaceSlug}`);
83+
return;
84+
}
85+
setError(acceptError.message);
86+
setAccepting(false);
87+
return;
88+
}
89+
90+
router.push(`/${workspaceSlug}`);
91+
}
92+
93+
return (
94+
<div className="flex flex-col gap-3">
95+
<p className="text-sm text-muted-foreground">
96+
You&apos;re signed in as{" "}
97+
<span className="font-medium text-foreground">{userEmail}</span>.
98+
</p>
99+
{error && <p className="text-xs text-destructive">{error}</p>}
100+
<Button onClick={handleAccept} disabled={accepting}>
101+
{accepting ? "Joining…" : "Accept invite"}
102+
</Button>
103+
</div>
104+
);
105+
}

0 commit comments

Comments
 (0)