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:
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user