ultisuite-client/components/web-search/web-search-providers-editor.tsx
R3D347HR4Y 8f81d7aba1
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
feat(admin-settings): refactor admin settings components for improved usability and consistency
- Replaced legacy components with new `SettingsCard`, `SettingsField`, and `SettingsToggleRow` for a unified design.
- Enhanced `AdminListControls` to support compact mode and improved pagination controls.
- Updated various sections including `AiAssistantSection`, `AuthenticationSection`, and `DriveMountOAuthSection` to utilize new components, streamlining the settings interface.
- Improved accessibility and user experience across admin settings with clearer labels and hints.
- Deprecated old components while maintaining backward compatibility for existing admin sections.
2026-06-15 11:10:17 +02:00

401 lines
16 KiB
TypeScript

"use client"
import { useEffect, useState } from "react"
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)
const [editingProviderId, setEditingProviderId] = useState<string | null>(null)
useEffect(() => {
if (editingProviderId && !value.providers.some((provider) => provider.id === editingProviderId)) {
setEditingProviderId(null)
}
}, [editingProviderId, value.providers])
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],
})
setEditingProviderId(provider.id)
}
function removeProvider(index: number) {
const removed = value.providers[index]
if (editingProviderId === removed?.id) {
setEditingProviderId(null)
}
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 })
const isEditing = editingProviderId === provider.id
const displayName =
provider.name || entry.label || `Fournisseur ${index + 1}`
if (!isEditing) {
return (
<div
key={provider.id}
className="flex w-full items-center justify-between gap-3 rounded-lg border border-border px-4 py-3"
>
<TechBrandSelectLabel
brand={provider.type}
icon={entry.icon}
className="min-w-0 text-sm font-medium"
>
{displayName}
</TechBrandSelectLabel>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setEditingProviderId(provider.id)}
>
Modifier
</Button>
</div>
)
}
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">
<TechBrandSelectLabel
brand={provider.type}
icon={entry.icon}
className="text-sm font-medium"
>
{displayName}
</TechBrandSelectLabel>
{!configured ? (
<p className="mt-1 text-xs text-muted-foreground">Configuration incomplète</p>
) : apiKeyConfigured && !(provider.api_key ?? "").trim() ? (
<p className="mt-1 text-xs text-muted-foreground">
Clé API enregistrée sur le serveur
</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setEditingProviderId(null)}
>
Fermer
</Button>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Supprimer le fournisseur"
onClick={() => removeProvider(index)}
>
<Trash2 className="size-4" />
</Button>
</div>
</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>
)
}