From 911f755d46866372ee67f59714690f98d6b727eb Mon Sep 17 00:00:00 2001 From: HYOJIN Date: Thu, 2 Apr 2026 17:12:51 +0900 Subject: [PATCH] =?UTF-8?q?feat(bypass-account):=20Bypass=20API=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EA=B3=84=EC=A0=95=20=EB=B0=9C?= =?UTF-8?q?=EA=B8=89=20=EC=8B=A0=EC=B2=AD=20=ED=94=84=EB=A1=9C=EC=84=B8?= =?UTF-8?q?=EC=8A=A4=20=EA=B0=9C=EB=B0=9C=20(#126)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Spring Security Basic Auth 인증 도입 (Bypass 데이터 API만) - 계정 신청/승인/거절 백엔드 API 및 프론트엔드 구현 - 계정 관리 (CRUD, 비밀번호 재설정, 상태/기간 수정) - 401 응답에 계정 상태 상세 메시지 포함 - Swagger UI Basic Auth 스킴/환경별 그룹 노출 연동 - 신청 폼 정규식 검증 및 접근기간 프리셋 선택 --- frontend/src/App.tsx | 6 + frontend/src/api/bypassAccountApi.ts | 138 ++++ frontend/src/components/Navbar.tsx | 3 + frontend/src/pages/BypassAccessRequest.tsx | 501 ++++++++++++++ .../src/pages/BypassAccountManagement.tsx | 484 ++++++++++++++ frontend/src/pages/BypassAccountRequests.tsx | 617 ++++++++++++++++++ pom.xml | 6 + .../config/BypassApiUserDetailsService.java | 51 ++ .../BypassAuthenticationEntryPoint.java | 56 ++ .../batch/global/config/SecurityConfig.java | 45 ++ .../batch/global/config/SwaggerConfig.java | 33 +- .../controller/BypassAccountController.java | 117 ++++ .../global/controller/WebViewController.java | 4 +- .../dto/bypass/BypassAccountResponse.java | 30 + .../bypass/BypassAccountUpdateRequest.java | 22 + .../dto/bypass/BypassRequestResponse.java | 30 + .../bypass/BypassRequestReviewRequest.java | 19 + .../bypass/BypassRequestSubmitRequest.java | 19 + .../snp/batch/global/model/AccountStatus.java | 5 + .../batch/global/model/BypassApiAccount.java | 118 ++++ .../batch/global/model/BypassApiRequest.java | 129 ++++ .../snp/batch/global/model/RequestStatus.java | 5 + .../BypassApiAccountRepository.java | 15 + .../BypassApiRequestRepository.java | 13 + .../service/BypassApiAccountService.java | 153 +++++ .../service/BypassApiRequestService.java | 131 ++++ src/main/resources/application-dev.yml | 2 +- .../db/schema/bypass_api_account.sql | 38 ++ 28 files changed, 2784 insertions(+), 6 deletions(-) create mode 100644 frontend/src/api/bypassAccountApi.ts create mode 100644 frontend/src/pages/BypassAccessRequest.tsx create mode 100644 frontend/src/pages/BypassAccountManagement.tsx create mode 100644 frontend/src/pages/BypassAccountRequests.tsx create mode 100644 src/main/java/com/snp/batch/global/config/BypassApiUserDetailsService.java create mode 100644 src/main/java/com/snp/batch/global/config/BypassAuthenticationEntryPoint.java create mode 100644 src/main/java/com/snp/batch/global/config/SecurityConfig.java create mode 100644 src/main/java/com/snp/batch/global/controller/BypassAccountController.java create mode 100644 src/main/java/com/snp/batch/global/dto/bypass/BypassAccountResponse.java create mode 100644 src/main/java/com/snp/batch/global/dto/bypass/BypassAccountUpdateRequest.java create mode 100644 src/main/java/com/snp/batch/global/dto/bypass/BypassRequestResponse.java create mode 100644 src/main/java/com/snp/batch/global/dto/bypass/BypassRequestReviewRequest.java create mode 100644 src/main/java/com/snp/batch/global/dto/bypass/BypassRequestSubmitRequest.java create mode 100644 src/main/java/com/snp/batch/global/model/AccountStatus.java create mode 100644 src/main/java/com/snp/batch/global/model/BypassApiAccount.java create mode 100644 src/main/java/com/snp/batch/global/model/BypassApiRequest.java create mode 100644 src/main/java/com/snp/batch/global/model/RequestStatus.java create mode 100644 src/main/java/com/snp/batch/global/repository/BypassApiAccountRepository.java create mode 100644 src/main/java/com/snp/batch/global/repository/BypassApiRequestRepository.java create mode 100644 src/main/java/com/snp/batch/service/BypassApiAccountService.java create mode 100644 src/main/java/com/snp/batch/service/BypassApiRequestService.java create mode 100644 src/main/resources/db/schema/bypass_api_account.sql diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6a4d349..e191c4a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -19,6 +19,9 @@ const BypassConfig = lazy(() => import('./pages/BypassConfig')); const BypassCatalog = lazy(() => import('./pages/BypassCatalog')); const ScreeningGuide = lazy(() => import('./pages/ScreeningGuide')); 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() { const { toasts, removeToast } = useToastContext(); @@ -54,6 +57,9 @@ function AppLayout() { } /> } /> } /> + } /> + } /> + } /> } /> } /> diff --git a/frontend/src/api/bypassAccountApi.ts b/frontend/src/api/bypassAccountApi.ts new file mode 100644 index 0000000..aae6f5e --- /dev/null +++ b/frontend/src/api/bypassAccountApi.ts @@ -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 { + content: T[]; + totalElements: number; + totalPages: number; + number: number; + size: number; +} + +interface ApiResponse { + success: boolean; + message: string; + data: T; +} + +// BASE URL +const BASE = '/snp-api/api/bypass-account'; + +// 헬퍼 함수 (bypassApi.ts 패턴과 동일) +async function fetchJson(url: string): Promise { + const res = await fetch(url); + if (!res.ok) throw new Error(`API Error: ${res.status} ${res.statusText}`); + return res.json(); +} + +async function postJson(url: string, body?: unknown): Promise { + 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(url: string, body?: unknown): Promise { + 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(url: string): Promise { + 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>>(`${BASE}/accounts?${params}`); + }, + getAccount: (id: number) => + fetchJson>(`${BASE}/accounts/${id}`), + updateAccount: (id: number, data: BypassAccountUpdateRequest) => + putJson>(`${BASE}/accounts/${id}`, data), + deleteAccount: (id: number) => + deleteJson>(`${BASE}/accounts/${id}`), + resetPassword: (id: number) => + postJson>(`${BASE}/accounts/${id}/reset-password`, {}), + + // Requests + submitRequest: (data: BypassRequestSubmitRequest) => + postJson>(`${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>>(`${BASE}/requests?${params}`); + }, + approveRequest: (id: number, data: BypassRequestReviewRequest) => + postJson>(`${BASE}/requests/${id}/approve`, data), + rejectRequest: (id: number, data: BypassRequestReviewRequest) => + postJson>(`${BASE}/requests/${id}/reject`, data), +}; diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index 1c2adf2..e129ed9 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -49,6 +49,9 @@ const MENU_STRUCTURE: MenuSection[] = [ children: [ { id: 'bypass-catalog', label: 'API 카탈로그', path: '/bypass-catalog' }, { 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' }, ], }, { diff --git a/frontend/src/pages/BypassAccessRequest.tsx b/frontend/src/pages/BypassAccessRequest.tsx new file mode 100644 index 0000000..b0c0d58 --- /dev/null +++ b/frontend/src/pages/BypassAccessRequest.tsx @@ -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(INITIAL_FORM); + const [phoneRaw, setPhoneRaw] = useState(''); + const [phoneDisplay, setPhoneDisplay] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [submittedId, setSubmittedId] = useState(null); + + // Validation state + const [errors, setErrors] = useState({}); + const [touched, setTouched] = useState(INITIAL_TOUCHED); + const [submitAttempted, setSubmitAttempted] = useState(false); + + // Period mode + const [periodManual, setPeriodManual] = useState(false); + const [selectedPreset, setSelectedPreset] = useState(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) => { + 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 ( +
+ {/* 헤더 */} +
+

Bypass API 접근 신청

+

+ Bypass API 사용 계정을 신청합니다. 검토 후 계정 정보가 이메일로 발송됩니다. +

+
+ + {submittedId !== null ? ( + /* 제출 완료 화면 */ +
+
+ + + +
+

신청이 완료되었습니다

+

+ 신청 번호: #{submittedId} +

+

+ 검토 후 입력하신 이메일로 안내 드리겠습니다. +

+ + +
+ ) : ( + /* 신청 폼 */ +
+
+
+ {/* 신청자명 + 기관 */} +
+
+ + handleChange('applicantName', e.target.value)} + onBlur={() => handleBlur('applicantName')} + placeholder="홍길동" + disabled={submitting} + className={inputClass('applicantName')} + /> + {showError('applicantName') && ( +

{errors.applicantName}

+ )} +
+
+ + 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" + /> +
+
+ + {/* 이메일 + 전화번호 */} +
+
+ + handleChange('email', e.target.value)} + onBlur={() => handleBlur('email')} + placeholder="example@domain.com" + disabled={submitting} + className={inputClass('email')} + /> + {showError('email') && ( +

{errors.email}

+ )} +
+
+ + handleBlur('phone')} + placeholder="010-0000-0000" + disabled={submitting} + className={inputClass('phone')} + /> + {showError('phone') && ( +

{errors.phone}

+ )} +
+
+ + {/* 사용 목적 */} +
+ +