generated from gc/template-java-maven
Merge pull request 'feat(ui): 서비스 API 분류 + API Key 관리 UI 전면 개선' (#32) from feature/ISSUE-31-api-registration into develop
This commit is contained in:
커밋
5a3ddac2b8
@ -6,6 +6,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/ko/1.0.0/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
|
||||||
|
- 서비스 API 도메인 분류 (apiDomain 컬럼) (#31)
|
||||||
|
- API Key 신청: 아코디언+테이블 계층형 API 선택 + 검색 (#31)
|
||||||
|
- API Key 검토: API 권한 편집 (체크박스 토글, API 추가 모달) (#31)
|
||||||
|
- API Key 관리: KPI 카드, 필터 칩, 검색, 15건 페이징 (#31)
|
||||||
|
- 키 상세: 상태별 색상 헤더, API Key 보기/숨기기+복사 (#31)
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
|
||||||
|
- API Key 관리 UI 전면 개선 (레퍼런스 디자인 적용) (#31)
|
||||||
|
- 검토 모달: 탭 분리, EditableDateCard, 승인확인 변경테이블 (#31)
|
||||||
|
- 상세 모달: 검토 모달 readOnly 재사용 (#31)
|
||||||
|
- 테이블 필드 한글화, 순서 변경, 소유자(userName) 추가 (#31)
|
||||||
|
|
||||||
## [2026-04-09]
|
## [2026-04-09]
|
||||||
|
|
||||||
### 추가
|
### 추가
|
||||||
|
|||||||
@ -91,6 +91,8 @@ CREATE TABLE IF NOT EXISTS snp_service_api (
|
|||||||
api_path VARCHAR(500) NOT NULL,
|
api_path VARCHAR(500) NOT NULL,
|
||||||
api_method VARCHAR(10) NOT NULL,
|
api_method VARCHAR(10) NOT NULL,
|
||||||
api_name VARCHAR(200) NOT NULL,
|
api_name VARCHAR(200) NOT NULL,
|
||||||
|
api_domain VARCHAR(100),
|
||||||
|
api_section VARCHAR(100),
|
||||||
description TEXT,
|
description TEXT,
|
||||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
|||||||
@ -61,6 +61,8 @@ const ServicesPage = () => {
|
|||||||
const [apiMethod, setApiMethod] = useState('GET');
|
const [apiMethod, setApiMethod] = useState('GET');
|
||||||
const [apiPath, setApiPath] = useState('');
|
const [apiPath, setApiPath] = useState('');
|
||||||
const [apiName, setApiName] = useState('');
|
const [apiName, setApiName] = useState('');
|
||||||
|
const [apiDomain, setApiDomain] = useState('');
|
||||||
|
const [apiSection, setApiSection] = useState('');
|
||||||
const [apiDescription, setApiDescription] = useState('');
|
const [apiDescription, setApiDescription] = useState('');
|
||||||
|
|
||||||
const fetchServices = async () => {
|
const fetchServices = async () => {
|
||||||
@ -174,6 +176,8 @@ const ServicesPage = () => {
|
|||||||
setApiMethod('GET');
|
setApiMethod('GET');
|
||||||
setApiPath('');
|
setApiPath('');
|
||||||
setApiName('');
|
setApiName('');
|
||||||
|
setApiDomain('');
|
||||||
|
setApiSection('');
|
||||||
setApiDescription('');
|
setApiDescription('');
|
||||||
setIsApiModalOpen(true);
|
setIsApiModalOpen(true);
|
||||||
};
|
};
|
||||||
@ -193,6 +197,8 @@ const ServicesPage = () => {
|
|||||||
apiMethod,
|
apiMethod,
|
||||||
apiPath,
|
apiPath,
|
||||||
apiName,
|
apiName,
|
||||||
|
apiDomain: apiDomain || undefined,
|
||||||
|
apiSection: apiSection || undefined,
|
||||||
description: apiDescription || undefined,
|
description: apiDescription || undefined,
|
||||||
};
|
};
|
||||||
const res = await createServiceApi(selectedService.serviceId, req);
|
const res = await createServiceApi(selectedService.serviceId, req);
|
||||||
@ -537,6 +543,28 @@ const ServicesPage = () => {
|
|||||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">도메인</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={apiDomain}
|
||||||
|
onChange={(e) => setApiDomain(e.target.value)}
|
||||||
|
placeholder="예: Compliance"
|
||||||
|
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">섹션</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={apiSection}
|
||||||
|
onChange={(e) => setApiSection(e.target.value)}
|
||||||
|
placeholder="예: 선박 규정"
|
||||||
|
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
Description
|
Description
|
||||||
|
|||||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -1,14 +1,47 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import type { ServiceInfo, ServiceApi } from '../../types/service';
|
import type { ServiceInfo, ServiceApi } from '../../types/service';
|
||||||
import { getServices, getServiceApis } from '../../services/serviceService';
|
import { getServices, getServiceApis } from '../../services/serviceService';
|
||||||
import { createKeyRequest } from '../../services/apiKeyService';
|
import { createKeyRequest } from '../../services/apiKeyService';
|
||||||
|
|
||||||
const METHOD_COLOR: Record<string, string> = {
|
const METHOD_BADGE_STYLE: Record<string, string> = {
|
||||||
GET: 'bg-green-100 text-green-800',
|
GET: 'bg-emerald-900/80 text-emerald-300 border border-emerald-700/50',
|
||||||
POST: 'bg-blue-100 text-blue-800',
|
POST: 'bg-amber-900/80 text-amber-300 border border-amber-700/50',
|
||||||
PUT: 'bg-orange-100 text-orange-800',
|
PUT: 'bg-blue-900/80 text-blue-300 border border-blue-700/50',
|
||||||
DELETE: 'bg-red-100 text-red-800',
|
DELETE: 'bg-red-900/80 text-red-300 border border-red-700/50',
|
||||||
|
PATCH: 'bg-purple-900/80 text-purple-300 border border-purple-700/50',
|
||||||
|
};
|
||||||
|
|
||||||
|
const IndeterminateCheckbox = ({ checked, indeterminate, onChange, className }: { checked: boolean; indeterminate: boolean; onChange: () => void; className?: string }) => {
|
||||||
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (ref.current) ref.current.indeterminate = indeterminate;
|
||||||
|
}, [indeterminate]);
|
||||||
|
return <input ref={ref} type="checkbox" checked={checked} onChange={onChange} className={className || 'rounded'} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DomainGroup {
|
||||||
|
domain: string;
|
||||||
|
apis: ServiceApi[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupApisByDomain = (apis: ServiceApi[]): DomainGroup[] => {
|
||||||
|
const domainMap = new Map<string, ServiceApi[]>();
|
||||||
|
|
||||||
|
apis.forEach((api) => {
|
||||||
|
const domain = api.apiDomain || '미분류';
|
||||||
|
if (!domainMap.has(domain)) {
|
||||||
|
domainMap.set(domain, []);
|
||||||
|
}
|
||||||
|
domainMap.get(domain)!.push(api);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result: DomainGroup[] = [];
|
||||||
|
domainMap.forEach((domainApis, domain) => {
|
||||||
|
result.push({ domain, apis: domainApis });
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
const KeyRequestPage = () => {
|
const KeyRequestPage = () => {
|
||||||
@ -17,6 +50,7 @@ const KeyRequestPage = () => {
|
|||||||
const [services, setServices] = useState<ServiceInfo[]>([]);
|
const [services, setServices] = useState<ServiceInfo[]>([]);
|
||||||
const [serviceApisMap, setServiceApisMap] = useState<Record<number, ServiceApi[]>>({});
|
const [serviceApisMap, setServiceApisMap] = useState<Record<number, ServiceApi[]>>({});
|
||||||
const [expandedServices, setExpandedServices] = useState<Set<number>>(new Set());
|
const [expandedServices, setExpandedServices] = useState<Set<number>>(new Set());
|
||||||
|
const [expandedDomains, setExpandedDomains] = useState<Set<string>>(new Set());
|
||||||
const [selectedApiIds, setSelectedApiIds] = useState<Set<number>>(new Set());
|
const [selectedApiIds, setSelectedApiIds] = useState<Set<number>>(new Set());
|
||||||
const [keyName, setKeyName] = useState('');
|
const [keyName, setKeyName] = useState('');
|
||||||
const [purpose, setPurpose] = useState('');
|
const [purpose, setPurpose] = useState('');
|
||||||
@ -27,6 +61,7 @@ const KeyRequestPage = () => {
|
|||||||
const [isPermanent, setIsPermanent] = useState(false);
|
const [isPermanent, setIsPermanent] = useState(false);
|
||||||
const [usageFromDate, setUsageFromDate] = useState('');
|
const [usageFromDate, setUsageFromDate] = useState('');
|
||||||
const [usageToDate, setUsageToDate] = useState('');
|
const [usageToDate, setUsageToDate] = useState('');
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@ -64,6 +99,14 @@ const KeyRequestPage = () => {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const groupedApisMap = useMemo(() => {
|
||||||
|
const result: Record<number, DomainGroup[]> = {};
|
||||||
|
Object.entries(serviceApisMap).forEach(([serviceId, apis]) => {
|
||||||
|
result[Number(serviceId)] = groupApisByDomain(apis);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}, [serviceApisMap]);
|
||||||
|
|
||||||
const handleToggleService = (serviceId: number) => {
|
const handleToggleService = (serviceId: number) => {
|
||||||
setExpandedServices((prev) => {
|
setExpandedServices((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@ -76,6 +119,18 @@ const KeyRequestPage = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleToggleDomain = (key: string) => {
|
||||||
|
setExpandedDomains((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) {
|
||||||
|
next.delete(key);
|
||||||
|
} else {
|
||||||
|
next.add(key);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleToggleApi = (apiId: number) => {
|
const handleToggleApi = (apiId: number) => {
|
||||||
setSelectedApiIds((prev) => {
|
setSelectedApiIds((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@ -105,6 +160,70 @@ const KeyRequestPage = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleToggleAllDomainApis = (serviceId: number, domain: string) => {
|
||||||
|
const domainGroups = groupedApisMap[serviceId] || [];
|
||||||
|
const domainGroup = domainGroups.find((d) => d.domain === domain);
|
||||||
|
if (!domainGroup) return;
|
||||||
|
|
||||||
|
const domainApis = domainGroup.apis;
|
||||||
|
const allSelected = domainApis.every((a) => selectedApiIds.has(a.apiId));
|
||||||
|
|
||||||
|
setSelectedApiIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
domainApis.forEach((a) => {
|
||||||
|
if (allSelected) {
|
||||||
|
next.delete(a.apiId);
|
||||||
|
} else {
|
||||||
|
next.add(a.apiId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const allApis = useMemo(() => {
|
||||||
|
return Object.values(serviceApisMap).flat();
|
||||||
|
}, [serviceApisMap]);
|
||||||
|
|
||||||
|
const allApisSelected = allApis.length > 0 && allApis.every((a) => selectedApiIds.has(a.apiId));
|
||||||
|
const someApisSelected = allApis.some((a) => selectedApiIds.has(a.apiId));
|
||||||
|
|
||||||
|
const handleToggleAll = () => {
|
||||||
|
if (allApisSelected) {
|
||||||
|
setSelectedApiIds(new Set());
|
||||||
|
} else {
|
||||||
|
setSelectedApiIds(new Set(allApis.map((a) => a.apiId)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearSelection = () => {
|
||||||
|
setSelectedApiIds(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredGroupedApisMap = useMemo(() => {
|
||||||
|
if (!searchQuery.trim()) return groupedApisMap;
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
const result: Record<number, DomainGroup[]> = {};
|
||||||
|
Object.entries(groupedApisMap).forEach(([serviceId, domainGroups]) => {
|
||||||
|
const filtered = domainGroups
|
||||||
|
.map((dg) => ({
|
||||||
|
domain: dg.domain,
|
||||||
|
apis: dg.apis.filter(
|
||||||
|
(a) =>
|
||||||
|
a.apiName.toLowerCase().includes(query) ||
|
||||||
|
a.apiPath.toLowerCase().includes(query),
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
.filter((dg) => dg.apis.length > 0);
|
||||||
|
if (filtered.length > 0) {
|
||||||
|
result[Number(serviceId)] = filtered;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}, [groupedApisMap, searchQuery]);
|
||||||
|
|
||||||
const handlePresetPeriod = (months: number) => {
|
const handlePresetPeriod = (months: number) => {
|
||||||
const from = new Date();
|
const from = new Date();
|
||||||
const to = new Date();
|
const to = new Date();
|
||||||
@ -321,78 +440,173 @@ const KeyRequestPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow mb-6">
|
{/* API Selection Section */}
|
||||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 shadow mb-6 overflow-hidden">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
{/* Header bar */}
|
||||||
API 선택 <span className="text-sm font-normal text-gray-500 dark:text-gray-400 dark:text-gray-400">({selectedApiIds.size}개 선택됨)</span>
|
<div className="flex items-center justify-between gap-4 px-5 py-3.5 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
|
||||||
</h2>
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<IndeterminateCheckbox
|
||||||
|
checked={allApisSelected}
|
||||||
|
indeterminate={!allApisSelected && someApisSelected}
|
||||||
|
onChange={handleToggleAll}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">API 선택</h2>
|
||||||
|
{selectedApiIds.size > 0 && (
|
||||||
|
<span className="text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 px-2.5 py-0.5 rounded-full">
|
||||||
|
{selectedApiIds.size}개 선택
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="API 검색..."
|
||||||
|
className="bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-1.5 text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:outline-none w-56"
|
||||||
|
/>
|
||||||
|
{selectedApiIds.size > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClearSelection}
|
||||||
|
className="text-xs text-gray-500 dark:text-gray-400 hover:text-red-500 dark:hover:text-red-400 px-2 py-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
선택 해제
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
|
||||||
|
{/* Service cards */}
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
{services.map((service) => {
|
{services.map((service) => {
|
||||||
const apis = serviceApisMap[service.serviceId] || [];
|
const apis = serviceApisMap[service.serviceId] || [];
|
||||||
const isExpanded = expandedServices.has(service.serviceId);
|
const domainGroups = filteredGroupedApisMap[service.serviceId] || [];
|
||||||
|
const isServiceExpanded = expandedServices.has(service.serviceId);
|
||||||
const selectedCount = apis.filter((a) => selectedApiIds.has(a.apiId)).length;
|
const selectedCount = apis.filter((a) => selectedApiIds.has(a.apiId)).length;
|
||||||
const allSelected = apis.length > 0 && apis.every((a) => selectedApiIds.has(a.apiId));
|
const allServiceSelected = apis.length > 0 && apis.every((a) => selectedApiIds.has(a.apiId));
|
||||||
|
const someServiceSelected = !allServiceSelected && apis.some((a) => selectedApiIds.has(a.apiId));
|
||||||
|
const hasSelections = selectedCount > 0;
|
||||||
|
|
||||||
|
if (searchQuery.trim() && domainGroups.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={service.serviceId}>
|
<div
|
||||||
|
key={service.serviceId}
|
||||||
|
className={`rounded-xl border overflow-hidden transition-colors ${hasSelections ? 'border-blue-300 dark:border-blue-700' : 'border-gray-200 dark:border-gray-700'}`}
|
||||||
|
>
|
||||||
|
{/* Service header */}
|
||||||
<div
|
<div
|
||||||
className="px-6 py-3 flex items-center justify-between cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700"
|
className={`flex items-center justify-between px-5 py-3.5 cursor-pointer ${hasSelections ? 'bg-blue-50/50 dark:bg-blue-900/20' : 'bg-gray-50 dark:bg-gray-800/80'}`}
|
||||||
onClick={() => handleToggleService(service.serviceId)}
|
onClick={() => handleToggleService(service.serviceId)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-gray-400 dark:text-gray-500 text-sm">{isExpanded ? '\u25BC' : '\u25B6'}</span>
|
<svg className={`h-4 w-4 text-gray-400 transition-transform ${isServiceExpanded ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<span className="font-medium text-gray-900 dark:text-gray-100">{service.serviceName}</span>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
<label className="flex items-center cursor-pointer" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<IndeterminateCheckbox
|
||||||
|
checked={allServiceSelected}
|
||||||
|
indeterminate={someServiceSelected}
|
||||||
|
onChange={() => handleToggleAllServiceApis(service.serviceId)}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<span className="font-semibold text-gray-900 dark:text-gray-100">{service.serviceName}</span>
|
||||||
{selectedCount > 0 && (
|
{selectedCount > 0 && (
|
||||||
<span className="text-xs bg-blue-100 text-blue-800 px-2 py-0.5 rounded-full">
|
<span className="text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 px-2 py-0.5 rounded-full">
|
||||||
{selectedCount}/{apis.length}
|
{selectedCount} selected
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">{apis.length}개 API</span>
|
<span className="text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 px-2 py-0.5 rounded-full">
|
||||||
|
{apis.length}개 API
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{isExpanded && (
|
|
||||||
<div className="px-6 pb-3">
|
{/* Service body */}
|
||||||
{apis.length > 0 && (
|
{isServiceExpanded && (
|
||||||
<div className="mb-2 pl-6">
|
<div className="px-4 py-3 space-y-2 bg-white dark:bg-gray-900">
|
||||||
<label className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 cursor-pointer">
|
{domainGroups.map((domainGroup) => {
|
||||||
<input
|
const domainKey = `${service.serviceId}-${domainGroup.domain}`;
|
||||||
type="checkbox"
|
const isDomainExpanded = expandedDomains.has(domainKey);
|
||||||
checked={allSelected}
|
const domainApis = domainGroup.apis;
|
||||||
onChange={() => handleToggleAllServiceApis(service.serviceId)}
|
const allDomainSelected = domainApis.length > 0 && domainApis.every((a) => selectedApiIds.has(a.apiId));
|
||||||
className="rounded"
|
const someDomainSelected = !allDomainSelected && domainApis.some((a) => selectedApiIds.has(a.apiId));
|
||||||
/>
|
|
||||||
전체 선택
|
return (
|
||||||
</label>
|
<div key={domainKey}>
|
||||||
</div>
|
{/* Domain row */}
|
||||||
)}
|
<div
|
||||||
<div className="space-y-1 pl-6">
|
className="ml-5 flex items-center gap-2.5 px-3 py-2 cursor-pointer rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
||||||
{apis.map((api) => (
|
onClick={() => handleToggleDomain(domainKey)}
|
||||||
<label
|
|
||||||
key={api.apiId}
|
|
||||||
className="flex items-center gap-2 py-1 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 rounded px-2"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={selectedApiIds.has(api.apiId)}
|
|
||||||
onChange={() => handleToggleApi(api.apiId)}
|
|
||||||
className="rounded"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className={`inline-block px-2 py-0.5 rounded text-xs font-bold ${
|
|
||||||
METHOD_COLOR[api.apiMethod] || 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{api.apiMethod}
|
<svg className={`h-3.5 w-3.5 text-gray-400 transition-transform ${isDomainExpanded ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
</span>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
<span className="font-mono text-sm text-gray-700 dark:text-gray-300">{api.apiPath}</span>
|
</svg>
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">- {api.apiName}</span>
|
<label className="flex items-center cursor-pointer" onClick={(e) => e.stopPropagation()}>
|
||||||
</label>
|
<IndeterminateCheckbox
|
||||||
))}
|
checked={allDomainSelected}
|
||||||
{apis.length === 0 && (
|
indeterminate={someDomainSelected}
|
||||||
<p className="text-sm text-gray-400 dark:text-gray-500 py-1">등록된 API가 없습니다.</p>
|
onChange={() => handleToggleAllDomainApis(service.serviceId, domainGroup.domain)}
|
||||||
)}
|
className="rounded"
|
||||||
</div>
|
/>
|
||||||
|
</label>
|
||||||
|
<span className="text-sm font-semibold text-gray-800 dark:text-gray-200">{domainGroup.domain}</span>
|
||||||
|
<span className="text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 px-2 py-0.5 rounded-full">
|
||||||
|
{domainApis.length}개
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API table */}
|
||||||
|
{isDomainExpanded && (
|
||||||
|
<div className="ml-3 mr-3 mb-3 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
{/* Table header */}
|
||||||
|
<div className="grid grid-cols-[24px_56px_1fr_1fr] gap-3 items-center bg-gray-50 dark:bg-gray-800 px-4 py-1.5 text-xs font-semibold text-gray-500 dark:text-gray-500 uppercase tracking-wider">
|
||||||
|
<div></div>
|
||||||
|
<div>Method</div>
|
||||||
|
<div>Path</div>
|
||||||
|
<div>Name</div>
|
||||||
|
</div>
|
||||||
|
{/* Table rows */}
|
||||||
|
{domainApis.map((api) => {
|
||||||
|
const isSelected = selectedApiIds.has(api.apiId);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={api.apiId}
|
||||||
|
className={`grid grid-cols-[24px_56px_1fr_1fr] gap-3 items-center px-4 py-2.5 hover:bg-gray-50 dark:hover:bg-gray-700/30 border-b border-gray-100 dark:border-gray-700/50 last:border-b-0 cursor-pointer ${isSelected ? 'bg-blue-50/50 dark:bg-blue-900/10' : ''}`}
|
||||||
|
onClick={() => handleToggleApi(api.apiId)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => handleToggleApi(api.apiId)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className={`inline-block px-2 py-0.5 rounded text-xs font-bold ${METHOD_BADGE_STYLE[api.apiMethod] || 'bg-gray-700 text-gray-300 border border-gray-600'}`}>
|
||||||
|
{api.apiMethod}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-xs text-gray-700 dark:text-gray-300 truncate">{api.apiPath}</div>
|
||||||
|
<div className="text-sm text-gray-600 dark:text-gray-400 truncate">{api.apiName}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{apis.length === 0 && (
|
||||||
|
<p className="text-sm text-gray-400 dark:text-gray-500 py-3 ml-5">등록된 API가 없습니다.</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -406,6 +620,22 @@ const KeyRequestPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom sticky summary bar */}
|
||||||
|
{selectedApiIds.size > 0 && (
|
||||||
|
<div className="sticky bottom-4 z-10 mx-auto mb-4">
|
||||||
|
<div className="bg-blue-600 dark:bg-blue-700 text-white rounded-xl px-5 py-3 shadow-lg flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">{selectedApiIds.size}개 API가 선택되었습니다</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClearSelection}
|
||||||
|
className="text-sm text-blue-200 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
선택 해제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@ -4,6 +4,7 @@ export interface ApiKey {
|
|||||||
apiKeyPrefix: string;
|
apiKeyPrefix: string;
|
||||||
maskedKey: string;
|
maskedKey: string;
|
||||||
status: 'PENDING' | 'ACTIVE' | 'INACTIVE' | 'EXPIRED' | 'REVOKED';
|
status: 'PENDING' | 'ACTIVE' | 'INACTIVE' | 'EXPIRED' | 'REVOKED';
|
||||||
|
userName: string | null;
|
||||||
expiresAt: string | null;
|
expiresAt: string | null;
|
||||||
lastUsedAt: string | null;
|
lastUsedAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|||||||
@ -19,6 +19,8 @@ export interface ServiceApi {
|
|||||||
apiPath: string;
|
apiPath: string;
|
||||||
apiMethod: string;
|
apiMethod: string;
|
||||||
apiName: string;
|
apiName: string;
|
||||||
|
apiDomain: string | null;
|
||||||
|
apiSection: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@ -46,6 +48,8 @@ export interface CreateServiceApiRequest {
|
|||||||
apiPath: string;
|
apiPath: string;
|
||||||
apiMethod: string;
|
apiMethod: string;
|
||||||
apiName: string;
|
apiName: string;
|
||||||
|
apiDomain?: string;
|
||||||
|
apiSection?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,7 @@ public record ApiKeyResponse(
|
|||||||
String apiKeyPrefix,
|
String apiKeyPrefix,
|
||||||
String maskedKey,
|
String maskedKey,
|
||||||
String status,
|
String status,
|
||||||
|
String userName,
|
||||||
LocalDateTime expiresAt,
|
LocalDateTime expiresAt,
|
||||||
LocalDateTime lastUsedAt,
|
LocalDateTime lastUsedAt,
|
||||||
LocalDateTime createdAt
|
LocalDateTime createdAt
|
||||||
@ -17,12 +18,14 @@ public record ApiKeyResponse(
|
|||||||
|
|
||||||
public static ApiKeyResponse from(SnpApiKey key) {
|
public static ApiKeyResponse from(SnpApiKey key) {
|
||||||
String masked = key.getApiKeyPrefix() + "****...****";
|
String masked = key.getApiKeyPrefix() + "****...****";
|
||||||
|
String ownerName = key.getUser() != null ? key.getUser().getUserName() : null;
|
||||||
return new ApiKeyResponse(
|
return new ApiKeyResponse(
|
||||||
key.getApiKeyId(),
|
key.getApiKeyId(),
|
||||||
key.getKeyName(),
|
key.getKeyName(),
|
||||||
key.getApiKeyPrefix(),
|
key.getApiKeyPrefix(),
|
||||||
masked,
|
masked,
|
||||||
key.getStatus().name(),
|
key.getStatus().name(),
|
||||||
|
ownerName,
|
||||||
key.getExpiresAt(),
|
key.getExpiresAt(),
|
||||||
key.getLastUsedAt(),
|
key.getLastUsedAt(),
|
||||||
key.getCreatedAt()
|
key.getCreatedAt()
|
||||||
|
|||||||
@ -5,8 +5,10 @@ import com.gcsc.connection.service.entity.SnpService;
|
|||||||
import com.gcsc.connection.tenant.entity.SnpTenant;
|
import com.gcsc.connection.tenant.entity.SnpTenant;
|
||||||
import com.gcsc.connection.user.entity.SnpUser;
|
import com.gcsc.connection.user.entity.SnpUser;
|
||||||
import jakarta.persistence.Column;
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.ConstraintMode;
|
||||||
import jakarta.persistence.Entity;
|
import jakarta.persistence.Entity;
|
||||||
import jakarta.persistence.FetchType;
|
import jakarta.persistence.FetchType;
|
||||||
|
import jakarta.persistence.ForeignKey;
|
||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
@ -49,15 +51,15 @@ public class SnpApiRequestLog {
|
|||||||
private String requestIp;
|
private String requestIp;
|
||||||
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
@JoinColumn(name = "service_id")
|
@JoinColumn(name = "service_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT))
|
||||||
private SnpService service;
|
private SnpService service;
|
||||||
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
@JoinColumn(name = "user_id")
|
@JoinColumn(name = "user_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT))
|
||||||
private SnpUser user;
|
private SnpUser user;
|
||||||
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
@JoinColumn(name = "api_key_id")
|
@JoinColumn(name = "api_key_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT))
|
||||||
private SnpApiKey apiKey;
|
private SnpApiKey apiKey;
|
||||||
|
|
||||||
@Column(name = "response_size")
|
@Column(name = "response_size")
|
||||||
@ -76,7 +78,7 @@ public class SnpApiRequestLog {
|
|||||||
private LocalDateTime requestedAt;
|
private LocalDateTime requestedAt;
|
||||||
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
@JoinColumn(name = "tenant_id")
|
@JoinColumn(name = "tenant_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT))
|
||||||
private SnpTenant tenant;
|
private SnpTenant tenant;
|
||||||
|
|
||||||
@Builder
|
@Builder
|
||||||
|
|||||||
@ -6,6 +6,8 @@ public record CreateServiceApiRequest(
|
|||||||
@NotBlank String apiPath,
|
@NotBlank String apiPath,
|
||||||
@NotBlank String apiMethod,
|
@NotBlank String apiMethod,
|
||||||
@NotBlank String apiName,
|
@NotBlank String apiName,
|
||||||
|
String apiDomain,
|
||||||
|
String apiSection,
|
||||||
String description
|
String description
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,8 @@ public record ServiceApiResponse(
|
|||||||
String apiPath,
|
String apiPath,
|
||||||
String apiMethod,
|
String apiMethod,
|
||||||
String apiName,
|
String apiName,
|
||||||
|
String apiDomain,
|
||||||
|
String apiSection,
|
||||||
String description,
|
String description,
|
||||||
Boolean isActive,
|
Boolean isActive,
|
||||||
LocalDateTime createdAt,
|
LocalDateTime createdAt,
|
||||||
@ -23,6 +25,8 @@ public record ServiceApiResponse(
|
|||||||
a.getApiPath(),
|
a.getApiPath(),
|
||||||
a.getApiMethod(),
|
a.getApiMethod(),
|
||||||
a.getApiName(),
|
a.getApiName(),
|
||||||
|
a.getApiDomain(),
|
||||||
|
a.getApiSection(),
|
||||||
a.getDescription(),
|
a.getDescription(),
|
||||||
a.getIsActive(),
|
a.getIsActive(),
|
||||||
a.getCreatedAt(),
|
a.getCreatedAt(),
|
||||||
|
|||||||
@ -40,6 +40,12 @@ public class SnpServiceApi extends BaseEntity {
|
|||||||
@Column(name = "api_name", length = 200, nullable = false)
|
@Column(name = "api_name", length = 200, nullable = false)
|
||||||
private String apiName;
|
private String apiName;
|
||||||
|
|
||||||
|
@Column(name = "api_domain", length = 100)
|
||||||
|
private String apiDomain;
|
||||||
|
|
||||||
|
@Column(name = "api_section", length = 100)
|
||||||
|
private String apiSection;
|
||||||
|
|
||||||
@Column(name = "description", columnDefinition = "TEXT")
|
@Column(name = "description", columnDefinition = "TEXT")
|
||||||
private String description;
|
private String description;
|
||||||
|
|
||||||
@ -48,19 +54,24 @@ public class SnpServiceApi extends BaseEntity {
|
|||||||
|
|
||||||
@Builder
|
@Builder
|
||||||
public SnpServiceApi(SnpService service, String apiPath, String apiMethod, String apiName,
|
public SnpServiceApi(SnpService service, String apiPath, String apiMethod, String apiName,
|
||||||
String description, Boolean isActive) {
|
String apiDomain, String apiSection, String description, Boolean isActive) {
|
||||||
this.service = service;
|
this.service = service;
|
||||||
this.apiPath = apiPath;
|
this.apiPath = apiPath;
|
||||||
this.apiMethod = apiMethod;
|
this.apiMethod = apiMethod;
|
||||||
this.apiName = apiName;
|
this.apiName = apiName;
|
||||||
|
this.apiDomain = apiDomain;
|
||||||
|
this.apiSection = apiSection;
|
||||||
this.description = description;
|
this.description = description;
|
||||||
this.isActive = isActive != null ? isActive : true;
|
this.isActive = isActive != null ? isActive : true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void update(String apiPath, String apiMethod, String apiName, String description, Boolean isActive) {
|
public void update(String apiPath, String apiMethod, String apiName,
|
||||||
|
String apiDomain, String apiSection, String description, Boolean isActive) {
|
||||||
if (apiPath != null) this.apiPath = apiPath;
|
if (apiPath != null) this.apiPath = apiPath;
|
||||||
if (apiMethod != null) this.apiMethod = apiMethod;
|
if (apiMethod != null) this.apiMethod = apiMethod;
|
||||||
if (apiName != null) this.apiName = apiName;
|
if (apiName != null) this.apiName = apiName;
|
||||||
|
if (apiDomain != null) this.apiDomain = apiDomain;
|
||||||
|
if (apiSection != null) this.apiSection = apiSection;
|
||||||
if (description != null) this.description = description;
|
if (description != null) this.description = description;
|
||||||
if (isActive != null) this.isActive = isActive;
|
if (isActive != null) this.isActive = isActive;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -102,6 +102,8 @@ public class ServiceManagementService {
|
|||||||
.apiPath(request.apiPath())
|
.apiPath(request.apiPath())
|
||||||
.apiMethod(request.apiMethod())
|
.apiMethod(request.apiMethod())
|
||||||
.apiName(request.apiName())
|
.apiName(request.apiName())
|
||||||
|
.apiDomain(request.apiDomain())
|
||||||
|
.apiSection(request.apiSection())
|
||||||
.description(request.description())
|
.description(request.description())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user