- workspace→repos 전체 소스 클린 재동기화(부분 auto-sync 컴파일불가 해소) - db/202_ai_provider_claude.sql: hrm_settings ai.provider UPSERT claude(멱등, 104 시드 이후) - application.yml schema-locations 에 202 등재(mode=always) - 키 미설정/실패 시 AiTextRouter 가 Ollama 자동 폴백(무중단·무회귀) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
206 lines
11 KiB
TypeScript
206 lines
11 KiB
TypeScript
import React, { useEffect, useState } from 'react'
|
|
import api, { adminOtpReset } from '../api/client'
|
|
|
|
export default function AdminPage() {
|
|
const [users, setUsers] = useState<any[]>([])
|
|
const [auditLogs, setAuditLogs] = useState<any[]>([])
|
|
const [settings, setSettings] = useState<any[]>([])
|
|
const [tab, setTab] = useState<'users'|'audit'|'settings'>('users')
|
|
const [showForm, setShowForm] = useState(false)
|
|
const [form, setForm] = useState<any>({ username: '', password: '', fullName: '', email: '', role: 'HR_STAFF' })
|
|
|
|
const loadUsers = () => api.get('/admin/users').then(r => setUsers(r.data.data || [])).catch(() => {})
|
|
const loadAudit = () => api.get('/admin/audit-logs', { params: { page: 1, size: 30 } }).then(r => setAuditLogs(r.data.data.items || [])).catch(() => {})
|
|
const loadSettings = () => api.get('/admin/settings').then(r => setSettings(r.data.data || [])).catch(() => {})
|
|
|
|
useEffect(() => { loadUsers(); loadAudit(); loadSettings() }, [])
|
|
|
|
const saveUser = async () => {
|
|
try {
|
|
await api.post('/admin/users', form)
|
|
setShowForm(false); setForm({ username: '', password: '', fullName: '', email: '', role: 'HR_STAFF' })
|
|
loadUsers()
|
|
} catch (e: any) { alert(e.response?.data?.message || '저장 실패') }
|
|
}
|
|
|
|
const toggleUser = async (id: number, active: boolean) => {
|
|
await api.patch(`/admin/users/${id}/status`, null, { params: { active: !active } })
|
|
loadUsers()
|
|
}
|
|
|
|
// 관리자 OTP 초기화(SUPERADMIN). 대상 사용자는 다음 로그인 시 OTP_SETUP(QR 재등록)을 탄다.
|
|
const resetOtp = async (id: number, username: string) => {
|
|
if (!window.confirm(
|
|
`'${username}' 사용자의 OTP를 초기화하시겠습니까?\n초기화하면 다음 로그인 시 재등록해야 합니다.`
|
|
)) return
|
|
try {
|
|
await adminOtpReset(id)
|
|
alert('OTP가 초기화되었습니다.')
|
|
} catch (e: any) {
|
|
alert(e?.response?.status === 403
|
|
? '권한이 없습니다 (SUPERADMIN 전용).'
|
|
: (e?.response?.data?.message || 'OTP 초기화에 실패했습니다.'))
|
|
}
|
|
}
|
|
|
|
const saveSetting = async (key: string, value: string) => {
|
|
await api.put(`/admin/settings/${key}`, null, { params: { value } })
|
|
loadSettings()
|
|
}
|
|
|
|
const roleBadge = (r: string) => {
|
|
const m: any = { SUPERADMIN: 'bg-red-100 text-red-700', MANAGER: 'bg-orange-100 text-orange-700', HR_STAFF: 'bg-blue-100 text-blue-700', VIEWER: 'bg-slate-100 text-slate-600' }
|
|
return <span className={`badge ${m[r]||''}`}>{r}</span>
|
|
}
|
|
|
|
const methodBadge = (m: string) => {
|
|
const c: any = { GET: 'bg-green-100 text-green-700', POST: 'bg-blue-100 text-blue-700', PUT: 'bg-yellow-100 text-yellow-700', PATCH: 'bg-yellow-100 text-yellow-700', DELETE: 'bg-red-100 text-red-700' }
|
|
return <span className={`badge text-xs font-mono ${c[m]||''}`}>{m}</span>
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<h1 className="text-2xl font-bold text-slate-800">시스템 관리</h1>
|
|
{tab === 'users' && <button className="btn-primary" onClick={() => setShowForm(true)}>+ 사용자 추가</button>}
|
|
</div>
|
|
|
|
<div className="flex gap-2 border-b border-slate-200">
|
|
{[{v:'users',l:'사용자 관리'},{v:'audit',l:'감사 로그'},{v:'settings',l:'시스템 설정'}].map(t => (
|
|
<button key={t.v} onClick={() => setTab(t.v as any)}
|
|
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${tab===t.v?'border-blue-600 text-blue-600':'border-transparent text-slate-500'}`}>
|
|
{t.l}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{tab === 'users' && (
|
|
<div className="card overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead className="border-b border-slate-100">
|
|
<tr>{['사용자명','이름','이메일','역할','상태','마지막 로그인','관리'].map(h=><th key={h} className="table-header">{h}</th>)}</tr>
|
|
</thead>
|
|
<tbody>
|
|
{users.map((u: any) => (
|
|
<tr key={u.id} className="border-b border-slate-50 hover:bg-slate-50">
|
|
<td className="table-cell font-mono text-xs">{u.username}</td>
|
|
<td className="table-cell font-medium">{u.full_name}</td>
|
|
<td className="table-cell text-xs">{u.email || '-'}</td>
|
|
<td className="table-cell">{roleBadge(u.role)}</td>
|
|
<td className="table-cell">
|
|
<span className={`badge ${u.is_active?'bg-green-100 text-green-700':'bg-slate-100 text-slate-500'}`}>
|
|
{u.is_active ? '활성' : '비활성'}
|
|
</span>
|
|
</td>
|
|
<td className="table-cell text-xs">{u.last_login_at?.slice(0,16) || '-'}</td>
|
|
<td className="table-cell">
|
|
<div className="flex items-center gap-3">
|
|
<button className={`text-xs hover:underline ${u.is_active?'text-red-600':'text-blue-600'}`}
|
|
onClick={() => toggleUser(u.id, u.is_active)}>
|
|
{u.is_active ? '비활성화' : '활성화'}
|
|
</button>
|
|
<button className="text-xs text-slate-500 hover:underline hover:text-blue-600"
|
|
onClick={() => resetOtp(u.id, u.username)} title="OTP 초기화(다음 로그인 재등록)">
|
|
OTP 초기화
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{users.length === 0 && <tr><td colSpan={7} className="text-center py-8 text-slate-400">사용자가 없습니다</td></tr>}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{tab === 'audit' && (
|
|
<div className="card overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead className="border-b border-slate-100">
|
|
<tr>{['시간','사용자','메서드','경로','상태','IP'].map(h=><th key={h} className="table-header">{h}</th>)}</tr>
|
|
</thead>
|
|
<tbody>
|
|
{auditLogs.map((l: any) => (
|
|
<tr key={l.id} className="border-b border-slate-50 hover:bg-slate-50">
|
|
<td className="table-cell text-xs">{l.created_at?.slice(0,19)}</td>
|
|
<td className="table-cell text-xs">{l.username || '-'}</td>
|
|
<td className="table-cell">{methodBadge(l.method)}</td>
|
|
<td className="table-cell text-xs font-mono max-w-[200px] truncate">{l.path}</td>
|
|
<td className="table-cell">
|
|
<span className={`badge ${l.status_code<400?'bg-green-100 text-green-700':'bg-red-100 text-red-700'}`}>
|
|
{l.status_code}
|
|
</span>
|
|
</td>
|
|
<td className="table-cell text-xs">{l.ip_address || '-'}</td>
|
|
</tr>
|
|
))}
|
|
{auditLogs.length === 0 && <tr><td colSpan={6} className="text-center py-8 text-slate-400">감사 로그가 없습니다</td></tr>}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{tab === 'settings' && (
|
|
<div className="card space-y-3">
|
|
<h2 className="font-semibold text-slate-700 mb-2">시스템 설정</h2>
|
|
{settings.map((s: any) => (
|
|
<div key={s.key} className="flex items-center justify-between p-3 border border-slate-100 rounded-lg">
|
|
<div>
|
|
<p className="text-sm font-medium text-slate-700">{s.key}</p>
|
|
{s.description && <p className="text-xs text-slate-400 mt-0.5">{s.description}</p>}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<input defaultValue={s.value}
|
|
onBlur={e => { if (e.target.value !== s.value) saveSetting(s.key, e.target.value) }}
|
|
className="border border-slate-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none w-40 text-right" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
{settings.length === 0 && <p className="text-center py-8 text-slate-400 text-sm">설정이 없습니다</p>}
|
|
</div>
|
|
)}
|
|
|
|
{showForm && (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
|
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md p-6">
|
|
<h2 className="text-lg font-bold text-slate-800 mb-5">사용자 추가</h2>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-xs font-medium text-slate-600 mb-1">사용자명*</label>
|
|
<input value={form.username} onChange={e => setForm({...form, username: e.target.value})}
|
|
className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-slate-600 mb-1">비밀번호*</label>
|
|
<input type="password" value={form.password} onChange={e => setForm({...form, password: e.target.value})}
|
|
className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-slate-600 mb-1">이름</label>
|
|
<input value={form.fullName} onChange={e => setForm({...form, fullName: e.target.value})}
|
|
className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-slate-600 mb-1">이메일</label>
|
|
<input type="email" value={form.email} onChange={e => setForm({...form, email: e.target.value})}
|
|
className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-slate-600 mb-1">역할</label>
|
|
<select value={form.role} onChange={e => setForm({...form, role: e.target.value})}
|
|
className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm">
|
|
{['SUPERADMIN','MANAGER','HR_STAFF','VIEWER'].map(r => <option key={r} value={r}>{r}</option>)}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end gap-3 mt-5">
|
|
<button className="btn-secondary" onClick={() => setShowForm(false)}>취소</button>
|
|
<button className="btn-primary" onClick={saveUser}>저장</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|