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

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;