Archived
后端:`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
189 lines
7.7 KiB
TypeScript
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>
|
|
);
|
|
}
|