feat: implement token key fetching and masking in API responses

This commit is contained in:
CaIon
2026-03-08 22:40:17 +08:00
parent c706a5c29a
commit 9bb2b6a6ae
13 changed files with 530 additions and 98 deletions

View File

@@ -29,7 +29,6 @@ const TokensActions = ({
setShowEdit,
batchCopyTokens,
batchDeleteTokens,
copyText,
t,
}) => {
// Modal states
@@ -99,8 +98,7 @@ const TokensActions = ({
<CopyTokensModal
visible={showCopyModal}
onCancel={() => setShowCopyModal(false)}
selectedKeys={selectedKeys}
copyText={copyText}
batchCopyTokens={batchCopyTokens}
t={t}
/>

View File

@@ -108,17 +108,28 @@ const renderGroupColumn = (text, record, t) => {
};
// Render token key column with show/hide and copy functionality
const renderTokenKey = (text, record, showKeys, setShowKeys, copyText) => {
const fullKey = 'sk-' + record.key;
const maskedKey =
'sk-' + record.key.slice(0, 4) + '**********' + record.key.slice(-4);
const renderTokenKey = (
text,
record,
showKeys,
resolvedTokenKeys,
loadingTokenKeys,
toggleTokenVisibility,
copyTokenKey,
) => {
const revealed = !!showKeys[record.id];
const loading = !!loadingTokenKeys[record.id];
const keyValue =
revealed && resolvedTokenKeys[record.id]
? resolvedTokenKeys[record.id]
: record.key || '';
const displayedKey = keyValue ? `sk-${keyValue}` : '';
return (
<div className='w-[200px]'>
<Input
readOnly
value={revealed ? fullKey : maskedKey}
value={displayedKey}
size='small'
suffix={
<div className='flex items-center'>
@@ -127,10 +138,11 @@ const renderTokenKey = (text, record, showKeys, setShowKeys, copyText) => {
size='small'
type='tertiary'
icon={revealed ? <IconEyeClosed /> : <IconEyeOpened />}
loading={loading}
aria-label='toggle token visibility'
onClick={(e) => {
onClick={async (e) => {
e.stopPropagation();
setShowKeys((prev) => ({ ...prev, [record.id]: !revealed }));
await toggleTokenVisibility(record);
}}
/>
<Button
@@ -138,10 +150,11 @@ const renderTokenKey = (text, record, showKeys, setShowKeys, copyText) => {
size='small'
type='tertiary'
icon={<IconCopy />}
loading={loading}
aria-label='copy token key'
onClick={async (e) => {
e.stopPropagation();
await copyText(fullKey);
await copyTokenKey(record);
}}
/>
</div>
@@ -427,8 +440,10 @@ const renderOperations = (
export const getTokensColumns = ({
t,
showKeys,
setShowKeys,
copyText,
resolvedTokenKeys,
loadingTokenKeys,
toggleTokenVisibility,
copyTokenKey,
manageToken,
onOpenLink,
setEditingToken,
@@ -461,7 +476,15 @@ export const getTokensColumns = ({
title: t('密钥'),
key: 'token_key',
render: (text, record) =>
renderTokenKey(text, record, showKeys, setShowKeys, copyText),
renderTokenKey(
text,
record,
showKeys,
resolvedTokenKeys,
loadingTokenKeys,
toggleTokenVisibility,
copyTokenKey,
),
},
{
title: t('可用模型'),

View File

@@ -39,8 +39,10 @@ const TokensTable = (tokensData) => {
rowSelection,
handleRow,
showKeys,
setShowKeys,
copyText,
resolvedTokenKeys,
loadingTokenKeys,
toggleTokenVisibility,
copyTokenKey,
manageToken,
onOpenLink,
setEditingToken,
@@ -54,8 +56,10 @@ const TokensTable = (tokensData) => {
return getTokensColumns({
t,
showKeys,
setShowKeys,
copyText,
resolvedTokenKeys,
loadingTokenKeys,
toggleTokenVisibility,
copyTokenKey,
manageToken,
onOpenLink,
setEditingToken,
@@ -65,8 +69,10 @@ const TokensTable = (tokensData) => {
}, [
t,
showKeys,
setShowKeys,
copyText,
resolvedTokenKeys,
loadingTokenKeys,
toggleTokenVisibility,
copyTokenKey,
manageToken,
onOpenLink,
setEditingToken,

View File

@@ -58,6 +58,7 @@ function TokensPage() {
t: (k) => k,
selectedModel: '',
prefillKey: '',
fetchTokenKey: async () => '',
});
const [modelOptions, setModelOptions] = useState([]);
const [selectedModel, setSelectedModel] = useState('');
@@ -74,6 +75,7 @@ function TokensPage() {
t: tokensData.t,
selectedModel,
prefillKey,
fetchTokenKey: tokensData.fetchTokenKey,
};
}, [
tokensData.tokens,
@@ -81,6 +83,7 @@ function TokensPage() {
tokensData.t,
selectedModel,
prefillKey,
tokensData.fetchTokenKey,
]);
const loadModels = async () => {
@@ -198,13 +201,14 @@ function TokensPage() {
openCCSwitchModalRef.current = openCCSwitchModal;
// Prefill to Fluent handler
const handlePrefillToFluent = () => {
const handlePrefillToFluent = async () => {
const {
tokens,
selectedKeys,
t,
selectedModel: chosenModel,
prefillKey: overrideKey,
fetchTokenKey,
} = latestRef.current;
const container = document.getElementById('fluent-new-api-container');
if (!container) {
@@ -241,7 +245,11 @@ function TokensPage() {
Toast.warning(t('没有可用令牌用于填充'));
return;
}
apiKeyToUse = 'sk-' + token.key;
try {
apiKeyToUse = 'sk-' + (await fetchTokenKey(token));
} catch (_) {
return;
}
}
const payload = {
@@ -351,7 +359,6 @@ function TokensPage() {
setShowEdit,
batchCopyTokens,
batchDeleteTokens,
copyText,
// Filters state
formInitValues,
@@ -401,7 +408,6 @@ function TokensPage() {
setShowEdit={setShowEdit}
batchCopyTokens={batchCopyTokens}
batchDeleteTokens={batchDeleteTokens}
copyText={copyText}
t={t}
/>

View File

@@ -116,8 +116,7 @@ export default function CCSwitchModal({
Toast.warning(t('请选择主模型'));
return;
}
const apiKey = 'sk-' + tokenKey;
const url = buildCCSwitchURL(app, name, models, apiKey);
const url = buildCCSwitchURL(app, name, models, 'sk-' + tokenKey);
window.open(url, '_blank');
onClose();
};

View File

@@ -20,24 +20,21 @@ For commercial licensing, please contact support@quantumnous.com
import React from 'react';
import { Modal, Button, Space } from '@douyinfe/semi-ui';
const CopyTokensModal = ({ visible, onCancel, selectedKeys, copyText, t }) => {
const CopyTokensModal = ({
visible,
onCancel,
batchCopyTokens,
t,
}) => {
// Handle copy with name and key format
const handleCopyWithName = async () => {
let content = '';
for (let i = 0; i < selectedKeys.length; i++) {
content += selectedKeys[i].name + ' sk-' + selectedKeys[i].key + '\n';
}
await copyText(content);
await batchCopyTokens('name+key');
onCancel();
};
// Handle copy with key only format
const handleCopyKeyOnly = async () => {
let content = '';
for (let i = 0; i < selectedKeys.length; i++) {
content += 'sk-' + selectedKeys[i].key + '\n';
}
await copyText(content);
await batchCopyTokens('key-only');
onCancel();
};

View File

@@ -73,7 +73,7 @@ const ColumnSelectorModal = ({
<RadioGroup
type='button'
value={billingDisplayMode}
onChange={(event) => setBillingDisplayMode(event.target.value)}
onChange={(value) => setBillingDisplayMode(value)}
>
<Radio value='price'>
{isTokensDisplay ? t('价格模式') : t('价格模式(默认)')}

View File

@@ -20,8 +20,22 @@ For commercial licensing, please contact support@quantumnous.com
import { API } from './api';
/**
* 获取可用的token keys
* @returns {Promise<string[]>} 返回active状态的token key数组
* 按需获取单个令牌的真实 key
* @param {number|string} tokenId
* @returns {Promise<string>} 返回不带 sk- 前缀的真实 token key
*/
export async function fetchTokenKey(tokenId) {
const response = await API.post(`/api/token/${tokenId}/key`);
const { success, data, message } = response.data || {};
if (!success || !data?.key) {
throw new Error(message || 'Failed to fetch token key');
}
return data.key;
}
/**
* 获取可用的 token keys
* @returns {Promise<string[]>} 返回 active 状态的不带 sk- 前缀的真实 token key 数组
*/
export async function fetchTokenKeys() {
try {
@@ -31,7 +45,12 @@ export async function fetchTokenKeys() {
const tokenItems = Array.isArray(data) ? data : data.items || [];
const activeTokens = tokenItems.filter((token) => token.status === 1);
return activeTokens.map((token) => token.key);
const keyResults = await Promise.allSettled(
activeTokens.map((token) => fetchTokenKey(token.id)),
);
return keyResults
.filter((result) => result.status === 'fulfilled' && result.value)
.map((result) => result.value);
} catch (error) {
console.error('Error fetching token keys:', error);
return [];

View File

@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Modal } from '@douyinfe/semi-ui';
import {
@@ -29,6 +29,7 @@ import {
} from '../../helpers';
import { ITEMS_PER_PAGE } from '../../constants';
import { useTableCompactMode } from '../common/useTableCompactMode';
import { fetchTokenKey as fetchTokenKeyById } from '../../helpers/token';
export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
const { t } = useTranslation();
@@ -54,6 +55,9 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
// UI state
const [compactMode, setCompactMode] = useTableCompactMode('tokens');
const [showKeys, setShowKeys] = useState({});
const [resolvedTokenKeys, setResolvedTokenKeys] = useState({});
const [loadingTokenKeys, setLoadingTokenKeys] = useState({});
const keyRequestsRef = useRef({});
// Form state
const [formApi, setFormApi] = useState(null);
@@ -87,6 +91,7 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
setTokenCount(payload.total || 0);
setActivePage(payload.page || 1);
setPageSize(payload.page_size || pageSize);
setShowKeys({});
};
// Load tokens function
@@ -122,14 +127,86 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
}
};
const fetchTokenKey = async (tokenOrId, options = {}) => {
const { suppressError = false } = options;
const tokenId =
typeof tokenOrId === 'object' ? tokenOrId?.id : Number(tokenOrId);
if (!tokenId) {
const error = new Error(t('令牌不存在'));
if (!suppressError) {
showError(error.message);
}
throw error;
}
if (resolvedTokenKeys[tokenId]) {
return resolvedTokenKeys[tokenId];
}
if (keyRequestsRef.current[tokenId]) {
return keyRequestsRef.current[tokenId];
}
const request = (async () => {
setLoadingTokenKeys((prev) => ({ ...prev, [tokenId]: true }));
try {
const fullKey = await fetchTokenKeyById(tokenId);
setResolvedTokenKeys((prev) => ({ ...prev, [tokenId]: fullKey }));
return fullKey;
} catch (error) {
const normalizedError = new Error(
error?.message || t('获取令牌密钥失败'),
);
if (!suppressError) {
showError(normalizedError.message);
}
throw normalizedError;
} finally {
delete keyRequestsRef.current[tokenId];
setLoadingTokenKeys((prev) => {
const next = { ...prev };
delete next[tokenId];
return next;
});
}
})();
keyRequestsRef.current[tokenId] = request;
return request;
};
const toggleTokenVisibility = async (record) => {
const tokenId = record?.id;
if (!tokenId) {
return;
}
if (showKeys[tokenId]) {
setShowKeys((prev) => ({ ...prev, [tokenId]: false }));
return;
}
const fullKey = await fetchTokenKey(record);
if (fullKey) {
setShowKeys((prev) => ({ ...prev, [tokenId]: true }));
}
};
const copyTokenKey = async (record) => {
const fullKey = await fetchTokenKey(record);
await copyText(`sk-${fullKey}`);
};
// Open link function for chat integrations
const onOpenLink = async (type, url, record) => {
const fullKey = await fetchTokenKey(record);
if (url && url.startsWith('ccswitch')) {
openCCSwitchModal(record.key);
openCCSwitchModal(fullKey);
return;
}
if (url && url.startsWith('fluent')) {
openFluentNotification(record.key);
openFluentNotification(fullKey);
return;
}
let status = localStorage.getItem('status');
@@ -145,7 +222,7 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
let cherryConfig = {
id: 'new-api',
baseUrl: serverAddress,
apiKey: 'sk-' + record.key,
apiKey: `sk-${fullKey}`,
};
let encodedConfig = encodeURIComponent(
encodeToBase64(JSON.stringify(cherryConfig)),
@@ -155,7 +232,7 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
let aionuiConfig = {
platform: 'new-api',
baseUrl: serverAddress,
apiKey: 'sk-' + record.key,
apiKey: `sk-${fullKey}`,
};
let encodedConfig = encodeURIComponent(
encodeToBase64(JSON.stringify(aionuiConfig)),
@@ -164,7 +241,7 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
} else {
let encodedServerAddress = encodeURIComponent(serverAddress);
url = url.replaceAll('{address}', encodedServerAddress);
url = url.replaceAll('{key}', 'sk-' + record.key);
url = url.replaceAll('{key}', `sk-${fullKey}`);
}
window.open(url, '_blank');
@@ -314,48 +391,28 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
};
// Batch copy tokens
const batchCopyTokens = (copyType) => {
const batchCopyTokens = async (copyType) => {
if (selectedKeys.length === 0) {
showError(t('请至少选择一个令牌!'));
return;
}
Modal.info({
title: t('复制令牌'),
icon: null,
content: t('请选择你的复制方式'),
footer: (
<div className='flex gap-2'>
<button
className='px-3 py-1 bg-gray-200 rounded'
onClick={async () => {
let content = '';
for (let i = 0; i < selectedKeys.length; i++) {
content +=
selectedKeys[i].name + ' sk-' + selectedKeys[i].key + '\n';
}
await copyText(content);
Modal.destroyAll();
}}
>
{t('名称+密钥')}
</button>
<button
className='px-3 py-1 bg-blue-500 text-white rounded'
onClick={async () => {
let content = '';
for (let i = 0; i < selectedKeys.length; i++) {
content += 'sk-' + selectedKeys[i].key + '\n';
}
await copyText(content);
Modal.destroyAll();
}}
>
{t('仅密钥')}
</button>
</div>
),
});
try {
const keys = await Promise.all(
selectedKeys.map((token) => fetchTokenKey(token, { suppressError: true })),
);
let content = '';
for (let i = 0; i < selectedKeys.length; i++) {
const fullKey = keys[i];
if (copyType === 'name+key') {
content += `${selectedKeys[i].name} sk-${fullKey}\n`;
} else {
content += `sk-${fullKey}\n`;
}
}
await copyText(content);
} catch (error) {
showError(error?.message || t('复制令牌失败'));
}
};
// Initialize data
@@ -392,6 +449,8 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
setCompactMode,
showKeys,
setShowKeys,
resolvedTokenKeys,
loadingTokenKeys,
// Form state
formApi,
@@ -403,6 +462,9 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
loadTokens,
refresh,
copyText,
fetchTokenKey,
toggleTokenVisibility,
copyTokenKey,
onOpenLink,
manageToken,
searchTokens,