diff --git a/hub/filelib-web/src/lib/GrantsPanel.svelte b/hub/filelib-web/src/lib/GrantsPanel.svelte index 3199707..f2e6301 100644 --- a/hub/filelib-web/src/lib/GrantsPanel.svelte +++ b/hub/filelib-web/src/lib/GrantsPanel.svelte @@ -84,7 +84,7 @@ } async function revoke(g: Grant): Promise { - 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 @@ - {g.principalId} + + {g.principalName} {#if g.isCreatorGrant}(创建者){/if} diff --git a/hub/filelib-web/src/lib/LibraryView.svelte b/hub/filelib-web/src/lib/LibraryView.svelte index 5a65257..8babc96 100644 --- a/hub/filelib-web/src/lib/LibraryView.svelte +++ b/hub/filelib-web/src/lib/LibraryView.svelte @@ -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());
@@ -97,7 +97,8 @@ {#if showUserFooter}
{initial}
- {$me?.userId ?? ""} + + {$me?.displayName ?? ""}
{/if} diff --git a/hub/filelib-web/src/lib/types.ts b/hub/filelib-web/src/lib/types.ts index f0bffd6..d39b00b 100644 --- a/hub/filelib-web/src/lib/types.ts +++ b/hub/filelib-web/src/lib/types.ts @@ -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; diff --git a/hub/src/database/filelib/grantService.ts b/hub/src/database/filelib/grantService.ts index cbb9f05..e443004 100644 --- a/hub/src/database/filelib/grantService.ts +++ b/hub/src/database/filelib/grantService.ts @@ -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> { + 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(); + 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 { + 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) }; }); } diff --git a/hub/test/integration/filelib-routes.test.ts b/hub/test/integration/filelib-routes.test.ts index b813cf9..ea2a2c5 100644 --- a/hub/test/integration/filelib-routes.test.ts +++ b/hub/test/integration/filelib-routes.test.ts @@ -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( + 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 · 文件冲突流", () => {