forked from bai/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) {
|
async function archiveTeam(t: TeamRow) {
|
||||||
if (!confirm(`归档团队 ${t.name}? 其活跃项目授权将被撤销。`)) return;
|
if (!confirm(`归档团队 ${t.name}? 归档后该团队不再解析为项目授权主体。`)) return;
|
||||||
try {
|
try {
|
||||||
await api.archiveTeam(slug, t.id);
|
await api.archiveTeam(slug, t.id);
|
||||||
if (expandedId === t.id) expandedId = null;
|
if (expandedId === t.id) expandedId = null;
|
||||||
|
|||||||
+8
-23
@@ -1,8 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Hub team lifecycle for org admin (ADR-0019 / ADR-0021).
|
* Hub team lifecycle for org admin (ADR-0019 / ADR-0021).
|
||||||
*
|
*
|
||||||
* Archiving a team soft-archives the team row and revokes active TEAM→PROJECT
|
* Archiving a team soft-archives the team row. Active TEAM→PROJECT grants and
|
||||||
* grants that use this team as principal (product pin).
|
* 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 type { Prisma, PrismaClient } from "@prisma/client";
|
||||||
import { lockActiveOrganization } from "./status.js";
|
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(
|
export async function archiveTeam(
|
||||||
prisma: PrismaClient,
|
prisma: PrismaClient,
|
||||||
input: { readonly organizationId: string; readonly teamId: string },
|
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) => {
|
return prisma.$transaction(async (tx) => {
|
||||||
await lockActiveOrganization(tx, input.organizationId);
|
await lockActiveOrganization(tx, input.organizationId);
|
||||||
const team = await requireActiveTeam(tx, input.teamId, input.organizationId);
|
const team = await requireActiveTeam(tx, input.teamId, input.organizationId);
|
||||||
const now = new Date();
|
|
||||||
await tx.team.update({
|
await tx.team.update({
|
||||||
where: { id: team.id },
|
where: { id: team.id },
|
||||||
data: { archivedAt: now },
|
data: { archivedAt: new Date() },
|
||||||
});
|
});
|
||||||
await tx.teamMembership.updateMany({
|
return { archived: true as const, teamId: team.id };
|
||||||
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,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -131,9 +131,8 @@ export async function listProjectTeamAccess(
|
|||||||
where: {
|
where: {
|
||||||
organizationId: project.organizationId,
|
organizationId: project.organizationId,
|
||||||
id: { in: grants.map((grant) => grant.principalId) },
|
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 teamsById = new Map(teams.map((team) => [team.id, team]));
|
||||||
const missing = grants.filter((grant) => !teamsById.has(grant.principalId));
|
const missing = grants.filter((grant) => !teamsById.has(grant.principalId));
|
||||||
@@ -143,7 +142,9 @@ export async function listProjectTeamAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return grants.map((grant) => entryFromGrant({
|
return grants
|
||||||
|
.filter((grant) => teamsById.get(grant.principalId)?.archivedAt === null)
|
||||||
|
.map((grant) => entryFromGrant({
|
||||||
grantId: grant.id,
|
grantId: grant.id,
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
organizationId: project.organizationId,
|
organizationId: project.organizationId,
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ describe("admin members + teams API", () => {
|
|||||||
});
|
});
|
||||||
expect(archive.statusCode).toBe(200);
|
expect(archive.statusCode).toBe(200);
|
||||||
expect(archive.json()).toEqual(
|
expect(archive.json()).toEqual(
|
||||||
expect.objectContaining({ archived: true, revokedGrants: 1 }),
|
expect.objectContaining({ archived: true, teamId: team.id }),
|
||||||
);
|
);
|
||||||
|
|
||||||
const after = await app.inject({
|
const after = await app.inject({
|
||||||
|
|||||||
Reference in New Issue
Block a user