diff --git a/app/(app)/projects/[id]/members/members-client.tsx b/app/(app)/projects/[id]/members/members-client.tsx index 53bac28d..c7c87027 100644 --- a/app/(app)/projects/[id]/members/members-client.tsx +++ b/app/(app)/projects/[id]/members/members-client.tsx @@ -5,10 +5,28 @@ import { inviteProjectMember, revokeProjectInvitation, removeProjectMember, + setProjectMemberRole, type TeamMember, type PendingInvitation, + type ProjectMemberRole, } from "@/app/actions/project-members"; +function RoleBadge({ role }: { role: ProjectMemberRole }) { + const isViewer = role === "viewer"; + return ( + + {isViewer ? "Read-only" : "Member"} + + ); +} + export function MembersClient({ projectId, isOwner, @@ -64,6 +82,7 @@ export function MembersClient({ function InviteForm({ projectId }: { projectId: string }) { const [email, setEmail] = useState(""); + const [role, setRole] = useState("member"); const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null); const [pending, start] = useTransition(); @@ -71,7 +90,7 @@ function InviteForm({ projectId }: { projectId: string }) { e.preventDefault(); setMessage(null); start(async () => { - const res = await inviteProjectMember(projectId, email); + const res = await inviteProjectMember(projectId, email, role); if (res.ok) { setEmail(""); setMessage({ ok: true, text: "Invitation sent." }); @@ -85,7 +104,8 @@ function InviteForm({ projectId }: { projectId: string }) {

Invite a team member

- 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.

setEmail(e.target.value)} autoComplete="off" /> +
+

+ 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 && (

    {sub}

    )}
    {isOwner && ( - +
    + + +
    )}
  • ); @@ -177,7 +238,10 @@ function InvitationRow({ return (
  • -

    {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>; } > { @@ -302,14 +314,17 @@ export async function requireProjectAccess(projectId: string): Promise< if (!project) return { ok: false, error: "Not found." }; const isOwner = (project as { owner_id: string }).owner_id === user.id; + let isViewer = false; if (!isOwner) { const { data: projectMembership } = await supabase .from("project_members") - .select("id") + .select("id, role") .eq("project_id", projectId) .eq("user_id", user.id) .maybeSingle(); - if (!projectMembership) { + if (projectMembership) { + isViewer = (projectMembership as { role?: string }).role === "viewer"; + } else { const orgId = (project as { organization_id?: string | null }).organization_id; if (!orgId) return { ok: false, error: "Not found." }; const { data: orgMembership } = await supabase @@ -323,7 +338,19 @@ export async function requireProjectAccess(projectId: string): Promise< } } - return { ok: true, userId: user.id, userEmail: user.email ?? null, isOwner, supabase }; + // Read-only members may only reach endpoints that explicitly allow it. + if (isViewer && !opts.allowViewer) { + return { ok: false, error: "Read-only access — you can't make changes to this project." }; + } + + return { + ok: true, + userId: user.id, + userEmail: user.email ?? null, + isOwner, + isViewer, + supabase, + }; } // Server-action / route-handler helper that writes the cookie. The diff --git a/supabase/migrations/20260611120000_project_member_viewer_role.sql b/supabase/migrations/20260611120000_project_member_viewer_role.sql new file mode 100644 index 00000000..e1f913f0 --- /dev/null +++ b/supabase/migrations/20260611120000_project_member_viewer_role.sql @@ -0,0 +1,109 @@ +-- Read-only team members ("viewers"). +-- +-- A project_members row can now carry a role: +-- 'member' (default) — full collaborator: read + write project data +-- 'viewer' — read-only: sees everything a member sees, but +-- cannot mutate any project data +-- +-- Read access is unchanged (every SELECT policy keeps using +-- is_project_member, which includes viewers). We gate every member WRITE +-- policy behind a new is_project_editor() check that excludes viewers. + +alter table public.project_members + add column if not exists role text not null default 'member'; + +alter table public.project_members + drop constraint if exists project_members_role_check; +alter table public.project_members + add constraint project_members_role_check + check (role in ('member', 'viewer')); + +-- Carry the chosen role through the invitation so it is applied on accept. +alter table public.project_invitations + add column if not exists role text not null default 'member'; + +alter table public.project_invitations + drop constraint if exists project_invitations_role_check; +alter table public.project_invitations + add constraint project_invitations_role_check + check (role in ('member', 'viewer')); + +-- is_project_editor: a user allowed to WRITE project data. +-- Mirrors is_project_member (latest definition) but excludes viewers on the +-- explicit-membership branch. Org owner/member retain write access; the org +-- 'project_member' visibility marker grants nothing on its own. +create or replace function public.is_project_editor(p_project_id uuid, p_user_id uuid) +returns boolean +language sql +security definer +stable +set search_path = public +as $$ + select exists( + select 1 + from public.project_members pm + where pm.project_id = p_project_id + and pm.user_id = p_user_id + and pm.role = 'member' + ) + or exists( + select 1 + from public.projects p + join public.organization_members om + on om.organization_id = p.organization_id + where p.id = p_project_id + and om.user_id = p_user_id + and om.role in ('owner', 'member') + ) +$$; + +-- ── projects (member update) ───────────────────────────────────────────── +drop policy if exists "projects member update" on public.projects; +create policy "projects member update" + on public.projects for update + using (public.is_project_editor(id, auth.uid())) + with check (public.is_project_editor(id, auth.uid())); + +-- ── project_repos (member insert) ──────────────────────────────────────── +drop policy if exists "project_repos member insert" on public.project_repos; +create policy "project_repos member insert" + on public.project_repos for insert + with check (public.is_project_editor(project_id, auth.uid())); + +-- ── tracker_integrations (member insert/update/delete) ─────────────────── +drop policy if exists "tracker_integrations member insert" on public.tracker_integrations; +create policy "tracker_integrations member insert" + on public.tracker_integrations for insert + with check (public.is_project_editor(project_id, auth.uid())); + +drop policy if exists "tracker_integrations member update" on public.tracker_integrations; +create policy "tracker_integrations member update" + on public.tracker_integrations for update + using (public.is_project_editor(project_id, auth.uid())) + with check (public.is_project_editor(project_id, auth.uid())); + +drop policy if exists "tracker_integrations member delete" on public.tracker_integrations; +create policy "tracker_integrations member delete" + on public.tracker_integrations for delete + using (public.is_project_editor(project_id, auth.uid())); + +-- ── lx_site (member update/insert) ─────────────────────────────────────── +drop policy if exists "lx_site member update" on public.lx_site; +create policy "lx_site member update" + on public.lx_site for update + using ( + project_id is not null + and public.is_project_editor(project_id, auth.uid()) + ) + with check ( + project_id is not null + and public.is_project_editor(project_id, auth.uid()) + ); + +drop policy if exists "lx_site member insert" on public.lx_site; +create policy "lx_site member insert" + on public.lx_site for insert + with check ( + project_id is not null + and public.is_project_editor(project_id, auth.uid()) + );