Some checks are pending
E2E / Playwright e2e (push) Waiting to run
- 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.
489 lines
16 KiB
TypeScript
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'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} />
|
|
}
|