Archived
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:
@@ -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,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';
|
||||
|
||||
@@ -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[];
|
||||
Reference in New Issue
Block a user