feat(org): 成员目录 —— Keycloak 读写面穿透、治理台成员/组织/权限三页、姓在前 personName、Staff.kind

后端:`KeycloakDirectory` 端口(users / groups / roles 读写;写直接穿透 Keycloak —— 这里建的人立刻能登录、
删的人立刻消失,停用与收权之后 logout 踢掉会话)、`keycloak-directory.ts` + `keycloak-admin-http.ts` 适配器、
org-directory 契约与 /api/gov 路由、`sync-roster` CLI(ops 路由够不着时整册照过来),
容器按 provisioning 服务账号有无决定注不注入(没配就如实说不可用)。
`personName` 姓在前:汉字直拼「柴浩」、否则留一个空格「Admin Amy」,token 与名册同步两条路同一把尺;
`staffKindOf` 按 Keycloak 固定前缀 service-account- 认服务账号,员工那一屏不摆它(`Staff.kind` 不落库,按账号名现判)。
前端:员工那一屏重做成成员 / 组织 / 权限三页签(member-page 下钻),页签栏右端一句话说清这一格管什么。
契约变更:contracts org.ts / api/http.ts / api/org-directory.ts;api-report 已刷新;org CONTRACT.md 与 ADR-012 同步。
2026-08-26 做的,一直留在工作树没入库。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
This commit is contained in:
xcs
2026-08-27 11:44:36 +08:00
co-authored by Claude Fable 5
parent bd86dd6088
commit 8556432dba
50 changed files with 4242 additions and 402 deletions
+72 -2
View File
@@ -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<Principal>;
@@ -75,6 +112,39 @@ export type OrgService = {
deleteRoleAccess(role: string): Promise<void>;
/** 能进该端的活跃员工 —— 治理台通知的收件人 */
staffWithEnd(surface: Surface): Promise<StaffWithPosition[]>;
/**
* 目录**没配 provisioning 服务账号就整个不可用** —— 一律抛 `DirectoryUnavailableError`
* (路由译成 502 + 原话)。不给「读得了写不了」这种半开状态:那样人会以为是自己点错了。
* 服务账号在这一层就剔干净;写面对着服务账号的 sub 一律抛 `ServiceAccountProtectedError`。
*/
directoryUsers(query: {
q?: string;
includeDisabled?: boolean;
}): Promise<{
users: OrgUser[];
}>;
directoryUser(sub: string): Promise<OrgUserDetail>;
createDirectoryUser(input: CreateDirectoryUser): Promise<OrgUser>;
patchDirectoryUser(sub: string, patch: DirectoryUserPatch): Promise<OrgUser>;
/** 停用顺手踢会话 —— 界面写了「已停用」,那枚 token 就不该还能用到过期 */
setDirectoryUserEnabled(sub: string, enabled: boolean): Promise<OrgUser>;
resetDirectoryUserPassword(sub: string, password: string): Promise<void>;
deleteDirectoryUser(sub: string): Promise<void>;
directoryRoles(): Promise<{
roles: OrgRole[];
}>;
/** 授 / 收一个 realm 角色;收权顺手踢会话。抛 AccessLockoutError(409):收完没人进得了运营中心 */
setDirectoryUserRole(sub: string, role: string, granted: boolean): Promise<OrgUser>;
directoryGroups(): Promise<{
groups: OrgGroup[];
}>;
directoryGroupMembers(groupId: string): Promise<{
members: OrgUser[];
}>;
createDirectoryGroup(name: string, parentId?: string): Promise<OrgGroup>;
renameDirectoryGroup(groupId: string, name: string): Promise<OrgGroup>;
deleteDirectoryGroup(groupId: string): Promise<void>;
setDirectoryUserGroup(sub: string, groupId: string, member: boolean): Promise<void>;
/** 建组 / 改组;id 留空即新建 */
saveWorkGroup(input: {
id?: string;
+7 -3
View File
@@ -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';
+72 -3
View File
@@ -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<readonly RosterUser[]>;
};
/**
* 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<readonly DirectoryUser[]>;
getUser(sub: string): Promise<DirectoryUser & {
readonly groups: ReadonlyArray<{
id: string;
path: string;
}>;
}>;
createUser(input: CreateDirectoryUser): Promise<string>;
updateUser(sub: string, patch: DirectoryUserPatch): Promise<void>;
setEnabled(sub: string, enabled: boolean): Promise<void>;
resetPassword(sub: string, password: string): Promise<void>;
deleteUser(sub: string): Promise<void>;
/** 踢掉此人全部会话 —— 停用与收权之后必调,否则旧 token 到期前照样通行 */
logout(sub: string): Promise<void>;
listRoles(): Promise<readonly DirectoryRole[]>;
setRole(sub: string, role: string, granted: boolean): Promise<void>;
listGroups(): Promise<readonly DirectoryGroup[]>;
groupMembers(groupId: string): Promise<readonly DirectoryUser[]>;
/** 组的直属成员用户名 —— 组树只要个数,别为了数数把每个人的角色都拉一遍 */
groupMemberUsernames(groupId: string): Promise<readonly string[]>;
createGroup(name: string, parentId?: string): Promise<DirectoryGroup>;
renameGroup(groupId: string, name: string): Promise<DirectoryGroup>;
deleteGroup(groupId: string): Promise<void>;
setUserGroup(sub: string, groupId: string, member: boolean): Promise<void>;
};
/** 名册即时更新 / 同步要改的那几列 */
export type IdentityPatch = {
readonly account: string;
@@ -45,7 +109,12 @@ export type PositionRepository = {
}): Promise<Position>;
};
/** 员工行 —— 契约 Staff 之外还带两枚时刻,只给用例判「要不要再写一次」,不出模块 */
export type StaffRecord = Staff & {
/**
* 库里真存着的那几列。`Staff.kind` 不在其中 —— 它由账号名现判(见 `staffKindSchema`),
* 存一列就会与账号名分叉,而分叉之后没人看得出以哪份为准。
*/
export type StoredStaff = Omit<Staff, 'kind'>;
export type StaffRecord = StoredStaff & {
readonly lastSeenAt?: Date;
readonly syncedAt?: Date;
};
@@ -55,7 +124,7 @@ export type StaffRepository = {
/** 按账号找 —— `account` 有唯一索引,建档前得先问一句「这个名字被谁占着」(同名让位) */
findByAccount(account: string): Promise<StaffRecord | undefined>;
listByPosition(positionId: PositionId): Promise<StaffRecord[]>;
insert(staff: Staff, marks: {
insert(staff: StoredStaff, marks: {
readonly lastSeenAt?: Date;
readonly syncedAt?: Date;
}): Promise<StaffRecord>;
+12
View File
@@ -19,6 +19,10 @@ export declare const listStaffRoute: {
disabled: "disabled";
}>>;
roles: z.ZodDefault<z.ZodArray<z.ZodString>>;
kind: z.ZodDefault<z.ZodEnum<{
person: "person";
service: "service";
}>>;
position: z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
code: z.ZodString;
@@ -55,6 +59,10 @@ export declare const createStaffRoute: {
disabled: "disabled";
}>>;
roles: z.ZodDefault<z.ZodArray<z.ZodString>>;
kind: z.ZodDefault<z.ZodEnum<{
person: "person";
service: "service";
}>>;
}, z.core.$strip>;
};
/** @deprecated 随 ADR-012 退役,后端回 410;改用 staffInFlightRoute */
@@ -266,6 +274,10 @@ export declare const setStaffPositionRoute: {
disabled: "disabled";
}>>;
roles: z.ZodDefault<z.ZodArray<z.ZodString>>;
kind: z.ZodDefault<z.ZodEnum<{
person: "person";
service: "service";
}>>;
position: z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
code: z.ZodString;
+1 -1
View File
@@ -41,7 +41,7 @@ export type RouteContract<TParams extends z.ZodTypeAny = z.ZodTypeAny, TQuery ex
export type RouteOutput<R extends RouteContract> = z.infer<R['output']>;
export type RouteBody<R extends RouteContract> = R['body'] extends z.ZodTypeAny ? z.infer<R['body']> : 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;
+1
View File
@@ -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';
+701
View File
@@ -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<z.ZodString>;
enabled: z.ZodBoolean;
roles: z.ZodArray<z.ZodString>;
ends: z.ZodArray<z.ZodEnum<{
design: "design";
ops: "ops";
workbench: "workbench";
}>>;
lastSeenAt: z.ZodOptional<z.ZodString>;
positionCode: z.ZodOptional<z.ZodString>;
}, z.core.$strip>;
export type OrgUser = z.infer<typeof orgUserSchema>;
export declare const orgUserDetailSchema: z.ZodObject<{
sub: z.ZodString;
username: z.ZodString;
displayName: z.ZodString;
email: z.ZodOptional<z.ZodString>;
enabled: z.ZodBoolean;
roles: z.ZodArray<z.ZodString>;
ends: z.ZodArray<z.ZodEnum<{
design: "design";
ops: "ops";
workbench: "workbench";
}>>;
lastSeenAt: z.ZodOptional<z.ZodString>;
positionCode: z.ZodOptional<z.ZodString>;
firstName: z.ZodOptional<z.ZodString>;
lastName: z.ZodOptional<z.ZodString>;
emailVerified: z.ZodBoolean;
groups: z.ZodArray<z.ZodObject<{
id: z.ZodString;
path: z.ZodString;
}, z.core.$strip>>;
}, z.core.$strip>;
export type OrgUserDetail = z.infer<typeof orgUserDetailSchema>;
export declare const listOrgUsersRoute: {
readonly method: "GET";
readonly path: "/api/gov/org/users";
readonly access: "ops";
readonly output: z.ZodObject<{
users: z.ZodArray<z.ZodObject<{
sub: z.ZodString;
username: z.ZodString;
displayName: z.ZodString;
email: z.ZodOptional<z.ZodString>;
enabled: z.ZodBoolean;
roles: z.ZodArray<z.ZodString>;
ends: z.ZodArray<z.ZodEnum<{
design: "design";
ops: "ops";
workbench: "workbench";
}>>;
lastSeenAt: z.ZodOptional<z.ZodString>;
positionCode: z.ZodOptional<z.ZodString>;
}, 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<z.ZodString>;
enabled: z.ZodBoolean;
roles: z.ZodArray<z.ZodString>;
ends: z.ZodArray<z.ZodEnum<{
design: "design";
ops: "ops";
workbench: "workbench";
}>>;
lastSeenAt: z.ZodOptional<z.ZodString>;
positionCode: z.ZodOptional<z.ZodString>;
firstName: z.ZodOptional<z.ZodString>;
lastName: z.ZodOptional<z.ZodString>;
emailVerified: z.ZodBoolean;
groups: z.ZodArray<z.ZodObject<{
id: z.ZodString;
path: z.ZodString;
}, z.core.$strip>>;
}, 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<z.ZodString>;
lastName: z.ZodOptional<z.ZodString>;
email: z.ZodOptional<z.ZodString>;
password: z.ZodOptional<z.ZodString>;
roles: z.ZodOptional<z.ZodArray<z.ZodString>>;
}, z.core.$strip>;
readonly output: z.ZodObject<{
sub: z.ZodString;
username: z.ZodString;
displayName: z.ZodString;
email: z.ZodOptional<z.ZodString>;
enabled: z.ZodBoolean;
roles: z.ZodArray<z.ZodString>;
ends: z.ZodArray<z.ZodEnum<{
design: "design";
ops: "ops";
workbench: "workbench";
}>>;
lastSeenAt: z.ZodOptional<z.ZodString>;
positionCode: z.ZodOptional<z.ZodString>;
}, 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<z.ZodString>;
lastName: z.ZodOptional<z.ZodString>;
email: z.ZodOptional<z.ZodString>;
}, z.core.$strip>;
readonly output: z.ZodObject<{
sub: z.ZodString;
username: z.ZodString;
displayName: z.ZodString;
email: z.ZodOptional<z.ZodString>;
enabled: z.ZodBoolean;
roles: z.ZodArray<z.ZodString>;
ends: z.ZodArray<z.ZodEnum<{
design: "design";
ops: "ops";
workbench: "workbench";
}>>;
lastSeenAt: z.ZodOptional<z.ZodString>;
positionCode: z.ZodOptional<z.ZodString>;
}, 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<z.ZodString>;
enabled: z.ZodBoolean;
roles: z.ZodArray<z.ZodString>;
ends: z.ZodArray<z.ZodEnum<{
design: "design";
ops: "ops";
workbench: "workbench";
}>>;
lastSeenAt: z.ZodOptional<z.ZodString>;
positionCode: z.ZodOptional<z.ZodString>;
}, 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<true>;
}, 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<true>;
}, z.core.$strip>;
};
export declare const orgRoleSchema: z.ZodObject<{
name: z.ZodString;
description: z.ZodOptional<z.ZodString>;
composite: z.ZodBoolean;
ends: z.ZodArray<z.ZodEnum<{
design: "design";
ops: "ops";
workbench: "workbench";
}>>;
holders: z.ZodNumber;
}, z.core.$strip>;
export type OrgRole = z.infer<typeof orgRoleSchema>;
export declare const listOrgRolesRoute: {
readonly method: "GET";
readonly path: "/api/gov/org/roles";
readonly access: "ops";
readonly output: z.ZodObject<{
roles: z.ZodArray<z.ZodObject<{
name: z.ZodString;
description: z.ZodOptional<z.ZodString>;
composite: z.ZodBoolean;
ends: z.ZodArray<z.ZodEnum<{
design: "design";
ops: "ops";
workbench: "workbench";
}>>;
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<z.ZodString>;
enabled: z.ZodBoolean;
roles: z.ZodArray<z.ZodString>;
ends: z.ZodArray<z.ZodEnum<{
design: "design";
ops: "ops";
workbench: "workbench";
}>>;
lastSeenAt: z.ZodOptional<z.ZodString>;
positionCode: z.ZodOptional<z.ZodString>;
}, z.core.$strip>;
};
export declare const orgGroupSchema: z.ZodObject<{
id: z.ZodString;
name: z.ZodString;
path: z.ZodString;
parentId: z.ZodOptional<z.ZodString>;
depth: z.ZodNumber;
memberCount: z.ZodNumber;
}, z.core.$strip>;
export type OrgGroup = z.infer<typeof orgGroupSchema>;
/** 组树平表,按 `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<z.ZodObject<{
id: z.ZodString;
name: z.ZodString;
path: z.ZodString;
parentId: z.ZodOptional<z.ZodString>;
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<z.ZodObject<{
sub: z.ZodString;
username: z.ZodString;
displayName: z.ZodString;
email: z.ZodOptional<z.ZodString>;
enabled: z.ZodBoolean;
roles: z.ZodArray<z.ZodString>;
ends: z.ZodArray<z.ZodEnum<{
design: "design";
ops: "ops";
workbench: "workbench";
}>>;
lastSeenAt: z.ZodOptional<z.ZodString>;
positionCode: z.ZodOptional<z.ZodString>;
}, 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.ZodString>;
}, z.core.$strip>;
readonly output: z.ZodObject<{
id: z.ZodString;
name: z.ZodString;
path: z.ZodString;
parentId: z.ZodOptional<z.ZodString>;
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<z.ZodString>;
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<true>;
}, 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<true>;
}, 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<z.ZodObject<{
sub: z.ZodString;
username: z.ZodString;
displayName: z.ZodString;
email: z.ZodOptional<z.ZodString>;
enabled: z.ZodBoolean;
roles: z.ZodArray<z.ZodString>;
ends: z.ZodArray<z.ZodEnum<{
design: "design";
ops: "ops";
workbench: "workbench";
}>>;
lastSeenAt: z.ZodOptional<z.ZodString>;
positionCode: z.ZodOptional<z.ZodString>;
}, 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<z.ZodString>;
enabled: z.ZodBoolean;
roles: z.ZodArray<z.ZodString>;
ends: z.ZodArray<z.ZodEnum<{
design: "design";
ops: "ops";
workbench: "workbench";
}>>;
lastSeenAt: z.ZodOptional<z.ZodString>;
positionCode: z.ZodOptional<z.ZodString>;
firstName: z.ZodOptional<z.ZodString>;
lastName: z.ZodOptional<z.ZodString>;
emailVerified: z.ZodBoolean;
groups: z.ZodArray<z.ZodObject<{
id: z.ZodString;
path: z.ZodString;
}, z.core.$strip>>;
}, 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<z.ZodString>;
lastName: z.ZodOptional<z.ZodString>;
email: z.ZodOptional<z.ZodString>;
password: z.ZodOptional<z.ZodString>;
roles: z.ZodOptional<z.ZodArray<z.ZodString>>;
}, z.core.$strip>;
readonly output: z.ZodObject<{
sub: z.ZodString;
username: z.ZodString;
displayName: z.ZodString;
email: z.ZodOptional<z.ZodString>;
enabled: z.ZodBoolean;
roles: z.ZodArray<z.ZodString>;
ends: z.ZodArray<z.ZodEnum<{
design: "design";
ops: "ops";
workbench: "workbench";
}>>;
lastSeenAt: z.ZodOptional<z.ZodString>;
positionCode: z.ZodOptional<z.ZodString>;
}, 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<z.ZodString>;
lastName: z.ZodOptional<z.ZodString>;
email: z.ZodOptional<z.ZodString>;
}, z.core.$strip>;
readonly output: z.ZodObject<{
sub: z.ZodString;
username: z.ZodString;
displayName: z.ZodString;
email: z.ZodOptional<z.ZodString>;
enabled: z.ZodBoolean;
roles: z.ZodArray<z.ZodString>;
ends: z.ZodArray<z.ZodEnum<{
design: "design";
ops: "ops";
workbench: "workbench";
}>>;
lastSeenAt: z.ZodOptional<z.ZodString>;
positionCode: z.ZodOptional<z.ZodString>;
}, 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<z.ZodString>;
enabled: z.ZodBoolean;
roles: z.ZodArray<z.ZodString>;
ends: z.ZodArray<z.ZodEnum<{
design: "design";
ops: "ops";
workbench: "workbench";
}>>;
lastSeenAt: z.ZodOptional<z.ZodString>;
positionCode: z.ZodOptional<z.ZodString>;
}, 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<true>;
}, 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<true>;
}, z.core.$strip>;
}, {
readonly method: "GET";
readonly path: "/api/gov/org/roles";
readonly access: "ops";
readonly output: z.ZodObject<{
roles: z.ZodArray<z.ZodObject<{
name: z.ZodString;
description: z.ZodOptional<z.ZodString>;
composite: z.ZodBoolean;
ends: z.ZodArray<z.ZodEnum<{
design: "design";
ops: "ops";
workbench: "workbench";
}>>;
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<z.ZodString>;
enabled: z.ZodBoolean;
roles: z.ZodArray<z.ZodString>;
ends: z.ZodArray<z.ZodEnum<{
design: "design";
ops: "ops";
workbench: "workbench";
}>>;
lastSeenAt: z.ZodOptional<z.ZodString>;
positionCode: z.ZodOptional<z.ZodString>;
}, z.core.$strip>;
}, {
readonly method: "GET";
readonly path: "/api/gov/org/groups";
readonly access: "ops";
readonly output: z.ZodObject<{
groups: z.ZodArray<z.ZodObject<{
id: z.ZodString;
name: z.ZodString;
path: z.ZodString;
parentId: z.ZodOptional<z.ZodString>;
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<z.ZodObject<{
sub: z.ZodString;
username: z.ZodString;
displayName: z.ZodString;
email: z.ZodOptional<z.ZodString>;
enabled: z.ZodBoolean;
roles: z.ZodArray<z.ZodString>;
ends: z.ZodArray<z.ZodEnum<{
design: "design";
ops: "ops";
workbench: "workbench";
}>>;
lastSeenAt: z.ZodOptional<z.ZodString>;
positionCode: z.ZodOptional<z.ZodString>;
}, 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.ZodString>;
}, z.core.$strip>;
readonly output: z.ZodObject<{
id: z.ZodString;
name: z.ZodString;
path: z.ZodString;
parentId: z.ZodOptional<z.ZodString>;
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<z.ZodString>;
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<true>;
}, 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<true>;
}, z.core.$strip>;
}];
+16
View File
@@ -20,6 +20,10 @@ export declare const sessionRoute: {
disabled: "disabled";
}>>;
roles: z.ZodDefault<z.ZodArray<z.ZodString>>;
kind: z.ZodDefault<z.ZodEnum<{
person: "person";
service: "service";
}>>;
}, z.core.$strip>;
position: z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
@@ -80,6 +84,10 @@ export declare const loginRoute: {
disabled: "disabled";
}>>;
roles: z.ZodDefault<z.ZodArray<z.ZodString>>;
kind: z.ZodDefault<z.ZodEnum<{
person: "person";
service: "service";
}>>;
}, z.core.$strip>;
position: z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
@@ -158,6 +166,10 @@ export declare const meRoute: {
disabled: "disabled";
}>>;
roles: z.ZodDefault<z.ZodArray<z.ZodString>>;
kind: z.ZodDefault<z.ZodEnum<{
person: "person";
service: "service";
}>>;
}, z.core.$strip>;
position: z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
@@ -891,6 +903,7 @@ export declare const taskDetailSchema: z.ZodObject<{
cols: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<1>, z.ZodLiteral<2>]>>;
}, z.core.$strip>>;
submitLabel: z.ZodOptional<z.ZodString>;
gateLedgers: z.ZodOptional<z.ZodArray<z.ZodString>>;
gate: z.ZodOptional<z.ZodObject<{
ready: z.ZodBoolean;
why: z.ZodString;
@@ -1475,6 +1488,7 @@ export declare const taskDetailSchema: z.ZodObject<{
cols: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<1>, z.ZodLiteral<2>]>>;
}, z.core.$strip>>;
submitLabel: z.ZodOptional<z.ZodString>;
gateLedgers: z.ZodOptional<z.ZodArray<z.ZodString>>;
gate: z.ZodOptional<z.ZodObject<{
ready: z.ZodBoolean;
why: z.ZodString;
@@ -2796,6 +2810,7 @@ export declare const taskDetailRoute: {
cols: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<1>, z.ZodLiteral<2>]>>;
}, z.core.$strip>>;
submitLabel: z.ZodOptional<z.ZodString>;
gateLedgers: z.ZodOptional<z.ZodArray<z.ZodString>>;
gate: z.ZodOptional<z.ZodObject<{
ready: z.ZodBoolean;
why: z.ZodString;
@@ -3380,6 +3395,7 @@ export declare const taskDetailRoute: {
cols: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<1>, z.ZodLiteral<2>]>>;
}, z.core.$strip>>;
submitLabel: z.ZodOptional<z.ZodString>;
gateLedgers: z.ZodOptional<z.ZodArray<z.ZodString>>;
gate: z.ZodOptional<z.ZodObject<{
ready: z.ZodBoolean;
why: z.ZodString;
+28
View File
@@ -19,6 +19,22 @@ export declare const staffStateSchema: z.ZodEnum<{
disabled: "disabled";
}>;
export type StaffState = z.infer<typeof staffStateSchema>;
/**
* 人,还是服务账号。
*
* Keycloak 给每个开了 `serviceAccountsEnabled` 的 client 自动建一个用户,用户名恒为
* `service-account-<clientId>` —— 那是 Keycloak 自己的命名,不是我们的约定;它也不出现在
* 管理台的 Users 列表、Admin API 的 `GET /users` 里(名册同步的下限保护②为此存在)。
*
* 它是**真身份**(试跑与 e2e 就用它跑单,铁规矩 6),但**不是人**:
* 员工管理那一屏不摆它,试跑台的负责人下拉照旧摆。
* 这条判断归后端 —— 界面不该去猜账号名长什么样。
*/
export declare const staffKindSchema: z.ZodEnum<{
person: "person";
service: "service";
}>;
export type StaffKind = z.infer<typeof staffKindSchema>;
export declare const staffSchema: z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
account: z.ZodString;
@@ -30,6 +46,10 @@ export declare const staffSchema: z.ZodObject<{
disabled: "disabled";
}>>;
roles: z.ZodDefault<z.ZodArray<z.ZodString>>;
kind: z.ZodDefault<z.ZodEnum<{
person: "person";
service: "service";
}>>;
}, z.core.$strip>;
export type Staff = z.infer<typeof staffSchema>;
export declare const principalSchema: z.ZodObject<{
@@ -44,6 +64,10 @@ export declare const principalSchema: z.ZodObject<{
disabled: "disabled";
}>>;
roles: z.ZodDefault<z.ZodArray<z.ZodString>>;
kind: z.ZodDefault<z.ZodEnum<{
person: "person";
service: "service";
}>>;
}, z.core.$strip>;
position: z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
@@ -226,6 +250,10 @@ export declare const sessionSchema: z.ZodObject<{
disabled: "disabled";
}>>;
roles: z.ZodDefault<z.ZodArray<z.ZodString>>;
kind: z.ZodDefault<z.ZodEnum<{
person: "person";
service: "service";
}>>;
}, z.core.$strip>;
position: z.ZodObject<{
id: z.core.$ZodBranded<z.ZodString, "PositionId", "out">;
+2 -1
View File
@@ -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",
@@ -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,
@@ -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']);
}
});
});
+16 -1
View File
@@ -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<Record<string, unkno
export type ContainerOverrides = {
readonly tokenVerifier?: TokenVerifier;
readonly roster?: RosterSource;
readonly directory?: KeycloakDirectory;
};
export async function createContainer(config: AppConfig, overrides: ContainerOverrides = {}): Promise<Container> {
@@ -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),
+196
View File
@@ -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<T>(run: () => Promise<T>): 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<AppEnv>, container: Container): void {
/**
* zod 出来的可选字段是 `string | undefined`,而入参类型在 `exactOptionalPropertyTypes`
* 下要的是「要么有、要么这个键不存在」—— 显式的 undefined 在这儿不算「没填」。
* 全仓同一个惯用法:条件展开。
*/
function defined<T extends Record<string, unknown>>(input: T): { [K in keyof T]: Exclude<T[K], undefined> } {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(input)) if (v !== undefined) out[k] = v;
return out as { [K in keyof T]: Exclude<T[K], undefined> };
}
/** 校验 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);
});
}
+2
View File
@@ -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<AppEnv> {
registerWorkbenchRoutes(app, container);
registerGovernanceRoutes(app, container);
registerOrgDirectoryRoutes(app, container);
registerDeskRoutes(app, container);
registerDeskElementRoutes(app, container);
registerDeskApprovalRoutes(app, container);
+46
View File
@@ -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<void> {
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();
}
+19
View File
@@ -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-<clientId>` 是 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` 只出成员表快照,在岗与否由调用方核
@@ -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-<clientId> 认作服务账号', () => {
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');
});
});
@@ -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<string, Position>();
const staff = new Map<string, StaffRecord>();
const roleAccess = new Map<string, RoleAccess>([
['admin', { role: 'admin', ends: ['design', 'ops', 'workbench'] }],
['user', { role: 'user', ends: ['workbench'] }],
]);
const groups = new Map<string, WorkGroup>();
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']);
});
});
@@ -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> = {}): DirectoryUser => ({
sub: 'sub-1',
username: 'chaihao',
enabled: true,
emailVerified: false,
roles: ['admin', 'user'],
...over,
});
describe('服务账号:是身份,不是人', () => {
it('Keycloak 的 service-account-<clientId> 认得出', () => {
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<StoredStaff & { lastSeenAt?: Date }> = [
{
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);
});
});
@@ -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);
});
@@ -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();
});
@@ -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();
@@ -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> = {}): Staff => ({
const local = (sub: string, over: Partial<StoredStaff> = {}): StoredStaff => ({
id: staffIdSchema.parse(sub),
account: sub,
name: sub,
+76 -1
View File
@@ -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<StaffWithPosition[]>;
/* ── 成员目录(Keycloak Admin API 的读写面;ADR-012) ────────────────── */
/**
* 目录**没配 provisioning 服务账号就整个不可用** —— 一律抛 `DirectoryUnavailableError`
* (路由译成 502 + 原话)。不给「读得了写不了」这种半开状态:那样人会以为是自己点错了。
* 服务账号在这一层就剔干净;写面对着服务账号的 sub 一律抛 `ServiceAccountProtectedError`。
*/
directoryUsers(query: { q?: string; includeDisabled?: boolean }): Promise<{ users: OrgUser[] }>;
directoryUser(sub: string): Promise<OrgUserDetail>;
createDirectoryUser(input: CreateDirectoryUser): Promise<OrgUser>;
patchDirectoryUser(sub: string, patch: DirectoryUserPatch): Promise<OrgUser>;
/** 停用顺手踢会话 —— 界面写了「已停用」,那枚 token 就不该还能用到过期 */
setDirectoryUserEnabled(sub: string, enabled: boolean): Promise<OrgUser>;
resetDirectoryUserPassword(sub: string, password: string): Promise<void>;
deleteDirectoryUser(sub: string): Promise<void>;
directoryRoles(): Promise<{ roles: OrgRole[] }>;
/** 授 / 收一个 realm 角色;收权顺手踢会话。抛 AccessLockoutError(409):收完没人进得了运营中心 */
setDirectoryUserRole(sub: string, role: string, granted: boolean): Promise<OrgUser>;
directoryGroups(): Promise<{ groups: OrgGroup[] }>;
directoryGroupMembers(groupId: string): Promise<{ members: OrgUser[] }>;
createDirectoryGroup(name: string, parentId?: string): Promise<OrgGroup>;
renameDirectoryGroup(groupId: string, name: string): Promise<OrgGroup>;
deleteDirectoryGroup(groupId: string): Promise<void>;
setDirectoryUserGroup(sub: string, groupId: string, member: boolean): Promise<void>;
/* ── 工作组(ADR-011)—— 起单时的岗位绑定,不是第五种策略 ────────────── */
/** 建组 / 改组;id 留空即新建 */
+24
View File
@@ -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,
@@ -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<T>(path: string): Promise<T>;
/** 建资源:成功 201 空体,回 `Location` 末段(新 id);Keycloak 不给就回 undefined */
post(path: string, body?: unknown): Promise<string | undefined>;
put(path: string, body?: unknown): Promise<void>;
/** `body` 可选是因为**撤 realm 角色是带 body 的 DELETE**(手册 §14.4),不是笔误 */
delete(path: string, body?: unknown): Promise<void>;
};
/** 列表接口默认 `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<Response> {
try {
return await fetchImpl(path, init);
} catch (error) {
throw new KeycloakUnavailableError(`够不着 Keycloak:${error instanceof Error ? error.message : String(error)}`);
}
}
async function adminToken(): Promise<string> {
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<Response> {
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<T>(path: string): Promise<T> {
return (await (await send('GET', path)).json()) as T;
},
async post(path: string, body?: unknown): Promise<string | undefined> {
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<void> {
await send('PUT', path, body);
},
async delete(path: string, body?: unknown): Promise<void> {
await send('DELETE', path, body);
},
};
}
@@ -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<KcRole[]>(`/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)),
@@ -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<T, R>(items: readonly T[], limit: number, run: (item: T) => Promise<R>): Promise<R[]> {
const out: R[] = new Array<R>(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<string[]> {
const roles = await api.get<KcRole[]>(`/users/${enc(sub)}/role-mappings/realm/composite`);
return roles.map((r) => r.name);
}
async function allUsers(): Promise<KcUser[]> {
const out: KcUser[] = [];
for (let first = 0; ; first += ADMIN_PAGE) {
const page = await api.get<KcUser[]>(`/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<void> {
await api.put(`/users/${enc(sub)}/reset-password`, { type: 'password', value: password, temporary: true });
}
async function setRole(sub: string, role: string, granted: boolean): Promise<void> {
// 授/撤的 body 是**角色对象数组**(手册 §14.4)—— 光给名字 Keycloak 不认
const all = await api.get<Array<KcRole & { id: string }>>('/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<KcUser[]> {
const members: KcUser[] = [];
for (let first = 0; ; first += ADMIN_PAGE) {
const page = await api.get<KcUser[]>(`/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<DirectoryGroup> {
const g = await api.get<KcGroup>(`/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<KcUser>(`/users/${enc(sub)}`);
const [roles, groups] = await Promise.all([
rolesOf(sub),
api.get<Array<{ id: string; path: string }>>(`/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<KcUser>(`/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<KcUser>(`/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<readonly DirectoryRole[]> {
const roles = await api.get<KcRole[]>('/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<KcGroup[]>('/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<KcGroup>(`/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);
},
};
}
@@ -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<typeof claimsSchema>): 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
? {}
@@ -5,11 +5,51 @@
* Keycloak 给每个用户自动挂的三个「家具角色」不是业务角色,这里滤掉:
* `default-roles-<realm>` / `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<string> = new Set(['offline_access', 'uma_authorization']);
/**
* Keycloak 给服务账号用户的固定命名 —— **它自己定的**,不是我们的约定:
* 开了 `serviceAccountsEnabled` 的 client 一律配一个 `service-account-<clientId>`。
* 按名字认而不按角色认,是因为角色是每个 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,
@@ -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<DirectoryUser, 'username' | 'firstName' | 'lastName'>): 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<string, number>): 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<StoredStaff & { readonly lastSeenAt?: Date }>,
positionCodeOf: (positionId: string) => string | undefined,
): Map<string, LocalMark> {
const out = new Map<string, LocalMark>();
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;
}
@@ -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<string, Staff>(local.map((s) => [s.id, s]));
const bySub = new Map<string, StoredStaff>(local.map((s) => [s.id, s]));
const diff: RosterDiff = { insert: [], update: [], setState: [] };
const seen = new Set<string>();
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<string, Staff['state']>(local.map((s) => [s.id, s.state]));
const roles = new Map<string, readonly string[]>(local.map((s) => [s.id, s.roles]));
for (const u of diff.update) roles.set(u.id, u.patch.roles);
@@ -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<T>(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<T>(run: () => Promise<T>): Promise<T> {
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<Map<string, LocalMark>> {
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<OrgUser[]> {
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<T>(scope: 'users' | 'groups' | 'all', run: () => Promise<T>): Promise<T> {
try {
return await guardKeycloak(run);
} finally {
bustDirectoryCache(scope);
}
}
/** 一次写之后回那一行的当下样子 —— 界面照它重画,不必自己拼 */
async function oneUser(sub: string): Promise<OrgUser> {
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<DirectoryUser, 'username'>): void {
if (isServiceAccount(user.username)) throw new ServiceAccountProtectedError(user.username);
}
/** 写面开工之前先问一句「这个 sub 是人吗」—— 服务账号一律不许动 */
async function assertPersonBySub(sub: string): Promise<void> {
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<OrgUserDetail> {
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) {
+74 -3
View File
@@ -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<VerifiedClaims> };
export type RosterUser = Identity & { readonly enabled: boolean };
export type RosterSource = { listUsers(): Promise<readonly RosterUser[]> };
/* ── 成员目录(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<readonly DirectoryUser[]>;
getUser(sub: string): Promise<DirectoryUser & { readonly groups: ReadonlyArray<{ id: string; path: string }> }>;
createUser(input: CreateDirectoryUser): Promise<string>;
updateUser(sub: string, patch: DirectoryUserPatch): Promise<void>;
setEnabled(sub: string, enabled: boolean): Promise<void>;
resetPassword(sub: string, password: string): Promise<void>;
deleteUser(sub: string): Promise<void>;
/** 踢掉此人全部会话 —— 停用与收权之后必调,否则旧 token 到期前照样通行 */
logout(sub: string): Promise<void>;
listRoles(): Promise<readonly DirectoryRole[]>;
setRole(sub: string, role: string, granted: boolean): Promise<void>;
listGroups(): Promise<readonly DirectoryGroup[]>;
groupMembers(groupId: string): Promise<readonly DirectoryUser[]>;
/** 组的直属成员用户名 —— 组树只要个数,别为了数数把每个人的角色都拉一遍 */
groupMemberUsernames(groupId: string): Promise<readonly string[]>;
createGroup(name: string, parentId?: string): Promise<DirectoryGroup>;
renameGroup(groupId: string, name: string): Promise<DirectoryGroup>;
deleteGroup(groupId: string): Promise<void>;
setUserGroup(sub: string, groupId: string, member: boolean): Promise<void>;
};
/** 名册即时更新 / 同步要改的那几列 */
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<Staff, 'kind'>;
export type StaffRecord = StoredStaff & { readonly lastSeenAt?: Date; readonly syncedAt?: Date };
export type StaffRepository = {
list(): Promise<StaffRecord[]>;
@@ -52,7 +123,7 @@ export type StaffRepository = {
/** 按账号找 —— `account` 有唯一索引,建档前得先问一句「这个名字被谁占着」(同名让位) */
findByAccount(account: string): Promise<StaffRecord | undefined>;
listByPosition(positionId: PositionId): Promise<StaffRecord[]>;
insert(staff: Staff, marks: { readonly lastSeenAt?: Date; readonly syncedAt?: Date }): Promise<StaffRecord>;
insert(staff: StoredStaff, marks: { readonly lastSeenAt?: Date; readonly syncedAt?: Date }): Promise<StaffRecord>;
updateIdentity(id: StaffId, patch: IdentityPatch): Promise<void>;
setPosition(id: StaffId, positionId: PositionId): Promise<void>;
setState(id: StaffId, state: Staff['state'], syncedAt: Date): Promise<void>;
+2
View File
@@ -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',
+6 -1
View File
@@ -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 }) {
* 摆一个只有一颗按钮的导航条,是在给人做无谓的选择。
* 台账例外 —— 它的页签是随发布生长的数据域,数量本来就不定。
*/}
<div className={'g-tabs' + (zone !== 'ledgers' && TABS[zone].length <= 1 ? ' hidden' : '')}>
<div className={'g-tabsrow' + (zone !== 'ledgers' && TABS[zone].length <= 1 ? ' hidden' : '')}>
<div className="g-tabs">
{zone === 'ledgers'
? // 台账的页签是**数据域**(短名 2-3 字),随发布生长 —— 设计产生运营的形状,落在页签上
domains.map((d) => (
@@ -273,6 +275,9 @@ export function GovShell({ surface }: { surface: Surface }) {
{t.label}
</button>
))}
</div>
{/* 右端一句话:这一格管什么、写下去会发生什么 —— 界面自己说清楚,不靠人记 */}
{tabHint !== undefined && <span className="g-tabs-hint">{tabHint}</span>}
</div>
<ZoneHome zone={zone} tab={effectiveTab} nav={nav} />
</>
+17
View File
@@ -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; }
@@ -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<string | undefined>(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 (
<QueryGate query={q}>
{u === undefined ? (
<Clear>{`Keycloak 里没有这个人(${id})—— 可能刚被删了。`}</Clear>
) : (
<>
<PageHead
ico="user-round"
kicker="成员"
title={u.displayName}
chips={
<>
<Chip ico="at-sign">{u.username}</Chip>
<St tone={u.enabled ? 'ok' : 'mute'} ico={u.enabled ? 'circle-check' : 'power'}>
{u.enabled ? '在职' : '已停用'}
</St>
</>
}
desc="身份与角色的真源是 Keycloak —— 这一屏的每个动作都直接改那边,没有撤销。"
/>
{err !== undefined && <Note tone="warn">{err}</Note>}
<Card ico="user-check" title="身份">
<Rows
items={[
{ k: '用户名', v: u.username, mono: true },
{ k: '姓名', v: u.displayName },
{ k: '邮箱', v: u.email ?? '—' },
{ k: '邮箱已验证', v: u.emailVerified ? '是' : '否' },
{ k: 'sub', v: u.sub, mono: true },
{ k: '本地岗位', v: u.positionCode ?? '未分配' },
{ k: '几时来过', v: u.lastSeenAt ?? '还没拿这枚身份访问过本系统' },
]}
/>
{editing === undefined ? (
<Acts
align="end"
items={[
{
label: '改资料',
ico: 'pencil-line',
onClick: () =>
setEditing({ firstName: u.firstName ?? '', lastName: u.lastName ?? '', email: u.email ?? '' }),
},
]}
/>
) : (
<div className="g-prop">
<div className="g-prop-h">改资料(用户名建后不可改)</div>
<div className="g-prop-b">
<Field k="姓">
<input className="g-input" value={editing.lastName} onChange={(e) => setEditing({ ...editing, lastName: e.target.value })} />
</Field>
<Field k="名">
<input className="g-input" value={editing.firstName} onChange={(e) => setEditing({ ...editing, firstName: e.target.value })} />
</Field>
<Field k="邮箱">
<input className="g-input" value={editing.email} onChange={(e) => setEditing({ ...editing, email: e.target.value })} />
</Field>
<Acts
align="end"
items={[
{ label: '取消', onClick: () => setEditing(undefined) },
{ label: '保存', ico: 'check', primary: true, busy: patch.isPending, onClick: () => patch.mutate(editing) },
]}
/>
</div>
</div>
)}
</Card>
<Card ico="shield-check" title="能进哪几个端" extra={<span className="x">由下面的角色 × 「权限」页签的配置算出来</span>}>
<div className="g-chiprow">
<EndChips ends={u.ends} />
</div>
</Card>
<Card ico="key" title="角色" extra={<span className="x">授收都穿透 Keycloak;收权当场踢掉他的会话</span>}>
<QueryGate query={roles}>
<div className="g-chiprow">
{(roles.data?.roles ?? []).map((r) => {
const has = u.roles.includes(r.name);
return (
<Btn
key={r.name}
className={'g-btn' + (has ? ' primary' : '')}
busy={setRole.isPending && setRole.variables?.role === r.name}
onClick={() => setRole.mutate({ role: r.name, granted: !has })}
title={
has
? `收掉「${r.name}」—— 他手上的会话会被当场踢掉`
: `授予「${r.name}」(可进:${r.ends.length === 0 ? '无端' : r.ends.join(' / ')})`
}
>
{(has ? '✓ ' : '+ ') + r.name}
</Btn>
);
})}
</div>
</QueryGate>
</Card>
<Card ico="network" title="所属组">
{u.groups.length === 0 ? (
<Clear>不属于任何组 —— 进出组在「组织」页签里做。</Clear>
) : (
<div className="g-chiprow">
{u.groups.map((g) => (
<Chip key={g.id} tone="brand" ico="network">
{g.path}
</Chip>
))}
</div>
)}
</Card>
<Card ico="lock" title="口令" extra={<span className="x">一律临时口令 —— 本人首登必须改</span>}>
<div className="g-chiprow" style={{ alignItems: 'center' }}>
<input
className="g-input"
style={{ flex: 1 }}
type="password"
placeholder="新的临时口令"
value={pwd}
onChange={(e) => setPwd(e.target.value)}
/>
<Btn className="g-btn" disabled={pwd === ''} busy={reset.isPending} onClick={() => reset.mutate()}>
重置口令
</Btn>
</div>
<Note>服务端不回显、不留痕 —— 这一下按完,这串字就只在你和他之间。</Note>
</Card>
<Card ico="power" title="启停与删除">
<Acts
items={[
{
label: u.enabled ? '停用' : '启用',
ico: 'power',
danger: u.enabled,
busy: setEnabled.isPending,
ask: {
title: `${u.enabled ? '停用' : '启用'}「${u.displayName}」`,
note: u.enabled
? '停用会当场踢掉他的全部会话 —— 手上那枚 token 立刻失效,不用等过期。'
: '启用之后他就能重新登录了。',
impact: {
who: u.displayName,
undo: '可以再改回来',
when: '立刻',
},
okLabel: u.enabled ? '确认停用' : '确认启用',
onOk: () => 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(),
},
},
]}
/>
</Card>
</>
)}
</QueryGate>
);
}
@@ -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 <Chip tone="mute">无端可进</Chip>;
return (
<>
{ends.map((e) => (
<Chip key={e} tone="brand">
{SURFACE_LABEL[e]}
</Chip>
))}
</>
);
}
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 (
<>
<Card
ico="users"
title="成员"
extra={
<span className="g-chiprow" style={{ alignItems: 'center' }}>
<input
className="g-input"
style={{ width: 240 }}
placeholder="按用户名 / 姓名 / 邮箱检索…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
<label className="g-tick">
<input type="checkbox" checked={includeDisabled} onChange={(e) => setIncludeDisabled(e.target.checked)} />
含停用
</label>
<Chip tone={counted ? 'brand' : 'mute'}>{counted ? `${rows.length} 人 · ${active} 在职` : '数着…'}</Chip>
<Btn className="g-btn primary" onClick={() => setCreating((v) => !v)}>
{creating ? '收起' : '新建成员'}
</Btn>
</span>
}
>
<Note>
真源是 Keycloak · 停用的人<b>立刻</b>登不上;在途任务不受影响(需手动改派)。服务账号不在这份名单里 ——
它是身份不是人。
</Note>
{creating && <CreateMemberForm onDone={() => setCreating(false)} />}
<QueryGate query={users}>
{rows.length === 0 ? (
<Clear>{q === '' ? '一个人都没有 —— 去 Keycloak 建人,或用「新建成员」。' : `没有匹配「${q}」的成员。`}</Clear>
) : (
<NList>
{rows.map((u) => (
<NRow
key={u.sub}
tone={u.enabled ? 'brand' : 'mute'}
ico="user-round"
title={u.displayName}
sub={`${u.username}${u.email === undefined ? '' : ` · ${u.email}`}`}
cols={[
{ k: '角色', v: u.roles.length === 0 ? '—' : u.roles.join(', ') },
{ k: '可进', v: <EndChips ends={u.ends} /> },
]}
right={
<St tone={u.enabled ? 'ok' : 'mute'} ico={u.enabled ? 'circle-check' : 'power'}>
{u.enabled ? '在职' : '已停用'}
</St>
}
onClick={() => nav.push({ k: 'emp', id: u.sub })}
/>
))}
</NList>
)}
</QueryGate>
</Card>
</>
);
}
/**
* 新建成员 —— 口令一律**临时**:本人首登必须改。
* 我们不存它、不回显 —— 它经过这个表单就再也不出现了,所以建完那一句话得把它说清楚。
*/
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<string | undefined>(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 (
<div className="g-prop">
<div className="g-prop-h">新建成员</div>
<div className="g-prop-b">
<Field k="用户名" hint="登录名,建后不可改">
<input className="g-input" value={username} onChange={(e) => setUsername(e.target.value)} placeholder="如 zhangsan" />
</Field>
<Field k="姓" hint="Keycloak 的 lastName">
<input className="g-input" value={lastName} onChange={(e) => setLastName(e.target.value)} />
</Field>
<Field k="名" hint="Keycloak 的 firstName">
<input className="g-input" value={firstName} onChange={(e) => setFirstName(e.target.value)} />
</Field>
<Field k="邮箱" hint="选填">
<input className="g-input" value={email} onChange={(e) => setEmail(e.target.value)} />
</Field>
<Field k="临时口令" hint="留空则建一个还登不进来的壳;填了则本人首登必须改">
<input className="g-input" type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
</Field>
{err !== undefined && <Note tone="warn">{err}</Note>}
<Acts
align="end"
items={[
{ label: '取消', onClick: onDone },
{
label: '建这个人',
ico: 'user-plus',
primary: true,
disabled: username.trim() === '',
busy: create.isPending,
ask: {
title: `在 Keycloak 里建「${username.trim()}」`,
note: '姓名照 Keycloak 的 lastName + firstName 拼;角色先不给 —— 建完去「权限」页签授。',
impact: {
who: '这个人立刻能登录(若给了临时口令)',
undo: '不能撤销 —— 建错了只能再删掉',
when: '按下即生效,Keycloak 那边当场就有',
},
okLabel: '确认新建',
onOk: () => create.mutate(),
},
},
]}
/>
</div>
</div>
);
}
@@ -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<string | undefined>(undefined);
const current = rows.find((g) => g.id === picked);
const tops = rows.filter((g) => g.depth === 0).length;
/* 取回来之前一个数都别摆、一句「还没有组」也别说 —— 那时候我们并不知道有没有组 */
const loaded = groups.data !== undefined;
return (
<div className="g-split">
<Card ico="network" title="组织树" extra={<Chip tone={loaded ? 'brand' : 'mute'}>{loaded ? `${tops} 顶层组` : '数着…'}</Chip>}>
<Note>Keycloak 组 · 点一行看它的成员与操作</Note>
<QueryGate query={groups}>
{rows.length === 0 ? (
<Clear>还没有组 —— 先在下面「建顶层组」。</Clear>
) : (
<div className="g-tree">
{rows.map((g) => (
<button
key={g.id}
className={'g-tree-r' + (picked === g.id ? ' on' : '')}
style={{ paddingLeft: 10 + g.depth * 18 }}
onClick={() => setPicked(g.id)}
title={g.path}
>
<b>{g.name}</b>
<em>{`${g.memberCount} 人`}</em>
</button>
))}
</div>
)}
</QueryGate>
<GroupCreate label="建顶层组" hint="组名(同级不得重名)" />
</Card>
{current === undefined ? (
<Card ico="users" title="成员">
<Clear>
{!loaded
? '组织树还在取…'
: rows.length === 0
? '还没有组 —— 先在左侧「建顶层组」,这里看它的成员与操作。'
: '在左侧点一个组,这里看它的成员与操作。'}
</Clear>
</Card>
) : (
<GroupDetail
key={current.id}
group={current}
onGone={() => setPicked(undefined)}
/>
)}
</div>
);
}
/** 建组 —— `parentId` 给了就是子组,不给就是顶层组 */
function GroupCreate({ label, hint, parentId }: { label: string; hint: string; parentId?: string }) {
const qc = useQueryClient();
const [name, setName] = useState('');
const [err, setErr] = useState<string | undefined>(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 (
<div className="g-prop">
<div className="g-prop-h">{label}</div>
<div className="g-prop-b">
<div className="g-chiprow" style={{ alignItems: 'center' }}>
<input className="g-input" style={{ flex: 1 }} placeholder={hint} value={name} onChange={(e) => setName(e.target.value)} />
<Btn className="g-btn" disabled={name.trim() === ''} busy={create.isPending} onClick={() => create.mutate()}>
建组
</Btn>
</div>
{err !== undefined && <Note tone="warn">{err}</Note>}
</div>
</div>
);
}
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<string | undefined>(undefined);
const [err, setErr] = useState<string | undefined>(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<GovOrgGroups>(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 (
<Card
ico="users"
title={group.name}
extra={<St tone="ok" ico="users">{`${inGroup.length} 人`}</St>}
>
<Note>{`路径 ${group.path}`}</Note>
{renaming !== undefined && (
<div className="g-prop">
<div className="g-prop-h">改组名</div>
<div className="g-prop-b">
<div className="g-chiprow" style={{ alignItems: 'center' }}>
<input className="g-input" style={{ flex: 1 }} value={renaming} onChange={(e) => setRenaming(e.target.value)} />
<Btn className="g-btn" onClick={() => setRenaming(undefined)}>取消</Btn>
<Btn
className="g-btn primary"
disabled={renaming.trim() === '' || renaming.trim() === group.name}
busy={rename.isPending}
onClick={() => rename.mutate(renaming.trim())}
>
改名
</Btn>
</div>
</div>
</div>
)}
{err !== undefined && <Note tone="warn">{err}</Note>}
<QueryGate query={members}>
{inGroup.length === 0 ? (
<Clear>这个组还没有人 —— 在下面「把人加进来」。</Clear>
) : (
<NList>
{inGroup.map((m) => (
<NRow
key={m.sub}
tone={m.enabled ? 'brand' : 'mute'}
ico="user-round"
title={m.displayName}
sub={m.username}
right={
<Btn
className="g-btn danger"
busy={setMember.isPending && setMember.variables?.sub === m.sub}
onClick={() => setMember.mutate({ sub: m.sub, member: false })}
>
移出
</Btn>
}
onClick={() => undefined}
/>
))}
</NList>
)}
</QueryGate>
<div className="g-prop">
<div className="g-prop-h">把人加进来</div>
<div className="g-prop-b">
{outside.length === 0 ? (
<Clear>所有人都已经在这个组里了。</Clear>
) : (
<div className="g-chiprow">
{outside.map((u) => (
<Btn
key={u.sub}
className="g-btn"
busy={setMember.isPending && setMember.variables?.sub === u.sub}
onClick={() => setMember.mutate({ sub: u.sub, member: true })}
>
{`+ ${u.displayName}`}
</Btn>
))}
</div>
)}
</div>
</div>
<GroupCreate label="在这个组下建子组" hint="子组名(同级不得重名)" parentId={group.id} />
<Acts
align="end"
items={[
{ label: '改组名', ico: 'pencil-line', onClick: () => 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(),
},
},
]}
/>
</Card>
);
}
@@ -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 <WorkGroupsTab />;
return <StaffHome tab={tab} nav={nav} />;
if (tab === 'perms') return <PermsTab />;
if (tab === 'orgs') return <OrgsTab />;
return <MembersTab nav={nav} />;
}
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 (
<>
<Note>
权限按<b>岗位</b>授予,不按人:客体三类(数据 / 工具 / 治理功能)× 动作三种(读 / 写 / 治理)。
人换岗即换权,权限随岗位走。
</Note>
<NList>
{positions.map((p) => (
<NRow
key={p.id}
tone="brand"
ico="key"
title={p.name}
sub={`岗位编码 ${p.code}`}
cols={[{ k: '在岗', v: staff.filter((s) => s.position.id === p.id).length }]}
onClick={() => nav.push({ k: 'role', id: p.id })}
/>
))}
</NList>
</>
);
}
return (
<QueryGate query={q}>
<>
<NList>
{staff.map((s) => {
const mine = (tasks.data?.tasks ?? []).filter((t) => t.ownerName === s.name && t.terminal === undefined);
return (
<NRow
key={s.id}
tone={s.state === 'active' ? 'brand' : 'mute'}
ico="user-round"
title={s.name}
sub={`${s.account} · ${s.position.name}(${s.position.code})`}
cols={[{ k: '在途任务', v: mine.length }]}
right={
<St tone={s.state === 'active' ? 'ok' : 'mute'} ico={s.state === 'active' ? 'circle-check' : 'power'}>
{s.state === 'active' ? '在职' : '已停用'}
</St>
}
onClick={() => nav.push({ k: 'emp', id: s.id })}
/>
);
})}
</NList>
</>
</QueryGate>
);
}
/* ---------------- ③ 工作流管理 ---------------- */
/** 详情页 `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 <Note>员工不存在。</Note>;
const mine = (tasks.data?.tasks ?? []).filter((t) => t.ownerName === s.name);
const live = mine.filter((t) => t.terminal === undefined);
return (
<>
<PageHead
ico="user-round"
kicker={`员工 · ${s.account}`}
title={s.name}
chips={
<>
<Chip ico="key">
{s.position.name} · {s.position.code}
</Chip>
<Chip tone={s.state === 'active' ? 'ok' : 'mute'}>{s.state === 'active' ? '在职' : '已停用'}</Chip>
</>
}
desc="停用前置检查:在途任务与待答卡须先改派完毕——停用不得让在途工作无人认领。"
/>
<Card ico="list-checks" title="在途任务" extra={<span className="g-mono">{live.length}</span>}>
<NList>
{live.map((t) => (
<NRow key={t.id} tone="brand" ico="list-checks" title={t.title} onClick={() => nav.push({ k: 'task', id: t.id })} />
))}
{live.length === 0 && <Clear>无在途任务——可安全停用。</Clear>}
</NList>
</Card>
<Entry ico="key" title="岗位授权" desc={`查看 ${s.position.name} 的授权清单`} onClick={() => nav.push({ k: 'role', id: s.position.id })} />
</>
);
return <MemberPage id={id} nav={nav} />;
}
export function RolePage({ id }: { id: string }) {
const staff = useGovStaff();
const grants = useGovGrants(id);
const position = (staff.data?.positions ?? []).find((p) => p.id === id);
return (
<>
<PageHead
ico="key"
kicker="岗位授权"
title={position?.name ?? id}
chips={<Chip ico="hash">{position?.code ?? '—'}</Chip>}
desc="权限按岗位授予,不按人:客体三类(数据 / 工具 / 治理功能)× 动作三种(读 / 写 / 治理)。人换岗即换权。"
/>
<div className="g-rows">
{(grants.data?.grants ?? []).map((g, i) => (
<div key={i}>
<em>
{g.object.kind === 'data'
? `数据 · ${g.object.domain}.${g.object.object}`
: g.object.kind === 'governance'
? `治理功能 · ${GOVERNANCE_FUNCTION_LABEL[g.object.function]}`
: `任务 · ${g.object.activityId ?? '全部活动'}`}
</em>
<b className="mono">{GRANT_ACTION_LABEL[g.action]}</b>
</div>
))}
{(grants.data?.grants ?? []).length === 0 && <Clear>未授予任何权限。</Clear>}
</div>
</>
);
}
/* ---------------- 流程包 ---------------- */
/* ---------------- 工作组(ADR-011) ---------------- */
/**
* 工作组 = 一组「岗位 → 员工」的绑定,**起单时装载、随工作流走**。
*
* 它是**绑定先于策略**,不是第五种负责人策略:起单人有组,整条流的岗位就按组指人;
* 没有组才回落四策略(独任 / 轮询 / 抢单 / 指派)。
* 装进工作流的是**快照** —— 改组不改已起的单,留痕不被改写。
*/
function WorkGroupsTab() {
const groups = useGovWorkGroups();
const staffQ = useGovStaff();
const [editing, setEditing] = useState<string | undefined>(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 (
<QueryGate query={groups}>
<>
<Note>
工作组把<b>岗位绑到人</b>:谁起的单,整条流的岗位就按谁的组指人 —— 绑定先于策略,
没绑定的岗位仍走四策略。绑定在起单那一刻<b>快照</b>进工作流,此后改组不影响已起的单。
</Note>
{rows.length === 0 ? (
<Clear>还没有工作组 —— 给谁建一个,他起的单整条流就能一次指到人。</Clear>
) : (
rows.map((g) =>
editing === g.ownerId ? (
<WorkGroupForm
key={g.id}
ownerId={g.ownerId}
ownerName={g.ownerName}
name={g.name}
members={g.members}
positions={positions}
staff={staff}
onDone={() => setEditing(undefined)}
onCancel={() => setEditing(undefined)}
/>
) : (
<Card
key={g.id}
ico="users"
title={g.name}
extra={
<button className="g-btn" onClick={() => setEditing(g.ownerId)}>
改绑定
</button>
}
>
<Rows
items={[
{ k: '组长', v: g.ownerName },
...positions.map((p) => ({
k: `${p.name} · ${p.code}`,
v: g.members[p.code] === undefined ? '未绑定 · 走四策略' : nameOf(g.members[p.code]!),
})),
]}
/>
</Card>
),
)
)}
<Card ico="user-round" title="给还没有组的人建一个">
<NList>
{staff
.filter((s) => !rows.some((g) => g.ownerId === s.id))
.map((s) =>
editing === s.id ? null : (
<NRow
key={s.id}
tone="mute"
ico="user-round"
title={s.name}
sub={`${s.position.name} · ${s.position.code}`}
right={<span className="g-mono">建组 →</span>}
onClick={() => setEditing(s.id)}
/>
),
)}
{staff.every((s) => rows.some((g) => g.ownerId === s.id)) && <Clear>每个人都有组了。</Clear>}
</NList>
</Card>
{staff
.filter((s) => !rows.some((g) => g.ownerId === s.id) && editing === s.id)
.map((s) => (
<WorkGroupForm
key={s.id}
ownerId={s.id}
ownerName={s.name}
name={`${s.name}的工作组`}
members={{}}
positions={positions}
staff={staff}
onDone={() => setEditing(undefined)}
onCancel={() => setEditing(undefined)}
/>
))}
</>
</QueryGate>
);
}
/** 建组 / 改组表单:逐岗位选人,留空即该岗位不绑定 */
function WorkGroupForm({
ownerId,
ownerName,
name,
members,
positions,
staff,
onDone,
onCancel,
}: {
ownerId: string;
ownerName: string;
name: string;
members: Readonly<Record<string, string>>;
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<Record<string, string>>({ ...members });
const save = useMutation({
mutationFn: (input: { name: string; members: Record<string, StaffId> }) =>
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 (
<Card ico="users" title={`工作组:${ownerName}`}>
<Field k="组名" hint="人读的一句话,如「柴浩的工作组」">
<input className="g-input" value={draftName} onChange={(e) => setDraftName(e.target.value)} />
</Field>
{positions.map((p) => (
<Field key={p.id} k={`${p.name} · ${p.code}`} hint="留空即不绑定,该岗位仍走四策略">
<select
className="g-input"
value={draft[p.code] ?? ''}
onChange={(e) => setDraft({ ...draft, [p.code]: e.target.value })}
>
<option value="">— 不绑定 —</option>
{staff.map((s) => (
<option key={s.id} value={s.id}>
{s.name}({s.position.code})
</option>
))}
</select>
</Field>
))}
<Acts
align="end"
items={[
{ label: '取消', ico: 'x', onClick: onCancel },
{
label: save.isPending ? '保存中…' : `保存(已绑 ${bound} 个岗位)`,
ico: 'circle-check',
primary: true,
disabled: draftName.trim() === '',
busy: save.isPending,
ask: {
title: `保存工作组:${draftName}`,
note: <Note>绑定先于策略 —— 起单人有组,整条流的岗位就按组指人;没绑定的岗位仍走四策略。</Note>,
impact: {
who: '仅此后新起的工作流;在途不受影响 —— 绑定是起单时的快照',
undo: '可随时改;解绑某岗位即回落四策略',
when: '确认后即时生效',
},
okLabel: '确认保存',
onOk: () => {
const picked: Record<string, StaffId> = {};
for (const [code, id] of Object.entries(draft)) {
if (id !== '') picked[code] = staffIdSchema.parse(id);
}
save.mutate({ name: draftName, members: picked });
},
},
},
]}
/>
{save.isError && <Note tone="warn">保存失败,请重试。</Note>}
</Card>
);
}
export { RolePage };
@@ -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 (
<Card
ico="shield-check"
title="角色 → 能进哪几个端"
// 取回来之前不摆数 —— 「0 个角色」在那一刻是假话
extra={<Chip tone={roles.data === undefined ? 'mute' : 'brand'}>{roles.data === undefined ? '数着…' : `${rows.length} 个角色`}</Chip>}
>
<Note>
角色来自 Keycloak(realm roles),<b>能进哪几个端由这里说了算</b> —— 每个请求都按它判,
不合就回 403。改完至少要有人还能进运营中心,否则这张表自己就锁死了。
</Note>
<QueryGate query={roles}>
{rows.length === 0 ? (
<Clear>realm 里还没有业务角色 —— 去 Keycloak 建(家具角色不摆在这儿)。</Clear>
) : (
<div className="g-rows">
{rows.map((r) => (
<RoleRow key={r.name} role={r} configured={(access.data?.roles ?? []).some((x) => x.role === r.name)} />
))}
</div>
)}
</QueryGate>
</Card>
);
}
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<readonly Surface[] | undefined>(undefined);
const [err, setErr] = useState<string | undefined>(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 (
<div className="g-prop">
<div className="g-prop-h">
<span className="g-mono">{role.name}</span>
{role.composite && <Chip tone="warn">组合角色</Chip>}
<span className="x">{`${role.holders} 人持有`}</span>
</div>
<div className="g-prop-b">
{role.description !== undefined && <Note>{role.description}</Note>}
{editing ? (
<div className="g-swrow">
{SURFACES.map((s) => (
<label key={s}>
<input
type="checkbox"
checked={shown.includes(s)}
onChange={(e) =>
setDraft(e.target.checked ? [...shown, s] : shown.filter((x) => x !== s))
}
/>
{SURFACE_LABEL[s]}
</label>
))}
<small>勾上的端,持有这个角色的人就能进;一个都不勾 = 有这个角色也哪儿都进不去。</small>
</div>
) : (
<div className="g-chiprow">
<EndChips ends={role.ends} />
{!configured && <Chip tone="mute">还没配过</Chip>}
</div>
)}
{err !== undefined && <Note tone="warn">{err}</Note>}
<Acts
align="end"
items={
editing
? [
{ label: '取消', onClick: () => { 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(),
},
},
]
: []),
]
}
/>
</div>
</div>
);
}
/** 角色详情:谁持有它 —— 从成员那一屏点进来,或从这一屏的角色名点进来 */
export function RolePage({ id }: { id: string }) {
const roles = useOrgRoles();
const role = (roles.data?.roles ?? []).find((r) => r.name === id);
return (
<QueryGate query={roles}>
{role === undefined ? (
<Clear>{`realm 里没有角色「${id}」—— 可能刚在 Keycloak 里删了。`}</Clear>
) : (
<Card ico="shield-check" title={role.name} extra={<St tone="ok" ico="users">{`${role.holders} 人持有`}</St>}>
<div className="g-chiprow">
<EndChips ends={role.ends} />
</div>
<Note>授收这个角色在「成员」那一屏的成员详情里做 —— 一次改一个人,收权当场踢掉他的会话。</Note>
</Card>
)}
</QueryGate>
);
}
+69
View File
@@ -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<GovWorkflow> =>
export const useGovTaskLog = (taskId: string): UseQueryResult<GovTaskLog> =>
useQuery({ queryKey: ['gov', 'tasklog', taskId], queryFn: () => call<GovTaskLog>(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<typeof listOrgUsersRoute>;
export type GovOrgUserDetail = RouteOutput<typeof getOrgUserRoute>;
export type GovOrgRoles = RouteOutput<typeof listOrgRolesRoute>;
export type GovOrgGroups = RouteOutput<typeof listOrgGroupsRoute>;
export type GovOrgGroupMembers = RouteOutput<typeof listOrgGroupMembersRoute>;
export type GovRoleAccess = RouteOutput<typeof listRoleAccessRoute>;
/**
* 成员列表 —— 检索与「含停用」都在**服务端**过滤(那边握着全量,前端只拿要摆的那些)。
* 服务端第一次取要几秒(Keycloak 没有批量取角色的口,见 debt),它自己有 60 秒缓存;
* 这边 `staleTime` 跟着设一档,免得同一屏的几个组件各要一遍。
*/
export const useOrgUsers = (q: string, includeDisabled: boolean): UseQueryResult<GovOrgUsers> =>
useQuery({
queryKey: ['gov', 'org', 'users', q, includeDisabled],
queryFn: () =>
call<GovOrgUsers>(listOrgUsersRoute, {
query: { q: q === '' ? undefined : q, includeDisabled: includeDisabled ? '1' : undefined },
}),
staleTime: 30_000,
});
export const useOrgUser = (sub: string | undefined): UseQueryResult<GovOrgUserDetail> =>
useQuery({
queryKey: ['gov', 'org', 'user', sub],
queryFn: () => call<GovOrgUserDetail>(getOrgUserRoute, { params: { sub: sub! } }),
enabled: sub !== undefined,
});
export const useOrgRoles = (): UseQueryResult<GovOrgRoles> =>
useQuery({ queryKey: ['gov', 'org', 'roles'], queryFn: () => call<GovOrgRoles>(listOrgRolesRoute), staleTime: 30_000 });
export const useOrgGroups = (): UseQueryResult<GovOrgGroups> =>
useQuery({ queryKey: ['gov', 'org', 'groups'], queryFn: () => call<GovOrgGroups>(listOrgGroupsRoute), staleTime: 30_000 });
export const useOrgGroupMembers = (groupId: string | undefined): UseQueryResult<GovOrgGroupMembers> =>
useQuery({
queryKey: ['gov', 'org', 'group-members', groupId],
queryFn: () => call<GovOrgGroupMembers>(listOrgGroupMembersRoute, { params: { id: groupId! } }),
enabled: groupId !== undefined,
});
/** 角色 → 端 —— 这张表是**本地**的(ADR-012),不是 Keycloak 的字段 */
export const useRoleAccess = (): UseQueryResult<GovRoleAccess> =>
useQuery({ queryKey: ['gov', 'org', 'role-access'], queryFn: () => call<GovRoleAccess>(listRoleAccessRoute) });
+15 -6
View File
@@ -110,10 +110,10 @@ export const ZM: Readonly<Record<GovZone, { title: string; desc: string; accent:
desc: '活动的运行时:任务 = 活动被调起的一次运行,恰有一名负责人。这里做任务与审批单的在途监控、干预与控制——暂停可恢复、叫停归负责人、终止级联作废。工作流一级去「工作流」区看。',
},
people: {
title: '员工管理',
title: '成员与组织',
icon: 'users',
accent: 'linear-gradient(140deg,#2bc7d4,#0e9fd8)',
desc: '员工负载、岗位与授权。权限按岗位授予:客体三类 × 动作三种;停用前置检查——在途任务与待答卡须先改派完毕。',
desc: '成员与组织的真源是 Keycloak:这里建的人立刻能登录、停的人立刻登不上,没有本地草稿,也没有撤销。权限只到端——一个角色能进设计中心 / 运营中心 / 工作台的哪几个,由本系统的「角色 → 端」表说了算。',
},
workflows: {
title: '流程包管理',
@@ -176,7 +176,10 @@ export const ZM: Readonly<Record<GovZone, { title: string; desc: string; accent:
* 还留着多格的只有运营侧的任务与员工(它们确实是几个不同维度),
* 以及 `ledgers`:它的页签是数据域,随发布动态生成(壳层运行时铺开),故此表留空。
*/
export const TABS: Readonly<Record<GovZone, ReadonlyArray<{ key: string; label: string; ico: string }>>> = {
/** `hint` 摆在页签栏右端 —— 一句话说清这一格管什么、写下去会发生什么 */
export const TABS: Readonly<
Record<GovZone, ReadonlyArray<{ key: string; label: string; ico: string; hint?: string }>>
> = {
blueprint: [{ key: 'map', label: '企业蓝图', ico: 'route' }],
/*
* 测试台三格 —— 从前是一格加一个「两档旋钮」,旋钮长在内容里,别处跳不进来、
@@ -206,10 +209,16 @@ export const TABS: Readonly<Record<GovZone, ReadonlyArray<{ key: string; label:
{ key: 'active', label: '激活中', ico: 'zap' },
{ key: 'disabled', label: '已禁用', ico: 'ban' },
],
/*
* 成员与组织(2026-08-26 用户定):三个页签一一对着 Keycloak 的三样东西 ——
* 成员 = users、权限 = realm roles、组织 = groups。
* 原「岗位授权」与「工作组」两个页签先拿掉(用户定);两处**后端数据与接口原样留着**,
* 拿掉的只是入口 —— 岗位仍决定活动派单的候选集,工作组仍在起单那一刻快照进工作流。
*/
people: [
{ key: 'emp', label: '员工', ico: 'users' },
{ key: 'roles', label: '岗位授权', ico: 'key' },
{ key: 'groups', label: '工作组', ico: 'users' },
{ key: 'members', label: '成员', ico: 'users', hint: 'Keycloak 目录投影:成员列表 · 新建 · 启停,点行进详情' },
{ key: 'perms', label: '权限', ico: 'shield-check', hint: '角色 → 能进哪几个端(本地配置)· 授收角色(穿透 Keycloak)' },
{ key: 'orgs', label: '组织', ico: 'network', hint: '组织树:建组 / 改名 / 删组 / 成员进出组' },
],
ledgers: [], // 动态:页签即数据域(短名呈现),由壳层按已发布域生成
system: [{ key: 'chan', label: '通道实例', ico: 'mail' }],
+23
View File
@@ -55,3 +55,26 @@
- Keycloak 侧前置(管理员):本服务 client(建议复用 `api-gateway`)、`web-console` 补 audience mapper、
provisioning 服务账号、e2e 服务账号 client(`bpos-e2e`,realm 角色 admin,firstName「AI」)。
- 欠账见 `docs/debt.md`「鉴权」段。
## 补充(2026-08-26):成员与组织三页签
运营中心「员工」那一屏按目标件重做成三个页签,一一对着 Keycloak 的三样东西:
| 页签 | 数据来自 | 写去哪 |
|---|---|---|
| **成员** | Keycloak users(合本地的岗位与「几时来过」) | 穿透 Keycloak:新建 / 改资料 / 启停 / 重置口令 / 删除 |
| **权限** | Keycloak realm roles × 本地 `sys.role_access` | 端配置写本地;角色授收穿透 Keycloak |
| **组织** | Keycloak groups | 穿透:建组 / 改名 / 删组 / 成员进出组 |
`/api/gov/org/*` 十五条,`access` 一律 `ops`。三处口径值得记:
1. **写是穿透的,没有撤销** —— 这里建的人立刻能登录、停的人立刻登不上。界面上凡写必二次确认
(三必答:谁受影响 / 能不能撤 / 几时生效)。
2. **服务账号是身份不是人** —— 目录读一律剔除、写面一律 403。停掉它的后果不显眼却很重:
试跑与 e2e 第二天全趴下。
3. **两种 502 分开说**:`keycloak-misconfigured`(服务账号缺角色,重试无益,去补权限)与
`keycloak-unavailable`(暂时够不着,可退避重试)。**重试有没有意义**是分界 —— 混成一个 code,
界面只能说一句含糊的「出错了」。
同批拿掉的:「岗位授权」与「工作组」两个页签(用户定)。**后端数据与接口原样留着** ——
岗位仍决定活动派单的候选集,工作组仍在起单那一刻快照进工作流;拿掉的只是入口。
+7
View File
@@ -64,6 +64,13 @@ export const API_ERROR_CODES = [
'precondition-failed',
'internal',
'gone',
/*
* 成员目录专用的两档 502(ADR-012)—— **重试有没有意义**是它们的分界,
* 前端据此说两句不同的话:一句「找管理员补权限」,一句「过会儿再点」。
* 混成一个 code 的话,界面只能说一句含糊的「出错了」。
*/
'keycloak-misconfigured',
'keycloak-unavailable',
] as const;
export type ApiErrorCode = (typeof API_ERROR_CODES)[number];
+1
View File
@@ -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';
+260
View File
@@ -0,0 +1,260 @@
/**
* 成员与组织(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';
import type { RouteContract } from './http.ts';
import { surfaceSchema } from './http.ts';
/* ── 成员 ─────────────────────────────────────────────────────────────── */
export const orgUserSchema = z.object({
/** Keycloak 用户 id(= `sub`,业务外键用它,不用用户名) */
sub: z.string().min(1),
/** 登录名,建后不可改 */
username: z.string().min(1),
/** `firstName lastName` 直拼;皆空回落 username —— 不会是空串 */
displayName: z.string().min(1),
email: z.string().optional(),
enabled: z.boolean(),
/** realm 角色(家具角色已滤) */
roles: z.array(z.string()),
/** 这些角色合起来能进哪几个端 —— 由 `sys.role_access` 算,不是 Keycloak 的字段 */
ends: z.array(surfaceSchema),
/** 本地名册记的「几时来过」;没有 = 还没拿这枚身份访问过本系统 */
lastSeenAt: z.string().optional(),
/** 本地岗位(哨兵岗 NONE = 未分配)—— 目录里只读,改岗位走 `setStaffPositionRoute` */
positionCode: z.string().optional(),
});
export type OrgUser = z.infer<typeof orgUserSchema>;
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<typeof orgUserDetailSchema>;
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<typeof orgRoleSchema>;
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<typeof orgGroupSchema>;
/** 组树平表,按 `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[];
+16
View File
@@ -21,6 +21,20 @@ export const STAFF_STATES = ['active', 'disabled'] as const;
export const staffStateSchema = z.enum(STAFF_STATES);
export type StaffState = z.infer<typeof staffStateSchema>;
/**
* 人,还是服务账号。
*
* Keycloak 给每个开了 `serviceAccountsEnabled` 的 client 自动建一个用户,用户名恒为
* `service-account-<clientId>` —— 那是 Keycloak 自己的命名,不是我们的约定;它也不出现在
* 管理台的 Users 列表、Admin API 的 `GET /users` 里(名册同步的下限保护②为此存在)。
*
* 它是**真身份**(试跑与 e2e 就用它跑单,铁规矩 6),但**不是人**:
* 员工管理那一屏不摆它,试跑台的负责人下拉照旧摆。
* 这条判断归后端 —— 界面不该去猜账号名长什么样。
*/
export const staffKindSchema = z.enum(['person', 'service']);
export type StaffKind = z.infer<typeof staffKindSchema>;
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<typeof staffSchema>;