Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 76 additions & 12 deletions app/(app)/projects/[id]/members/members-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<span
className={`badge ${isViewer ? "" : "badge-pass"} text-xs`}
title={
isViewer
? "Read-only — can view stats and project data but cannot make changes"
: "Full team member — can view and edit project data"
}
>
{isViewer ? "Read-only" : "Member"}
</span>
);
}

export function MembersClient({
projectId,
isOwner,
Expand Down Expand Up @@ -64,14 +82,15 @@ export function MembersClient({

function InviteForm({ projectId }: { projectId: string }) {
const [email, setEmail] = useState("");
const [role, setRole] = useState<ProjectMemberRole>("member");
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null);
const [pending, start] = useTransition();

function onSubmit(e: React.FormEvent) {
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." });
Expand All @@ -85,7 +104,8 @@ function InviteForm({ projectId }: { projectId: string }) {
<section className="card p-4">
<h2 className="text-lg font-semibold">Invite a team member</h2>
<p className="mt-1 text-sm text-[var(--color-muted)]">
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.
</p>
<form onSubmit={onSubmit} className="mt-3 flex flex-wrap gap-2">
<input
Expand All @@ -97,10 +117,23 @@ function InviteForm({ projectId }: { projectId: string }) {
onChange={(e) => setEmail(e.target.value)}
autoComplete="off"
/>
<select
className="input w-auto"
value={role}
onChange={(e) => setRole(e.target.value as ProjectMemberRole)}
aria-label="Access level"
>
<option value="member">Member (can edit)</option>
<option value="viewer">Read-only (view stats)</option>
</select>
<button type="submit" className="btn" disabled={pending}>
{pending ? "Sending…" : "Send invite"}
</button>
</form>
<p className="mt-2 text-xs text-[var(--color-muted)]">
Read-only members can view stats and project data but can't change any
settings.
</p>
{message && (
<p
className={`mt-2 text-sm ${
Expand Down Expand Up @@ -129,30 +162,58 @@ function MemberRow({
const sub = member.profile?.display_name
? member.profile.email
: new Date(member.created_at).toLocaleDateString();
const isViewer = member.role === "viewer";

function onRemove() {
start(async () => {
await removeProjectMember(projectId, member.user_id);
});
}

function onToggleRole() {
start(async () => {
await setProjectMemberRole(
projectId,
member.user_id,
isViewer ? "member" : "viewer",
);
});
}

return (
<li className="flex flex-wrap items-center justify-between gap-3 p-3">
<div>
<p className="text-sm font-medium">{label}</p>
<div className="flex items-center gap-2">
<p className="text-sm font-medium">{label}</p>
<RoleBadge role={member.role} />
</div>
{sub && (
<p className="text-xs text-[var(--color-muted)]">{sub}</p>
)}
</div>
{isOwner && (
<button
type="button"
className="btn-ghost text-xs text-[var(--color-fail)]"
disabled={pending}
onClick={onRemove}
>
{pending ? "Removing…" : "Remove"}
</button>
<div className="flex items-center gap-3">
<button
type="button"
className="btn-ghost text-xs"
disabled={pending}
onClick={onToggleRole}
>
{pending
? "Saving…"
: isViewer
? "Make editor"
: "Make read-only"}
</button>
<button
type="button"
className="btn-ghost text-xs text-[var(--color-fail)]"
disabled={pending}
onClick={onRemove}
>
{pending ? "Removing…" : "Remove"}
</button>
</div>
)}
</li>
);
Expand All @@ -177,7 +238,10 @@ function InvitationRow({
return (
<li className="flex flex-wrap items-center justify-between gap-3 p-3">
<div>
<p className="text-sm font-medium">{invitation.email}</p>
<div className="flex items-center gap-2">
<p className="text-sm font-medium">{invitation.email}</p>
<RoleBadge role={invitation.role} />
</div>
<p className="text-xs text-[var(--color-muted)]">
{expired
? "Expired"
Expand Down
38 changes: 34 additions & 4 deletions app/actions/project-members.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -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();

Expand Down Expand Up @@ -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;
};

Expand All @@ -168,6 +193,7 @@ export type PendingInvitation = {
email: string;
expires_at: string;
created_at: string;
role: ProjectMemberRole;
};

export async function listProjectTeam(projectId: string): Promise<
Expand Down Expand Up @@ -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 });

Expand All @@ -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 });
Expand All @@ -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",
})),
};
}
2 changes: 1 addition & 1 deletion app/api/projects/[id]/live-events/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
2 changes: 1 addition & 1 deletion app/api/projects/[id]/runs/[runId]/markdown/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Expand Down
2 changes: 1 addition & 1 deletion app/api/projects/[id]/runs/[runId]/status/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
3 changes: 2 additions & 1 deletion app/invite/[token]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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",
});
}

Expand Down
35 changes: 31 additions & 4 deletions lib/lx/currentSite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof createClient>>;
}
> {
Expand All @@ -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
Expand All @@ -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
Expand Down
Loading
Loading