Files
knowledge-governance/web/src/MembersSection.tsx
T

83 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useState } from 'react'
import { createManagedUser, fetchManagedUsers, resetManagedUserPassword, setManagedUserRole, setManagedUserState } from './members'
import type { ManagedUser } from './members'
import { roleLabels, useOptionalAuth } from './auth'
import { PasswordInput } from './PasswordInput'
export const memberRoleLabels = roleLabels
function friendly(cause: unknown, fallback: string): string {
return cause instanceof Error && cause.message ? cause.message : fallback
}
export function MembersSection() {
const identity = useOptionalAuth()?.identity ?? null
const [users, setUsers] = useState<ManagedUser[]>([])
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [message, setMessage] = useState<string | null>(null)
const [form, setForm] = useState({ username: '', display_name: '', role: 'engineer' as ManagedUser['role'], password: '' })
const load = useCallback(async () => {
setLoading(true); setError(null)
try { setUsers(await fetchManagedUsers()) }
catch (cause) { setError(friendly(cause, '加载成员失败')) }
finally { setLoading(false) }
}, [])
useEffect(() => { void load() }, [load])
const run = async (key: string, action: () => Promise<unknown>, success: string) => {
setBusy(key); setError(null); setMessage(null)
try { await action(); setMessage(success); await load() }
catch (cause) { setError(friendly(cause, '操作失败')) }
finally { setBusy(null) }
}
const create = () => run('create', async () => {
await createManagedUser(form)
setForm({ username: '', display_name: '', role: 'engineer', password: '' })
}, `成员 ${form.username} 已创建,初始密码只用于首次登录`)
const formValid = /^[a-z0-9][a-z0-9._-]{1,63}$/.test(form.username.trim()) && form.password.length >= 8
return <section className="settings-panel wide" id="settings-members">
<div className="settings-section-heading"><div><h2>成员与角色</h2><p>登录后的一切操作自动记录在成员名下;角色决定谁能审核、封批和行使生产授权。</p></div></div>
{loading ? <p>加载成员…</p> : null}
{error ? <p className="error-message" role="alert">{error}</p> : null}
{message ? <p className="success-message" role="status">{message}</p> : null}
<div className="table-wrap"><table><thead><tr><th>成员</th><th>角色</th><th>状态</th><th>最近登录</th><th>操作</th></tr></thead><tbody>
{users.map((user) => {
const self = user.id === identity?.id
return <tr key={user.id}>
<td><strong>{user.display_name || user.username}</strong><small>{user.username}{user.must_change_password ? ' · 待设置新密码' : ''}</small></td>
<td>{self ? <span>{memberRoleLabels[user.role]}</span> : (
<select value={user.role} disabled={busy === user.id} aria-label={`${user.username} 角色`} onChange={(event) => void run(user.id, () => setManagedUserRole(user.id, event.target.value as ManagedUser['role']), '角色已更新,该成员需要重新登录')}>
<option value="viewer">观察者</option><option value="engineer">知识工程师</option><option value="admin">管理员</option>
</select>
)}</td>
<td><span className={`status status-${user.state === 'active' ? 'completed' : 'failed'}`}>{user.state === 'active' ? '启用' : '已停用'}</span></td>
<td>{user.last_login_at ? new Date(user.last_login_at).toLocaleString('zh-CN', { hour12: false }) : '从未登录'}</td>
<td><div className="row-actions">
{!self ? <button type="button" className="secondary-action" disabled={busy === user.id} onClick={() => {
const temporary = window.prompt(`为 ${user.username} 生成一个临时密码(至少 8 位,对方首次登录必须更换)`)
if (temporary) void run(user.id, () => resetManagedUserPassword(user.id, temporary), `${user.username} 的临时密码已生效`)
}}>重置密码</button> : null}
{!self ? <button type="button" className={user.state === 'active' ? 'danger' : 'secondary-action'} disabled={busy === user.id} onClick={() => void run(user.id, () => setManagedUserState(user.id, user.state === 'active' ? 'disabled' : 'active'), user.state === 'active' ? '账号已停用,其在线会话立即失效' : '账号已重新启用')}>
{user.state === 'active' ? '停用' : '启用'}
</button> : <small>当前登录人</small>}
</div></td>
</tr>
})}
</tbody></table></div>
<div className="member-create-form" aria-label="新增成员">
<label>用户名<input value={form.username} maxLength={64} placeholder="小写字母/数字,如 zhangsan" onChange={(event) => setForm((current) => ({ ...current, username: event.target.value.toLowerCase() }))} /></label>
<label>姓名<input value={form.display_name} maxLength={128} onChange={(event) => setForm((current) => ({ ...current, display_name: event.target.value }))} /></label>
<label>角色<select value={form.role} onChange={(event) => setForm((current) => ({ ...current, role: event.target.value as ManagedUser['role'] }))}><option value="viewer">观察者</option><option value="engineer">知识工程师</option><option value="admin">管理员</option></select></label>
<label>初始密码(≥8 位,首登强制修改)<PasswordInput value={form.password} autoComplete="new-password" onChange={(event) => setForm((current) => ({ ...current, password: event.target.value }))} /></label>
<button type="button" disabled={!formValid || busy === 'create'} onClick={() => void create()}>新增成员</button>
</div>
<small>停用不会删除任何业务记录,只禁止登录并立即撤销其在线会话。</small>
</section>
}