feat: Add a toggle to reveal media provider API keys (#867)

This commit is contained in:
nettee 2026-05-08 11:46:21 +08:00 committed by GitHub
parent 665e52b295
commit 8930b9650c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 121 additions and 8 deletions

View file

@ -17,6 +17,7 @@ type IconName =
| 'edit'
| 'external-link'
| 'eye'
| 'eye-off'
| 'file'
| 'file-code'
| 'folder'
@ -175,6 +176,15 @@ export function Icon({ name, size = 14, strokeWidth = 1.6, ...rest }: Props) {
<circle cx="12" cy="12" r="3" />
</svg>
);
case 'eye-off':
return (
<svg {...common}>
<path d="m3 3 18 18" />
<path d="M10.6 10.6a2 2 0 0 0 2.8 2.8" />
<path d="M9.9 4.2A9.9 9.9 0 0 1 12 4c6.5 0 10 8 10 8a17.8 17.8 0 0 1-2.1 3.1" />
<path d="M6.1 6.1C3.5 7.9 2 12 2 12s3.5 8 10 8a9.9 9.9 0 0 0 4.2-.9" />
</svg>
);
case 'external-link':
return (
<svg {...common}>

View file

@ -1883,6 +1883,19 @@ function MediaProvidersSection({
setCfg: Dispatch<SetStateAction<AppConfig>>;
}) {
const { t } = useI18n();
const [visibleApiKeys, setVisibleApiKeys] = useState<ReadonlySet<string>>(
() => new Set(),
);
useEffect(() => {
setVisibleApiKeys((current) => {
const next = new Set<string>();
for (const providerId of current) {
const apiKey = cfg.mediaProviders?.[providerId]?.apiKey ?? '';
if (apiKey.trim()) next.add(providerId);
}
return next.size === current.size ? current : next;
});
}, [cfg.mediaProviders]);
const providers = MEDIA_PROVIDERS
.filter((p) => p.settingsVisible !== false)
.slice()
@ -1911,6 +1924,17 @@ function MediaProvidersSection({
return { ...curr, mediaProviders: map };
});
};
const toggleApiKeyVisibility = (providerId: string) => {
setVisibleApiKeys((current) => {
const next = new Set(current);
if (next.has(providerId)) {
next.delete(providerId);
} else {
next.add(providerId);
}
return next;
});
};
return (
<section className="settings-section">
@ -1927,6 +1951,7 @@ function MediaProvidersSection({
const disabled = !provider.integrated;
const supportsCustomModel = provider.supportsCustomModel === true;
const clearable = Boolean(entry.apiKey.trim() || entry.baseUrl.trim() || entry.model?.trim());
const apiKeyVisible = visibleApiKeys.has(provider.id);
return (
<div key={provider.id} className={`media-provider-row${provider.integrated ? '' : ' pending'}`}>
<div className="media-provider-head">
@ -1946,14 +1971,30 @@ function MediaProvidersSection({
</div>
</div>
<div className="media-provider-body">
<input
type="password"
value={entry.apiKey}
placeholder={t('settings.mediaProviderPlaceholder')}
aria-label={`${provider.label} ${t('settings.mediaProviderApiKey')}`}
disabled={disabled}
onChange={(e) => updateProvider(provider, { apiKey: e.target.value })}
/>
<div className="media-provider-secret-field">
<input
type={apiKeyVisible ? 'text' : 'password'}
value={entry.apiKey}
placeholder={t('settings.mediaProviderPlaceholder')}
aria-label={`${provider.label} ${t('settings.mediaProviderApiKey')}`}
disabled={disabled}
onChange={(e) => updateProvider(provider, { apiKey: e.target.value })}
/>
<button
type="button"
className="secret-visibility-button"
disabled={disabled}
aria-label={
apiKeyVisible
? `${provider.label} ${t('settings.hideKey')}`
: `${provider.label} ${t('settings.showKey')}`
}
aria-pressed={apiKeyVisible}
onClick={() => toggleApiKeyVisibility(provider.id)}
>
<Icon name={apiKeyVisible ? 'eye' : 'eye-off'} size={15} />
</button>
</div>
<input
value={entry.baseUrl}
placeholder={provider.defaultBaseUrl || t('settings.mediaProviderBaseUrlPlaceholder')}

View file

@ -1510,6 +1510,40 @@ code {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
gap: 6px;
}
.media-provider-secret-field {
position: relative;
min-width: 0;
}
.media-provider-secret-field input {
width: 100%;
padding-right: 34px;
}
.secret-visibility-button {
position: absolute;
top: 50%;
right: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
color: var(--text-muted);
background: transparent;
border: 0;
border-radius: var(--radius-pill);
box-shadow: none;
transform: translateY(-50%);
}
.secret-visibility-button:hover:not(:disabled) {
color: var(--text);
background: var(--bg-subtle);
box-shadow: none;
}
.secret-visibility-button:disabled {
color: var(--text-faint);
cursor: not-allowed;
}
.section-head {
display: flex;
justify-content: space-between;

View file

@ -727,6 +727,34 @@ describe('SettingsDialog media providers interactions', () => {
);
});
it('re-masks a replacement media provider API key until reveal is used again', () => {
renderSettingsDialog(
{
mode: 'daemon',
agentId: 'codex',
mediaProviders: {
openai: { apiKey: 'sk-media', baseUrl: 'https://api.openai.com/v1' },
},
},
{ initialSection: 'media' },
);
const apiKeyInput = screen.getByLabelText('OpenAI API key') as HTMLInputElement;
expect(apiKeyInput.type).toBe('password');
fireEvent.click(screen.getByRole('button', { name: 'OpenAI Show key' }));
expect(apiKeyInput.type).toBe('text');
fireEvent.click(screen.getAllByRole('button', { name: 'Clear' })[0]!);
expect(apiKeyInput.type).toBe('password');
fireEvent.change(apiKeyInput, { target: { value: 'sk-replacement' } });
expect(apiKeyInput.type).toBe('password');
fireEvent.click(screen.getByRole('button', { name: 'OpenAI Show key' }));
expect(apiKeyInput.type).toBe('text');
});
it('supports providers with a custom model override field', () => {
const { onSave } = renderSettingsDialog(
{ mode: 'daemon', agentId: 'codex' },