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
58 changes: 7 additions & 51 deletions app/(dashboard)/team/page.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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) => ({
Expand All @@ -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 (
<TeamPanel
workspaceName={workspaceOrg.name}
Expand Down
79 changes: 79 additions & 0 deletions lib/team.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, it, expect, beforeEach } from "vitest";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { eq } from "drizzle-orm";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { getDb } from "@/lib/db/client";
import * as schema from "@/lib/db/schema";
import { listPendingInvitations } from "@/lib/team";

function makeTestDb() {
const file = path.join(mkdtempSync(path.join(tmpdir(), "sentou-team-")), "t.db");
const db = getDb(file);
migrate(db, { migrationsFolder: "lib/db/migrations" });
return db;
}

const ORG_ID = "org-1";
const INVITE_ID = "invite-token-secret";

function seed(db: ReturnType<typeof makeTestDb>) {
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<typeof makeTestDb>;

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([]);
});
});
70 changes: 70 additions & 0 deletions lib/team.ts
Original file line number Diff line number Diff line change
@@ -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
// <TeamPanel>, 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<typeof schema>,
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,
}));
}
Loading