You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
314 lines
11 KiB
TypeScript
314 lines
11 KiB
TypeScript
import {
|
|
type Component,
|
|
createSignal,
|
|
createResource,
|
|
Show,
|
|
For,
|
|
} from 'solid-js';
|
|
import { Key, Eye, EyeOff, CheckCircle, XCircle, Trash2, RefreshCw } from 'lucide-solid';
|
|
import { apiKeysApi } from '~/api/apiKeys';
|
|
import { useI18n } from '~/i18n';
|
|
import { useToast } from '~/components/ui/Toast';
|
|
import { isApiError } from '~/types';
|
|
import type { ProviderConfig, UserApiKey } from '~/types';
|
|
|
|
interface ApiKeyManagerProps {
|
|
providers: ProviderConfig[];
|
|
}
|
|
|
|
/**
|
|
* Manages per-provider API keys (BYOK) displayed on the Settings page.
|
|
*
|
|
* Renders one {@link ProviderKeyCard} per configured provider. The card
|
|
* supports creating, testing, and deleting keys, as well as toggling
|
|
* key visibility (show/hide). The `test` button makes a live validation
|
|
* call to verify the key works.
|
|
*/
|
|
const ApiKeyManager: Component<ApiKeyManagerProps> = (props) => {
|
|
const { t } = useI18n();
|
|
const { addToast } = useToast();
|
|
|
|
const [apiKeys, { refetch }] = createResource(() => apiKeysApi.list());
|
|
|
|
const getKeyForProvider = (providerName: string): UserApiKey | undefined => {
|
|
return apiKeys()?.find((k) => k.provider_name === providerName);
|
|
};
|
|
|
|
return (
|
|
<div class="bg-white shadow overflow-hidden sm:rounded-lg border border-gray-200 mt-8">
|
|
<div class="px-4 py-5 sm:px-6 border-b border-gray-200">
|
|
<div class="flex items-center">
|
|
<Key class="h-5 w-5 text-indigo-600 mr-2" />
|
|
<h2 class="text-lg font-medium text-gray-900">
|
|
{t('settings.apiKeys.title')}
|
|
</h2>
|
|
</div>
|
|
<p class="mt-1 text-sm text-gray-500">
|
|
{t('settings.apiKeys.description')}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="divide-y divide-gray-200">
|
|
<For each={props.providers}>
|
|
{(provider) => (
|
|
<ProviderKeyCard
|
|
provider={provider}
|
|
apiKey={getKeyForProvider(provider.provider_name)}
|
|
onKeyChanged={refetch}
|
|
/>
|
|
)}
|
|
</For>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface ProviderKeyCardProps {
|
|
provider: ProviderConfig;
|
|
apiKey: UserApiKey | undefined;
|
|
onKeyChanged: () => void;
|
|
}
|
|
|
|
/** Individual card for a single provider's API key with CRUD and test actions. */
|
|
const ProviderKeyCard: Component<ProviderKeyCardProps> = (props) => {
|
|
const { t } = useI18n();
|
|
const { addToast } = useToast();
|
|
|
|
const [keyInput, setKeyInput] = createSignal('');
|
|
const [showKey, setShowKey] = createSignal(false);
|
|
const [editing, setEditing] = createSignal(false);
|
|
const [saving, setSaving] = createSignal(false);
|
|
const [testing, setTesting] = createSignal(false);
|
|
const [confirmDelete, setConfirmDelete] = createSignal(false);
|
|
|
|
const isConfigured = () => !!props.apiKey;
|
|
const showInput = () => !isConfigured() || editing();
|
|
|
|
const handleSave = async () => {
|
|
const key = keyInput().trim();
|
|
if (!key) return;
|
|
|
|
setSaving(true);
|
|
try {
|
|
await apiKeysApi.create({
|
|
provider_name: props.provider.provider_name,
|
|
api_key: key,
|
|
});
|
|
addToast({
|
|
type: 'success',
|
|
message: t('settings.apiKeys.saved'),
|
|
duration: 4000,
|
|
});
|
|
setKeyInput('');
|
|
setShowKey(false);
|
|
setEditing(false);
|
|
props.onKeyChanged();
|
|
} catch (err) {
|
|
const message = isApiError(err) ? err.message : t('settings.apiKeys.saveError');
|
|
addToast({ type: 'error', message, duration: 5000 });
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleTest = async () => {
|
|
setTesting(true);
|
|
try {
|
|
const result = await apiKeysApi.test(props.provider.provider_name);
|
|
if (result.success) {
|
|
addToast({
|
|
type: 'success',
|
|
message: t('settings.apiKeys.testSuccess'),
|
|
duration: 4000,
|
|
});
|
|
} else {
|
|
addToast({
|
|
type: 'error',
|
|
message: t('settings.apiKeys.testFailure', { message: result.message }),
|
|
duration: 6000,
|
|
});
|
|
}
|
|
} catch (err) {
|
|
const message = isApiError(err) ? err.message : t('settings.apiKeys.testFailure', { message: 'Erreur inconnue' });
|
|
addToast({ type: 'error', message, duration: 5000 });
|
|
} finally {
|
|
setTesting(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
try {
|
|
await apiKeysApi.remove(props.provider.provider_name);
|
|
addToast({
|
|
type: 'success',
|
|
message: t('settings.apiKeys.deleted'),
|
|
duration: 4000,
|
|
});
|
|
setConfirmDelete(false);
|
|
setEditing(false);
|
|
props.onKeyChanged();
|
|
} catch (err) {
|
|
const message = isApiError(err) ? err.message : t('settings.apiKeys.deleteError');
|
|
addToast({ type: 'error', message, duration: 5000 });
|
|
}
|
|
};
|
|
|
|
const handleCancel = () => {
|
|
setEditing(false);
|
|
setKeyInput('');
|
|
setShowKey(false);
|
|
setConfirmDelete(false);
|
|
};
|
|
|
|
return (
|
|
<div class="px-4 py-4 sm:px-6">
|
|
{/* Header: Provider name + status badge */}
|
|
<div class="flex items-center justify-between mb-3">
|
|
<div class="flex items-center gap-3">
|
|
<span class="text-sm font-medium text-gray-900">
|
|
{props.provider.display_name}
|
|
</span>
|
|
<Show
|
|
when={isConfigured()}
|
|
fallback={
|
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">
|
|
{t('settings.apiKeys.notConfigured')}
|
|
</span>
|
|
}
|
|
>
|
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
|
{t('settings.apiKeys.configured')}
|
|
</span>
|
|
</Show>
|
|
</div>
|
|
|
|
{/* Action buttons for configured keys */}
|
|
<Show when={isConfigured() && !editing()}>
|
|
<div class="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={handleTest}
|
|
disabled={testing()}
|
|
class="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded-md text-indigo-700 bg-indigo-50 hover:bg-indigo-100 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-500 disabled:opacity-50"
|
|
>
|
|
<Show
|
|
when={!testing()}
|
|
fallback={
|
|
<div class="animate-spin rounded-full h-3 w-3 border-b-2 border-indigo-700 mr-1.5" />
|
|
}
|
|
>
|
|
<RefreshCw class="h-3 w-3 mr-1.5" />
|
|
</Show>
|
|
{testing() ? t('settings.apiKeys.testing') : t('settings.apiKeys.test')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setEditing(true)}
|
|
class="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded-md text-gray-700 bg-gray-100 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-500"
|
|
>
|
|
{t('settings.apiKeys.update')}
|
|
</button>
|
|
<Show
|
|
when={!confirmDelete()}
|
|
fallback={
|
|
<div class="flex items-center gap-1">
|
|
<button
|
|
type="button"
|
|
onClick={handleDelete}
|
|
class="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-red-500"
|
|
>
|
|
{t('common.confirm')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setConfirmDelete(false)}
|
|
class="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded-md text-gray-700 bg-gray-100 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-500"
|
|
>
|
|
{t('common.cancel')}
|
|
</button>
|
|
</div>
|
|
}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => setConfirmDelete(true)}
|
|
class="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded-md text-red-700 bg-red-50 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-red-500"
|
|
>
|
|
<Trash2 class="h-3 w-3 mr-1.5" />
|
|
{t('settings.apiKeys.delete')}
|
|
</button>
|
|
</Show>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
|
|
{/* Key prefix display */}
|
|
<Show when={isConfigured() && !editing()}>
|
|
<p class="text-sm text-gray-500">
|
|
<span class="font-mono text-gray-400">
|
|
{t('settings.apiKeys.keyPrefix', { prefix: props.apiKey!.key_prefix })}
|
|
</span>
|
|
</p>
|
|
</Show>
|
|
|
|
{/* Input form: shown when not configured or editing */}
|
|
<Show when={showInput()}>
|
|
<div class="mt-2">
|
|
<label
|
|
for={`api-key-${props.provider.provider_name}`}
|
|
class="block text-sm font-medium text-gray-700 mb-1"
|
|
>
|
|
{t('settings.apiKeys.inputLabel')}
|
|
</label>
|
|
<div class="flex items-center gap-2">
|
|
<div class="relative flex-1">
|
|
<input
|
|
type={showKey() ? 'text' : 'password'}
|
|
id={`api-key-${props.provider.provider_name}`}
|
|
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md py-2 px-3 pr-10 border font-mono"
|
|
value={keyInput()}
|
|
onInput={(e) => setKeyInput(e.currentTarget.value)}
|
|
placeholder={t('settings.apiKeys.inputPlaceholder', {
|
|
provider: props.provider.display_name,
|
|
})}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowKey(!showKey())}
|
|
class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600"
|
|
aria-label={showKey() ? t('settings.apiKeys.hideKey') : t('settings.apiKeys.showKey')}
|
|
>
|
|
<Show when={showKey()} fallback={<Eye class="h-4 w-4" />}>
|
|
<EyeOff class="h-4 w-4" />
|
|
</Show>
|
|
</button>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={handleSave}
|
|
disabled={saving() || !keyInput().trim()}
|
|
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
|
|
>
|
|
<Show when={saving()}>
|
|
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2" />
|
|
</Show>
|
|
{saving() ? t('settings.apiKeys.saving') : t('settings.apiKeys.save')}
|
|
</button>
|
|
<Show when={editing()}>
|
|
<button
|
|
type="button"
|
|
onClick={handleCancel}
|
|
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
|
>
|
|
{t('common.cancel')}
|
|
</button>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ApiKeyManager;
|