- 이메일 공통 모듈 (spring-boot-starter-mail, EmailService, Thymeleaf 템플릿) - 승인 시 계정 발급 이메일 / 거절 시 사유 이메일 자동 발송 - 재심사 기능 (REJECTED → PENDING) - UI 텍스트 리레이블링 (S&P Global API) - 신청 폼 전화번호 필드 제거 및 레이아웃 개선
481 lines
24 KiB
TypeScript
481 lines
24 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
|
import {
|
|
bypassAccountApi,
|
|
type BypassAccountResponse,
|
|
type BypassAccountUpdateRequest,
|
|
type PageResponse,
|
|
} from '../api/bypassAccountApi';
|
|
import { useToastContext } from '../contexts/ToastContext';
|
|
import Pagination from '../components/Pagination';
|
|
import ConfirmModal from '../components/ConfirmModal';
|
|
import LoadingSpinner from '../components/LoadingSpinner';
|
|
|
|
const STATUS_TABS = [
|
|
{ value: '', label: '전체' },
|
|
{ value: 'ACTIVE', label: 'ACTIVE' },
|
|
{ value: 'SUSPENDED', label: 'SUSPENDED' },
|
|
{ value: 'EXPIRED', label: 'EXPIRED' },
|
|
] as const;
|
|
|
|
const STATUS_BADGE_COLORS: Record<string, string> = {
|
|
ACTIVE: 'bg-emerald-100 text-emerald-700',
|
|
SUSPENDED: 'bg-amber-100 text-amber-700',
|
|
EXPIRED: 'bg-red-100 text-red-700',
|
|
};
|
|
|
|
const ACCOUNT_STATUS_OPTIONS = ['ACTIVE', 'SUSPENDED', 'EXPIRED'];
|
|
|
|
const PAGE_SIZE = 20;
|
|
|
|
interface EditFormState {
|
|
displayName: string;
|
|
organization: string;
|
|
email: string;
|
|
phone: string;
|
|
status: string;
|
|
accessStartDate: string;
|
|
accessEndDate: string;
|
|
}
|
|
|
|
export default function BypassAccountManagement() {
|
|
const { showToast } = useToastContext();
|
|
|
|
const [pageData, setPageData] = useState<PageResponse<BypassAccountResponse> | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [statusFilter, setStatusFilter] = useState('');
|
|
const [page, setPage] = useState(0);
|
|
|
|
// Edit modal
|
|
const [editTarget, setEditTarget] = useState<BypassAccountResponse | null>(null);
|
|
const [editForm, setEditForm] = useState<EditFormState>({
|
|
displayName: '',
|
|
organization: '',
|
|
email: '',
|
|
phone: '',
|
|
status: '',
|
|
accessStartDate: '',
|
|
accessEndDate: '',
|
|
});
|
|
const [editSubmitting, setEditSubmitting] = useState(false);
|
|
|
|
// Delete confirm
|
|
const [deleteTarget, setDeleteTarget] = useState<BypassAccountResponse | null>(null);
|
|
const [deleteSubmitting, setDeleteSubmitting] = useState(false);
|
|
|
|
// Password reset confirm + credential modal
|
|
const [resetTarget, setResetTarget] = useState<BypassAccountResponse | null>(null);
|
|
const [resetSubmitting, setResetSubmitting] = useState(false);
|
|
const [credentialAccount, setCredentialAccount] = useState<BypassAccountResponse | null>(null);
|
|
|
|
const loadAccounts = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await bypassAccountApi.getAccounts(statusFilter || undefined, page, PAGE_SIZE);
|
|
setPageData(res.data ?? null);
|
|
} catch (err) {
|
|
showToast('계정 목록 조회 실패', 'error');
|
|
console.error(err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [showToast, statusFilter, page]);
|
|
|
|
useEffect(() => {
|
|
loadAccounts();
|
|
}, [loadAccounts]);
|
|
|
|
const handleStatusFilterChange = (value: string) => {
|
|
setStatusFilter(value);
|
|
setPage(0);
|
|
};
|
|
|
|
const openEditModal = (account: BypassAccountResponse) => {
|
|
setEditTarget(account);
|
|
setEditForm({
|
|
displayName: account.displayName ?? '',
|
|
organization: account.organization ?? '',
|
|
email: account.email ?? '',
|
|
phone: account.phone ?? '',
|
|
status: account.status,
|
|
accessStartDate: account.accessStartDate ?? '',
|
|
accessEndDate: account.accessEndDate ?? '',
|
|
});
|
|
};
|
|
|
|
const handleEditSubmit = async () => {
|
|
if (!editTarget) return;
|
|
setEditSubmitting(true);
|
|
try {
|
|
const updateData: BypassAccountUpdateRequest = {
|
|
status: editForm.status || undefined,
|
|
accessStartDate: editForm.accessStartDate || undefined,
|
|
accessEndDate: editForm.accessEndDate || undefined,
|
|
};
|
|
await bypassAccountApi.updateAccount(editTarget.id, updateData);
|
|
setEditTarget(null);
|
|
showToast('계정 정보가 수정되었습니다.', 'success');
|
|
await loadAccounts();
|
|
} catch (err) {
|
|
showToast('계정 수정 실패', 'error');
|
|
console.error(err);
|
|
} finally {
|
|
setEditSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleDeleteConfirm = async () => {
|
|
if (!deleteTarget) return;
|
|
setDeleteSubmitting(true);
|
|
try {
|
|
await bypassAccountApi.deleteAccount(deleteTarget.id);
|
|
setDeleteTarget(null);
|
|
showToast('계정이 삭제되었습니다.', 'success');
|
|
await loadAccounts();
|
|
} catch (err) {
|
|
showToast('계정 삭제 실패', 'error');
|
|
console.error(err);
|
|
} finally {
|
|
setDeleteSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleResetPasswordConfirm = async () => {
|
|
if (!resetTarget) return;
|
|
setResetSubmitting(true);
|
|
try {
|
|
const res = await bypassAccountApi.resetPassword(resetTarget.id);
|
|
setResetTarget(null);
|
|
setCredentialAccount(res.data);
|
|
showToast('비밀번호가 재설정되었습니다.', 'success');
|
|
await loadAccounts();
|
|
} catch (err) {
|
|
showToast('비밀번호 재설정 실패', 'error');
|
|
console.error(err);
|
|
} finally {
|
|
setResetSubmitting(false);
|
|
}
|
|
};
|
|
|
|
if (loading && !pageData) return <LoadingSpinner />;
|
|
|
|
const accounts = pageData?.content ?? [];
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* 헤더 */}
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-wing-text">계정 관리</h1>
|
|
<p className="mt-1 text-sm text-wing-muted">
|
|
Bypass API 계정을 조회하고 관리합니다.
|
|
</p>
|
|
</div>
|
|
|
|
{/* 상태 필터 탭 */}
|
|
<div className="bg-wing-surface rounded-xl shadow-md p-4">
|
|
<div className="flex gap-2 flex-wrap">
|
|
{STATUS_TABS.map((tab) => (
|
|
<button
|
|
key={tab.value}
|
|
type="button"
|
|
onClick={() => handleStatusFilterChange(tab.value)}
|
|
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
|
statusFilter === tab.value
|
|
? 'bg-wing-accent text-white'
|
|
: 'bg-wing-card text-wing-muted hover:text-wing-text border border-wing-border'
|
|
}`}
|
|
>
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 테이블 */}
|
|
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-wing-border bg-wing-card">
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
|
|
Username
|
|
</th>
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
|
|
표시명
|
|
</th>
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
|
|
기관
|
|
</th>
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
|
|
이메일
|
|
</th>
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
|
|
상태
|
|
</th>
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
|
|
접근 기간
|
|
</th>
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
|
|
등록일
|
|
</th>
|
|
<th className="text-right px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
|
|
액션
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-wing-border">
|
|
{accounts.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={8} className="px-4 py-12 text-center text-wing-muted text-sm">
|
|
등록된 계정이 없습니다.
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
accounts.map((account) => (
|
|
<tr key={account.id} className="hover:bg-wing-hover transition-colors">
|
|
<td className="px-4 py-3 font-mono text-xs text-wing-text">
|
|
{account.username}
|
|
</td>
|
|
<td className="px-4 py-3 font-medium text-wing-text">
|
|
{account.displayName}
|
|
</td>
|
|
<td className="px-4 py-3 text-xs text-wing-muted">
|
|
{account.organization ?? '-'}
|
|
</td>
|
|
<td className="px-4 py-3 text-xs text-wing-muted">
|
|
{account.email ?? '-'}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span
|
|
className={[
|
|
'px-2 py-0.5 text-xs font-semibold rounded-full',
|
|
STATUS_BADGE_COLORS[account.status] ?? 'bg-wing-card text-wing-muted border border-wing-border',
|
|
].join(' ')}
|
|
>
|
|
{account.status}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3 text-xs text-wing-muted whitespace-nowrap">
|
|
{account.accessStartDate && account.accessEndDate
|
|
? `${account.accessStartDate} ~ ${account.accessEndDate}`
|
|
: account.accessStartDate ?? '-'}
|
|
</td>
|
|
<td className="px-4 py-3 text-xs text-wing-muted whitespace-nowrap">
|
|
{account.createdAt
|
|
? new Date(account.createdAt).toLocaleDateString('ko-KR')
|
|
: '-'}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex justify-end gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => openEditModal(account)}
|
|
className="px-3 py-1.5 text-xs font-medium text-wing-text bg-wing-card hover:bg-wing-hover border border-wing-border rounded-lg transition-colors"
|
|
>
|
|
수정
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setResetTarget(account)}
|
|
className="px-3 py-1.5 text-xs font-medium text-amber-600 hover:bg-amber-50 border border-amber-200 rounded-lg transition-colors"
|
|
>
|
|
비밀번호 재설정
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setDeleteTarget(account)}
|
|
className="px-3 py-1.5 text-xs font-medium text-red-500 hover:bg-red-50 border border-red-200 rounded-lg transition-colors"
|
|
>
|
|
삭제
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{pageData && pageData.totalPages > 1 && (
|
|
<div className="px-4 py-3 border-t border-wing-border">
|
|
<Pagination
|
|
page={pageData.number}
|
|
totalPages={pageData.totalPages}
|
|
totalElements={pageData.totalElements}
|
|
pageSize={PAGE_SIZE}
|
|
onPageChange={setPage}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 수정 모달 */}
|
|
{editTarget && (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-wing-overlay"
|
|
onClick={() => setEditTarget(null)}
|
|
>
|
|
<div
|
|
className="bg-wing-surface rounded-xl shadow-2xl p-6 max-w-lg w-full mx-4"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<h3 className="text-lg font-semibold text-wing-text mb-1">계정 수정</h3>
|
|
<p className="text-sm text-wing-muted mb-4 font-mono">{editTarget.username}</p>
|
|
|
|
{/* 신청자 정보 (읽기 전용) */}
|
|
<div className="bg-wing-card rounded-lg p-3 border border-wing-border mb-4">
|
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
|
<div>
|
|
<span className="text-wing-muted">표시명: </span>
|
|
<span className="text-wing-text font-medium">{editTarget.displayName}</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-wing-muted">기관: </span>
|
|
<span className="text-wing-text">{editTarget.organization ?? '-'}</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-wing-muted">이메일: </span>
|
|
<span className="text-wing-text">{editTarget.email ?? '-'}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-wing-text mb-1">상태</label>
|
|
<select
|
|
value={editForm.status}
|
|
onChange={(e) => setEditForm((f) => ({ ...f, status: e.target.value }))}
|
|
className="w-full px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text focus:outline-none focus:ring-2 focus:ring-wing-accent/50"
|
|
>
|
|
{ACCOUNT_STATUS_OPTIONS.map((s) => (
|
|
<option key={s} value={s}>{s}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="block text-sm font-medium text-wing-text mb-1">접근 시작일</label>
|
|
<input
|
|
type="date"
|
|
value={editForm.accessStartDate}
|
|
onChange={(e) => setEditForm((f) => ({ ...f, accessStartDate: e.target.value }))}
|
|
className="w-full px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text focus:outline-none focus:ring-2 focus:ring-wing-accent/50"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-wing-text mb-1">접근 종료일</label>
|
|
<input
|
|
type="date"
|
|
value={editForm.accessEndDate}
|
|
onChange={(e) => setEditForm((f) => ({ ...f, accessEndDate: e.target.value }))}
|
|
className="w-full px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text focus:outline-none focus:ring-2 focus:ring-wing-accent/50"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end gap-3 mt-6">
|
|
<button
|
|
type="button"
|
|
onClick={() => setEditTarget(null)}
|
|
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
|
>
|
|
취소
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleEditSubmit}
|
|
disabled={editSubmitting}
|
|
className="px-4 py-2 text-sm font-medium text-white bg-wing-accent hover:bg-wing-accent/80 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{editSubmitting ? '저장 중...' : '저장'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 삭제 확인 모달 */}
|
|
<ConfirmModal
|
|
open={deleteTarget !== null}
|
|
title="계정 삭제"
|
|
message={`"${deleteTarget?.username}"을(를) 정말 삭제하시겠습니까?\n삭제된 데이터는 복구할 수 없습니다.`}
|
|
confirmLabel={deleteSubmitting ? '삭제 중...' : '삭제'}
|
|
confirmColor="bg-red-500 hover:bg-red-600"
|
|
onConfirm={handleDeleteConfirm}
|
|
onCancel={() => setDeleteTarget(null)}
|
|
/>
|
|
|
|
{/* 비밀번호 재설정 확인 모달 */}
|
|
<ConfirmModal
|
|
open={resetTarget !== null}
|
|
title="비밀번호 재설정"
|
|
message={`"${resetTarget?.username}" 계정의 비밀번호를 재설정하시겠습니까?\n새 비밀번호가 생성됩니다.`}
|
|
confirmLabel={resetSubmitting ? '처리 중...' : '재설정'}
|
|
confirmColor="bg-amber-500 hover:bg-amber-600"
|
|
onConfirm={handleResetPasswordConfirm}
|
|
onCancel={() => setResetTarget(null)}
|
|
/>
|
|
|
|
{/* 계정 발급 완료 모달 */}
|
|
{credentialAccount && (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-wing-overlay"
|
|
onClick={() => setCredentialAccount(null)}
|
|
>
|
|
<div
|
|
className="bg-wing-surface rounded-xl shadow-2xl p-6 max-w-md w-full mx-4"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="text-lg font-bold text-wing-text mb-4">비밀번호 재설정 완료</div>
|
|
<div className="bg-amber-50 border border-amber-300 rounded-lg p-3 text-amber-800 text-xs mb-4">
|
|
이 화면을 닫으면 비밀번호를 다시 확인할 수 없습니다.
|
|
</div>
|
|
<div className="space-y-3">
|
|
<div>
|
|
<div className="text-xs text-wing-muted mb-1">사용자명</div>
|
|
<div className="flex items-center gap-2 bg-wing-card rounded-lg p-3 border border-wing-border">
|
|
<code className="text-sm font-mono flex-1">{credentialAccount.username}</code>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
navigator.clipboard.writeText(credentialAccount.username);
|
|
showToast('복사됨', 'success');
|
|
}}
|
|
className="text-xs text-blue-600 hover:underline shrink-0"
|
|
>
|
|
복사
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-xs text-wing-muted mb-1">새 비밀번호</div>
|
|
<div className="flex items-center gap-2 bg-wing-card rounded-lg p-3 border border-wing-border">
|
|
<code className="text-sm font-mono flex-1">{credentialAccount.plainPassword}</code>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
navigator.clipboard.writeText(credentialAccount.plainPassword!);
|
|
showToast('복사됨', 'success');
|
|
}}
|
|
className="text-xs text-blue-600 hover:underline shrink-0"
|
|
>
|
|
복사
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setCredentialAccount(null)}
|
|
className="mt-6 w-full py-2 rounded-lg bg-slate-900 text-white text-sm font-bold hover:bg-slate-800"
|
|
>
|
|
확인
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|