forked from EduCraft/curriculum-project-hub
refactor(org): keep archived-team grants as dead rows
Archiving a team no longer cascade-revokes its active TEAM->PROJECT grants and memberships. The archived flag alone makes the team principal unresolvable (permissions/principals.ts refuses archived teams), so the dead grant/membership rows confer no access. listProjectTeamAccess now filters archived teams out of the project view instead of relying on a revokedAt cascade, and the org-admin teams page confirm copy is updated. archiveTeam drops the revokedGrants count from its return shape. ADR-0019 / Spec.System.Organization: principal resolution, not grant mutation, is the access boundary for archived teams.
This commit is contained in:
@@ -67,7 +67,7 @@
|
||||
}
|
||||
|
||||
async function archiveTeam(t: TeamRow) {
|
||||
if (!confirm(`归档团队 ${t.name}? 其活跃项目授权将被撤销。`)) return;
|
||||
if (!confirm(`归档团队 ${t.name}? 归档后该团队不再解析为项目授权主体。`)) return;
|
||||
try {
|
||||
await api.archiveTeam(slug, t.id);
|
||||
if (expandedId === t.id) expandedId = null;
|
||||
|
||||
+8
-23
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* 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).
|
||||
* Archiving a team soft-archives the team row. Active TEAM→PROJECT grants and
|
||||
* memberships are left in place as dead rows: principal resolution refuses
|
||||
* archived teams (see `permissions/principals.ts`), so they confer no access.
|
||||
*/
|
||||
import type { Prisma, PrismaClient } from "@prisma/client";
|
||||
import { lockActiveOrganization } from "./status.js";
|
||||
@@ -125,37 +126,21 @@ export async function updateTeam(
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-archive team and revoke its active project grants (TEAM principal).
|
||||
* Soft-archive team. Active grants/memberships are not cascade-revoked; the
|
||||
* archived flag alone makes the team's principal unresolvable (no access).
|
||||
*/
|
||||
export async function archiveTeam(
|
||||
prisma: PrismaClient,
|
||||
input: { readonly organizationId: string; readonly teamId: string },
|
||||
): Promise<{ readonly archived: true; readonly teamId: string; readonly revokedGrants: number }> {
|
||||
): Promise<{ readonly archived: true; readonly teamId: string }> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
await lockActiveOrganization(tx, input.organizationId);
|
||||
const team = await requireActiveTeam(tx, input.teamId, input.organizationId);
|
||||
const now = new Date();
|
||||
await tx.team.update({
|
||||
where: { id: team.id },
|
||||
data: { archivedAt: now },
|
||||
data: { archivedAt: new Date() },
|
||||
});
|
||||
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,
|
||||
};
|
||||
return { archived: true as const, teamId: team.id };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -131,9 +131,8 @@ export async function listProjectTeamAccess(
|
||||
where: {
|
||||
organizationId: project.organizationId,
|
||||
id: { in: grants.map((grant) => grant.principalId) },
|
||||
archivedAt: null,
|
||||
},
|
||||
select: { id: true, slug: true, name: true },
|
||||
select: { id: true, slug: true, name: true, archivedAt: true },
|
||||
});
|
||||
const teamsById = new Map(teams.map((team) => [team.id, team]));
|
||||
const missing = grants.filter((grant) => !teamsById.has(grant.principalId));
|
||||
@@ -143,13 +142,15 @@ export async function listProjectTeamAccess(
|
||||
);
|
||||
}
|
||||
|
||||
return grants.map((grant) => entryFromGrant({
|
||||
grantId: grant.id,
|
||||
projectId: project.id,
|
||||
organizationId: project.organizationId,
|
||||
team: teamsById.get(grant.principalId)!,
|
||||
role: grant.role,
|
||||
}));
|
||||
return grants
|
||||
.filter((grant) => teamsById.get(grant.principalId)?.archivedAt === null)
|
||||
.map((grant) => entryFromGrant({
|
||||
grantId: grant.id,
|
||||
projectId: project.id,
|
||||
organizationId: project.organizationId,
|
||||
team: teamsById.get(grant.principalId)!,
|
||||
role: grant.role,
|
||||
}));
|
||||
}
|
||||
|
||||
type ProjectForAccess = {
|
||||
|
||||
@@ -176,7 +176,7 @@ describe("admin members + teams API", () => {
|
||||
});
|
||||
expect(archive.statusCode).toBe(200);
|
||||
expect(archive.json()).toEqual(
|
||||
expect.objectContaining({ archived: true, revokedGrants: 1 }),
|
||||
expect.objectContaining({ archived: true, teamId: team.id }),
|
||||
);
|
||||
|
||||
const after = await app.inject({
|
||||
|
||||
Reference in New Issue
Block a user