guardia-cms/frontend/src/pages/UserManagement.tsx

95 lines
6.0 KiB
TypeScript

import { useEffect, useState } from 'react'
import { Plus, Trash2, KeyRound, Power, ShieldCheck } from 'lucide-react'
import { CMS_ROLES } from '../components/rbac'
import {
getUsers, createUser, updateUserRole, updateUserActive, resetPassword, deleteUser,
} from '../api/client'
interface User { id: number; username: string; displayName?: string; role: string; active: boolean; createdAt: string | null }
function friendlyError(e: any): string {
const msg: string = e?.response?.data?.message || ''
if (e?.response?.status === 403) return '권한이 없습니다 (SuperAdmin 전용).'
if (/409|exist|이미/i.test(msg)) return '이미 존재하는 사용자명입니다.'
if (/last|마지막|423/i.test(msg)) return '마지막 SuperAdmin은 강등/삭제/비활성화할 수 없습니다.'
if (/404|not found/i.test(msg)) return '대상 사용자를 찾을 수 없습니다.'
return '요청을 처리하지 못했습니다. 잠시 후 다시 시도하세요.'
}
export default function UserManagement() {
const [list, setList] = useState<User[]>([])
const [form, setForm] = useState({ username: '', password: '', displayName: '', role: 'AUTHOR' })
const [show, setShow] = useState(false)
const [err, setErr] = useState('')
const load = () => getUsers().then(r => { setList(r.data?.data || []); setErr('') }).catch(e => setErr(friendlyError(e)))
useEffect(() => { load() }, [])
const wrap = async (fn: () => Promise<any>) => {
try { await fn(); setErr(''); await load() } catch (e) { setErr(friendlyError(e)) }
}
const create = async () => {
if (!form.username || !form.password) { setErr('사용자명과 비밀번호를 입력하세요.'); return }
await wrap(async () => { await createUser(form); setForm({ username: '', password: '', displayName: '', role: 'AUTHOR' }); setShow(false) })
}
const changeRole = (u: User, role: string) => wrap(() => updateUserRole(u.id, role))
const toggleActive = (u: User) => wrap(() => updateUserActive(u.id, !u.active))
const doReset = (u: User) => { const pw = window.prompt(`'${u.username}' 의 새 비밀번호`); if (pw) wrap(() => resetPassword(u.id, pw)) }
const remove = (u: User) => { if (window.confirm(`'${u.username}' 삭제?`)) wrap(() => deleteUser(u.id)) }
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-xl font-bold flex items-center gap-2"><ShieldCheck className="text-brand" size={20} /> / </h1>
<button onClick={() => setShow(!show)} className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-brand text-ink text-sm font-semibold">
<Plus size={16} />
</button>
</div>
{err && <div className="bg-rose-500/10 border border-rose-500/40 text-rose-300 text-sm rounded-lg px-4 py-2.5 mb-4">{err}</div>}
{show && (
<div className="bg-card border border-edge rounded-xl p-5 mb-6 grid grid-cols-5 gap-3">
<input placeholder="사용자명" value={form.username} onChange={e => setForm({ ...form, username: e.target.value })} className="inp" />
<input placeholder="표시 이름" value={form.displayName} onChange={e => setForm({ ...form, displayName: e.target.value })} className="inp" />
<input placeholder="비밀번호" type="password" value={form.password} onChange={e => setForm({ ...form, password: e.target.value })} className="inp" />
<select value={form.role} onChange={e => setForm({ ...form, role: e.target.value })} className="inp">
{CMS_ROLES.map(r => <option key={r} value={r}>{r}</option>)}
</select>
<button onClick={create} className="px-4 rounded-lg bg-brand text-ink text-sm font-semibold"></button>
</div>
)}
<div className="bg-card border border-edge rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead className="text-slate-500 text-xs bg-panel">
<tr><th className="text-left py-3 px-4">ID</th><th className="text-left"></th><th className="text-left"> </th><th className="text-left"></th><th className="text-left"></th><th className="text-left"></th><th className="text-right px-4"></th></tr>
</thead>
<tbody>
{list.map(u => (
<tr key={u.id} className="border-t border-edge/50">
<td className="py-3 px-4 text-slate-500">{u.id}</td>
<td className="font-medium">{u.username}</td>
<td className="text-slate-400">{u.displayName || '-'}</td>
<td>
<select value={u.role} onChange={e => changeRole(u, e.target.value)} className="px-2 py-1 rounded-md bg-ink border border-edge text-xs focus:border-brand outline-none">
{CMS_ROLES.map(r => <option key={r} value={r}>{r}</option>)}
</select>
</td>
<td><span className={`inline-block px-2 py-0.5 rounded-md text-xs font-medium ${u.active ? 'bg-accent/15 text-accent' : 'bg-slate-500/15 text-slate-400'}`}>{u.active ? '활성' : '비활성'}</span></td>
<td className="text-slate-500 text-xs">{u.createdAt ? new Date(u.createdAt).toLocaleString('ko-KR') : '-'}</td>
<td className="px-4"><div className="flex items-center justify-end gap-3">
<button onClick={() => toggleActive(u)} title={u.active ? '비활성화' : '활성화'} className="text-slate-400 hover:text-brand"><Power size={16} /></button>
<button onClick={() => doReset(u)} title="비밀번호 재설정" className="text-slate-400 hover:text-brand"><KeyRound size={16} /></button>
<button onClick={() => remove(u)} title="삭제" className="text-rose-400 hover:text-rose-300"><Trash2 size={16} /></button>
</div></td>
</tr>
))}
{list.length === 0 && <tr><td colSpan={7} className="py-6 text-center text-slate-500"> .</td></tr>}
</tbody>
</table>
</div>
</div>
)
}