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, + })); +}