Files
EasyCLIProxyAPI/src/pages/ConfigPanel.tsx
T
lzt404 88baeab512 feat(config): improve agent and WebUI configuration security
- add WebUI management key settings
- generate strong per-install management secrets
- rotate the legacy 123456 management key on upgrade
- preserve custom management keys
- restore Codex configuration without a state JSON file
2026-08-02 00:08:16 +08:00

1093 lines
40 KiB
TypeScript

import { FormEvent, useEffect, useRef, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import {
AlertCircle,
Check,
Copy,
Clock3,
Eye,
EyeOff,
KeyRound,
Link2,
Network,
Pencil,
Plus,
RefreshCw,
Route,
ShieldCheck,
Sparkles,
Trash2,
X,
} from 'lucide-react';
import { useCoreRuntime, type CoreStatus } from '../coreRuntime';
import { useI18n } from '../i18n';
import { SpeedAliasesPage } from './SpeedAliasesPage';
import { ThinkingAliasesPage } from './ThinkingAliasesPage';
type CoreConfigSettings = {
apiKeys: CoreApiKey[];
managementSecretConfigured: boolean;
port: number;
allowLan: boolean;
routingStrategy: string;
proxyUrl: string;
routingSessionAffinity: boolean;
routingSessionAffinityTtl: string;
};
type CoreApiKey = {
apiKey: string;
remark: string;
};
type ConfigAction =
| 'add-key'
| 'update-key'
| 'delete-key'
| 'management-secret'
| 'routing'
| 'network'
| null;
type NoticeTone = 'success' | 'error';
type ConfigSubpage = 'general' | 'network' | 'aliases' | 'speedAliases';
const ROUTING_OPTIONS = [
{ value: 'round-robin', labelKey: 'config.routing.roundRobin' },
{ value: 'fill-first', labelKey: 'config.routing.fillFirst' },
] as const;
export function ConfigPanelPage() {
const { t } = useI18n();
const { status: coreStatus, publishStatus, refreshStatus } = useCoreRuntime();
const [settings, setSettings] = useState<CoreConfigSettings | null>(null);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState('');
const [busyAction, setBusyAction] = useState<ConfigAction>(null);
const [addDialogOpen, setAddDialogOpen] = useState(false);
const [editingApiKey, setEditingApiKey] = useState<string | null>(null);
const [deleteIndex, setDeleteIndex] = useState<number | null>(null);
const [newApiKey, setNewApiKey] = useState('');
const [newApiKeyRemark, setNewApiKeyRemark] = useState('');
const [showApiKey, setShowApiKey] = useState(false);
const [formError, setFormError] = useState('');
const [managementSecretDraft, setManagementSecretDraft] = useState('');
const [managementSecretConfirm, setManagementSecretConfirm] = useState('');
const [showManagementSecret, setShowManagementSecret] = useState(false);
const [managementSecretError, setManagementSecretError] = useState('');
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
const [notice, setNotice] = useState<{ message: string; tone: NoticeTone } | null>(null);
const [activeSubpage, setActiveSubpage] = useState<ConfigSubpage>('general');
const [portDraft, setPortDraft] = useState('8317');
const [allowLanDraft, setAllowLanDraft] = useState(false);
const [proxyUrlDraft, setProxyUrlDraft] = useState('');
const [sessionAffinityDraft, setSessionAffinityDraft] = useState(false);
const [sessionTtlDraft, setSessionTtlDraft] = useState('');
const [portError, setPortError] = useState('');
const [savedStatusVisible, setSavedStatusVisible] = useState(false);
const noticeTimerRef = useRef<number | null>(null);
const copyTimerRef = useRef<number | null>(null);
useEffect(() => {
let disposed = false;
let stop: (() => void) | null = null;
void loadSettings();
void listen('config-files-changed', () => {
if (!disposed) void loadSettings();
}).then((unlisten) => {
if (disposed) unlisten();
else stop = unlisten;
});
return () => {
disposed = true;
stop?.();
if (noticeTimerRef.current !== null) {
window.clearTimeout(noticeTimerRef.current);
}
if (copyTimerRef.current !== null) {
window.clearTimeout(copyTimerRef.current);
}
};
}, []);
const showNotice = (message: string, tone: NoticeTone) => {
if (noticeTimerRef.current !== null) {
window.clearTimeout(noticeTimerRef.current);
}
setNotice({ message, tone });
noticeTimerRef.current = window.setTimeout(() => {
setNotice(null);
noticeTimerRef.current = null;
}, 3200);
};
const applySettings = (result: CoreConfigSettings, syncNetworkDrafts = true) => {
setSettings(result);
if (syncNetworkDrafts) {
setPortDraft(String(result.port));
setAllowLanDraft(result.allowLan);
setProxyUrlDraft(result.proxyUrl);
setSessionAffinityDraft(result.routingSessionAffinity);
setSessionTtlDraft(result.routingSessionAffinityTtl);
setPortError('');
}
};
async function loadSettings(syncNetworkDrafts = true) {
setLoading(true);
setLoadError('');
try {
const result = await invoke<CoreConfigSettings>('get_core_config_settings');
applySettings(result, syncNetworkDrafts);
} catch (error) {
setSettings(null);
setLoadError(String(error));
} finally {
setLoading(false);
}
}
const runMutation = async (
action: Exclude<ConfigAction, null>,
command: string,
args: Record<string, unknown>,
successMessage: string,
) => {
setBusyAction(action);
try {
const result = await invoke<CoreConfigSettings>(command, args);
applySettings(result, false);
setLoadError('');
showNotice(successMessage, 'success');
return true;
} catch (error) {
if (settings) applySettings(settings, false);
showNotice(t('config.error.saveFailed', { error: String(error) }), 'error');
void loadSettings(false);
return false;
} finally {
setBusyAction(null);
}
};
const openAddDialog = () => {
setEditingApiKey(null);
setNewApiKey('');
setNewApiKeyRemark('');
setShowApiKey(false);
setFormError('');
setAddDialogOpen(true);
};
const openEditDialog = (entry: CoreApiKey) => {
setEditingApiKey(entry.apiKey);
setNewApiKey(entry.apiKey);
setNewApiKeyRemark(entry.remark);
setShowApiKey(false);
setFormError('');
setAddDialogOpen(true);
};
const closeAddDialog = () => {
if (busyAction === 'add-key' || busyAction === 'update-key') {
return;
}
setAddDialogOpen(false);
setEditingApiKey(null);
setFormError('');
};
const generateApiKey = () => {
const bytes = new Uint8Array(24);
crypto.getRandomValues(bytes);
const value = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
setNewApiKey(`sk-${value}`);
setShowApiKey(true);
setFormError('');
};
const generateManagementSecret = () => {
const bytes = new Uint8Array(24);
crypto.getRandomValues(bytes);
const value = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
const secret = `webui-${value}`;
setManagementSecretDraft(secret);
setManagementSecretConfirm(secret);
setShowManagementSecret(true);
setManagementSecretError('');
};
const saveManagementSecret = async (event: FormEvent) => {
event.preventDefault();
const secretKey = managementSecretDraft.trim();
if (!secretKey) {
setManagementSecretError(t('config.webuiKey.error.empty'));
return;
}
if (secretKey === '123456') {
setManagementSecretError(t('config.webuiKey.error.legacyDefault'));
return;
}
if (secretKey.length > 512) {
setManagementSecretError(t('config.webuiKey.error.tooLong'));
return;
}
if (/[\u0000-\u001f\u007f-\u009f]/.test(secretKey)) {
setManagementSecretError(t('config.webuiKey.error.invalid'));
return;
}
if (secretKey !== managementSecretConfirm.trim()) {
setManagementSecretError(t('config.webuiKey.error.mismatch'));
return;
}
const saved = await runMutation(
'management-secret',
'set_core_management_secret_key',
{ secretKey },
t('config.webuiKey.notice.updated'),
);
if (saved) {
setManagementSecretDraft('');
setManagementSecretConfirm('');
setShowManagementSecret(false);
setManagementSecretError('');
}
};
const submitApiKey = async (event: FormEvent) => {
event.preventDefault();
const apiKey = newApiKey.trim();
if (!apiKey) {
setFormError(t('config.error.emptyKey'));
return;
}
if (!/^[\x21-\x7e]+$/.test(apiKey)) {
setFormError(t('config.error.invalidKey'));
return;
}
if (settings?.apiKeys.some((entry) => entry.apiKey === apiKey && entry.apiKey !== editingApiKey)) {
setFormError(t('config.error.duplicateKey'));
return;
}
const remark = newApiKeyRemark.trim();
if (remark.length > 80) {
setFormError(t('config.error.remarkTooLong'));
return;
}
const editing = editingApiKey !== null;
const saved = await runMutation(
editing ? 'update-key' : 'add-key',
editing ? 'update_core_api_key' : 'add_core_api_key',
editing ? { originalApiKey: editingApiKey, apiKey, remark } : { apiKey, remark },
editing ? t('config.notice.keyUpdated') : t('config.notice.keyAdded'),
);
if (saved) {
setAddDialogOpen(false);
setEditingApiKey(null);
setNewApiKey('');
setNewApiKeyRemark('');
}
};
const confirmDelete = async () => {
if (deleteIndex === null) {
return;
}
const deleted = await runMutation(
'delete-key',
'delete_core_api_key',
{ apiKey: selectedDeleteKey },
t('config.notice.keyDeleted'),
);
if (deleted) {
setDeleteIndex(null);
}
};
const copyApiKey = async (apiKey: string, index: number) => {
try {
await navigator.clipboard.writeText(apiKey);
setCopiedIndex(index);
showNotice(t('config.notice.keyCopied'), 'success');
if (copyTimerRef.current !== null) {
window.clearTimeout(copyTimerRef.current);
}
copyTimerRef.current = window.setTimeout(() => {
setCopiedIndex(null);
copyTimerRef.current = null;
}, 1800);
} catch {
showNotice(t('config.notice.keyCopyFailed'), 'error');
}
};
const changeRoutingStrategy = async (strategy: string) => {
if (strategy === settings?.routingStrategy) {
return;
}
setSavedStatusVisible(false);
const saved = await runMutation(
'routing',
'set_core_routing_strategy',
{ strategy },
t('config.notice.routingUpdated'),
);
if (saved) setSavedStatusVisible(true);
};
const saveNetworkRoutingSettings = async () => {
if (!settings || busyAction !== null) return;
const port = Number(portDraft);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
setPortError(t('config.error.portRange'));
showNotice(t('config.error.portRange'), 'error');
return;
}
const proxyUrl = proxyUrlDraft.trim();
const routingSessionAffinityTtl = sessionTtlDraft.trim();
const networkChanged = port !== settings.port || allowLanDraft !== settings.allowLan;
setPortError('');
setSavedStatusVisible(false);
setBusyAction('network');
try {
const result = await invoke<CoreConfigSettings>('save_network_routing_settings', {
settings: {
port,
allowLan: allowLanDraft,
proxyUrl,
routingSessionAffinity: sessionAffinityDraft,
routingSessionAffinityTtl,
},
});
applySettings(result);
setLoadError('');
setSavedStatusVisible(true);
if (networkChanged && coreStatus?.running) {
try {
const status = await invoke<CoreStatus>('restart_core_process');
publishStatus(status);
showNotice(t('config.notice.networkRestarted'), 'success');
} catch (error) {
await refreshStatus();
showNotice(t('config.error.networkRestartFailed', { error: String(error) }), 'error');
}
} else if (networkChanged) {
showNotice(t('config.notice.networkNextStart'), 'success');
} else {
showNotice(t('config.notice.networkUpdated'), 'success');
}
} catch (error) {
applySettings(settings);
showNotice(t('config.error.saveFailed', { error: String(error) }), 'error');
void loadSettings();
} finally {
setBusyAction(null);
}
};
const controlsDisabled = loading || settings === null || busyAction !== null;
const networkRoutingDirty = Boolean(settings) && (
portDraft !== String(settings?.port)
|| allowLanDraft !== settings?.allowLan
|| proxyUrlDraft.trim() !== settings?.proxyUrl
|| sessionAffinityDraft !== settings?.routingSessionAffinity
|| sessionTtlDraft.trim() !== settings?.routingSessionAffinityTtl
);
const networkStatusLabel = loading
? t('common.loading')
: settings === null
? t('common.unavailable')
: busyAction === 'network' || busyAction === 'routing'
? t('config.network.saving')
: networkRoutingDirty
? t('config.network.unsaved')
: savedStatusVisible
? t('config.network.saved')
: '';
const networkStatusIsSaved = !loading
&& settings !== null
&& busyAction === null
&& !networkRoutingDirty
&& savedStatusVisible;
const selectedDeleteKey =
deleteIndex === null ? '' : settings?.apiKeys[deleteIndex]?.apiKey || '';
const deletingLastKey = deleteIndex !== null && settings?.apiKeys.length === 1;
const keyMutationBusy = busyAction === 'add-key' || busyAction === 'update-key';
const managementSecretBusy = busyAction === 'management-secret';
return (
<section className="page config-page">
<div className="agent-subpage-tabs config-subpage-tabs" role="tablist" aria-label={t('config.tabs.label')}>
<button
type="button"
id="config-subpage-tab-general"
role="tab"
className={activeSubpage === 'general' ? 'active' : ''}
aria-selected={activeSubpage === 'general'}
aria-controls="config-subpage-panel-general"
tabIndex={activeSubpage === 'general' ? 0 : -1}
onClick={() => setActiveSubpage('general')}
>
{t('config.tabs.general')}
</button>
<button
type="button"
id="config-subpage-tab-network"
role="tab"
className={activeSubpage === 'network' ? 'active' : ''}
aria-selected={activeSubpage === 'network'}
aria-controls="config-subpage-panel-network"
tabIndex={activeSubpage === 'network' ? 0 : -1}
onClick={() => setActiveSubpage('network')}
>
{t('config.tabs.network')}
</button>
<button
type="button"
id="config-subpage-tab-aliases"
role="tab"
className={activeSubpage === 'aliases' ? 'active' : ''}
aria-selected={activeSubpage === 'aliases'}
aria-controls="config-subpage-panel-aliases"
tabIndex={activeSubpage === 'aliases' ? 0 : -1}
onClick={() => setActiveSubpage('aliases')}
>
{t('app.nav.thinkingAliases')}
</button>
<button
type="button"
id="config-subpage-tab-speed-aliases"
role="tab"
className={activeSubpage === 'speedAliases' ? 'active' : ''}
aria-selected={activeSubpage === 'speedAliases'}
aria-controls="config-subpage-panel-speed-aliases"
tabIndex={activeSubpage === 'speedAliases' ? 0 : -1}
onClick={() => setActiveSubpage('speedAliases')}
>
{t('app.nav.speedAliases')}
</button>
</div>
{activeSubpage === 'general' ? (
<div
className="config-subpage-panel"
id="config-subpage-panel-general"
role="tabpanel"
aria-labelledby="config-subpage-tab-general"
>
<section className="panel config-keys-panel">
<div className="config-panel-heading">
<div className="config-heading-title">
<KeyRound size={18} aria-hidden="true" />
<h2>{t('config.keys.title')}</h2>
</div>
<div className="config-heading-actions">
<span className="config-count" aria-label={t('config.keys.count')}>
{settings?.apiKeys.length ?? 0}
</span>
<button
type="button"
className="icon-button"
onClick={openAddDialog}
disabled={controlsDisabled}
title={t('config.keys.add')}
aria-label={t('config.keys.add')}
>
<Plus size={18} aria-hidden="true" />
</button>
</div>
</div>
<div className="config-key-list" aria-busy={loading || undefined}>
{loading ? (
Array.from({ length: 5 }, (_, index) => (
<div className="config-key-row skeleton" key={index} aria-hidden="true">
<span />
<span />
</div>
))
) : loadError ? (
<div className="config-unavailable">
<AlertCircle size={24} aria-hidden="true" />
<strong>{t('config.unavailable')}</strong>
<span title={loadError}>{loadError}</span>
<button type="button" className="secondary-button compact-button" onClick={() => void loadSettings()}>
<RefreshCw size={16} aria-hidden="true" />
{t('common.retry')}
</button>
</div>
) : settings && settings.apiKeys.length > 0 ? (
settings.apiKeys.map((entry, index) => (
<div className="config-key-row" key={`${index}-${entry.apiKey}`}>
<div className="config-key-identity">
<span className="config-key-index">{String(index + 1).padStart(2, '0')}</span>
<div className="config-key-details">
<div className="config-key-label-line">
<strong title={entry.remark || t('config.keys.noRemark')}>
{entry.remark || t('config.keys.noRemark')}
</strong>
</div>
<code title={maskApiKey(entry.apiKey)}>{maskApiKey(entry.apiKey)}</code>
</div>
</div>
<div className="config-key-actions">
<button
type="button"
className="icon-button quiet"
onClick={() => void copyApiKey(entry.apiKey, index)}
disabled={controlsDisabled}
title={t('config.keys.copy')}
aria-label={t('config.keys.copyNth', { number: index + 1 })}
>
{copiedIndex === index ? (
<Check size={16} aria-hidden="true" />
) : (
<Copy size={16} aria-hidden="true" />
)}
</button>
<button
type="button"
className="icon-button quiet"
onClick={() => openEditDialog(entry)}
disabled={controlsDisabled}
title={t('config.keys.edit')}
aria-label={t('config.keys.editNth', { number: index + 1 })}
>
<Pencil size={16} aria-hidden="true" />
</button>
<button
type="button"
className="icon-button danger"
onClick={() => setDeleteIndex(index)}
disabled={controlsDisabled}
title={t('config.keys.delete')}
aria-label={t('config.keys.deleteNth', { number: index + 1 })}
>
<Trash2 size={16} aria-hidden="true" />
</button>
</div>
</div>
))
) : (
<div className="config-empty-list">
<KeyRound size={26} aria-hidden="true" />
<strong>{t('config.keys.empty')}</strong>
</div>
)}
</div>
</section>
<section className="panel config-management-panel">
<div className="config-panel-heading">
<div className="config-heading-title">
<ShieldCheck size={18} aria-hidden="true" />
<h2>{t('config.webuiKey.title')}</h2>
</div>
{!loading && settings ? (
<span
className={`state-pill ${settings.managementSecretConfigured ? 'success' : ''}`}
>
{settings.managementSecretConfigured
? t('config.webuiKey.configured')
: t('config.webuiKey.unconfigured')}
</span>
) : null}
</div>
<div className="config-management-content">
<div className="config-management-description">
<strong>{t('config.webuiKey.heading')}</strong>
<p>{t('config.webuiKey.description')}</p>
<small>{t('config.webuiKey.securityHint')}</small>
</div>
<form
className="config-management-form"
onSubmit={(event) => void saveManagementSecret(event)}
>
<label className="config-management-field">
<span>{t('config.webuiKey.newKey')}</span>
<div className="config-secret-input">
<input
type={showManagementSecret ? 'text' : 'password'}
autoComplete="new-password"
maxLength={512}
value={managementSecretDraft}
disabled={controlsDisabled}
aria-invalid={Boolean(managementSecretError)}
placeholder={t('config.webuiKey.placeholder')}
onChange={(event) => {
setManagementSecretDraft(event.currentTarget.value);
setManagementSecretError('');
}}
/>
<button
type="button"
className="icon-button quiet"
disabled={controlsDisabled}
onClick={() => setShowManagementSecret((value) => !value)}
title={showManagementSecret ? t('config.keys.hide') : t('config.keys.show')}
aria-label={showManagementSecret ? t('config.keys.hide') : t('config.keys.show')}
>
{showManagementSecret ? (
<EyeOff size={16} aria-hidden="true" />
) : (
<Eye size={16} aria-hidden="true" />
)}
</button>
</div>
</label>
<label className="config-management-field">
<span>{t('config.webuiKey.confirmKey')}</span>
<input
className="config-dialog-text-input"
type={showManagementSecret ? 'text' : 'password'}
autoComplete="new-password"
maxLength={512}
value={managementSecretConfirm}
disabled={controlsDisabled}
aria-invalid={Boolean(managementSecretError)}
placeholder={t('config.webuiKey.confirmPlaceholder')}
onChange={(event) => {
setManagementSecretConfirm(event.currentTarget.value);
setManagementSecretError('');
}}
/>
</label>
<div className="config-management-form-footer">
<span
className={`config-management-error ${managementSecretError ? 'visible' : ''}`}
role="alert"
>
{managementSecretError || ' '}
</span>
<div className="config-management-actions">
<button
type="button"
className="secondary-button compact-button"
disabled={controlsDisabled}
onClick={generateManagementSecret}
>
<Sparkles size={16} aria-hidden="true" />
{t('config.webuiKey.generate')}
</button>
<button
type="submit"
className="primary-button compact-button"
disabled={controlsDisabled || !managementSecretDraft.trim()}
>
<Check size={16} aria-hidden="true" />
{managementSecretBusy ? t('common.saving') : t('config.webuiKey.save')}
</button>
</div>
</div>
</form>
</div>
</section>
</div>
) : activeSubpage === 'network' ? (
<div
className="config-subpage-panel config-network-subpage"
id="config-subpage-panel-network"
role="tabpanel"
aria-labelledby="config-subpage-tab-network"
>
<section className="panel config-network-panel">
<div className="config-panel-heading">
<div className="config-heading-title">
<Network size={18} aria-hidden="true" />
<h2>{t('config.network.title')}</h2>
</div>
<div className="config-heading-actions">
{networkStatusLabel ? (
<span className={`state-pill ${networkStatusIsSaved ? 'success' : ''}`}>
{networkStatusLabel}
</span>
) : null}
<button
type="button"
className="primary-button compact-button"
disabled={controlsDisabled || !networkRoutingDirty}
onClick={() => void saveNetworkRoutingSettings()}
>
<Check size={16} aria-hidden="true" />
{busyAction === 'network'
? t('config.network.saving')
: t('config.network.confirmSave')}
</button>
</div>
</div>
<div className="config-network-sections">
<section className="config-network-section" aria-labelledby="config-network-section-title">
<div className="config-network-section-heading">
<Network size={16} aria-hidden="true" />
<h3 id="config-network-section-title">{t('config.network.networkSection')}</h3>
</div>
<div className="config-network-grid">
<label className="config-network-field config-network-port-field">
<span>{t('config.network.port')}</span>
<input
className={`config-network-input ${portError ? 'error' : ''}`}
type="text"
inputMode="numeric"
pattern="[0-9]*"
maxLength={5}
value={portDraft}
disabled={controlsDisabled}
aria-invalid={Boolean(portError)}
title={portError || t('config.network.portHint')}
onChange={(event) => {
setSavedStatusVisible(false);
setPortDraft(event.currentTarget.value.replace(/\D/g, '').slice(0, 5));
setPortError('');
}}
onKeyDown={(event) => {
if (event.key === 'Escape' && settings) {
setPortDraft(String(settings.port));
setPortError('');
event.currentTarget.blur();
}
}}
/>
<small>{t('config.network.portHint')}</small>
</label>
<div className="config-network-field config-network-toggle">
<div>
<span>{t('config.network.allowLan')}</span>
<small>{t('config.network.allowLanHint')}</small>
</div>
<label className="switch-control" title={t('config.network.allowLan')}>
<input
type="checkbox"
aria-label={t('config.network.allowLan')}
checked={allowLanDraft}
disabled={controlsDisabled}
onChange={(event) => {
setSavedStatusVisible(false);
setAllowLanDraft(event.currentTarget.checked);
}}
/>
<span className="switch-track" />
</label>
</div>
<label className="config-network-field">
<span className="config-network-label">
<Link2 size={15} aria-hidden="true" />
{t('config.network.proxyUrl')}
</span>
<input
className="config-network-input"
type="text"
value={proxyUrlDraft}
disabled={controlsDisabled}
placeholder={t('config.network.proxyPlaceholder')}
onChange={(event) => {
setSavedStatusVisible(false);
setProxyUrlDraft(event.currentTarget.value);
}}
onKeyDown={(event) => {
if (event.key === 'Escape' && settings) {
setProxyUrlDraft(settings.proxyUrl);
event.currentTarget.blur();
}
}}
/>
<small>{t('config.network.proxyHint')}</small>
</label>
</div>
</section>
<section className="config-network-section" aria-labelledby="config-routing-section-title">
<div className="config-network-section-heading">
<Route size={16} aria-hidden="true" />
<h3 id="config-routing-section-title">{t('config.network.routingSection')}</h3>
</div>
<div className="config-network-grid">
<div className="config-network-field config-network-toggle">
<div>
<span>{t('config.network.sessionAffinity')}</span>
<small>{t('config.network.sessionAffinityHint')}</small>
</div>
<label className="switch-control" title={t('config.network.sessionAffinity')}>
<input
type="checkbox"
aria-label={t('config.network.sessionAffinity')}
checked={sessionAffinityDraft}
disabled={controlsDisabled}
onChange={(event) => {
setSavedStatusVisible(false);
setSessionAffinityDraft(event.currentTarget.checked);
}}
/>
<span className="switch-track" />
</label>
</div>
<label className="config-network-field">
<span className="config-network-label">
<Clock3 size={15} aria-hidden="true" />
{t('config.network.sessionTtl')}
</span>
<input
className="config-network-input"
type="text"
value={sessionTtlDraft}
disabled={controlsDisabled}
placeholder="1h"
onChange={(event) => {
setSavedStatusVisible(false);
setSessionTtlDraft(event.currentTarget.value);
}}
onKeyDown={(event) => {
if (event.key === 'Escape' && settings) {
setSessionTtlDraft(settings.routingSessionAffinityTtl);
event.currentTarget.blur();
}
}}
/>
<small>{t('config.network.sessionTtlHint')}</small>
</label>
<div className="config-network-field config-network-routing-field">
<span className="config-network-label">
<Route size={15} aria-hidden="true" />
{t('config.routing.title')}
</span>
<div className="routing-segmented" role="group" aria-label={t('config.routing.title')}>
{ROUTING_OPTIONS.map((option) => (
<button
type="button"
key={option.value}
className={settings?.routingStrategy === option.value ? 'active' : ''}
aria-pressed={settings?.routingStrategy === option.value}
disabled={controlsDisabled}
onClick={() => void changeRoutingStrategy(option.value)}
title={option.value}
>
{t(option.labelKey)}
</button>
))}
</div>
<small title={settings?.routingStrategy || undefined}>
{loading
? t('common.loading')
: settings === null
? t('common.unavailable')
: routingStrategyLabel(settings.routingStrategy, t)}
</small>
</div>
</div>
</section>
</div>
</section>
</div>
) : activeSubpage === 'aliases' ? (
<div
className="config-subpage-panel"
id="config-subpage-panel-aliases"
role="tabpanel"
aria-labelledby="config-subpage-tab-aliases"
>
<ThinkingAliasesPage />
</div>
) : (
<div
className="config-subpage-panel"
id="config-subpage-panel-speed-aliases"
role="tabpanel"
aria-labelledby="config-subpage-tab-speed-aliases"
>
<SpeedAliasesPage />
</div>
)}
{addDialogOpen ? (
<div className="config-dialog-backdrop" onMouseDown={(event) => {
if (event.currentTarget === event.target) closeAddDialog();
}}>
<form
className="config-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="add-api-key-title"
onSubmit={(event) => void submitApiKey(event)}
>
<div className="config-dialog-heading">
<div>
<KeyRound size={19} aria-hidden="true" />
<h2 id="add-api-key-title">
{editingApiKey === null ? t('config.keys.addTitle') : t('config.keys.editTitle')}
</h2>
</div>
<button
type="button"
className="icon-button quiet"
onClick={closeAddDialog}
disabled={keyMutationBusy}
title={t('common.close')}
aria-label={t('common.close')}
>
<X size={18} aria-hidden="true" />
</button>
</div>
<label className="config-dialog-field">
<span>{t('config.keys.label')}</span>
<div className="config-secret-input">
<input
autoFocus
type={showApiKey ? 'text' : 'password'}
value={newApiKey}
onChange={(event) => {
setNewApiKey(event.currentTarget.value);
setFormError('');
}}
disabled={keyMutationBusy}
aria-invalid={Boolean(formError)}
placeholder="sk-..."
/>
<button
type="button"
className="icon-button quiet"
onClick={() => setShowApiKey((visible) => !visible)}
disabled={keyMutationBusy}
title={showApiKey ? t('config.keys.hide') : t('config.keys.show')}
aria-label={showApiKey ? t('config.keys.hide') : t('config.keys.show')}
>
{showApiKey ? (
<EyeOff size={17} aria-hidden="true" />
) : (
<Eye size={17} aria-hidden="true" />
)}
</button>
</div>
</label>
<label className="config-dialog-field">
<span>{t('config.keys.remark')}</span>
<input
className="config-dialog-text-input"
type="text"
value={newApiKeyRemark}
maxLength={80}
onChange={(event) => {
setNewApiKeyRemark(event.currentTarget.value);
setFormError('');
}}
disabled={keyMutationBusy}
placeholder={t('config.keys.remarkPlaceholder')}
/>
</label>
<div className={`config-form-message ${formError ? 'error' : ''}`}>
{formError || ' '}
</div>
<div className="config-dialog-actions">
<button
type="button"
className="secondary-button"
onClick={generateApiKey}
disabled={keyMutationBusy}
>
<Sparkles size={16} aria-hidden="true" />
{t('config.keys.generate')}
</button>
<button type="submit" className="primary-button" disabled={keyMutationBusy}>
{editingApiKey === null ? <Plus size={16} aria-hidden="true" /> : <Check size={16} aria-hidden="true" />}
{keyMutationBusy
? editingApiKey === null ? t('config.keys.adding') : t('common.saving')
: editingApiKey === null ? t('common.add') : t('common.save')}
</button>
</div>
</form>
</div>
) : null}
{deleteIndex !== null ? (
<div className="config-dialog-backdrop" onMouseDown={(event) => {
if (event.currentTarget === event.target && busyAction !== 'delete-key') {
setDeleteIndex(null);
}
}}>
<div
className={`config-dialog config-delete-dialog ${deletingLastKey ? 'has-warning' : ''}`}
role="alertdialog"
aria-modal="true"
aria-labelledby="delete-api-key-title"
>
<div className="config-dialog-heading">
<div>
<Trash2 size={19} aria-hidden="true" />
<h2 id="delete-api-key-title">{t('config.keys.deleteTitle')}</h2>
</div>
</div>
<code className="config-delete-key">{maskApiKey(selectedDeleteKey)}</code>
{deletingLastKey ? (
<div className="config-delete-warning">
<AlertCircle size={17} aria-hidden="true" />
<span>{t('config.keys.deleteAllWarning')}</span>
</div>
) : null}
<div className="config-dialog-actions two-actions">
<button
type="button"
className="secondary-button"
onClick={() => setDeleteIndex(null)}
disabled={busyAction === 'delete-key'}
>
{t('common.cancel')}
</button>
<button
type="button"
className="danger-button"
onClick={() => void confirmDelete()}
disabled={busyAction === 'delete-key'}
>
<Trash2 size={16} aria-hidden="true" />
{busyAction === 'delete-key' ? t('common.deleting') : t('common.delete')}
</button>
</div>
</div>
</div>
) : null}
{notice ? (
<div className={`config-toast ${notice.tone}`} role="status" title={notice.message}>
{notice.tone === 'success' ? (
<Check size={17} aria-hidden="true" />
) : (
<AlertCircle size={17} aria-hidden="true" />
)}
<span>{notice.message}</span>
</div>
) : null}
</section>
);
}
function maskApiKey(apiKey: string) {
const value = apiKey.trim();
if (!value) {
return '';
}
const visible = value.length < 4 ? 1 : 2;
return `${value.slice(0, visible)}${'*'.repeat(Math.max(6, 10 - visible * 2))}${value.slice(-visible)}`;
}
function routingStrategyLabel(strategy: string | undefined, t: ReturnType<typeof useI18n>['t']) {
if (!strategy) {
return t('common.loading');
}
const option = ROUTING_OPTIONS.find((item) => item.value === strategy);
return option ? t(option.labelKey) : strategy;
}