forked from EduCraft/curriculum-project-hub
fix(filelib): 授权表与侧栏展示 displayName 而非裸 userId
GrantDto 加 principalName:USER → User.displayName,GROUP → MemberGroup.name, 取不到行(用户/组已删)时回落为 principalId,与 /database/api/me 同一回落语义。 解析走批量 helper(两条 IN 查询,非 N+1),listGrants/putGrants/forceAdjustGrants 三个出口共用,保证 GET 与 PUT 响应同形状。组不按 archivedAt 过滤 —— 已归档组的 历史授权仍需显示名字,否则管理员无法辨认后收回。 principalName 是纯展示字段;写路径仍只认 principalId,不得据此做授权判断。 前端: - GrantsPanel 主体列由裸 id 改为展示名,id 移入 title 供排查;收回确认框同步。 - LibraryView 侧栏身份区改用 $me.displayName(/me 早已返回,此前未消费)。 - types.ts 去掉重复声明的 Grant 与无引用的 GroupSearchResult。 集成测试断言三种情形(displayName / 组名 / 已删主体回落)。
This commit is contained in:
@@ -84,7 +84,7 @@
|
||||
}
|
||||
|
||||
async function revoke(g: Grant): Promise<void> {
|
||||
if (!confirm(`收回「${g.principalId}」的 ${g.role} 授权?`)) return;
|
||||
if (!confirm(`收回「${g.principalName}」的 ${g.role} 授权?`)) return;
|
||||
try {
|
||||
await api(`/database/api/nodes/${node.id}/grants/${encodeURIComponent(g.id)}`, {
|
||||
method: "DELETE",
|
||||
@@ -133,7 +133,8 @@
|
||||
<td>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<span class="flex text-ink-3"><Icon name={g.principalType === "USER" ? "user" : "group"} size={14} /></span>
|
||||
<span class="font-mono text-[12px]">{g.principalId}</span>
|
||||
<!-- 展示名优先(后端已解析);id 作为 title 供排查。 -->
|
||||
<span class="font-medium" title={g.principalId}>{g.principalName}</span>
|
||||
{#if g.isCreatorGrant}<span class="quiet">(创建者)</span>{/if}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
const initial = $derived(($me?.userId ?? "U").slice(0, 1).toUpperCase());
|
||||
const initial = $derived((($me?.displayName ?? $me?.userId) ?? "U").slice(0, 1).toUpperCase());
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-0 flex-1">
|
||||
@@ -97,7 +97,8 @@
|
||||
{#if showUserFooter}
|
||||
<div class="flex items-center gap-2 border-t border-line-soft px-4 py-3 text-[12.5px]">
|
||||
<div class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent text-[11px] font-semibold text-white">{initial}</div>
|
||||
<span class="flex-1 truncate text-ink">{$me?.userId ?? ""}</span>
|
||||
<!-- 展示名优先;/me 取不到 User 行时后端已回落为 userId。 -->
|
||||
<span class="flex-1 truncate text-ink" title={$me?.userId ?? ""}>{$me?.displayName ?? ""}</span>
|
||||
<button class="rounded-lg border border-line-soft px-2.5 py-1 text-[11.5px] text-ink-3 transition hover:bg-hover hover:text-ink" onclick={logout} title="退出登录">退出</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -72,21 +72,6 @@ export interface ExportJob {
|
||||
readonly createdAt: string;
|
||||
}
|
||||
|
||||
export interface Grant {
|
||||
readonly id: string;
|
||||
readonly principalType: "USER" | "GROUP";
|
||||
readonly principalId: string;
|
||||
readonly role: Role;
|
||||
readonly isCreatorGrant: boolean;
|
||||
readonly createdAt: string;
|
||||
}
|
||||
|
||||
export interface GroupSearchResult {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly breadcrumb: string;
|
||||
}
|
||||
|
||||
/** 成员组(ADR-0028);后端返回扁平列表,前端按 parentId/depth 拼树。 */
|
||||
export interface MemberGroupNode {
|
||||
readonly id: string;
|
||||
@@ -114,6 +99,8 @@ export interface Grant {
|
||||
readonly id: string;
|
||||
readonly principalType: "USER" | "GROUP";
|
||||
readonly principalId: string;
|
||||
/** 后端解析好的展示名(USER→displayName / GROUP→组名);取不到行时回落为 principalId。 */
|
||||
readonly principalName: string;
|
||||
readonly role: Role;
|
||||
/** 创建者授权不可收回、不可改(契约 8.1)。 */
|
||||
readonly isCreatorGrant: boolean;
|
||||
|
||||
@@ -25,16 +25,24 @@ export interface GrantDto {
|
||||
readonly id: string;
|
||||
readonly principalType: "USER" | "GROUP";
|
||||
readonly principalId: string;
|
||||
/**
|
||||
* 展示名(ADR-0029:后端负责把 id 解析成人看的名字,前端不二次查询)。
|
||||
* USER → `User.displayName`;GROUP → `MemberGroup.name`;
|
||||
* 取不到行(用户/组已删)时回落为 principalId,与 `/database/api/me` 同一回落语义。
|
||||
* 纯展示字段:写路径仍只认 principalId,不得用它做任何授权判断。
|
||||
*/
|
||||
readonly principalName: string;
|
||||
readonly role: FileLibRole;
|
||||
readonly isCreatorGrant: boolean;
|
||||
readonly createdAt: Date;
|
||||
}
|
||||
|
||||
function toDto(grant: FileLibGrant): GrantDto {
|
||||
function toDto(grant: FileLibGrant, principalName?: string): GrantDto {
|
||||
return {
|
||||
id: grant.id,
|
||||
principalType: grant.principalType,
|
||||
principalId: grant.principalId,
|
||||
principalName: principalName ?? grant.principalId,
|
||||
role: grant.role,
|
||||
isCreatorGrant: grant.isCreatorGrant,
|
||||
createdAt: grant.createdAt,
|
||||
@@ -44,6 +52,35 @@ function toDto(grant: FileLibGrant): GrantDto {
|
||||
type Tx = Prisma.TransactionClient;
|
||||
type Deps = AccessDeps & { readonly prisma: PrismaClient };
|
||||
|
||||
/**
|
||||
* 批量解析 principal 展示名(两条 IN 查询,不做 N+1)。
|
||||
* 组不按 archivedAt 过滤:已归档组的历史授权仍要能显示出名字来给管理员收回。
|
||||
*/
|
||||
async function resolvePrincipalNames(
|
||||
tx: Tx | PrismaClient,
|
||||
grants: readonly FileLibGrant[],
|
||||
): Promise<ReadonlyMap<string, string>> {
|
||||
const userIds = [...new Set(grants.filter((g) => g.principalType === "USER").map((g) => g.principalId))];
|
||||
const groupIds = [...new Set(grants.filter((g) => g.principalType === "GROUP").map((g) => g.principalId))];
|
||||
const [users, groups] = await Promise.all([
|
||||
userIds.length === 0
|
||||
? Promise.resolve([])
|
||||
: tx.user.findMany({ where: { id: { in: userIds } }, select: { id: true, displayName: true } }),
|
||||
groupIds.length === 0
|
||||
? Promise.resolve([])
|
||||
: tx.memberGroup.findMany({ where: { id: { in: groupIds } }, select: { id: true, name: true } }),
|
||||
]);
|
||||
const names = new Map<string, string>();
|
||||
for (const u of users) names.set(`USER:${u.id}`, u.displayName);
|
||||
for (const g of groups) names.set(`GROUP:${g.id}`, g.name);
|
||||
return names;
|
||||
}
|
||||
|
||||
async function toDtosWithNames(tx: Tx | PrismaClient, grants: readonly FileLibGrant[]): Promise<readonly GrantDto[]> {
|
||||
const names = await resolvePrincipalNames(tx, grants);
|
||||
return grants.map((g) => toDto(g, names.get(`${g.principalType}:${g.principalId}`)));
|
||||
}
|
||||
|
||||
/** MANAGE 门禁:带 tx 时用调用方事务(与后续写同绳),不带时自开一个。 */
|
||||
async function requireManage(
|
||||
deps: Deps,
|
||||
@@ -66,7 +103,7 @@ export async function listGrants(
|
||||
where: { organizationId: deps.organizationId, nodeId, revokedAt: null },
|
||||
orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }],
|
||||
});
|
||||
return grants.map(toDto);
|
||||
return toDtosWithNames(deps.prisma, grants);
|
||||
}
|
||||
|
||||
export interface PutGrantsResult {
|
||||
@@ -137,7 +174,7 @@ export async function putGrants(
|
||||
where: { organizationId: deps.organizationId, nodeId: node.id, revokedAt: null },
|
||||
orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }],
|
||||
});
|
||||
return { granted, updated, grants: grants.map(toDto) };
|
||||
return { granted, updated, grants: await toDtosWithNames(tx, grants) };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -236,7 +273,7 @@ export async function forceAdjustGrants(
|
||||
where: { organizationId: deps.organizationId, nodeId: node.id, revokedAt: null },
|
||||
orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }],
|
||||
});
|
||||
return { granted, updated, grants: grants.map(toDto) };
|
||||
return { granted, updated, grants: await toDtosWithNames(tx, grants) };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -150,6 +150,42 @@ describe("filelib http · 8.1 授权矩阵", () => {
|
||||
expect(revoke.statusCode).toBe(403);
|
||||
expect(revoke.json().error.code).toBe("cannot_touch_creator");
|
||||
});
|
||||
|
||||
it("grants 返回 principalName:USER→displayName / GROUP→组名;取不到行时回落为 id", async () => {
|
||||
const rootId = await createRoot(ADMIN_COOKIE());
|
||||
const group = await prisma.memberGroup.create({ data: { name: "物理组" } });
|
||||
|
||||
const put = await app.inject({
|
||||
method: "PUT", url: `/database/api/nodes/${rootId}/grants`,
|
||||
headers: { cookie: ADMIN_COOKIE() },
|
||||
payload: {
|
||||
grants: [
|
||||
{ principalType: "USER", principalId: "u_alice", role: "EDIT" },
|
||||
{ principalType: "GROUP", principalId: group.id, role: "VIEW" },
|
||||
// 数据库里没有对应 User 行(已删/脏数据)→ 回落为 principalId
|
||||
{ principalType: "USER", principalId: "u_ghost", role: "VIEW" },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(put.statusCode).toBe(200);
|
||||
|
||||
const list = await app.inject({
|
||||
method: "GET", url: `/database/api/nodes/${rootId}/grants`,
|
||||
headers: { cookie: ADMIN_COOKIE() },
|
||||
});
|
||||
expect(list.statusCode).toBe(200);
|
||||
const byPrincipal = new Map<string, string>(
|
||||
list.json().grants.map((g: { principalId: string; principalName: string }) => [g.principalId, g.principalName]),
|
||||
);
|
||||
expect(byPrincipal.get("u_alice")).toBe("Alice");
|
||||
expect(byPrincipal.get(group.id)).toBe("物理组");
|
||||
expect(byPrincipal.get("u_ghost")).toBe("u_ghost");
|
||||
// 创建者授权也要解析出名字
|
||||
const creator = list.json().grants.find((g: { isCreatorGrant: boolean }) => g.isCreatorGrant);
|
||||
expect(creator.principalName).toBe("Admin");
|
||||
// PUT 响应与 GET 同形状
|
||||
expect(put.json().grants.every((g: { principalName?: string }) => typeof g.principalName === "string")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filelib http · 文件冲突流", () => {
|
||||
|
||||
Reference in New Issue
Block a user