From 10358140b77a39a1239c55c4e746efc90749aa1c Mon Sep 17 00:00:00 2001 From: HYOJIN Date: Mon, 6 Apr 2026 14:25:49 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(bypass):=20=EA=B3=84=EC=A0=95=20?= =?UTF-8?q?=EC=8B=A0=EC=B2=AD=20=EC=8B=9C=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=EB=AA=85,=20=EC=98=88=EC=83=81=20=ED=98=B8=EC=B6=9C?= =?UTF-8?q?=EB=9F=89,=20=EC=84=9C=EB=B9=84=EC=8A=A4=20IP=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80=20(#152)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/api/bypassAccountApi.ts | 21 ++ frontend/src/pages/BypassAccessRequest.tsx | 272 ++++++++++++------ .../src/pages/BypassAccountManagement.tsx | 102 +++---- frontend/src/pages/BypassAccountRequests.tsx | 148 +++++++--- .../controller/BypassAccountController.java | 1 + .../dto/bypass/BypassAccountResponse.java | 2 + .../dto/bypass/BypassRequestResponse.java | 3 + .../bypass/BypassRequestSubmitRequest.java | 3 + .../batch/global/dto/bypass/ServiceIpDto.java | 17 ++ .../batch/global/model/BypassApiAccount.java | 3 + .../batch/global/model/BypassApiRequest.java | 18 ++ .../global/model/BypassApiServiceIp.java | 45 +++ .../BypassApiServiceIpRepository.java | 11 + .../service/BypassApiAccountService.java | 65 +++++ .../service/BypassApiRequestService.java | 37 ++- .../db/schema/bypass_api_account.sql | 23 ++ 16 files changed, 606 insertions(+), 165 deletions(-) create mode 100644 src/main/java/com/snp/batch/global/dto/bypass/ServiceIpDto.java create mode 100644 src/main/java/com/snp/batch/global/model/BypassApiServiceIp.java create mode 100644 src/main/java/com/snp/batch/global/repository/BypassApiServiceIpRepository.java diff --git a/frontend/src/api/bypassAccountApi.ts b/frontend/src/api/bypassAccountApi.ts index 0dae666..e4d7e63 100644 --- a/frontend/src/api/bypassAccountApi.ts +++ b/frontend/src/api/bypassAccountApi.ts @@ -3,6 +3,7 @@ export interface BypassAccountResponse { username: string; displayName: string; organization: string | null; + projectName: string | null; email: string | null; phone: string | null; status: string; @@ -11,6 +12,7 @@ export interface BypassAccountResponse { createdAt: string; updatedAt: string; plainPassword: string | null; + serviceIps: string | null; } export interface BypassAccountUpdateRequest { @@ -39,6 +41,9 @@ export interface BypassRequestResponse { accountUsername: string | null; createdAt: string; updatedAt: string; + projectName: string | null; + expectedCallVolume: string | null; + serviceIps: string | null; } export interface BypassRequestSubmitRequest { @@ -48,6 +53,9 @@ export interface BypassRequestSubmitRequest { email: string; phone: string; requestedAccessPeriod: string; + projectName: string; + expectedCallVolume: string; + serviceIps: string; // JSON string } export interface BypassRequestReviewRequest { @@ -71,6 +79,13 @@ interface ApiResponse { data: T; } +export interface ServiceIpDto { + ip: string; + purpose: string; + expectedCallVolume: string; + description: string; +} + // BASE URL const BASE = '/snp-api/api/bypass-account'; @@ -122,6 +137,12 @@ export const bypassAccountApi = { deleteJson>(`${BASE}/accounts/${id}`), resetPassword: (id: number) => postJson>(`${BASE}/accounts/${id}/reset-password`, {}), + getAccountIps: (accountId: number) => + fetchJson>(`${BASE}/accounts/${accountId}/ips`), + addAccountIp: (accountId: number, data: ServiceIpDto) => + postJson>(`${BASE}/accounts/${accountId}/ips`, data), + deleteAccountIp: (accountId: number, ipId: number) => + deleteJson>(`${BASE}/accounts/${accountId}/ips/${ipId}`), // Requests submitRequest: (data: BypassRequestSubmitRequest) => diff --git a/frontend/src/pages/BypassAccessRequest.tsx b/frontend/src/pages/BypassAccessRequest.tsx index 892ca55..af2fb3e 100644 --- a/frontend/src/pages/BypassAccessRequest.tsx +++ b/frontend/src/pages/BypassAccessRequest.tsx @@ -12,18 +12,27 @@ interface FormState { purpose: string; email: string; requestedAccessPeriod: string; + projectName: string; + serviceIp: string; + servicePurpose: string; + expectedCallVolume: string; + serviceDescription: string; } interface ErrorState { applicantName?: string; email?: string; requestedAccessPeriod?: string; + projectName?: string; + serviceIp?: string; } interface TouchedState { applicantName: boolean; email: boolean; requestedAccessPeriod: boolean; + projectName: boolean; + serviceIp: boolean; } type PeriodPreset = '3개월' | '6개월' | '9개월' | '1년'; @@ -34,12 +43,19 @@ const INITIAL_FORM: FormState = { purpose: '', email: '', requestedAccessPeriod: '', + projectName: '', + serviceIp: '', + servicePurpose: 'DEV_PC', + expectedCallVolume: 'LOW', + serviceDescription: '', }; const INITIAL_TOUCHED: TouchedState = { applicantName: false, email: false, requestedAccessPeriod: false, + projectName: false, + serviceIp: false, }; const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; @@ -93,6 +109,14 @@ function validateForm(form: FormState): ErrorState { errors.requestedAccessPeriod = '사용 기간을 선택해주세요.'; } + if (!form.projectName.trim()) { + errors.projectName = '프로젝트/서비스명을 입력해주세요.'; + } + + if (!form.serviceIp.trim()) { + errors.serviceIp = '서비스 IP를 입력해주세요.'; + } + return errors; } @@ -178,7 +202,7 @@ export default function BypassAccessRequest() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setSubmitAttempted(true); - setTouched({ applicantName: true, email: true, requestedAccessPeriod: true }); + setTouched({ applicantName: true, email: true, requestedAccessPeriod: true, projectName: true, serviceIp: true }); const currentErrors = validateForm(form); @@ -197,6 +221,12 @@ export default function BypassAccessRequest() { setSubmitting(true); try { + const serviceIpEntry = form.serviceIp.trim() ? [{ + ip: form.serviceIp.trim(), + purpose: form.servicePurpose, + expectedCallVolume: form.expectedCallVolume, + description: form.serviceDescription, + }] : []; const requestData: BypassRequestSubmitRequest = { applicantName: form.applicantName, organization: form.organization, @@ -204,6 +234,9 @@ export default function BypassAccessRequest() { email: form.email, phone: '', requestedAccessPeriod: form.requestedAccessPeriod, + projectName: form.projectName, + expectedCallVolume: form.expectedCallVolume, + serviceIps: JSON.stringify(serviceIpEntry), }; const res = await bypassAccountApi.submitRequest(requestData); setSubmittedId(res.data.id); @@ -284,7 +317,7 @@ export default function BypassAccessRequest() {
- {/* 신청자명 + 기관 */} + {/* Row 1: 신청자명 + 기관 */}
- {/* 이메일 + 사용 기간 */} + {/* Row 2: 이메일 + 프로젝트/서비스명 */}
-
-
- - -
- {!periodManual ? ( -
- {PRESETS.map((preset) => ( - - ))} -
- ) : ( -
-
- 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" - /> -
- ~ -
- 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" - /> -
-
- )} - - {form.requestedAccessPeriod && !periodRangeError && ( -

- 선택된 기간: {form.requestedAccessPeriod} -

- )} - {periodRangeError && ( -

{periodRangeError}

- )} - {showError('requestedAccessPeriod') && !periodRangeError && ( -

{errors.requestedAccessPeriod}

+
+ + handleChange('projectName', e.target.value)} + onBlur={() => handleBlur('projectName')} + placeholder="사용할 프로젝트 또는 서비스명" + disabled={submitting} + className={inputClass('projectName')} + /> + {showError('projectName') && ( +

{errors.projectName}

)}
- {/* 사용 목적 */} + {/* Row 3: 사용 기간 (full width) */} +
+
+ + +
+ {!periodManual ? ( +
+ {PRESETS.map((preset) => ( + + ))} +
+ ) : ( +
+
+ 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" + /> +
+ ~ +
+ 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" + /> +
+
+ )} + + {form.requestedAccessPeriod && !periodRangeError && ( +

+ 선택된 기간: {form.requestedAccessPeriod} +

+ )} + {periodRangeError && ( +

{periodRangeError}

+ )} + {showError('requestedAccessPeriod') && !periodRangeError && ( +

{errors.requestedAccessPeriod}

+ )} +
+ + {/* Row 4: 서비스 IP (단건) */} +
+ +
+ IP 주소 + 용도 + 예상 호출량 + 설명 +
+
+ handleChange('serviceIp', e.target.value)} + onBlur={() => handleBlur('serviceIp')} + placeholder="192.168.1.1" + disabled={submitting} + className={inputClass('serviceIp')} + /> + + + handleChange('serviceDescription', e.target.value)} + placeholder="설명 (선택)" + disabled={submitting} + className="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" + /> +
+ {showError('serviceIp') && ( +

{errors.serviceIp}

+ )} +

+ 여러 IP가 필요한 경우 IP별로 별도 신청해주세요. (1신청 = 1IP = 1계정) +

+
+ + {/* Row 5: 사용 목적 */}