- StatsResponse/LoginHistory 타입을 백엔드 스키마에 동기화 - 통계 카드 7개(사용자 상태별, 오늘 로그인, 전체 롤) + 로그인 이력 테이블 추가 - 권한 삭제 API 경로 수정 (/admin/roles/permissions/) - 사용자 관리에 관리자 지정/해제 토글 및 ADMIN 뱃지 추가 - 가이드/홈 페이지에 활동 추적(POST /activity/track) 연동 - CiCdGuide.tsx ESLint no-useless-escape 에러 수정 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
281 lines
11 KiB
TypeScript
281 lines
11 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
|
import type { Role, User } from '../../types';
|
|
import { api } from '../../utils/api';
|
|
|
|
type FilterStatus = 'ALL' | 'PENDING' | 'ACTIVE' | 'REJECTED' | 'DISABLED';
|
|
|
|
export function UserManagement() {
|
|
const [users, setUsers] = useState<User[]>([]);
|
|
const [roles, setRoles] = useState<Role[]>([]);
|
|
const [filter, setFilter] = useState<FilterStatus>('ALL');
|
|
const [loading, setLoading] = useState(true);
|
|
const [roleModalUser, setRoleModalUser] = useState<User | null>(null);
|
|
const [selectedRoleIds, setSelectedRoleIds] = useState<number[]>([]);
|
|
|
|
const fetchData = useCallback(async () => {
|
|
try {
|
|
const [usersData, rolesData] = await Promise.all([
|
|
api.get<User[]>('/admin/users'),
|
|
api.get<Role[]>('/admin/roles'),
|
|
]);
|
|
setUsers(usersData);
|
|
setRoles(rolesData);
|
|
} catch {
|
|
// API 미연동 시 빈 배열 유지
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, [fetchData]);
|
|
|
|
const handleAction = async (userId: number, action: 'approve' | 'reject' | 'disable') => {
|
|
try {
|
|
await api.put<User>(`/admin/users/${userId}/${action}`, {});
|
|
fetchData();
|
|
} catch {
|
|
// 에러 처리
|
|
}
|
|
};
|
|
|
|
const handleToggleAdmin = async (userId: number, isAdmin: boolean) => {
|
|
try {
|
|
if (isAdmin) {
|
|
await api.delete(`/admin/users/${userId}/admin`);
|
|
} else {
|
|
await api.post(`/admin/users/${userId}/admin`);
|
|
}
|
|
fetchData();
|
|
} catch {
|
|
// 에러 처리
|
|
}
|
|
};
|
|
|
|
const handleRoleSave = async () => {
|
|
if (!roleModalUser) return;
|
|
try {
|
|
await api.put<User>(`/admin/users/${roleModalUser.id}/roles`, { roleIds: selectedRoleIds });
|
|
setRoleModalUser(null);
|
|
fetchData();
|
|
} catch {
|
|
// 에러 처리
|
|
}
|
|
};
|
|
|
|
const filteredUsers = filter === 'ALL' ? users : users.filter((u) => u.status === filter);
|
|
|
|
const statusBadge = (status: User['status']) => {
|
|
const styles: Record<string, string> = {
|
|
ACTIVE: 'bg-success/10 text-success',
|
|
PENDING: 'bg-warning/10 text-warning',
|
|
REJECTED: 'bg-danger/10 text-danger',
|
|
DISABLED: 'bg-bg-tertiary text-text-muted',
|
|
};
|
|
const labels: Record<string, string> = {
|
|
ACTIVE: '활성', PENDING: '대기', REJECTED: '거절', DISABLED: '비활성',
|
|
};
|
|
return (
|
|
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${styles[status]}`}>
|
|
{labels[status]}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-20">
|
|
<div className="animate-spin h-6 w-6 border-3 border-accent border-t-transparent rounded-full" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="p-8">
|
|
<h1 className="text-2xl font-bold text-text-primary mb-6">사용자 관리</h1>
|
|
|
|
{/* 필터 */}
|
|
<div className="flex gap-2 mb-6">
|
|
{(['ALL', 'PENDING', 'ACTIVE', 'REJECTED', 'DISABLED'] as FilterStatus[]).map((s) => (
|
|
<button
|
|
key={s}
|
|
onClick={() => setFilter(s)}
|
|
className={`px-3 py-1.5 rounded-lg text-sm cursor-pointer transition-colors ${
|
|
filter === s
|
|
? 'bg-accent text-white'
|
|
: 'bg-surface border border-border-default text-text-secondary hover:bg-bg-tertiary'
|
|
}`}
|
|
>
|
|
{s === 'ALL' ? '전체' : s === 'PENDING' ? '대기' : s === 'ACTIVE' ? '활성' : s === 'REJECTED' ? '거절' : '비활성'}
|
|
{s !== 'ALL' && (
|
|
<span className="ml-1 text-xs opacity-70">
|
|
({users.filter((u) => u.status === s).length})
|
|
</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* 테이블 */}
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full bg-surface border border-border-default rounded-lg overflow-hidden text-sm">
|
|
<thead>
|
|
<tr className="bg-bg-tertiary">
|
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">사용자</th>
|
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">상태</th>
|
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">롤</th>
|
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">가입일</th>
|
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">작업</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border-subtle">
|
|
{filteredUsers.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={5} className="px-4 py-8 text-center text-text-muted">
|
|
{users.length === 0 ? '등록된 사용자가 없습니다.' : '해당 상태의 사용자가 없습니다.'}
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
filteredUsers.map((user) => (
|
|
<tr key={user.id}>
|
|
<td className="px-4 py-3">
|
|
<div className="flex items-center gap-3">
|
|
{user.avatarUrl ? (
|
|
<img src={user.avatarUrl} alt="" className="w-8 h-8 rounded-full" />
|
|
) : (
|
|
<div className="w-8 h-8 bg-accent-soft rounded-full flex items-center justify-center text-xs font-medium text-accent">
|
|
{user.name[0]}
|
|
</div>
|
|
)}
|
|
<div>
|
|
<p className="font-medium text-text-primary">
|
|
{user.name}
|
|
{user.isAdmin && (
|
|
<span className="ml-1.5 px-1.5 py-0.5 bg-accent/10 text-accent rounded text-[10px] font-semibold align-middle">
|
|
ADMIN
|
|
</span>
|
|
)}
|
|
</p>
|
|
<p className="text-xs text-text-muted">{user.email}</p>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3">{statusBadge(user.status)}</td>
|
|
<td className="px-4 py-3 text-text-secondary">
|
|
{user.roles.length > 0
|
|
? user.roles.map((r) => r.name).join(', ')
|
|
: <span className="text-text-muted">미배정</span>}
|
|
</td>
|
|
<td className="px-4 py-3 text-text-muted text-xs">
|
|
{new Date(user.createdAt).toLocaleDateString('ko-KR')}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex gap-1.5">
|
|
{user.status === 'PENDING' && (
|
|
<>
|
|
<button
|
|
onClick={() => handleAction(user.id, 'approve')}
|
|
className="px-2.5 py-1 bg-success/10 text-success rounded text-xs font-medium hover:bg-success/20 cursor-pointer"
|
|
>
|
|
승인
|
|
</button>
|
|
<button
|
|
onClick={() => handleAction(user.id, 'reject')}
|
|
className="px-2.5 py-1 bg-danger/10 text-danger rounded text-xs font-medium hover:bg-danger/20 cursor-pointer"
|
|
>
|
|
거절
|
|
</button>
|
|
</>
|
|
)}
|
|
{user.status === 'ACTIVE' && (
|
|
<button
|
|
onClick={() => handleAction(user.id, 'disable')}
|
|
className="px-2.5 py-1 bg-bg-tertiary text-text-muted rounded text-xs font-medium hover:bg-border-default cursor-pointer"
|
|
>
|
|
비활성화
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => {
|
|
setRoleModalUser(user);
|
|
setSelectedRoleIds(user.roles.map((r) => r.id));
|
|
}}
|
|
className="px-2.5 py-1 bg-accent-soft text-accent rounded text-xs font-medium hover:bg-accent/20 cursor-pointer"
|
|
>
|
|
롤 배정
|
|
</button>
|
|
{user.status === 'ACTIVE' && (
|
|
<button
|
|
onClick={() => handleToggleAdmin(user.id, user.isAdmin)}
|
|
className={`px-2.5 py-1 rounded text-xs font-medium cursor-pointer ${
|
|
user.isAdmin
|
|
? 'bg-warning/10 text-warning hover:bg-warning/20'
|
|
: 'bg-info/10 text-info hover:bg-info/20'
|
|
}`}
|
|
>
|
|
{user.isAdmin ? '관리자 해제' : '관리자 지정'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* 롤 배정 모달 */}
|
|
{roleModalUser && (
|
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
|
<div className="bg-surface rounded-xl shadow-xl max-w-md w-full p-6">
|
|
<h3 className="text-lg font-bold text-text-primary mb-4">
|
|
롤 배정 — {roleModalUser.name}
|
|
</h3>
|
|
<div className="space-y-2 mb-6">
|
|
{roles.map((role) => (
|
|
<label key={role.id} className="flex items-center gap-3 p-2 rounded-lg hover:bg-bg-tertiary cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedRoleIds.includes(role.id)}
|
|
onChange={(e) => {
|
|
setSelectedRoleIds(
|
|
e.target.checked
|
|
? [...selectedRoleIds, role.id]
|
|
: selectedRoleIds.filter((id) => id !== role.id),
|
|
);
|
|
}}
|
|
className="rounded accent-accent"
|
|
/>
|
|
<div>
|
|
<p className="text-sm font-medium text-text-primary">{role.name}</p>
|
|
<p className="text-xs text-text-muted">{role.description}</p>
|
|
</div>
|
|
</label>
|
|
))}
|
|
{roles.length === 0 && (
|
|
<p className="text-sm text-text-muted py-4 text-center">등록된 롤이 없습니다.</p>
|
|
)}
|
|
</div>
|
|
<div className="flex justify-end gap-2">
|
|
<button
|
|
onClick={() => setRoleModalUser(null)}
|
|
className="px-4 py-2 text-sm text-text-secondary border border-border-default rounded-lg hover:bg-bg-tertiary cursor-pointer"
|
|
>
|
|
취소
|
|
</button>
|
|
<button
|
|
onClick={handleRoleSave}
|
|
className="px-4 py-2 text-sm bg-accent text-white rounded-lg hover:bg-accent-hover cursor-pointer"
|
|
>
|
|
저장
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|