import { DeleteOutlined, LinkOutlined, PlusOutlined, QrcodeOutlined, ReloadOutlined, SaveOutlined, StopOutlined, UnorderedListOutlined } from '@ant-design/icons'; import { Alert, App, Button, Card, Checkbox, Drawer, Empty, Flex, Form, Input, InputNumber, Modal, QRCode, Select, Space, Table, Tabs, Tag, Typography } from 'antd'; import { useEffect, useLayoutEffect, useMemo, useState, type Key, type MouseEvent as ReactMouseEvent } from 'react'; import { getReadyPlayAppEntries } from '../../views/play/apps/apps/appsRegistry'; import { copyTextToClipboard } from '../../utils/clipboard'; import { openExternalLinkInNewWindow } from './mainChatPanel/linkNavigation'; import { resolveChatPathForSession } from './chatSessionRouting'; import { useTokenAccess } from './tokenAccess'; import { confirmWithKeyboard } from './modalKeyboard'; import { deleteSharedResourceTokens, deleteSharedResourceToken, loadSharedResourceTokenDetail, revokeSharedResourceTokens, restoreSharedResourceToken, revokeSharedResourceToken, saveSharedResourceToken, useSharedResourceTokenRegistry, type SharedResourcePermission, type SharedResourceTokenActivityRecord, type SharedResourceTokenInput, type SharedResourceTokenRecord, type SharedResourceType, } from './sharedResourceTokenAccess'; import { createInstallManifestObjectUrl, swapInstallDocumentMetadata } from './pwa/installManifest'; import './SharedResourceManagementPage.css'; const { Paragraph, Text, Title } = Typography; const PERMISSION_OPTIONS: Array<{ value: SharedResourcePermission; label: string; description: string }> = [ { value: 'view', label: '조회', description: '리소스 내용을 열람합니다.' }, { value: 'download', label: '다운로드', description: '파일 또는 산출물을 내려받습니다.' }, { value: 'comment', label: '의견등록', description: '댓글 또는 후속 응답을 남깁니다.' }, { value: 'upload', label: '업로드', description: '첨부 파일과 대체 산출물을 올립니다.' }, { value: 'manage', label: '관리', description: '권한 변경과 토큰 재발급을 허용합니다.' }, ]; const RESOURCE_TYPE_OPTIONS: Array<{ value: SharedResourceType; label: string }> = [ { value: 'directory', label: '폴더' }, { value: 'file', label: '파일' }, { value: 'document', label: '문서' }, { value: 'chat-share', label: '공유 채팅' }, { value: 'external-url', label: '외부 URL' }, ]; const MANAGEMENT_APP_OPTIONS = [ { 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: '관리' }, ] as const; const PLAY_APP_OPTIONS = getReadyPlayAppEntries().map((entry) => ({ value: entry.id, label: entry.name, description: entry.searchDescription ?? `${entry.name} 앱 실행`, category: 'Play' as const, })); const APP_OPTIONS = [...MANAGEMENT_APP_OPTIONS, ...PLAY_APP_OPTIONS]; const APP_OPTION_LABEL_MAP = new Map(APP_OPTIONS.map((option) => [option.value, option.label] as const)); const ADMIN_PERMISSION_PRESET: SharedResourcePermission[] = ['view', 'download', 'comment', 'upload', 'manage']; const ADMIN_ALLOWED_APP_PRESET = MANAGEMENT_APP_OPTIONS.map((option) => option.value); type SharedResourceManagementSharedPreview = { managedResourceTokenId?: string | null; sharePath?: string | null; expiresAt?: string | null; tokenSetting?: { id: string; name: string; defaultExpiresInMinutes: number; allowedAppIds: string[]; } | null; }; type SharedResourceManagementSharedAccess = { shareToken: string; managedResourceTokenId?: string | null; }; type ConversationDrawerState = { tokenId: string; tokenName: string; url: string; }; function isChatShareToken>( item: T | null | undefined, ): item is T & { resourceType: 'chat-share' } { return item?.resourceType === 'chat-share'; } type SharedResourceTokenFormValue = { id?: string; name: string; description: string; tokenSettingId: string; resourcePath: string; resourceType: SharedResourceType; shareToken?: string; sharePath?: string; allowedAppIds: string[]; permissions: SharedResourcePermission[]; enabled: boolean; expiresAt?: string; usageLimit: number; }; const SHARED_RESOURCE_INSTALL_THEME_COLOR = '#0f766e'; const SHARED_RESOURCE_INSTALL_BACKGROUND_COLOR = '#f3fbf9'; const SHARED_RESOURCE_INSTALL_TITLE = '공유 리소스 관리'; function buildSharedResourceInstallTitle(isSharedManageMode: boolean) { return isSharedManageMode ? '공유 리소스 관리 링크' : SHARED_RESOURCE_INSTALL_TITLE; } const EMPTY_FORM_VALUE: SharedResourceTokenFormValue = { name: '', description: '', tokenSettingId: '', resourcePath: '', resourceType: 'directory', shareToken: '', sharePath: '', allowedAppIds: [], permissions: ['view'], enabled: true, expiresAt: '', usageLimit: 0, }; function formatDateTime(value: string | null | undefined) { if (!value) { return '미기록'; } return new Date(value).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul', }); } function formatUsage(value: number) { return `${value.toLocaleString('ko-KR')}회`; } function formatTokenUsage(value: number) { return `${Math.max(0, Math.round(Number(value) || 0)).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 formatPermissionLabel(permission: SharedResourcePermission) { return PERMISSION_OPTIONS.find((option) => option.value === permission)?.label ?? permission; } function formatAppLabel(appId: string) { return APP_OPTION_LABEL_MAP.get(appId) ?? appId; } function formatDurationMinutesLabel(totalMinutes: number | null | undefined) { const normalizedMinutes = Number(totalMinutes ?? 0); if (!Number.isFinite(normalizedMinutes)) { return ''; } if (normalizedMinutes <= 0) { return '무제한'; } const roundedMinutes = Math.max(1, Math.round(normalizedMinutes)); const days = Math.floor(roundedMinutes / (24 * 60)); const hours = Math.floor((roundedMinutes % (24 * 60)) / 60); const minutes = roundedMinutes % 60; const parts: string[] = []; if (days > 0) { parts.push(`${days}일`); } if (hours > 0) { parts.push(`${hours}시간`); } if (minutes > 0 || parts.length === 0) { parts.push(`${minutes}분`); } return parts.join(' '); } function formatRemainingTimeLabel(expiresAt: string, nowMs: number) { const expiresAtMs = new Date(expiresAt).getTime(); if (!Number.isFinite(expiresAtMs)) { return ''; } const diffMs = expiresAtMs - nowMs; if (diffMs <= 0) { return '남은시간 없음'; } const totalMinutes = Math.floor(diffMs / (60 * 1000)); const days = Math.floor(totalMinutes / (24 * 60)); const hours = Math.floor((totalMinutes % (24 * 60)) / 60); const minutes = totalMinutes % 60; const parts: string[] = []; if (days > 0) { parts.push(`${days}일`); } if (hours > 0) { parts.push(`${hours}시간`); } if (minutes > 0 || parts.length === 0) { parts.push(`${minutes}분`); } return `남은시간 ${parts.join(' ')}`; } function formatExpirySummary(value: string | null | undefined, nowMs: number) { if (!value) { return '만료 미설정'; } const remainingTime = formatRemainingTimeLabel(value, nowMs); if (remainingTime) { return remainingTime; } return '만료 시각 확인 필요'; } function resolveEffectiveExpiresAt(item: SharedResourceTokenRecord) { const explicitExpiresAt = item.effectiveExpiresAt ?? item.expiresAt; if (explicitExpiresAt) { return explicitExpiresAt; } const defaultExpiresInMinutes = Number(item.tokenSettingSnapshot?.defaultExpiresInMinutes ?? 0); const createdAtMs = Date.parse(item.createdAt); if (!Number.isFinite(defaultExpiresInMinutes) || defaultExpiresInMinutes <= 0 || !Number.isFinite(createdAtMs)) { return null; } return new Date(createdAtMs + defaultExpiresInMinutes * 60 * 1000).toISOString(); } function formatEffectiveExpirySummary(item: SharedResourceTokenRecord, nowMs: number) { const effectiveExpiresAt = resolveEffectiveExpiresAt(item); const baseSummary = formatExpirySummary(effectiveExpiresAt, nowMs); return baseSummary; } function resolveStatusMeta(item: SharedResourceTokenRecord) { if (item.revokedAt) { return { color: 'red', label: '회수됨' }; } if (!item.enabled) { return { color: 'default', label: '비활성' }; } const expiresAt = resolveEffectiveExpiresAt(item); if (expiresAt && Date.parse(expiresAt) < Date.now()) { return { color: 'orange', label: '만료' }; } return { color: 'green', label: '사용중' }; } function resolveLinkedSettingStatusMeta(item: SharedResourceTokenRecord) { if (item.tokenSettingSnapshot) { return { color: 'cyan', label: `${item.tokenSettingSnapshot.name} 스냅샷` }; } if (!item.tokenSettingId) { return { color: 'default', label: '미연결' }; } return { color: 'default', label: `${item.tokenSettingId} 기준 스냅샷 없음` }; } function resolveFormAllowedAppIds(item: SharedResourceTokenRecord) { const effectiveAllowedAppIds = item.allowedAppIds.length > 0 ? item.allowedAppIds : item.resourceAllowedAppIds.length > 0 ? item.resourceAllowedAppIds : item.tokenSettingSnapshot?.allowedAppIds ?? []; return Array.from(new Set(effectiveAllowedAppIds.map((appId) => appId.trim()).filter(Boolean))); } function toFormValue(item: SharedResourceTokenRecord | null): SharedResourceTokenFormValue { if (!item) { return EMPTY_FORM_VALUE; } return { id: item.id, name: item.name, description: item.description, tokenSettingId: isChatShareToken(item) ? '' : item.tokenSettingId ?? '', resourcePath: item.resourcePath, resourceType: item.resourceType, shareToken: item.shareToken, sharePath: item.sharePath, allowedAppIds: resolveFormAllowedAppIds(item), permissions: item.permissions, enabled: item.enabled, expiresAt: item.expiresAt ?? '', usageLimit: item.usageLimit, }; } function buildAbsoluteShareUrl(path: string | null | undefined) { const normalizedPath = path?.trim() ?? ''; if (!normalizedPath) { return '-'; } if (/^https?:\/\//i.test(normalizedPath)) { return normalizedPath; } if (typeof window === 'undefined') { return normalizedPath; } return new URL(normalizedPath, window.location.origin).toString(); } function decodeBase64Url(value: string) { const normalized = value.replace(/-/g, '+').replace(/_/g, '/'); const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4); if (typeof window === 'undefined' || typeof window.atob !== 'function') { return null; } try { return window.atob(padded); } catch { return null; } } function resolveChatShareSessionId(path: string | null | undefined) { const normalizedPath = path?.trim() ?? ''; if (!normalizedPath) { return null; } try { const url = /^https?:\/\//i.test(normalizedPath) && typeof window !== 'undefined' ? new URL(normalizedPath) : new URL(normalizedPath, typeof window !== 'undefined' ? window.location.origin : 'https://preview.sm-home.cloud'); const prefix = '/chat/share/'; if (!url.pathname.startsWith(prefix)) { return null; } const encodedToken = decodeURIComponent(url.pathname.slice(prefix.length).trim()); const delimiterIndex = encodedToken.lastIndexOf('.'); if (delimiterIndex <= 0) { return null; } const payloadText = decodeBase64Url(encodedToken.slice(0, delimiterIndex)); if (!payloadText) { return null; } const payload = JSON.parse(payloadText) as { sessionId?: unknown }; return typeof payload.sessionId === 'string' && payload.sessionId.trim() ? payload.sessionId.trim() : null; } catch { return null; } } function buildChatConversationUrl(item: Pick | null | undefined) { if (!isChatShareToken(item)) { return null; } const shareUrl = buildAbsoluteShareUrl(item.sharePath); if (shareUrl !== '-') { return shareUrl; } const sessionId = resolveChatShareSessionId(item.sharePath || item.resourcePath); if (!sessionId) { return null; } return buildAbsoluteShareUrl(`${resolveChatPathForSession(sessionId)}?sessionId=${encodeURIComponent(sessionId)}`); } function sanitizeActivityDetail(value: string | null | undefined) { const normalized = value?.trim() ?? ''; if (!normalized) { return null; } const withoutAbsoluteUrls = normalized.replace(/https?:\/\/\S+/giu, '').trim(); const withoutSharePaths = withoutAbsoluteUrls .replace(/(?:^|\s)(?:\/chat\/share\/|\/share\/resource\/)\S*/giu, ' ') .replace(/\s{2,}/g, ' ') .trim(); return withoutSharePaths || null; } function SharedResourceQrPanel({ shareUrl, title = '공유 QR', }: { shareUrl: string; title?: string; }) { return (
{title}
{shareUrl} 모바일 카메라로 바로 열 수 있습니다.
); } export function SharedResourceManagementPage({ sharedPreview = null, sharedAccess = null, disableInstallMetadata = false, }: { sharedPreview?: SharedResourceManagementSharedPreview | null; sharedAccess?: SharedResourceManagementSharedAccess | null; disableInstallMetadata?: boolean; }) { const { message } = App.useApp(); const { hasAccess } = useTokenAccess(); const isSharedManageMode = !hasAccess && Boolean(sharedAccess?.shareToken); const { tokens, isLoading, errorMessage, refresh } = useSharedResourceTokenRegistry( isSharedManageMode ? { shareToken: sharedAccess?.shareToken } : undefined, ); const [selectedTokenId, setSelectedTokenId] = useState(null); const [detailMode, setDetailMode] = useState<'list' | 'detail'>('list'); const [isCreating, setIsCreating] = useState(false); const [isSaving, setIsSaving] = useState(false); const [isDetailLoading, setIsDetailLoading] = useState(false); const [saveErrorMessage, setSaveErrorMessage] = useState(''); const [detailData, setDetailData] = useState<{ token: SharedResourceTokenRecord; activities: SharedResourceTokenActivityRecord[] } | null>(null); const [nowMs, setNowMs] = useState(() => Date.now()); const [activeDetailTab, setActiveDetailTab] = useState<'basic' | 'settings' | 'history'>('basic'); const [selectedRowKeys, setSelectedRowKeys] = useState([]); const [qrPreviewTokenId, setQrPreviewTokenId] = useState(null); const [conversationDrawer, setConversationDrawer] = useState(null); const [conversationDrawerKey, setConversationDrawerKey] = useState(0); const [form] = Form.useForm(); const [modalApi, modalContextHolder] = Modal.useModal(); useLayoutEffect(() => { if (disableInstallMetadata || typeof window === 'undefined') { return undefined; } const startPath = `${window.location.pathname}${window.location.search}${window.location.hash}`; const installTitle = buildSharedResourceInstallTitle(isSharedManageMode); const manifestObjectUrl = createInstallManifestObjectUrl({ startPath, scope: window.location.pathname, name: installTitle, shortName: '공유 리소스', description: isSharedManageMode ? '공유 리소스 관리 링크를 홈 화면 앱으로 바로 엽니다.' : '공유 리소스 관리 화면을 홈 화면 앱으로 바로 엽니다.', themeColor: SHARED_RESOURCE_INSTALL_THEME_COLOR, backgroundColor: SHARED_RESOURCE_INSTALL_BACKGROUND_COLOR, }); const restoreManifest = swapInstallDocumentMetadata({ manifestHref: manifestObjectUrl, title: installTitle, themeColor: SHARED_RESOURCE_INSTALL_THEME_COLOR, }); return () => { restoreManifest(); if (manifestObjectUrl) { window.URL.revokeObjectURL(manifestObjectUrl); } }; }, [disableInstallMetadata, isSharedManageMode]); useEffect(() => { const intervalId = window.setInterval(() => { setNowMs(Date.now()); }, 60 * 1000); return () => window.clearInterval(intervalId); }, []); useEffect(() => { if (isSharedManageMode) { const nextSelectedTokenId = sharedAccess?.managedResourceTokenId?.trim() || tokens[0]?.id || null; setSelectedTokenId(nextSelectedTokenId); setDetailMode('detail'); setIsCreating(false); return; } if (selectedTokenId && tokens.some((item) => item.id === selectedTokenId)) { return; } setSelectedTokenId(tokens[0]?.id ?? null); }, [isSharedManageMode, selectedTokenId, sharedAccess?.managedResourceTokenId, tokens]); useEffect(() => { if (selectedRowKeys.length === 0) { return; } const tokenIdSet = new Set(tokens.map((item) => item.id)); const nextSelectedRowKeys = selectedRowKeys.filter((key) => tokenIdSet.has(typeof key === 'string' ? key : String(key))); if (nextSelectedRowKeys.length !== selectedRowKeys.length) { setSelectedRowKeys(nextSelectedRowKeys); } }, [selectedRowKeys, tokens]); useEffect(() => { if (detailMode !== 'detail') { return; } if (isCreating) { form.resetFields(); form.setFieldsValue(EMPTY_FORM_VALUE); setDetailData(null); return; } if (!selectedTokenId) { setDetailData(null); return; } let cancelled = false; const loadDetail = async () => { setIsDetailLoading(true); setSaveErrorMessage(''); try { const detail = await loadSharedResourceTokenDetail( selectedTokenId, isSharedManageMode ? { shareToken: sharedAccess?.shareToken } : undefined, ); if (cancelled) { return; } setDetailData(detail); form.resetFields(); form.setFieldsValue(toFormValue(detail.token)); } catch (error) { if (!cancelled && !(error instanceof DOMException && error.name === 'AbortError')) { setSaveErrorMessage(error instanceof Error ? error.message : '상세 정보를 불러오지 못했습니다.'); } } finally { if (!cancelled) { setIsDetailLoading(false); } } }; void loadDetail(); return () => { cancelled = true; }; }, [detailMode, form, isCreating, isSharedManageMode, selectedTokenId, sharedAccess?.shareToken]); const summary = useMemo(() => { const activeCount = tokens.filter((item) => !item.revokedAt && item.enabled).length; const revokedCount = tokens.filter((item) => Boolean(item.revokedAt)).length; const usageTotal = tokens.reduce((sum, item) => sum + item.usageCount, 0); const tokenUsageTotal = tokens.reduce((sum, item) => sum + item.usageTokenTotal, 0); const activityTotal = tokens.reduce((sum, item) => sum + item.activityCount, 0); return { activeCount, revokedCount, usageTotal, tokenUsageTotal, activityTotal, }; }, [tokens]); const selectedTokenIds = useMemo( () => selectedRowKeys .map((value) => (typeof value === 'string' ? value : String(value))) .filter((value): value is string => Boolean(value)), [selectedRowKeys], ); const selectedTokens = useMemo( () => tokens.filter((item) => selectedTokenIds.includes(item.id)), [selectedTokenIds, tokens], ); const bulkRevocableTokenIds = useMemo( () => selectedTokens.filter((item) => !item.revokedAt).map((item) => item.id), [selectedTokens], ); const bulkDeletableTokenIds = useMemo(() => selectedTokens.map((item) => item.id), [selectedTokens]); const qrPreviewToken = useMemo(() => tokens.find((item) => item.id === qrPreviewTokenId) ?? null, [qrPreviewTokenId, tokens]); const qrPreviewUrl = useMemo( () => (qrPreviewToken?.sharePath ? buildAbsoluteShareUrl(qrPreviewToken.sharePath) : ''), [qrPreviewToken], ); const detailConversationUrl = useMemo( () => (detailData?.token ? buildChatConversationUrl(detailData.token) : null), [detailData], ); const openConversationWindow = (url: string, event?: ReactMouseEvent) => { openExternalLinkInNewWindow(url, { event, allowSameTabFallback: false, onUnsupportedStandalone: (fallbackUrl) => { void copyTextToClipboard(fallbackUrl) .then(() => { message.info('현재 창은 유지하고 공유채팅 URL만 복사했습니다. 브라우저나 새 PWA 창에서 붙여 열어 주세요.'); }) .catch(() => { message.info('현재 창은 유지했습니다. 새 창 열기가 막히면 QR 코드나 URL 복사로 이어서 열어 주세요.'); }); }, }); }; const openConversationDrawer = (tokenId: string, tokenName: string, url: string) => { setConversationDrawer({ tokenId, tokenName, url, }); setConversationDrawerKey((current) => current + 1); }; const listColumns = useMemo( () => [ { title: '리소스', key: 'resource', ellipsis: true, render: (_value: unknown, item: SharedResourceTokenRecord) => { const linkedSettingMeta = resolveLinkedSettingStatusMeta(item); return (
{item.name} {resolveStatusMeta(item).label}
{item.resourceLabel && item.resourceLabel !== item.name ? ( {item.resourceLabel} ) : null} {RESOURCE_TYPE_OPTIONS.find((option) => option.value === item.resourceType)?.label ?? item.resourceType} {item.tokenSettingId ? {linkedSettingMeta.label} : null} {item.linkedTokenSetting?.syncMessage ? ( {item.linkedTokenSetting.syncMessage} ) : null}
); }, }, { title: '권한', key: 'permissions', width: 188, render: (_value: unknown, item: SharedResourceTokenRecord) => (
{item.permissions.map((permission) => ( {formatPermissionLabel(permission)} ))}
), }, { title: '사용', key: 'usage', width: 132, render: (_value: unknown, item: SharedResourceTokenRecord) => (
{formatTokenUsage(item.usageTokenTotal)} {`요청 ${item.usageRequestCount}건 · 사용 ${formatUsage(item.usageCount)}`}
), }, { title: '최근', key: 'recent', width: 220, render: (_value: unknown, item: SharedResourceTokenRecord) => (
{formatDateTime(item.lastTokenUsedAt ?? item.lastActivityAt ?? item.updatedAt)} {item.usageCompletedRequestCount > 0 ? `완료 ${item.usageCompletedRequestCount}건 · ${formatEffectiveExpirySummary(item, nowMs)}` : formatEffectiveExpirySummary(item, nowMs)}
), }, { title: '채팅', key: 'chat', width: 108, align: 'center' as const, render: (_value: unknown, item: SharedResourceTokenRecord) => { const conversationUrl = buildChatConversationUrl(item); return ( ); }, }, { title: 'QR', key: 'qr', width: 96, align: 'center' as const, render: (_value: unknown, item: SharedResourceTokenRecord) => ( ), }, ], [nowMs], ); const activityColumns = useMemo( () => [ { title: '시각', dataIndex: 'createdAt', key: 'createdAt', render: (value: string) => formatDateTime(value), }, { title: '유형', dataIndex: 'type', key: 'type', render: (value: SharedResourceTokenActivityRecord['type']) => {value}, }, { title: '내용', dataIndex: 'summary', key: 'summary', }, { title: '비고', dataIndex: 'detail', key: 'detail', render: (value: string | null) => sanitizeActivityDetail(value) ?? '-', }, { title: '접속 정보', key: 'ip', render: (_value: unknown, item: SharedResourceTokenActivityRecord) => { 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')} ) : '-'; }, }, { title: '사용량', dataIndex: 'usageDelta', key: 'usageDelta', render: (value: number) => (value > 0 ? `+${value}` : '-'), }, ], [], ); const openCreateForm = () => { setIsCreating(true); setSelectedTokenId(null); setDetailMode('detail'); setActiveDetailTab('basic'); setDetailData(null); form.resetFields(); form.setFieldsValue(EMPTY_FORM_VALUE); }; const openDetail = (tokenId: string) => { setIsCreating(false); setSelectedTokenId(tokenId); setSelectedRowKeys([tokenId]); setDetailMode('detail'); }; const closeDetail = () => { setIsCreating(false); setDetailMode('list'); setActiveDetailTab('basic'); setDetailData(null); }; const handleSave = async (values: SharedResourceTokenFormValue) => { setIsSaving(true); setSaveErrorMessage(''); try { if (values.expiresAt?.trim() && Number.isNaN(Date.parse(values.expiresAt))) { throw new Error('만료 시각은 ISO 날짜 형식으로 입력해 주세요.'); } const payload: SharedResourceTokenInput = { id: values.id ?? detailData?.token.id, name: values.name, description: values.description, tokenSettingId: values.tokenSettingId?.trim() || undefined, resourceLabel: values.name, resourcePath: values.resourcePath, resourceType: values.resourceType, shareToken: values.shareToken?.trim() || undefined, sharePath: values.sharePath?.trim() || undefined, resourceAllowedAppIds: Array.from(new Set(values.allowedAppIds.map((appId) => appId.trim()).filter(Boolean))), resourceAllowedAppIdsOverrideEnabled: true, permissions: values.permissions, enabled: values.enabled, expiresAt: values.expiresAt?.trim() ? new Date(values.expiresAt).toISOString() : null, usageLimit: values.usageLimit, }; const saved = await saveSharedResourceToken(payload, isSharedManageMode ? { shareToken: sharedAccess?.shareToken } : undefined); setIsCreating(false); setSelectedTokenId(saved.token.id); setDetailData(saved); form.setFieldsValue(toFormValue(saved.token)); await refresh(); } catch (error) { setSaveErrorMessage(error instanceof Error ? error.message : '공유 리소스 토큰 저장에 실패했습니다.'); } finally { setIsSaving(false); } }; const handleRevoke = async () => { if (!detailData?.token) { return; } const confirmed = await confirmWithKeyboard(modalApi, { title: `"${detailData.token.name}" 공유 토큰을 회수할까요?`, okText: '회수', cancelText: '취소', okButtonProps: { danger: true }, }); if (!confirmed) { return; } setIsSaving(true); setSaveErrorMessage(''); try { const saved = await revokeSharedResourceToken( detailData.token.id, '관리 화면에서 수동 회수', isSharedManageMode ? { shareToken: sharedAccess?.shareToken } : undefined, ); setDetailData(saved); form.setFieldsValue(toFormValue(saved.token)); await refresh(); } catch (error) { setSaveErrorMessage(error instanceof Error ? error.message : '공유 토큰 회수에 실패했습니다.'); } finally { setIsSaving(false); } }; const handleRestore = async () => { if (!detailData?.token) { return; } setIsSaving(true); setSaveErrorMessage(''); try { const saved = await restoreSharedResourceToken( detailData.token.id, isSharedManageMode ? { shareToken: sharedAccess?.shareToken } : undefined, ); setDetailData(saved); form.setFieldsValue(toFormValue(saved.token)); await refresh(); } catch (error) { setSaveErrorMessage(error instanceof Error ? error.message : '공유 토큰 복원에 실패했습니다.'); } finally { setIsSaving(false); } }; const handleDelete = async () => { if (!detailData?.token) { return; } const confirmed = await confirmWithKeyboard(modalApi, { title: `"${detailData.token.name}" 공유 토큰을 삭제할까요?`, content: '삭제 후 되돌릴 수 없고 연결된 공유 토큰 이력도 함께 제거됩니다.', okText: '삭제', cancelText: '취소', okButtonProps: { danger: true }, }); if (!confirmed) { return; } setIsSaving(true); setSaveErrorMessage(''); try { await deleteSharedResourceToken( detailData.token.id, isSharedManageMode ? { shareToken: sharedAccess?.shareToken } : undefined, ); await refresh(); closeDetail(); } catch (error) { setSaveErrorMessage(error instanceof Error ? error.message : '공유 토큰 삭제에 실패했습니다.'); } finally { setIsSaving(false); } }; const handleBulkRevoke = async () => { if (bulkRevocableTokenIds.length === 0) { return; } const confirmed = await confirmWithKeyboard(modalApi, { title: `선택한 ${bulkRevocableTokenIds.length}개 공유 토큰을 일괄 회수할까요?`, content: selectedTokens.length > bulkRevocableTokenIds.length ? '이미 회수된 항목은 그대로 두고, 사용 중인 항목만 회수합니다.' : '선택한 항목의 공유 접근을 즉시 중단합니다.', okText: '일괄 회수', cancelText: '취소', okButtonProps: { danger: true }, }); if (!confirmed) { return; } setIsSaving(true); setSaveErrorMessage(''); try { const result = await revokeSharedResourceTokens( bulkRevocableTokenIds, '관리 화면 목록에서 일괄 회수', isSharedManageMode ? { shareToken: sharedAccess?.shareToken } : undefined, ); await refresh(); setSelectedRowKeys(result.processedTokenIds); } catch (error) { setSaveErrorMessage(error instanceof Error ? error.message : '공유 토큰 일괄 회수에 실패했습니다.'); } finally { setIsSaving(false); } }; const handleBulkDelete = async () => { if (bulkDeletableTokenIds.length === 0) { return; } const confirmed = await confirmWithKeyboard(modalApi, { title: `선택한 ${bulkDeletableTokenIds.length}개 공유 토큰을 일괄 삭제할까요?`, content: '삭제 후 되돌릴 수 없고 연결된 공유 토큰 이력도 함께 제거됩니다.', okText: '일괄 삭제', cancelText: '취소', okButtonProps: { danger: true }, }); if (!confirmed) { return; } setIsSaving(true); setSaveErrorMessage(''); try { const result = await deleteSharedResourceTokens( bulkDeletableTokenIds, isSharedManageMode ? { shareToken: sharedAccess?.shareToken } : undefined, ); await refresh(); setSelectedRowKeys((currentKeys) => currentKeys.filter((key) => !result.processedTokenIds.includes(typeof key === 'string' ? key : String(key))), ); if (selectedTokenId && result.processedTokenIds.includes(selectedTokenId)) { closeDetail(); } } catch (error) { setSaveErrorMessage(error instanceof Error ? error.message : '공유 토큰 일괄 삭제에 실패했습니다.'); } finally { setIsSaving(false); } }; const sharedManageInfoAlert = isSharedManageMode ? ( ) : null; const applyAdminPreset = () => { const currentAllowedAppIds = form.getFieldValue('allowedAppIds') as string[] | undefined; const currentPermissions = form.getFieldValue('permissions') as SharedResourcePermission[] | undefined; form.setFieldsValue({ allowedAppIds: Array.from(new Set([...(currentAllowedAppIds ?? []), ...ADMIN_ALLOWED_APP_PRESET])), permissions: Array.from(new Set([...(currentPermissions ?? []), ...ADMIN_PERMISSION_PRESET])), }); }; if (!hasAccess && !isSharedManageMode) { if (sharedPreview) { return (
공유 링크 {buildAbsoluteShareUrl(sharedPreview.sharePath)}
발급 시점 토큰 정책 {sharedPreview.tokenSetting ? `${sharedPreview.tokenSetting.name} (${sharedPreview.tokenSetting.id})` : '스냅샷 정보 없음'} {sharedPreview.tokenSetting ? `기본 유효시간 ${formatDurationMinutesLabel(sharedPreview.tokenSetting.defaultExpiresInMinutes)}` : '기본 유효시간 정보 없음'} {sharedPreview.expiresAt ? formatExpirySummary(sharedPreview.expiresAt, nowMs) : '만료 미설정'}
허용 앱
{(sharedPreview.tokenSetting?.allowedAppIds ?? []).length > 0 ? ( sharedPreview.tokenSetting?.allowedAppIds.map((appId) => {appId}) ) : ( 등록된 허용 앱이 없습니다. )}
{sharedPreview.managedResourceTokenId ? ( 관리 토큰 ID {sharedPreview.managedResourceTokenId} ) : null}
); } return ( ); } return (
{modalContextHolder} setQrPreviewTokenId(null)} centered > {qrPreviewToken && qrPreviewUrl ? ( ) : ( )} { setConversationDrawer(null); }} extra={ conversationDrawer ? ( ) : null } styles={{ body: { padding: 0, }, }} > {conversationDrawer ? (