Compare commits
12 커밋
| 작성자 | SHA1 | 날짜 | |
|---|---|---|---|
| 59df3fe5df | |||
| c7ce77c51e | |||
| 10358140b7 | |||
| 66716753de | |||
| c8919adab5 | |||
| ad18ab9c30 | |||
| 20489558de | |||
| 0c40e2306f | |||
| e8a8d9d6fd | |||
| 7212566b9f | |||
| 8e2640b6e7 | |||
| 911f755d46 |
@ -4,6 +4,27 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- 계정 신청 시 프로젝트명, 예상 호출량, 서비스 IP 필드 추가 (#152)
|
||||||
|
- Bypass API 사용자 계정 발급 신청 프로세스 (#126)
|
||||||
|
- Spring Security Basic Auth 인증 (Bypass 데이터 API)
|
||||||
|
- 계정 신청/승인/거절 백엔드 API 및 프론트엔드
|
||||||
|
- 계정 관리 (CRUD, 비밀번호 재설정, 상태/기간 수정)
|
||||||
|
- 401 응답에 계정 상태 상세 메시지 포함
|
||||||
|
- Swagger UI Basic Auth 스킴 연동
|
||||||
|
- 신청 폼 정규식 검증 및 접근기간 프리셋
|
||||||
|
- Bypass API 카탈로그 Swagger 딥링크 연동 (#142)
|
||||||
|
- 카탈로그 테스트 버튼 클릭 시 해당 API로 Swagger UI 딥링크 이동
|
||||||
|
- Swagger UI deep-linking 활성화
|
||||||
|
- Bypass API 계정 이메일 알림 및 거절 후속 조치 (#140)
|
||||||
|
- 이메일 공통 모듈 (EmailService, Thymeleaf HTML 템플릿)
|
||||||
|
- 승인 시 계정 발급 이메일 / 거절 시 사유 이메일 자동 발송
|
||||||
|
- 재심사 기능 (REJECTED → PENDING)
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
- UI 텍스트 리레이블링: S&P Bypass → S&P Global API
|
||||||
|
- 신청 폼 전화번호 필드 제거 및 레이아웃 개선
|
||||||
|
|
||||||
## [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>
|
||||||
|
|||||||
161
frontend/src/api/bypassAccountApi.ts
Normal file
161
frontend/src/api/bypassAccountApi.ts
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
export interface BypassAccountResponse {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
organization: string | null;
|
||||||
|
projectName: string | null;
|
||||||
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
status: string;
|
||||||
|
accessStartDate: string | null;
|
||||||
|
accessEndDate: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
plainPassword: string | null;
|
||||||
|
serviceIps: 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;
|
||||||
|
projectName: string | null;
|
||||||
|
expectedCallVolume: string | null;
|
||||||
|
serviceIps: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BypassRequestSubmitRequest {
|
||||||
|
applicantName: string;
|
||||||
|
organization: string;
|
||||||
|
purpose: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
requestedAccessPeriod: string;
|
||||||
|
projectName: string;
|
||||||
|
expectedCallVolume: string;
|
||||||
|
serviceIps: string; // JSON 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceIpDto {
|
||||||
|
ip: string;
|
||||||
|
purpose: string;
|
||||||
|
expectedCallVolume: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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`, {}),
|
||||||
|
getAccountIps: (accountId: number) =>
|
||||||
|
fetchJson<ApiResponse<ServiceIpDto[]>>(`${BASE}/accounts/${accountId}/ips`),
|
||||||
|
addAccountIp: (accountId: number, data: ServiceIpDto) =>
|
||||||
|
postJson<ApiResponse<ServiceIpDto>>(`${BASE}/accounts/${accountId}/ips`, data),
|
||||||
|
deleteAccountIp: (accountId: number, ipId: number) =>
|
||||||
|
deleteJson<ApiResponse<void>>(`${BASE}/accounts/${accountId}/ips/${ipId}`),
|
||||||
|
|
||||||
|
// 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),
|
||||||
|
reopenRequest: (id: number) =>
|
||||||
|
postJson<ApiResponse<BypassRequestResponse>>(`${BASE}/requests/${id}/reopen`, {}),
|
||||||
|
};
|
||||||
@ -38,17 +38,20 @@ const MENU_STRUCTURE: MenuSection[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'bypass',
|
id: 'bypass',
|
||||||
label: 'S&P Bypass',
|
label: 'S&P Global API',
|
||||||
shortLabel: 'Bypass',
|
shortLabel: 'S&P Global API',
|
||||||
icon: (
|
icon: (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l4-4 4 4m0 6l-4 4-4-4" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
defaultPath: '/bypass-catalog',
|
defaultPath: '/bypass-catalog',
|
||||||
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' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
563
frontend/src/pages/BypassAccessRequest.tsx
Normal file
563
frontend/src/pages/BypassAccessRequest.tsx
Normal file
@ -0,0 +1,563 @@
|
|||||||
|
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;
|
||||||
|
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년';
|
||||||
|
|
||||||
|
const INITIAL_FORM: FormState = {
|
||||||
|
applicantName: '',
|
||||||
|
organization: '',
|
||||||
|
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@]+$/;
|
||||||
|
|
||||||
|
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): 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 (!form.requestedAccessPeriod.trim()) {
|
||||||
|
errors.requestedAccessPeriod = '사용 기간을 선택해주세요.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.projectName.trim()) {
|
||||||
|
errors.projectName = '프로젝트/서비스명을 입력해주세요.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.serviceIp.trim()) {
|
||||||
|
errors.serviceIp = '서비스 IP를 입력해주세요.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BypassAccessRequest() {
|
||||||
|
const { showToast } = useToastContext();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [form, setForm] = useState<FormState>(INITIAL_FORM);
|
||||||
|
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 handleBlur = (field: keyof TouchedState) => {
|
||||||
|
setTouched((prev) => ({ ...prev, [field]: true }));
|
||||||
|
const currentErrors = validateForm(form);
|
||||||
|
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, requestedAccessPeriod: true, projectName: true, serviceIp: true });
|
||||||
|
|
||||||
|
const currentErrors = validateForm(form);
|
||||||
|
|
||||||
|
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 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,
|
||||||
|
purpose: form.purpose,
|
||||||
|
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);
|
||||||
|
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">S&P API 계정 신청</h1>
|
||||||
|
<p className="mt-1 text-sm text-wing-muted">
|
||||||
|
S&P 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);
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
S&P API 목록 보기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* 신청 폼 */
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md p-6">
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Row 1: 신청자명 + 기관 */}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Row 2: 이메일 + 프로젝트/서비스명 */}
|
||||||
|
<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-projectName">
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
|
프로젝트/서비스명 <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.projectName}
|
||||||
|
onChange={(e) => handleChange('projectName', e.target.value)}
|
||||||
|
onBlur={() => handleBlur('projectName')}
|
||||||
|
placeholder="사용할 프로젝트 또는 서비스명"
|
||||||
|
disabled={submitting}
|
||||||
|
className={inputClass('projectName')}
|
||||||
|
/>
|
||||||
|
{showError('projectName') && (
|
||||||
|
<p className="mt-1 text-xs text-red-500">{errors.projectName}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 3: 사용 기간 (full width) */}
|
||||||
|
<div id="field-requestedAccessPeriod">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<label className="text-sm font-medium text-wing-text">
|
||||||
|
사용 기간 <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<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 ? (
|
||||||
|
<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>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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>
|
||||||
|
|
||||||
|
{/* Row 4: 서비스 IP (단건) */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
|
서비스 IP <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-4 gap-2 mb-1 text-[10px] text-wing-muted font-medium">
|
||||||
|
<span>IP 주소</span>
|
||||||
|
<span>용도</span>
|
||||||
|
<span>예상 호출량</span>
|
||||||
|
<span>설명</span>
|
||||||
|
</div>
|
||||||
|
<div id="field-serviceIp" className="grid grid-cols-4 gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.serviceIp}
|
||||||
|
onChange={(e) => handleChange('serviceIp', e.target.value)}
|
||||||
|
onBlur={() => handleBlur('serviceIp')}
|
||||||
|
placeholder="192.168.1.1"
|
||||||
|
disabled={submitting}
|
||||||
|
className={inputClass('serviceIp')}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={form.servicePurpose}
|
||||||
|
onChange={(e) => handleChange('servicePurpose', e.target.value)}
|
||||||
|
disabled={submitting}
|
||||||
|
className="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"
|
||||||
|
>
|
||||||
|
<option value="DEV_PC">개발 PC</option>
|
||||||
|
<option value="PROD_SERVER">운영 서버</option>
|
||||||
|
<option value="TEST_SERVER">테스트 서버</option>
|
||||||
|
<option value="ETC">기타</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={form.expectedCallVolume}
|
||||||
|
onChange={(e) => handleChange('expectedCallVolume', e.target.value)}
|
||||||
|
disabled={submitting}
|
||||||
|
className="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"
|
||||||
|
>
|
||||||
|
<option value="LOW">100건 이하/일</option>
|
||||||
|
<option value="MEDIUM">1,000건 이하/일</option>
|
||||||
|
<option value="HIGH">10,000건 이하/일</option>
|
||||||
|
<option value="VERY_HIGH">10,000건 이상/일</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.serviceDescription}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{showError('serviceIp') && (
|
||||||
|
<p className="mt-1 text-xs text-red-500">{errors.serviceIp}</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-1 text-xs text-wing-muted">
|
||||||
|
여러 IP가 필요한 경우 IP별로 별도 신청해주세요. (1신청 = 1IP = 1계정)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 5: 사용 목적 */}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
486
frontend/src/pages/BypassAccountManagement.tsx
Normal file
486
frontend/src/pages/BypassAccountManagement.tsx
Normal file
@ -0,0 +1,486 @@
|
|||||||
|
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">상태</th>
|
||||||
|
<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-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">
|
||||||
|
<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 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 whitespace-nowrap">
|
||||||
|
{account.createdAt
|
||||||
|
? new Date(account.createdAt).toLocaleDateString('ko-KR')
|
||||||
|
: '-'}
|
||||||
|
</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">
|
||||||
|
{account.organization ?? '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-wing-muted">
|
||||||
|
{account.email ?? '-'}
|
||||||
|
</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 text-xs space-y-1.5">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<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>
|
||||||
|
<div>
|
||||||
|
<span className="text-wing-muted">이메일: </span>
|
||||||
|
<span className="text-wing-text">{editTarget.email ?? '-'}</span>
|
||||||
|
</div>
|
||||||
|
{editTarget.serviceIps && (() => {
|
||||||
|
const PURPOSE_MAP: Record<string, string> = { DEV_PC: '개발 PC', PROD_SERVER: '운영 서버', TEST_SERVER: '테스트 서버', ETC: '기타' };
|
||||||
|
const VOLUME_MAP: Record<string, string> = { LOW: '100건 이하/일', MEDIUM: '1,000건 이하/일', HIGH: '10,000건 이하/일', VERY_HIGH: '10,000건 이상/일' };
|
||||||
|
let ips: {ip: string; purpose: string; expectedCallVolume?: string; description: string}[] = [];
|
||||||
|
try { ips = JSON.parse(editTarget.serviceIps); } catch { /* empty */ }
|
||||||
|
if (ips.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="text-wing-muted mb-1">서비스 IP</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{ips.map((ip, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<code className="font-mono text-wing-text">{ip.ip}</code>
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-wing-surface text-wing-muted text-[10px]">{PURPOSE_MAP[ip.purpose] ?? ip.purpose}</span>
|
||||||
|
{ip.expectedCallVolume && <span className="px-1.5 py-0.5 rounded bg-wing-surface text-wing-muted text-[10px]">{VOLUME_MAP[ip.expectedCallVolume] ?? ip.expectedCallVolume}</span>}
|
||||||
|
{ip.description && <span className="text-wing-muted">{ip.description}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
700
frontend/src/pages/BypassAccountRequests.tsx
Normal file
700
frontend/src/pages/BypassAccountRequests.tsx
Normal file
@ -0,0 +1,700 @@
|
|||||||
|
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-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={7} 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">
|
||||||
|
<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 space-y-2 text-xs">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<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>
|
||||||
|
<div>
|
||||||
|
<span className="text-wing-muted">이메일: </span>
|
||||||
|
<span className="text-wing-text">{approveTarget.email ?? '-'}</span>
|
||||||
|
</div>
|
||||||
|
{approveTarget.projectName && (
|
||||||
|
<div>
|
||||||
|
<span className="text-wing-muted">프로젝트/서비스명: </span>
|
||||||
|
<span className="text-wing-text">{approveTarget.projectName}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<span className="text-wing-muted">신청 사용 기간: </span>
|
||||||
|
<span className="text-wing-text">{approveTarget.requestedAccessPeriod ?? '-'}</span>
|
||||||
|
</div>
|
||||||
|
{approveTarget.serviceIps && (() => {
|
||||||
|
let ips: {ip: string; purpose: string; expectedCallVolume?: string; description: string}[] = [];
|
||||||
|
try { ips = JSON.parse(approveTarget.serviceIps); } catch {}
|
||||||
|
if (ips.length === 0) return null;
|
||||||
|
const PURPOSE_MAP: Record<string, string> = { DEV_PC: '개발 PC', PROD_SERVER: '운영 서버', TEST_SERVER: '테스트 서버', ETC: '기타' };
|
||||||
|
const VOLUME_MAP: Record<string, string> = { LOW: '100건 이하/일', MEDIUM: '1,000건 이하/일', HIGH: '10,000건 이하/일', VERY_HIGH: '10,000건 이상/일' };
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="text-wing-muted mb-1">서비스 IP</div>
|
||||||
|
<div className="bg-wing-surface rounded-lg border border-wing-border p-2 space-y-1">
|
||||||
|
{ips.map((ip, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<code className="font-mono text-wing-text">{ip.ip}</code>
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-wing-card text-wing-muted text-[10px]">{PURPOSE_MAP[ip.purpose] ?? ip.purpose}</span>
|
||||||
|
{ip.expectedCallVolume && <span className="px-1.5 py-0.5 rounded bg-wing-card text-wing-muted text-[10px]">{VOLUME_MAP[ip.expectedCallVolume] ?? ip.expectedCallVolume}</span>}
|
||||||
|
{ip.description && <span className="text-wing-muted">{ip.description}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
{approveTarget.purpose && (
|
||||||
|
<div>
|
||||||
|
<div className="text-wing-muted mb-1">신청 사유</div>
|
||||||
|
<div className="text-wing-text bg-wing-surface rounded-lg border border-wing-border p-2 whitespace-pre-wrap">{approveTarget.purpose}</div>
|
||||||
|
</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 space-y-2 text-xs">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<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>
|
||||||
|
<div>
|
||||||
|
<span className="text-wing-muted">이메일: </span>
|
||||||
|
<span className="text-wing-text">{rejectTarget.email ?? '-'}</span>
|
||||||
|
</div>
|
||||||
|
{rejectTarget.projectName && (
|
||||||
|
<div>
|
||||||
|
<span className="text-wing-muted">프로젝트/서비스명: </span>
|
||||||
|
<span className="text-wing-text">{rejectTarget.projectName}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<span className="text-wing-muted">신청 사용 기간: </span>
|
||||||
|
<span className="text-wing-text">{rejectTarget.requestedAccessPeriod ?? '-'}</span>
|
||||||
|
</div>
|
||||||
|
{rejectTarget.serviceIps && (() => {
|
||||||
|
let ips: {ip: string; purpose: string; expectedCallVolume?: string; description: string}[] = [];
|
||||||
|
try { ips = JSON.parse(rejectTarget.serviceIps); } catch {}
|
||||||
|
if (ips.length === 0) return null;
|
||||||
|
const PURPOSE_MAP: Record<string, string> = { DEV_PC: '개발 PC', PROD_SERVER: '운영 서버', TEST_SERVER: '테스트 서버', ETC: '기타' };
|
||||||
|
const VOLUME_MAP: Record<string, string> = { LOW: '100건 이하/일', MEDIUM: '1,000건 이하/일', HIGH: '10,000건 이하/일', VERY_HIGH: '10,000건 이상/일' };
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="text-wing-muted mb-1">서비스 IP</div>
|
||||||
|
<div className="bg-wing-surface rounded-lg border border-wing-border p-2 space-y-1">
|
||||||
|
{ips.map((ip, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<code className="font-mono text-wing-text">{ip.ip}</code>
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-wing-card text-wing-muted text-[10px]">{PURPOSE_MAP[ip.purpose] ?? ip.purpose}</span>
|
||||||
|
{ip.expectedCallVolume && <span className="px-1.5 py-0.5 rounded bg-wing-card text-wing-muted text-[10px]">{VOLUME_MAP[ip.expectedCallVolume] ?? ip.expectedCallVolume}</span>}
|
||||||
|
{ip.description && <span className="text-wing-muted">{ip.description}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
{rejectTarget.purpose && (
|
||||||
|
<div>
|
||||||
|
<div className="text-wing-muted mb-1">신청 사유</div>
|
||||||
|
<div className="text-wing-text bg-wing-surface rounded-lg border border-wing-border p-2 whitespace-pre-wrap">{rejectTarget.purpose}</div>
|
||||||
|
</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.projectName ?? '-'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-wing-muted mb-0.5">신청 사용 기간</div>
|
||||||
|
<div className="text-wing-text">{detailTarget.requestedAccessPeriod ?? '-'}</div>
|
||||||
|
</div>
|
||||||
|
{detailTarget.serviceIps && (() => {
|
||||||
|
let ips: {ip: string; purpose: string; expectedCallVolume?: string; description: string}[] = [];
|
||||||
|
try { ips = JSON.parse(detailTarget.serviceIps); } catch {}
|
||||||
|
if (ips.length === 0) return null;
|
||||||
|
const PURPOSE_MAP: Record<string, string> = { DEV_PC: '개발 PC', PROD_SERVER: '운영 서버', TEST_SERVER: '테스트 서버', ETC: '기타' };
|
||||||
|
const VOLUME_MAP: Record<string, string> = { LOW: '100건 이하/일', MEDIUM: '1,000건 이하/일', HIGH: '10,000건 이하/일', VERY_HIGH: '10,000건 이상/일' };
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-wing-muted mb-1">서비스 IP</div>
|
||||||
|
<div className="bg-wing-card rounded-lg border border-wing-border p-2 space-y-1">
|
||||||
|
{ips.map((ip, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 text-xs">
|
||||||
|
<code className="font-mono text-wing-text">{ip.ip}</code>
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-wing-surface text-wing-muted text-[10px]">{PURPOSE_MAP[ip.purpose] ?? ip.purpose}</span>
|
||||||
|
{ip.expectedCallVolume && <span className="px-1.5 py-0.5 rounded bg-wing-surface text-wing-muted text-[10px]">{VOLUME_MAP[ip.expectedCallVolume] ?? ip.expectedCallVolume}</span>}
|
||||||
|
{ip.description && <span className="text-wing-muted">{ip.description}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{detailTarget.status === 'REJECTED' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await bypassAccountApi.reopenRequest(detailTarget.id);
|
||||||
|
setDetailTarget(null);
|
||||||
|
showToast('재심사 상태로 변경되었습니다.', 'success');
|
||||||
|
await loadRequests();
|
||||||
|
} catch (err) {
|
||||||
|
showToast('재심사 처리 실패', 'error');
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-amber-500 hover:bg-amber-600 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -36,7 +36,16 @@ const METHOD_COLORS: Record<string, string> = {
|
|||||||
DELETE: 'bg-red-100 text-red-700',
|
DELETE: 'bg-red-100 text-red-700',
|
||||||
};
|
};
|
||||||
|
|
||||||
const SWAGGER_URL = '/snp-api/swagger-ui/index.html?urls.primaryName=3.%20Bypass%20API';
|
const SWAGGER_BASE = '/snp-api/swagger-ui/index.html?urls.primaryName=3.%20Bypass%20API';
|
||||||
|
|
||||||
|
function buildSwaggerDeepLink(config: BypassConfig): string {
|
||||||
|
// Swagger UI deep link: #/{Tag}/{operationId}
|
||||||
|
// Tag = domainName 첫글자 대문자 (예: compliance → Compliance)
|
||||||
|
// operationId = get{EndpointName}Data (SpringDoc 기본 패턴)
|
||||||
|
const tag = config.domainName.charAt(0).toUpperCase() + config.domainName.slice(1);
|
||||||
|
const operationId = `get${config.endpointName}Data`;
|
||||||
|
return `${SWAGGER_BASE}#/${tag}/${operationId}`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function BypassCatalog() {
|
export default function BypassCatalog() {
|
||||||
const [configs, setConfigs] = useState<BypassConfig[]>([]);
|
const [configs, setConfigs] = useState<BypassConfig[]>([]);
|
||||||
@ -83,13 +92,13 @@ export default function BypassCatalog() {
|
|||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-wing-text">Bypass API 카탈로그</h1>
|
<h1 className="text-2xl font-bold text-wing-text">S&P Global API 카탈로그</h1>
|
||||||
<p className="mt-1 text-sm text-wing-muted">
|
<p className="mt-1 text-sm text-wing-muted">
|
||||||
등록된 Bypass API 목록입니다. Swagger UI에서 직접 테스트할 수 있습니다.
|
S&P Global Maritime API 목록입니다. Swagger UI에서 직접 테스트할 수 있습니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
href={SWAGGER_URL}
|
href={SWAGGER_BASE}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="px-4 py-2 text-sm font-medium text-white bg-wing-accent hover:bg-wing-accent/80 rounded-lg transition-colors no-underline"
|
className="px-4 py-2 text-sm font-medium text-white bg-wing-accent hover:bg-wing-accent/80 rounded-lg transition-colors no-underline"
|
||||||
@ -236,7 +245,7 @@ export default function BypassCatalog() {
|
|||||||
)}
|
)}
|
||||||
<div className="pt-1 border-t border-wing-border mt-auto">
|
<div className="pt-1 border-t border-wing-border mt-auto">
|
||||||
<a
|
<a
|
||||||
href={SWAGGER_URL}
|
href={buildSwaggerDeepLink(config)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-xs font-medium text-blue-500 hover:text-blue-600 no-underline transition-colors"
|
className="text-xs font-medium text-blue-500 hover:text-blue-600 no-underline transition-colors"
|
||||||
@ -291,7 +300,7 @@ export default function BypassCatalog() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-right">
|
||||||
<a
|
<a
|
||||||
href={SWAGGER_URL}
|
href={buildSwaggerDeepLink(config)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-xs font-medium text-blue-500 hover:text-blue-600 no-underline transition-colors"
|
className="text-xs font-medium text-blue-500 hover:text-blue-600 no-underline transition-colors"
|
||||||
|
|||||||
@ -12,13 +12,13 @@ const sections = [
|
|||||||
menuCount: 6,
|
menuCount: 6,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'S&P Bypass',
|
title: 'S&P Global API',
|
||||||
description: 'S&P Bypass API 관리',
|
description: 'S&P Global Maritime API',
|
||||||
detail: 'API 등록, 코드 생성 관리, 테스트',
|
detail: 'API 카탈로그, API 계정 신청',
|
||||||
path: '/bypass-catalog',
|
path: '/bypass-catalog',
|
||||||
icon: '🔗',
|
icon: '🌐',
|
||||||
iconClass: 'gc-card-icon gc-card-icon-guide',
|
iconClass: 'gc-card-icon gc-card-icon-guide',
|
||||||
menuCount: 1,
|
menuCount: 5,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'S&P Risk & Compliance',
|
title: 'S&P Risk & Compliance',
|
||||||
|
|||||||
12
pom.xml
12
pom.xml
@ -39,6 +39,18 @@
|
|||||||
<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 Mail -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-mail</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()
|
||||||
|
.title("Bypass API")
|
||||||
|
.description("S&P Global 선박/해운 데이터를 제공합니다.")
|
||||||
|
.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()
|
.addOpenApiCustomizer(openApi -> openApi.info(new Info()
|
||||||
.title("Bypass API")
|
.title("Bypass Account Management API")
|
||||||
.description("외부 연동용 S&P 데이터 Bypass 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,124 @@
|
|||||||
|
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.global.dto.bypass.ServiceIpDto;
|
||||||
|
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)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/requests/{id}/reopen")
|
||||||
|
@Operation(summary = "신청 재심사 (거절 → 대기)")
|
||||||
|
public ResponseEntity<ApiResponse<BypassRequestResponse>> reopenRequest(@PathVariable Long id) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(requestService.reopenRequest(id)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,32 @@
|
|||||||
|
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 projectName;
|
||||||
|
private String email;
|
||||||
|
private String phone;
|
||||||
|
private String status;
|
||||||
|
private LocalDate accessStartDate;
|
||||||
|
private LocalDate accessEndDate;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
private String plainPassword;
|
||||||
|
private String serviceIps; // JSON string of registered IPs
|
||||||
|
}
|
||||||
@ -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,33 @@
|
|||||||
|
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 projectName;
|
||||||
|
private String expectedCallVolume;
|
||||||
|
private String serviceIps;
|
||||||
|
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,22 @@
|
|||||||
|
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;
|
||||||
|
private String projectName;
|
||||||
|
private String expectedCallVolume;
|
||||||
|
private String serviceIps; // JSON string: [{"ip":"...","purpose":"...","description":"..."}]
|
||||||
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
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 ServiceIpDto {
|
||||||
|
private String ip;
|
||||||
|
private String purpose;
|
||||||
|
private String description;
|
||||||
|
private String expectedCallVolume;
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
package com.snp.batch.global.model;
|
||||||
|
|
||||||
|
public enum AccountStatus {
|
||||||
|
ACTIVE, SUSPENDED, EXPIRED
|
||||||
|
}
|
||||||
121
src/main/java/com/snp/batch/global/model/BypassApiAccount.java
Normal file
121
src/main/java/com/snp/batch/global/model/BypassApiAccount.java
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
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 = "project_name", length = 200)
|
||||||
|
private String projectName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이메일
|
||||||
|
*/
|
||||||
|
@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();
|
||||||
|
}
|
||||||
|
}
|
||||||
147
src/main/java/com/snp/batch/global/model/BypassApiRequest.java
Normal file
147
src/main/java/com/snp/batch/global/model/BypassApiRequest.java
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로젝트명
|
||||||
|
*/
|
||||||
|
@Column(name = "project_name", length = 200)
|
||||||
|
private String projectName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 예상 호출량
|
||||||
|
*/
|
||||||
|
@Column(name = "expected_call_volume", length = 50)
|
||||||
|
private String expectedCallVolume;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 서비스 IP 목록 (JSON 문자열, 예: [{"ip":"192.168.1.1","purpose":"DEV_PC","description":"개발용"}])
|
||||||
|
*/
|
||||||
|
@Column(name = "service_ips", columnDefinition = "TEXT")
|
||||||
|
private String serviceIps;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 신청 상태 (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,45 @@
|
|||||||
|
package com.snp.batch.global.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "bypass_api_service_ip")
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class BypassApiServiceIp {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "account_id", nullable = false)
|
||||||
|
private BypassApiAccount account;
|
||||||
|
|
||||||
|
@Column(name = "ip_address", nullable = false, length = 45)
|
||||||
|
private String ipAddress;
|
||||||
|
|
||||||
|
@Column(name = "purpose", nullable = false, length = 50)
|
||||||
|
@Builder.Default
|
||||||
|
private String purpose = "ETC";
|
||||||
|
|
||||||
|
@Column(name = "description", length = 200)
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@Column(name = "expected_call_volume", length = 50)
|
||||||
|
private String expectedCallVolume;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
protected void onCreate() {
|
||||||
|
this.createdAt = 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,11 @@
|
|||||||
|
package com.snp.batch.global.repository;
|
||||||
|
|
||||||
|
import com.snp.batch.global.model.BypassApiServiceIp;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface BypassApiServiceIpRepository extends JpaRepository<BypassApiServiceIp, Long> {
|
||||||
|
List<BypassApiServiceIp> findByAccountId(Long accountId);
|
||||||
|
void deleteByAccountId(Long accountId);
|
||||||
|
}
|
||||||
218
src/main/java/com/snp/batch/service/BypassApiAccountService.java
Normal file
218
src/main/java/com/snp/batch/service/BypassApiAccountService.java
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
package com.snp.batch.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.snp.batch.global.dto.bypass.BypassAccountResponse;
|
||||||
|
import com.snp.batch.global.dto.bypass.BypassAccountUpdateRequest;
|
||||||
|
import com.snp.batch.global.dto.bypass.ServiceIpDto;
|
||||||
|
import com.snp.batch.global.model.AccountStatus;
|
||||||
|
import com.snp.batch.global.model.BypassApiAccount;
|
||||||
|
import com.snp.batch.global.model.BypassApiServiceIp;
|
||||||
|
import com.snp.batch.global.repository.BypassApiAccountRepository;
|
||||||
|
import com.snp.batch.global.repository.BypassApiServiceIpRepository;
|
||||||
|
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;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@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 BypassApiServiceIpRepository serviceIpRepository;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public BypassAccountResponse createAccount(String displayName, String organization,
|
||||||
|
String projectName,
|
||||||
|
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)
|
||||||
|
.projectName(projectName)
|
||||||
|
.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();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<ServiceIpDto> getServiceIps(Long accountId) {
|
||||||
|
findOrThrow(accountId);
|
||||||
|
return serviceIpRepository.findByAccountId(accountId).stream()
|
||||||
|
.map(ip -> ServiceIpDto.builder()
|
||||||
|
.ip(ip.getIpAddress())
|
||||||
|
.purpose(ip.getPurpose())
|
||||||
|
.description(ip.getDescription())
|
||||||
|
.expectedCallVolume(ip.getExpectedCallVolume())
|
||||||
|
.build())
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ServiceIpDto addServiceIp(Long accountId, ServiceIpDto dto) {
|
||||||
|
BypassApiAccount account = findOrThrow(accountId);
|
||||||
|
BypassApiServiceIp saved = serviceIpRepository.save(BypassApiServiceIp.builder()
|
||||||
|
.account(account)
|
||||||
|
.ipAddress(dto.getIp())
|
||||||
|
.purpose(dto.getPurpose() != null ? dto.getPurpose() : "ETC")
|
||||||
|
.description(dto.getDescription())
|
||||||
|
.expectedCallVolume(dto.getExpectedCallVolume())
|
||||||
|
.build());
|
||||||
|
return ServiceIpDto.builder()
|
||||||
|
.ip(saved.getIpAddress())
|
||||||
|
.purpose(saved.getPurpose())
|
||||||
|
.description(saved.getDescription())
|
||||||
|
.expectedCallVolume(saved.getExpectedCallVolume())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void deleteServiceIp(Long accountId, Long ipId) {
|
||||||
|
findOrThrow(accountId);
|
||||||
|
serviceIpRepository.deleteById(ipId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getServiceIpsJson(Long accountId) {
|
||||||
|
List<BypassApiServiceIp> ips = serviceIpRepository.findByAccountId(accountId);
|
||||||
|
if (ips.isEmpty()) return null;
|
||||||
|
try {
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
return mapper.writeValueAsString(ips.stream().map(ip ->
|
||||||
|
ServiceIpDto.builder()
|
||||||
|
.ip(ip.getIpAddress())
|
||||||
|
.purpose(ip.getPurpose())
|
||||||
|
.description(ip.getDescription())
|
||||||
|
.expectedCallVolume(ip.getExpectedCallVolume())
|
||||||
|
.build()
|
||||||
|
).toList());
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private BypassAccountResponse toResponse(BypassApiAccount account, String plainPassword) {
|
||||||
|
return BypassAccountResponse.builder()
|
||||||
|
.id(account.getId())
|
||||||
|
.username(account.getUsername())
|
||||||
|
.displayName(account.getDisplayName())
|
||||||
|
.organization(account.getOrganization())
|
||||||
|
.projectName(account.getProjectName())
|
||||||
|
.email(account.getEmail())
|
||||||
|
.phone(account.getPhone())
|
||||||
|
.status(account.getStatus().name())
|
||||||
|
.accessStartDate(account.getAccessStartDate())
|
||||||
|
.accessEndDate(account.getAccessEndDate())
|
||||||
|
.createdAt(account.getCreatedAt())
|
||||||
|
.updatedAt(account.getUpdatedAt())
|
||||||
|
.plainPassword(plainPassword)
|
||||||
|
.serviceIps(account.getId() != null ? getServiceIpsJson(account.getId()) : null)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private BypassAccountResponse toResponse(BypassApiAccount account) {
|
||||||
|
return toResponse(account, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
205
src/main/java/com/snp/batch/service/BypassApiRequestService.java
Normal file
205
src/main/java/com/snp/batch/service/BypassApiRequestService.java
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
package com.snp.batch.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
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.dto.bypass.ServiceIpDto;
|
||||||
|
import com.snp.batch.global.model.BypassApiAccount;
|
||||||
|
import com.snp.batch.global.model.BypassApiRequest;
|
||||||
|
import com.snp.batch.global.model.BypassApiServiceIp;
|
||||||
|
import com.snp.batch.global.model.RequestStatus;
|
||||||
|
import com.snp.batch.global.repository.BypassApiAccountRepository;
|
||||||
|
import com.snp.batch.global.repository.BypassApiRequestRepository;
|
||||||
|
import com.snp.batch.global.repository.BypassApiServiceIpRepository;
|
||||||
|
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;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class BypassApiRequestService {
|
||||||
|
|
||||||
|
private final BypassApiRequestRepository requestRepository;
|
||||||
|
private final BypassApiAccountRepository accountRepository;
|
||||||
|
private final BypassApiServiceIpRepository serviceIpRepository;
|
||||||
|
private final BypassApiAccountService accountService;
|
||||||
|
private final EmailService emailService;
|
||||||
|
|
||||||
|
@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())
|
||||||
|
.projectName(request.getProjectName())
|
||||||
|
.expectedCallVolume(request.getExpectedCallVolume())
|
||||||
|
.serviceIps(request.getServiceIps())
|
||||||
|
.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.getProjectName(),
|
||||||
|
request.getEmail(),
|
||||||
|
request.getPhone(),
|
||||||
|
review.getAccessStartDate(),
|
||||||
|
review.getAccessEndDate()
|
||||||
|
);
|
||||||
|
|
||||||
|
request.setStatus(RequestStatus.APPROVED);
|
||||||
|
request.setReviewedBy(review.getReviewedBy());
|
||||||
|
request.setReviewedAt(LocalDateTime.now());
|
||||||
|
BypassApiAccount accountEntity = accountRepository.getReferenceById(accountResponse.getId());
|
||||||
|
request.setAccount(accountEntity);
|
||||||
|
requestRepository.save(request);
|
||||||
|
|
||||||
|
// 서비스 IP 등록
|
||||||
|
if (request.getServiceIps() != null && !request.getServiceIps().isBlank()) {
|
||||||
|
try {
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
List<ServiceIpDto> ips = mapper.readValue(request.getServiceIps(),
|
||||||
|
mapper.getTypeFactory().constructCollectionType(List.class, ServiceIpDto.class));
|
||||||
|
for (ServiceIpDto ipDto : ips) {
|
||||||
|
serviceIpRepository.save(BypassApiServiceIp.builder()
|
||||||
|
.account(accountEntity)
|
||||||
|
.ipAddress(ipDto.getIp())
|
||||||
|
.purpose(ipDto.getPurpose() != null ? ipDto.getPurpose() : "ETC")
|
||||||
|
.description(ipDto.getDescription())
|
||||||
|
.expectedCallVolume(ipDto.getExpectedCallVolume())
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("서비스 IP 파싱/저장 실패: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이메일 발송 (비동기)
|
||||||
|
if (request.getEmail() != null && !request.getEmail().isBlank()) {
|
||||||
|
emailService.sendAccountApprovedEmail(
|
||||||
|
request.getEmail(),
|
||||||
|
request.getApplicantName(),
|
||||||
|
accountResponse.getUsername(),
|
||||||
|
accountResponse.getPlainPassword(),
|
||||||
|
review.getAccessStartDate() != null ? review.getAccessStartDate().toString() : null,
|
||||||
|
review.getAccessEndDate() != null ? review.getAccessEndDate().toString() : null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 이메일 발송 (비동기)
|
||||||
|
if (request.getEmail() != null && !request.getEmail().isBlank()) {
|
||||||
|
emailService.sendRequestRejectedEmail(
|
||||||
|
request.getEmail(),
|
||||||
|
request.getApplicantName(),
|
||||||
|
review.getRejectReason()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Bypass API 계정 신청 거절: requestId={}", id);
|
||||||
|
return toResponse(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public BypassRequestResponse reopenRequest(Long id) {
|
||||||
|
BypassApiRequest request = findOrThrow(id);
|
||||||
|
if (request.getStatus() != RequestStatus.REJECTED) {
|
||||||
|
throw new IllegalStateException("거절된 신청만 재심사할 수 있습니다: " + request.getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
request.setStatus(RequestStatus.PENDING);
|
||||||
|
request.setReviewedBy(null);
|
||||||
|
request.setReviewedAt(null);
|
||||||
|
request.setRejectReason(null);
|
||||||
|
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())
|
||||||
|
.projectName(request.getProjectName())
|
||||||
|
.expectedCallVolume(request.getExpectedCallVolume())
|
||||||
|
.serviceIps(request.getServiceIps())
|
||||||
|
.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();
|
||||||
|
}
|
||||||
|
}
|
||||||
84
src/main/java/com/snp/batch/service/EmailService.java
Normal file
84
src/main/java/com/snp/batch/service/EmailService.java
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
package com.snp.batch.service;
|
||||||
|
|
||||||
|
import jakarta.mail.MessagingException;
|
||||||
|
import jakarta.mail.internet.MimeMessage;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.mail.javamail.JavaMailSender;
|
||||||
|
import org.springframework.mail.javamail.MimeMessageHelper;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.thymeleaf.TemplateEngine;
|
||||||
|
import org.thymeleaf.context.Context;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이메일 발송 공통 서비스
|
||||||
|
*
|
||||||
|
* 비동기(@Async)로 발송하여 API 응답 지연을 방지한다.
|
||||||
|
* Thymeleaf 템플릿 엔진으로 HTML 이메일을 구성한다.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class EmailService {
|
||||||
|
|
||||||
|
private final JavaMailSender mailSender;
|
||||||
|
private final TemplateEngine templateEngine;
|
||||||
|
|
||||||
|
@Value("${spring.mail.username:noreply@example.com}")
|
||||||
|
private String fromAddress;
|
||||||
|
|
||||||
|
@Value("${app.base-url:https://guide.gc-si.dev/snp-api}")
|
||||||
|
private String baseUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bypass API 계정 발급 이메일 발송
|
||||||
|
*/
|
||||||
|
@Async
|
||||||
|
public void sendAccountApprovedEmail(String toEmail, String applicantName,
|
||||||
|
String username, String plainPassword,
|
||||||
|
String accessStartDate, String accessEndDate) {
|
||||||
|
Context context = new Context();
|
||||||
|
context.setVariable("applicantName", applicantName);
|
||||||
|
context.setVariable("username", username);
|
||||||
|
context.setVariable("password", plainPassword);
|
||||||
|
context.setVariable("accessStartDate", accessStartDate != null ? accessStartDate : "-");
|
||||||
|
context.setVariable("accessEndDate", accessEndDate != null ? accessEndDate : "-");
|
||||||
|
context.setVariable("swaggerUrl", baseUrl + "/swagger-ui/index.html?urls.primaryName=3.%20Bypass%20API");
|
||||||
|
|
||||||
|
String html = templateEngine.process("email/account-approved", context);
|
||||||
|
sendHtmlEmail(toEmail, "[S&P Data Platform] S&P API 계정이 발급되었습니다", html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bypass API 계정 신청 거절 이메일 발송
|
||||||
|
*/
|
||||||
|
@Async
|
||||||
|
public void sendRequestRejectedEmail(String toEmail, String applicantName,
|
||||||
|
String rejectReason) {
|
||||||
|
Context context = new Context();
|
||||||
|
context.setVariable("applicantName", applicantName);
|
||||||
|
context.setVariable("rejectReason", rejectReason != null && !rejectReason.isBlank()
|
||||||
|
? rejectReason : "별도 안내 없음");
|
||||||
|
context.setVariable("requestUrl", baseUrl + "/bypass-access-request");
|
||||||
|
|
||||||
|
String html = templateEngine.process("email/request-rejected", context);
|
||||||
|
sendHtmlEmail(toEmail, "[S&P Data Platform] S&P API 계정 신청이 거절되었습니다", html);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendHtmlEmail(String to, String subject, String htmlContent) {
|
||||||
|
try {
|
||||||
|
MimeMessage message = mailSender.createMimeMessage();
|
||||||
|
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
|
||||||
|
helper.setFrom(fromAddress);
|
||||||
|
helper.setTo(to);
|
||||||
|
helper.setSubject(subject);
|
||||||
|
helper.setText(htmlContent, true);
|
||||||
|
mailSender.send(message);
|
||||||
|
log.info("이메일 발송 완료: to={}, subject={}", to, subject);
|
||||||
|
} catch (MessagingException e) {
|
||||||
|
log.error("이메일 발송 실패: to={}, subject={}", to, subject, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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:
|
||||||
|
|||||||
@ -38,6 +38,17 @@ spring:
|
|||||||
prefix: classpath:/templates/
|
prefix: classpath:/templates/
|
||||||
suffix: .html
|
suffix: .html
|
||||||
|
|
||||||
|
# Email (SMTP)
|
||||||
|
mail:
|
||||||
|
host: smtp.gmail.com
|
||||||
|
port: 587
|
||||||
|
username: hjkim4@gcsc.co.kr
|
||||||
|
password: khow urga yyxh rciq
|
||||||
|
properties:
|
||||||
|
mail.smtp.auth: true
|
||||||
|
mail.smtp.starttls.enable: true
|
||||||
|
mail.smtp.starttls.required: true
|
||||||
|
|
||||||
# Quartz Scheduler Configuration - Using JDBC Store for persistence
|
# Quartz Scheduler Configuration - Using JDBC Store for persistence
|
||||||
quartz:
|
quartz:
|
||||||
job-store-type: jdbc # JDBC store for schedule persistence
|
job-store-type: jdbc # JDBC store for schedule persistence
|
||||||
@ -70,6 +81,12 @@ management:
|
|||||||
health:
|
health:
|
||||||
show-details: always
|
show-details: always
|
||||||
|
|
||||||
|
# Springdoc / Swagger UI
|
||||||
|
springdoc:
|
||||||
|
swagger-ui:
|
||||||
|
deep-linking: true
|
||||||
|
display-request-duration: true
|
||||||
|
|
||||||
# Logging Configuration (logback-spring.xml에서 상세 설정)
|
# Logging Configuration (logback-spring.xml에서 상세 설정)
|
||||||
logging:
|
logging:
|
||||||
config: classpath:logback-spring.xml
|
config: classpath:logback-spring.xml
|
||||||
|
|||||||
61
src/main/resources/db/schema/bypass_api_account.sql
Normal file
61
src/main/resources/db/schema/bypass_api_account.sql
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
-- 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);
|
||||||
|
|
||||||
|
-- 서비스 IP 관리 테이블
|
||||||
|
CREATE TABLE bypass_api_service_ip (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
account_id BIGINT NOT NULL REFERENCES bypass_api_account(id) ON DELETE CASCADE,
|
||||||
|
ip_address VARCHAR(45) NOT NULL,
|
||||||
|
purpose VARCHAR(50) NOT NULL DEFAULT 'ETC',
|
||||||
|
description VARCHAR(200),
|
||||||
|
created_at TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_bypass_service_ip_account ON bypass_api_service_ip(account_id);
|
||||||
|
|
||||||
|
-- bypass_api_request에 컬럼 추가
|
||||||
|
ALTER TABLE bypass_api_request ADD COLUMN project_name VARCHAR(200);
|
||||||
|
ALTER TABLE bypass_api_request ADD COLUMN expected_call_volume VARCHAR(50);
|
||||||
|
ALTER TABLE bypass_api_request ADD COLUMN service_ips TEXT;
|
||||||
|
|
||||||
|
-- 서비스 IP에 예상 호출량 컬럼 추가
|
||||||
|
ALTER TABLE bypass_api_service_ip ADD COLUMN expected_call_volume VARCHAR(50);
|
||||||
|
|
||||||
|
-- bypass_api_account에 프로젝트명 컬럼 추가
|
||||||
|
ALTER TABLE bypass_api_account ADD COLUMN project_name VARCHAR(200);
|
||||||
63
src/main/resources/templates/email/account-approved.html
Normal file
63
src/main/resources/templates/email/account-approved.html
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns:th="http://www.thymeleaf.org">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0; padding:0; background-color:#f4f4f5; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
||||||
|
<div style="max-width:560px; margin:40px auto; background:#ffffff; border-radius:12px; overflow:hidden; box-shadow:0 1px 3px rgba(0,0,0,0.1);">
|
||||||
|
<!-- Header -->
|
||||||
|
<div style="background:#0f172a; padding:24px 32px;">
|
||||||
|
<h1 style="color:#ffffff; font-size:18px; margin:0;">S&P Data Platform</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<div style="padding:32px;">
|
||||||
|
<p style="font-size:15px; color:#1e293b; margin:0 0 16px;">
|
||||||
|
안녕하세요, <strong th:text="${applicantName}">신청자</strong>님.
|
||||||
|
</p>
|
||||||
|
<p style="font-size:14px; color:#475569; margin:0 0 24px; line-height:1.6;">
|
||||||
|
S&P API 계정 발급 신청이 승인되었습니다.<br/>
|
||||||
|
아래 계정 정보로 API를 사용하실 수 있습니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Credentials box -->
|
||||||
|
<div style="background:#f8fafc; border:1px solid #e2e8f0; border-radius:8px; padding:20px; margin-bottom:24px;">
|
||||||
|
<table style="width:100%; font-size:14px; color:#1e293b;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:6px 0; color:#64748b; width:100px;">사용자명</td>
|
||||||
|
<td style="padding:6px 0; font-family:monospace; font-weight:bold;" th:text="${username}">bypass_xxxxxxxx</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:6px 0; color:#64748b;">비밀번호</td>
|
||||||
|
<td style="padding:6px 0; font-family:monospace; font-weight:bold;" th:text="${password}">xxxxxxxx</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:6px 0; color:#64748b;">유효 기간</td>
|
||||||
|
<td style="padding:6px 0;" th:text="${accessStartDate} + ' ~ ' + ${accessEndDate}">2026-01-01 ~ 2026-12-31</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Warning -->
|
||||||
|
<div style="background:#fef3c7; border:1px solid #fcd34d; border-radius:8px; padding:12px 16px; margin-bottom:24px;">
|
||||||
|
<p style="font-size:12px; color:#92400e; margin:0; line-height:1.5;">
|
||||||
|
이 비밀번호는 이메일로 1회만 안내됩니다. 안전한 곳에 보관해주세요.<br/>
|
||||||
|
비밀번호 분실 시 관리자에게 재설정을 요청하세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="font-size:13px; color:#475569; margin:0; line-height:1.5;">
|
||||||
|
<a th:href="${swaggerUrl}" style="color:#2563eb; text-decoration:none; font-weight:500;">Swagger UI</a>에서
|
||||||
|
[Authorize] 버튼을 클릭하여 위 계정으로 인증 후 사용할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div style="background:#f8fafc; padding:16px 32px; border-top:1px solid #e2e8f0;">
|
||||||
|
<p style="font-size:11px; color:#94a3b8; margin:0;">
|
||||||
|
본 메일은 S&P Data Platform에서 자동 발송되었습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
50
src/main/resources/templates/email/request-rejected.html
Normal file
50
src/main/resources/templates/email/request-rejected.html
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns:th="http://www.thymeleaf.org">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0; padding:0; background-color:#f4f4f5; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
||||||
|
<div style="max-width:560px; margin:40px auto; background:#ffffff; border-radius:12px; overflow:hidden; box-shadow:0 1px 3px rgba(0,0,0,0.1);">
|
||||||
|
<!-- Header -->
|
||||||
|
<div style="background:#0f172a; padding:24px 32px;">
|
||||||
|
<h1 style="color:#ffffff; font-size:18px; margin:0;">S&P Data Platform</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<div style="padding:32px;">
|
||||||
|
<p style="font-size:15px; color:#1e293b; margin:0 0 16px;">
|
||||||
|
안녕하세요, <strong th:text="${applicantName}">신청자</strong>님.
|
||||||
|
</p>
|
||||||
|
<p style="font-size:14px; color:#475569; margin:0 0 24px; line-height:1.6;">
|
||||||
|
S&P API 계정 신청이 거절되었습니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Reason box -->
|
||||||
|
<div style="background:#fef2f2; border:1px solid #fecaca; border-radius:8px; padding:16px; margin-bottom:24px;">
|
||||||
|
<p style="font-size:12px; color:#991b1b; margin:0 0 4px; font-weight:bold;">거절 사유</p>
|
||||||
|
<p style="font-size:14px; color:#1e293b; margin:0; line-height:1.6; white-space:pre-wrap;" th:text="${rejectReason}">거절 사유 내용</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="font-size:13px; color:#475569; margin:0; line-height:1.5;">
|
||||||
|
추가 문의 사항이 있으시면 관리자에게 연락해주세요.<br/>
|
||||||
|
신청서를 수정하여 다시 제출하실 수 있습니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- 신청 바로가기 -->
|
||||||
|
<div style="text-align:center; margin-top:24px;">
|
||||||
|
<a th:href="${requestUrl}"
|
||||||
|
style="display:inline-block; padding:10px 24px; background:#0f172a; color:#ffffff; font-size:13px; font-weight:600; text-decoration:none; border-radius:8px;">
|
||||||
|
API 계정 신청 바로가기
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div style="background:#f8fafc; padding:16px 32px; border-top:1px solid #e2e8f0;">
|
||||||
|
<p style="font-size:11px; color:#94a3b8; margin:0;">
|
||||||
|
본 메일은 S&P Data Platform에서 자동 발송되었습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
불러오는 중...
Reference in New Issue
Block a user