mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
feat: Add a toggle to reveal media provider API keys (#867)
This commit is contained in:
parent
665e52b295
commit
8930b9650c
4 changed files with 121 additions and 8 deletions
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<div className="media-provider-secret-field">
|
||||
<input
|
||||
type="password"
|
||||
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')}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
Loading…
Reference in a new issue