feat(bypass-account): Bypass API 사용자 계정 발급 신청 프로세스 개발 (#126) #144

병합
HYOJIN feature/ISSUE-126-bypass-account 에서 develop 로 2 commits 를 머지했습니다 2026-04-02 17:15:04 +09:00
29개의 변경된 파일2793개의 추가작업 그리고 6개의 파일을 삭제

파일 보기

@ -4,6 +4,15 @@
## [Unreleased] ## [Unreleased]
### 추가
- Bypass API 사용자 계정 발급 신청 프로세스 (#126)
- Spring Security Basic Auth 인증 (Bypass 데이터 API)
- 계정 신청/승인/거절 백엔드 API 및 프론트엔드
- 계정 관리 (CRUD, 비밀번호 재설정, 상태/기간 수정)
- 401 응답에 계정 상태 상세 메시지 포함
- Swagger UI Basic Auth 스킴 연동
- 신청 폼 정규식 검증 및 접근기간 프리셋
## [2026-04-02] ## [2026-04-02]
### 추가 ### 추가

파일 보기

@ -19,6 +19,9 @@ const BypassConfig = lazy(() => import('./pages/BypassConfig'));
const BypassCatalog = lazy(() => import('./pages/BypassCatalog')); const BypassCatalog = lazy(() => import('./pages/BypassCatalog'));
const ScreeningGuide = lazy(() => import('./pages/ScreeningGuide')); const ScreeningGuide = lazy(() => import('./pages/ScreeningGuide'));
const RiskComplianceHistory = lazy(() => import('./pages/RiskComplianceHistory')); const RiskComplianceHistory = lazy(() => import('./pages/RiskComplianceHistory'));
const BypassAccountRequests = lazy(() => import('./pages/BypassAccountRequests'));
const BypassAccountManagement = lazy(() => import('./pages/BypassAccountManagement'));
const BypassAccessRequest = lazy(() => import('./pages/BypassAccessRequest'));
function AppLayout() { function AppLayout() {
const { toasts, removeToast } = useToastContext(); const { toasts, removeToast } = useToastContext();
@ -54,6 +57,9 @@ function AppLayout() {
<Route path="/schedule-timeline" element={<Timeline />} /> <Route path="/schedule-timeline" element={<Timeline />} />
<Route path="/bypass-catalog" element={<BypassCatalog />} /> <Route path="/bypass-catalog" element={<BypassCatalog />} />
<Route path="/bypass-config" element={<BypassConfig />} /> <Route path="/bypass-config" element={<BypassConfig />} />
<Route path="/bypass-account-requests" element={<BypassAccountRequests />} />
<Route path="/bypass-account-management" element={<BypassAccountManagement />} />
<Route path="/bypass-access-request" element={<BypassAccessRequest />} />
<Route path="/screening-guide" element={<ScreeningGuide />} /> <Route path="/screening-guide" element={<ScreeningGuide />} />
<Route path="/risk-compliance-history" element={<RiskComplianceHistory />} /> <Route path="/risk-compliance-history" element={<RiskComplianceHistory />} />
</Routes> </Routes>

파일 보기

@ -0,0 +1,138 @@
export interface BypassAccountResponse {
id: number;
username: string;
displayName: string;
organization: string | null;
email: string | null;
phone: string | null;
status: string;
accessStartDate: string | null;
accessEndDate: string | null;
createdAt: string;
updatedAt: string;
plainPassword: string | null;
}
export interface BypassAccountUpdateRequest {
displayName?: string;
organization?: string;
email?: string;
phone?: string;
status?: string;
accessStartDate?: string;
accessEndDate?: string;
}
export interface BypassRequestResponse {
id: number;
applicantName: string;
organization: string | null;
purpose: string | null;
email: string | null;
phone: string | null;
requestedAccessPeriod: string | null;
status: string;
reviewedBy: string | null;
reviewedAt: string | null;
rejectReason: string | null;
accountId: number | null;
accountUsername: string | null;
createdAt: string;
updatedAt: string;
}
export interface BypassRequestSubmitRequest {
applicantName: string;
organization: string;
purpose: string;
email: string;
phone: string;
requestedAccessPeriod: string;
}
export interface BypassRequestReviewRequest {
reviewedBy: string;
rejectReason?: string;
accessStartDate?: string;
accessEndDate?: string;
}
export interface PageResponse<T> {
content: T[];
totalElements: number;
totalPages: number;
number: number;
size: number;
}
interface ApiResponse<T> {
success: boolean;
message: string;
data: T;
}
// BASE URL
const BASE = '/snp-api/api/bypass-account';
// 헬퍼 함수 (bypassApi.ts 패턴과 동일)
async function fetchJson<T>(url: string): Promise<T> {
const res = await fetch(url);
if (!res.ok) throw new Error(`API Error: ${res.status} ${res.statusText}`);
return res.json();
}
async function postJson<T>(url: string, body?: unknown): Promise<T> {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body != null ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`API Error: ${res.status} ${res.statusText}`);
return res.json();
}
async function putJson<T>(url: string, body?: unknown): Promise<T> {
const res = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: body != null ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`API Error: ${res.status} ${res.statusText}`);
return res.json();
}
async function deleteJson<T>(url: string): Promise<T> {
const res = await fetch(url, { method: 'DELETE' });
if (!res.ok) throw new Error(`API Error: ${res.status} ${res.statusText}`);
return res.json();
}
export const bypassAccountApi = {
// Accounts
getAccounts: (status?: string, page = 0, size = 20) => {
const params = new URLSearchParams({ page: String(page), size: String(size) });
if (status) params.set('status', status);
return fetchJson<ApiResponse<PageResponse<BypassAccountResponse>>>(`${BASE}/accounts?${params}`);
},
getAccount: (id: number) =>
fetchJson<ApiResponse<BypassAccountResponse>>(`${BASE}/accounts/${id}`),
updateAccount: (id: number, data: BypassAccountUpdateRequest) =>
putJson<ApiResponse<BypassAccountResponse>>(`${BASE}/accounts/${id}`, data),
deleteAccount: (id: number) =>
deleteJson<ApiResponse<void>>(`${BASE}/accounts/${id}`),
resetPassword: (id: number) =>
postJson<ApiResponse<BypassAccountResponse>>(`${BASE}/accounts/${id}/reset-password`, {}),
// Requests
submitRequest: (data: BypassRequestSubmitRequest) =>
postJson<ApiResponse<BypassRequestResponse>>(`${BASE}/requests`, data),
getRequests: (status?: string, page = 0, size = 20) => {
const params = new URLSearchParams({ page: String(page), size: String(size) });
if (status) params.set('status', status);
return fetchJson<ApiResponse<PageResponse<BypassRequestResponse>>>(`${BASE}/requests?${params}`);
},
approveRequest: (id: number, data: BypassRequestReviewRequest) =>
postJson<ApiResponse<BypassAccountResponse>>(`${BASE}/requests/${id}/approve`, data),
rejectRequest: (id: number, data: BypassRequestReviewRequest) =>
postJson<ApiResponse<BypassRequestResponse>>(`${BASE}/requests/${id}/reject`, data),
};

파일 보기

@ -49,6 +49,9 @@ const MENU_STRUCTURE: MenuSection[] = [
children: [ children: [
{ id: 'bypass-catalog', label: 'API 카탈로그', path: '/bypass-catalog' }, { id: 'bypass-catalog', label: 'API 카탈로그', path: '/bypass-catalog' },
{ id: 'bypass-config', label: 'API 관리', path: '/bypass-config' }, { id: 'bypass-config', label: 'API 관리', path: '/bypass-config' },
{ id: 'bypass-account-requests', label: '계정 신청 관리', path: '/bypass-account-requests' },
{ id: 'bypass-account-management', label: '계정 관리', path: '/bypass-account-management' },
{ id: 'bypass-access-request', label: 'API 접근 신청', path: '/bypass-access-request' },
], ],
}, },
{ {

파일 보기

@ -0,0 +1,501 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
bypassAccountApi,
type BypassRequestSubmitRequest,
} from '../api/bypassAccountApi';
import { useToastContext } from '../contexts/ToastContext';
interface FormState {
applicantName: string;
organization: string;
purpose: string;
email: string;
phone: string;
requestedAccessPeriod: string;
}
interface ErrorState {
applicantName?: string;
email?: string;
phone?: string;
requestedAccessPeriod?: string;
}
interface TouchedState {
applicantName: boolean;
email: boolean;
phone: boolean;
requestedAccessPeriod: boolean;
}
type PeriodPreset = '3개월' | '6개월' | '9개월' | '1년';
const INITIAL_FORM: FormState = {
applicantName: '',
organization: '',
purpose: '',
email: '',
phone: '',
requestedAccessPeriod: '',
};
const INITIAL_TOUCHED: TouchedState = {
applicantName: false,
email: false,
phone: false,
requestedAccessPeriod: false,
};
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const PHONE_DIGITS_REGEX = /^\d{11}$/;
function formatPhoneDisplay(digits: string): string {
const d = digits.slice(0, 11);
if (d.length <= 3) return d;
if (d.length <= 7) return `${d.slice(0, 3)}-${d.slice(3)}`;
return `${d.slice(0, 3)}-${d.slice(3, 7)}-${d.slice(7)}`;
}
function toDateString(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function addMonths(date: Date, months: number): Date {
const result = new Date(date);
result.setMonth(result.getMonth() + months);
return result;
}
function addYears(date: Date, years: number): Date {
const result = new Date(date);
result.setFullYear(result.getFullYear() + years);
return result;
}
function calcPresetPeriod(preset: PeriodPreset): string {
const today = new Date();
const from = toDateString(today);
let to: string;
switch (preset) {
case '3개월': to = toDateString(addMonths(today, 3)); break;
case '6개월': to = toDateString(addMonths(today, 6)); break;
case '9개월': to = toDateString(addMonths(today, 9)); break;
case '1년': to = toDateString(addYears(today, 1)); break;
}
return `${from} ~ ${to}`;
}
function validateForm(form: FormState, phoneRaw: string): ErrorState {
const errors: ErrorState = {};
if (!form.applicantName.trim()) {
errors.applicantName = '신청자명을 입력해주세요.';
}
if (!form.email.trim()) {
errors.email = '이메일을 입력해주세요.';
} else if (!EMAIL_REGEX.test(form.email.trim())) {
errors.email = '올바른 이메일 형식을 입력해주세요.';
}
if (phoneRaw && !PHONE_DIGITS_REGEX.test(phoneRaw)) {
errors.phone = '전화번호는 11자리 숫자로 입력해주세요. (예: 01012345678)';
}
if (!form.requestedAccessPeriod.trim()) {
errors.requestedAccessPeriod = '요청 접근 기간을 선택해주세요.';
}
return errors;
}
export default function BypassAccessRequest() {
const { showToast } = useToastContext();
const navigate = useNavigate();
const [form, setForm] = useState<FormState>(INITIAL_FORM);
const [phoneRaw, setPhoneRaw] = useState('');
const [phoneDisplay, setPhoneDisplay] = useState('');
const [submitting, setSubmitting] = useState(false);
const [submittedId, setSubmittedId] = useState<number | null>(null);
// Validation state
const [errors, setErrors] = useState<ErrorState>({});
const [touched, setTouched] = useState<TouchedState>(INITIAL_TOUCHED);
const [submitAttempted, setSubmitAttempted] = useState(false);
// Period mode
const [periodManual, setPeriodManual] = useState(false);
const [selectedPreset, setSelectedPreset] = useState<PeriodPreset | null>(null);
const [periodFrom, setPeriodFrom] = useState('');
const [periodTo, setPeriodTo] = useState('');
const [periodRangeError, setPeriodRangeError] = useState('');
const handleChange = (field: keyof FormState, value: string) => {
setForm((prev) => ({ ...prev, [field]: value }));
};
const handlePhoneChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const digits = e.target.value.replace(/\D/g, '').slice(0, 11);
setPhoneRaw(digits);
setPhoneDisplay(formatPhoneDisplay(digits));
setForm((prev) => ({ ...prev, phone: digits }));
};
const handleBlur = (field: keyof TouchedState) => {
setTouched((prev) => ({ ...prev, [field]: true }));
const currentErrors = validateForm(form, phoneRaw);
setErrors(currentErrors);
};
const handlePresetClick = (preset: PeriodPreset) => {
setSelectedPreset(preset);
const period = calcPresetPeriod(preset);
handleChange('requestedAccessPeriod', period);
};
const handlePeriodFromChange = (value: string) => {
setPeriodFrom(value);
setPeriodRangeError('');
if (value && periodTo) {
if (value >= periodTo) {
setPeriodRangeError('시작일은 종료일보다 이전이어야 합니다.');
} else {
handleChange('requestedAccessPeriod', `${value} ~ ${periodTo}`);
}
}
};
const handlePeriodToChange = (value: string) => {
setPeriodTo(value);
setPeriodRangeError('');
if (periodFrom && value) {
if (periodFrom >= value) {
setPeriodRangeError('시작일은 종료일보다 이전이어야 합니다.');
} else {
handleChange('requestedAccessPeriod', `${periodFrom} ~ ${value}`);
}
}
};
const handleToggleManual = () => {
setPeriodManual((prev) => {
const next = !prev;
if (!next) {
// Switching back to preset: clear manual inputs
setPeriodFrom('');
setPeriodTo('');
setPeriodRangeError('');
setSelectedPreset(null);
handleChange('requestedAccessPeriod', '');
} else {
// Switching to manual: clear preset selection
setSelectedPreset(null);
handleChange('requestedAccessPeriod', '');
}
return next;
});
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitAttempted(true);
setTouched({ applicantName: true, email: true, phone: true, requestedAccessPeriod: true });
const currentErrors = validateForm(form, phoneRaw);
if (periodManual && periodRangeError) {
return;
}
if (Object.keys(currentErrors).length > 0) {
setErrors(currentErrors);
// 첫 번째 에러 필드로 스크롤
const firstErrorField = Object.keys(currentErrors)[0];
const el = document.getElementById(`field-${firstErrorField}`);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
return;
}
setSubmitting(true);
try {
const requestData: BypassRequestSubmitRequest = {
applicantName: form.applicantName,
organization: form.organization,
purpose: form.purpose,
email: form.email,
phone: form.phone,
requestedAccessPeriod: form.requestedAccessPeriod,
};
const res = await bypassAccountApi.submitRequest(requestData);
setSubmittedId(res.data.id);
showToast('신청이 완료되었습니다.', 'success');
} catch (err) {
showToast('신청 제출 실패. 다시 시도해주세요.', 'error');
console.error(err);
} finally {
setSubmitting(false);
}
};
const showError = (field: keyof TouchedState): boolean =>
(touched[field] || submitAttempted) && Boolean(errors[field]);
const inputClass = (field: keyof TouchedState) =>
`w-full px-3 py-2 text-sm rounded-lg border ${
showError(field) ? 'border-red-400' : 'border-wing-border'
} bg-wing-card text-wing-text placeholder:text-wing-muted focus:outline-none focus:ring-2 ${
showError(field) ? 'focus:ring-red-400/50' : 'focus:ring-wing-accent/50'
}`;
const PRESETS: PeriodPreset[] = ['3개월', '6개월', '9개월', '1년'];
return (
<div className="space-y-6">
{/* 헤더 */}
<div>
<h1 className="text-2xl font-bold text-wing-text">Bypass API </h1>
<p className="mt-1 text-sm text-wing-muted">
Bypass API . .
</p>
</div>
{submittedId !== null ? (
/* 제출 완료 화면 */
<div className="bg-wing-surface rounded-xl shadow-md p-8 text-center">
<div className="w-14 h-14 bg-emerald-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-7 h-7 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="text-xl font-bold text-wing-text mb-2"> </h2>
<p className="text-sm text-wing-muted mb-1">
: <span className="font-semibold text-wing-text">#{submittedId}</span>
</p>
<p className="text-sm text-wing-muted">
.
</p>
<button
type="button"
onClick={() => {
setForm(INITIAL_FORM);
setPhoneRaw('');
setPhoneDisplay('');
setErrors({});
setTouched(INITIAL_TOUCHED);
setSubmitAttempted(false);
setSelectedPreset(null);
setPeriodManual(false);
setPeriodFrom('');
setPeriodTo('');
setPeriodRangeError('');
setSubmittedId(null);
}}
className="mt-6 px-6 py-2 text-sm 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={() => navigate('/bypass-catalog')}
className="mt-3 px-6 py-2 text-sm font-medium text-wing-text bg-wing-card hover:bg-wing-hover border border-wing-border rounded-lg transition-colors"
>
Bypass API
</button>
</div>
) : (
/* 신청 폼 */
<form onSubmit={handleSubmit}>
<div className="bg-wing-surface rounded-xl shadow-md p-6">
<div className="space-y-5">
{/* 신청자명 + 기관 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div id="field-applicantName">
<label className="block text-sm font-medium text-wing-text mb-1">
<span className="text-red-500">*</span>
</label>
<input
type="text"
value={form.applicantName}
onChange={(e) => handleChange('applicantName', e.target.value)}
onBlur={() => handleBlur('applicantName')}
placeholder="홍길동"
disabled={submitting}
className={inputClass('applicantName')}
/>
{showError('applicantName') && (
<p className="mt-1 text-xs text-red-500">{errors.applicantName}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-wing-text mb-1">
</label>
<input
type="text"
value={form.organization}
onChange={(e) => handleChange('organization', e.target.value)}
placeholder="소속 기관명"
disabled={submitting}
className="w-full px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text placeholder:text-wing-muted focus:outline-none focus:ring-2 focus:ring-wing-accent/50"
/>
</div>
</div>
{/* 이메일 + 전화번호 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div id="field-email">
<label className="block text-sm font-medium text-wing-text mb-1">
<span className="text-red-500">*</span>
</label>
<input
type="text"
value={form.email}
onChange={(e) => handleChange('email', e.target.value)}
onBlur={() => handleBlur('email')}
placeholder="example@domain.com"
disabled={submitting}
className={inputClass('email')}
/>
{showError('email') && (
<p className="mt-1 text-xs text-red-500">{errors.email}</p>
)}
</div>
<div id="field-phone">
<label className="block text-sm font-medium text-wing-text mb-1">
</label>
<input
type="text"
value={phoneDisplay}
onChange={handlePhoneChange}
onBlur={() => handleBlur('phone')}
placeholder="010-0000-0000"
disabled={submitting}
className={inputClass('phone')}
/>
{showError('phone') && (
<p className="mt-1 text-xs text-red-500">{errors.phone}</p>
)}
</div>
</div>
{/* 사용 목적 */}
<div>
<label className="block text-sm font-medium text-wing-text mb-1">
</label>
<textarea
value={form.purpose}
onChange={(e) => handleChange('purpose', e.target.value)}
placeholder="Bypass API를 사용하려는 목적을 간략히 설명해주세요."
rows={4}
disabled={submitting}
className="w-full px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text placeholder:text-wing-muted focus:outline-none focus:ring-2 focus:ring-wing-accent/50 resize-none"
/>
</div>
{/* 요청 접근 기간 */}
<div id="field-requestedAccessPeriod">
<label className="block text-sm font-medium text-wing-text mb-1">
<span className="text-red-500">*</span>
</label>
<div className="flex items-center gap-2 mb-2">
<button
type="button"
onClick={handleToggleManual}
className="flex items-center gap-2 text-xs text-wing-muted hover:text-wing-text transition-colors"
>
<span> </span>
<span
className={`relative inline-flex h-4 w-8 items-center rounded-full transition-colors ${
periodManual ? 'bg-wing-accent' : 'bg-wing-border'
}`}
>
<span
className={`inline-block h-3 w-3 rounded-full bg-white shadow transition-transform ${
periodManual ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</span>
</button>
</div>
{!periodManual ? (
/* Preset buttons */
<div className="flex flex-wrap gap-2">
{PRESETS.map((preset) => (
<button
key={preset}
type="button"
onClick={() => handlePresetClick(preset)}
disabled={submitting}
className={`px-4 py-1.5 text-sm rounded-full border transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
selectedPreset === preset
? 'bg-wing-accent text-white border-wing-accent'
: 'bg-wing-card text-wing-text border-wing-border hover:border-wing-accent hover:text-wing-accent'
}`}
>
{preset}
</button>
))}
</div>
) : (
/* Manual date range inputs */
<div className="flex items-center gap-3">
<div className="flex-1">
<input
type="date"
value={periodFrom}
onChange={(e) => handlePeriodFromChange(e.target.value)}
disabled={submitting}
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>
<span className="text-sm text-wing-muted flex-shrink-0">~</span>
<div className="flex-1">
<input
type="date"
value={periodTo}
onChange={(e) => handlePeriodToChange(e.target.value)}
disabled={submitting}
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>
)}
{/* Period preview */}
{form.requestedAccessPeriod && !periodRangeError && (
<p className="mt-2 text-xs text-wing-muted">
: <span className="font-medium text-wing-text">{form.requestedAccessPeriod}</span>
</p>
)}
{periodRangeError && (
<p className="mt-1 text-xs text-red-500">{periodRangeError}</p>
)}
{showError('requestedAccessPeriod') && !periodRangeError && (
<p className="mt-1 text-xs text-red-500">{errors.requestedAccessPeriod}</p>
)}
</div>
</div>
<div className="mt-6 flex justify-end">
<button
type="submit"
disabled={submitting}
className="px-6 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"
>
{submitting ? '제출 중...' : '신청 제출'}
</button>
</div>
</div>
</form>
)}
</div>
);
}

파일 보기

@ -0,0 +1,484 @@
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>
<span className="text-wing-muted">: </span>
<span className="text-wing-text">{editTarget.phone ?? '-'}</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>
);
}

파일 보기

@ -0,0 +1,617 @@
import { useState, useEffect, useCallback } from 'react';
import {
bypassAccountApi,
type BypassRequestResponse,
type BypassAccountResponse,
type PageResponse,
} from '../api/bypassAccountApi';
import { useToastContext } from '../contexts/ToastContext';
import Pagination from '../components/Pagination';
import LoadingSpinner from '../components/LoadingSpinner';
const STATUS_TABS = [
{ value: '', label: '전체' },
{ value: 'PENDING', label: 'PENDING' },
{ value: 'APPROVED', label: 'APPROVED' },
{ value: 'REJECTED', label: 'REJECTED' },
] as const;
const STATUS_BADGE_COLORS: Record<string, string> = {
PENDING: 'bg-amber-100 text-amber-700',
APPROVED: 'bg-emerald-100 text-emerald-700',
REJECTED: 'bg-red-100 text-red-700',
};
const PAGE_SIZE = 20;
interface ApproveFormState {
reviewedBy: string;
accessStartDate: string;
accessEndDate: string;
}
interface RejectFormState {
reviewedBy: string;
rejectReason: string;
}
export default function BypassAccountRequests() {
const { showToast } = useToastContext();
const [pageData, setPageData] = useState<PageResponse<BypassRequestResponse> | null>(null);
const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState('');
const [page, setPage] = useState(0);
// Approve modal
const [approveTarget, setApproveTarget] = useState<BypassRequestResponse | null>(null);
const [approveForm, setApproveForm] = useState<ApproveFormState>({
reviewedBy: '',
accessStartDate: '',
accessEndDate: '',
});
const [approveSubmitting, setApproveSubmitting] = useState(false);
// Reject modal
const [rejectTarget, setRejectTarget] = useState<BypassRequestResponse | null>(null);
const [rejectForm, setRejectForm] = useState<RejectFormState>({ reviewedBy: '', rejectReason: '' });
const [rejectSubmitting, setRejectSubmitting] = useState(false);
// Detail modal
const [detailTarget, setDetailTarget] = useState<BypassRequestResponse | null>(null);
// Credential modal
const [credentialAccount, setCredentialAccount] = useState<BypassAccountResponse | null>(null);
const loadRequests = useCallback(async () => {
setLoading(true);
try {
const res = await bypassAccountApi.getRequests(statusFilter || undefined, page, PAGE_SIZE);
setPageData(res.data ?? null);
} catch (err) {
showToast('신청 목록 조회 실패', 'error');
console.error(err);
} finally {
setLoading(false);
}
}, [showToast, statusFilter, page]);
useEffect(() => {
loadRequests();
}, [loadRequests]);
const handleStatusFilterChange = (value: string) => {
setStatusFilter(value);
setPage(0);
};
const openApproveModal = (req: BypassRequestResponse) => {
setApproveTarget(req);
setApproveForm({ reviewedBy: '', accessStartDate: '', accessEndDate: '' });
};
const openRejectModal = (req: BypassRequestResponse) => {
setRejectTarget(req);
setRejectForm({ reviewedBy: '', rejectReason: '' });
};
const handleApproveSubmit = async () => {
if (!approveTarget) return;
if (!approveForm.reviewedBy.trim()) {
showToast('검토자명을 입력해주세요.', 'error');
return;
}
setApproveSubmitting(true);
try {
const res = await bypassAccountApi.approveRequest(approveTarget.id, {
reviewedBy: approveForm.reviewedBy,
accessStartDate: approveForm.accessStartDate || undefined,
accessEndDate: approveForm.accessEndDate || undefined,
});
setApproveTarget(null);
setCredentialAccount(res.data);
showToast('신청이 승인되었습니다.', 'success');
await loadRequests();
} catch (err) {
showToast('승인 처리 실패', 'error');
console.error(err);
} finally {
setApproveSubmitting(false);
}
};
const handleRejectSubmit = async () => {
if (!rejectTarget) return;
if (!rejectForm.reviewedBy.trim()) {
showToast('검토자명을 입력해주세요.', 'error');
return;
}
setRejectSubmitting(true);
try {
await bypassAccountApi.rejectRequest(rejectTarget.id, {
reviewedBy: rejectForm.reviewedBy,
rejectReason: rejectForm.rejectReason || undefined,
});
setRejectTarget(null);
showToast('신청이 거절되었습니다.', 'success');
await loadRequests();
} catch (err) {
showToast('거절 처리 실패', 'error');
console.error(err);
} finally {
setRejectSubmitting(false);
}
};
if (loading && !pageData) return <LoadingSpinner />;
const requests = 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"></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">
{requests.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-12 text-center text-wing-muted text-sm">
.
</td>
</tr>
) : (
requests.map((req) => (
<tr key={req.id} className="hover:bg-wing-hover transition-colors">
<td className="px-4 py-3">
<span
className={[
'px-2 py-0.5 text-xs font-semibold rounded-full',
STATUS_BADGE_COLORS[req.status] ?? 'bg-wing-card text-wing-muted border border-wing-border',
].join(' ')}
>
{req.status}
</span>
</td>
<td className="px-4 py-3 font-medium text-wing-text">
{req.applicantName}
</td>
<td className="px-4 py-3 text-xs text-wing-muted whitespace-nowrap">
{req.createdAt
? new Date(req.createdAt).toLocaleDateString('ko-KR')
: '-'}
</td>
<td className="px-4 py-3 text-xs text-wing-muted">
{req.requestedAccessPeriod ?? '-'}
</td>
<td className="px-4 py-3 text-xs text-wing-muted">
{req.organization ?? '-'}
</td>
<td className="px-4 py-3 text-xs text-wing-muted">
{req.email ?? '-'}
</td>
<td className="px-4 py-3 text-xs text-wing-muted max-w-[200px]">
<span className="truncate block" title={req.purpose ?? ''}>
{req.purpose ?? '-'}
</span>
</td>
<td className="px-4 py-3">
<div className="flex justify-end">
<button
type="button"
onClick={() => setDetailTarget(req)}
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>
</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>
{/* 승인 모달 */}
{approveTarget && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-wing-overlay"
onClick={() => setApproveTarget(null)}
>
<div
className="bg-wing-surface rounded-xl shadow-2xl p-6 max-w-md w-full mx-4"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-semibold text-wing-text mb-4"> </h3>
{/* 신청 상세 정보 */}
<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">{approveTarget.applicantName}</span>
</div>
<div>
<span className="text-wing-muted">: </span>
<span className="text-wing-text">{approveTarget.organization ?? '-'}</span>
</div>
<div>
<span className="text-wing-muted">: </span>
<span className="text-wing-text">{approveTarget.email ?? '-'}</span>
</div>
<div>
<span className="text-wing-muted">: </span>
<span className="text-wing-text">{approveTarget.phone ?? '-'}</span>
</div>
<div className="col-span-2">
<span className="text-wing-muted"> : </span>
<span className="text-wing-text">{approveTarget.requestedAccessPeriod ?? '-'}</span>
</div>
</div>
{approveTarget.purpose && (
<div className="mt-2 pt-2 border-t border-wing-border text-xs">
<span className="text-wing-muted"> : </span>
<span className="text-wing-text">{approveTarget.purpose}</span>
</div>
)}
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-wing-text mb-1">
<span className="text-red-500">*</span>
</label>
<input
type="text"
value={approveForm.reviewedBy}
onChange={(e) => setApproveForm((f) => ({ ...f, reviewedBy: e.target.value }))}
placeholder="검토자 이름 입력"
className="w-full px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text placeholder:text-wing-muted focus:outline-none focus:ring-2 focus:ring-wing-accent/50"
/>
</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={approveForm.accessStartDate}
onChange={(e) => setApproveForm((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={approveForm.accessEndDate}
onChange={(e) => setApproveForm((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={() => { setDetailTarget(approveTarget); setApproveTarget(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={handleApproveSubmit}
disabled={approveSubmitting}
className="px-4 py-2 text-sm font-medium text-white bg-emerald-600 hover:bg-emerald-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{approveSubmitting ? '처리 중...' : '승인'}
</button>
</div>
</div>
</div>
)}
{/* 거절 모달 */}
{rejectTarget && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-wing-overlay"
onClick={() => setRejectTarget(null)}
>
<div
className="bg-wing-surface rounded-xl shadow-2xl p-6 max-w-md w-full mx-4"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-semibold text-wing-text mb-4"> </h3>
{/* 신청 상세 정보 */}
<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">{rejectTarget.applicantName}</span>
</div>
<div>
<span className="text-wing-muted">: </span>
<span className="text-wing-text">{rejectTarget.organization ?? '-'}</span>
</div>
<div>
<span className="text-wing-muted">: </span>
<span className="text-wing-text">{rejectTarget.email ?? '-'}</span>
</div>
<div>
<span className="text-wing-muted">: </span>
<span className="text-wing-text">{rejectTarget.phone ?? '-'}</span>
</div>
<div className="col-span-2">
<span className="text-wing-muted"> : </span>
<span className="text-wing-text">{rejectTarget.requestedAccessPeriod ?? '-'}</span>
</div>
</div>
{rejectTarget.purpose && (
<div className="mt-2 pt-2 border-t border-wing-border text-xs">
<span className="text-wing-muted"> : </span>
<span className="text-wing-text">{rejectTarget.purpose}</span>
</div>
)}
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-wing-text mb-1">
<span className="text-red-500">*</span>
</label>
<input
type="text"
value={rejectForm.reviewedBy}
onChange={(e) => setRejectForm((f) => ({ ...f, reviewedBy: e.target.value }))}
placeholder="검토자 이름 입력"
className="w-full px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text placeholder:text-wing-muted 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>
<textarea
value={rejectForm.rejectReason}
onChange={(e) => setRejectForm((f) => ({ ...f, rejectReason: e.target.value }))}
placeholder="거절 사유를 입력하세요 (선택)"
rows={3}
className="w-full px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text placeholder:text-wing-muted focus:outline-none focus:ring-2 focus:ring-wing-accent/50 resize-none"
/>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
type="button"
onClick={() => { setDetailTarget(rejectTarget); setRejectTarget(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={handleRejectSubmit}
disabled={rejectSubmitting}
className="px-4 py-2 text-sm font-medium text-white bg-red-500 hover:bg-red-600 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{rejectSubmitting ? '처리 중...' : '거절'}
</button>
</div>
</div>
</div>
)}
{/* 신청 상세 모달 */}
{detailTarget && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-wing-overlay"
onClick={() => setDetailTarget(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-4"> </h3>
<div className="space-y-3 text-sm">
<div className="grid grid-cols-2 gap-3">
<div>
<div className="text-xs text-wing-muted mb-0.5"></div>
<div className="text-wing-text font-medium">{detailTarget.applicantName}</div>
</div>
<div>
<div className="text-xs text-wing-muted mb-0.5"></div>
<div className="text-wing-text">{detailTarget.organization ?? '-'}</div>
</div>
<div>
<div className="text-xs text-wing-muted mb-0.5"></div>
<div className="text-wing-text">{detailTarget.email ?? '-'}</div>
</div>
<div>
<div className="text-xs text-wing-muted mb-0.5"></div>
<div className="text-wing-text">{detailTarget.phone ?? '-'}</div>
</div>
</div>
<div>
<div className="text-xs text-wing-muted mb-0.5"> </div>
<div className="text-wing-text">{detailTarget.requestedAccessPeriod ?? '-'}</div>
</div>
<div>
<div className="text-xs text-wing-muted mb-0.5"> </div>
<div className="text-wing-text whitespace-pre-wrap bg-wing-card rounded-lg p-3 border border-wing-border max-h-48 overflow-y-auto">
{detailTarget.purpose || '-'}
</div>
</div>
{detailTarget.status !== 'PENDING' && (
<div className="border-t border-wing-border pt-3 space-y-2">
<div className="grid grid-cols-2 gap-3">
<div>
<div className="text-xs text-wing-muted mb-0.5"> </div>
<span className={`px-2 py-0.5 text-xs font-semibold rounded-full ${STATUS_BADGE_COLORS[detailTarget.status] ?? ''}`}>
{detailTarget.status}
</span>
</div>
<div>
<div className="text-xs text-wing-muted mb-0.5"></div>
<div className="text-wing-text">{detailTarget.reviewedBy ?? '-'}</div>
</div>
</div>
{detailTarget.rejectReason && (
<div>
<div className="text-xs text-wing-muted mb-0.5"> </div>
<div className="text-wing-text">{detailTarget.rejectReason}</div>
</div>
)}
</div>
)}
</div>
<div className="flex justify-end gap-3 mt-6">
<button
type="button"
onClick={() => setDetailTarget(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>
{detailTarget.status === 'PENDING' && (
<>
<button
type="button"
onClick={() => { setDetailTarget(null); openRejectModal(detailTarget); }}
className="px-4 py-2 text-sm font-medium text-red-500 hover:bg-red-50 border border-red-200 rounded-lg transition-colors"
>
</button>
<button
type="button"
onClick={() => { setDetailTarget(null); openApproveModal(detailTarget); }}
className="px-4 py-2 text-sm font-medium text-white bg-emerald-600 hover:bg-emerald-700 rounded-lg transition-colors"
>
</button>
</>
)}
</div>
</div>
</div>
)}
{/* 계정 발급 완료 모달 */}
{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>
);
}

파일 보기

@ -39,6 +39,12 @@
<artifactId>spring-boot-starter-web</artifactId> <artifactId>spring-boot-starter-web</artifactId>
</dependency> </dependency>
<!-- Spring Boot Starter Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Spring Boot Starter Batch --> <!-- Spring Boot Starter Batch -->
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>

파일 보기

@ -0,0 +1,51 @@
package com.snp.batch.global.config;
import com.snp.batch.global.model.AccountStatus;
import com.snp.batch.global.model.BypassApiAccount;
import com.snp.batch.global.repository.BypassApiAccountRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
/**
* Bypass API 계정 인증용 UserDetailsService
*
* bypass_api_account 테이블에서 사용자를 로드하고,
* 계정 상태 접근 기간을 검증한다.
*/
@Service
@RequiredArgsConstructor
public class BypassApiUserDetailsService implements UserDetailsService {
private final BypassApiAccountRepository accountRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
BypassApiAccount account = accountRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
LocalDate today = LocalDate.now();
boolean withinPeriod = true;
if (account.getAccessStartDate() != null && today.isBefore(account.getAccessStartDate())) {
withinPeriod = false;
}
if (account.getAccessEndDate() != null && today.isAfter(account.getAccessEndDate())) {
withinPeriod = false;
}
boolean accountEnabled = account.getStatus() == AccountStatus.ACTIVE && withinPeriod;
return User.builder()
.username(account.getUsername())
.password(account.getPasswordHash())
.disabled(!accountEnabled)
.accountLocked(account.getStatus() == AccountStatus.SUSPENDED)
.authorities("ROLE_BYPASS_API")
.build();
}
}

파일 보기

@ -0,0 +1,56 @@
package com.snp.batch.global.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.authentication.LockedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import java.io.IOException;
import java.util.Map;
/**
* Basic Auth 인증 실패 계정 상태에 따라 구체적인 오류 정보를 JSON으로 반환하는 EntryPoint.
*
* <ul>
* <li>LockedException (accountLocked=true, SUSPENDED): ACCOUNT_SUSPENDED</li>
* <li>DisabledException (disabled=true, 비활성/기간 만료): ACCOUNT_DISABLED</li>
* <li> (잘못된 자격증명 ): INVALID_CREDENTIALS</li>
* </ul>
*
* WWW-Authenticate 헤더를 유지하여 Swagger UI의 Basic Auth 다이얼로그와 호환성을 보장한다.
*/
public class BypassAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
// WWW-Authenticate 헤더를 보내지 않음 브라우저 네이티브 로그인 다이얼로그 방지
// Swagger UI는 자체 Authorize 메커니즘으로 Basic Auth를 처리함
String error;
String message;
if (authException instanceof LockedException) {
error = "ACCOUNT_SUSPENDED";
message = "정지된 계정입니다. 관리자에게 문의하세요.";
} else if (authException instanceof DisabledException) {
error = "ACCOUNT_DISABLED";
message = "비활성화된 계정입니다. 접근 기간이 만료되었거나 계정이 비활성 상태입니다.";
} else {
error = "INVALID_CREDENTIALS";
message = "사용자명 또는 비밀번호가 올바르지 않습니다.";
}
objectMapper.writeValue(response.getOutputStream(),
Map.of("error", error, "message", message));
}
}

파일 보기

@ -0,0 +1,45 @@
package com.snp.batch.global.config;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
/**
* Spring Security 설정
*
* Bypass 데이터 API만 Basic Auth 인증을 적용하고,
* 나머지(배치관리, Swagger, 프론트엔드 ) 기존처럼 오픈 유지.
*/
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/compliance/**").authenticated()
.requestMatchers("/api/risk/**").authenticated()
.anyRequest().permitAll()
)
.httpBasic(basic -> basic
.authenticationEntryPoint(new BypassAuthenticationEntryPoint()));
return http.build();
}
}

파일 보기

@ -1,9 +1,12 @@
package com.snp.batch.global.config; package com.snp.batch.global.config;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact; import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.License; import io.swagger.v3.oas.models.info.License;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import io.swagger.v3.oas.models.servers.Server; import io.swagger.v3.oas.models.servers.Server;
import org.springdoc.core.models.GroupedOpenApi; import org.springdoc.core.models.GroupedOpenApi;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@ -81,10 +84,26 @@ public class SwaggerConfig {
return GroupedOpenApi.builder() return GroupedOpenApi.builder()
.group("3. Bypass API") .group("3. Bypass API")
.pathsToMatch("/api/**") .pathsToMatch("/api/**")
.pathsToExclude("/api/batch/**", "/api/bypass-config/**", "/api/screening-guide/**") .pathsToExclude("/api/batch/**", "/api/bypass-config/**", "/api/screening-guide/**", "/api/bypass-account/**")
.addOpenApiCustomizer(openApi -> openApi.info(new Info() .addOpenApiCustomizer(openApi -> {
openApi.info(new Info()
.title("Bypass API") .title("Bypass API")
.description("외부 연동용 S&P 데이터 Bypass API") .description("외부 연동용 S&P 데이터 Bypass API")
.version("v1.0.0"));
openApi.addSecurityItem(new SecurityRequirement().addList("basicAuth"));
})
.build();
}
@Bean
@ConditionalOnProperty(name = "app.environment", havingValue = "dev", matchIfMissing = true)
public GroupedOpenApi bypassAccountApi() {
return GroupedOpenApi.builder()
.group("5. Bypass Account")
.pathsToMatch("/api/bypass-account/**")
.addOpenApiCustomizer(openApi -> openApi.info(new Info()
.title("Bypass Account Management API")
.description("Bypass API 계정 및 신청 관리 API")
.version("v1.0.0"))) .version("v1.0.0")))
.build(); .build();
} }
@ -109,7 +128,13 @@ public class SwaggerConfig {
return new OpenAPI() return new OpenAPI()
.info(defaultApiInfo()) .info(defaultApiInfo())
.servers(servers); .servers(servers)
.components(new Components()
.addSecuritySchemes("basicAuth",
new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("basic")
.description("Bypass API 접근 계정 (발급된 ID/PW 사용)")));
} }
private Info defaultApiInfo() { private Info defaultApiInfo() {

파일 보기

@ -0,0 +1,117 @@
package com.snp.batch.global.controller;
import com.snp.batch.common.web.ApiResponse;
import com.snp.batch.global.dto.bypass.BypassAccountResponse;
import com.snp.batch.global.dto.bypass.BypassAccountUpdateRequest;
import com.snp.batch.global.dto.bypass.BypassRequestReviewRequest;
import com.snp.batch.global.dto.bypass.BypassRequestResponse;
import com.snp.batch.global.dto.bypass.BypassRequestSubmitRequest;
import com.snp.batch.service.BypassApiAccountService;
import com.snp.batch.service.BypassApiRequestService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* Bypass API 계정 신청 관리 컨트롤러
*/
@Slf4j
@RestController
@RequestMapping("/api/bypass-account")
@RequiredArgsConstructor
@Tag(name = "Bypass Account", description = "Bypass API 계정 및 신청 관리")
public class BypassAccountController {
private final BypassApiAccountService accountService;
private final BypassApiRequestService requestService;
// --- Account CRUD ---
@GetMapping("/accounts")
@Operation(summary = "계정 목록 조회")
public ResponseEntity<ApiResponse<Page<BypassAccountResponse>>> getAccounts(
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ResponseEntity.ok(ApiResponse.success(accountService.getAccounts(status, page, size)));
}
@GetMapping("/accounts/{id}")
@Operation(summary = "계정 상세 조회")
public ResponseEntity<ApiResponse<BypassAccountResponse>> getAccount(@PathVariable Long id) {
return ResponseEntity.ok(ApiResponse.success(accountService.getAccount(id)));
}
@PutMapping("/accounts/{id}")
@Operation(summary = "계정 수정")
public ResponseEntity<ApiResponse<BypassAccountResponse>> updateAccount(
@PathVariable Long id,
@RequestBody BypassAccountUpdateRequest request) {
return ResponseEntity.ok(ApiResponse.success(accountService.updateAccount(id, request)));
}
@DeleteMapping("/accounts/{id}")
@Operation(summary = "계정 삭제")
public ResponseEntity<ApiResponse<Void>> deleteAccount(@PathVariable Long id) {
accountService.deleteAccount(id);
return ResponseEntity.ok(ApiResponse.success(null));
}
@PostMapping("/accounts/{id}/reset-password")
@Operation(summary = "비밀번호 재설정")
public ResponseEntity<ApiResponse<BypassAccountResponse>> resetPassword(@PathVariable Long id) {
return ResponseEntity.ok(ApiResponse.success(accountService.resetPassword(id)));
}
// --- Request management ---
@PostMapping("/requests")
@Operation(summary = "API 접근 신청 (공개)")
public ResponseEntity<ApiResponse<BypassRequestResponse>> submitRequest(
@RequestBody BypassRequestSubmitRequest request) {
return ResponseEntity.ok(ApiResponse.success(requestService.submitRequest(request)));
}
@GetMapping("/requests")
@Operation(summary = "신청 목록 조회")
public ResponseEntity<ApiResponse<Page<BypassRequestResponse>>> getRequests(
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ResponseEntity.ok(ApiResponse.success(requestService.getRequests(status, page, size)));
}
@GetMapping("/requests/{id}")
@Operation(summary = "신청 상세 조회")
public ResponseEntity<ApiResponse<BypassRequestResponse>> getRequest(@PathVariable Long id) {
return ResponseEntity.ok(ApiResponse.success(requestService.getRequest(id)));
}
@PostMapping("/requests/{id}/approve")
@Operation(summary = "신청 승인 (계정 자동 생성)")
public ResponseEntity<ApiResponse<BypassAccountResponse>> approveRequest(
@PathVariable Long id,
@RequestBody BypassRequestReviewRequest review) {
return ResponseEntity.ok(ApiResponse.success(requestService.approveRequest(id, review)));
}
@PostMapping("/requests/{id}/reject")
@Operation(summary = "신청 거절")
public ResponseEntity<ApiResponse<BypassRequestResponse>> rejectRequest(
@PathVariable Long id,
@RequestBody BypassRequestReviewRequest review) {
return ResponseEntity.ok(ApiResponse.success(requestService.rejectRequest(id, review)));
}
}

파일 보기

@ -16,9 +16,11 @@ public class WebViewController {
"/recollects", "/recollects/{id:\\d+}", "/recollects", "/recollects/{id:\\d+}",
"/schedules", "/schedule-timeline", "/monitoring", "/schedules", "/schedule-timeline", "/monitoring",
"/bypass-catalog", "/bypass-config", "/screening-guide", "/risk-compliance-history", "/bypass-catalog", "/bypass-config", "/screening-guide", "/risk-compliance-history",
"/bypass-account-requests", "/bypass-account-management", "/bypass-access-request",
"/dashboard/**", "/jobs/**", "/executions/**", "/recollects/**", "/dashboard/**", "/jobs/**", "/executions/**", "/recollects/**",
"/schedules/**", "/schedule-timeline/**", "/monitoring/**", "/schedules/**", "/schedule-timeline/**", "/monitoring/**",
"/bypass-catalog/**", "/bypass-config/**", "/screening-guide/**", "/risk-compliance-history/**"}) "/bypass-catalog/**", "/bypass-config/**", "/screening-guide/**", "/risk-compliance-history/**",
"/bypass-account-requests/**", "/bypass-account-management/**", "/bypass-access-request/**"})
public String forward() { public String forward() {
return "forward:/index.html"; return "forward:/index.html";
} }

파일 보기

@ -0,0 +1,30 @@
package com.snp.batch.global.dto.bypass;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class BypassAccountResponse {
private Long id;
private String username;
private String displayName;
private String organization;
private String email;
private String phone;
private String status;
private LocalDate accessStartDate;
private LocalDate accessEndDate;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private String plainPassword;
}

파일 보기

@ -0,0 +1,22 @@
package com.snp.batch.global.dto.bypass;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BypassAccountUpdateRequest {
private String displayName;
private String organization;
private String email;
private String phone;
private String status;
private LocalDate accessStartDate;
private LocalDate accessEndDate;
}

파일 보기

@ -0,0 +1,30 @@
package com.snp.batch.global.dto.bypass;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BypassRequestResponse {
private Long id;
private String applicantName;
private String organization;
private String purpose;
private String email;
private String phone;
private String requestedAccessPeriod;
private String status;
private String reviewedBy;
private LocalDateTime reviewedAt;
private String rejectReason;
private Long accountId;
private String accountUsername;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

파일 보기

@ -0,0 +1,19 @@
package com.snp.batch.global.dto.bypass;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BypassRequestReviewRequest {
private String reviewedBy;
private String rejectReason;
private LocalDate accessStartDate;
private LocalDate accessEndDate;
}

파일 보기

@ -0,0 +1,19 @@
package com.snp.batch.global.dto.bypass;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BypassRequestSubmitRequest {
private String applicantName;
private String organization;
private String purpose;
private String email;
private String phone;
private String requestedAccessPeriod;
}

파일 보기

@ -0,0 +1,5 @@
package com.snp.batch.global.model;
public enum AccountStatus {
ACTIVE, SUSPENDED, EXPIRED
}

파일 보기

@ -0,0 +1,118 @@
package com.snp.batch.global.model;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* Bypass API 계정 정보를 저장하는 엔티티
* 외부 사용자에게 Bypass API 접근 권한을 부여하기 위한 계정 정보 관리
*
* JPA를 사용하므로 @PrePersist, @PreUpdate로 감사 필드 자동 설정
*/
@Entity
@Table(name = "bypass_api_account",
indexes = {
@Index(name = "idx_bypass_account_username", columnList = "username"),
@Index(name = "idx_bypass_account_status", columnList = "status")
}
)
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BypassApiAccount {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* 로그인 아이디 (고유값)
*/
@Column(name = "username", nullable = false, unique = true, length = 50)
private String username;
/**
* 해시된 비밀번호
*/
@Column(name = "password_hash", nullable = false, length = 255)
private String passwordHash;
/**
* 표시명
*/
@Column(name = "display_name", nullable = false, length = 100)
private String displayName;
/**
* 소속 기관
*/
@Column(name = "organization", length = 200)
private String organization;
/**
* 이메일
*/
@Column(name = "email", length = 200)
private String email;
/**
* 연락처
*/
@Column(name = "phone", length = 50)
private String phone;
/**
* 계정 상태 (ACTIVE, SUSPENDED, EXPIRED)
*/
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 20)
@Builder.Default
private AccountStatus status = AccountStatus.ACTIVE;
/**
* 접근 허용 시작일
*/
@Column(name = "access_start_date")
private LocalDate accessStartDate;
/**
* 접근 허용 종료일
*/
@Column(name = "access_end_date")
private LocalDate accessEndDate;
/**
* 생성 일시 (감사 필드)
*/
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
/**
* 수정 일시 (감사 필드)
*/
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
/**
* 엔티티 저장 자동 호출 (INSERT )
*/
@PrePersist
protected void onCreate() {
LocalDateTime now = LocalDateTime.now();
this.createdAt = now;
this.updatedAt = now;
}
/**
* 엔티티 업데이트 자동 호출 (UPDATE )
*/
@PreUpdate
protected void onUpdate() {
this.updatedAt = LocalDateTime.now();
}
}

파일 보기

@ -0,0 +1,129 @@
package com.snp.batch.global.model;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
/**
* Bypass API 접근 신청 정보를 저장하는 엔티티
* 외부 사용자가 Bypass API 접근을 신청하고 관리자가 승인/반려하는 워크플로우 관리
*
* JPA를 사용하므로 @PrePersist, @PreUpdate로 감사 필드 자동 설정
*/
@Entity
@Table(name = "bypass_api_request",
indexes = {
@Index(name = "idx_bypass_request_status", columnList = "status")
}
)
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BypassApiRequest {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* 신청자 이름
*/
@Column(name = "applicant_name", nullable = false, length = 100)
private String applicantName;
/**
* 소속 기관
*/
@Column(name = "organization", length = 200)
private String organization;
/**
* 신청 목적
*/
@Column(name = "purpose", columnDefinition = "TEXT")
private String purpose;
/**
* 이메일
*/
@Column(name = "email", length = 200)
private String email;
/**
* 연락처
*/
@Column(name = "phone", length = 50)
private String phone;
/**
* 요청 접근 기간
*/
@Column(name = "requested_access_period", length = 100)
private String requestedAccessPeriod;
/**
* 신청 상태 (PENDING, APPROVED, REJECTED)
*/
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 20)
@Builder.Default
private RequestStatus status = RequestStatus.PENDING;
/**
* 검토자
*/
@Column(name = "reviewed_by", length = 100)
private String reviewedBy;
/**
* 검토 일시
*/
@Column(name = "reviewed_at")
private LocalDateTime reviewedAt;
/**
* 반려 사유
*/
@Column(name = "reject_reason", columnDefinition = "TEXT")
private String rejectReason;
/**
* 승인 생성된 계정
*/
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "account_id")
private BypassApiAccount account;
/**
* 생성 일시 (감사 필드)
*/
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
/**
* 수정 일시 (감사 필드)
*/
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
/**
* 엔티티 저장 자동 호출 (INSERT )
*/
@PrePersist
protected void onCreate() {
LocalDateTime now = LocalDateTime.now();
this.createdAt = now;
this.updatedAt = now;
}
/**
* 엔티티 업데이트 자동 호출 (UPDATE )
*/
@PreUpdate
protected void onUpdate() {
this.updatedAt = LocalDateTime.now();
}
}

파일 보기

@ -0,0 +1,5 @@
package com.snp.batch.global.model;
public enum RequestStatus {
PENDING, APPROVED, REJECTED
}

파일 보기

@ -0,0 +1,15 @@
package com.snp.batch.global.repository;
import com.snp.batch.global.model.AccountStatus;
import com.snp.batch.global.model.BypassApiAccount;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface BypassApiAccountRepository extends JpaRepository<BypassApiAccount, Long> {
Optional<BypassApiAccount> findByUsername(String username);
boolean existsByUsername(String username);
Page<BypassApiAccount> findByStatus(AccountStatus status, Pageable pageable);
}

파일 보기

@ -0,0 +1,13 @@
package com.snp.batch.global.repository;
import com.snp.batch.global.model.BypassApiRequest;
import com.snp.batch.global.model.RequestStatus;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
public interface BypassApiRequestRepository extends JpaRepository<BypassApiRequest, Long> {
Page<BypassApiRequest> findByStatus(RequestStatus status, Pageable pageable);
Page<BypassApiRequest> findAllByOrderByCreatedAtDesc(Pageable pageable);
Page<BypassApiRequest> findByStatusOrderByCreatedAtDesc(RequestStatus status, Pageable pageable);
}

파일 보기

@ -0,0 +1,153 @@
package com.snp.batch.service;
import com.snp.batch.global.dto.bypass.BypassAccountResponse;
import com.snp.batch.global.dto.bypass.BypassAccountUpdateRequest;
import com.snp.batch.global.model.AccountStatus;
import com.snp.batch.global.model.BypassApiAccount;
import com.snp.batch.global.repository.BypassApiAccountRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.security.SecureRandom;
import java.time.LocalDate;
@Slf4j
@Service
@RequiredArgsConstructor
public class BypassApiAccountService {
private static final String USERNAME_PREFIX = "bypass_";
private static final int USERNAME_RANDOM_LENGTH = 8;
private static final int PASSWORD_LENGTH = 16;
private static final String ALPHANUMERIC = "abcdefghijklmnopqrstuvwxyz0123456789";
private static final String PASSWORD_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%&*";
private static final SecureRandom RANDOM = new SecureRandom();
private final BypassApiAccountRepository accountRepository;
private final PasswordEncoder passwordEncoder;
@Transactional
public BypassAccountResponse createAccount(String displayName, String organization,
String email, String phone,
LocalDate accessStartDate, LocalDate accessEndDate) {
String rawUsername = generateUsername();
String rawPassword = generatePassword();
BypassApiAccount account = BypassApiAccount.builder()
.username(rawUsername)
.passwordHash(passwordEncoder.encode(rawPassword))
.displayName(displayName)
.organization(organization)
.email(email)
.phone(phone)
.status(AccountStatus.ACTIVE)
.accessStartDate(accessStartDate)
.accessEndDate(accessEndDate)
.build();
BypassApiAccount saved = accountRepository.save(account);
log.info("Bypass API 계정 생성: username={}", rawUsername);
return toResponse(saved, rawPassword);
}
@Transactional(readOnly = true)
public Page<BypassAccountResponse> getAccounts(String status, int page, int size) {
PageRequest pageRequest = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"));
Page<BypassApiAccount> accounts;
if (status != null && !status.isBlank()) {
accounts = accountRepository.findByStatus(AccountStatus.valueOf(status), pageRequest);
} else {
accounts = accountRepository.findAll(pageRequest);
}
return accounts.map(this::toResponse);
}
@Transactional(readOnly = true)
public BypassAccountResponse getAccount(Long id) {
return toResponse(findOrThrow(id));
}
@Transactional
public BypassAccountResponse updateAccount(Long id, BypassAccountUpdateRequest request) {
BypassApiAccount account = findOrThrow(id);
if (request.getDisplayName() != null) account.setDisplayName(request.getDisplayName());
if (request.getOrganization() != null) account.setOrganization(request.getOrganization());
if (request.getEmail() != null) account.setEmail(request.getEmail());
if (request.getPhone() != null) account.setPhone(request.getPhone());
if (request.getStatus() != null) account.setStatus(AccountStatus.valueOf(request.getStatus()));
if (request.getAccessStartDate() != null) account.setAccessStartDate(request.getAccessStartDate());
if (request.getAccessEndDate() != null) account.setAccessEndDate(request.getAccessEndDate());
return toResponse(accountRepository.save(account));
}
@Transactional
public void deleteAccount(Long id) {
BypassApiAccount account = findOrThrow(id);
accountRepository.delete(account);
log.info("Bypass API 계정 삭제: username={}", account.getUsername());
}
@Transactional
public BypassAccountResponse resetPassword(Long id) {
BypassApiAccount account = findOrThrow(id);
String rawPassword = generatePassword();
account.setPasswordHash(passwordEncoder.encode(rawPassword));
accountRepository.save(account);
log.info("Bypass API 비밀번호 재설정: username={}", account.getUsername());
return toResponse(account, rawPassword);
}
private BypassApiAccount findOrThrow(Long id) {
return accountRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("계정을 찾을 수 없습니다: " + id));
}
private String generateUsername() {
for (int i = 0; i < 10; i++) {
StringBuilder sb = new StringBuilder(USERNAME_PREFIX);
for (int j = 0; j < USERNAME_RANDOM_LENGTH; j++) {
sb.append(ALPHANUMERIC.charAt(RANDOM.nextInt(ALPHANUMERIC.length())));
}
String candidate = sb.toString();
if (!accountRepository.existsByUsername(candidate)) {
return candidate;
}
}
throw new IllegalStateException("Username 생성 실패: 10회 시도 초과");
}
private String generatePassword() {
StringBuilder sb = new StringBuilder(PASSWORD_LENGTH);
for (int i = 0; i < PASSWORD_LENGTH; i++) {
sb.append(PASSWORD_CHARS.charAt(RANDOM.nextInt(PASSWORD_CHARS.length())));
}
return sb.toString();
}
private BypassAccountResponse toResponse(BypassApiAccount account, String plainPassword) {
return BypassAccountResponse.builder()
.id(account.getId())
.username(account.getUsername())
.displayName(account.getDisplayName())
.organization(account.getOrganization())
.email(account.getEmail())
.phone(account.getPhone())
.status(account.getStatus().name())
.accessStartDate(account.getAccessStartDate())
.accessEndDate(account.getAccessEndDate())
.createdAt(account.getCreatedAt())
.updatedAt(account.getUpdatedAt())
.plainPassword(plainPassword)
.build();
}
private BypassAccountResponse toResponse(BypassApiAccount account) {
return toResponse(account, null);
}
}

파일 보기

@ -0,0 +1,131 @@
package com.snp.batch.service;
import com.snp.batch.global.dto.bypass.BypassAccountResponse;
import com.snp.batch.global.dto.bypass.BypassRequestReviewRequest;
import com.snp.batch.global.dto.bypass.BypassRequestResponse;
import com.snp.batch.global.dto.bypass.BypassRequestSubmitRequest;
import com.snp.batch.global.model.BypassApiRequest;
import com.snp.batch.global.model.RequestStatus;
import com.snp.batch.global.repository.BypassApiAccountRepository;
import com.snp.batch.global.repository.BypassApiRequestRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
@Slf4j
@Service
@RequiredArgsConstructor
public class BypassApiRequestService {
private final BypassApiRequestRepository requestRepository;
private final BypassApiAccountRepository accountRepository;
private final BypassApiAccountService accountService;
@Transactional
public BypassRequestResponse submitRequest(BypassRequestSubmitRequest request) {
BypassApiRequest entity = BypassApiRequest.builder()
.applicantName(request.getApplicantName())
.organization(request.getOrganization())
.purpose(request.getPurpose())
.email(request.getEmail())
.phone(request.getPhone())
.requestedAccessPeriod(request.getRequestedAccessPeriod())
.build();
BypassApiRequest saved = requestRepository.save(entity);
log.info("Bypass API 계정 신청 접수: applicant={}", request.getApplicantName());
return toResponse(saved);
}
@Transactional(readOnly = true)
public Page<BypassRequestResponse> getRequests(String status, int page, int size) {
PageRequest pageRequest = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"));
Page<BypassApiRequest> requests;
if (status != null && !status.isBlank()) {
requests = requestRepository.findByStatusOrderByCreatedAtDesc(
RequestStatus.valueOf(status), pageRequest);
} else {
requests = requestRepository.findAllByOrderByCreatedAtDesc(pageRequest);
}
return requests.map(this::toResponse);
}
@Transactional(readOnly = true)
public BypassRequestResponse getRequest(Long id) {
return toResponse(findOrThrow(id));
}
@Transactional
public BypassAccountResponse approveRequest(Long id, BypassRequestReviewRequest review) {
BypassApiRequest request = findOrThrow(id);
if (request.getStatus() != RequestStatus.PENDING) {
throw new IllegalStateException("이미 처리된 신청입니다: " + request.getStatus());
}
BypassAccountResponse accountResponse = accountService.createAccount(
request.getApplicantName(),
request.getOrganization(),
request.getEmail(),
request.getPhone(),
review.getAccessStartDate(),
review.getAccessEndDate()
);
request.setStatus(RequestStatus.APPROVED);
request.setReviewedBy(review.getReviewedBy());
request.setReviewedAt(LocalDateTime.now());
request.setAccount(accountRepository.getReferenceById(accountResponse.getId()));
requestRepository.save(request);
log.info("Bypass API 계정 신청 승인: requestId={}, accountUsername={}",
id, accountResponse.getUsername());
return accountResponse;
}
@Transactional
public BypassRequestResponse rejectRequest(Long id, BypassRequestReviewRequest review) {
BypassApiRequest request = findOrThrow(id);
if (request.getStatus() != RequestStatus.PENDING) {
throw new IllegalStateException("이미 처리된 신청입니다: " + request.getStatus());
}
request.setStatus(RequestStatus.REJECTED);
request.setReviewedBy(review.getReviewedBy());
request.setReviewedAt(LocalDateTime.now());
request.setRejectReason(review.getRejectReason());
BypassApiRequest saved = requestRepository.save(request);
log.info("Bypass API 계정 신청 거절: requestId={}", id);
return toResponse(saved);
}
private BypassApiRequest findOrThrow(Long id) {
return requestRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("신청을 찾을 수 없습니다: " + id));
}
private BypassRequestResponse toResponse(BypassApiRequest request) {
return BypassRequestResponse.builder()
.id(request.getId())
.applicantName(request.getApplicantName())
.organization(request.getOrganization())
.purpose(request.getPurpose())
.email(request.getEmail())
.phone(request.getPhone())
.requestedAccessPeriod(request.getRequestedAccessPeriod())
.status(request.getStatus().name())
.reviewedBy(request.getReviewedBy())
.reviewedAt(request.getReviewedAt())
.rejectReason(request.getRejectReason())
.accountId(request.getAccount() != null ? request.getAccount().getId() : null)
.accountUsername(request.getAccount() != null ? request.getAccount().getUsername() : null)
.createdAt(request.getCreatedAt())
.updatedAt(request.getUpdatedAt())
.build();
}
}

파일 보기

@ -77,7 +77,7 @@ logging:
# Custom Application Properties # Custom Application Properties
app: app:
environment: prod environment: pro
batch: batch:
chunk-size: 1000 chunk-size: 1000
schedule: schedule:

파일 보기

@ -0,0 +1,38 @@
-- Bypass API 계정 관리 테이블
CREATE TABLE bypass_api_account (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
display_name VARCHAR(100) NOT NULL,
organization VARCHAR(200),
email VARCHAR(200),
phone VARCHAR(50),
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
access_start_date DATE,
access_end_date DATE,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE INDEX idx_bypass_account_username ON bypass_api_account(username);
CREATE INDEX idx_bypass_account_status ON bypass_api_account(status);
CREATE TABLE bypass_api_request (
id BIGSERIAL PRIMARY KEY,
applicant_name VARCHAR(100) NOT NULL,
organization VARCHAR(200),
purpose TEXT,
email VARCHAR(200),
phone VARCHAR(50),
requested_access_period VARCHAR(100),
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
reviewed_by VARCHAR(100),
reviewed_at TIMESTAMP,
reject_reason TEXT,
account_id BIGINT REFERENCES bypass_api_account(id),
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE INDEX idx_bypass_request_status ON bypass_api_request(status);