ultisuite-client/components/admin/settings/sections/plugins-section.tsx
R3D347HR4Y 9e9fd208ad
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
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.
2026-06-15 00:22:20 +02:00

479 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 { FieldGroup } from "@/components/admin/settings/field-group"
import { DeployLockedHint, useDeployFieldLocked } from "@/components/admin/settings/deploy-locked-hint"
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import { isPluginDeployLocked } from "@/lib/admin/deploy-runtime"
import { Switch } from "@/components/ui/switch"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { TechBrandSelectLabel } from "@/components/admin/settings/tech-brand-select-label"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { cn } from "@/lib/utils"
const SIMPLE_PLUGIN_IDS = new Set(["mail-automation", "contact-discovery", "public-share"])
const CONFIG_PLUGIN_IDS = new Set(["office-editor", "richtext-editor", "ai-assistant"])
export function PluginsSection() {
const plugins = useOrgSettingsStore((s) => s.plugins)
const togglePlugin = useOrgSettingsStore((s) => s.togglePlugin)
const deployLocked = useOrgSettingsStore((s) => s.meta?.deployLocked)
const simplePlugins = plugins.filter((p) => SIMPLE_PLUGIN_IDS.has(p.id))
const configPlugins = plugins.filter((p) => CONFIG_PLUGIN_IDS.has(p.id))
return (
<OrgSettingsSection
title="Plugins"
description="Modules fonctionnels 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)
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>
)
}