Some checks are pending
E2E / Playwright e2e (push) Waiting to run
- 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.
401 lines
16 KiB
TypeScript
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'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'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>
|
|
)
|
|
}
|