- 최고관리자(Supervisor) 전용 2중 보안 잠금 시스템 및 인증 UI 적용 - 데이터베이스 인프라 및 암호화 마스터 키 자가 관리 기능 구축 - 권한 계층(Supervisor > Admin > User) 기반의 메뉴 노출 및 접근 제어 로직 강화 - 시스템 버전 정보 페이지 신규 추가 및 패키지 버전 자동 연동 (v0.2.5) - 사용자 관리 UI 디자인 개선 및 폰트/스타일 일원화
325 lines
17 KiB
TypeScript
325 lines
17 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { Card } from '../../shared/ui/Card';
|
|
import { Button } from '../../shared/ui/Button';
|
|
import { Input } from '../../shared/ui/Input';
|
|
import { apiClient } from '../../shared/api/client';
|
|
import { Plus, Edit2, Trash2, X, Check, Shield, User as UserIcon, ShieldCheck, RefreshCcw } from 'lucide-react';
|
|
import type { User } from '../../shared/auth/AuthContext';
|
|
import { useAuth } from '../../shared/auth/AuthContext';
|
|
import './UserManagementPage.css';
|
|
|
|
interface UserFormData {
|
|
id: string;
|
|
password?: string;
|
|
name: string;
|
|
department: string;
|
|
position: string;
|
|
phone: string;
|
|
role: 'supervisor' | 'admin' | 'user';
|
|
}
|
|
|
|
export function UserManagementPage() {
|
|
const { user: currentUser } = useAuth();
|
|
const [users, setUsers] = useState<User[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
// Modal State
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [formData, setFormData] = useState<UserFormData>({
|
|
id: '',
|
|
password: '',
|
|
name: '',
|
|
department: '',
|
|
position: '',
|
|
phone: '',
|
|
role: 'user'
|
|
});
|
|
|
|
useEffect(() => {
|
|
fetchUsers();
|
|
}, []);
|
|
|
|
const fetchUsers = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await apiClient.get('/users');
|
|
setUsers(res.data);
|
|
} catch (error) {
|
|
console.error('Failed to fetch users', error);
|
|
alert('사용자 목록을 불러오지 못했습니다.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleOpenAdd = () => {
|
|
setFormData({
|
|
id: '',
|
|
password: '',
|
|
name: '',
|
|
department: '',
|
|
position: '',
|
|
phone: '',
|
|
role: 'user'
|
|
});
|
|
setIsEditing(false);
|
|
setIsModalOpen(true);
|
|
};
|
|
|
|
const handleOpenEdit = (user: User) => {
|
|
setFormData({
|
|
id: user.id,
|
|
password: '',
|
|
name: user.name,
|
|
department: user.department || '',
|
|
position: user.position || '',
|
|
phone: user.phone || '',
|
|
role: user.role
|
|
});
|
|
setIsEditing(true);
|
|
setIsModalOpen(true);
|
|
};
|
|
|
|
const handleDelete = async (id: string) => {
|
|
if (!confirm('정말 이 사용자를 삭제하시겠습니까?')) return;
|
|
try {
|
|
await apiClient.delete(`/users/${id}`);
|
|
fetchUsers();
|
|
} catch (error) {
|
|
console.error('Failed to delete user', error);
|
|
alert('삭제 실패');
|
|
}
|
|
};
|
|
|
|
const formatPhoneNumber = (value: string) => {
|
|
const cleaned = value.replace(/\D/g, '');
|
|
if (cleaned.length <= 3) return cleaned;
|
|
else if (cleaned.length <= 7) return `${cleaned.slice(0, 3)}-${cleaned.slice(3)}`;
|
|
else return `${cleaned.slice(0, 3)}-${cleaned.slice(3, 7)}-${cleaned.slice(7, 11)}`;
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
try {
|
|
if (isEditing) {
|
|
const payload: any = { ...formData };
|
|
if (!payload.password) delete payload.password;
|
|
await apiClient.put(`/users/${formData.id}`, payload);
|
|
alert('수정되었습니다.');
|
|
} else {
|
|
if (!formData.password) return alert('비밀번호를 입력하세요.');
|
|
await apiClient.post('/users', formData);
|
|
alert('등록되었습니다.');
|
|
}
|
|
setIsModalOpen(false);
|
|
fetchUsers();
|
|
} catch (error: any) {
|
|
alert(`오류: ${error.response?.data?.error || error.message}`);
|
|
}
|
|
};
|
|
|
|
const getRoleBadge = (role: string) => {
|
|
switch (role) {
|
|
case 'supervisor':
|
|
return (
|
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-amber-100 text-amber-700 ring-1 ring-amber-200">
|
|
<ShieldCheck size={10} className="mr-1" /> 최고관리자
|
|
</span>
|
|
);
|
|
case 'admin':
|
|
return (
|
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-indigo-100 text-indigo-700 ring-1 ring-indigo-200">
|
|
<Shield size={10} className="mr-1" /> 관리자
|
|
</span>
|
|
);
|
|
default:
|
|
return (
|
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-slate-100 text-slate-600 ring-1 ring-slate-200">
|
|
<UserIcon size={10} className="mr-1" /> 사용자
|
|
</span>
|
|
);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="page-container p-6 max-w-7xl mx-auto">
|
|
<div className="flex justify-between items-center mb-6">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-slate-900">시스템 관리 - 사용자 관리</h1>
|
|
<p className="text-slate-500 mt-1">시스템 접속 권한 및 사용자 정보를 관리합니다.</p>
|
|
</div>
|
|
<Button onClick={handleOpenAdd} icon={<Plus size={16} />}>사용자 등록</Button>
|
|
</div>
|
|
|
|
<Card className="overflow-hidden shadow-sm border-slate-200">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm text-left">
|
|
<thead className="text-xs text-slate-500 uppercase bg-slate-50/80 border-b border-slate-200">
|
|
<tr>
|
|
<th className="px-6 py-4">아이디 / 권한</th>
|
|
<th className="px-6 py-4">이름</th>
|
|
<th className="px-6 py-4">소속 / 직위</th>
|
|
<th className="px-6 py-4">연락처</th>
|
|
<th className="px-6 py-4">마지막 접속</th>
|
|
<th className="px-6 py-4 text-center">관리</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-100 bg-white">
|
|
{loading ? (
|
|
<tr>
|
|
<td colSpan={6} className="px-6 py-12 text-center text-slate-400">
|
|
<div className="flex flex-col items-center gap-2">
|
|
<RefreshCcw size={24} className="animate-spin text-indigo-500" />
|
|
<span>사용자 데이터를 불러오는 중...</span>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
) : users.map((user) => (
|
|
<tr key={user.id} className="hover:bg-slate-50/50 transition-colors">
|
|
<td className="px-6 py-4">
|
|
<div className="font-bold text-slate-900">{user.id}</div>
|
|
<div className="mt-1">{getRoleBadge(user.role)}</div>
|
|
</td>
|
|
<td className="px-6 py-4 font-semibold text-slate-800">{user.name}</td>
|
|
<td className="px-6 py-4">
|
|
<div className="text-slate-900 font-bold">{user.department || '-'}</div>
|
|
<div className="text-slate-600 text-[11px] font-medium">{user.position}</div>
|
|
</td>
|
|
<td className="px-6 py-4 text-slate-700 font-medium">{user.phone || '-'}</td>
|
|
<td className="px-6 py-4 text-slate-400 text-[11px]">
|
|
{user.last_login ? new Date(user.last_login).toLocaleString() : '미접속'}
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
<div className="flex justify-center gap-1">
|
|
<button className="p-2 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors" onClick={() => handleOpenEdit(user)}>
|
|
<Edit2 size={16} />
|
|
</button>
|
|
<button className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" onClick={() => handleDelete(user.id)}>
|
|
<Trash2 size={16} />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{!loading && users.length === 0 && (
|
|
<tr>
|
|
<td colSpan={6} className="px-6 py-12 text-center text-slate-400">등록된 사용자가 없습니다.</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Modal */}
|
|
{isModalOpen && (
|
|
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-900/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
|
|
<Card className="w-full max-w-md shadow-2xl border-slate-200 overflow-hidden">
|
|
<div className="p-6 border-b border-slate-100 flex justify-between items-center bg-slate-50/50">
|
|
<h2 className="text-lg font-bold text-slate-800">
|
|
{isEditing ? '사용자 정보 수정' : '새 사용자 등록'}
|
|
</h2>
|
|
<button onClick={() => setIsModalOpen(false)} className="text-slate-400 hover:text-slate-600 bg-white border border-slate-200 rounded-lg p-1">
|
|
<X size={18} />
|
|
</button>
|
|
</div>
|
|
<form onSubmit={handleSubmit} className="p-6 space-y-4" autoComplete="off">
|
|
<div>
|
|
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">아이디 <span className="text-red-500">*</span></label>
|
|
<Input
|
|
value={formData.id}
|
|
onChange={(e) => setFormData({ ...formData, id: e.target.value })}
|
|
disabled={isEditing}
|
|
placeholder="로그인 아이디 입력"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">
|
|
비밀번호 <span className="text-red-500">{!isEditing && '*'}</span>
|
|
</label>
|
|
<Input
|
|
type="password"
|
|
value={formData.password}
|
|
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
|
placeholder={isEditing ? "(변경시에만 입력)" : "초기 비밀번호 입력"}
|
|
required={!isEditing}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">이름 <span className="text-red-500">*</span></label>
|
|
<Input
|
|
value={formData.name}
|
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">권한</label>
|
|
<select
|
|
className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 transition-all outline-none"
|
|
value={formData.role}
|
|
onChange={(e) => setFormData({ ...formData, role: e.target.value as any })}
|
|
disabled={
|
|
// 1. 현재 관리자(admin)는 최고관리자(supervisor)의 권한을 바꿀 수 없음
|
|
(isEditing && users.find(u => u.id === formData.id)?.role === 'supervisor' && currentUser?.role !== 'supervisor') ||
|
|
// 2. 현재 관리자(admin)는 자기 자신의 권한을 'supervisor'로 올릴 수 없음 (애초에 옵션에서 걸러지겠지만 안전 장치)
|
|
(formData.role === 'supervisor' && currentUser?.role !== 'supervisor')
|
|
}
|
|
>
|
|
<option value="user">일반 사용자</option>
|
|
<option value="admin">관리자</option>
|
|
{/* 최고관리자(supervisor) 옵션은 오직 최고관리자만 부여 가능 */}
|
|
{(currentUser?.role === 'supervisor' || (isEditing && users.find(u => u.id === formData.id)?.role === 'supervisor')) && (
|
|
<option value="supervisor">최고 관리자 (Supervisor)</option>
|
|
)}
|
|
</select>
|
|
{currentUser?.role !== 'supervisor' && (
|
|
<p className="text-[10px] text-slate-400 mt-1">* 최고관리자 권한 부여는 최고관리자만 가능합니다.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">부서</label>
|
|
<Input
|
|
value={formData.department}
|
|
onChange={(e) => setFormData({ ...formData, department: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">직위</label>
|
|
<Input
|
|
value={formData.position}
|
|
onChange={(e) => setFormData({ ...formData, position: e.target.value })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">핸드폰 번호</label>
|
|
<Input
|
|
value={formData.phone}
|
|
onChange={(e) => setFormData({ ...formData, phone: formatPhoneNumber(e.target.value) })}
|
|
placeholder="010-0000-0000"
|
|
maxLength={13}
|
|
/>
|
|
</div>
|
|
|
|
<div className="pt-4 flex justify-end gap-2">
|
|
<Button type="button" variant="secondary" onClick={() => setIsModalOpen(false)}>취소</Button>
|
|
<Button type="submit" className="bg-indigo-600" icon={<Check size={16} />}>{isEditing ? '저장' : '전송'}</Button>
|
|
</div>
|
|
</form>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|