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:
gwdgithubnom
2026-05-27 22:46:00 +08:00
Unverified
parent d64210fd58
commit 3ef3c684b3
6 changed files with 186 additions and 33 deletions
@@ -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;
+2
View File
@@ -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",
+2
View File
@@ -1310,6 +1310,8 @@
"apiKeyEntriesSection": "API-ключи",
"apiKeyEntry": "Ключ #{{index}}",
"addApiKeyEntry": "Добавить ключ",
"showApiKey": "Показать ключ API",
"hideApiKey": "Скрыть ключ API",
"validation": {
"nameRequired": "Название обязательно",
"apiKeyRequired": "Нужен хотя бы один API-ключ",
+2
View File
@@ -1313,6 +1313,8 @@
"apiKeyEntriesSection": "API 密钥条目",
"apiKeyEntry": "密钥 #{{index}}",
"addApiKeyEntry": "添加密钥条目",
"showApiKey": "显示密钥",
"hideApiKey": "隐藏密钥",
"validation": {
"nameRequired": "名称必填",
"apiKeyRequired": "至少填写一个 API 密钥",
+2
View File
@@ -1339,6 +1339,8 @@
"apiKeyEntriesSection": "API 金鑰條目",
"apiKeyEntry": "金鑰 #{{index}}",
"addApiKeyEntry": "新增金鑰條目",
"showApiKey": "顯示金鑰",
"hideApiKey": "隱藏金鑰",
"validation": {
"nameRequired": "名稱必填",
"apiKeyRequired": "至少填寫一個 API 金鑰",