import { DeleteOutlined, EditOutlined, PlusOutlined, SaveOutlined, UnorderedListOutlined } from '@ant-design/icons'; import { Alert, App, Button, Card, Checkbox, Descriptions, Empty, Form, Input, InputNumber, List, Modal, Space, Switch, Table, Tabs, Tag, Typography } from 'antd'; import { useEffect, useMemo, useRef, useState } from 'react'; import { getReadyPlayAppEntries } from '../../views/play/apps/apps/appsRegistry'; import { confirmWithKeyboard } from './modalKeyboard'; import { deleteTokenSetting, fetchTokenSettingActivities, type TokenSettingActivityRecord, type TokenSettingRecord, upsertTokenSetting, useTokenSettingRegistry, } from './tokenSettingAccess'; import { useTokenAccess } from './tokenAccess'; import './TokenSettingManagementPage.css'; const { Paragraph, Text, Title } = Typography; type TokenSettingFormValue = { originalId?: string; id: string; name: string; description: string; defaultExpiresInMinutes: number; maxTokensPer30Days: number; maxTokensPer7Days: number; maxTokensPer5Hours: number; oneTimeTokenLimit: number; allowedAppIds: string[]; enabled: boolean; }; type AppOption = { value: string; label: string; description: string; category: '관리' | 'Play'; }; type SharedTokenSettingPreview = { id: string; name: string; defaultExpiresInMinutes: number; maxTokensPer30Days: number; maxTokensPer7Days: number; maxTokensPer5Hours: number; oneTimeTokenLimit: number; allowedAppIds: string[]; }; type SharedTokenSettingAccess = { shareToken: string; canManage: boolean; }; const MANAGEMENT_APP_OPTIONS: AppOption[] = [ { value: 'chat-live', label: 'Codex Live', description: '채팅방 조회와 응답 흐름 진입', category: '관리' }, { value: 'chat-room-settings', label: '채팅방 설정', description: 'Codex Live 채팅방 Context 설정 편집 접근', category: '관리' }, { value: 'text-memo-widget', label: '메모', description: '공유채팅 Apps에서 메모 컴포넌트 실행', category: '관리' }, { value: 'token-setting', label: '토큰관리 설정', description: '토큰 설정 목록과 상세 편집 접근', category: '관리' }, { value: 'shared-resource', label: '공유 리소스 관리', description: '공유 링크와 권한, 활동 이력 관리', category: '관리' }, { value: 'plan-board', label: '자동화 현황', description: '작업 현황과 요청 보드 접근', category: '관리' }, { value: 'app-settings', label: '앱 설정', description: '헤더 설정, 알림, 업데이트 화면 접근', category: '관리' }, { value: 'server-command', label: '서버관리', description: '서버 상태 확인과 재기동 예약/실행 접근', category: '관리' }, { value: 'resource-manager', label: '리소스 관리', description: '세션 리소스와 파일 미리보기 접근', category: '관리' }, { value: 'error-log', label: '에러 로그', description: '앱 로그와 장애 이력 조회', category: '관리' }, ]; const PLAY_APP_OPTIONS: AppOption[] = getReadyPlayAppEntries().map((entry) => ({ value: entry.id, label: entry.name, description: entry.searchDescription ?? `${entry.name} 앱 실행`, category: 'Play', })); const APP_OPTIONS: AppOption[] = [...MANAGEMENT_APP_OPTIONS, ...PLAY_APP_OPTIONS]; const APP_OPTION_LABEL_MAP = new Map(APP_OPTIONS.map((item) => [item.value, item.label] as const)); const EMPTY_FORM_VALUE: TokenSettingFormValue = { id: '', name: '', description: '', defaultExpiresInMinutes: 60, maxTokensPer30Days: 0, maxTokensPer7Days: 100_000, maxTokensPer5Hours: 100_000, oneTimeTokenLimit: 0, allowedAppIds: [], enabled: true, }; function normalizeSettingId(value: string | null | undefined) { return String(value ?? '') .trim() .toLowerCase() .replace(/\s+/g, '-') .replace(/[^a-z0-9._-]/g, ''); } function toFormValue(setting: TokenSettingRecord | null): TokenSettingFormValue { if (!setting) { return EMPTY_FORM_VALUE; } return { originalId: setting.id, id: setting.id, name: setting.name, description: setting.description, defaultExpiresInMinutes: setting.defaultExpiresInMinutes, maxTokensPer30Days: setting.maxTokensPer30Days, maxTokensPer7Days: setting.maxTokensPer7Days, maxTokensPer5Hours: setting.maxTokensPer5Hours, oneTimeTokenLimit: setting.oneTimeTokenLimit, allowedAppIds: setting.allowedAppIds, enabled: setting.enabled, }; } function formatDuration(minutes: number) { if (minutes <= 0) { return '무제한'; } if (minutes % (60 * 24) === 0) { return `${minutes / (60 * 24)}일`; } if (minutes % 60 === 0) { return `${minutes / 60}시간`; } return `${minutes}분`; } function formatTokenLimit(value: number) { if (value <= 0) { return '무제한'; } return `${value.toLocaleString('ko-KR')} 토큰`; } function formatUnlimitedNumberInput(value: string | number | null | undefined, unit: string) { if (value === null || value === undefined || value === '') { return ''; } const normalized = Number(String(value).replace(/[^\d.-]/g, '')); if (!Number.isFinite(normalized)) { return String(value); } if (normalized <= 0) { return '무제한'; } return `${normalized.toLocaleString('ko-KR')} ${unit}`; } function parseUnlimitedNumberInput(value: string | undefined) { if (!value) { return ''; } if (value.includes('무제한')) { return '0'; } return value.replace(/[^\d]/g, ''); } function formatQuotaSummary(setting: TokenSettingRecord) { return [`7일 ${formatTokenLimit(setting.maxTokensPer7Days)}`, `5시간 ${formatTokenLimit(setting.maxTokensPer5Hours)}`].join(' / '); } function resolveAppLabels(appIds: string[]) { return appIds.map((item) => APP_OPTION_LABEL_MAP.get(item) ?? item); } export function TokenSettingManagementPage({ sharedPreviewTokenSetting = null, sharedAccess = null, }: { sharedPreviewTokenSetting?: SharedTokenSettingPreview | null; sharedAccess?: SharedTokenSettingAccess | null; }) { const { message } = App.useApp(); const { hasAccess } = useTokenAccess(); const isSharedManageMode = !hasAccess && Boolean(sharedAccess?.canManage && sharedAccess.shareToken); const isSharedPreviewMode = !hasAccess && Boolean(sharedPreviewTokenSetting) && !isSharedManageMode; const sharedPreviewRecord = useMemo( () => sharedPreviewTokenSetting ? { id: sharedPreviewTokenSetting.id, name: sharedPreviewTokenSetting.name, description: '', defaultExpiresInMinutes: sharedPreviewTokenSetting.defaultExpiresInMinutes, maxExpiresInMinutes: sharedPreviewTokenSetting.defaultExpiresInMinutes, maxTokensPer30Days: sharedPreviewTokenSetting.maxTokensPer30Days, maxTokensPer7Days: sharedPreviewTokenSetting.maxTokensPer7Days, maxTokensPer5Hours: sharedPreviewTokenSetting.maxTokensPer5Hours, oneTimeTokenLimit: sharedPreviewTokenSetting.oneTimeTokenLimit, allowedAppIds: sharedPreviewTokenSetting.allowedAppIds, enabled: true, updatedAt: '', } : null, [sharedPreviewTokenSetting], ); const { tokenSettings, setTokenSettings, isLoading, errorMessage } = useTokenSettingRegistry( hasAccess || isSharedManageMode, isSharedManageMode ? { shareToken: sharedAccess?.shareToken } : undefined, ); const [selectedTokenSettingId, setSelectedTokenSettingId] = useState( sharedPreviewTokenSetting?.id ?? tokenSettings[0]?.id ?? null, ); const [detailMode, setDetailMode] = useState<'list' | 'detail'>(sharedPreviewTokenSetting ? 'detail' : 'list'); const [isCreating, setIsCreating] = useState(false); const [isSaving, setIsSaving] = useState(false); const [saveErrorMessage, setSaveErrorMessage] = useState(''); const [saveSuccessMessage, setSaveSuccessMessage] = useState(''); const [activeDetailTab, setActiveDetailTab] = useState<'basic' | 'quota' | 'apps' | 'history'>('basic'); const [activities, setActivities] = useState([]); const [isActivityLoading, setIsActivityLoading] = useState(false); const [form] = Form.useForm(); const [modalApi, modalContextHolder] = Modal.useModal(); const lastHydratedFormKeyRef = useRef(''); const selectedTokenSetting = useMemo( () => tokenSettings.find((item) => item.id === selectedTokenSettingId) ?? null, [selectedTokenSettingId, tokenSettings], ); const effectiveSelectedTokenSetting = selectedTokenSetting ?? (isSharedPreviewMode || isSharedManageMode ? sharedPreviewRecord : null); useEffect(() => { if (isSharedPreviewMode || isSharedManageMode) { setSelectedTokenSettingId(sharedPreviewTokenSetting?.id ?? null); if (detailMode !== 'detail') { setDetailMode('detail'); } return; } if (selectedTokenSettingId && tokenSettings.some((item) => item.id === selectedTokenSettingId)) { return; } setSelectedTokenSettingId(tokenSettings[0]?.id ?? null); }, [detailMode, isSharedManageMode, isSharedPreviewMode, selectedTokenSettingId, sharedPreviewTokenSetting?.id, tokenSettings]); useEffect(() => { if (detailMode !== 'detail') { lastHydratedFormKeyRef.current = ''; return; } const nextFormKey = isCreating ? '__create__' : effectiveSelectedTokenSetting?.id ?? '__empty__'; if (lastHydratedFormKeyRef.current === nextFormKey) { return; } lastHydratedFormKeyRef.current = nextFormKey; form.resetFields(); form.setFieldsValue(toFormValue(isCreating ? null : effectiveSelectedTokenSetting)); }, [detailMode, effectiveSelectedTokenSetting?.id, form, isCreating]); useEffect(() => { if (detailMode !== 'detail' || isCreating || !effectiveSelectedTokenSetting?.id || isSharedPreviewMode) { setActivities([]); setIsActivityLoading(false); return; } let cancelled = false; setIsActivityLoading(true); void fetchTokenSettingActivities( effectiveSelectedTokenSetting.id, isSharedManageMode ? { shareToken: sharedAccess?.shareToken } : undefined, ) .then((nextItems) => { if (!cancelled) { setActivities(nextItems); } }) .catch(() => { if (!cancelled) { setActivities([]); } }) .finally(() => { if (!cancelled) { setIsActivityLoading(false); } }); return () => { cancelled = true; }; }, [detailMode, effectiveSelectedTokenSetting?.id, isCreating, isSharedManageMode, isSharedPreviewMode, sharedAccess?.shareToken]); const activityColumns = useMemo( () => [ { title: '시각', dataIndex: 'createdAt', key: 'createdAt', render: (value: string) => new Date(value).toLocaleString('ko-KR'), }, { title: '유형', dataIndex: 'activityType', key: 'activityType', render: (value: TokenSettingActivityRecord['activityType']) => {value}, }, { title: '내용', dataIndex: 'summary', key: 'summary', }, { title: '변경 상세', dataIndex: 'detail', key: 'detail', render: (value: string | null) => value || '-', }, { title: 'IP', key: 'ip', render: (_value: unknown, item: TokenSettingActivityRecord) => { const lines = [ item.externalIp ? `외부 ${item.externalIp}` : null, item.clientIp ? `서버 ${item.clientIp}` : null, item.forwardedFor ? `XFF ${item.forwardedFor}` : null, item.clientId ? `client ${item.clientId}` : null, ].filter(Boolean); return lines.length > 0 ? {lines.join('\n')} : '-'; }, }, ], [], ); const openCreateForm = () => { setIsCreating(true); setSelectedTokenSettingId(null); setDetailMode('detail'); setSaveErrorMessage(''); setSaveSuccessMessage(''); setActiveDetailTab('basic'); form.resetFields(); form.setFieldsValue(EMPTY_FORM_VALUE); }; const openDetail = (tokenSettingId: string) => { setIsCreating(false); setSelectedTokenSettingId(tokenSettingId); setDetailMode('detail'); setSaveErrorMessage(''); setSaveSuccessMessage(''); setActiveDetailTab('basic'); }; const closeDetail = () => { setIsCreating(false); setDetailMode('list'); setSaveErrorMessage(''); setSaveSuccessMessage(''); }; const handleDelete = async () => { if (!selectedTokenSetting) { return; } const confirmed = await confirmWithKeyboard(modalApi, { title: `"${selectedTokenSetting.name}" 토큰 설정을 삭제할까요?`, okText: '삭제', cancelText: '취소', okButtonProps: { danger: true }, }); if (!confirmed) { return; } const nextTokenSettings = deleteTokenSetting(tokenSettings, selectedTokenSetting.id); setIsSaving(true); setSaveErrorMessage(''); setSaveSuccessMessage(''); try { const savedTokenSettings = await setTokenSettings(nextTokenSettings); setSelectedTokenSettingId(savedTokenSettings[0]?.id ?? null); setIsCreating(false); setDetailMode('list'); form.resetFields(); form.setFieldsValue(EMPTY_FORM_VALUE); message.success('토큰 설정을 삭제했습니다.'); } catch (error) { setSaveErrorMessage(error instanceof Error ? error.message : '토큰 설정 삭제에 실패했습니다.'); } finally { setIsSaving(false); } }; if (!hasAccess) { if (!isSharedPreviewMode && !isSharedManageMode) { return ( <> {modalContextHolder} ); } } return (
{modalContextHolder} {detailMode === 'list' ? ( } onClick={openCreateForm}> 신규 설정 ) } >
{errorMessage ? : null} {saveErrorMessage ? : null}
등록 토큰 설정 {isLoading ? '불러오는 중' : `${tokenSettings.length}건`}
설정 ID를 기준으로 이후 토큰 발급기와 공유 채팅방이 권한, 유효시간, 7일/5시간 사용량 기준을 초기값으로 가져갑니다. {tokenSettings.length > 0 ? ( ( openDetail(item.id)} actions={[
) : ( 공유 링크 ) : isSharedManageMode ? ( 공유 링크 관리 ) : (