oa-ai/src/app/admin/create-user/page.tsx

282 lines
17 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.

'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 }}>&#128273;</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>
)
}