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 Link from "next/link"
import { Sparkles } from "lucide-react" import { Sparkles } from "lucide-react"
import { AiChatIframe } from "@/components/ai/ai-chat-iframe" 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" import { Button } from "@/components/ui/button"
export default function ChatPage() { export default function ChatPage() {
const { data: config, isLoading, isError } = useAiConfig() const { data: config, isLoading, isError } = useAiConfig()
const { data: quota } = useAiQuota(Boolean(config?.enabled))
if (isLoading) { if (isLoading) {
return ( return (
@ -47,23 +46,10 @@ export default function ChatPage() {
} }
return ( return (
<div className="flex h-dvh flex-col"> <AiChatIframe
<header className="flex items-center justify-between border-b px-4 py-2"> publicPath={config.public_path}
<div className="flex items-center gap-2 text-sm font-medium"> context={{ app: "standalone", temporary: false }}
<Sparkles className="h-4 w-4 text-[#1a73e8]" /> className="h-dvh w-full border-0"
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"
/>
</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 { cn } from "@/lib/utils"
import { import {
ADMIN_SETTINGS_NAV, ADMIN_SETTINGS_NAV,
isAdminSettingsFullWidthLayoutPath,
isAdminSettingsNavActive, isAdminSettingsNavActive,
isAdminSettingsWideLayoutPath, isAdminSettingsWideLayoutPath,
} from "@/lib/admin-settings/settings-nav" } from "@/lib/admin-settings/settings-nav"
@ -106,11 +107,13 @@ export function AdminSettingsLayout({ children }: { children: React.ReactNode })
</div> </div>
</nav> </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 <div
className={cn( className={cn(
"mx-auto w-full max-w-3xl", "mx-auto flex min-h-full w-full flex-col",
isAdminSettingsWideLayoutPath(pathname) && "lg:max-w-6xl" !isAdminSettingsFullWidthLayoutPath(pathname) && "max-w-3xl",
isAdminSettingsWideLayoutPath(pathname) && "lg:max-w-6xl",
isAdminSettingsFullWidthLayoutPath(pathname) && "max-w-none"
)} )}
> >
{children} {children}

View File

@ -1,52 +1,95 @@
"use client" "use client"
import dynamic from "next/dynamic"
import type { ComponentType } from "react"
import { import {
resolveAdminSettingsSection, resolveAdminSettingsSection,
type AdminSettingsSectionId, type AdminSettingsSectionId,
} from "@/lib/admin-settings/settings-nav" } from "@/lib/admin-settings/settings-nav"
import { AdminAccessGuard } from "@/components/admin/settings/admin-access-guard" 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> = { function loadSection<P = object>(
overview: OverviewSection, loader: () => Promise<{ default: ComponentType<P> }>
users: UsersSection, ) {
authentication: AuthenticationSection, return dynamic(loader, { ssr: false })
security: SecuritySection, }
"storage-quotas": StorageQuotasSection,
"usage-quotas": UsageQuotasSection, const SECTIONS: Record<AdminSettingsSectionId, ComponentType> = {
"file-policies": FilePoliciesSection, overview: loadSection(() =>
"public-shares": PublicSharesSection, import("@/components/admin/settings/sections/overview-section").then((m) => ({
llm: LlmSection, default: m.OverviewSection,
search: SearchSection, }))
plugins: PluginsSection, ),
nextcloud: NextcloudSection, users: loadSection(() =>
agenda: AgendaSection, import("@/components/admin/settings/sections/users-section").then((m) => ({
ultimeet: UltimeetSection, default: m.UsersSection,
mailing: MailingSection, }))
"mail-domains": MailDomainsSection, ),
onlyoffice: OnlyofficeSection, authentication: loadSection(() =>
richtext: RichtextSection, import("@/components/admin/settings/sections/authentication-section").then((m) => ({
"ai-assistant": AiAssistantSection, default: m.AuthenticationSection,
audit: AuditSection, }))
),
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({ 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,29 +49,33 @@ export function OrgSettingsSection({
} }
return ( return (
<> <div className="flex min-h-full flex-col">
<SettingsSectionHeader title={title} description={description} /> <div className="flex-1 space-y-6 pb-6">
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} /> <SettingsSectionHeader title={title} description={description} />
{!showPendingBanner ? null : <AdminPendingApiBanner />} <SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
{showEffectiveBanner ? <AdminRuntimePanel /> : null} {!showPendingBanner ? null : <AdminPendingApiBanner />}
<div className="space-y-6">{children}</div> {showEffectiveBanner ? <AdminRuntimePanel /> : null}
{children}
</div>
{hasSave ? ( {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">
<Button <div className="flex flex-wrap items-center gap-3">
type="button" <Button
onClick={() => void handleSave()} type="button"
disabled={!apiSynced || isFetching} onClick={() => void handleSave()}
> disabled={!apiSynced || isFetching}
Enregistrer >
</Button> Enregistrer
{saved ? ( </Button>
<span className="text-sm text-green-600 dark:text-green-500"> {saved ? (
Réglages enregistrés sur le serveur <span className="text-sm text-green-600 dark:text-green-500">
</span> Réglages enregistrés sur le serveur
) : null} </span>
{error ? <span className="text-sm text-destructive">{error}</span> : null} ) : null}
{error ? <span className="text-sm text-destructive">{error}</span> : null}
</div>
</div> </div>
) : null} ) : 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" "use client"
import { useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { Plus, RefreshCw, Trash2 } from "lucide-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 { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
import { DeployLockedHint } from "@/components/admin/settings/deploy-locked-hint" import { DeployLockedHint } from "@/components/admin/settings/deploy-locked-hint"
import { useDeployFieldLocked } 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 { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import type { AiModelCatalogEntry } from "@/lib/admin-settings/org-settings-types" import type { AiModelCatalogEntry } from "@/lib/admin-settings/org-settings-types"
import { useDiscoverOrgLLMModels } from "@/lib/api/hooks/use-admin-llm" import { useDiscoverOrgLLMModels } from "@/lib/api/hooks/use-admin-llm"
@ -22,20 +28,14 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
function emptyModelEntry(): AiModelCatalogEntry {
return {
model_id: "",
label: "",
enabled: true,
}
}
export function AiAssistantSection() { export function AiAssistantSection() {
const aiAssistant = useOrgSettingsStore((s) => s.aiAssistant) const aiAssistant = useOrgSettingsStore((s) => s.aiAssistant)
const setAiAssistant = useOrgSettingsStore((s) => s.setAiAssistant) const setAiAssistant = useOrgSettingsStore((s) => s.setAiAssistant)
const setPlugins = useOrgSettingsStore((s) => s.setPlugins) const setPlugins = useOrgSettingsStore((s) => s.setPlugins)
const plugins = useOrgSettingsStore((s) => s.plugins) const plugins = useOrgSettingsStore((s) => s.plugins)
const llm = useOrgSettingsStore((s) => s.llm) 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 effective = useOrgSettingsStore((s) => s.meta?.effective.ai_assistant)
const enabledLocked = useDeployFieldLocked("ai_assistant", "enabled") const enabledLocked = useDeployFieldLocked("ai_assistant", "enabled")
const publicPathLocked = useDeployFieldLocked("ai_assistant", "public_path") const publicPathLocked = useDeployFieldLocked("ai_assistant", "public_path")
@ -44,14 +44,57 @@ export function AiAssistantSection() {
const runtimeEnabled = effective?.enabled ?? false const runtimeEnabled = effective?.enabled ?? false
const orgEnabled = aiAssistant.enabled || pluginEnabled 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 [discoverProviderId, setDiscoverProviderId] = useState(llm.default_provider_id)
const [discoveredModels, setDiscoveredModels] = useState<string[]>([]) const [discoveredModels, setDiscoveredModels] = useState<string[]>([])
const discoverProvider = useMemo( const discoverProvider = useMemo(
() => llm.providers.find((p) => p.id === discoverProviderId) ?? llm.providers[0], () => llmDraft.providers.find((p) => p.id === discoverProviderId) ?? llmDraft.providers[0],
[discoverProviderId, llm.providers], [discoverProviderId, llmDraft.providers],
) )
const discoverModels = useDiscoverOrgLLMModels() 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() { async function handleDiscoverModels() {
if (!discoverProvider?.id) return if (!discoverProvider?.id) return
setDiscoveredModels([]) setDiscoveredModels([])
@ -63,31 +106,14 @@ export function AiAssistantSection() {
} }
} }
function updateModel(index: number, patch: Partial<AiModelCatalogEntry>) { const orgLlmProviderSecrets = secrets?.llm_providers as
const models = aiAssistant.models.map((entry, i) => | Record<string, { configured?: boolean }>
i === index ? { ...entry, ...patch } : entry, | undefined
)
function setAuthorizedModels(models: AiModelCatalogEntry[]) {
setAiAssistant({ models }) 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) { function setUltiAIEnabled(enabled: boolean) {
setAiAssistant({ enabled }) setAiAssistant({ enabled })
setPlugins( setPlugins(
@ -100,249 +126,254 @@ export function AiAssistantSection() {
return ( return (
<OrgSettingsSection <OrgSettingsSection
title="UltiAI" title="UltiAI"
description="Assistant IA intégré (OpenWebUI) avec gateway LLM, tools et sync Nextcloud." description="Assistant IA intégré (OpenWebUI), fournisseurs LLM, gateway, tools et sync Nextcloud."
policySection={["ai_assistant", "plugins"]} policySection={["ai_assistant", "plugins", "llm"]}
beforeSave={() => setLlm(llmDraft)}
> >
<Card> <AutomationTabMasonry columns={2}>
<CardHeader className="pb-3"> <Card>
<div className="flex items-center justify-between gap-4"> <CardHeader className="pb-3">
<div> <div className="flex items-center justify-between gap-4">
<CardTitle className="text-sm font-medium">Assistant IA</CardTitle> <div>
<CardDescription> <CardTitle className="text-sm font-medium">Assistant IA</CardTitle>
Active le plugin UltiAI pour toute l&apos;organisation. Le service OpenWebUI doit <CardDescription>
aussi être déployé. Active le plugin UltiAI pour toute l&apos;organisation. Le service OpenWebUI doit
</CardDescription> aussi être déployé.
</CardDescription>
</div>
<Switch
checked={orgEnabled}
disabled={enabledLocked}
onCheckedChange={setUltiAIEnabled}
/>
</div> </div>
<Switch {enabledLocked ? (
checked={orgEnabled} <DeployLockedHint section="ai_assistant" field="enabled" />
disabled={enabledLocked} ) : null}
onCheckedChange={setUltiAIEnabled} <div className="flex flex-wrap gap-2 pt-1">
/> <Badge variant={orgEnabled ? "default" : "secondary"}>
</div> Politique org. {orgEnabled ? "activée" : "désactivée"}
{enabledLocked ? ( </Badge>
<DeployLockedHint section="ai_assistant" field="enabled" /> <Badge variant={runtimeEnabled ? "default" : "outline"}>
) : null} Runtime Compose {runtimeEnabled ? "actif" : "inactif"}
<div className="flex flex-wrap gap-2 pt-1"> </Badge>
<Badge variant={orgEnabled ? "default" : "secondary"}> </div>
Politique org. {orgEnabled ? "activée" : "désactivée"} {!orgEnabled && !runtimeEnabled ? (
</Badge>
<Badge variant={runtimeEnabled ? "default" : "outline"}>
Runtime Compose {runtimeEnabled ? "actif" : "inactif"}
</Badge>
</div>
{!orgEnabled && !runtimeEnabled ? (
<p className="text-xs text-muted-foreground">
Activez le plugin UltiAI dans Administration Plugins, ou définissez{" "}
<code className="rounded bg-muted px-1">AI_ASSISTANT_ENABLED=true</code> dans le
déploiement, puis redémarrez le backend et OpenWebUI.
</p>
) : null}
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2 sm:col-span-2">
<Label>Chemin public (proxy)</Label>
<Input
value={aiAssistant.public_path}
onChange={(e) => setAiAssistant({ public_path: e.target.value })}
placeholder="/ai"
disabled={publicPathLocked}
/>
<DeployLockedHint section="ai_assistant" field="public_path" />
</div>
<div className="space-y-2 sm:col-span-2">
<Label>URL interne OpenWebUI</Label>
<Input
value={aiAssistant.openwebui_internal_url}
onChange={(e) => setAiAssistant({ openwebui_internal_url: e.target.value })}
placeholder="http://openwebui:8080"
disabled={openwebuiLocked}
/>
<DeployLockedHint section="ai_assistant" field="openwebui_internal_url" />
</div>
<div className="space-y-2">
<Label>Modèle par défaut</Label>
<Input
value={aiAssistant.default_model}
onChange={(e) => setAiAssistant({ default_model: e.target.value })}
placeholder="gpt-4o"
/>
</div>
<div className="space-y-2">
<Label>Chemin historique NC</Label>
<Input
value={aiAssistant.chat_nc_path}
onChange={(e) => setAiAssistant({ chat_nc_path: e.target.value })}
placeholder="/.ultimail/ai/chats"
/>
</div>
<div className="flex items-center justify-between gap-4 sm:col-span-2">
<div>
<Label>Embed temporaire par défaut</Label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Les panneaux mail/drive/contacts ne sauvegardent pas l&apos;historique. Activez le plugin UltiAI dans Administration Plugins, ou définissez{" "}
<code className="rounded bg-muted px-1">AI_ASSISTANT_ENABLED=true</code> dans le
déploiement, puis redémarrez le backend et OpenWebUI.
</p> </p>
) : null}
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2 sm:col-span-2">
<Label>Chemin public (proxy)</Label>
<Input
value={aiAssistant.public_path}
onChange={(e) => setAiAssistant({ public_path: e.target.value })}
placeholder="/ai"
disabled={publicPathLocked}
/>
<DeployLockedHint section="ai_assistant" field="public_path" />
</div> </div>
<Switch <div className="space-y-2 sm:col-span-2">
checked={aiAssistant.embed_default_temporary} <Label>URL interne OpenWebUI</Label>
onCheckedChange={(v) => setAiAssistant({ embed_default_temporary: v })} <Input
/> value={aiAssistant.openwebui_internal_url}
</div> onChange={(e) => setAiAssistant({ openwebui_internal_url: e.target.value })}
<div className="flex items-center justify-between gap-4 sm:col-span-2"> placeholder="http://openwebui:8080"
<div> disabled={openwebuiLocked}
<Label>Sync historique Nextcloud</Label> />
<p className="text-xs text-muted-foreground"> <DeployLockedHint section="ai_assistant" field="openwebui_internal_url" />
Pipeline OpenWebUI fichiers .ultichat.json sur le drive utilisateur.
</p>
</div> </div>
<Switch <div className="space-y-2 sm:col-span-2">
checked={aiAssistant.chat_sync_enabled} <Label>Modèle par défaut</Label>
onCheckedChange={(v) => setAiAssistant({ chat_sync_enabled: v })} {defaultModelOptions.length > 0 ? (
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Modèles autorisés</CardTitle>
<CardDescription>
Liste vide = tous les modèles des fournisseurs LLM org. Sinon, seuls les modèles
autorisés sont visibles pour les utilisateurs. Le surnom remplace le nom technique.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{llm.providers.length === 0 ? (
<p className="text-sm text-muted-foreground">
Configurez d&apos;abord un fournisseur LLM dans Administration Fournisseurs LLM.
</p>
) : (
<div className="flex flex-wrap items-end gap-3 rounded-lg border p-3">
<div className="min-w-[220px] flex-1 space-y-2">
<Label className="text-xs">Découvrir depuis le fournisseur</Label>
<Select <Select
value={discoverProvider?.id ?? ""} value={aiAssistant.default_model || "__auto__"}
onValueChange={setDiscoverProviderId} onValueChange={(value) =>
setAiAssistant({
default_model: value === "__auto__" ? "" : value,
})
}
> >
<SelectTrigger className="h-9"> <SelectTrigger className="h-9 w-full min-w-0">
<SelectValue placeholder="Choisir un fournisseur…" /> <SelectValue placeholder="Choisir un modèle…" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent className="max-h-60">
{llm.providers.map((provider) => ( <SelectItem value="__auto__">
<SelectItem key={provider.id} value={provider.id}> Automatique (fournisseur LLM par défaut)
{provider.name || provider.base_url} </SelectItem>
</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> </SelectContent>
</Select> </Select>
</div> ) : (
<Button <Input
type="button" value={aiAssistant.default_model}
variant="outline" onChange={(e) => setAiAssistant({ default_model: e.target.value })}
size="sm" placeholder="gpt-4o-mini"
disabled={!discoverProvider?.id || discoverModels.isPending}
onClick={() => void handleDiscoverModels()}
>
<RefreshCw
className={`mr-2 size-4 ${discoverModels.isPending ? "animate-spin" : ""}`}
/> />
Découvrir les modèles )}
</Button> <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>
)} <div className="space-y-2 sm:col-span-2">
<Label>Chemin historique NC</Label>
{discoverModels.isError ? ( <Input
<p className="text-sm text-destructive"> value={aiAssistant.chat_nc_path}
{discoverModels.error instanceof Error onChange={(e) => setAiAssistant({ chat_nc_path: e.target.value })}
? discoverModels.error.message placeholder="/.ultimail/ai/chats"
: "Impossible de lister les modèles sur ce fournisseur. Enregistrez d'abord le fournisseur LLM avec une clé API valide."} />
</p> </div>
) : null} <div className="flex items-center justify-between gap-4 sm:col-span-2">
<div>
{discoveredModels.length ? ( <Label>Embed temporaire par défaut</Label>
<div className="space-y-2"> <p className="text-xs text-muted-foreground">
<Label className="text-xs">Modèles disponibles sur l&apos;endpoint</Label> Les panneaux mail/drive/contacts ne sauvegardent pas l&apos;historique.
<div className="flex flex-wrap gap-2"> </p>
{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> </div>
<Switch
checked={aiAssistant.embed_default_temporary}
onCheckedChange={(v) => setAiAssistant({ embed_default_temporary: v })}
/>
</div> </div>
) : null} <div className="flex items-center justify-between gap-4 sm:col-span-2">
<div>
<Label>Sync historique Nextcloud</Label>
<p className="text-xs text-muted-foreground">
Pipeline OpenWebUI fichiers .ultichat.json sur le drive utilisateur.
</p>
</div>
<Switch
checked={aiAssistant.chat_sync_enabled}
onCheckedChange={(v) => setAiAssistant({ chat_sync_enabled: v })}
/>
</div>
</CardContent>
</Card>
<div className="flex items-center justify-between"> <div className="flex flex-col gap-4 sm:col-span-2">
<Label>Catalogue organisation</Label> <AdminOrgLlmPolicyCard draft={llmDraft} setDraft={setLlmDraft} />
<Button type="button" variant="outline" size="sm" onClick={addManualModel}>
<Plus className="mr-2 size-4" />
Ajouter manuellement
</Button>
</div>
{aiAssistant.models.length === 0 ? ( <Card>
<p className="text-sm text-muted-foreground"> <CardHeader className="pb-3">
Aucune restriction tous les modèles LLM configurés restent disponibles. <CardTitle className="text-sm font-medium">Fournisseurs LLM</CardTitle>
</p> <CardDescription>
) : ( Modèles IA organisationnels pour UltiAI, le tri, l&apos;enrichissement contacts et
<div className="space-y-2"> les automatisations.
{aiAssistant.models.map((entry, index) => ( </CardDescription>
<div </CardHeader>
key={`${entry.model_id}-${index}`} <CardContent>
className="grid gap-2 rounded-lg border p-3 sm:grid-cols-[1fr_1fr_auto_auto]" <LlmProvidersEditor
> columns={1}
<div className="space-y-1"> providers={llmDraft.providers}
<Label className="text-xs">ID modèle</Label> defaultProviderId={llmDraft.default_provider_id}
<Input providerSecrets={orgLlmProviderSecrets}
className="h-9" onProvidersChange={(providers) =>
value={entry.model_id} setLlmDraft((prev) => ({ ...prev, providers }))
onChange={(e) => updateModel(index, { model_id: e.target.value })} }
placeholder="gpt-4o-mini" onDefaultProviderIdChange={(default_provider_id) =>
/> setLlmDraft((prev) => ({ ...prev, default_provider_id }))
</div> }
<div className="space-y-1"> />
<Label className="text-xs">Surnom utilisateur</Label> </CardContent>
<Input </Card>
className="h-9" </div>
value={entry.label}
onChange={(e) => updateModel(index, { label: e.target.value })} <UltiAiToolsCard
placeholder="GPT-4o Mini" enabledTools={aiAssistant.enabled_tools}
/> onChange={(enabled_tools) => setAiAssistant({ enabled_tools })}
</div> webSearchSettingsHref="/admin/settings/search"
<label className="flex items-center gap-2 self-end pb-1 text-sm"> />
<Switch
checked={entry.enabled} <Card>
onCheckedChange={(enabled) => updateModel(index, { enabled })} <CardHeader className="pb-3">
/> <CardTitle className="text-sm font-medium">Modèles autorisés</CardTitle>
Autorisé <CardDescription>
</label> Liste vide = tous les modèles des fournisseurs LLM org. Sinon, seuls les modèles
<Button autorisés sont visibles pour les utilisateurs. Le surnom remplace le nom technique.
type="button" </CardDescription>
variant="ghost" </CardHeader>
size="icon" <CardContent className="space-y-4">
className="self-end" {llmDraft.providers.length === 0 ? (
onClick={() => removeModel(index)} <p className="text-sm text-muted-foreground">
aria-label="Supprimer le modèle" 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">
<div className="min-w-[220px] flex-1 space-y-2">
<Label className="text-xs">Découvrir depuis le fournisseur</Label>
<Select
value={discoverProvider?.id ?? ""}
onValueChange={setDiscoverProviderId}
> >
<Trash2 className="size-4" /> <SelectTrigger className="h-9">
</Button> <SelectValue placeholder="Choisir un fournisseur…" />
</SelectTrigger>
<SelectContent>
{llmDraft.providers.map((provider) => (
<SelectItem key={provider.id} value={provider.id}>
{provider.name || provider.base_url}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
))} <Button
</div> type="button"
)} variant="outline"
</CardContent> size="sm"
</Card> disabled={!discoverProvider?.id || discoverModels.isPending}
onClick={() => void handleDiscoverModels()}
>
<RefreshCw
className={`mr-2 size-4 ${discoverModels.isPending ? "animate-spin" : ""}`}
/>
Découvrir les modèles
</Button>
</div>
)}
{discoverModels.isError ? (
<p className="text-sm text-destructive">
{discoverModels.error instanceof Error
? discoverModels.error.message
: "Impossible de lister les modèles sur ce fournisseur. Enregistrez d'abord le fournisseur LLM avec une clé API valide."}
</p>
) : null}
{llmDraft.providers.length > 0 ? (
<div className="space-y-2">
<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}
</CardContent>
</Card>
</AutomationTabMasonry>
</OrgSettingsSection> </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" "use client"
import { useCallback, useRef } from "react"
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form" 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 { DeployLockedHint, useDeployFieldLocked } from "@/components/admin/settings/deploy-locked-hint"
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store" import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import { Card, CardContent } from "@/components/ui/card"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
export function AuthenticationSection() { export function AuthenticationSection() {
const authentik = useOrgSettingsStore((s) => s.authentik) const authentik = useOrgSettingsStore((s) => s.authentik)
@ -22,95 +26,119 @@ export function AuthenticationSection() {
const apiURL = apiLocked ? (effective?.api_url ?? authentik.api_url) : authentik.api_url 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 clientID = clientLocked ? (effective?.client_id ?? authentik.client_id) : authentik.client_id
return ( const identityBeforeSaveRef = useRef<(() => void) | null>(null)
<div className="space-y-8"> const registerIdentityBeforeSave = useCallback((fn: (() => void) | null) => {
<OrgSettingsSection identityBeforeSaveRef.current = fn
title="Authentification Authentik" }, [])
description="SSO, provisionnement des comptes Ultimail et groupes par défaut."
policySection="authentik"
>
<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>
{enabledLocked ? <DeployLockedHint section="authentik" field="enabled" /> : null}
</div>
<Switch
checked={enabled}
disabled={enabledLocked}
onCheckedChange={(v) => setAuthentik({ enabled: v })}
/>
</div>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<Label>URL API Authentik</Label>
<Input
className="mt-1 h-9"
value={apiURL}
disabled={apiLocked}
onChange={(e) => setAuthentik({ api_url: e.target.value })}
placeholder="https://auth.example.com/api/v3"
/>
</div>
<div>
<Label>Slug application</Label>
<Input
className="mt-1 h-9"
value={authentik.slug}
onChange={(e) => setAuthentik({ slug: e.target.value })}
/>
</div>
<div>
<Label>Client ID OIDC</Label>
<Input
className="mt-1 h-9"
value={clientID}
disabled={clientLocked}
onChange={(e) => setAuthentik({ client_id: e.target.value })}
/>
</div>
<div className="sm:col-span-2">
<Label>Groupes par défaut (séparés par des virgules)</Label>
<Input
className="mt-1 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>
<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>
<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>
<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>
<Switch
checked={authentik.allow_password_fallback}
onCheckedChange={(allow_password_fallback) =>
setAuthentik({ allow_password_fallback })
}
/>
</label>
</CardContent>
</Card>
</OrgSettingsSection>
<IdentityProvidersSection /> return (
</div> <OrgSettingsSection
title="Authentification"
description="SSO Authentik, provisionnement des comptes Ultimail et fournisseurs d'identité upstream."
policySection={["authentik", "identity_providers"]}
beforeSave={async () => {
identityBeforeSaveRef.current?.()
}}
>
<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
checked={enabled}
disabled={enabledLocked}
onCheckedChange={(v) => setAuthentik({ enabled: v })}
/>
</div>
<div className="mt-4 space-y-4 border-t pt-4">
<FieldGroup>
<Label>URL API Authentik</Label>
<Input
className="h-9"
value={apiURL}
disabled={apiLocked}
onChange={(e) => setAuthentik({ api_url: e.target.value })}
placeholder="https://auth.example.com/api/v3"
/>
{apiLocked ? <DeployLockedHint section="authentik" field="api_url" /> : null}
</FieldGroup>
<div className="grid min-w-0 gap-4">
<FieldGroup>
<Label>Slug application</Label>
<Input
className="h-9"
value={authentik.slug}
onChange={(e) => setAuthentik({ slug: e.target.value })}
/>
</FieldGroup>
<FieldGroup>
<Label>Client ID OIDC</Label>
<Input
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>
<FieldGroup>
<Label>Groupes par défaut (séparés par des virgules)</Label>
<Input
className="h-9"
value={authentik.default_groups}
onChange={(e) => setAuthentik({ default_groups: e.target.value })}
/>
</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>
</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">
<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>
</FieldGroup>
<Switch
checked={authentik.allow_password_fallback}
onCheckedChange={(allow_password_fallback) =>
setAuthentik({ allow_password_fallback })
}
/>
</label>
</div>
</CardContent>
</Card>
<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 { useEffect, useState } from "react"
import { Check, Copy } from "lucide-react" import { Check, Copy } from "lucide-react"
import { toast } from "sonner" 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 { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import type { DriveMountOAuthProvider, DriveMountOAuthSettings } from "@/lib/admin-settings/org-settings-types" import type { DriveMountOAuthProvider, DriveMountOAuthSettings } from "@/lib/admin-settings/org-settings-types"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@ -11,21 +13,29 @@ import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { buildDriveMountOAuthRedirectURI } from "@/lib/drive/drive-mount-oauth" 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", id: "google",
label: "Google Drive", label: "Google Drive",
hint: "Console Google Cloud — API Drive, redirect URI ci-dessous", hint: "Console Google Cloud — API Drive, redirect URI ci-dessous",
icon: "logos:google-drive",
}, },
{ {
id: "dropbox", id: "dropbox",
label: "Dropbox", label: "Dropbox",
hint: "App Dropbox — permissions files.metadata.read, files.content.read/write", hint: "App Dropbox — permissions files.metadata.read, files.content.read/write",
icon: "logos:dropbox",
}, },
{ {
id: "microsoft", id: "microsoft",
label: "Microsoft OneDrive", label: "Microsoft OneDrive",
hint: "Azure AD — Microsoft Graph Files.ReadWrite", 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({ export function DriveMountOAuthSection({
draft, draft,
onChange, onChange,
embedded = false,
}: { }: {
draft: DriveMountOAuthSettings draft: DriveMountOAuthSettings
onChange: (next: DriveMountOAuthSettings) => void onChange: (next: DriveMountOAuthSettings) => void
embedded?: boolean
}) { }) {
const secrets = useOrgSettingsStore((s) => s.meta?.secrets) const secrets = useOrgSettingsStore((s) => s.meta?.secrets)
const [redirectUri, setRedirectUri] = useState("") const [redirectUri, setRedirectUri] = useState("")
@ -70,16 +82,18 @@ export function DriveMountOAuthSection({
} }
return ( 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"}>
<div> {!embedded ? (
<h3 className="text-sm font-medium">Connexion cloud (OAuth)</h3> <div>
<p className="mt-1 text-xs text-muted-foreground"> <h3 className="text-sm font-medium">Connexion cloud (OAuth)</h3>
Permet aux utilisateurs de monter Google Drive, Dropbox ou OneDrive depuis UltiDrive. <p className="mt-1 text-xs text-muted-foreground">
</p> Permet aux utilisateurs de monter Google Drive, Dropbox ou OneDrive depuis UltiDrive.
</div> </p>
<div> </div>
) : null}
<FieldGroup>
<Label>URI de redirection OAuth</Label> <Label>URI de redirection OAuth</Label>
<div className="mt-1 flex gap-2"> <div className="flex gap-2">
<Input <Input
className="h-9 flex-1 font-mono text-xs" className="h-9 flex-1 font-mono text-xs"
readOnly readOnly
@ -98,41 +112,44 @@ export function DriveMountOAuthSection({
Copier Copier
</Button> </Button>
</div> </div>
<p className="mt-1 text-xs text-muted-foreground"> <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). Basée sur l&apos;URL actuelle du navigateur. Enregistrez-la chez chaque fournisseur OAuth
(Google, Dropbox, Microsoft).
</p> </p>
</div> </FieldGroup>
<div className="space-y-4"> <div className="space-y-4">
{PROVIDERS.map(({ id, label, hint }) => { {PROVIDERS.map(({ id, label, hint, icon }) => {
const provider = draft[id] const provider = draft[id]
const configured = Boolean(secrets?.[SECRET_KEYS[id]]?.configured) const configured = Boolean(secrets?.[SECRET_KEYS[id]]?.configured)
return ( return (
<div key={id} className="space-y-3 rounded-md border p-3"> <div key={id} className="space-y-3 rounded-md border p-3">
<label className="flex items-center justify-between gap-4"> <label className="flex items-center justify-between gap-4">
<div> <FieldGroup>
<p className="text-sm font-medium">{label}</p> <TechBrandSelectLabel icon={icon} className="text-sm font-medium">
{label}
</TechBrandSelectLabel>
<p className="text-xs text-muted-foreground">{hint}</p> <p className="text-xs text-muted-foreground">{hint}</p>
</div> </FieldGroup>
<Switch <Switch
checked={provider.enabled} checked={provider.enabled}
onCheckedChange={(enabled) => updateProvider(id, { enabled })} onCheckedChange={(enabled) => updateProvider(id, { enabled })}
/> />
</label> </label>
{provider.enabled ? ( {provider.enabled ? (
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid min-w-0 gap-4">
<div className="sm:col-span-2"> <FieldGroup>
<Label>Client ID</Label> <Label>Client ID</Label>
<Input <Input
className="mt-1 h-9 font-mono text-xs" className="h-9 font-mono text-xs"
value={provider.client_id} value={provider.client_id}
onChange={(e) => updateProvider(id, { client_id: e.target.value })} onChange={(e) => updateProvider(id, { client_id: e.target.value })}
autoComplete="off" autoComplete="off"
/> />
</div> </FieldGroup>
<div className="sm:col-span-2"> <FieldGroup>
<Label>Client secret</Label> <Label>Client secret</Label>
<Input <Input
className="mt-1 h-9 font-mono text-xs" className="h-9 font-mono text-xs"
type="password" type="password"
value={provider.client_secret} value={provider.client_secret}
onChange={(e) => updateProvider(id, { client_secret: e.target.value })} onChange={(e) => updateProvider(id, { client_secret: e.target.value })}
@ -140,9 +157,9 @@ export function DriveMountOAuthSection({
autoComplete="off" autoComplete="off"
/> />
{configured && !provider.client_secret.trim() ? ( {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} ) : null}
</div> </FieldGroup>
</div> </div>
) : null} ) : null}
</div> </div>

View File

@ -1,6 +1,7 @@
"use client" "use client"
import { useState } from "react" import { useState } from "react"
import { FieldGroup } from "@/components/admin/settings/field-group"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
@ -9,7 +10,7 @@ import {
useAdminDriveOrgFolders, useAdminDriveOrgFolders,
} from "@/lib/api/hooks/use-admin-drive-queries" } from "@/lib/api/hooks/use-admin-drive-queries"
export function DriveOrgFoldersSection() { export function DriveOrgFoldersSection({ embedded = false }: { embedded?: boolean }) {
const folders = useAdminDriveOrgFolders() const folders = useAdminDriveOrgFolders()
const { create, remove, sync } = useAdminDriveOrgFolderMutations() const { create, remove, sync } = useAdminDriveOrgFolderMutations()
const [orgSlug, setOrgSlug] = useState("") const [orgSlug, setOrgSlug] = useState("")
@ -17,23 +18,25 @@ export function DriveOrgFoldersSection() {
const [syncSlugs, setSyncSlugs] = useState("") const [syncSlugs, setSyncSlugs] = useState("")
return ( 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"}>
<div> {!embedded ? (
<h3 className="text-sm font-medium">Dossiers d&apos;organisation</h3> <div>
<p className="text-xs text-muted-foreground"> <h3 className="text-sm font-medium">Dossiers d&apos;organisation</h3>
Group folders Nextcloud liés aux organisations Authentik. <p className="text-xs text-muted-foreground">
</p> Espaces de stockage internes (group folders Nextcloud) liés aux organisations Authentik.
</div> </p>
</div>
) : null}
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid min-w-0 gap-4">
<div className="grid gap-1.5"> <FieldGroup>
<Label htmlFor="org-slug">Slug organisation</Label> <Label htmlFor="org-slug">Slug organisation</Label>
<Input id="org-slug" value={orgSlug} onChange={(e) => setOrgSlug(e.target.value)} placeholder="acme" /> <Input id="org-slug" value={orgSlug} onChange={(e) => setOrgSlug(e.target.value)} placeholder="acme" />
</div> </FieldGroup>
<div className="grid gap-1.5"> <FieldGroup>
<Label htmlFor="org-mount">Nom du dossier</Label> <Label htmlFor="org-mount">Nom du dossier</Label>
<Input id="org-mount" value={mountPoint} onChange={(e) => setMountPoint(e.target.value)} placeholder="Acme Corp" /> <Input id="org-mount" value={mountPoint} onChange={(e) => setMountPoint(e.target.value)} placeholder="Acme Corp" />
</div> </FieldGroup>
</div> </div>
<Button <Button
size="sm" size="sm"
@ -45,8 +48,12 @@ export function DriveOrgFoldersSection() {
Créer le dossier Créer le dossier
</Button> </Button>
<div className="grid gap-1.5"> <FieldGroup>
<Label htmlFor="sync-orgs">Sync auto (slugs séparés par des virgules)</Label> <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 <Input
id="sync-orgs" id="sync-orgs"
value={syncSlugs} value={syncSlugs}
@ -63,9 +70,9 @@ export function DriveOrgFoldersSection() {
) )
} }
> >
Provisionner Provisionner les dossiers
</Button> </Button>
</div> </FieldGroup>
<ul className="divide-y rounded-md border text-sm"> <ul className="divide-y rounded-md border text-sm">
{(folders.data ?? []).map((folder) => ( {(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 { useEffect, useState } from "react"
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form" 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 { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
@ -15,6 +18,7 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import { DriveOrgFoldersSection } from "@/components/admin/settings/sections/drive-org-section" 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" import { DriveMountOAuthSection } from "@/components/admin/settings/sections/drive-mount-oauth-section"
export function FilePoliciesSection() { export function FilePoliciesSection() {
@ -40,120 +44,151 @@ export function FilePoliciesSection() {
policySection="file_policies" policySection="file_policies"
beforeSave={() => setFilePolicies({ mount_oauth: mountOAuthDraft })} beforeSave={() => setFilePolicies({ mount_oauth: mountOAuthDraft })}
> >
<div className="grid gap-4 sm:grid-cols-2"> <AutomationTabMasonry columns={2}>
<div> <AdminSettingsCard
<Label>Taille max upload (Mo)</Label> title="Politiques UltiDrive"
<Input description="Limites d'upload, partage externe, extensions et analyse antivirus."
className="mt-1 h-9" >
type="number" <div className="grid min-w-0 gap-4">
min={1} <FieldGroup>
value={filePolicies.max_upload_mib} <Label>Taille max upload (Mo)</Label>
onChange={(e) => <Input
setFilePolicies({ max_upload_mib: Number(e.target.value) || 1 }) className="h-9"
} type="number"
/> min={1}
</div> value={filePolicies.max_upload_mib}
<div> onChange={(e) =>
<Label>Expiration liens par défaut (jours)</Label> setFilePolicies({ max_upload_mib: Number(e.target.value) || 1 })
<Input }
className="mt-1 h-9" />
type="number" </FieldGroup>
min={1} <FieldGroup>
value={filePolicies.default_link_expiry_days} <Label>Expiration liens par défaut (jours)</Label>
onChange={(e) => <Input
setFilePolicies({ className="h-9"
default_link_expiry_days: Number(e.target.value) || 1, type="number"
}) min={1}
} value={filePolicies.default_link_expiry_days}
/> onChange={(e) =>
</div> setFilePolicies({
<div> default_link_expiry_days: Number(e.target.value) || 1,
<Label>Rétention corbeille (jours)</Label> })
<Input }
className="mt-1 h-9" />
type="number" </FieldGroup>
min={1} <FieldGroup>
value={filePolicies.retention_trash_days} <Label>Rétention corbeille (jours)</Label>
onChange={(e) => <Input
setFilePolicies({ retention_trash_days: Number(e.target.value) || 1 }) className="h-9"
} type="number"
/> min={1}
</div> value={filePolicies.retention_trash_days}
<div> onChange={(e) =>
<Label>Partage externe</Label> setFilePolicies({ retention_trash_days: Number(e.target.value) || 1 })
<Select }
value={filePolicies.external_sharing} />
onValueChange={(external_sharing) => </FieldGroup>
setFilePolicies({ <FieldGroup>
external_sharing: external_sharing as typeof filePolicies.external_sharing, <Label>Partage externe</Label>
}) <Select
} value={filePolicies.external_sharing}
> onValueChange={(external_sharing) =>
<SelectTrigger className="mt-1 h-9"> setFilePolicies({
<SelectValue /> external_sharing: external_sharing as typeof filePolicies.external_sharing,
</SelectTrigger> })
<SelectContent> }
<SelectItem value="disabled">Désactivé</SelectItem> >
<SelectItem value="authenticated">Utilisateurs authentifiés</SelectItem> <SelectTrigger className="h-9 w-full min-w-0">
<SelectItem value="public_link">Liens publics autorisés</SelectItem> <SelectValue />
</SelectContent> </SelectTrigger>
</Select> <SelectContent>
</div> <SelectItem value="disabled">Désactivé</SelectItem>
<div className="sm:col-span-2"> <SelectItem value="authenticated">Utilisateurs authentifiés</SelectItem>
<Label>Extensions autorisées (vide = toutes)</Label> <SelectItem value="public_link">Liens publics autorisés</SelectItem>
<Textarea </SelectContent>
className="mt-1 min-h-[80px] font-mono text-xs" </Select>
value={filePolicies.allowed_extensions} </FieldGroup>
onChange={(e) => setFilePolicies({ allowed_extensions: e.target.value })} <FieldGroup>
placeholder="pdf, docx, png, jpg" <Label>Extensions autorisées (vide = toutes)</Label>
/> <Textarea
</div> className="min-h-[80px] font-mono text-xs"
<label className="flex items-center justify-between gap-4 rounded-lg border p-3 sm:col-span-2"> value={filePolicies.allowed_extensions}
<div> onChange={(e) => setFilePolicies({ allowed_extensions: e.target.value })}
<p className="text-sm font-medium">Bloquer les exécutables</p> placeholder="pdf, docx, png, jpg"
<p className="text-xs text-muted-foreground">exe, bat, sh, app, etc.</p> />
</FieldGroup>
</div> </div>
<Switch
checked={filePolicies.block_executable} <label className="flex items-center justify-between gap-4 rounded-lg border p-3">
onCheckedChange={(block_executable) => setFilePolicies({ block_executable })} <FieldGroup>
/> <p className="text-sm font-medium">Bloquer les exécutables</p>
</label> <p className="text-xs text-muted-foreground">exe, bat, sh, app, etc.</p>
<label className="flex items-center justify-between gap-4 rounded-lg border p-3 sm:col-span-2"> </FieldGroup>
<div> <Switch
<p className="text-sm font-medium">Analyse antivirus à l&apos;upload</p> checked={filePolicies.block_executable}
<p className="text-xs text-muted-foreground"> onCheckedChange={(block_executable) => setFilePolicies({ block_executable })}
VirusTotal scan synchrone à l&apos;upload Drive et pièces jointes mail
</p>
</div>
<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">
<Label>Clé API VirusTotal</Label>
<Input
className="mt-1 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"}
/> />
{vtKeyConfigured && !(filePolicies.virustotal_api_key ?? "").trim() ? ( </label>
<p className="mt-1 text-xs text-muted-foreground">Clé configurée</p>
) : null} <label className="flex items-center justify-between gap-4 rounded-lg border p-3">
{vtKeyMissing ? ( <FieldGroup>
<p className="mt-1 text-xs text-amber-600 dark:text-amber-500"> <p className="text-sm font-medium">Analyse antivirus à l&apos;upload</p>
Analyse activée sans clé API les uploads ne seront pas scannés. <p className="text-xs text-muted-foreground">
VirusTotal scan synchrone à l&apos;upload Drive et pièces jointes mail
</p> </p>
) : null} </FieldGroup>
</div> <Switch
) : null} checked={filePolicies.virus_scan_enabled}
</div> onCheckedChange={(virus_scan_enabled) => setFilePolicies({ virus_scan_enabled })}
<DriveMountOAuthSection draft={mountOAuthDraft} onChange={setMountOAuthDraft} /> />
<DriveOrgFoldersSection /> </label>
{filePolicies.virus_scan_enabled ? (
<FieldGroup>
<Label>Clé API VirusTotal</Label>
<Input
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"
}
/>
{vtKeyConfigured && !(filePolicies.virustotal_api_key ?? "").trim() ? (
<p className="text-xs text-muted-foreground">Clé configurée</p>
) : null}
{vtKeyMissing ? (
<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}
</FieldGroup>
) : null}
</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> </OrgSettingsSection>
) )
} }

View File

@ -9,8 +9,8 @@ import {
TestTube2, TestTube2,
Trash2, Trash2,
} from "lucide-react" } from "lucide-react"
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
import { guideForProvider } from "@/components/admin/settings/guides/identity-provider-guides" 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 { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import type { import type {
IdentityProvider, IdentityProvider,
@ -49,6 +49,14 @@ import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { toast } from "sonner" 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[] { function splitList(value: string): string[] {
return value return value
.split(/[\n,]/) .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 identityProviders = useOrgSettingsStore((s) => s.identityProviders)
const setIdentityProviders = useOrgSettingsStore((s) => s.setIdentityProviders) const setIdentityProviders = useOrgSettingsStore((s) => s.setIdentityProviders)
const meta = useOrgSettingsStore((s) => s.meta) const meta = useOrgSettingsStore((s) => s.meta)
@ -216,13 +228,13 @@ export function IdentityProvidersSection() {
} }
} }
useEffect(() => {
onRegisterBeforeSave?.(() => setIdentityProviders(draft))
return () => onRegisterBeforeSave?.(null)
}, [draft, onRegisterBeforeSave, setIdentityProviders])
return ( 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"> <label className="flex items-center justify-between gap-4 rounded-lg border p-4">
<div> <div>
<p className="text-sm font-medium">Inscription self-service Authentik</p> <p className="text-sm font-medium">Inscription self-service Authentik</p>
@ -323,9 +335,21 @@ export function IdentityProvidersSection() {
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="oauth">OAuth (Google, GitHub, LinkedIn)</SelectItem> <SelectItem value="oauth">
<SelectItem value="saml">SAML (Azure AD, Okta)</SelectItem> <TechBrandSelectLabel brand="oauth">
<SelectItem value="ldap">LDAP / Active Directory</SelectItem> 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> </SelectContent>
</Select> </Select>
</div> </div>
@ -391,14 +415,30 @@ export function IdentityProvidersSection() {
} }
> >
<SelectTrigger className="mt-1 h-9"> <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> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="google">Google</SelectItem> <SelectItem value="google">
<SelectItem value="github">GitHub</SelectItem> <TechBrandSelectLabel brand="google">Google</TechBrandSelectLabel>
<SelectItem value="linkedin">LinkedIn</SelectItem> </SelectItem>
<SelectItem value="microsoft">Microsoft</SelectItem> <SelectItem value="github">
<SelectItem value="custom">Autre / custom</SelectItem> <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> </SelectContent>
</Select> </Select>
</div> </div>
@ -754,6 +794,6 @@ export function IdentityProvidersSection() {
) : null} ) : null}
</SheetContent> </SheetContent>
</Sheet> </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" "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 { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" 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 { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
import { MigrationProjectsPanel } from "@/components/admin/settings/sections/migration-projects-panel" import { MigrationProjectsPanel } from "@/components/admin/settings/sections/migration-projects-panel"
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import { import {
useCreateMailDomain, useCreateMailDomain,
useMailDomains, useMailDomains,
@ -17,42 +30,155 @@ export function MailDomainsSection() {
const domainsQuery = useMailDomains() const domainsQuery = useMailDomains()
const createDomain = useCreateMailDomain() const createDomain = useCreateMailDomain()
const [domainName, setDomainName] = useState("") const [domainName, setDomainName] = useState("")
const mailing = useOrgSettingsStore((s) => s.mailing)
const setMailing = useOrgSettingsStore((s) => s.setMailing)
const domains = domainsQuery.data?.domains ?? [] const domains = domainsQuery.data?.domains ?? []
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<OrgSettingsSection <OrgSettingsSection
title="Domaines mail hébergés" title="Mail"
description="Stalwart — vérification DNS, DKIM et provisioning des boîtes @domaine." description="Domaines hébergés Stalwart, relais SMTP des notifications suite et migration."
policySection="mailing"
> >
<div className="grid gap-4 md:grid-cols-[1fr_auto]"> <AutomationTabMasonry columns={2}>
<div className="space-y-2"> <Card>
<Label htmlFor="new-domain">Nouveau domaine</Label> <CardHeader className="pb-3">
<Input <CardTitle className="text-sm font-medium">Domaines hébergés</CardTitle>
id="new-domain" <CardDescription>
value={domainName} Vérification DNS, DKIM et provisioning des boîtes @domaine.
onChange={(e) => setDomainName(e.target.value)} </CardDescription>
placeholder="entreprise.com" </CardHeader>
/> <CardContent>
</div> <div className="grid gap-4 md:grid-cols-[1fr_auto]">
<div className="flex items-end"> <div className="space-y-2">
<Button <Label htmlFor="new-domain">Nouveau domaine</Label>
disabled={!domainName || createDomain.isPending} <Input
onClick={() => { id="new-domain"
void createDomain.mutateAsync({ name: domainName }).then(() => setDomainName("")) value={domainName}
}} onChange={(e) => setDomainName(e.target.value)}
> placeholder="entreprise.com"
Ajouter />
</Button> </div>
</div> <div className="flex items-end">
</div> <Button
disabled={!domainName || createDomain.isPending}
onClick={() => {
void createDomain.mutateAsync({ name: domainName }).then(() => setDomainName(""))
}}
>
Ajouter
</Button>
</div>
</div>
<ul className="mt-6 space-y-3"> <ul className="mt-6 space-y-3">
{domains.map((domain) => ( {domains.map((domain) => (
<DomainRow key={domain.id} domain={domain} /> <DomainRow key={domain.id} domain={domain} />
))} ))}
</ul> </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> </OrgSettingsSection>
<MigrationProjectsPanel domains={domains} /> <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({ function DomainRow({
domain, domain,
}: { }: {
@ -73,6 +223,19 @@ function DomainRow({
}) { }) {
const verifyTxt = useVerifyMailDomainTXT(domain.id) const verifyTxt = useVerifyMailDomainTXT(domain.id)
const verifyMx = useVerifyMailDomainMX(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 ( return (
<li className="rounded-lg border p-4"> <li className="rounded-lg border p-4">
@ -84,9 +247,26 @@ function DomainRow({
</p> </p>
<p className="text-sm text-muted-foreground">Statut : {domain.status}</p> <p className="text-sm text-muted-foreground">Statut : {domain.status}</p>
{domain.verification_token && ( {domain.verification_token && (
<p className="mt-1 text-xs text-muted-foreground"> <div className="mt-2 space-y-1 text-xs text-muted-foreground">
TXT : <code>_ultisuite-verify.{domain.name}</code> = {domain.verification_token} <p>Enregistrement TXT :</p>
</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>
<div className="flex gap-2"> <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, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form" import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
import { TechBrandSelectLabel } from "@/components/admin/settings/tech-brand-select-label"
import { import {
type DNSCheckReport, type DNSCheckReport,
type MailDomain, type MailDomain,
@ -209,11 +210,19 @@ export function MigrationProjectsPanel({ domains }: { domains: MailDomain[] }) {
}} }}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue>
<TechBrandSelectLabel brand={sourceProvider}>
{sourceProvider === "google" ? "Google Workspace" : "Microsoft 365"}
</TechBrandSelectLabel>
</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="google">Google Workspace</SelectItem> <SelectItem value="google">
<SelectItem value="microsoft">Microsoft 365</SelectItem> <TechBrandSelectLabel brand="google">Google Workspace</TechBrandSelectLabel>
</SelectItem>
<SelectItem value="microsoft">
<TechBrandSelectLabel brand="microsoft">Microsoft 365</TechBrandSelectLabel>
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@ -221,15 +230,25 @@ export function MigrationProjectsPanel({ domains }: { domains: MailDomain[] }) {
<Label>Mode d&apos;authentification</Label> <Label>Mode d&apos;authentification</Label>
<Select value={authMode} onValueChange={setAuthMode}> <Select value={authMode} onValueChange={setAuthMode}>
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue>
<TechBrandSelectLabel brand={authMode === "oauth" ? "oauth" : authMode}>
{AUTH_MODE_LABELS[authMode] ?? authMode}
</TechBrandSelectLabel>
</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="oauth">OAuth utilisateur</SelectItem> <SelectItem value="oauth">
<TechBrandSelectLabel brand="oauth">OAuth utilisateur</TechBrandSelectLabel>
</SelectItem>
<SelectItem value="google_dwd" disabled={sourceProvider !== "google"}> <SelectItem value="google_dwd" disabled={sourceProvider !== "google"}>
Google DWD (service account) <TechBrandSelectLabel brand="google">
Google DWD (service account)
</TechBrandSelectLabel>
</SelectItem> </SelectItem>
<SelectItem value="microsoft_app" disabled={sourceProvider !== "microsoft"}> <SelectItem value="microsoft_app" disabled={sourceProvider !== "microsoft"}>
Microsoft app-only (client credentials) <TechBrandSelectLabel brand="microsoft">
Microsoft app-only (client credentials)
</TechBrandSelectLabel>
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </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" "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 { 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 { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import { isPluginDeployLocked } from "@/lib/admin/deploy-runtime" import { isPluginDeployLocked } from "@/lib/admin/deploy-runtime"
import { Switch } from "@/components/ui/switch" 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 { 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() { export function PluginsSection() {
const plugins = useOrgSettingsStore((s) => s.plugins) const plugins = useOrgSettingsStore((s) => s.plugins)
const togglePlugin = useOrgSettingsStore((s) => s.togglePlugin) const togglePlugin = useOrgSettingsStore((s) => s.togglePlugin)
const deployLocked = useOrgSettingsStore((s) => s.meta?.deployLocked) 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 ( return (
<OrgSettingsSection <OrgSettingsSection
title="Plugins" title="Plugins"
description="Modules fonctionnels activables pour toute l'organisation." description="Modules fonctionnels et intégrations activables pour toute l'organisation."
policySection="plugins" policySection={["plugins", "nextcloud", "onlyoffice", "richtext"]}
> >
<div className="space-y-3"> <AutomationTabMasonry columns={2}>
{plugins.map((plugin) => { <NextcloudPluginCard />
{simplePlugins.map((plugin) => {
const locked = isPluginDeployLocked(deployLocked, plugin.id) const locked = isPluginDeployLocked(deployLocked, plugin.id)
return ( return (
<Card key={plugin.id}> <PluginToggleCard
<CardContent className="flex items-center gap-4 py-4"> key={plugin.id}
<div className="min-w-0 flex-1"> name={plugin.name}
<div className="flex items-center gap-2"> description={plugin.description}
<p className="font-medium">{plugin.name}</p> version={plugin.version}
<Badge variant="outline">v{plugin.version}</Badge> enabled={plugin.enabled}
</div> locked={locked}
<p className="mt-1 text-sm text-muted-foreground">{plugin.description}</p> lockSection="plugins"
{plugin.id === "ai-assistant" && !plugin.enabled ? ( lockField={plugin.id}
<p className="mt-1 text-xs text-muted-foreground"> onToggle={(enabled) => togglePlugin(plugin.id, enabled)}
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)}
/>
</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> </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 { ExternalLink, Link2, Trash2 } from "lucide-react"
import { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header" import { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header"
import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner" 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 { useAdminPublicShares } from "@/lib/api/hooks/use-admin-queries"
import { useRevokeAdminPublicShare } from "@/lib/api/hooks/use-admin-mutations" import { useRevokeAdminPublicShare } from "@/lib/api/hooks/use-admin-mutations"
import type { AdminPublicShare } from "@/lib/api/admin-types" import type { AdminPublicShare } from "@/lib/api/admin-types"
@ -26,17 +27,33 @@ const ACCESS_MODE_LABELS: Record<string, string> = {
internal: "Interne", 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() { export function PublicSharesSection() {
const [q, setQ] = useState("") const [q, setQ] = useState("")
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(25)
const [sort, setSort] = useState("-created_at")
const queryParams = useMemo( const queryParams = useMemo(
() => ({ () => ({
page, page,
page_size: 25, page_size: pageSize,
sort,
q: q.trim() || undefined, q: q.trim() || undefined,
}), }),
[page, q] [page, pageSize, sort, q]
) )
const { data, isFetching, isError, refetch } = useAdminPublicShares(queryParams) const { data, isFetching, isError, refetch } = useAdminPublicShares(queryParams)
@ -44,8 +61,8 @@ export function PublicSharesSection() {
const shares = data?.shares ?? [] const shares = data?.shares ?? []
const total = data?.pagination.total ?? 0 const total = data?.pagination.total ?? 0
const pageSize = data?.pagination.page_size ?? 25 const resolvedPageSize = data?.pagination.page_size ?? pageSize
const totalPages = Math.max(1, Math.ceil(total / pageSize)) const totalPages = Math.max(1, Math.ceil(total / resolvedPageSize))
return ( return (
<> <>
@ -55,19 +72,40 @@ export function PublicSharesSection() {
/> />
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} /> <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">
<Label className="text-xs">Recherche</Label> <div className="min-w-[240px] flex-1">
<Input <Label className="text-xs">Recherche</Label>
className="mt-1 h-9" <Input
value={q} className="mt-1 h-9"
onChange={(e) => { value={q}
setQ(e.target.value) onChange={(e) => {
setPage(1) setQ(e.target.value)
}} setPage(1)
placeholder="Propriétaire, chemin, token, destinataire…" }}
/> placeholder="Propriétaire, chemin, token, destinataire…"
/>
</div>
</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"> <div className="overflow-x-auto rounded-lg border">
<Table> <Table>
<TableHeader> <TableHeader>
@ -106,32 +144,6 @@ export function PublicSharesSection() {
</TableBody> </TableBody>
</Table> </Table>
</div> </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" "use client"
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form" 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 { 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 { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
@ -13,13 +17,15 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } 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() { export function SearchSection() {
const search = useOrgSettingsStore((s) => s.search) const search = useOrgSettingsStore((s) => s.search)
const setSearch = useOrgSettingsStore((s) => s.setSearch) const setSearch = useOrgSettingsStore((s) => s.setSearch)
const effective = useOrgSettingsStore((s) => s.meta?.effective.search) 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 engineLocked = useDeployFieldLocked("search", "suite_engine")
const meiliURLLocked = useDeployFieldLocked("search", "meilisearch_url") const meiliURLLocked = useDeployFieldLocked("search", "meilisearch_url")
@ -40,19 +46,47 @@ export function SearchSection() {
return ( return (
<OrgSettingsSection <OrgSettingsSection
title="Moteur de recherche" 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" policySection="search"
> >
<Card> <AutomationTabMasonry columns={2}>
<CardHeader className="pb-3"> <AdminSettingsCard
<CardTitle className="text-sm font-medium">Recherche suite</CardTitle> title="Recherche web"
<CardDescription> description={
Moteur d&apos;indexation pour la recherche globale (variables SEARCH_ENGINE côté serveur). <>
</CardDescription> Fournisseurs pour l&apos;enrichissement IA contacts et le tool UltiAI{" "}
{engineLocked ? <DeployLockedHint section="search" field="suite_engine" /> : null} <code className="rounded bg-muted px-1">web_search</code>. Les utilisateurs peuvent
</CardHeader> surcharger cette config dans leurs réglages si l&apos;imposition org. est désactivée.
<CardContent className="space-y-4"> </>
<div> }
>
<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> <Label>Moteur</Label>
<Select <Select
value={suiteEngine} value={suiteEngine}
@ -63,107 +97,94 @@ export function SearchSection() {
}) })
} }
> >
<SelectTrigger className="mt-1 h-9"> <SelectTrigger className="h-9 w-full min-w-0">
<SelectValue /> <SelectValue>
<TechBrandSelectLabel brand={suiteEngine}>
{suiteEngine === "postgres"
? "PostgreSQL (full-text)"
: suiteEngine === "meilisearch"
? "Meilisearch"
: "Typesense"}
</TechBrandSelectLabel>
</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="postgres">PostgreSQL (full-text)</SelectItem> <SelectItem value="postgres">
<SelectItem value="meilisearch">Meilisearch</SelectItem> <TechBrandSelectLabel brand="postgres">PostgreSQL (full-text)</TechBrandSelectLabel>
<SelectItem value="typesense">Typesense</SelectItem> </SelectItem>
<SelectItem value="meilisearch">
<TechBrandSelectLabel brand="meilisearch">Meilisearch</TechBrandSelectLabel>
</SelectItem>
<SelectItem value="typesense">
<TechBrandSelectLabel brand="typesense">Typesense</TechBrandSelectLabel>
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </FieldGroup>
{suiteEngine === "meilisearch" ? ( {suiteEngine === "meilisearch" ? (
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid min-w-0 gap-4">
<div> <FieldGroup>
<Label>URL Meilisearch</Label> <Label>URL Meilisearch</Label>
<Input <Input
className="mt-1 h-9" className="h-9"
value={meiliURL} value={meiliURL}
disabled={meiliURLLocked} disabled={meiliURLLocked}
onChange={(e) => setSearch({ meilisearch_url: e.target.value })} onChange={(e) => setSearch({ meilisearch_url: e.target.value })}
/> />
</div> {meiliURLLocked ? (
<div> <DeployLockedHint section="search" field="meilisearch_url" />
) : null}
</FieldGroup>
<FieldGroup>
<Label>Clé API</Label> <Label>Clé API</Label>
<Input <Input
className="mt-1 h-9" className="h-9"
type="password" type="password"
value={search.meilisearch_api_key} value={search.meilisearch_api_key}
disabled={meiliKeyLocked} disabled={meiliKeyLocked}
onChange={(e) => setSearch({ meilisearch_api_key: e.target.value })} onChange={(e) => setSearch({ meilisearch_api_key: e.target.value })}
placeholder={meiliKeyLocked ? "Défini via MEILISEARCH_API_KEY" : undefined} placeholder={meiliKeyLocked ? "Défini via MEILISEARCH_API_KEY" : undefined}
/> />
</div> {meiliKeyLocked ? (
<DeployLockedHint section="search" field="meilisearch_api_key" />
) : null}
</FieldGroup>
</div> </div>
) : null} ) : null}
{suiteEngine === "typesense" ? ( {suiteEngine === "typesense" ? (
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid min-w-0 gap-4">
<div> <FieldGroup>
<Label>URL Typesense</Label> <Label>URL Typesense</Label>
<Input <Input
className="mt-1 h-9" className="h-9"
value={typesenseURL} value={typesenseURL}
disabled={typesenseURLLocked} disabled={typesenseURLLocked}
onChange={(e) => setSearch({ typesense_url: e.target.value })} onChange={(e) => setSearch({ typesense_url: e.target.value })}
/> />
</div> {typesenseURLLocked ? (
<div> <DeployLockedHint section="search" field="typesense_url" />
) : null}
</FieldGroup>
<FieldGroup>
<Label>Clé API</Label> <Label>Clé API</Label>
<Input <Input
className="mt-1 h-9" className="h-9"
type="password" type="password"
value={search.typesense_api_key} value={search.typesense_api_key}
disabled={typesenseKeyLocked} disabled={typesenseKeyLocked}
onChange={(e) => setSearch({ typesense_api_key: e.target.value })} onChange={(e) => setSearch({ typesense_api_key: e.target.value })}
placeholder={typesenseKeyLocked ? "Défini via TYPESENSE_API_KEY" : undefined} placeholder={typesenseKeyLocked ? "Défini via TYPESENSE_API_KEY" : undefined}
/> />
</div> {typesenseKeyLocked ? (
<DeployLockedHint section="search" field="typesense_api_key" />
) : null}
</FieldGroup>
</div> </div>
) : null} ) : null}
</CardContent> </AdminSettingsCard>
</Card> </AutomationTabMasonry>
<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>
</OrgSettingsSection> </OrgSettingsSection>
) )
} }

View File

@ -11,7 +11,6 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
const METHODS = [ const METHODS = [
{ id: "totp" as const, label: "Application TOTP" }, { id: "totp" as const, label: "Application TOTP" },
{ id: "webauthn" as const, label: "Clés de sécurité (WebAuthn)" }, { id: "webauthn" as const, label: "Clés de sécurité (WebAuthn)" },
{ id: "sms" as const, label: "SMS" },
] ]
export function SecuritySection() { 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, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { TechBrandSelectLabel } from "@/components/admin/settings/tech-brand-select-label"
export function UltimeetSection() { export function UltimeetSection() {
const meet = useOrgSettingsStore((s) => s.meet) const meet = useOrgSettingsStore((s) => s.meet)
@ -169,12 +170,16 @@ export function UltimeetSection() {
} }
> >
<SelectTrigger className="h-9"> <SelectTrigger className="h-9">
<SelectValue /> <SelectValue>
<TechBrandSelectLabel brand={draft.external_api_provider}>
{MEET_EXTERNAL_API_PROVIDER_LABELS[draft.external_api_provider]}
</TechBrandSelectLabel>
</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{Object.entries(MEET_EXTERNAL_API_PROVIDER_LABELS).map(([id, label]) => ( {Object.entries(MEET_EXTERNAL_API_PROVIDER_LABELS).map(([id, label]) => (
<SelectItem key={id} value={id}> <SelectItem key={id} value={id}>
{label} <TechBrandSelectLabel brand={id}>{label}</TechBrandSelectLabel>
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </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" "use client"
import { useEffect, useMemo, useState } from "react" 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 { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header"
import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner" 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 { import {
useDeleteAdminUser, useDeleteAdminUser,
useDisableAdminUser, useDisableAdminUser,
@ -25,6 +28,7 @@ import {
} from "@/lib/admin/user-role" } from "@/lib/admin/user-role"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
@ -66,34 +70,87 @@ import {
const ROLE_OPTIONS: AdminUserRole[] = ["admin", "user", "guest", "suspended"] 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() { export function UsersSection() {
const [q, setQ] = useState("") const [q, setQ] = useState("")
const [role, setRole] = useState<string>("all") const [role, setRole] = useState<string>("all")
const [groupId, setGroupId] = useState<string>("all")
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(25)
const [sort, setSort] = useState("-created_at")
const [inviteOpen, setInviteOpen] = useState(false) const [inviteOpen, setInviteOpen] = useState(false)
const [groupsOpen, setGroupsOpen] = useState(false)
const [selectedId, setSelectedId] = useState<string | null>(null) const [selectedId, setSelectedId] = useState<string | null>(null)
const [selectedIds, setSelectedIds] = useState<string[]>([])
const queryParams = useMemo( const queryParams = useMemo(
() => ({ () => ({
page, page,
page_size: 25, page_size: pageSize,
sort,
q: q.trim() || undefined, q: q.trim() || undefined,
role: role === "all" ? undefined : role, 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 { data, isFetching, isError, refetch } = useAdminUsers(queryParams)
const users = data?.users ?? [] const users = data?.users
const total = data?.pagination.total ?? 0 const total = data?.pagination.total ?? 0
const pageSize = data?.pagination.page_size ?? 25 const resolvedPageSize = data?.pagination.page_size ?? pageSize
const totalPages = Math.max(1, Math.ceil(total / 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 ( return (
<> <>
<SettingsSectionHeader <SettingsSectionHeader
title="Utilisateurs" 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()} /> <SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
@ -120,13 +177,7 @@ export function UsersSection() {
}} }}
> >
<SelectTrigger className="mt-1 h-9"> <SelectTrigger className="mt-1 h-9">
<SelectValue> <SelectValue placeholder="Type d'utilisateur" />
{role === "all" ? (
<UserRoleLabel role="all" />
) : (
<UserRoleLabel role={role as AdminUserRole} />
)}
</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all"> <SelectItem value="all">
@ -140,18 +191,82 @@ export function UsersSection() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </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)}> <Button className="h-9" onClick={() => setInviteOpen(true)}>
<UserPlus className="mr-2 size-4" /> <UserPlus className="mr-2 size-4" />
Inviter Inviter
</Button> </Button>
</div> </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"> <div className="overflow-x-auto rounded-lg border">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <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>Utilisateur</TableHead>
<TableHead>Type</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">Mail</TableHead>
<TableHead className="hidden lg:table-cell">Drive</TableHead> <TableHead className="hidden lg:table-cell">Drive</TableHead>
<TableHead className="hidden md:table-cell">ID externe</TableHead> <TableHead className="hidden md:table-cell">ID externe</TableHead>
@ -159,17 +274,19 @@ export function UsersSection() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{users.length === 0 ? ( {(users ?? []).length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground"> <TableCell colSpan={8} className="text-center text-muted-foreground">
Aucun utilisateur trouvé. Aucun utilisateur trouvé.
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
users.map((user) => ( (users ?? []).map((user) => (
<UserRow <UserRow
key={user.id} key={user.id}
user={user} user={user}
selected={selectedIds.includes(user.id)}
onToggleSelect={(checked) => toggleUser(user.id, checked)}
onOpen={() => setSelectedId(user.id)} onOpen={() => setSelectedId(user.id)}
/> />
)) ))
@ -178,59 +295,57 @@ export function UsersSection() {
</Table> </Table>
</div> </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} /> <InviteUserDialog open={inviteOpen} onOpenChange={setInviteOpen} />
<UsersGroupsDialog open={groupsOpen} onOpenChange={setGroupsOpen} />
<UserDetailSheet userId={selectedId} onClose={() => setSelectedId(null)} /> <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 disableUser = useDisableAdminUser()
const reactivateUser = useReactivateAdminUser() const reactivateUser = useReactivateAdminUser()
const deleteUser = useDeleteAdminUser() const deleteUser = useDeleteAdminUser()
return ( return (
<TableRow className="cursor-pointer" onClick={onOpen}> <TableRow className="cursor-pointer">
<TableCell> <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="font-medium">{user.name || "—"}</div>
<div className="text-xs text-muted-foreground">{user.email}</div> <div className="text-xs text-muted-foreground">{user.email}</div>
</TableCell> </TableCell>
<TableCell> <TableCell onClick={onOpen}>
<RoleBadge role={resolveUserRole(user)} /> <RoleBadge role={resolveUserRole(user)} />
</TableCell> </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)} {formatBytes(user.storage?.mail_used_bytes ?? 0)}
</TableCell> </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)} {formatBytes(user.storage?.drive_used_bytes ?? 0)}
</TableCell> </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} {user.external_id}
</TableCell> </TableCell>
<TableCell onClick={(e) => e.stopPropagation()}> <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({ function UserRoleLabel({
role, role,
className, className,
@ -430,6 +567,12 @@ function UserDetailSheet({
<div className="space-y-8 px-6 py-6"> <div className="space-y-8 px-6 py-6">
<div className="space-y-4"> <div className="space-y-4">
<RoleBadge role={resolveUserRole(user)} /> <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"> <div className="space-y-2">
<Label>Type d&apos;utilisateur</Label> <Label>Type d&apos;utilisateur</Label>
<Select <Select
@ -437,9 +580,7 @@ function UserDetailSheet({
onValueChange={(v) => setSelectedRole(v as AdminUserRole)} onValueChange={(v) => setSelectedRole(v as AdminUserRole)}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue> <SelectValue placeholder="Type d'utilisateur" />
<UserRoleLabel role={selectedRole} />
</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{ROLE_OPTIONS.map((option) => ( {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 { useEffectiveAgendaSettings } from "@/lib/agenda/use-effective-agenda-settings"
import { useIsDemoApp } from "@/lib/demo/use-is-demo-app" import { useIsDemoApp } from "@/lib/demo/use-is-demo-app"
import { useThemeModeControls } from "@/lib/demo/use-theme-mode-controls" import { useThemeModeControls } from "@/lib/demo/use-theme-mode-controls"
import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry"
import { import {
MAIL_SETTINGS_PAGE_MASONRY_CLASS,
MAIL_SETTINGS_PAGE_MASONRY_SECTION_CLASS, MAIL_SETTINGS_PAGE_MASONRY_SECTION_CLASS,
} from "@/lib/mail-chrome-classes" } from "@/lib/mail-chrome-classes"
import type { MailThemeMode } from "@/lib/mail-settings/types" import type { MailThemeMode } from "@/lib/mail-settings/types"
@ -459,7 +459,7 @@ export function AgendaSettingsFields({
) )
if (isPage) { if (isPage) {
return <div className={MAIL_SETTINGS_PAGE_MASONRY_CLASS}>{fields}</div> return <AutomationTabMasonry columns={2}>{fields}</AutomationTabMasonry>
} }
return fields return fields

View File

@ -1,13 +1,11 @@
"use client" "use client"
import { useEffect, useMemo, useRef } from "react" import { useEffect, useMemo, useRef, useState } from "react"
import type { AiChatContext } from "@/lib/ai/chat-context" 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 { buildAiEmbedUrl, resolveAiEmbedOrigin } from "@/lib/ai/embed-url"
import { import { useAiIframeExternalLinks } from "@/lib/ai/use-ai-iframe-navigation"
useAiIframeExternalLinks, import { useAiConfig, useCreateAiSession } from "@/lib/api/hooks/use-ai-queries"
useAiIframeNavigation,
} from "@/lib/ai/use-ai-iframe-navigation"
import { useTheme } from "next-themes" import { useTheme } from "next-themes"
type AiChatIframeProps = { type AiChatIframeProps = {
@ -19,15 +17,64 @@ type AiChatIframeProps = {
export function AiChatIframe({ publicPath = "/ai", context, className }: AiChatIframeProps) { export function AiChatIframe({ publicPath = "/ai", context, className }: AiChatIframeProps) {
const iframeRef = useRef<HTMLIFrameElement>(null) const iframeRef = useRef<HTMLIFrameElement>(null)
const { resolvedTheme } = useTheme() 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 embedOrigin = useMemo(() => resolveAiEmbedOrigin(publicPath), [publicPath])
const src = useMemo(() => { const enabledTools = config?.enabled_tools ?? []
const qs = buildEmbedSearchParams(context) const mcpUrl = config?.mcp_url ?? "/api/v1/ai/mcp"
return buildAiEmbedUrl(publicPath, qs)
}, [publicPath, context]) 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) 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(() => { useEffect(() => {
const iframe = iframeRef.current const iframe = iframeRef.current
if (!iframe?.contentWindow || !embedOrigin) return if (!iframe?.contentWindow || !embedOrigin) return
@ -40,21 +87,54 @@ export function AiChatIframe({ publicPath = "/ai", context, className }: AiChatI
useEffect(() => { useEffect(() => {
const iframe = iframeRef.current const iframe = iframeRef.current
if (!iframe?.contentWindow || !embedOrigin) return if (!iframe?.contentWindow || !embedOrigin) return
const systemPrompt = [
systemPromptFromContext(context),
context.systemPromptExtra,
]
.filter(Boolean)
.join("\n\n")
iframe.contentWindow.postMessage( iframe.contentWindow.postMessage(
{ {
type: "ULTI_CONTEXT_UPDATE", type: "ULTI_CONTEXT_UPDATE",
context, context,
systemPrompt: context.systemPromptExtra, systemPrompt: systemPrompt || undefined,
}, },
embedOrigin embedOrigin
) )
}, [context, 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 ( return (
<iframe <iframe
key={iframeSrc}
ref={iframeRef} ref={iframeRef}
title="UltiAI" title="UltiAI"
src={src} src={iframeSrc}
className={className} className={className}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-downloads" sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-downloads"
allow="clipboard-read; clipboard-write" allow="clipboard-read; clipboard-write"

View File

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

View File

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

View File

@ -1,7 +1,6 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { Plus, Trash2 } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
@ -16,25 +15,18 @@ import {
useLLMSettings, useLLMSettings,
useUpdateLLMSettings, useUpdateLLMSettings,
} from "@/lib/api/hooks/use-contact-discovery" } 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 { LLMModelSuggestInput } from "@/components/gmail/settings/automation/llm-model-suggest-input"
import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry" 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 { import {
CONTACTS_MUTED_TEXT, CONTACTS_MUTED_TEXT,
CONTACTS_PRIMARY_BTN_CLASS, CONTACTS_PRIMARY_BTN_CLASS,
} from "@/lib/contacts-chrome-classes" } from "@/lib/contacts-chrome-classes"
import { cn } from "@/lib/utils" 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() { export function LLMProvidersPanel() {
const { data: remote, isLoading } = useLLMSettings() const { data: remote, isLoading } = useLLMSettings()
const updateSettings = useUpdateLLMSettings() const updateSettings = useUpdateLLMSettings()
@ -48,40 +40,11 @@ export function LLMProvidersPanel() {
if (remote) { if (remote) {
setDraft({ setDraft({
...remote, ...remote,
providers: remote.providers ?? [], providers: (remote.providers ?? []).map(normalizeLlmProvider),
}) })
} }
}, [remote]) }, [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() { async function handleSave() {
await updateSettings.mutateAsync(draft) await updateSettings.mutateAsync(draft)
setSaved(true) setSaved(true)
@ -101,87 +64,30 @@ export function LLMProvidersPanel() {
</p> </p>
</div> </div>
<AutomationTabMasonry columns={2}> <LlmProvidersEditor
{draft.providers.map((provider, index) => ( providers={draft.providers}
<div key={provider.id} className="space-y-3 rounded-lg border border-border p-4"> defaultProviderId={draft.default_provider_id}
<div className="flex items-center justify-between"> onProvidersChange={(providers) => setDraft((prev) => ({ ...prev, providers }))}
<span className="text-sm font-medium">{provider.name || `Fournisseur ${index + 1}`}</span> onDefaultProviderIdChange={(default_provider_id) =>
<Button setDraft((prev) => ({ ...prev, default_provider_id }))
variant="ghost" }
size="icon" renderDefaultModelInput={({ provider, onChange }) => (
onClick={() => removeProvider(index)} <LLMModelSuggestInput
aria-label="Supprimer" baseUrl={provider.base_url}
> apiKey={provider.api_key}
<Trash2 className="h-4 w-4" /> value={provider.default_model}
</Button> onChange={onChange}
</div> placeholder="gpt-4o-mini"
<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>
<LLMModelSuggestInput
className="mt-1"
baseUrl={provider.base_url}
apiKey={provider.api_key}
value={provider.default_model}
onChange={(default_model) => updateProvider(index, { default_model })}
placeholder="gpt-4o-mini"
/>
</div>
</div>
</div>
))}
<AutomationTabMasonry columns={2}>
<div className="space-y-3 rounded-lg border border-border p-4"> <div className="space-y-3 rounded-lg border border-border p-4">
<h4 className="text-sm font-medium">Découverte de contacts</h4> <h4 className="text-sm font-medium">Découverte de contacts</h4>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<div> <div>
<Label className="text-xs">Fournisseur par défaut</Label> <Label className="text-xs">Fournisseur pour l&apos;enrichissement</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>
<Select <Select
value={draft.contact_discovery_provider_id ?? draft.default_provider_id} value={draft.contact_discovery_provider_id ?? draft.default_provider_id}
onValueChange={(v) => onValueChange={(v) =>
@ -192,11 +98,17 @@ export function LLMProvidersPanel() {
<SelectValue placeholder="Même que défaut" /> <SelectValue placeholder="Même que défaut" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{draft.providers.map((p) => ( {draft.providers.map((p) => {
<SelectItem key={p.id} value={p.id}> const type = inferLlmProviderType(p)
{p.name || p.id} const entry = llmCatalogEntry(type)
</SelectItem> return (
))} <SelectItem key={p.id} value={p.id}>
<TechBrandSelectLabel brand={type} icon={entry.icon}>
{p.name || entry.label}
</TechBrandSelectLabel>
</SelectItem>
)
})}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@ -216,10 +128,6 @@ export function LLMProvidersPanel() {
</AutomationTabMasonry> </AutomationTabMasonry>
<div className="flex flex-wrap gap-2"> <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 <Button
onClick={handleSave} onClick={handleSave}
disabled={updateSettings.isPending} disabled={updateSettings.isPending}

View File

@ -1,68 +1,35 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { ExternalLink } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { import {
useSearchSettings, useSearchSettings,
useUpdateSearchSettings, useUpdateSearchSettings,
} from "@/lib/api/hooks/use-contact-discovery" } 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 { import {
CONTACTS_MUTED_TEXT, CONTACTS_MUTED_TEXT,
CONTACTS_PRIMARY_BTN_CLASS, CONTACTS_PRIMARY_BTN_CLASS,
} from "@/lib/contacts-chrome-classes" } from "@/lib/contacts-chrome-classes"
import { normalizeSearchProviders } from "@/lib/web-search/search-provider-catalog"
import { cn } from "@/lib/utils" 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() { export function SearchProvidersPanel() {
const { data: remote, isLoading } = useSearchSettings() const { data: remote, isLoading } = useSearchSettings()
const updateSettings = useUpdateSearchSettings() const updateSettings = useUpdateSearchSettings()
const [draft, setDraft] = useState<ApiSearchSettings>(normalizeDraft(undefined)) const [draft, setDraft] = useState<ApiSearchSettings>(normalizeSearchProviders(undefined))
const [saved, setSaved] = useState(false) const [saved, setSaved] = useState(false)
useEffect(() => { useEffect(() => {
if (remote) { if (remote) {
setDraft(normalizeDraft(remote)) setDraft(normalizeSearchProviders(remote))
} }
}, [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() { async function handleSave() {
await updateSettings.mutateAsync(draft) const saved = await updateSettings.mutateAsync(draft)
setDraft(normalizeSearchProviders(saved))
setSaved(true) setSaved(true)
setTimeout(() => setSaved(false), 2000) setTimeout(() => setSaved(false), 2000)
} }
@ -72,44 +39,16 @@ export function SearchProvidersPanel() {
} }
return ( return (
<div className="space-y-6"> <div className="w-full space-y-6">
<div> <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)}> <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 Brave, Bing, DuckDuckGo, SearXNG ou API JSON custom pour l&apos;enrichissement IA des
publics, réseaux sociaux, poste, entreprise). contacts et le tool UltiAI <code className="rounded bg-muted px-1 text-xs">web_search</code>.
</p> </p>
</div> </div>
<div className="space-y-3 rounded-lg border border-border p-4"> <WebSearchProvidersEditor value={draft} onChange={setDraft} />
<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>
<Button <Button
onClick={handleSave} onClick={handleSave}

View File

@ -34,7 +34,7 @@ export function AutomationSettingsSection() {
<TabsContent value="llm" className="mt-4"> <TabsContent value="llm" className="mt-4">
<LLMProvidersPanel /> <LLMProvidersPanel />
</TabsContent> </TabsContent>
<TabsContent value="search" className="mt-4"> <TabsContent value="search" className="mt-4 w-full">
<SearchProvidersPanel /> <SearchProvidersPanel />
</TabsContent> </TabsContent>
<TabsContent value="tokens" className="mt-4"> <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 { ApiOrgPolicy, ApiOrgSettingsResponse } from "@/lib/api/admin-org-types"
import type { OrgPolicySectionKey } 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 { DEFAULT_MEET_POLICY } from "@/lib/meet/meet-settings-types"
import { normalizeLlmProvider } from "@/lib/llm/llm-provider-catalog"
const INTEGRATION_HREFS: Record<string, string> = { const INTEGRATION_HREFS: Record<string, string> = {
authentik: "/admin/settings/authentication", authentik: "/admin/settings/authentication",
nextcloud: "/admin/settings/nextcloud", nextcloud: "/admin/settings/plugins",
onlyoffice: "/admin/settings/onlyoffice", onlyoffice: "/admin/settings/plugins",
smtp: "/admin/settings/mailing", 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( function mergeIntegrations(
@ -129,7 +147,9 @@ export function apiOrgPolicyToStore(policy: ApiOrgPolicy): Partial<OrgSettingsSt
twoFactor: { twoFactor: {
required_for_all: policy.two_factor.required_for_all, required_for_all: policy.two_factor.required_for_all,
required_for_admins: policy.two_factor.required_for_admins, 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, grace_period_days: policy.two_factor.grace_period_days,
remember_device_days: policy.two_factor.remember_device_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), filePolicies: mergeFilePolicies(policy.file_policies),
llm: { llm: {
...policy.llm, ...policy.llm,
providers: (policy.llm.providers ?? []).map((provider) => ({ providers: (policy.llm.providers ?? []).map((provider) =>
...provider, normalizeLlmProvider({
api_key: provider.api_key ?? "", ...provider,
})), api_key: provider.api_key ?? "",
}),
),
}, },
search: { search: {
...policy.search, ...policy.search,
@ -164,9 +186,9 @@ export function apiOrgPolicyToStore(policy: ApiOrgPolicy): Partial<OrgSettingsSt
enabled: policy.ai_assistant?.enabled ?? false, enabled: policy.ai_assistant?.enabled ?? false,
openwebui_internal_url: policy.ai_assistant?.openwebui_internal_url ?? "", openwebui_internal_url: policy.ai_assistant?.openwebui_internal_url ?? "",
public_path: policy.ai_assistant?.public_path ?? "/ai", 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 ?? "", 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_sync_enabled: policy.ai_assistant?.chat_sync_enabled ?? true,
chat_nc_path: policy.ai_assistant?.chat_nc_path ?? "/.ultimail/ai/chats", chat_nc_path: policy.ai_assistant?.chat_nc_path ?? "/.ultimail/ai/chats",
models: (policy.ai_assistant?.models ?? []).map((entry) => ({ models: (policy.ai_assistant?.models ?? []).map((entry) => ({
@ -190,7 +212,7 @@ export function apiOrgPolicyToStore(policy: ApiOrgPolicy): Partial<OrgSettingsSt
...policy.meet?.post_actions, ...policy.meet?.post_actions,
}, },
}, },
plugins: policy.plugins ?? [], plugins: mergePlugins(policy.plugins),
integrations: mergeIntegrations(policy.integrations as IntegrationEntry[]), integrations: mergeIntegrations(policy.integrations as IntegrationEntry[]),
} }
} }
@ -214,7 +236,9 @@ export function storeToApiOrgPolicy(state: OrgSettingsState): ApiOrgPolicy {
two_factor: { two_factor: {
required_for_all: state.twoFactor.required_for_all, required_for_all: state.twoFactor.required_for_all,
required_for_admins: state.twoFactor.required_for_admins, 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, grace_period_days: state.twoFactor.grace_period_days,
remember_device_days: state.twoFactor.remember_device_days, remember_device_days: state.twoFactor.remember_device_days,
}, },

View File

@ -1,6 +1,7 @@
"use client" "use client"
import { create } from "zustand" 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 { OrgSettingsMeta } from "@/lib/admin-settings/map-api-org-settings"
import type { import type {
Administrator, Administrator,
@ -23,6 +24,7 @@ import type {
IdentityProvidersPolicy, IdentityProvidersPolicy,
} from "@/lib/admin-settings/org-settings-types" } from "@/lib/admin-settings/org-settings-types"
import { DEFAULT_MEET_POLICY } from "@/lib/meet/meet-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 = { const DEFAULT_AUTHENTIK: AuthentikSettings = {
enabled: true, enabled: true,
@ -137,9 +139,9 @@ const DEFAULT_AI_ASSISTANT: AiAssistantSettings = {
enabled: false, enabled: false,
openwebui_internal_url: "", openwebui_internal_url: "",
public_path: "/ai", public_path: "/ai",
embed_default_temporary: true, embed_default_temporary: false,
default_model: "", default_model: "",
enabled_tools: ["mail", "drive", "contacts", "search"], enabled_tools: [...DEFAULT_ULTIAI_ENABLED_TOOLS],
chat_sync_enabled: true, chat_sync_enabled: true,
chat_nc_path: "/.ultimail/ai/chats", chat_nc_path: "/.ultimail/ai/chats",
models: [], models: [],
@ -155,51 +157,6 @@ const DEFAULT_AGENDA: AgendaOrgPolicySettings = {
const DEFAULT_MEET: MeetOrgPolicySettings = DEFAULT_MEET_POLICY 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[] = [ const DEFAULT_INTEGRATIONS: IntegrationEntry[] = [
{ {
id: "authentik", id: "authentik",
@ -215,7 +172,7 @@ const DEFAULT_INTEGRATIONS: IntegrationEntry[] = [
description: "Drive, agenda, contacts et Talk.", description: "Drive, agenda, contacts et Talk.",
enabled: false, enabled: false,
configured: false, configured: false,
href: "/admin/settings/nextcloud", href: "/admin/settings/plugins",
}, },
{ {
id: "onlyoffice", id: "onlyoffice",
@ -223,7 +180,7 @@ const DEFAULT_INTEGRATIONS: IntegrationEntry[] = [
description: "Édition de documents dans le navigateur.", description: "Édition de documents dans le navigateur.",
enabled: false, enabled: false,
configured: false, configured: false,
href: "/admin/settings/onlyoffice", href: "/admin/settings/plugins",
}, },
{ {
id: "smtp", id: "smtp",
@ -231,7 +188,7 @@ const DEFAULT_INTEGRATIONS: IntegrationEntry[] = [
description: "SMTP pour notifications suite (partages, mentions).", description: "SMTP pour notifications suite (partages, mentions).",
enabled: false, enabled: false,
configured: false, configured: false,
href: "/admin/settings/mailing", href: "/admin/settings/mail-domains",
}, },
] ]
@ -321,7 +278,7 @@ export const useOrgSettingsStore = create<
aiAssistant: DEFAULT_AI_ASSISTANT, aiAssistant: DEFAULT_AI_ASSISTANT,
agenda: DEFAULT_AGENDA, agenda: DEFAULT_AGENDA,
meet: DEFAULT_MEET, meet: DEFAULT_MEET,
plugins: DEFAULT_PLUGINS, plugins: DEFAULT_ORG_PLUGINS,
integrations: DEFAULT_INTEGRATIONS, integrations: DEFAULT_INTEGRATIONS,
meta: null, meta: null,
apiSynced: false, apiSynced: false,
@ -386,6 +343,18 @@ export const useOrgSettingsStore = create<
aiAssistant: { ...s.aiAssistant, enabled }, 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 } return { plugins }
}), }),
setIntegrations: (integrations) => set({ integrations }), setIntegrations: (integrations) => set({ integrations }),

View File

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

View File

@ -1,16 +1,11 @@
import type { LucideIcon } from "lucide-react" import type { LucideIcon } from "lucide-react"
import { import {
Activity,
Bot, Bot,
Calendar, Calendar,
Video, Video,
Cloud,
FileCog, FileCog,
FileText,
Gauge, Gauge,
HardDrive,
Link2, Link2,
Globe,
LayoutDashboard, LayoutDashboard,
Mail, Mail,
Puzzle, Puzzle,
@ -26,18 +21,13 @@ export type AdminSettingsSectionId =
| "users" | "users"
| "authentication" | "authentication"
| "security" | "security"
| "storage-quotas" | "quotas"
| "usage-quotas"
| "file-policies" | "file-policies"
| "public-shares" | "public-shares"
| "llm" | "llm"
| "search" | "search"
| "plugins" | "plugins"
| "nextcloud"
| "mailing"
| "mail-domains" | "mail-domains"
| "onlyoffice"
| "richtext"
| "ai-assistant" | "ai-assistant"
| "agenda" | "agenda"
| "ultimeet" | "ultimeet"
@ -81,17 +71,10 @@ export const ADMIN_SETTINGS_NAV: AdminSettingsNavItem[] = [
icon: ShieldCheck, icon: ShieldCheck,
}, },
{ {
id: "storage-quotas", id: "quotas",
label: "Quotas stockage", label: "Quotas",
description: "Limites mail, drive et photos", description: "Stockage, LLM, recherche web et automatisations",
href: "/admin/settings/storage-quotas", href: "/admin/settings/quotas",
icon: HardDrive,
},
{
id: "usage-quotas",
label: "Quotas d'usage",
description: "LLM, recherche web et API par utilisateur",
href: "/admin/settings/usage-quotas",
icon: Gauge, icon: Gauge,
}, },
{ {
@ -108,13 +91,6 @@ export const ADMIN_SETTINGS_NAV: AdminSettingsNavItem[] = [
href: "/admin/settings/public-shares", href: "/admin/settings/public-shares",
icon: Link2, icon: Link2,
}, },
{
id: "llm",
label: "Fournisseurs LLM",
description: "Modèles IA organisationnels",
href: "/admin/settings/llm",
icon: Bot,
},
{ {
id: "search", id: "search",
label: "Moteur de recherche", label: "Moteur de recherche",
@ -125,17 +101,10 @@ export const ADMIN_SETTINGS_NAV: AdminSettingsNavItem[] = [
{ {
id: "plugins", id: "plugins",
label: "Plugins", label: "Plugins",
description: "Modules fonctionnels activables", description: "Nextcloud, modules et intégrations activables",
href: "/admin/settings/plugins", href: "/admin/settings/plugins",
icon: Puzzle, icon: Puzzle,
}, },
{
id: "nextcloud",
label: "Nextcloud",
description: "Drive, agenda, contacts et Talk",
href: "/admin/settings/nextcloud",
icon: Cloud,
},
{ {
id: "agenda", id: "agenda",
label: "Agenda", label: "Agenda",
@ -152,36 +121,15 @@ export const ADMIN_SETTINGS_NAV: AdminSettingsNavItem[] = [
}, },
{ {
id: "mail-domains", id: "mail-domains",
label: "Domaines mail", label: "Mail",
description: "Hébergement Stalwart, DNS et migration", description: "Domaines hébergés, SMTP et migration",
href: "/admin/settings/mail-domains", href: "/admin/settings/mail-domains",
icon: Globe,
},
{
id: "mailing",
label: "Mailing unifié",
description: "SMTP des notifications suite",
href: "/admin/settings/mailing",
icon: Mail, 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", id: "ai-assistant",
label: "UltiAI", label: "UltiAI",
description: "Assistant IA intégré et tools suite", description: "Assistant IA, fournisseurs LLM et tools suite",
href: "/admin/settings/ai-assistant", href: "/admin/settings/ai-assistant",
icon: Bot, icon: Bot,
}, },
@ -210,6 +158,10 @@ export function resolveAdminSettingsSection(
segments: string[] | undefined segments: string[] | undefined
): AdminSettingsSectionId { ): AdminSettingsSectionId {
const slug = segments?.[0] 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) => { const match = ADMIN_SETTINGS_NAV.find((item) => {
if (item.id === "overview") return !slug || slug === "overview" if (item.id === "overview") return !slug || slug === "overview"
return item.href.endsWith(`/${slug}`) return item.href.endsWith(`/${slug}`)
@ -219,10 +171,19 @@ export function resolveAdminSettingsSection(
const ADMIN_WIDE_SECTIONS: AdminSettingsSectionId[] = [ const ADMIN_WIDE_SECTIONS: AdminSettingsSectionId[] = [
"overview", "overview",
"audit",
"ai-assistant",
"plugins",
"quotas",
"search",
"mail-domains",
"authentication",
"file-policies",
]
const ADMIN_FULL_WIDTH_SECTIONS: AdminSettingsSectionId[] = [
"users", "users",
"public-shares", "public-shares",
"audit",
"llm",
] ]
export function isAdminSettingsWideLayoutPath(pathname: string | null): boolean { export function isAdminSettingsWideLayoutPath(pathname: string | null): boolean {
@ -233,3 +194,12 @@ export function isAdminSettingsWideLayoutPath(pathname: string | null): boolean
isAdminSettingsNavActive(pathname, item) 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_DOCS_APPLY"; payload: unknown }
| { type: "ULTI_ASSISTANT_TEXT"; text: string } | { type: "ULTI_ASSISTANT_TEXT"; text: string }
| { type: "ULTI_THEME"; theme: "light" | "dark" } | { 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_OPEN_LINK"; href: string }
| { type: "ULTI_TOOL_RESULT"; payload: unknown } | { 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 params = new URLSearchParams()
const model = defaultModel?.trim()
if (model) params.set("model", model)
if (context.temporary !== false) params.set("temporary-chat", "true") if (context.temporary !== false) params.set("temporary-chat", "true")
if (context.app) params.set("app", context.app) if (context.app) params.set("app", context.app)
if (context.messageId) params.set("message_id", context.messageId) 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 { export function systemPromptFromContext(context: AiChatContext): string {
const lines = [ 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.", "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) { if (context.app === "mail" && context.subject) {
lines.push(`Contexte mail — sujet: ${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 const { pathname, search, hash, origin } = win.location
if (embedOrigin && origin !== embedOrigin) return if (embedOrigin && origin !== embedOrigin) return
const inBase = const inBase = pathname === base || pathname.startsWith(`${base}/`)
pathname === base || pathname.startsWith(`${base}/`)
if (inBase) return if (inBase) return
// Suite landing or explicit suite module — stay on OpenWebUI home. const target =
if (pathname === "/" || pathname === "" || isSuiteRoute(pathname)) { pathname === "/" || pathname === "" || isSuiteRoute(pathname)
win.location.replace(`${base}/${search}${hash}`) ? `${base}/${search}${hash}`
return : `${base}${pathname}${search}${hash}`
}
// OpenWebUI route without /ai prefix (e.g. /notes, /workspace) — preserve path. const targetUrl = target.startsWith("http") ? target : `${origin}${target}`
win.location.replace(`${base}${pathname}${search}${hash}`) if (win.location.href === targetUrl) return
// One-shot fix only — polling caused SvelteKit "Redirect loop" → 500: Internal Error
win.location.replace(targetUrl)
} catch { } catch {
// Cross-origin — parent cannot read location. // Cross-origin — parent cannot read location.
} }
} }
iframe.addEventListener("load", enforceBasePath) const onLoad = () => window.setTimeout(enforceBasePath, 0)
const timer = window.setInterval(enforceBasePath, 400) iframe.addEventListener("load", onLoad)
return () => { return () => {
iframe.removeEventListener("load", enforceBasePath) iframe.removeEventListener("load", onLoad)
window.clearInterval(timer)
} }
}, [iframeRef, publicPath]) }, [iframeRef, publicPath])
} }

View File

@ -67,7 +67,7 @@ export type ApiIdentityProvidersPolicy = {
export type ApiOrgTwoFactor = { export type ApiOrgTwoFactor = {
required_for_all: boolean required_for_all: boolean
required_for_admins: boolean required_for_admins: boolean
allowed_methods: ("totp" | "webauthn" | "sms")[] allowed_methods: ("totp" | "webauthn")[]
grace_period_days: number grace_period_days: number
remember_device_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 AdminUserRole = "admin" | "user" | "guest" | "suspended"
export type AdminUserGroupRef = {
id: string
name: string
}
export type AdminUser = { export type AdminUser = {
id: string id: string
external_id: string external_id: string
@ -10,6 +15,7 @@ export type AdminUser = {
status: AdminUserStatus status: AdminUserStatus
platform_admin: boolean platform_admin: boolean
role: AdminUserRole role: AdminUserRole
groups?: AdminUserGroupRef[]
storage?: AdminUserStorage storage?: AdminUserStorage
invited_at?: string | null invited_at?: string | null
disabled_at?: string | null disabled_at?: string | null
@ -44,6 +50,59 @@ export type AdminUsersListResponse = {
pagination: AdminPagination 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 = { export type AdminAuditLog = {
id: string id: string
actor: string actor: string

View File

@ -3,7 +3,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { apiClient } from "@/lib/api/client" import { apiClient } from "@/lib/api/client"
import { useAuthReady } from "@/lib/api/use-auth-ready" 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() { export function useAdminDriveOrgFolders() {
const { ready, authenticated } = useAuthReady() const { ready, authenticated } = useAuthReady()
@ -50,3 +50,49 @@ export function useAdminDriveOrgFolderMutations() {
return { create, update, remove, sync } 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 { useMutation, useQueryClient } from "@tanstack/react-query"
import { apiClient } from "@/lib/api/client" import { apiClient } from "@/lib/api/client"
import type { import type {
AdminBulkUsersRequest,
AdminBulkUsersResponse,
AdminCreateUserGroupRequest,
AdminCreateUserRequest, AdminCreateUserRequest,
AdminInviteUserRequest, AdminInviteUserRequest,
AdminSetQuotaRequest, AdminSetQuotaRequest,
AdminSetUserGroupMembersRequest,
AdminSetUserRoleRequest, AdminSetUserRoleRequest,
AdminUpdateUserGroupRequest,
AdminUpdateUserRequest, AdminUpdateUserRequest,
AdminUser, AdminUser,
AdminUserDetail, AdminUserDetail,
AdminUserGroup,
} from "@/lib/api/admin-types" } from "@/lib/api/admin-types"
export function useCreateAdminUser() { 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, AdminPublicSharesListResponse,
AdminStatsResponse, AdminStatsResponse,
AdminUserDetail, AdminUserDetail,
AdminUserGroupsListResponse,
AdminUsersListResponse, AdminUsersListResponse,
} from "@/lib/api/admin-types" } from "@/lib/api/admin-types"
import { useAuthReady } from "@/lib/api/use-auth-ready" import { useAuthReady } from "@/lib/api/use-auth-ready"
@ -15,8 +16,16 @@ export type AdminUsersQueryParams = {
page?: number page?: number
page_size?: number page_size?: number
q?: string q?: string
sort?: string
status?: string status?: string
role?: string role?: string
group_id?: string
}
export type AdminUserGroupsQueryParams = {
page?: number
page_size?: number
q?: string
} }
export function useAdminStats() { export function useAdminStats() {
@ -37,8 +46,24 @@ export function useAdminUsers(params: AdminUsersQueryParams = {}) {
page: params.page?.toString(), page: params.page?.toString(),
page_size: params.page_size?.toString(), page_size: params.page_size?.toString(),
q: params.q, q: params.q,
sort: params.sort,
status: params.status, status: params.status,
role: params.role, 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, 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() const { ready, authenticated } = useAuthReady()
return useQuery({ return useQuery({
queryKey: ["admin", "public-shares", params], queryKey: ["admin", "public-shares", params],
@ -62,6 +89,7 @@ export function useAdminPublicShares(params: { page?: number; page_size?: number
page: params.page?.toString(), page: params.page?.toString(),
page_size: params.page_size?.toString(), page_size: params.page_size?.toString(),
q: params.q, q: params.q,
sort: params.sort,
}), }),
enabled: ready && authenticated, enabled: ready && authenticated,
}) })

View File

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

View File

@ -790,8 +790,8 @@ export function useUpdateSearchSettings() {
return useMutation({ return useMutation({
mutationFn: (settings: ApiSearchSettings) => mutationFn: (settings: ApiSearchSettings) =>
apiClient.put<ApiSearchSettings>('/contacts/discovery/search-settings', settings), apiClient.put<ApiSearchSettings>('/contacts/discovery/search-settings', settings),
onSuccess: () => { onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['search-settings'] }) 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 { export interface ApiLLMProvider {
id: string id: string
name: string name: string
type?: ApiLLMProviderType
base_url: string base_url: string
api_key?: string api_key?: string
default_model: string default_model: string
@ -17,13 +36,25 @@ export interface ApiLLMModelsResponse {
models: string[] models: string[]
} }
export type ApiSearchProviderType = 'brave' export type ApiSearchProviderType =
| "brave"
| "bing"
| "duckduckgo"
| "searxng"
| "custom"
export interface ApiSearchProvider { export interface ApiSearchProvider {
id: string id: string
name: string name: string
type: ApiSearchProviderType type: ApiSearchProviderType
api_key?: string 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 { 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)]", "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+. */ /** @deprecated Utiliser `AutomationTabMasonry` (2 piles verticales indépendantes). */
export const MAIL_SETTINGS_PAGE_MASONRY_CLASS = "lg:columns-2 lg:gap-5" export const MAIL_SETTINGS_PAGE_MASONRY_CLASS = "w-full"
export const MAIL_SETTINGS_PAGE_MASONRY_ITEM_CLASS = /** @deprecated Géré par `AutomationTabMasonry`. */
"lg:mb-5 lg:break-inside-avoid" export const MAIL_SETTINGS_PAGE_MASONRY_ITEM_CLASS = "min-w-0"
/** Bloc empilé → card en masonry (variant page affichage). */ /** Bloc empilé → card en masonry (variant page affichage). */
export const MAIL_SETTINGS_PAGE_MASONRY_SECTION_CLASS = cn( export const MAIL_SETTINGS_PAGE_MASONRY_SECTION_CLASS = cn(
"mail-settings-masonry-section border-border px-0 py-5", "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)]", "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", "rules", "Règles de tri", "automatisation filtre tri forward réponse"),
entry("automation", "webhooks", "Webhooks", "http post template payload externe"), entry("automation", "webhooks", "Webhooks", "http post template payload externe"),
entry("automation", "llm", "Fournisseurs LLM", "ia openai tri intelligent llm"), 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", "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-rules", "Règles agenda", "événement calendrier invitation visio participant"),
entry("automation", "agenda-webhooks", "Webhooks agenda", "événement calendrier créé modifié supprimé réponse"), 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