forked from EduCraft/curriculum-project-hub
080efa70c5
The org admin SPA was org-admin only: every project route used requireOrgRole, so a plain MEMBER could not reach the projects they held a project grant on, and an org OWNER/ADMIN could mutate any project without holding the project's `manage` grant. That contradicts ADR-0004 (spec `Permission.lean`): org role is not a project authorization root, and the only out-of-role override is platform-admin force-release (`RequiresAdmin`), not org admin. Add `requireProjectPermission` (guards.ts): resolve any org member, bind the project to their org, then check the PermissionGrant authorizer. `allowOrgAdminOversight=true` lets OWNER/ADMIN through for *read* oversight only; mutations pinned to `collaborator.manage` (grant/revoke team-access) pass `allowOrgAdminOversight=false`, so an org admin still needs the project MANAGE grant to mutate access. The project detail GET now also returns `actorIsOrgAdmin` and `actorCanManageProject` so the SPA can render mutation controls only for entitled actors. Add a member-facing project surface: - `GET /api/org/:orgSlug/my-projects` + `listMyProjects` resolve the actor's principals and return the projects with a READ+ grant. - The SPA routes members (non-admin) to the projects page instead of the admin overview, renders a member project shell on project routes, shows a `我的项目` list for members and the full folder explorer for admins. - The project detail page gates rename/archive/bind/sessions behind org admin and the grant/revoke UI behind `actorCanManageProject`. - The denied panel now points members at their authorized projects. Update admin-members-teams integration test: seed the owner with a MANAGE grant on the test project so the org-owner flow still passes the new project-level gate on team-access grant/revoke.
264 lines
9.2 KiB
TypeScript
264 lines
9.2 KiB
TypeScript
/**
|
|
* Org explorer HTTP routes under `/api/org/:orgSlug`.
|
|
*/
|
|
import type { PrismaClient } from "@prisma/client";
|
|
import type { FastifyInstance } from "fastify";
|
|
import {
|
|
archiveFolder,
|
|
archiveOrgProjectChatBinding,
|
|
archiveProject,
|
|
createOrgFolder,
|
|
createOrgProject,
|
|
getOrgProjectDetail,
|
|
listMyProjects,
|
|
listOrgExplorer,
|
|
moveOrgProjectToFolder,
|
|
renameFolder,
|
|
renameProject,
|
|
} from "../../org/explorer.js";
|
|
import { requireOrgRole, requireProjectPermission, ORG_ADMIN_ROLES, type GuardDeps } from "../auth/guards.js";
|
|
import { handleRouteError } from "../errors.js";
|
|
import { createPermissionAuthorizer } from "../../permissions/authorizer.js";
|
|
|
|
export interface ExplorerRouteConfig {
|
|
readonly prisma: PrismaClient;
|
|
readonly sessionSecret: string;
|
|
readonly projectWorkspaceRoot: string;
|
|
}
|
|
|
|
export async function registerExplorerRoutes(
|
|
app: FastifyInstance,
|
|
config: ExplorerRouteConfig,
|
|
): Promise<void> {
|
|
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
|
|
|
|
app.get("/api/org/:orgSlug/explorer", async (request, reply) => {
|
|
try {
|
|
const { orgSlug } = request.params as { orgSlug: string };
|
|
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
|
if (auth === null) return;
|
|
return await listOrgExplorer(config.prisma, auth.organization.id);
|
|
} catch (err) {
|
|
return handleRouteError(reply, err);
|
|
}
|
|
});
|
|
|
|
app.get("/api/org/:orgSlug/my-projects", async (request, reply) => {
|
|
try {
|
|
const { orgSlug } = request.params as { orgSlug: string };
|
|
const auth = await requireOrgRole(request, reply, guardDeps, {
|
|
orgSlug,
|
|
roles: ["OWNER", "ADMIN", "MEMBER"],
|
|
});
|
|
if (auth === null) return;
|
|
const projects = await listMyProjects(config.prisma, {
|
|
organizationId: auth.organization.id,
|
|
actorFeishuOpenId: auth.feishuOpenId,
|
|
});
|
|
return { projects };
|
|
} catch (err) {
|
|
return handleRouteError(reply, err);
|
|
}
|
|
});
|
|
|
|
app.post("/api/org/:orgSlug/folders", 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 {
|
|
name?: unknown;
|
|
parentId?: unknown;
|
|
sortKey?: unknown;
|
|
};
|
|
if (typeof body.name !== "string") {
|
|
return reply.status(400).send({
|
|
error: { code: "bad_request", message: "name is required" },
|
|
});
|
|
}
|
|
const folder = await createOrgFolder(config.prisma, {
|
|
organizationId: auth.organization.id,
|
|
name: body.name,
|
|
...(typeof body.parentId === "string" ? { parentId: body.parentId } : {}),
|
|
...(typeof body.sortKey === "string" ? { sortKey: body.sortKey } : {}),
|
|
});
|
|
return reply.status(201).send({
|
|
id: folder.id,
|
|
name: folder.name,
|
|
parentId: folder.parentId,
|
|
sortKey: folder.sortKey,
|
|
});
|
|
} catch (err) {
|
|
return handleRouteError(reply, err);
|
|
}
|
|
});
|
|
|
|
app.patch("/api/org/:orgSlug/folders/:folderId", async (request, reply) => {
|
|
try {
|
|
const { orgSlug, folderId } = request.params as { orgSlug: string; folderId: string };
|
|
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
|
if (auth === null) return;
|
|
const body = request.body as {
|
|
name?: unknown;
|
|
sortKey?: unknown;
|
|
parentId?: unknown;
|
|
};
|
|
const folder = await renameFolder(config.prisma, {
|
|
organizationId: auth.organization.id,
|
|
folderId,
|
|
...(typeof body.name === "string" ? { name: body.name } : {}),
|
|
...(typeof body.sortKey === "string" ? { sortKey: body.sortKey } : {}),
|
|
...(body.parentId === null || typeof body.parentId === "string"
|
|
? { parentId: body.parentId as string | null }
|
|
: {}),
|
|
});
|
|
return {
|
|
id: folder.id,
|
|
name: folder.name,
|
|
parentId: folder.parentId,
|
|
sortKey: folder.sortKey,
|
|
};
|
|
} catch (err) {
|
|
return handleRouteError(reply, err);
|
|
}
|
|
});
|
|
|
|
app.post("/api/org/:orgSlug/folders/:folderId/archive", async (request, reply) => {
|
|
try {
|
|
const { orgSlug, folderId } = request.params as { orgSlug: string; folderId: string };
|
|
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
|
if (auth === null) return;
|
|
return await archiveFolder(config.prisma, {
|
|
organizationId: auth.organization.id,
|
|
folderId,
|
|
});
|
|
} catch (err) {
|
|
return handleRouteError(reply, err);
|
|
}
|
|
});
|
|
|
|
app.post("/api/org/:orgSlug/projects", 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 { name?: unknown; folderId?: unknown };
|
|
if (typeof body.name !== "string") {
|
|
return reply.status(400).send({
|
|
error: { code: "bad_request", message: "name is required" },
|
|
});
|
|
}
|
|
const result = await createOrgProject(config.prisma, {
|
|
organizationId: auth.organization.id,
|
|
actorFeishuOpenId: auth.feishuOpenId,
|
|
name: body.name,
|
|
workspaceRoot: config.projectWorkspaceRoot,
|
|
...(typeof body.folderId === "string" ? { folderId: body.folderId } : {}),
|
|
});
|
|
return reply.status(201).send(result);
|
|
} catch (err) {
|
|
return handleRouteError(reply, err);
|
|
}
|
|
});
|
|
|
|
app.get("/api/org/:orgSlug/projects/:projectId", async (request, reply) => {
|
|
try {
|
|
const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string };
|
|
const auth = await requireProjectPermission(request, reply, guardDeps, {
|
|
orgSlug,
|
|
projectId,
|
|
action: "project.read",
|
|
allowOrgAdminOversight: true,
|
|
});
|
|
if (auth === null) return;
|
|
const detail = await getOrgProjectDetail(config.prisma, {
|
|
organizationId: auth.organization.id,
|
|
projectId,
|
|
});
|
|
const manageDecision = await createPermissionAuthorizer(config.prisma).can({
|
|
actor: { feishuOpenId: auth.feishuOpenId },
|
|
action: "collaborator.manage",
|
|
resource: { type: "PROJECT", id: projectId },
|
|
});
|
|
return {
|
|
...detail,
|
|
actorIsOrgAdmin: ORG_ADMIN_ROLES.includes(auth.membershipRole),
|
|
actorCanManageProject: manageDecision.allowed,
|
|
};
|
|
} catch (err) {
|
|
return handleRouteError(reply, err);
|
|
}
|
|
});
|
|
|
|
app.patch("/api/org/:orgSlug/projects/:projectId", 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;
|
|
const body = request.body as { name?: unknown };
|
|
if (typeof body.name !== "string") {
|
|
return reply.status(400).send({
|
|
error: { code: "bad_request", message: "name is required" },
|
|
});
|
|
}
|
|
return await renameProject(config.prisma, {
|
|
organizationId: auth.organization.id,
|
|
projectId,
|
|
name: body.name,
|
|
});
|
|
} catch (err) {
|
|
return handleRouteError(reply, err);
|
|
}
|
|
});
|
|
|
|
app.patch("/api/org/:orgSlug/projects/:projectId/folder", 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;
|
|
const body = request.body as { folderId?: unknown };
|
|
if (body.folderId !== null && typeof body.folderId !== "string") {
|
|
return reply.status(400).send({
|
|
error: { code: "bad_request", message: "folderId must be string or null" },
|
|
});
|
|
}
|
|
return await moveOrgProjectToFolder(config.prisma, {
|
|
organizationId: auth.organization.id,
|
|
projectId,
|
|
folderId: body.folderId as string | null,
|
|
});
|
|
} catch (err) {
|
|
return handleRouteError(reply, err);
|
|
}
|
|
});
|
|
|
|
app.post("/api/org/:orgSlug/projects/:projectId/archive", 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;
|
|
return await archiveProject(config.prisma, {
|
|
organizationId: auth.organization.id,
|
|
projectId,
|
|
});
|
|
} catch (err) {
|
|
return handleRouteError(reply, err);
|
|
}
|
|
});
|
|
|
|
app.post("/api/org/:orgSlug/projects/:projectId/binding/archive", 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;
|
|
return await archiveOrgProjectChatBinding(config.prisma, {
|
|
organizationId: auth.organization.id,
|
|
projectId,
|
|
actorFeishuOpenId: auth.feishuOpenId,
|
|
});
|
|
} catch (err) {
|
|
return handleRouteError(reply, err);
|
|
}
|
|
});
|
|
}
|