forked from boos/BoosAPI
Merge pull request #204 from Shinku-Chen/rand-sk-2
生成密钥的时候,能随机生成,同时能复制token
This commit is contained in:
@@ -7,10 +7,13 @@ import { useToast } from '../components/Toast.js';
|
|||||||
import ModernSelect from '../components/ModernSelect.js';
|
import ModernSelect from '../components/ModernSelect.js';
|
||||||
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
|
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
|
||||||
import { tr } from '../i18n.js';
|
import { tr } from '../i18n.js';
|
||||||
|
import { generateDownstreamSkKey } from './helpers/generateDownstreamSkKey.js';
|
||||||
|
|
||||||
const DownstreamKeyTrendChart = lazy(() => import('../components/charts/DownstreamKeyTrendChart.js'));
|
const DownstreamKeyTrendChart = lazy(() => import('../components/charts/DownstreamKeyTrendChart.js'));
|
||||||
type DownstreamKeyTrendBucket = import('../components/charts/DownstreamKeyTrendChart.js').DownstreamKeyTrendBucket;
|
type DownstreamKeyTrendBucket = import('../components/charts/DownstreamKeyTrendChart.js').DownstreamKeyTrendBucket;
|
||||||
|
|
||||||
|
const PROXY_TOKEN_PREFIX = 'sk-';
|
||||||
|
|
||||||
type Range = '24h' | '7d' | 'all';
|
type Range = '24h' | '7d' | 'all';
|
||||||
type Status = 'all' | 'enabled' | 'disabled';
|
type Status = 'all' | 'enabled' | 'disabled';
|
||||||
|
|
||||||
@@ -259,6 +262,86 @@ function tagChipStyle(kind: 'normal' | 'accent' = 'normal'): React.CSSProperties
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function copyToClipboard(text: string): Promise<void> {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
textarea.value = text;
|
||||||
|
textarea.style.position = 'fixed';
|
||||||
|
textarea.style.opacity = '0';
|
||||||
|
textarea.style.left = '-9999px';
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.focus();
|
||||||
|
textarea.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DownstreamKeyCopyIconButton({ fullKey }: { fullKey: string | undefined }) {
|
||||||
|
const toast = useToast();
|
||||||
|
const [pressed, setPressed] = useState(false);
|
||||||
|
|
||||||
|
const disabled = !fullKey?.trim();
|
||||||
|
const release = () => setPressed(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="复制完整密钥"
|
||||||
|
aria-label="复制完整密钥"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 2,
|
||||||
|
lineHeight: 0,
|
||||||
|
flexShrink: 0,
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
color: disabled
|
||||||
|
? 'var(--color-text-muted)'
|
||||||
|
: pressed
|
||||||
|
? 'var(--color-text-primary)'
|
||||||
|
: 'var(--color-text-muted)',
|
||||||
|
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: disabled ? 0.4 : 1,
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
onMouseDown={() => {
|
||||||
|
if (!disabled) setPressed(true);
|
||||||
|
}}
|
||||||
|
onMouseUp={release}
|
||||||
|
onMouseLeave={release}
|
||||||
|
onTouchStart={() => {
|
||||||
|
if (!disabled) setPressed(true);
|
||||||
|
}}
|
||||||
|
onTouchEnd={release}
|
||||||
|
onTouchCancel={release}
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const full = fullKey?.trim();
|
||||||
|
if (!full) {
|
||||||
|
toast.info('完整密钥暂不可用,请刷新页面后重试');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await copyToClipboard(full);
|
||||||
|
toast.success('已复制到剪贴板');
|
||||||
|
} catch {
|
||||||
|
toast.error('复制失败');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function buildEditorForm(item?: ManagedItem | DownstreamApiKeyItem | null): EditorForm {
|
function buildEditorForm(item?: ManagedItem | DownstreamApiKeyItem | null): EditorForm {
|
||||||
return {
|
return {
|
||||||
name: item?.name || '',
|
name: item?.name || '',
|
||||||
@@ -877,7 +960,22 @@ function EditorModal({
|
|||||||
</div>
|
</div>
|
||||||
<div className="downstream-key-modal-field">
|
<div className="downstream-key-modal-field">
|
||||||
<div className="downstream-key-modal-label">下游密钥</div>
|
<div className="downstream-key-modal-label">下游密钥</div>
|
||||||
<input value={form.key} onChange={(e) => onChange((prev) => ({ ...prev, key: e.target.value }))} placeholder="sk-..." style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }} />
|
<div style={{ display: 'flex', gap: 8, alignItems: 'stretch', minWidth: 0 }}>
|
||||||
|
<input
|
||||||
|
value={form.key}
|
||||||
|
onChange={(e) => onChange((prev) => ({ ...prev, key: e.target.value }))}
|
||||||
|
placeholder="sk-..."
|
||||||
|
style={{ ...inputStyle, flex: 1, minWidth: 0, fontFamily: 'var(--font-mono)' }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ flexShrink: 0, whiteSpace: 'nowrap', alignSelf: 'stretch' }}
|
||||||
|
onClick={() => onChange((prev) => ({ ...prev, key: generateDownstreamSkKey(PROXY_TOKEN_PREFIX) }))}
|
||||||
|
>
|
||||||
|
随机
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="downstream-key-modal-field">
|
<div className="downstream-key-modal-field">
|
||||||
<div className="downstream-key-modal-label">主分组</div>
|
<div className="downstream-key-modal-label">主分组</div>
|
||||||
@@ -1637,7 +1735,10 @@ export default function DownstreamKeys() {
|
|||||||
<strong style={{ color: 'var(--color-text-primary)' }}>{row.name}</strong>
|
<strong style={{ color: 'var(--color-text-primary)' }}>{row.name}</strong>
|
||||||
<StatusBadge enabled={row.enabled} />
|
<StatusBadge enabled={row.enabled} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>{row.keyMasked}</div>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', marginBottom: 4 }}>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--color-text-muted)' }}>{row.keyMasked}</span>
|
||||||
|
<DownstreamKeyCopyIconButton fullKey={row.key} />
|
||||||
|
</div>
|
||||||
{row.description ? <div style={{ fontSize: 12, color: 'var(--color-text-secondary)', maxWidth: 320 }}>{row.description}</div> : null}
|
{row.description ? <div style={{ fontSize: 12, color: 'var(--color-text-secondary)', maxWidth: 320 }}>{row.description}</div> : null}
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
|
||||||
<span className={`badge ${row.groupName ? 'badge-info' : 'badge-muted'}`} style={{ fontSize: 11 }}>
|
<span className={`badge ${row.groupName ? 'badge-info' : 'badge-muted'}`} style={{ fontSize: 11 }}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
@@ -17,6 +17,7 @@ import { clearAuthSession } from '../authSession.js';
|
|||||||
import { clearAppInstallationState } from '../appLocalState.js';
|
import { clearAppInstallationState } from '../appLocalState.js';
|
||||||
import { tr } from '../i18n.js';
|
import { tr } from '../i18n.js';
|
||||||
import { ROUTE_ICON_NONE_VALUE } from './token-routes/utils.js';
|
import { ROUTE_ICON_NONE_VALUE } from './token-routes/utils.js';
|
||||||
|
import { generateDownstreamSkKey } from './helpers/generateDownstreamSkKey.js';
|
||||||
|
|
||||||
const PROXY_TOKEN_PREFIX = 'sk-';
|
const PROXY_TOKEN_PREFIX = 'sk-';
|
||||||
const ROUTE_BRAND_ICON_PREFIX = 'brand:';
|
const ROUTE_BRAND_ICON_PREFIX = 'brand:';
|
||||||
@@ -1164,8 +1165,20 @@ export default function Settings() {
|
|||||||
</code>
|
</code>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
...inputStyle,
|
display: 'flex',
|
||||||
|
gap: 10,
|
||||||
|
alignItems: 'stretch',
|
||||||
marginBottom: 10,
|
marginBottom: 10,
|
||||||
|
minWidth: 0,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...inputStyle,
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 200,
|
||||||
|
marginBottom: 0,
|
||||||
padding: 0,
|
padding: 0,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -1180,17 +1193,19 @@ export default function Settings() {
|
|||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: 'var(--color-text-secondary)',
|
color: 'var(--color-text-secondary)',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
|
background: 'color-mix(in srgb, var(--color-text-muted) 6%, transparent)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{PROXY_TOKEN_PREFIX}
|
{PROXY_TOKEN_PREFIX}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="text"
|
||||||
value={proxyTokenSuffix}
|
value={proxyTokenSuffix}
|
||||||
onChange={(e) => setProxyTokenSuffix(normalizeProxyTokenSuffix(e.target.value))}
|
onChange={(e) => setProxyTokenSuffix(normalizeProxyTokenSuffix(e.target.value))}
|
||||||
placeholder="请输入 sk- 后的令牌内容"
|
placeholder="请输入 sk- 后的令牌内容"
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
border: 'none',
|
border: 'none',
|
||||||
outline: 'none',
|
outline: 'none',
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
@@ -1201,6 +1216,33 @@ export default function Settings() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-soft-primary"
|
||||||
|
aria-label="随机生成访问令牌后缀"
|
||||||
|
title="生成高熵随机后缀(不会自动保存)"
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
padding: '10px 18px',
|
||||||
|
fontSize: 13,
|
||||||
|
gap: 8,
|
||||||
|
alignSelf: 'stretch',
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
const full = generateDownstreamSkKey(PROXY_TOKEN_PREFIX);
|
||||||
|
setProxyTokenSuffix(full.slice(PROXY_TOKEN_PREFIX.length));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
随机生成
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button onClick={saveProxyToken} disabled={savingToken} className="btn btn-primary">
|
<button onClick={saveProxyToken} disabled={savingToken} className="btn btn-primary">
|
||||||
{savingToken ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '更新下游访问令牌'}
|
{savingToken ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '更新下游访问令牌'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export function generateDownstreamSkKey(prefix: string): string {
|
||||||
|
const bytes = new Uint8Array(32);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
return `${prefix}${hex}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user