1743 lines
70 KiB
TypeScript
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>
|
|
);
|
|
}
|