From 9e9fd208adab73d8c0cd764fc0151099418bc487 Mon Sep 17 00:00:00 2001 From: R3D347HR4Y Date: Mon, 15 Jun 2026 00:22:20 +0200 Subject: [PATCH] feat(admin-settings): enhance admin settings with new components and layout improvements - Introduced new components for managing admin settings, including AdminListControls, AdminSettingsCard, and TechBrandSelectLabel. - Implemented dynamic loading for admin settings sections to optimize performance. - Enhanced the layout of various admin settings sections for better user experience. - Updated the AiAssistantSection to include LLM provider management and improved model selection. - Refactored authentication settings to streamline configuration and improve accessibility. --- app/chat/layout.tsx | 16 + app/chat/page.tsx | 26 +- .../admin/settings/admin-list-controls.tsx | 113 ++++ .../admin/settings/admin-settings-card.tsx | 27 + .../admin/settings/admin-settings-layout.tsx | 9 +- .../settings/admin-settings-section-view.tsx | 125 ++-- components/admin/settings/field-group.tsx | 11 + .../admin/settings/org-settings-form.tsx | 46 +- .../admin-org-llm-providers-panel.tsx | 51 ++ .../sections/ai-assistant-section.tsx | 547 +++++++++--------- .../sections/ai-authorized-model-picker.tsx | 204 +++++++ .../sections/authentication-section.tsx | 210 ++++--- .../sections/drive-mount-oauth-section.tsx | 67 ++- .../settings/sections/drive-org-section.tsx | 41 +- .../sections/drive-org-webdav-section.tsx | 183 ++++++ .../sections/file-policies-section.tsx | 257 ++++---- .../sections/identity-providers-section.tsx | 76 ++- .../admin/settings/sections/llm-section.tsx | 196 ------- .../sections/mail-domains-section.tsx | 244 +++++++- .../settings/sections/mailing-section.tsx | 126 ---- .../sections/migration-projects-panel.tsx | 33 +- .../settings/sections/nextcloud-section.tsx | 127 ---- .../settings/sections/onlyoffice-section.tsx | 82 --- .../settings/sections/plugins-section.tsx | 485 +++++++++++++++- .../sections/public-shares-section.tsx | 94 +-- .../settings/sections/quotas-section.tsx | 167 ++++++ .../settings/sections/richtext-section.tsx | 89 --- .../settings/sections/search-section.tsx | 173 +++--- .../settings/sections/security-section.tsx | 1 - .../sections/storage-quotas-section.tsx | 89 --- .../settings/sections/ultiai-tools-card.tsx | 60 ++ .../settings/sections/ultimeet-section.tsx | 9 +- .../sections/usage-quotas-section.tsx | 97 ---- .../settings/sections/users-bulk-toolbar.tsx | 242 ++++++++ .../settings/sections/users-groups-dialog.tsx | 213 +++++++ .../admin/settings/sections/users-section.tsx | 249 ++++++-- .../settings/tech-brand-select-label.tsx | 35 ++ components/agenda/agenda-settings-fields.tsx | 4 +- components/ai/ai-chat-iframe.tsx | 106 +++- components/gmail/mail-settings-fields.tsx | 4 +- .../automation/automation-tab-masonry.tsx | 60 +- .../automation/llm-providers-panel.tsx | 162 ++---- .../automation/search-providers-panel.tsx | 85 +-- .../sections/automation-settings-section.tsx | 2 +- components/llm/llm-providers-editor.tsx | 359 ++++++++++++ .../web-search-providers-editor.tsx | 343 +++++++++++ lib/admin-settings/default-plugins.ts | 46 ++ lib/admin-settings/map-api-org-settings.ts | 50 +- lib/admin-settings/org-settings-store.ts | 71 +-- lib/admin-settings/org-settings-types.ts | 2 +- lib/admin-settings/settings-nav.ts | 96 ++- lib/admin-settings/tech-brand-icons.ts | 93 +++ lib/ai/chat-context.ts | 20 +- lib/ai/ultiai-tool-groups.ts | 53 ++ lib/ai/use-ai-iframe-navigation.ts | 27 +- lib/api/admin-org-types.ts | 2 +- lib/api/admin-types.ts | 59 ++ lib/api/hooks/use-admin-drive-queries.ts | 48 +- lib/api/hooks/use-admin-mutations.ts | 65 +++ lib/api/hooks/use-admin-queries.ts | 30 +- lib/api/hooks/use-ai-queries.ts | 3 + lib/api/hooks/use-contact-discovery.ts | 4 +- lib/contacts/discovery-types.ts | 33 +- lib/llm/llm-provider-catalog.ts | 233 ++++++++ lib/mail-chrome-classes.ts | 10 +- lib/mail-settings/settings-search-index.ts | 2 +- lib/web-search/search-provider-catalog.ts | 173 ++++++ tsconfig.tsbuildinfo | 2 +- 68 files changed, 5017 insertions(+), 2050 deletions(-) create mode 100644 app/chat/layout.tsx create mode 100644 components/admin/settings/admin-list-controls.tsx create mode 100644 components/admin/settings/admin-settings-card.tsx create mode 100644 components/admin/settings/field-group.tsx create mode 100644 components/admin/settings/sections/admin-org-llm-providers-panel.tsx create mode 100644 components/admin/settings/sections/ai-authorized-model-picker.tsx create mode 100644 components/admin/settings/sections/drive-org-webdav-section.tsx delete mode 100644 components/admin/settings/sections/llm-section.tsx delete mode 100644 components/admin/settings/sections/mailing-section.tsx delete mode 100644 components/admin/settings/sections/nextcloud-section.tsx delete mode 100644 components/admin/settings/sections/onlyoffice-section.tsx create mode 100644 components/admin/settings/sections/quotas-section.tsx delete mode 100644 components/admin/settings/sections/richtext-section.tsx delete mode 100644 components/admin/settings/sections/storage-quotas-section.tsx create mode 100644 components/admin/settings/sections/ultiai-tools-card.tsx delete mode 100644 components/admin/settings/sections/usage-quotas-section.tsx create mode 100644 components/admin/settings/sections/users-bulk-toolbar.tsx create mode 100644 components/admin/settings/sections/users-groups-dialog.tsx create mode 100644 components/admin/settings/tech-brand-select-label.tsx create mode 100644 components/llm/llm-providers-editor.tsx create mode 100644 components/web-search/web-search-providers-editor.tsx create mode 100644 lib/admin-settings/default-plugins.ts create mode 100644 lib/admin-settings/tech-brand-icons.ts create mode 100644 lib/ai/ultiai-tool-groups.ts create mode 100644 lib/llm/llm-provider-catalog.ts create mode 100644 lib/web-search/search-provider-catalog.ts diff --git a/app/chat/layout.tsx b/app/chat/layout.tsx new file mode 100644 index 0000000..62b3dd7 --- /dev/null +++ b/app/chat/layout.tsx @@ -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 +} diff --git a/app/chat/page.tsx b/app/chat/page.tsx index 6153e1e..1e59b50 100644 --- a/app/chat/page.tsx +++ b/app/chat/page.tsx @@ -3,12 +3,11 @@ import Link from "next/link" import { Sparkles } from "lucide-react" import { AiChatIframe } from "@/components/ai/ai-chat-iframe" -import { useAiConfig, useAiQuota } from "@/lib/api/hooks/use-ai-queries" +import { useAiConfig } from "@/lib/api/hooks/use-ai-queries" import { Button } from "@/components/ui/button" export default function ChatPage() { const { data: config, isLoading, isError } = useAiConfig() - const { data: quota } = useAiQuota(Boolean(config?.enabled)) if (isLoading) { return ( @@ -47,23 +46,10 @@ export default function ChatPage() { } return ( -
-
-
- - UltiAI -
- {quota ? ( - - {quota.requests_remaining}/{quota.requests_limit} requêtes aujourd'hui - - ) : null} -
- -
+ ) } diff --git a/components/admin/settings/admin-list-controls.tsx b/components/admin/settings/admin-list-controls.tsx new file mode 100644 index 0000000..9b80d28 --- /dev/null +++ b/components/admin/settings/admin-list-controls.tsx @@ -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 ( +
+
+
+ + +
+
+ + +
+
+ +
+

+ {total === 0 + ? `0 ${itemLabel}` + : `${rangeStart.toLocaleString("fr-FR")}–${rangeEnd.toLocaleString("fr-FR")} sur ${total.toLocaleString("fr-FR")} ${itemLabel}`} +

+
+ + +
+
+
+ ) +} diff --git a/components/admin/settings/admin-settings-card.tsx b/components/admin/settings/admin-settings-card.tsx new file mode 100644 index 0000000..b58b14e --- /dev/null +++ b/components/admin/settings/admin-settings-card.tsx @@ -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 ( + + +
+

{title}

+

{description}

+ {hint} +
+
{children}
+
+
+ ) +} diff --git a/components/admin/settings/admin-settings-layout.tsx b/components/admin/settings/admin-settings-layout.tsx index a93062d..32264a9 100644 --- a/components/admin/settings/admin-settings-layout.tsx +++ b/components/admin/settings/admin-settings-layout.tsx @@ -5,6 +5,7 @@ import { usePathname } from "next/navigation" import { cn } from "@/lib/utils" import { ADMIN_SETTINGS_NAV, + isAdminSettingsFullWidthLayoutPath, isAdminSettingsNavActive, isAdminSettingsWideLayoutPath, } from "@/lib/admin-settings/settings-nav" @@ -106,11 +107,13 @@ export function AdminSettingsLayout({ children }: { children: React.ReactNode }) -
+
{children} diff --git a/components/admin/settings/admin-settings-section-view.tsx b/components/admin/settings/admin-settings-section-view.tsx index 3074e8c..315e77b 100644 --- a/components/admin/settings/admin-settings-section-view.tsx +++ b/components/admin/settings/admin-settings-section-view.tsx @@ -1,52 +1,95 @@ "use client" +import dynamic from "next/dynamic" +import type { ComponentType } from "react" import { resolveAdminSettingsSection, type AdminSettingsSectionId, } from "@/lib/admin-settings/settings-nav" import { AdminAccessGuard } from "@/components/admin/settings/admin-access-guard" -import { OverviewSection } from "@/components/admin/settings/sections/overview-section" -import { UsersSection } from "@/components/admin/settings/sections/users-section" -import { AuthenticationSection } from "@/components/admin/settings/sections/authentication-section" -import { SecuritySection } from "@/components/admin/settings/sections/security-section" -import { StorageQuotasSection } from "@/components/admin/settings/sections/storage-quotas-section" -import { UsageQuotasSection } from "@/components/admin/settings/sections/usage-quotas-section" -import { FilePoliciesSection } from "@/components/admin/settings/sections/file-policies-section" -import { PublicSharesSection } from "@/components/admin/settings/sections/public-shares-section" -import { LlmSection } from "@/components/admin/settings/sections/llm-section" -import { SearchSection } from "@/components/admin/settings/sections/search-section" -import { PluginsSection } from "@/components/admin/settings/sections/plugins-section" -import { NextcloudSection } from "@/components/admin/settings/sections/nextcloud-section" -import { MailingSection } from "@/components/admin/settings/sections/mailing-section" -import { MailDomainsSection } from "@/components/admin/settings/sections/mail-domains-section" -import { OnlyofficeSection } from "@/components/admin/settings/sections/onlyoffice-section" -import { RichtextSection } from "@/components/admin/settings/sections/richtext-section" -import { AiAssistantSection } from "@/components/admin/settings/sections/ai-assistant-section" -import { AgendaSection } from "@/components/admin/settings/sections/agenda-section" -import { UltimeetSection } from "@/components/admin/settings/sections/ultimeet-section" -import { AuditSection } from "@/components/admin/settings/sections/audit-section" -const SECTIONS: Record = { - overview: OverviewSection, - users: UsersSection, - authentication: AuthenticationSection, - security: SecuritySection, - "storage-quotas": StorageQuotasSection, - "usage-quotas": UsageQuotasSection, - "file-policies": FilePoliciesSection, - "public-shares": PublicSharesSection, - llm: LlmSection, - search: SearchSection, - plugins: PluginsSection, - nextcloud: NextcloudSection, - agenda: AgendaSection, - ultimeet: UltimeetSection, - mailing: MailingSection, - "mail-domains": MailDomainsSection, - onlyoffice: OnlyofficeSection, - richtext: RichtextSection, - "ai-assistant": AiAssistantSection, - audit: AuditSection, +function loadSection

( + loader: () => Promise<{ default: ComponentType

}> +) { + return dynamic(loader, { ssr: false }) +} + +const SECTIONS: Record = { + overview: loadSection(() => + import("@/components/admin/settings/sections/overview-section").then((m) => ({ + default: m.OverviewSection, + })) + ), + users: loadSection(() => + import("@/components/admin/settings/sections/users-section").then((m) => ({ + default: m.UsersSection, + })) + ), + authentication: loadSection(() => + import("@/components/admin/settings/sections/authentication-section").then((m) => ({ + default: m.AuthenticationSection, + })) + ), + security: loadSection(() => + import("@/components/admin/settings/sections/security-section").then((m) => ({ + default: m.SecuritySection, + })) + ), + quotas: loadSection(() => + import("@/components/admin/settings/sections/quotas-section").then((m) => ({ + default: m.QuotasSection, + })) + ), + "file-policies": loadSection(() => + import("@/components/admin/settings/sections/file-policies-section").then((m) => ({ + default: m.FilePoliciesSection, + })) + ), + "public-shares": loadSection(() => + import("@/components/admin/settings/sections/public-shares-section").then((m) => ({ + default: m.PublicSharesSection, + })) + ), + llm: loadSection(() => + import("@/components/admin/settings/sections/ai-assistant-section").then((m) => ({ + default: m.AiAssistantSection, + })) + ), + search: loadSection(() => + import("@/components/admin/settings/sections/search-section").then((m) => ({ + default: m.SearchSection, + })) + ), + plugins: loadSection(() => + import("@/components/admin/settings/sections/plugins-section").then((m) => ({ + default: m.PluginsSection, + })) + ), + agenda: loadSection(() => + import("@/components/admin/settings/sections/agenda-section").then((m) => ({ + default: m.AgendaSection, + })) + ), + ultimeet: loadSection(() => + import("@/components/admin/settings/sections/ultimeet-section").then((m) => ({ + default: m.UltimeetSection, + })) + ), + "mail-domains": loadSection(() => + import("@/components/admin/settings/sections/mail-domains-section").then((m) => ({ + default: m.MailDomainsSection, + })) + ), + "ai-assistant": loadSection(() => + import("@/components/admin/settings/sections/ai-assistant-section").then((m) => ({ + default: m.AiAssistantSection, + })) + ), + audit: loadSection(() => + import("@/components/admin/settings/sections/audit-section").then((m) => ({ + default: m.AuditSection, + })) + ), } export function AdminSettingsSectionView({ diff --git a/components/admin/settings/field-group.tsx b/components/admin/settings/field-group.tsx new file mode 100644 index 0000000..91e65c2 --- /dev/null +++ b/components/admin/settings/field-group.tsx @@ -0,0 +1,11 @@ +import { cn } from "@/lib/utils" + +export function FieldGroup({ + children, + className, +}: { + children: React.ReactNode + className?: string +}) { + return

{children}
+} diff --git a/components/admin/settings/org-settings-form.tsx b/components/admin/settings/org-settings-form.tsx index cde745b..0b53e2d 100644 --- a/components/admin/settings/org-settings-form.tsx +++ b/components/admin/settings/org-settings-form.tsx @@ -49,29 +49,33 @@ export function OrgSettingsSection({ } return ( - <> - - refetch()} /> - {!showPendingBanner ? null : } - {showEffectiveBanner ? : null} -
{children}
+
+
+ + refetch()} /> + {!showPendingBanner ? null : } + {showEffectiveBanner ? : null} + {children} +
{hasSave ? ( -
- - {saved ? ( - - Réglages enregistrés sur le serveur - - ) : null} - {error ? {error} : null} +
+
+ + {saved ? ( + + Réglages enregistrés sur le serveur + + ) : null} + {error ? {error} : null} +
) : null} - +
) } diff --git a/components/admin/settings/sections/admin-org-llm-providers-panel.tsx b/components/admin/settings/sections/admin-org-llm-providers-panel.tsx new file mode 100644 index 0000000..a0ae227 --- /dev/null +++ b/components/admin/settings/sections/admin-org-llm-providers-panel.tsx @@ -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> +}) { + return ( + + + Politique LLM + + Contrôle l'accès aux fournisseurs IA pour les utilisateurs de l'organisation. + + + + + + + + ) +} diff --git a/components/admin/settings/sections/ai-assistant-section.tsx b/components/admin/settings/sections/ai-assistant-section.tsx index 1193d08..243e451 100644 --- a/components/admin/settings/sections/ai-assistant-section.tsx +++ b/components/admin/settings/sections/ai-assistant-section.tsx @@ -1,10 +1,16 @@ "use client" -import { useMemo, useState } from "react" -import { Plus, RefreshCw, Trash2 } from "lucide-react" +import { useEffect, useMemo, useState } from "react" +import { RefreshCw } from "lucide-react" +import { AiAuthorizedModelPicker } from "@/components/admin/settings/sections/ai-authorized-model-picker" +import { UltiAiToolsCard } from "@/components/admin/settings/sections/ultiai-tools-card" import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form" import { DeployLockedHint } from "@/components/admin/settings/deploy-locked-hint" import { useDeployFieldLocked } from "@/components/admin/settings/deploy-locked-hint" +import { AdminOrgLlmPolicyCard } from "@/components/admin/settings/sections/admin-org-llm-providers-panel" +import { LlmProvidersEditor } from "@/components/llm/llm-providers-editor" +import { normalizeLlmProvider } from "@/lib/llm/llm-provider-catalog" +import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry" import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store" import type { AiModelCatalogEntry } from "@/lib/admin-settings/org-settings-types" import { useDiscoverOrgLLMModels } from "@/lib/api/hooks/use-admin-llm" @@ -22,20 +28,14 @@ import { SelectValue, } from "@/components/ui/select" -function emptyModelEntry(): AiModelCatalogEntry { - return { - model_id: "", - label: "", - enabled: true, - } -} - export function AiAssistantSection() { const aiAssistant = useOrgSettingsStore((s) => s.aiAssistant) const setAiAssistant = useOrgSettingsStore((s) => s.setAiAssistant) const setPlugins = useOrgSettingsStore((s) => s.setPlugins) const plugins = useOrgSettingsStore((s) => s.plugins) const llm = useOrgSettingsStore((s) => s.llm) + const setLlm = useOrgSettingsStore((s) => s.setLlm) + const secrets = useOrgSettingsStore((s) => s.meta?.secrets) const effective = useOrgSettingsStore((s) => s.meta?.effective.ai_assistant) const enabledLocked = useDeployFieldLocked("ai_assistant", "enabled") const publicPathLocked = useDeployFieldLocked("ai_assistant", "public_path") @@ -44,14 +44,57 @@ export function AiAssistantSection() { const runtimeEnabled = effective?.enabled ?? false const orgEnabled = aiAssistant.enabled || pluginEnabled + const [llmDraft, setLlmDraft] = useState(llm) + + useEffect(() => { + setLlmDraft({ + ...llm, + providers: (llm.providers ?? []).map(normalizeLlmProvider), + }) + }, [llm]) + const [discoverProviderId, setDiscoverProviderId] = useState(llm.default_provider_id) const [discoveredModels, setDiscoveredModels] = useState([]) const discoverProvider = useMemo( - () => llm.providers.find((p) => p.id === discoverProviderId) ?? llm.providers[0], - [discoverProviderId, llm.providers], + () => llmDraft.providers.find((p) => p.id === discoverProviderId) ?? llmDraft.providers[0], + [discoverProviderId, llmDraft.providers], ) const discoverModels = useDiscoverOrgLLMModels() + const defaultModelOptions = useMemo(() => { + const ids = new Set() + if (aiAssistant.models.length > 0) { + for (const entry of aiAssistant.models) { + if (entry.enabled && entry.model_id.trim()) ids.add(entry.model_id.trim()) + } + } else { + for (const provider of llmDraft.providers) { + if (provider.default_model?.trim()) ids.add(provider.default_model.trim()) + } + for (const modelId of discoveredModels) { + if (modelId.trim()) ids.add(modelId.trim()) + } + } + if (aiAssistant.default_model.trim()) ids.add(aiAssistant.default_model.trim()) + return Array.from(ids).sort((a, b) => a.localeCompare(b)) + }, [aiAssistant.models, aiAssistant.default_model, llmDraft.providers, discoveredModels]) + + useEffect(() => { + if (!discoverProvider?.id) return + let cancelled = false + void discoverModels + .mutateAsync(discoverProvider.id) + .then((result) => { + if (!cancelled) setDiscoveredModels(result.models ?? []) + }) + .catch(() => { + if (!cancelled) setDiscoveredModels([]) + }) + return () => { + cancelled = true + } + }, [discoverProvider?.id]) + async function handleDiscoverModels() { if (!discoverProvider?.id) return setDiscoveredModels([]) @@ -63,31 +106,14 @@ export function AiAssistantSection() { } } - function updateModel(index: number, patch: Partial) { - const models = aiAssistant.models.map((entry, i) => - i === index ? { ...entry, ...patch } : entry, - ) + const orgLlmProviderSecrets = secrets?.llm_providers as + | Record + | undefined + + function setAuthorizedModels(models: AiModelCatalogEntry[]) { setAiAssistant({ models }) } - function removeModel(index: number) { - setAiAssistant({ models: aiAssistant.models.filter((_, i) => i !== index) }) - } - - function addManualModel() { - setAiAssistant({ models: [...aiAssistant.models, emptyModelEntry()] }) - } - - function addDiscoveredModel(modelId: string) { - if (aiAssistant.models.some((entry) => entry.model_id === modelId)) return - setAiAssistant({ - models: [ - ...aiAssistant.models, - { model_id: modelId, label: modelId, enabled: true }, - ], - }) - } - function setUltiAIEnabled(enabled: boolean) { setAiAssistant({ enabled }) setPlugins( @@ -100,249 +126,254 @@ export function AiAssistantSection() { return ( setLlm(llmDraft)} > - - -
-
- Assistant IA - - Active le plugin UltiAI pour toute l'organisation. Le service OpenWebUI doit - aussi être déployé. - + + + +
+
+ Assistant IA + + Active le plugin UltiAI pour toute l'organisation. Le service OpenWebUI doit + aussi être déployé. + +
+
- -
- {enabledLocked ? ( - - ) : null} -
- - Politique org. {orgEnabled ? "activée" : "désactivée"} - - - Runtime Compose {runtimeEnabled ? "actif" : "inactif"} - -
- {!orgEnabled && !runtimeEnabled ? ( -

- Activez le plugin UltiAI dans Administration → Plugins, ou définissez{" "} - AI_ASSISTANT_ENABLED=true dans le - déploiement, puis redémarrez le backend et OpenWebUI. -

- ) : null} - - -
- - setAiAssistant({ public_path: e.target.value })} - placeholder="/ai" - disabled={publicPathLocked} - /> - -
-
- - setAiAssistant({ openwebui_internal_url: e.target.value })} - placeholder="http://openwebui:8080" - disabled={openwebuiLocked} - /> - -
-
- - setAiAssistant({ default_model: e.target.value })} - placeholder="gpt-4o" - /> -
-
- - setAiAssistant({ chat_nc_path: e.target.value })} - placeholder="/.ultimail/ai/chats" - /> -
-
-
- + {enabledLocked ? ( + + ) : null} +
+ + Politique org. {orgEnabled ? "activée" : "désactivée"} + + + Runtime Compose {runtimeEnabled ? "actif" : "inactif"} + +
+ {!orgEnabled && !runtimeEnabled ? (

- Les panneaux mail/drive/contacts ne sauvegardent pas l'historique. + Activez le plugin UltiAI dans Administration → Plugins, ou définissez{" "} + AI_ASSISTANT_ENABLED=true dans le + déploiement, puis redémarrez le backend et OpenWebUI.

+ ) : null} + + +
+ + setAiAssistant({ public_path: e.target.value })} + placeholder="/ai" + disabled={publicPathLocked} + /> +
- setAiAssistant({ embed_default_temporary: v })} - /> -
-
-
- -

- Pipeline OpenWebUI → fichiers .ultichat.json sur le drive utilisateur. -

+
+ + setAiAssistant({ openwebui_internal_url: e.target.value })} + placeholder="http://openwebui:8080" + disabled={openwebuiLocked} + /> +
- setAiAssistant({ chat_sync_enabled: v })} - /> -
- - - - - - Modèles autorisés - - 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. - - - - {llm.providers.length === 0 ? ( -

- Configurez d'abord un fournisseur LLM dans Administration → Fournisseurs LLM. -

- ) : ( -
-
- +
+ + {defaultModelOptions.length > 0 ? ( -
- + )} +

+ Modèle pré-sélectionné dans UltiAI pour tous les utilisateurs. Configurez un + fournisseur LLM ou découvrez les modèles ci-dessous. +

- )} - - {discoverModels.isError ? ( -

- {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."} -

- ) : null} - - {discoveredModels.length ? ( -
- -
- {discoveredModels.map((modelId) => { - const alreadyAdded = aiAssistant.models.some( - (entry) => entry.model_id === modelId, - ) - return ( - - ) - })} +
+ + setAiAssistant({ chat_nc_path: e.target.value })} + placeholder="/.ultimail/ai/chats" + /> +
+
+
+ +

+ Les panneaux mail/drive/contacts ne sauvegardent pas l'historique. +

+ setAiAssistant({ embed_default_temporary: v })} + />
- ) : null} +
+
+ +

+ Pipeline OpenWebUI → fichiers .ultichat.json sur le drive utilisateur. +

+
+ setAiAssistant({ chat_sync_enabled: v })} + /> +
+ + -
- - -
+
+ - {aiAssistant.models.length === 0 ? ( -

- Aucune restriction — tous les modèles LLM configurés restent disponibles. -

- ) : ( -
- {aiAssistant.models.map((entry, index) => ( -
-
- - updateModel(index, { model_id: e.target.value })} - placeholder="gpt-4o-mini" - /> -
-
- - updateModel(index, { label: e.target.value })} - placeholder="GPT-4o Mini" - /> -
- -
+ + setAiAssistant({ enabled_tools })} + webSearchSettingsHref="/admin/settings/search" + /> + + + + Modèles autorisés + + 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. + + + + {llmDraft.providers.length === 0 ? ( +

+ Configurez d'abord un fournisseur LLM dans la section ci-dessus. +

+ ) : ( +
+
+ +
- ))} -
- )} -
-
+ +
+ )} + + {discoverModels.isError ? ( +

+ {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."} +

+ ) : null} + + {llmDraft.providers.length > 0 ? ( +
+ + + {discoveredModels.length === 0 ? ( +

+ Découvrez les modèles depuis un fournisseur pour remplir l'autocomplétion, + ou saisissez un ID manuellement puis Entrée. +

+ ) : null} +
+ ) : null} + + + ) } diff --git a/components/admin/settings/sections/ai-authorized-model-picker.tsx b/components/admin/settings/sections/ai-authorized-model-picker.tsx new file mode 100644 index 0000000..bb44a0b --- /dev/null +++ b/components/admin/settings/sections/ai-authorized-model-picker.tsx @@ -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(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 ( +
+
+ { + 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 ? ( +
    + {suggestions.map((modelId, index) => ( +
  • + +
  • + ))} +
+ ) : null} +
+ + {models.length > 0 ? ( +
+ {models.map((entry) => ( + + + + + + +
+ +

+ {entry.model_id} +

+
+
+ + updateLabel(entry.model_id, e.target.value)} + placeholder={entry.model_id} + /> +
+
+
+ +
+ ))} +
+ ) : emptyHint ? ( +

{emptyHint}

+ ) : null} +
+ ) +} diff --git a/components/admin/settings/sections/authentication-section.tsx b/components/admin/settings/sections/authentication-section.tsx index 751cebe..69ada1d 100644 --- a/components/admin/settings/sections/authentication-section.tsx +++ b/components/admin/settings/sections/authentication-section.tsx @@ -1,13 +1,17 @@ "use client" +import { useCallback, useRef } from "react" import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form" -import { IdentityProvidersSection } from "@/components/admin/settings/sections/identity-providers-section" +import { AdminSettingsCard } from "@/components/admin/settings/admin-settings-card" +import { FieldGroup } from "@/components/admin/settings/field-group" +import { IdentityProvidersPanel } from "@/components/admin/settings/sections/identity-providers-section" +import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry" import { DeployLockedHint, useDeployFieldLocked } from "@/components/admin/settings/deploy-locked-hint" import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store" +import { Card, CardContent } from "@/components/ui/card" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Switch } from "@/components/ui/switch" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" export function AuthenticationSection() { const authentik = useOrgSettingsStore((s) => s.authentik) @@ -22,95 +26,119 @@ export function AuthenticationSection() { const apiURL = apiLocked ? (effective?.api_url ?? authentik.api_url) : authentik.api_url const clientID = clientLocked ? (effective?.client_id ?? authentik.client_id) : authentik.client_id - return ( -
- - - -
-
- Authentik activé - Connexion via le fournisseur d'identité organisationnel. - {enabledLocked ? : null} -
- setAuthentik({ enabled: v })} - /> -
-
- -
- - setAuthentik({ api_url: e.target.value })} - placeholder="https://auth.example.com/api/v3" - /> -
-
- - setAuthentik({ slug: e.target.value })} - /> -
-
- - setAuthentik({ client_id: e.target.value })} - /> -
-
- - setAuthentik({ default_groups: e.target.value })} - /> -
- - -
-
-
+ const identityBeforeSaveRef = useRef<(() => void) | null>(null) + const registerIdentityBeforeSave = useCallback((fn: (() => void) | null) => { + identityBeforeSaveRef.current = fn + }, []) - -
+ return ( + { + identityBeforeSaveRef.current?.() + }} + > + + + +
+
+

Authentik

+

+ Connexion via le fournisseur d'identité organisationnel. +

+ {enabledLocked ? : null} +
+ setAuthentik({ enabled: v })} + /> +
+ +
+ + + setAuthentik({ api_url: e.target.value })} + placeholder="https://auth.example.com/api/v3" + /> + {apiLocked ? : null} + + +
+ + + setAuthentik({ slug: e.target.value })} + /> + + + + setAuthentik({ client_id: e.target.value })} + /> + {clientLocked ? : null} + +
+ + + + setAuthentik({ default_groups: e.target.value })} + /> + + + + + +
+
+
+ + + + +
+
) } diff --git a/components/admin/settings/sections/drive-mount-oauth-section.tsx b/components/admin/settings/sections/drive-mount-oauth-section.tsx index 8f2a69b..6044732 100644 --- a/components/admin/settings/sections/drive-mount-oauth-section.tsx +++ b/components/admin/settings/sections/drive-mount-oauth-section.tsx @@ -3,6 +3,8 @@ import { useEffect, useState } from "react" import { Check, Copy } from "lucide-react" import { toast } from "sonner" +import { TechBrandSelectLabel } from "@/components/admin/settings/tech-brand-select-label" +import { FieldGroup } from "@/components/admin/settings/field-group" import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store" import type { DriveMountOAuthProvider, DriveMountOAuthSettings } from "@/lib/admin-settings/org-settings-types" import { Button } from "@/components/ui/button" @@ -11,21 +13,29 @@ import { Label } from "@/components/ui/label" import { Switch } from "@/components/ui/switch" import { buildDriveMountOAuthRedirectURI } from "@/lib/drive/drive-mount-oauth" -const PROVIDERS: { id: DriveMountOAuthProvider; label: string; hint: string }[] = [ +const PROVIDERS: { + id: DriveMountOAuthProvider + label: string + hint: string + icon: string +}[] = [ { id: "google", label: "Google Drive", hint: "Console Google Cloud — API Drive, redirect URI ci-dessous", + icon: "logos:google-drive", }, { id: "dropbox", label: "Dropbox", hint: "App Dropbox — permissions files.metadata.read, files.content.read/write", + icon: "logos:dropbox", }, { id: "microsoft", label: "Microsoft OneDrive", hint: "Azure AD — Microsoft Graph Files.ReadWrite", + icon: "logos:microsoft-onedrive", }, ] @@ -38,9 +48,11 @@ const SECRET_KEYS: Record void + embedded?: boolean }) { const secrets = useOrgSettingsStore((s) => s.meta?.secrets) const [redirectUri, setRedirectUri] = useState("") @@ -70,16 +82,18 @@ export function DriveMountOAuthSection({ } return ( -
-
-

Connexion cloud (OAuth)

-

- Permet aux utilisateurs de monter Google Drive, Dropbox ou OneDrive depuis UltiDrive. -

-
-
+
+ {!embedded ? ( +
+

Connexion cloud (OAuth)

+

+ Permet aux utilisateurs de monter Google Drive, Dropbox ou OneDrive depuis UltiDrive. +

+
+ ) : null} + -
+
-

- Basée sur l'URL actuelle du navigateur. Enregistrez-la chez chaque fournisseur OAuth (Google, Dropbox, Microsoft). +

+ Basée sur l'URL actuelle du navigateur. Enregistrez-la chez chaque fournisseur OAuth + (Google, Dropbox, Microsoft).

-
+
- {PROVIDERS.map(({ id, label, hint }) => { + {PROVIDERS.map(({ id, label, hint, icon }) => { const provider = draft[id] const configured = Boolean(secrets?.[SECRET_KEYS[id]]?.configured) return (
{provider.enabled ? ( -
-
+
+ updateProvider(id, { client_id: e.target.value })} autoComplete="off" /> -
-
+ + updateProvider(id, { client_secret: e.target.value })} @@ -140,9 +157,9 @@ export function DriveMountOAuthSection({ autoComplete="off" /> {configured && !provider.client_secret.trim() ? ( -

Secret configuré

+

Secret configuré

) : null} -
+
) : null}
diff --git a/components/admin/settings/sections/drive-org-section.tsx b/components/admin/settings/sections/drive-org-section.tsx index 3125868..78d550f 100644 --- a/components/admin/settings/sections/drive-org-section.tsx +++ b/components/admin/settings/sections/drive-org-section.tsx @@ -1,6 +1,7 @@ "use client" import { useState } from "react" +import { FieldGroup } from "@/components/admin/settings/field-group" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" @@ -9,7 +10,7 @@ import { useAdminDriveOrgFolders, } from "@/lib/api/hooks/use-admin-drive-queries" -export function DriveOrgFoldersSection() { +export function DriveOrgFoldersSection({ embedded = false }: { embedded?: boolean }) { const folders = useAdminDriveOrgFolders() const { create, remove, sync } = useAdminDriveOrgFolderMutations() const [orgSlug, setOrgSlug] = useState("") @@ -17,23 +18,25 @@ export function DriveOrgFoldersSection() { const [syncSlugs, setSyncSlugs] = useState("") return ( -
-
-

Dossiers d'organisation

-

- Group folders Nextcloud liés aux organisations Authentik. -

-
+
+ {!embedded ? ( +
+

Dossiers d'organisation

+

+ Espaces de stockage internes (group folders Nextcloud) liés aux organisations Authentik. +

+
+ ) : null} -
-
+
+ setOrgSlug(e.target.value)} placeholder="acme" /> -
-
+ + setMountPoint(e.target.value)} placeholder="Acme Corp" /> -
+
-
+
    {(folders.data ?? []).map((folder) => ( diff --git a/components/admin/settings/sections/drive-org-webdav-section.tsx b/components/admin/settings/sections/drive-org-webdav-section.tsx new file mode 100644 index 0000000..abe10ef --- /dev/null +++ b/components/admin/settings/sections/drive-org-webdav-section.tsx @@ -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 ( +
    + {!embedded ? ( +
    +

    Montages WebDAV d'organisation

    +

    + Connecte un serveur WebDAV partagé (NAS, Nextcloud externe, etc.) visible par tous les + utilisateurs UltiDrive. +

    +
    + ) : null} + +

    + Le slug d'organisation sert au rattachement administratif. Le volume est monté globalement + dans Nextcloud et apparaît dans UltiDrive pour tous les utilisateurs. +

    + +
    + + + setOrgSlug(e.target.value)} + placeholder="acme" + /> + + + + setDisplayName(e.target.value)} + placeholder="NAS partagé" + /> + + + + setHost(e.target.value)} + placeholder="nas.example.com" + /> + + + + setRoot(e.target.value)} + placeholder="/remote.php/dav/files/user" + /> + + + + setUserName(e.target.value)} /> + + + + setPassword(e.target.value)} + autoComplete="new-password" + /> + +
    + + + + + +
      + {(mounts.data ?? []).map((mount) => ( +
    • +
      +
      +

      {mount.display_name}

      + + {mountStatusLabel(mount.status)} + +
      +

      + {mount.org_slug ?? "—"} · WebDAV · {mount.mount_point} +

      + {mount.last_error ? ( +

      {mount.last_error}

      + ) : null} +
      + +
    • + ))} + {mounts.data?.length === 0 ? ( +
    • Aucun montage WebDAV d'organisation
    • + ) : null} +
    +
    + ) +} diff --git a/components/admin/settings/sections/file-policies-section.tsx b/components/admin/settings/sections/file-policies-section.tsx index 6f79773..0e5c078 100644 --- a/components/admin/settings/sections/file-policies-section.tsx +++ b/components/admin/settings/sections/file-policies-section.tsx @@ -2,6 +2,9 @@ import { useEffect, useState } from "react" import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form" +import { AdminSettingsCard } from "@/components/admin/settings/admin-settings-card" +import { FieldGroup } from "@/components/admin/settings/field-group" +import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry" import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" @@ -15,6 +18,7 @@ import { SelectValue, } from "@/components/ui/select" import { DriveOrgFoldersSection } from "@/components/admin/settings/sections/drive-org-section" +import { DriveOrgWebDAVSection } from "@/components/admin/settings/sections/drive-org-webdav-section" import { DriveMountOAuthSection } from "@/components/admin/settings/sections/drive-mount-oauth-section" export function FilePoliciesSection() { @@ -40,120 +44,151 @@ export function FilePoliciesSection() { policySection="file_policies" beforeSave={() => setFilePolicies({ mount_oauth: mountOAuthDraft })} > -
    -
    - - - setFilePolicies({ max_upload_mib: Number(e.target.value) || 1 }) - } - /> -
    -
    - - - setFilePolicies({ - default_link_expiry_days: Number(e.target.value) || 1, - }) - } - /> -
    -
    - - - setFilePolicies({ retention_trash_days: Number(e.target.value) || 1 }) - } - /> -
    -
    - - -
    -
    - -