feat(bypass): 계정 신청 시 프로젝트명, 예상 호출량, 서비스 IP 필드 추가 (#152) #156
@ -5,6 +5,7 @@
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### 추가
|
### 추가
|
||||||
|
- 계정 신청 시 프로젝트명, 예상 호출량, 서비스 IP 필드 추가 (#152)
|
||||||
- Bypass API 사용자 계정 발급 신청 프로세스 (#126)
|
- Bypass API 사용자 계정 발급 신청 프로세스 (#126)
|
||||||
- Spring Security Basic Auth 인증 (Bypass 데이터 API)
|
- Spring Security Basic Auth 인증 (Bypass 데이터 API)
|
||||||
- 계정 신청/승인/거절 백엔드 API 및 프론트엔드
|
- 계정 신청/승인/거절 백엔드 API 및 프론트엔드
|
||||||
|
|||||||
@ -3,6 +3,7 @@ export interface BypassAccountResponse {
|
|||||||
username: string;
|
username: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
organization: string | null;
|
organization: string | null;
|
||||||
|
projectName: string | null;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
@ -11,6 +12,7 @@ export interface BypassAccountResponse {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
plainPassword: string | null;
|
plainPassword: string | null;
|
||||||
|
serviceIps: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BypassAccountUpdateRequest {
|
export interface BypassAccountUpdateRequest {
|
||||||
@ -39,6 +41,9 @@ export interface BypassRequestResponse {
|
|||||||
accountUsername: string | null;
|
accountUsername: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
projectName: string | null;
|
||||||
|
expectedCallVolume: string | null;
|
||||||
|
serviceIps: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BypassRequestSubmitRequest {
|
export interface BypassRequestSubmitRequest {
|
||||||
@ -48,6 +53,9 @@ export interface BypassRequestSubmitRequest {
|
|||||||
email: string;
|
email: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
requestedAccessPeriod: string;
|
requestedAccessPeriod: string;
|
||||||
|
projectName: string;
|
||||||
|
expectedCallVolume: string;
|
||||||
|
serviceIps: string; // JSON string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BypassRequestReviewRequest {
|
export interface BypassRequestReviewRequest {
|
||||||
@ -71,6 +79,13 @@ interface ApiResponse<T> {
|
|||||||
data: T;
|
data: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ServiceIpDto {
|
||||||
|
ip: string;
|
||||||
|
purpose: string;
|
||||||
|
expectedCallVolume: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
// BASE URL
|
// BASE URL
|
||||||
const BASE = '/snp-api/api/bypass-account';
|
const BASE = '/snp-api/api/bypass-account';
|
||||||
|
|
||||||
@ -122,6 +137,12 @@ export const bypassAccountApi = {
|
|||||||
deleteJson<ApiResponse<void>>(`${BASE}/accounts/${id}`),
|
deleteJson<ApiResponse<void>>(`${BASE}/accounts/${id}`),
|
||||||
resetPassword: (id: number) =>
|
resetPassword: (id: number) =>
|
||||||
postJson<ApiResponse<BypassAccountResponse>>(`${BASE}/accounts/${id}/reset-password`, {}),
|
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
|
// Requests
|
||||||
submitRequest: (data: BypassRequestSubmitRequest) =>
|
submitRequest: (data: BypassRequestSubmitRequest) =>
|
||||||
|
|||||||
@ -12,18 +12,27 @@ interface FormState {
|
|||||||
purpose: string;
|
purpose: string;
|
||||||
email: string;
|
email: string;
|
||||||
requestedAccessPeriod: string;
|
requestedAccessPeriod: string;
|
||||||
|
projectName: string;
|
||||||
|
serviceIp: string;
|
||||||
|
servicePurpose: string;
|
||||||
|
expectedCallVolume: string;
|
||||||
|
serviceDescription: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ErrorState {
|
interface ErrorState {
|
||||||
applicantName?: string;
|
applicantName?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
requestedAccessPeriod?: string;
|
requestedAccessPeriod?: string;
|
||||||
|
projectName?: string;
|
||||||
|
serviceIp?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TouchedState {
|
interface TouchedState {
|
||||||
applicantName: boolean;
|
applicantName: boolean;
|
||||||
email: boolean;
|
email: boolean;
|
||||||
requestedAccessPeriod: boolean;
|
requestedAccessPeriod: boolean;
|
||||||
|
projectName: boolean;
|
||||||
|
serviceIp: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type PeriodPreset = '3개월' | '6개월' | '9개월' | '1년';
|
type PeriodPreset = '3개월' | '6개월' | '9개월' | '1년';
|
||||||
@ -34,12 +43,19 @@ const INITIAL_FORM: FormState = {
|
|||||||
purpose: '',
|
purpose: '',
|
||||||
email: '',
|
email: '',
|
||||||
requestedAccessPeriod: '',
|
requestedAccessPeriod: '',
|
||||||
|
projectName: '',
|
||||||
|
serviceIp: '',
|
||||||
|
servicePurpose: 'DEV_PC',
|
||||||
|
expectedCallVolume: 'LOW',
|
||||||
|
serviceDescription: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
const INITIAL_TOUCHED: TouchedState = {
|
const INITIAL_TOUCHED: TouchedState = {
|
||||||
applicantName: false,
|
applicantName: false,
|
||||||
email: false,
|
email: false,
|
||||||
requestedAccessPeriod: false,
|
requestedAccessPeriod: false,
|
||||||
|
projectName: false,
|
||||||
|
serviceIp: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
@ -93,6 +109,14 @@ function validateForm(form: FormState): ErrorState {
|
|||||||
errors.requestedAccessPeriod = '사용 기간을 선택해주세요.';
|
errors.requestedAccessPeriod = '사용 기간을 선택해주세요.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!form.projectName.trim()) {
|
||||||
|
errors.projectName = '프로젝트/서비스명을 입력해주세요.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.serviceIp.trim()) {
|
||||||
|
errors.serviceIp = '서비스 IP를 입력해주세요.';
|
||||||
|
}
|
||||||
|
|
||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -178,7 +202,7 @@ export default function BypassAccessRequest() {
|
|||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSubmitAttempted(true);
|
setSubmitAttempted(true);
|
||||||
setTouched({ applicantName: true, email: true, requestedAccessPeriod: true });
|
setTouched({ applicantName: true, email: true, requestedAccessPeriod: true, projectName: true, serviceIp: true });
|
||||||
|
|
||||||
const currentErrors = validateForm(form);
|
const currentErrors = validateForm(form);
|
||||||
|
|
||||||
@ -197,6 +221,12 @@ export default function BypassAccessRequest() {
|
|||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
|
const serviceIpEntry = form.serviceIp.trim() ? [{
|
||||||
|
ip: form.serviceIp.trim(),
|
||||||
|
purpose: form.servicePurpose,
|
||||||
|
expectedCallVolume: form.expectedCallVolume,
|
||||||
|
description: form.serviceDescription,
|
||||||
|
}] : [];
|
||||||
const requestData: BypassRequestSubmitRequest = {
|
const requestData: BypassRequestSubmitRequest = {
|
||||||
applicantName: form.applicantName,
|
applicantName: form.applicantName,
|
||||||
organization: form.organization,
|
organization: form.organization,
|
||||||
@ -204,6 +234,9 @@ export default function BypassAccessRequest() {
|
|||||||
email: form.email,
|
email: form.email,
|
||||||
phone: '',
|
phone: '',
|
||||||
requestedAccessPeriod: form.requestedAccessPeriod,
|
requestedAccessPeriod: form.requestedAccessPeriod,
|
||||||
|
projectName: form.projectName,
|
||||||
|
expectedCallVolume: form.expectedCallVolume,
|
||||||
|
serviceIps: JSON.stringify(serviceIpEntry),
|
||||||
};
|
};
|
||||||
const res = await bypassAccountApi.submitRequest(requestData);
|
const res = await bypassAccountApi.submitRequest(requestData);
|
||||||
setSubmittedId(res.data.id);
|
setSubmittedId(res.data.id);
|
||||||
@ -284,7 +317,7 @@ export default function BypassAccessRequest() {
|
|||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="bg-wing-surface rounded-xl shadow-md p-6">
|
<div className="bg-wing-surface rounded-xl shadow-md p-6">
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{/* 신청자명 + 기관 */}
|
{/* Row 1: 신청자명 + 기관 */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div id="field-applicantName">
|
<div id="field-applicantName">
|
||||||
<label className="block text-sm font-medium text-wing-text mb-1">
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
@ -318,7 +351,7 @@ export default function BypassAccessRequest() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 이메일 + 사용 기간 */}
|
{/* Row 2: 이메일 + 프로젝트/서비스명 */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div id="field-email">
|
<div id="field-email">
|
||||||
<label className="block text-sm font-medium text-wing-text mb-1">
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
@ -337,87 +370,166 @@ export default function BypassAccessRequest() {
|
|||||||
<p className="mt-1 text-xs text-red-500">{errors.email}</p>
|
<p className="mt-1 text-xs text-red-500">{errors.email}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div id="field-requestedAccessPeriod">
|
<div id="field-projectName">
|
||||||
<div className="flex items-center justify-between mb-1">
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
<label className="text-sm font-medium text-wing-text">
|
프로젝트/서비스명 <span className="text-red-500">*</span>
|
||||||
사용 기간 <span className="text-red-500">*</span>
|
</label>
|
||||||
</label>
|
<input
|
||||||
<button
|
type="text"
|
||||||
type="button"
|
value={form.projectName}
|
||||||
onClick={handleToggleManual}
|
onChange={(e) => handleChange('projectName', e.target.value)}
|
||||||
className="flex items-center gap-2 text-xs text-wing-muted hover:text-wing-text transition-colors"
|
onBlur={() => handleBlur('projectName')}
|
||||||
>
|
placeholder="사용할 프로젝트 또는 서비스명"
|
||||||
<span>직접 선택</span>
|
disabled={submitting}
|
||||||
<span
|
className={inputClass('projectName')}
|
||||||
className={`relative inline-flex h-4 w-8 items-center rounded-full transition-colors ${
|
/>
|
||||||
periodManual ? 'bg-wing-accent' : 'bg-wing-border'
|
{showError('projectName') && (
|
||||||
}`}
|
<p className="mt-1 text-xs text-red-500">{errors.projectName}</p>
|
||||||
>
|
|
||||||
<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>
|
</div>
|
||||||
</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>
|
<div>
|
||||||
<label className="block text-sm font-medium text-wing-text mb-1">
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
사용 목적
|
사용 목적
|
||||||
|
|||||||
@ -196,30 +196,14 @@ export default function BypassAccountManagement() {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-wing-border bg-wing-card">
|
<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 className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">상태</th>
|
||||||
Username
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">Username</th>
|
||||||
</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 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>
|
<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 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>
|
||||||
</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-wing-border">
|
<tbody className="divide-y divide-wing-border">
|
||||||
@ -232,18 +216,6 @@ export default function BypassAccountManagement() {
|
|||||||
) : (
|
) : (
|
||||||
accounts.map((account) => (
|
accounts.map((account) => (
|
||||||
<tr key={account.id} className="hover:bg-wing-hover transition-colors">
|
<tr key={account.id} className="hover:bg-wing-hover transition-colors">
|
||||||
<td className="px-4 py-3 font-mono text-xs text-wing-text">
|
|
||||||
{account.username}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 font-medium text-wing-text">
|
|
||||||
{account.displayName}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-xs text-wing-muted">
|
|
||||||
{account.organization ?? '-'}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-xs text-wing-muted">
|
|
||||||
{account.email ?? '-'}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span
|
<span
|
||||||
className={[
|
className={[
|
||||||
@ -254,16 +226,28 @@ export default function BypassAccountManagement() {
|
|||||||
{account.status}
|
{account.status}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-xs text-wing-muted whitespace-nowrap">
|
<td className="px-4 py-3 font-mono text-xs text-wing-text">
|
||||||
{account.accessStartDate && account.accessEndDate
|
{account.username}
|
||||||
? `${account.accessStartDate} ~ ${account.accessEndDate}`
|
</td>
|
||||||
: account.accessStartDate ?? '-'}
|
<td className="px-4 py-3 font-medium text-wing-text">
|
||||||
|
{account.displayName}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-xs text-wing-muted whitespace-nowrap">
|
<td className="px-4 py-3 text-xs text-wing-muted whitespace-nowrap">
|
||||||
{account.createdAt
|
{account.createdAt
|
||||||
? new Date(account.createdAt).toLocaleDateString('ko-KR')
|
? new Date(account.createdAt).toLocaleDateString('ko-KR')
|
||||||
: '-'}
|
: '-'}
|
||||||
</td>
|
</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">
|
<td className="px-4 py-3">
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
@ -323,8 +307,8 @@ export default function BypassAccountManagement() {
|
|||||||
<p className="text-sm text-wing-muted mb-4 font-mono">{editTarget.username}</p>
|
<p className="text-sm text-wing-muted mb-4 font-mono">{editTarget.username}</p>
|
||||||
|
|
||||||
{/* 신청자 정보 (읽기 전용) */}
|
{/* 신청자 정보 (읽기 전용) */}
|
||||||
<div className="bg-wing-card rounded-lg p-3 border border-wing-border mb-4">
|
<div className="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 text-xs">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-wing-muted">표시명: </span>
|
<span className="text-wing-muted">표시명: </span>
|
||||||
<span className="text-wing-text font-medium">{editTarget.displayName}</span>
|
<span className="text-wing-text font-medium">{editTarget.displayName}</span>
|
||||||
@ -333,11 +317,33 @@ export default function BypassAccountManagement() {
|
|||||||
<span className="text-wing-muted">기관: </span>
|
<span className="text-wing-muted">기관: </span>
|
||||||
<span className="text-wing-text">{editTarget.organization ?? '-'}</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>
|
|
||||||
</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>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@ -355,7 +361,7 @@ export default function BypassAccountManagement() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-wing-text mb-1">접근 시작일</label>
|
<label className="block text-sm font-medium text-wing-text mb-1">사용 시작일</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={editForm.accessStartDate}
|
value={editForm.accessStartDate}
|
||||||
@ -364,7 +370,7 @@ export default function BypassAccountManagement() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-wing-text mb-1">접근 종료일</label>
|
<label className="block text-sm font-medium text-wing-text mb-1">사용 종료일</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={editForm.accessEndDate}
|
value={editForm.accessEndDate}
|
||||||
|
|||||||
@ -98,7 +98,7 @@ export default function BypassAccountRequests() {
|
|||||||
const handleApproveSubmit = async () => {
|
const handleApproveSubmit = async () => {
|
||||||
if (!approveTarget) return;
|
if (!approveTarget) return;
|
||||||
if (!approveForm.reviewedBy.trim()) {
|
if (!approveForm.reviewedBy.trim()) {
|
||||||
showToast('검토자명을 입력해주세요.', 'error');
|
showToast('승인자을 입력해주세요.', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setApproveSubmitting(true);
|
setApproveSubmitting(true);
|
||||||
@ -123,7 +123,7 @@ export default function BypassAccountRequests() {
|
|||||||
const handleRejectSubmit = async () => {
|
const handleRejectSubmit = async () => {
|
||||||
if (!rejectTarget) return;
|
if (!rejectTarget) return;
|
||||||
if (!rejectForm.reviewedBy.trim()) {
|
if (!rejectForm.reviewedBy.trim()) {
|
||||||
showToast('검토자명을 입력해주세요.', 'error');
|
showToast('승인자을 입력해주세요.', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setRejectSubmitting(true);
|
setRejectSubmitting(true);
|
||||||
@ -189,14 +189,13 @@ export default function BypassAccountRequests() {
|
|||||||
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">요청 기간</th>
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">요청 기간</th>
|
||||||
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">기관</th>
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">기관</th>
|
||||||
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">이메일</th>
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">이메일</th>
|
||||||
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">신청 사유</th>
|
|
||||||
<th className="text-right px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">액션</th>
|
<th className="text-right px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">액션</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-wing-border">
|
<tbody className="divide-y divide-wing-border">
|
||||||
{requests.length === 0 ? (
|
{requests.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="px-4 py-12 text-center text-wing-muted text-sm">
|
<td colSpan={7} className="px-4 py-12 text-center text-wing-muted text-sm">
|
||||||
신청 내역이 없습니다.
|
신청 내역이 없습니다.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -230,11 +229,6 @@ export default function BypassAccountRequests() {
|
|||||||
<td className="px-4 py-3 text-xs text-wing-muted">
|
<td className="px-4 py-3 text-xs text-wing-muted">
|
||||||
{req.email ?? '-'}
|
{req.email ?? '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-xs text-wing-muted max-w-[200px]">
|
|
||||||
<span className="truncate block" title={req.purpose ?? ''}>
|
|
||||||
{req.purpose ?? '-'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<button
|
<button
|
||||||
@ -279,8 +273,8 @@ export default function BypassAccountRequests() {
|
|||||||
<h3 className="text-lg font-semibold text-wing-text mb-4">신청 승인</h3>
|
<h3 className="text-lg font-semibold text-wing-text mb-4">신청 승인</h3>
|
||||||
|
|
||||||
{/* 신청 상세 정보 */}
|
{/* 신청 상세 정보 */}
|
||||||
<div className="bg-wing-card rounded-lg p-3 border border-wing-border mb-4">
|
<div className="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 text-xs">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-wing-muted">신청자명: </span>
|
<span className="text-wing-muted">신청자명: </span>
|
||||||
<span className="text-wing-text font-medium">{approveTarget.applicantName}</span>
|
<span className="text-wing-text font-medium">{approveTarget.applicantName}</span>
|
||||||
@ -289,19 +283,47 @@ export default function BypassAccountRequests() {
|
|||||||
<span className="text-wing-muted">기관: </span>
|
<span className="text-wing-muted">기관: </span>
|
||||||
<span className="text-wing-text">{approveTarget.organization ?? '-'}</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>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-xs">
|
<div>
|
||||||
<span className="text-wing-muted">요청 기간: </span>
|
<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>
|
<span className="text-wing-text">{approveTarget.requestedAccessPeriod ?? '-'}</span>
|
||||||
</div>
|
</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 && (
|
{approveTarget.purpose && (
|
||||||
<div className="mt-2 pt-2 border-t border-wing-border text-xs">
|
<div>
|
||||||
<span className="text-wing-muted">신청 사유: </span>
|
<div className="text-wing-muted mb-1">신청 사유</div>
|
||||||
<span className="text-wing-text">{approveTarget.purpose}</span>
|
<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>
|
</div>
|
||||||
@ -309,7 +331,7 @@ export default function BypassAccountRequests() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-wing-text mb-1">
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
검토자명 <span className="text-red-500">*</span>
|
승인자 <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -322,7 +344,7 @@ export default function BypassAccountRequests() {
|
|||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-wing-text mb-1">
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
접근 시작일
|
사용 시작일
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
@ -333,7 +355,7 @@ export default function BypassAccountRequests() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-wing-text mb-1">
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
접근 종료일
|
사용 종료일
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
@ -378,8 +400,8 @@ export default function BypassAccountRequests() {
|
|||||||
<h3 className="text-lg font-semibold text-wing-text mb-4">신청 거절</h3>
|
<h3 className="text-lg font-semibold text-wing-text mb-4">신청 거절</h3>
|
||||||
|
|
||||||
{/* 신청 상세 정보 */}
|
{/* 신청 상세 정보 */}
|
||||||
<div className="bg-wing-card rounded-lg p-3 border border-wing-border mb-4">
|
<div className="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 text-xs">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-wing-muted">신청자명: </span>
|
<span className="text-wing-muted">신청자명: </span>
|
||||||
<span className="text-wing-text font-medium">{rejectTarget.applicantName}</span>
|
<span className="text-wing-text font-medium">{rejectTarget.applicantName}</span>
|
||||||
@ -388,19 +410,47 @@ export default function BypassAccountRequests() {
|
|||||||
<span className="text-wing-muted">기관: </span>
|
<span className="text-wing-muted">기관: </span>
|
||||||
<span className="text-wing-text">{rejectTarget.organization ?? '-'}</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>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-xs">
|
<div>
|
||||||
<span className="text-wing-muted">요청 기간: </span>
|
<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>
|
<span className="text-wing-text">{rejectTarget.requestedAccessPeriod ?? '-'}</span>
|
||||||
</div>
|
</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 && (
|
{rejectTarget.purpose && (
|
||||||
<div className="mt-2 pt-2 border-t border-wing-border text-xs">
|
<div>
|
||||||
<span className="text-wing-muted">신청 사유: </span>
|
<div className="text-wing-muted mb-1">신청 사유</div>
|
||||||
<span className="text-wing-text">{rejectTarget.purpose}</span>
|
<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>
|
</div>
|
||||||
@ -408,7 +458,7 @@ export default function BypassAccountRequests() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-wing-text mb-1">
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
검토자명 <span className="text-red-500">*</span>
|
승인자 <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -477,11 +527,37 @@ export default function BypassAccountRequests() {
|
|||||||
<div className="text-xs text-wing-muted mb-0.5">이메일</div>
|
<div className="text-xs text-wing-muted mb-0.5">이메일</div>
|
||||||
<div className="text-wing-text">{detailTarget.email ?? '-'}</div>
|
<div className="text-wing-text">{detailTarget.email ?? '-'}</div>
|
||||||
</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>
|
<div>
|
||||||
<div className="text-xs text-wing-muted mb-0.5">요청 접근 기간</div>
|
<div className="text-xs text-wing-muted mb-0.5">신청 사용 기간</div>
|
||||||
<div className="text-wing-text">{detailTarget.requestedAccessPeriod ?? '-'}</div>
|
<div className="text-wing-text">{detailTarget.requestedAccessPeriod ?? '-'}</div>
|
||||||
</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>
|
||||||
<div className="text-xs text-wing-muted mb-0.5">신청 사유</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">
|
<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">
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import com.snp.batch.global.dto.bypass.BypassAccountUpdateRequest;
|
|||||||
import com.snp.batch.global.dto.bypass.BypassRequestReviewRequest;
|
import com.snp.batch.global.dto.bypass.BypassRequestReviewRequest;
|
||||||
import com.snp.batch.global.dto.bypass.BypassRequestResponse;
|
import com.snp.batch.global.dto.bypass.BypassRequestResponse;
|
||||||
import com.snp.batch.global.dto.bypass.BypassRequestSubmitRequest;
|
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.BypassApiAccountService;
|
||||||
import com.snp.batch.service.BypassApiRequestService;
|
import com.snp.batch.service.BypassApiRequestService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
|||||||
@ -19,6 +19,7 @@ public class BypassAccountResponse {
|
|||||||
private String username;
|
private String username;
|
||||||
private String displayName;
|
private String displayName;
|
||||||
private String organization;
|
private String organization;
|
||||||
|
private String projectName;
|
||||||
private String email;
|
private String email;
|
||||||
private String phone;
|
private String phone;
|
||||||
private String status;
|
private String status;
|
||||||
@ -27,4 +28,5 @@ public class BypassAccountResponse {
|
|||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
private String plainPassword;
|
private String plainPassword;
|
||||||
|
private String serviceIps; // JSON string of registered IPs
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,9 @@ public class BypassRequestResponse {
|
|||||||
private String email;
|
private String email;
|
||||||
private String phone;
|
private String phone;
|
||||||
private String requestedAccessPeriod;
|
private String requestedAccessPeriod;
|
||||||
|
private String projectName;
|
||||||
|
private String expectedCallVolume;
|
||||||
|
private String serviceIps;
|
||||||
private String status;
|
private String status;
|
||||||
private String reviewedBy;
|
private String reviewedBy;
|
||||||
private LocalDateTime reviewedAt;
|
private LocalDateTime reviewedAt;
|
||||||
|
|||||||
@ -16,4 +16,7 @@ public class BypassRequestSubmitRequest {
|
|||||||
private String email;
|
private String email;
|
||||||
private String phone;
|
private String phone;
|
||||||
private String requestedAccessPeriod;
|
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;
|
||||||
|
}
|
||||||
@ -54,6 +54,9 @@ public class BypassApiAccount {
|
|||||||
@Column(name = "organization", length = 200)
|
@Column(name = "organization", length = 200)
|
||||||
private String organization;
|
private String organization;
|
||||||
|
|
||||||
|
@Column(name = "project_name", length = 200)
|
||||||
|
private String projectName;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이메일
|
* 이메일
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -64,6 +64,24 @@ public class BypassApiRequest {
|
|||||||
@Column(name = "requested_access_period", length = 100)
|
@Column(name = "requested_access_period", length = 100)
|
||||||
private String requestedAccessPeriod;
|
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)
|
* 신청 상태 (PENDING, APPROVED, REJECTED)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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,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);
|
||||||
|
}
|
||||||
@ -1,10 +1,14 @@
|
|||||||
package com.snp.batch.service;
|
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.BypassAccountResponse;
|
||||||
import com.snp.batch.global.dto.bypass.BypassAccountUpdateRequest;
|
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.AccountStatus;
|
||||||
import com.snp.batch.global.model.BypassApiAccount;
|
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.BypassApiAccountRepository;
|
||||||
|
import com.snp.batch.global.repository.BypassApiServiceIpRepository;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
@ -16,6 +20,7 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
|
|
||||||
import java.security.SecureRandom;
|
import java.security.SecureRandom;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@ -30,10 +35,12 @@ public class BypassApiAccountService {
|
|||||||
private static final SecureRandom RANDOM = new SecureRandom();
|
private static final SecureRandom RANDOM = new SecureRandom();
|
||||||
|
|
||||||
private final BypassApiAccountRepository accountRepository;
|
private final BypassApiAccountRepository accountRepository;
|
||||||
|
private final BypassApiServiceIpRepository serviceIpRepository;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public BypassAccountResponse createAccount(String displayName, String organization,
|
public BypassAccountResponse createAccount(String displayName, String organization,
|
||||||
|
String projectName,
|
||||||
String email, String phone,
|
String email, String phone,
|
||||||
LocalDate accessStartDate, LocalDate accessEndDate) {
|
LocalDate accessStartDate, LocalDate accessEndDate) {
|
||||||
String rawUsername = generateUsername();
|
String rawUsername = generateUsername();
|
||||||
@ -44,6 +51,7 @@ public class BypassApiAccountService {
|
|||||||
.passwordHash(passwordEncoder.encode(rawPassword))
|
.passwordHash(passwordEncoder.encode(rawPassword))
|
||||||
.displayName(displayName)
|
.displayName(displayName)
|
||||||
.organization(organization)
|
.organization(organization)
|
||||||
|
.projectName(projectName)
|
||||||
.email(email)
|
.email(email)
|
||||||
.phone(phone)
|
.phone(phone)
|
||||||
.status(AccountStatus.ACTIVE)
|
.status(AccountStatus.ACTIVE)
|
||||||
@ -130,12 +138,68 @@ public class BypassApiAccountService {
|
|||||||
return sb.toString();
|
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) {
|
private BypassAccountResponse toResponse(BypassApiAccount account, String plainPassword) {
|
||||||
return BypassAccountResponse.builder()
|
return BypassAccountResponse.builder()
|
||||||
.id(account.getId())
|
.id(account.getId())
|
||||||
.username(account.getUsername())
|
.username(account.getUsername())
|
||||||
.displayName(account.getDisplayName())
|
.displayName(account.getDisplayName())
|
||||||
.organization(account.getOrganization())
|
.organization(account.getOrganization())
|
||||||
|
.projectName(account.getProjectName())
|
||||||
.email(account.getEmail())
|
.email(account.getEmail())
|
||||||
.phone(account.getPhone())
|
.phone(account.getPhone())
|
||||||
.status(account.getStatus().name())
|
.status(account.getStatus().name())
|
||||||
@ -144,6 +208,7 @@ public class BypassApiAccountService {
|
|||||||
.createdAt(account.getCreatedAt())
|
.createdAt(account.getCreatedAt())
|
||||||
.updatedAt(account.getUpdatedAt())
|
.updatedAt(account.getUpdatedAt())
|
||||||
.plainPassword(plainPassword)
|
.plainPassword(plainPassword)
|
||||||
|
.serviceIps(account.getId() != null ? getServiceIpsJson(account.getId()) : null)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,13 +1,18 @@
|
|||||||
package com.snp.batch.service;
|
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.BypassAccountResponse;
|
||||||
import com.snp.batch.global.dto.bypass.BypassRequestReviewRequest;
|
import com.snp.batch.global.dto.bypass.BypassRequestReviewRequest;
|
||||||
import com.snp.batch.global.dto.bypass.BypassRequestResponse;
|
import com.snp.batch.global.dto.bypass.BypassRequestResponse;
|
||||||
import com.snp.batch.global.dto.bypass.BypassRequestSubmitRequest;
|
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.BypassApiRequest;
|
||||||
|
import com.snp.batch.global.model.BypassApiServiceIp;
|
||||||
import com.snp.batch.global.model.RequestStatus;
|
import com.snp.batch.global.model.RequestStatus;
|
||||||
import com.snp.batch.global.repository.BypassApiAccountRepository;
|
import com.snp.batch.global.repository.BypassApiAccountRepository;
|
||||||
import com.snp.batch.global.repository.BypassApiRequestRepository;
|
import com.snp.batch.global.repository.BypassApiRequestRepository;
|
||||||
|
import com.snp.batch.global.repository.BypassApiServiceIpRepository;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
@ -17,6 +22,7 @@ import org.springframework.stereotype.Service;
|
|||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@ -25,6 +31,7 @@ public class BypassApiRequestService {
|
|||||||
|
|
||||||
private final BypassApiRequestRepository requestRepository;
|
private final BypassApiRequestRepository requestRepository;
|
||||||
private final BypassApiAccountRepository accountRepository;
|
private final BypassApiAccountRepository accountRepository;
|
||||||
|
private final BypassApiServiceIpRepository serviceIpRepository;
|
||||||
private final BypassApiAccountService accountService;
|
private final BypassApiAccountService accountService;
|
||||||
private final EmailService emailService;
|
private final EmailService emailService;
|
||||||
|
|
||||||
@ -37,6 +44,9 @@ public class BypassApiRequestService {
|
|||||||
.email(request.getEmail())
|
.email(request.getEmail())
|
||||||
.phone(request.getPhone())
|
.phone(request.getPhone())
|
||||||
.requestedAccessPeriod(request.getRequestedAccessPeriod())
|
.requestedAccessPeriod(request.getRequestedAccessPeriod())
|
||||||
|
.projectName(request.getProjectName())
|
||||||
|
.expectedCallVolume(request.getExpectedCallVolume())
|
||||||
|
.serviceIps(request.getServiceIps())
|
||||||
.build();
|
.build();
|
||||||
BypassApiRequest saved = requestRepository.save(entity);
|
BypassApiRequest saved = requestRepository.save(entity);
|
||||||
log.info("Bypass API 계정 신청 접수: applicant={}", request.getApplicantName());
|
log.info("Bypass API 계정 신청 접수: applicant={}", request.getApplicantName());
|
||||||
@ -71,6 +81,7 @@ public class BypassApiRequestService {
|
|||||||
BypassAccountResponse accountResponse = accountService.createAccount(
|
BypassAccountResponse accountResponse = accountService.createAccount(
|
||||||
request.getApplicantName(),
|
request.getApplicantName(),
|
||||||
request.getOrganization(),
|
request.getOrganization(),
|
||||||
|
request.getProjectName(),
|
||||||
request.getEmail(),
|
request.getEmail(),
|
||||||
request.getPhone(),
|
request.getPhone(),
|
||||||
review.getAccessStartDate(),
|
review.getAccessStartDate(),
|
||||||
@ -80,9 +91,30 @@ public class BypassApiRequestService {
|
|||||||
request.setStatus(RequestStatus.APPROVED);
|
request.setStatus(RequestStatus.APPROVED);
|
||||||
request.setReviewedBy(review.getReviewedBy());
|
request.setReviewedBy(review.getReviewedBy());
|
||||||
request.setReviewedAt(LocalDateTime.now());
|
request.setReviewedAt(LocalDateTime.now());
|
||||||
request.setAccount(accountRepository.getReferenceById(accountResponse.getId()));
|
BypassApiAccount accountEntity = accountRepository.getReferenceById(accountResponse.getId());
|
||||||
|
request.setAccount(accountEntity);
|
||||||
requestRepository.save(request);
|
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()) {
|
if (request.getEmail() != null && !request.getEmail().isBlank()) {
|
||||||
emailService.sendAccountApprovedEmail(
|
emailService.sendAccountApprovedEmail(
|
||||||
@ -157,6 +189,9 @@ public class BypassApiRequestService {
|
|||||||
.email(request.getEmail())
|
.email(request.getEmail())
|
||||||
.phone(request.getPhone())
|
.phone(request.getPhone())
|
||||||
.requestedAccessPeriod(request.getRequestedAccessPeriod())
|
.requestedAccessPeriod(request.getRequestedAccessPeriod())
|
||||||
|
.projectName(request.getProjectName())
|
||||||
|
.expectedCallVolume(request.getExpectedCallVolume())
|
||||||
|
.serviceIps(request.getServiceIps())
|
||||||
.status(request.getStatus().name())
|
.status(request.getStatus().name())
|
||||||
.reviewedBy(request.getReviewedBy())
|
.reviewedBy(request.getReviewedBy())
|
||||||
.reviewedAt(request.getReviewedAt())
|
.reviewedAt(request.getReviewedAt())
|
||||||
|
|||||||
@ -36,3 +36,26 @@ CREATE TABLE bypass_api_request (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_bypass_request_status ON bypass_api_request(status);
|
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);
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user