From 683d5674d1ab5de08269535908aa4f0d33db17b9 Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Fri, 10 Jul 2026 00:55:34 +0800 Subject: [PATCH] feat(hub): add org members, teams, and project team-access APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manage memberships with last-OWNER protection, team lifecycle (archive revokes TEAM grants), and TEAM→PROJECT access under org admin auth. --- hub/src/admin/routes/accessRoutes.ts | 85 +++++ hub/src/admin/routes/membersRoutes.ts | 104 +++++++ hub/src/admin/routes/orgRoutes.ts | 15 + hub/src/admin/routes/teamsRoutes.ts | 140 +++++++++ hub/src/org/members.ts | 213 +++++++++++++ hub/src/org/teams.ts | 290 ++++++++++++++++++ .../integration/admin-members-teams.test.ts | 161 ++++++++++ 7 files changed, 1008 insertions(+) create mode 100644 hub/src/admin/routes/accessRoutes.ts create mode 100644 hub/src/admin/routes/membersRoutes.ts create mode 100644 hub/src/admin/routes/teamsRoutes.ts create mode 100644 hub/src/org/members.ts create mode 100644 hub/src/org/teams.ts create mode 100644 hub/test/integration/admin-members-teams.test.ts diff --git a/hub/src/admin/routes/accessRoutes.ts b/hub/src/admin/routes/accessRoutes.ts new file mode 100644 index 0000000..c1d0b63 --- /dev/null +++ b/hub/src/admin/routes/accessRoutes.ts @@ -0,0 +1,85 @@ +import type { PermissionRole, PrismaClient } from "@prisma/client"; +import type { FastifyInstance } from "fastify"; +import { + grantTeamProjectAccess, + listProjectTeamAccess, + revokeTeamProjectAccess, +} from "../../permissions/projectTeamAccess.js"; +import { requireOrgProject, requireOrgRole, type GuardDeps } from "../auth/guards.js"; +import { handleRouteError } from "../errors.js"; + +const ROLES: readonly PermissionRole[] = ["READ", "EDIT", "MANAGE"]; + +export async function registerAccessRoutes( + app: FastifyInstance, + config: { readonly prisma: PrismaClient; readonly sessionSecret: string }, +): Promise { + const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret }; + + app.get("/api/org/:orgSlug/projects/:projectId/team-access", async (request, reply) => { + try { + const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string }; + const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + if (auth === null) return; + await requireOrgProject(guardDeps, auth.organization.id, projectId); + return { access: await listProjectTeamAccess(config.prisma, projectId) }; + } catch (err) { + return handleRouteError(reply, err); + } + }); + + app.put("/api/org/:orgSlug/projects/:projectId/team-access", async (request, reply) => { + try { + const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string }; + const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + if (auth === null) return; + await requireOrgProject(guardDeps, auth.organization.id, projectId); + const body = request.body as { + teamId?: unknown; + teamSlug?: unknown; + role?: unknown; + }; + const role = parseRole(body.role); + if (role === null) { + return reply.status(400).send({ + error: { code: "bad_request", message: "role must be READ|EDIT|MANAGE" }, + }); + } + const entry = await grantTeamProjectAccess(config.prisma, { + projectId, + role, + createdByUserId: auth.user.id, + ...(typeof body.teamId === "string" ? { teamId: body.teamId } : {}), + ...(typeof body.teamSlug === "string" ? { teamSlug: body.teamSlug } : {}), + }); + return entry; + } catch (err) { + return handleRouteError(reply, err); + } + }); + + app.delete( + "/api/org/:orgSlug/projects/:projectId/team-access/:teamId", + async (request, reply) => { + try { + const { orgSlug, projectId, teamId } = request.params as { + orgSlug: string; + projectId: string; + teamId: string; + }; + const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + if (auth === null) return; + await requireOrgProject(guardDeps, auth.organization.id, projectId); + const count = await revokeTeamProjectAccess(config.prisma, { projectId, teamId }); + return { revoked: count }; + } catch (err) { + return handleRouteError(reply, err); + } + }, + ); +} + +function parseRole(value: unknown): PermissionRole | null { + if (typeof value !== "string") return null; + return (ROLES as readonly string[]).includes(value) ? (value as PermissionRole) : null; +} diff --git a/hub/src/admin/routes/membersRoutes.ts b/hub/src/admin/routes/membersRoutes.ts new file mode 100644 index 0000000..a3fd10a --- /dev/null +++ b/hub/src/admin/routes/membersRoutes.ts @@ -0,0 +1,104 @@ +import type { OrganizationMemberRole, PrismaClient } from "@prisma/client"; +import type { FastifyInstance } from "fastify"; +import { + addOrgMember, + listOrgMembers, + revokeOrgMember, + setOrgMemberRole, +} from "../../org/members.js"; +import { requireOrgRole, type GuardDeps } from "../auth/guards.js"; +import { handleRouteError } from "../errors.js"; + +const ROLES: readonly OrganizationMemberRole[] = ["OWNER", "ADMIN", "MEMBER"]; + +export async function registerMembersRoutes( + app: FastifyInstance, + config: { readonly prisma: PrismaClient; readonly sessionSecret: string }, +): Promise { + const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret }; + + app.get("/api/org/:orgSlug/members", async (request, reply) => { + try { + const { orgSlug } = request.params as { orgSlug: string }; + const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + if (auth === null) return; + return { members: await listOrgMembers(config.prisma, auth.organization.id) }; + } catch (err) { + return handleRouteError(reply, err); + } + }); + + app.post("/api/org/:orgSlug/members", async (request, reply) => { + try { + const { orgSlug } = request.params as { orgSlug: string }; + const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + if (auth === null) return; + const body = request.body as { + feishuOpenId?: unknown; + displayName?: unknown; + role?: unknown; + }; + if (typeof body.feishuOpenId !== "string") { + return reply.status(400).send({ + error: { code: "bad_request", message: "feishuOpenId is required" }, + }); + } + const role = parseRole(body.role) ?? "MEMBER"; + const member = await addOrgMember(config.prisma, { + organizationId: auth.organization.id, + actorRole: auth.membershipRole, + feishuOpenId: body.feishuOpenId, + role, + ...(typeof body.displayName === "string" ? { displayName: body.displayName } : {}), + }); + return reply.status(201).send(member); + } catch (err) { + return handleRouteError(reply, err); + } + }); + + app.patch("/api/org/:orgSlug/members/:userId", async (request, reply) => { + try { + const { orgSlug, userId } = request.params as { orgSlug: string; userId: string }; + const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + if (auth === null) return; + const body = request.body as { role?: unknown }; + const role = parseRole(body.role); + if (role === null) { + return reply.status(400).send({ + error: { code: "bad_request", message: "role must be OWNER|ADMIN|MEMBER" }, + }); + } + return await setOrgMemberRole(config.prisma, { + organizationId: auth.organization.id, + actorUserId: auth.user.id, + actorRole: auth.membershipRole, + targetUserId: userId, + role, + }); + } catch (err) { + return handleRouteError(reply, err); + } + }); + + app.post("/api/org/:orgSlug/members/:userId/revoke", async (request, reply) => { + try { + const { orgSlug, userId } = request.params as { orgSlug: string; userId: string }; + const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + if (auth === null) return; + return await revokeOrgMember(config.prisma, { + organizationId: auth.organization.id, + actorUserId: auth.user.id, + actorRole: auth.membershipRole, + targetUserId: userId, + }); + } catch (err) { + return handleRouteError(reply, err); + } + }); +} + +function parseRole(value: unknown): OrganizationMemberRole | null { + if (typeof value !== "string") return null; + return (ROLES as readonly string[]).includes(value) ? (value as OrganizationMemberRole) : null; +} diff --git a/hub/src/admin/routes/orgRoutes.ts b/hub/src/admin/routes/orgRoutes.ts index 1533055..0446546 100644 --- a/hub/src/admin/routes/orgRoutes.ts +++ b/hub/src/admin/routes/orgRoutes.ts @@ -9,7 +9,10 @@ import { ensureOrganizationProjectSettings, setMembersCanCreateProjects, } from "../../projectOnboarding.js"; +import { registerAccessRoutes } from "./accessRoutes.js"; import { registerExplorerRoutes } from "./explorerRoutes.js"; +import { registerMembersRoutes } from "./membersRoutes.js"; +import { registerTeamsRoutes } from "./teamsRoutes.js"; export interface OrgRouteConfig { readonly prisma: PrismaClient; @@ -81,4 +84,16 @@ export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteCo sessionSecret: config.sessionSecret, projectWorkspaceRoot: config.projectWorkspaceRoot, }); + await registerMembersRoutes(app, { + prisma: config.prisma, + sessionSecret: config.sessionSecret, + }); + await registerTeamsRoutes(app, { + prisma: config.prisma, + sessionSecret: config.sessionSecret, + }); + await registerAccessRoutes(app, { + prisma: config.prisma, + sessionSecret: config.sessionSecret, + }); } diff --git a/hub/src/admin/routes/teamsRoutes.ts b/hub/src/admin/routes/teamsRoutes.ts new file mode 100644 index 0000000..9a76af1 --- /dev/null +++ b/hub/src/admin/routes/teamsRoutes.ts @@ -0,0 +1,140 @@ +import type { PrismaClient } from "@prisma/client"; +import type { FastifyInstance } from "fastify"; +import { + addTeamMember, + archiveTeam, + createTeam, + listOrgTeams, + listTeamMembers, + revokeTeamMember, + updateTeam, +} from "../../org/teams.js"; +import { requireOrgRole, type GuardDeps } from "../auth/guards.js"; +import { handleRouteError } from "../errors.js"; + +export async function registerTeamsRoutes( + app: FastifyInstance, + config: { readonly prisma: PrismaClient; readonly sessionSecret: string }, +): Promise { + const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret }; + + app.get("/api/org/:orgSlug/teams", async (request, reply) => { + try { + const { orgSlug } = request.params as { orgSlug: string }; + const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + if (auth === null) return; + return { teams: await listOrgTeams(config.prisma, auth.organization.id) }; + } catch (err) { + return handleRouteError(reply, err); + } + }); + + app.post("/api/org/:orgSlug/teams", async (request, reply) => { + try { + const { orgSlug } = request.params as { orgSlug: string }; + const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + if (auth === null) return; + const body = request.body as { slug?: unknown; name?: unknown; description?: unknown }; + if (typeof body.slug !== "string" || typeof body.name !== "string") { + return reply.status(400).send({ + error: { code: "bad_request", message: "slug and name are required" }, + }); + } + const team = await createTeam(config.prisma, { + organizationId: auth.organization.id, + slug: body.slug, + name: body.name, + ...(typeof body.description === "string" ? { description: body.description } : {}), + }); + return reply.status(201).send(team); + } catch (err) { + return handleRouteError(reply, err); + } + }); + + app.patch("/api/org/:orgSlug/teams/:teamId", async (request, reply) => { + try { + const { orgSlug, teamId } = request.params as { orgSlug: string; teamId: string }; + const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + if (auth === null) return; + const body = request.body as { name?: unknown; description?: unknown }; + return await updateTeam(config.prisma, { + organizationId: auth.organization.id, + teamId, + ...(typeof body.name === "string" ? { name: body.name } : {}), + ...(body.description === null || typeof body.description === "string" + ? { description: body.description as string | null } + : {}), + }); + } catch (err) { + return handleRouteError(reply, err); + } + }); + + app.post("/api/org/:orgSlug/teams/:teamId/archive", async (request, reply) => { + try { + const { orgSlug, teamId } = request.params as { orgSlug: string; teamId: string }; + const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + if (auth === null) return; + return await archiveTeam(config.prisma, { + organizationId: auth.organization.id, + teamId, + }); + } catch (err) { + return handleRouteError(reply, err); + } + }); + + app.get("/api/org/:orgSlug/teams/:teamId/members", async (request, reply) => { + try { + const { orgSlug, teamId } = request.params as { orgSlug: string; teamId: string }; + const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + if (auth === null) return; + return { + members: await listTeamMembers(config.prisma, { + organizationId: auth.organization.id, + teamId, + }), + }; + } catch (err) { + return handleRouteError(reply, err); + } + }); + + app.post("/api/org/:orgSlug/teams/:teamId/members", async (request, reply) => { + try { + const { orgSlug, teamId } = request.params as { orgSlug: string; teamId: string }; + const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + if (auth === null) return; + const body = request.body as { userId?: unknown; feishuOpenId?: unknown }; + const member = await addTeamMember(config.prisma, { + organizationId: auth.organization.id, + teamId, + ...(typeof body.userId === "string" ? { userId: body.userId } : {}), + ...(typeof body.feishuOpenId === "string" ? { feishuOpenId: body.feishuOpenId } : {}), + }); + return reply.status(201).send(member); + } catch (err) { + return handleRouteError(reply, err); + } + }); + + app.post("/api/org/:orgSlug/teams/:teamId/members/:userId/revoke", async (request, reply) => { + try { + const { orgSlug, teamId, userId } = request.params as { + orgSlug: string; + teamId: string; + userId: string; + }; + const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + if (auth === null) return; + return await revokeTeamMember(config.prisma, { + organizationId: auth.organization.id, + teamId, + userId, + }); + } catch (err) { + return handleRouteError(reply, err); + } + }); +} diff --git a/hub/src/org/members.ts b/hub/src/org/members.ts new file mode 100644 index 0000000..204cec9 --- /dev/null +++ b/hub/src/org/members.ts @@ -0,0 +1,213 @@ +/** + * Organization membership management for org admin (ADR-0021). + * + * Role rules (product pin, not yet in Lean): + * 1. Actor must be OWNER or ADMIN (enforced at HTTP layer). + * 2. Only OWNER can grant/revoke OWNER or modify another OWNER. + * 3. Cannot revoke or demote the last remaining OWNER. + * 4. ADMIN cannot modify OWNER memberships. + */ +import type { OrganizationMemberRole, PrismaClient } from "@prisma/client"; + +export interface OrgMemberRow { + readonly userId: string; + readonly feishuOpenId: string; + readonly displayName: string; + readonly avatarUrl: string | null; + readonly role: OrganizationMemberRole; + readonly createdAt: string; +} + +export async function listOrgMembers( + prisma: PrismaClient, + organizationId: string, +): Promise { + const rows = await prisma.organizationMembership.findMany({ + where: { organizationId, revokedAt: null }, + select: { + role: true, + createdAt: true, + user: { + select: { id: true, feishuOpenId: true, displayName: true, avatarUrl: true }, + }, + }, + orderBy: [{ role: "asc" }, { createdAt: "asc" }], + }); + return rows.map((row) => ({ + userId: row.user.id, + feishuOpenId: row.user.feishuOpenId, + displayName: row.user.displayName, + avatarUrl: row.user.avatarUrl, + role: row.role, + createdAt: row.createdAt.toISOString(), + })); +} + +export async function addOrgMember( + prisma: PrismaClient, + input: { + readonly organizationId: string; + readonly actorRole: OrganizationMemberRole; + readonly feishuOpenId: string; + readonly displayName?: string | undefined; + readonly role: OrganizationMemberRole; + }, +): Promise { + const openId = requireNonEmpty(input.feishuOpenId, "feishuOpenId"); + assertCanAssignRole(input.actorRole, input.role, /* targetIsOwner */ false); + + const user = await prisma.user.upsert({ + where: { feishuOpenId: openId }, + create: { + feishuOpenId: openId, + displayName: input.displayName?.trim() || openId, + }, + update: { + ...(input.displayName !== undefined && input.displayName.trim() !== "" + ? { displayName: input.displayName.trim() } + : {}), + }, + }); + + const existing = await prisma.organizationMembership.findFirst({ + where: { organizationId: input.organizationId, userId: user.id, revokedAt: null }, + }); + if (existing !== null) { + throw new Error(`user ${user.id} is already an active member`); + } + + const membership = await prisma.organizationMembership.create({ + data: { + organizationId: input.organizationId, + userId: user.id, + role: input.role, + }, + }); + + return { + userId: user.id, + feishuOpenId: user.feishuOpenId, + displayName: user.displayName, + avatarUrl: user.avatarUrl, + role: membership.role, + createdAt: membership.createdAt.toISOString(), + }; +} + +export async function setOrgMemberRole( + prisma: PrismaClient, + input: { + readonly organizationId: string; + readonly actorUserId: string; + readonly actorRole: OrganizationMemberRole; + readonly targetUserId: string; + readonly role: OrganizationMemberRole; + }, +): Promise { + const membership = await prisma.organizationMembership.findFirst({ + where: { + organizationId: input.organizationId, + userId: input.targetUserId, + revokedAt: null, + }, + select: { + id: true, + role: true, + createdAt: true, + user: { + select: { id: true, feishuOpenId: true, displayName: true, avatarUrl: true }, + }, + }, + }); + if (membership === null) { + throw new Error(`member not found: ${input.targetUserId}`); + } + + assertCanAssignRole(input.actorRole, input.role, membership.role === "OWNER"); + if (membership.role === "OWNER" && input.actorRole !== "OWNER") { + throw new Error("cannot modify OWNER membership as ADMIN"); + } + if (membership.role === "OWNER" && input.role !== "OWNER") { + await assertNotLastOwner(prisma, input.organizationId, input.targetUserId); + } + + const updated = await prisma.organizationMembership.update({ + where: { id: membership.id }, + data: { role: input.role }, + }); + + return { + userId: membership.user.id, + feishuOpenId: membership.user.feishuOpenId, + displayName: membership.user.displayName, + avatarUrl: membership.user.avatarUrl, + role: updated.role, + createdAt: membership.createdAt.toISOString(), + }; +} + +export async function revokeOrgMember( + prisma: PrismaClient, + input: { + readonly organizationId: string; + readonly actorUserId: string; + readonly actorRole: OrganizationMemberRole; + readonly targetUserId: string; + }, +): Promise<{ readonly revoked: true; readonly userId: string }> { + const membership = await prisma.organizationMembership.findFirst({ + where: { + organizationId: input.organizationId, + userId: input.targetUserId, + revokedAt: null, + }, + select: { id: true, role: true }, + }); + if (membership === null) { + throw new Error(`member not found: ${input.targetUserId}`); + } + if (membership.role === "OWNER" && input.actorRole !== "OWNER") { + throw new Error("cannot revoke OWNER membership as ADMIN"); + } + if (membership.role === "OWNER") { + await assertNotLastOwner(prisma, input.organizationId, input.targetUserId); + } + + await prisma.organizationMembership.update({ + where: { id: membership.id }, + data: { revokedAt: new Date() }, + }); + return { revoked: true as const, userId: input.targetUserId }; +} + +function assertCanAssignRole( + actorRole: OrganizationMemberRole, + newRole: OrganizationMemberRole, + targetIsOwner: boolean, +): void { + if (newRole === "OWNER" && actorRole !== "OWNER") { + throw new Error("only OWNER can grant OWNER role"); + } + if (targetIsOwner && actorRole !== "OWNER") { + throw new Error("cannot modify OWNER membership as ADMIN"); + } +} + +async function assertNotLastOwner( + prisma: PrismaClient, + organizationId: string, + targetUserId: string, +): Promise { + const owners = await prisma.organizationMembership.count({ + where: { organizationId, role: "OWNER", revokedAt: null }, + }); + if (owners <= 1) { + throw new Error(`cannot revoke or demote last OWNER (${targetUserId})`); + } +} + +function requireNonEmpty(value: string, label: string): string { + const trimmed = value.trim(); + if (trimmed === "") throw new Error(`${label} is required`); + return trimmed; +} diff --git a/hub/src/org/teams.ts b/hub/src/org/teams.ts new file mode 100644 index 0000000..b892cf0 --- /dev/null +++ b/hub/src/org/teams.ts @@ -0,0 +1,290 @@ +/** + * Hub team lifecycle for org admin (ADR-0019 / ADR-0021). + * + * Archiving a team soft-archives the team row and revokes active TEAM→PROJECT + * grants that use this team as principal (product pin). + */ +import type { Prisma, PrismaClient } from "@prisma/client"; + +export interface TeamRow { + readonly id: string; + readonly slug: string; + readonly name: string; + readonly description: string | null; + readonly memberCount: number; + readonly createdAt: string; +} + +export interface TeamMemberRow { + readonly userId: string; + readonly feishuOpenId: string; + readonly displayName: string; + readonly createdAt: string; +} + +export async function listOrgTeams( + prisma: PrismaClient, + organizationId: string, +): Promise { + const teams = await prisma.team.findMany({ + where: { organizationId, archivedAt: null }, + select: { + id: true, + slug: true, + name: true, + description: true, + createdAt: true, + _count: { select: { memberships: { where: { revokedAt: null } } } }, + }, + orderBy: { slug: "asc" }, + }); + return teams.map((t) => ({ + id: t.id, + slug: t.slug, + name: t.name, + description: t.description, + memberCount: t._count.memberships, + createdAt: t.createdAt.toISOString(), + })); +} + +export async function createTeam( + prisma: PrismaClient, + input: { + readonly organizationId: string; + readonly slug: string; + readonly name: string; + readonly description?: string | undefined; + }, +): Promise { + const slug = sanitizeSlug(input.slug); + const name = requireNonEmpty(input.name, "name"); + const existing = await prisma.team.findFirst({ + where: { organizationId: input.organizationId, slug, archivedAt: null }, + select: { id: true }, + }); + if (existing !== null) { + throw new Error(`team slug already exists: ${slug}`); + } + const team = await prisma.team.create({ + data: { + organizationId: input.organizationId, + slug, + name, + ...(input.description !== undefined ? { description: input.description } : {}), + }, + }); + return { + id: team.id, + slug: team.slug, + name: team.name, + description: team.description, + memberCount: 0, + createdAt: team.createdAt.toISOString(), + }; +} + +export async function updateTeam( + prisma: PrismaClient, + input: { + readonly organizationId: string; + readonly teamId: string; + readonly name?: string | undefined; + readonly description?: string | null | undefined; + }, +): Promise { + const team = await requireActiveTeam(prisma, input.teamId, input.organizationId); + const updated = await prisma.team.update({ + where: { id: team.id }, + data: { + ...(input.name !== undefined ? { name: requireNonEmpty(input.name, "name") } : {}), + ...(input.description !== undefined ? { description: input.description } : {}), + }, + select: { + id: true, + slug: true, + name: true, + description: true, + createdAt: true, + _count: { select: { memberships: { where: { revokedAt: null } } } }, + }, + }); + return { + id: updated.id, + slug: updated.slug, + name: updated.name, + description: updated.description, + memberCount: updated._count.memberships, + createdAt: updated.createdAt.toISOString(), + }; +} + +/** + * Soft-archive team and revoke its active project grants (TEAM principal). + */ +export async function archiveTeam( + prisma: PrismaClient, + input: { readonly organizationId: string; readonly teamId: string }, +): Promise<{ readonly archived: true; readonly teamId: string; readonly revokedGrants: number }> { + return prisma.$transaction(async (tx) => { + const team = await requireActiveTeam(tx, input.teamId, input.organizationId); + const now = new Date(); + await tx.team.update({ + where: { id: team.id }, + data: { archivedAt: now }, + }); + await tx.teamMembership.updateMany({ + where: { teamId: team.id, revokedAt: null }, + data: { revokedAt: now }, + }); + const grants = await tx.permissionGrant.updateMany({ + where: { + principalType: "TEAM", + principalId: team.id, + revokedAt: null, + }, + data: { revokedAt: now }, + }); + return { + archived: true as const, + teamId: team.id, + revokedGrants: grants.count, + }; + }); +} + +export async function listTeamMembers( + prisma: PrismaClient, + input: { readonly organizationId: string; readonly teamId: string }, +): Promise { + await requireActiveTeam(prisma, input.teamId, input.organizationId); + const rows = await prisma.teamMembership.findMany({ + where: { teamId: input.teamId, revokedAt: null }, + select: { + createdAt: true, + user: { select: { id: true, feishuOpenId: true, displayName: true } }, + }, + orderBy: { createdAt: "asc" }, + }); + return rows.map((row) => ({ + userId: row.user.id, + feishuOpenId: row.user.feishuOpenId, + displayName: row.user.displayName, + createdAt: row.createdAt.toISOString(), + })); +} + +export async function addTeamMember( + prisma: PrismaClient, + input: { + readonly organizationId: string; + readonly teamId: string; + readonly userId?: string | undefined; + readonly feishuOpenId?: string | undefined; + }, +): Promise { + const team = await requireActiveTeam(prisma, input.teamId, input.organizationId); + const user = await resolveUser(prisma, input); + // User should be an org member to join a team (product pin for pilot). + const membership = await prisma.organizationMembership.findFirst({ + where: { + organizationId: input.organizationId, + userId: user.id, + revokedAt: null, + }, + select: { id: true }, + }); + if (membership === null) { + throw new Error(`user ${user.id} is not an active member of the organization`); + } + const existing = await prisma.teamMembership.findFirst({ + where: { teamId: team.id, userId: user.id, revokedAt: null }, + }); + if (existing !== null) { + throw new Error(`user ${user.id} is already on team ${team.id}`); + } + const row = await prisma.teamMembership.create({ + data: { teamId: team.id, userId: user.id }, + }); + return { + userId: user.id, + feishuOpenId: user.feishuOpenId, + displayName: user.displayName, + createdAt: row.createdAt.toISOString(), + }; +} + +export async function revokeTeamMember( + prisma: PrismaClient, + input: { + readonly organizationId: string; + readonly teamId: string; + readonly userId: string; + }, +): Promise<{ readonly revoked: true; readonly userId: string }> { + await requireActiveTeam(prisma, input.teamId, input.organizationId); + const membership = await prisma.teamMembership.findFirst({ + where: { teamId: input.teamId, userId: input.userId, revokedAt: null }, + select: { id: true }, + }); + if (membership === null) { + throw new Error(`team member not found: ${input.userId}`); + } + await prisma.teamMembership.update({ + where: { id: membership.id }, + data: { revokedAt: new Date() }, + }); + return { revoked: true as const, userId: input.userId }; +} + +async function resolveUser( + prisma: PrismaClient, + input: { readonly userId?: string | undefined; readonly feishuOpenId?: string | undefined }, +): Promise<{ readonly id: string; readonly feishuOpenId: string; readonly displayName: string }> { + if (input.userId !== undefined && input.userId !== "") { + const user = await prisma.user.findUnique({ + where: { id: input.userId }, + select: { id: true, feishuOpenId: true, displayName: true }, + }); + if (user === null) throw new Error(`user not found: ${input.userId}`); + return user; + } + if (input.feishuOpenId !== undefined && input.feishuOpenId !== "") { + const user = await prisma.user.findUnique({ + where: { feishuOpenId: input.feishuOpenId }, + select: { id: true, feishuOpenId: true, displayName: true }, + }); + if (user === null) throw new Error(`user not found: ${input.feishuOpenId}`); + return user; + } + throw new Error("userId or feishuOpenId is required"); +} + +async function requireActiveTeam( + prisma: PrismaClient | Prisma.TransactionClient, + teamId: string, + organizationId: string, +): Promise<{ readonly id: string }> { + const team = await prisma.team.findUnique({ + where: { id: teamId }, + select: { id: true, organizationId: true, archivedAt: true }, + }); + if (team === null || team.archivedAt !== null || team.organizationId !== organizationId) { + throw new Error(`active team not found: ${teamId}`); + } + return team; +} + +function sanitizeSlug(raw: string): string { + const slug = requireNonEmpty(raw, "slug").toLowerCase(); + if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(slug)) { + throw new Error("slug must be lowercase alphanumeric with optional hyphens"); + } + return slug; +} + +function requireNonEmpty(value: string, label: string): string { + const trimmed = value.trim(); + if (trimmed === "") throw new Error(`${label} is required`); + return trimmed; +} diff --git a/hub/test/integration/admin-members-teams.test.ts b/hub/test/integration/admin-members-teams.test.ts new file mode 100644 index 0000000..21ae813 --- /dev/null +++ b/hub/test/integration/admin-members-teams.test.ts @@ -0,0 +1,161 @@ +/** + * Org members + teams + team-access API tests. + */ +import Fastify from "fastify"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; +import { registerAdminPlugin } from "../../src/admin/plugin.js"; +import { + mintSessionToken, + sessionCookieHeader, +} from "../../src/admin/routes/authRoutes.js"; +import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js"; + +const SESSION_SECRET = "integration-test-session-secret"; + +async function buildApp() { + const app = Fastify({ logger: false }); + await registerAdminPlugin(app, { + prisma, + sessionSecret: SESSION_SECRET, + publicBaseUrl: "http://127.0.0.1:8788", + feishuAppId: "cli_test", + feishuAppSecret: "secret_test", + projectWorkspaceRoot: "/tmp/cph-test-ws", + cookieSecure: false, + }); + await app.ready(); + return app; +} + +async function seedOwner(): Promise { + await prisma.user.create({ + data: { id: "u-owner", feishuOpenId: "ou_owner", displayName: "Owner" }, + }); + await prisma.organizationMembership.create({ + data: { organizationId: DEFAULT_ORG_ID, userId: "u-owner", role: "OWNER" }, + }); + return mintSessionToken({ userId: "u-owner", feishuOpenId: "ou_owner" }, SESSION_SECRET); +} + +describe("admin members + teams API", () => { + beforeEach(async () => { + await resetDb(); + }); + + afterAll(async () => { + await prisma.$disconnect(); + }); + + it("adds members and enforces last-OWNER protection", async () => { + const token = await seedOwner(); + const app = await buildApp(); + try { + const cookie = sessionCookieHeader(token); + + const add = await app.inject({ + method: "POST", + url: "/api/org/test-default/members", + headers: { cookie, "content-type": "application/json" }, + payload: { feishuOpenId: "ou_teacher", displayName: "Teacher", role: "ADMIN" }, + }); + expect(add.statusCode).toBe(201); + const teacher = add.json() as { userId: string; role: string }; + expect(teacher.role).toBe("ADMIN"); + + const list = await app.inject({ + method: "GET", + url: "/api/org/test-default/members", + headers: { cookie }, + }); + expect(list.statusCode).toBe(200); + expect((list.json() as { members: unknown[] }).members).toHaveLength(2); + + const refuse = await app.inject({ + method: "POST", + url: "/api/org/test-default/members/u-owner/revoke", + headers: { cookie }, + }); + expect(refuse.statusCode).toBe(403); + expect(JSON.stringify(refuse.json())).toContain("last OWNER"); + } finally { + await app.close(); + } + }); + + it("creates team, adds member, grants project access, archives team", async () => { + const token = await seedOwner(); + await prisma.user.create({ + data: { id: "u-m", feishuOpenId: "ou_m", displayName: "M" }, + }); + await prisma.organizationMembership.create({ + data: { organizationId: DEFAULT_ORG_ID, userId: "u-m", role: "MEMBER" }, + }); + await prisma.project.create({ + data: { + id: "p1", + organizationId: DEFAULT_ORG_ID, + name: "P1", + workspaceDir: "/tmp/p1", + }, + }); + + const app = await buildApp(); + try { + const cookie = sessionCookieHeader(token); + + const teamRes = await app.inject({ + method: "POST", + url: "/api/org/test-default/teams", + headers: { cookie, "content-type": "application/json" }, + payload: { slug: "math", name: "Math Team" }, + }); + expect(teamRes.statusCode).toBe(201); + const team = teamRes.json() as { id: string }; + + const memberRes = await app.inject({ + method: "POST", + url: `/api/org/test-default/teams/${team.id}/members`, + headers: { cookie, "content-type": "application/json" }, + payload: { userId: "u-m" }, + }); + expect(memberRes.statusCode).toBe(201); + + const grantRes = await app.inject({ + method: "PUT", + url: "/api/org/test-default/projects/p1/team-access", + headers: { cookie, "content-type": "application/json" }, + payload: { teamId: team.id, role: "EDIT" }, + }); + expect(grantRes.statusCode).toBe(200); + expect(grantRes.json()).toEqual( + expect.objectContaining({ teamId: team.id, role: "EDIT" }), + ); + + const listAccess = await app.inject({ + method: "GET", + url: "/api/org/test-default/projects/p1/team-access", + headers: { cookie }, + }); + expect((listAccess.json() as { access: unknown[] }).access).toHaveLength(1); + + const archive = await app.inject({ + method: "POST", + url: `/api/org/test-default/teams/${team.id}/archive`, + headers: { cookie }, + }); + expect(archive.statusCode).toBe(200); + expect(archive.json()).toEqual( + expect.objectContaining({ archived: true, revokedGrants: 1 }), + ); + + const after = await app.inject({ + method: "GET", + url: "/api/org/test-default/projects/p1/team-access", + headers: { cookie }, + }); + expect((after.json() as { access: unknown[] }).access).toHaveLength(0); + } finally { + await app.close(); + } + }); +});