Files
ai-code-app/src/app/main/SharedResourceManagementPage.tsx
2026-05-27 10:43:01 +09:00

1743 lines
70 KiB
TypeScript

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<T extends Pick<SharedResourceTokenRecord, 'resourceType'>>(
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<SharedResourceTokenRecord, 'resourceType' | 'sharePath' | 'resourcePath'> | 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 (
<div className="shared-resource-management-page__qr-panel">
<Text strong>{title}</Text>
<div className="shared-resource-management-page__qr-code-wrap">
<QRCode value={shareUrl} size={168} bordered={false} />
</div>
<Paragraph
copyable={{ text: shareUrl }}
ellipsis={{ rows: 2, tooltip: shareUrl }}
className="shared-resource-management-page__qr-url"
>
{shareUrl}
</Paragraph>
<Text type="secondary"> .</Text>
</div>
);
}
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<string | null>(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<Key[]>([]);
const [qrPreviewTokenId, setQrPreviewTokenId] = useState<string | null>(null);
const [conversationDrawer, setConversationDrawer] = useState<ConversationDrawerState | null>(null);
const [conversationDrawerKey, setConversationDrawerKey] = useState(0);
const [form] = Form.useForm<SharedResourceTokenFormValue>();
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<HTMLElement>) => {
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 (
<div className="shared-resource-management-page__table-primary">
<div className="shared-resource-management-page__table-title-row">
<Text strong ellipsis={{ tooltip: item.name }}>
{item.name}
</Text>
<Tag color={resolveStatusMeta(item).color}>{resolveStatusMeta(item).label}</Tag>
</div>
<Space size={[6, 4]} wrap>
{item.resourceLabel && item.resourceLabel !== item.name ? (
<Text type="secondary" ellipsis={{ tooltip: item.resourceLabel }}>
{item.resourceLabel}
</Text>
) : null}
<Tag>{RESOURCE_TYPE_OPTIONS.find((option) => option.value === item.resourceType)?.label ?? item.resourceType}</Tag>
{item.tokenSettingId ? <Tag color={linkedSettingMeta.color}>{linkedSettingMeta.label}</Tag> : null}
</Space>
{item.linkedTokenSetting?.syncMessage ? (
<Text type="danger" className="shared-resource-management-page__table-warning">
{item.linkedTokenSetting.syncMessage}
</Text>
) : null}
</div>
);
},
},
{
title: '권한',
key: 'permissions',
width: 188,
render: (_value: unknown, item: SharedResourceTokenRecord) => (
<div className="shared-resource-management-page__table-tags">
{item.permissions.map((permission) => (
<Tag key={`${item.id}-${permission}`}>{formatPermissionLabel(permission)}</Tag>
))}
</div>
),
},
{
title: '사용',
key: 'usage',
width: 132,
render: (_value: unknown, item: SharedResourceTokenRecord) => (
<div className="shared-resource-management-page__table-metrics">
<Text>{formatTokenUsage(item.usageTokenTotal)}</Text>
<Text type="secondary">{`요청 ${item.usageRequestCount}건 · 사용 ${formatUsage(item.usageCount)}`}</Text>
</div>
),
},
{
title: '최근',
key: 'recent',
width: 220,
render: (_value: unknown, item: SharedResourceTokenRecord) => (
<div className="shared-resource-management-page__table-metrics">
<Text>{formatDateTime(item.lastTokenUsedAt ?? item.lastActivityAt ?? item.updatedAt)}</Text>
<Text
type="secondary"
ellipsis={{ tooltip: `${formatEffectiveExpirySummary(item, nowMs)}\n${item.description || item.sharePath || '-'}` }}
>
{item.usageCompletedRequestCount > 0
? `완료 ${item.usageCompletedRequestCount}건 · ${formatEffectiveExpirySummary(item, nowMs)}`
: formatEffectiveExpirySummary(item, nowMs)}
</Text>
</div>
),
},
{
title: '채팅',
key: 'chat',
width: 108,
align: 'center' as const,
render: (_value: unknown, item: SharedResourceTokenRecord) => {
const conversationUrl = buildChatConversationUrl(item);
return (
<Button
size="small"
icon={<LinkOutlined />}
disabled={!conversationUrl}
onClick={(event) => {
event.stopPropagation();
if (!conversationUrl) {
return;
}
openConversationDrawer(item.id, item.name, conversationUrl);
}}
>
</Button>
);
},
},
{
title: 'QR',
key: 'qr',
width: 96,
align: 'center' as const,
render: (_value: unknown, item: SharedResourceTokenRecord) => (
<Button
size="small"
icon={<QrcodeOutlined />}
disabled={!item.sharePath}
onClick={(event) => {
event.stopPropagation();
setQrPreviewTokenId(item.id);
}}
>
QR
</Button>
),
},
],
[nowMs],
);
const activityColumns = useMemo(
() => [
{
title: '시각',
dataIndex: 'createdAt',
key: 'createdAt',
render: (value: string) => formatDateTime(value),
},
{
title: '유형',
dataIndex: 'type',
key: 'type',
render: (value: SharedResourceTokenActivityRecord['type']) => <Tag>{value}</Tag>,
},
{
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 ? (
<Typography.Text style={{ whiteSpace: 'pre-line' }}>{lines.join('\n')}</Typography.Text>
) : '-';
},
},
{
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 ? (
<Alert
showIcon
type="info"
message="현재 공유 링크에 연결된 공유 토큰 상세를 직접 수정할 수 있습니다."
description="앱 권한, 채팅방 설정 접근, 만료 시각, 사용 한도, 공유 토큰 값, 권한 묶음까지 현재 토큰 범위 안에서 바로 재설정합니다."
/>
) : 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 (
<Card title="공유 리소스 관리" className="chat-type-management-page">
<Alert
showIcon
type="info"
message="공유 링크에서 허용된 리소스 관리 정보입니다."
description="관리자 권한 없이 열었으므로 현재 공유 링크와 연결된 항목만 읽기 전용으로 표시합니다."
/>
<Flex vertical gap={12} style={{ marginTop: 16 }}>
<div className="shared-resource-management-page__detail-grid">
<div className="shared-resource-management-page__detail-block">
<Text strong> </Text>
<Paragraph
copyable={sharedPreview.sharePath ? { text: buildAbsoluteShareUrl(sharedPreview.sharePath) } : false}
ellipsis={sharedPreview.sharePath ? { rows: 1, tooltip: buildAbsoluteShareUrl(sharedPreview.sharePath) } : false}
style={{ marginTop: 10, marginBottom: 0 }}
>
{buildAbsoluteShareUrl(sharedPreview.sharePath)}
</Paragraph>
</div>
<div className="shared-resource-management-page__detail-block">
<Text strong> </Text>
<Paragraph style={{ marginTop: 10, marginBottom: 6 }}>
{sharedPreview.tokenSetting
? `${sharedPreview.tokenSetting.name} (${sharedPreview.tokenSetting.id})`
: '스냅샷 정보 없음'}
</Paragraph>
<Paragraph style={{ marginBottom: 6 }}>
{sharedPreview.tokenSetting
? `기본 유효시간 ${formatDurationMinutesLabel(sharedPreview.tokenSetting.defaultExpiresInMinutes)}`
: '기본 유효시간 정보 없음'}
</Paragraph>
<Paragraph style={{ marginBottom: 0 }}>
{sharedPreview.expiresAt ? formatExpirySummary(sharedPreview.expiresAt, nowMs) : '만료 미설정'}
</Paragraph>
</div>
</div>
<div className="shared-resource-management-page__detail-block">
<Text strong> </Text>
<div className="shared-resource-management-page__detail-tags" style={{ marginTop: 10 }}>
{(sharedPreview.tokenSetting?.allowedAppIds ?? []).length > 0 ? (
sharedPreview.tokenSetting?.allowedAppIds.map((appId) => <Tag key={appId}>{appId}</Tag>)
) : (
<Text type="secondary"> .</Text>
)}
</div>
{sharedPreview.managedResourceTokenId ? (
<Paragraph type="secondary" style={{ marginTop: 12, marginBottom: 0 }}>
ID {sharedPreview.managedResourceTokenId}
</Paragraph>
) : null}
</div>
</Flex>
</Card>
);
}
return (
<Card title="공유 리소스 관리" className="chat-type-management-page">
<Alert
showIcon
type="warning"
message="관리 페이지는 토큰 등록 사용자만 사용할 수 있습니다."
description="설정 > 토큰 관리에서 권한 토큰을 등록한 뒤 공유 토큰 URL과 활동 내역을 관리하세요."
/>
</Card>
);
}
return (
<div
className={`chat-type-management-page shared-resource-management-page${
detailMode === 'detail' ? ' chat-type-management-page--detail' : ''
}`}
>
{modalContextHolder}
<Modal
open={Boolean(qrPreviewToken && qrPreviewUrl)}
title={qrPreviewToken ? `"${qrPreviewToken.name}" QR 코드` : '공유 QR 코드'}
footer={null}
onCancel={() => setQrPreviewTokenId(null)}
centered
>
{qrPreviewToken && qrPreviewUrl ? (
<SharedResourceQrPanel shareUrl={qrPreviewUrl} title="공유 URL QR" />
) : (
<Empty description="QR 코드를 표시할 공유 URL이 없습니다." />
)}
</Modal>
<Drawer
open={Boolean(conversationDrawer)}
title={conversationDrawer ? `${conversationDrawer.tokenName} 채팅` : '공유 채팅'}
placement="right"
width="100vw"
rootClassName="shared-resource-management-page__conversation-drawer"
onClose={() => {
setConversationDrawer(null);
}}
extra={
conversationDrawer ? (
<Space size={8}>
<Button onClick={() => setConversationDrawerKey((current) => current + 1)}></Button>
<Button onClick={(event) => openConversationWindow(conversationDrawer.url, event)}> </Button>
</Space>
) : null
}
styles={{
body: {
padding: 0,
},
}}
>
{conversationDrawer ? (
<iframe
key={`${conversationDrawer.tokenId}-${conversationDrawerKey}`}
title={`${conversationDrawer.tokenName} 공유 채팅`}
src={conversationDrawer.url}
className="shared-resource-management-page__conversation-frame"
/>
) : null}
</Drawer>
{detailMode === 'list' ? (
<Card
title="공유 리소스 관리"
className="chat-type-management-page__card shared-resource-management-page__card"
extra={
<Space size={8}>
<Button icon={<ReloadOutlined />} onClick={() => void refresh()}>
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateForm} disabled={isSharedManageMode}>
</Button>
</Space>
}
>
<div className="chat-type-management-page__list">
<div className="chat-type-management-page__list-scroll">
{sharedManageInfoAlert}
<div className="shared-resource-management-page__summary-strip" aria-label="공유 리소스 요약">
<div className="shared-resource-management-page__summary-intro">
<Text strong> </Text>
</div>
<div className="shared-resource-management-page__summary-pills">
<div className="shared-resource-management-page__summary-pill">
<span className="shared-resource-management-page__summary-label"></span>
<span className="shared-resource-management-page__summary-value">{summary.activeCount}</span>
</div>
<div className="shared-resource-management-page__summary-pill">
<span className="shared-resource-management-page__summary-label"></span>
<span className="shared-resource-management-page__summary-value">{summary.revokedCount}</span>
</div>
<div className="shared-resource-management-page__summary-pill">
<span className="shared-resource-management-page__summary-label"> </span>
<span className="shared-resource-management-page__summary-value">{formatTokenUsage(summary.tokenUsageTotal)}</span>
</div>
<div className="shared-resource-management-page__summary-pill">
<span className="shared-resource-management-page__summary-label">/</span>
<span className="shared-resource-management-page__summary-value">{`${formatUsage(summary.usageTotal)} · ${summary.activityTotal}`}</span>
</div>
</div>
</div>
{errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null}
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
<div className="chat-type-management-page__list-header">
<Title level={5}> </Title>
<Space size={8} wrap>
<Text type="secondary">{isLoading ? '불러오는 중' : `${tokens.length}`}</Text>
<Text type="secondary"> </Text>
</Space>
</div>
<div className="shared-resource-management-page__bulk-toolbar">
<Space size={8} wrap>
<Text strong>{selectedTokenIds.length > 0 ? `${selectedTokenIds.length}건 선택` : '선택 없음'}</Text>
{selectedTokenIds.length > 0 ? (
<Text type="secondary">
{bulkRevocableTokenIds.length > 0
? `회수 가능 ${bulkRevocableTokenIds.length}`
: '회수 가능한 항목 없음'}
</Text>
) : null}
</Space>
<Space size={8} wrap>
<Button disabled={selectedTokenIds.length === 0 || isSaving} onClick={() => setSelectedRowKeys([])}>
</Button>
<Button
disabled={bulkRevocableTokenIds.length === 0 || isSaving}
icon={<StopOutlined />}
onClick={() => void handleBulkRevoke()}
>
</Button>
<Button
danger
disabled={bulkDeletableTokenIds.length === 0 || isSaving}
icon={<DeleteOutlined />}
onClick={() => void handleBulkDelete()}
>
</Button>
</Space>
</div>
{tokens.length > 0 ? (
<Table<SharedResourceTokenRecord>
className="shared-resource-management-page__table"
rowKey="id"
size="small"
pagination={{ pageSize: 30, size: 'small', showSizeChanger: false, hideOnSinglePage: true }}
columns={listColumns}
dataSource={tokens}
loading={isLoading}
rowSelection={{
selectedRowKeys,
onChange: (nextSelectedRowKeys) => setSelectedRowKeys(nextSelectedRowKeys),
preserveSelectedRowKeys: true,
}}
onRow={(record) => ({
onDoubleClick: () => openDetail(record.id),
})}
rowClassName={(record) =>
record.id === selectedTokenId
? 'shared-resource-management-page__table-row shared-resource-management-page__table-row--active'
: 'shared-resource-management-page__table-row'
}
locale={{
emptyText: <Empty description="등록된 공유 리소스 토큰이 없습니다." />,
}}
scroll={{ x: 900, y: 'calc(100dvh - 220px)' }}
/>
) : (
<Empty description="등록된 공유 리소스 토큰이 없습니다." />
)}
</div>
</div>
</Card>
) : (
<Card
title={isCreating ? '공유 토큰 등록' : '공유 토큰 상세'}
className="chat-type-management-page__card shared-resource-management-page__card"
extra={
<Space size={6} className="chat-type-management-page__header-actions" wrap>
<Button
type="primary"
shape="circle"
icon={<SaveOutlined />}
loading={isSaving}
aria-label={isCreating ? '등록' : '수정 저장'}
onClick={() => void form.submit()}
/>
<Button
shape="circle"
icon={<PlusOutlined />}
disabled={isSaving || isSharedManageMode}
aria-label="새 입력"
onClick={openCreateForm}
/>
{!isCreating && detailData?.token?.revokedAt ? (
<Button shape="circle" icon={<ReloadOutlined />} disabled={isSaving} aria-label="복원" onClick={() => void handleRestore()} />
) : null}
{!isCreating && detailData?.token && !detailData.token.revokedAt ? (
<Button
shape="circle"
icon={<StopOutlined />}
disabled={isSaving}
aria-label="회수"
onClick={() => void handleRevoke()}
/>
) : null}
{!isCreating && detailData?.token ? (
<Button
danger
shape="circle"
icon={<DeleteOutlined />}
disabled={isSaving}
aria-label="삭제"
onClick={() => void handleDelete()}
/>
) : null}
<Button shape="circle" icon={<UnorderedListOutlined />} aria-label="목록 가기" onClick={closeDetail} />
</Space>
}
>
<div className="chat-type-management-page__editor">
{sharedManageInfoAlert}
{errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null}
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
<Form<SharedResourceTokenFormValue>
layout="vertical"
form={form}
initialValues={EMPTY_FORM_VALUE}
onFinish={handleSave}
className="chat-type-management-page__editor-form"
>
<Form.Item name="id" hidden>
<Input />
</Form.Item>
<div className="chat-type-management-page__editor-scroll">
<Tabs
activeKey={activeDetailTab}
onChange={(key) => setActiveDetailTab(key as 'basic' | 'settings' | 'history')}
className="shared-resource-management-page__detail-tabs"
items={[
{
key: 'basic',
label: '기본 정보',
children: (
<div className="shared-resource-management-page__section-scroll">
<div className="shared-resource-management-page__compact-panel">
<div className="shared-resource-management-page__compact-panel-main">
<Text strong>{isCreating ? '신규 공유 토큰 준비' : detailData?.token.name ?? '공유 토큰 상세'}</Text>
<Text type="secondary">
{isCreating
? '필수 입력을 한 화면에서 빠르게 등록하고 저장 후 운영 이력을 확인합니다.'
: detailData
? [
RESOURCE_TYPE_OPTIONS.find((option) => option.value === detailData.token.resourceType)?.label ??
detailData.token.resourceType,
detailData.token.resourceLabel !== detailData.token.name ? detailData.token.resourceLabel : '',
]
.filter(Boolean)
.join(' · ')
: '선택한 토큰의 기본 정보와 운영 상태를 바로 편집합니다.'}
</Text>
</div>
<div className="shared-resource-management-page__compact-panel-side">
{!isCreating && detailData ? (
<>
<Tag color={resolveStatusMeta(detailData.token).color}>{resolveStatusMeta(detailData.token).label}</Tag>
<Tag>{formatEffectiveExpirySummary(detailData.token, nowMs)}</Tag>
<Tag>{`사용 ${formatUsage(detailData.token.usageCount)}`}</Tag>
</>
) : (
<>
<Tag color="blue"> </Tag>
<Tag> </Tag>
</>
)}
</div>
</div>
<div className="shared-resource-management-page__compact-grid">
<div className="shared-resource-management-page__field-span-2">
<Form.Item label="공유 이름" name="name" rules={[{ required: true, message: '공유 이름을 입력하세요.' }]}>
<Input placeholder="예: 외주 검수용 리소스 링크" />
</Form.Item>
</div>
<Form.Item label="리소스 유형" name="resourceType" rules={[{ required: true }]}>
<Select options={RESOURCE_TYPE_OPTIONS} />
</Form.Item>
<div className="shared-resource-management-page__field-span-3">
<Form.Item
label="리소스 경로 / URL"
name="resourcePath"
rules={[{ required: true, message: '리소스 경로를 입력하세요.' }]}
>
<Input placeholder="resource/Codex Live/... 또는 https://..." />
</Form.Item>
</div>
<div className="shared-resource-management-page__field-span-3">
<Form.Item label="설명" name="description">
<Input.TextArea rows={3} placeholder="공유 목적, 대상자, 제한 조건을 적어 두세요." />
</Form.Item>
</div>
</div>
<div className="shared-resource-management-page__inline-option-row">
<Form.Item label={null} name="enabled" valuePropName="checked" className="shared-resource-management-page__inline-option-item">
<Checkbox> URL을 </Checkbox>
</Form.Item>
<Text type="secondary"> .</Text>
</div>
{!isCreating && detailData ? (
<div className="shared-resource-management-page__compact-grid shared-resource-management-page__compact-grid--summary">
<div className="shared-resource-management-page__detail-block shared-resource-management-page__field-span-2">
<Text strong> </Text>
<div className="shared-resource-management-page__status-row" style={{ marginTop: 10 }}>
<Tag color={resolveStatusMeta(detailData.token).color}>{resolveStatusMeta(detailData.token).label}</Tag>
<Tag>{`토큰 ${formatTokenUsage(detailData.token.usageTokenTotal)}`}</Tag>
<Tag>{`요청 ${detailData.token.usageRequestCount}`}</Tag>
<Tag>{`사용 ${formatUsage(detailData.token.usageCount)} · 활동 ${detailData.token.activityCount}`}</Tag>
</div>
{detailData.token.sharePath ? (
<Button
type="link"
icon={<QrcodeOutlined />}
style={{ paddingInline: 0, marginTop: 12 }}
onClick={() => setQrPreviewTokenId(detailData.token.id)}
>
QR
</Button>
) : null}
{detailConversationUrl ? (
<Button
type="link"
icon={<LinkOutlined />}
style={{ paddingInline: 0, marginTop: 4 }}
onClick={() => openConversationDrawer(detailData.token.id, detailData.token.name, detailConversationUrl)}
>
</Button>
) : null}
</div>
<div className="shared-resource-management-page__detail-block">
<Text strong> </Text>
<Paragraph style={{ marginTop: 10, marginBottom: 6 }}>{`생성 ${formatDateTime(detailData.token.createdAt)}`}</Paragraph>
<Paragraph style={{ marginBottom: 6 }}>
{`최근 토큰 사용 ${formatDateTime(detailData.token.lastTokenUsedAt ?? detailData.token.lastUsedAt)}`}
</Paragraph>
<Paragraph style={{ marginBottom: 6 }}>{`완료 요청 ${detailData.token.usageCompletedRequestCount}`}</Paragraph>
<Paragraph style={{ marginBottom: 0 }}>{formatEffectiveExpirySummary(detailData.token, nowMs)}</Paragraph>
</div>
{detailData.token.sharePath ? (
<div className="shared-resource-management-page__detail-block">
<SharedResourceQrPanel shareUrl={buildAbsoluteShareUrl(detailData.token.sharePath)} />
</div>
) : null}
</div>
) : null}
</div>
),
},
{
key: 'settings',
label: '설정',
children: (
<div className="shared-resource-management-page__section-scroll">
<div className="shared-resource-management-page__compact-grid">
{!isChatShareToken(detailData?.token ?? { resourceType: form.getFieldValue('resourceType') }) ? (
<Form.Item label="연결 토큰 설정 ID" name="tokenSettingId">
<Input placeholder="예: chat-share-default" />
</Form.Item>
) : (
<div className="shared-resource-management-page__field-span-2">
<Alert
showIcon
type="info"
message="공유 채팅 토큰은 발급 시점 스냅샷만 유지합니다."
description="생성 이후에는 원본 토큰 설정과 다시 연결하지 않습니다."
/>
</div>
)}
<Form.Item label="만료 시각" name="expiresAt">
<Input placeholder="2026-05-22T14:30:00+09:00" />
</Form.Item>
<Form.Item label="사용 한도" name="usageLimit" extra="0이면 무제한으로 유지합니다.">
<InputNumber
min={0}
style={{ width: '100%' }}
formatter={(value) => formatUnlimitedNumberInput(value, '회')}
parser={((value: string | undefined) => Number(parseUnlimitedNumberInput(value) || 0)) as unknown as (displayValue: string | undefined) => 0}
/>
</Form.Item>
<div className="shared-resource-management-page__field-span-2">
<Form.Item label="공유 URL 경로" name="sharePath">
<Input placeholder="/share/resource/..." />
</Form.Item>
</div>
<div className="shared-resource-management-page__field-span-3">
<Form.Item label="공유 토큰 값" name="shareToken">
<Input placeholder="비워두면 자동 생성" />
</Form.Item>
</div>
</div>
<Form.Item
label={`앱 권한 ${APP_OPTIONS.length}`}
name="allowedAppIds"
extra="공유 토큰 상세에서 최종 허용 앱 목록을 직접 저장합니다."
>
<Checkbox.Group style={{ width: '100%' }}>
<div className="shared-resource-management-page__permission-grid">
{APP_OPTIONS.map((option) => (
<label key={option.value} className="shared-resource-management-page__permission-card">
<div className="shared-resource-management-page__permission-card-header">
<div className="shared-resource-management-page__permission-card-copy">
<Text strong>{option.label}</Text>
<Text type="secondary">{option.description}</Text>
</div>
<Checkbox value={option.value} />
</div>
<Text type="secondary">{option.value}</Text>
</label>
))}
</div>
</Checkbox.Group>
</Form.Item>
<div className="shared-resource-management-page__inline-option-row">
<Button onClick={applyAdminPreset}> </Button>
<Text type="secondary">
, / .
</Text>
</div>
<Form.Item
label="권한"
name="permissions"
rules={[{ required: true, message: '최소 1개 권한을 선택하세요.' }]}
>
<Checkbox.Group style={{ width: '100%' }}>
<div className="shared-resource-management-page__permission-grid">
{PERMISSION_OPTIONS.map((option) => (
<label key={option.value} className="shared-resource-management-page__permission-card">
<div className="shared-resource-management-page__permission-card-header">
<div className="shared-resource-management-page__permission-card-copy">
<Text strong>{option.label}</Text>
<Text type="secondary">{option.description}</Text>
</div>
<Checkbox value={option.value} />
</div>
</label>
))}
</div>
</Checkbox.Group>
</Form.Item>
</div>
),
},
{
key: 'history',
label: '이력',
children: (
<div className="shared-resource-management-page__section-scroll">
{isCreating ? (
<Alert
showIcon
type="info"
message="신규 등록 전에는 이력과 운영 데이터를 표시하지 않습니다."
description="먼저 기본 정보와 설정을 저장하면 공유 URL, 사용량, 활동 내역을 이 탭에서 확인할 수 있습니다."
/>
) : detailData ? (
<>
<div className="shared-resource-management-page__compact-grid shared-resource-management-page__compact-grid--summary">
<div className="shared-resource-management-page__detail-block">
<Text strong> </Text>
<Paragraph style={{ marginTop: 10, marginBottom: 6 }}>
{isChatShareToken(detailData.token)
? `발급 스냅샷 ${detailData.token.tokenSettingSnapshot
? `${detailData.token.tokenSettingSnapshot.name} (${detailData.token.tokenSettingSnapshot.id})`
: '없음'}`
: `발급 기준 ${
detailData.token.tokenSettingSnapshot
? `${detailData.token.tokenSettingSnapshot.name} (${detailData.token.tokenSettingSnapshot.id})`
: detailData.token.tokenSettingId ?? '미연결'
}`}
</Paragraph>
<Paragraph style={{ marginBottom: 6 }}>{`권한 기준 ${resolveLinkedSettingStatusMeta(detailData.token).label}`}</Paragraph>
<Paragraph style={{ marginBottom: 0 }}>{`수정 ${formatDateTime(detailData.token.updatedAt)}`}</Paragraph>
</div>
<div className="shared-resource-management-page__detail-block shared-resource-management-page__field-span-2">
<Text strong> </Text>
<div className="shared-resource-management-page__detail-tags" style={{ marginTop: 10 }}>
{detailData.token.permissions.map((permission) => (
<Tag key={`${detailData.token.id}-${permission}`}>{formatPermissionLabel(permission)}</Tag>
))}
</div>
<Text strong style={{ display: 'block', marginTop: 12 }}> </Text>
<div className="shared-resource-management-page__detail-tags" style={{ marginTop: 10 }}>
{detailData.token.allowedAppIds.length > 0 ? (
detailData.token.allowedAppIds.map((appId) => (
<Tag key={`${detailData.token.id}-${appId}`}>{formatAppLabel(appId)}</Tag>
))
) : (
<Text type="secondary"> .</Text>
)}
</div>
</div>
</div>
<Card title="활동 내역" bordered={false} className="shared-resource-management-page__activity-card">
{isDetailLoading ? (
<Paragraph> ...</Paragraph>
) : detailData.activities.length > 0 ? (
<Table
size="small"
rowKey="id"
columns={activityColumns}
dataSource={detailData.activities}
pagination={{ pageSize: 8, hideOnSinglePage: true }}
scroll={{ x: 760 }}
/>
) : (
<Empty description="기록된 활동 내역이 없습니다." />
)}
</Card>
</>
) : isDetailLoading ? (
<Paragraph> ...</Paragraph>
) : (
<Empty description="표시할 이력이 없습니다." />
)}
</div>
),
},
]}
/>
</div>
</Form>
</div>
</Card>
)}
</div>
);
}