generated from gc/template-java-maven
공통:
- 다크/라이트 모드 (ThemeContext, Tailwind dark variant, 전체 페이지 적용)
- 사이드바 아이콘 링크체인 (#FF2E63), 헤더/사이드바 높이 통일
- 컨텐츠 영역 max-w-7xl 마진 통일 (대시보드 제외)
- 전체 Actions 버튼 bg-color-100 스타일 통일
- date input 달력 아이콘 다크모드 (filter invert)
API Keys:
- Request: 영구 사용 옵션 추가, 프리셋/영구 버튼 다크모드
- My Keys: ADMIN 직접 생성 제거 → Request 페이지 정식 폼으로 통일
- Admin: 키 관리 만료일 컬럼 추가, 권한 편집 제거 (승인 단계에서만 가능)
Gateway:
- API 경로 {변수} 패턴 매칭 지원
Closes #15
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
353 lines
15 KiB
TypeScript
353 lines
15 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import type { UserDetail, CreateUserRequest, UpdateUserRequest } from '../../types/user';
|
|
import type { Tenant } from '../../types/tenant';
|
|
import { getUsers, createUser, updateUser, deactivateUser } from '../../services/userService';
|
|
import { getTenants } from '../../services/tenantService';
|
|
|
|
const ROLE_BADGE: Record<string, string> = {
|
|
ADMIN: 'bg-red-100 text-red-800',
|
|
MANAGER: 'bg-orange-100 text-orange-800',
|
|
USER: 'bg-blue-100 text-blue-800',
|
|
VIEWER: 'bg-gray-100 text-gray-800',
|
|
};
|
|
|
|
const UsersPage = () => {
|
|
const [users, setUsers] = useState<UserDetail[]>([]);
|
|
const [tenants, setTenants] = useState<Tenant[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
const [editingUser, setEditingUser] = useState<UserDetail | null>(null);
|
|
|
|
const [loginId, setLoginId] = useState('');
|
|
const [password, setPassword] = useState('');
|
|
const [userName, setUserName] = useState('');
|
|
const [email, setEmail] = useState('');
|
|
const [tenantId, setTenantId] = useState<string>('');
|
|
const [role, setRole] = useState('USER');
|
|
const [isActive, setIsActive] = useState(true);
|
|
|
|
const fetchData = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const [usersRes, tenantsRes] = await Promise.all([getUsers(), getTenants()]);
|
|
if (usersRes.success && usersRes.data) {
|
|
setUsers(usersRes.data);
|
|
}
|
|
if (tenantsRes.success && tenantsRes.data) {
|
|
setTenants(tenantsRes.data);
|
|
}
|
|
} catch {
|
|
setError('데이터를 불러오는데 실패했습니다.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, []);
|
|
|
|
const handleOpenCreate = () => {
|
|
setEditingUser(null);
|
|
setLoginId('');
|
|
setPassword('');
|
|
setUserName('');
|
|
setEmail('');
|
|
setTenantId('');
|
|
setRole('USER');
|
|
setIsActive(true);
|
|
setIsModalOpen(true);
|
|
};
|
|
|
|
const handleOpenEdit = (user: UserDetail) => {
|
|
setEditingUser(user);
|
|
setLoginId(user.loginId);
|
|
setPassword('');
|
|
setUserName(user.userName);
|
|
setEmail(user.email || '');
|
|
setTenantId(user.tenantId ? String(user.tenantId) : '');
|
|
setRole(user.role);
|
|
setIsActive(user.isActive);
|
|
setIsModalOpen(true);
|
|
};
|
|
|
|
const handleCloseModal = () => {
|
|
setIsModalOpen(false);
|
|
setEditingUser(null);
|
|
setError(null);
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setError(null);
|
|
|
|
try {
|
|
if (editingUser) {
|
|
const req: UpdateUserRequest = {
|
|
tenantId: tenantId ? Number(tenantId) : undefined,
|
|
userName,
|
|
email: email || undefined,
|
|
role,
|
|
password: password || undefined,
|
|
isActive,
|
|
};
|
|
const res = await updateUser(editingUser.userId, req);
|
|
if (!res.success) {
|
|
setError(res.message || '사용자 수정에 실패했습니다.');
|
|
return;
|
|
}
|
|
} else {
|
|
const req: CreateUserRequest = {
|
|
tenantId: tenantId ? Number(tenantId) : undefined,
|
|
loginId,
|
|
password,
|
|
userName,
|
|
email: email || undefined,
|
|
role,
|
|
};
|
|
const res = await createUser(req);
|
|
if (!res.success) {
|
|
setError(res.message || '사용자 생성에 실패했습니다.');
|
|
return;
|
|
}
|
|
}
|
|
handleCloseModal();
|
|
await fetchData();
|
|
} catch {
|
|
setError(editingUser ? '사용자 수정에 실패했습니다.' : '사용자 생성에 실패했습니다.');
|
|
}
|
|
};
|
|
|
|
const handleDeactivate = async (user: UserDetail) => {
|
|
if (!confirm(`'${user.userName}' 사용자를 비활성화하시겠습니까?`)) return;
|
|
|
|
try {
|
|
const res = await deactivateUser(user.userId);
|
|
if (!res.success) {
|
|
setError(res.message || '사용자 비활성화에 실패했습니다.');
|
|
return;
|
|
}
|
|
await fetchData();
|
|
} catch {
|
|
setError('사용자 비활성화에 실패했습니다.');
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return <div className="max-w-7xl mx-auto text-center py-10 text-gray-500 dark:text-gray-400">로딩 중...</div>;
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-7xl mx-auto">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Users</h1>
|
|
<button
|
|
onClick={handleOpenCreate}
|
|
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
|
|
>
|
|
Create User
|
|
</button>
|
|
</div>
|
|
|
|
{error && !isModalOpen && (
|
|
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
|
)}
|
|
|
|
<div className="overflow-x-auto bg-white dark:bg-gray-800 rounded-lg shadow">
|
|
<table className="w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
|
|
<thead className="bg-gray-50 dark:bg-gray-700">
|
|
<tr>
|
|
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Login ID</th>
|
|
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Name</th>
|
|
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Email</th>
|
|
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Tenant</th>
|
|
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Role</th>
|
|
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Active</th>
|
|
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Last Login</th>
|
|
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
|
{users.map((user) => (
|
|
<tr key={user.userId} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
|
<td className="px-4 py-3 font-mono text-gray-900 dark:text-gray-100">{user.loginId}</td>
|
|
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{user.userName}</td>
|
|
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{user.email || '-'}</td>
|
|
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{user.tenantName || '-'}</td>
|
|
<td className="px-4 py-3">
|
|
<span
|
|
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
|
|
ROLE_BADGE[user.role] || 'bg-gray-100 text-gray-800'
|
|
}`}
|
|
>
|
|
{user.role}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span
|
|
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
|
|
user.isActive
|
|
? 'bg-green-100 text-green-800'
|
|
: 'bg-red-100 text-red-800'
|
|
}`}
|
|
>
|
|
{user.isActive ? 'Active' : 'Inactive'}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">
|
|
{user.lastLoginAt
|
|
? new Date(user.lastLoginAt).toLocaleString()
|
|
: '-'}
|
|
</td>
|
|
<td className="px-4 py-3 space-x-2">
|
|
<button
|
|
onClick={() => handleOpenEdit(user)}
|
|
className="bg-blue-100 hover:bg-blue-200 text-blue-700 dark:bg-blue-900/30 dark:hover:bg-blue-800/40 dark:text-blue-400 px-3 py-1 rounded-lg text-sm font-medium"
|
|
>
|
|
수정
|
|
</button>
|
|
{user.isActive && (
|
|
<button
|
|
onClick={() => handleDeactivate(user)}
|
|
className="bg-red-100 hover:bg-red-200 text-red-700 dark:bg-red-900/30 dark:hover:bg-red-800/40 dark:text-red-400 px-3 py-1 rounded-lg text-sm font-medium"
|
|
>
|
|
비활성화
|
|
</button>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{users.length === 0 && (
|
|
<tr>
|
|
<td colSpan={8} className="px-4 py-8 text-center text-gray-400 dark:text-gray-500">
|
|
등록된 사용자가 없습니다.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{isModalOpen && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4">
|
|
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
|
{editingUser ? '사용자 수정' : '사용자 생성'}
|
|
</h2>
|
|
</div>
|
|
<form onSubmit={handleSubmit}>
|
|
<div className="px-6 py-4 space-y-4 max-h-[70vh] overflow-y-auto">
|
|
{error && (
|
|
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
|
)}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Login ID</label>
|
|
<input
|
|
type="text"
|
|
value={loginId}
|
|
onChange={(e) => setLoginId(e.target.value)}
|
|
disabled={!!editingUser}
|
|
required
|
|
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:bg-gray-100 disabled:text-gray-500 dark:disabled:bg-gray-600 dark:disabled:text-gray-400"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Password</label>
|
|
<input
|
|
type="password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
required={!editingUser}
|
|
placeholder={editingUser ? '변경 시 입력' : ''}
|
|
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">User Name</label>
|
|
<input
|
|
type="text"
|
|
value={userName}
|
|
onChange={(e) => setUserName(e.target.value)}
|
|
required
|
|
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email</label>
|
|
<input
|
|
type="email"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Tenant</label>
|
|
<select
|
|
value={tenantId}
|
|
onChange={(e) => setTenantId(e.target.value)}
|
|
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
|
>
|
|
<option value="">-- 선택 --</option>
|
|
{tenants.map((t) => (
|
|
<option key={t.tenantId} value={t.tenantId}>
|
|
{t.tenantName}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Role</label>
|
|
<select
|
|
value={role}
|
|
onChange={(e) => setRole(e.target.value)}
|
|
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
|
>
|
|
<option value="ADMIN">ADMIN</option>
|
|
<option value="MANAGER">MANAGER</option>
|
|
<option value="USER">USER</option>
|
|
<option value="VIEWER">VIEWER</option>
|
|
</select>
|
|
</div>
|
|
{editingUser && (
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
id="isActive"
|
|
checked={isActive}
|
|
onChange={(e) => setIsActive(e.target.checked)}
|
|
className="rounded"
|
|
/>
|
|
<label htmlFor="isActive" className="text-sm text-gray-700 dark:text-gray-300">
|
|
Active
|
|
</label>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={handleCloseModal}
|
|
className="bg-gray-200 hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 px-4 py-2 rounded-lg text-sm font-medium"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
|
|
>
|
|
Save
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default UsersPage;
|