From 8f81d7aba1929aa43faa783941334f2181f4bb3b Mon Sep 17 00:00:00 2001 From: R3D347HR4Y Date: Mon, 15 Jun 2026 11:10:17 +0200 Subject: [PATCH] feat(admin-settings): refactor admin settings components for improved usability and consistency - Replaced legacy components with new `SettingsCard`, `SettingsField`, and `SettingsToggleRow` for a unified design. - Enhanced `AdminListControls` to support compact mode and improved pagination controls. - Updated various sections including `AiAssistantSection`, `AuthenticationSection`, and `DriveMountOAuthSection` to utilize new components, streamlining the settings interface. - Improved accessibility and user experience across admin settings with clearer labels and hints. - Deprecated old components while maintaining backward compatibility for existing admin sections. --- .../admin/settings/admin-list-controls.tsx | 166 ++++-- .../admin/settings/admin-settings-card.tsx | 19 +- .../admin-org-llm-providers-panel.tsx | 58 +- .../settings/sections/agenda-section.tsx | 93 ++-- .../sections/ai-assistant-section.tsx | 423 +++++++-------- .../sections/authentication-section.tsx | 168 +++--- .../sections/drive-mount-oauth-section.tsx | 90 ++-- .../settings/sections/drive-org-section.tsx | 80 +-- .../sections/drive-org-webdav-section.tsx | 92 ++-- .../sections/file-policies-section.tsx | 179 ++++--- .../sections/identity-providers-section.tsx | 73 ++- .../sections/mail-domains-section.tsx | 165 +++--- .../sections/migration-projects-panel.tsx | 44 +- .../settings/sections/plugins-section.tsx | 223 ++++---- .../sections/public-shares-section.tsx | 19 +- .../settings/sections/quotas-section.tsx | 221 ++++---- .../settings/sections/search-section.tsx | 109 ++-- .../settings/sections/security-section.tsx | 132 +++-- .../settings/sections/ultiai-tools-card.tsx | 52 +- .../settings/sections/ultimeet-section.tsx | 500 ++++++++---------- components/drive/public-office-editor.tsx | 3 +- components/drive/share-dialog.tsx | 10 +- components/settings/settings-kit.tsx | 286 ++++++++++ .../web-search-providers-editor.tsx | 85 ++- lib/admin-settings/settings-nav.ts | 1 + tsconfig.tsbuildinfo | 2 +- 26 files changed, 1806 insertions(+), 1487 deletions(-) create mode 100644 components/settings/settings-kit.tsx diff --git a/components/admin/settings/admin-list-controls.tsx b/components/admin/settings/admin-list-controls.tsx index 9b80d28..edf03d2 100644 --- a/components/admin/settings/admin-list-controls.tsx +++ b/components/admin/settings/admin-list-controls.tsx @@ -1,5 +1,7 @@ "use client" +import type { ReactNode } from "react" +import { ChevronLeft, ChevronRight } from "lucide-react" import { Button } from "@/components/ui/button" import { Label } from "@/components/ui/label" import { @@ -9,6 +11,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select" +import { cn } from "@/lib/utils" export type AdminListSortOption = { value: string @@ -29,6 +32,8 @@ export function AdminListControls({ onPageSizeChange, onSortChange, itemLabel, + compact = false, + leading, }: { page: number pageSize: number @@ -41,72 +46,133 @@ export function AdminListControls({ onPageSizeChange: (pageSize: number) => void onSortChange: (sort: string) => void itemLabel: string + /** Barre outils dense : labels dans les selects, pagination icônes. */ + compact?: boolean + /** Champ ou filtre aligné à gauche (ex. recherche). */ + leading?: ReactNode }) { const rangeStart = total === 0 ? 0 : (page - 1) * pageSize + 1 const rangeEnd = Math.min(page * pageSize, total) + const rangeLabel = + total === 0 + ? `0 ${itemLabel}` + : `${rangeStart.toLocaleString("fr-FR")}–${rangeEnd.toLocaleString("fr-FR")} sur ${total.toLocaleString("fr-FR")} ${itemLabel}` + + const pageSizeSelect = ( + + ) + + const sortSelect = ( + + ) + + const pagination = compact ? ( +
+ + +
+ ) : ( +
+ + +
+ ) + + if (compact) { + return ( +
+
+ {leading} + {pageSizeSelect} + {sortSelect} +
+
+

{rangeLabel}

+ {pagination} +
+
+ ) + } return (
- + {pageSizeSelect}
- + {sortSelect}
-

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

-
- - -
+

{rangeLabel}

+ {pagination}
) diff --git a/components/admin/settings/admin-settings-card.tsx b/components/admin/settings/admin-settings-card.tsx index b58b14e..2ed4551 100644 --- a/components/admin/settings/admin-settings-card.tsx +++ b/components/admin/settings/admin-settings-card.tsx @@ -1,6 +1,10 @@ import type { ReactNode } from "react" -import { Card, CardContent } from "@/components/ui/card" +import { SettingsCard } from "@/components/settings/settings-kit" +/** + * @deprecated Utiliser directement `SettingsCard` (`@/components/settings/settings-kit`). + * Conservé comme alias rétrocompatible pour les sections admin existantes. + */ export function AdminSettingsCard({ title, description, @@ -13,15 +17,8 @@ export function AdminSettingsCard({ children: ReactNode }) { return ( - - -
-

{title}

-

{description}

- {hint} -
-
{children}
-
-
+ + {children} + ) } diff --git a/components/admin/settings/sections/admin-org-llm-providers-panel.tsx b/components/admin/settings/sections/admin-org-llm-providers-panel.tsx index a0ae227..a22856e 100644 --- a/components/admin/settings/sections/admin-org-llm-providers-panel.tsx +++ b/components/admin/settings/sections/admin-org-llm-providers-panel.tsx @@ -1,8 +1,7 @@ "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" +import { SettingsCard, SettingsToggleRow } from "@/components/settings/settings-kit" export function AdminOrgLlmPolicyCard({ draft, @@ -12,40 +11,25 @@ export function AdminOrgLlmPolicyCard({ setDraft: React.Dispatch> }) { return ( - - - Politique LLM - - Contrôle l'accès aux fournisseurs IA pour les utilisateurs de l'organisation. - - - - - - - + + + setDraft((p) => ({ ...p, enforce_org_providers })) + } + /> + + setDraft((p) => ({ ...p, allow_user_override })) + } + /> + ) } diff --git a/components/admin/settings/sections/agenda-section.tsx b/components/admin/settings/sections/agenda-section.tsx index 0200ee6..217e2d2 100644 --- a/components/admin/settings/sections/agenda-section.tsx +++ b/components/admin/settings/sections/agenda-section.tsx @@ -2,6 +2,12 @@ import { useEffect, useState } from "react" import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form" +import { + SettingsCard, + SettingsField, + SettingsToggleRow, +} from "@/components/settings/settings-kit" +import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry" import { AgendaVideoProviderSelectLabel } from "@/components/agenda/agenda-video-provider-select-label" import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store" import { @@ -10,8 +16,6 @@ import { type AgendaVideoProvider, } from "@/lib/agenda/agenda-settings-types" import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" -import { Switch } from "@/components/ui/switch" import { Select, SelectContent, @@ -53,29 +57,22 @@ export function AgendaSection() { policySection="agenda" beforeSave={() => setAgenda(draft)} > -
-
- -
- + + + setDraft((p) => ({ ...p, enforce_org_theme: v }))} + /> + -
-
+ + -
- -
- + + setDraft((p) => ({ ...p, enforce_org_video_provider: v }))} + /> + -
-
+ + -
-

Clés API visioconférence (organisation)

-

- Stockées côté serveur. UltiMeet n'exige pas de clé API. -

+ {(["zoom", "google_meet", "teams", "jitsi"] as AgendaVideoProvider[]).map( (provider) => ( -
- + updateApiKey(provider, e.target.value)} /> -
+ ), )} -
-
+ + ) } diff --git a/components/admin/settings/sections/ai-assistant-section.tsx b/components/admin/settings/sections/ai-assistant-section.tsx index 243e451..ae388fc 100644 --- a/components/admin/settings/sections/ai-assistant-section.tsx +++ b/components/admin/settings/sections/ai-assistant-section.tsx @@ -11,13 +11,18 @@ import { AdminOrgLlmPolicyCard } from "@/components/admin/settings/sections/admi 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 { + SettingsCard, + SettingsField, + SettingsGrid, + SettingsHint, + SettingsToggleRow, +} from "@/components/settings/settings-kit" 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" -import { Label } from "@/components/ui/label" import { Switch } from "@/components/ui/switch" import { Button } from "@/components/ui/button" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Input } from "@/components/ui/input" import { Badge } from "@/components/ui/badge" import { @@ -131,165 +136,146 @@ export function AiAssistantSection() { beforeSave={() => setLlm(llmDraft)} > - - -
-
- Assistant IA - - Active le plugin UltiAI pour toute l'organisation. Le service OpenWebUI doit - aussi être déployé. - -
- -
- {enabledLocked ? ( - - ) : null} -
+ + } + badges={ + <> 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} -
- -
- + + } + hint={ + <> + {enabledLocked ? : null} + {!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} + /> + + + {defaultModelOptions.length > 0 ? ( + + ) : ( setAiAssistant({ public_path: e.target.value })} - placeholder="/ai" - disabled={publicPathLocked} + className="h-9" + value={aiAssistant.default_model} + onChange={(e) => setAiAssistant({ default_model: e.target.value })} + placeholder="gpt-4o-mini" /> - -
-
- - setAiAssistant({ openwebui_internal_url: e.target.value })} - placeholder="http://openwebui:8080" - disabled={openwebuiLocked} - /> - -
-
- - {defaultModelOptions.length > 0 ? ( - - ) : ( - setAiAssistant({ default_model: e.target.value })} - placeholder="gpt-4o-mini" - /> - )} -

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

-
-
- - 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 })} - /> -
-
-
- -

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

-
- setAiAssistant({ chat_sync_enabled: v })} - /> -
-
-
+ )} + + + setAiAssistant({ chat_nc_path: e.target.value })} + placeholder="/.ultimail/ai/chats" + /> + + setAiAssistant({ embed_default_temporary: v })} + /> + setAiAssistant({ chat_sync_enabled: v })} + /> +
- - - Fournisseurs LLM - - Modèles IA organisationnels pour UltiAI, le tri, l'enrichissement contacts et - les automatisations. - - - - - setLlmDraft((prev) => ({ ...prev, providers })) - } - onDefaultProviderIdChange={(default_provider_id) => - setLlmDraft((prev) => ({ ...prev, default_provider_id })) - } - /> - - + + + setLlmDraft((prev) => ({ ...prev, providers })) + } + onDefaultProviderIdChange={(default_provider_id) => + setLlmDraft((prev) => ({ ...prev, default_provider_id })) + } + /> +
- - - 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. -

- ) : ( -
-
- - -
-
- ) : null} -
-
+ Découvrir les modèles + + + )} + + {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 ? ( + + + + ) : null} +
) diff --git a/components/admin/settings/sections/authentication-section.tsx b/components/admin/settings/sections/authentication-section.tsx index 69ada1d..d730a73 100644 --- a/components/admin/settings/sections/authentication-section.tsx +++ b/components/admin/settings/sections/authentication-section.tsx @@ -2,15 +2,17 @@ import { useCallback, useRef } 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 { + SettingsCard, + SettingsField, + SettingsGrid, + SettingsToggleRow, +} from "@/components/settings/settings-kit" 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" export function AuthenticationSection() { @@ -41,103 +43,85 @@ export function AuthenticationSection() { }} > - - -
-
-

Authentik

-

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

- {enabledLocked ? : null} -
- setAuthentik({ enabled: v })} + : null} + action={ + setAuthentik({ enabled: v })} + /> + } + > + : undefined} + > + setAuthentik({ api_url: e.target.value })} + placeholder="https://auth.example.com/api/v3" + /> + + + + + setAuthentik({ slug: e.target.value })} /> -
+ + : undefined + } + > + setAuthentik({ client_id: e.target.value })} + /> + + -
- - - setAuthentik({ api_url: e.target.value })} - placeholder="https://auth.example.com/api/v3" - /> - {apiLocked ? : null} - + + setAuthentik({ default_groups: e.target.value })} + /> + -
- - - setAuthentik({ slug: e.target.value })} - /> - - - - setAuthentik({ client_id: e.target.value })} - /> - {clientLocked ? : null} - -
+ setAuthentik({ enforce_sso })} + /> - - - setAuthentik({ default_groups: e.target.value })} - /> - + + setAuthentik({ allow_password_fallback }) + } + /> + - - - -
-
-
- - - +
) diff --git a/components/admin/settings/sections/drive-mount-oauth-section.tsx b/components/admin/settings/sections/drive-mount-oauth-section.tsx index 6044732..f64b739 100644 --- a/components/admin/settings/sections/drive-mount-oauth-section.tsx +++ b/components/admin/settings/sections/drive-mount-oauth-section.tsx @@ -4,13 +4,17 @@ 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 { + SettingsCard, + SettingsField, + SettingsGrid, + SettingsHint, + SettingsToggleRow, +} from "@/components/settings/settings-kit" 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" import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" -import { Switch } from "@/components/ui/switch" import { buildDriveMountOAuthRedirectURI } from "@/lib/drive/drive-mount-oauth" const PROVIDERS: { @@ -81,18 +85,12 @@ export function DriveMountOAuthSection({ } } - return ( -
- {!embedded ? ( -
-

Connexion cloud (OAuth)

-

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

-
- ) : null} - - + const content = ( + <> +
-

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

-
+
{PROVIDERS.map(({ id, label, hint, icon }) => { const provider = draft[id] const configured = Boolean(secrets?.[SECRET_KEYS[id]]?.configured) return ( -
-
-
+ + ) + + if (embedded) return
{content}
+ + return ( + + {content} + ) } diff --git a/components/admin/settings/sections/drive-org-section.tsx b/components/admin/settings/sections/drive-org-section.tsx index 78d550f..0e2590b 100644 --- a/components/admin/settings/sections/drive-org-section.tsx +++ b/components/admin/settings/sections/drive-org-section.tsx @@ -1,10 +1,13 @@ "use client" import { useState } from "react" -import { FieldGroup } from "@/components/admin/settings/field-group" +import { + SettingsCard, + SettingsField, + SettingsGrid, +} from "@/components/settings/settings-kit" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" import { useAdminDriveOrgFolderMutations, useAdminDriveOrgFolders, @@ -17,27 +20,28 @@ export function DriveOrgFoldersSection({ embedded = false }: { embedded?: boolea const [mountPoint, setMountPoint] = useState("") const [syncSlugs, setSyncSlugs] = useState("") - return ( -
- {!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" /> - -
+ const content = ( + <> + + + setOrgSlug(e.target.value)} + placeholder="acme" + /> + + + setMountPoint(e.target.value)} + placeholder="Acme Corp" + /> + + - + -
    +
      {(folders.data ?? []).map((folder) => (
    • @@ -96,6 +101,17 @@ export function DriveOrgFoldersSection({ embedded = false }: { embedded?: boolea
    • Aucun dossier d'organisation
    • ) : null}
    -
+ + ) + + if (embedded) return
{content}
+ + return ( + + {content} + ) } diff --git a/components/admin/settings/sections/drive-org-webdav-section.tsx b/components/admin/settings/sections/drive-org-webdav-section.tsx index abe10ef..dbf53b9 100644 --- a/components/admin/settings/sections/drive-org-webdav-section.tsx +++ b/components/admin/settings/sections/drive-org-webdav-section.tsx @@ -1,10 +1,14 @@ "use client" import { useState } from "react" -import { FieldGroup } from "@/components/admin/settings/field-group" +import { + SettingsCard, + SettingsCheckboxRow, + SettingsField, + SettingsGrid, +} from "@/components/settings/settings-kit" 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, @@ -43,80 +47,75 @@ export function DriveOrgWebDAVSection({ embedded = false }: { embedded?: boolean 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} - + const content = ( + <>

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)} /> - - - + + + setUserName(e.target.value)} + /> + + setPassword(e.target.value)} autoComplete="new-password" /> - -
+ + - +
+ + ) + + if (embedded) return
{content}
+ + return ( + + {content} + ) } diff --git a/components/admin/settings/sections/file-policies-section.tsx b/components/admin/settings/sections/file-policies-section.tsx index 0e5c078..69e6f89 100644 --- a/components/admin/settings/sections/file-policies-section.tsx +++ b/components/admin/settings/sections/file-policies-section.tsx @@ -2,13 +2,22 @@ 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 { + SettingsCard, + SettingsField, + SettingsGrid, + SettingsHint, + SettingsToggleRow, +} from "@/components/settings/settings-kit" 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" -import { Switch } from "@/components/ui/switch" +import { + InputGroup, + InputGroupAddon, + InputGroupInput, + InputGroupText, +} from "@/components/ui/input-group" import { Textarea } from "@/components/ui/textarea" import { Select, @@ -45,51 +54,61 @@ export function FilePoliciesSection() { 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 }) - } - /> - - - + + + + + + setFilePolicies({ max_upload_mib: Number(e.target.value) || 1 }) + } + /> + + Mo + + + + + + + setFilePolicies({ + default_link_expiry_days: Number(e.target.value) || 1, + }) + } + /> + + jours + + + + + + + setFilePolicies({ retention_trash_days: Number(e.target.value) || 1 }) + } + /> + + jours + + + + + - - - + +