852 lines
35 KiB
TypeScript
852 lines
35 KiB
TypeScript
import { DeleteOutlined, EditOutlined, PlusOutlined, SaveOutlined, UnorderedListOutlined } from '@ant-design/icons';
|
|
import { Alert, App, Button, Card, Checkbox, Descriptions, Empty, Form, Input, InputNumber, List, Modal, Space, Switch, Table, Tabs, Tag, Typography } from 'antd';
|
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { getReadyPlayAppEntries } from '../../views/play/apps/apps/appsRegistry';
|
|
import { confirmWithKeyboard } from './modalKeyboard';
|
|
import {
|
|
deleteTokenSetting,
|
|
fetchTokenSettingActivities,
|
|
type TokenSettingActivityRecord,
|
|
type TokenSettingRecord,
|
|
upsertTokenSetting,
|
|
useTokenSettingRegistry,
|
|
} from './tokenSettingAccess';
|
|
import { useTokenAccess } from './tokenAccess';
|
|
import './TokenSettingManagementPage.css';
|
|
|
|
const { Paragraph, Text, Title } = Typography;
|
|
|
|
type TokenSettingFormValue = {
|
|
originalId?: string;
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
defaultExpiresInMinutes: number;
|
|
maxTokensPer30Days: number;
|
|
maxTokensPer7Days: number;
|
|
maxTokensPer5Hours: number;
|
|
oneTimeTokenLimit: number;
|
|
allowedAppIds: string[];
|
|
enabled: boolean;
|
|
};
|
|
|
|
type AppOption = {
|
|
value: string;
|
|
label: string;
|
|
description: string;
|
|
category: '관리' | 'Play';
|
|
};
|
|
|
|
type SharedTokenSettingPreview = {
|
|
id: string;
|
|
name: string;
|
|
defaultExpiresInMinutes: number;
|
|
maxTokensPer30Days: number;
|
|
maxTokensPer7Days: number;
|
|
maxTokensPer5Hours: number;
|
|
oneTimeTokenLimit: number;
|
|
allowedAppIds: string[];
|
|
};
|
|
|
|
type SharedTokenSettingAccess = {
|
|
shareToken: string;
|
|
canManage: boolean;
|
|
};
|
|
|
|
const MANAGEMENT_APP_OPTIONS: AppOption[] = [
|
|
{ value: 'chat-live', label: 'Codex Live', description: '채팅방 조회와 응답 흐름 진입', category: '관리' },
|
|
{ value: 'chat-room-settings', label: '채팅방 설정', description: 'Codex Live 채팅방 Context 설정 편집 접근', category: '관리' },
|
|
{ value: 'text-memo-widget', label: '메모', description: '공유채팅 Apps에서 메모 컴포넌트 실행', category: '관리' },
|
|
{ value: 'token-setting', label: '토큰관리 설정', description: '토큰 설정 목록과 상세 편집 접근', category: '관리' },
|
|
{ value: 'shared-resource', label: '공유 리소스 관리', description: '공유 링크와 권한, 활동 이력 관리', category: '관리' },
|
|
{ value: 'plan-board', label: '자동화 현황', description: '작업 현황과 요청 보드 접근', category: '관리' },
|
|
{ value: 'app-settings', label: '앱 설정', description: '헤더 설정, 알림, 업데이트 화면 접근', category: '관리' },
|
|
{ value: 'server-command', label: '서버관리', description: '서버 상태 확인과 재기동 예약/실행 접근', category: '관리' },
|
|
{ value: 'resource-manager', label: '리소스 관리', description: '세션 리소스와 파일 미리보기 접근', category: '관리' },
|
|
{ value: 'error-log', label: '에러 로그', description: '앱 로그와 장애 이력 조회', category: '관리' },
|
|
];
|
|
|
|
const PLAY_APP_OPTIONS: AppOption[] = getReadyPlayAppEntries().map((entry) => ({
|
|
value: entry.id,
|
|
label: entry.name,
|
|
description: entry.searchDescription ?? `${entry.name} 앱 실행`,
|
|
category: 'Play',
|
|
}));
|
|
|
|
const APP_OPTIONS: AppOption[] = [...MANAGEMENT_APP_OPTIONS, ...PLAY_APP_OPTIONS];
|
|
const APP_OPTION_LABEL_MAP = new Map(APP_OPTIONS.map((item) => [item.value, item.label] as const));
|
|
|
|
const EMPTY_FORM_VALUE: TokenSettingFormValue = {
|
|
id: '',
|
|
name: '',
|
|
description: '',
|
|
defaultExpiresInMinutes: 60,
|
|
maxTokensPer30Days: 0,
|
|
maxTokensPer7Days: 100_000,
|
|
maxTokensPer5Hours: 100_000,
|
|
oneTimeTokenLimit: 0,
|
|
allowedAppIds: [],
|
|
enabled: true,
|
|
};
|
|
|
|
function normalizeSettingId(value: string | null | undefined) {
|
|
return String(value ?? '')
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/\s+/g, '-')
|
|
.replace(/[^a-z0-9._-]/g, '');
|
|
}
|
|
|
|
function toFormValue(setting: TokenSettingRecord | null): TokenSettingFormValue {
|
|
if (!setting) {
|
|
return EMPTY_FORM_VALUE;
|
|
}
|
|
|
|
return {
|
|
originalId: setting.id,
|
|
id: setting.id,
|
|
name: setting.name,
|
|
description: setting.description,
|
|
defaultExpiresInMinutes: setting.defaultExpiresInMinutes,
|
|
maxTokensPer30Days: setting.maxTokensPer30Days,
|
|
maxTokensPer7Days: setting.maxTokensPer7Days,
|
|
maxTokensPer5Hours: setting.maxTokensPer5Hours,
|
|
oneTimeTokenLimit: setting.oneTimeTokenLimit,
|
|
allowedAppIds: setting.allowedAppIds,
|
|
enabled: setting.enabled,
|
|
};
|
|
}
|
|
|
|
function formatDuration(minutes: number) {
|
|
if (minutes <= 0) {
|
|
return '무제한';
|
|
}
|
|
|
|
if (minutes % (60 * 24) === 0) {
|
|
return `${minutes / (60 * 24)}일`;
|
|
}
|
|
|
|
if (minutes % 60 === 0) {
|
|
return `${minutes / 60}시간`;
|
|
}
|
|
|
|
return `${minutes}분`;
|
|
}
|
|
|
|
function formatTokenLimit(value: number) {
|
|
if (value <= 0) {
|
|
return '무제한';
|
|
}
|
|
|
|
return `${value.toLocaleString('ko-KR')} 토큰`;
|
|
}
|
|
|
|
function formatUnlimitedNumberInput(value: string | number | null | undefined, unit: string) {
|
|
if (value === null || value === undefined || value === '') {
|
|
return '';
|
|
}
|
|
|
|
const normalized = Number(String(value).replace(/[^\d.-]/g, ''));
|
|
|
|
if (!Number.isFinite(normalized)) {
|
|
return String(value);
|
|
}
|
|
|
|
if (normalized <= 0) {
|
|
return '무제한';
|
|
}
|
|
|
|
return `${normalized.toLocaleString('ko-KR')} ${unit}`;
|
|
}
|
|
|
|
function parseUnlimitedNumberInput(value: string | undefined) {
|
|
if (!value) {
|
|
return '';
|
|
}
|
|
|
|
if (value.includes('무제한')) {
|
|
return '0';
|
|
}
|
|
|
|
return value.replace(/[^\d]/g, '');
|
|
}
|
|
|
|
function formatQuotaSummary(setting: TokenSettingRecord) {
|
|
return [`7일 ${formatTokenLimit(setting.maxTokensPer7Days)}`, `5시간 ${formatTokenLimit(setting.maxTokensPer5Hours)}`].join(' / ');
|
|
}
|
|
|
|
function resolveAppLabels(appIds: string[]) {
|
|
return appIds.map((item) => APP_OPTION_LABEL_MAP.get(item) ?? item);
|
|
}
|
|
|
|
export function TokenSettingManagementPage({
|
|
sharedPreviewTokenSetting = null,
|
|
sharedAccess = null,
|
|
}: {
|
|
sharedPreviewTokenSetting?: SharedTokenSettingPreview | null;
|
|
sharedAccess?: SharedTokenSettingAccess | null;
|
|
}) {
|
|
const { message } = App.useApp();
|
|
const { hasAccess } = useTokenAccess();
|
|
const isSharedManageMode = !hasAccess && Boolean(sharedAccess?.canManage && sharedAccess.shareToken);
|
|
const isSharedPreviewMode = !hasAccess && Boolean(sharedPreviewTokenSetting) && !isSharedManageMode;
|
|
const sharedPreviewRecord = useMemo<TokenSettingRecord | null>(
|
|
() =>
|
|
sharedPreviewTokenSetting
|
|
? {
|
|
id: sharedPreviewTokenSetting.id,
|
|
name: sharedPreviewTokenSetting.name,
|
|
description: '',
|
|
defaultExpiresInMinutes: sharedPreviewTokenSetting.defaultExpiresInMinutes,
|
|
maxExpiresInMinutes: sharedPreviewTokenSetting.defaultExpiresInMinutes,
|
|
maxTokensPer30Days: sharedPreviewTokenSetting.maxTokensPer30Days,
|
|
maxTokensPer7Days: sharedPreviewTokenSetting.maxTokensPer7Days,
|
|
maxTokensPer5Hours: sharedPreviewTokenSetting.maxTokensPer5Hours,
|
|
oneTimeTokenLimit: sharedPreviewTokenSetting.oneTimeTokenLimit,
|
|
allowedAppIds: sharedPreviewTokenSetting.allowedAppIds,
|
|
enabled: true,
|
|
updatedAt: '',
|
|
}
|
|
: null,
|
|
[sharedPreviewTokenSetting],
|
|
);
|
|
const { tokenSettings, setTokenSettings, isLoading, errorMessage } = useTokenSettingRegistry(
|
|
hasAccess || isSharedManageMode,
|
|
isSharedManageMode ? { shareToken: sharedAccess?.shareToken } : undefined,
|
|
);
|
|
const [selectedTokenSettingId, setSelectedTokenSettingId] = useState<string | null>(
|
|
sharedPreviewTokenSetting?.id ?? tokenSettings[0]?.id ?? null,
|
|
);
|
|
const [detailMode, setDetailMode] = useState<'list' | 'detail'>(sharedPreviewTokenSetting ? 'detail' : 'list');
|
|
const [isCreating, setIsCreating] = useState(false);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [saveErrorMessage, setSaveErrorMessage] = useState('');
|
|
const [saveSuccessMessage, setSaveSuccessMessage] = useState('');
|
|
const [activeDetailTab, setActiveDetailTab] = useState<'basic' | 'quota' | 'apps' | 'history'>('basic');
|
|
const [activities, setActivities] = useState<TokenSettingActivityRecord[]>([]);
|
|
const [isActivityLoading, setIsActivityLoading] = useState(false);
|
|
const [form] = Form.useForm<TokenSettingFormValue>();
|
|
const [modalApi, modalContextHolder] = Modal.useModal();
|
|
const lastHydratedFormKeyRef = useRef('');
|
|
|
|
const selectedTokenSetting = useMemo(
|
|
() => tokenSettings.find((item) => item.id === selectedTokenSettingId) ?? null,
|
|
[selectedTokenSettingId, tokenSettings],
|
|
);
|
|
const effectiveSelectedTokenSetting =
|
|
selectedTokenSetting ?? (isSharedPreviewMode || isSharedManageMode ? sharedPreviewRecord : null);
|
|
|
|
useEffect(() => {
|
|
if (isSharedPreviewMode || isSharedManageMode) {
|
|
setSelectedTokenSettingId(sharedPreviewTokenSetting?.id ?? null);
|
|
if (detailMode !== 'detail') {
|
|
setDetailMode('detail');
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (selectedTokenSettingId && tokenSettings.some((item) => item.id === selectedTokenSettingId)) {
|
|
return;
|
|
}
|
|
|
|
setSelectedTokenSettingId(tokenSettings[0]?.id ?? null);
|
|
}, [detailMode, isSharedManageMode, isSharedPreviewMode, selectedTokenSettingId, sharedPreviewTokenSetting?.id, tokenSettings]);
|
|
|
|
useEffect(() => {
|
|
if (detailMode !== 'detail') {
|
|
lastHydratedFormKeyRef.current = '';
|
|
return;
|
|
}
|
|
|
|
const nextFormKey = isCreating ? '__create__' : effectiveSelectedTokenSetting?.id ?? '__empty__';
|
|
|
|
if (lastHydratedFormKeyRef.current === nextFormKey) {
|
|
return;
|
|
}
|
|
|
|
lastHydratedFormKeyRef.current = nextFormKey;
|
|
form.resetFields();
|
|
form.setFieldsValue(toFormValue(isCreating ? null : effectiveSelectedTokenSetting));
|
|
}, [detailMode, effectiveSelectedTokenSetting?.id, form, isCreating]);
|
|
|
|
useEffect(() => {
|
|
if (detailMode !== 'detail' || isCreating || !effectiveSelectedTokenSetting?.id || isSharedPreviewMode) {
|
|
setActivities([]);
|
|
setIsActivityLoading(false);
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
setIsActivityLoading(true);
|
|
|
|
void fetchTokenSettingActivities(
|
|
effectiveSelectedTokenSetting.id,
|
|
isSharedManageMode ? { shareToken: sharedAccess?.shareToken } : undefined,
|
|
)
|
|
.then((nextItems) => {
|
|
if (!cancelled) {
|
|
setActivities(nextItems);
|
|
}
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) {
|
|
setActivities([]);
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) {
|
|
setIsActivityLoading(false);
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [detailMode, effectiveSelectedTokenSetting?.id, isCreating, isSharedManageMode, isSharedPreviewMode, sharedAccess?.shareToken]);
|
|
|
|
const activityColumns = useMemo(
|
|
() => [
|
|
{
|
|
title: '시각',
|
|
dataIndex: 'createdAt',
|
|
key: 'createdAt',
|
|
render: (value: string) => new Date(value).toLocaleString('ko-KR'),
|
|
},
|
|
{
|
|
title: '유형',
|
|
dataIndex: 'activityType',
|
|
key: 'activityType',
|
|
render: (value: TokenSettingActivityRecord['activityType']) => <Tag>{value}</Tag>,
|
|
},
|
|
{
|
|
title: '내용',
|
|
dataIndex: 'summary',
|
|
key: 'summary',
|
|
},
|
|
{
|
|
title: '변경 상세',
|
|
dataIndex: 'detail',
|
|
key: 'detail',
|
|
render: (value: string | null) => value || '-',
|
|
},
|
|
{
|
|
title: 'IP',
|
|
key: 'ip',
|
|
render: (_value: unknown, item: TokenSettingActivityRecord) => {
|
|
const lines = [
|
|
item.externalIp ? `외부 ${item.externalIp}` : null,
|
|
item.clientIp ? `서버 ${item.clientIp}` : null,
|
|
item.forwardedFor ? `XFF ${item.forwardedFor}` : null,
|
|
item.clientId ? `client ${item.clientId}` : null,
|
|
].filter(Boolean);
|
|
return lines.length > 0 ? <Text style={{ whiteSpace: 'pre-line' }}>{lines.join('\n')}</Text> : '-';
|
|
},
|
|
},
|
|
],
|
|
[],
|
|
);
|
|
|
|
const openCreateForm = () => {
|
|
setIsCreating(true);
|
|
setSelectedTokenSettingId(null);
|
|
setDetailMode('detail');
|
|
setSaveErrorMessage('');
|
|
setSaveSuccessMessage('');
|
|
setActiveDetailTab('basic');
|
|
form.resetFields();
|
|
form.setFieldsValue(EMPTY_FORM_VALUE);
|
|
};
|
|
|
|
const openDetail = (tokenSettingId: string) => {
|
|
setIsCreating(false);
|
|
setSelectedTokenSettingId(tokenSettingId);
|
|
setDetailMode('detail');
|
|
setSaveErrorMessage('');
|
|
setSaveSuccessMessage('');
|
|
setActiveDetailTab('basic');
|
|
};
|
|
|
|
const closeDetail = () => {
|
|
setIsCreating(false);
|
|
setDetailMode('list');
|
|
setSaveErrorMessage('');
|
|
setSaveSuccessMessage('');
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (!selectedTokenSetting) {
|
|
return;
|
|
}
|
|
|
|
const confirmed = await confirmWithKeyboard(modalApi, {
|
|
title: `"${selectedTokenSetting.name}" 토큰 설정을 삭제할까요?`,
|
|
okText: '삭제',
|
|
cancelText: '취소',
|
|
okButtonProps: { danger: true },
|
|
});
|
|
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
|
|
const nextTokenSettings = deleteTokenSetting(tokenSettings, selectedTokenSetting.id);
|
|
setIsSaving(true);
|
|
setSaveErrorMessage('');
|
|
setSaveSuccessMessage('');
|
|
|
|
try {
|
|
const savedTokenSettings = await setTokenSettings(nextTokenSettings);
|
|
setSelectedTokenSettingId(savedTokenSettings[0]?.id ?? null);
|
|
setIsCreating(false);
|
|
setDetailMode('list');
|
|
form.resetFields();
|
|
form.setFieldsValue(EMPTY_FORM_VALUE);
|
|
message.success('토큰 설정을 삭제했습니다.');
|
|
} catch (error) {
|
|
setSaveErrorMessage(error instanceof Error ? error.message : '토큰 설정 삭제에 실패했습니다.');
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
if (!hasAccess) {
|
|
if (!isSharedPreviewMode && !isSharedManageMode) {
|
|
return (
|
|
<>
|
|
{modalContextHolder}
|
|
<Card title="토큰 설정" className="chat-type-management-page">
|
|
<Alert
|
|
showIcon
|
|
type="warning"
|
|
message="관리 페이지는 토큰 등록 사용자만 사용할 수 있습니다."
|
|
description="설정 > 토큰 관리에서 권한 토큰을 등록한 뒤 토큰 설정을 관리하세요."
|
|
/>
|
|
</Card>
|
|
</>
|
|
);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className={`chat-type-management-page${detailMode === 'detail' ? ' chat-type-management-page--detail' : ''}`}>
|
|
{modalContextHolder}
|
|
{detailMode === 'list' ? (
|
|
<Card
|
|
title="토큰 설정"
|
|
className="chat-type-management-page__card"
|
|
extra={
|
|
isSharedPreviewMode || isSharedManageMode ? null : (
|
|
<Button icon={<PlusOutlined />} onClick={openCreateForm}>
|
|
신규 설정
|
|
</Button>
|
|
)
|
|
}
|
|
>
|
|
<div className="chat-type-management-page__list">
|
|
{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>
|
|
<Text type="secondary">{isLoading ? '불러오는 중' : `${tokenSettings.length}건`}</Text>
|
|
</div>
|
|
<Paragraph type="secondary" className="token-setting-management-page__helper">
|
|
설정 ID를 기준으로 이후 토큰 발급기와 공유 채팅방이 권한, 유효시간, 7일/5시간 사용량 기준을 초기값으로 가져갑니다.
|
|
</Paragraph>
|
|
{tokenSettings.length > 0 ? (
|
|
<List
|
|
dataSource={tokenSettings}
|
|
renderItem={(item) => (
|
|
<List.Item
|
|
className={
|
|
item.id === selectedTokenSettingId
|
|
? 'chat-type-management-page__item chat-type-management-page__item--active'
|
|
: 'chat-type-management-page__item'
|
|
}
|
|
onClick={() => openDetail(item.id)}
|
|
actions={[
|
|
<Button
|
|
key="edit"
|
|
type="text"
|
|
icon={<EditOutlined />}
|
|
disabled={isSaving}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
openDetail(item.id);
|
|
}}
|
|
/>,
|
|
]}
|
|
>
|
|
<div className="chat-type-management-page__item-main">
|
|
<Space size={[8, 8]} wrap>
|
|
<Text strong>{item.name}</Text>
|
|
<Text type="secondary">{item.id}</Text>
|
|
<Tag color={item.enabled ? 'green' : 'default'}>{item.enabled ? '사용' : '중지'}</Tag>
|
|
</Space>
|
|
<div className="token-setting-management-page__stats">
|
|
<Tag>{`유효시간 ${formatDuration(item.defaultExpiresInMinutes)}`}</Tag>
|
|
<Tag>{formatQuotaSummary(item)}</Tag>
|
|
<Tag>{`앱 ${item.allowedAppIds.length}개`}</Tag>
|
|
</div>
|
|
<div className="chat-type-management-page__item-description">
|
|
{item.description || '설명 없음'}
|
|
</div>
|
|
<Space size={[6, 6]} wrap>
|
|
{resolveAppLabels(item.allowedAppIds).map((label) => (
|
|
<Tag key={`${item.id}-${label}`}>{label}</Tag>
|
|
))}
|
|
</Space>
|
|
</div>
|
|
</List.Item>
|
|
)}
|
|
/>
|
|
) : (
|
|
<Empty description="등록된 토큰 설정이 없습니다." />
|
|
)}
|
|
</div>
|
|
</Card>
|
|
) : (
|
|
<Card
|
|
title={isCreating ? '토큰 설정 등록' : '토큰 설정 상세'}
|
|
className="chat-type-management-page__card"
|
|
extra={
|
|
isSharedPreviewMode ? (
|
|
<Tag color="blue">공유 링크</Tag>
|
|
) : isSharedManageMode ? (
|
|
<Tag color="cyan">공유 링크 관리</Tag>
|
|
) : (
|
|
<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} aria-label="새 입력" onClick={openCreateForm} />
|
|
{!isCreating && selectedTokenSetting ? (
|
|
<Button
|
|
danger
|
|
shape="circle"
|
|
icon={<DeleteOutlined />}
|
|
loading={isSaving}
|
|
aria-label="삭제"
|
|
onClick={() => void handleDelete()}
|
|
/>
|
|
) : null}
|
|
<Button shape="circle" icon={<UnorderedListOutlined />} aria-label="목록 가기" onClick={closeDetail} />
|
|
</Space>
|
|
)
|
|
}
|
|
>
|
|
<div className="chat-type-management-page__editor">
|
|
{isSharedPreviewMode ? (
|
|
<Alert
|
|
showIcon
|
|
type="info"
|
|
message="현재 공유 링크에 연결된 토큰 설정입니다."
|
|
description="허용된 앱과 한도를 이 화면에서 바로 확인할 수 있습니다."
|
|
/>
|
|
) : null}
|
|
{isSharedManageMode ? (
|
|
<Alert
|
|
showIcon
|
|
type="info"
|
|
message="현재 공유 링크에 연결된 토큰 설정을 관리 중입니다."
|
|
description="이 공유 링크에 연결된 설정 1건만 수정할 수 있습니다."
|
|
/>
|
|
) : null}
|
|
{(!isSharedPreviewMode || isSharedManageMode) && errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null}
|
|
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
|
|
{saveSuccessMessage ? <Alert showIcon type="success" message={saveSuccessMessage} /> : null}
|
|
<Form
|
|
className="chat-type-management-page__editor-form"
|
|
disabled={isSharedPreviewMode}
|
|
layout="vertical"
|
|
form={form}
|
|
initialValues={EMPTY_FORM_VALUE}
|
|
scrollToFirstError
|
|
onFinishFailed={() => {
|
|
setSaveSuccessMessage('');
|
|
setSaveErrorMessage('필수 입력값과 권한 앱 선택을 확인해 주세요.');
|
|
}}
|
|
onFinish={async (values) => {
|
|
const nextTokenSettings = upsertTokenSetting(tokenSettings, values);
|
|
const isNewSetting = isCreating;
|
|
setIsSaving(true);
|
|
setSaveErrorMessage('');
|
|
setSaveSuccessMessage('');
|
|
|
|
try {
|
|
const savedTokenSettings = await setTokenSettings(nextTokenSettings);
|
|
const normalizedSavedId = normalizeSettingId(values.id);
|
|
const savedTokenSetting =
|
|
savedTokenSettings.find((item) => item.id === normalizedSavedId) ??
|
|
savedTokenSettings.find((item) => item.id === normalizeSettingId(values.originalId));
|
|
setIsCreating(false);
|
|
setSelectedTokenSettingId(savedTokenSetting?.id ?? null);
|
|
setDetailMode('detail');
|
|
const nextSuccessMessage = isNewSetting ? '토큰 설정을 등록했습니다.' : '토큰 설정을 저장했습니다.';
|
|
setSaveSuccessMessage(nextSuccessMessage);
|
|
message.success(nextSuccessMessage);
|
|
} catch (error) {
|
|
setSaveErrorMessage(error instanceof Error ? error.message : '토큰 설정 저장에 실패했습니다.');
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}}
|
|
>
|
|
<Form.Item name="originalId" hidden>
|
|
<Input />
|
|
</Form.Item>
|
|
<div className="chat-type-management-page__editor-scroll">
|
|
<Tabs
|
|
activeKey={activeDetailTab}
|
|
onChange={(key) => setActiveDetailTab(key as 'basic' | 'quota' | 'apps' | 'history')}
|
|
className="token-setting-management-page__detail-tabs"
|
|
items={[
|
|
{
|
|
key: 'basic',
|
|
label: '기본 정보',
|
|
children: (
|
|
<div className="token-setting-management-page__section-scroll">
|
|
{isSharedPreviewMode && sharedPreviewRecord ? (
|
|
<Descriptions column={1} bordered size="small" className="token-setting-management-page__summary-descriptions">
|
|
<Descriptions.Item label="설정 ID">{sharedPreviewRecord.id}</Descriptions.Item>
|
|
<Descriptions.Item label="설정명">{sharedPreviewRecord.name}</Descriptions.Item>
|
|
<Descriptions.Item label="유효시간">{formatDuration(sharedPreviewRecord.defaultExpiresInMinutes)}</Descriptions.Item>
|
|
<Descriptions.Item label="앱 개수">{`${sharedPreviewRecord.allowedAppIds.length}개`}</Descriptions.Item>
|
|
</Descriptions>
|
|
) : (
|
|
<div className="chat-type-management-page__meta-grid">
|
|
<Form.Item
|
|
className="chat-type-management-page__meta-item"
|
|
label="설정 ID"
|
|
name="id"
|
|
extra={isSharedManageMode ? '공유 링크 관리 모드에서는 설정 ID를 변경할 수 없습니다.' : undefined}
|
|
rules={[
|
|
{ required: true, message: '설정 ID를 입력하세요.' },
|
|
{
|
|
validator: async (_rule, value) => {
|
|
const normalized = normalizeSettingId(value);
|
|
|
|
if (!normalized) {
|
|
throw new Error('영문, 숫자, `-`, `_`, `.` 조합으로 입력하세요.');
|
|
}
|
|
},
|
|
},
|
|
]}
|
|
>
|
|
<Input placeholder="예: photoprism-basic" disabled={isSharedManageMode} />
|
|
</Form.Item>
|
|
<Form.Item
|
|
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--enabled"
|
|
label="사용"
|
|
name="enabled"
|
|
valuePropName="checked"
|
|
>
|
|
<Switch checkedChildren="사용" unCheckedChildren="중지" />
|
|
</Form.Item>
|
|
<Form.Item
|
|
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--name"
|
|
label="설정명"
|
|
name="name"
|
|
rules={[{ required: true, message: '설정명을 입력하세요.' }]}
|
|
>
|
|
<Input placeholder="예: PhotoPrism 읽기 전용" />
|
|
</Form.Item>
|
|
<Form.Item
|
|
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--name"
|
|
label="설명"
|
|
name="description"
|
|
>
|
|
<Input.TextArea
|
|
autoSize={{ minRows: 4, maxRows: 10 }}
|
|
placeholder="이 설정으로 발급할 토큰의 용도와 제한을 적어 두세요."
|
|
/>
|
|
</Form.Item>
|
|
</div>
|
|
)}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'quota',
|
|
label: '만료·한도',
|
|
children: (
|
|
<div className="token-setting-management-page__section-scroll">
|
|
<div className="token-setting-management-page__quota-grid">
|
|
<Form.Item
|
|
label="유효시간(분)"
|
|
name="defaultExpiresInMinutes"
|
|
extra="0이면 만료 없이 계속 사용할 수 있습니다."
|
|
rules={[{ required: true, message: '기본 유효시간을 입력하세요.' }]}
|
|
>
|
|
<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>
|
|
<Form.Item
|
|
label="7일 사용 가능 토큰"
|
|
name="maxTokensPer7Days"
|
|
extra="최근 7일 동안 누적 사용할 수 있는 총 토큰입니다."
|
|
rules={[{ required: true, message: '7일 토큰 한도를 입력하세요.' }]}
|
|
>
|
|
<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>
|
|
<Form.Item
|
|
label="5시간 사용 가능 토큰"
|
|
name="maxTokensPer5Hours"
|
|
extra="최근 5시간 동안 누적 사용할 수 있는 총 토큰입니다."
|
|
rules={[{ required: true, message: '5시간 토큰 한도를 입력하세요.' }]}
|
|
>
|
|
<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>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'apps',
|
|
label: `앱 권한 ${APP_OPTIONS.length}`,
|
|
children: (
|
|
<div className="token-setting-management-page__section-scroll">
|
|
<Form.Item
|
|
label="실행 가능 앱"
|
|
name="allowedAppIds"
|
|
rules={[
|
|
{
|
|
validator: async (_rule, value) => {
|
|
if (Array.isArray(value) && value.length > 0) {
|
|
return;
|
|
}
|
|
|
|
throw new Error('최소 1개 이상의 앱 권한을 선택하세요.');
|
|
},
|
|
},
|
|
]}
|
|
>
|
|
<Checkbox.Group style={{ width: '100%' }}>
|
|
<div className="token-setting-management-page__app-grid">
|
|
{APP_OPTIONS.map((option) => (
|
|
<div key={option.value} className="token-setting-management-page__app-card">
|
|
<div className="token-setting-management-page__app-card-header">
|
|
<div className="token-setting-management-page__app-card-title">
|
|
<Text strong>{option.label}</Text>
|
|
<Text type="secondary">{option.value}</Text>
|
|
</div>
|
|
<Tag>{option.category}</Tag>
|
|
</div>
|
|
<Text type="secondary">{option.description}</Text>
|
|
<Checkbox value={option.value}>권한 부여</Checkbox>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Checkbox.Group>
|
|
</Form.Item>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'history',
|
|
label: '변경 이력',
|
|
children: (
|
|
<div className="token-setting-management-page__section-scroll">
|
|
{isCreating ? (
|
|
<Alert
|
|
showIcon
|
|
type="info"
|
|
message="신규 등록 전에는 변경 이력이 없습니다."
|
|
description="설정을 먼저 저장하면 이후 수정/삭제 이력과 IP 기록이 여기에 쌓입니다."
|
|
/>
|
|
) : isActivityLoading ? (
|
|
<Paragraph>불러오는 중...</Paragraph>
|
|
) : activities.length > 0 ? (
|
|
<Table
|
|
size="small"
|
|
rowKey="id"
|
|
columns={activityColumns}
|
|
dataSource={activities}
|
|
pagination={{ pageSize: 8, hideOnSinglePage: true }}
|
|
scroll={{ x: 760 }}
|
|
/>
|
|
) : (
|
|
<Empty description="기록된 변경 이력이 없습니다." />
|
|
)}
|
|
</div>
|
|
),
|
|
},
|
|
]}
|
|
/>
|
|
</div>
|
|
{isSharedPreviewMode ? (
|
|
<div className="token-setting-management-page__form-actions">
|
|
<Text type="secondary">공유 링크에서는 현재 연결된 설정 1건의 상세 정보만 확인할 수 있습니다.</Text>
|
|
</div>
|
|
) : isSharedManageMode ? (
|
|
<div className="token-setting-management-page__form-actions">
|
|
<Space size={8} wrap>
|
|
<Button
|
|
type="primary"
|
|
icon={<SaveOutlined />}
|
|
loading={isSaving}
|
|
onClick={() => {
|
|
void form.submit();
|
|
}}
|
|
>
|
|
저장
|
|
</Button>
|
|
</Space>
|
|
<Text type="secondary">현재 공유 링크에 연결된 토큰 설정 1건만 수정됩니다.</Text>
|
|
</div>
|
|
) : (
|
|
<div className="token-setting-management-page__form-actions">
|
|
<Space size={8} wrap>
|
|
<Button
|
|
type="primary"
|
|
icon={<SaveOutlined />}
|
|
loading={isSaving}
|
|
onClick={() => {
|
|
void form.submit();
|
|
}}
|
|
>
|
|
{isCreating ? '등록' : '저장'}
|
|
</Button>
|
|
<Button icon={<PlusOutlined />} disabled={isSaving} onClick={openCreateForm}>
|
|
새 입력
|
|
</Button>
|
|
{!isCreating && selectedTokenSetting ? (
|
|
<Button danger icon={<DeleteOutlined />} loading={isSaving} onClick={() => void handleDelete()}>
|
|
삭제
|
|
</Button>
|
|
) : null}
|
|
<Button icon={<UnorderedListOutlined />} onClick={closeDetail}>
|
|
목록
|
|
</Button>
|
|
</Space>
|
|
<Text type="secondary">저장 후 현재 상세 화면을 유지한 채 결과 메시지를 바로 보여줍니다.</Text>
|
|
</div>
|
|
)}
|
|
</Form>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|