From b47ddafd98c34600b8bbbc5e5dc09aed29cd5c3e Mon Sep 17 00:00:00 2001 From: Tony Rivera Date: Wed, 1 Jul 2026 20:58:36 -0400 Subject: [PATCH] fix: restrict pending invitations to admins on the Team page The Team page fetched every pending invitation, including its id, and passed the list to the client TeamPanel for all authenticated users. Client component props are serialized into the page payload, and an invitation id is its acceptance token (accept-invite resolves the invite by that id). So any member could read a pending invite token from the page and accept the seat, including an admin invite. Move the query into listPendingInvitations, which returns an empty list for non-admins so a token never reaches them. Non-admins already could not act on invitations in the UI. Adds a test for both paths. --- app/(dashboard)/team/page.tsx | 58 ++++--------------------- lib/team.test.ts | 79 +++++++++++++++++++++++++++++++++++ lib/team.ts | 70 +++++++++++++++++++++++++++++++ 3 files changed, 156 insertions(+), 51 deletions(-) create mode 100644 lib/team.test.ts create mode 100644 lib/team.ts diff --git a/app/(dashboard)/team/page.tsx b/app/(dashboard)/team/page.tsx index 8851033..6488154 100644 --- a/app/(dashboard)/team/page.tsx +++ b/app/(dashboard)/team/page.tsx @@ -1,35 +1,17 @@ -import { eq, asc, and } from "drizzle-orm"; +import { eq, asc } from "drizzle-orm"; import { headers } from "next/headers"; import { redirect } from "next/navigation"; import { auth } from "@/lib/auth"; import { getDb } from "@/lib/db/client"; import * as schema from "@/lib/db/schema"; import { resolveRole, isAdmin } from "@/lib/auth-session"; +import { listPendingInvitations, type TeamMember } from "@/lib/team"; import { TeamPanel } from "@/components/transit/TeamPanel"; export const dynamic = "force-dynamic"; -// --------------------------------------------------------------------------- -// Types passed to the client panel — all Dates serialized to ISO strings -// --------------------------------------------------------------------------- - -export type TeamMember = { - memberId: string; - userId: string; - role: string; - joinedAt: string; - name: string; - email: string; -}; - -export type PendingInvitation = { - id: string; - email: string; - role: string; - expiresAt: string; - createdAt: string; - inviterName: string; -}; +// Types passed to the client panel — all Dates serialized to ISO strings. +export type { TeamMember, PendingInvitation } from "@/lib/team"; // --------------------------------------------------------------------------- // TeamPage — server component; gates via session + resolves data @@ -72,26 +54,9 @@ export default async function TeamPage() { .orderBy(asc(schema.member.createdAt)) .all(); - // Pending invitations with inviter name - const rawInvitations = db - .select({ - id: schema.invitation.id, - email: schema.invitation.email, - role: schema.invitation.role, - expiresAt: schema.invitation.expiresAt, - createdAt: schema.invitation.createdAt, - inviterName: schema.user.name, - }) - .from(schema.invitation) - .innerJoin(schema.user, eq(schema.invitation.inviterId, schema.user.id)) - .where( - and( - eq(schema.invitation.organizationId, workspaceOrg.id), - eq(schema.invitation.status, "pending"), - ), - ) - .orderBy(asc(schema.invitation.createdAt)) - .all(); + // Pending invitations — ADMIN/OWNER ONLY. The invitation id is the acceptance token and + // every prop reaches the client via the RSC payload, so non-admins must never receive one. + const invitations = listPendingInvitations(db, workspaceOrg.id, actorIsAdmin); // Serialize Dates to ISO strings for client component boundary const members: TeamMember[] = rawMembers.map((m) => ({ @@ -103,15 +68,6 @@ export default async function TeamPage() { email: m.email, })); - const invitations: PendingInvitation[] = rawInvitations.map((i) => ({ - id: i.id, - email: i.email, - role: i.role ?? "member", - expiresAt: i.expiresAt instanceof Date ? i.expiresAt.toISOString() : String(i.expiresAt), - createdAt: i.createdAt instanceof Date ? i.createdAt.toISOString() : String(i.createdAt), - inviterName: i.inviterName, - })); - return ( ) { + const now = new Date(); + db.insert(schema.user) + .values({ id: "u-inviter", name: "Owner", email: "owner@example.com", createdAt: now, updatedAt: now }) + .run(); + db.insert(schema.organization) + .values({ id: ORG_ID, name: "Workspace", slug: "workspace", createdAt: now }) + .run(); + db.insert(schema.invitation) + .values({ + id: INVITE_ID, + organizationId: ORG_ID, + email: "invitee@example.com", + role: "admin", + status: "pending", + expiresAt: new Date(now.getTime() + 48 * 3600_000), + createdAt: now, + inviterId: "u-inviter", + }) + .run(); +} + +describe("listPendingInvitations", () => { + let db: ReturnType; + + beforeEach(() => { + db = makeTestDb(); + seed(db); + }); + + it("returns nothing for a non-admin actor, so the invite token never reaches them", () => { + const rows = listPendingInvitations(db, ORG_ID, false); + expect(rows).toEqual([]); + // Belt-and-suspenders: the acceptance token must not appear anywhere in the output. + expect(JSON.stringify(rows)).not.toContain(INVITE_ID); + }); + + it("returns the pending invitation (including its id) for an admin actor", () => { + const rows = listPendingInvitations(db, ORG_ID, true); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + id: INVITE_ID, + email: "invitee@example.com", + role: "admin", + inviterName: "Owner", + }); + expect(typeof rows[0].expiresAt).toBe("string"); + expect(typeof rows[0].createdAt).toBe("string"); + }); + + it("excludes non-pending invitations even for an admin", () => { + expect(listPendingInvitations(db, ORG_ID, true)).toHaveLength(1); + db.update(schema.invitation) + .set({ status: "accepted" }) + .where(eq(schema.invitation.id, INVITE_ID)) + .run(); + expect(listPendingInvitations(db, ORG_ID, true)).toEqual([]); + }); +}); diff --git a/lib/team.ts b/lib/team.ts new file mode 100644 index 0000000..b788fd0 --- /dev/null +++ b/lib/team.ts @@ -0,0 +1,70 @@ +import { eq, and, asc } from "drizzle-orm"; +import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3"; +import * as schema from "@/lib/db/schema"; + +// DTOs serialized across the Team page's server→client boundary. Dates are ISO strings. +export type TeamMember = { + memberId: string; + userId: string; + role: string; + joinedAt: string; + name: string; + email: string; +}; + +export type PendingInvitation = { + id: string; + email: string; + role: string; + expiresAt: string; + createdAt: string; + inviterName: string; +}; + +function iso(v: unknown): string { + return v instanceof Date ? v.toISOString() : String(v); +} + +// Pending invitations for the workspace org — ADMIN/OWNER ONLY. +// +// An invitation's `id` IS its acceptance token: app/accept-invite resolves the invite via +// eq(invitation.id, token). Every value returned here is passed as a prop to the client +// , and Next serializes all client-component props into the page's RSC payload, so it +// reaches the browser of anyone who can load /team. Returning these to a non-admin would hand a +// usable invite token (including for an admin-role invite) to any authenticated member, who could +// then accept a pending seat and escalate. Non-admins get an empty list and never receive a token. +export function listPendingInvitations( + db: BetterSQLite3Database, + organizationId: string, + actorIsAdmin: boolean, +): PendingInvitation[] { + if (!actorIsAdmin) return []; + const rows = db + .select({ + id: schema.invitation.id, + email: schema.invitation.email, + role: schema.invitation.role, + expiresAt: schema.invitation.expiresAt, + createdAt: schema.invitation.createdAt, + inviterName: schema.user.name, + }) + .from(schema.invitation) + .innerJoin(schema.user, eq(schema.invitation.inviterId, schema.user.id)) + .where( + and( + eq(schema.invitation.organizationId, organizationId), + eq(schema.invitation.status, "pending"), + ), + ) + .orderBy(asc(schema.invitation.createdAt)) + .all(); + + return rows.map((i) => ({ + id: i.id, + email: i.email, + role: i.role ?? "member", + expiresAt: iso(i.expiresAt), + createdAt: iso(i.createdAt), + inviterName: i.inviterName, + })); +}