generated from gc/template-java-maven
- 디자인 시스템 가이드 문서 11개 생성 (docs/design/) - CSS 변수 토큰 시스템 (@theme + :root/.dark 전환) - cn() 유틸리티 (clsx + tailwind-merge) - Button/Badge 공통 컴포넌트 (variant/size, 다크모드 대응) - 하드코딩 Tailwind 색상 → CSS 변수 토큰 리팩토링 (30개 파일) - 차트 팔레트 다크모드 색상 업데이트 (CHART_COLORS_HEX) - 버튼 다크모드 채도/대비 강화 (primary-600 기반) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
426 lines
20 KiB
TypeScript
426 lines
20 KiB
TypeScript
import { useState, useEffect, useMemo } from 'react';
|
|
import { createKeyRequest } from '../services/apiKeyService';
|
|
import { getCatalog } from '../services/apiHubService';
|
|
import type { ServiceCatalog } from '../types/apihub';
|
|
import Button from './ui/Button';
|
|
|
|
interface ApiKeyRequestModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
initialApiIds: number[];
|
|
initialApiNames?: string[];
|
|
onSuccess?: () => void;
|
|
}
|
|
|
|
const ApiKeyRequestModal = ({ isOpen, onClose, initialApiIds, onSuccess }: ApiKeyRequestModalProps) => {
|
|
const [keyName, setKeyName] = useState('');
|
|
const [purpose, setPurpose] = useState('');
|
|
const [serviceIp, setServiceIp] = useState('');
|
|
const [servicePurpose, setServicePurpose] = useState('');
|
|
const [dailyEstimate, setDailyEstimate] = useState('');
|
|
const [periodMode, setPeriodMode] = useState<'preset' | 'custom'>('preset');
|
|
const [isPermanent, setIsPermanent] = useState(false);
|
|
const [fromDate, setFromDate] = useState('');
|
|
const [toDate, setToDate] = useState('');
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [success, setSuccess] = useState(false);
|
|
const [selectedApiIds, setSelectedApiIds] = useState<Set<number>>(new Set());
|
|
const [catalog, setCatalog] = useState<ServiceCatalog[]>([]);
|
|
const [expandedDomains, setExpandedDomains] = useState<Set<string>>(new Set());
|
|
const [apiSearch, setApiSearch] = useState('');
|
|
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
setKeyName('');
|
|
setPurpose('');
|
|
setServiceIp('');
|
|
setServicePurpose('');
|
|
setDailyEstimate('');
|
|
setPeriodMode('preset');
|
|
setIsPermanent(false);
|
|
setFromDate('');
|
|
setToDate('');
|
|
setError(null);
|
|
setSuccess(false);
|
|
setSelectedApiIds(new Set(initialApiIds));
|
|
setApiSearch('');
|
|
// 초기 API의 도메인을 펼침
|
|
if (catalog.length > 0) {
|
|
const domains = new Set<string>();
|
|
for (const svc of catalog) {
|
|
for (const dg of svc.domains) {
|
|
if (dg.apis.some((a) => initialApiIds.includes(a.apiId))) {
|
|
domains.add(formatDomainKey(dg.domain));
|
|
}
|
|
}
|
|
}
|
|
setExpandedDomains(domains);
|
|
}
|
|
if (catalog.length === 0) {
|
|
getCatalog().then((res) => {
|
|
if (res.data) setCatalog(res.data);
|
|
});
|
|
}
|
|
}
|
|
}, [isOpen]);
|
|
|
|
const formatDomainKey = (d: string) => (/^[a-zA-Z\s\-_]+$/.test(d) ? d.toUpperCase() : d);
|
|
|
|
// 카탈로그에서 도메인 기준 플랫 그룹
|
|
const domainGroups = useMemo(() => {
|
|
const map = new Map<string, { apiId: number; apiName: string }[]>();
|
|
for (const svc of catalog) {
|
|
for (const dg of svc.domains) {
|
|
const key = formatDomainKey(dg.domain);
|
|
const existing = map.get(key) ?? [];
|
|
for (const a of dg.apis) {
|
|
if (!existing.some((e) => e.apiId === a.apiId)) {
|
|
if (!apiSearch.trim() || a.apiName.toLowerCase().includes(apiSearch.toLowerCase())) {
|
|
existing.push({ apiId: a.apiId, apiName: a.apiName });
|
|
}
|
|
}
|
|
}
|
|
if (existing.length > 0) map.set(key, existing);
|
|
}
|
|
}
|
|
return Array.from(map.entries());
|
|
}, [catalog, apiSearch]);
|
|
|
|
const handlePresetPeriod = (months: number) => {
|
|
const from = new Date();
|
|
const to = new Date();
|
|
to.setMonth(to.getMonth() + months);
|
|
setFromDate(from.toISOString().split('T')[0]);
|
|
setToDate(to.toISOString().split('T')[0]);
|
|
setPeriodMode('preset');
|
|
setIsPermanent(false);
|
|
};
|
|
|
|
const handlePermanent = () => {
|
|
setIsPermanent(true);
|
|
setFromDate('');
|
|
setToDate('');
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (selectedApiIds.size === 0) {
|
|
setError('최소 하나의 API를 선택해주세요.');
|
|
return;
|
|
}
|
|
if (!isPermanent && (!fromDate || !toDate)) {
|
|
setError('사용 기간을 설정해주세요.');
|
|
return;
|
|
}
|
|
setError(null);
|
|
setIsSubmitting(true);
|
|
try {
|
|
const res = await createKeyRequest({
|
|
keyName,
|
|
purpose: purpose || undefined,
|
|
requestedApiIds: Array.from(selectedApiIds),
|
|
serviceIp: serviceIp || undefined,
|
|
servicePurpose: servicePurpose || undefined,
|
|
dailyRequestEstimate: dailyEstimate ? Number(dailyEstimate) : undefined,
|
|
usageFromDate: isPermanent ? undefined : fromDate,
|
|
usageToDate: isPermanent ? undefined : toDate,
|
|
});
|
|
if (res.success) {
|
|
setSuccess(true);
|
|
setTimeout(() => {
|
|
onClose();
|
|
onSuccess?.();
|
|
}, 2000);
|
|
} else {
|
|
setError(res.message || 'API Key 신청에 실패했습니다.');
|
|
}
|
|
} catch {
|
|
setError('API Key 신청에 실패했습니다.');
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
if (!isOpen) return null;
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
|
|
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
|
>
|
|
<div className="bg-[var(--color-bg-surface)] rounded-xl shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
|
{/* 헤더 */}
|
|
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--color-border)]">
|
|
<h2 className="text-lg font-bold text-[var(--color-text-primary)]">API 사용 신청</h2>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] transition-colors"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{/* 성공 메시지 */}
|
|
{success ? (
|
|
<div className="px-6 py-10 text-center">
|
|
<div className="bg-[var(--color-success-subtle)] border border-[var(--color-success-border)] rounded-lg p-6">
|
|
<h3 className="text-lg font-semibold text-[var(--color-success-text-strong)] mb-2">신청이 완료되었습니다</h3>
|
|
<p className="text-[var(--color-success-text)] text-sm">관리자 승인 후 API Key가 생성됩니다.</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<form onSubmit={handleSubmit} className="px-6 py-5 space-y-5">
|
|
{/* 에러 메시지 */}
|
|
{error && (
|
|
<div className="p-3 bg-[var(--color-danger-subtle)] border border-[var(--color-danger-border)] text-[var(--color-danger-text)] rounded-lg text-sm">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* API 선택 (도메인 기반 체크박스 트리) */}
|
|
<div className="border border-[var(--color-border)] rounded-lg overflow-hidden">
|
|
<div className="flex items-center justify-between px-4 py-2.5 bg-[var(--color-bg-base)] border-b border-[var(--color-border)]">
|
|
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]">
|
|
API 선택
|
|
{selectedApiIds.size > 0 && (
|
|
<span className="ml-2 text-xs font-normal text-indigo-500">{selectedApiIds.size}건 선택</span>
|
|
)}
|
|
</h3>
|
|
<input
|
|
type="text"
|
|
value={apiSearch}
|
|
onChange={(e) => setApiSearch(e.target.value)}
|
|
placeholder="API 검색..."
|
|
className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-lg px-2.5 py-1 text-xs text-[var(--color-text-primary)] placeholder-[var(--color-text-tertiary)] focus:ring-1 focus:ring-[var(--color-primary)] focus:outline-none w-40"
|
|
/>
|
|
</div>
|
|
<div className="max-h-56 overflow-y-auto">
|
|
{domainGroups.length === 0 ? (
|
|
<p className="text-xs text-[var(--color-text-tertiary)] text-center py-6">검색 결과가 없습니다</p>
|
|
) : domainGroups.map(([domain, apis]) => {
|
|
const isExpanded = expandedDomains.has(domain);
|
|
const allSelected = apis.every((a) => selectedApiIds.has(a.apiId));
|
|
const someSelected = !allSelected && apis.some((a) => selectedApiIds.has(a.apiId));
|
|
return (
|
|
<div key={domain}>
|
|
<div
|
|
className="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-[var(--color-bg-base)] border-b border-[var(--color-border)]"
|
|
onClick={() => setExpandedDomains((prev) => { const n = new Set(prev); n.has(domain) ? n.delete(domain) : n.add(domain); return n; })}
|
|
>
|
|
<svg className={`h-3.5 w-3.5 text-[var(--color-text-tertiary)] transition-transform ${isExpanded ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
|
|
<input
|
|
type="checkbox"
|
|
checked={allSelected}
|
|
ref={(el) => { if (el) el.indeterminate = someSelected; }}
|
|
onChange={() => {
|
|
setSelectedApiIds((prev) => {
|
|
const n = new Set(prev);
|
|
apis.forEach((a) => allSelected ? n.delete(a.apiId) : n.add(a.apiId));
|
|
return n;
|
|
});
|
|
}}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="rounded"
|
|
/>
|
|
<span className="text-sm font-semibold text-[var(--color-text-primary)]">{domain}</span>
|
|
<span className="text-xs text-[var(--color-text-tertiary)]">{apis.length}</span>
|
|
</div>
|
|
{isExpanded && apis.map((a) => (
|
|
<div
|
|
key={a.apiId}
|
|
className={`flex items-center gap-2 pl-12 pr-4 py-2 cursor-pointer hover:bg-[var(--color-bg-base)] border-b border-[var(--color-border)] ${selectedApiIds.has(a.apiId) ? 'bg-[var(--color-primary-subtle)]' : ''}`}
|
|
onClick={() => setSelectedApiIds((prev) => { const n = new Set(prev); n.has(a.apiId) ? n.delete(a.apiId) : n.add(a.apiId); return n; })}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedApiIds.has(a.apiId)}
|
|
onChange={() => setSelectedApiIds((prev) => { const n = new Set(prev); n.has(a.apiId) ? n.delete(a.apiId) : n.add(a.apiId); return n; })}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="rounded"
|
|
/>
|
|
<span className="text-sm text-[var(--color-text-primary)] truncate">{a.apiName}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Key Name */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
|
|
Key Name <span className="text-red-500">*</span>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={keyName}
|
|
onChange={(e) => setKeyName(e.target.value)}
|
|
required
|
|
placeholder="API Key 이름을 입력하세요"
|
|
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
|
|
/>
|
|
</div>
|
|
|
|
{/* 사용 목적 */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">사용 목적</label>
|
|
<textarea
|
|
value={purpose}
|
|
onChange={(e) => setPurpose(e.target.value)}
|
|
rows={2}
|
|
placeholder="사용 목적을 입력하세요"
|
|
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
|
|
/>
|
|
</div>
|
|
|
|
{/* 사용 기간 */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
|
|
사용 기간 <span className="text-red-500">*</span>
|
|
</label>
|
|
<div className="flex items-center gap-2 mb-2">
|
|
{[3, 6, 9].map((m) => (
|
|
<button
|
|
key={m}
|
|
type="button"
|
|
onClick={() => handlePresetPeriod(m)}
|
|
disabled={isPermanent || periodMode === 'custom'}
|
|
className={`px-3 py-1.5 text-sm rounded-lg border border-[var(--color-border-strong)] text-[var(--color-text-primary)] bg-[var(--color-bg-surface)] ${isPermanent || periodMode === 'custom' ? 'opacity-40 cursor-not-allowed' : 'hover:bg-[var(--color-primary-subtle)]'}`}
|
|
>
|
|
{m}개월
|
|
</button>
|
|
))}
|
|
<span className="text-[var(--color-text-tertiary)] mx-1">|</span>
|
|
<button
|
|
type="button"
|
|
onClick={handlePermanent}
|
|
className={`px-3 py-1.5 text-sm rounded-lg border font-medium ${isPermanent ? 'bg-indigo-600 text-white border-indigo-600' : 'text-indigo-600 border-indigo-300 hover:bg-indigo-50'}`}
|
|
>
|
|
영구
|
|
</button>
|
|
<span className="text-[var(--color-text-tertiary)] mx-1">|</span>
|
|
<label className="flex items-center gap-2 text-sm text-[var(--color-text-primary)] cursor-pointer select-none">
|
|
직접 선택
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setPeriodMode(periodMode === 'custom' ? 'preset' : 'custom');
|
|
setIsPermanent(false);
|
|
}}
|
|
className={`relative w-10 h-5 rounded-full transition-colors ${periodMode === 'custom' && !isPermanent ? 'bg-[var(--color-primary)] dark:bg-[var(--color-primary-600)]' : 'bg-[var(--color-border-strong)]'}`}
|
|
>
|
|
<span className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform ${periodMode === 'custom' && !isPermanent ? 'translate-x-5' : ''}`} />
|
|
</button>
|
|
</label>
|
|
</div>
|
|
{isPermanent ? (
|
|
<div className="flex items-center gap-2 px-3 py-2 bg-indigo-50 border border-indigo-200 rounded-lg">
|
|
<span className="text-indigo-700 text-sm font-medium">영구 사용 (만료일 없음)</span>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="date"
|
|
value={fromDate}
|
|
onChange={(e) => setFromDate(e.target.value)}
|
|
readOnly={periodMode !== 'custom'}
|
|
className={`border border-[var(--color-border-strong)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none ${periodMode !== 'custom' ? 'bg-[var(--color-bg-base)] text-[var(--color-text-secondary)]' : 'bg-[var(--color-bg-surface)] text-[var(--color-text-primary)]'}`}
|
|
/>
|
|
<span className="text-[var(--color-text-secondary)]">~</span>
|
|
<input
|
|
type="date"
|
|
value={toDate}
|
|
onChange={(e) => setToDate(e.target.value)}
|
|
readOnly={periodMode !== 'custom'}
|
|
className={`border border-[var(--color-border-strong)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none ${periodMode !== 'custom' ? 'bg-[var(--color-bg-base)] text-[var(--color-text-secondary)]' : 'bg-[var(--color-bg-surface)] text-[var(--color-text-primary)]'}`}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 서비스 IP */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
|
|
서비스 IP <span className="text-red-500">*</span>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={serviceIp}
|
|
onChange={(e) => setServiceIp(e.target.value)}
|
|
required
|
|
placeholder="예: 192.168.1.100"
|
|
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
|
|
/>
|
|
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">발급받은 API Key로 프록시 서버에 요청하는 서비스 IP</p>
|
|
</div>
|
|
|
|
{/* 서비스 용도 + 하루 예상 요청량 */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
|
|
서비스 용도 <span className="text-red-500">*</span>
|
|
</label>
|
|
<select
|
|
value={servicePurpose}
|
|
onChange={(e) => setServicePurpose(e.target.value)}
|
|
required
|
|
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
|
|
>
|
|
<option value="">선택하세요</option>
|
|
<option value="로컬 환경">로컬 환경</option>
|
|
<option value="개발 서버">개발 서버</option>
|
|
<option value="검증 서버">검증 서버</option>
|
|
<option value="운영 서버">운영 서버</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
|
|
하루 예상 요청량 <span className="text-red-500">*</span>
|
|
</label>
|
|
<select
|
|
value={dailyEstimate}
|
|
onChange={(e) => setDailyEstimate(e.target.value)}
|
|
required
|
|
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
|
|
>
|
|
<option value="">선택하세요</option>
|
|
<option value="100">100 이하</option>
|
|
<option value="500">100~500</option>
|
|
<option value="1000">500~1,000</option>
|
|
<option value="5000">1,000~5,000</option>
|
|
<option value="10000">5,000~10,000</option>
|
|
<option value="50000">10,000 이상</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 하단 버튼 */}
|
|
<div className="flex justify-end gap-3 pt-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={onClose}
|
|
>
|
|
취소
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
disabled={isSubmitting}
|
|
>
|
|
{isSubmitting ? '신청 중...' : '신청하기'}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ApiKeyRequestModal;
|