diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 1742287..c13973c 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -15,6 +15,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/ko/1.0.0/). - 헬스체크 상태 조회/이력 API (GET/POST /api/heartbeat) (#7) - @EnableMethodSecurity + @PreAuthorize 역할 기반 접근 제어 (#7) - 테넌트/사용자/서비스 관리 프론트엔드 페이지 (CRUD 테이블 + 모달) (#7) +- API Key AES-256-GCM 암호화 생성/복호화 조회 (#8) +- API Key 신청→승인/반려 워크플로우 (#8) +- API Key 신청 필드 (사용기간, 서비스IP, 서비스용도, 하루 예상 요청량) (#8) +- API Key Permission 관리 API (#8) +- API Key 관리 프론트엔드 (신청/검토/키관리/권한편집) (#8) ## [2026-04-07] diff --git a/frontend/src/pages/apikeys/KeyAdminPage.tsx b/frontend/src/pages/apikeys/KeyAdminPage.tsx index 21f557f..0df4f22 100644 --- a/frontend/src/pages/apikeys/KeyAdminPage.tsx +++ b/frontend/src/pages/apikeys/KeyAdminPage.tsx @@ -1,8 +1,776 @@ +import { useState, useEffect, useCallback } from 'react'; +import type { ApiKey, ApiKeyDetail, ApiKeyRequest } from '../../types/apikey'; +import type { ServiceInfo, ServiceApi } from '../../types/service'; +import { + getAllKeys, + getKeyDetail, + revokeKey, + getAllRequests, + reviewRequest, + getPermissions, + updatePermissions, +} from '../../services/apiKeyService'; +import { getServices, getServiceApis } from '../../services/serviceService'; + +const STATUS_BADGE: Record = { + ACTIVE: 'bg-green-100 text-green-800', + PENDING: 'bg-yellow-100 text-yellow-800', + REVOKED: 'bg-red-100 text-red-800', + EXPIRED: 'bg-gray-100 text-gray-800', + INACTIVE: 'bg-gray-100 text-gray-800', + APPROVED: 'bg-green-100 text-green-800', + REJECTED: 'bg-red-100 text-red-800', +}; + +const METHOD_COLOR: Record = { + GET: 'bg-green-100 text-green-800', + POST: 'bg-blue-100 text-blue-800', + PUT: 'bg-orange-100 text-orange-800', + DELETE: 'bg-red-100 text-red-800', +}; + +const formatDateTime = (dateStr: string | null): string => { + if (!dateStr) return '-'; + return new Date(dateStr).toLocaleString('ko-KR'); +}; + +interface AllApisMap { + services: ServiceInfo[]; + apisByService: Record; + apiById: Record; +} + const KeyAdminPage = () => { + const [activeTab, setActiveTab] = useState<'requests' | 'keys'>('requests'); + + // Shared state + const [allApisMap, setAllApisMap] = useState({ services: [], apisByService: {}, apiById: {} }); + const [error, setError] = useState(null); + + // Requests tab state + const [requests, setRequests] = useState([]); + const [requestsLoading, setRequestsLoading] = useState(true); + const [isReviewModalOpen, setIsReviewModalOpen] = useState(false); + const [selectedRequest, setSelectedRequest] = useState(null); + const [adjustedApiIds, setAdjustedApiIds] = useState>(new Set()); + const [reviewComment, setReviewComment] = useState(''); + const [adjustedFromDate, setAdjustedFromDate] = useState(''); + const [adjustedToDate, setAdjustedToDate] = useState(''); + + // Keys tab state + const [allKeys, setAllKeys] = useState([]); + const [keysLoading, setKeysLoading] = useState(true); + const [isDetailModalOpen, setIsDetailModalOpen] = useState(false); + const [selectedKeyDetail, setSelectedKeyDetail] = useState(null); + const [isPermissionModalOpen, setIsPermissionModalOpen] = useState(false); + const [permissionKeyId, setPermissionKeyId] = useState(null); + const [permissionKeyName, setPermissionKeyName] = useState(''); + const [permissionApiIds, setPermissionApiIds] = useState>(new Set()); + const [detailCopied, setDetailCopied] = useState(false); + + // Raw key modal (after approve) + const [rawKeyModal, setRawKeyModal] = useState<{ keyName: string; rawKey: string } | null>(null); + const [rawKeyCopied, setRawKeyCopied] = useState(false); + + const fetchAllApis = useCallback(async () => { + try { + const servicesRes = await getServices(); + if (servicesRes.success && servicesRes.data) { + const services = servicesRes.data; + const apisByService: Record = {}; + const apiById: Record = {}; + + await Promise.all( + services.map(async (service) => { + const apisRes = await getServiceApis(service.serviceId); + if (apisRes.success && apisRes.data) { + apisByService[service.serviceId] = apisRes.data; + apisRes.data.forEach((api) => { + apiById[api.apiId] = { ...api, serviceName: service.serviceName }; + }); + } + }), + ); + + setAllApisMap({ services, apisByService, apiById }); + } + } catch { + // Silently fail for API map loading + } + }, []); + + const fetchRequests = useCallback(async () => { + try { + setRequestsLoading(true); + const res = await getAllRequests(); + if (res.success && res.data) { + setRequests(res.data); + } + } catch { + setError('신청 목록을 불러오는데 실패했습니다.'); + } finally { + setRequestsLoading(false); + } + }, []); + + const fetchKeys = useCallback(async () => { + try { + setKeysLoading(true); + const res = await getAllKeys(); + if (res.success && res.data) { + setAllKeys(res.data); + } + } catch { + setError('키 목록을 불러오는데 실패했습니다.'); + } finally { + setKeysLoading(false); + } + }, []); + + useEffect(() => { + fetchAllApis(); + fetchRequests(); + fetchKeys(); + }, [fetchAllApis, fetchRequests, fetchKeys]); + + // --- Requests tab handlers --- + + const handleOpenReview = (req: ApiKeyRequest) => { + setSelectedRequest(req); + setAdjustedApiIds(new Set(req.requestedApiIds)); + setReviewComment(''); + setAdjustedFromDate(req.usageFromDate ? req.usageFromDate.split('T')[0] : ''); + setAdjustedToDate(req.usageToDate ? req.usageToDate.split('T')[0] : ''); + setError(null); + setIsReviewModalOpen(true); + }; + + const handleCloseReview = () => { + setIsReviewModalOpen(false); + setSelectedRequest(null); + setError(null); + }; + + const handleToggleReviewApi = (apiId: number) => { + setAdjustedApiIds((prev) => { + const next = new Set(prev); + if (next.has(apiId)) { + next.delete(apiId); + } else { + next.add(apiId); + } + return next; + }); + }; + + const handleReviewSubmit = async (status: 'APPROVED' | 'REJECTED') => { + if (!selectedRequest) return; + setError(null); + + try { + const res = await reviewRequest(selectedRequest.requestId, { + status, + reviewComment: reviewComment || undefined, + adjustedApiIds: status === 'APPROVED' ? Array.from(adjustedApiIds) : undefined, + adjustedFromDate: status === 'APPROVED' && adjustedFromDate ? adjustedFromDate : undefined, + adjustedToDate: status === 'APPROVED' && adjustedToDate ? adjustedToDate : undefined, + }); + + if (res.success) { + handleCloseReview(); + await fetchRequests(); + await fetchKeys(); + + if (status === 'APPROVED' && res.data) { + setRawKeyModal({ keyName: res.data.keyName, rawKey: res.data.rawKey }); + } + } else { + setError(res.message || '검토 처리에 실패했습니다.'); + } + } catch { + setError('검토 처리에 실패했습니다.'); + } + }; + + // --- Keys tab handlers --- + + const handleViewDetail = async (key: ApiKey) => { + setError(null); + try { + const res = await getKeyDetail(key.apiKeyId); + if (res.success && res.data) { + setSelectedKeyDetail(res.data); + setDetailCopied(false); + setIsDetailModalOpen(true); + } else { + setError(res.message || '키 상세 정보를 불러오는데 실패했습니다.'); + } + } catch { + setError('키 상세 정보를 불러오는데 실패했습니다.'); + } + }; + + const handleCopyDecryptedKey = async () => { + if (!selectedKeyDetail) return; + await navigator.clipboard.writeText(selectedKeyDetail.decryptedKey); + setDetailCopied(true); + setTimeout(() => setDetailCopied(false), 2000); + }; + + const handleOpenPermissions = async (key: ApiKey) => { + setError(null); + setPermissionKeyId(key.apiKeyId); + setPermissionKeyName(key.keyName); + + try { + const res = await getPermissions(key.apiKeyId); + if (res.success && res.data) { + setPermissionApiIds(new Set(res.data.map((p) => p.apiId))); + } else { + setPermissionApiIds(new Set()); + } + setIsPermissionModalOpen(true); + } catch { + setError('권한 정보를 불러오는데 실패했습니다.'); + } + }; + + const handleTogglePermissionApi = (apiId: number) => { + setPermissionApiIds((prev) => { + const next = new Set(prev); + if (next.has(apiId)) { + next.delete(apiId); + } else { + next.add(apiId); + } + return next; + }); + }; + + const handleSavePermissions = async () => { + if (permissionKeyId === null) return; + setError(null); + + try { + const res = await updatePermissions(permissionKeyId, { + apiIds: Array.from(permissionApiIds), + }); + if (res.success) { + setIsPermissionModalOpen(false); + setPermissionKeyId(null); + } else { + setError(res.message || '권한 저장에 실패했습니다.'); + } + } catch { + setError('권한 저장에 실패했습니다.'); + } + }; + + const handleRevokeKey = async (key: ApiKey) => { + if (!window.confirm(`'${key.keyName}' 키를 폐기하시겠습니까? 이 작업은 되돌릴 수 없습니다.`)) return; + + try { + const res = await revokeKey(key.apiKeyId); + if (res.success) { + await fetchKeys(); + } else { + setError(res.message || '키 폐기에 실패했습니다.'); + } + } catch { + setError('키 폐기에 실패했습니다.'); + } + }; + + const handleCopyRawKey = async () => { + if (!rawKeyModal) return; + await navigator.clipboard.writeText(rawKeyModal.rawKey); + setRawKeyCopied(true); + setTimeout(() => setRawKeyCopied(false), 2000); + }; + + // --- Render helpers --- + + const renderApiCheckboxes = ( + selectedIds: Set, + onToggle: (apiId: number) => void, + ) => { + return allApisMap.services.map((service) => { + const apis = allApisMap.apisByService[service.serviceId] || []; + if (apis.length === 0) return null; + + return ( +
+

{service.serviceName}

+
+ {apis.map((api) => ( + + ))} +
+
+ ); + }); + }; + return (
-

API Key Admin

-

Coming soon

+

API Key 관리

+ + {error && !isReviewModalOpen && !isDetailModalOpen && !isPermissionModalOpen && ( +
{error}
+ )} + + {/* Tabs */} +
+ + +
+ + {/* Tab: 신청 관리 */} + {activeTab === 'requests' && ( +
+ {requestsLoading ? ( +
로딩 중...
+ ) : ( +
+ + + + + + + + + + + + + + {requests.map((req) => ( + + + + + + + + + + ))} + {requests.length === 0 && ( + + + + )} + +
RequesterKey NamePurposeStatusAPIsCreated AtActions
{req.userName}{req.keyName} + {req.purpose || '-'} + + + {req.status} + + {req.requestedApiIds.length}개{formatDateTime(req.createdAt)} + {req.status === 'PENDING' && ( + + )} +
+ 신청 내역이 없습니다. +
+
+ )} +
+ )} + + {/* Tab: 키 관리 */} + {activeTab === 'keys' && ( +
+ {keysLoading ? ( +
로딩 중...
+ ) : ( +
+ + + + + + + + + + + + + + {allKeys.map((key) => ( + + + + + + + + + + ))} + {allKeys.length === 0 && ( + + + + )} + +
Key NamePrefixUserStatusLast UsedCreatedActions
{key.keyName}{key.apiKeyPrefix}{key.maskedKey} + + {key.status} + + {formatDateTime(key.lastUsedAt)}{formatDateTime(key.createdAt)} +
+ + + {key.status === 'ACTIVE' && ( + + )} +
+
+ 등록된 API Key가 없습니다. +
+
+ )} +
+ )} + + {/* Review Modal */} + {isReviewModalOpen && selectedRequest && ( +
+
+
+

신청 검토

+
+
+ {error && ( +
{error}
+ )} + +
+
+ +

{selectedRequest.userName}

+
+
+ +

{selectedRequest.keyName}

+
+
+ {selectedRequest.purpose && ( +
+ +

{selectedRequest.purpose}

+
+ )} + +
+ {selectedRequest.serviceIp && ( +
+ +

{selectedRequest.serviceIp}

+
+ )} + {selectedRequest.servicePurpose && ( +
+ +

{selectedRequest.servicePurpose}

+
+ )} + {selectedRequest.dailyRequestEstimate != null && ( +
+ +

{Number(selectedRequest.dailyRequestEstimate).toLocaleString()}건

+
+ )} +
+ +
+ +
+ setAdjustedFromDate(e.target.value)} + className="border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none" /> + ~ + setAdjustedToDate(e.target.value)} + className="border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none" /> +
+
+ +
+ +
+ {renderApiCheckboxes(adjustedApiIds, handleToggleReviewApi)} +
+
+ +
+ +