snp-batch-validation/frontend/src/pages/BypassAccountManagement.tsx
HYOJIN ad18ab9c30 feat(email): Bypass API 계정 이메일 알림 및 거절 후속 조치 (#140)
- 이메일 공통 모듈 (spring-boot-starter-mail, EmailService, Thymeleaf 템플릿)
- 승인 시 계정 발급 이메일 / 거절 시 사유 이메일 자동 발송
- 재심사 기능 (REJECTED → PENDING)
- UI 텍스트 리레이블링 (S&P Global API)
- 신청 폼 전화번호 필드 제거 및 레이아웃 개선
2026-04-03 10:34:45 +09:00

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>
);
}