83 lines
5.8 KiB
TypeScript
83 lines
5.8 KiB
TypeScript
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>
|
||
}
|