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
+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);
});
}