feat(admin-settings): enhance admin settings with new components and layout improvements
Some checks are pending
E2E / Playwright e2e (push) Waiting to run

- 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.
This commit is contained in:
R3D347HR4Y 2026-06-15 00:22:20 +02:00
parent 3477361db0
commit 9e9fd208ad
68 changed files with 5017 additions and 2050 deletions

16
app/chat/layout.tsx Normal file
View File

@ -0,0 +1,16 @@
import type { Metadata } from "next"
import type { ReactNode } from "react"
export const metadata: Metadata = {
title: { absolute: "UltiAI" },
description: "Assistant IA intégré à la suite Ultimail",
icons: {
icon: [{ url: "/ultiai-mark.svg", type: "image/svg+xml" }],
apple: [{ url: "/ultiai-mark.svg", type: "image/svg+xml" }],
shortcut: "/ultiai-mark.svg",
},
}
export default function ChatLayout({ children }: { children: ReactNode }) {
return children
}

View File

@ -3,12 +3,11 @@
import Link from "next/link"
import { Sparkles } from "lucide-react"
import { AiChatIframe } from "@/components/ai/ai-chat-iframe"
import { useAiConfig, useAiQuota } from "@/lib/api/hooks/use-ai-queries"
import { useAiConfig } from "@/lib/api/hooks/use-ai-queries"
import { Button } from "@/components/ui/button"
export default function ChatPage() {
const { data: config, isLoading, isError } = useAiConfig()
const { data: quota } = useAiQuota(Boolean(config?.enabled))
if (isLoading) {
return (
@ -47,23 +46,10 @@ export default function ChatPage() {
}
return (
<div className="flex h-dvh flex-col">
<header className="flex items-center justify-between border-b px-4 py-2">
<div className="flex items-center gap-2 text-sm font-medium">
<Sparkles className="h-4 w-4 text-[#1a73e8]" />
UltiAI
</div>
{quota ? (
<span className="text-xs text-muted-foreground">
{quota.requests_remaining}/{quota.requests_limit} requêtes aujourd&apos;hui
</span>
) : null}
</header>
<AiChatIframe
publicPath={config.public_path}
context={{ app: "standalone", temporary: false }}
className="min-h-0 flex-1 border-0"
className="h-dvh w-full border-0"
/>
</div>
)
}

View File

@ -0,0 +1,113 @@
"use client"
import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
export type AdminListSortOption = {
value: string
label: string
}
const DEFAULT_PAGE_SIZE_OPTIONS = [10, 25, 50, 100] as const
export function AdminListControls({
page,
pageSize,
total,
totalPages,
pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,
sort,
sortOptions,
onPageChange,
onPageSizeChange,
onSortChange,
itemLabel,
}: {
page: number
pageSize: number
total: number
totalPages: number
pageSizeOptions?: readonly number[]
sort: string
sortOptions: readonly AdminListSortOption[]
onPageChange: (page: number) => void
onPageSizeChange: (pageSize: number) => void
onSortChange: (sort: string) => void
itemLabel: string
}) {
const rangeStart = total === 0 ? 0 : (page - 1) * pageSize + 1
const rangeEnd = Math.min(page * pageSize, total)
return (
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="flex flex-wrap items-end gap-3">
<div className="w-36">
<Label className="text-xs">Par page</Label>
<Select
value={String(pageSize)}
onValueChange={(value) => onPageSizeChange(Number(value))}
>
<SelectTrigger className="mt-1 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{pageSizeOptions.map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="min-w-[200px] flex-1 sm:max-w-xs">
<Label className="text-xs">Tri</Label>
<Select value={sort} onValueChange={onSortChange}>
<SelectTrigger className="mt-1 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{sortOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 sm:justify-end">
<p className="text-sm text-muted-foreground">
{total === 0
? `0 ${itemLabel}`
: `${rangeStart.toLocaleString("fr-FR")}${rangeEnd.toLocaleString("fr-FR")} sur ${total.toLocaleString("fr-FR")} ${itemLabel}`}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
>
Précédent
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)}
>
Suivant
</Button>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,27 @@
import type { ReactNode } from "react"
import { Card, CardContent } from "@/components/ui/card"
export function AdminSettingsCard({
title,
description,
hint,
children,
}: {
title: string
description: ReactNode
hint?: ReactNode
children: ReactNode
}) {
return (
<Card className="gap-0 py-0">
<CardContent className="py-4">
<div>
<p className="font-medium">{title}</p>
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
{hint}
</div>
<div className="mt-4 space-y-4 border-t pt-4">{children}</div>
</CardContent>
</Card>
)
}

View File

@ -5,6 +5,7 @@ import { usePathname } from "next/navigation"
import { cn } from "@/lib/utils"
import {
ADMIN_SETTINGS_NAV,
isAdminSettingsFullWidthLayoutPath,
isAdminSettingsNavActive,
isAdminSettingsWideLayoutPath,
} from "@/lib/admin-settings/settings-nav"
@ -106,11 +107,13 @@ export function AdminSettingsLayout({ children }: { children: React.ReactNode })
</div>
</nav>
<main className="min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-8">
<main className="min-h-0 flex-1 overflow-y-auto px-4 pt-5 sm:px-8">
<div
className={cn(
"mx-auto w-full max-w-3xl",
isAdminSettingsWideLayoutPath(pathname) && "lg:max-w-6xl"
"mx-auto flex min-h-full w-full flex-col",
!isAdminSettingsFullWidthLayoutPath(pathname) && "max-w-3xl",
isAdminSettingsWideLayoutPath(pathname) && "lg:max-w-6xl",
isAdminSettingsFullWidthLayoutPath(pathname) && "max-w-none"
)}
>
{children}

View File

@ -1,52 +1,95 @@
"use client"
import dynamic from "next/dynamic"
import type { ComponentType } from "react"
import {
resolveAdminSettingsSection,
type AdminSettingsSectionId,
} from "@/lib/admin-settings/settings-nav"
import { AdminAccessGuard } from "@/components/admin/settings/admin-access-guard"
import { OverviewSection } from "@/components/admin/settings/sections/overview-section"
import { UsersSection } from "@/components/admin/settings/sections/users-section"
import { AuthenticationSection } from "@/components/admin/settings/sections/authentication-section"
import { SecuritySection } from "@/components/admin/settings/sections/security-section"
import { StorageQuotasSection } from "@/components/admin/settings/sections/storage-quotas-section"
import { UsageQuotasSection } from "@/components/admin/settings/sections/usage-quotas-section"
import { FilePoliciesSection } from "@/components/admin/settings/sections/file-policies-section"
import { PublicSharesSection } from "@/components/admin/settings/sections/public-shares-section"
import { LlmSection } from "@/components/admin/settings/sections/llm-section"
import { SearchSection } from "@/components/admin/settings/sections/search-section"
import { PluginsSection } from "@/components/admin/settings/sections/plugins-section"
import { NextcloudSection } from "@/components/admin/settings/sections/nextcloud-section"
import { MailingSection } from "@/components/admin/settings/sections/mailing-section"
import { MailDomainsSection } from "@/components/admin/settings/sections/mail-domains-section"
import { OnlyofficeSection } from "@/components/admin/settings/sections/onlyoffice-section"
import { RichtextSection } from "@/components/admin/settings/sections/richtext-section"
import { AiAssistantSection } from "@/components/admin/settings/sections/ai-assistant-section"
import { AgendaSection } from "@/components/admin/settings/sections/agenda-section"
import { UltimeetSection } from "@/components/admin/settings/sections/ultimeet-section"
import { AuditSection } from "@/components/admin/settings/sections/audit-section"
const SECTIONS: Record<AdminSettingsSectionId, React.ComponentType> = {
overview: OverviewSection,
users: UsersSection,
authentication: AuthenticationSection,
security: SecuritySection,
"storage-quotas": StorageQuotasSection,
"usage-quotas": UsageQuotasSection,
"file-policies": FilePoliciesSection,
"public-shares": PublicSharesSection,
llm: LlmSection,
search: SearchSection,
plugins: PluginsSection,
nextcloud: NextcloudSection,
agenda: AgendaSection,
ultimeet: UltimeetSection,
mailing: MailingSection,
"mail-domains": MailDomainsSection,
onlyoffice: OnlyofficeSection,
richtext: RichtextSection,
"ai-assistant": AiAssistantSection,
audit: AuditSection,
function loadSection<P = object>(
loader: () => Promise<{ default: ComponentType<P> }>
) {
return dynamic(loader, { ssr: false })
}
const SECTIONS: Record<AdminSettingsSectionId, ComponentType> = {
overview: loadSection(() =>
import("@/components/admin/settings/sections/overview-section").then((m) => ({
default: m.OverviewSection,
}))
),
users: loadSection(() =>
import("@/components/admin/settings/sections/users-section").then((m) => ({
default: m.UsersSection,
}))
),
authentication: loadSection(() =>
import("@/components/admin/settings/sections/authentication-section").then((m) => ({
default: m.AuthenticationSection,
}))
),
security: loadSection(() =>
import("@/components/admin/settings/sections/security-section").then((m) => ({
default: m.SecuritySection,
}))
),
quotas: loadSection(() =>
import("@/components/admin/settings/sections/quotas-section").then((m) => ({
default: m.QuotasSection,
}))
),
"file-policies": loadSection(() =>
import("@/components/admin/settings/sections/file-policies-section").then((m) => ({
default: m.FilePoliciesSection,
}))
),
"public-shares": loadSection(() =>
import("@/components/admin/settings/sections/public-shares-section").then((m) => ({
default: m.PublicSharesSection,
}))
),
llm: loadSection(() =>
import("@/components/admin/settings/sections/ai-assistant-section").then((m) => ({
default: m.AiAssistantSection,
}))
),
search: loadSection(() =>
import("@/components/admin/settings/sections/search-section").then((m) => ({
default: m.SearchSection,
}))
),
plugins: loadSection(() =>
import("@/components/admin/settings/sections/plugins-section").then((m) => ({
default: m.PluginsSection,
}))
),
agenda: loadSection(() =>
import("@/components/admin/settings/sections/agenda-section").then((m) => ({
default: m.AgendaSection,
}))
),
ultimeet: loadSection(() =>
import("@/components/admin/settings/sections/ultimeet-section").then((m) => ({
default: m.UltimeetSection,
}))
),
"mail-domains": loadSection(() =>
import("@/components/admin/settings/sections/mail-domains-section").then((m) => ({
default: m.MailDomainsSection,
}))
),
"ai-assistant": loadSection(() =>
import("@/components/admin/settings/sections/ai-assistant-section").then((m) => ({
default: m.AiAssistantSection,
}))
),
audit: loadSection(() =>
import("@/components/admin/settings/sections/audit-section").then((m) => ({
default: m.AuditSection,
}))
),
}
export function AdminSettingsSectionView({

View File

@ -0,0 +1,11 @@
import { cn } from "@/lib/utils"
export function FieldGroup({
children,
className,
}: {
children: React.ReactNode
className?: string
}) {
return <div className={cn("space-y-2.5", className)}>{children}</div>
}

View File

@ -49,14 +49,17 @@ export function OrgSettingsSection({
}
return (
<>
<div className="flex min-h-full flex-col">
<div className="flex-1 space-y-6 pb-6">
<SettingsSectionHeader title={title} description={description} />
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
{!showPendingBanner ? null : <AdminPendingApiBanner />}
{showEffectiveBanner ? <AdminRuntimePanel /> : null}
<div className="space-y-6">{children}</div>
{children}
</div>
{hasSave ? (
<div className="mt-6 flex flex-wrap items-center gap-3">
<div className="sticky bottom-0 z-10 -mx-4 shrink-0 border-t border-border bg-mail-surface/95 px-4 py-4 backdrop-blur supports-[backdrop-filter]:bg-mail-surface/80 sm:-mx-8 sm:px-8 dark:bg-mail-surface-elevated/95">
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
onClick={() => void handleSave()}
@ -71,7 +74,8 @@ export function OrgSettingsSection({
) : null}
{error ? <span className="text-sm text-destructive">{error}</span> : null}
</div>
</div>
) : null}
</>
</div>
)
}

View File

@ -0,0 +1,51 @@
"use client"
import type { OrgLLMSettings } from "@/lib/admin-settings/org-settings-types"
import { Switch } from "@/components/ui/switch"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
export function AdminOrgLlmPolicyCard({
draft,
setDraft,
}: {
draft: OrgLLMSettings
setDraft: React.Dispatch<React.SetStateAction<OrgLLMSettings>>
}) {
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Politique LLM</CardTitle>
<CardDescription>
Contrôle l&apos;accès aux fournisseurs IA pour les utilisateurs de l&apos;organisation.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<label className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium">Imposer les fournisseurs org.</p>
<p className="text-xs text-muted-foreground">
Les utilisateurs ne peuvent pas utiliser d&apos;autres clés API.
</p>
</div>
<Switch
checked={draft.enforce_org_providers}
onCheckedChange={(enforce_org_providers) =>
setDraft((p) => ({ ...p, enforce_org_providers }))
}
/>
</label>
<label className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium">Autoriser surcharge utilisateur</p>
</div>
<Switch
checked={draft.allow_user_override}
onCheckedChange={(allow_user_override) =>
setDraft((p) => ({ ...p, allow_user_override }))
}
/>
</label>
</CardContent>
</Card>
)
}

View File

@ -1,10 +1,16 @@
"use client"
import { useMemo, useState } from "react"
import { Plus, RefreshCw, Trash2 } from "lucide-react"
import { useEffect, useMemo, useState } from "react"
import { RefreshCw } from "lucide-react"
import { AiAuthorizedModelPicker } from "@/components/admin/settings/sections/ai-authorized-model-picker"
import { UltiAiToolsCard } from "@/components/admin/settings/sections/ultiai-tools-card"
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
import { DeployLockedHint } from "@/components/admin/settings/deploy-locked-hint"
import { useDeployFieldLocked } from "@/components/admin/settings/deploy-locked-hint"
import { AdminOrgLlmPolicyCard } from "@/components/admin/settings/sections/admin-org-llm-providers-panel"
import { LlmProvidersEditor } from "@/components/llm/llm-providers-editor"
import { normalizeLlmProvider } from "@/lib/llm/llm-provider-catalog"
import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry"
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import type { AiModelCatalogEntry } from "@/lib/admin-settings/org-settings-types"
import { useDiscoverOrgLLMModels } from "@/lib/api/hooks/use-admin-llm"
@ -22,20 +28,14 @@ import {
SelectValue,
} from "@/components/ui/select"
function emptyModelEntry(): AiModelCatalogEntry {
return {
model_id: "",
label: "",
enabled: true,
}
}
export function AiAssistantSection() {
const aiAssistant = useOrgSettingsStore((s) => s.aiAssistant)
const setAiAssistant = useOrgSettingsStore((s) => s.setAiAssistant)
const setPlugins = useOrgSettingsStore((s) => s.setPlugins)
const plugins = useOrgSettingsStore((s) => s.plugins)
const llm = useOrgSettingsStore((s) => s.llm)
const setLlm = useOrgSettingsStore((s) => s.setLlm)
const secrets = useOrgSettingsStore((s) => s.meta?.secrets)
const effective = useOrgSettingsStore((s) => s.meta?.effective.ai_assistant)
const enabledLocked = useDeployFieldLocked("ai_assistant", "enabled")
const publicPathLocked = useDeployFieldLocked("ai_assistant", "public_path")
@ -44,14 +44,57 @@ export function AiAssistantSection() {
const runtimeEnabled = effective?.enabled ?? false
const orgEnabled = aiAssistant.enabled || pluginEnabled
const [llmDraft, setLlmDraft] = useState(llm)
useEffect(() => {
setLlmDraft({
...llm,
providers: (llm.providers ?? []).map(normalizeLlmProvider),
})
}, [llm])
const [discoverProviderId, setDiscoverProviderId] = useState(llm.default_provider_id)
const [discoveredModels, setDiscoveredModels] = useState<string[]>([])
const discoverProvider = useMemo(
() => llm.providers.find((p) => p.id === discoverProviderId) ?? llm.providers[0],
[discoverProviderId, llm.providers],
() => llmDraft.providers.find((p) => p.id === discoverProviderId) ?? llmDraft.providers[0],
[discoverProviderId, llmDraft.providers],
)
const discoverModels = useDiscoverOrgLLMModels()
const defaultModelOptions = useMemo(() => {
const ids = new Set<string>()
if (aiAssistant.models.length > 0) {
for (const entry of aiAssistant.models) {
if (entry.enabled && entry.model_id.trim()) ids.add(entry.model_id.trim())
}
} else {
for (const provider of llmDraft.providers) {
if (provider.default_model?.trim()) ids.add(provider.default_model.trim())
}
for (const modelId of discoveredModels) {
if (modelId.trim()) ids.add(modelId.trim())
}
}
if (aiAssistant.default_model.trim()) ids.add(aiAssistant.default_model.trim())
return Array.from(ids).sort((a, b) => a.localeCompare(b))
}, [aiAssistant.models, aiAssistant.default_model, llmDraft.providers, discoveredModels])
useEffect(() => {
if (!discoverProvider?.id) return
let cancelled = false
void discoverModels
.mutateAsync(discoverProvider.id)
.then((result) => {
if (!cancelled) setDiscoveredModels(result.models ?? [])
})
.catch(() => {
if (!cancelled) setDiscoveredModels([])
})
return () => {
cancelled = true
}
}, [discoverProvider?.id])
async function handleDiscoverModels() {
if (!discoverProvider?.id) return
setDiscoveredModels([])
@ -63,31 +106,14 @@ export function AiAssistantSection() {
}
}
function updateModel(index: number, patch: Partial<AiModelCatalogEntry>) {
const models = aiAssistant.models.map((entry, i) =>
i === index ? { ...entry, ...patch } : entry,
)
const orgLlmProviderSecrets = secrets?.llm_providers as
| Record<string, { configured?: boolean }>
| undefined
function setAuthorizedModels(models: AiModelCatalogEntry[]) {
setAiAssistant({ models })
}
function removeModel(index: number) {
setAiAssistant({ models: aiAssistant.models.filter((_, i) => i !== index) })
}
function addManualModel() {
setAiAssistant({ models: [...aiAssistant.models, emptyModelEntry()] })
}
function addDiscoveredModel(modelId: string) {
if (aiAssistant.models.some((entry) => entry.model_id === modelId)) return
setAiAssistant({
models: [
...aiAssistant.models,
{ model_id: modelId, label: modelId, enabled: true },
],
})
}
function setUltiAIEnabled(enabled: boolean) {
setAiAssistant({ enabled })
setPlugins(
@ -100,9 +126,11 @@ export function AiAssistantSection() {
return (
<OrgSettingsSection
title="UltiAI"
description="Assistant IA intégré (OpenWebUI) avec gateway LLM, tools et sync Nextcloud."
policySection={["ai_assistant", "plugins"]}
description="Assistant IA intégré (OpenWebUI), fournisseurs LLM, gateway, tools et sync Nextcloud."
policySection={["ai_assistant", "plugins", "llm"]}
beforeSave={() => setLlm(llmDraft)}
>
<AutomationTabMasonry columns={2}>
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-4">
@ -159,15 +187,49 @@ export function AiAssistantSection() {
/>
<DeployLockedHint section="ai_assistant" field="openwebui_internal_url" />
</div>
<div className="space-y-2">
<div className="space-y-2 sm:col-span-2">
<Label>Modèle par défaut</Label>
{defaultModelOptions.length > 0 ? (
<Select
value={aiAssistant.default_model || "__auto__"}
onValueChange={(value) =>
setAiAssistant({
default_model: value === "__auto__" ? "" : value,
})
}
>
<SelectTrigger className="h-9 w-full min-w-0">
<SelectValue placeholder="Choisir un modèle…" />
</SelectTrigger>
<SelectContent className="max-h-60">
<SelectItem value="__auto__">
Automatique (fournisseur LLM par défaut)
</SelectItem>
{defaultModelOptions.map((modelId) => {
const catalogLabel = aiAssistant.models.find(
(entry) => entry.model_id === modelId,
)?.label
return (
<SelectItem key={modelId} value={modelId}>
{catalogLabel?.trim() ? `${catalogLabel} (${modelId})` : modelId}
</SelectItem>
)
})}
</SelectContent>
</Select>
) : (
<Input
value={aiAssistant.default_model}
onChange={(e) => setAiAssistant({ default_model: e.target.value })}
placeholder="gpt-4o"
placeholder="gpt-4o-mini"
/>
)}
<p className="text-xs text-muted-foreground">
Modèle pré-sélectionné dans UltiAI pour tous les utilisateurs. Configurez un
fournisseur LLM ou découvrez les modèles ci-dessous.
</p>
</div>
<div className="space-y-2">
<div className="space-y-2 sm:col-span-2">
<Label>Chemin historique NC</Label>
<Input
value={aiAssistant.chat_nc_path}
@ -202,6 +264,40 @@ export function AiAssistantSection() {
</CardContent>
</Card>
<div className="flex flex-col gap-4 sm:col-span-2">
<AdminOrgLlmPolicyCard draft={llmDraft} setDraft={setLlmDraft} />
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Fournisseurs LLM</CardTitle>
<CardDescription>
Modèles IA organisationnels pour UltiAI, le tri, l&apos;enrichissement contacts et
les automatisations.
</CardDescription>
</CardHeader>
<CardContent>
<LlmProvidersEditor
columns={1}
providers={llmDraft.providers}
defaultProviderId={llmDraft.default_provider_id}
providerSecrets={orgLlmProviderSecrets}
onProvidersChange={(providers) =>
setLlmDraft((prev) => ({ ...prev, providers }))
}
onDefaultProviderIdChange={(default_provider_id) =>
setLlmDraft((prev) => ({ ...prev, default_provider_id }))
}
/>
</CardContent>
</Card>
</div>
<UltiAiToolsCard
enabledTools={aiAssistant.enabled_tools}
onChange={(enabled_tools) => setAiAssistant({ enabled_tools })}
webSearchSettingsHref="/admin/settings/search"
/>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Modèles autorisés</CardTitle>
@ -211,9 +307,9 @@ export function AiAssistantSection() {
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{llm.providers.length === 0 ? (
{llmDraft.providers.length === 0 ? (
<p className="text-sm text-muted-foreground">
Configurez d&apos;abord un fournisseur LLM dans Administration Fournisseurs LLM.
Configurez d&apos;abord un fournisseur LLM dans la section ci-dessus.
</p>
) : (
<div className="flex flex-wrap items-end gap-3 rounded-lg border p-3">
@ -227,7 +323,7 @@ export function AiAssistantSection() {
<SelectValue placeholder="Choisir un fournisseur…" />
</SelectTrigger>
<SelectContent>
{llm.providers.map((provider) => (
{llmDraft.providers.map((provider) => (
<SelectItem key={provider.id} value={provider.id}>
{provider.name || provider.base_url}
</SelectItem>
@ -258,91 +354,26 @@ export function AiAssistantSection() {
</p>
) : null}
{discoveredModels.length ? (
{llmDraft.providers.length > 0 ? (
<div className="space-y-2">
<Label className="text-xs">Modèles disponibles sur l&apos;endpoint</Label>
<div className="flex flex-wrap gap-2">
{discoveredModels.map((modelId) => {
const alreadyAdded = aiAssistant.models.some(
(entry) => entry.model_id === modelId,
)
return (
<Button
key={modelId}
type="button"
size="sm"
variant={alreadyAdded ? "secondary" : "outline"}
disabled={alreadyAdded}
onClick={() => addDiscoveredModel(modelId)}
>
{alreadyAdded ? "Ajouté" : `+ ${modelId}`}
</Button>
)
})}
</div>
<Label>Catalogue organisation</Label>
<AiAuthorizedModelPicker
models={aiAssistant.models}
onChange={setAuthorizedModels}
availableModelIds={discoveredModels}
emptyHint="Aucune restriction — tous les modèles LLM configurés restent disponibles."
/>
{discoveredModels.length === 0 ? (
<p className="text-xs text-muted-foreground">
Découvrez les modèles depuis un fournisseur pour remplir l&apos;autocomplétion,
ou saisissez un ID manuellement puis Entrée.
</p>
) : null}
</div>
) : null}
<div className="flex items-center justify-between">
<Label>Catalogue organisation</Label>
<Button type="button" variant="outline" size="sm" onClick={addManualModel}>
<Plus className="mr-2 size-4" />
Ajouter manuellement
</Button>
</div>
{aiAssistant.models.length === 0 ? (
<p className="text-sm text-muted-foreground">
Aucune restriction tous les modèles LLM configurés restent disponibles.
</p>
) : (
<div className="space-y-2">
{aiAssistant.models.map((entry, index) => (
<div
key={`${entry.model_id}-${index}`}
className="grid gap-2 rounded-lg border p-3 sm:grid-cols-[1fr_1fr_auto_auto]"
>
<div className="space-y-1">
<Label className="text-xs">ID modèle</Label>
<Input
className="h-9"
value={entry.model_id}
onChange={(e) => updateModel(index, { model_id: e.target.value })}
placeholder="gpt-4o-mini"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Surnom utilisateur</Label>
<Input
className="h-9"
value={entry.label}
onChange={(e) => updateModel(index, { label: e.target.value })}
placeholder="GPT-4o Mini"
/>
</div>
<label className="flex items-center gap-2 self-end pb-1 text-sm">
<Switch
checked={entry.enabled}
onCheckedChange={(enabled) => updateModel(index, { enabled })}
/>
Autorisé
</label>
<Button
type="button"
variant="ghost"
size="icon"
className="self-end"
onClick={() => removeModel(index)}
aria-label="Supprimer le modèle"
>
<Trash2 className="size-4" />
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
</AutomationTabMasonry>
</OrgSettingsSection>
)
}

View File

@ -0,0 +1,204 @@
"use client"
import { useMemo, useRef, useState } from "react"
import { X } from "lucide-react"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import type { AiModelCatalogEntry } from "@/lib/admin-settings/org-settings-types"
import { cn } from "@/lib/utils"
const VISIBLE_SUGGESTIONS = 5
const SUGGESTION_ROW_HEIGHT_REM = 2.25
function modelKey(entry: AiModelCatalogEntry) {
return entry.model_id
}
function chipLabel(entry: AiModelCatalogEntry) {
const label = entry.label.trim()
return label && label !== entry.model_id ? label : entry.model_id
}
export function AiAuthorizedModelPicker({
models,
onChange,
availableModelIds,
disabled,
emptyHint,
}: {
models: AiModelCatalogEntry[]
onChange: (models: AiModelCatalogEntry[]) => void
availableModelIds: string[]
disabled?: boolean
emptyHint?: string
}) {
const [query, setQuery] = useState("")
const [focused, setFocused] = useState(false)
const [activeIndex, setActiveIndex] = useState(0)
const blurTimer = useRef<number | null>(null)
const taken = useMemo(() => new Set(models.map((m) => m.model_id)), [models])
const suggestions = useMemo(() => {
const q = query.trim().toLowerCase()
const pool = availableModelIds.filter((id) => !taken.has(id))
const matches = q ? pool.filter((id) => id.toLowerCase().includes(q)) : pool
return matches.sort((a, b) => a.localeCompare(b))
}, [availableModelIds, query, taken])
const showSuggestions = focused && !disabled && suggestions.length > 0
function addModel(modelId: string) {
const id = modelId.trim()
if (!id || taken.has(id)) return
onChange([...models, { model_id: id, label: id, enabled: true }])
setQuery("")
setActiveIndex(0)
}
function removeModel(modelId: string) {
onChange(models.filter((entry) => entry.model_id !== modelId))
}
function updateLabel(modelId: string, label: string) {
onChange(
models.map((entry) =>
entry.model_id === modelId ? { ...entry, label } : entry,
),
)
}
function tryAddFromQuery() {
const id = query.trim()
if (!id) return false
if (suggestions[activeIndex]) {
addModel(suggestions[activeIndex]!)
return true
}
if (!taken.has(id)) {
addModel(id)
return true
}
return false
}
return (
<div className="space-y-2">
<div className="relative">
<Input
value={query}
disabled={disabled}
placeholder="Rechercher ou saisir un modèle…"
className="h-9"
onChange={(e) => {
setQuery(e.target.value)
setActiveIndex(0)
}}
onFocus={() => {
if (blurTimer.current) window.clearTimeout(blurTimer.current)
setFocused(true)
}}
onBlur={() => {
blurTimer.current = window.setTimeout(() => setFocused(false), 120)
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
tryAddFromQuery()
return
}
if (!showSuggestions || suggestions.length === 0) return
if (e.key === "ArrowDown") {
e.preventDefault()
setActiveIndex((i) => (i + 1) % suggestions.length)
} else if (e.key === "ArrowUp") {
e.preventDefault()
setActiveIndex((i) => (i - 1 + suggestions.length) % suggestions.length)
} else if (e.key === "Escape") {
setFocused(false)
}
}}
/>
{showSuggestions ? (
<ul
className="absolute z-20 mt-1 w-full overflow-y-auto rounded-md border border-border bg-popover py-1 shadow-md"
style={{ maxHeight: `${VISIBLE_SUGGESTIONS * SUGGESTION_ROW_HEIGHT_REM}rem` }}
>
{suggestions.map((modelId, index) => (
<li key={modelId}>
<button
type="button"
className={cn(
"block w-full px-3 py-1.5 text-left text-xs hover:bg-muted",
index === activeIndex && "bg-muted",
)}
onMouseDown={(e) => {
e.preventDefault()
addModel(modelId)
}}
>
<span className="block truncate font-mono">{modelId}</span>
</button>
</li>
))}
</ul>
) : null}
</div>
{models.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{models.map((entry) => (
<span
key={modelKey(entry)}
className="inline-flex max-w-full items-center gap-1 rounded-full border border-border bg-muted/60 px-2 py-0.5 text-[11px] text-foreground"
>
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="max-w-[14rem] truncate text-left hover:underline"
title={entry.model_id}
>
{chipLabel(entry)}
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-72 space-y-2 p-3">
<div className="space-y-1">
<Label className="text-xs">ID modèle</Label>
<p className="truncate font-mono text-xs text-muted-foreground">
{entry.model_id}
</p>
</div>
<div className="space-y-1">
<Label className="text-xs">Surnom utilisateur</Label>
<Input
className="h-8 text-xs"
value={entry.label}
onChange={(e) => updateLabel(entry.model_id, e.target.value)}
placeholder={entry.model_id}
/>
</div>
</PopoverContent>
</Popover>
<button
type="button"
className="shrink-0 rounded-full p-0.5 hover:bg-background/80"
aria-label={`Retirer ${entry.model_id}`}
onClick={() => removeModel(entry.model_id)}
>
<X className="size-3" />
</button>
</span>
))}
</div>
) : emptyHint ? (
<p className="text-sm text-muted-foreground">{emptyHint}</p>
) : null}
</div>
)
}

View File

@ -1,13 +1,17 @@
"use client"
import { useCallback, useRef } from "react"
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
import { IdentityProvidersSection } from "@/components/admin/settings/sections/identity-providers-section"
import { AdminSettingsCard } from "@/components/admin/settings/admin-settings-card"
import { FieldGroup } from "@/components/admin/settings/field-group"
import { IdentityProvidersPanel } from "@/components/admin/settings/sections/identity-providers-section"
import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry"
import { DeployLockedHint, useDeployFieldLocked } from "@/components/admin/settings/deploy-locked-hint"
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import { Card, CardContent } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
export function AuthenticationSection() {
const authentik = useOrgSettingsStore((s) => s.authentik)
@ -22,19 +26,29 @@ export function AuthenticationSection() {
const apiURL = apiLocked ? (effective?.api_url ?? authentik.api_url) : authentik.api_url
const clientID = clientLocked ? (effective?.client_id ?? authentik.client_id) : authentik.client_id
const identityBeforeSaveRef = useRef<(() => void) | null>(null)
const registerIdentityBeforeSave = useCallback((fn: (() => void) | null) => {
identityBeforeSaveRef.current = fn
}, [])
return (
<div className="space-y-8">
<OrgSettingsSection
title="Authentification Authentik"
description="SSO, provisionnement des comptes Ultimail et groupes par défaut."
policySection="authentik"
title="Authentification"
description="SSO Authentik, provisionnement des comptes Ultimail et fournisseurs d'identité upstream."
policySection={["authentik", "identity_providers"]}
beforeSave={async () => {
identityBeforeSaveRef.current?.()
}}
>
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-4">
<div>
<CardTitle className="text-sm font-medium">Authentik activé</CardTitle>
<CardDescription>Connexion via le fournisseur d&apos;identité organisationnel.</CardDescription>
<AutomationTabMasonry columns={2}>
<Card className="gap-0 py-0">
<CardContent className="py-4">
<div className="flex items-start gap-4">
<div className="min-w-0 flex-1">
<p className="font-medium">Authentik</p>
<p className="mt-1 text-sm text-muted-foreground">
Connexion via le fournisseur d&apos;identité organisationnel.
</p>
{enabledLocked ? <DeployLockedHint section="authentik" field="enabled" /> : null}
</div>
<Switch
@ -43,62 +57,70 @@ export function AuthenticationSection() {
onCheckedChange={(v) => setAuthentik({ enabled: v })}
/>
</div>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<div className="mt-4 space-y-4 border-t pt-4">
<FieldGroup>
<Label>URL API Authentik</Label>
<Input
className="mt-1 h-9"
className="h-9"
value={apiURL}
disabled={apiLocked}
onChange={(e) => setAuthentik({ api_url: e.target.value })}
placeholder="https://auth.example.com/api/v3"
/>
</div>
<div>
{apiLocked ? <DeployLockedHint section="authentik" field="api_url" /> : null}
</FieldGroup>
<div className="grid min-w-0 gap-4">
<FieldGroup>
<Label>Slug application</Label>
<Input
className="mt-1 h-9"
className="h-9"
value={authentik.slug}
onChange={(e) => setAuthentik({ slug: e.target.value })}
/>
</div>
<div>
</FieldGroup>
<FieldGroup>
<Label>Client ID OIDC</Label>
<Input
className="mt-1 h-9"
className="h-9"
value={clientID}
disabled={clientLocked}
onChange={(e) => setAuthentik({ client_id: e.target.value })}
/>
{clientLocked ? <DeployLockedHint section="authentik" field="client_id" /> : null}
</FieldGroup>
</div>
<div className="sm:col-span-2">
<FieldGroup>
<Label>Groupes par défaut (séparés par des virgules)</Label>
<Input
className="mt-1 h-9"
className="h-9"
value={authentik.default_groups}
onChange={(e) => setAuthentik({ default_groups: e.target.value })}
/>
</div>
<label className="flex items-center justify-between gap-4 rounded-lg border p-3 sm:col-span-2">
<div>
</FieldGroup>
<label className="flex items-center justify-between gap-4 rounded-lg border p-3">
<FieldGroup>
<p className="text-sm font-medium">Forcer le SSO</p>
<p className="text-xs text-muted-foreground">
Désactive la connexion locale sauf pour les administrateurs.
</p>
</div>
</FieldGroup>
<Switch
checked={authentik.enforce_sso}
onCheckedChange={(enforce_sso) => setAuthentik({ enforce_sso })}
/>
</label>
<label className="flex items-center justify-between gap-4 rounded-lg border p-3 sm:col-span-2">
<div>
<label className="flex items-center justify-between gap-4 rounded-lg border p-3">
<FieldGroup>
<p className="text-sm font-medium">Mot de passe local de secours</p>
<p className="text-xs text-muted-foreground">
Autoriser un fallback mot de passe si Authentik est indisponible.
</p>
</div>
</FieldGroup>
<Switch
checked={authentik.allow_password_fallback}
onCheckedChange={(allow_password_fallback) =>
@ -106,11 +128,17 @@ export function AuthenticationSection() {
}
/>
</label>
</div>
</CardContent>
</Card>
</OrgSettingsSection>
<IdentityProvidersSection />
</div>
<AdminSettingsCard
title="Fournisseurs d'identité"
description="Sources upstream Authentik (OAuth, SAML, LDAP) avec restrictions d'accès."
>
<IdentityProvidersPanel onRegisterBeforeSave={registerIdentityBeforeSave} />
</AdminSettingsCard>
</AutomationTabMasonry>
</OrgSettingsSection>
)
}

View File

@ -3,6 +3,8 @@
import { useEffect, useState } from "react"
import { Check, Copy } from "lucide-react"
import { toast } from "sonner"
import { TechBrandSelectLabel } from "@/components/admin/settings/tech-brand-select-label"
import { FieldGroup } from "@/components/admin/settings/field-group"
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import type { DriveMountOAuthProvider, DriveMountOAuthSettings } from "@/lib/admin-settings/org-settings-types"
import { Button } from "@/components/ui/button"
@ -11,21 +13,29 @@ import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { buildDriveMountOAuthRedirectURI } from "@/lib/drive/drive-mount-oauth"
const PROVIDERS: { id: DriveMountOAuthProvider; label: string; hint: string }[] = [
const PROVIDERS: {
id: DriveMountOAuthProvider
label: string
hint: string
icon: string
}[] = [
{
id: "google",
label: "Google Drive",
hint: "Console Google Cloud — API Drive, redirect URI ci-dessous",
icon: "logos:google-drive",
},
{
id: "dropbox",
label: "Dropbox",
hint: "App Dropbox — permissions files.metadata.read, files.content.read/write",
icon: "logos:dropbox",
},
{
id: "microsoft",
label: "Microsoft OneDrive",
hint: "Azure AD — Microsoft Graph Files.ReadWrite",
icon: "logos:microsoft-onedrive",
},
]
@ -38,9 +48,11 @@ const SECRET_KEYS: Record<DriveMountOAuthProvider, "mount_oauth_google" | "mount
export function DriveMountOAuthSection({
draft,
onChange,
embedded = false,
}: {
draft: DriveMountOAuthSettings
onChange: (next: DriveMountOAuthSettings) => void
embedded?: boolean
}) {
const secrets = useOrgSettingsStore((s) => s.meta?.secrets)
const [redirectUri, setRedirectUri] = useState("")
@ -70,16 +82,18 @@ export function DriveMountOAuthSection({
}
return (
<div className="space-y-4 rounded-lg border p-4">
<div className={embedded ? "space-y-4" : "space-y-4 rounded-lg border p-4"}>
{!embedded ? (
<div>
<h3 className="text-sm font-medium">Connexion cloud (OAuth)</h3>
<p className="mt-1 text-xs text-muted-foreground">
Permet aux utilisateurs de monter Google Drive, Dropbox ou OneDrive depuis UltiDrive.
</p>
</div>
<div>
) : null}
<FieldGroup>
<Label>URI de redirection OAuth</Label>
<div className="mt-1 flex gap-2">
<div className="flex gap-2">
<Input
className="h-9 flex-1 font-mono text-xs"
readOnly
@ -98,41 +112,44 @@ export function DriveMountOAuthSection({
Copier
</Button>
</div>
<p className="mt-1 text-xs text-muted-foreground">
Basée sur l&apos;URL actuelle du navigateur. Enregistrez-la chez chaque fournisseur OAuth (Google, Dropbox, Microsoft).
<p className="text-xs text-muted-foreground">
Basée sur l&apos;URL actuelle du navigateur. Enregistrez-la chez chaque fournisseur OAuth
(Google, Dropbox, Microsoft).
</p>
</div>
</FieldGroup>
<div className="space-y-4">
{PROVIDERS.map(({ id, label, hint }) => {
{PROVIDERS.map(({ id, label, hint, icon }) => {
const provider = draft[id]
const configured = Boolean(secrets?.[SECRET_KEYS[id]]?.configured)
return (
<div key={id} className="space-y-3 rounded-md border p-3">
<label className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium">{label}</p>
<FieldGroup>
<TechBrandSelectLabel icon={icon} className="text-sm font-medium">
{label}
</TechBrandSelectLabel>
<p className="text-xs text-muted-foreground">{hint}</p>
</div>
</FieldGroup>
<Switch
checked={provider.enabled}
onCheckedChange={(enabled) => updateProvider(id, { enabled })}
/>
</label>
{provider.enabled ? (
<div className="grid gap-3 sm:grid-cols-2">
<div className="sm:col-span-2">
<div className="grid min-w-0 gap-4">
<FieldGroup>
<Label>Client ID</Label>
<Input
className="mt-1 h-9 font-mono text-xs"
className="h-9 font-mono text-xs"
value={provider.client_id}
onChange={(e) => updateProvider(id, { client_id: e.target.value })}
autoComplete="off"
/>
</div>
<div className="sm:col-span-2">
</FieldGroup>
<FieldGroup>
<Label>Client secret</Label>
<Input
className="mt-1 h-9 font-mono text-xs"
className="h-9 font-mono text-xs"
type="password"
value={provider.client_secret}
onChange={(e) => updateProvider(id, { client_secret: e.target.value })}
@ -140,9 +157,9 @@ export function DriveMountOAuthSection({
autoComplete="off"
/>
{configured && !provider.client_secret.trim() ? (
<p className="mt-1 text-xs text-muted-foreground">Secret configuré</p>
<p className="text-xs text-muted-foreground">Secret configuré</p>
) : null}
</div>
</FieldGroup>
</div>
) : null}
</div>

View File

@ -1,6 +1,7 @@
"use client"
import { useState } from "react"
import { FieldGroup } from "@/components/admin/settings/field-group"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
@ -9,7 +10,7 @@ import {
useAdminDriveOrgFolders,
} from "@/lib/api/hooks/use-admin-drive-queries"
export function DriveOrgFoldersSection() {
export function DriveOrgFoldersSection({ embedded = false }: { embedded?: boolean }) {
const folders = useAdminDriveOrgFolders()
const { create, remove, sync } = useAdminDriveOrgFolderMutations()
const [orgSlug, setOrgSlug] = useState("")
@ -17,23 +18,25 @@ export function DriveOrgFoldersSection() {
const [syncSlugs, setSyncSlugs] = useState("")
return (
<div className="space-y-6 rounded-lg border p-4">
<div className={embedded ? "space-y-4" : "space-y-6 rounded-lg border p-4"}>
{!embedded ? (
<div>
<h3 className="text-sm font-medium">Dossiers d&apos;organisation</h3>
<p className="text-xs text-muted-foreground">
Group folders Nextcloud liés aux organisations Authentik.
Espaces de stockage internes (group folders Nextcloud) liés aux organisations Authentik.
</p>
</div>
) : null}
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-1.5">
<div className="grid min-w-0 gap-4">
<FieldGroup>
<Label htmlFor="org-slug">Slug organisation</Label>
<Input id="org-slug" value={orgSlug} onChange={(e) => setOrgSlug(e.target.value)} placeholder="acme" />
</div>
<div className="grid gap-1.5">
</FieldGroup>
<FieldGroup>
<Label htmlFor="org-mount">Nom du dossier</Label>
<Input id="org-mount" value={mountPoint} onChange={(e) => setMountPoint(e.target.value)} placeholder="Acme Corp" />
</div>
</FieldGroup>
</div>
<Button
size="sm"
@ -45,8 +48,12 @@ export function DriveOrgFoldersSection() {
Créer le dossier
</Button>
<div className="grid gap-1.5">
<Label htmlFor="sync-orgs">Sync auto (slugs séparés par des virgules)</Label>
<FieldGroup>
<Label htmlFor="sync-orgs">Provisionnement automatique</Label>
<p className="text-xs text-muted-foreground">
Crée un dossier d&apos;organisation pour chaque slug listé, s&apos;il n&apos;existe pas encore.
Les slugs correspondent aux organisations Authentik.
</p>
<Input
id="sync-orgs"
value={syncSlugs}
@ -63,9 +70,9 @@ export function DriveOrgFoldersSection() {
)
}
>
Provisionner
Provisionner les dossiers
</Button>
</div>
</FieldGroup>
<ul className="divide-y rounded-md border text-sm">
{(folders.data ?? []).map((folder) => (

View File

@ -0,0 +1,183 @@
"use client"
import { useState } from "react"
import { FieldGroup } from "@/components/admin/settings/field-group"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import {
useAdminDriveOrgMountMutations,
useAdminDriveOrgMounts,
} from "@/lib/api/hooks/use-admin-drive-queries"
function mountStatusLabel(status: string) {
switch (status) {
case "active":
return "Actif"
case "error":
return "Erreur"
case "pending":
return "En attente"
default:
return status
}
}
export function DriveOrgWebDAVSection({ embedded = false }: { embedded?: boolean }) {
const mounts = useAdminDriveOrgMounts()
const { create, remove } = useAdminDriveOrgMountMutations()
const [orgSlug, setOrgSlug] = useState("")
const [displayName, setDisplayName] = useState("")
const [host, setHost] = useState("")
const [root, setRoot] = useState("/")
const [userName, setUserName] = useState("")
const [password, setPassword] = useState("")
const [secure, setSecure] = useState(true)
const canCreate =
orgSlug.trim() &&
displayName.trim() &&
host.trim() &&
userName.trim() &&
password.trim()
return (
<div className={embedded ? "space-y-4" : "space-y-6 rounded-lg border p-4"}>
{!embedded ? (
<div>
<h3 className="text-sm font-medium">Montages WebDAV d&apos;organisation</h3>
<p className="text-xs text-muted-foreground">
Connecte un serveur WebDAV partagé (NAS, Nextcloud externe, etc.) visible par tous les
utilisateurs UltiDrive.
</p>
</div>
) : null}
<p className="text-xs text-muted-foreground">
Le slug d&apos;organisation sert au rattachement administratif. Le volume est monté globalement
dans Nextcloud et apparaît dans UltiDrive pour tous les utilisateurs.
</p>
<div className="grid min-w-0 gap-4 sm:grid-cols-2">
<FieldGroup>
<Label htmlFor="webdav-org-slug">Slug organisation</Label>
<Input
id="webdav-org-slug"
value={orgSlug}
onChange={(e) => setOrgSlug(e.target.value)}
placeholder="acme"
/>
</FieldGroup>
<FieldGroup>
<Label htmlFor="webdav-display-name">Nom affiché</Label>
<Input
id="webdav-display-name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="NAS partagé"
/>
</FieldGroup>
<FieldGroup>
<Label htmlFor="webdav-host">Hôte</Label>
<Input
id="webdav-host"
value={host}
onChange={(e) => setHost(e.target.value)}
placeholder="nas.example.com"
/>
</FieldGroup>
<FieldGroup>
<Label htmlFor="webdav-root">Chemin racine</Label>
<Input
id="webdav-root"
value={root}
onChange={(e) => setRoot(e.target.value)}
placeholder="/remote.php/dav/files/user"
/>
</FieldGroup>
<FieldGroup>
<Label htmlFor="webdav-user">Utilisateur</Label>
<Input id="webdav-user" value={userName} onChange={(e) => setUserName(e.target.value)} />
</FieldGroup>
<FieldGroup>
<Label htmlFor="webdav-pass">Mot de passe</Label>
<Input
id="webdav-pass"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
/>
</FieldGroup>
</div>
<label className="flex cursor-pointer items-center gap-2 text-sm">
<input type="checkbox" checked={secure} onChange={(e) => setSecure(e.target.checked)} />
Connexion HTTPS
</label>
<Button
size="sm"
disabled={!canCreate || create.isPending}
onClick={() =>
void create.mutateAsync({
org_slug: orgSlug.trim(),
display_name: displayName.trim(),
webdav: {
host: host.trim(),
root: root.trim() || "/",
user: userName.trim(),
password,
secure,
},
}).then(() => {
setOrgSlug("")
setDisplayName("")
setHost("")
setRoot("/")
setUserName("")
setPassword("")
setSecure(true)
})
}
>
Ajouter le montage
</Button>
<ul className="divide-y rounded-md border text-sm">
{(mounts.data ?? []).map((mount) => (
<li key={mount.id} className="flex items-start justify-between gap-3 px-3 py-2">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<p className="truncate font-medium">{mount.display_name}</p>
<Badge variant={mount.status === "active" ? "secondary" : "destructive"} className="text-[10px]">
{mountStatusLabel(mount.status)}
</Badge>
</div>
<p className="truncate text-xs text-muted-foreground">
{mount.org_slug ?? "—"} · WebDAV · {mount.mount_point}
</p>
{mount.last_error ? (
<p className="text-xs text-destructive">{mount.last_error}</p>
) : null}
</div>
<Button
size="sm"
variant="ghost"
className="shrink-0 text-destructive"
disabled={remove.isPending}
onClick={() => void remove.mutateAsync(mount.id)}
>
Supprimer
</Button>
</li>
))}
{mounts.data?.length === 0 ? (
<li className="px-3 py-4 text-center text-muted-foreground">Aucun montage WebDAV d&apos;organisation</li>
) : null}
</ul>
</div>
)
}

View File

@ -2,6 +2,9 @@
import { useEffect, useState } from "react"
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
import { AdminSettingsCard } from "@/components/admin/settings/admin-settings-card"
import { FieldGroup } from "@/components/admin/settings/field-group"
import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry"
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
@ -15,6 +18,7 @@ import {
SelectValue,
} from "@/components/ui/select"
import { DriveOrgFoldersSection } from "@/components/admin/settings/sections/drive-org-section"
import { DriveOrgWebDAVSection } from "@/components/admin/settings/sections/drive-org-webdav-section"
import { DriveMountOAuthSection } from "@/components/admin/settings/sections/drive-mount-oauth-section"
export function FilePoliciesSection() {
@ -40,11 +44,16 @@ export function FilePoliciesSection() {
policySection="file_policies"
beforeSave={() => setFilePolicies({ mount_oauth: mountOAuthDraft })}
>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<AutomationTabMasonry columns={2}>
<AdminSettingsCard
title="Politiques UltiDrive"
description="Limites d'upload, partage externe, extensions et analyse antivirus."
>
<div className="grid min-w-0 gap-4">
<FieldGroup>
<Label>Taille max upload (Mo)</Label>
<Input
className="mt-1 h-9"
className="h-9"
type="number"
min={1}
value={filePolicies.max_upload_mib}
@ -52,11 +61,11 @@ export function FilePoliciesSection() {
setFilePolicies({ max_upload_mib: Number(e.target.value) || 1 })
}
/>
</div>
<div>
</FieldGroup>
<FieldGroup>
<Label>Expiration liens par défaut (jours)</Label>
<Input
className="mt-1 h-9"
className="h-9"
type="number"
min={1}
value={filePolicies.default_link_expiry_days}
@ -66,11 +75,11 @@ export function FilePoliciesSection() {
})
}
/>
</div>
<div>
</FieldGroup>
<FieldGroup>
<Label>Rétention corbeille (jours)</Label>
<Input
className="mt-1 h-9"
className="h-9"
type="number"
min={1}
value={filePolicies.retention_trash_days}
@ -78,8 +87,8 @@ export function FilePoliciesSection() {
setFilePolicies({ retention_trash_days: Number(e.target.value) || 1 })
}
/>
</div>
<div>
</FieldGroup>
<FieldGroup>
<Label>Partage externe</Label>
<Select
value={filePolicies.external_sharing}
@ -89,7 +98,7 @@ export function FilePoliciesSection() {
})
}
>
<SelectTrigger className="mt-1 h-9">
<SelectTrigger className="h-9 w-full min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
@ -98,62 +107,88 @@ export function FilePoliciesSection() {
<SelectItem value="public_link">Liens publics autorisés</SelectItem>
</SelectContent>
</Select>
</div>
<div className="sm:col-span-2">
</FieldGroup>
<FieldGroup>
<Label>Extensions autorisées (vide = toutes)</Label>
<Textarea
className="mt-1 min-h-[80px] font-mono text-xs"
className="min-h-[80px] font-mono text-xs"
value={filePolicies.allowed_extensions}
onChange={(e) => setFilePolicies({ allowed_extensions: e.target.value })}
placeholder="pdf, docx, png, jpg"
/>
</FieldGroup>
</div>
<label className="flex items-center justify-between gap-4 rounded-lg border p-3 sm:col-span-2">
<div>
<label className="flex items-center justify-between gap-4 rounded-lg border p-3">
<FieldGroup>
<p className="text-sm font-medium">Bloquer les exécutables</p>
<p className="text-xs text-muted-foreground">exe, bat, sh, app, etc.</p>
</div>
</FieldGroup>
<Switch
checked={filePolicies.block_executable}
onCheckedChange={(block_executable) => setFilePolicies({ block_executable })}
/>
</label>
<label className="flex items-center justify-between gap-4 rounded-lg border p-3 sm:col-span-2">
<div>
<label className="flex items-center justify-between gap-4 rounded-lg border p-3">
<FieldGroup>
<p className="text-sm font-medium">Analyse antivirus à l&apos;upload</p>
<p className="text-xs text-muted-foreground">
VirusTotal scan synchrone à l&apos;upload Drive et pièces jointes mail
</p>
</div>
</FieldGroup>
<Switch
checked={filePolicies.virus_scan_enabled}
onCheckedChange={(virus_scan_enabled) => setFilePolicies({ virus_scan_enabled })}
/>
</label>
{filePolicies.virus_scan_enabled ? (
<div className="sm:col-span-2">
<FieldGroup>
<Label>Clé API VirusTotal</Label>
<Input
className="mt-1 h-9"
className="h-9"
type="password"
autoComplete="off"
value={filePolicies.virustotal_api_key ?? ""}
onChange={(e) => setFilePolicies({ virustotal_api_key: e.target.value })}
placeholder={vtKeyConfigured ? "•••••••• (laisser vide pour conserver)" : "Coller la clé API"}
placeholder={
vtKeyConfigured ? "•••••••• (laisser vide pour conserver)" : "Coller la clé API"
}
/>
{vtKeyConfigured && !(filePolicies.virustotal_api_key ?? "").trim() ? (
<p className="mt-1 text-xs text-muted-foreground">Clé configurée</p>
<p className="text-xs text-muted-foreground">Clé configurée</p>
) : null}
{vtKeyMissing ? (
<p className="mt-1 text-xs text-amber-600 dark:text-amber-500">
<p className="text-xs text-amber-600 dark:text-amber-500">
Analyse activée sans clé API les uploads ne seront pas scannés.
</p>
) : null}
</div>
</FieldGroup>
) : null}
</div>
<DriveMountOAuthSection draft={mountOAuthDraft} onChange={setMountOAuthDraft} />
<DriveOrgFoldersSection />
</AdminSettingsCard>
<AdminSettingsCard
title="Connexion cloud (OAuth)"
description="Permet aux utilisateurs de monter Google Drive, Dropbox ou OneDrive depuis UltiDrive."
>
<DriveMountOAuthSection draft={mountOAuthDraft} onChange={setMountOAuthDraft} embedded />
</AdminSettingsCard>
<AdminSettingsCard
title="Montages WebDAV d'organisation"
description="Serveur WebDAV partagé (NAS, Nextcloud externe) visible par tous les utilisateurs UltiDrive."
>
<DriveOrgWebDAVSection embedded />
</AdminSettingsCard>
<AdminSettingsCard
title="Dossiers d'organisation"
description="Espaces de stockage internes (group folders Nextcloud) liés aux organisations Authentik."
>
<DriveOrgFoldersSection embedded />
</AdminSettingsCard>
</AutomationTabMasonry>
</OrgSettingsSection>
)
}

View File

@ -9,8 +9,8 @@ import {
TestTube2,
Trash2,
} from "lucide-react"
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
import { guideForProvider } from "@/components/admin/settings/guides/identity-provider-guides"
import { TechBrandSelectLabel } from "@/components/admin/settings/tech-brand-select-label"
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import type {
IdentityProvider,
@ -49,6 +49,14 @@ import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import { toast } from "sonner"
const OAUTH_PRESET_LABELS: Record<OAuthProviderPreset, string> = {
google: "Google",
github: "GitHub",
linkedin: "LinkedIn",
microsoft: "Microsoft",
custom: "Autre / custom",
}
function splitList(value: string): string[] {
return value
.split(/[\n,]/)
@ -121,7 +129,11 @@ function syncBadge(status: IdentityProvider["sync_status"]) {
}
}
export function IdentityProvidersSection() {
export function IdentityProvidersPanel({
onRegisterBeforeSave,
}: {
onRegisterBeforeSave?: (fn: (() => void) | null) => void
}) {
const identityProviders = useOrgSettingsStore((s) => s.identityProviders)
const setIdentityProviders = useOrgSettingsStore((s) => s.setIdentityProviders)
const meta = useOrgSettingsStore((s) => s.meta)
@ -216,13 +228,13 @@ export function IdentityProvidersSection() {
}
}
useEffect(() => {
onRegisterBeforeSave?.(() => setIdentityProviders(draft))
return () => onRegisterBeforeSave?.(null)
}, [draft, onRegisterBeforeSave, setIdentityProviders])
return (
<OrgSettingsSection
title="Fournisseurs d'identité"
description="Sources upstream Authentik (OAuth, SAML, LDAP) avec restrictions d'accès."
policySection="identity_providers"
beforeSave={() => setIdentityProviders(draft)}
>
<>
<label className="flex items-center justify-between gap-4 rounded-lg border p-4">
<div>
<p className="text-sm font-medium">Inscription self-service Authentik</p>
@ -323,9 +335,21 @@ export function IdentityProvidersSection() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="oauth">OAuth (Google, GitHub, LinkedIn)</SelectItem>
<SelectItem value="saml">SAML (Azure AD, Okta)</SelectItem>
<SelectItem value="ldap">LDAP / Active Directory</SelectItem>
<SelectItem value="oauth">
<TechBrandSelectLabel brand="oauth">
OAuth (Google, GitHub, LinkedIn)
</TechBrandSelectLabel>
</SelectItem>
<SelectItem value="saml">
<TechBrandSelectLabel brand="saml">
SAML (Azure AD, Okta)
</TechBrandSelectLabel>
</SelectItem>
<SelectItem value="ldap">
<TechBrandSelectLabel brand="ldap">
LDAP / Active Directory
</TechBrandSelectLabel>
</SelectItem>
</SelectContent>
</Select>
</div>
@ -391,14 +415,30 @@ export function IdentityProvidersSection() {
}
>
<SelectTrigger className="mt-1 h-9">
<SelectValue />
<SelectValue>
{editingProvider.oauth?.provider ? (
<TechBrandSelectLabel brand={editingProvider.oauth.provider}>
{OAUTH_PRESET_LABELS[editingProvider.oauth.provider]}
</TechBrandSelectLabel>
) : null}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="google">Google</SelectItem>
<SelectItem value="github">GitHub</SelectItem>
<SelectItem value="linkedin">LinkedIn</SelectItem>
<SelectItem value="microsoft">Microsoft</SelectItem>
<SelectItem value="custom">Autre / custom</SelectItem>
<SelectItem value="google">
<TechBrandSelectLabel brand="google">Google</TechBrandSelectLabel>
</SelectItem>
<SelectItem value="github">
<TechBrandSelectLabel brand="github">GitHub</TechBrandSelectLabel>
</SelectItem>
<SelectItem value="linkedin">
<TechBrandSelectLabel brand="linkedin">LinkedIn</TechBrandSelectLabel>
</SelectItem>
<SelectItem value="microsoft">
<TechBrandSelectLabel brand="microsoft">Microsoft</TechBrandSelectLabel>
</SelectItem>
<SelectItem value="custom">
<TechBrandSelectLabel brand="custom">Autre / custom</TechBrandSelectLabel>
</SelectItem>
</SelectContent>
</Select>
</div>
@ -754,6 +794,6 @@ export function IdentityProvidersSection() {
) : null}
</SheetContent>
</Sheet>
</OrgSettingsSection>
</>
)
}

View File

@ -1,196 +0,0 @@
"use client"
import { useEffect, useState } from "react"
import { Plus, Trash2 } from "lucide-react"
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import type { ApiLLMProvider } from "@/lib/contacts/discovery-types"
import { isOrgLLMProviderKeyConfigured } from "@/lib/api/hooks/use-admin-llm"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
function emptyProvider(): ApiLLMProvider {
return {
id: crypto.randomUUID(),
name: "",
base_url: "https://api.openai.com/v1",
api_key: "",
default_model: "gpt-4o-mini",
}
}
export function LlmSection() {
const llm = useOrgSettingsStore((s) => s.llm)
const setLlm = useOrgSettingsStore((s) => s.setLlm)
const secrets = useOrgSettingsStore((s) => s.meta?.secrets)
const [draft, setDraft] = useState(llm)
useEffect(() => {
setDraft(llm)
}, [llm])
function updateProvider(index: number, patch: Partial<ApiLLMProvider>) {
setDraft((prev) => {
const providers = [...prev.providers]
providers[index] = { ...providers[index], ...patch }
return { ...prev, providers }
})
}
function addProvider() {
const p = emptyProvider()
setDraft((prev) => ({
...prev,
providers: [...prev.providers, p],
default_provider_id: prev.default_provider_id || p.id,
}))
}
function removeProvider(index: number) {
setDraft((prev) => {
const removed = prev.providers[index]
const providers = prev.providers.filter((_, i) => i !== index)
let defaultId = prev.default_provider_id
if (defaultId === removed?.id) defaultId = providers[0]?.id ?? ""
return { ...prev, providers, default_provider_id: defaultId }
})
}
return (
<OrgSettingsSection
title="Fournisseurs LLM"
description="Modèles IA organisationnels pour le tri, l'enrichissement contacts, UltiAI et les automatisations."
policySection="llm"
beforeSave={() => setLlm(draft)}
>
<div className="flex flex-wrap gap-4 rounded-lg border p-4">
<label className="flex flex-1 items-center justify-between gap-4">
<div>
<p className="text-sm font-medium">Imposer les fournisseurs org.</p>
<p className="text-xs text-muted-foreground">
Les utilisateurs ne peuvent pas utiliser d&apos;autres clés API.
</p>
</div>
<Switch
checked={draft.enforce_org_providers}
onCheckedChange={(enforce_org_providers) =>
setDraft((p) => ({ ...p, enforce_org_providers }))
}
/>
</label>
<label className="flex flex-1 items-center justify-between gap-4">
<div>
<p className="text-sm font-medium">Autoriser surcharge utilisateur</p>
</div>
<Switch
checked={draft.allow_user_override}
onCheckedChange={(allow_user_override) =>
setDraft((p) => ({ ...p, allow_user_override }))
}
/>
</label>
</div>
<div className="flex items-center justify-between">
<Label>Fournisseur par défaut</Label>
<Button variant="outline" size="sm" onClick={addProvider}>
<Plus className="mr-2 size-4" />
Ajouter
</Button>
</div>
{draft.providers.length > 0 ? (
<Select
value={draft.default_provider_id}
onValueChange={(default_provider_id) =>
setDraft((p) => ({ ...p, default_provider_id }))
}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="Choisir…" />
</SelectTrigger>
<SelectContent>
{draft.providers.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name || p.base_url || p.id}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<p className="text-sm text-muted-foreground">
Aucun fournisseur configuré. Ajoutez-en un puis enregistrez.
</p>
)}
<div className="grid gap-4 lg:grid-cols-2">
{draft.providers.map((provider, index) => {
const keyConfigured = isOrgLLMProviderKeyConfigured(secrets, provider.id)
return (
<div key={provider.id} className="space-y-3 rounded-lg border p-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{provider.name || provider.base_url || `Fournisseur ${index + 1}`}
</span>
<Button variant="ghost" size="icon" onClick={() => removeProvider(index)}>
<Trash2 className="size-4" />
</Button>
</div>
<div>
<Label className="text-xs">Nom</Label>
<Input
className="mt-1 h-9"
value={provider.name}
onChange={(e) => updateProvider(index, { name: e.target.value })}
placeholder="OpenAI"
/>
</div>
<div>
<Label className="text-xs">URL de base</Label>
<Input
className="mt-1 h-9"
value={provider.base_url}
onChange={(e) => updateProvider(index, { base_url: e.target.value })}
placeholder="https://api.openai.com/v1"
/>
</div>
<div>
<Label className="text-xs">Clé API</Label>
<Input
className="mt-1 h-9"
type="password"
autoComplete="off"
value={provider.api_key ?? ""}
onChange={(e) => updateProvider(index, { api_key: e.target.value })}
placeholder={
keyConfigured ? "•••••••• (laisser vide pour conserver)" : "sk-…"
}
/>
{keyConfigured && !(provider.api_key ?? "").trim() ? (
<p className="mt-1 text-xs text-muted-foreground">Clé API enregistrée</p>
) : null}
</div>
<div>
<Label className="text-xs">Modèle par défaut</Label>
<Input
className="mt-1 h-9"
value={provider.default_model}
onChange={(e) => updateProvider(index, { default_model: e.target.value })}
placeholder="gpt-4o-mini"
/>
</div>
</div>
)
})}
</div>
</OrgSettingsSection>
)
}

View File

@ -1,11 +1,24 @@
"use client"
import { useState } from "react"
import { useCallback, useState } from "react"
import { Check, Copy } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry"
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
import { MigrationProjectsPanel } from "@/components/admin/settings/sections/migration-projects-panel"
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import {
useCreateMailDomain,
useMailDomains,
@ -17,15 +30,27 @@ export function MailDomainsSection() {
const domainsQuery = useMailDomains()
const createDomain = useCreateMailDomain()
const [domainName, setDomainName] = useState("")
const mailing = useOrgSettingsStore((s) => s.mailing)
const setMailing = useOrgSettingsStore((s) => s.setMailing)
const domains = domainsQuery.data?.domains ?? []
return (
<div className="space-y-8">
<OrgSettingsSection
title="Domaines mail hébergés"
description="Stalwart — vérification DNS, DKIM et provisioning des boîtes @domaine."
title="Mail"
description="Domaines hébergés Stalwart, relais SMTP des notifications suite et migration."
policySection="mailing"
>
<AutomationTabMasonry columns={2}>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Domaines hébergés</CardTitle>
<CardDescription>
Vérification DNS, DKIM et provisioning des boîtes @domaine.
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-[1fr_auto]">
<div className="space-y-2">
<Label htmlFor="new-domain">Nouveau domaine</Label>
@ -53,6 +78,107 @@ export function MailDomainsSection() {
<DomainRow key={domain.id} domain={domain} />
))}
</ul>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-4">
<div>
<CardTitle className="text-sm font-medium">Notifications suite (SMTP)</CardTitle>
<CardDescription>
Partages de fichiers, mentions, invitations distinct des comptes mail utilisateur.
</CardDescription>
</div>
<Switch
checked={mailing.enabled}
onCheckedChange={(enabled) => setMailing({ enabled })}
/>
</div>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<div>
<Label>Hôte SMTP</Label>
<Input
className="mt-1 h-9"
value={mailing.smtp_host}
onChange={(e) => setMailing({ smtp_host: e.target.value })}
placeholder="smtp.example.com"
/>
</div>
<div>
<Label>Port</Label>
<Input
className="mt-1 h-9"
type="number"
value={mailing.smtp_port}
onChange={(e) => setMailing({ smtp_port: Number(e.target.value) || 587 })}
/>
</div>
<div>
<Label>Utilisateur</Label>
<Input
className="mt-1 h-9"
value={mailing.smtp_user}
onChange={(e) => setMailing({ smtp_user: e.target.value })}
/>
</div>
<div>
<Label>Mot de passe</Label>
<Input
className="mt-1 h-9"
type="password"
value={mailing.smtp_password}
onChange={(e) => setMailing({ smtp_password: e.target.value })}
/>
</div>
<div>
<Label>Chiffrement</Label>
<Select
value={mailing.tls_mode}
onValueChange={(tls_mode) =>
setMailing({ tls_mode: tls_mode as typeof mailing.tls_mode })
}
>
<SelectTrigger className="mt-1 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="starttls">STARTTLS</SelectItem>
<SelectItem value="ssl">SSL/TLS</SelectItem>
<SelectItem value="none">Aucun</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Adresse d&apos;expédition</Label>
<Input
className="mt-1 h-9"
type="email"
value={mailing.from_email}
onChange={(e) => setMailing({ from_email: e.target.value })}
/>
</div>
<div>
<Label>Nom affiché</Label>
<Input
className="mt-1 h-9"
value={mailing.from_name}
onChange={(e) => setMailing({ from_name: e.target.value })}
/>
</div>
<div className="sm:col-span-2">
<Label>Reply-To (optionnel)</Label>
<Input
className="mt-1 h-9"
type="email"
value={mailing.reply_to ?? ""}
onChange={(e) => setMailing({ reply_to: e.target.value })}
/>
</div>
</CardContent>
</Card>
</AutomationTabMasonry>
</OrgSettingsSection>
<MigrationProjectsPanel domains={domains} />
@ -60,6 +186,30 @@ export function MailDomainsSection() {
)
}
function CopyTxtButton({
label,
copied,
onCopy,
}: {
label: string
copied: boolean
onCopy: () => void
}) {
return (
<Button
type="button"
size="icon"
variant="ghost"
className="h-6 w-6 shrink-0"
aria-label={label}
title={label}
onClick={onCopy}
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5 opacity-60" />}
</Button>
)
}
function DomainRow({
domain,
}: {
@ -73,6 +223,19 @@ function DomainRow({
}) {
const verifyTxt = useVerifyMailDomainTXT(domain.id)
const verifyMx = useVerifyMailDomainMX(domain.id)
const [copiedField, setCopiedField] = useState<"name" | "value" | null>(null)
const txtName = `_ultisuite-verify.${domain.name}`
const copyTxtField = useCallback(async (text: string, field: "name" | "value", successLabel: string) => {
try {
await navigator.clipboard.writeText(text)
setCopiedField(field)
toast.success(successLabel)
window.setTimeout(() => setCopiedField(null), 2000)
} catch {
toast.error("Impossible de copier")
}
}, [])
return (
<li className="rounded-lg border p-4">
@ -84,9 +247,26 @@ function DomainRow({
</p>
<p className="text-sm text-muted-foreground">Statut : {domain.status}</p>
{domain.verification_token && (
<p className="mt-1 text-xs text-muted-foreground">
TXT : <code>_ultisuite-verify.{domain.name}</code> = {domain.verification_token}
</p>
<div className="mt-2 space-y-1 text-xs text-muted-foreground">
<p>Enregistrement TXT :</p>
<div className="flex flex-wrap items-center gap-1">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono">{txtName}</code>
<CopyTxtButton
label="Copier le nom TXT"
copied={copiedField === "name"}
onCopy={() => void copyTxtField(txtName, "name", "Nom TXT copié")}
/>
<span className="px-0.5">=</span>
<code className="rounded bg-muted px-1.5 py-0.5 font-mono">{domain.verification_token}</code>
<CopyTxtButton
label="Copier la valeur TXT"
copied={copiedField === "value"}
onCopy={() =>
void copyTxtField(domain.verification_token!, "value", "Valeur TXT copiée")
}
/>
</div>
</div>
)}
</div>
<div className="flex gap-2">

View File

@ -1,126 +0,0 @@
"use client"
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
export function MailingSection() {
const mailing = useOrgSettingsStore((s) => s.mailing)
const setMailing = useOrgSettingsStore((s) => s.setMailing)
return (
<OrgSettingsSection
title="Mailing unifié"
description="SMTP pour les notifications suite : partages de fichiers, mentions, invitations."
policySection="mailing"
>
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-4">
<div>
<CardTitle className="text-sm font-medium">Relais SMTP</CardTitle>
<CardDescription>
Ex. « Marie vous a partagé un fichier » distinct des comptes mail utilisateur.
</CardDescription>
</div>
<Switch
checked={mailing.enabled}
onCheckedChange={(enabled) => setMailing({ enabled })}
/>
</div>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<div>
<Label>Hôte SMTP</Label>
<Input
className="mt-1 h-9"
value={mailing.smtp_host}
onChange={(e) => setMailing({ smtp_host: e.target.value })}
placeholder="smtp.example.com"
/>
</div>
<div>
<Label>Port</Label>
<Input
className="mt-1 h-9"
type="number"
value={mailing.smtp_port}
onChange={(e) => setMailing({ smtp_port: Number(e.target.value) || 587 })}
/>
</div>
<div>
<Label>Utilisateur</Label>
<Input
className="mt-1 h-9"
value={mailing.smtp_user}
onChange={(e) => setMailing({ smtp_user: e.target.value })}
/>
</div>
<div>
<Label>Mot de passe</Label>
<Input
className="mt-1 h-9"
type="password"
value={mailing.smtp_password}
onChange={(e) => setMailing({ smtp_password: e.target.value })}
/>
</div>
<div>
<Label>Chiffrement</Label>
<Select
value={mailing.tls_mode}
onValueChange={(tls_mode) =>
setMailing({ tls_mode: tls_mode as typeof mailing.tls_mode })
}
>
<SelectTrigger className="mt-1 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="starttls">STARTTLS</SelectItem>
<SelectItem value="ssl">SSL/TLS</SelectItem>
<SelectItem value="none">Aucun</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Adresse d&apos;expédition</Label>
<Input
className="mt-1 h-9"
type="email"
value={mailing.from_email}
onChange={(e) => setMailing({ from_email: e.target.value })}
/>
</div>
<div>
<Label>Nom affiché</Label>
<Input
className="mt-1 h-9"
value={mailing.from_name}
onChange={(e) => setMailing({ from_name: e.target.value })}
/>
</div>
<div className="sm:col-span-2">
<Label>Reply-To (optionnel)</Label>
<Input
className="mt-1 h-9"
type="email"
value={mailing.reply_to ?? ""}
onChange={(e) => setMailing({ reply_to: e.target.value })}
/>
</div>
</CardContent>
</Card>
</OrgSettingsSection>
)
}

View File

@ -15,6 +15,7 @@ import {
SelectValue,
} from "@/components/ui/select"
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
import { TechBrandSelectLabel } from "@/components/admin/settings/tech-brand-select-label"
import {
type DNSCheckReport,
type MailDomain,
@ -209,11 +210,19 @@ export function MigrationProjectsPanel({ domains }: { domains: MailDomain[] }) {
}}
>
<SelectTrigger>
<SelectValue />
<SelectValue>
<TechBrandSelectLabel brand={sourceProvider}>
{sourceProvider === "google" ? "Google Workspace" : "Microsoft 365"}
</TechBrandSelectLabel>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="google">Google Workspace</SelectItem>
<SelectItem value="microsoft">Microsoft 365</SelectItem>
<SelectItem value="google">
<TechBrandSelectLabel brand="google">Google Workspace</TechBrandSelectLabel>
</SelectItem>
<SelectItem value="microsoft">
<TechBrandSelectLabel brand="microsoft">Microsoft 365</TechBrandSelectLabel>
</SelectItem>
</SelectContent>
</Select>
</div>
@ -221,15 +230,25 @@ export function MigrationProjectsPanel({ domains }: { domains: MailDomain[] }) {
<Label>Mode d&apos;authentification</Label>
<Select value={authMode} onValueChange={setAuthMode}>
<SelectTrigger>
<SelectValue />
<SelectValue>
<TechBrandSelectLabel brand={authMode === "oauth" ? "oauth" : authMode}>
{AUTH_MODE_LABELS[authMode] ?? authMode}
</TechBrandSelectLabel>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="oauth">OAuth utilisateur</SelectItem>
<SelectItem value="oauth">
<TechBrandSelectLabel brand="oauth">OAuth utilisateur</TechBrandSelectLabel>
</SelectItem>
<SelectItem value="google_dwd" disabled={sourceProvider !== "google"}>
<TechBrandSelectLabel brand="google">
Google DWD (service account)
</TechBrandSelectLabel>
</SelectItem>
<SelectItem value="microsoft_app" disabled={sourceProvider !== "microsoft"}>
<TechBrandSelectLabel brand="microsoft">
Microsoft app-only (client credentials)
</TechBrandSelectLabel>
</SelectItem>
</SelectContent>
</Select>

View File

@ -1,127 +0,0 @@
"use client"
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
import { DeployLockedHint, useDeployFieldLocked } from "@/components/admin/settings/deploy-locked-hint"
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
export function NextcloudSection() {
const nextcloud = useOrgSettingsStore((s) => s.nextcloud)
const setNextcloud = useOrgSettingsStore((s) => s.setNextcloud)
const effective = useOrgSettingsStore((s) => s.meta?.effective.nextcloud)
const enabledLocked = useDeployFieldLocked("nextcloud", "enabled")
const urlLocked = useDeployFieldLocked("nextcloud", "base_url")
const userLocked = useDeployFieldLocked("nextcloud", "admin_user")
const passLocked = useDeployFieldLocked("nextcloud", "admin_password")
const enabled = enabledLocked ? (effective?.enabled ?? nextcloud.enabled) : nextcloud.enabled
const baseURL = urlLocked ? (effective?.base_url ?? nextcloud.base_url) : nextcloud.base_url
const adminUser = userLocked ? (effective?.admin_user ?? nextcloud.admin_user) : nextcloud.admin_user
return (
<OrgSettingsSection
title="Nextcloud"
description="Connexion au serveur Nextcloud pour drive, agenda, contacts et Talk."
policySection="nextcloud"
>
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-4">
<div>
<CardTitle className="text-sm font-medium">Intégration Nextcloud</CardTitle>
<CardDescription>Variables NEXTCLOUD_* côté serveur.</CardDescription>
{enabledLocked ? <DeployLockedHint section="nextcloud" field="enabled" /> : null}
</div>
<Switch
checked={enabled}
disabled={enabledLocked}
onCheckedChange={(v) => setNextcloud({ enabled: v })}
/>
</div>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<Label>URL de base</Label>
<Input
className="mt-1 h-9"
value={baseURL}
disabled={urlLocked}
onChange={(e) => setNextcloud({ base_url: e.target.value })}
placeholder="https://cloud.example.com"
/>
</div>
<div>
<Label>Utilisateur admin</Label>
<Input
className="mt-1 h-9"
value={adminUser}
disabled={userLocked}
onChange={(e) => setNextcloud({ admin_user: e.target.value })}
/>
</div>
<div>
<Label>Mot de passe admin</Label>
<Input
className="mt-1 h-9"
type="password"
value={nextcloud.admin_password}
disabled={passLocked}
onChange={(e) => setNextcloud({ admin_password: e.target.value })}
placeholder={passLocked ? "Défini via NC_ADMIN_PASSWORD" : undefined}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Services activés</CardTitle>
<CardDescription>Modules suite exposés aux utilisateurs.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<ServiceToggle
label="UltiDrive (fichiers)"
checked={nextcloud.drive_enabled}
onChange={(drive_enabled) => setNextcloud({ drive_enabled })}
/>
<ServiceToggle
label="Agenda"
checked={nextcloud.calendar_enabled}
onChange={(calendar_enabled) => setNextcloud({ calendar_enabled })}
/>
<ServiceToggle
label="Contacts"
checked={nextcloud.contacts_enabled}
onChange={(contacts_enabled) => setNextcloud({ contacts_enabled })}
/>
<ServiceToggle
label="Talk (visio)"
checked={nextcloud.talk_enabled}
onChange={(talk_enabled) => setNextcloud({ talk_enabled })}
/>
</CardContent>
</Card>
</OrgSettingsSection>
)
}
function ServiceToggle({
label,
checked,
onChange,
}: {
label: string
checked: boolean
onChange: (v: boolean) => void
}) {
return (
<label className="flex items-center justify-between gap-4 rounded-lg border p-3">
<span className="text-sm font-medium">{label}</span>
<Switch checked={checked} onCheckedChange={onChange} />
</label>
)
}

View File

@ -1,82 +0,0 @@
"use client"
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
import { DeployLockedHint, useDeployFieldLocked } from "@/components/admin/settings/deploy-locked-hint"
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
export function OnlyofficeSection() {
const onlyoffice = useOrgSettingsStore((s) => s.onlyoffice)
const setOnlyoffice = useOrgSettingsStore((s) => s.setOnlyoffice)
const effective = useOrgSettingsStore((s) => s.meta?.effective.onlyoffice)
const enabledLocked = useDeployFieldLocked("onlyoffice", "enabled")
const urlLocked = useDeployFieldLocked("onlyoffice", "document_server_url")
const jwtLocked = useDeployFieldLocked("onlyoffice", "jwt_secret")
const headerLocked = useDeployFieldLocked("onlyoffice", "jwt_header")
const enabled = enabledLocked ? (effective?.enabled ?? onlyoffice.enabled) : onlyoffice.enabled
const docURL = urlLocked
? (effective?.document_server_url ?? onlyoffice.document_server_url)
: onlyoffice.document_server_url
return (
<OrgSettingsSection
title="OnlyOffice"
description="Édition collaborative de documents Office dans le navigateur."
policySection="onlyoffice"
>
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-4">
<div>
<CardTitle className="text-sm font-medium">Document Server</CardTitle>
<CardDescription>Variables ONLYOFFICE_* côté serveur.</CardDescription>
{enabledLocked ? <DeployLockedHint section="onlyoffice" field="enabled" /> : null}
</div>
<Switch
checked={enabled}
disabled={enabledLocked}
onCheckedChange={(v) => setOnlyoffice({ enabled: v })}
/>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label>URL du serveur de documents</Label>
<Input
className="mt-1 h-9"
value={docURL}
disabled={urlLocked}
onChange={(e) => setOnlyoffice({ document_server_url: e.target.value })}
placeholder="https://office.example.com"
/>
</div>
<div>
<Label>Secret JWT</Label>
<Input
className="mt-1 h-9"
type="password"
value={onlyoffice.jwt_secret}
disabled={jwtLocked}
onChange={(e) => setOnlyoffice({ jwt_secret: e.target.value })}
placeholder={jwtLocked ? "Défini via ONLYOFFICE_JWT_SECRET" : undefined}
/>
</div>
<div>
<Label>En-tête JWT</Label>
<Input
className="mt-1 h-9"
value={onlyoffice.jwt_header}
disabled={headerLocked}
onChange={(e) => setOnlyoffice({ jwt_header: e.target.value })}
/>
</div>
</CardContent>
</Card>
</OrgSettingsSection>
)
}

View File

@ -1,55 +1,478 @@
"use client"
import Link from "next/link"
import { ChevronDown, Settings2 } from "lucide-react"
import { useState } from "react"
import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry"
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
import { DeployLockedHint } from "@/components/admin/settings/deploy-locked-hint"
import { FieldGroup } from "@/components/admin/settings/field-group"
import { DeployLockedHint, useDeployFieldLocked } from "@/components/admin/settings/deploy-locked-hint"
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import { isPluginDeployLocked } from "@/lib/admin/deploy-runtime"
import { Switch } from "@/components/ui/switch"
import { Card, CardContent } from "@/components/ui/card"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { TechBrandSelectLabel } from "@/components/admin/settings/tech-brand-select-label"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { cn } from "@/lib/utils"
const SIMPLE_PLUGIN_IDS = new Set(["mail-automation", "contact-discovery", "public-share"])
const CONFIG_PLUGIN_IDS = new Set(["office-editor", "richtext-editor", "ai-assistant"])
export function PluginsSection() {
const plugins = useOrgSettingsStore((s) => s.plugins)
const togglePlugin = useOrgSettingsStore((s) => s.togglePlugin)
const deployLocked = useOrgSettingsStore((s) => s.meta?.deployLocked)
const simplePlugins = plugins.filter((p) => SIMPLE_PLUGIN_IDS.has(p.id))
const configPlugins = plugins.filter((p) => CONFIG_PLUGIN_IDS.has(p.id))
return (
<OrgSettingsSection
title="Plugins"
description="Modules fonctionnels activables pour toute l'organisation."
policySection="plugins"
description="Modules fonctionnels et intégrations activables pour toute l'organisation."
policySection={["plugins", "nextcloud", "onlyoffice", "richtext"]}
>
<div className="space-y-3">
{plugins.map((plugin) => {
<AutomationTabMasonry columns={2}>
<NextcloudPluginCard />
{simplePlugins.map((plugin) => {
const locked = isPluginDeployLocked(deployLocked, plugin.id)
return (
<Card key={plugin.id}>
<CardContent className="flex items-center gap-4 py-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="font-medium">{plugin.name}</p>
<Badge variant="outline">v{plugin.version}</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">{plugin.description}</p>
{plugin.id === "ai-assistant" && !plugin.enabled ? (
<p className="mt-1 text-xs text-muted-foreground">
N&apos;oubliez pas d&apos;enregistrer après activation. OpenWebUI doit être
déployé (<code className="rounded bg-muted px-1">AI_ASSISTANT_ENABLED=true</code>
).
</p>
) : null}
{locked ? <DeployLockedHint section="plugins" field={plugin.id} /> : null}
</div>
<Switch
checked={plugin.enabled}
disabled={locked}
onCheckedChange={(enabled) => togglePlugin(plugin.id, enabled)}
<PluginToggleCard
key={plugin.id}
name={plugin.name}
description={plugin.description}
version={plugin.version}
enabled={plugin.enabled}
locked={locked}
lockSection="plugins"
lockField={plugin.id}
onToggle={(enabled) => togglePlugin(plugin.id, enabled)}
/>
</CardContent>
</Card>
)
})}
</div>
{configPlugins.map((plugin) => {
const locked = isPluginDeployLocked(deployLocked, plugin.id)
if (plugin.id === "office-editor") {
return (
<OnlyOfficePluginCard
key={plugin.id}
plugin={plugin}
locked={locked}
onToggle={(enabled) => togglePlugin(plugin.id, enabled)}
/>
)
}
if (plugin.id === "richtext-editor") {
return (
<RichtextPluginCard
key={plugin.id}
plugin={plugin}
locked={locked}
onToggle={(enabled) => togglePlugin(plugin.id, enabled)}
/>
)
}
return (
<AiAssistantPluginCard
key={plugin.id}
plugin={plugin}
locked={locked}
onToggle={(enabled) => togglePlugin(plugin.id, enabled)}
/>
)
})}
</AutomationTabMasonry>
</OrgSettingsSection>
)
}
function PluginToggleCard({
name,
description,
version,
enabled,
locked,
lockSection,
lockField,
onToggle,
hint,
action,
children,
defaultOpen = false,
}: {
name: string
description: string
version?: string
enabled: boolean
locked?: boolean
lockSection?: string
lockField?: string
onToggle: (enabled: boolean) => void
hint?: React.ReactNode
action?: React.ReactNode
children?: React.ReactNode
defaultOpen?: boolean
}) {
const [open, setOpen] = useState(defaultOpen)
const hasConfig = Boolean(children)
return (
<Card className="gap-0 py-0">
<CardContent className="py-4">
<div className="flex items-start gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="font-medium">{name}</p>
{version ? <Badge variant="outline">v{version}</Badge> : null}
</div>
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
{hint}
{locked && lockSection && lockField ? (
<DeployLockedHint section={lockSection} field={lockField} />
) : null}
</div>
<Switch checked={enabled} disabled={locked} onCheckedChange={onToggle} />
</div>
{action ? <div className="mt-3">{action}</div> : null}
{hasConfig && !action ? (
<Collapsible open={open} onOpenChange={setOpen} className="mt-3">
<CollapsibleTrigger asChild>
<Button type="button" variant="ghost" size="sm" className="gap-1.5 px-2">
<Settings2 className="size-3.5" aria-hidden />
Configuration
<ChevronDown
className={cn("size-3.5 transition-transform", open && "rotate-180")}
aria-hidden
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-3 space-y-4 border-t pt-4">
{children}
</CollapsibleContent>
</Collapsible>
) : null}
</CardContent>
</Card>
)
}
function NextcloudPluginCard() {
const nextcloud = useOrgSettingsStore((s) => s.nextcloud)
const setNextcloud = useOrgSettingsStore((s) => s.setNextcloud)
const effective = useOrgSettingsStore((s) => s.meta?.effective.nextcloud)
const enabledLocked = useDeployFieldLocked("nextcloud", "enabled")
const urlLocked = useDeployFieldLocked("nextcloud", "base_url")
const userLocked = useDeployFieldLocked("nextcloud", "admin_user")
const passLocked = useDeployFieldLocked("nextcloud", "admin_password")
const enabled = enabledLocked ? (effective?.enabled ?? nextcloud.enabled) : nextcloud.enabled
const baseURL = urlLocked ? (effective?.base_url ?? nextcloud.base_url) : nextcloud.base_url
const adminUser = userLocked ? (effective?.admin_user ?? nextcloud.admin_user) : nextcloud.admin_user
return (
<PluginToggleCard
name="Nextcloud Suite"
description="Plateforme drive, agenda, contacts et Talk. Requis pour UltiDrive et les modules associés."
enabled={enabled}
locked={enabledLocked}
lockSection="nextcloud"
lockField="enabled"
onToggle={(v) => setNextcloud({ enabled: v })}
defaultOpen={enabled}
>
<div className="grid gap-4 sm:grid-cols-2">
<FieldGroup className="sm:col-span-2">
<Label>URL de base</Label>
<Input
className="h-9"
value={baseURL}
disabled={urlLocked}
onChange={(e) => setNextcloud({ base_url: e.target.value })}
placeholder="https://cloud.example.com"
/>
{urlLocked ? <DeployLockedHint section="nextcloud" field="base_url" /> : null}
</FieldGroup>
<FieldGroup>
<Label>Utilisateur admin</Label>
<Input
className="h-9"
value={adminUser}
disabled={userLocked}
onChange={(e) => setNextcloud({ admin_user: e.target.value })}
/>
{userLocked ? <DeployLockedHint section="nextcloud" field="admin_user" /> : null}
</FieldGroup>
<FieldGroup>
<Label>Mot de passe admin</Label>
<Input
className="h-9"
type="password"
value={nextcloud.admin_password}
disabled={passLocked}
onChange={(e) => setNextcloud({ admin_password: e.target.value })}
placeholder={passLocked ? "Défini via NC_ADMIN_PASSWORD" : undefined}
/>
{passLocked ? <DeployLockedHint section="nextcloud" field="admin_password" /> : null}
</FieldGroup>
</div>
<div className="space-y-3">
<FieldGroup>
<p className="text-sm font-medium">Modules exposés</p>
<p className="text-xs text-muted-foreground">
Active ou masque chaque application Nextcloud dans la suite.
</p>
</FieldGroup>
<div className="space-y-2">
<ServiceToggle
label="UltiDrive (fichiers)"
checked={nextcloud.drive_enabled}
onChange={(drive_enabled) => setNextcloud({ drive_enabled })}
/>
<ServiceToggle
label="Agenda"
checked={nextcloud.calendar_enabled}
onChange={(calendar_enabled) => setNextcloud({ calendar_enabled })}
/>
<ServiceToggle
label="Contacts"
checked={nextcloud.contacts_enabled}
onChange={(contacts_enabled) => setNextcloud({ contacts_enabled })}
/>
<ServiceToggle
label="Talk (visio)"
checked={nextcloud.talk_enabled}
onChange={(talk_enabled) => setNextcloud({ talk_enabled })}
/>
</div>
</div>
</PluginToggleCard>
)
}
function OnlyOfficePluginCard({
plugin,
locked,
onToggle,
}: {
plugin: { name: string; description: string; version: string; enabled: boolean }
locked: boolean
onToggle: (enabled: boolean) => void
}) {
const onlyoffice = useOrgSettingsStore((s) => s.onlyoffice)
const setOnlyoffice = useOrgSettingsStore((s) => s.setOnlyoffice)
const effective = useOrgSettingsStore((s) => s.meta?.effective.onlyoffice)
const enabledLocked = useDeployFieldLocked("onlyoffice", "enabled")
const urlLocked = useDeployFieldLocked("onlyoffice", "document_server_url")
const jwtLocked = useDeployFieldLocked("onlyoffice", "jwt_secret")
const headerLocked = useDeployFieldLocked("onlyoffice", "jwt_header")
const enabled = enabledLocked
? (effective?.enabled ?? plugin.enabled)
: plugin.enabled
const docURL = urlLocked
? (effective?.document_server_url ?? onlyoffice.document_server_url)
: onlyoffice.document_server_url
return (
<PluginToggleCard
name={plugin.name}
description={plugin.description}
version={plugin.version}
enabled={enabled}
locked={locked || enabledLocked}
lockSection="plugins"
lockField="office-editor"
onToggle={onToggle}
defaultOpen={enabled}
>
<div className="space-y-4">
{enabledLocked ? <DeployLockedHint section="onlyoffice" field="enabled" /> : null}
<FieldGroup>
<Label>URL du serveur de documents</Label>
<Input
className="h-9"
value={docURL}
disabled={urlLocked}
onChange={(e) => setOnlyoffice({ document_server_url: e.target.value })}
placeholder="https://office.example.com"
/>
{urlLocked ? <DeployLockedHint section="onlyoffice" field="document_server_url" /> : null}
</FieldGroup>
<FieldGroup>
<Label>Secret JWT</Label>
<Input
className="h-9"
type="password"
value={onlyoffice.jwt_secret}
disabled={jwtLocked}
onChange={(e) => setOnlyoffice({ jwt_secret: e.target.value })}
placeholder={jwtLocked ? "Défini via ONLYOFFICE_JWT_SECRET" : undefined}
/>
{jwtLocked ? <DeployLockedHint section="onlyoffice" field="jwt_secret" /> : null}
</FieldGroup>
<FieldGroup>
<Label>En-tête JWT</Label>
<Input
className="h-9"
value={onlyoffice.jwt_header}
disabled={headerLocked}
onChange={(e) => setOnlyoffice({ jwt_header: e.target.value })}
/>
{headerLocked ? <DeployLockedHint section="onlyoffice" field="jwt_header" /> : null}
</FieldGroup>
</div>
</PluginToggleCard>
)
}
function RichtextPluginCard({
plugin,
locked,
onToggle,
}: {
plugin: { name: string; description: string; version: string; enabled: boolean }
locked: boolean
onToggle: (enabled: boolean) => void
}) {
const richtext = useOrgSettingsStore((s) => s.richtext)
const setRichtext = useOrgSettingsStore((s) => s.setRichtext)
return (
<PluginToggleCard
name={plugin.name}
description={`${plugin.description} OnlyOffice reste actif pour tableurs et présentations.`}
version={plugin.version}
enabled={plugin.enabled}
locked={locked}
lockSection="plugins"
lockField="richtext-editor"
onToggle={onToggle}
defaultOpen={plugin.enabled}
>
<div className="grid min-w-0 gap-4">
<FieldGroup className="min-w-0">
<Label>Mode de stockage</Label>
<Select
value={richtext.storage_mode}
onValueChange={(storage_mode: "sidecar" | "overwrite") =>
setRichtext({ storage_mode })
}
>
<SelectTrigger className="w-full min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sidecar">
Sidecar (.ultidoc.json à côté de l&apos;original)
</SelectItem>
<SelectItem value="overwrite">Remplacer par .ultidoc.json</SelectItem>
</SelectContent>
</Select>
</FieldGroup>
<FieldGroup className="min-w-0">
<Label>Export miroir (optionnel)</Label>
<Select
value={richtext.export_mirror_format || "none"}
onValueChange={(v) =>
setRichtext({ export_mirror_format: v === "none" ? "" : "docx" })
}
>
<SelectTrigger className="w-full min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Aucun</SelectItem>
<SelectItem value="docx">
<TechBrandSelectLabel brand="docx">DOCX (Microsoft Word)</TechBrandSelectLabel>
</SelectItem>
</SelectContent>
</Select>
</FieldGroup>
<FieldGroup className="min-w-0">
<Label>URL WebSocket Hocuspocus (public)</Label>
<Input
value={richtext.hocuspocus_url}
onChange={(e) => setRichtext({ hocuspocus_url: e.target.value })}
placeholder="ws://localhost:1234"
/>
</FieldGroup>
</div>
</PluginToggleCard>
)
}
function AiAssistantPluginCard({
plugin,
locked,
onToggle,
}: {
plugin: { name: string; description: string; version: string; enabled: boolean }
locked: boolean
onToggle: (enabled: boolean) => void
}) {
return (
<PluginToggleCard
name={plugin.name}
description={plugin.description}
version={plugin.version}
enabled={plugin.enabled}
locked={locked}
lockSection="plugins"
lockField="ai-assistant"
onToggle={onToggle}
hint={
!plugin.enabled ? (
<p className="mt-1 text-xs text-muted-foreground">
OpenWebUI doit être déployé (
<code className="rounded bg-muted px-1">AI_ASSISTANT_ENABLED=true</code>
).
</p>
) : null
}
action={
<Button type="button" variant="outline" size="sm" asChild>
<Link href="/admin/settings/ai-assistant">Configurer UltiAI </Link>
</Button>
}
/>
)
}
function ServiceToggle({
label,
checked,
onChange,
}: {
label: string
checked: boolean
onChange: (v: boolean) => void
}) {
return (
<label className="flex items-center justify-between gap-4 rounded-lg border p-3">
<span className="text-sm font-medium">{label}</span>
<Switch checked={checked} onCheckedChange={onChange} />
</label>
)
}

View File

@ -4,6 +4,7 @@ import { useMemo, useState } from "react"
import { ExternalLink, Link2, Trash2 } from "lucide-react"
import { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header"
import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner"
import { AdminListControls } from "@/components/admin/settings/admin-list-controls"
import { useAdminPublicShares } from "@/lib/api/hooks/use-admin-queries"
import { useRevokeAdminPublicShare } from "@/lib/api/hooks/use-admin-mutations"
import type { AdminPublicShare } from "@/lib/api/admin-types"
@ -26,17 +27,33 @@ const ACCESS_MODE_LABELS: Record<string, string> = {
internal: "Interne",
}
const PUBLIC_SHARE_SORT_OPTIONS = [
{ value: "-created_at", label: "Créé (récent)" },
{ value: "created_at", label: "Créé (ancien)" },
{ value: "-last_access_at", label: "Dernier accès (récent)" },
{ value: "last_access_at", label: "Dernier accès (ancien)" },
{ value: "-access_count", label: "Accès (plus)" },
{ value: "access_count", label: "Accès (moins)" },
{ value: "path", label: "Chemin (A→Z)" },
{ value: "-path", label: "Chemin (Z→A)" },
{ value: "owner_email", label: "Propriétaire (A→Z)" },
{ value: "-owner_email", label: "Propriétaire (Z→A)" },
] as const
export function PublicSharesSection() {
const [q, setQ] = useState("")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(25)
const [sort, setSort] = useState("-created_at")
const queryParams = useMemo(
() => ({
page,
page_size: 25,
page_size: pageSize,
sort,
q: q.trim() || undefined,
}),
[page, q]
[page, pageSize, sort, q]
)
const { data, isFetching, isError, refetch } = useAdminPublicShares(queryParams)
@ -44,8 +61,8 @@ export function PublicSharesSection() {
const shares = data?.shares ?? []
const total = data?.pagination.total ?? 0
const pageSize = data?.pagination.page_size ?? 25
const totalPages = Math.max(1, Math.ceil(total / pageSize))
const resolvedPageSize = data?.pagination.page_size ?? pageSize
const totalPages = Math.max(1, Math.ceil(total / resolvedPageSize))
return (
<>
@ -55,7 +72,8 @@ export function PublicSharesSection() {
/>
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
<div className="mb-4 max-w-md">
<div className="mb-4 flex flex-wrap items-end gap-3">
<div className="min-w-[240px] flex-1">
<Label className="text-xs">Recherche</Label>
<Input
className="mt-1 h-9"
@ -67,6 +85,26 @@ export function PublicSharesSection() {
placeholder="Propriétaire, chemin, token, destinataire…"
/>
</div>
</div>
<AdminListControls
page={page}
pageSize={resolvedPageSize}
total={total}
totalPages={totalPages}
sort={sort}
sortOptions={[...PUBLIC_SHARE_SORT_OPTIONS]}
onPageChange={setPage}
onPageSizeChange={(next) => {
setPageSize(next)
setPage(1)
}}
onSortChange={(next) => {
setSort(next)
setPage(1)
}}
itemLabel="partage(s)"
/>
<div className="overflow-x-auto rounded-lg border">
<Table>
@ -106,32 +144,6 @@ export function PublicSharesSection() {
</TableBody>
</Table>
</div>
{totalPages > 1 ? (
<div className="mt-4 flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{total.toLocaleString("fr-FR")} partage(s)
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
Précédent
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
Suivant
</Button>
</div>
</div>
) : null}
</>
)
}

View File

@ -0,0 +1,167 @@
"use client"
import Link from "next/link"
import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry"
import { AdminSettingsCard } from "@/components/admin/settings/admin-settings-card"
import { FieldGroup } from "@/components/admin/settings/field-group"
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
export function QuotasSection() {
const storageQuotas = useOrgSettingsStore((s) => s.storageQuotas)
const setStorageQuotas = useOrgSettingsStore((s) => s.setStorageQuotas)
const usageQuotas = useOrgSettingsStore((s) => s.usageQuotas)
const setUsageQuotas = useOrgSettingsStore((s) => s.setUsageQuotas)
return (
<OrgSettingsSection
title="Quotas"
description="Limites de stockage et d'usage appliquées par défaut aux comptes de l'organisation."
policySection={["storage_quotas", "usage_quotas"]}
>
<AutomationTabMasonry columns={2}>
<AdminSettingsCard
title="Stockage par défaut"
description="Mail, drive et photos. Les quotas individuels se gèrent depuis la fiche utilisateur."
>
<div className="grid gap-4 sm:grid-cols-3">
<QuotaInput
label="Mail (Go)"
value={storageQuotas.default_mail_gib}
onChange={(v) => setStorageQuotas({ default_mail_gib: v })}
/>
<QuotaInput
label="Drive (Go)"
value={storageQuotas.default_drive_gib}
onChange={(v) => setStorageQuotas({ default_drive_gib: v })}
/>
<QuotaInput
label="Photos (Go)"
value={storageQuotas.default_photos_gib}
onChange={(v) => setStorageQuotas({ default_photos_gib: v })}
/>
</div>
<FieldGroup>
<Label>Seuil d&apos;alerte (%)</Label>
<Input
className="h-9 max-w-xs"
type="number"
min={50}
max={100}
value={storageQuotas.warn_threshold_pct}
onChange={(e) =>
setStorageQuotas({ warn_threshold_pct: Number(e.target.value) || 90 })
}
/>
</FieldGroup>
<Button asChild variant="outline" size="sm">
<Link href="/admin/settings/users">Gérer les quotas par utilisateur</Link>
</Button>
</AdminSettingsCard>
<AdminSettingsCard
title="Intelligence artificielle"
description="Requêtes LLM et tokens consommés par mois."
>
<div className="grid gap-4 sm:grid-cols-2">
<FieldGroup>
<Label>Requêtes LLM / jour</Label>
<Input
className="h-9"
type="number"
min={0}
value={usageQuotas.llm_requests_per_day}
onChange={(e) =>
setUsageQuotas({ llm_requests_per_day: Number(e.target.value) || 0 })
}
/>
</FieldGroup>
<FieldGroup>
<Label>Tokens LLM / mois</Label>
<Input
className="h-9"
type="number"
min={0}
value={usageQuotas.llm_tokens_per_month}
onChange={(e) =>
setUsageQuotas({ llm_tokens_per_month: Number(e.target.value) || 0 })
}
/>
</FieldGroup>
</div>
</AdminSettingsCard>
<AdminSettingsCard
title="Recherche et automatisations"
description="Recherche web, tokens API et webhooks par utilisateur."
>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-3">
<FieldGroup>
<Label>Recherches web / jour</Label>
<Input
className="h-9"
type="number"
min={0}
value={usageQuotas.search_requests_per_day}
onChange={(e) =>
setUsageQuotas({ search_requests_per_day: Number(e.target.value) || 0 })
}
/>
</FieldGroup>
<FieldGroup>
<Label>Tokens API max / utilisateur</Label>
<Input
className="h-9"
type="number"
min={0}
value={usageQuotas.max_api_tokens_per_user}
onChange={(e) =>
setUsageQuotas({ max_api_tokens_per_user: Number(e.target.value) || 0 })
}
/>
</FieldGroup>
<FieldGroup>
<Label>Webhooks max / utilisateur</Label>
<Input
className="h-9"
type="number"
min={0}
value={usageQuotas.max_webhooks_per_user}
onChange={(e) =>
setUsageQuotas({ max_webhooks_per_user: Number(e.target.value) || 0 })
}
/>
</FieldGroup>
</div>
</AdminSettingsCard>
</AutomationTabMasonry>
</OrgSettingsSection>
)
}
function QuotaInput({
label,
value,
onChange,
}: {
label: string
value: number
onChange: (v: number) => void
}) {
return (
<FieldGroup>
<Label>{label}</Label>
<Input
className="h-9"
type="number"
min={0}
step={0.5}
value={value}
onChange={(e) => onChange(Number(e.target.value) || 0)}
/>
</FieldGroup>
)
}

View File

@ -1,89 +0,0 @@
"use client"
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Input } from "@/components/ui/input"
export function RichtextSection() {
const richtext = useOrgSettingsStore((s) => s.richtext)
const setRichtext = useOrgSettingsStore((s) => s.setRichtext)
return (
<OrgSettingsSection
title="Éditeur rich text"
description="TipTap pour les documents texte (docx, odt, md…). OnlyOffice reste actif pour tableurs et présentations."
policySection="richtext"
>
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-4">
<div>
<CardTitle className="text-sm font-medium">TipTap + Hocuspocus</CardTitle>
<CardDescription>
Formats word via l&apos;éditeur rich text ; sauvegarde en .ultidoc.json dans Nextcloud.
</CardDescription>
</div>
<Switch
checked={richtext.enabled}
onCheckedChange={(enabled) => setRichtext({ enabled })}
/>
</div>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Mode de stockage</Label>
<Select
value={richtext.storage_mode}
onValueChange={(storage_mode: "sidecar" | "overwrite") =>
setRichtext({ storage_mode })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sidecar">Sidecar (.ultidoc.json à côté de l&apos;original)</SelectItem>
<SelectItem value="overwrite">Remplacer par .ultidoc.json</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Export miroir (optionnel)</Label>
<Select
value={richtext.export_mirror_format || "none"}
onValueChange={(v) =>
setRichtext({ export_mirror_format: v === "none" ? "" : "docx" })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Aucun</SelectItem>
<SelectItem value="docx">DOCX</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2 sm:col-span-2">
<Label>URL WebSocket Hocuspocus (public)</Label>
<Input
value={richtext.hocuspocus_url}
onChange={(e) => setRichtext({ hocuspocus_url: e.target.value })}
placeholder="ws://localhost:1234"
/>
</div>
</CardContent>
</Card>
</OrgSettingsSection>
)
}

View File

@ -1,7 +1,11 @@
"use client"
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
import { AdminSettingsCard } from "@/components/admin/settings/admin-settings-card"
import { DeployLockedHint, useDeployFieldLocked } from "@/components/admin/settings/deploy-locked-hint"
import { FieldGroup } from "@/components/admin/settings/field-group"
import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry"
import { WebSearchProvidersEditor } from "@/components/web-search/web-search-providers-editor"
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
@ -13,13 +17,15 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { normalizeSearchProviders } from "@/lib/web-search/search-provider-catalog"
import { TechBrandSelectLabel } from "@/components/admin/settings/tech-brand-select-label"
export function SearchSection() {
const search = useOrgSettingsStore((s) => s.search)
const setSearch = useOrgSettingsStore((s) => s.setSearch)
const effective = useOrgSettingsStore((s) => s.meta?.effective.search)
const brave = search.web_search.providers[0]
const providerSecrets = useOrgSettingsStore((s) => s.meta?.secrets?.web_search_providers)
const webSearch = normalizeSearchProviders(search.web_search)
const engineLocked = useDeployFieldLocked("search", "suite_engine")
const meiliURLLocked = useDeployFieldLocked("search", "meilisearch_url")
@ -40,19 +46,47 @@ export function SearchSection() {
return (
<OrgSettingsSection
title="Moteur de recherche"
description="Index de recherche suite (mail, drive) et recherche web pour l'IA contacts."
description="Index de recherche suite (mail, drive) et recherche web (contacts, UltiAI)."
policySection="search"
>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Recherche suite</CardTitle>
<CardDescription>
Moteur d&apos;indexation pour la recherche globale (variables SEARCH_ENGINE côté serveur).
</CardDescription>
{engineLocked ? <DeployLockedHint section="search" field="suite_engine" /> : null}
</CardHeader>
<CardContent className="space-y-4">
<div>
<AutomationTabMasonry columns={2}>
<AdminSettingsCard
title="Recherche web"
description={
<>
Fournisseurs pour l&apos;enrichissement IA contacts et le tool UltiAI{" "}
<code className="rounded bg-muted px-1">web_search</code>. Les utilisateurs peuvent
surcharger cette config dans leurs réglages si l&apos;imposition org. est désactivée.
</>
}
>
<label className="flex items-center justify-between gap-4 rounded-lg border p-3">
<FieldGroup>
<p className="text-sm font-medium">Imposer la config organisation</p>
<p className="text-xs text-muted-foreground">
Sinon, chaque utilisateur configure ses propres fournisseurs.
</p>
</FieldGroup>
<Switch
checked={search.enforce_org_search}
onCheckedChange={(enforce_org_search) => setSearch({ enforce_org_search })}
/>
</label>
<WebSearchProvidersEditor
value={webSearch}
onChange={(web_search) => setSearch({ web_search })}
providerSecrets={providerSecrets}
columns={1}
/>
</AdminSettingsCard>
<AdminSettingsCard
title="Recherche suite"
description="Moteur d'indexation pour la recherche globale (variables SEARCH_ENGINE côté serveur)."
hint={engineLocked ? <DeployLockedHint section="search" field="suite_engine" /> : null}
>
<FieldGroup>
<Label>Moteur</Label>
<Select
value={suiteEngine}
@ -63,107 +97,94 @@ export function SearchSection() {
})
}
>
<SelectTrigger className="mt-1 h-9">
<SelectValue />
<SelectTrigger className="h-9 w-full min-w-0">
<SelectValue>
<TechBrandSelectLabel brand={suiteEngine}>
{suiteEngine === "postgres"
? "PostgreSQL (full-text)"
: suiteEngine === "meilisearch"
? "Meilisearch"
: "Typesense"}
</TechBrandSelectLabel>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="postgres">PostgreSQL (full-text)</SelectItem>
<SelectItem value="meilisearch">Meilisearch</SelectItem>
<SelectItem value="typesense">Typesense</SelectItem>
<SelectItem value="postgres">
<TechBrandSelectLabel brand="postgres">PostgreSQL (full-text)</TechBrandSelectLabel>
</SelectItem>
<SelectItem value="meilisearch">
<TechBrandSelectLabel brand="meilisearch">Meilisearch</TechBrandSelectLabel>
</SelectItem>
<SelectItem value="typesense">
<TechBrandSelectLabel brand="typesense">Typesense</TechBrandSelectLabel>
</SelectItem>
</SelectContent>
</Select>
</div>
</FieldGroup>
{suiteEngine === "meilisearch" ? (
<div className="grid gap-3 sm:grid-cols-2">
<div>
<div className="grid min-w-0 gap-4">
<FieldGroup>
<Label>URL Meilisearch</Label>
<Input
className="mt-1 h-9"
className="h-9"
value={meiliURL}
disabled={meiliURLLocked}
onChange={(e) => setSearch({ meilisearch_url: e.target.value })}
/>
</div>
<div>
{meiliURLLocked ? (
<DeployLockedHint section="search" field="meilisearch_url" />
) : null}
</FieldGroup>
<FieldGroup>
<Label>Clé API</Label>
<Input
className="mt-1 h-9"
className="h-9"
type="password"
value={search.meilisearch_api_key}
disabled={meiliKeyLocked}
onChange={(e) => setSearch({ meilisearch_api_key: e.target.value })}
placeholder={meiliKeyLocked ? "Défini via MEILISEARCH_API_KEY" : undefined}
/>
</div>
{meiliKeyLocked ? (
<DeployLockedHint section="search" field="meilisearch_api_key" />
) : null}
</FieldGroup>
</div>
) : null}
{suiteEngine === "typesense" ? (
<div className="grid gap-3 sm:grid-cols-2">
<div>
<div className="grid min-w-0 gap-4">
<FieldGroup>
<Label>URL Typesense</Label>
<Input
className="mt-1 h-9"
className="h-9"
value={typesenseURL}
disabled={typesenseURLLocked}
onChange={(e) => setSearch({ typesense_url: e.target.value })}
/>
</div>
<div>
{typesenseURLLocked ? (
<DeployLockedHint section="search" field="typesense_url" />
) : null}
</FieldGroup>
<FieldGroup>
<Label>Clé API</Label>
<Input
className="mt-1 h-9"
className="h-9"
type="password"
value={search.typesense_api_key}
disabled={typesenseKeyLocked}
onChange={(e) => setSearch({ typesense_api_key: e.target.value })}
placeholder={typesenseKeyLocked ? "Défini via TYPESENSE_API_KEY" : undefined}
/>
</div>
{typesenseKeyLocked ? (
<DeployLockedHint section="search" field="typesense_api_key" />
) : null}
</FieldGroup>
</div>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Recherche web (Brave)</CardTitle>
<CardDescription>Utilisée pour l&apos;enrichissement IA des contacts.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<label className="flex items-center justify-between gap-4 rounded-lg border p-3">
<div>
<p className="text-sm font-medium">Imposer la config organisation</p>
</div>
<Switch
checked={search.enforce_org_search}
onCheckedChange={(enforce_org_search) => setSearch({ enforce_org_search })}
/>
</label>
<div>
<Label>Token API Brave</Label>
<Input
className="mt-1 h-9"
type="password"
value={brave?.api_key ?? ""}
onChange={(e) =>
setSearch({
web_search: {
default_provider_id: "brave-default",
providers: [
{
id: "brave-default",
name: "Brave Search",
type: "brave",
api_key: e.target.value,
},
],
},
})
}
/>
</div>
</CardContent>
</Card>
</AdminSettingsCard>
</AutomationTabMasonry>
</OrgSettingsSection>
)
}

View File

@ -11,7 +11,6 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
const METHODS = [
{ id: "totp" as const, label: "Application TOTP" },
{ id: "webauthn" as const, label: "Clés de sécurité (WebAuthn)" },
{ id: "sms" as const, label: "SMS" },
]
export function SecuritySection() {

View File

@ -1,89 +0,0 @@
"use client"
import Link from "next/link"
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
export function StorageQuotasSection() {
const storageQuotas = useOrgSettingsStore((s) => s.storageQuotas)
const setStorageQuotas = useOrgSettingsStore((s) => s.setStorageQuotas)
return (
<OrgSettingsSection
title="Quotas de stockage"
description="Limites par défaut appliquées aux nouveaux comptes (mail, drive, photos)."
policySection="storage_quotas"
>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Quotas par défaut</CardTitle>
<CardDescription>
Les quotas individuels se gèrent depuis la fiche utilisateur.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-3">
<QuotaInput
label="Mail (Go)"
value={storageQuotas.default_mail_gib}
onChange={(v) => setStorageQuotas({ default_mail_gib: v })}
/>
<QuotaInput
label="Drive (Go)"
value={storageQuotas.default_drive_gib}
onChange={(v) => setStorageQuotas({ default_drive_gib: v })}
/>
<QuotaInput
label="Photos (Go)"
value={storageQuotas.default_photos_gib}
onChange={(v) => setStorageQuotas({ default_photos_gib: v })}
/>
<div className="sm:col-span-3">
<Label>Seuil d&apos;alerte (%)</Label>
<Input
className="mt-1 h-9 max-w-xs"
type="number"
min={50}
max={100}
value={storageQuotas.warn_threshold_pct}
onChange={(e) =>
setStorageQuotas({ warn_threshold_pct: Number(e.target.value) || 90 })
}
/>
</div>
</CardContent>
</Card>
<Button asChild variant="outline" size="sm">
<Link href="/admin/settings/users">Gérer les quotas par utilisateur</Link>
</Button>
</OrgSettingsSection>
)
}
function QuotaInput({
label,
value,
onChange,
}: {
label: string
value: number
onChange: (v: number) => void
}) {
return (
<div>
<Label>{label}</Label>
<Input
className="mt-1 h-9"
type="number"
min={0}
step={0.5}
value={value}
onChange={(e) => onChange(Number(e.target.value) || 0)}
/>
</div>
)
}

View File

@ -0,0 +1,60 @@
"use client"
import Link from "next/link"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import {
toggleUltiAiToolGroup,
ULTIAI_TOOL_GROUPS,
} from "@/lib/ai/ultiai-tool-groups"
type UltiAiToolsCardProps = {
enabledTools: string[]
onChange: (enabledTools: string[]) => void
webSearchSettingsHref?: string
}
export function UltiAiToolsCard({
enabledTools,
onChange,
webSearchSettingsHref = "/mail/settings/automation",
}: UltiAiToolsCardProps) {
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Tools UltiAI</CardTitle>
<CardDescription>
Groupes d&apos;outils MCP exposés à l&apos;assistant. La recherche web supporte Brave,
Bing, DuckDuckGo, SearXNG et API JSON config dans{" "}
<Link href={webSearchSettingsHref} className="underline underline-offset-2">
Automatisations Recherche
</Link>{" "}
(utilisateur) ou Administration Moteur de recherche (organisation).
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{ULTIAI_TOOL_GROUPS.map((group) => {
const checked = enabledTools.includes(group.id)
return (
<label
key={group.id}
className="flex items-start justify-between gap-4 rounded-lg border p-3"
>
<div className="min-w-0">
<Label className="text-sm font-medium">{group.label}</Label>
<p className="mt-0.5 text-xs text-muted-foreground">{group.description}</p>
</div>
<Switch
checked={checked}
onCheckedChange={(enabled) =>
onChange(toggleUltiAiToolGroup(enabledTools, group.id, enabled))
}
/>
</label>
)
})}
</CardContent>
</Card>
)
}

View File

@ -22,6 +22,7 @@ import {
SelectValue,
} from "@/components/ui/select"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { TechBrandSelectLabel } from "@/components/admin/settings/tech-brand-select-label"
export function UltimeetSection() {
const meet = useOrgSettingsStore((s) => s.meet)
@ -169,12 +170,16 @@ export function UltimeetSection() {
}
>
<SelectTrigger className="h-9">
<SelectValue />
<SelectValue>
<TechBrandSelectLabel brand={draft.external_api_provider}>
{MEET_EXTERNAL_API_PROVIDER_LABELS[draft.external_api_provider]}
</TechBrandSelectLabel>
</SelectValue>
</SelectTrigger>
<SelectContent>
{Object.entries(MEET_EXTERNAL_API_PROVIDER_LABELS).map(([id, label]) => (
<SelectItem key={id} value={id}>
{label}
<TechBrandSelectLabel brand={id}>{label}</TechBrandSelectLabel>
</SelectItem>
))}
</SelectContent>

View File

@ -1,97 +0,0 @@
"use client"
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
export function UsageQuotasSection() {
const usageQuotas = useOrgSettingsStore((s) => s.usageQuotas)
const setUsageQuotas = useOrgSettingsStore((s) => s.setUsageQuotas)
return (
<OrgSettingsSection
title="Quotas d'usage"
description="Limites d'utilisation des services IA, recherche web et automatisations par utilisateur."
policySection="usage_quotas"
>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Intelligence artificielle</CardTitle>
<CardDescription>Requêtes LLM et tokens consommés par mois.</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<div>
<Label>Requêtes LLM / jour</Label>
<Input
className="mt-1 h-9"
type="number"
min={0}
value={usageQuotas.llm_requests_per_day}
onChange={(e) =>
setUsageQuotas({ llm_requests_per_day: Number(e.target.value) || 0 })
}
/>
</div>
<div>
<Label>Tokens LLM / mois</Label>
<Input
className="mt-1 h-9"
type="number"
min={0}
value={usageQuotas.llm_tokens_per_month}
onChange={(e) =>
setUsageQuotas({ llm_tokens_per_month: Number(e.target.value) || 0 })
}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Recherche et automatisations</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-3">
<div>
<Label>Recherches web / jour</Label>
<Input
className="mt-1 h-9"
type="number"
min={0}
value={usageQuotas.search_requests_per_day}
onChange={(e) =>
setUsageQuotas({ search_requests_per_day: Number(e.target.value) || 0 })
}
/>
</div>
<div>
<Label>Tokens API max / utilisateur</Label>
<Input
className="mt-1 h-9"
type="number"
min={0}
value={usageQuotas.max_api_tokens_per_user}
onChange={(e) =>
setUsageQuotas({ max_api_tokens_per_user: Number(e.target.value) || 0 })
}
/>
</div>
<div>
<Label>Webhooks max / utilisateur</Label>
<Input
className="mt-1 h-9"
type="number"
min={0}
value={usageQuotas.max_webhooks_per_user}
onChange={(e) =>
setUsageQuotas({ max_webhooks_per_user: Number(e.target.value) || 0 })
}
/>
</div>
</CardContent>
</Card>
</OrgSettingsSection>
)
}

View File

@ -0,0 +1,242 @@
"use client"
import { useState } from "react"
import { Ban, RotateCcw, Trash2, UserCog, UsersRound, X } from "lucide-react"
import { toast } from "sonner"
import type { AdminBulkUsersAction, AdminUserGroup, AdminUserRole } from "@/lib/api/admin-types"
import { useBulkAdminUsers } from "@/lib/api/hooks/use-admin-mutations"
import {
USER_ROLE_ICONS,
USER_ROLE_LABELS,
} from "@/lib/admin/user-role"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
const ROLE_OPTIONS: AdminUserRole[] = ["admin", "user", "guest", "suspended"]
export function UsersBulkToolbar({
selectedIds,
groups,
onClear,
}: {
selectedIds: string[]
groups: AdminUserGroup[]
onClear: () => void
}) {
const bulk = useBulkAdminUsers()
const [roleDialogOpen, setRoleDialogOpen] = useState(false)
const [groupDialogOpen, setGroupDialogOpen] = useState(false)
const [groupAction, setGroupAction] = useState<"add_to_group" | "remove_from_group">(
"add_to_group"
)
const [selectedRole, setSelectedRole] = useState<AdminUserRole>("user")
const [selectedGroupId, setSelectedGroupId] = useState<string>("")
const count = selectedIds.length
if (count === 0) return null
async function run(action: AdminBulkUsersAction, extra?: { role?: AdminUserRole; group_id?: string }) {
try {
const result = await bulk.mutateAsync({
user_ids: selectedIds,
action,
...extra,
})
if (result.failed?.length) {
toast.warning(
`${result.success_count} réussi(s), ${result.failed.length} échec(s)`
)
} else {
toast.success(`${result.success_count} utilisateur(s) mis à jour`)
}
onClear()
} catch {
toast.error("Action de masse impossible")
}
}
return (
<>
<div className="mb-3 flex flex-wrap items-center gap-2 rounded-lg border bg-muted/30 px-3 py-2">
<Button variant="ghost" size="icon" className="size-8" onClick={onClear}>
<X className="size-4" />
</Button>
<span className="text-sm font-medium">
{count} sélectionné{count > 1 ? "s" : ""}
</span>
<div className="ml-auto flex flex-wrap gap-1">
<Button
variant="outline"
size="sm"
disabled={bulk.isPending}
onClick={() => void run("disable")}
>
<Ban className="mr-2 size-4" />
Suspendre
</Button>
<Button
variant="outline"
size="sm"
disabled={bulk.isPending}
onClick={() => void run("reactivate")}
>
<RotateCcw className="mr-2 size-4" />
Réactiver
</Button>
<Button
variant="outline"
size="sm"
disabled={bulk.isPending}
onClick={() => setRoleDialogOpen(true)}
>
<UserCog className="mr-2 size-4" />
Changer le type
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" disabled={bulk.isPending || groups.length === 0}>
<UsersRound className="mr-2 size-4" />
Groupes
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setGroupAction("add_to_group")
setSelectedGroupId(groups[0]?.id ?? "")
setGroupDialogOpen(true)
}}
>
Ajouter à un groupe
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setGroupAction("remove_from_group")
setSelectedGroupId(groups[0]?.id ?? "")
setGroupDialogOpen(true)
}}
>
Retirer d&apos;un groupe
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
size="sm"
className="text-destructive"
disabled={bulk.isPending}
onClick={() => {
if (confirm(`Supprimer ${count} utilisateur(s) ?`)) {
void run("delete")
}
}}
>
<Trash2 className="mr-2 size-4" />
Supprimer
</Button>
</div>
</div>
<Dialog open={roleDialogOpen} onOpenChange={setRoleDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Changer le type</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<Label>Type d&apos;utilisateur</Label>
<Select value={selectedRole} onValueChange={(v) => setSelectedRole(v as AdminUserRole)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ROLE_OPTIONS.map((role) => {
const Icon = USER_ROLE_ICONS[role]
return (
<SelectItem key={role} value={role}>
<span className="flex items-center gap-2">
<Icon className="size-4" />
{USER_ROLE_LABELS[role]}
</span>
</SelectItem>
)
})}
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRoleDialogOpen(false)}>
Annuler
</Button>
<Button
onClick={() => {
void run("set_role", { role: selectedRole })
setRoleDialogOpen(false)
}}
>
Appliquer
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={groupDialogOpen} onOpenChange={setGroupDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{groupAction === "add_to_group" ? "Ajouter au groupe" : "Retirer du groupe"}
</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<Label>Groupe</Label>
<Select value={selectedGroupId} onValueChange={setSelectedGroupId}>
<SelectTrigger>
<SelectValue placeholder="Choisir un groupe" />
</SelectTrigger>
<SelectContent>
{groups.map((group) => (
<SelectItem key={group.id} value={group.id}>
{group.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setGroupDialogOpen(false)}>
Annuler
</Button>
<Button
disabled={!selectedGroupId}
onClick={() => {
void run(groupAction, { group_id: selectedGroupId })
setGroupDialogOpen(false)
}}
>
Appliquer
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@ -0,0 +1,213 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { Pencil, Plus, Trash2, UsersRound } from "lucide-react"
import { useAdminUserGroups } from "@/lib/api/hooks/use-admin-queries"
import {
useCreateAdminUserGroup,
useDeleteAdminUserGroup,
useUpdateAdminUserGroup,
} from "@/lib/api/hooks/use-admin-mutations"
import type { AdminUserGroup } from "@/lib/api/admin-types"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
export function UsersGroupsDialog({
open,
onOpenChange,
}: {
open: boolean
onOpenChange: (open: boolean) => void
}) {
const [q, setQ] = useState("")
const [editorOpen, setEditorOpen] = useState(false)
const [editing, setEditing] = useState<AdminUserGroup | null>(null)
const queryParams = useMemo(
() => ({ page: 1, page_size: 100, q: q.trim() || undefined }),
[q]
)
const { data, isFetching } = useAdminUserGroups(queryParams)
const groups = data?.groups ?? []
function openCreate() {
setEditing(null)
setEditorOpen(true)
}
function openEdit(group: AdminUserGroup) {
setEditing(group)
setEditorOpen(true)
}
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Groupes d&apos;utilisateurs</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="flex gap-2">
<Input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Rechercher un groupe"
className="h-9"
/>
<Button className="h-9 shrink-0" onClick={openCreate}>
<Plus className="mr-2 size-4" />
Créer
</Button>
</div>
<div className="max-h-80 space-y-2 overflow-y-auto rounded-lg border p-2">
{isFetching ? (
<p className="px-2 py-4 text-sm text-muted-foreground">Chargement</p>
) : groups.length === 0 ? (
<p className="px-2 py-4 text-sm text-muted-foreground">Aucun groupe.</p>
) : (
groups.map((group) => (
<GroupRow key={group.id} group={group} onEdit={() => openEdit(group)} />
))
)}
</div>
</div>
</DialogContent>
</Dialog>
<GroupEditorDialog
open={editorOpen}
onOpenChange={setEditorOpen}
group={editing}
/>
</>
)
}
function GroupRow({
group,
onEdit,
}: {
group: AdminUserGroup
onEdit: () => void
}) {
const deleteGroup = useDeleteAdminUserGroup()
return (
<div className="flex items-start gap-3 rounded-md px-2 py-2 hover:bg-muted/50">
<UsersRound className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="font-medium">{group.name}</div>
{group.description ? (
<div className="text-xs text-muted-foreground">{group.description}</div>
) : null}
<div className="mt-1 text-xs text-muted-foreground">
{group.member_count.toLocaleString("fr-FR")} membre(s)
</div>
</div>
<div className="flex shrink-0 gap-1">
<Button variant="ghost" size="icon" className="size-8" onClick={onEdit}>
<Pencil className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-8 text-destructive"
onClick={() => {
if (confirm(`Supprimer le groupe « ${group.name} » ?`)) {
void deleteGroup.mutateAsync(group.id)
}
}}
>
<Trash2 className="size-4" />
</Button>
</div>
</div>
)
}
function GroupEditorDialog({
open,
onOpenChange,
group,
}: {
open: boolean
onOpenChange: (open: boolean) => void
group: AdminUserGroup | null
}) {
const createGroup = useCreateAdminUserGroup()
const updateGroup = useUpdateAdminUserGroup(group?.id ?? "")
const [name, setName] = useState("")
const [description, setDescription] = useState("")
const isEdit = Boolean(group)
useEffect(() => {
if (!open) return
setName(group?.name ?? "")
setDescription(group?.description ?? "")
}, [open, group])
async function submit() {
const trimmedName = name.trim()
if (!trimmedName) return
if (isEdit && group) {
await updateGroup.mutateAsync({
name: trimmedName,
description: description.trim(),
})
} else {
await createGroup.mutateAsync({
name: trimmedName,
description: description.trim() || undefined,
})
}
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{isEdit ? "Modifier le groupe" : "Nouveau groupe"}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div>
<Label>Nom</Label>
<Input className="mt-1" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div>
<Label>Description (optionnel)</Label>
<Textarea
className="mt-1 min-h-20"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Annuler
</Button>
<Button
disabled={!name.trim() || createGroup.isPending || updateGroup.isPending}
onClick={() => void submit()}
>
{isEdit ? "Enregistrer" : "Créer"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -1,10 +1,13 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { MoreHorizontal, UserPlus } from "lucide-react"
import { MoreHorizontal, UserPlus, UsersRound } from "lucide-react"
import { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header"
import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner"
import { useAdminUser, useAdminUsers } from "@/lib/api/hooks/use-admin-queries"
import { AdminListControls, type AdminListSortOption } from "@/components/admin/settings/admin-list-controls"
import { UsersBulkToolbar } from "@/components/admin/settings/sections/users-bulk-toolbar"
import { UsersGroupsDialog } from "@/components/admin/settings/sections/users-groups-dialog"
import { useAdminUser, useAdminUserGroups, useAdminUsers } from "@/lib/api/hooks/use-admin-queries"
import {
useDeleteAdminUser,
useDisableAdminUser,
@ -25,6 +28,7 @@ import {
} from "@/lib/admin/user-role"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
@ -66,34 +70,87 @@ import {
const ROLE_OPTIONS: AdminUserRole[] = ["admin", "user", "guest", "suspended"]
const USER_SORT_OPTIONS: AdminListSortOption[] = [
{ value: "-created_at", label: "Création (récent)" },
{ value: "created_at", label: "Création (ancien)" },
{ value: "name", label: "Nom (A→Z)" },
{ value: "-name", label: "Nom (Z→A)" },
{ value: "email", label: "E-mail (A→Z)" },
{ value: "-email", label: "E-mail (Z→A)" },
{ value: "-updated_at", label: "Mise à jour (récent)" },
{ value: "updated_at", label: "Mise à jour (ancien)" },
]
export function UsersSection() {
const [q, setQ] = useState("")
const [role, setRole] = useState<string>("all")
const [groupId, setGroupId] = useState<string>("all")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(25)
const [sort, setSort] = useState("-created_at")
const [inviteOpen, setInviteOpen] = useState(false)
const [groupsOpen, setGroupsOpen] = useState(false)
const [selectedId, setSelectedId] = useState<string | null>(null)
const [selectedIds, setSelectedIds] = useState<string[]>([])
const queryParams = useMemo(
() => ({
page,
page_size: 25,
page_size: pageSize,
sort,
q: q.trim() || undefined,
role: role === "all" ? undefined : role,
group_id: groupId === "all" ? undefined : groupId,
}),
[page, q, role]
[page, pageSize, sort, q, role, groupId]
)
const { data: groupsData } = useAdminUserGroups({ page: 1, page_size: 200 })
const groups = groupsData?.groups ?? []
const { data, isFetching, isError, refetch } = useAdminUsers(queryParams)
const users = data?.users ?? []
const users = data?.users
const total = data?.pagination.total ?? 0
const pageSize = data?.pagination.page_size ?? 25
const totalPages = Math.max(1, Math.ceil(total / pageSize))
const resolvedPageSize = data?.pagination.page_size ?? pageSize
const totalPages = Math.max(1, Math.ceil(total / resolvedPageSize))
const pageUserIds = useMemo(() => (users ?? []).map((user) => user.id), [users])
const pageUserIdsKey = pageUserIds.join(",")
const allPageSelected =
pageUserIds.length > 0 && pageUserIds.every((id) => selectedIds.includes(id))
const somePageSelected =
pageUserIds.some((id) => selectedIds.includes(id)) && !allPageSelected
useEffect(() => {
const ids = pageUserIdsKey.length > 0 ? pageUserIdsKey.split(",") : []
setSelectedIds((prev) => {
const next = prev.filter((id) => ids.includes(id))
if (next.length === prev.length && next.every((id, index) => id === prev[index])) {
return prev
}
return next
})
}, [pageUserIdsKey])
function toggleUser(id: string, checked: boolean) {
setSelectedIds((prev) =>
checked ? Array.from(new Set([...prev, id])) : prev.filter((value) => value !== id)
)
}
function togglePage(checked: boolean) {
if (checked) {
setSelectedIds((prev) => Array.from(new Set([...prev, ...pageUserIds])))
return
}
setSelectedIds((prev) => prev.filter((id) => !pageUserIds.includes(id)))
}
return (
<>
<SettingsSectionHeader
title="Utilisateurs"
description="Comptes, types d'utilisateur, invitations et quotas."
description="Comptes, groupes, types d'utilisateur, invitations et quotas."
/>
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
@ -120,13 +177,7 @@ export function UsersSection() {
}}
>
<SelectTrigger className="mt-1 h-9">
<SelectValue>
{role === "all" ? (
<UserRoleLabel role="all" />
) : (
<UserRoleLabel role={role as AdminUserRole} />
)}
</SelectValue>
<SelectValue placeholder="Type d'utilisateur" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
@ -140,18 +191,82 @@ export function UsersSection() {
</SelectContent>
</Select>
</div>
<div className="w-52">
<Label className="text-xs">Groupe</Label>
<Select
value={groupId}
onValueChange={(v) => {
setGroupId(v)
setPage(1)
}}
>
<SelectTrigger className="mt-1 h-9">
<SelectValue placeholder="Groupe" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
<span className="flex items-center gap-2">
<UsersRound className="size-4 opacity-80" />
Tous les groupes
</span>
</SelectItem>
{groups.map((group) => (
<SelectItem key={group.id} value={group.id}>
{group.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button variant="outline" className="h-9" onClick={() => setGroupsOpen(true)}>
<UsersRound className="mr-2 size-4" />
Groupes
</Button>
<Button className="h-9" onClick={() => setInviteOpen(true)}>
<UserPlus className="mr-2 size-4" />
Inviter
</Button>
</div>
<UsersBulkToolbar
selectedIds={selectedIds}
groups={groups}
onClear={() => setSelectedIds([])}
/>
<AdminListControls
page={page}
pageSize={resolvedPageSize}
total={total}
totalPages={totalPages}
sort={sort}
sortOptions={USER_SORT_OPTIONS}
onPageChange={setPage}
onPageSizeChange={(next) => {
setPageSize(next)
setPage(1)
}}
onSortChange={(next) => {
setSort(next)
setPage(1)
}}
itemLabel="utilisateur(s)"
/>
<div className="overflow-x-auto rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<Checkbox
checked={allPageSelected ? true : somePageSelected ? "indeterminate" : false}
onCheckedChange={(checked) => togglePage(checked === true)}
aria-label="Sélectionner la page"
/>
</TableHead>
<TableHead>Utilisateur</TableHead>
<TableHead>Type</TableHead>
<TableHead className="hidden xl:table-cell">Groupes</TableHead>
<TableHead className="hidden lg:table-cell">Mail</TableHead>
<TableHead className="hidden lg:table-cell">Drive</TableHead>
<TableHead className="hidden md:table-cell">ID externe</TableHead>
@ -159,17 +274,19 @@ export function UsersSection() {
</TableRow>
</TableHeader>
<TableBody>
{users.length === 0 ? (
{(users ?? []).length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground">
<TableCell colSpan={8} className="text-center text-muted-foreground">
Aucun utilisateur trouvé.
</TableCell>
</TableRow>
) : (
users.map((user) => (
(users ?? []).map((user) => (
<UserRow
key={user.id}
user={user}
selected={selectedIds.includes(user.id)}
onToggleSelect={(checked) => toggleUser(user.id, checked)}
onOpen={() => setSelectedId(user.id)}
/>
))
@ -178,59 +295,57 @@ export function UsersSection() {
</Table>
</div>
{totalPages > 1 ? (
<div className="mt-4 flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{total.toLocaleString("fr-FR")} utilisateur(s)
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
Précédent
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
Suivant
</Button>
</div>
</div>
) : null}
<InviteUserDialog open={inviteOpen} onOpenChange={setInviteOpen} />
<UsersGroupsDialog open={groupsOpen} onOpenChange={setGroupsOpen} />
<UserDetailSheet userId={selectedId} onClose={() => setSelectedId(null)} />
</>
)
}
function UserRow({ user, onOpen }: { user: AdminUser; onOpen: () => void }) {
function UserRow({
user,
selected,
onToggleSelect,
onOpen,
}: {
user: AdminUser
selected: boolean
onToggleSelect: (checked: boolean) => void
onOpen: () => void
}) {
const disableUser = useDisableAdminUser()
const reactivateUser = useReactivateAdminUser()
const deleteUser = useDeleteAdminUser()
return (
<TableRow className="cursor-pointer" onClick={onOpen}>
<TableCell>
<TableRow className="cursor-pointer">
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selected}
onCheckedChange={(checked) => onToggleSelect(checked === true)}
aria-label={`Sélectionner ${user.email}`}
/>
</TableCell>
<TableCell onClick={onOpen}>
<div className="font-medium">{user.name || "—"}</div>
<div className="text-xs text-muted-foreground">{user.email}</div>
</TableCell>
<TableCell>
<TableCell onClick={onOpen}>
<RoleBadge role={resolveUserRole(user)} />
</TableCell>
<TableCell className="hidden text-xs text-muted-foreground lg:table-cell">
<TableCell className="hidden xl:table-cell" onClick={onOpen}>
<UserGroupsBadges groups={user.groups ?? []} />
</TableCell>
<TableCell className="hidden text-xs text-muted-foreground lg:table-cell" onClick={onOpen}>
{formatBytes(user.storage?.mail_used_bytes ?? 0)}
</TableCell>
<TableCell className="hidden text-xs text-muted-foreground lg:table-cell">
<TableCell className="hidden text-xs text-muted-foreground lg:table-cell" onClick={onOpen}>
{formatBytes(user.storage?.drive_used_bytes ?? 0)}
</TableCell>
<TableCell className="hidden max-w-[200px] truncate font-mono text-xs md:table-cell">
<TableCell
className="hidden max-w-[200px] truncate font-mono text-xs md:table-cell"
onClick={onOpen}
>
{user.external_id}
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
@ -273,6 +388,28 @@ function UserRow({ user, onOpen }: { user: AdminUser; onOpen: () => void }) {
)
}
function UserGroupsBadges({ groups }: { groups: AdminUser["groups"] }) {
if (!groups?.length) {
return <span className="text-xs text-muted-foreground"></span>
}
const visible = groups.slice(0, 2)
const extra = groups.length - visible.length
return (
<div className="flex flex-wrap gap-1">
{visible.map((group) => (
<Badge key={group.id} variant="outline" className="text-xs font-normal">
{group.name}
</Badge>
))}
{extra > 0 ? (
<Badge variant="secondary" className="text-xs font-normal">
+{extra}
</Badge>
) : null}
</div>
)
}
function UserRoleLabel({
role,
className,
@ -430,6 +567,12 @@ function UserDetailSheet({
<div className="space-y-8 px-6 py-6">
<div className="space-y-4">
<RoleBadge role={resolveUserRole(user)} />
{user.groups?.length ? (
<div className="space-y-2">
<Label>Groupes</Label>
<UserGroupsBadges groups={user.groups} />
</div>
) : null}
<div className="space-y-2">
<Label>Type d&apos;utilisateur</Label>
<Select
@ -437,9 +580,7 @@ function UserDetailSheet({
onValueChange={(v) => setSelectedRole(v as AdminUserRole)}
>
<SelectTrigger>
<SelectValue>
<UserRoleLabel role={selectedRole} />
</SelectValue>
<SelectValue placeholder="Type d'utilisateur" />
</SelectTrigger>
<SelectContent>
{ROLE_OPTIONS.map((option) => (

View File

@ -0,0 +1,35 @@
"use client"
import { Icon } from "@iconify/react"
import { techBrandIcon } from "@/lib/admin-settings/tech-brand-icons"
import { cn } from "@/lib/utils"
export function TechBrandSelectLabel({
brand,
icon,
children,
className,
iconClassName,
suffix,
}: {
brand?: string
icon?: string
children: React.ReactNode
className?: string
iconClassName?: string
suffix?: React.ReactNode
}) {
const resolved = icon ?? (brand ? techBrandIcon(brand) : undefined)
return (
<span className={cn("inline-flex min-w-0 items-center gap-2", className)}>
{resolved ? (
<Icon icon={resolved} className={cn("size-4 shrink-0", iconClassName)} aria-hidden />
) : null}
<span className="truncate">
{children}
{suffix}
</span>
</span>
)
}

View File

@ -43,8 +43,8 @@ import {
import { useEffectiveAgendaSettings } from "@/lib/agenda/use-effective-agenda-settings"
import { useIsDemoApp } from "@/lib/demo/use-is-demo-app"
import { useThemeModeControls } from "@/lib/demo/use-theme-mode-controls"
import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry"
import {
MAIL_SETTINGS_PAGE_MASONRY_CLASS,
MAIL_SETTINGS_PAGE_MASONRY_SECTION_CLASS,
} from "@/lib/mail-chrome-classes"
import type { MailThemeMode } from "@/lib/mail-settings/types"
@ -459,7 +459,7 @@ export function AgendaSettingsFields({
)
if (isPage) {
return <div className={MAIL_SETTINGS_PAGE_MASONRY_CLASS}>{fields}</div>
return <AutomationTabMasonry columns={2}>{fields}</AutomationTabMasonry>
}
return fields

View File

@ -1,13 +1,11 @@
"use client"
import { useEffect, useMemo, useRef } from "react"
import { useEffect, useMemo, useRef, useState } from "react"
import type { AiChatContext } from "@/lib/ai/chat-context"
import { buildEmbedSearchParams } from "@/lib/ai/chat-context"
import { buildEmbedSearchParams, systemPromptFromContext } from "@/lib/ai/chat-context"
import { buildAiEmbedUrl, resolveAiEmbedOrigin } from "@/lib/ai/embed-url"
import {
useAiIframeExternalLinks,
useAiIframeNavigation,
} from "@/lib/ai/use-ai-iframe-navigation"
import { useAiIframeExternalLinks } from "@/lib/ai/use-ai-iframe-navigation"
import { useAiConfig, useCreateAiSession } from "@/lib/api/hooks/use-ai-queries"
import { useTheme } from "next-themes"
type AiChatIframeProps = {
@ -19,15 +17,64 @@ type AiChatIframeProps = {
export function AiChatIframe({ publicPath = "/ai", context, className }: AiChatIframeProps) {
const iframeRef = useRef<HTMLIFrameElement>(null)
const { resolvedTheme } = useTheme()
const { data: config, isSuccess } = useAiConfig()
const createSession = useCreateAiSession()
const [sessionToken, setSessionToken] = useState<string | undefined>()
const [sessionId, setSessionId] = useState<string | undefined>()
const sessionContextKey = useMemo(
() =>
[
context.app,
context.temporary,
context.messageId,
context.accountId,
context.drivePath,
context.fileId,
context.contactId,
].join("|"),
[
context.app,
context.temporary,
context.messageId,
context.accountId,
context.drivePath,
context.fileId,
context.contactId,
]
)
const embedOrigin = useMemo(() => resolveAiEmbedOrigin(publicPath), [publicPath])
const src = useMemo(() => {
const qs = buildEmbedSearchParams(context)
return buildAiEmbedUrl(publicPath, qs)
}, [publicPath, context])
const enabledTools = config?.enabled_tools ?? []
const mcpUrl = config?.mcp_url ?? "/api/v1/ai/mcp"
const iframeSrc = useMemo(() => {
if (!isSuccess || !config?.enabled) return null
const qs = buildEmbedSearchParams(context, config.default_model)
return buildAiEmbedUrl(publicPath, qs)
}, [isSuccess, config, publicPath, context])
useAiIframeNavigation(iframeRef, publicPath)
useAiIframeExternalLinks(embedOrigin)
useEffect(() => {
if (!config?.enabled) return
let cancelled = false
createSession
.mutateAsync(context)
.then((session) => {
if (cancelled) return
setSessionToken(session.token_secret)
setSessionId(session.session_id)
})
.catch(() => {
if (!cancelled) {
setSessionToken(undefined)
setSessionId(undefined)
}
})
return () => {
cancelled = true
}
}, [config?.enabled, sessionContextKey, context, createSession.mutateAsync])
useEffect(() => {
const iframe = iframeRef.current
if (!iframe?.contentWindow || !embedOrigin) return
@ -40,21 +87,54 @@ export function AiChatIframe({ publicPath = "/ai", context, className }: AiChatI
useEffect(() => {
const iframe = iframeRef.current
if (!iframe?.contentWindow || !embedOrigin) return
const systemPrompt = [
systemPromptFromContext(context),
context.systemPromptExtra,
]
.filter(Boolean)
.join("\n\n")
iframe.contentWindow.postMessage(
{
type: "ULTI_CONTEXT_UPDATE",
context,
systemPrompt: context.systemPromptExtra,
systemPrompt: systemPrompt || undefined,
},
embedOrigin
)
}, [context, embedOrigin])
useEffect(() => {
const iframe = iframeRef.current
if (!iframe?.contentWindow || !embedOrigin || !sessionToken) return
iframe.contentWindow.postMessage(
{
type: "ULTI_SESSION",
token_secret: sessionToken,
session_id: sessionId,
mcp_url: mcpUrl,
enabled_tools: enabledTools,
default_model: config?.default_model,
},
embedOrigin
)
}, [sessionToken, sessionId, mcpUrl, enabledTools, config?.default_model, embedOrigin])
if (!iframeSrc) {
return (
<div
className={className}
aria-busy="true"
aria-label="Chargement UltiAI"
/>
)
}
return (
<iframe
key={iframeSrc}
ref={iframeRef}
title="UltiAI"
src={src}
src={iframeSrc}
className={className}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-downloads"
allow="clipboard-read; clipboard-write"

View File

@ -1,8 +1,8 @@
"use client"
import { cn } from "@/lib/utils"
import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry"
import {
MAIL_SETTINGS_PAGE_MASONRY_CLASS,
MAIL_SETTINGS_PAGE_MASONRY_SECTION_CLASS,
} from "@/lib/mail-chrome-classes"
import { useThemeModeControls } from "@/lib/demo/use-theme-mode-controls"
@ -345,7 +345,7 @@ export function MailSettingsFields({
)
if (isPage) {
return <div className={MAIL_SETTINGS_PAGE_MASONRY_CLASS}>{fields}</div>
return <AutomationTabMasonry columns={2}>{fields}</AutomationTabMasonry>
}
return fields

View File

@ -2,10 +2,12 @@
import { Children, type ReactNode } from "react"
import { cn } from "@/lib/utils"
import {
MAIL_SETTINGS_PAGE_MASONRY_CLASS,
MAIL_SETTINGS_PAGE_MASONRY_ITEM_CLASS,
} from "@/lib/mail-chrome-classes"
function MasonryStack({ children }: { children: ReactNode }) {
return (
<div className="flex min-w-0 flex-1 flex-col gap-4 lg:gap-5">{children}</div>
)
}
export function AutomationTabMasonry({
columns,
@ -16,25 +18,43 @@ export function AutomationTabMasonry({
children: ReactNode
className?: string
}) {
if (columns === 1) {
return <div className={cn("space-y-4", className)}>{children}</div>
}
const items = Children.toArray(children).filter(Boolean)
if (columns === 1) {
return (
<div
className={cn(
"space-y-4 lg:space-y-0",
MAIL_SETTINGS_PAGE_MASONRY_CLASS,
className
)}
>
<div className={cn("flex w-full flex-col gap-4", className)}>{children}</div>
)
}
const left = items.filter((_, index) => index % 2 === 0)
const right = items.filter((_, index) => index % 2 === 1)
return (
<div className={cn("w-full", className)}>
<div className="flex flex-col gap-4 lg:hidden">
{items.map((child, index) => (
<div key={index} className={MAIL_SETTINGS_PAGE_MASONRY_ITEM_CLASS}>
<div key={index} className="min-w-0">
{child}
</div>
))}
</div>
<div className="hidden items-start gap-5 lg:flex">
<MasonryStack>
{left.map((child, index) => (
<div key={index} className="min-w-0">
{child}
</div>
))}
</MasonryStack>
<MasonryStack>
{right.map((child, index) => (
<div key={index} className="min-w-0">
{child}
</div>
))}
</MasonryStack>
</div>
</div>
)
}

View File

@ -1,7 +1,6 @@
"use client"
import { useEffect, useState } from "react"
import { Plus, Trash2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
@ -16,25 +15,18 @@ import {
useLLMSettings,
useUpdateLLMSettings,
} from "@/lib/api/hooks/use-contact-discovery"
import type { ApiLLMProvider, ApiLLMSettings } from "@/lib/contacts/discovery-types"
import type { ApiLLMSettings } from "@/lib/contacts/discovery-types"
import { LLMModelSuggestInput } from "@/components/gmail/settings/automation/llm-model-suggest-input"
import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry"
import { LlmProvidersEditor } from "@/components/llm/llm-providers-editor"
import { TechBrandSelectLabel } from "@/components/admin/settings/tech-brand-select-label"
import { inferLlmProviderType, llmCatalogEntry, normalizeLlmProvider } from "@/lib/llm/llm-provider-catalog"
import {
CONTACTS_MUTED_TEXT,
CONTACTS_PRIMARY_BTN_CLASS,
} from "@/lib/contacts-chrome-classes"
import { cn } from "@/lib/utils"
function emptyProvider(): ApiLLMProvider {
return {
id: crypto.randomUUID(),
name: "",
base_url: "https://api.openai.com/v1",
api_key: "",
default_model: "gpt-4o-mini",
}
}
export function LLMProvidersPanel() {
const { data: remote, isLoading } = useLLMSettings()
const updateSettings = useUpdateLLMSettings()
@ -48,40 +40,11 @@ export function LLMProvidersPanel() {
if (remote) {
setDraft({
...remote,
providers: remote.providers ?? [],
providers: (remote.providers ?? []).map(normalizeLlmProvider),
})
}
}, [remote])
function updateProvider(index: number, patch: Partial<ApiLLMProvider>) {
setDraft((prev) => {
const providers = [...prev.providers]
providers[index] = { ...providers[index], ...patch }
return { ...prev, providers }
})
}
function addProvider() {
const p = emptyProvider()
setDraft((prev) => ({
...prev,
providers: [...prev.providers, p],
default_provider_id: prev.default_provider_id || p.id,
}))
}
function removeProvider(index: number) {
setDraft((prev) => {
const removed = prev.providers[index]
const providers = prev.providers.filter((_, i) => i !== index)
let defaultId = prev.default_provider_id
if (defaultId === removed?.id) {
defaultId = providers[0]?.id ?? ""
}
return { ...prev, providers, default_provider_id: defaultId }
})
}
async function handleSave() {
await updateSettings.mutateAsync(draft)
setSaved(true)
@ -101,87 +64,30 @@ export function LLMProvidersPanel() {
</p>
</div>
<AutomationTabMasonry columns={2}>
{draft.providers.map((provider, index) => (
<div key={provider.id} className="space-y-3 rounded-lg border border-border p-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{provider.name || `Fournisseur ${index + 1}`}</span>
<Button
variant="ghost"
size="icon"
onClick={() => removeProvider(index)}
aria-label="Supprimer"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<Label className="text-xs">Nom</Label>
<Input
className="mt-1 h-9"
value={provider.name}
onChange={(e) => updateProvider(index, { name: e.target.value })}
placeholder="OpenAI"
/>
</div>
<div className="sm:col-span-2">
<Label className="text-xs">URL de base</Label>
<Input
className="mt-1 h-9"
value={provider.base_url}
onChange={(e) => updateProvider(index, { base_url: e.target.value })}
placeholder="https://api.openai.com/v1"
/>
</div>
<div className="sm:col-span-2">
<Label className="text-xs">Clé API</Label>
<Input
className="mt-1 h-9"
type="password"
value={provider.api_key ?? ""}
onChange={(e) => updateProvider(index, { api_key: e.target.value })}
placeholder="sk-…"
/>
</div>
<div className="sm:col-span-2">
<Label className="text-xs">Modèle par défaut</Label>
<LlmProvidersEditor
providers={draft.providers}
defaultProviderId={draft.default_provider_id}
onProvidersChange={(providers) => setDraft((prev) => ({ ...prev, providers }))}
onDefaultProviderIdChange={(default_provider_id) =>
setDraft((prev) => ({ ...prev, default_provider_id }))
}
renderDefaultModelInput={({ provider, onChange }) => (
<LLMModelSuggestInput
className="mt-1"
baseUrl={provider.base_url}
apiKey={provider.api_key}
value={provider.default_model}
onChange={(default_model) => updateProvider(index, { default_model })}
onChange={onChange}
placeholder="gpt-4o-mini"
/>
</div>
</div>
</div>
))}
)}
/>
<AutomationTabMasonry columns={2}>
<div className="space-y-3 rounded-lg border border-border p-4">
<h4 className="text-sm font-medium">Découverte de contacts</h4>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<Label className="text-xs">Fournisseur par défaut</Label>
<Select
value={draft.default_provider_id}
onValueChange={(v) => setDraft((p) => ({ ...p, default_provider_id: v }))}
>
<SelectTrigger className="mt-1 h-9">
<SelectValue placeholder="Choisir…" />
</SelectTrigger>
<SelectContent>
{draft.providers.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name || p.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs">Modèle pour l&apos;enrichissement</Label>
<Label className="text-xs">Fournisseur pour l&apos;enrichissement</Label>
<Select
value={draft.contact_discovery_provider_id ?? draft.default_provider_id}
onValueChange={(v) =>
@ -192,11 +98,17 @@ export function LLMProvidersPanel() {
<SelectValue placeholder="Même que défaut" />
</SelectTrigger>
<SelectContent>
{draft.providers.map((p) => (
{draft.providers.map((p) => {
const type = inferLlmProviderType(p)
const entry = llmCatalogEntry(type)
return (
<SelectItem key={p.id} value={p.id}>
{p.name || p.id}
<TechBrandSelectLabel brand={type} icon={entry.icon}>
{p.name || entry.label}
</TechBrandSelectLabel>
</SelectItem>
))}
)
})}
</SelectContent>
</Select>
</div>
@ -216,10 +128,6 @@ export function LLMProvidersPanel() {
</AutomationTabMasonry>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={addProvider}>
<Plus className="mr-2 h-4 w-4" />
Ajouter un fournisseur
</Button>
<Button
onClick={handleSave}
disabled={updateSettings.isPending}

View File

@ -1,68 +1,35 @@
"use client"
import { useEffect, useState } from "react"
import { ExternalLink } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
useSearchSettings,
useUpdateSearchSettings,
} from "@/lib/api/hooks/use-contact-discovery"
import type { ApiSearchProvider, ApiSearchSettings } from "@/lib/contacts/discovery-types"
import type { ApiSearchSettings } from "@/lib/contacts/discovery-types"
import { WebSearchProvidersEditor } from "@/components/web-search/web-search-providers-editor"
import {
CONTACTS_MUTED_TEXT,
CONTACTS_PRIMARY_BTN_CLASS,
} from "@/lib/contacts-chrome-classes"
import { normalizeSearchProviders } from "@/lib/web-search/search-provider-catalog"
import { cn } from "@/lib/utils"
const BRAVE_PROVIDER_ID = "brave-default"
function defaultBraveProvider(): ApiSearchProvider {
return {
id: BRAVE_PROVIDER_ID,
name: "Brave Search",
type: "brave",
api_key: "",
}
}
function normalizeDraft(raw: ApiSearchSettings | undefined): ApiSearchSettings {
const providers = raw?.providers?.length ? raw.providers : [defaultBraveProvider()]
const brave = providers.find((p) => p.type === "brave") ?? defaultBraveProvider()
return {
default_provider_id: raw?.default_provider_id || brave.id,
providers: [brave],
}
}
export function SearchProvidersPanel() {
const { data: remote, isLoading } = useSearchSettings()
const updateSettings = useUpdateSearchSettings()
const [draft, setDraft] = useState<ApiSearchSettings>(normalizeDraft(undefined))
const [draft, setDraft] = useState<ApiSearchSettings>(normalizeSearchProviders(undefined))
const [saved, setSaved] = useState(false)
useEffect(() => {
if (remote) {
setDraft(normalizeDraft(remote))
setDraft(normalizeSearchProviders(remote))
}
}, [remote])
const brave = draft.providers[0] ?? defaultBraveProvider()
function updateBrave(patch: Partial<ApiSearchProvider>) {
setDraft((prev) => {
const current = prev.providers[0] ?? defaultBraveProvider()
const updated = { ...current, ...patch }
return {
default_provider_id: updated.id,
providers: [updated],
}
})
}
async function handleSave() {
await updateSettings.mutateAsync(draft)
const saved = await updateSettings.mutateAsync(draft)
setDraft(normalizeSearchProviders(saved))
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
@ -72,44 +39,16 @@ export function SearchProvidersPanel() {
}
return (
<div className="space-y-6">
<div className="w-full space-y-6">
<div>
<h3 className="text-base font-medium">Fournisseurs de recherche</h3>
<h3 className="text-base font-medium">Fournisseurs de recherche web</h3>
<p className={cn("mt-1 text-sm", CONTACTS_MUTED_TEXT)}>
Recherche web utilisée lors de l&apos;amélioration IA des fiches contacts (profils
publics, réseaux sociaux, poste, entreprise).
Brave, Bing, DuckDuckGo, SearXNG ou API JSON custom pour l&apos;enrichissement IA des
contacts et le tool UltiAI <code className="rounded bg-muted px-1 text-xs">web_search</code>.
</p>
</div>
<div className="space-y-3 rounded-lg border border-border p-4">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">{brave.name}</span>
<a
href="https://api.search.brave.com"
target="_blank"
rel="noopener noreferrer"
className={cn("inline-flex items-center gap-1 text-xs hover:underline", CONTACTS_MUTED_TEXT)}
>
Obtenir une clé API
<ExternalLink className="h-3 w-3" />
</a>
</div>
<div>
<Label className="text-xs">Token API (X-Subscription-Token)</Label>
<Input
className="mt-1 h-9"
type="password"
value={brave.api_key ?? ""}
onChange={(e) => updateBrave({ api_key: e.target.value })}
placeholder="BSA…"
autoComplete="off"
/>
<p className={cn("mt-1.5 text-xs", CONTACTS_MUTED_TEXT)}>
Les 5 premiers résultats web sont ajoutés au prompt LLM avec un avertissement sur les
homonymes. Sans token, l&apos;amélioration IA fonctionne sans recherche en ligne.
</p>
</div>
</div>
<WebSearchProvidersEditor value={draft} onChange={setDraft} />
<Button
onClick={handleSave}

View File

@ -34,7 +34,7 @@ export function AutomationSettingsSection() {
<TabsContent value="llm" className="mt-4">
<LLMProvidersPanel />
</TabsContent>
<TabsContent value="search" className="mt-4">
<TabsContent value="search" className="mt-4 w-full">
<SearchProvidersPanel />
</TabsContent>
<TabsContent value="tokens" className="mt-4">

View File

@ -0,0 +1,359 @@
"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 { 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 type { ApiLLMProvider, ApiLLMProviderType } from "@/lib/contacts/discovery-types"
import {
emptyLlmProvider,
inferLlmProviderType,
isLlmProviderConfigured,
llmCatalogEntry,
LLM_PROVIDER_CATALOG,
normalizeLlmProvider,
} from "@/lib/llm/llm-provider-catalog"
import { cn } from "@/lib/utils"
type LlmProviderSecrets = Record<string, { configured?: boolean } | undefined>
export type LlmProvidersEditorProps = {
providers: ApiLLMProvider[]
defaultProviderId: string
onProvidersChange: (providers: ApiLLMProvider[]) => void
onDefaultProviderIdChange: (id: string) => void
className?: string
columns?: 1 | 2
providerSecrets?: LlmProviderSecrets
renderDefaultModelInput?: (props: {
provider: ApiLLMProvider
index: number
onChange: (default_model: string) => void
}) => React.ReactNode
}
function providerOptions(
providers: ApiLLMProvider[],
providerSecrets?: LlmProviderSecrets,
) {
return providers.map((provider) => ({
provider: normalizeLlmProvider(provider),
configured: isLlmProviderConfigured(provider, {
apiKeyConfigured: providerSecrets?.[provider.id]?.configured,
}),
}))
}
export function LlmProvidersEditor({
providers,
defaultProviderId,
onProvidersChange,
onDefaultProviderIdChange,
className,
columns = 2,
providerSecrets,
renderDefaultModelInput,
}: LlmProvidersEditorProps) {
const options = providerOptions(providers, providerSecrets)
const [editingProviderId, setEditingProviderId] = useState<string | null>(null)
useEffect(() => {
if (editingProviderId && !providers.some((provider) => provider.id === editingProviderId)) {
setEditingProviderId(null)
}
}, [editingProviderId, providers])
function commit(nextProviders: ApiLLMProvider[]) {
onProvidersChange(nextProviders.map(normalizeLlmProvider))
}
function updateProvider(index: number, patch: Partial<ApiLLMProvider>) {
const next = [...providers]
next[index] = { ...next[index], ...patch }
commit(next)
}
function setProviderType(index: number, type: ApiLLMProviderType) {
const current = providers[index]
const entry = llmCatalogEntry(type)
const next = {
...emptyLlmProvider(type),
id: current?.id ?? emptyLlmProvider(type).id,
api_key: current?.api_key ?? "",
}
const updated = [...providers]
updated[index] = next
commit(updated)
}
function addProvider() {
const provider = emptyLlmProvider("openai")
commit([...providers, provider])
setEditingProviderId(provider.id)
if (!defaultProviderId) {
onDefaultProviderIdChange(provider.id)
}
}
function removeProvider(index: number) {
const removed = providers[index]
if (editingProviderId === removed?.id) {
setEditingProviderId(null)
}
const nextProviders = providers.filter((_, i) => i !== index)
const remaining = providerOptions(nextProviders, providerSecrets)
let nextDefaultId = defaultProviderId
if (nextDefaultId === removed?.id) {
nextDefaultId =
remaining.find((entry) => entry.configured)?.provider.id ??
nextProviders[0]?.id ??
""
}
onProvidersChange(nextProviders.map(normalizeLlmProvider))
onDefaultProviderIdChange(nextDefaultId)
}
return (
<div className={cn("w-full space-y-4", className)}>
{providers.length > 0 ? (
<FieldGroup>
<Label className="text-xs">Fournisseur par défaut</Label>
<Select
value={defaultProviderId || providers[0]?.id || "__none__"}
onValueChange={(id) =>
onDefaultProviderIdChange(id === "__none__" ? "" : id)
}
>
<SelectTrigger className="h-9 w-full min-w-0">
<SelectValue placeholder="Choisir…" />
</SelectTrigger>
<SelectContent>
{options.map(({ provider, configured }) => {
const type = inferLlmProviderType(provider)
const entry = llmCatalogEntry(type)
return (
<SelectItem
key={provider.id}
value={provider.id}
disabled={!configured}
>
<TechBrandSelectLabel
brand={type}
icon={entry.icon}
suffix={!configured ? " (incomplet)" : undefined}
>
{provider.name || entry.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}>
{providers.map((provider, index) => {
const normalized = normalizeLlmProvider(provider)
const type = inferLlmProviderType(normalized)
const entry = llmCatalogEntry(type)
const apiKeyConfigured = providerSecrets?.[provider.id]?.configured ?? false
const configured = isLlmProviderConfigured(provider, { apiKeyConfigured })
const isEditing = editingProviderId === provider.id
const displayName =
normalized.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={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={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">Fournisseur</Label>
<Select
value={type}
onValueChange={(value) =>
setProviderType(index, value as ApiLLMProviderType)
}
>
<SelectTrigger className="h-9 w-full min-w-0">
<SelectValue>
<TechBrandSelectLabel brand={type} icon={entry.icon}>
{entry.label}
</TechBrandSelectLabel>
</SelectValue>
</SelectTrigger>
<SelectContent className="max-h-72">
{LLM_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 })}
placeholder={entry.label}
/>
</FieldGroup>
<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={entry.baseURLPlaceholder ?? entry.defaultBaseURL}
/>
</FieldGroup>
{type !== "ollama" ? (
<FieldGroup>
<Label className="text-xs">Clé API</Label>
<Input
className="h-9"
type="password"
autoComplete="off"
value={provider.api_key ?? ""}
onChange={(e) => updateProvider(index, { api_key: e.target.value })}
placeholder={
apiKeyConfigured && !(provider.api_key ?? "").trim()
? "•••••••• (laisser vide pour conserver)"
: type === "openai"
? "sk-…"
: undefined
}
/>
</FieldGroup>
) : null}
<FieldGroup>
<Label className="text-xs">Modèle par défaut</Label>
{renderDefaultModelInput ? (
renderDefaultModelInput({
provider,
index,
onChange: (default_model) => updateProvider(index, { default_model }),
})
) : (
<Input
className="h-9"
value={provider.default_model}
onChange={(e) => updateProvider(index, { default_model: e.target.value })}
placeholder={entry.defaultModel || "gpt-4o-mini"}
/>
)}
</FieldGroup>
</div>
</div>
)
})}
</AutomationTabMasonry>
<Button type="button" variant="outline" size="sm" onClick={addProvider}>
<Plus className="mr-2 size-4" />
Ajouter un fournisseur
</Button>
</div>
)
}
export { emptyLlmProvider }

View File

@ -0,0 +1,343 @@
"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>
)
}

View File

@ -0,0 +1,46 @@
import type { PluginEntry } from "@/lib/admin-settings/org-settings-types"
export const DEFAULT_ORG_PLUGINS: PluginEntry[] = [
{
id: "mail-automation",
name: "Automatisations mail",
description: "Règles, webhooks et tri IA sur la réception.",
enabled: true,
version: "1.0.0",
},
{
id: "contact-discovery",
name: "Découverte contacts",
description: "Enrichissement IA et signatures détectées.",
enabled: true,
version: "1.0.0",
},
{
id: "public-share",
name: "Partage public Drive",
description: "Liens publics et partages externes.",
enabled: true,
version: "1.0.0",
},
{
id: "office-editor",
name: "Édition OnlyOffice",
description: "Édition collaborative de documents.",
enabled: false,
version: "1.0.0",
},
{
id: "richtext-editor",
name: "Édition rich text TipTap",
description: "Édition rich text TipTap pour documents Word.",
enabled: true,
version: "1.0.0",
},
{
id: "ai-assistant",
name: "UltiAI",
description: "Assistant IA intégré avec tools mail, drive, contacts, agenda et recherche web.",
enabled: false,
version: "1.0.0",
},
]

View File

@ -1,13 +1,31 @@
import type { ApiOrgPolicy, ApiOrgSettingsResponse } from "@/lib/api/admin-org-types"
import type { OrgPolicySectionKey } from "@/lib/api/admin-org-types"
import type { IntegrationEntry, OrgSettingsState, FilePolicySettings, DriveMountOAuthSettings, DriveMountOAuthProviderSettings, IdentityProvidersPolicy, IdentityProvider } from "@/lib/admin-settings/org-settings-types"
import { DEFAULT_ULTIAI_ENABLED_TOOLS } from "@/lib/ai/ultiai-tool-groups"
import type { IntegrationEntry, OrgSettingsState, FilePolicySettings, DriveMountOAuthSettings, DriveMountOAuthProviderSettings, IdentityProvidersPolicy, IdentityProvider, PluginEntry } from "@/lib/admin-settings/org-settings-types"
import { DEFAULT_ORG_PLUGINS } from "@/lib/admin-settings/default-plugins"
import { DEFAULT_MEET_POLICY } from "@/lib/meet/meet-settings-types"
import { normalizeLlmProvider } from "@/lib/llm/llm-provider-catalog"
const INTEGRATION_HREFS: Record<string, string> = {
authentik: "/admin/settings/authentication",
nextcloud: "/admin/settings/nextcloud",
onlyoffice: "/admin/settings/onlyoffice",
smtp: "/admin/settings/mailing",
nextcloud: "/admin/settings/plugins",
onlyoffice: "/admin/settings/plugins",
smtp: "/admin/settings/mail-domains",
}
function mergePlugins(fromApi: PluginEntry[] | undefined): PluginEntry[] {
if (!fromApi?.length) return DEFAULT_ORG_PLUGINS.map((plugin) => ({ ...plugin }))
const byId = new Map(fromApi.map((plugin) => [plugin.id, plugin]))
const merged = DEFAULT_ORG_PLUGINS.map((plugin) => ({
...plugin,
...byId.get(plugin.id),
}))
for (const plugin of fromApi) {
if (!DEFAULT_ORG_PLUGINS.some((entry) => entry.id === plugin.id)) {
merged.push(plugin)
}
}
return merged
}
function mergeIntegrations(
@ -129,7 +147,9 @@ export function apiOrgPolicyToStore(policy: ApiOrgPolicy): Partial<OrgSettingsSt
twoFactor: {
required_for_all: policy.two_factor.required_for_all,
required_for_admins: policy.two_factor.required_for_admins,
allowed_methods: policy.two_factor.allowed_methods,
allowed_methods: policy.two_factor.allowed_methods.filter(
(m): m is "totp" | "webauthn" => m === "totp" || m === "webauthn"
),
grace_period_days: policy.two_factor.grace_period_days,
remember_device_days: policy.two_factor.remember_device_days,
},
@ -138,10 +158,12 @@ export function apiOrgPolicyToStore(policy: ApiOrgPolicy): Partial<OrgSettingsSt
filePolicies: mergeFilePolicies(policy.file_policies),
llm: {
...policy.llm,
providers: (policy.llm.providers ?? []).map((provider) => ({
providers: (policy.llm.providers ?? []).map((provider) =>
normalizeLlmProvider({
...provider,
api_key: provider.api_key ?? "",
})),
}),
),
},
search: {
...policy.search,
@ -164,9 +186,9 @@ export function apiOrgPolicyToStore(policy: ApiOrgPolicy): Partial<OrgSettingsSt
enabled: policy.ai_assistant?.enabled ?? false,
openwebui_internal_url: policy.ai_assistant?.openwebui_internal_url ?? "",
public_path: policy.ai_assistant?.public_path ?? "/ai",
embed_default_temporary: policy.ai_assistant?.embed_default_temporary ?? true,
embed_default_temporary: policy.ai_assistant?.embed_default_temporary ?? false,
default_model: policy.ai_assistant?.default_model ?? "",
enabled_tools: policy.ai_assistant?.enabled_tools ?? ["mail", "drive", "contacts", "search"],
enabled_tools: policy.ai_assistant?.enabled_tools ?? [...DEFAULT_ULTIAI_ENABLED_TOOLS],
chat_sync_enabled: policy.ai_assistant?.chat_sync_enabled ?? true,
chat_nc_path: policy.ai_assistant?.chat_nc_path ?? "/.ultimail/ai/chats",
models: (policy.ai_assistant?.models ?? []).map((entry) => ({
@ -190,7 +212,7 @@ export function apiOrgPolicyToStore(policy: ApiOrgPolicy): Partial<OrgSettingsSt
...policy.meet?.post_actions,
},
},
plugins: policy.plugins ?? [],
plugins: mergePlugins(policy.plugins),
integrations: mergeIntegrations(policy.integrations as IntegrationEntry[]),
}
}
@ -214,7 +236,9 @@ export function storeToApiOrgPolicy(state: OrgSettingsState): ApiOrgPolicy {
two_factor: {
required_for_all: state.twoFactor.required_for_all,
required_for_admins: state.twoFactor.required_for_admins,
allowed_methods: state.twoFactor.allowed_methods,
allowed_methods: state.twoFactor.allowed_methods.filter(
(m): m is "totp" | "webauthn" => m === "totp" || m === "webauthn"
),
grace_period_days: state.twoFactor.grace_period_days,
remember_device_days: state.twoFactor.remember_device_days,
},

View File

@ -1,6 +1,7 @@
"use client"
import { create } from "zustand"
import { DEFAULT_ULTIAI_ENABLED_TOOLS } from "@/lib/ai/ultiai-tool-groups"
import type { OrgSettingsMeta } from "@/lib/admin-settings/map-api-org-settings"
import type {
Administrator,
@ -23,6 +24,7 @@ import type {
IdentityProvidersPolicy,
} from "@/lib/admin-settings/org-settings-types"
import { DEFAULT_MEET_POLICY } from "@/lib/meet/meet-settings-types"
import { DEFAULT_ORG_PLUGINS } from "@/lib/admin-settings/default-plugins"
const DEFAULT_AUTHENTIK: AuthentikSettings = {
enabled: true,
@ -137,9 +139,9 @@ const DEFAULT_AI_ASSISTANT: AiAssistantSettings = {
enabled: false,
openwebui_internal_url: "",
public_path: "/ai",
embed_default_temporary: true,
embed_default_temporary: false,
default_model: "",
enabled_tools: ["mail", "drive", "contacts", "search"],
enabled_tools: [...DEFAULT_ULTIAI_ENABLED_TOOLS],
chat_sync_enabled: true,
chat_nc_path: "/.ultimail/ai/chats",
models: [],
@ -155,51 +157,6 @@ const DEFAULT_AGENDA: AgendaOrgPolicySettings = {
const DEFAULT_MEET: MeetOrgPolicySettings = DEFAULT_MEET_POLICY
const DEFAULT_PLUGINS: PluginEntry[] = [
{
id: "mail-automation",
name: "Automatisations mail",
description: "Règles, webhooks et tri IA sur la réception.",
enabled: true,
version: "1.0.0",
},
{
id: "contact-discovery",
name: "Découverte contacts",
description: "Enrichissement IA et signatures détectées.",
enabled: true,
version: "1.0.0",
},
{
id: "public-share",
name: "Partage public Drive",
description: "Liens publics et partages externes.",
enabled: true,
version: "1.0.0",
},
{
id: "office-editor",
name: "Édition OnlyOffice",
description: "Édition collaborative de documents.",
enabled: false,
version: "1.0.0",
},
{
id: "richtext-editor",
name: "Édition rich text TipTap",
description: "Édition rich text TipTap pour documents Word.",
enabled: true,
version: "1.0.0",
},
{
id: "ai-assistant",
name: "UltiAI",
description: "Assistant IA intégré avec tools mail, drive et contacts.",
enabled: false,
version: "1.0.0",
},
]
const DEFAULT_INTEGRATIONS: IntegrationEntry[] = [
{
id: "authentik",
@ -215,7 +172,7 @@ const DEFAULT_INTEGRATIONS: IntegrationEntry[] = [
description: "Drive, agenda, contacts et Talk.",
enabled: false,
configured: false,
href: "/admin/settings/nextcloud",
href: "/admin/settings/plugins",
},
{
id: "onlyoffice",
@ -223,7 +180,7 @@ const DEFAULT_INTEGRATIONS: IntegrationEntry[] = [
description: "Édition de documents dans le navigateur.",
enabled: false,
configured: false,
href: "/admin/settings/onlyoffice",
href: "/admin/settings/plugins",
},
{
id: "smtp",
@ -231,7 +188,7 @@ const DEFAULT_INTEGRATIONS: IntegrationEntry[] = [
description: "SMTP pour notifications suite (partages, mentions).",
enabled: false,
configured: false,
href: "/admin/settings/mailing",
href: "/admin/settings/mail-domains",
},
]
@ -321,7 +278,7 @@ export const useOrgSettingsStore = create<
aiAssistant: DEFAULT_AI_ASSISTANT,
agenda: DEFAULT_AGENDA,
meet: DEFAULT_MEET,
plugins: DEFAULT_PLUGINS,
plugins: DEFAULT_ORG_PLUGINS,
integrations: DEFAULT_INTEGRATIONS,
meta: null,
apiSynced: false,
@ -386,6 +343,18 @@ export const useOrgSettingsStore = create<
aiAssistant: { ...s.aiAssistant, enabled },
}
}
if (id === "office-editor") {
return {
plugins,
onlyoffice: { ...s.onlyoffice, enabled },
}
}
if (id === "richtext-editor") {
return {
plugins,
richtext: { ...s.richtext, enabled },
}
}
return { plugins }
}),
setIntegrations: (integrations) => set({ integrations }),

View File

@ -78,7 +78,7 @@ export type IdentityProvidersPolicy = {
export type TwoFactorPolicy = {
required_for_all: boolean
required_for_admins: boolean
allowed_methods: ("totp" | "webauthn" | "sms")[]
allowed_methods: ("totp" | "webauthn")[]
grace_period_days: number
remember_device_days: number
}

View File

@ -1,16 +1,11 @@
import type { LucideIcon } from "lucide-react"
import {
Activity,
Bot,
Calendar,
Video,
Cloud,
FileCog,
FileText,
Gauge,
HardDrive,
Link2,
Globe,
LayoutDashboard,
Mail,
Puzzle,
@ -26,18 +21,13 @@ export type AdminSettingsSectionId =
| "users"
| "authentication"
| "security"
| "storage-quotas"
| "usage-quotas"
| "quotas"
| "file-policies"
| "public-shares"
| "llm"
| "search"
| "plugins"
| "nextcloud"
| "mailing"
| "mail-domains"
| "onlyoffice"
| "richtext"
| "ai-assistant"
| "agenda"
| "ultimeet"
@ -81,17 +71,10 @@ export const ADMIN_SETTINGS_NAV: AdminSettingsNavItem[] = [
icon: ShieldCheck,
},
{
id: "storage-quotas",
label: "Quotas stockage",
description: "Limites mail, drive et photos",
href: "/admin/settings/storage-quotas",
icon: HardDrive,
},
{
id: "usage-quotas",
label: "Quotas d'usage",
description: "LLM, recherche web et API par utilisateur",
href: "/admin/settings/usage-quotas",
id: "quotas",
label: "Quotas",
description: "Stockage, LLM, recherche web et automatisations",
href: "/admin/settings/quotas",
icon: Gauge,
},
{
@ -108,13 +91,6 @@ export const ADMIN_SETTINGS_NAV: AdminSettingsNavItem[] = [
href: "/admin/settings/public-shares",
icon: Link2,
},
{
id: "llm",
label: "Fournisseurs LLM",
description: "Modèles IA organisationnels",
href: "/admin/settings/llm",
icon: Bot,
},
{
id: "search",
label: "Moteur de recherche",
@ -125,17 +101,10 @@ export const ADMIN_SETTINGS_NAV: AdminSettingsNavItem[] = [
{
id: "plugins",
label: "Plugins",
description: "Modules fonctionnels activables",
description: "Nextcloud, modules et intégrations activables",
href: "/admin/settings/plugins",
icon: Puzzle,
},
{
id: "nextcloud",
label: "Nextcloud",
description: "Drive, agenda, contacts et Talk",
href: "/admin/settings/nextcloud",
icon: Cloud,
},
{
id: "agenda",
label: "Agenda",
@ -152,36 +121,15 @@ export const ADMIN_SETTINGS_NAV: AdminSettingsNavItem[] = [
},
{
id: "mail-domains",
label: "Domaines mail",
description: "Hébergement Stalwart, DNS et migration",
label: "Mail",
description: "Domaines hébergés, SMTP et migration",
href: "/admin/settings/mail-domains",
icon: Globe,
},
{
id: "mailing",
label: "Mailing unifié",
description: "SMTP des notifications suite",
href: "/admin/settings/mailing",
icon: Mail,
},
{
id: "onlyoffice",
label: "OnlyOffice",
description: "Édition collaborative de documents",
href: "/admin/settings/onlyoffice",
icon: Activity,
},
{
id: "richtext",
label: "Éditeur rich text",
description: "TipTap pour documents texte",
href: "/admin/settings/richtext",
icon: FileText,
},
{
id: "ai-assistant",
label: "UltiAI",
description: "Assistant IA intégré et tools suite",
description: "Assistant IA, fournisseurs LLM et tools suite",
href: "/admin/settings/ai-assistant",
icon: Bot,
},
@ -210,6 +158,10 @@ export function resolveAdminSettingsSection(
segments: string[] | undefined
): AdminSettingsSectionId {
const slug = segments?.[0]
if (slug === "llm") return "ai-assistant"
if (slug === "nextcloud" || slug === "onlyoffice" || slug === "richtext") return "plugins"
if (slug === "mailing") return "mail-domains"
if (slug === "storage-quotas" || slug === "usage-quotas") return "quotas"
const match = ADMIN_SETTINGS_NAV.find((item) => {
if (item.id === "overview") return !slug || slug === "overview"
return item.href.endsWith(`/${slug}`)
@ -219,10 +171,19 @@ export function resolveAdminSettingsSection(
const ADMIN_WIDE_SECTIONS: AdminSettingsSectionId[] = [
"overview",
"audit",
"ai-assistant",
"plugins",
"quotas",
"search",
"mail-domains",
"authentication",
"file-policies",
]
const ADMIN_FULL_WIDTH_SECTIONS: AdminSettingsSectionId[] = [
"users",
"public-shares",
"audit",
"llm",
]
export function isAdminSettingsWideLayoutPath(pathname: string | null): boolean {
@ -233,3 +194,12 @@ export function isAdminSettingsWideLayoutPath(pathname: string | null): boolean
isAdminSettingsNavActive(pathname, item)
)
}
export function isAdminSettingsFullWidthLayoutPath(pathname: string | null): boolean {
if (!pathname?.startsWith("/admin/settings")) return false
return ADMIN_SETTINGS_NAV.some(
(item) =>
ADMIN_FULL_WIDTH_SECTIONS.includes(item.id) &&
isAdminSettingsNavActive(pathname, item)
)
}

View File

@ -0,0 +1,93 @@
/** Iconify icon ids for external tech brands in admin selects. */
export function techBrandIcon(brand: string): string | undefined {
switch (brand) {
case "google":
case "google_workspace":
case "google_dwd":
return "logos:google-icon"
case "google_drive":
case "googledrive":
return "logos:google-drive"
case "microsoft":
case "microsoft_365":
case "microsoft_app":
case "onedrive":
return "logos:microsoft-icon"
case "dropbox":
return "logos:dropbox"
case "github":
return "logos:github-icon"
case "linkedin":
return "logos:linkedin-icon"
case "azure":
case "azure_ad":
return "logos:microsoft-azure"
case "okta":
return "logos:okta-icon"
case "brave":
return "simple-icons:brave"
case "bing":
return "simple-icons:microsoftbing"
case "duckduckgo":
return "simple-icons:duckduckgo"
case "searxng":
return "simple-icons:searxng"
case "postgres":
case "postgresql":
return "simple-icons:postgresql"
case "meilisearch":
return "simple-icons:meilisearch"
case "typesense":
return "simple-icons:typesense"
case "nextcloud":
return "simple-icons:nextcloud"
case "onlyoffice":
return "simple-icons:onlyoffice"
case "openai":
case "openai_compatible":
return "simple-icons:openai"
case "anthropic":
return "simple-icons:anthropic"
case "mistral":
return "simple-icons:mistralai"
case "azure_openai":
return "logos:microsoft-azure"
case "azure_ai_anthropic":
return "logos:microsoft-azure"
case "aws_bedrock":
return "simple-icons:amazonaws"
case "google_gemini":
case "gemini":
return "simple-icons:googlegemini"
case "groq":
return "simple-icons:groq"
case "deepseek":
return "simple-icons:deepseek"
case "openrouter":
return "simple-icons:openrouter"
case "together":
return "simple-icons:togetherdotai"
case "fireworks":
return "simple-icons:fireworks"
case "xai":
return "simple-icons:x"
case "ollama":
case "ollama_cloud":
return "simple-icons:ollama"
case "deepgram":
return "simple-icons:deepgram"
case "ldap":
case "active_directory":
return "mdi:microsoft-active-directory"
case "saml":
return "mdi:shield-key"
case "oauth":
return "mdi:key-chain"
case "custom":
return "mdi:puzzle-outline"
case "docx":
return "logos:microsoft-word"
default:
return undefined
}
}

View File

@ -33,11 +33,23 @@ export type AiPostMessage =
| { type: "ULTI_DOCS_APPLY"; payload: unknown }
| { type: "ULTI_ASSISTANT_TEXT"; text: string }
| { type: "ULTI_THEME"; theme: "light" | "dark" }
| {
type: "ULTI_SESSION"
token_secret?: string
mcp_url?: string
enabled_tools?: string[]
session_id?: string
}
| { type: "ULTI_OPEN_LINK"; href: string }
| { type: "ULTI_TOOL_RESULT"; payload: unknown }
export function buildEmbedSearchParams(context: AiChatContext): string {
export function buildEmbedSearchParams(
context: AiChatContext,
defaultModel?: string,
): string {
const params = new URLSearchParams()
const model = defaultModel?.trim()
if (model) params.set("model", model)
if (context.temporary !== false) params.set("temporary-chat", "true")
if (context.app) params.set("app", context.app)
if (context.messageId) params.set("message_id", context.messageId)
@ -52,8 +64,12 @@ export function buildEmbedSearchParams(context: AiChatContext): string {
export function systemPromptFromContext(context: AiChatContext): string {
const lines = [
"Tu es UltiAI, l'assistant intégré à la suite Ultimail (mail, drive, contacts).",
"Tu es UltiAI, l'assistant intégré à la suite Ultimail (mail, drive, contacts, agenda).",
"Réponds en français sauf demande contraire. Utilise les tools disponibles pour agir sur les données utilisateur.",
"Recherche suite (index local) via suite_search ; recherche web publique via web_search si configurée.",
"Après chaque appel d'outil, réponds toujours en langage naturel : résume le résultat, cite les sources (sujet, chemin, nom), propose la suite.",
"Ne termine jamais un tour utilisateur avec uniquement un appel d'outil sans texte explicatif.",
"Respecte strictement le paramètre limit des tools (ne demande pas plus de résultats que nécessaire).",
]
if (context.app === "mail" && context.subject) {
lines.push(`Contexte mail — sujet: ${context.subject}`)

View File

@ -0,0 +1,53 @@
export type UltiAiToolGroup = {
id: string
label: string
description: string
}
/** Tool groups exposed to UltiAI via MCP (X-Ulti-Enabled-Tools). */
export const ULTIAI_TOOL_GROUPS: UltiAiToolGroup[] = [
{
id: "mail",
label: "Mail",
description: "Recherche, lecture, envoi, libellés et suppression de messages.",
},
{
id: "drive",
label: "Drive",
description: "Fichiers, dossiers, partages et déplacements.",
},
{
id: "contacts",
label: "Contacts",
description: "Carnets d'adresses et fiches contacts.",
},
{
id: "agenda",
label: "Agenda",
description: "Calendriers, événements, invitations et visioconférence.",
},
{
id: "search",
label: "Recherche suite",
description: "Index unifié mail, drive et contacts (pas le web public).",
},
{
id: "web_search",
label: "Recherche web",
description:
"Recherche en ligne (Brave, Bing, SearXNG, DuckDuckGo, API JSON). Même réglages que contacts — onglet Recherche.",
},
]
export const DEFAULT_ULTIAI_ENABLED_TOOLS = ULTIAI_TOOL_GROUPS.map((group) => group.id)
export function toggleUltiAiToolGroup(
enabledTools: string[],
groupId: string,
enabled: boolean,
): string[] {
const set = new Set(enabledTools)
if (enabled) set.add(groupId)
else set.delete(groupId)
return ULTIAI_TOOL_GROUPS.map((group) => group.id).filter((id) => set.has(id))
}

View File

@ -45,30 +45,29 @@ export function useAiIframeNavigation(
const { pathname, search, hash, origin } = win.location
if (embedOrigin && origin !== embedOrigin) return
const inBase =
pathname === base || pathname.startsWith(`${base}/`)
const inBase = pathname === base || pathname.startsWith(`${base}/`)
if (inBase) return
// Suite landing or explicit suite module — stay on OpenWebUI home.
if (pathname === "/" || pathname === "" || isSuiteRoute(pathname)) {
win.location.replace(`${base}/${search}${hash}`)
return
}
const target =
pathname === "/" || pathname === "" || isSuiteRoute(pathname)
? `${base}/${search}${hash}`
: `${base}${pathname}${search}${hash}`
// OpenWebUI route without /ai prefix (e.g. /notes, /workspace) — preserve path.
win.location.replace(`${base}${pathname}${search}${hash}`)
const targetUrl = target.startsWith("http") ? target : `${origin}${target}`
if (win.location.href === targetUrl) return
// One-shot fix only — polling caused SvelteKit "Redirect loop" → 500: Internal Error
win.location.replace(targetUrl)
} catch {
// Cross-origin — parent cannot read location.
}
}
iframe.addEventListener("load", enforceBasePath)
const timer = window.setInterval(enforceBasePath, 400)
const onLoad = () => window.setTimeout(enforceBasePath, 0)
iframe.addEventListener("load", onLoad)
return () => {
iframe.removeEventListener("load", enforceBasePath)
window.clearInterval(timer)
iframe.removeEventListener("load", onLoad)
}
}, [iframeRef, publicPath])
}

View File

@ -67,7 +67,7 @@ export type ApiIdentityProvidersPolicy = {
export type ApiOrgTwoFactor = {
required_for_all: boolean
required_for_admins: boolean
allowed_methods: ("totp" | "webauthn" | "sms")[]
allowed_methods: ("totp" | "webauthn")[]
grace_period_days: number
remember_device_days: number
}

View File

@ -2,6 +2,11 @@ export type AdminUserStatus = "active" | "disabled" | "invited"
export type AdminUserRole = "admin" | "user" | "guest" | "suspended"
export type AdminUserGroupRef = {
id: string
name: string
}
export type AdminUser = {
id: string
external_id: string
@ -10,6 +15,7 @@ export type AdminUser = {
status: AdminUserStatus
platform_admin: boolean
role: AdminUserRole
groups?: AdminUserGroupRef[]
storage?: AdminUserStorage
invited_at?: string | null
disabled_at?: string | null
@ -44,6 +50,59 @@ export type AdminUsersListResponse = {
pagination: AdminPagination
}
export type AdminUserGroup = {
id: string
name: string
description: string
member_count: number
created_at: string
updated_at: string
}
export type AdminUserGroupsListResponse = {
groups: AdminUserGroup[]
pagination: AdminPagination
}
export type AdminCreateUserGroupRequest = {
name: string
description?: string
}
export type AdminUpdateUserGroupRequest = {
name?: string
description?: string
}
export type AdminSetUserGroupMembersRequest = {
user_ids: string[]
}
export type AdminBulkUsersAction =
| "disable"
| "reactivate"
| "delete"
| "set_role"
| "add_to_group"
| "remove_from_group"
export type AdminBulkUsersRequest = {
user_ids: string[]
action: AdminBulkUsersAction
role?: AdminUserRole
group_id?: string
}
export type AdminBulkUsersFailure = {
user_id: string
message: string
}
export type AdminBulkUsersResponse = {
success_count: number
failed?: AdminBulkUsersFailure[]
}
export type AdminAuditLog = {
id: string
actor: string

View File

@ -3,7 +3,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { apiClient } from "@/lib/api/client"
import { useAuthReady } from "@/lib/api/use-auth-ready"
import type { DriveOrgFolder } from "@/lib/api/types"
import type { DriveMount, DriveOrgFolder } from "@/lib/api/types"
export function useAdminDriveOrgFolders() {
const { ready, authenticated } = useAuthReady()
@ -50,3 +50,49 @@ export function useAdminDriveOrgFolderMutations() {
return { create, update, remove, sync }
}
export function useAdminDriveOrgMounts() {
const { ready, authenticated } = useAuthReady()
return useQuery({
queryKey: ["admin", "drive", "org-mounts"],
enabled: ready && authenticated,
queryFn: async () => {
const res = await apiClient.get<{ mounts: DriveMount[] }>("/admin/drive/org-mounts")
return res.mounts ?? []
},
})
}
export type AdminOrgWebDAVMountBody = {
org_slug: string
display_name: string
webdav: {
host: string
root: string
user: string
password: string
secure: boolean
}
}
export function useAdminDriveOrgMountMutations() {
const qc = useQueryClient()
const invalidate = () => {
qc.invalidateQueries({ queryKey: ["admin", "drive", "org-mounts"] })
qc.invalidateQueries({ queryKey: ["drive", "mounts"] })
}
const create = useMutation({
mutationFn: (body: AdminOrgWebDAVMountBody) =>
apiClient.post<DriveMount>("/admin/drive/org-mounts", body),
onSuccess: invalidate,
})
const remove = useMutation({
mutationFn: (id: string) =>
apiClient.delete(`/admin/drive/org-mounts/${encodeURIComponent(id)}`),
onSuccess: invalidate,
})
return { create, remove }
}

View File

@ -3,13 +3,19 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { apiClient } from "@/lib/api/client"
import type {
AdminBulkUsersRequest,
AdminBulkUsersResponse,
AdminCreateUserGroupRequest,
AdminCreateUserRequest,
AdminInviteUserRequest,
AdminSetQuotaRequest,
AdminSetUserGroupMembersRequest,
AdminSetUserRoleRequest,
AdminUpdateUserGroupRequest,
AdminUpdateUserRequest,
AdminUser,
AdminUserDetail,
AdminUserGroup,
} from "@/lib/api/admin-types"
export function useCreateAdminUser() {
@ -126,3 +132,62 @@ export function useDeleteAdminUser() {
},
})
}
export function useCreateAdminUserGroup() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (body: AdminCreateUserGroupRequest) =>
apiClient.post<AdminUserGroup>("/admin/user-groups", body),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["admin", "user-groups"] })
},
})
}
export function useUpdateAdminUserGroup(groupId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (body: AdminUpdateUserGroupRequest) =>
apiClient.put<AdminUserGroup>(`/admin/user-groups/${groupId}`, body),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["admin", "user-groups"] })
void queryClient.invalidateQueries({ queryKey: ["admin", "users"] })
},
})
}
export function useDeleteAdminUserGroup() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (groupId: string) => apiClient.delete(`/admin/user-groups/${groupId}`),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["admin", "user-groups"] })
void queryClient.invalidateQueries({ queryKey: ["admin", "users"] })
},
})
}
export function useSetAdminUserGroupMembers(groupId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (body: AdminSetUserGroupMembersRequest) =>
apiClient.put<AdminUserGroup>(`/admin/user-groups/${groupId}/members`, body),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["admin", "user-groups"] })
void queryClient.invalidateQueries({ queryKey: ["admin", "users"] })
},
})
}
export function useBulkAdminUsers() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (body: AdminBulkUsersRequest) =>
apiClient.post<AdminBulkUsersResponse>("/admin/users/bulk", body),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["admin", "users"] })
void queryClient.invalidateQueries({ queryKey: ["admin", "user-groups"] })
void queryClient.invalidateQueries({ queryKey: ["admin", "stats"] })
},
})
}

View File

@ -7,6 +7,7 @@ import type {
AdminPublicSharesListResponse,
AdminStatsResponse,
AdminUserDetail,
AdminUserGroupsListResponse,
AdminUsersListResponse,
} from "@/lib/api/admin-types"
import { useAuthReady } from "@/lib/api/use-auth-ready"
@ -15,8 +16,16 @@ export type AdminUsersQueryParams = {
page?: number
page_size?: number
q?: string
sort?: string
status?: string
role?: string
group_id?: string
}
export type AdminUserGroupsQueryParams = {
page?: number
page_size?: number
q?: string
}
export function useAdminStats() {
@ -37,8 +46,24 @@ export function useAdminUsers(params: AdminUsersQueryParams = {}) {
page: params.page?.toString(),
page_size: params.page_size?.toString(),
q: params.q,
sort: params.sort,
status: params.status,
role: params.role,
group_id: params.group_id,
}),
enabled: ready && authenticated,
})
}
export function useAdminUserGroups(params: AdminUserGroupsQueryParams = {}) {
const { ready, authenticated } = useAuthReady()
return useQuery({
queryKey: ["admin", "user-groups", params],
queryFn: () =>
apiClient.get<AdminUserGroupsListResponse>("/admin/user-groups", {
page: params.page?.toString(),
page_size: params.page_size?.toString(),
q: params.q,
}),
enabled: ready && authenticated,
})
@ -53,7 +78,9 @@ export function useAdminUser(userId: string | null) {
})
}
export function useAdminPublicShares(params: { page?: number; page_size?: number; q?: string } = {}) {
export function useAdminPublicShares(
params: { page?: number; page_size?: number; q?: string; sort?: string } = {}
) {
const { ready, authenticated } = useAuthReady()
return useQuery({
queryKey: ["admin", "public-shares", params],
@ -62,6 +89,7 @@ export function useAdminPublicShares(params: { page?: number; page_size?: number
page: params.page?.toString(),
page_size: params.page_size?.toString(),
q: params.q,
sort: params.sort,
}),
enabled: ready && authenticated,
})

View File

@ -7,6 +7,7 @@ import type { AiChatContext } from "@/lib/ai/chat-context"
export type AiConfig = {
enabled: boolean
public_path: string
mcp_url?: string
embed_default_temporary: boolean
default_model: string
enabled_tools: string[]
@ -29,6 +30,8 @@ export type AiSessionResponse = {
embed_url: string
token_secret?: string
temporary: boolean
mcp_url?: string
enabled_tools?: string[]
}
export function useAiConfig() {

View File

@ -790,8 +790,8 @@ export function useUpdateSearchSettings() {
return useMutation({
mutationFn: (settings: ApiSearchSettings) =>
apiClient.put<ApiSearchSettings>('/contacts/discovery/search-settings', settings),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['search-settings'] })
onSuccess: (data) => {
queryClient.setQueryData(['search-settings'], normalizeSearchSettings(data))
},
})
}

View File

@ -1,6 +1,25 @@
export type ApiLLMProviderType =
| "openai"
| "anthropic"
| "mistral"
| "azure_openai"
| "azure_ai_anthropic"
| "aws_bedrock"
| "google_gemini"
| "groq"
| "deepseek"
| "openrouter"
| "together"
| "fireworks"
| "xai"
| "ollama"
| "ollama_cloud"
| "custom"
export interface ApiLLMProvider {
id: string
name: string
type?: ApiLLMProviderType
base_url: string
api_key?: string
default_model: string
@ -17,13 +36,25 @@ export interface ApiLLMModelsResponse {
models: string[]
}
export type ApiSearchProviderType = 'brave'
export type ApiSearchProviderType =
| "brave"
| "bing"
| "duckduckgo"
| "searxng"
| "custom"
export interface ApiSearchProvider {
id: string
name: string
type: ApiSearchProviderType
api_key?: string
base_url?: string
query_param?: string
auth_header?: string
results_path?: string
title_field?: string
url_field?: string
description_field?: string
}
export interface ApiSearchSettings {

View File

@ -0,0 +1,233 @@
import type { ApiLLMProvider, ApiLLMProviderType } from "@/lib/contacts/discovery-types"
import { techBrandIcon } from "@/lib/admin-settings/tech-brand-icons"
export type { ApiLLMProviderType }
export type LLMProviderCatalogEntry = {
type: ApiLLMProviderType
label: string
description: string
icon?: string
docsUrl?: string
defaultBaseURL: string
defaultModel: string
baseURLPlaceholder?: string
}
export const LLM_PROVIDER_CATALOG: LLMProviderCatalogEntry[] = [
{
type: "openai",
label: "OpenAI",
description: "API officielle OpenAI (GPT, o-series).",
icon: techBrandIcon("openai"),
docsUrl: "https://platform.openai.com/docs/api-reference",
defaultBaseURL: "https://api.openai.com/v1",
defaultModel: "gpt-4o-mini",
},
{
type: "anthropic",
label: "Anthropic",
description: "Claude via la couche OpenAI-compatible d'Anthropic.",
icon: techBrandIcon("anthropic"),
docsUrl: "https://platform.claude.com/docs/en/api/openai-sdk",
defaultBaseURL: "https://api.anthropic.com/v1",
defaultModel: "claude-sonnet-4-6",
},
{
type: "mistral",
label: "Mistral AI",
description: "API Mistral (OpenAI-compatible).",
icon: techBrandIcon("mistral"),
docsUrl: "https://docs.mistral.ai/api/",
defaultBaseURL: "https://api.mistral.ai/v1",
defaultModel: "mistral-small-latest",
},
{
type: "azure_openai",
label: "Azure OpenAI",
description: "Modèles OpenAI déployés sur Azure AI Foundry.",
icon: techBrandIcon("azure_openai"),
docsUrl: "https://learn.microsoft.com/azure/ai-foundry/openai/reference",
defaultBaseURL: "https://VOTRE_RESSOURCE.openai.azure.com/openai/v1",
defaultModel: "gpt-4o-mini",
baseURLPlaceholder: "https://ma-ressource.openai.azure.com/openai/v1",
},
{
type: "azure_ai_anthropic",
label: "Anthropic via Azure",
description:
"Claude sur Microsoft Foundry. Remplacez VOTRE_RESSOURCE ; vérifiez la compatibilité OpenAI de votre déploiement.",
icon: techBrandIcon("azure_ai_anthropic"),
docsUrl:
"https://learn.microsoft.com/azure/ai-foundry/foundry-models/how-to/use-foundry-models-claude",
defaultBaseURL: "https://VOTRE_RESSOURCE.services.ai.azure.com/anthropic/v1",
defaultModel: "claude-sonnet-4-6",
baseURLPlaceholder:
"https://ma-ressource.services.ai.azure.com/anthropic/v1",
},
{
type: "aws_bedrock",
label: "Anthropic via AWS Bedrock",
description: "Claude et autres modèles via Bedrock (OpenAI-compatible).",
icon: techBrandIcon("aws_bedrock"),
docsUrl: "https://docs.aws.amazon.com/bedrock/latest/userguide/inference-chat-completions-mantle.html",
defaultBaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com/openai/v1",
defaultModel: "us.anthropic.claude-sonnet-4-6",
baseURLPlaceholder: "https://bedrock-runtime.eu-west-1.amazonaws.com/openai/v1",
},
{
type: "google_gemini",
label: "Google Gemini",
description: "API Gemini en mode OpenAI-compatible.",
icon: techBrandIcon("google_gemini"),
docsUrl: "https://ai.google.dev/gemini-api/docs/openai",
defaultBaseURL: "https://generativelanguage.googleapis.com/v1beta/openai/",
defaultModel: "gemini-2.0-flash",
},
{
type: "groq",
label: "Groq",
description: "Inférence rapide (Llama, Mixtral, etc.).",
icon: techBrandIcon("groq"),
docsUrl: "https://console.groq.com/docs/openai",
defaultBaseURL: "https://api.groq.com/openai/v1",
defaultModel: "llama-3.3-70b-versatile",
},
{
type: "deepseek",
label: "DeepSeek",
description: "Modèles DeepSeek (OpenAI-compatible).",
icon: techBrandIcon("deepseek"),
docsUrl: "https://api-docs.deepseek.com/",
defaultBaseURL: "https://api.deepseek.com/v1",
defaultModel: "deepseek-chat",
},
{
type: "openrouter",
label: "OpenRouter",
description: "Passerelle multi-fournisseurs (Claude, GPT, Llama…).",
icon: techBrandIcon("openrouter"),
docsUrl: "https://openrouter.ai/docs",
defaultBaseURL: "https://openrouter.ai/api/v1",
defaultModel: "anthropic/claude-sonnet-4",
},
{
type: "together",
label: "Together AI",
description: "Modèles open-source hébergés.",
icon: techBrandIcon("together"),
docsUrl: "https://docs.together.ai/docs/openai-api",
defaultBaseURL: "https://api.together.xyz/v1",
defaultModel: "meta-llama/Llama-3.3-70B-Instruct-Turbo",
},
{
type: "fireworks",
label: "Fireworks AI",
description: "Inférence serverless pour modèles open-source.",
icon: techBrandIcon("fireworks"),
docsUrl: "https://docs.fireworks.ai/tools-sdks/openai-compatibility",
defaultBaseURL: "https://api.fireworks.ai/inference/v1",
defaultModel: "accounts/fireworks/models/llama-v3p3-70b-instruct",
},
{
type: "xai",
label: "xAI (Grok)",
description: "API Grok (OpenAI-compatible).",
icon: techBrandIcon("xai"),
docsUrl: "https://docs.x.ai/docs/guides/chat-completions",
defaultBaseURL: "https://api.x.ai/v1",
defaultModel: "grok-2-latest",
},
{
type: "ollama",
label: "Ollama (local)",
description: "Serveur Ollama local avec API OpenAI-compatible.",
icon: techBrandIcon("ollama"),
docsUrl: "https://github.com/ollama/ollama/blob/main/docs/openai.md",
defaultBaseURL: "http://localhost:11434/v1",
defaultModel: "llama3.2",
},
{
type: "ollama_cloud",
label: "Ollama Cloud",
description: "Modèles hébergés sur ollama.com (clé API requise).",
icon: techBrandIcon("ollama"),
docsUrl: "https://docs.ollama.com/cloud",
defaultBaseURL: "https://ollama.com/v1",
defaultModel: "gpt-oss:120b",
},
{
type: "custom",
label: "Endpoint personnalisé",
description: "Toute API compatible OpenAI (/v1/chat/completions).",
icon: techBrandIcon("custom"),
defaultBaseURL: "",
defaultModel: "",
baseURLPlaceholder: "https://api.example.com/v1",
},
]
export function llmCatalogEntry(type: ApiLLMProviderType): LLMProviderCatalogEntry {
return LLM_PROVIDER_CATALOG.find((entry) => entry.type === type) ?? LLM_PROVIDER_CATALOG.at(-1)!
}
export function emptyLlmProvider(type: ApiLLMProviderType = "openai"): ApiLLMProvider {
const entry = llmCatalogEntry(type)
return {
id: crypto.randomUUID(),
type,
name: entry.label,
base_url: entry.defaultBaseURL,
api_key: "",
default_model: entry.defaultModel,
}
}
export function inferLlmProviderType(provider: ApiLLMProvider): ApiLLMProviderType {
if (provider.type) return provider.type
const url = provider.base_url?.toLowerCase() ?? ""
if (url.includes("api.openai.com")) return "openai"
if (url.includes("api.anthropic.com")) return "anthropic"
if (url.includes("api.mistral.ai")) return "mistral"
if (url.includes("openai.azure.com")) return "azure_openai"
if (url.includes("services.ai.azure.com/anthropic")) return "azure_ai_anthropic"
if (url.includes("bedrock")) return "aws_bedrock"
if (url.includes("ollama.com")) return "ollama_cloud"
if (url.includes("localhost:11434") || url.includes("127.0.0.1:11434")) return "ollama"
if (url.includes("generativelanguage.googleapis.com")) return "google_gemini"
if (url.includes("api.groq.com")) return "groq"
if (url.includes("api.deepseek.com")) return "deepseek"
if (url.includes("openrouter.ai")) return "openrouter"
if (url.includes("api.together.xyz")) return "together"
if (url.includes("api.fireworks.ai")) return "fireworks"
if (url.includes("api.x.ai")) return "xai"
return "custom"
}
export function normalizeLlmProvider(provider: ApiLLMProvider): ApiLLMProvider {
const type = inferLlmProviderType(provider)
const entry = llmCatalogEntry(type)
return {
...provider,
type,
name: provider.name?.trim() || entry.label,
}
}
export function isLlmProviderConfigured(
provider: ApiLLMProvider,
options?: { apiKeyConfigured?: boolean },
): boolean {
const type = inferLlmProviderType(provider)
const entry = llmCatalogEntry(type)
if (!provider.base_url?.trim() && type !== "custom") {
return false
}
if (type === "ollama") {
return Boolean(provider.base_url?.trim())
}
if (type === "custom") {
return Boolean(provider.base_url?.trim())
}
return Boolean(provider.api_key?.trim() || options?.apiKeyConfigured)
}

View File

@ -320,15 +320,15 @@ export const MAIL_SETTINGS_CARD_CLASS = cn(
"dark:bg-mail-surface-elevated dark:shadow-[0_1px_4px_rgba(0,0,0,0.35)]",
)
/** Masonry 2 colonnes pour sections réglages (affichage, signatures…) en lg+. */
export const MAIL_SETTINGS_PAGE_MASONRY_CLASS = "lg:columns-2 lg:gap-5"
/** @deprecated Utiliser `AutomationTabMasonry` (2 piles verticales indépendantes). */
export const MAIL_SETTINGS_PAGE_MASONRY_CLASS = "w-full"
export const MAIL_SETTINGS_PAGE_MASONRY_ITEM_CLASS =
"lg:mb-5 lg:break-inside-avoid"
/** @deprecated Géré par `AutomationTabMasonry`. */
export const MAIL_SETTINGS_PAGE_MASONRY_ITEM_CLASS = "min-w-0"
/** Bloc empilé → card en masonry (variant page affichage). */
export const MAIL_SETTINGS_PAGE_MASONRY_SECTION_CLASS = cn(
"mail-settings-masonry-section border-border px-0 py-5",
"lg:mb-5 lg:break-inside-avoid lg:rounded-xl lg:border lg:border-mail-border lg:bg-mail-surface lg:px-5 lg:py-5 lg:shadow-sm",
"lg:rounded-xl lg:border lg:border-mail-border lg:bg-mail-surface lg:px-5 lg:py-5 lg:shadow-sm",
"dark:lg:bg-mail-surface-elevated dark:lg:shadow-[0_1px_4px_rgba(0,0,0,0.35)]",
)

View File

@ -65,7 +65,7 @@ export const MAIL_SETTINGS_SEARCH_INDEX: MailSettingsSearchEntry[] = [
entry("automation", "rules", "Règles de tri", "automatisation filtre tri forward réponse"),
entry("automation", "webhooks", "Webhooks", "http post template payload externe"),
entry("automation", "llm", "Fournisseurs LLM", "ia openai tri intelligent llm"),
entry("automation", "search-providers", "Fournisseurs de recherche", "web search api recherche"),
entry("automation", "search-providers", "Fournisseurs de recherche", "web search api brave bing searxng duckduckgo ultiai contacts"),
entry("automation", "api-tokens", "Tokens API", "agent ia accès programmatique fine-grained agenda calendrier"),
entry("automation", "agenda-rules", "Règles agenda", "événement calendrier invitation visio participant"),
entry("automation", "agenda-webhooks", "Webhooks agenda", "événement calendrier créé modifié supprimé réponse"),

View File

@ -0,0 +1,173 @@
import type { ApiSearchProvider, ApiSearchSettings } from "@/lib/contacts/discovery-types"
import { techBrandIcon } from "@/lib/admin-settings/tech-brand-icons"
export type ApiSearchProviderType =
| "brave"
| "bing"
| "duckduckgo"
| "searxng"
| "custom"
export type SearchProviderCatalogEntry = {
type: ApiSearchProviderType
label: string
description: string
icon?: string
docsUrl?: string
requiresApiKey: boolean
requiresBaseURL: boolean
supportsCustomMapping: boolean
}
export const SEARCH_PROVIDER_CATALOG: SearchProviderCatalogEntry[] = [
{
type: "brave",
label: "Brave Search",
description: "API officielle Brave (X-Subscription-Token).",
icon: techBrandIcon("brave"),
docsUrl: "https://api.search.brave.com",
requiresApiKey: true,
requiresBaseURL: false,
supportsCustomMapping: false,
},
{
type: "bing",
label: "Bing Web Search",
description: "Azure Cognitive Services Bing Search v7.",
icon: techBrandIcon("bing"),
docsUrl: "https://learn.microsoft.com/azure/cognitive-services/bing-web-search/",
requiresApiKey: true,
requiresBaseURL: false,
supportsCustomMapping: false,
},
{
type: "duckduckgo",
label: "DuckDuckGo",
description: "Scraping HTML léger, sans clé API (best-effort).",
icon: techBrandIcon("duckduckgo"),
requiresApiKey: false,
requiresBaseURL: false,
supportsCustomMapping: false,
},
{
type: "searxng",
label: "SearXNG",
description: "Instance SearXNG auto-hébergée (format JSON).",
icon: techBrandIcon("searxng"),
docsUrl: "https://docs.searxng.org/",
requiresApiKey: false,
requiresBaseURL: true,
supportsCustomMapping: false,
},
{
type: "custom",
label: "API JSON personnalisée",
description: "Endpoint GET JSON avec mapping des champs résultats.",
icon: techBrandIcon("custom"),
requiresApiKey: false,
requiresBaseURL: true,
supportsCustomMapping: true,
},
]
export function catalogEntry(type: ApiSearchProviderType): SearchProviderCatalogEntry {
return (
SEARCH_PROVIDER_CATALOG.find((entry) => entry.type === type) ??
SEARCH_PROVIDER_CATALOG[0]
)
}
export function emptySearchProvider(type: ApiSearchProviderType = "brave"): ApiSearchProvider {
const entry = catalogEntry(type)
return {
id: crypto.randomUUID(),
name: entry.label,
type,
api_key: "",
base_url: defaultBaseURL(type),
query_param: "q",
auth_header: defaultAuthHeader(type),
results_path: type === "custom" ? "results" : "",
title_field: type === "custom" ? "title" : "",
url_field: type === "custom" ? "url" : "",
description_field: type === "custom" ? "description" : "",
}
}
function defaultBaseURL(type: ApiSearchProviderType): string {
switch (type) {
case "bing":
return "https://api.bing.microsoft.com/v7.0/search"
case "searxng":
return "https://searx.example.org"
case "custom":
return "https://api.example.com/search"
default:
return ""
}
}
function defaultAuthHeader(type: ApiSearchProviderType): string {
switch (type) {
case "bing":
return "Ocp-Apim-Subscription-Key"
default:
return ""
}
}
export function ensureSearchSettingsDefaults(settings: ApiSearchSettings): ApiSearchSettings {
const providers = settings.providers ?? []
let defaultId = settings.default_provider_id ?? ""
if (!defaultId && providers.length === 1) {
defaultId = providers[0]?.id ?? ""
}
if (defaultId && !providers.some((provider) => provider.id === defaultId)) {
defaultId = providers[0]?.id ?? ""
}
return {
...settings,
default_provider_id: defaultId,
providers,
}
}
export function normalizeSearchProviders(raw: ApiSearchSettings | undefined): ApiSearchSettings {
const providers = raw?.providers?.length
? raw.providers.map((provider) => ({
...emptySearchProvider(provider.type ?? "brave"),
...provider,
type: provider.type ?? "brave",
}))
: []
return ensureSearchSettingsDefaults({
default_provider_id: raw?.default_provider_id || providers[0]?.id || "",
providers,
})
}
export function isSearchProviderConfigured(
provider: ApiSearchProvider,
options?: { apiKeyConfigured?: boolean },
): boolean {
switch (provider.type) {
case "brave":
case "bing":
return Boolean(provider.api_key?.trim() || options?.apiKeyConfigured)
case "duckduckgo":
return true
case "searxng":
return Boolean(provider.base_url?.trim())
case "custom":
return Boolean(
provider.base_url?.trim() &&
provider.results_path?.trim() &&
provider.title_field?.trim() &&
provider.url_field?.trim(),
)
default:
return false
}
}

File diff suppressed because one or more lines are too long