feat(hub): add org members, teams, and project team-access APIs

Manage memberships with last-OWNER protection, team lifecycle (archive
revokes TEAM grants), and TEAM→PROJECT access under org admin auth.
This commit is contained in:
2026-07-10 00:55:34 +08:00
parent 1f8510a47f
commit 683d5674d1
7 changed files with 1008 additions and 0 deletions
+85
View File
@@ -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<void> {
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;
}
+104
View File
@@ -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<void> {
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;
}
+15
View File
@@ -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,
});
}
+140
View File
@@ -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<void> {
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);
}
});
}