- They'll get an email with a link to join this project. + They'll get an email with a link to join this project. Invitees sign up + for free — no payment required.
++ Read-only members can view stats and project data but can't change any + settings. +
{message && ({ @@ -136,23 +170,50 @@ function MemberRow({ }); } + function onToggleRole() { + start(async () => { + await setProjectMemberRole( + projectId, + member.user_id, + isViewer ? "member" : "viewer", + ); + }); + } + return (
{label}
+{label}
+{sub}
)}{invitation.email}
+{invitation.email}
+
{expired
? "Expired"
diff --git a/app/actions/project-members.ts b/app/actions/project-members.ts
index e0abc2fb..a4fc501c 100644
--- a/app/actions/project-members.ts
+++ b/app/actions/project-members.ts
@@ -24,14 +24,18 @@ async function requireOwner(projectId: string) {
return { ok: true as const, user, project, supabase };
}
+export type ProjectMemberRole = "member" | "viewer";
+
export async function inviteProjectMember(
projectId: string,
email: string,
+ role: ProjectMemberRole = "member",
): Promise<{ ok: boolean; error?: string }> {
const normalized = email.trim().toLowerCase();
if (!normalized.includes("@")) {
return { ok: false, error: "Invalid email address." };
}
+ const memberRole: ProjectMemberRole = role === "viewer" ? "viewer" : "member";
const ctx = await requireOwner(projectId);
if (!ctx.ok) return { ok: false, error: ctx.error };
@@ -57,7 +61,7 @@ export async function inviteProjectMember(
const { data: invitation, error: insertErr } = await svc
.from("project_invitations")
- .insert({ project_id: projectId, email: normalized, invited_by: user.id })
+ .insert({ project_id: projectId, email: normalized, invited_by: user.id, role: memberRole })
.select("token")
.single();
@@ -156,10 +160,31 @@ export async function removeProjectMember(
return { ok: true };
}
+export async function setProjectMemberRole(
+ projectId: string,
+ userId: string,
+ role: ProjectMemberRole,
+): Promise<{ ok: boolean; error?: string }> {
+ const memberRole: ProjectMemberRole = role === "viewer" ? "viewer" : "member";
+ const ctx = await requireOwner(projectId);
+ if (!ctx.ok) return { ok: false, error: ctx.error };
+
+ const { error } = await ctx.supabase
+ .from("project_members")
+ .update({ role: memberRole })
+ .eq("project_id", projectId)
+ .eq("user_id", userId);
+
+ if (error) return { ok: false, error: error.message };
+ revalidatePath(`/projects/${projectId}/members`);
+ return { ok: true };
+}
+
export type TeamMember = {
id: string;
user_id: string;
created_at: string;
+ role: ProjectMemberRole;
profile: { id: string; email: string; display_name: string } | null;
};
@@ -168,6 +193,7 @@ export type PendingInvitation = {
email: string;
expires_at: string;
created_at: string;
+ role: ProjectMemberRole;
};
export async function listProjectTeam(projectId: string): Promise<
@@ -196,7 +222,7 @@ export async function listProjectTeam(projectId: string): Promise<
const { data: membersRaw } = await svc
.from("project_members")
- .select("id, user_id, created_at")
+ .select("id, user_id, created_at, role")
.eq("project_id", projectId)
.order("created_at", { ascending: true });
@@ -213,13 +239,14 @@ export async function listProjectTeam(projectId: string): Promise<
id: m.id,
user_id: m.user_id,
created_at: m.created_at,
+ role: m.role === "viewer" ? "viewer" : "member",
profile:
(profilesRaw ?? []).find((p: any) => p.id === m.user_id) ?? null,
}));
const { data: invitationsRaw } = await svc
.from("project_invitations")
- .select("id, email, expires_at, created_at")
+ .select("id, email, expires_at, created_at, role")
.eq("project_id", projectId)
.is("accepted_at", null)
.order("created_at", { ascending: false });
@@ -228,6 +255,9 @@ export async function listProjectTeam(projectId: string): Promise<
ok: true,
isOwner: project.owner_id === user.id,
members,
- invitations: invitationsRaw ?? [],
+ invitations: (invitationsRaw ?? []).map((i: any) => ({
+ ...i,
+ role: i.role === "viewer" ? "viewer" : "member",
+ })),
};
}
diff --git a/app/api/projects/[id]/live-events/route.ts b/app/api/projects/[id]/live-events/route.ts
index 9c0e0117..44378f04 100644
--- a/app/api/projects/[id]/live-events/route.ts
+++ b/app/api/projects/[id]/live-events/route.ts
@@ -35,7 +35,7 @@ export async function GET(
) {
const { id: projectId } = await params;
- const access = await requireProjectAccess(projectId);
+ const access = await requireProjectAccess(projectId, { allowViewer: true });
if (!access.ok) {
const status = access.error === "Not authenticated." ? 401 : 404;
return NextResponse.json({ error: access.error }, { status });
diff --git a/app/api/projects/[id]/runs/[runId]/markdown/route.ts b/app/api/projects/[id]/runs/[runId]/markdown/route.ts
index bd3f61ed..3210e53f 100644
--- a/app/api/projects/[id]/runs/[runId]/markdown/route.ts
+++ b/app/api/projects/[id]/runs/[runId]/markdown/route.ts
@@ -14,7 +14,7 @@ export async function GET(
{ params }: { params: Promise<{ id: string; runId: string }> },
) {
const { id: projectId, runId } = await params;
- const access = await requireProjectAccess(projectId);
+ const access = await requireProjectAccess(projectId, { allowViewer: true });
if (!access.ok) {
return new Response(access.error, { status: access.error === "Not authenticated." ? 401 : 404 });
}
diff --git a/app/api/projects/[id]/runs/[runId]/status/route.ts b/app/api/projects/[id]/runs/[runId]/status/route.ts
index 4a1ddb1c..f578b54e 100644
--- a/app/api/projects/[id]/runs/[runId]/status/route.ts
+++ b/app/api/projects/[id]/runs/[runId]/status/route.ts
@@ -11,7 +11,7 @@ export async function GET(
{ params }: { params: Promise<{ id: string; runId: string }> },
) {
const { id: projectId, runId } = await params;
- const access = await requireProjectAccess(projectId);
+ const access = await requireProjectAccess(projectId, { allowViewer: true });
if (!access.ok) {
const status = access.error === "Not authenticated." ? 401 : 404;
return NextResponse.json({ ok: false, error: access.error }, { status });
diff --git a/app/invite/[token]/page.tsx b/app/invite/[token]/page.tsx
index 1145f8c5..99a839cd 100644
--- a/app/invite/[token]/page.tsx
+++ b/app/invite/[token]/page.tsx
@@ -15,7 +15,7 @@ export default async function InvitePage({
const { data: inv } = await svc
.from("project_invitations")
- .select("id, project_id, email, expires_at, accepted_at")
+ .select("id, project_id, email, expires_at, accepted_at, role")
.eq("token", token)
.maybeSingle();
@@ -118,6 +118,7 @@ export default async function InvitePage({
project_id: inv.project_id,
user_id: user.id,
invited_by: invitationFull?.invited_by ?? user.id,
+ role: inv.role === "viewer" ? "viewer" : "member",
});
}
diff --git a/lib/lx/currentSite.ts b/lib/lx/currentSite.ts
index b5f03c58..f9315ffd 100644
--- a/lib/lx/currentSite.ts
+++ b/lib/lx/currentSite.ts
@@ -278,13 +278,25 @@ function normalizeProject(
// Server-action / route-handler helper that verifies the signed-in user owns
// or is a member of the given project. Returns the user id, an isOwner flag,
// and the authenticated supabase client so callers can make further queries.
-export async function requireProjectAccess(projectId: string): Promise<
+// Gate a server action / route handler on project access.
+//
+// By DEFAULT this requires WRITE access — read-only ("viewer") project
+// members are rejected. This is deliberate: most callers mutate project
+// data, and many do so through the service-role client (which bypasses
+// RLS), so the only thing standing between a viewer and a mutation is
+// this gate. Read-only endpoints that viewers legitimately need (live
+// visitors, run status/markdown) must opt in with `{ allowViewer: true }`.
+export async function requireProjectAccess(
+ projectId: string,
+ opts: { allowViewer?: boolean } = {},
+): Promise<
| { ok: false; error: string }
| {
ok: true;
userId: string;
userEmail: string | null;
isOwner: boolean;
+ isViewer: boolean;
supabase: Awaited