forked from EduCraft/curriculum-project-hub
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:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user