diff --git a/api-report/apps/api/src/modules/org/contract.d.ts b/api-report/apps/api/src/modules/org/contract.d.ts index aa1575b..9d48630 100644 --- a/api-report/apps/api/src/modules/org/contract.d.ts +++ b/api-report/apps/api/src/modules/org/contract.d.ts @@ -5,8 +5,27 @@ * 本模块管三件事:①验签后的人是谁(名册即时更新);②他能进哪几个端(角色→端);③岗位与工作组。 * 口令 / 会话 / 岗位授权(客体三类 × 动作三种)已退役。 */ -import type { InFlightWork, Position, PositionId, Principal, RoleAccess, RosterSyncOutcome, Staff, StaffId, Surface, WorkGroup, WorkGroupBinding } from '@contracts'; -export type { InFlightWork, Position, Principal, RoleAccess, RosterSyncOutcome, Staff, Surface, WorkGroup, WorkGroupBinding }; +import type { InFlightWork, OrgGroup, OrgRole, OrgUser, OrgUserDetail, Position, PositionId, Principal, RoleAccess, RosterSyncOutcome, Staff, StaffId, Surface, WorkGroup, WorkGroupBinding } from '@contracts'; +export type { InFlightWork, OrgGroup, OrgRole, OrgUser, OrgUserDetail, Position, Principal, RoleAccess, RosterSyncOutcome, Staff, Surface, WorkGroup, WorkGroupBinding }; +/** + * 目录写面的两个入参 —— 住在 contract.ts 而不是 port.ts:它们是**服务出口的形状** + * (路由照它填),port 只是恰好也要用。反过来放会让 port ↔ contract 成环。 + */ +export type CreateDirectoryUser = { + readonly username: string; + readonly firstName?: string; + readonly lastName?: string; + readonly email?: string; + /** 临时口令;不给就建一个还登不进来的壳 */ + readonly password?: string; + /** 一并授的 realm 角色 */ + readonly roles?: readonly string[]; +}; +export type DirectoryUserPatch = { + readonly firstName?: string; + readonly lastName?: string; + readonly email?: string; +}; /** 员工连同其岗位的读模型 —— 治理台员工管理列表用 */ export type StaffWithPosition = Staff & { readonly position: Position; @@ -47,6 +66,24 @@ export declare class AccountDisabledError extends Error { export declare class AccessLockoutError extends Error { constructor(message?: string); } +/** + * 目录整个不可用 —— 没配 provisioning 服务账号,或那个账号缺 realm-management 角色。 + * **重试无益**:路由译成 502 并原样带出 Keycloak 的话,界面提示找管理员补权限(手册 §14.6)。 + */ +export declare class DirectoryUnavailableError extends Error { + /** true = 配错了(补权限);false = 暂时够不着(可退避重试)—— 两句话,人的下一步不同 */ + readonly misconfigured: boolean; + constructor(message: string, misconfigured: boolean); +} +/** + * 写面对着一个服务账号 —— 拒。它是 Keycloak 给 client 自动建的身份(见 `staffKindSchema`), + * 在这三个页签里**根本不该出现**;能对着它下手,只能是有人手输了 sub。 + * 停掉它的后果不显眼却很重:试跑与 e2e 第二天全趴下。 + */ +export declare class ServiceAccountProtectedError extends Error { + readonly account: string; + constructor(account: string); +} export type OrgService = { /** 验签 → 名册即时更新 → 角色→端。抛 TokenRejectedError(401)/ AccountDisabledError(403) */ authenticate(token: string): Promise; @@ -75,6 +112,39 @@ export type OrgService = { deleteRoleAccess(role: string): Promise; /** 能进该端的活跃员工 —— 治理台通知的收件人 */ staffWithEnd(surface: Surface): Promise; + /** + * 目录**没配 provisioning 服务账号就整个不可用** —— 一律抛 `DirectoryUnavailableError` + * (路由译成 502 + 原话)。不给「读得了写不了」这种半开状态:那样人会以为是自己点错了。 + * 服务账号在这一层就剔干净;写面对着服务账号的 sub 一律抛 `ServiceAccountProtectedError`。 + */ + directoryUsers(query: { + q?: string; + includeDisabled?: boolean; + }): Promise<{ + users: OrgUser[]; + }>; + directoryUser(sub: string): Promise; + createDirectoryUser(input: CreateDirectoryUser): Promise; + patchDirectoryUser(sub: string, patch: DirectoryUserPatch): Promise; + /** 停用顺手踢会话 —— 界面写了「已停用」,那枚 token 就不该还能用到过期 */ + setDirectoryUserEnabled(sub: string, enabled: boolean): Promise; + resetDirectoryUserPassword(sub: string, password: string): Promise; + deleteDirectoryUser(sub: string): Promise; + directoryRoles(): Promise<{ + roles: OrgRole[]; + }>; + /** 授 / 收一个 realm 角色;收权顺手踢会话。抛 AccessLockoutError(409):收完没人进得了运营中心 */ + setDirectoryUserRole(sub: string, role: string, granted: boolean): Promise; + directoryGroups(): Promise<{ + groups: OrgGroup[]; + }>; + directoryGroupMembers(groupId: string): Promise<{ + members: OrgUser[]; + }>; + createDirectoryGroup(name: string, parentId?: string): Promise; + renameDirectoryGroup(groupId: string, name: string): Promise; + deleteDirectoryGroup(groupId: string): Promise; + setDirectoryUserGroup(sub: string, groupId: string, member: boolean): Promise; /** 建组 / 改组;id 留空即新建 */ saveWorkGroup(input: { id?: string; diff --git a/api-report/apps/api/src/modules/org/index.d.ts b/api-report/apps/api/src/modules/org/index.d.ts index 2a126d4..383966a 100644 --- a/api-report/apps/api/src/modules/org/index.d.ts +++ b/api-report/apps/api/src/modules/org/index.d.ts @@ -1,18 +1,22 @@ /** * module: org —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。 */ -export type { Identity, InFlightWork, OrgService, Position, Principal, RoleAccess, RosterSyncOutcome, Staff, StaffWithPosition, Surface, WorkGroup, WorkGroupBinding, } from './contract.ts'; -export { AccessLockoutError, AccountDisabledError, PositionNotFoundError, StaffNotFoundError, TokenRejectedError, } from './contract.ts'; -export type { Clock, IdGenerator, IdentityPatch, InFlightWorkProbe, PositionRepository, RoleAccessRepository, RosterSource, RosterUser, StaffRecord, StaffRepository, TokenVerifier, VerifiedClaims, WorkGroupRepository, } from './port.ts'; +export type { CreateDirectoryUser, DirectoryUserPatch, Identity, InFlightWork, OrgGroup, OrgRole, OrgService, OrgUser, OrgUserDetail, Position, Principal, RoleAccess, RosterSyncOutcome, Staff, StaffWithPosition, Surface, WorkGroup, WorkGroupBinding, } from './contract.ts'; +export { AccessLockoutError, AccountDisabledError, DirectoryUnavailableError, PositionNotFoundError, ServiceAccountProtectedError, StaffNotFoundError, TokenRejectedError, } from './contract.ts'; +export type { Clock, DirectoryGroup, DirectoryRole, DirectoryUser, IdGenerator, IdentityPatch, InFlightWorkProbe, KeycloakDirectory, PositionRepository, RoleAccessRepository, RosterSource, RosterUser, StaffRecord, StaffRepository, TokenVerifier, VerifiedClaims, WorkGroupRepository, } from './port.ts'; export type { OrgServiceDeps } from './internal/usecase/org-service.ts'; export { createOrgService } from './internal/usecase/org-service.ts'; export { identityOf, isNoiseRole, normalizeRoles } from './internal/domain/claims.ts'; export { assertNotLockedOut, endsOf, normalizeEnds } from './internal/domain/ends.ts'; +export { displayNameOf, filterOrgUsers, isServiceAccount, sortOrgUsers, toOrgGroups, toOrgRoles, toOrgUser, } from './internal/domain/directory.ts'; export { buildInFlightWork } from './internal/domain/in-flight.ts'; export { DEFAULT_ROLE_ACCESS, NONE_POSITION_CODE, POSITION_SEED, TEST_POSITION_CODE } from './internal/domain/seed-data.ts'; export { createKeycloakTokenVerifier, explainJoseError } from './internal/adapter/keycloak-token-verifier.ts'; export type { KeycloakVerifierOptions } from './internal/adapter/keycloak-token-verifier.ts'; export { createKeycloakRoster } from './internal/adapter/keycloak-admin-roster.ts'; export type { KeycloakRosterOptions } from './internal/adapter/keycloak-admin-roster.ts'; +export { createKeycloakDirectory } from './internal/adapter/keycloak-directory.ts'; +export { createAdminHttp, KeycloakMisconfiguredError, KeycloakRejectedError, KeycloakUnavailableError } from './internal/adapter/keycloak-admin-http.ts'; +export type { AdminHttp, KeycloakAdminOptions } from './internal/adapter/keycloak-admin-http.ts'; export { createPositionRepository, createRoleAccessRepository, createStaffRepository, createWorkGroupRepository, } from './internal/adapter/org-repository.ts'; export { positionTable, roleAccessTable, staffTable, workGroupTable } from './internal/adapter/org-schema.ts'; diff --git a/api-report/apps/api/src/modules/org/port.d.ts b/api-report/apps/api/src/modules/org/port.d.ts index f0b9ff1..8b59759 100644 --- a/api-report/apps/api/src/modules/org/port.d.ts +++ b/api-report/apps/api/src/modules/org/port.d.ts @@ -3,7 +3,7 @@ * adapter 实现之,usecase 依赖之,app 装配之。 */ import type { Position, PositionId, RoleAccess, Staff, StaffId, WorkGroup } from '@contracts'; -import type { Identity } from './contract.ts'; +import type { CreateDirectoryUser, DirectoryUserPatch, Identity } from './contract.ts'; export type Clock = { now(): Date; }; @@ -11,7 +11,12 @@ export type Clock = { export type VerifiedClaims = { readonly sub: string; readonly preferred_username: string; + /** Keycloak 拼好的 `firstName lastName` —— 西式顺序,只在拆不开时才用它 */ readonly name?: string; + /** 名(firstName) */ + readonly given_name?: string; + /** 姓(lastName)—— 我们按「姓在前」自己拼,见 `personName` */ + readonly family_name?: string; readonly email?: string; readonly realm_access?: { readonly roles?: readonly string[]; @@ -28,6 +33,65 @@ export type RosterUser = Identity & { export type RosterSource = { listUsers(): Promise; }; +/** + * Keycloak 那一侧的一个用户 —— **原样的形状**,不掺本地字段。 + * 与 `RosterUser` 的区别:名册只要「同步要比对的那几列」,目录要摆给人看的全部。 + */ +export type DirectoryUser = { + readonly sub: string; + readonly username: string; + readonly firstName?: string; + readonly lastName?: string; + readonly email?: string; + readonly enabled: boolean; + readonly emailVerified: boolean; + /** realm 角色(家具角色已滤) */ + readonly roles: readonly string[]; +}; +/** 组树里的一行 —— 平表,层级看 `parentId` / `depth`(契约同款,理由见 org-directory.ts) */ +export type DirectoryGroup = { + readonly id: string; + readonly name: string; + readonly path: string; + readonly parentId?: string; + readonly depth: number; + readonly memberCount: number; +}; +export type DirectoryRole = { + readonly name: string; + readonly description?: string; + readonly composite: boolean; +}; +/** + * 成员目录 —— app 未配 provisioning 服务账号时不注入(与 `RosterSource` 同款)。 + * 读是投影,写**直接穿透**到 Keycloak:这里建的人立刻能登录,这里删的人从 Keycloak 消失。 + */ +export type KeycloakDirectory = { + listUsers(): Promise; + getUser(sub: string): Promise; + }>; + createUser(input: CreateDirectoryUser): Promise; + updateUser(sub: string, patch: DirectoryUserPatch): Promise; + setEnabled(sub: string, enabled: boolean): Promise; + resetPassword(sub: string, password: string): Promise; + deleteUser(sub: string): Promise; + /** 踢掉此人全部会话 —— 停用与收权之后必调,否则旧 token 到期前照样通行 */ + logout(sub: string): Promise; + listRoles(): Promise; + setRole(sub: string, role: string, granted: boolean): Promise; + listGroups(): Promise; + groupMembers(groupId: string): Promise; + /** 组的直属成员用户名 —— 组树只要个数,别为了数数把每个人的角色都拉一遍 */ + groupMemberUsernames(groupId: string): Promise; + createGroup(name: string, parentId?: string): Promise; + renameGroup(groupId: string, name: string): Promise; + deleteGroup(groupId: string): Promise; + setUserGroup(sub: string, groupId: string, member: boolean): Promise; +}; /** 名册即时更新 / 同步要改的那几列 */ export type IdentityPatch = { readonly account: string; @@ -45,7 +109,12 @@ export type PositionRepository = { }): Promise; }; /** 员工行 —— 契约 Staff 之外还带两枚时刻,只给用例判「要不要再写一次」,不出模块 */ -export type StaffRecord = Staff & { +/** + * 库里真存着的那几列。`Staff.kind` 不在其中 —— 它由账号名现判(见 `staffKindSchema`), + * 存一列就会与账号名分叉,而分叉之后没人看得出以哪份为准。 + */ +export type StoredStaff = Omit; +export type StaffRecord = StoredStaff & { readonly lastSeenAt?: Date; readonly syncedAt?: Date; }; @@ -55,7 +124,7 @@ export type StaffRepository = { /** 按账号找 —— `account` 有唯一索引,建档前得先问一句「这个名字被谁占着」(同名让位) */ findByAccount(account: string): Promise; listByPosition(positionId: PositionId): Promise; - insert(staff: Staff, marks: { + insert(staff: StoredStaff, marks: { readonly lastSeenAt?: Date; readonly syncedAt?: Date; }): Promise; diff --git a/api-report/packages/contracts/src/api/governance.d.ts b/api-report/packages/contracts/src/api/governance.d.ts index 8a8de12..bc1569e 100644 --- a/api-report/packages/contracts/src/api/governance.d.ts +++ b/api-report/packages/contracts/src/api/governance.d.ts @@ -19,6 +19,10 @@ export declare const listStaffRoute: { disabled: "disabled"; }>>; roles: z.ZodDefault>; + kind: z.ZodDefault>; position: z.ZodObject<{ id: z.core.$ZodBranded; code: z.ZodString; @@ -55,6 +59,10 @@ export declare const createStaffRoute: { disabled: "disabled"; }>>; roles: z.ZodDefault>; + kind: z.ZodDefault>; }, z.core.$strip>; }; /** @deprecated 随 ADR-012 退役,后端回 410;改用 staffInFlightRoute */ @@ -266,6 +274,10 @@ export declare const setStaffPositionRoute: { disabled: "disabled"; }>>; roles: z.ZodDefault>; + kind: z.ZodDefault>; position: z.ZodObject<{ id: z.core.$ZodBranded; code: z.ZodString; diff --git a/api-report/packages/contracts/src/api/http.d.ts b/api-report/packages/contracts/src/api/http.d.ts index 75dc9ae..75213ff 100644 --- a/api-report/packages/contracts/src/api/http.d.ts +++ b/api-report/packages/contracts/src/api/http.d.ts @@ -41,7 +41,7 @@ export type RouteContract = z.infer; export type RouteBody = R['body'] extends z.ZodTypeAny ? z.infer : never; /** 结构化错误:前端按 code 分辨,不解析文案。`gone` = 410,路由已随统一鉴权中心退役 */ -export declare const API_ERROR_CODES: readonly ["unauthenticated", "forbidden", "not-found", "validation-failed", "conflict", "precondition-failed", "internal", "gone"]; +export declare const API_ERROR_CODES: readonly ["unauthenticated", "forbidden", "not-found", "validation-failed", "conflict", "precondition-failed", "internal", "gone", "keycloak-misconfigured", "keycloak-unavailable"]; export type ApiErrorCode = (typeof API_ERROR_CODES)[number]; export type ApiError = { readonly code: ApiErrorCode; diff --git a/api-report/packages/contracts/src/api/index.d.ts b/api-report/packages/contracts/src/api/index.d.ts index 297cfdd..4b5edcb 100644 --- a/api-report/packages/contracts/src/api/index.d.ts +++ b/api-report/packages/contracts/src/api/index.d.ts @@ -1,5 +1,6 @@ export * from './http.ts'; export * from './workbench.ts'; export * from './governance.ts'; +export * from './org-directory.ts'; export * from './ingest.ts'; export * from './mail.ts'; diff --git a/api-report/packages/contracts/src/api/org-directory.d.ts b/api-report/packages/contracts/src/api/org-directory.d.ts new file mode 100644 index 0000000..41f1440 --- /dev/null +++ b/api-report/packages/contracts/src/api/org-directory.d.ts @@ -0,0 +1,701 @@ +/** + * 成员与组织(ADR-012 · 运营中心「员工」那一屏的三个页签)。 + * + * **真源是 Keycloak**:读接口是它的投影,写接口**直接穿透**到 Admin API —— + * 这里建的人立刻能登录、这里停的人立刻登不上、这里删的人从 Keycloak 消失。 + * 没有「先存本地草稿再同步」这回事,**也没有撤销**;界面上凡写必二次确认。 + * + * 与本地名册的关系:`sys.staff` 是这份目录的投影 + 一列本地岗位(见 org/CONTRACT.md)。 + * 涉及身份的写(停用 / 删除 / 授收角色)之后,本地那一行当场跟手 —— 两屏不许说两句话。 + * + * 三处口径与 Keycloak 的形状有关,写在这儿免得每条接口重复: + * ① **服务账号一律不可见、不可写**:目录读剔除 `service-account-*`(它是身份不是人, + * 见 `staffKindSchema`);写面对着服务账号的 sub 一律 403。 + * ② **组树是平表**:一行一个组,靠 `parentId` / `depth` 表达层级(按 `path` 升序已排好)。 + * 契约里不放递归结构 —— 递归 schema 到了声明文件那一层只会变成一坨 any。 + * ③ **列表无分页**:realm 不大,一次拉全;Keycloak 那一侧的 100 条上限由适配器分页兜住。 + */ +import { z } from 'zod'; +export declare const orgUserSchema: z.ZodObject<{ + sub: z.ZodString; + username: z.ZodString; + displayName: z.ZodString; + email: z.ZodOptional; + enabled: z.ZodBoolean; + roles: z.ZodArray; + ends: z.ZodArray>; + lastSeenAt: z.ZodOptional; + positionCode: z.ZodOptional; +}, z.core.$strip>; +export type OrgUser = z.infer; +export declare const orgUserDetailSchema: z.ZodObject<{ + sub: z.ZodString; + username: z.ZodString; + displayName: z.ZodString; + email: z.ZodOptional; + enabled: z.ZodBoolean; + roles: z.ZodArray; + ends: z.ZodArray>; + lastSeenAt: z.ZodOptional; + positionCode: z.ZodOptional; + firstName: z.ZodOptional; + lastName: z.ZodOptional; + emailVerified: z.ZodBoolean; + groups: z.ZodArray>; +}, z.core.$strip>; +export type OrgUserDetail = z.infer; +export declare const listOrgUsersRoute: { + readonly method: "GET"; + readonly path: "/api/gov/org/users"; + readonly access: "ops"; + readonly output: z.ZodObject<{ + users: z.ZodArray; + enabled: z.ZodBoolean; + roles: z.ZodArray; + ends: z.ZodArray>; + lastSeenAt: z.ZodOptional; + positionCode: z.ZodOptional; + }, z.core.$strip>>; + }, z.core.$strip>; +}; +export declare const getOrgUserRoute: { + readonly method: "GET"; + readonly path: "/api/gov/org/users/:sub"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + sub: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + sub: z.ZodString; + username: z.ZodString; + displayName: z.ZodString; + email: z.ZodOptional; + enabled: z.ZodBoolean; + roles: z.ZodArray; + ends: z.ZodArray>; + lastSeenAt: z.ZodOptional; + positionCode: z.ZodOptional; + firstName: z.ZodOptional; + lastName: z.ZodOptional; + emailVerified: z.ZodBoolean; + groups: z.ZodArray>; + }, z.core.$strip>; +}; +/** + * 新建成员 —— 建完立刻能登录。口令是**临时**的:本人首登必须改 + * (`temporary:true`,手册 §14.2);我们不存它,也不回显。 + */ +export declare const createOrgUserRoute: { + readonly method: "POST"; + readonly path: "/api/gov/org/users"; + readonly access: "ops"; + readonly body: z.ZodObject<{ + username: z.ZodString; + firstName: z.ZodOptional; + lastName: z.ZodOptional; + email: z.ZodOptional; + password: z.ZodOptional; + roles: z.ZodOptional>; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + sub: z.ZodString; + username: z.ZodString; + displayName: z.ZodString; + email: z.ZodOptional; + enabled: z.ZodBoolean; + roles: z.ZodArray; + ends: z.ZodArray>; + lastSeenAt: z.ZodOptional; + positionCode: z.ZodOptional; + }, z.core.$strip>; +}; +/** 改资料(姓名 / 邮箱)—— 用户名建后不可改,不在这里 */ +export declare const patchOrgUserRoute: { + readonly method: "PATCH"; + readonly path: "/api/gov/org/users/:sub"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + sub: z.ZodString; + }, z.core.$strip>; + readonly body: z.ZodObject<{ + firstName: z.ZodOptional; + lastName: z.ZodOptional; + email: z.ZodOptional; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + sub: z.ZodString; + username: z.ZodString; + displayName: z.ZodString; + email: z.ZodOptional; + enabled: z.ZodBoolean; + roles: z.ZodArray; + ends: z.ZodArray>; + lastSeenAt: z.ZodOptional; + positionCode: z.ZodOptional; + }, z.core.$strip>; +}; +/** + * 启用 / 停用。停用**立刻踢掉该用户全部会话** —— 不然他攥着的 token + * 还能用到过期为止,而「已停用」这三个字在界面上已经写出去了(界面不撒谎)。 + */ +export declare const setOrgUserEnabledRoute: { + readonly method: "POST"; + readonly path: "/api/gov/org/users/:sub/enabled"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + sub: z.ZodString; + }, z.core.$strip>; + readonly body: z.ZodObject<{ + enabled: z.ZodBoolean; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + sub: z.ZodString; + username: z.ZodString; + displayName: z.ZodString; + email: z.ZodOptional; + enabled: z.ZodBoolean; + roles: z.ZodArray; + ends: z.ZodArray>; + lastSeenAt: z.ZodOptional; + positionCode: z.ZodOptional; + }, z.core.$strip>; +}; +/** 重置口令 —— 一律临时口令,本人首登必须改;服务端不回显、不留痕 */ +export declare const resetOrgUserPasswordRoute: { + readonly method: "POST"; + readonly path: "/api/gov/org/users/:sub/reset-password"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + sub: z.ZodString; + }, z.core.$strip>; + readonly body: z.ZodObject<{ + password: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + ok: z.ZodLiteral; + }, z.core.$strip>; +}; +/** 删人 —— 从 Keycloak 消失,不可撤销;本地名册那一行同批停用(留痕与在途还挂着他) */ +export declare const deleteOrgUserRoute: { + readonly method: "DELETE"; + readonly path: "/api/gov/org/users/:sub"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + sub: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + ok: z.ZodLiteral; + }, z.core.$strip>; +}; +export declare const orgRoleSchema: z.ZodObject<{ + name: z.ZodString; + description: z.ZodOptional; + composite: z.ZodBoolean; + ends: z.ZodArray>; + holders: z.ZodNumber; +}, z.core.$strip>; +export type OrgRole = z.infer; +export declare const listOrgRolesRoute: { + readonly method: "GET"; + readonly path: "/api/gov/org/roles"; + readonly access: "ops"; + readonly output: z.ZodObject<{ + roles: z.ZodArray; + composite: z.ZodBoolean; + ends: z.ZodArray>; + holders: z.ZodNumber; + }, z.core.$strip>>; + }, z.core.$strip>; +}; +/** + * 授 / 收一个 realm 角色。收权**立刻踢掉该用户全部会话**(手册 §17.3)—— + * 否则他手上那枚 token 还带着旧角色,收权要等到 token 过期才生效。 + * 不能收自己的最后一条进运营中心的路(409)—— 收完没人进得来,这扇门就锁死了。 + */ +export declare const setOrgUserRoleRoute: { + readonly method: "POST"; + readonly path: "/api/gov/org/users/:sub/roles/:role"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + sub: z.ZodString; + role: z.ZodString; + }, z.core.$strip>; + readonly body: z.ZodObject<{ + granted: z.ZodBoolean; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + sub: z.ZodString; + username: z.ZodString; + displayName: z.ZodString; + email: z.ZodOptional; + enabled: z.ZodBoolean; + roles: z.ZodArray; + ends: z.ZodArray>; + lastSeenAt: z.ZodOptional; + positionCode: z.ZodOptional; + }, z.core.$strip>; +}; +export declare const orgGroupSchema: z.ZodObject<{ + id: z.ZodString; + name: z.ZodString; + path: z.ZodString; + parentId: z.ZodOptional; + depth: z.ZodNumber; + memberCount: z.ZodNumber; +}, z.core.$strip>; +export type OrgGroup = z.infer; +/** 组树平表,按 `path` 升序 —— 层级看 `parentId` / `depth` */ +export declare const listOrgGroupsRoute: { + readonly method: "GET"; + readonly path: "/api/gov/org/groups"; + readonly access: "ops"; + readonly output: z.ZodObject<{ + groups: z.ZodArray; + depth: z.ZodNumber; + memberCount: z.ZodNumber; + }, z.core.$strip>>; + }, z.core.$strip>; +}; +/** 组的直属成员(含被停用的人 —— 这一屏管的是归属,不是能不能登录) */ +export declare const listOrgGroupMembersRoute: { + readonly method: "GET"; + readonly path: "/api/gov/org/groups/:id/members"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + id: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + members: z.ZodArray; + enabled: z.ZodBoolean; + roles: z.ZodArray; + ends: z.ZodArray>; + lastSeenAt: z.ZodOptional; + positionCode: z.ZodOptional; + }, z.core.$strip>>; + }, z.core.$strip>; +}; +/** 建组:`parentId` 给了就是子组,不给就是顶层组。同级不得重名(Keycloak 那一侧拦,409) */ +export declare const createOrgGroupRoute: { + readonly method: "POST"; + readonly path: "/api/gov/org/groups"; + readonly access: "ops"; + readonly body: z.ZodObject<{ + name: z.ZodString; + parentId: z.ZodOptional; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + id: z.ZodString; + name: z.ZodString; + path: z.ZodString; + parentId: z.ZodOptional; + depth: z.ZodNumber; + memberCount: z.ZodNumber; + }, z.core.$strip>; +}; +export declare const renameOrgGroupRoute: { + readonly method: "PATCH"; + readonly path: "/api/gov/org/groups/:id"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + id: z.ZodString; + }, z.core.$strip>; + readonly body: z.ZodObject<{ + name: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + id: z.ZodString; + name: z.ZodString; + path: z.ZodString; + parentId: z.ZodOptional; + depth: z.ZodNumber; + memberCount: z.ZodNumber; + }, z.core.$strip>; +}; +/** 删组 —— 连同子组一起没(Keycloak 语义);人不会被删,只是不再属于这个组 */ +export declare const deleteOrgGroupRoute: { + readonly method: "DELETE"; + readonly path: "/api/gov/org/groups/:id"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + id: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + ok: z.ZodLiteral; + }, z.core.$strip>; +}; +/** 成员进 / 出组 */ +export declare const setOrgUserGroupRoute: { + readonly method: "POST"; + readonly path: "/api/gov/org/users/:sub/groups/:groupId"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + sub: z.ZodString; + groupId: z.ZodString; + }, z.core.$strip>; + readonly body: z.ZodObject<{ + member: z.ZodBoolean; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + ok: z.ZodLiteral; + }, z.core.$strip>; +}; +/** + * 这一屏的全部路由 —— 归属一处列全,测试拿它逐条断言。 + * 逐个 import 去断言的写法,新增一条只要忘了加进名单就悄悄漏过去了。 + */ +export declare const ORG_DIRECTORY_ROUTES: readonly [{ + readonly method: "GET"; + readonly path: "/api/gov/org/users"; + readonly access: "ops"; + readonly output: z.ZodObject<{ + users: z.ZodArray; + enabled: z.ZodBoolean; + roles: z.ZodArray; + ends: z.ZodArray>; + lastSeenAt: z.ZodOptional; + positionCode: z.ZodOptional; + }, z.core.$strip>>; + }, z.core.$strip>; +}, { + readonly method: "GET"; + readonly path: "/api/gov/org/users/:sub"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + sub: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + sub: z.ZodString; + username: z.ZodString; + displayName: z.ZodString; + email: z.ZodOptional; + enabled: z.ZodBoolean; + roles: z.ZodArray; + ends: z.ZodArray>; + lastSeenAt: z.ZodOptional; + positionCode: z.ZodOptional; + firstName: z.ZodOptional; + lastName: z.ZodOptional; + emailVerified: z.ZodBoolean; + groups: z.ZodArray>; + }, z.core.$strip>; +}, { + readonly method: "POST"; + readonly path: "/api/gov/org/users"; + readonly access: "ops"; + readonly body: z.ZodObject<{ + username: z.ZodString; + firstName: z.ZodOptional; + lastName: z.ZodOptional; + email: z.ZodOptional; + password: z.ZodOptional; + roles: z.ZodOptional>; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + sub: z.ZodString; + username: z.ZodString; + displayName: z.ZodString; + email: z.ZodOptional; + enabled: z.ZodBoolean; + roles: z.ZodArray; + ends: z.ZodArray>; + lastSeenAt: z.ZodOptional; + positionCode: z.ZodOptional; + }, z.core.$strip>; +}, { + readonly method: "PATCH"; + readonly path: "/api/gov/org/users/:sub"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + sub: z.ZodString; + }, z.core.$strip>; + readonly body: z.ZodObject<{ + firstName: z.ZodOptional; + lastName: z.ZodOptional; + email: z.ZodOptional; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + sub: z.ZodString; + username: z.ZodString; + displayName: z.ZodString; + email: z.ZodOptional; + enabled: z.ZodBoolean; + roles: z.ZodArray; + ends: z.ZodArray>; + lastSeenAt: z.ZodOptional; + positionCode: z.ZodOptional; + }, z.core.$strip>; +}, { + readonly method: "POST"; + readonly path: "/api/gov/org/users/:sub/enabled"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + sub: z.ZodString; + }, z.core.$strip>; + readonly body: z.ZodObject<{ + enabled: z.ZodBoolean; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + sub: z.ZodString; + username: z.ZodString; + displayName: z.ZodString; + email: z.ZodOptional; + enabled: z.ZodBoolean; + roles: z.ZodArray; + ends: z.ZodArray>; + lastSeenAt: z.ZodOptional; + positionCode: z.ZodOptional; + }, z.core.$strip>; +}, { + readonly method: "POST"; + readonly path: "/api/gov/org/users/:sub/reset-password"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + sub: z.ZodString; + }, z.core.$strip>; + readonly body: z.ZodObject<{ + password: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + ok: z.ZodLiteral; + }, z.core.$strip>; +}, { + readonly method: "DELETE"; + readonly path: "/api/gov/org/users/:sub"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + sub: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + ok: z.ZodLiteral; + }, z.core.$strip>; +}, { + readonly method: "GET"; + readonly path: "/api/gov/org/roles"; + readonly access: "ops"; + readonly output: z.ZodObject<{ + roles: z.ZodArray; + composite: z.ZodBoolean; + ends: z.ZodArray>; + holders: z.ZodNumber; + }, z.core.$strip>>; + }, z.core.$strip>; +}, { + readonly method: "POST"; + readonly path: "/api/gov/org/users/:sub/roles/:role"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + sub: z.ZodString; + role: z.ZodString; + }, z.core.$strip>; + readonly body: z.ZodObject<{ + granted: z.ZodBoolean; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + sub: z.ZodString; + username: z.ZodString; + displayName: z.ZodString; + email: z.ZodOptional; + enabled: z.ZodBoolean; + roles: z.ZodArray; + ends: z.ZodArray>; + lastSeenAt: z.ZodOptional; + positionCode: z.ZodOptional; + }, z.core.$strip>; +}, { + readonly method: "GET"; + readonly path: "/api/gov/org/groups"; + readonly access: "ops"; + readonly output: z.ZodObject<{ + groups: z.ZodArray; + depth: z.ZodNumber; + memberCount: z.ZodNumber; + }, z.core.$strip>>; + }, z.core.$strip>; +}, { + readonly method: "GET"; + readonly path: "/api/gov/org/groups/:id/members"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + id: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + members: z.ZodArray; + enabled: z.ZodBoolean; + roles: z.ZodArray; + ends: z.ZodArray>; + lastSeenAt: z.ZodOptional; + positionCode: z.ZodOptional; + }, z.core.$strip>>; + }, z.core.$strip>; +}, { + readonly method: "POST"; + readonly path: "/api/gov/org/groups"; + readonly access: "ops"; + readonly body: z.ZodObject<{ + name: z.ZodString; + parentId: z.ZodOptional; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + id: z.ZodString; + name: z.ZodString; + path: z.ZodString; + parentId: z.ZodOptional; + depth: z.ZodNumber; + memberCount: z.ZodNumber; + }, z.core.$strip>; +}, { + readonly method: "PATCH"; + readonly path: "/api/gov/org/groups/:id"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + id: z.ZodString; + }, z.core.$strip>; + readonly body: z.ZodObject<{ + name: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + id: z.ZodString; + name: z.ZodString; + path: z.ZodString; + parentId: z.ZodOptional; + depth: z.ZodNumber; + memberCount: z.ZodNumber; + }, z.core.$strip>; +}, { + readonly method: "DELETE"; + readonly path: "/api/gov/org/groups/:id"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + id: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + ok: z.ZodLiteral; + }, z.core.$strip>; +}, { + readonly method: "POST"; + readonly path: "/api/gov/org/users/:sub/groups/:groupId"; + readonly access: "ops"; + readonly params: z.ZodObject<{ + sub: z.ZodString; + groupId: z.ZodString; + }, z.core.$strip>; + readonly body: z.ZodObject<{ + member: z.ZodBoolean; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + ok: z.ZodLiteral; + }, z.core.$strip>; +}]; diff --git a/api-report/packages/contracts/src/api/workbench.d.ts b/api-report/packages/contracts/src/api/workbench.d.ts index b120b40..e85fe04 100644 --- a/api-report/packages/contracts/src/api/workbench.d.ts +++ b/api-report/packages/contracts/src/api/workbench.d.ts @@ -20,6 +20,10 @@ export declare const sessionRoute: { disabled: "disabled"; }>>; roles: z.ZodDefault>; + kind: z.ZodDefault>; }, z.core.$strip>; position: z.ZodObject<{ id: z.core.$ZodBranded; @@ -80,6 +84,10 @@ export declare const loginRoute: { disabled: "disabled"; }>>; roles: z.ZodDefault>; + kind: z.ZodDefault>; }, z.core.$strip>; position: z.ZodObject<{ id: z.core.$ZodBranded; @@ -158,6 +166,10 @@ export declare const meRoute: { disabled: "disabled"; }>>; roles: z.ZodDefault>; + kind: z.ZodDefault>; }, z.core.$strip>; position: z.ZodObject<{ id: z.core.$ZodBranded; @@ -891,6 +903,7 @@ export declare const taskDetailSchema: z.ZodObject<{ cols: z.ZodOptional, z.ZodLiteral<2>]>>; }, z.core.$strip>>; submitLabel: z.ZodOptional; + gateLedgers: z.ZodOptional>; gate: z.ZodOptional, z.ZodLiteral<2>]>>; }, z.core.$strip>>; submitLabel: z.ZodOptional; + gateLedgers: z.ZodOptional>; gate: z.ZodOptional, z.ZodLiteral<2>]>>; }, z.core.$strip>>; submitLabel: z.ZodOptional; + gateLedgers: z.ZodOptional>; gate: z.ZodOptional, z.ZodLiteral<2>]>>; }, z.core.$strip>>; submitLabel: z.ZodOptional; + gateLedgers: z.ZodOptional>; gate: z.ZodOptional; export type StaffState = z.infer; +/** + * 人,还是服务账号。 + * + * Keycloak 给每个开了 `serviceAccountsEnabled` 的 client 自动建一个用户,用户名恒为 + * `service-account-` —— 那是 Keycloak 自己的命名,不是我们的约定;它也不出现在 + * 管理台的 Users 列表、Admin API 的 `GET /users` 里(名册同步的下限保护②为此存在)。 + * + * 它是**真身份**(试跑与 e2e 就用它跑单,铁规矩 6),但**不是人**: + * 员工管理那一屏不摆它,试跑台的负责人下拉照旧摆。 + * 这条判断归后端 —— 界面不该去猜账号名长什么样。 + */ +export declare const staffKindSchema: z.ZodEnum<{ + person: "person"; + service: "service"; +}>; +export type StaffKind = z.infer; export declare const staffSchema: z.ZodObject<{ id: z.core.$ZodBranded; account: z.ZodString; @@ -30,6 +46,10 @@ export declare const staffSchema: z.ZodObject<{ disabled: "disabled"; }>>; roles: z.ZodDefault>; + kind: z.ZodDefault>; }, z.core.$strip>; export type Staff = z.infer; export declare const principalSchema: z.ZodObject<{ @@ -44,6 +64,10 @@ export declare const principalSchema: z.ZodObject<{ disabled: "disabled"; }>>; roles: z.ZodDefault>; + kind: z.ZodDefault>; }, z.core.$strip>; position: z.ZodObject<{ id: z.core.$ZodBranded; @@ -226,6 +250,10 @@ export declare const sessionSchema: z.ZodObject<{ disabled: "disabled"; }>>; roles: z.ZodDefault>; + kind: z.ZodDefault>; }, z.core.$strip>; position: z.ZodObject<{ id: z.core.$ZodBranded; diff --git a/apps/api/package.json b/apps/api/package.json index 3036521..75b137e 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -13,7 +13,8 @@ "seed": "node dist/seed.js", "migrate": "node dist/migrate.js", "reset-runs": "node dist/reset-runs.js", - "install-activities": "node dist/install-activities.js" + "install-activities": "node dist/install-activities.js", + "sync-roster": "node dist/sync-roster.js" }, "dependencies": { "@ai-sdk/openai-compatible": "^3.0.14", diff --git a/apps/api/src/app/__tests__/auth-middleware.test.ts b/apps/api/src/app/__tests__/auth-middleware.test.ts index 5881c31..5df5ae9 100644 --- a/apps/api/src/app/__tests__/auth-middleware.test.ts +++ b/apps/api/src/app/__tests__/auth-middleware.test.ts @@ -11,7 +11,7 @@ import { createTicketIssuer } from '../ticket.ts'; const position: Position = { id: positionIdSchema.parse('none'), code: 'NONE', name: '未分配' }; const principal = (ends: Surface[], roles: string[]): Principal => ({ - staff: { id: staffIdSchema.parse('sub-1'), account: 'alice', name: '爱丽丝', positionId: position.id, state: 'active', roles }, + staff: { id: staffIdSchema.parse('sub-1'), account: 'alice', name: '爱丽丝', positionId: position.id, state: 'active', roles, kind: 'person' }, position, roles, ends, diff --git a/apps/api/src/app/__tests__/org-directory-registry.test.ts b/apps/api/src/app/__tests__/org-directory-registry.test.ts new file mode 100644 index 0000000..3c4a60f --- /dev/null +++ b/apps/api/src/app/__tests__/org-directory-registry.test.ts @@ -0,0 +1,36 @@ +/** + * 成员与组织的路由归属(ADR-012):15 条全部进契约、全部注册、全部归运营中心。 + * + * 归属写在契约的 `access` 上,鉴权中间件一处按注册表判 —— 所以**漏标一条** + * 就会落进 `?? 'any'` 兜底:只要登录过的人都能调。这条测试就是拦那个的。 + */ +import { describe, expect, it } from 'vitest'; +import * as contracts from '@contracts'; +import { ORG_DIRECTORY_ROUTES } from '@contracts'; +import { appRouteRegistry } from '../server.ts'; +import { buildRouteRegistry, routesOf } from '../route-registry.ts'; + +describe('成员与组织路由契约', () => { + it('15 条全在契约命名空间里,注册表建得起来', () => { + const registry = buildRouteRegistry(routesOf(Object.values(contracts))); + expect(ORG_DIRECTORY_ROUTES).toHaveLength(15); + for (const route of ORG_DIRECTORY_ROUTES) { + expect(registry.has(route.method, route.path)).toBe(true); + } + }); + + it('读写一律归运营中心 —— 成员目录没有设计端的消费方', () => { + for (const route of ORG_DIRECTORY_ROUTES) { + expect([route.path, route.access]).toEqual([route.path, 'ops']); + } + }); + + it('查得到的归属就是 ops,不落兜底 —— 兜底等于「登录了就能调」', () => { + const registry = appRouteRegistry(); + for (const route of ORG_DIRECTORY_ROUTES) { + // 拿一条**具体**路径去查(带 :param 的模版要真能匹配上,不然线上每次都 miss) + const concrete = route.path.replace(/:[A-Za-z_][\w]*/g, 'X'); + expect([concrete, registry.lookup(route.method, concrete)]).toEqual([concrete, 'ops']); + } + }); +}); diff --git a/apps/api/src/app/container.ts b/apps/api/src/app/container.ts index 83ff4eb..8a9448d 100644 --- a/apps/api/src/app/container.ts +++ b/apps/api/src/app/container.ts @@ -16,8 +16,9 @@ import { createEventService, createEventStore } from '@modules/event'; import type { LedgerService } from '@modules/ledger'; import type { LoopEngineService } from '@modules/loop-engine'; import { createDraftStore, createLedgerService, createLedgerStore, DRAFT_DDL } from '@modules/ledger'; -import type { InFlightWorkProbe, OrgService, RosterSource, TokenVerifier } from '@modules/org'; +import type { InFlightWorkProbe, KeycloakDirectory, OrgService, RosterSource, TokenVerifier } from '@modules/org'; import { + createKeycloakDirectory, createKeycloakRoster, createKeycloakTokenVerifier, createOrgService, @@ -185,6 +186,7 @@ const sfeelEvaluator = (expression: string, scope: Readonly { @@ -256,9 +258,22 @@ export async function createContainer(config: AppConfig, overrides: ContainerOve secret: keycloak.provisioning.secret, })); + /* 成员目录与名册同步同一枚 provisioning 服务账号 —— 没配就都不注入,界面如实说不可用 */ + const directory: KeycloakDirectory | undefined = + overrides.directory ?? + (keycloak?.provisioning === undefined + ? undefined + : createKeycloakDirectory({ + url: keycloak.url, + realm: keycloak.realm, + clientId: keycloak.provisioning.clientId, + secret: keycloak.provisioning.secret, + })); + const org = createOrgService({ verifier, ...(roster === undefined ? {} : { roster }), + ...(directory === undefined ? {} : { directory }), positions: createPositionRepository(db), staff: createStaffRepository(db), roleAccess: createRoleAccessRepository(db), diff --git a/apps/api/src/app/routes/org-directory.ts b/apps/api/src/app/routes/org-directory.ts new file mode 100644 index 0000000..efecfe4 --- /dev/null +++ b/apps/api/src/app/routes/org-directory.ts @@ -0,0 +1,196 @@ +/** + * 成员与组织的路由组装(ADR-012)—— 运营中心「员工」那一屏的三个页签。 + * 端级授权由 server.ts 的鉴权中间件按契约上的 `access: 'ops'` 统一判,这里不写守卫。 + * + * **写是穿透**:成功之后 Keycloak 已经变了,没有撤销。所以这一层只做三件事 —— + * 把入参校出来、把模块的错误翻成 HTTP、把写完的那一行原样回给界面(界面照它重画,不自己拼)。 + * + * 错误分档(手册 §15 + BPOS §17.0): + * 502 `keycloak_misconfigured` 服务账号缺角色 / 没配 —— **重试无益**,去补权限; + * 502 `keycloak_unavailable` 暂时够不着 —— 可退避重试; + * 409 `conflict` 同级重名、收权会锁死运营中心; + * 403 `forbidden` 对着服务账号下手。 + * 两种 502 分开说,是因为**人的下一步不同**:一个去找管理员,一个等一会儿再点。 + */ +import type { Hono } from 'hono'; +import type { ApiError } from '@contracts'; +import { + createOrgGroupRoute, + createOrgUserRoute, + deleteOrgGroupRoute, + deleteOrgUserRoute, + getOrgUserRoute, + listOrgGroupMembersRoute, + listOrgGroupsRoute, + listOrgRolesRoute, + listOrgUsersRoute, + patchOrgUserRoute, + renameOrgGroupRoute, + resetOrgUserPasswordRoute, + setOrgUserEnabledRoute, + setOrgUserGroupRoute, + setOrgUserRoleRoute, +} from '@contracts'; +import { + AccessLockoutError, + DirectoryUnavailableError, + ServiceAccountProtectedError, +} from '@modules/org'; +import type { Container } from '../container.ts'; +import type { AppEnv } from '../http-env.ts'; + +/** 出错时统一翻译 —— 每条路由各写一遍 try/catch 只会漏掉其中几条 */ +async function answer(run: () => Promise): Promise<{ body: T } | { error: ApiError; status: 403 | 404 | 409 | 502 }> { + try { + return { body: await run() }; + } catch (error) { + if (error instanceof DirectoryUnavailableError) { + return { + error: { code: error.misconfigured ? 'keycloak-misconfigured' : 'keycloak-unavailable', message: error.message }, + status: 502, + }; + } + if (error instanceof ServiceAccountProtectedError) { + return { error: { code: 'forbidden', message: error.message }, status: 403 }; + } + if (error instanceof AccessLockoutError) { + return { error: { code: 'conflict', message: error.message }, status: 409 }; + } + // Keycloak 明确拒绝(重名 / 约束不过)—— 原话带给人看,别自己编一句更含糊的 + if (error instanceof Error && error.name === 'KeycloakRejectedError') { + const status = (error as Error & { status?: number }).status; + return { error: { code: status === 404 ? 'not-found' : 'conflict', message: error.message }, status: status === 404 ? 404 : 409 }; + } + throw error; + } +} + +export function registerOrgDirectoryRoutes(app: Hono, container: Container): void { + /** + * zod 出来的可选字段是 `string | undefined`,而入参类型在 `exactOptionalPropertyTypes` + * 下要的是「要么有、要么这个键不存在」—— 显式的 undefined 在这儿不算「没填」。 + * 全仓同一个惯用法:条件展开。 + */ +function defined>(input: T): { [K in keyof T]: Exclude } { + const out: Record = {}; + for (const [k, v] of Object.entries(input)) if (v !== undefined) out[k] = v; + return out as { [K in keyof T]: Exclude }; +} + +/** 校验 body,不合就 400 —— 与治理台其余路由同一套话 */ + const bad = (detail: unknown): ApiError => ({ code: 'validation-failed', message: '入参不合法', detail }); + + /* ── 成员 ───────────────────────────────────────────────────────── */ + + app.get(listOrgUsersRoute.path, async (c) => { + const q = c.req.query('q') ?? ''; + // 只有 `1` 算真 —— `true` / `yes` 这类一律当假,免得两处理解不同(与 BPOS §1.1 同款) + const includeDisabled = c.req.query('includeDisabled') === '1'; + const out = await answer(() => container.org.directoryUsers({ q, includeDisabled })); + return 'error' in out ? c.json(out.error, out.status) : c.json(out.body); + }); + + app.get(getOrgUserRoute.path, async (c) => { + const out = await answer(() => container.org.directoryUser(c.req.param('sub'))); + return 'error' in out ? c.json(out.error, out.status) : c.json(out.body); + }); + + app.post(createOrgUserRoute.path, async (c) => { + const parsed = createOrgUserRoute.body.safeParse(await c.req.json()); + if (!parsed.success) return c.json(bad(parsed.error.issues), 400); + const out = await answer(() => container.org.createDirectoryUser(defined(parsed.data))); + return 'error' in out ? c.json(out.error, out.status) : c.json(out.body); + }); + + app.patch(patchOrgUserRoute.path, async (c) => { + const parsed = patchOrgUserRoute.body.safeParse(await c.req.json()); + if (!parsed.success) return c.json(bad(parsed.error.issues), 400); + const out = await answer(() => container.org.patchDirectoryUser(c.req.param('sub'), defined(parsed.data))); + return 'error' in out ? c.json(out.error, out.status) : c.json(out.body); + }); + + app.post(setOrgUserEnabledRoute.path, async (c) => { + const parsed = setOrgUserEnabledRoute.body.safeParse(await c.req.json()); + if (!parsed.success) return c.json(bad(parsed.error.issues), 400); + const out = await answer(() => container.org.setDirectoryUserEnabled(c.req.param('sub'), parsed.data.enabled)); + return 'error' in out ? c.json(out.error, out.status) : c.json(out.body); + }); + + app.post(resetOrgUserPasswordRoute.path, async (c) => { + const parsed = resetOrgUserPasswordRoute.body.safeParse(await c.req.json()); + if (!parsed.success) return c.json(bad(parsed.error.issues), 400); + const out = await answer(async () => { + await container.org.resetDirectoryUserPassword(c.req.param('sub'), parsed.data.password); + return { ok: true as const }; + }); + return 'error' in out ? c.json(out.error, out.status) : c.json(out.body); + }); + + app.delete(deleteOrgUserRoute.path, async (c) => { + const out = await answer(async () => { + await container.org.deleteDirectoryUser(c.req.param('sub')); + return { ok: true as const }; + }); + return 'error' in out ? c.json(out.error, out.status) : c.json(out.body); + }); + + /* ── 权限:realm 角色 × 能进哪几个端 × 谁持有 ───────────────────── */ + + app.get(listOrgRolesRoute.path, async (c) => { + const out = await answer(() => container.org.directoryRoles()); + return 'error' in out ? c.json(out.error, out.status) : c.json(out.body); + }); + + app.post(setOrgUserRoleRoute.path, async (c) => { + const parsed = setOrgUserRoleRoute.body.safeParse(await c.req.json()); + if (!parsed.success) return c.json(bad(parsed.error.issues), 400); + const out = await answer(() => + container.org.setDirectoryUserRole(c.req.param('sub'), c.req.param('role'), parsed.data.granted), + ); + return 'error' in out ? c.json(out.error, out.status) : c.json(out.body); + }); + + /* ── 组织 ───────────────────────────────────────────────────────── */ + + app.get(listOrgGroupsRoute.path, async (c) => { + const out = await answer(() => container.org.directoryGroups()); + return 'error' in out ? c.json(out.error, out.status) : c.json(out.body); + }); + + app.get(listOrgGroupMembersRoute.path, async (c) => { + const out = await answer(() => container.org.directoryGroupMembers(c.req.param('id'))); + return 'error' in out ? c.json(out.error, out.status) : c.json(out.body); + }); + + app.post(createOrgGroupRoute.path, async (c) => { + const parsed = createOrgGroupRoute.body.safeParse(await c.req.json()); + if (!parsed.success) return c.json(bad(parsed.error.issues), 400); + const out = await answer(() => container.org.createDirectoryGroup(parsed.data.name, parsed.data.parentId)); + return 'error' in out ? c.json(out.error, out.status) : c.json(out.body); + }); + + app.patch(renameOrgGroupRoute.path, async (c) => { + const parsed = renameOrgGroupRoute.body.safeParse(await c.req.json()); + if (!parsed.success) return c.json(bad(parsed.error.issues), 400); + const out = await answer(() => container.org.renameDirectoryGroup(c.req.param('id'), parsed.data.name)); + return 'error' in out ? c.json(out.error, out.status) : c.json(out.body); + }); + + app.delete(deleteOrgGroupRoute.path, async (c) => { + const out = await answer(async () => { + await container.org.deleteDirectoryGroup(c.req.param('id')); + return { ok: true as const }; + }); + return 'error' in out ? c.json(out.error, out.status) : c.json(out.body); + }); + + app.post(setOrgUserGroupRoute.path, async (c) => { + const parsed = setOrgUserGroupRoute.body.safeParse(await c.req.json()); + if (!parsed.success) return c.json(bad(parsed.error.issues), 400); + const out = await answer(async () => { + await container.org.setDirectoryUserGroup(c.req.param('sub'), c.req.param('groupId'), parsed.data.member); + return { ok: true as const }; + }); + return 'error' in out ? c.json(out.error, out.status) : c.json(out.body); + }); +} diff --git a/apps/api/src/app/server.ts b/apps/api/src/app/server.ts index 6e6e8a0..7922af1 100644 --- a/apps/api/src/app/server.ts +++ b/apps/api/src/app/server.ts @@ -28,6 +28,7 @@ import { registerRegistrationRoutes } from './routes/registration.ts'; import { registerGovernanceOpsRoutes } from './routes/governance-ops.ts'; import { registerGovernanceRoutes } from './routes/governance.ts'; import { registerIngestRoutes } from './routes/ingest.ts'; +import { registerOrgDirectoryRoutes } from './routes/org-directory.ts'; import { registerMailRoutes } from './routes/mail.ts'; import { registerStreamRoute } from './routes/stream.ts'; import { registerWorkbenchRoutes } from './routes/workbench.ts'; @@ -115,6 +116,7 @@ export function createServer(container: Container): Hono { registerWorkbenchRoutes(app, container); registerGovernanceRoutes(app, container); + registerOrgDirectoryRoutes(app, container); registerDeskRoutes(app, container); registerDeskElementRoutes(app, container); registerDeskApprovalRoutes(app, container); diff --git a/apps/api/src/app/sync-roster.ts b/apps/api/src/app/sync-roster.ts new file mode 100644 index 0000000..124216e --- /dev/null +++ b/apps/api/src/app/sync-roster.ts @@ -0,0 +1,46 @@ +/** + * 名册同步 —— 从 Keycloak Admin API 把整册照过来(ADR-012)。 + * + * pnpm --filter @app/api sync-roster + * + * 与 `POST /api/gov/staff/sync` 是**同一段用例**(`org.syncRoster`),只是换了个入口: + * 那条路由要 `ops`,而运维手上常常只有 shell —— 服务账号进不了运营中心, + * 前端也未必摆着那枚钮。首登建档(JIT)覆盖的是「在用的人」,这条补的是「还没登录过的人」。 + * + * 三个数说的是**真落进库的条数**;`failed` 单列 —— 半途出事的同步不许报成一排绿数字。 + */ +import { loadConfig } from './config.ts'; +import { createContainer } from './container.ts'; + +export async function syncRoster(): Promise { + const container = await createContainer(loadConfig()); + try { + const outcome = await container.org.syncRoster(); + if (!outcome.configured) { + process.stderr.write(`✗ ${outcome.message}\n`); + process.exitCode = 1; + return; + } + process.stdout.write( + `同步完成:远端 ${outcome.total} 人 · 新建 ${outcome.inserted} · 更新 ${outcome.updated} · 停用 ${outcome.disabled}\n`, + ); + for (const f of outcome.failed) process.stderr.write(` ✗ ${f.account}:${f.reason}\n`); + if (outcome.failed.length > 0) process.exitCode = 1; + + const { staff } = await container.org.listStaff(); + process.stdout.write(`\n名册 ${staff.length} 人:\n`); + for (const s of staff) { + // 标出人 / 服务账号 —— 服务账号在 Keycloak 管理台的 Users 列表里也看不到,这里如实说 + const kind = s.kind === 'service' ? '服务账号' : '人 '; + process.stdout.write( + ` ${kind} ${s.account.padEnd(32)} ${s.name.padEnd(14)} ${s.position.code.padEnd(5)} ${s.state.padEnd(9)} ${s.roles.join(',')}\n`, + ); + } + } finally { + await container.close(); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + await syncRoster(); +} diff --git a/apps/api/src/modules/org/CONTRACT.md b/apps/api/src/modules/org/CONTRACT.md index 3b1d849..5396557 100644 --- a/apps/api/src/modules/org/CONTRACT.md +++ b/apps/api/src/modules/org/CONTRACT.md @@ -32,6 +32,15 @@ 不变量: - `Staff.id` 就是 Keycloak `sub`;`account` 只作显示,永不当键 + - **姓在前、名在后**(2026-08-26 用户定):`Staff.name` 由 `personName` 一处拼 —— + token 那条路取 `family_name` / `given_name`,名册同步取 `lastName` / `firstName`; + **不吃 Keycloak 拼好的 `name` claim**(那是 `firstName lastName`,西式序)。 + 两边都是汉字直拼(「柴浩」),否则留一个空格(「Admin Amy」);皆空回落用户名。 + 拆不开的 realm(没配 profile scope)才退回 `name` claim —— 重排不了的东西如实用它。 + - `Staff.kind` 由**账号名现判**、不落库:`service-account-` 是 Keycloak 自己的命名, + 开了 `serviceAccountsEnabled` 的 client 一律配一个。服务账号是**真身份**(试跑与 e2e 用它跑单), + 但不是人 —— 员工管理那一屏据此不摆它,试跑台的负责人下拉照旧摆(铁规矩 6)。 + 按名字认不按角色认:角色是各 realm 自己配的,这个前缀由 Keycloak 保证 - `state` 只由名册同步置 `disabled`;`authenticate` / `principalOf` 对 disabled 一律抛 AccountDisabledError, 有效 token 不能把它翻回 active(撤权在同步之后即时生效) - **停用判定走在 JIT 写之前**:已停用的人攥着有效 token 也驱动不了 `updateIdentity`; @@ -45,6 +54,16 @@ ③落库之前按差异算一遍「同步后还有谁能进运营中心」,不过就整体拒绝(AccessLockoutError → 409),一条都不写 - 同步逐条落库:**单条失败不停在半路**,失败的人点名进 `RosterSyncOutcome.failed`; 三个计数说的是真落下的条数,不是打算写几条 + - **成员目录**(`directory*`,ADR-012):没配 provisioning 就**整个不可用**(抛 + `DirectoryUnavailableError` → 502),不给「读得了写不了」的半开状态 —— 那样人会以为是自己点错了。 + 读是投影、写**穿透**到 Keycloak(没有本地草稿,也没有撤销)。四条不变量: + ①**服务账号一律不可见不可写**(目录读剔干净、写面抛 `ServiceAccountProtectedError` → 403); + ②**停用与收权当场踢会话** —— 否则那枚 token 到期前照样通行,而屏上已经写了「已停用」; + ③**收权先算自锁**:收完没人进得了运营中心就整体拒(409),与改「角色→端」同一道闸、同一个理由; + ④**删人本地只停不删** —— 留痕、在途任务、台账的 `by` 都还指着这个 sub。 + - 目录读有 **60 秒进程内缓存**,**任一写(成功或失败)之后整体清空**:写完立刻重拉一定看得到新结果, + 前端不必延时重试。失败也清 —— 半途失败之后那份缓存说不清停在哪一边。 + 唯一滞后:**别的**管理员在另一个后端实例上写,这边最多滞后 60 秒。 - `staffOfPosition` 只回 active;哨兵岗 NONE 恒回 [] - `ensureSeeded` 幂等:岗位按 code 判存在;缺省角色→端只在表空时播;测试账号策略只补不覆盖 - 工作组是绑定不是策略(ADR-011):`bindingOf` 只出成员表快照,在岗与否由调用方核 diff --git a/apps/api/src/modules/org/__tests__/claims.test.ts b/apps/api/src/modules/org/__tests__/claims.test.ts index 559c3c9..8180d50 100644 --- a/apps/api/src/modules/org/__tests__/claims.test.ts +++ b/apps/api/src/modules/org/__tests__/claims.test.ts @@ -1,6 +1,6 @@ /** domain 测试零 mock(编码规范 §16) */ import { describe, expect, it } from 'vitest'; -import { identityOf, isNoiseRole, normalizeRoles } from '../internal/domain/claims.ts'; +import { identityOf, isNoiseRole, normalizeRoles, personName, staffKindOf } from '../internal/domain/claims.ts'; describe('identityOf —— claims 归一成一个人', () => { it('完整 claims:sub / 用户名 / 姓名 / 邮箱 / 角色(滤噪声、排序、去重)', () => { @@ -43,3 +43,58 @@ describe('噪声角色', () => { expect(normalizeRoles(['user', '', 'admin', 'user'])).toEqual(['admin', 'user']); }); }); + +describe('staffKindOf —— 人还是服务账号', () => { + it('Keycloak 的 service-account- 认作服务账号', () => { + expect(staffKindOf('service-account-task-dispatcher')).toBe('service'); + }); + it('大小写与前后空格不影响判断', () => { + expect(staffKindOf(' Service-Account-Provisioning ')).toBe('service'); + }); + it('真人一律 person —— 名字里带 service 也不算', () => { + expect(staffKindOf('chaihao')).toBe('person'); + expect(staffKindOf('ai-test')).toBe('person'); + expect(staffKindOf('service-desk')).toBe('person'); + expect(staffKindOf('')).toBe('person'); + }); +}); + +describe('personName —— 姓在前、名在后', () => { + it('中文名直拼,不留空格', () => { + expect(personName({ familyName: '柴', givenName: '浩', fallback: 'chaihao' })).toBe('柴浩'); + expect(personName({ familyName: '丁', givenName: '子乔', fallback: 'x' })).toBe('丁子乔'); + }); + it('非汉字留一个空格 —— 拼在一起会连成一个词', () => { + expect(personName({ familyName: 'Admin', givenName: 'Amy', fallback: 'amy' })).toBe('Admin Amy'); + }); + it('一边混着字母就留空格(拼死了读不出来是两截)', () => { + expect(personName({ familyName: '李', givenName: 'Ken', fallback: 'x' })).toBe('李 Ken'); + }); + it('只有一半就摆那一半,不留孤零零的空格', () => { + expect(personName({ familyName: '柴', fallback: 'chaihao' })).toBe('柴'); + expect(personName({ givenName: '浩', fallback: 'chaihao' })).toBe('浩'); + }); + it('两边都空(或只有空白)→ 回落,名字这一格不许空着', () => { + expect(personName({ fallback: 'chaihao' })).toBe('chaihao'); + expect(personName({ familyName: ' ', givenName: '', fallback: 'chaihao' })).toBe('chaihao'); + }); +}); + +describe('identityOf 的名字:自己拼,不吃 Keycloak 拼好的那个', () => { + it('有 family_name / given_name 就按姓在前拼 —— name claim 是西式序,不用它', () => { + const id = identityOf({ + sub: 's', + preferred_username: 'chaihao', + name: '浩 柴', + given_name: '浩', + family_name: '柴', + }); + expect(id.name).toBe('柴浩'); + }); + it('realm 没配 profile scope(拆不开)→ 如实用 name claim', () => { + expect(identityOf({ sub: 's', preferred_username: 'bob', name: 'Bob Lee' }).name).toBe('Bob Lee'); + }); + it('两样都没有 → 回落用户名', () => { + expect(identityOf({ sub: 's', preferred_username: 'bob' }).name).toBe('bob'); + }); +}); diff --git a/apps/api/src/modules/org/__tests__/directory-service.test.ts b/apps/api/src/modules/org/__tests__/directory-service.test.ts new file mode 100644 index 0000000..0b67b92 --- /dev/null +++ b/apps/api/src/modules/org/__tests__/directory-service.test.ts @@ -0,0 +1,313 @@ +/** + * 成员目录的用例层 —— 两道闸与一处缓存(ADR-012)。 + * Keycloak 是外界,给它一个记账的替身(编码规范 §16:只有外界能 mock)。 + */ +import { describe, expect, it } from 'vitest'; +import type { Position, RoleAccess, WorkGroup } from '@contracts'; +import { positionIdSchema } from '@contracts'; +import { AccessLockoutError, DirectoryUnavailableError, ServiceAccountProtectedError } from '../contract.ts'; +import type { DirectoryUser, KeycloakDirectory, OrgServiceDeps, StaffRecord } from '../index.ts'; +import { createOrgService } from '../index.ts'; + +const PEOPLE: DirectoryUser[] = [ + { sub: 'sub-admin', username: 'chaihao', enabled: true, emailVerified: true, roles: ['admin', 'user'] }, + { sub: 'sub-user', username: 'linpeixuan', enabled: true, emailVerified: false, roles: ['user'] }, + { sub: 'sub-svc', username: 'service-account-task-dispatcher', enabled: true, emailVerified: false, roles: ['service'] }, +]; + +type Log = string[]; + +/** 记账替身 —— 只回预置的形状,把调过什么记下来 */ +function fakeDirectory(log: Log, users = PEOPLE): KeycloakDirectory { + const find = (sub: string): DirectoryUser => { + const u = users.find((x) => x.sub === sub); + if (u === undefined) throw new Error(`没有这个人:${sub}`); + return u; + }; + return { + listUsers: async () => { + log.push('listUsers'); + return users; + }, + getUser: async (sub) => ({ ...find(sub), groups: [] }), + createUser: async (input) => { + log.push(`createUser:${input.username}`); + return 'sub-new'; + }, + updateUser: async (sub) => void log.push(`updateUser:${sub}`), + setEnabled: async (sub, enabled) => void log.push(`setEnabled:${sub}:${String(enabled)}`), + resetPassword: async (sub) => void log.push(`resetPassword:${sub}`), + deleteUser: async (sub) => void log.push(`deleteUser:${sub}`), + logout: async (sub) => void log.push(`logout:${sub}`), + listRoles: async () => [ + { name: 'admin', composite: false }, + { name: 'user', composite: false }, + { name: 'default-roles-corp', composite: true }, + ], + setRole: async (sub, role, granted) => void log.push(`setRole:${sub}:${role}:${String(granted)}`), + listGroups: async () => { + log.push('listGroups'); + return [{ id: 'g1', name: '技术部', path: '/技术部', depth: 0, memberCount: 0 }]; + }, + groupMembers: async () => users, + groupMemberUsernames: async () => users.map((u) => u.username), + createGroup: async (name) => { + log.push(`createGroup:${name}`); + return { id: 'g2', name, path: `/${name}`, depth: 0, memberCount: 0 }; + }, + renameGroup: async (id, name) => { + log.push(`renameGroup:${id}:${name}`); + return { id, name, path: `/${name}`, depth: 0, memberCount: 0 }; + }, + deleteGroup: async (id) => void log.push(`deleteGroup:${id}`), + setUserGroup: async (sub, gid, member) => void log.push(`setUserGroup:${sub}:${gid}:${String(member)}`), + }; +} + +function harness(opts: { directory?: KeycloakDirectory } = {}) { + const positions = new Map(); + const staff = new Map(); + const roleAccess = new Map([ + ['admin', { role: 'admin', ends: ['design', 'ops', 'workbench'] }], + ['user', { role: 'user', ends: ['workbench'] }], + ]); + const groups = new Map(); + let nowMs = Date.parse('2026-08-26T09:00:00Z'); + + const deps: OrgServiceDeps = { + verifier: { verify: async () => ({ sub: 'x', preferred_username: 'x' }) }, + ...(opts.directory === undefined ? {} : { directory: opts.directory }), + positions: { + list: async () => [...positions.values()], + findByCode: async (code) => [...positions.values()].find((p) => p.code === code), + findById: async (id) => positions.get(id), + upsert: async (p) => { + const id = p.id ?? positionIdSchema.parse(p.code.toLowerCase()); + const row: Position = { id, code: p.code, name: p.name }; + positions.set(id, row); + return row; + }, + }, + staff: { + list: async () => [...staff.values()], + findById: async (id) => staff.get(id), + findByAccount: async (a) => [...staff.values()].find((s) => s.account === a), + listByPosition: async () => [], + insert: async (s, marks) => { + const row: StaffRecord = { ...s, ...marks }; + staff.set(row.id, row); + return row; + }, + updateIdentity: async () => undefined, + setPosition: async () => undefined, + setState: async (id, state) => { + const row = staff.get(id); + if (row !== undefined) staff.set(id, { ...row, state }); + }, + }, + roleAccess: { + list: async () => [...roleAccess.values()], + upsert: async (row) => { + roleAccess.set(row.role, row); + return row; + }, + remove: async (role) => void roleAccess.delete(role), + }, + workGroups: { + listByOwner: async () => [], + upsert: async (g) => { + groups.set(g.id, g); + return g; + }, + }, + inFlight: { openTasksOf: async () => [], openCardsOf: async () => [], openApprovalsOf: async () => [] }, + clock: { now: () => new Date(nowMs) }, + ids: { next: () => 'id' }, + testAccounts: [], + }; + return { org: createOrgService(deps), staff, tick: (ms: number) => (nowMs += ms) }; +} + +describe('没配 provisioning —— 目录整个不可用,不给半开状态', () => { + it('读也不行,写也不行;话里说清缺什么', async () => { + const h = harness(); + await expect(h.org.directoryUsers({})).rejects.toBeInstanceOf(DirectoryUnavailableError); + await expect(h.org.directoryGroups()).rejects.toBeInstanceOf(DirectoryUnavailableError); + await h.org.directoryUsers({}).catch((e: unknown) => { + expect((e as Error).message).toContain('KEYCLOAK_PROVISIONING_CLIENT_ID'); + expect((e as DirectoryUnavailableError).misconfigured).toBe(true); + }); + }); +}); + +describe('服务账号:是身份,不是人', () => { + it('目录读里一个都不出现', async () => { + const h = harness({ directory: fakeDirectory([]) }); + const { users } = await h.org.directoryUsers({ includeDisabled: true }); + expect(users.map((u) => u.username)).toEqual(['chaihao', 'linpeixuan']); + }); + + it('对着它下手一律拒 —— 停掉它,试跑与 e2e 第二天全趴下', async () => { + const h = harness({ directory: fakeDirectory([]) }); + await expect(h.org.setDirectoryUserEnabled('sub-svc', false)).rejects.toBeInstanceOf(ServiceAccountProtectedError); + await expect(h.org.deleteDirectoryUser('sub-svc')).rejects.toBeInstanceOf(ServiceAccountProtectedError); + await expect(h.org.setDirectoryUserRole('sub-svc', 'admin', false)).rejects.toBeInstanceOf(ServiceAccountProtectedError); + }); + + it('连建一个叫这名字的都不许 —— 免得从正门造出一个假服务账号', async () => { + const h = harness({ directory: fakeDirectory([]) }); + await expect( + h.org.createDirectoryUser({ username: 'service-account-fake' }), + ).rejects.toBeInstanceOf(ServiceAccountProtectedError); + }); + + it('组成员里也剔干净', async () => { + const h = harness({ directory: fakeDirectory([]) }); + const { members } = await h.org.directoryGroupMembers('g1'); + expect(members.map((m) => m.username)).toEqual(['chaihao', 'linpeixuan']); + }); +}); + +describe('收权不能把最后一个管理员关在门外', () => { + it('收掉唯一 admin 的 admin → 拒,一个字都没写出去', async () => { + const log: Log = []; + const h = harness({ directory: fakeDirectory(log, [PEOPLE[0]!, PEOPLE[1]!]) }); + await expect(h.org.setDirectoryUserRole('sub-admin', 'admin', false)).rejects.toBeInstanceOf(AccessLockoutError); + expect(log.filter((l) => l.startsWith('setRole'))).toEqual([]); + }); + + it('还有另一个 admin 在 → 放行,并**当场踢掉他的会话**', async () => { + const log: Log = []; + const two: DirectoryUser[] = [ + PEOPLE[0]!, + { sub: 'sub-admin2', username: 'amy', enabled: true, emailVerified: false, roles: ['admin', 'user'] }, + ]; + const h = harness({ directory: fakeDirectory(log, two) }); + await h.org.setDirectoryUserRole('sub-admin', 'admin', false); + expect(log).toContain('setRole:sub-admin:admin:false'); + // 不踢的话他手上那枚 token 到期前还带着 admin —— 收权要等过期才生效 + expect(log).toContain('logout:sub-admin'); + }); + + it('授权那一路不查自锁(只会更宽,锁不死)', async () => { + const log: Log = []; + const h = harness({ directory: fakeDirectory(log, [PEOPLE[1]!]) }); + await h.org.setDirectoryUserRole('sub-user', 'admin', true); + expect(log).toContain('setRole:sub-user:admin:true'); + expect(log).not.toContain('logout:sub-user'); + }); +}); + +describe('停用与删除:本地那一行跟手', () => { + it('停用 → 踢会话 + 本地置 disabled(两屏不许说两句话)', async () => { + const log: Log = []; + const h = harness({ directory: fakeDirectory(log) }); + await h.org.ensureSeeded(); + await h.org.registerIdentity({ sub: 'sub-user', account: 'linpeixuan', name: '佩萱 林', roles: ['user'] }); + await h.org.setDirectoryUserEnabled('sub-user', false); + expect(log).toContain('setEnabled:sub-user:false'); + expect(log).toContain('logout:sub-user'); + expect(h.staff.get('sub-user')?.state).toBe('disabled'); + }); + + it('启用不踢会话(他本来就登不上,踢什么)', async () => { + const log: Log = []; + const h = harness({ directory: fakeDirectory(log) }); + await h.org.setDirectoryUserEnabled('sub-user', true); + expect(log).not.toContain('logout:sub-user'); + }); + + it('删人 → 本地那一行**停用不删**:留痕、在途、台账都还指着这个 sub', async () => { + const log: Log = []; + const h = harness({ directory: fakeDirectory(log) }); + await h.org.ensureSeeded(); + await h.org.registerIdentity({ sub: 'sub-user', account: 'linpeixuan', name: '佩萱 林', roles: ['user'] }); + await h.org.deleteDirectoryUser('sub-user'); + expect(log).toContain('deleteUser:sub-user'); + expect(h.staff.get('sub-user')?.state).toBe('disabled'); + expect(h.staff.has('sub-user')).toBe(true); + }); +}); + +describe('60 秒读缓存 —— 写完立刻清,不必延时重试', () => { + it('60 秒内不再问 Keycloak', async () => { + const log: Log = []; + const h = harness({ directory: fakeDirectory(log) }); + await h.org.directoryUsers({}); + await h.org.directoryUsers({ q: 'chai' }); + expect(log.filter((l) => l === 'listUsers')).toHaveLength(1); + }); + + it('过了 60 秒自己重取', async () => { + const log: Log = []; + const h = harness({ directory: fakeDirectory(log) }); + await h.org.directoryUsers({}); + h.tick(61_000); + await h.org.directoryUsers({}); + expect(log.filter((l) => l === 'listUsers')).toHaveLength(2); + }); + + /* + * 清缓存**按写真正碰到的东西清**。全清的代价看得见:删一个组之后下一次读要把 + * 「全量成员的角色」那趟 N+1 重付一遍,屏上那棵树因此多钉着已经没了的组好几秒。 + */ + it('建组只清组那一份 —— 人的角色与端没变,别陪着重付一趟 N+1', async () => { + const log: Log = []; + const h = harness({ directory: fakeDirectory(log) }); + await h.org.directoryUsers({}); + await h.org.directoryGroups(); + await h.org.createDirectoryGroup('新组'); + await h.org.directoryUsers({}); + await h.org.directoryGroups(); + expect(log.filter((l) => l === 'listUsers')).toHaveLength(1); + expect(log.filter((l) => l === 'listGroups')).toHaveLength(2); + }); + + it('改资料只清人那一份 —— 组的成员数没变', async () => { + const log: Log = []; + const h = harness({ directory: fakeDirectory(log) }); + await h.org.directoryUsers({}); + await h.org.directoryGroups(); + await h.org.patchDirectoryUser('sub-user', { firstName: '佩萱' }); + await h.org.directoryUsers({}); + await h.org.directoryGroups(); + expect(log.filter((l) => l === 'listUsers')).toHaveLength(2); + expect(log.filter((l) => l === 'listGroups')).toHaveLength(1); + }); + + it('删人两边都清 —— 人没了,他在的那些组也少一个人', async () => { + const log: Log = []; + const h = harness({ directory: fakeDirectory(log) }); + await h.org.directoryUsers({}); + await h.org.directoryGroups(); + await h.org.deleteDirectoryUser('sub-user'); + await h.org.directoryUsers({}); + await h.org.directoryGroups(); + expect(log.filter((l) => l === 'listUsers')).toHaveLength(2); + expect(log.filter((l) => l === 'listGroups')).toHaveLength(2); + }); + + it('写**失败**了也清 —— 半途失败之后那份缓存说不清是哪一边', async () => { + const log: Log = []; + const dir = fakeDirectory(log); + const h = harness({ + directory: { ...dir, createGroup: async () => { throw new Error('同级重名'); } }, + }); + await h.org.directoryGroups(); + await expect(h.org.createDirectoryGroup('技术部')).rejects.toThrow('同级重名'); + await h.org.directoryGroups(); + expect(log.filter((l) => l === 'listGroups')).toHaveLength(2); + }); +}); + +describe('角色清单', () => { + it('家具角色不摆(授收它没有意义),持有数按生效角色数', async () => { + const h = harness({ directory: fakeDirectory([]) }); + const { roles } = await h.org.directoryRoles(); + expect(roles.map((r) => r.name)).toEqual(['admin', 'user']); + // service-account 已剔,所以 user 是 2 不是 3 + expect(roles.find((r) => r.name === 'user')?.holders).toBe(2); + expect(roles.find((r) => r.name === 'admin')?.holders).toBe(1); + expect(roles.find((r) => r.name === 'admin')?.ends).toEqual(['design', 'ops', 'workbench']); + }); +}); diff --git a/apps/api/src/modules/org/__tests__/directory.test.ts b/apps/api/src/modules/org/__tests__/directory.test.ts new file mode 100644 index 0000000..44c0736 --- /dev/null +++ b/apps/api/src/modules/org/__tests__/directory.test.ts @@ -0,0 +1,159 @@ +/** domain 测试零 mock(编码规范 §16) */ +import { describe, expect, it } from 'vitest'; +import type { RoleAccess } from '@contracts'; +import { positionIdSchema, staffIdSchema } from '@contracts'; +import type { DirectoryGroup, DirectoryRole, DirectoryUser, StoredStaff } from '../port.ts'; +import { + displayNameOf, + filterOrgUsers, + isServiceAccount, + localMarks, + sortOrgUsers, + toOrgGroups, + toOrgRoles, + toOrgUser, +} from '../internal/domain/directory.ts'; + +const TABLE: RoleAccess[] = [ + { role: 'admin', ends: ['design', 'ops', 'workbench'] }, + { role: 'user', ends: ['workbench'] }, +]; + +const kc = (over: Partial = {}): DirectoryUser => ({ + sub: 'sub-1', + username: 'chaihao', + enabled: true, + emailVerified: false, + roles: ['admin', 'user'], + ...over, +}); + +describe('服务账号:是身份,不是人', () => { + it('Keycloak 的 service-account- 认得出', () => { + expect(isServiceAccount('service-account-task-dispatcher')).toBe(true); + expect(isServiceAccount('chaihao')).toBe(false); + // 名字里带 service 不算 —— 认的是 Keycloak 那个固定前缀,不是关键词 + expect(isServiceAccount('service-desk')).toBe(false); + }); +}); + +describe('displayNameOf', () => { + it('姓在前、名在后 —— 中文名直拼', () => { + expect(displayNameOf({ username: 'a', firstName: '浩', lastName: '柴' })).toBe('柴浩'); + }); + it('只有一半就摆那一半', () => { + expect(displayNameOf({ username: 'a', lastName: '柴' })).toBe('柴'); + }); + it('两个都空 → 回落用户名,不回空串', () => { + expect(displayNameOf({ username: 'amy', firstName: ' ', lastName: '' })).toBe('amy'); + }); +}); + +describe('toOrgUser —— Keycloak 那份 + 本地那两列', () => { + it('ends 由本地表算,不是 Keycloak 的字段', () => { + expect(toOrgUser(kc(), TABLE).ends).toEqual(['design', 'ops', 'workbench']); + expect(toOrgUser(kc({ roles: ['user'] }), TABLE).ends).toEqual(['workbench']); + }); + it('角色一行都没命中 → 无端可进(不是报错,是事实)', () => { + expect(toOrgUser(kc({ roles: ['service'] }), TABLE).ends).toEqual([]); + }); + it('本地两列合进来;没有就整个键不出现', () => { + const withLocal = toOrgUser(kc(), TABLE, { lastSeenAt: new Date('2026-08-26T07:00:00Z'), positionCode: 'TEST' }); + expect(withLocal.lastSeenAt).toBe('2026-08-26T07:00:00.000Z'); + expect(withLocal.positionCode).toBe('TEST'); + const bare = toOrgUser(kc(), TABLE); + expect('lastSeenAt' in bare).toBe(false); + expect('positionCode' in bare).toBe(false); + }); +}); + +describe('sortOrgUsers —— 来过的在前', () => { + it('来过的按时刻倒序在前,没来过的按用户名在后', () => { + const rows = [ + toOrgUser(kc({ sub: 'c', username: 'cathy' }), TABLE), + toOrgUser(kc({ sub: 'a', username: 'amy' }), TABLE, { lastSeenAt: new Date('2026-08-01T00:00:00Z') }), + toOrgUser(kc({ sub: 'b', username: 'bob' }), TABLE), + toOrgUser(kc({ sub: 'd', username: 'dan' }), TABLE, { lastSeenAt: new Date('2026-08-20T00:00:00Z') }), + ]; + expect(sortOrgUsers(rows).map((u) => u.username)).toEqual(['dan', 'amy', 'bob', 'cathy']); + }); +}); + +describe('filterOrgUsers —— 检索与「含停用」', () => { + const rows = [ + toOrgUser(kc({ sub: '1', username: 'chaihao', firstName: '浩', lastName: '柴', email: 'a@x.com' }), TABLE), + toOrgUser(kc({ sub: '2', username: 'amy', enabled: false }), TABLE), + ]; + it('缺省不摆停用的人', () => { + expect(filterOrgUsers(rows, '', false).map((u) => u.username)).toEqual(['chaihao']); + }); + it('含停用 → 都摆', () => { + expect(filterOrgUsers(rows, '', true)).toHaveLength(2); + }); + it('用户名 / 姓名 / 邮箱任一含关键词即命中,不区分大小写', () => { + expect(filterOrgUsers(rows, 'CHAI', true)).toHaveLength(1); + expect(filterOrgUsers(rows, '柴', true)).toHaveLength(1); + expect(filterOrgUsers(rows, 'a@x', true)).toHaveLength(1); + }); + it('**含在中间**也搜得到 —— 这正是不转给 Keycloak 的 search= 的理由(那个是前缀式)', () => { + expect(filterOrgUsers(rows, 'aiha', true).map((u) => u.username)).toEqual(['chaihao']); + }); + it('搜不到就是空,不是全量', () => { + expect(filterOrgUsers(rows, '查无此人', true)).toEqual([]); + }); +}); + +describe('toOrgRoles', () => { + const roles: DirectoryRole[] = [ + { name: 'admin', composite: false }, + { name: 'user', composite: false, description: '员工' }, + ]; + it('每个角色配上本地算的 ends 与调用方数好的持有数', () => { + const out = toOrgRoles(roles, TABLE, [5, 8]); + expect(out[0]).toMatchObject({ name: 'admin', ends: ['design', 'ops', 'workbench'], holders: 5 }); + expect(out[1]).toMatchObject({ name: 'user', ends: ['workbench'], holders: 8, description: '员工' }); + }); + it('持有数没给到那一位 → 0,不是 undefined', () => { + expect(toOrgRoles(roles, TABLE, [3])[1]?.holders).toBe(0); + }); +}); + +describe('toOrgGroups —— 平表 + 成员数', () => { + const groups: DirectoryGroup[] = [ + { id: 'g1', name: '技术部', path: '/技术部', depth: 0, memberCount: 0 }, + { id: 'g2', name: '平台组', path: '/技术部/平台组', parentId: 'g1', depth: 1, memberCount: 0 }, + ]; + it('成员数由调用方现数;顶层组没有 parentId 那个键', () => { + const out = toOrgGroups(groups, new Map([['g1', 3]])); + expect(out[0]).toEqual({ id: 'g1', name: '技术部', path: '/技术部', depth: 0, memberCount: 3 }); + expect(out[1]?.parentId).toBe('g1'); + }); + it('没数过的组是 0 —— 不给 undefined 让界面摆一个空格', () => { + expect(toOrgGroups(groups, new Map())[1]?.memberCount).toBe(0); + }); +}); + +describe('localMarks', () => { + const none = positionIdSchema.parse('pos-none'); + const staff: Array = [ + { + id: staffIdSchema.parse('sub-1'), + account: 'chaihao', + name: '柴浩', + positionId: none, + state: 'active', + roles: ['admin'], + lastSeenAt: new Date('2026-08-26T07:00:00Z'), + }, + { id: staffIdSchema.parse('sub-2'), account: 'amy', name: 'Amy', positionId: none, state: 'active', roles: [] }, + ]; + it('按 sub 索引;岗位查不到就不摆那个键', () => { + const marks = localMarks(staff, (id) => (id === none ? 'NONE' : undefined)); + expect(marks.get('sub-1')).toEqual({ lastSeenAt: new Date('2026-08-26T07:00:00Z'), positionCode: 'NONE' }); + expect('lastSeenAt' in (marks.get('sub-2') ?? {})).toBe(false); + }); + it('岗位表里没有那一行 → 键不出现,不摆空串', () => { + const marks = localMarks(staff, () => undefined); + expect('positionCode' in (marks.get('sub-1') ?? {})).toBe(false); + }); +}); diff --git a/apps/api/src/modules/org/__tests__/keycloak-admin-roster.test.ts b/apps/api/src/modules/org/__tests__/keycloak-admin-roster.test.ts index 8689740..355f6a7 100644 --- a/apps/api/src/modules/org/__tests__/keycloak-admin-roster.test.ts +++ b/apps/api/src/modules/org/__tests__/keycloak-admin-roster.test.ts @@ -8,8 +8,9 @@ function fakeKeycloak(): { fetch: typeof fetch; calls: string[] } { id: `sub-${i}`, username: `u${i}`, enabled: i !== 3, - firstName: i === 0 ? '' : '名', - lastName: i === 0 ? '' : `${i}`, + // 第 0 位两边都空 —— 试的是「名字这一格不许空着,回落用户名」那一支 + firstName: i === 0 ? '' : '浩', + lastName: i === 0 ? '' : '柴', email: `u${i}@x.test`, })); const json = (body: unknown, status = 200): Response => @@ -42,13 +43,14 @@ function fakeKeycloak(): { fetch: typeof fetch; calls: string[] } { } describe('Keycloak Admin API 名册', () => { - it('分页拉全(100 + 5)、姓名拼 firstName lastName、缺名回退用户名、角色滤噪声、enabled 照抄', async () => { + it('分页拉全(100 + 5)、姓在前名在后、缺名回退用户名、角色滤噪声、enabled 照抄', async () => { const kc = fakeKeycloak(); const roster = createKeycloakRoster({ url: 'https://kc.test/', realm: 'corp', clientId: 'provisioning', secret: 's', fetch: kc.fetch }); const users = await roster.listUsers(); expect(users).toHaveLength(105); expect(users[0]).toEqual({ sub: 'sub-0', account: 'u0', name: 'u0', email: 'u0@x.test', roles: ['user'], enabled: true }); - expect(users[1]).toEqual({ sub: 'sub-1', account: 'u1', name: '名 1', email: 'u1@x.test', roles: ['admin', 'user'], enabled: true }); + // 姓在前:lastName「柴」+ firstName「浩」= 柴浩(中文直拼,不留空格) + expect(users[1]).toEqual({ sub: 'sub-1', account: 'u1', name: '柴浩', email: 'u1@x.test', roles: ['admin', 'user'], enabled: true }); expect(users[3]?.enabled).toBe(false); expect(kc.calls.filter((c) => c.includes('/users?')).length).toBe(2); }); diff --git a/apps/api/src/modules/org/__tests__/keycloak-token-verifier.test.ts b/apps/api/src/modules/org/__tests__/keycloak-token-verifier.test.ts index 85187f3..f1e6cd2 100644 --- a/apps/api/src/modules/org/__tests__/keycloak-token-verifier.test.ts +++ b/apps/api/src/modules/org/__tests__/keycloak-token-verifier.test.ts @@ -62,6 +62,24 @@ describe('Keycloak 验签', () => { expect(claims.realm_access?.roles).toContain('user'); }); + /* + * 验签这一层是**挑字段**拼 `VerifiedClaims` 的 —— 类型上加了字段而这儿忘了挑, + * 编译一声不吭,下游拿到的永远是 undefined。实测栽过一次: + * 姓名改「姓在前」之后每个请求还是把名字写回西式序,查了两台后端才发现漏在这儿。 + * 所以这条钉的是**整条链**:真 token 的形状进去,`identityOf` 认得出的形状出来。 + */ + it('given_name / family_name 要原样带出来 —— 姓在前的拼法全靠它俩', async () => { + const claims = await verifier.verify(await mint({ claims: { given_name: '浩', family_name: '柴' } })); + expect(claims.given_name).toBe('浩'); + expect(claims.family_name).toBe('柴'); + }); + + it('token 里没有那两个 claim → 键干脆不出现(不是 undefined)', async () => { + const claims = await verifier.verify(await mint()); + expect('given_name' in claims).toBe(false); + expect('family_name' in claims).toBe(false); + }); + it('过期 30 秒内(时钟容差)仍通过', async () => { await expect(verifier.verify(await mint({ exp: nowSec() - 10 }))).resolves.toBeDefined(); }); diff --git a/apps/api/src/modules/org/__tests__/org-service.test.ts b/apps/api/src/modules/org/__tests__/org-service.test.ts index 3124742..4be338a 100644 --- a/apps/api/src/modules/org/__tests__/org-service.test.ts +++ b/apps/api/src/modules/org/__tests__/org-service.test.ts @@ -243,6 +243,22 @@ describe('名册与岗位', () => { expect(await h.org.staffOfPosition('NONE')).toEqual([]); }); + it('listStaff 给每行标 kind —— 服务账号是身份不是人(员工管理那一屏据此不摆它)', async () => { + const h = harness(); + await h.org.ensureSeeded(); + await h.org.registerIdentity({ sub: 'p', account: 'chaihao', name: '柴浩', roles: ['admin'] }); + await h.org.registerIdentity({ + sub: 's', + account: 'service-account-task-dispatcher', + name: 'service-account-task-dispatcher', + roles: ['service'], + }); + const { staff } = await h.org.listStaff(); + const kinds = Object.fromEntries(staff.map((x) => [x.account, x.kind])); + expect(kinds['chaihao']).toBe('person'); + expect(kinds['service-account-task-dispatcher']).toBe('service'); + }); + it('syncRoster 未配置 → configured:false', async () => { const h = harness(); await h.org.ensureSeeded(); diff --git a/apps/api/src/modules/org/__tests__/roster-diff.test.ts b/apps/api/src/modules/org/__tests__/roster-diff.test.ts index 03efa13..f3f2a71 100644 --- a/apps/api/src/modules/org/__tests__/roster-diff.test.ts +++ b/apps/api/src/modules/org/__tests__/roster-diff.test.ts @@ -1,12 +1,11 @@ /** domain 测试零 mock(编码规范 §16) */ import { describe, expect, it } from 'vitest'; -import type { Staff } from '@contracts'; import { positionIdSchema, staffIdSchema } from '@contracts'; import { activeRolesAfter, diffRoster, identityChanged, sameRoles } from '../internal/domain/roster-diff.ts'; -import type { RosterUser } from '../port.ts'; +import type { RosterUser, StoredStaff } from '../port.ts'; const none = positionIdSchema.parse('none'); -const local = (sub: string, over: Partial = {}): Staff => ({ +const local = (sub: string, over: Partial = {}): StoredStaff => ({ id: staffIdSchema.parse(sub), account: sub, name: sub, diff --git a/apps/api/src/modules/org/contract.ts b/apps/api/src/modules/org/contract.ts index e17e299..09fc8c2 100644 --- a/apps/api/src/modules/org/contract.ts +++ b/apps/api/src/modules/org/contract.ts @@ -7,6 +7,10 @@ */ import type { InFlightWork, + OrgGroup, + OrgRole, + OrgUser, + OrgUserDetail, Position, PositionId, Principal, @@ -19,7 +23,27 @@ import type { WorkGroupBinding, } from '@contracts'; -export type { InFlightWork, Position, Principal, RoleAccess, RosterSyncOutcome, Staff, Surface, WorkGroup, WorkGroupBinding }; +export type { InFlightWork, OrgGroup, OrgRole, OrgUser, OrgUserDetail, Position, Principal, RoleAccess, RosterSyncOutcome, Staff, Surface, WorkGroup, WorkGroupBinding }; +/** + * 目录写面的两个入参 —— 住在 contract.ts 而不是 port.ts:它们是**服务出口的形状** + * (路由照它填),port 只是恰好也要用。反过来放会让 port ↔ contract 成环。 + */ +export type CreateDirectoryUser = { + readonly username: string; + readonly firstName?: string; + readonly lastName?: string; + readonly email?: string; + /** 临时口令;不给就建一个还登不进来的壳 */ + readonly password?: string; + /** 一并授的 realm 角色 */ + readonly roles?: readonly string[]; +}; + +export type DirectoryUserPatch = { + readonly firstName?: string; + readonly lastName?: string; + readonly email?: string; +}; /** 员工连同其岗位的读模型 —— 治理台员工管理列表用 */ export type StaffWithPosition = Staff & { readonly position: Position }; @@ -78,6 +102,32 @@ export class AccessLockoutError extends Error { } } +/** + * 目录整个不可用 —— 没配 provisioning 服务账号,或那个账号缺 realm-management 角色。 + * **重试无益**:路由译成 502 并原样带出 Keycloak 的话,界面提示找管理员补权限(手册 §14.6)。 + */ +export class DirectoryUnavailableError extends Error { + /** true = 配错了(补权限);false = 暂时够不着(可退避重试)—— 两句话,人的下一步不同 */ + readonly misconfigured: boolean; + constructor(message: string, misconfigured: boolean) { + super(message); + this.name = 'DirectoryUnavailableError'; + this.misconfigured = misconfigured; + } +} + +/** + * 写面对着一个服务账号 —— 拒。它是 Keycloak 给 client 自动建的身份(见 `staffKindSchema`), + * 在这三个页签里**根本不该出现**;能对着它下手,只能是有人手输了 sub。 + * 停掉它的后果不显眼却很重:试跑与 e2e 第二天全趴下。 + */ +export class ServiceAccountProtectedError extends Error { + constructor(readonly account: string) { + super(`「${account}」是服务账号,不是人 —— 成员目录不管它;要停它去 Keycloak 停对应的 client`); + this.name = 'ServiceAccountProtectedError'; + } +} + export type OrgService = { /* ── 身份(ADR-012)—— 中间件在 app 层,身份判定读这里 ─────────────── */ @@ -109,6 +159,31 @@ export type OrgService = { /** 能进该端的活跃员工 —— 治理台通知的收件人 */ staffWithEnd(surface: Surface): Promise; + /* ── 成员目录(Keycloak Admin API 的读写面;ADR-012) ────────────────── */ + + /** + * 目录**没配 provisioning 服务账号就整个不可用** —— 一律抛 `DirectoryUnavailableError` + * (路由译成 502 + 原话)。不给「读得了写不了」这种半开状态:那样人会以为是自己点错了。 + * 服务账号在这一层就剔干净;写面对着服务账号的 sub 一律抛 `ServiceAccountProtectedError`。 + */ + directoryUsers(query: { q?: string; includeDisabled?: boolean }): Promise<{ users: OrgUser[] }>; + directoryUser(sub: string): Promise; + createDirectoryUser(input: CreateDirectoryUser): Promise; + patchDirectoryUser(sub: string, patch: DirectoryUserPatch): Promise; + /** 停用顺手踢会话 —— 界面写了「已停用」,那枚 token 就不该还能用到过期 */ + setDirectoryUserEnabled(sub: string, enabled: boolean): Promise; + resetDirectoryUserPassword(sub: string, password: string): Promise; + deleteDirectoryUser(sub: string): Promise; + directoryRoles(): Promise<{ roles: OrgRole[] }>; + /** 授 / 收一个 realm 角色;收权顺手踢会话。抛 AccessLockoutError(409):收完没人进得了运营中心 */ + setDirectoryUserRole(sub: string, role: string, granted: boolean): Promise; + directoryGroups(): Promise<{ groups: OrgGroup[] }>; + directoryGroupMembers(groupId: string): Promise<{ members: OrgUser[] }>; + createDirectoryGroup(name: string, parentId?: string): Promise; + renameDirectoryGroup(groupId: string, name: string): Promise; + deleteDirectoryGroup(groupId: string): Promise; + setDirectoryUserGroup(sub: string, groupId: string, member: boolean): Promise; + /* ── 工作组(ADR-011)—— 起单时的岗位绑定,不是第五种策略 ────────────── */ /** 建组 / 改组;id 留空即新建 */ diff --git a/apps/api/src/modules/org/index.ts b/apps/api/src/modules/org/index.ts index f920fb0..c599f3e 100644 --- a/apps/api/src/modules/org/index.ts +++ b/apps/api/src/modules/org/index.ts @@ -2,9 +2,15 @@ * module: org —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。 */ export type { + CreateDirectoryUser, + DirectoryUserPatch, Identity, InFlightWork, + OrgGroup, + OrgRole, OrgService, + OrgUser, + OrgUserDetail, Position, Principal, RoleAccess, @@ -18,16 +24,22 @@ export type { export { AccessLockoutError, AccountDisabledError, + DirectoryUnavailableError, PositionNotFoundError, + ServiceAccountProtectedError, StaffNotFoundError, TokenRejectedError, } from './contract.ts'; export type { Clock, + DirectoryGroup, + DirectoryRole, + DirectoryUser, IdGenerator, IdentityPatch, InFlightWorkProbe, + KeycloakDirectory, PositionRepository, RoleAccessRepository, RosterSource, @@ -44,6 +56,15 @@ export { createOrgService } from './internal/usecase/org-service.ts'; export { identityOf, isNoiseRole, normalizeRoles } from './internal/domain/claims.ts'; export { assertNotLockedOut, endsOf, normalizeEnds } from './internal/domain/ends.ts'; +export { + displayNameOf, + filterOrgUsers, + isServiceAccount, + sortOrgUsers, + toOrgGroups, + toOrgRoles, + toOrgUser, +} from './internal/domain/directory.ts'; export { buildInFlightWork } from './internal/domain/in-flight.ts'; export { DEFAULT_ROLE_ACCESS, NONE_POSITION_CODE, POSITION_SEED, TEST_POSITION_CODE } from './internal/domain/seed-data.ts'; @@ -51,6 +72,9 @@ export { createKeycloakTokenVerifier, explainJoseError } from './internal/adapte export type { KeycloakVerifierOptions } from './internal/adapter/keycloak-token-verifier.ts'; export { createKeycloakRoster } from './internal/adapter/keycloak-admin-roster.ts'; export type { KeycloakRosterOptions } from './internal/adapter/keycloak-admin-roster.ts'; +export { createKeycloakDirectory } from './internal/adapter/keycloak-directory.ts'; +export { createAdminHttp, KeycloakMisconfiguredError, KeycloakRejectedError, KeycloakUnavailableError } from './internal/adapter/keycloak-admin-http.ts'; +export type { AdminHttp, KeycloakAdminOptions } from './internal/adapter/keycloak-admin-http.ts'; export { createPositionRepository, createRoleAccessRepository, diff --git a/apps/api/src/modules/org/internal/adapter/keycloak-admin-http.ts b/apps/api/src/modules/org/internal/adapter/keycloak-admin-http.ts new file mode 100644 index 0000000..8954e8a --- /dev/null +++ b/apps/api/src/modules/org/internal/adapter/keycloak-admin-http.ts @@ -0,0 +1,143 @@ +/** + * Keycloak Admin API 的 HTTP 底座(手册 §11 / §14;ADR-012)。 + * + * 名册同步与成员目录**共用同一枚管理 token** —— 从前只有名册那一条路, + * token 缓存写在它里面;目录接上来之后各缓存一份就是两条独立的续期时钟, + * 边界上谁先过期谁自己去换,白烧两次握手,还没人说得清当下在用哪一枚。 + * + * 三条路径规则照手册 §14.1:`{client-uuid}` 不是 clientId、创建返回 201 空体 + * (新 id 在 `Location` 末段)、列表默认 `max=100` 必须分页。 + * + * 错误分两种说 —— **重试有没有意义,决定了界面该说什么话**: + * `KeycloakMisconfiguredError`(401/403)= 服务账号缺角色或凭证不对,重试无益; + * `KeycloakUnavailableError`(网络 / 5xx)= 暂时够不着,过会儿再来。 + */ + +/** 服务账号缺 `realm-management` 角色,或凭证不对 —— 重试无益,去补权限 */ +export class KeycloakMisconfiguredError extends Error { + constructor(message: string) { + super(message); + this.name = 'KeycloakMisconfiguredError'; + } +} + +/** Keycloak 暂时够不着(网络 / 5xx)—— 可退避重试 */ +export class KeycloakUnavailableError extends Error { + constructor(message: string) { + super(message); + this.name = 'KeycloakUnavailableError'; + } +} + +/** Keycloak 明确拒绝了这次写(重名 / 约束不过)—— 原话带出去给人看 */ +export class KeycloakRejectedError extends Error { + readonly status: number; + constructor(status: number, message: string) { + super(message); + this.name = 'KeycloakRejectedError'; + this.status = status; + } +} + +export type KeycloakAdminOptions = { + readonly url: string; + readonly realm: string; + readonly clientId: string; + readonly secret: string; + /** 测试注入;缺省全局 fetch */ + readonly fetch?: typeof fetch; + /** 毫秒时刻;测试注入 */ + readonly now?: () => number; +}; + +export type AdminHttp = { + get(path: string): Promise; + /** 建资源:成功 201 空体,回 `Location` 末段(新 id);Keycloak 不给就回 undefined */ + post(path: string, body?: unknown): Promise; + put(path: string, body?: unknown): Promise; + /** `body` 可选是因为**撤 realm 角色是带 body 的 DELETE**(手册 §14.4),不是笔误 */ + delete(path: string, body?: unknown): Promise; +}; + +/** 列表接口默认 `max=100`(手册 §14.1 第三条),分页取全 */ +export const ADMIN_PAGE = 100; + +export function createAdminHttp(options: KeycloakAdminOptions): AdminHttp { + const fetchImpl = options.fetch ?? fetch; + const now = options.now ?? ((): number => Date.now()); + const base = options.url.replace(/\/+$/, ''); + const realmBase = `${base}/admin/realms/${options.realm}`; + let cached: { token: string; expiresAt: number } | undefined; + + async function call(path: string, init: RequestInit): Promise { + try { + return await fetchImpl(path, init); + } catch (error) { + throw new KeycloakUnavailableError(`够不着 Keycloak:${error instanceof Error ? error.message : String(error)}`); + } + } + + async function adminToken(): Promise { + if (cached !== undefined && now() < cached.expiresAt) return cached.token; + const resp = await call(`${base}/realms/master/protocol/openid-connect/token`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'client_credentials', + client_id: options.clientId, + client_secret: options.secret, + }), + }); + if (!resp.ok) { + const text = await resp.text(); + // 401/400 在这条路上一律是凭证不对(client_id / secret)—— 重试一百次也是同一句 + if (resp.status < 500) throw new KeycloakMisconfiguredError(`取管理 token 失败:${resp.status} ${text}`); + throw new KeycloakUnavailableError(`取管理 token 失败:${resp.status} ${text}`); + } + const data = (await resp.json()) as { access_token: string; expires_in: number }; + // 提前 60 秒过期,免得边界上拿着一枚刚好失效的 token 去调 + cached = { token: data.access_token, expiresAt: now() + (data.expires_in - 60) * 1000 }; + return cached.token; + } + + async function send(method: string, path: string, body?: unknown): Promise { + const token = await adminToken(); + const resp = await call(`${realmBase}${path}`, { + method, + headers: { + authorization: `Bearer ${token}`, + ...(body === undefined ? {} : { 'content-type': 'application/json' }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + if (resp.ok) return resp; + const text = await resp.text(); + const where = `${method} ${path}`; + if (resp.status === 401 || resp.status === 403) { + // 手册 §14.6:403 就是服务账号缺 realm-management 角色 —— 说清缺在哪一步 + throw new KeycloakMisconfiguredError( + `${where} 被拒(${resp.status})—— provisioning 服务账号缺 realm-management 角色(手册 §14.6);原话:${text}`, + ); + } + if (resp.status >= 500) throw new KeycloakUnavailableError(`${where} 失败(${resp.status}):${text}`); + throw new KeycloakRejectedError(resp.status, `${where} 被拒(${resp.status}):${text}`); + } + + return { + async get(path: string): Promise { + return (await (await send('GET', path)).json()) as T; + }, + async post(path: string, body?: unknown): Promise { + const resp = await send('POST', path, body); + // 手册 §14.1 第二条:新资源 id 在 Location 末段,响应体是空的 + const location = resp.headers.get('location'); + return location === null ? undefined : location.split('/').pop(); + }, + async put(path: string, body?: unknown): Promise { + await send('PUT', path, body); + }, + async delete(path: string, body?: unknown): Promise { + await send('DELETE', path, body); + }, + }; +} diff --git a/apps/api/src/modules/org/internal/adapter/keycloak-admin-roster.ts b/apps/api/src/modules/org/internal/adapter/keycloak-admin-roster.ts index bffb86b..c20de69 100644 --- a/apps/api/src/modules/org/internal/adapter/keycloak-admin-roster.ts +++ b/apps/api/src/modules/org/internal/adapter/keycloak-admin-roster.ts @@ -7,7 +7,7 @@ * 需要的 realm-management 角色只有 `view-users`(手册 14.6 最小权限)。 */ import type { RosterSource, RosterUser } from '../../port.ts'; -import { normalizeRoles } from '../domain/claims.ts'; +import { normalizeRoles, personName } from '../domain/claims.ts'; export type KeycloakRosterOptions = { readonly url: string; @@ -76,14 +76,15 @@ export function createKeycloakRoster(options: KeycloakRosterOptions): RosterSour const out: RosterUser[] = []; for (const u of users) { const roles = await adminGet(`/users/${encodeURIComponent(u.id)}/role-mappings/realm/composite`); - const name = [u.firstName, u.lastName] - .filter((s): s is string => s !== undefined && s.trim() !== '') - .join(' ') - .trim(); out.push({ sub: u.id, account: u.username, - name: name === '' ? u.username : name, + // 姓在前、名在后 —— 与 token 那条路同一把尺(`personName`),两处理解不许分叉 + name: personName({ + ...(u.lastName === undefined ? {} : { familyName: u.lastName }), + ...(u.firstName === undefined ? {} : { givenName: u.firstName }), + fallback: u.username, + }), // 与 token 那条路同一把尺 —— 归一自己再写一遍,两处理解一分叉, // `identityChanged` 的逐字比对就会天天判「变了」(或天天判「没变」) roles: normalizeRoles(roles.map((r) => r.name)), diff --git a/apps/api/src/modules/org/internal/adapter/keycloak-directory.ts b/apps/api/src/modules/org/internal/adapter/keycloak-directory.ts new file mode 100644 index 0000000..961983d --- /dev/null +++ b/apps/api/src/modules/org/internal/adapter/keycloak-directory.ts @@ -0,0 +1,262 @@ +/** + * 成员目录 —— Keycloak Admin API 的读写面(手册 §14;ADR-012)。 + * + * 与名册同步(`keycloak-admin-roster.ts`)共用 `createAdminHttp` 的那枚管理 token。 + * 这里**不做过滤、不做归并**:剔服务账号、拼 `ends`、合本地岗位都归 domain 与 usecase —— + * 适配器只把 Keycloak 的形状原样翻过来,翻错了一眼看得出。 + */ +import type { + AdminHttp, + KeycloakAdminOptions, +} from './keycloak-admin-http.ts'; +import { ADMIN_PAGE, createAdminHttp } from './keycloak-admin-http.ts'; +import type { CreateDirectoryUser, DirectoryUserPatch } from '../../contract.ts'; +import type { + DirectoryGroup, + DirectoryRole, + DirectoryUser, + KeycloakDirectory, +} from '../../port.ts'; +import { normalizeRoles } from '../domain/claims.ts'; + +type KcUser = { + readonly id: string; + readonly username: string; + readonly enabled: boolean; + readonly emailVerified?: boolean; + readonly firstName?: string; + readonly lastName?: string; + readonly email?: string; +}; +type KcRole = { readonly name: string; readonly description?: string; readonly composite?: boolean }; +type KcGroup = { + readonly id: string; + readonly name: string; + readonly path: string; + readonly subGroups?: readonly KcGroup[]; +}; + +/** 空串与 undefined 是同一件事:Keycloak 那边没填,别在契约里变成一个空格子 */ +function opt(value: string | undefined): { [k: string]: string } { + return value === undefined || value.trim() === '' ? {} : { v: value.trim() }; +} + +function toUser(u: KcUser, roles: readonly string[]): DirectoryUser { + const first = opt(u.firstName); + const last = opt(u.lastName); + const mail = opt(u.email); + return { + sub: u.id, + username: u.username, + enabled: u.enabled, + emailVerified: u.emailVerified === true, + roles: normalizeRoles([...roles]), + ...(first.v === undefined ? {} : { firstName: first.v }), + ...(last.v === undefined ? {} : { lastName: last.v }), + ...(mail.v === undefined ? {} : { email: mail.v }), + }; +} + +const enc = encodeURIComponent; + +/** + * 有限并发地跑一批 —— Keycloak 没有「一次拿回全部人的角色」的接口(手册 §14.4 逐人查), + * 串行跑 8 个人就是 8 趟远程往返(实测整页 37 秒)。并发上限压着, + * 免得一屏把管理端打成一次小型压测。 + */ +async function mapLimit(items: readonly T[], limit: number, run: (item: T) => Promise): Promise { + const out: R[] = new Array(items.length); + let cursor = 0; + const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { + for (;;) { + const i = cursor; + cursor += 1; + if (i >= items.length) return; + out[i] = await run(items[i]!); + } + }); + await Promise.all(workers); + return out; +} + +const ROLE_FETCH_CONCURRENCY = 8; + +export function createKeycloakDirectory(options: KeycloakAdminOptions, http?: AdminHttp): KeycloakDirectory { + const api = http ?? createAdminHttp(options); + + /** 实际生效的 realm 角色 —— 排查权限永远看 `/composite`(手册 §14.4) */ + async function rolesOf(sub: string): Promise { + const roles = await api.get(`/users/${enc(sub)}/role-mappings/realm/composite`); + return roles.map((r) => r.name); + } + + async function allUsers(): Promise { + const out: KcUser[] = []; + for (let first = 0; ; first += ADMIN_PAGE) { + const page = await api.get(`/users?first=${first}&max=${ADMIN_PAGE}&briefRepresentation=false`); + out.push(...page); + if (page.length < ADMIN_PAGE) break; + } + return out; + } + + /** 一行一个组;`subGroups` 递归摊平,顺手记住谁是谁的爹 */ + function flatten(nodes: readonly KcGroup[], parentId: string | undefined, depth: number, into: DirectoryGroup[]): void { + for (const g of nodes) { + into.push({ + id: g.id, + name: g.name, + path: g.path, + depth, + memberCount: 0, // 由 usecase 按组成员现数 —— Keycloak 的组表不带这个数 + ...(parentId === undefined ? {} : { parentId }), + }); + flatten(g.subGroups ?? [], g.id, depth + 1, into); + } + } + + /** 口令一律**临时**:本人首登必须改(手册 §14.2);我们不存、不回显、不留痕 */ + async function resetPassword(sub: string, password: string): Promise { + await api.put(`/users/${enc(sub)}/reset-password`, { type: 'password', value: password, temporary: true }); + } + + async function setRole(sub: string, role: string, granted: boolean): Promise { + // 授/撤的 body 是**角色对象数组**(手册 §14.4)—— 光给名字 Keycloak 不认 + const all = await api.get>('/roles'); + const target = all.find((r) => r.name === role); + if (target === undefined) throw new Error(`realm 里没有角色「${role}」`); + const body = [{ id: target.id, name: target.name }]; + const path = `/users/${enc(sub)}/role-mappings/realm`; + if (granted) await api.post(path, body); + else await api.delete(path, body); + } + + async function rawGroupMembers(groupId: string): Promise { + const members: KcUser[] = []; + for (let first = 0; ; first += ADMIN_PAGE) { + const page = await api.get(`/groups/${enc(groupId)}/members?first=${first}&max=${ADMIN_PAGE}`); + members.push(...page); + if (page.length < ADMIN_PAGE) break; + } + return members; + } + + async function groupById(groupId: string): Promise { + const g = await api.get(`/groups/${enc(groupId)}`); + const segments = g.path.split('/').filter((s) => s !== ''); + return { id: g.id, name: g.name, path: g.path, depth: segments.length - 1, memberCount: 0 }; + } + + return { + async listUsers() { + const users = await allUsers(); + return mapLimit(users, ROLE_FETCH_CONCURRENCY, async (u) => toUser(u, await rolesOf(u.id))); + }, + + async getUser(sub) { + const u = await api.get(`/users/${enc(sub)}`); + const [roles, groups] = await Promise.all([ + rolesOf(sub), + api.get>(`/users/${enc(sub)}/groups`), + ]); + return { ...toUser(u, roles), groups: groups.map((g) => ({ id: g.id, path: g.path })) }; + }, + + async createUser(input: CreateDirectoryUser) { + const first = opt(input.firstName); + const last = opt(input.lastName); + const mail = opt(input.email); + const sub = await api.post('/users', { + username: input.username, + enabled: true, + ...(first.v === undefined ? {} : { firstName: first.v }), + ...(last.v === undefined ? {} : { lastName: last.v }), + ...(mail.v === undefined ? {} : { email: mail.v }), + }); + if (sub === undefined) throw new Error('Keycloak 建用户成功但没回 Location —— 拿不到新 id,别往下走'); + if (input.password !== undefined) await resetPassword(sub, input.password); + for (const role of input.roles ?? []) await setRole(sub, role, true); + return sub; + }, + + async updateUser(sub, patch: DirectoryUserPatch) { + // 手册 §14.2:PUT 是全量覆盖 —— 先 GET 再改再 PUT,不然没带的字段会被清空 + const current = await api.get(`/users/${enc(sub)}`); + const first = opt(patch.firstName ?? current.firstName); + const last = opt(patch.lastName ?? current.lastName); + const mail = opt(patch.email ?? current.email); + await api.put(`/users/${enc(sub)}`, { + ...current, + firstName: first.v ?? '', + lastName: last.v ?? '', + email: mail.v ?? '', + }); + }, + + async setEnabled(sub, enabled) { + const current = await api.get(`/users/${enc(sub)}`); + await api.put(`/users/${enc(sub)}`, { ...current, enabled }); + }, + + resetPassword, + + async deleteUser(sub) { + await api.delete(`/users/${enc(sub)}`); + }, + + async logout(sub) { + await api.post(`/users/${enc(sub)}/logout`); + }, + + async listRoles(): Promise { + const roles = await api.get('/roles'); + return roles.map((r) => ({ + name: r.name, + composite: r.composite === true, + ...(r.description === undefined || r.description.trim() === '' ? {} : { description: r.description.trim() }), + })); + }, + + setRole, + + async listGroups() { + const tree = await api.get('/groups?briefRepresentation=false'); + const flat: DirectoryGroup[] = []; + flatten(tree, undefined, 0, flat); + return flat.sort((a, b) => a.path.localeCompare(b.path)); + }, + + async groupMembers(groupId) { + const members = await rawGroupMembers(groupId); + return mapLimit(members, ROLE_FETCH_CONCURRENCY, async (u) => toUser(u, await rolesOf(u.id))); + }, + + async groupMemberUsernames(groupId) { + return (await rawGroupMembers(groupId)).map((u) => u.username); + }, + + + async createGroup(name, parentId) { + const path = parentId === undefined ? '/groups' : `/groups/${enc(parentId)}/children`; + const id = await api.post(path, { name }); + if (id === undefined) throw new Error('Keycloak 建组成功但没回 Location —— 拿不到新 id,别往下走'); + return groupById(id); + }, + + async renameGroup(groupId, name) { + const current = await api.get(`/groups/${enc(groupId)}`); + await api.put(`/groups/${enc(groupId)}`, { ...current, name }); + return groupById(groupId); + }, + + async deleteGroup(groupId) { + await api.delete(`/groups/${enc(groupId)}`); + }, + + async setUserGroup(sub, groupId, member) { + const path = `/users/${enc(sub)}/groups/${enc(groupId)}`; + if (member) await api.put(path); + else await api.delete(path); + }, + }; +} diff --git a/apps/api/src/modules/org/internal/adapter/keycloak-token-verifier.ts b/apps/api/src/modules/org/internal/adapter/keycloak-token-verifier.ts index 06c6795..4e32b32 100644 --- a/apps/api/src/modules/org/internal/adapter/keycloak-token-verifier.ts +++ b/apps/api/src/modules/org/internal/adapter/keycloak-token-verifier.ts @@ -24,6 +24,9 @@ const claimsSchema = z.object({ sub: z.string().min(1), preferred_username: z.string().min(1), name: z.string().optional(), + /* 姓与名分开的两个 claim —— 我们按「姓在前」自己拼(`personName`),不吃拼好的 `name` */ + given_name: z.string().optional(), + family_name: z.string().optional(), email: z.string().optional(), realm_access: z.object({ roles: z.array(z.string()).optional() }).optional(), }); @@ -39,6 +42,8 @@ function toVerifiedClaims(claims: z.infer): VerifiedClaims sub: claims.sub, preferred_username: claims.preferred_username, ...(claims.name === undefined ? {} : { name: claims.name }), + ...(claims.given_name === undefined ? {} : { given_name: claims.given_name }), + ...(claims.family_name === undefined ? {} : { family_name: claims.family_name }), ...(claims.email === undefined ? {} : { email: claims.email }), ...(claims.realm_access === undefined ? {} diff --git a/apps/api/src/modules/org/internal/domain/claims.ts b/apps/api/src/modules/org/internal/domain/claims.ts index 0015c65..617e432 100644 --- a/apps/api/src/modules/org/internal/domain/claims.ts +++ b/apps/api/src/modules/org/internal/domain/claims.ts @@ -5,11 +5,51 @@ * Keycloak 给每个用户自动挂的三个「家具角色」不是业务角色,这里滤掉: * `default-roles-` / `offline_access` / `uma_authorization`。 */ +import type { StaffKind } from '@contracts'; import type { Identity } from '../../contract.ts'; import type { VerifiedClaims } from '../../port.ts'; const NOISE_ROLES: ReadonlySet = new Set(['offline_access', 'uma_authorization']); +/** + * Keycloak 给服务账号用户的固定命名 —— **它自己定的**,不是我们的约定: + * 开了 `serviceAccountsEnabled` 的 client 一律配一个 `service-account-`。 + * 按名字认而不按角色认,是因为角色是每个 realm 自己配的(这边叫 `service`,别处未必), + * 而这个前缀由 Keycloak 保证。 + */ +const SERVICE_ACCOUNT_PREFIX = 'service-account-'; + +/** + * 姓在前、名在后(2026-08-26 用户定)。 + * + * Keycloak 自己拼的 `name` claim 是 `firstName lastName` —— 西式顺序,中文名读出来是「浩 柴」。 + * 所以**不吃那个 claim**,拿分开的 `family_name` / `given_name` 自己拼; + * 名册同步那条路同理,拿 `lastName` / `firstName`。 + * + * 中间那个空格按**字形**决定,不按语言设置:两边都是汉字就直拼(「柴浩」), + * 否则留一个空格(「Admin Amy」)—— 中文名里塞个空格,读的人会以为那是两个词。 + */ +const CJK = /^[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\u3040-\u30ff]+$/u; + +export function personName(input: { + readonly familyName?: string; + readonly givenName?: string; + /** 两边都空时回落到这个(通常是用户名)—— 名字这一格永远不许是空的 */ + readonly fallback: string; +}): string { + const family = (input.familyName ?? '').trim(); + const given = (input.givenName ?? '').trim(); + if (family === '' && given === '') return input.fallback; + if (family === '') return given; + if (given === '') return family; + return CJK.test(family) && CJK.test(given) ? `${family}${given}` : `${family} ${given}`; +} + +/** 这个账号是人还是服务账号 —— 员工管理那一屏据此不摆服务账号(它是身份,不是人) */ +export function staffKindOf(account: string): StaffKind { + return account.trim().toLowerCase().startsWith(SERVICE_ACCOUNT_PREFIX) ? 'service' : 'person'; +} + export function isNoiseRole(role: string): boolean { return NOISE_ROLES.has(role) || role.startsWith('default-roles-'); } @@ -21,7 +61,21 @@ export function normalizeRoles(roles: readonly string[]): string[] { export function identityOf(claims: VerifiedClaims): Identity { const account = claims.preferred_username; - const name = claims.name === undefined || claims.name.trim() === '' ? account : claims.name.trim(); + /* + * 先按姓在前自己拼;`family_name` / `given_name` 都没有的 realm(没配 profile scope) + * 才退回 Keycloak 拼好的 `name` —— 拆不开的东西没法重排,如实用它。 + */ + const composed = personName({ + ...(claims.family_name === undefined ? {} : { familyName: claims.family_name }), + ...(claims.given_name === undefined ? {} : { givenName: claims.given_name }), + fallback: '', + }); + const name = + composed !== '' + ? composed + : claims.name === undefined || claims.name.trim() === '' + ? account + : claims.name.trim(); const email = claims.email === undefined || claims.email.trim() === '' ? undefined : claims.email.trim(); return { sub: claims.sub, diff --git a/apps/api/src/modules/org/internal/domain/directory.ts b/apps/api/src/modules/org/internal/domain/directory.ts new file mode 100644 index 0000000..9908a12 --- /dev/null +++ b/apps/api/src/modules/org/internal/domain/directory.ts @@ -0,0 +1,121 @@ +/** + * 成员目录的归一(纯函数,零 IO;ADR-012)。 + * + * Keycloak 那一份(身份 · 角色 · 组)与本地那一份(岗位 · 几时来过)在这里合成一行 —— + * **两份事实各有各的主人**:改身份去 Keycloak,改岗位在本地;这段只负责摆在一起给人看。 + * + * 服务账号在这一层就剔掉:它是真身份、跑得了单,但**不是人**(见 `staffKindSchema`), + * 而这三个页签管的是人。剔在 domain 而不是各路由自己记得滤 —— 漏一处就是一处泄漏, + * 而泄漏的后果是有人在界面上把 e2e 账号停用了,试跑第二天全趴下。 + */ +import type { OrgGroup, OrgRole, OrgUser, RoleAccess } from '@contracts'; +import type { DirectoryGroup, DirectoryRole, DirectoryUser, StoredStaff } from '../../port.ts'; +import { personName, staffKindOf } from './claims.ts'; +import { endsOf } from './ends.ts'; + +/** 本地名册里跟这个人有关的那两列 —— 目录只读它们,不写 */ +export type LocalMark = { readonly lastSeenAt?: Date; readonly positionCode?: string }; + +export function isServiceAccount(username: string): boolean { + return staffKindOf(username) === 'service'; +} + +/** 姓在前、名在后;皆空回落 username —— 名字这一格不会是空的。拼法归 `personName` 一处 */ +export function displayNameOf(user: Pick): string { + return personName({ + ...(user.lastName === undefined ? {} : { familyName: user.lastName }), + ...(user.firstName === undefined ? {} : { givenName: user.firstName }), + fallback: user.username, + }); +} + +export function toOrgUser(user: DirectoryUser, table: readonly RoleAccess[], local?: LocalMark): OrgUser { + return { + sub: user.sub, + username: user.username, + displayName: displayNameOf(user), + enabled: user.enabled, + roles: [...user.roles], + ends: endsOf(user.roles, table), + ...(user.email === undefined ? {} : { email: user.email }), + ...(local?.lastSeenAt === undefined ? {} : { lastSeenAt: local.lastSeenAt.toISOString() }), + ...(local?.positionCode === undefined ? {} : { positionCode: local.positionCode }), + }; +} + +/** + * 排序:来过的在前(倒序),没来过的在后(按用户名升序)。 + * 「没有 `lastSeenAt`」说的是**还没拿这枚身份访问过本系统**,不等于「从未登录 Keycloak」—— + * 名册同步照过来的人天生就是这一档。 + */ +export function sortOrgUsers(users: readonly OrgUser[]): OrgUser[] { + return [...users].sort((a, b) => { + if (a.lastSeenAt !== undefined && b.lastSeenAt !== undefined) { + return a.lastSeenAt === b.lastSeenAt ? a.username.localeCompare(b.username) : b.lastSeenAt.localeCompare(a.lastSeenAt); + } + if (a.lastSeenAt !== undefined) return -1; + if (b.lastSeenAt !== undefined) return 1; + return a.username.localeCompare(b.username); + }); +} + +/** + * 检索:用户名 / 姓名 / 邮箱任一含关键词(不区分大小写)。 + * 在**我们这一侧**过滤而不是转给 Keycloak 的 `search=` —— 那个是前缀式, + * 「按姓名搜中间那两个字」搜不出来,而人会以为这个人不在。realm 不大,全量在手上。 + */ +export function filterOrgUsers(users: readonly OrgUser[], q: string, includeDisabled: boolean): OrgUser[] { + const needle = q.trim().toLowerCase(); + return users.filter((u) => { + if (!includeDisabled && !u.enabled) return false; + if (needle === '') return true; + return [u.username, u.displayName, u.email ?? ''].some((f) => f.toLowerCase().includes(needle)); + }); +} + +/** + * 每个角色配上「能进哪几个端」与「几个人持有」—— 前者本地说了算,后者 Keycloak 说了算。 + * `holders` 由调用方按角色现数后传进来(每个角色一趟),不在这儿从全量用户里筛 —— + * 那样等于逼调用方先把每个人的角色都拉一遍。 + */ +export function toOrgRoles( + roles: readonly DirectoryRole[], + table: readonly RoleAccess[], + holders: readonly number[], +): OrgRole[] { + return roles.map((r, i) => ({ + name: r.name, + composite: r.composite, + ends: endsOf([r.name], table), + holders: holders[i] ?? 0, + ...(r.description === undefined ? {} : { description: r.description }), + })); +} + +/** 组树平表配上直属成员数(已剔服务账号)—— Keycloak 的组表本身不带这个数 */ +export function toOrgGroups(groups: readonly DirectoryGroup[], counts: ReadonlyMap): OrgGroup[] { + return groups.map((g) => ({ + id: g.id, + name: g.name, + path: g.path, + depth: g.depth, + memberCount: counts.get(g.id) ?? 0, + ...(g.parentId === undefined ? {} : { parentId: g.parentId }), + })); +} + +/** 本地名册 → 按 sub 取那两列 */ +export function localMarks( + staff: ReadonlyArray, + positionCodeOf: (positionId: string) => string | undefined, +): Map { + const out = new Map(); + for (const s of staff) { + const code = positionCodeOf(s.positionId); + out.set(s.id, { + ...(s.lastSeenAt === undefined ? {} : { lastSeenAt: s.lastSeenAt }), + ...(code === undefined ? {} : { positionCode: code }), + }); + } + return out; +} diff --git a/apps/api/src/modules/org/internal/domain/roster-diff.ts b/apps/api/src/modules/org/internal/domain/roster-diff.ts index d7d55bc..b4c0841 100644 --- a/apps/api/src/modules/org/internal/domain/roster-diff.ts +++ b/apps/api/src/modules/org/internal/domain/roster-diff.ts @@ -10,7 +10,7 @@ */ import type { Staff, StaffId } from '@contracts'; import type { Identity } from '../../contract.ts'; -import type { IdentityPatch, RosterUser } from '../../port.ts'; +import type { IdentityPatch, RosterUser, StoredStaff } from '../../port.ts'; import { isTestAccount } from './test-account.ts'; export type RosterDiff = { @@ -27,7 +27,7 @@ export function sameRoles(a: readonly string[], b: readonly string[]): boolean { } /** 账号 / 姓名 / 邮箱 / 角色任一不同即「变了」;state 不在此列(它归同步) */ -export function identityChanged(staff: Staff, identity: Identity): boolean { +export function identityChanged(staff: StoredStaff, identity: Identity): boolean { return ( staff.account !== identity.account || staff.name !== identity.name || @@ -51,10 +51,10 @@ export function patchOf(identity: Identity): IdentityPatch { */ export function diffRoster( remote: readonly RosterUser[], - local: readonly Staff[], + local: readonly StoredStaff[], testAccounts: readonly string[], ): RosterDiff { - const bySub = new Map(local.map((s) => [s.id, s])); + const bySub = new Map(local.map((s) => [s.id, s])); const diff: RosterDiff = { insert: [], update: [], setState: [] }; const seen = new Set(); for (const user of remote) { @@ -90,7 +90,7 @@ export function diffRoster( * 一次同步能停很多人;要是把最后一个能进运营中心的人也停了,那张表与同步按钮就都没人点得动了, * 只剩去库里手改 `state`。所以要**在落 setState 之前**先按差异算一遍结果。 */ -export function activeRolesAfter(local: readonly Staff[], diff: RosterDiff): string[][] { +export function activeRolesAfter(local: readonly StoredStaff[], diff: RosterDiff): string[][] { const state = new Map(local.map((s) => [s.id, s.state])); const roles = new Map(local.map((s) => [s.id, s.roles])); for (const u of diff.update) roles.set(u.id, u.patch.roles); diff --git a/apps/api/src/modules/org/internal/usecase/org-service.ts b/apps/api/src/modules/org/internal/usecase/org-service.ts index 248b531..8f13cb0 100644 --- a/apps/api/src/modules/org/internal/usecase/org-service.ts +++ b/apps/api/src/modules/org/internal/usecase/org-service.ts @@ -6,15 +6,30 @@ * `state` 由名册同步说了算,JIT 不碰它(插入时 active):本地 disabled 即使 token 有效也拒 —— * 撤权在同步之后即时生效;Keycloak 恢复了就再同步一次。 */ -import type { Position, Principal, RoleAccess, Staff } from '@contracts'; +import type { OrgGroup, OrgRole, OrgUser, OrgUserDetail, Position, Principal, RoleAccess, Staff } from '@contracts'; import { positionIdSchema, staffIdSchema } from '@contracts'; -import type { Identity, OrgService, RosterSyncOutcome, StaffWithPosition } from '../../contract.ts'; -import { AccountDisabledError, PositionNotFoundError, StaffNotFoundError } from '../../contract.ts'; +import type { + CreateDirectoryUser, + DirectoryUserPatch, + Identity, + OrgService, + RosterSyncOutcome, + StaffWithPosition, +} from '../../contract.ts'; +import { + AccountDisabledError, + DirectoryUnavailableError, + PositionNotFoundError, + ServiceAccountProtectedError, + StaffNotFoundError, +} from '../../contract.ts'; import type { Clock, + DirectoryUser, IdGenerator, InFlightWorkProbe, PositionRepository, + KeycloakDirectory, RoleAccessRepository, RosterSource, StaffRecord, @@ -22,7 +37,17 @@ import type { TokenVerifier, WorkGroupRepository, } from '../../port.ts'; -import { identityOf } from '../domain/claims.ts'; +import { identityOf, isNoiseRole, staffKindOf } from '../domain/claims.ts'; +import type { LocalMark } from '../domain/directory.ts'; +import { + filterOrgUsers, + isServiceAccount, + localMarks, + sortOrgUsers, + toOrgGroups, + toOrgRoles, + toOrgUser, +} from '../domain/directory.ts'; import { assertNotLockedOut, endsOf, normalizeEnds } from '../domain/ends.ts'; import { buildInFlightWork } from '../domain/in-flight.ts'; import { activeRolesAfter, diffRoster, identityChanged, patchOf } from '../domain/roster-diff.ts'; @@ -33,6 +58,8 @@ export type OrgServiceDeps = { readonly verifier: TokenVerifier; /** 未配 provisioning 服务账号时缺省 —— 同步按钮如实回「未配置」 */ readonly roster?: RosterSource; + /** 同上;缺省时成员目录整个不可用(抛 DirectoryUnavailableError → 502) */ + readonly directory?: KeycloakDirectory; readonly positions: PositionRepository; readonly staff: StaffRepository; readonly roleAccess: RoleAccessRepository; @@ -47,10 +74,19 @@ export type OrgServiceDeps = { /** `last_seen_at` 多久之内不重写 —— 每个请求一次写太贵 */ const LAST_SEEN_REWRITE_MS = 60_000; +/** + * 成员目录读缓存的寿命。Keycloak 没有「一次拿回全部人的生效角色」的接口(手册 §14.4 逐人查), + * 一屏就是十几趟远程往返 —— 实测 37 秒。缓存之后: + * **本进程写完立刻整体清空** → 写后马上重拉一定看得到新结果,前端不必延时重试; + * 唯一的滞后是**别的**管理员在另一个后端实例上写,这边最多滞后这么久。 + */ +const DIRECTORY_TTL_MS = 60_000; + /** 契约里的 Staff 没有那两枚时刻 —— 出模块前剥掉 */ function toStaff(record: StaffRecord): Staff { const { lastSeenAt: _seen, syncedAt: _synced, ...staff } = record; - return staff; + // `kind` 由账号名现判,不落库 —— 它是 Keycloak 命名的事实,不是我们存的一列状态 + return { ...staff, kind: staffKindOf(staff.account) }; } export function createOrgService(deps: OrgServiceDeps): OrgService { @@ -147,6 +183,117 @@ export function createOrgService(deps: OrgServiceDeps): OrgService { return toStaff(fresh ?? created); } + /* ── 成员目录的几个共用件 ─────────────────────────────────────────── */ + + /** 只缓存**贵的那两趟**(全量成员 / 组树);详情与成员单查照旧现取 */ + let usersCache: { at: number; value: OrgUser[] } | undefined; + let groupsCache: { at: number; value: OrgGroup[] } | undefined; + + /** + * 写完清缓存 —— **按这次写真正碰到的东西清**,不是一律全清。 + * 全清的代价看得见:删一个组之后,下一次读要把「全量成员的角色」那趟 N+1 重付一遍, + * 屏上那棵树因此多钉着已经没了的组好几秒(实测删 4 秒 + 重取 5.5 秒)。 + * + * 'users' 改的是人本身(资料 / 口令)—— 组的成员数没变 + * 'groups' 改的是组(建 / 改名 / 删)—— 人的角色与端没变 + * 'all' 两边都动了(删人 = 人没了 + 他所在的组少一个人;进出组同理) + */ + function bustDirectoryCache(scope: 'users' | 'groups' | 'all'): void { + if (scope !== 'groups') usersCache = undefined; + if (scope !== 'users') groupsCache = undefined; + } + + function fresh(entry: { at: number; value: T } | undefined): T | undefined { + if (entry === undefined) return undefined; + return deps.clock.now().getTime() - entry.at < DIRECTORY_TTL_MS ? entry.value : undefined; + } + + function needDirectory(): KeycloakDirectory { + if (deps.directory === undefined) { + throw new DirectoryUnavailableError( + '未配置 KEYCLOAK_PROVISIONING_CLIENT_ID / KEYCLOAK_PROVISIONING_CLIENT_SECRET —— 成员与组织的真源是 Keycloak,读写都要经它的 Admin API', + true, + ); + } + return deps.directory; + } + + /** + * 把适配器抛的两类 Keycloak 故障翻成模块自己的错误 —— **重试有没有意义**是分界: + * 配错了(缺角色 / 凭证不对)重试一百次也是同一句;暂时够不着才值得过会儿再来。 + * 别的错(重名、约束不过)原样往上抛,路由译成 4xx 并带原话。 + */ + async function guardKeycloak(run: () => Promise): Promise { + try { + return await run(); + } catch (error) { + if (error instanceof Error && error.name === 'KeycloakMisconfiguredError') { + throw new DirectoryUnavailableError(error.message, true); + } + if (error instanceof Error && error.name === 'KeycloakUnavailableError') { + throw new DirectoryUnavailableError(error.message, false); + } + throw error; + } + } + + /** 本地名册那两列,按 sub 索引 —— 目录只读它们 */ + async function localMarkMap(): Promise> { + const [rows, positions] = await Promise.all([deps.staff.list(), deps.positions.list()]); + const codeOf = new Map(positions.map((p) => [p.id as string, p.code])); + return localMarks(rows, (id) => codeOf.get(id)); + } + + /** 目录全量(已剔服务账号、已配好端与本地两列、已排序)—— 检索在这之后做 */ + async function allDirectoryUsers(): Promise { + const dir = needDirectory(); + const hit = fresh(usersCache); + if (hit !== undefined) return hit; + const [raw, table, marks] = await Promise.all([ + guardKeycloak(() => dir.listUsers()), + deps.roleAccess.list(), + localMarkMap(), + ]); + const people = raw.filter((u) => !isServiceAccount(u.username)); + const value = sortOrgUsers(people.map((u) => toOrgUser(u, table, marks.get(u.sub)))); + usersCache = { at: deps.clock.now().getTime(), value }; + return value; + } + + /** + * 一次穿透写 —— 跑完**一律清缓存**。 + * 包起来而不是各写面自己记得调:漏一处就是一处「写完看不到」, + * 而那种漏不报错,只是画面比事实旧(与治理台写路由统一广播同一个道理)。 + */ + async function writeThrough(scope: 'users' | 'groups' | 'all', run: () => Promise): Promise { + try { + return await guardKeycloak(run); + } finally { + bustDirectoryCache(scope); + } + } + + /** 一次写之后回那一行的当下样子 —— 界面照它重画,不必自己拼 */ + async function oneUser(sub: string): Promise { + const dir = needDirectory(); + const [raw, table, marks] = await Promise.all([ + guardKeycloak(() => dir.getUser(sub)), + deps.roleAccess.list(), + localMarkMap(), + ]); + return toOrgUser(raw, table, marks.get(sub)); + } + + function assertPerson(user: Pick): void { + if (isServiceAccount(user.username)) throw new ServiceAccountProtectedError(user.username); + } + + /** 写面开工之前先问一句「这个 sub 是人吗」—— 服务账号一律不许动 */ + async function assertPersonBySub(sub: string): Promise { + const dir = needDirectory(); + assertPerson(await guardKeycloak(() => dir.getUser(sub))); + } + const service: OrgService = { async authenticate(token) { const claims = await deps.verifier.verify(token); @@ -308,6 +455,169 @@ export function createOrgService(deps: OrgServiceDeps): OrgService { return staff.filter((s) => s.state === 'active' && endsOf(s.roles, table).includes(surface)); }, + /* ── 成员目录(Keycloak Admin API 的读写面;ADR-012) ──────────────── */ + + async directoryUsers(query) { + const users = await allDirectoryUsers(); + const q = query.q ?? ''; + return { users: filterOrgUsers(users, q, query.includeDisabled === true) }; + }, + + async directoryUser(sub): Promise { + const dir = needDirectory(); + const raw = await guardKeycloak(() => dir.getUser(sub)); + assertPerson(raw); + const [table, marks] = await Promise.all([deps.roleAccess.list(), localMarkMap()]); + return { + ...toOrgUser(raw, table, marks.get(raw.sub)), + emailVerified: raw.emailVerified, + groups: raw.groups.map((g) => ({ ...g })), + ...(raw.firstName === undefined ? {} : { firstName: raw.firstName }), + ...(raw.lastName === undefined ? {} : { lastName: raw.lastName }), + }; + }, + + async createDirectoryUser(input: CreateDirectoryUser) { + const dir = needDirectory(); + if (isServiceAccount(input.username)) throw new ServiceAccountProtectedError(input.username); + const sub = await writeThrough('all', () => dir.createUser(input)); + return oneUser(sub); + }, + + async patchDirectoryUser(sub, patch: DirectoryUserPatch) { + const dir = needDirectory(); + await assertPersonBySub(sub); + await writeThrough('users', () => dir.updateUser(sub, patch)); + return oneUser(sub); + }, + + async setDirectoryUserEnabled(sub, enabled) { + const dir = needDirectory(); + await assertPersonBySub(sub); + await writeThrough('users', () => dir.setEnabled(sub, enabled)); + /* + * 停用之后**踢会话** —— 界面上已经写了「已停用」,而他手上那枚 token 到期前照样通行。 + * 这是「界面不撒谎」在鉴权上的样子:说停了就得真的进不来。 + */ + if (!enabled) await writeThrough('users', () => dir.logout(sub)); + // 本地那一行跟手:两屏说的是同一件事(名册同步也会做,但那要等人去按) + const local = await deps.staff.findById(staffIdSchema.parse(sub)); + if (local !== undefined) await deps.staff.setState(local.id, enabled ? 'active' : 'disabled', deps.clock.now()); + return oneUser(sub); + }, + + async resetDirectoryUserPassword(sub, password) { + const dir = needDirectory(); + await assertPersonBySub(sub); + await writeThrough('users', () => dir.resetPassword(sub, password)); + }, + + async deleteDirectoryUser(sub) { + const dir = needDirectory(); + await assertPersonBySub(sub); + await writeThrough('all', () => dir.deleteUser(sub)); + /* + * 本地那一行**停用,不删** —— 留痕、在途任务、台账的 `by` 都还指着这个 sub。 + * 删了那些地方就成了悬空的 id,而历史是只增不改的。 + */ + const local = await deps.staff.findById(staffIdSchema.parse(sub)); + if (local !== undefined) await deps.staff.setState(local.id, 'disabled', deps.clock.now()); + }, + + async directoryRoles(): Promise<{ roles: OrgRole[] }> { + const dir = needDirectory(); + const [roles, table, users] = await Promise.all([ + guardKeycloak(() => dir.listRoles()), + deps.roleAccess.list(), + allDirectoryUsers(), + ]); + // 家具角色不摆:它们是 Keycloak 给每个用户自动挂的,授收都没有意义(见 isNoiseRole) + const real = roles.filter((r) => !isNoiseRole(r.name)); + /* + * 持有数从**生效角色**里数(每人的 `/composite`),不用 `GET /roles/{name}/users` —— + * 那个只回**直接授予**的:`user` 是经 `default-roles-corp` 继承来的, + * 那条路会报「持有 0 人」而实际人人都有。快了却是错的,不要。 + * 速度靠 `allDirectoryUsers` 的 60 秒缓存拿。 + */ + const counts = real.map((r) => users.filter((u) => u.roles.includes(r.name)).length); + return { roles: toOrgRoles(real, table, counts) }; + }, + + async setDirectoryUserRole(sub, role, granted) { + const dir = needDirectory(); + await assertPersonBySub(sub); + /* + * 收权之前先算一遍「收完还有谁进得了运营中心」—— 与改「角色→端」那条同一道闸, + * 理由也同一个:这两条路都能把最后一个管理员关在门外,而**恢复动作本身要 ops**。 + */ + if (!granted) { + const [table, users] = await Promise.all([deps.roleAccess.list(), allDirectoryUsers()]); + const after = users + .filter((u) => u.enabled) + .map((u) => (u.sub === sub ? u.roles.filter((r) => r !== role) : u.roles)); + assertNotLockedOut( + table, + after, + `收掉「${role}」之后就没有人能进运营中心了 —— 这扇门会锁死(恢复动作本身要运营中心),拒绝`, + ); + } + await writeThrough('users', () => dir.setRole(sub, role, granted)); + // 收权立刻踢会话:否则他手上那枚 token 还带着旧角色,收权要等到过期才生效 + if (!granted) await writeThrough('users', () => dir.logout(sub)); + return oneUser(sub); + }, + + async directoryGroups(): Promise<{ groups: OrgGroup[] }> { + const dir = needDirectory(); + const hit = fresh(groupsCache); + if (hit !== undefined) return { groups: hit }; + const groups = await guardKeycloak(() => dir.listGroups()); + // 数数就只数数 —— 别为了一个数字把每个成员的角色都拉一遍(N+1 的第二处) + const pairs = await Promise.all( + groups.map(async (g) => { + const names = await guardKeycloak(() => dir.groupMemberUsernames(g.id)); + return [g.id, names.filter((n) => !isServiceAccount(n)).length] as const; + }), + ); + const value = toOrgGroups(groups, new Map(pairs)); + groupsCache = { at: deps.clock.now().getTime(), value }; + return { groups: value }; + }, + + async directoryGroupMembers(groupId) { + const dir = needDirectory(); + const [raw, table, marks] = await Promise.all([ + guardKeycloak(() => dir.groupMembers(groupId)), + deps.roleAccess.list(), + localMarkMap(), + ]); + const members = raw.filter((u) => !isServiceAccount(u.username)).map((u) => toOrgUser(u, table, marks.get(u.sub))); + return { members: sortOrgUsers(members) }; + }, + + async createDirectoryGroup(name, parentId) { + const dir = needDirectory(); + const g = await writeThrough('groups', () => dir.createGroup(name, parentId)); + return toOrgGroups([g], new Map())[0]!; + }, + + async renameDirectoryGroup(groupId, name) { + const dir = needDirectory(); + const g = await writeThrough('groups', () => dir.renameGroup(groupId, name)); + return toOrgGroups([g], new Map())[0]!; + }, + + async deleteDirectoryGroup(groupId) { + const dir = needDirectory(); + await writeThrough('groups', () => dir.deleteGroup(groupId)); + }, + + async setDirectoryUserGroup(sub, groupId, member) { + const dir = needDirectory(); + await assertPersonBySub(sub); + await writeThrough('groups', () => dir.setUserGroup(sub, groupId, member)); + }, + /* ── 工作组(ADR-011)—— 起单时的岗位绑定,不是第五种策略 ────────── */ async saveWorkGroup(input) { diff --git a/apps/api/src/modules/org/port.ts b/apps/api/src/modules/org/port.ts index b438108..8350e4d 100644 --- a/apps/api/src/modules/org/port.ts +++ b/apps/api/src/modules/org/port.ts @@ -3,7 +3,7 @@ * adapter 实现之,usecase 依赖之,app 装配之。 */ import type { Position, PositionId, RoleAccess, Staff, StaffId, WorkGroup } from '@contracts'; -import type { Identity } from './contract.ts'; +import type { CreateDirectoryUser, DirectoryUserPatch, Identity } from './contract.ts'; export type Clock = { now(): Date }; @@ -13,7 +13,12 @@ export type Clock = { now(): Date }; export type VerifiedClaims = { readonly sub: string; readonly preferred_username: string; + /** Keycloak 拼好的 `firstName lastName` —— 西式顺序,只在拆不开时才用它 */ readonly name?: string; + /** 名(firstName) */ + readonly given_name?: string; + /** 姓(lastName)—— 我们按「姓在前」自己拼,见 `personName` */ + readonly family_name?: string; readonly email?: string; readonly realm_access?: { readonly roles?: readonly string[] }; }; @@ -25,6 +30,66 @@ export type TokenVerifier = { verify(token: string): Promise }; export type RosterUser = Identity & { readonly enabled: boolean }; export type RosterSource = { listUsers(): Promise }; +/* ── 成员目录(Keycloak Admin API 的读写面;ADR-012) ────────────────── */ + +/** + * Keycloak 那一侧的一个用户 —— **原样的形状**,不掺本地字段。 + * 与 `RosterUser` 的区别:名册只要「同步要比对的那几列」,目录要摆给人看的全部。 + */ +export type DirectoryUser = { + readonly sub: string; + readonly username: string; + readonly firstName?: string; + readonly lastName?: string; + readonly email?: string; + readonly enabled: boolean; + readonly emailVerified: boolean; + /** realm 角色(家具角色已滤) */ + readonly roles: readonly string[]; +}; + +/** 组树里的一行 —— 平表,层级看 `parentId` / `depth`(契约同款,理由见 org-directory.ts) */ +export type DirectoryGroup = { + readonly id: string; + readonly name: string; + readonly path: string; + readonly parentId?: string; + readonly depth: number; + readonly memberCount: number; +}; + +export type DirectoryRole = { + readonly name: string; + readonly description?: string; + readonly composite: boolean; +}; + +/** + * 成员目录 —— app 未配 provisioning 服务账号时不注入(与 `RosterSource` 同款)。 + * 读是投影,写**直接穿透**到 Keycloak:这里建的人立刻能登录,这里删的人从 Keycloak 消失。 + */ +export type KeycloakDirectory = { + listUsers(): Promise; + getUser(sub: string): Promise }>; + createUser(input: CreateDirectoryUser): Promise; + updateUser(sub: string, patch: DirectoryUserPatch): Promise; + setEnabled(sub: string, enabled: boolean): Promise; + resetPassword(sub: string, password: string): Promise; + deleteUser(sub: string): Promise; + /** 踢掉此人全部会话 —— 停用与收权之后必调,否则旧 token 到期前照样通行 */ + logout(sub: string): Promise; + listRoles(): Promise; + setRole(sub: string, role: string, granted: boolean): Promise; + listGroups(): Promise; + groupMembers(groupId: string): Promise; + /** 组的直属成员用户名 —— 组树只要个数,别为了数数把每个人的角色都拉一遍 */ + groupMemberUsernames(groupId: string): Promise; + createGroup(name: string, parentId?: string): Promise; + renameGroup(groupId: string, name: string): Promise; + deleteGroup(groupId: string): Promise; + setUserGroup(sub: string, groupId: string, member: boolean): Promise; +}; + /** 名册即时更新 / 同步要改的那几列 */ export type IdentityPatch = { readonly account: string; @@ -44,7 +109,13 @@ export type PositionRepository = { }; /** 员工行 —— 契约 Staff 之外还带两枚时刻,只给用例判「要不要再写一次」,不出模块 */ -export type StaffRecord = Staff & { readonly lastSeenAt?: Date; readonly syncedAt?: Date }; +/** + * 库里真存着的那几列。`Staff.kind` 不在其中 —— 它由账号名现判(见 `staffKindSchema`), + * 存一列就会与账号名分叉,而分叉之后没人看得出以哪份为准。 + */ +export type StoredStaff = Omit; + +export type StaffRecord = StoredStaff & { readonly lastSeenAt?: Date; readonly syncedAt?: Date }; export type StaffRepository = { list(): Promise; @@ -52,7 +123,7 @@ export type StaffRepository = { /** 按账号找 —— `account` 有唯一索引,建档前得先问一句「这个名字被谁占着」(同名让位) */ findByAccount(account: string): Promise; listByPosition(positionId: PositionId): Promise; - insert(staff: Staff, marks: { readonly lastSeenAt?: Date; readonly syncedAt?: Date }): Promise; + insert(staff: StoredStaff, marks: { readonly lastSeenAt?: Date; readonly syncedAt?: Date }): Promise; updateIdentity(id: StaffId, patch: IdentityPatch): Promise; setPosition(id: StaffId, positionId: PositionId): Promise; setState(id: StaffId, state: Staff['state'], syncedAt: Date): Promise; diff --git a/apps/api/tsup.config.ts b/apps/api/tsup.config.ts index d5c2a54..e530baa 100644 --- a/apps/api/tsup.config.ts +++ b/apps/api/tsup.config.ts @@ -9,6 +9,8 @@ export default defineConfig({ 'reset-runs': 'src/app/reset-runs.ts', // 只装活动包(不碰治理台配置)—— 开发活动时一天要装十几次 'install-activities': 'src/app/install-activities.ts', + // 名册同步:整册从 Keycloak 照过来(ops 路由够不着时走这条) + 'sync-roster': 'src/app/sync-roster.ts', }, format: ['esm'], target: 'node22', diff --git a/apps/web/src/app/gov-shell.tsx b/apps/web/src/app/gov-shell.tsx index 1c3faad..893fbc2 100644 --- a/apps/web/src/app/gov-shell.tsx +++ b/apps/web/src/app/gov-shell.tsx @@ -135,6 +135,7 @@ export function GovShell({ surface }: { surface: Surface }) { const domains = sortDomains([...new Set((objects.data?.objects ?? []).map((o) => o.domain))]); // 进台账没带域(左轨点进来)时落到首域 —— 页签与内容同一判定 const effectiveTab = zone === 'ledgers' && tab === '' ? (domains[0] ?? '') : tab; + const tabHint = TABS[zone].find((t) => t.key === tab)?.hint; /* 面包屑:台面 › 区 › 页签 › 下钻各级 */ const crumbs: Array<{ label: string; go?: () => void }> = [ @@ -249,7 +250,8 @@ export function GovShell({ surface }: { surface: Surface }) { * 摆一个只有一颗按钮的导航条,是在给人做无谓的选择。 * 台账例外 —— 它的页签是随发布生长的数据域,数量本来就不定。 */} -
+
+
{zone === 'ledgers' ? // 台账的页签是**数据域**(短名 2-3 字),随发布生长 —— 设计产生运营的形状,落在页签上 domains.map((d) => ( @@ -273,6 +275,9 @@ export function GovShell({ surface }: { surface: Surface }) { {t.label} ))} +
+ {/* 右端一句话:这一格管什么、写下去会发生什么 —— 界面自己说清楚,不靠人记 */} + {tabHint !== undefined && {tabHint}}
diff --git a/apps/web/src/app/styles/console.css b/apps/web/src/app/styles/console.css index 61af5bd..c4ddf53 100644 --- a/apps/web/src/app/styles/console.css +++ b/apps/web/src/app/styles/console.css @@ -84,6 +84,10 @@ .g-head-t p { margin: 5px 0 0; font-size: 12.5px; line-height: 1.7; color: var(--ink-3); max-width: 108ch; } /* 区内页签:一个页签 = 一个维度 */ +/* 页签栏一行两件:左边那颗药丸是页签,右端一句话说清这一格管什么(可缺席) */ +.g-tabsrow { display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.g-tabsrow.hidden { display: none; } +.g-tabs-hint { font-size: 12px; color: var(--ink-4); text-align: right; } .g-tabs.hidden { display: none; } .g-tabs { display: flex; flex-wrap: wrap; gap: 3px; padding: 4px; border-radius: 12px; background: #e3e8ef; align-self: flex-start; } .g-tab { display: inline-flex; align-items: center; gap: 7px; height: 32px; padding: 0 14px; border: 0; border-radius: 9px; background: transparent; color: var(--ink-3); font: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer; transition: background var(--t-fast), color var(--t-fast); } @@ -742,6 +746,19 @@ /* 供应商:一行小签 —— 挑一个即预填它的默认端点。 从前是 146px 宽的大牌子,一个人吃掉 118px 高,把下面的输入框全顶出屏幕。 */ .g-chiprow { display: flex; flex-wrap: wrap; gap: 6px; } +/* 组织那一屏:左树右详情;窄屏叠成一列(树在上,详情在下) */ +.g-split { display: grid; grid-template-columns: minmax(240px, 340px) 1fr; gap: 14px; align-items: start; } +@media (max-width: 900px) { .g-split { grid-template-columns: 1fr; } } +.g-tree { display: flex; flex-direction: column; gap: 2px; } +.g-tree-r { display: flex; align-items: center; gap: 8px; width: 100%; padding: 7px 10px; border: 0; border-radius: 8px; background: transparent; color: var(--ink-2); font: inherit; font-size: 12.5px; text-align: left; cursor: pointer; transition: background var(--t-fast), color var(--t-fast); } +.g-tree-r:hover { background: #f1f4f8; color: var(--ink); } +.g-tree-r.on { background: var(--brand-soft, #e8f0ff); color: var(--ink); font-weight: 700; } +.g-tree-r b { flex: 1; font-weight: inherit; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.g-tree-r em { font-style: normal; font-size: 11px; color: var(--ink-4); } +/* 一枚勾选 + 一句话 —— `.g-swrow` 会把行内**所有** input 压成 14px, + 而这一行里还站着检索框,所以单起一个,只管勾选框自己 */ +.g-tick { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--ink-2); cursor: pointer; white-space: nowrap; } +.g-tick input { width: 14px; height: 14px; accent-color: var(--brand); cursor: pointer; } .g-chip { padding: 6px 11px; border: 1px solid var(--cline); border-radius: 15px; background: #fff; font: 500 12px/1.2 var(--sans); color: var(--ink-3); cursor: pointer; transition: border-color var(--t-fast), background var(--t-fast), color var(--t-fast); } .g-chip:hover { border-color: #aebdcd; color: var(--ink); } .g-chip.on { border-color: var(--brand); background: var(--brand-wash); color: var(--brand-deep); font-weight: 600; } diff --git a/apps/web/src/features/gov-staff/internal/member-page.tsx b/apps/web/src/features/gov-staff/internal/member-page.tsx new file mode 100644 index 0000000..7f26b98 --- /dev/null +++ b/apps/web/src/features/gov-staff/internal/member-page.tsx @@ -0,0 +1,255 @@ +/** + * 成员详情 —— 一个人的全部:身份、能进哪几个端、角色授收、所属组、启停与删除。 + * + * 这一屏的每个动作都**穿透到 Keycloak**,没有撤销;所以一律走二次确认带。 + * 两处顺手做的事要说出来,不然人不知道: + * 停用与收权都**当场踢掉他的全部会话** —— 否则他手上那枚 token 到期前照样通行, + * 而屏上已经写了「已停用」。界面不撒谎。 + */ +import { useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { + deleteOrgUserRoute, + patchOrgUserRoute, + resetOrgUserPasswordRoute, + setOrgUserEnabledRoute, + setOrgUserRoleRoute, +} from '@contracts'; +import { call } from '@web-shared/typed-fetch.ts'; +import { Acts, Card, Chip, Clear, Field, Note, PageHead, QueryGate, Rows, St } from '@web-shared/gov/primitives.tsx'; +import { Btn } from '@web-shared/Btn.tsx'; +import type { Nav } from '@web-shared/gov/gov-route.ts'; +import { ORG_KEYS, useOrgRoles, useOrgUser } from '@web-shared/gov/gov-api.ts'; +import { EndChips } from './members.tsx'; + +export function MemberPage({ id, nav }: { id: string; nav: Nav }) { + const qc = useQueryClient(); + const q = useOrgUser(id); + const roles = useOrgRoles(); + const u = q.data; + const [err, setErr] = useState(undefined); + const [pwd, setPwd] = useState(''); + const [editing, setEditing] = useState<{ firstName: string; lastName: string; email: string } | undefined>(undefined); + + /** 改一个人:他自己 + 名单 + 角色持有数;组的成员数没变(删人那一路另说) */ + const bust = (): void => { + void qc.invalidateQueries({ queryKey: ORG_KEYS.user }); + void qc.invalidateQueries({ queryKey: ORG_KEYS.users }); + void qc.invalidateQueries({ queryKey: ORG_KEYS.roles }); + }; + const fail = (e: unknown): void => setErr(e instanceof Error ? e.message : String(e)); + + const setEnabled = useMutation({ + mutationFn: (enabled: boolean) => call(setOrgUserEnabledRoute, { params: { sub: id }, body: { enabled } }), + onSuccess: () => { setErr(undefined); bust(); }, + onError: fail, + }); + const setRole = useMutation({ + mutationFn: (v: { role: string; granted: boolean }) => + call(setOrgUserRoleRoute, { params: { sub: id, role: v.role }, body: { granted: v.granted } }), + onSuccess: () => { setErr(undefined); bust(); }, + onError: fail, + }); + const patch = useMutation({ + mutationFn: (v: { firstName: string; lastName: string; email: string }) => + call(patchOrgUserRoute, { params: { sub: id }, body: v }), + onSuccess: () => { setEditing(undefined); setErr(undefined); bust(); }, + onError: fail, + }); + const reset = useMutation({ + mutationFn: () => call(resetOrgUserPasswordRoute, { params: { sub: id }, body: { password: pwd } }), + onSuccess: () => { setPwd(''); setErr(undefined); }, + onError: fail, + }); + const drop = useMutation({ + mutationFn: () => call(deleteOrgUserRoute, { params: { sub: id } }), + // 删人是唯一会动到组的那一路:他从他所在的每个组里没了 + onSuccess: () => { + bust(); + void qc.invalidateQueries({ queryKey: ORG_KEYS.groups }); + nav.jump('people', 'members'); + }, + onError: fail, + }); + + return ( + + {u === undefined ? ( + {`Keycloak 里没有这个人(${id})—— 可能刚被删了。`} + ) : ( + <> + + {u.username} + + {u.enabled ? '在职' : '已停用'} + + + } + desc="身份与角色的真源是 Keycloak —— 这一屏的每个动作都直接改那边,没有撤销。" + /> + + {err !== undefined && {err}} + + + + {editing === undefined ? ( + + setEditing({ firstName: u.firstName ?? '', lastName: u.lastName ?? '', email: u.email ?? '' }), + }, + ]} + /> + ) : ( +
+
改资料(用户名建后不可改)
+
+ + setEditing({ ...editing, lastName: e.target.value })} /> + + + setEditing({ ...editing, firstName: e.target.value })} /> + + + setEditing({ ...editing, email: e.target.value })} /> + + setEditing(undefined) }, + { label: '保存', ico: 'check', primary: true, busy: patch.isPending, onClick: () => patch.mutate(editing) }, + ]} + /> +
+
+ )} +
+ + 由下面的角色 × 「权限」页签的配置算出来}> +
+ +
+
+ + 授收都穿透 Keycloak;收权当场踢掉他的会话}> + +
+ {(roles.data?.roles ?? []).map((r) => { + const has = u.roles.includes(r.name); + return ( + setRole.mutate({ role: r.name, granted: !has })} + title={ + has + ? `收掉「${r.name}」—— 他手上的会话会被当场踢掉` + : `授予「${r.name}」(可进:${r.ends.length === 0 ? '无端' : r.ends.join(' / ')})` + } + > + {(has ? '✓ ' : '+ ') + r.name} + + ); + })} +
+
+
+ + + {u.groups.length === 0 ? ( + 不属于任何组 —— 进出组在「组织」页签里做。 + ) : ( +
+ {u.groups.map((g) => ( + + {g.path} + + ))} +
+ )} +
+ + 一律临时口令 —— 本人首登必须改}> +
+ setPwd(e.target.value)} + /> + reset.mutate()}> + 重置口令 + +
+ 服务端不回显、不留痕 —— 这一下按完,这串字就只在你和他之间。 +
+ + + setEnabled.mutate(!u.enabled), + }, + }, + { + label: '删除', + ico: 'trash-2', + danger: true, + busy: drop.isPending, + ask: { + title: `把「${u.displayName}」从 Keycloak 删掉`, + note: '本地名册那一行会停用但不删 —— 留痕、在途任务、台账里都还指着这个 sub,删了那些地方就成了悬空的 id。', + impact: { + who: u.displayName, + undo: '不能撤销 —— 从 Keycloak 消失,再建一个是新的 sub', + when: '按下即生效', + }, + okLabel: '确认删除', + onOk: () => drop.mutate(), + }, + }, + ]} + /> + + + )} +
+ ); +} diff --git a/apps/web/src/features/gov-staff/internal/members.tsx b/apps/web/src/features/gov-staff/internal/members.tsx new file mode 100644 index 0000000..0f8c372 --- /dev/null +++ b/apps/web/src/features/gov-staff/internal/members.tsx @@ -0,0 +1,188 @@ +/** + * 成员 —— Keycloak 目录的投影(ADR-012)。 + * + * **这里的写是穿透的**:建的人立刻能登录、停的人立刻登不上,没有本地草稿,也没有撤销。 + * 所以每个写动作都走 `Acts` 的二次确认带(三必答:谁受影响 / 能不能撤 / 几时生效)。 + * + * 检索与「含停用」都交给服务端 —— 那边握着全量,前端只拿要摆的那些。 + */ +import { useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { createOrgUserRoute, SURFACE_LABEL } from '@contracts'; +import type { Surface } from '@contracts'; +import { call } from '@web-shared/typed-fetch.ts'; +import { Acts, Card, Chip, Clear, Field, NList, NRow, Note, QueryGate, St } from '@web-shared/gov/primitives.tsx'; +import { Btn } from '@web-shared/Btn.tsx'; +import type { Nav } from '@web-shared/gov/gov-route.ts'; +import { ORG_KEYS, useOrgUsers } from '@web-shared/gov/gov-api.ts'; + +/** 能进的端 —— 摆人读名;一个都没有就直说,别留一片空白让人以为没加载出来 */ +export function EndChips({ ends }: { ends: readonly Surface[] }) { + if (ends.length === 0) return 无端可进; + return ( + <> + {ends.map((e) => ( + + {SURFACE_LABEL[e]} + + ))} + + ); +} + +export function MembersTab({ nav }: { nav: Nav }) { + const [q, setQ] = useState(''); + const [includeDisabled, setIncludeDisabled] = useState(true); + const [creating, setCreating] = useState(false); + const users = useOrgUsers(q, includeDisabled); + const rows = users.data?.users ?? []; + const active = rows.filter((u) => u.enabled).length; + /* + * 数还没取回来就别摆数 —— `?? []` 会让计数在载入那几秒显示「0 人 · 0 在职」, + * 而那是**一句假话**:不是没有人,是还不知道有几个人(铁规矩 8)。 + */ + const counted = users.data !== undefined; + + return ( + <> + + setQ(e.target.value)} + /> + + {counted ? `${rows.length} 人 · ${active} 在职` : '数着…'} + setCreating((v) => !v)}> + {creating ? '收起' : '新建成员'} + + + } + > + + 真源是 Keycloak · 停用的人立刻登不上;在途任务不受影响(需手动改派)。服务账号不在这份名单里 —— + 它是身份不是人。 + + {creating && setCreating(false)} />} + + {rows.length === 0 ? ( + {q === '' ? '一个人都没有 —— 去 Keycloak 建人,或用「新建成员」。' : `没有匹配「${q}」的成员。`} + ) : ( + + {rows.map((u) => ( + }, + ]} + right={ + + {u.enabled ? '在职' : '已停用'} + + } + onClick={() => nav.push({ k: 'emp', id: u.sub })} + /> + ))} + + )} + + + + ); +} + +/** + * 新建成员 —— 口令一律**临时**:本人首登必须改。 + * 我们不存它、不回显 —— 它经过这个表单就再也不出现了,所以建完那一句话得把它说清楚。 + */ +function CreateMemberForm({ onDone }: { onDone: () => void }) { + const qc = useQueryClient(); + const [username, setUsername] = useState(''); + const [lastName, setLastName] = useState(''); + const [firstName, setFirstName] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [err, setErr] = useState(undefined); + + const create = useMutation({ + mutationFn: () => + call(createOrgUserRoute, { + body: { + username: username.trim(), + ...(lastName.trim() === '' ? {} : { lastName: lastName.trim() }), + ...(firstName.trim() === '' ? {} : { firstName: firstName.trim() }), + ...(email.trim() === '' ? {} : { email: email.trim() }), + ...(password === '' ? {} : { password }), + }, + }), + onSuccess: () => { + // 多了一个人 → 名单变了,角色的持有数也跟着变;组没动 + void qc.invalidateQueries({ queryKey: ORG_KEYS.users }); + void qc.invalidateQueries({ queryKey: ORG_KEYS.roles }); + onDone(); + }, + onError: (e: unknown) => setErr(e instanceof Error ? e.message : String(e)), + }); + + return ( +
+
新建成员
+
+ + setUsername(e.target.value)} placeholder="如 zhangsan" /> + + + setLastName(e.target.value)} /> + + + setFirstName(e.target.value)} /> + + + setEmail(e.target.value)} /> + + + setPassword(e.target.value)} /> + + {err !== undefined && {err}} + create.mutate(), + }, + }, + ]} + /> +
+
+ ); +} diff --git a/apps/web/src/features/gov-staff/internal/orgs.tsx b/apps/web/src/features/gov-staff/internal/orgs.tsx new file mode 100644 index 0000000..16262ff --- /dev/null +++ b/apps/web/src/features/gov-staff/internal/orgs.tsx @@ -0,0 +1,276 @@ +/** + * 组织 —— Keycloak 的组(ADR-012)。左边一棵树,右边选中那个组的成员与操作。 + * + * 树是**平表**渲染的:一行一个组,靠 `depth` 缩进(契约里就不放递归结构 —— + * 递归 schema 到了声明文件那一层只会变成一坨 any)。按 `path` 升序,父在子前。 + * + * 删组连子组一起没(Keycloak 语义),人不会被删 —— 只是不再属于这个组。 + */ +import { useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { + createOrgGroupRoute, + deleteOrgGroupRoute, + renameOrgGroupRoute, + setOrgUserGroupRoute, +} from '@contracts'; +import { call } from '@web-shared/typed-fetch.ts'; +import { Acts, Card, Chip, Clear, NList, NRow, Note, QueryGate, St } from '@web-shared/gov/primitives.tsx'; +import { Btn } from '@web-shared/Btn.tsx'; +import { ORG_KEYS, useOrgGroupMembers, useOrgGroups, useOrgUsers } from '@web-shared/gov/gov-api.ts'; +import type { GovOrgGroups } from '@web-shared/gov/gov-api.ts'; + +export function OrgsTab() { + const groups = useOrgGroups(); + const rows = groups.data?.groups ?? []; + const [picked, setPicked] = useState(undefined); + const current = rows.find((g) => g.id === picked); + const tops = rows.filter((g) => g.depth === 0).length; + /* 取回来之前一个数都别摆、一句「还没有组」也别说 —— 那时候我们并不知道有没有组 */ + const loaded = groups.data !== undefined; + + return ( +
+ {loaded ? `${tops} 顶层组` : '数着…'}}> + Keycloak 组 · 点一行看它的成员与操作 + + {rows.length === 0 ? ( + 还没有组 —— 先在下面「建顶层组」。 + ) : ( +
+ {rows.map((g) => ( + + ))} +
+ )} +
+ +
+ + {current === undefined ? ( + + + {!loaded + ? '组织树还在取…' + : rows.length === 0 + ? '还没有组 —— 先在左侧「建顶层组」,这里看它的成员与操作。' + : '在左侧点一个组,这里看它的成员与操作。'} + + + ) : ( + setPicked(undefined)} + /> + )} +
+ ); +} + +/** 建组 —— `parentId` 给了就是子组,不给就是顶层组 */ +function GroupCreate({ label, hint, parentId }: { label: string; hint: string; parentId?: string }) { + const qc = useQueryClient(); + const [name, setName] = useState(''); + const [err, setErr] = useState(undefined); + const create = useMutation({ + mutationFn: () => + call(createOrgGroupRoute, { + body: { name: name.trim(), ...(parentId === undefined ? {} : { parentId }) }, + }), + onSuccess: () => { + setName(''); + setErr(undefined); + // 只有组变了 —— 成员那一趟很贵,别拖它下水 + void qc.invalidateQueries({ queryKey: ORG_KEYS.groups }); + }, + onError: (e: unknown) => setErr(e instanceof Error ? e.message : String(e)), + }); + return ( +
+
{label}
+
+
+ setName(e.target.value)} /> + create.mutate()}> + 建组 + +
+ {err !== undefined && {err}} +
+
+ ); +} + +function GroupDetail({ + group, + onGone, +}: { + group: { id: string; name: string; path: string; depth: number; memberCount: number }; + onGone: () => void; +}) { + const qc = useQueryClient(); + const members = useOrgGroupMembers(group.id); + const all = useOrgUsers('', true); + const [renaming, setRenaming] = useState(undefined); + const [err, setErr] = useState(undefined); + /** 组这一屏的写只动组与组成员;人的角色与端一个字没变 */ + const bust = (): void => { + void qc.invalidateQueries({ queryKey: ORG_KEYS.groups }); + void qc.invalidateQueries({ queryKey: ORG_KEYS.groupMembers }); + }; + const fail = (e: unknown): void => setErr(e instanceof Error ? e.message : String(e)); + + const rename = useMutation({ + mutationFn: (name: string) => call(renameOrgGroupRoute, { params: { id: group.id }, body: { name } }), + onSuccess: () => { setRenaming(undefined); setErr(undefined); bust(); }, + onError: fail, + }); + const drop = useMutation({ + mutationFn: () => call(deleteOrgGroupRoute, { params: { id: group.id } }), + onSuccess: () => { + /* + * 删成功了 —— **「这个组没了」是已知事实**,不必等服务端再说一遍。 + * 那一趟重取要好几秒(组树得逐组数成员),期间树上还钉着已经没了的组; + * 撞上 token 到期强刷的那一次更久,实测有一轮干脆没刷过来。 + * 手上有的答案先落进缓存,重取照发 —— 回来时一比,一样。 + * 子组跟着一起没(Keycloak 语义),按**路径前缀**连它们一起摘,不是只摘直属那一层。 + */ + qc.setQueryData(ORG_KEYS.groups, (old) => + old === undefined + ? old + : { groups: old.groups.filter((g) => g.id !== group.id && !g.path.startsWith(`${group.path}/`)) }, + ); + onGone(); + bust(); + }, + onError: fail, + }); + const setMember = useMutation({ + mutationFn: (v: { sub: string; member: boolean }) => + call(setOrgUserGroupRoute, { params: { sub: v.sub, groupId: group.id }, body: { member: v.member } }), + onSuccess: () => { setErr(undefined); bust(); }, + onError: fail, + }); + + const inGroup = members.data?.members ?? []; + const inIds = new Set(inGroup.map((m) => m.sub)); + const outside = (all.data?.users ?? []).filter((u) => !inIds.has(u.sub)); + + return ( + {`${inGroup.length} 人`}} + > + {`路径 ${group.path}`} + + {renaming !== undefined && ( +
+
改组名
+
+
+ setRenaming(e.target.value)} /> + setRenaming(undefined)}>取消 + rename.mutate(renaming.trim())} + > + 改名 + +
+
+
+ )} + + {err !== undefined && {err}} + + + {inGroup.length === 0 ? ( + 这个组还没有人 —— 在下面「把人加进来」。 + ) : ( + + {inGroup.map((m) => ( + setMember.mutate({ sub: m.sub, member: false })} + > + 移出 + + } + onClick={() => undefined} + /> + ))} + + )} + + +
+
把人加进来
+
+ {outside.length === 0 ? ( + 所有人都已经在这个组里了。 + ) : ( +
+ {outside.map((u) => ( + setMember.mutate({ sub: u.sub, member: true })} + > + {`+ ${u.displayName}`} + + ))} +
+ )} +
+
+ + + + setRenaming(group.name) }, + { + label: '删掉这个组', + ico: 'trash-2', + danger: true, + busy: drop.isPending, + ask: { + title: `删掉「${group.name}」`, + note: '子组会跟着一起没。人不会被删 —— 只是不再属于这个组。', + impact: { + who: `这个组里的 ${inGroup.length} 个人,以及它下面的所有子组`, + undo: '不能撤销 —— 组和它的层级关系直接从 Keycloak 消失', + when: '按下即生效', + }, + okLabel: '确认删组', + onOk: () => drop.mutate(), + }, + }, + ]} + /> +
+ ); +} diff --git a/apps/web/src/features/gov-staff/internal/panel.tsx b/apps/web/src/features/gov-staff/internal/panel.tsx index 4be5325..f4a2ccb 100644 --- a/apps/web/src/features/gov-staff/internal/panel.tsx +++ b/apps/web/src/features/gov-staff/internal/panel.tsx @@ -1,367 +1,31 @@ /** - * feature `gov-staff` —— 员工管理:账号、岗位授权、停用前置检查 + * feature `gov-staff` —— 成员与组织(ADR-012,2026-08-26 用户按目标件定的三页签)。 * - * 自 `~/coding/JCAI_PRD/governance/_components/Governance.tsx` 平移: - * DOM 结构、类名(`g-*` + 色调 `t-*`)与文案原样保留,mock 常量换成服务端投影(README 平移规则 1/2/4)。 - * 区首页只负责导航,治理动作一律落在下钻详情页的**就地确认带**(影响预览三必答)。 + * 三个页签一一对着 Keycloak 的三样东西: + * **成员** = users(目录投影;新建 / 启停 / 删除都穿透过去) + * **权限** = realm roles × 本系统的「角色 → 端」表(每个请求都按它判) + * **组织** = groups(建组 / 改名 / 删组 / 成员进出组) + * + * 原「岗位授权」与「工作组」两个页签已拿掉(用户定)。**后端数据与接口原样留着** —— + * 岗位仍决定活动派单的候选集,工作组仍在起单那一刻快照进工作流;拿掉的只是入口。 */ -import { useState } from 'react'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { GOVERNANCE_FUNCTION_LABEL, GRANT_ACTION_LABEL, saveWorkGroupRoute, staffIdSchema } from '@contracts'; -import type { StaffId } from '@contracts'; -import { call } from '@web-shared/typed-fetch.ts'; -import { - Acts, - Card, - Chip, - Clear, - Entry, - Field, - NList, - NRow, - Note, - PageHead, - QueryGate, - Rows, - St, -} from '@web-shared/gov/primitives.tsx'; +import { MembersTab } from './members.tsx'; +import { MemberPage } from './member-page.tsx'; +import { OrgsTab } from './orgs.tsx'; +import { PermsTab, RolePage } from './perms.tsx'; import type { Nav } from '@web-shared/gov/gov-route.ts'; -import { useGovGrants, useGovStaff, useGovTasks, useGovWorkGroups } from '@web-shared/gov/gov-api.ts'; export type HomeProps = { tab: string; nav: Nav }; export function PeopleHome({ tab, nav }: HomeProps) { - if (tab === 'groups') return ; - return ; + if (tab === 'perms') return ; + if (tab === 'orgs') return ; + return ; } -function StaffHome({ tab, nav }: HomeProps) { - const q = useGovStaff(); - const tasks = useGovTasks(); - const staff = q.data?.staff ?? []; - const positions = q.data?.positions ?? []; - - if (tab === 'roles') { - return ( - <> - - 权限按岗位授予,不按人:客体三类(数据 / 工具 / 治理功能)× 动作三种(读 / 写 / 治理)。 - 人换岗即换权,权限随岗位走。 - - - {positions.map((p) => ( - s.position.id === p.id).length }]} - onClick={() => nav.push({ k: 'role', id: p.id })} - /> - ))} - - - ); - } - - return ( - - <> - - {staff.map((s) => { - const mine = (tasks.data?.tasks ?? []).filter((t) => t.ownerName === s.name && t.terminal === undefined); - return ( - - {s.state === 'active' ? '在职' : '已停用'} - - } - onClick={() => nav.push({ k: 'emp', id: s.id })} - /> - ); - })} - - - - ); -} - -/* ---------------- ③ 工作流管理 ---------------- */ - +/** 详情页 `emp` —— 路由名沿用(URL 里已有的书签不该失效),内容是成员详情 */ export function EmpPage({ id, nav }: { id: string; nav: Nav }) { - const q = useGovStaff(); - const tasks = useGovTasks(); - const s = (q.data?.staff ?? []).find((x) => x.id === id); - if (s === undefined) return 员工不存在。; - const mine = (tasks.data?.tasks ?? []).filter((t) => t.ownerName === s.name); - const live = mine.filter((t) => t.terminal === undefined); - return ( - <> - - - {s.position.name} · {s.position.code} - - {s.state === 'active' ? '在职' : '已停用'} - - } - desc="停用前置检查:在途任务与待答卡须先改派完毕——停用不得让在途工作无人认领。" - /> - {live.length}}> - - {live.map((t) => ( - nav.push({ k: 'task', id: t.id })} /> - ))} - {live.length === 0 && 无在途任务——可安全停用。} - - - nav.push({ k: 'role', id: s.position.id })} /> - - ); + return ; } -export function RolePage({ id }: { id: string }) { - const staff = useGovStaff(); - const grants = useGovGrants(id); - const position = (staff.data?.positions ?? []).find((p) => p.id === id); - return ( - <> - {position?.code ?? '—'}} - desc="权限按岗位授予,不按人:客体三类(数据 / 工具 / 治理功能)× 动作三种(读 / 写 / 治理)。人换岗即换权。" - /> -
- {(grants.data?.grants ?? []).map((g, i) => ( -
- - {g.object.kind === 'data' - ? `数据 · ${g.object.domain}.${g.object.object}` - : g.object.kind === 'governance' - ? `治理功能 · ${GOVERNANCE_FUNCTION_LABEL[g.object.function]}` - : `任务 · ${g.object.activityId ?? '全部活动'}`} - - {GRANT_ACTION_LABEL[g.action]} -
- ))} - {(grants.data?.grants ?? []).length === 0 && 未授予任何权限。} -
- - ); -} - -/* ---------------- 流程包 ---------------- */ - - -/* ---------------- 工作组(ADR-011) ---------------- */ - -/** - * 工作组 = 一组「岗位 → 员工」的绑定,**起单时装载、随工作流走**。 - * - * 它是**绑定先于策略**,不是第五种负责人策略:起单人有组,整条流的岗位就按组指人; - * 没有组才回落四策略(独任 / 轮询 / 抢单 / 指派)。 - * 装进工作流的是**快照** —— 改组不改已起的单,留痕不被改写。 - */ -function WorkGroupsTab() { - const groups = useGovWorkGroups(); - const staffQ = useGovStaff(); - const [editing, setEditing] = useState(undefined); - - const staff = staffQ.data?.staff ?? []; - const positions = staffQ.data?.positions ?? []; - const nameOf = (id: string) => staff.find((s) => s.id === id)?.name ?? id; - const rows = groups.data?.groups ?? []; - - return ( - - <> - - 工作组把岗位绑到人:谁起的单,整条流的岗位就按谁的组指人 —— 绑定先于策略, - 没绑定的岗位仍走四策略。绑定在起单那一刻快照进工作流,此后改组不影响已起的单。 - - {rows.length === 0 ? ( - 还没有工作组 —— 给谁建一个,他起的单整条流就能一次指到人。 - ) : ( - rows.map((g) => - editing === g.ownerId ? ( - setEditing(undefined)} - onCancel={() => setEditing(undefined)} - /> - ) : ( - setEditing(g.ownerId)}> - 改绑定 - - } - > - ({ - k: `${p.name} · ${p.code}`, - v: g.members[p.code] === undefined ? '未绑定 · 走四策略' : nameOf(g.members[p.code]!), - })), - ]} - /> - - ), - ) - )} - - - {staff - .filter((s) => !rows.some((g) => g.ownerId === s.id)) - .map((s) => - editing === s.id ? null : ( - 建组 →} - onClick={() => setEditing(s.id)} - /> - ), - )} - {staff.every((s) => rows.some((g) => g.ownerId === s.id)) && 每个人都有组了。} - - - {staff - .filter((s) => !rows.some((g) => g.ownerId === s.id) && editing === s.id) - .map((s) => ( - setEditing(undefined)} - onCancel={() => setEditing(undefined)} - /> - ))} - - - ); -} - -/** 建组 / 改组表单:逐岗位选人,留空即该岗位不绑定 */ -function WorkGroupForm({ - ownerId, - ownerName, - name, - members, - positions, - staff, - onDone, - onCancel, -}: { - ownerId: string; - ownerName: string; - name: string; - members: Readonly>; - positions: ReadonlyArray<{ id: string; code: string; name: string }>; - staff: ReadonlyArray<{ id: string; name: string; position: { code: string; name: string } }>; - onDone: () => void; - onCancel: () => void; -}) { - const queryClient = useQueryClient(); - const [draftName, setDraftName] = useState(name); - const [draft, setDraft] = useState>({ ...members }); - - const save = useMutation({ - mutationFn: (input: { name: string; members: Record }) => - call(saveWorkGroupRoute, { - body: { ownerId: staffIdSchema.parse(ownerId), name: input.name, members: input.members }, - }), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ['gov', 'work-groups'] }); - onDone(); - }, - }); - - const bound = Object.values(draft).filter((v) => v !== '').length; - - return ( - - - setDraftName(e.target.value)} /> - - {positions.map((p) => ( - - - - ))} - 绑定先于策略 —— 起单人有组,整条流的岗位就按组指人;没绑定的岗位仍走四策略。, - impact: { - who: '仅此后新起的工作流;在途不受影响 —— 绑定是起单时的快照', - undo: '可随时改;解绑某岗位即回落四策略', - when: '确认后即时生效', - }, - okLabel: '确认保存', - onOk: () => { - const picked: Record = {}; - for (const [code, id] of Object.entries(draft)) { - if (id !== '') picked[code] = staffIdSchema.parse(id); - } - save.mutate({ name: draftName, members: picked }); - }, - }, - }, - ]} - /> - {save.isError && 保存失败,请重试。} - - ); -} +export { RolePage }; diff --git a/apps/web/src/features/gov-staff/internal/perms.tsx b/apps/web/src/features/gov-staff/internal/perms.tsx new file mode 100644 index 0000000..600ca81 --- /dev/null +++ b/apps/web/src/features/gov-staff/internal/perms.tsx @@ -0,0 +1,204 @@ +/** + * 权限 —— **两份事实拼在一起**(ADR-012): + * 角色本身是 Keycloak 的(realm roles,授收穿透过去); + * 「这个角色能进哪几个端」是**本系统自己的**(`sys.role_access`),Keycloak 不知道这回事。 + * + * 权限只到端:设计中心 / 运营中心 / 工作台。一个请求进不进得来,鉴权中间件每次都按这张表判 —— + * 所以这一屏改的不是一句描述,是**下一个请求的成败**。 + * + * 锁死不变量:改完之后至少还得有人能进运营中心 —— 否则这张表就再也没人改得了。 + * 服务端算完了才落库,不过就 409;这边如实把那句话摆出来,不自己拦(拦了两处口径会分叉)。 + */ +import { useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import type { QueryClient } from '@tanstack/react-query'; +import { deleteRoleAccessRoute, setRoleAccessRoute, SURFACE_LABEL, SURFACES } from '@contracts'; +import type { Surface } from '@contracts'; +import { call } from '@web-shared/typed-fetch.ts'; +import { Acts, Card, Chip, Clear, Note, QueryGate, St } from '@web-shared/gov/primitives.tsx'; +import { ORG_KEYS, useOrgRoles, useRoleAccess } from '@web-shared/gov/gov-api.ts'; +import { EndChips } from './members.tsx'; + +/** + * 改「角色 → 端」之后,**每个人的可进端都跟着变** —— 所以人那一份也得失效, + * 不然成员那一屏还摆着旧的端胶囊(同一件事两屏说两句话)。组没动。 + */ +function bustAccess(qc: QueryClient): void { + void qc.invalidateQueries({ queryKey: ORG_KEYS.roleAccess }); + void qc.invalidateQueries({ queryKey: ORG_KEYS.roles }); + void qc.invalidateQueries({ queryKey: ORG_KEYS.users }); + void qc.invalidateQueries({ queryKey: ORG_KEYS.user }); +} + +export function PermsTab() { + const roles = useOrgRoles(); + const access = useRoleAccess(); + const rows = roles.data?.roles ?? []; + + return ( + {roles.data === undefined ? '数着…' : `${rows.length} 个角色`}} + > + + 角色来自 Keycloak(realm roles),能进哪几个端由这里说了算 —— 每个请求都按它判, + 不合就回 403。改完至少要有人还能进运营中心,否则这张表自己就锁死了。 + + + {rows.length === 0 ? ( + realm 里还没有业务角色 —— 去 Keycloak 建(家具角色不摆在这儿)。 + ) : ( +
+ {rows.map((r) => ( + x.role === r.name)} /> + ))} +
+ )} +
+
+ ); +} + +function RoleRow({ + role, + configured, +}: { + role: { name: string; description?: string | undefined; composite: boolean; ends: readonly Surface[]; holders: number }; + configured: boolean; +}) { + const qc = useQueryClient(); + const [draft, setDraft] = useState(undefined); + const [err, setErr] = useState(undefined); + const editing = draft !== undefined; + const shown = draft ?? role.ends; + + const save = useMutation({ + mutationFn: (ends: readonly Surface[]) => + // 一个端都不给 = 这个角色哪儿都进不去。删掉那一行与存一行空的是同一件事,存空的更好读 + call(setRoleAccessRoute, { params: { role: role.name }, body: { ends: [...ends] } }), + onSuccess: () => { + setDraft(undefined); + setErr(undefined); + bustAccess(qc); + }, + onError: (e: unknown) => setErr(e instanceof Error ? e.message : String(e)), + }); + + const remove = useMutation({ + mutationFn: () => call(deleteRoleAccessRoute, { params: { role: role.name } }), + onSuccess: () => { + setDraft(undefined); + setErr(undefined); + bustAccess(qc); + }, + onError: (e: unknown) => setErr(e instanceof Error ? e.message : String(e)), + }); + + return ( +
+
+ {role.name} + {role.composite && 组合角色} + {`${role.holders} 人持有`} +
+
+ {role.description !== undefined && {role.description}} + {editing ? ( +
+ {SURFACES.map((s) => ( + + ))} + 勾上的端,持有这个角色的人就能进;一个都不勾 = 有这个角色也哪儿都进不去。 +
+ ) : ( +
+ + {!configured && 还没配过} +
+ )} + {err !== undefined && {err}} + { setDraft(undefined); setErr(undefined); } }, + { + label: '保存', + ico: 'check', + primary: true, + busy: save.isPending, + ask: { + title: `把「${role.name}」的可进端改成:${shown.length === 0 ? '无' : shown.map((s) => SURFACE_LABEL[s]).join(' / ')}`, + note: '这张表每个请求都在读 —— 改完下一个请求就按新的判。', + impact: { + who: `持有这个角色的 ${role.holders} 个人`, + undo: '可以改回来 —— 但改完得还有人进得了运营中心,否则服务端会拒', + when: '立刻;他们手上的 token 不用重新登录也按新表判', + }, + okLabel: '确认改', + onOk: () => save.mutate(shown), + }, + }, + ] + : [ + { label: '改可进端', ico: 'pencil-line', onClick: () => setDraft(role.ends) }, + ...(configured + ? [ + { + label: '删掉这一行', + ico: 'trash-2', + danger: true, + busy: remove.isPending, + ask: { + title: `删掉「${role.name}」的端配置`, + note: '删掉之后这个角色不再命中任何端 —— 与「一个端都不勾」是同一个效果。', + impact: { + who: `持有这个角色的 ${role.holders} 个人`, + undo: '可以再配回来', + when: '立刻', + }, + okLabel: '确认删', + onOk: () => remove.mutate(), + }, + }, + ] + : []), + ] + } + /> +
+
+ ); +} + +/** 角色详情:谁持有它 —— 从成员那一屏点进来,或从这一屏的角色名点进来 */ +export function RolePage({ id }: { id: string }) { + const roles = useOrgRoles(); + const role = (roles.data?.roles ?? []).find((r) => r.name === id); + return ( + + {role === undefined ? ( + {`realm 里没有角色「${id}」—— 可能刚在 Keycloak 里删了。`} + ) : ( + {`${role.holders} 人持有`}}> +
+ +
+ 授收这个角色在「成员」那一屏的成员详情里做 —— 一次改一个人,收权当场踢掉他的会话。 +
+ )} +
+ ); +} diff --git a/apps/web/src/shared/gov/gov-api.ts b/apps/web/src/shared/gov/gov-api.ts index c77d625..34e7128 100644 --- a/apps/web/src/shared/gov/gov-api.ts +++ b/apps/web/src/shared/gov/gov-api.ts @@ -23,6 +23,7 @@ import type { GovTriggersOutput, GovWorkGroupsOutput, GovWorkflowOutput, + RouteOutput, ProcessPackage, GovLedgerByActivityOutput, } from '@contracts'; @@ -44,6 +45,12 @@ import { listLlmInstancesRoute, listLlmProvidersRoute, listProcessPackagesRoute, + getOrgUserRoute, + listOrgGroupMembersRoute, + listOrgGroupsRoute, + listOrgRolesRoute, + listOrgUsersRoute, + listRoleAccessRoute, listStaffRoute, listToolsRoute, listTriggersRoute, @@ -190,3 +197,65 @@ export const useGovWorkflow = (id: string): UseQueryResult => export const useGovTaskLog = (taskId: string): UseQueryResult => useQuery({ queryKey: ['gov', 'tasklog', taskId], queryFn: () => call(govTaskLogRoute, { params: { taskId } }) }); + +/* ── 成员与组织(ADR-012)———————————————————————————————————————— */ + +/** + * 各屏的查询键 —— 写完**按碰到的那几屏**失效,别一把 `['gov','org']` 全清。 + * 成员那一趟最贵(服务端要逐人问角色,见 debt),把它拖进来重取, + * 屏上那棵组织树就多钉着已经没了的组好几秒。 + */ +export const ORG_KEYS = { + users: ['gov', 'org', 'users'] as const, + user: ['gov', 'org', 'user'] as const, + roles: ['gov', 'org', 'roles'] as const, + groups: ['gov', 'org', 'groups'] as const, + groupMembers: ['gov', 'org', 'group-members'] as const, + roleAccess: ['gov', 'org', 'role-access'] as const, +}; + +export type GovOrgUsers = RouteOutput; +export type GovOrgUserDetail = RouteOutput; +export type GovOrgRoles = RouteOutput; +export type GovOrgGroups = RouteOutput; +export type GovOrgGroupMembers = RouteOutput; +export type GovRoleAccess = RouteOutput; + +/** + * 成员列表 —— 检索与「含停用」都在**服务端**过滤(那边握着全量,前端只拿要摆的那些)。 + * 服务端第一次取要几秒(Keycloak 没有批量取角色的口,见 debt),它自己有 60 秒缓存; + * 这边 `staleTime` 跟着设一档,免得同一屏的几个组件各要一遍。 + */ +export const useOrgUsers = (q: string, includeDisabled: boolean): UseQueryResult => + useQuery({ + queryKey: ['gov', 'org', 'users', q, includeDisabled], + queryFn: () => + call(listOrgUsersRoute, { + query: { q: q === '' ? undefined : q, includeDisabled: includeDisabled ? '1' : undefined }, + }), + staleTime: 30_000, + }); + +export const useOrgUser = (sub: string | undefined): UseQueryResult => + useQuery({ + queryKey: ['gov', 'org', 'user', sub], + queryFn: () => call(getOrgUserRoute, { params: { sub: sub! } }), + enabled: sub !== undefined, + }); + +export const useOrgRoles = (): UseQueryResult => + useQuery({ queryKey: ['gov', 'org', 'roles'], queryFn: () => call(listOrgRolesRoute), staleTime: 30_000 }); + +export const useOrgGroups = (): UseQueryResult => + useQuery({ queryKey: ['gov', 'org', 'groups'], queryFn: () => call(listOrgGroupsRoute), staleTime: 30_000 }); + +export const useOrgGroupMembers = (groupId: string | undefined): UseQueryResult => + useQuery({ + queryKey: ['gov', 'org', 'group-members', groupId], + queryFn: () => call(listOrgGroupMembersRoute, { params: { id: groupId! } }), + enabled: groupId !== undefined, + }); + +/** 角色 → 端 —— 这张表是**本地**的(ADR-012),不是 Keycloak 的字段 */ +export const useRoleAccess = (): UseQueryResult => + useQuery({ queryKey: ['gov', 'org', 'role-access'], queryFn: () => call(listRoleAccessRoute) }); diff --git a/apps/web/src/shared/gov/gov-route.ts b/apps/web/src/shared/gov/gov-route.ts index a2780e6..7021d64 100644 --- a/apps/web/src/shared/gov/gov-route.ts +++ b/apps/web/src/shared/gov/gov-route.ts @@ -110,10 +110,10 @@ export const ZM: Readonly>> = { +/** `hint` 摆在页签栏右端 —— 一句话说清这一格管什么、写下去会发生什么 */ +export const TABS: Readonly< + Record> +> = { blueprint: [{ key: 'map', label: '企业蓝图', ico: 'route' }], /* * 测试台三格 —— 从前是一格加一个「两档旋钮」,旋钮长在内容里,别处跳不进来、 @@ -206,10 +209,16 @@ export const TABS: Readonly; + +export const orgUserDetailSchema = orgUserSchema.extend({ + firstName: z.string().optional(), + lastName: z.string().optional(), + emailVerified: z.boolean(), + /** 直属组;`path` 形如 `/技术部/平台组` */ + groups: z.array(z.object({ id: z.string(), path: z.string() })), +}); +export type OrgUserDetail = z.infer; + +export const listOrgUsersRoute = { + method: 'GET', + path: '/api/gov/org/users', + access: 'ops', + output: z.object({ users: z.array(orgUserSchema) }), +} as const satisfies RouteContract; + +export const getOrgUserRoute = { + method: 'GET', + path: '/api/gov/org/users/:sub', + access: 'ops', + params: z.object({ sub: z.string().min(1) }), + output: orgUserDetailSchema, +} as const satisfies RouteContract; + +/** + * 新建成员 —— 建完立刻能登录。口令是**临时**的:本人首登必须改 + * (`temporary:true`,手册 §14.2);我们不存它,也不回显。 + */ +export const createOrgUserRoute = { + method: 'POST', + path: '/api/gov/org/users', + access: 'ops', + body: z.object({ + username: z.string().min(1), + firstName: z.string().optional(), + lastName: z.string().optional(), + email: z.string().optional(), + /** 临时口令;不给就建一个登不进来的壳(人自己去改密码那条路) */ + password: z.string().min(1).optional(), + /** 一并授的 realm 角色;不给就只有 Keycloak 的默认角色 */ + roles: z.array(z.string()).optional(), + }), + output: orgUserSchema, +} as const satisfies RouteContract; + +/** 改资料(姓名 / 邮箱)—— 用户名建后不可改,不在这里 */ +export const patchOrgUserRoute = { + method: 'PATCH', + path: '/api/gov/org/users/:sub', + access: 'ops', + params: z.object({ sub: z.string().min(1) }), + body: z.object({ + firstName: z.string().optional(), + lastName: z.string().optional(), + email: z.string().optional(), + }), + output: orgUserSchema, +} as const satisfies RouteContract; + +/** + * 启用 / 停用。停用**立刻踢掉该用户全部会话** —— 不然他攥着的 token + * 还能用到过期为止,而「已停用」这三个字在界面上已经写出去了(界面不撒谎)。 + */ +export const setOrgUserEnabledRoute = { + method: 'POST', + path: '/api/gov/org/users/:sub/enabled', + access: 'ops', + params: z.object({ sub: z.string().min(1) }), + body: z.object({ enabled: z.boolean() }), + output: orgUserSchema, +} as const satisfies RouteContract; + +/** 重置口令 —— 一律临时口令,本人首登必须改;服务端不回显、不留痕 */ +export const resetOrgUserPasswordRoute = { + method: 'POST', + path: '/api/gov/org/users/:sub/reset-password', + access: 'ops', + params: z.object({ sub: z.string().min(1) }), + body: z.object({ password: z.string().min(1) }), + output: z.object({ ok: z.literal(true) }), +} as const satisfies RouteContract; + +/** 删人 —— 从 Keycloak 消失,不可撤销;本地名册那一行同批停用(留痕与在途还挂着他) */ +export const deleteOrgUserRoute = { + method: 'DELETE', + path: '/api/gov/org/users/:sub', + access: 'ops', + params: z.object({ sub: z.string().min(1) }), + output: z.object({ ok: z.literal(true) }), +} as const satisfies RouteContract; + +/* ── 权限:realm 角色 × 能进哪几个端 × 谁持有 ─────────────────────────── */ + +export const orgRoleSchema = z.object({ + name: z.string().min(1), + description: z.string().optional(), + /** Keycloak 的组合角色(展开后会带出别的角色) */ + composite: z.boolean(), + /** 这个角色能进哪几个端(`sys.role_access`;没配过 = 空 = 哪儿都进不去) */ + ends: z.array(surfaceSchema), + /** 持有它的成员数(已剔服务账号) */ + holders: z.number().int().nonnegative(), +}); +export type OrgRole = z.infer; + +export const listOrgRolesRoute = { + method: 'GET', + path: '/api/gov/org/roles', + access: 'ops', + output: z.object({ roles: z.array(orgRoleSchema) }), +} as const satisfies RouteContract; + +/** + * 授 / 收一个 realm 角色。收权**立刻踢掉该用户全部会话**(手册 §17.3)—— + * 否则他手上那枚 token 还带着旧角色,收权要等到 token 过期才生效。 + * 不能收自己的最后一条进运营中心的路(409)—— 收完没人进得来,这扇门就锁死了。 + */ +export const setOrgUserRoleRoute = { + method: 'POST', + path: '/api/gov/org/users/:sub/roles/:role', + access: 'ops', + params: z.object({ sub: z.string().min(1), role: z.string().min(1) }), + body: z.object({ granted: z.boolean() }), + output: orgUserSchema, +} as const satisfies RouteContract; + +/* ── 组织:Keycloak 组 ─────────────────────────────────────────────────── */ + +export const orgGroupSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + /** 从根拼下来,如 `/技术部/平台组` */ + path: z.string().min(1), + /** 顶层组为空 */ + parentId: z.string().optional(), + /** 0 = 顶层 */ + depth: z.number().int().nonnegative(), + /** 本组**直属**成员数(已剔服务账号,不含子组) */ + memberCount: z.number().int().nonnegative(), +}); +export type OrgGroup = z.infer; + +/** 组树平表,按 `path` 升序 —— 层级看 `parentId` / `depth` */ +export const listOrgGroupsRoute = { + method: 'GET', + path: '/api/gov/org/groups', + access: 'ops', + output: z.object({ groups: z.array(orgGroupSchema) }), +} as const satisfies RouteContract; + +/** 组的直属成员(含被停用的人 —— 这一屏管的是归属,不是能不能登录) */ +export const listOrgGroupMembersRoute = { + method: 'GET', + path: '/api/gov/org/groups/:id/members', + access: 'ops', + params: z.object({ id: z.string().min(1) }), + output: z.object({ members: z.array(orgUserSchema) }), +} as const satisfies RouteContract; + +/** 建组:`parentId` 给了就是子组,不给就是顶层组。同级不得重名(Keycloak 那一侧拦,409) */ +export const createOrgGroupRoute = { + method: 'POST', + path: '/api/gov/org/groups', + access: 'ops', + body: z.object({ name: z.string().min(1), parentId: z.string().optional() }), + output: orgGroupSchema, +} as const satisfies RouteContract; + +export const renameOrgGroupRoute = { + method: 'PATCH', + path: '/api/gov/org/groups/:id', + access: 'ops', + params: z.object({ id: z.string().min(1) }), + body: z.object({ name: z.string().min(1) }), + output: orgGroupSchema, +} as const satisfies RouteContract; + +/** 删组 —— 连同子组一起没(Keycloak 语义);人不会被删,只是不再属于这个组 */ +export const deleteOrgGroupRoute = { + method: 'DELETE', + path: '/api/gov/org/groups/:id', + access: 'ops', + params: z.object({ id: z.string().min(1) }), + output: z.object({ ok: z.literal(true) }), +} as const satisfies RouteContract; + +/** 成员进 / 出组 */ +export const setOrgUserGroupRoute = { + method: 'POST', + path: '/api/gov/org/users/:sub/groups/:groupId', + access: 'ops', + params: z.object({ sub: z.string().min(1), groupId: z.string().min(1) }), + body: z.object({ member: z.boolean() }), + output: z.object({ ok: z.literal(true) }), +} as const satisfies RouteContract; + +/** + * 这一屏的全部路由 —— 归属一处列全,测试拿它逐条断言。 + * 逐个 import 去断言的写法,新增一条只要忘了加进名单就悄悄漏过去了。 + */ +export const ORG_DIRECTORY_ROUTES = [ + listOrgUsersRoute, + getOrgUserRoute, + createOrgUserRoute, + patchOrgUserRoute, + setOrgUserEnabledRoute, + resetOrgUserPasswordRoute, + deleteOrgUserRoute, + listOrgRolesRoute, + setOrgUserRoleRoute, + listOrgGroupsRoute, + listOrgGroupMembersRoute, + createOrgGroupRoute, + renameOrgGroupRoute, + deleteOrgGroupRoute, + setOrgUserGroupRoute, +] as const satisfies readonly RouteContract[]; diff --git a/packages/contracts/src/org.ts b/packages/contracts/src/org.ts index e53c340..0f40110 100644 --- a/packages/contracts/src/org.ts +++ b/packages/contracts/src/org.ts @@ -21,6 +21,20 @@ export const STAFF_STATES = ['active', 'disabled'] as const; export const staffStateSchema = z.enum(STAFF_STATES); export type StaffState = z.infer; +/** + * 人,还是服务账号。 + * + * Keycloak 给每个开了 `serviceAccountsEnabled` 的 client 自动建一个用户,用户名恒为 + * `service-account-` —— 那是 Keycloak 自己的命名,不是我们的约定;它也不出现在 + * 管理台的 Users 列表、Admin API 的 `GET /users` 里(名册同步的下限保护②为此存在)。 + * + * 它是**真身份**(试跑与 e2e 就用它跑单,铁规矩 6),但**不是人**: + * 员工管理那一屏不摆它,试跑台的负责人下拉照旧摆。 + * 这条判断归后端 —— 界面不该去猜账号名长什么样。 + */ +export const staffKindSchema = z.enum(['person', 'service']); +export type StaffKind = z.infer; + export const staffSchema = z.object({ /** = Keycloak `sub`(手册硬约束 8:业务外键用 sub,不用用户名) */ id: staffIdSchema, @@ -35,6 +49,8 @@ export const staffSchema = z.object({ state: staffStateSchema.default('active'), /** realm 角色快照(token 签发时;噪声角色已滤) */ roles: z.array(z.string()).default([]), + /** 人 / 服务账号 —— 由账号名判(见 `staffKindSchema`),不落库 */ + kind: staffKindSchema.default('person'), }); export type Staff = z.infer;