ultisuite-client/components/web-search/web-search-providers-editor.tsx
R3D347HR4Y 9e9fd208ad
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
feat(admin-settings): enhance admin settings with new components and layout improvements
- Introduced new components for managing admin settings, including AdminListControls, AdminSettingsCard, and TechBrandSelectLabel.
- Implemented dynamic loading for admin settings sections to optimize performance.
- Enhanced the layout of various admin settings sections for better user experience.
- Updated the AiAssistantSection to include LLM provider management and improved model selection.
- Refactored authentication settings to streamline configuration and improve accessibility.
2026-06-15 00:22:20 +02:00

344 lines
14 KiB
TypeScript

"use client"
import { ExternalLink, Plus, Trash2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import type { ApiSearchProvider, ApiSearchSettings } from "@/lib/contacts/discovery-types"
import { FieldGroup } from "@/components/admin/settings/field-group"
import { TechBrandSelectLabel } from "@/components/admin/settings/tech-brand-select-label"
import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry"
import {
catalogEntry,
emptySearchProvider,
ensureSearchSettingsDefaults,
isSearchProviderConfigured,
SEARCH_PROVIDER_CATALOG,
} from "@/lib/web-search/search-provider-catalog"
import { cn } from "@/lib/utils"
type WebSearchProviderSecrets = Record<string, { configured?: boolean } | undefined>
type WebSearchProvidersEditorProps = {
value: ApiSearchSettings
onChange: (value: ApiSearchSettings) => void
className?: string
columns?: 1 | 2
providerSecrets?: WebSearchProviderSecrets
}
function providerOptions(value: ApiSearchSettings, providerSecrets?: WebSearchProviderSecrets) {
return value.providers.map((provider) => ({
provider,
configured: isSearchProviderConfigured(provider, {
apiKeyConfigured: providerSecrets?.[provider.id]?.configured,
}),
}))
}
export function WebSearchProvidersEditor({
value,
onChange,
className,
columns = 2,
providerSecrets,
}: WebSearchProvidersEditorProps) {
const options = providerOptions(value, providerSecrets)
function commit(next: ApiSearchSettings) {
onChange(ensureSearchSettingsDefaults(next))
}
function updateProvider(index: number, patch: Partial<ApiSearchProvider>) {
const providers = [...value.providers]
providers[index] = { ...providers[index], ...patch }
commit({ ...value, providers })
}
function setProviderType(index: number, type: ApiSearchProvider["type"]) {
const current = value.providers[index]
const next = {
...emptySearchProvider(type),
id: current?.id ?? emptySearchProvider(type).id,
name: catalogEntry(type).label,
api_key: current?.api_key ?? "",
}
const providers = [...value.providers]
providers[index] = next
commit({ ...value, providers })
}
function addProvider() {
const provider = emptySearchProvider("brave")
commit({
default_provider_id: value.default_provider_id || provider.id,
providers: [...value.providers, provider],
})
}
function removeProvider(index: number) {
const removed = value.providers[index]
const providers = value.providers.filter((_, i) => i !== index)
const remainingOptions = providerOptions({ ...value, providers }, providerSecrets)
let defaultId = value.default_provider_id
if (defaultId === removed?.id) {
defaultId =
remainingOptions.find((entry) => entry.configured)?.provider.id ??
providers[0]?.id ??
""
}
commit({ default_provider_id: defaultId, providers })
}
return (
<div className={cn("w-full space-y-4", className)}>
{value.providers.length > 0 ? (
<FieldGroup>
<Label className="text-xs">Fournisseur par défaut</Label>
<Select
value={value.default_provider_id || value.providers[0]?.id || "__none__"}
onValueChange={(default_provider_id) =>
commit({
...value,
default_provider_id: default_provider_id === "__none__" ? "" : default_provider_id,
})
}
>
<SelectTrigger className="h-9 w-full min-w-0">
<SelectValue placeholder="Choisir…" />
</SelectTrigger>
<SelectContent>
{options.map(({ provider, configured }) => (
<SelectItem
key={provider.id}
value={provider.id}
disabled={!configured}
>
<TechBrandSelectLabel
brand={provider.type}
icon={catalogEntry(provider.type).icon}
suffix={!configured ? " (incomplet)" : undefined}
>
{provider.name || catalogEntry(provider.type).label}
</TechBrandSelectLabel>
</SelectItem>
))}
</SelectContent>
</Select>
{options.some((entry) => !entry.configured) ? (
<p className="text-xs text-muted-foreground">
Les fournisseurs incomplets restent visibles mais ne peuvent pas être sélectionnés
par défaut.
</p>
) : null}
</FieldGroup>
) : null}
<AutomationTabMasonry columns={columns}>
{value.providers.map((provider, index) => {
const entry = catalogEntry(provider.type)
const apiKeyConfigured = providerSecrets?.[provider.id]?.configured ?? false
const configured = isSearchProviderConfigured(provider, { apiKeyConfigured })
return (
<div key={provider.id} className="w-full rounded-lg border border-border py-4">
<div className="flex items-start justify-between gap-2 px-4">
<div className="min-w-0">
<p className="text-sm font-medium">
{provider.name || entry.label || `Fournisseur ${index + 1}`}
</p>
{!configured ? (
<p className="text-xs text-muted-foreground">Configuration incomplète</p>
) : apiKeyConfigured && !(provider.api_key ?? "").trim() ? (
<p className="text-xs text-muted-foreground">Clé API enregistrée sur le serveur</p>
) : null}
</div>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Supprimer le fournisseur"
onClick={() => removeProvider(index)}
>
<Trash2 className="size-4" />
</Button>
</div>
<div className="mt-4 space-y-4 border-t px-4 pt-4">
<FieldGroup>
<Label className="text-xs">Type</Label>
<Select
value={provider.type}
onValueChange={(type) =>
setProviderType(index, type as ApiSearchProvider["type"])
}
>
<SelectTrigger className="h-9 w-full min-w-0">
<SelectValue>
<TechBrandSelectLabel brand={provider.type} icon={entry.icon}>
{entry.label}
</TechBrandSelectLabel>
</SelectValue>
</SelectTrigger>
<SelectContent>
{SEARCH_PROVIDER_CATALOG.map((item) => (
<SelectItem key={item.type} value={item.type}>
<TechBrandSelectLabel brand={item.type} icon={item.icon}>
{item.label}
</TechBrandSelectLabel>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{entry.description}</p>
{entry.docsUrl ? (
<a
href={entry.docsUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:underline"
>
Documentation
<ExternalLink className="size-3" />
</a>
) : null}
</FieldGroup>
<FieldGroup>
<Label className="text-xs">Nom affiché</Label>
<Input
className="h-9"
value={provider.name}
onChange={(e) => updateProvider(index, { name: e.target.value })}
/>
</FieldGroup>
{entry.requiresApiKey || provider.api_key || apiKeyConfigured ? (
<FieldGroup>
<Label className="text-xs">
{provider.type === "brave" ? "Token API (X-Subscription-Token)" : "Clé API"}
</Label>
<Input
className="h-9"
type="password"
value={provider.api_key ?? ""}
onChange={(e) => updateProvider(index, { api_key: e.target.value })}
autoComplete="off"
placeholder={
apiKeyConfigured && !(provider.api_key ?? "").trim()
? "•••••••• (laisser vide pour conserver)"
: provider.type === "brave"
? "BSA…"
: undefined
}
/>
</FieldGroup>
) : null}
{entry.requiresBaseURL || provider.base_url ? (
<FieldGroup>
<Label className="text-xs">URL de base</Label>
<Input
className="h-9"
value={provider.base_url ?? ""}
onChange={(e) => updateProvider(index, { base_url: e.target.value })}
placeholder={
provider.type === "searxng"
? "https://searx.example.org"
: "https://api.example.com/search"
}
/>
</FieldGroup>
) : null}
{provider.type === "bing" || provider.type === "searxng" || provider.type === "custom" ? (
<FieldGroup>
<Label className="text-xs">En-tête d&apos;authentification (optionnel)</Label>
<Input
className="h-9"
value={provider.auth_header ?? ""}
onChange={(e) => updateProvider(index, { auth_header: e.target.value })}
placeholder={
provider.type === "bing" ? "Ocp-Apim-Subscription-Key" : "Authorization"
}
/>
</FieldGroup>
) : null}
{provider.type === "custom" || provider.type === "searxng" || provider.query_param ? (
<FieldGroup>
<Label className="text-xs">Paramètre de requête</Label>
<Input
className="h-9"
value={provider.query_param ?? "q"}
onChange={(e) => updateProvider(index, { query_param: e.target.value })}
/>
</FieldGroup>
) : null}
{entry.supportsCustomMapping ? (
<>
<FieldGroup>
<Label className="text-xs">Chemin JSON des résultats</Label>
<Input
className="h-9"
value={provider.results_path ?? ""}
onChange={(e) => updateProvider(index, { results_path: e.target.value })}
placeholder="results ou data.items"
/>
</FieldGroup>
<div className="grid min-w-0 gap-4">
<FieldGroup>
<Label className="text-xs">Champ titre</Label>
<Input
className="h-9"
value={provider.title_field ?? ""}
onChange={(e) => updateProvider(index, { title_field: e.target.value })}
/>
</FieldGroup>
<FieldGroup>
<Label className="text-xs">Champ URL</Label>
<Input
className="h-9"
value={provider.url_field ?? ""}
onChange={(e) => updateProvider(index, { url_field: e.target.value })}
/>
</FieldGroup>
<FieldGroup>
<Label className="text-xs">Champ description</Label>
<Input
className="h-9"
value={provider.description_field ?? ""}
onChange={(e) =>
updateProvider(index, { description_field: e.target.value })
}
/>
</FieldGroup>
</div>
<p className="text-xs text-muted-foreground">
L&apos;URL peut contenir <code className="rounded bg-muted px-1">{"{query}"}</code>{" "}
et <code className="rounded bg-muted px-1">{"{count}"}</code>. Sinon, le paramètre
de requête est ajouté automatiquement.
</p>
</>
) : null}
</div>
</div>
)
})}
</AutomationTabMasonry>
<Button type="button" variant="outline" size="sm" onClick={addProvider}>
<Plus className="mr-2 size-4" />
Ajouter un fournisseur
</Button>
</div>
)
}