diff --git a/src/api.ts b/src/api.ts index 59f0446..d83542d 100644 --- a/src/api.ts +++ b/src/api.ts @@ -84,9 +84,10 @@ export const api = { return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; reloginRequired: boolean }> }), listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string }[]>(r)), - createAdminUser: (owner: string, name: string, password: string, isAdmin: boolean) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, name, password, isAdmin }) }).then(r => json<{ owner: string; mustChangePassword: boolean }>(r)), - // ---- 管理员:一次性登录 Key(忘记密码恢复主入口;旧"直接重置密码"接口退出常规 UI)---- - adminIssueLoginKey: (owner: string, ttlMinutes?: number) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ purpose: 'recover', ...(ttlMinutes ? { ttlMinutes } : {}) }) }).then(r => json<{ id: string; owner: string; purpose: string; status: string; expiresAt: string; key: string }>(r)), + // 建号只创建待初始化账号(无密码、无权限位);初始凭证由服务器本地 CLI 发放激活 Key。 + createAdminUser: (owner: string, name: string) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile: { name } }) }).then(r => json<{ owner: string; initializationState: string }>(r)), + // ---- 管理员:一次性登录 Key(HTTP 签发端点已被恢复提案契约替换为 410,客户端方法随之移除; + // list/revoke 仍有效,供恢复提案视图复用;兑换入口不变)---- adminListLoginKeys: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`).then(r => json<{ keys: { id: string; owner: string; purpose: string; status: string; expiresAt: string; createdAt: string; createdBy?: string }[] }>(r)).then(d => d.keys ?? []), adminRevokeLoginKey: (owner: string, id: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys/${encodeURIComponent(id)}`, { method: 'DELETE' }).then(r => json<{ ok: boolean }>(r)), adminExecEnv: () => auth.fetch(`${BASE}/api/admin/exec-env`).then(r => json(r)), diff --git a/src/components/adminSections.tsx b/src/components/adminSections.tsx index 4e56ab8..1afe886 100644 --- a/src/components/adminSections.tsx +++ b/src/components/adminSections.tsx @@ -9,19 +9,11 @@ import { Btn, EmptyState, Icon, SettingRow, SettingsGroup, UIRenderer, type DocB import { SettingInput, flatten, unflatten } from './SettingsModal' import type { SettingDesc } from '../contracts' import { docBridge } from './Workbench' -import { randomPassword } from '../passwords' -// ---- 用户管理(原 AdminUsersModal 主体,R2 管理员发放制 + 2026-10-13 恢复 Key)---- +// ---- 用户管理(原 AdminUsersModal 主体,R2 管理员发放制):建号仅创建待初始化 +// 账号(HTTP 契约不含密码与管理员位),初始凭证由服务器本地 CLI 发放激活 Key; +// 登录 Key 签发已被恢复提案契约替换(HTTP 410),提案视图为后续增量。---- interface AdminUserRow { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string } -interface LoginKeyRow { id: string; owner: string; purpose: string; status: string; expiresAt: string; createdAt: string; createdBy?: string } - -/** Key 状态徽标:复用既有 chip 体系,不新增颜色。 */ -function keyStatusChip(status: string) { - if (status === 'unused') return 待使用 - if (status === 'consumed') return 已使用 - if (status === 'expired') return 已过期 - return 已撤销 -} export function UsersPanel() { const me = useStore(s => s.me) @@ -30,63 +22,24 @@ export function UsersPanel() { const [notice, setNotice] = useState('') const [owner, setOwner] = useState('') const [name, setName] = useState('') - const [pw, setPw] = useState('') - const [asAdmin, setAsAdmin] = useState(false) const [busy, setBusy] = useState(false) - // 登录 Key 面板:keyFor 打开指定用户的签发/状态视图;issued 明文仅签发响应持有一次。 - const [keyFor, setKeyFor] = useState(null) - const [issued, setIssued] = useState<{ key: string; expiresAt: string } | null>(null) - const [keys, setKeys] = useState(null) const refresh = async () => { try { setUsers(await api.listAdminUsers()) } catch (e) { setErr(String(e)) } } - const refreshKeys = async (owner: string) => { - try { setKeys(await api.adminListLoginKeys(owner)) } catch (e) { setErr(String(e)) } - } useEffect(() => { refresh() }, []) - const openKeyPanel = async (owner: string) => { - setErr(''); setNotice(''); setIssued(null); setKeyFor(owner); setKeys(null) - await refreshKeys(owner) - } - const create = async () => { setErr(''); setNotice('') - if (pw.length < 8) { setErr('初始密码至少 8 位'); return } setBusy(true) try { - await api.createAdminUser(owner.trim(), name.trim(), pw, asAdmin) - setNotice(`已创建 ${owner.trim()}:首次登录须改密`) - setOwner(''); setName(''); setPw('') + const out = await api.createAdminUser(owner.trim(), name.trim()) + setNotice(`已创建 ${out.owner}(待初始化):请在服务器执行 user initialize-account 发放激活 Key`) + setOwner(''); setName('') await refresh() } catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) } } - const issue = async () => { - if (!keyFor) return - setErr(''); setNotice(''); setBusy(true) - try { - const out = await api.adminIssueLoginKey(keyFor) - setIssued({ key: out.key, expiresAt: out.expiresAt }) - await refreshKeys(keyFor) - } catch (e) { setErr(String(e)) } finally { setBusy(false) } - } - - const copyKey = async () => { - if (!issued) return - try { await navigator.clipboard.writeText(issued.key); setNotice('已复制到剪贴板,请安全交付给该用户') } catch { setErr('复制失败,请手动选择复制') } - } - - const revoke = async (id: string) => { - if (!keyFor) return - setErr(''); setBusy(true) - try { - await api.adminRevokeLoginKey(keyFor, id) - await refreshKeys(keyFor) - } catch (e) { setErr(String(e)) } finally { setBusy(false) } - } - return (
{me?.isAdmin && ( @@ -94,69 +47,28 @@ export function UsersPanel() {
setOwner(e.target.value)} /> setName(e.target.value)} /> +
-
- setPw(e.target.value)} /> - - - -
-

创建后该用户首次登录必须修改此初始密码;忘记密码时为其生成一次性登录 Key。

+

账号创建后处于待初始化状态:在服务器执行 user initialize-account 发放一次性激活 Key,用户经兑换入口设置密码后登录

)} {notice &&
{notice}
} {err &&
{err}
} - {keyFor ? ( -
-
- {keyFor} 的一次性登录 Key(忘记密码恢复) - - -
- {issued && ( -
- e.currentTarget.select()} /> - -
- )} - {issued &&

明文仅此一次显示,关闭后不可再取;单次有效,{new Date(issued.expiresAt).toLocaleString()} 过期。请经安全渠道交付本人。

} - {keys !== null && keys.length > 0 && ( - - - - {keys.map(k => ( - - - - - - - - ))} - -
签发时间用途状态过期时间
{new Date(k.createdAt).toLocaleString()}{k.purpose === 'recover' ? '恢复' : '激活'}{keyStatusChip(k.status)}{new Date(k.expiresAt).toLocaleString()}{k.status === 'unused' && }
- )} -
- ) : ( - - - - {(users ?? []).map(u => ( - - - - - - - - - ))} - {users !== null && users.length === 0 && } - -
用户名显示名角色状态密码设置于
{u.owner}{u.name || '—'}{u.isAdmin ? ADMIN : USER}{u.mustChangePassword ? 待改密 : u.hasPassword ? '正常' : '未设密码'}{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}{me?.isAdmin && ( - - )}
暂无账号
- )} + + + + {(users ?? []).map(u => ( + + + + + + + + ))} + {users !== null && users.length === 0 && } + +
用户名显示名角色状态密码设置于
{u.owner}{u.name || '—'}{u.isAdmin ? ADMIN : USER}{u.mustChangePassword ? 待改密 : u.hasPassword ? '正常' : '未设密码'}{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}
暂无账号
) } diff --git a/src/passwords.ts b/src/passwords.ts deleted file mode 100644 index b0ad3c3..0000000 --- a/src/passwords.ts +++ /dev/null @@ -1,11 +0,0 @@ -// 浏览器端随机初始密码(管理员未显式给定密码时): -// crypto.getRandomValues 拒绝易混淆字符(0/O/1/l/I),保证可抄写。 -const ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789' - -export function randomPassword(length = 12): string { - const buf = new Uint32Array(length) - crypto.getRandomValues(buf) - let out = '' - for (let i = 0; i < length; i++) out += ALPHABET[buf[i] % ALPHABET.length] - return out -}