This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/apps/web/src/features/gov-staff/internal/members.tsx
T
xcsandClaude Fable 5 8556432dba 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
2026-08-27 11:44:36 +08:00

189 lines
7.7 KiB
TypeScript

/**
* 成员 —— 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>
);
}