mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
fix(ui): add show/hide toggle for API key inputs and populate key on edit
Add eye toggle button for all API key input fields so users can verify their keys before saving. Changes: - BaseProviderForm: wrap single API key field (Gemini/Codex/Claude/Vertex) with passwordField + toggle button - BaseProviderForm: wrap per-entry API key fields (OpenAI-compatible) with passwordField + toggle button - BaseProviderForm: extract applyRawApiKey helper to populate apiKey from resource.raw in edit mode - BaseProviderForm: sync initialFormSignature with applyRawApiKey to prevent false isDirty - sharedForm.module.scss: add .passwordField, .passwordInput, .passwordToggle styles - i18n: add showApiKey/hideApiKey keys to en, zh-CN, zh-TW, ru locale files - accessibility: add aria-label and title attributes on both toggle buttons
This commit is contained in:
@@ -4,6 +4,8 @@ import {
|
||||
IconAlertTriangle,
|
||||
IconCheckCircle2,
|
||||
IconDownload,
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
IconLoader2,
|
||||
IconPlus,
|
||||
IconX,
|
||||
@@ -65,6 +67,19 @@ const headersObjectToText = (headers?: Record<string, string>): string =>
|
||||
const stripDisableAllRule = (list?: string[]): string[] =>
|
||||
(list ?? []).filter((s) => s.trim() !== '*');
|
||||
|
||||
/** Populate apiKey from resource.raw when editing a non-OpenAI provider. */
|
||||
const applyRawApiKey = (
|
||||
brand: Exclude<ProviderBrand, 'ampcode'>,
|
||||
resource: ProviderResource | null,
|
||||
mode: 'create' | 'edit',
|
||||
form: ProviderEntryFormInput
|
||||
): void => {
|
||||
if (mode === 'edit' && resource && brand !== 'openaiCompatibility') {
|
||||
const rawKey = (resource.raw as { apiKey?: string } | undefined)?.apiKey ?? '';
|
||||
if (rawKey) form.apiKey = rawKey;
|
||||
}
|
||||
};
|
||||
|
||||
function buildInitialForm(
|
||||
brand: Exclude<ProviderBrand, 'ampcode'>,
|
||||
resource: ProviderResource | null,
|
||||
@@ -204,13 +219,37 @@ export function BaseProviderForm({
|
||||
const { t } = useTranslation();
|
||||
const descriptor = PROVIDER_DESCRIPTORS[brand];
|
||||
const fid = useId();
|
||||
const [form, setForm] = useState<ProviderEntryFormInput>(() =>
|
||||
buildInitialForm(brand, resource, mode)
|
||||
);
|
||||
const [initialFormSignature] = useState<string>(() =>
|
||||
JSON.stringify(buildInitialForm(brand, resource, mode))
|
||||
);
|
||||
const [form, setForm] = useState<ProviderEntryFormInput>(() => {
|
||||
const initial = buildInitialForm(brand, resource, mode);
|
||||
applyRawApiKey(brand, resource, mode, initial);
|
||||
return initial;
|
||||
});
|
||||
const [initialFormSignature] = useState<string>(() => {
|
||||
const initial = buildInitialForm(brand, resource, mode);
|
||||
applyRawApiKey(brand, resource, mode, initial);
|
||||
return JSON.stringify(initial);
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showPasswords, setShowPasswords] = useState<Set<number>>(new Set());
|
||||
const [showSingleApiKey, setShowSingleApiKey] = useState(false);
|
||||
|
||||
// Reset password visibility when the sheet closes or resource changes
|
||||
useEffect(() => {
|
||||
setShowPasswords(new Set());
|
||||
setShowSingleApiKey(false);
|
||||
}, [resource?.id, mode]);
|
||||
|
||||
const togglePasswordVisibility = (idx: number) => {
|
||||
setShowPasswords((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(idx)) {
|
||||
next.delete(idx);
|
||||
} else {
|
||||
next.add(idx);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const isDirty = useMemo(
|
||||
() => JSON.stringify(form) !== initialFormSignature,
|
||||
@@ -454,19 +493,43 @@ export function BaseProviderForm({
|
||||
<label className={styles.label} htmlFor={`${fid}-apiKey`}>
|
||||
{t('providersPage.form.apiKey')}
|
||||
</label>
|
||||
<input
|
||||
id={`${fid}-apiKey`}
|
||||
className={styles.input}
|
||||
type="password"
|
||||
value={form.apiKey}
|
||||
onChange={(e) => updateField('apiKey', e.target.value)}
|
||||
placeholder={
|
||||
mode === 'edit'
|
||||
? t('providersPage.form.apiKeyEditPlaceholder')
|
||||
: t('providersPage.form.apiKeyCreatePlaceholder')
|
||||
}
|
||||
disabled={mutating}
|
||||
/>
|
||||
<div className={styles.passwordField}>
|
||||
<input
|
||||
id={`${fid}-apiKey`}
|
||||
className={styles.passwordInput}
|
||||
type={showSingleApiKey ? 'text' : 'password'}
|
||||
value={form.apiKey}
|
||||
onChange={(e) => updateField('apiKey', e.target.value)}
|
||||
placeholder={
|
||||
mode === 'edit'
|
||||
? t('providersPage.form.apiKeyEditPlaceholder')
|
||||
: t('providersPage.form.apiKeyCreatePlaceholder')
|
||||
}
|
||||
disabled={mutating}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.passwordToggle}
|
||||
onClick={() => setShowSingleApiKey((v) => !v)}
|
||||
disabled={mutating}
|
||||
aria-label={
|
||||
showSingleApiKey
|
||||
? t('providersPage.form.hideApiKey')
|
||||
: t('providersPage.form.showApiKey')
|
||||
}
|
||||
title={
|
||||
showSingleApiKey
|
||||
? t('providersPage.form.hideApiKey')
|
||||
: t('providersPage.form.showApiKey')
|
||||
}
|
||||
>
|
||||
{showSingleApiKey ? (
|
||||
<IconEyeOff size={16} />
|
||||
) : (
|
||||
<IconEye size={16} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -695,21 +758,45 @@ export function BaseProviderForm({
|
||||
<label className={styles.label}>
|
||||
{t('providersPage.form.apiKey')}
|
||||
</label>
|
||||
<input
|
||||
className={styles.input}
|
||||
type="password"
|
||||
value={entry.apiKey}
|
||||
onChange={(e) =>
|
||||
updateField(
|
||||
'apiKeyEntries',
|
||||
apiKeyEntries.map((it, i) =>
|
||||
i === idx ? { ...it, apiKey: e.target.value } : it
|
||||
<div className={styles.passwordField}>
|
||||
<input
|
||||
className={styles.passwordInput}
|
||||
type={showPasswords.has(idx) ? 'text' : 'password'}
|
||||
value={entry.apiKey}
|
||||
onChange={(e) =>
|
||||
updateField(
|
||||
'apiKeyEntries',
|
||||
apiKeyEntries.map((it, i) =>
|
||||
i === idx ? { ...it, apiKey: e.target.value } : it
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
disabled={mutating}
|
||||
placeholder={t('providersPage.form.apiKeyCreatePlaceholder')}
|
||||
/>
|
||||
}
|
||||
disabled={mutating}
|
||||
placeholder={t('providersPage.form.apiKeyCreatePlaceholder')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.passwordToggle}
|
||||
onClick={() => togglePasswordVisibility(idx)}
|
||||
disabled={mutating}
|
||||
aria-label={
|
||||
showPasswords.has(idx)
|
||||
? t('providersPage.form.hideApiKey')
|
||||
: t('providersPage.form.showApiKey')
|
||||
}
|
||||
title={
|
||||
showPasswords.has(idx)
|
||||
? t('providersPage.form.hideApiKey')
|
||||
: t('providersPage.form.showApiKey')
|
||||
}
|
||||
>
|
||||
{showPasswords.has(idx) ? (
|
||||
<IconEyeOff size={16} />
|
||||
) : (
|
||||
<IconEye size={16} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>
|
||||
|
||||
@@ -490,6 +490,64 @@
|
||||
}
|
||||
}
|
||||
|
||||
.passwordField {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.passwordInput {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 8px 36px 8px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px var(--primary-10);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.passwordToggle {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color $transition-fast;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.entriesList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1313,6 +1313,8 @@
|
||||
"apiKeyEntriesSection": "API key entries",
|
||||
"apiKeyEntry": "Key #{{index}}",
|
||||
"addApiKeyEntry": "Add key entry",
|
||||
"showApiKey": "Show API key",
|
||||
"hideApiKey": "Hide API key",
|
||||
"validation": {
|
||||
"nameRequired": "Name is required",
|
||||
"apiKeyRequired": "At least one API key is required",
|
||||
|
||||
@@ -1310,6 +1310,8 @@
|
||||
"apiKeyEntriesSection": "API-ключи",
|
||||
"apiKeyEntry": "Ключ #{{index}}",
|
||||
"addApiKeyEntry": "Добавить ключ",
|
||||
"showApiKey": "Показать ключ API",
|
||||
"hideApiKey": "Скрыть ключ API",
|
||||
"validation": {
|
||||
"nameRequired": "Название обязательно",
|
||||
"apiKeyRequired": "Нужен хотя бы один API-ключ",
|
||||
|
||||
@@ -1313,6 +1313,8 @@
|
||||
"apiKeyEntriesSection": "API 密钥条目",
|
||||
"apiKeyEntry": "密钥 #{{index}}",
|
||||
"addApiKeyEntry": "添加密钥条目",
|
||||
"showApiKey": "显示密钥",
|
||||
"hideApiKey": "隐藏密钥",
|
||||
"validation": {
|
||||
"nameRequired": "名称必填",
|
||||
"apiKeyRequired": "至少填写一个 API 密钥",
|
||||
|
||||
@@ -1339,6 +1339,8 @@
|
||||
"apiKeyEntriesSection": "API 金鑰條目",
|
||||
"apiKeyEntry": "金鑰 #{{index}}",
|
||||
"addApiKeyEntry": "新增金鑰條目",
|
||||
"showApiKey": "顯示金鑰",
|
||||
"hideApiKey": "隱藏金鑰",
|
||||
"validation": {
|
||||
"nameRequired": "名稱必填",
|
||||
"apiKeyRequired": "至少填寫一個 API 金鑰",
|
||||
|
||||
Reference in New Issue
Block a user