ultisuite-client/components/admin/settings/sections/plugins-section.tsx
R3D347HR4Y 2a0958b70d
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
feat: update agenda references to use ULTICAL_APP_NAME and enhance AI usage sections
- Replaced hardcoded "Agenda" labels with dynamic ULTICAL_APP_NAME in various components for consistency.
- Introduced new AiUsageSection and CompteAiUsageSection components to track AI usage and costs.
- Updated settings and metadata to reflect changes in AI cost policies and usage limits.
- Enhanced user interface elements for better accessibility and user experience across admin settings.
2026-06-16 10:46:31 +02:00

489 lines
16 KiB
TypeScript

"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 {
SettingsCard,
SettingsField,
SettingsGrid,
SettingsToggleRow,
} from "@/components/settings/settings-kit"
import { DeployLockedHint, useDeployFieldLocked } from "@/components/admin/settings/deploy-locked-hint"
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import { isPluginDeployLocked } from "@/lib/admin/deploy-runtime"
import { Switch } from "@/components/ui/switch"
import { Badge } from "@/components/ui/badge"
import { TechBrandSelectLabel } from "@/components/admin/settings/tech-brand-select-label"
import { Input } from "@/components/ui/input"
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 { ULTICAL_APP_NAME } from "@/lib/suite/page-metadata"
import { cn } from "@/lib/utils"
const SIMPLE_PLUGIN_IDS = new Set(["mail-automation", "contact-discovery", "public-share"])
const CONFIG_PLUGIN_IDS = new Set(["office-editor", "richtext-editor", "ai-assistant"])
export function PluginsSection() {
const plugins = useOrgSettingsStore((s) => s.plugins)
const togglePlugin = useOrgSettingsStore((s) => s.togglePlugin)
const deployLocked = useOrgSettingsStore((s) => s.meta?.deployLocked)
const simplePlugins = plugins.filter((p) => SIMPLE_PLUGIN_IDS.has(p.id))
const configPlugins = plugins.filter((p) => CONFIG_PLUGIN_IDS.has(p.id))
return (
<OrgSettingsSection
title="Plugins"
description="Modules fonctionnels et intégrations activables pour toute l'organisation."
policySection={["plugins", "nextcloud", "onlyoffice", "richtext"]}
>
<AutomationTabMasonry columns={2}>
<NextcloudPluginCard />
{simplePlugins.map((plugin) => {
const locked = isPluginDeployLocked(deployLocked, plugin.id)
return (
<PluginToggleCard
key={plugin.id}
name={plugin.name}
description={plugin.description}
version={plugin.version}
enabled={plugin.enabled}
locked={locked}
lockSection="plugins"
lockField={plugin.id}
onToggle={(enabled) => togglePlugin(plugin.id, enabled)}
/>
)
})}
{configPlugins.map((plugin) => {
const locked = isPluginDeployLocked(deployLocked, plugin.id)
if (plugin.id === "office-editor") {
return (
<OnlyOfficePluginCard
key={plugin.id}
plugin={plugin}
locked={locked}
onToggle={(enabled) => togglePlugin(plugin.id, enabled)}
/>
)
}
if (plugin.id === "richtext-editor") {
return (
<RichtextPluginCard
key={plugin.id}
plugin={plugin}
locked={locked}
onToggle={(enabled) => togglePlugin(plugin.id, enabled)}
/>
)
}
return (
<AiAssistantPluginCard
key={plugin.id}
plugin={plugin}
locked={locked}
onToggle={(enabled) => togglePlugin(plugin.id, enabled)}
/>
)
})}
</AutomationTabMasonry>
</OrgSettingsSection>
)
}
function PluginToggleCard({
name,
description,
version,
enabled,
locked,
lockSection,
lockField,
onToggle,
hint,
action,
children,
defaultOpen = false,
}: {
name: string
description: string
version?: string
enabled: boolean
locked?: boolean
lockSection?: string
lockField?: string
onToggle: (enabled: boolean) => void
hint?: React.ReactNode
action?: React.ReactNode
children?: React.ReactNode
defaultOpen?: boolean
}) {
const [open, setOpen] = useState(defaultOpen)
const hasConfig = Boolean(children)
const body = action ? (
action
) : hasConfig ? (
<Collapsible open={open} onOpenChange={setOpen}>
<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 border-mail-border pt-4">
{children}
</CollapsibleContent>
</Collapsible>
) : null
return (
<SettingsCard
title={
<span className="flex items-center gap-2">
{name}
{version ? <Badge variant="outline">v{version}</Badge> : null}
</span>
}
description={description}
action={<Switch checked={enabled} disabled={locked} onCheckedChange={onToggle} />}
hint={
hint || (locked && lockSection && lockField) ? (
<>
{hint}
{locked && lockSection && lockField ? (
<DeployLockedHint section={lockSection} field={lockField} />
) : null}
</>
) : null
}
divider={false}
contentClassName="space-y-0 !mt-3 !pt-0"
>
{body}
</SettingsCard>
)
}
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, UltiCal, 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}
>
<SettingsGrid columns={2}>
<SettingsField
label="URL de base"
className="sm:col-span-2"
hint={urlLocked ? <DeployLockedHint section="nextcloud" field="base_url" /> : undefined}
>
<Input
className="h-9"
value={baseURL}
disabled={urlLocked}
onChange={(e) => setNextcloud({ base_url: e.target.value })}
placeholder="https://cloud.example.com"
/>
</SettingsField>
<SettingsField
label="Utilisateur admin"
hint={userLocked ? <DeployLockedHint section="nextcloud" field="admin_user" /> : undefined}
>
<Input
className="h-9"
value={adminUser}
disabled={userLocked}
onChange={(e) => setNextcloud({ admin_user: e.target.value })}
/>
</SettingsField>
<SettingsField
label="Mot de passe admin"
hint={passLocked ? <DeployLockedHint section="nextcloud" field="admin_password" /> : undefined}
>
<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}
/>
</SettingsField>
</SettingsGrid>
<div className="space-y-2">
<div>
<p className="text-sm font-medium">Modules exposés</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Active ou masque chaque application Nextcloud dans la suite.
</p>
</div>
<ServiceToggle
label="UltiDrive (fichiers)"
checked={nextcloud.drive_enabled}
onChange={(drive_enabled) => setNextcloud({ drive_enabled })}
/>
<ServiceToggle
label={ULTICAL_APP_NAME}
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>
</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}
<SettingsField
label="URL du serveur de documents"
hint={
urlLocked ? <DeployLockedHint section="onlyoffice" field="document_server_url" /> : undefined
}
>
<Input
className="h-9"
value={docURL}
disabled={urlLocked}
onChange={(e) => setOnlyoffice({ document_server_url: e.target.value })}
placeholder="https://office.example.com"
/>
</SettingsField>
<SettingsField
label="Secret JWT"
hint={jwtLocked ? <DeployLockedHint section="onlyoffice" field="jwt_secret" /> : undefined}
>
<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}
/>
</SettingsField>
<SettingsField
label="En-tête JWT"
hint={headerLocked ? <DeployLockedHint section="onlyoffice" field="jwt_header" /> : undefined}
>
<Input
className="h-9"
value={onlyoffice.jwt_header}
disabled={headerLocked}
onChange={(e) => setOnlyoffice({ jwt_header: e.target.value })}
/>
</SettingsField>
</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}
>
<SettingsGrid columns={1}>
<SettingsField label="Mode de stockage">
<Select
value={richtext.storage_mode}
onValueChange={(storage_mode: "sidecar" | "overwrite") =>
setRichtext({ storage_mode })
}
>
<SelectTrigger className="h-9 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>
</SettingsField>
<SettingsField label="Export miroir (optionnel)">
<Select
value={richtext.export_mirror_format || "none"}
onValueChange={(v) =>
setRichtext({ export_mirror_format: v === "none" ? "" : "docx" })
}
>
<SelectTrigger className="h-9 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>
</SettingsField>
<SettingsField label="URL WebSocket Hocuspocus (public)">
<Input
className="h-9"
value={richtext.hocuspocus_url}
onChange={(e) => setRichtext({ hocuspocus_url: e.target.value })}
placeholder="ws://localhost:1234"
/>
</SettingsField>
</SettingsGrid>
</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 <SettingsToggleRow title={label} checked={checked} onCheckedChange={onChange} />
}