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.
479 lines
16 KiB
TypeScript
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'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>
|
|
)
|
|
}
|