282 lines
17 KiB
TypeScript
282 lines
17 KiB
TypeScript
'use client'
|
||
|
||
import { useState, useEffect, useCallback } from 'react'
|
||
import HeaderUI from '@/components/HeaderUI'
|
||
import RoleManager from './role-manager'
|
||
|
||
interface Role { name: string; display_name: string }
|
||
interface LdapUser { username: string; email: string; displayName: string; createdAt: string }
|
||
|
||
const s = {
|
||
wrap: { minHeight: '100vh', background: 'var(--bg)' } as React.CSSProperties,
|
||
main: { padding: 'clamp(16px, 3vw, 48px)' } as React.CSSProperties,
|
||
tabBar: { display: 'flex', gap: 0, borderBottom: '1px solid var(--border)', marginBottom: 32, overflowX: 'auto' as any, whiteSpace: 'nowrap' as any } as React.CSSProperties,
|
||
content: {} as React.CSSProperties,
|
||
grid2: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 20 } as React.CSSProperties,
|
||
tableWrap: { overflowX: 'auto' as any } as React.CSSProperties,
|
||
field: { marginBottom: 20 } as React.CSSProperties,
|
||
label: { display: 'block', fontSize: 13, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 6 } as React.CSSProperties,
|
||
input: { width: '100%', height: 44, padding: '0 14px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--bg-card)', color: 'var(--text)', fontSize: 14, outline: 'none', boxSizing: 'border-box' as any },
|
||
select: { width: '100%', height: 44, padding: '0 14px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--bg-card)', color: 'var(--text)', fontSize: 14, outline: 'none', cursor: 'pointer', boxSizing: 'border-box' as any },
|
||
btn: { width: '100%', height: 46, background: '#2563eb', color: '#fff', border: 'none', borderRadius: 8, fontSize: 15, fontWeight: 600, cursor: 'pointer' },
|
||
table: { width: '100%', borderCollapse: 'collapse' as any } as React.CSSProperties,
|
||
th: { textAlign: 'left' as any, fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', padding: '10px 12px', borderBottom: '2px solid var(--border)', textTransform: 'uppercase' as any, letterSpacing: '0.05em' } as React.CSSProperties,
|
||
td: { padding: '14px 12px', borderBottom: '1px solid var(--border)', fontSize: 13, color: 'var(--text)' } as React.CSSProperties,
|
||
toast: { position: 'fixed', bottom: 24, right: 24, zIndex: 100, padding: '12px 20px', borderRadius: 10, fontSize: 13, fontWeight: 500, boxShadow: '0 4px 24px rgba(0,0,0,0.12)', animation: 'slideUp 0.3s ease', maxWidth: 400 } as React.CSSProperties,
|
||
badge: { display: 'inline-block', padding: '2px 10px', borderRadius: 10, fontSize: 11, fontWeight: 500 } as React.CSSProperties,
|
||
}
|
||
|
||
export default function AdminUsersPage() {
|
||
const [tab, setTab] = useState<'create' | 'manage' | 'roles'>('create')
|
||
const [username, setUsername] = useState('')
|
||
const [displayName, setDisplayName] = useState('')
|
||
const [email, setEmail] = useState('')
|
||
const [assetsRole, setAssetsRole] = useState('viewer')
|
||
const [issueRole, setIssueRole] = useState('viewer')
|
||
const [monitorRole, setMonitorRole] = useState('viewer')
|
||
const [assetsRoles, setAssetsRoles] = useState<Role[]>([])
|
||
const [issueRoles, setIssueRoles] = useState<Role[]>([])
|
||
const [monitorRoles, setMonitorRoles] = useState<Role[]>([])
|
||
const [users, setUsers] = useState<LdapUser[]>([])
|
||
const [loading, setLoading] = useState(false)
|
||
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null)
|
||
const [deleting, setDeleting] = useState<string | null>(null)
|
||
const [loginUser, setLoginUser] = useState('')
|
||
const [loginDisplayName, setLoginDisplayName] = useState('')
|
||
const [isAdmin, setIsAdmin] = useState(false)
|
||
const [syncingEmails, setSyncingEmails] = useState(false)
|
||
const [showPwd, setShowPwd] = useState(false)
|
||
const [generatedPwd, setGeneratedPwd] = useState('')
|
||
const [pwdUser, setPwdUser] = useState('')
|
||
const [pwdName, setPwdName] = useState('')
|
||
const [copied, setCopied] = useState(false)
|
||
|
||
const fetchRoles = useCallback(async () => {
|
||
try {
|
||
const [rolesR, usersR] = await Promise.all([
|
||
fetch('/api/admin/roles').then(r => r.json()),
|
||
fetch('/api/admin/users').then(r => r.json()),
|
||
])
|
||
if (rolesR.assets?.length) setAssetsRoles(rolesR.assets)
|
||
if (rolesR.issue?.length) setIssueRoles(rolesR.issue)
|
||
if (rolesR.monitor?.length) setMonitorRoles(rolesR.monitor)
|
||
if (usersR.users?.length) setUsers(usersR.users)
|
||
} catch {}
|
||
}, [])
|
||
|
||
const fetchLoginUser = useCallback(async () => {
|
||
try {
|
||
const res = await fetch('/api/auth/me')
|
||
const d = await res.json()
|
||
if (d.user) { setLoginUser(d.user.username || ''); setLoginDisplayName(d.user.displayName || d.user.username || ''); setIsAdmin(d.user.isAdmin || false) }
|
||
} catch {}
|
||
}, [])
|
||
|
||
useEffect(() => { fetchRoles(); fetchLoginUser() }, [fetchRoles, fetchLoginUser])
|
||
|
||
async function refreshUsers() {
|
||
try { const u = await fetch('/api/admin/users').then(r => r.json()); if (u.users?.length) setUsers(u.users) } catch {}
|
||
}
|
||
|
||
function showResult(ok: boolean, msg: string) { setResult({ ok, msg }); setTimeout(() => setResult(null), 4000) }
|
||
|
||
async function handleCreate(e: React.FormEvent) {
|
||
e.preventDefault()
|
||
setLoading(true)
|
||
try {
|
||
const res = await fetch('/api/admin/create-user', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, displayName, assetsRole, issueRole, monitorRole, email: email || undefined }) })
|
||
const d = await res.json()
|
||
if (res.ok) {
|
||
if (d.password) { setGeneratedPwd(d.password); setPwdUser(username); setPwdName(displayName || username); setShowPwd(true); setCopied(false) }
|
||
setUsername(''); setDisplayName(''); setEmail('')
|
||
refreshUsers()
|
||
}
|
||
showResult(res.ok, d.message || d.error || '操作完成')
|
||
} catch { showResult(false, '网络错误') }
|
||
finally { setLoading(false) }
|
||
}
|
||
|
||
async function handleCopy() {
|
||
const text = `您好,${pwdName}:\n\n您的 OA 统一门户账号已创建,请使用以下信息登录:\n\n 用户名:${pwdUser}\n 密 码:${generatedPwd}\n\n登录地址:https://oa.tlyq.ai\n\n请在首次登录后及时修改密码。`
|
||
try { await navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 2000) } catch {}
|
||
}
|
||
|
||
async function handleDelete(target: string) {
|
||
if (!confirm(`确定删除用户「${target}」?\n\n将从 LLDAP 及所有站点中永久删除,不可撤销。`)) return
|
||
setDeleting(target)
|
||
try {
|
||
const res = await fetch('/api/admin/users', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: target }) })
|
||
const d = await res.json()
|
||
if (res.ok) setUsers(users.filter(u => u.username !== target))
|
||
showResult(res.ok, res.ok ? `已删除 ${target}` : (d.error || '删除失败'))
|
||
} catch { showResult(false, '网络错误') }
|
||
finally { setDeleting(null) }
|
||
}
|
||
|
||
async function handleSyncEmails() {
|
||
setSyncingEmails(true)
|
||
try {
|
||
const res = await fetch('/api/admin/sync-emails', { method: 'POST' })
|
||
const d = await res.json()
|
||
showResult(res.ok, res.ok ? `已同步 ${d.synced} 个用户邮箱至各站点` : (d.error || '同步失败'))
|
||
} catch { showResult(false, '网络错误') }
|
||
finally { setSyncingEmails(false) }
|
||
}
|
||
|
||
const tabItem = (t: string, label: string) => (
|
||
<button onClick={() => { setTab(t as any); setResult(null) }} style={{
|
||
padding: '14px 28px', border: 'none', background: 'transparent',
|
||
color: tab === t ? '#2563eb' : 'var(--text-secondary)',
|
||
fontSize: 14, fontWeight: tab === t ? 700 : 500, cursor: 'pointer',
|
||
borderBottom: tab === t ? '3px solid #2563eb' : '3px solid transparent',
|
||
marginBottom: -1, transition: 'all 0.15s',
|
||
}}>{label}</button>
|
||
)
|
||
|
||
return (
|
||
<div style={s.wrap}>
|
||
<HeaderUI displayName={loginDisplayName || loginUser} isAdmin={isAdmin} backLabel="用户管理" />
|
||
|
||
{showPwd && (
|
||
<div style={{ position: 'fixed', inset: 0, zIndex: 200, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(4px)' }} onClick={() => setShowPwd(false)}>
|
||
<div style={{ background: 'var(--bg-card)', borderRadius: 16, padding: '36px 40px', boxShadow: '0 20px 60px rgba(0,0,0,0.2)', textAlign: 'center', maxWidth: 420, width: '90%', border: '1px solid var(--border)' }} onClick={e => e.stopPropagation()}>
|
||
<div style={{ fontSize: 32, marginBottom: 12 }}>🔑</div>
|
||
<h2 style={{ fontSize: 18, fontWeight: 700, margin: '0 0 6px', color: 'var(--text)' }}>用户创建成功</h2>
|
||
<p style={{ fontSize: 13, color: 'var(--text-muted)', margin: '0 0 16px' }}>{pwdName !== pwdUser ? `${pwdName}(${pwdUser})` : pwdUser}</p>
|
||
<div style={{ background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 10, padding: '14px 20px', fontSize: 22, fontWeight: 700, fontFamily: 'monospace', letterSpacing: '0.05em', color: '#2563eb', marginBottom: 8, wordBreak: 'break-all', userSelect: 'all' }}>{generatedPwd}</div>
|
||
<p style={{ fontSize: 11, color: 'var(--text-muted)', margin: '0 0 20px' }}>初始密码,请妥善保存</p>
|
||
<div style={{ display: 'flex', gap: 10 }}>
|
||
<button onClick={handleCopy} style={{ flex: 1, height: 42, borderRadius: 8, border: 'none', cursor: 'pointer', background: copied ? '#f0fdf4' : '#2563eb', color: copied ? '#16a34a' : '#fff', fontSize: 14, fontWeight: 500 }}>{copied ? '✓ 已复制' : '复制信息'}</button>
|
||
<button onClick={() => setShowPwd(false)} style={{ flex: 1, height: 42, borderRadius: 8, border: '1px solid var(--border)', background: 'var(--bg-card)', color: 'var(--text-secondary)', fontSize: 14, cursor: 'pointer' }}>关闭</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Toast */}
|
||
{result && (
|
||
<div style={{ ...s.toast, background: result.ok ? '#f0fdf4' : '#fef2f2', color: result.ok ? '#16a34a' : '#dc2626', border: result.ok ? '1px solid #bbf7d0' : '1px solid #fecaca' }}>
|
||
{result.msg}
|
||
</div>
|
||
)}
|
||
|
||
<div style={s.main}>
|
||
{/* Tab Bar */}
|
||
<div style={{ ...s.tabBar, justifyContent: 'space-between', alignItems: 'center' }}>
|
||
<div style={{ display: 'flex' }}>
|
||
{tabItem('create', '创建用户')}
|
||
{tabItem('manage', '删除用户')}
|
||
{tabItem('roles', '权限管理')}
|
||
</div>
|
||
<button onClick={handleSyncEmails} disabled={syncingEmails} style={{
|
||
padding: '8px 18px', borderRadius: 6, border: '1px solid var(--border)',
|
||
background: 'var(--bg-card)', color: syncingEmails ? 'var(--text-muted)' : 'var(--text-secondary)',
|
||
fontSize: 12, cursor: syncingEmails ? 'not-allowed' : 'pointer', fontWeight: 500,
|
||
marginBottom: -1,
|
||
}}>{syncingEmails ? '同步中...' : '同步邮箱至站点'}</button>
|
||
</div>
|
||
|
||
<div style={s.content}>
|
||
{/* ====== 创建用户 ====== */}
|
||
{tab === 'create' && (
|
||
<form onSubmit={handleCreate}>
|
||
<div style={s.grid2}>
|
||
<div style={s.field}>
|
||
<label style={s.label}>用户名</label>
|
||
<input type="text" value={username} onChange={e => setUsername(e.target.value)} placeholder="英文用户名" required style={s.input} />
|
||
</div>
|
||
<div style={s.field}>
|
||
<label style={s.label}>显示名</label>
|
||
<input type="text" value={displayName} onChange={e => setDisplayName(e.target.value)} placeholder="可选,留空同用户名" style={s.input} />
|
||
</div>
|
||
</div>
|
||
<div style={s.field}>
|
||
<label style={s.label}>邮箱地址 <span style={{ fontSize: 11, color: 'var(--text-muted)', fontWeight: 400 }}>(选填,填写后将发送密码设置链接至此邮箱)</span></label>
|
||
<input type="email" value={email} onChange={e => setEmail(e.target.value)} placeholder="user@example.com" style={s.input} />
|
||
</div>
|
||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 24, marginBottom: 20 }}>
|
||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text)', marginBottom: 16 }}>各站点角色</div>
|
||
<div style={s.grid2}>
|
||
<div style={s.field}>
|
||
<label style={s.label}>资产管理</label>
|
||
<select value={assetsRole} onChange={e => setAssetsRole(e.target.value)} style={s.select}>
|
||
{assetsRoles.map(r => <option key={r.name} value={r.name}>{r.display_name}({r.name})</option>)}
|
||
</select>
|
||
</div>
|
||
<div style={s.field}>
|
||
<label style={s.label}>工单跟踪</label>
|
||
<select value={issueRole} onChange={e => setIssueRole(e.target.value)} style={s.select}>
|
||
{issueRoles.map(r => <option key={r.name} value={r.name}>{r.display_name}({r.name})</option>)}
|
||
</select>
|
||
</div>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||
<label style={s.label}>告警监控</label>
|
||
<select value={monitorRole} onChange={e => setMonitorRole(e.target.value)} style={s.select}>
|
||
{monitorRoles.map(r => <option key={r.name} value={r.name}>{r.display_name}({r.name})</option>)}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<button type="submit" disabled={loading} style={{ ...s.btn, background: loading ? '#93c5fd' : '#2563eb', cursor: loading ? 'not-allowed' : 'pointer' }}>
|
||
{loading ? '创建中...' : '创建用户'}
|
||
</button>
|
||
</form>
|
||
)}
|
||
|
||
{/* ====== 删除用户 ====== */}
|
||
{tab === 'manage' && (
|
||
<div>
|
||
{users.length === 0 ? (
|
||
<p style={{ fontSize: 13, color: 'var(--text-muted)', textAlign: 'center', padding: 40 }}>加载中...</p>
|
||
) : (
|
||
<div style={s.tableWrap}><table style={s.table}>
|
||
<thead>
|
||
<tr>
|
||
<th style={s.th}>用户名</th>
|
||
<th style={s.th}>显示名</th>
|
||
<th style={s.th}>邮箱</th>
|
||
<th style={s.th}>创建时间</th>
|
||
<th style={{ ...s.th, textAlign: 'right' as any }}>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{users.map(u => (
|
||
<tr key={u.username} style={{ transition: 'background 0.15s' }}
|
||
onMouseEnter={e => (e.currentTarget.style.background = 'var(--bg-hover)')}
|
||
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}>
|
||
<td style={{ ...s.td, fontWeight: 500 }}>{u.username}</td>
|
||
<td style={{ ...s.td, color: (u.displayName === u.username || !u.displayName) ? 'var(--text-muted)' : 'var(--text)' }}>
|
||
{(u.displayName && u.displayName !== u.username) ? u.displayName : '—'}
|
||
</td>
|
||
<td style={{ ...s.td, color: u.email ? 'var(--text-secondary)' : 'var(--text-muted)', fontSize: 12 }}>
|
||
{u.email || '未设置'}
|
||
</td>
|
||
<td style={{ ...s.td, color: 'var(--text-muted)', fontSize: 12 }}>{u.createdAt?.substring(0, 19)}</td>
|
||
<td style={{ ...s.td, textAlign: 'right' as any }}>
|
||
{(u.username === 'admin' || u.username === 'localadmin') ? (
|
||
<span style={{ ...s.badge, background: 'var(--bg-hover)', color: 'var(--text-muted)' }}>系统保留</span>
|
||
) : (
|
||
<button onClick={() => handleDelete(u.username)} disabled={deleting === u.username} style={{
|
||
padding: '6px 18px', borderRadius: 6, border: 'none', cursor: 'pointer',
|
||
background: deleting === u.username ? '#fecaca' : '#fef2f2',
|
||
color: deleting === u.username ? '#fca5a5' : '#dc2626',
|
||
fontSize: 12, fontWeight: 500, transition: 'all 0.15s',
|
||
}}>{deleting === u.username ? '...' : '删除'}</button>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table></div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* ====== 权限管理 ====== */}
|
||
{tab === 'roles' && <RoleManager setResult={showResult} onUserUpdated={fetchLoginUser} />}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|