feat(admin-settings): refactor admin settings components for improved usability and consistency
Some checks are pending
E2E / Playwright e2e (push) Waiting to run

- 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.
This commit is contained in:
R3D347HR4Y 2026-06-15 11:10:17 +02:00
parent 9e9fd208ad
commit 8f81d7aba1
26 changed files with 1806 additions and 1487 deletions

View File

@ -1,5 +1,7 @@
"use client" "use client"
import type { ReactNode } from "react"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { import {
@ -9,6 +11,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import { cn } from "@/lib/utils"
export type AdminListSortOption = { export type AdminListSortOption = {
value: string value: string
@ -29,6 +32,8 @@ export function AdminListControls({
onPageSizeChange, onPageSizeChange,
onSortChange, onSortChange,
itemLabel, itemLabel,
compact = false,
leading,
}: { }: {
page: number page: number
pageSize: number pageSize: number
@ -41,72 +46,133 @@ export function AdminListControls({
onPageSizeChange: (pageSize: number) => void onPageSizeChange: (pageSize: number) => void
onSortChange: (sort: string) => void onSortChange: (sort: string) => void
itemLabel: string 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 rangeStart = total === 0 ? 0 : (page - 1) * pageSize + 1
const rangeEnd = Math.min(page * pageSize, total) 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 = (
<Select value={String(pageSize)} onValueChange={(value) => onPageSizeChange(Number(value))}>
<SelectTrigger
className={cn("h-9", compact ? "w-18 shrink-0" : "mt-1")}
aria-label="Éléments par page"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{pageSizeOptions.map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
)
const sortSelect = (
<Select value={sort} onValueChange={onSortChange}>
<SelectTrigger
className={cn(
"h-9",
compact ? "min-w-36 max-w-48 flex-1 basis-36" : "mt-1",
)}
aria-label="Tri"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{sortOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)
const pagination = compact ? (
<div className="flex shrink-0 gap-1">
<Button
variant="outline"
size="icon"
className="size-8"
disabled={page <= 1}
aria-label="Page précédente"
onClick={() => onPageChange(page - 1)}
>
<ChevronLeft className="size-4" />
</Button>
<Button
variant="outline"
size="icon"
className="size-8"
disabled={page >= totalPages}
aria-label="Page suivante"
onClick={() => onPageChange(page + 1)}
>
<ChevronRight className="size-4" />
</Button>
</div>
) : (
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
>
Précédent
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)}
>
Suivant
</Button>
</div>
)
if (compact) {
return (
<div className="mb-3 space-y-2">
<div className="flex flex-wrap items-center gap-2">
{leading}
{pageSizeSelect}
{sortSelect}
</div>
<div className="flex items-center justify-between gap-2">
<p className="min-w-0 truncate text-xs text-muted-foreground">{rangeLabel}</p>
{pagination}
</div>
</div>
)
}
return ( return (
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between"> <div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="flex flex-wrap items-end gap-3"> <div className="flex flex-wrap items-end gap-3">
<div className="w-36"> <div className="w-36">
<Label className="text-xs">Par page</Label> <Label className="text-xs">Par page</Label>
<Select {pageSizeSelect}
value={String(pageSize)}
onValueChange={(value) => onPageSizeChange(Number(value))}
>
<SelectTrigger className="mt-1 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{pageSizeOptions.map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
<div className="min-w-[200px] flex-1 sm:max-w-xs"> <div className="min-w-[200px] flex-1 sm:max-w-xs">
<Label className="text-xs">Tri</Label> <Label className="text-xs">Tri</Label>
<Select value={sort} onValueChange={onSortChange}> {sortSelect}
<SelectTrigger className="mt-1 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{sortOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center justify-between gap-3 sm:justify-end"> <div className="flex flex-wrap items-center justify-between gap-3 sm:justify-end">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">{rangeLabel}</p>
{total === 0 {pagination}
? `0 ${itemLabel}`
: `${rangeStart.toLocaleString("fr-FR")}${rangeEnd.toLocaleString("fr-FR")} sur ${total.toLocaleString("fr-FR")} ${itemLabel}`}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
>
Précédent
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)}
>
Suivant
</Button>
</div>
</div> </div>
</div> </div>
) )

View File

@ -1,6 +1,10 @@
import type { ReactNode } from "react" 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({ export function AdminSettingsCard({
title, title,
description, description,
@ -13,15 +17,8 @@ export function AdminSettingsCard({
children: ReactNode children: ReactNode
}) { }) {
return ( return (
<Card className="gap-0 py-0"> <SettingsCard title={title} description={description} hint={hint}>
<CardContent className="py-4"> {children}
<div> </SettingsCard>
<p className="font-medium">{title}</p>
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
{hint}
</div>
<div className="mt-4 space-y-4 border-t pt-4">{children}</div>
</CardContent>
</Card>
) )
} }

View File

@ -1,8 +1,7 @@
"use client" "use client"
import type { OrgLLMSettings } from "@/lib/admin-settings/org-settings-types" import type { OrgLLMSettings } from "@/lib/admin-settings/org-settings-types"
import { Switch } from "@/components/ui/switch" import { SettingsCard, SettingsToggleRow } from "@/components/settings/settings-kit"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
export function AdminOrgLlmPolicyCard({ export function AdminOrgLlmPolicyCard({
draft, draft,
@ -12,40 +11,25 @@ export function AdminOrgLlmPolicyCard({
setDraft: React.Dispatch<React.SetStateAction<OrgLLMSettings>> setDraft: React.Dispatch<React.SetStateAction<OrgLLMSettings>>
}) { }) {
return ( return (
<Card> <SettingsCard
<CardHeader className="pb-3"> title="Politique LLM"
<CardTitle className="text-sm font-medium">Politique LLM</CardTitle> description="Contrôle l'accès aux fournisseurs IA pour les utilisateurs de l'organisation."
<CardDescription> >
Contrôle l&apos;accès aux fournisseurs IA pour les utilisateurs de l&apos;organisation. <SettingsToggleRow
</CardDescription> title="Imposer les fournisseurs org."
</CardHeader> description="Les utilisateurs ne peuvent pas utiliser d'autres clés API."
<CardContent className="space-y-3"> checked={draft.enforce_org_providers}
<label className="flex items-center justify-between gap-4"> onCheckedChange={(enforce_org_providers) =>
<div> setDraft((p) => ({ ...p, enforce_org_providers }))
<p className="text-sm font-medium">Imposer les fournisseurs org.</p> }
<p className="text-xs text-muted-foreground"> />
Les utilisateurs ne peuvent pas utiliser d&apos;autres clés API. <SettingsToggleRow
</p> title="Autoriser surcharge utilisateur"
</div> checked={draft.allow_user_override}
<Switch onCheckedChange={(allow_user_override) =>
checked={draft.enforce_org_providers} setDraft((p) => ({ ...p, allow_user_override }))
onCheckedChange={(enforce_org_providers) => }
setDraft((p) => ({ ...p, enforce_org_providers })) />
} </SettingsCard>
/>
</label>
<label className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium">Autoriser surcharge utilisateur</p>
</div>
<Switch
checked={draft.allow_user_override}
onCheckedChange={(allow_user_override) =>
setDraft((p) => ({ ...p, allow_user_override }))
}
/>
</label>
</CardContent>
</Card>
) )
} }

View File

@ -2,6 +2,12 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form" 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 { AgendaVideoProviderSelectLabel } from "@/components/agenda/agenda-video-provider-select-label"
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store" import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import { import {
@ -10,8 +16,6 @@ import {
type AgendaVideoProvider, type AgendaVideoProvider,
} from "@/lib/agenda/agenda-settings-types" } from "@/lib/agenda/agenda-settings-types"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { import {
Select, Select,
SelectContent, SelectContent,
@ -53,29 +57,22 @@ export function AgendaSection() {
policySection="agenda" policySection="agenda"
beforeSave={() => setAgenda(draft)} beforeSave={() => setAgenda(draft)}
> >
<div className="space-y-6 rounded-lg border p-4"> <AutomationTabMasonry columns={2}>
<div className="space-y-3"> <SettingsCard title="Thème" description="Mode clair/sombre par défaut de l'agenda.">
<label className="flex items-center justify-between gap-4"> <SettingsToggleRow
<div> title="Imposer le thème organisationnel"
<p className="text-sm font-medium">Imposer le thème organisationnel</p> description="Les utilisateurs ne peuvent plus changer le mode clair/sombre."
<p className="text-xs text-muted-foreground"> checked={draft.enforce_org_theme}
Les utilisateurs ne peuvent plus changer le mode clair/sombre. onCheckedChange={(v) => setDraft((p) => ({ ...p, enforce_org_theme: v }))}
</p> />
</div> <SettingsField label="Thème par défaut">
<Switch
checked={draft.enforce_org_theme}
onCheckedChange={(v) => setDraft((p) => ({ ...p, enforce_org_theme: v }))}
/>
</label>
<div>
<Label>Thème par défaut</Label>
<Select <Select
value={draft.default_theme_mode} value={draft.default_theme_mode}
onValueChange={(v) => onValueChange={(v) =>
setDraft((p) => ({ ...p, default_theme_mode: v as MailThemeMode })) setDraft((p) => ({ ...p, default_theme_mode: v as MailThemeMode }))
} }
> >
<SelectTrigger className="mt-1 h-9 w-full max-w-xs"> <SelectTrigger className="h-9 w-full max-w-xs">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -86,26 +83,20 @@ export function AgendaSection() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </SettingsField>
</div> </SettingsCard>
<div className="space-y-3 border-t pt-4"> <SettingsCard
<label className="flex items-center justify-between gap-4"> title="Visioconférence"
<div> description="Fournisseur visio par défaut pour les événements."
<p className="text-sm font-medium">Imposer le fournisseur visio</p> >
<p className="text-xs text-muted-foreground"> <SettingsToggleRow
Les utilisateurs ne peuvent plus choisir Zoom, Meet, etc. title="Imposer le fournisseur visio"
</p> description="Les utilisateurs ne peuvent plus choisir Zoom, Meet, etc."
</div> checked={draft.enforce_org_video_provider}
<Switch onCheckedChange={(v) => setDraft((p) => ({ ...p, enforce_org_video_provider: v }))}
checked={draft.enforce_org_video_provider} />
onCheckedChange={(v) => <SettingsField label="Fournisseur visio par défaut">
setDraft((p) => ({ ...p, enforce_org_video_provider: v }))
}
/>
</label>
<div>
<Label>Fournisseur visio par défaut</Label>
<Select <Select
value={draft.default_video_provider} value={draft.default_video_provider}
onValueChange={(v) => onValueChange={(v) =>
@ -115,7 +106,7 @@ export function AgendaSection() {
})) }))
} }
> >
<SelectTrigger className="mt-1 h-9 w-full max-w-xs"> <SelectTrigger className="h-9 w-full max-w-xs">
<SelectValue> <SelectValue>
<AgendaVideoProviderSelectLabel provider={draft.default_video_provider} /> <AgendaVideoProviderSelectLabel provider={draft.default_video_provider} />
</SelectValue> </SelectValue>
@ -128,31 +119,29 @@ export function AgendaSection() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </SettingsField>
</div> </SettingsCard>
<div className="space-y-3 border-t pt-4"> <SettingsCard
<p className="text-sm font-medium">Clés API visioconférence (organisation)</p> title="Clés API visioconférence (organisation)"
<p className="text-xs text-muted-foreground"> description="Stockées côté serveur. UltiMeet n'exige pas de clé API."
Stockées côté serveur. UltiMeet n&apos;exige pas de clé API. >
</p>
{(["zoom", "google_meet", "teams", "jitsi"] as AgendaVideoProvider[]).map( {(["zoom", "google_meet", "teams", "jitsi"] as AgendaVideoProvider[]).map(
(provider) => ( (provider) => (
<div key={provider}> <SettingsField key={provider} label={AGENDA_VIDEO_PROVIDER_LABELS[provider]}>
<Label>{AGENDA_VIDEO_PROVIDER_LABELS[provider]}</Label>
<Input <Input
type="password" type="password"
autoComplete="off" autoComplete="off"
className="mt-1 h-9" className="h-9"
placeholder="Clé API (laisser vide pour conserver l'existante)" placeholder="Clé API (laisser vide pour conserver l'existante)"
value={draft.video_provider_api_keys[provider] ?? ""} value={draft.video_provider_api_keys[provider] ?? ""}
onChange={(e) => updateApiKey(provider, e.target.value)} onChange={(e) => updateApiKey(provider, e.target.value)}
/> />
</div> </SettingsField>
), ),
)} )}
</div> </SettingsCard>
</div> </AutomationTabMasonry>
</OrgSettingsSection> </OrgSettingsSection>
) )
} }

View File

@ -11,13 +11,18 @@ import { AdminOrgLlmPolicyCard } from "@/components/admin/settings/sections/admi
import { LlmProvidersEditor } from "@/components/llm/llm-providers-editor" import { LlmProvidersEditor } from "@/components/llm/llm-providers-editor"
import { normalizeLlmProvider } from "@/lib/llm/llm-provider-catalog" import { normalizeLlmProvider } from "@/lib/llm/llm-provider-catalog"
import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry" 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 { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import type { AiModelCatalogEntry } from "@/lib/admin-settings/org-settings-types" import type { AiModelCatalogEntry } from "@/lib/admin-settings/org-settings-types"
import { useDiscoverOrgLLMModels } from "@/lib/api/hooks/use-admin-llm" import { useDiscoverOrgLLMModels } from "@/lib/api/hooks/use-admin-llm"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { import {
@ -131,165 +136,146 @@ export function AiAssistantSection() {
beforeSave={() => setLlm(llmDraft)} beforeSave={() => setLlm(llmDraft)}
> >
<AutomationTabMasonry columns={2}> <AutomationTabMasonry columns={2}>
<Card> <SettingsCard
<CardHeader className="pb-3"> title="Assistant IA"
<div className="flex items-center justify-between gap-4"> description="Active le plugin UltiAI pour toute l'organisation. Le service OpenWebUI doit aussi être déployé."
<div> action={
<CardTitle className="text-sm font-medium">Assistant IA</CardTitle> <Switch
<CardDescription> checked={orgEnabled}
Active le plugin UltiAI pour toute l&apos;organisation. Le service OpenWebUI doit disabled={enabledLocked}
aussi être déployé. onCheckedChange={setUltiAIEnabled}
</CardDescription> />
</div> }
<Switch badges={
checked={orgEnabled} <>
disabled={enabledLocked}
onCheckedChange={setUltiAIEnabled}
/>
</div>
{enabledLocked ? (
<DeployLockedHint section="ai_assistant" field="enabled" />
) : null}
<div className="flex flex-wrap gap-2 pt-1">
<Badge variant={orgEnabled ? "default" : "secondary"}> <Badge variant={orgEnabled ? "default" : "secondary"}>
Politique org. {orgEnabled ? "activée" : "désactivée"} Politique org. {orgEnabled ? "activée" : "désactivée"}
</Badge> </Badge>
<Badge variant={runtimeEnabled ? "default" : "outline"}> <Badge variant={runtimeEnabled ? "default" : "outline"}>
Runtime Compose {runtimeEnabled ? "actif" : "inactif"} Runtime Compose {runtimeEnabled ? "actif" : "inactif"}
</Badge> </Badge>
</div> </>
{!orgEnabled && !runtimeEnabled ? ( }
<p className="text-xs text-muted-foreground"> hint={
Activez le plugin UltiAI dans Administration Plugins, ou définissez{" "} <>
<code className="rounded bg-muted px-1">AI_ASSISTANT_ENABLED=true</code> dans le {enabledLocked ? <DeployLockedHint section="ai_assistant" field="enabled" /> : null}
déploiement, puis redémarrez le backend et OpenWebUI. {!orgEnabled && !runtimeEnabled ? (
</p> <SettingsHint>
) : null} Activez le plugin UltiAI dans Administration Plugins, ou définissez{" "}
</CardHeader> <code className="rounded bg-muted px-1">AI_ASSISTANT_ENABLED=true</code> dans le
<CardContent className="grid gap-4 sm:grid-cols-2"> déploiement, puis redémarrez le backend et OpenWebUI.
<div className="space-y-2 sm:col-span-2"> </SettingsHint>
<Label>Chemin public (proxy)</Label> ) : null}
</>
}
>
<SettingsField
label="Chemin public (proxy)"
hint={<DeployLockedHint section="ai_assistant" field="public_path" />}
>
<Input
className="h-9"
value={aiAssistant.public_path}
onChange={(e) => setAiAssistant({ public_path: e.target.value })}
placeholder="/ai"
disabled={publicPathLocked}
/>
</SettingsField>
<SettingsField
label="URL interne OpenWebUI"
hint={<DeployLockedHint section="ai_assistant" field="openwebui_internal_url" />}
>
<Input
className="h-9"
value={aiAssistant.openwebui_internal_url}
onChange={(e) => setAiAssistant({ openwebui_internal_url: e.target.value })}
placeholder="http://openwebui:8080"
disabled={openwebuiLocked}
/>
</SettingsField>
<SettingsField
label="Modèle par défaut"
hint="Modèle pré-sélectionné dans UltiAI pour tous les utilisateurs. Configurez un fournisseur LLM ou découvrez les modèles ci-dessous."
>
{defaultModelOptions.length > 0 ? (
<Select
value={aiAssistant.default_model || "__auto__"}
onValueChange={(value) =>
setAiAssistant({
default_model: value === "__auto__" ? "" : value,
})
}
>
<SelectTrigger className="h-9 w-full min-w-0">
<SelectValue placeholder="Choisir un modèle…" />
</SelectTrigger>
<SelectContent className="max-h-60">
<SelectItem value="__auto__">
Automatique (fournisseur LLM par défaut)
</SelectItem>
{defaultModelOptions.map((modelId) => {
const catalogLabel = aiAssistant.models.find(
(entry) => entry.model_id === modelId,
)?.label
return (
<SelectItem key={modelId} value={modelId}>
{catalogLabel?.trim() ? `${catalogLabel} (${modelId})` : modelId}
</SelectItem>
)
})}
</SelectContent>
</Select>
) : (
<Input <Input
value={aiAssistant.public_path} className="h-9"
onChange={(e) => setAiAssistant({ public_path: e.target.value })} value={aiAssistant.default_model}
placeholder="/ai" onChange={(e) => setAiAssistant({ default_model: e.target.value })}
disabled={publicPathLocked} placeholder="gpt-4o-mini"
/> />
<DeployLockedHint section="ai_assistant" field="public_path" /> )}
</div> </SettingsField>
<div className="space-y-2 sm:col-span-2"> <SettingsField label="Chemin historique NC">
<Label>URL interne OpenWebUI</Label> <Input
<Input className="h-9"
value={aiAssistant.openwebui_internal_url} value={aiAssistant.chat_nc_path}
onChange={(e) => setAiAssistant({ openwebui_internal_url: e.target.value })} onChange={(e) => setAiAssistant({ chat_nc_path: e.target.value })}
placeholder="http://openwebui:8080" placeholder="/.ultimail/ai/chats"
disabled={openwebuiLocked} />
/> </SettingsField>
<DeployLockedHint section="ai_assistant" field="openwebui_internal_url" /> <SettingsToggleRow
</div> title="Embed temporaire par défaut"
<div className="space-y-2 sm:col-span-2"> description="Les panneaux mail/drive/contacts ne sauvegardent pas l'historique."
<Label>Modèle par défaut</Label> checked={aiAssistant.embed_default_temporary}
{defaultModelOptions.length > 0 ? ( onCheckedChange={(v) => setAiAssistant({ embed_default_temporary: v })}
<Select />
value={aiAssistant.default_model || "__auto__"} <SettingsToggleRow
onValueChange={(value) => title="Sync historique Nextcloud"
setAiAssistant({ description="Pipeline OpenWebUI → fichiers .ultichat.json sur le drive utilisateur."
default_model: value === "__auto__" ? "" : value, checked={aiAssistant.chat_sync_enabled}
}) onCheckedChange={(v) => setAiAssistant({ chat_sync_enabled: v })}
} />
> </SettingsCard>
<SelectTrigger className="h-9 w-full min-w-0">
<SelectValue placeholder="Choisir un modèle…" />
</SelectTrigger>
<SelectContent className="max-h-60">
<SelectItem value="__auto__">
Automatique (fournisseur LLM par défaut)
</SelectItem>
{defaultModelOptions.map((modelId) => {
const catalogLabel = aiAssistant.models.find(
(entry) => entry.model_id === modelId,
)?.label
return (
<SelectItem key={modelId} value={modelId}>
{catalogLabel?.trim() ? `${catalogLabel} (${modelId})` : modelId}
</SelectItem>
)
})}
</SelectContent>
</Select>
) : (
<Input
value={aiAssistant.default_model}
onChange={(e) => setAiAssistant({ default_model: e.target.value })}
placeholder="gpt-4o-mini"
/>
)}
<p className="text-xs text-muted-foreground">
Modèle pré-sélectionné dans UltiAI pour tous les utilisateurs. Configurez un
fournisseur LLM ou découvrez les modèles ci-dessous.
</p>
</div>
<div className="space-y-2 sm:col-span-2">
<Label>Chemin historique NC</Label>
<Input
value={aiAssistant.chat_nc_path}
onChange={(e) => setAiAssistant({ chat_nc_path: e.target.value })}
placeholder="/.ultimail/ai/chats"
/>
</div>
<div className="flex items-center justify-between gap-4 sm:col-span-2">
<div>
<Label>Embed temporaire par défaut</Label>
<p className="text-xs text-muted-foreground">
Les panneaux mail/drive/contacts ne sauvegardent pas l&apos;historique.
</p>
</div>
<Switch
checked={aiAssistant.embed_default_temporary}
onCheckedChange={(v) => setAiAssistant({ embed_default_temporary: v })}
/>
</div>
<div className="flex items-center justify-between gap-4 sm:col-span-2">
<div>
<Label>Sync historique Nextcloud</Label>
<p className="text-xs text-muted-foreground">
Pipeline OpenWebUI fichiers .ultichat.json sur le drive utilisateur.
</p>
</div>
<Switch
checked={aiAssistant.chat_sync_enabled}
onCheckedChange={(v) => setAiAssistant({ chat_sync_enabled: v })}
/>
</div>
</CardContent>
</Card>
<div className="flex flex-col gap-4 sm:col-span-2"> <div className="flex flex-col gap-4 sm:col-span-2">
<AdminOrgLlmPolicyCard draft={llmDraft} setDraft={setLlmDraft} /> <AdminOrgLlmPolicyCard draft={llmDraft} setDraft={setLlmDraft} />
<Card> <SettingsCard
<CardHeader className="pb-3"> title="Fournisseurs LLM"
<CardTitle className="text-sm font-medium">Fournisseurs LLM</CardTitle> description="Modèles IA organisationnels pour UltiAI, le tri, l'enrichissement contacts et les automatisations."
<CardDescription> >
Modèles IA organisationnels pour UltiAI, le tri, l&apos;enrichissement contacts et <LlmProvidersEditor
les automatisations. columns={1}
</CardDescription> providers={llmDraft.providers}
</CardHeader> defaultProviderId={llmDraft.default_provider_id}
<CardContent> providerSecrets={orgLlmProviderSecrets}
<LlmProvidersEditor onProvidersChange={(providers) =>
columns={1} setLlmDraft((prev) => ({ ...prev, providers }))
providers={llmDraft.providers} }
defaultProviderId={llmDraft.default_provider_id} onDefaultProviderIdChange={(default_provider_id) =>
providerSecrets={orgLlmProviderSecrets} setLlmDraft((prev) => ({ ...prev, default_provider_id }))
onProvidersChange={(providers) => }
setLlmDraft((prev) => ({ ...prev, providers })) />
} </SettingsCard>
onDefaultProviderIdChange={(default_provider_id) =>
setLlmDraft((prev) => ({ ...prev, default_provider_id }))
}
/>
</CardContent>
</Card>
</div> </div>
<UltiAiToolsCard <UltiAiToolsCard
@ -298,81 +284,74 @@ export function AiAssistantSection() {
webSearchSettingsHref="/admin/settings/search" webSearchSettingsHref="/admin/settings/search"
/> />
<Card> <SettingsCard
<CardHeader className="pb-3"> title="Modèles autorisés"
<CardTitle className="text-sm font-medium">Modèles autorisés</CardTitle> description="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."
<CardDescription> >
Liste vide = tous les modèles des fournisseurs LLM org. Sinon, seuls les modèles {llmDraft.providers.length === 0 ? (
autorisés sont visibles pour les utilisateurs. Le surnom remplace le nom technique. <p className="text-sm text-muted-foreground">
</CardDescription> Configurez d&apos;abord un fournisseur LLM dans la section ci-dessus.
</CardHeader> </p>
<CardContent className="space-y-4"> ) : (
{llmDraft.providers.length === 0 ? ( <div className="flex flex-wrap items-end gap-3 rounded-lg border border-mail-border bg-mail-surface-muted/40 p-3">
<p className="text-sm text-muted-foreground"> <SettingsField label="Découvrir depuis le fournisseur" className="min-w-[220px] flex-1">
Configurez d&apos;abord un fournisseur LLM dans la section ci-dessus. <Select
</p> value={discoverProvider?.id ?? ""}
) : ( onValueChange={setDiscoverProviderId}
<div className="flex flex-wrap items-end gap-3 rounded-lg border p-3">
<div className="min-w-[220px] flex-1 space-y-2">
<Label className="text-xs">Découvrir depuis le fournisseur</Label>
<Select
value={discoverProvider?.id ?? ""}
onValueChange={setDiscoverProviderId}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="Choisir un fournisseur…" />
</SelectTrigger>
<SelectContent>
{llmDraft.providers.map((provider) => (
<SelectItem key={provider.id} value={provider.id}>
{provider.name || provider.base_url}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
type="button"
variant="outline"
size="sm"
disabled={!discoverProvider?.id || discoverModels.isPending}
onClick={() => void handleDiscoverModels()}
> >
<RefreshCw <SelectTrigger className="h-9">
className={`mr-2 size-4 ${discoverModels.isPending ? "animate-spin" : ""}`} <SelectValue placeholder="Choisir un fournisseur…" />
/> </SelectTrigger>
Découvrir les modèles <SelectContent>
</Button> {llmDraft.providers.map((provider) => (
</div> <SelectItem key={provider.id} value={provider.id}>
)} {provider.name || provider.base_url}
</SelectItem>
{discoverModels.isError ? ( ))}
<p className="text-sm text-destructive"> </SelectContent>
{discoverModels.error instanceof Error </Select>
? discoverModels.error.message </SettingsField>
: "Impossible de lister les modèles sur ce fournisseur. Enregistrez d'abord le fournisseur LLM avec une clé API valide."} <Button
</p> type="button"
) : null} variant="outline"
size="sm"
{llmDraft.providers.length > 0 ? ( disabled={!discoverProvider?.id || discoverModels.isPending}
<div className="space-y-2"> onClick={() => void handleDiscoverModels()}
<Label>Catalogue organisation</Label> >
<AiAuthorizedModelPicker <RefreshCw
models={aiAssistant.models} className={`mr-2 size-4 ${discoverModels.isPending ? "animate-spin" : ""}`}
onChange={setAuthorizedModels}
availableModelIds={discoveredModels}
emptyHint="Aucune restriction — tous les modèles LLM configurés restent disponibles."
/> />
{discoveredModels.length === 0 ? ( Découvrir les modèles
<p className="text-xs text-muted-foreground"> </Button>
Découvrez les modèles depuis un fournisseur pour remplir l&apos;autocomplétion, </div>
ou saisissez un ID manuellement puis Entrée. )}
</p>
) : null} {discoverModels.isError ? (
</div> <SettingsHint tone="danger">
) : null} {discoverModels.error instanceof Error
</CardContent> ? discoverModels.error.message
</Card> : "Impossible de lister les modèles sur ce fournisseur. Enregistrez d'abord le fournisseur LLM avec une clé API valide."}
</SettingsHint>
) : null}
{llmDraft.providers.length > 0 ? (
<SettingsField
label="Catalogue organisation"
hint={
discoveredModels.length === 0
? "Découvrez les modèles depuis un fournisseur pour remplir l'autocomplétion, ou saisissez un ID manuellement puis Entrée."
: undefined
}
>
<AiAuthorizedModelPicker
models={aiAssistant.models}
onChange={setAuthorizedModels}
availableModelIds={discoveredModels}
emptyHint="Aucune restriction — tous les modèles LLM configurés restent disponibles."
/>
</SettingsField>
) : null}
</SettingsCard>
</AutomationTabMasonry> </AutomationTabMasonry>
</OrgSettingsSection> </OrgSettingsSection>
) )

View File

@ -2,15 +2,17 @@
import { useCallback, useRef } from "react" import { useCallback, useRef } from "react"
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form" import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
import { AdminSettingsCard } from "@/components/admin/settings/admin-settings-card" import {
import { FieldGroup } from "@/components/admin/settings/field-group" SettingsCard,
SettingsField,
SettingsGrid,
SettingsToggleRow,
} from "@/components/settings/settings-kit"
import { IdentityProvidersPanel } from "@/components/admin/settings/sections/identity-providers-section" import { IdentityProvidersPanel } from "@/components/admin/settings/sections/identity-providers-section"
import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry" import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry"
import { DeployLockedHint, useDeployFieldLocked } from "@/components/admin/settings/deploy-locked-hint" import { DeployLockedHint, useDeployFieldLocked } from "@/components/admin/settings/deploy-locked-hint"
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store" import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import { Card, CardContent } from "@/components/ui/card"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
export function AuthenticationSection() { export function AuthenticationSection() {
@ -41,103 +43,85 @@ export function AuthenticationSection() {
}} }}
> >
<AutomationTabMasonry columns={2}> <AutomationTabMasonry columns={2}>
<Card className="gap-0 py-0"> <SettingsCard
<CardContent className="py-4"> title="Authentik"
<div className="flex items-start gap-4"> description="Connexion via le fournisseur d'identité organisationnel."
<div className="min-w-0 flex-1"> hint={enabledLocked ? <DeployLockedHint section="authentik" field="enabled" /> : null}
<p className="font-medium">Authentik</p> action={
<p className="mt-1 text-sm text-muted-foreground"> <Switch
Connexion via le fournisseur d&apos;identité organisationnel. checked={enabled}
</p> disabled={enabledLocked}
{enabledLocked ? <DeployLockedHint section="authentik" field="enabled" /> : null} onCheckedChange={(v) => setAuthentik({ enabled: v })}
</div> />
<Switch }
checked={enabled} >
disabled={enabledLocked} <SettingsField
onCheckedChange={(v) => setAuthentik({ enabled: v })} label="URL API Authentik"
hint={apiLocked ? <DeployLockedHint section="authentik" field="api_url" /> : undefined}
>
<Input
className="h-9"
value={apiURL}
disabled={apiLocked}
onChange={(e) => setAuthentik({ api_url: e.target.value })}
placeholder="https://auth.example.com/api/v3"
/>
</SettingsField>
<SettingsGrid columns={1}>
<SettingsField label="Slug application">
<Input
className="h-9"
value={authentik.slug}
onChange={(e) => setAuthentik({ slug: e.target.value })}
/> />
</div> </SettingsField>
<SettingsField
label="Client ID OIDC"
hint={
clientLocked ? <DeployLockedHint section="authentik" field="client_id" /> : undefined
}
>
<Input
className="h-9"
value={clientID}
disabled={clientLocked}
onChange={(e) => setAuthentik({ client_id: e.target.value })}
/>
</SettingsField>
</SettingsGrid>
<div className="mt-4 space-y-4 border-t pt-4"> <SettingsField label="Groupes par défaut (séparés par des virgules)">
<FieldGroup> <Input
<Label>URL API Authentik</Label> className="h-9"
<Input value={authentik.default_groups}
className="h-9" onChange={(e) => setAuthentik({ default_groups: e.target.value })}
value={apiURL} />
disabled={apiLocked} </SettingsField>
onChange={(e) => setAuthentik({ api_url: e.target.value })}
placeholder="https://auth.example.com/api/v3"
/>
{apiLocked ? <DeployLockedHint section="authentik" field="api_url" /> : null}
</FieldGroup>
<div className="grid min-w-0 gap-4"> <SettingsToggleRow
<FieldGroup> title="Forcer le SSO"
<Label>Slug application</Label> description="Désactive la connexion locale sauf pour les administrateurs."
<Input checked={authentik.enforce_sso}
className="h-9" onCheckedChange={(enforce_sso) => setAuthentik({ enforce_sso })}
value={authentik.slug} />
onChange={(e) => setAuthentik({ slug: e.target.value })}
/>
</FieldGroup>
<FieldGroup>
<Label>Client ID OIDC</Label>
<Input
className="h-9"
value={clientID}
disabled={clientLocked}
onChange={(e) => setAuthentik({ client_id: e.target.value })}
/>
{clientLocked ? <DeployLockedHint section="authentik" field="client_id" /> : null}
</FieldGroup>
</div>
<FieldGroup> <SettingsToggleRow
<Label>Groupes par défaut (séparés par des virgules)</Label> title="Mot de passe local de secours"
<Input description="Autoriser un fallback mot de passe si Authentik est indisponible."
className="h-9" checked={authentik.allow_password_fallback}
value={authentik.default_groups} onCheckedChange={(allow_password_fallback) =>
onChange={(e) => setAuthentik({ default_groups: e.target.value })} setAuthentik({ allow_password_fallback })
/> }
</FieldGroup> />
</SettingsCard>
<label className="flex items-center justify-between gap-4 rounded-lg border p-3"> <SettingsCard
<FieldGroup>
<p className="text-sm font-medium">Forcer le SSO</p>
<p className="text-xs text-muted-foreground">
Désactive la connexion locale sauf pour les administrateurs.
</p>
</FieldGroup>
<Switch
checked={authentik.enforce_sso}
onCheckedChange={(enforce_sso) => setAuthentik({ enforce_sso })}
/>
</label>
<label className="flex items-center justify-between gap-4 rounded-lg border p-3">
<FieldGroup>
<p className="text-sm font-medium">Mot de passe local de secours</p>
<p className="text-xs text-muted-foreground">
Autoriser un fallback mot de passe si Authentik est indisponible.
</p>
</FieldGroup>
<Switch
checked={authentik.allow_password_fallback}
onCheckedChange={(allow_password_fallback) =>
setAuthentik({ allow_password_fallback })
}
/>
</label>
</div>
</CardContent>
</Card>
<AdminSettingsCard
title="Fournisseurs d'identité" title="Fournisseurs d'identité"
description="Sources upstream Authentik (OAuth, SAML, LDAP) avec restrictions d'accès." description="Sources upstream Authentik (OAuth, SAML, LDAP) avec restrictions d'accès."
> >
<IdentityProvidersPanel onRegisterBeforeSave={registerIdentityBeforeSave} /> <IdentityProvidersPanel onRegisterBeforeSave={registerIdentityBeforeSave} />
</AdminSettingsCard> </SettingsCard>
</AutomationTabMasonry> </AutomationTabMasonry>
</OrgSettingsSection> </OrgSettingsSection>
) )

View File

@ -4,13 +4,17 @@ import { useEffect, useState } from "react"
import { Check, Copy } from "lucide-react" import { Check, Copy } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
import { TechBrandSelectLabel } from "@/components/admin/settings/tech-brand-select-label" 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 { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import type { DriveMountOAuthProvider, DriveMountOAuthSettings } from "@/lib/admin-settings/org-settings-types" import type { DriveMountOAuthProvider, DriveMountOAuthSettings } from "@/lib/admin-settings/org-settings-types"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" 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" import { buildDriveMountOAuthRedirectURI } from "@/lib/drive/drive-mount-oauth"
const PROVIDERS: { const PROVIDERS: {
@ -81,18 +85,12 @@ export function DriveMountOAuthSection({
} }
} }
return ( const content = (
<div className={embedded ? "space-y-4" : "space-y-4 rounded-lg border p-4"}> <>
{!embedded ? ( <SettingsField
<div> label="URI de redirection OAuth"
<h3 className="text-sm font-medium">Connexion cloud (OAuth)</h3> hint="Basée sur l'URL actuelle du navigateur. Enregistrez-la chez chaque fournisseur OAuth (Google, Dropbox, Microsoft)."
<p className="mt-1 text-xs text-muted-foreground"> >
Permet aux utilisateurs de monter Google Drive, Dropbox ou OneDrive depuis UltiDrive.
</p>
</div>
) : null}
<FieldGroup>
<Label>URI de redirection OAuth</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
className="h-9 flex-1 font-mono text-xs" className="h-9 flex-1 font-mono text-xs"
@ -112,42 +110,42 @@ export function DriveMountOAuthSection({
Copier Copier
</Button> </Button>
</div> </div>
<p className="text-xs text-muted-foreground"> </SettingsField>
Basée sur l&apos;URL actuelle du navigateur. Enregistrez-la chez chaque fournisseur OAuth
(Google, Dropbox, Microsoft).
</p>
</FieldGroup>
<div className="space-y-4"> <div className="space-y-4">
{PROVIDERS.map(({ id, label, hint, icon }) => { {PROVIDERS.map(({ id, label, hint, icon }) => {
const provider = draft[id] const provider = draft[id]
const configured = Boolean(secrets?.[SECRET_KEYS[id]]?.configured) const configured = Boolean(secrets?.[SECRET_KEYS[id]]?.configured)
return ( return (
<div key={id} className="space-y-3 rounded-md border p-3"> <div key={id} className="space-y-3 rounded-md border border-mail-border bg-mail-surface-muted/40 p-3">
<label className="flex items-center justify-between gap-4"> <SettingsToggleRow
<FieldGroup> variant="plain"
title={
<TechBrandSelectLabel icon={icon} className="text-sm font-medium"> <TechBrandSelectLabel icon={icon} className="text-sm font-medium">
{label} {label}
</TechBrandSelectLabel> </TechBrandSelectLabel>
<p className="text-xs text-muted-foreground">{hint}</p> }
</FieldGroup> description={hint}
<Switch checked={provider.enabled}
checked={provider.enabled} onCheckedChange={(enabled) => updateProvider(id, { enabled })}
onCheckedChange={(enabled) => updateProvider(id, { enabled })} />
/>
</label>
{provider.enabled ? ( {provider.enabled ? (
<div className="grid min-w-0 gap-4"> <SettingsGrid columns={1}>
<FieldGroup> <SettingsField label="Client ID">
<Label>Client ID</Label>
<Input <Input
className="h-9 font-mono text-xs" className="h-9 font-mono text-xs"
value={provider.client_id} value={provider.client_id}
onChange={(e) => updateProvider(id, { client_id: e.target.value })} onChange={(e) => updateProvider(id, { client_id: e.target.value })}
autoComplete="off" autoComplete="off"
/> />
</FieldGroup> </SettingsField>
<FieldGroup> <SettingsField
<Label>Client secret</Label> label="Client secret"
hint={
configured && !provider.client_secret.trim() ? (
<SettingsHint>Secret configuré</SettingsHint>
) : undefined
}
>
<Input <Input
className="h-9 font-mono text-xs" className="h-9 font-mono text-xs"
type="password" type="password"
@ -156,16 +154,24 @@ export function DriveMountOAuthSection({
placeholder={configured ? "•••••••• (laisser vide pour conserver)" : "Coller le secret"} placeholder={configured ? "•••••••• (laisser vide pour conserver)" : "Coller le secret"}
autoComplete="off" autoComplete="off"
/> />
{configured && !provider.client_secret.trim() ? ( </SettingsField>
<p className="text-xs text-muted-foreground">Secret configuré</p> </SettingsGrid>
) : null}
</FieldGroup>
</div>
) : null} ) : null}
</div> </div>
) )
})} })}
</div> </div>
</div> </>
)
if (embedded) return <div className="space-y-4">{content}</div>
return (
<SettingsCard
title="Connexion cloud (OAuth)"
description="Permet aux utilisateurs de monter Google Drive, Dropbox ou OneDrive depuis UltiDrive."
>
{content}
</SettingsCard>
) )
} }

View File

@ -1,10 +1,13 @@
"use client" "use client"
import { useState } from "react" 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 { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { import {
useAdminDriveOrgFolderMutations, useAdminDriveOrgFolderMutations,
useAdminDriveOrgFolders, useAdminDriveOrgFolders,
@ -17,27 +20,28 @@ export function DriveOrgFoldersSection({ embedded = false }: { embedded?: boolea
const [mountPoint, setMountPoint] = useState("") const [mountPoint, setMountPoint] = useState("")
const [syncSlugs, setSyncSlugs] = useState("") const [syncSlugs, setSyncSlugs] = useState("")
return ( const content = (
<div className={embedded ? "space-y-4" : "space-y-6 rounded-lg border p-4"}> <>
{!embedded ? ( <SettingsGrid columns={1}>
<div> <SettingsField label="Slug organisation" htmlFor="org-slug">
<h3 className="text-sm font-medium">Dossiers d&apos;organisation</h3> <Input
<p className="text-xs text-muted-foreground"> id="org-slug"
Espaces de stockage internes (group folders Nextcloud) liés aux organisations Authentik. className="h-9"
</p> value={orgSlug}
</div> onChange={(e) => setOrgSlug(e.target.value)}
) : null} placeholder="acme"
/>
<div className="grid min-w-0 gap-4"> </SettingsField>
<FieldGroup> <SettingsField label="Nom du dossier" htmlFor="org-mount">
<Label htmlFor="org-slug">Slug organisation</Label> <Input
<Input id="org-slug" value={orgSlug} onChange={(e) => setOrgSlug(e.target.value)} placeholder="acme" /> id="org-mount"
</FieldGroup> className="h-9"
<FieldGroup> value={mountPoint}
<Label htmlFor="org-mount">Nom du dossier</Label> onChange={(e) => setMountPoint(e.target.value)}
<Input id="org-mount" value={mountPoint} onChange={(e) => setMountPoint(e.target.value)} placeholder="Acme Corp" /> placeholder="Acme Corp"
</FieldGroup> />
</div> </SettingsField>
</SettingsGrid>
<Button <Button
size="sm" size="sm"
disabled={!orgSlug.trim() || !mountPoint.trim() || create.isPending} disabled={!orgSlug.trim() || !mountPoint.trim() || create.isPending}
@ -48,14 +52,14 @@ export function DriveOrgFoldersSection({ embedded = false }: { embedded?: boolea
Créer le dossier Créer le dossier
</Button> </Button>
<FieldGroup> <SettingsField
<Label htmlFor="sync-orgs">Provisionnement automatique</Label> label="Provisionnement automatique"
<p className="text-xs text-muted-foreground"> htmlFor="sync-orgs"
Crée un dossier d&apos;organisation pour chaque slug listé, s&apos;il n&apos;existe pas encore. hint="Crée un dossier d'organisation pour chaque slug listé, s'il n'existe pas encore. Les slugs correspondent aux organisations Authentik."
Les slugs correspondent aux organisations Authentik. >
</p>
<Input <Input
id="sync-orgs" id="sync-orgs"
className="h-9"
value={syncSlugs} value={syncSlugs}
onChange={(e) => setSyncSlugs(e.target.value)} onChange={(e) => setSyncSlugs(e.target.value)}
placeholder="acme, beta" placeholder="acme, beta"
@ -63,6 +67,7 @@ export function DriveOrgFoldersSection({ embedded = false }: { embedded?: boolea
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
className="mt-2"
disabled={!syncSlugs.trim() || sync.isPending} disabled={!syncSlugs.trim() || sync.isPending}
onClick={() => onClick={() =>
void sync.mutateAsync( void sync.mutateAsync(
@ -72,9 +77,9 @@ export function DriveOrgFoldersSection({ embedded = false }: { embedded?: boolea
> >
Provisionner les dossiers Provisionner les dossiers
</Button> </Button>
</FieldGroup> </SettingsField>
<ul className="divide-y rounded-md border text-sm"> <ul className="divide-y divide-mail-border rounded-md border border-mail-border text-sm">
{(folders.data ?? []).map((folder) => ( {(folders.data ?? []).map((folder) => (
<li key={folder.id} className="flex items-center justify-between gap-3 px-3 py-2"> <li key={folder.id} className="flex items-center justify-between gap-3 px-3 py-2">
<div className="min-w-0"> <div className="min-w-0">
@ -96,6 +101,17 @@ export function DriveOrgFoldersSection({ embedded = false }: { embedded?: boolea
<li className="px-3 py-4 text-center text-muted-foreground">Aucun dossier d&apos;organisation</li> <li className="px-3 py-4 text-center text-muted-foreground">Aucun dossier d&apos;organisation</li>
) : null} ) : null}
</ul> </ul>
</div> </>
)
if (embedded) return <div className="space-y-4">{content}</div>
return (
<SettingsCard
title="Dossiers d'organisation"
description="Espaces de stockage internes (group folders Nextcloud) liés aux organisations Authentik."
>
{content}
</SettingsCard>
) )
} }

View File

@ -1,10 +1,14 @@
"use client" "use client"
import { useState } from "react" 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 { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { import {
useAdminDriveOrgMountMutations, useAdminDriveOrgMountMutations,
@ -43,80 +47,75 @@ export function DriveOrgWebDAVSection({ embedded = false }: { embedded?: boolean
userName.trim() && userName.trim() &&
password.trim() password.trim()
return ( const content = (
<div className={embedded ? "space-y-4" : "space-y-6 rounded-lg border p-4"}> <>
{!embedded ? (
<div>
<h3 className="text-sm font-medium">Montages WebDAV d&apos;organisation</h3>
<p className="text-xs text-muted-foreground">
Connecte un serveur WebDAV partagé (NAS, Nextcloud externe, etc.) visible par tous les
utilisateurs UltiDrive.
</p>
</div>
) : null}
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Le slug d&apos;organisation sert au rattachement administratif. Le volume est monté globalement Le slug d&apos;organisation sert au rattachement administratif. Le volume est monté globalement
dans Nextcloud et apparaît dans UltiDrive pour tous les utilisateurs. dans Nextcloud et apparaît dans UltiDrive pour tous les utilisateurs.
</p> </p>
<div className="grid min-w-0 gap-4 sm:grid-cols-2"> <SettingsGrid columns={2}>
<FieldGroup> <SettingsField label="Slug organisation" htmlFor="webdav-org-slug">
<Label htmlFor="webdav-org-slug">Slug organisation</Label>
<Input <Input
id="webdav-org-slug" id="webdav-org-slug"
className="h-9"
value={orgSlug} value={orgSlug}
onChange={(e) => setOrgSlug(e.target.value)} onChange={(e) => setOrgSlug(e.target.value)}
placeholder="acme" placeholder="acme"
/> />
</FieldGroup> </SettingsField>
<FieldGroup> <SettingsField label="Nom affiché" htmlFor="webdav-display-name">
<Label htmlFor="webdav-display-name">Nom affiché</Label>
<Input <Input
id="webdav-display-name" id="webdav-display-name"
className="h-9"
value={displayName} value={displayName}
onChange={(e) => setDisplayName(e.target.value)} onChange={(e) => setDisplayName(e.target.value)}
placeholder="NAS partagé" placeholder="NAS partagé"
/> />
</FieldGroup> </SettingsField>
<FieldGroup> <SettingsField label="Hôte" htmlFor="webdav-host">
<Label htmlFor="webdav-host">Hôte</Label>
<Input <Input
id="webdav-host" id="webdav-host"
className="h-9"
value={host} value={host}
onChange={(e) => setHost(e.target.value)} onChange={(e) => setHost(e.target.value)}
placeholder="nas.example.com" placeholder="nas.example.com"
/> />
</FieldGroup> </SettingsField>
<FieldGroup> <SettingsField label="Chemin racine" htmlFor="webdav-root">
<Label htmlFor="webdav-root">Chemin racine</Label>
<Input <Input
id="webdav-root" id="webdav-root"
className="h-9"
value={root} value={root}
onChange={(e) => setRoot(e.target.value)} onChange={(e) => setRoot(e.target.value)}
placeholder="/remote.php/dav/files/user" placeholder="/remote.php/dav/files/user"
/> />
</FieldGroup> </SettingsField>
<FieldGroup> <SettingsField label="Utilisateur" htmlFor="webdav-user">
<Label htmlFor="webdav-user">Utilisateur</Label> <Input
<Input id="webdav-user" value={userName} onChange={(e) => setUserName(e.target.value)} /> id="webdav-user"
</FieldGroup> className="h-9"
<FieldGroup> value={userName}
<Label htmlFor="webdav-pass">Mot de passe</Label> onChange={(e) => setUserName(e.target.value)}
/>
</SettingsField>
<SettingsField label="Mot de passe" htmlFor="webdav-pass">
<Input <Input
id="webdav-pass" id="webdav-pass"
className="h-9"
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password" autoComplete="new-password"
/> />
</FieldGroup> </SettingsField>
</div> </SettingsGrid>
<label className="flex cursor-pointer items-center gap-2 text-sm"> <SettingsCheckboxRow
<input type="checkbox" checked={secure} onChange={(e) => setSecure(e.target.checked)} /> title="Connexion HTTPS"
Connexion HTTPS checked={secure}
</label> onCheckedChange={setSecure}
/>
<Button <Button
size="sm" size="sm"
@ -146,7 +145,7 @@ export function DriveOrgWebDAVSection({ embedded = false }: { embedded?: boolean
Ajouter le montage Ajouter le montage
</Button> </Button>
<ul className="divide-y rounded-md border text-sm"> <ul className="divide-y divide-mail-border rounded-md border border-mail-border text-sm">
{(mounts.data ?? []).map((mount) => ( {(mounts.data ?? []).map((mount) => (
<li key={mount.id} className="flex items-start justify-between gap-3 px-3 py-2"> <li key={mount.id} className="flex items-start justify-between gap-3 px-3 py-2">
<div className="min-w-0 space-y-1"> <div className="min-w-0 space-y-1">
@ -178,6 +177,17 @@ export function DriveOrgWebDAVSection({ embedded = false }: { embedded?: boolean
<li className="px-3 py-4 text-center text-muted-foreground">Aucun montage WebDAV d&apos;organisation</li> <li className="px-3 py-4 text-center text-muted-foreground">Aucun montage WebDAV d&apos;organisation</li>
) : null} ) : null}
</ul> </ul>
</div> </>
)
if (embedded) return <div className="space-y-4">{content}</div>
return (
<SettingsCard
title="Montages WebDAV d'organisation"
description="Connecte un serveur WebDAV partagé (NAS, Nextcloud externe, etc.) visible par tous les utilisateurs UltiDrive."
>
{content}
</SettingsCard>
) )
} }

View File

@ -2,13 +2,22 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form" import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
import { AdminSettingsCard } from "@/components/admin/settings/admin-settings-card" import {
import { FieldGroup } from "@/components/admin/settings/field-group" SettingsCard,
SettingsField,
SettingsGrid,
SettingsHint,
SettingsToggleRow,
} from "@/components/settings/settings-kit"
import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry" import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry"
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store" import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import {
import { Switch } from "@/components/ui/switch" InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@/components/ui/input-group"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { import {
Select, Select,
@ -45,51 +54,61 @@ export function FilePoliciesSection() {
beforeSave={() => setFilePolicies({ mount_oauth: mountOAuthDraft })} beforeSave={() => setFilePolicies({ mount_oauth: mountOAuthDraft })}
> >
<AutomationTabMasonry columns={2}> <AutomationTabMasonry columns={2}>
<AdminSettingsCard <SettingsCard
title="Politiques UltiDrive" title="Politiques UltiDrive"
description="Limites d'upload, partage externe, extensions et analyse antivirus." description="Limites d'upload, partage externe, extensions et analyse antivirus."
> >
<div className="grid min-w-0 gap-4"> <SettingsGrid columns={1} className="space-y-4">
<FieldGroup> <SettingsGrid columns={2} className="sm:grid-cols-3">
<Label>Taille max upload (Mo)</Label> <SettingsField label="Taille max upload">
<Input <InputGroup>
className="h-9" <InputGroupInput
type="number" type="number"
min={1} min={1}
value={filePolicies.max_upload_mib} value={filePolicies.max_upload_mib}
onChange={(e) => onChange={(e) =>
setFilePolicies({ max_upload_mib: Number(e.target.value) || 1 }) setFilePolicies({ max_upload_mib: Number(e.target.value) || 1 })
} }
/> />
</FieldGroup> <InputGroupAddon align="inline-end">
<FieldGroup> <InputGroupText>Mo</InputGroupText>
<Label>Expiration liens par défaut (jours)</Label> </InputGroupAddon>
<Input </InputGroup>
className="h-9" </SettingsField>
type="number" <SettingsField label="Expiration liens par défaut">
min={1} <InputGroup>
value={filePolicies.default_link_expiry_days} <InputGroupInput
onChange={(e) => type="number"
setFilePolicies({ min={1}
default_link_expiry_days: Number(e.target.value) || 1, value={filePolicies.default_link_expiry_days}
}) onChange={(e) =>
} setFilePolicies({
/> default_link_expiry_days: Number(e.target.value) || 1,
</FieldGroup> })
<FieldGroup> }
<Label>Rétention corbeille (jours)</Label> />
<Input <InputGroupAddon align="inline-end">
className="h-9" <InputGroupText>jours</InputGroupText>
type="number" </InputGroupAddon>
min={1} </InputGroup>
value={filePolicies.retention_trash_days} </SettingsField>
onChange={(e) => <SettingsField label="Rétention corbeille">
setFilePolicies({ retention_trash_days: Number(e.target.value) || 1 }) <InputGroup>
} <InputGroupInput
/> type="number"
</FieldGroup> min={1}
<FieldGroup> value={filePolicies.retention_trash_days}
<Label>Partage externe</Label> onChange={(e) =>
setFilePolicies({ retention_trash_days: Number(e.target.value) || 1 })
}
/>
<InputGroupAddon align="inline-end">
<InputGroupText>jours</InputGroupText>
</InputGroupAddon>
</InputGroup>
</SettingsField>
</SettingsGrid>
<SettingsField label="Partage externe">
<Select <Select
value={filePolicies.external_sharing} value={filePolicies.external_sharing}
onValueChange={(external_sharing) => onValueChange={(external_sharing) =>
@ -107,45 +126,33 @@ export function FilePoliciesSection() {
<SelectItem value="public_link">Liens publics autorisés</SelectItem> <SelectItem value="public_link">Liens publics autorisés</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</FieldGroup> </SettingsField>
<FieldGroup> <SettingsField label="Extensions autorisées (vide = toutes)">
<Label>Extensions autorisées (vide = toutes)</Label>
<Textarea <Textarea
className="min-h-[80px] font-mono text-xs" className="min-h-[80px] font-mono text-xs"
value={filePolicies.allowed_extensions} value={filePolicies.allowed_extensions}
onChange={(e) => setFilePolicies({ allowed_extensions: e.target.value })} onChange={(e) => setFilePolicies({ allowed_extensions: e.target.value })}
placeholder="pdf, docx, png, jpg" placeholder="pdf, docx, png, jpg"
/> />
</FieldGroup> </SettingsField>
</div> </SettingsGrid>
<label className="flex items-center justify-between gap-4 rounded-lg border p-3"> <SettingsToggleRow
<FieldGroup> title="Bloquer les exécutables"
<p className="text-sm font-medium">Bloquer les exécutables</p> description="exe, bat, sh, app, etc."
<p className="text-xs text-muted-foreground">exe, bat, sh, app, etc.</p> checked={filePolicies.block_executable}
</FieldGroup> onCheckedChange={(block_executable) => setFilePolicies({ block_executable })}
<Switch />
checked={filePolicies.block_executable}
onCheckedChange={(block_executable) => setFilePolicies({ block_executable })}
/>
</label>
<label className="flex items-center justify-between gap-4 rounded-lg border p-3"> <SettingsToggleRow
<FieldGroup> title="Analyse antivirus à l'upload"
<p className="text-sm font-medium">Analyse antivirus à l&apos;upload</p> description="VirusTotal — scan synchrone à l'upload Drive et pièces jointes mail"
<p className="text-xs text-muted-foreground"> checked={filePolicies.virus_scan_enabled}
VirusTotal scan synchrone à l&apos;upload Drive et pièces jointes mail onCheckedChange={(virus_scan_enabled) => setFilePolicies({ virus_scan_enabled })}
</p> />
</FieldGroup>
<Switch
checked={filePolicies.virus_scan_enabled}
onCheckedChange={(virus_scan_enabled) => setFilePolicies({ virus_scan_enabled })}
/>
</label>
{filePolicies.virus_scan_enabled ? ( {filePolicies.virus_scan_enabled ? (
<FieldGroup> <SettingsField label="Clé API VirusTotal">
<Label>Clé API VirusTotal</Label>
<Input <Input
className="h-9" className="h-9"
type="password" type="password"
@ -157,37 +164,37 @@ export function FilePoliciesSection() {
} }
/> />
{vtKeyConfigured && !(filePolicies.virustotal_api_key ?? "").trim() ? ( {vtKeyConfigured && !(filePolicies.virustotal_api_key ?? "").trim() ? (
<p className="text-xs text-muted-foreground">Clé configurée</p> <SettingsHint>Clé configurée</SettingsHint>
) : null} ) : null}
{vtKeyMissing ? ( {vtKeyMissing ? (
<p className="text-xs text-amber-600 dark:text-amber-500"> <SettingsHint tone="warning">
Analyse activée sans clé API les uploads ne seront pas scannés. Analyse activée sans clé API les uploads ne seront pas scannés.
</p> </SettingsHint>
) : null} ) : null}
</FieldGroup> </SettingsField>
) : null} ) : null}
</AdminSettingsCard> </SettingsCard>
<AdminSettingsCard <SettingsCard
title="Connexion cloud (OAuth)" title="Connexion cloud (OAuth)"
description="Permet aux utilisateurs de monter Google Drive, Dropbox ou OneDrive depuis UltiDrive." description="Permet aux utilisateurs de monter Google Drive, Dropbox ou OneDrive depuis UltiDrive."
> >
<DriveMountOAuthSection draft={mountOAuthDraft} onChange={setMountOAuthDraft} embedded /> <DriveMountOAuthSection draft={mountOAuthDraft} onChange={setMountOAuthDraft} embedded />
</AdminSettingsCard> </SettingsCard>
<AdminSettingsCard <SettingsCard
title="Montages WebDAV d'organisation" title="Montages WebDAV d'organisation"
description="Serveur WebDAV partagé (NAS, Nextcloud externe) visible par tous les utilisateurs UltiDrive." description="Serveur WebDAV partagé (NAS, Nextcloud externe) visible par tous les utilisateurs UltiDrive."
> >
<DriveOrgWebDAVSection embedded /> <DriveOrgWebDAVSection embedded />
</AdminSettingsCard> </SettingsCard>
<AdminSettingsCard <SettingsCard
title="Dossiers d'organisation" title="Dossiers d'organisation"
description="Espaces de stockage internes (group folders Nextcloud) liés aux organisations Authentik." description="Espaces de stockage internes (group folders Nextcloud) liés aux organisations Authentik."
> >
<DriveOrgFoldersSection embedded /> <DriveOrgFoldersSection embedded />
</AdminSettingsCard> </SettingsCard>
</AutomationTabMasonry> </AutomationTabMasonry>
</OrgSettingsSection> </OrgSettingsSection>
) )

View File

@ -11,6 +11,7 @@ import {
} from "lucide-react" } from "lucide-react"
import { guideForProvider } from "@/components/admin/settings/guides/identity-provider-guides" import { guideForProvider } from "@/components/admin/settings/guides/identity-provider-guides"
import { TechBrandSelectLabel } from "@/components/admin/settings/tech-brand-select-label" import { TechBrandSelectLabel } from "@/components/admin/settings/tech-brand-select-label"
import { SettingsToggleRow } from "@/components/settings/settings-kit"
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store" import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import type { import type {
IdentityProvider, IdentityProvider,
@ -235,20 +236,14 @@ export function IdentityProvidersPanel({
return ( return (
<> <>
<label className="flex items-center justify-between gap-4 rounded-lg border p-4"> <SettingsToggleRow
<div> title="Inscription self-service Authentik"
<p className="text-sm font-medium">Inscription self-service Authentik</p> description="Flow ulti-enrollment : autoriser la création de compte locale en parallèle du SSO entreprise."
<p className="text-xs text-muted-foreground"> checked={draft.allow_self_enrollment}
Flow ulti-enrollment : autoriser la création de compte locale en parallèle du SSO entreprise. onCheckedChange={(allow_self_enrollment) =>
</p> setDraft((prev) => ({ ...prev, allow_self_enrollment }))
</div> }
<Switch />
checked={draft.allow_self_enrollment}
onCheckedChange={(allow_self_enrollment) =>
setDraft((prev) => ({ ...prev, allow_self_enrollment }))
}
/>
</label>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label>Fournisseurs configurés</Label> <Label>Fournisseurs configurés</Label>
@ -267,7 +262,7 @@ export function IdentityProvidersPanel({
{draft.providers.map((provider, index) => ( {draft.providers.map((provider, index) => (
<div <div
key={provider.id} key={provider.id}
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border p-4" className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-mail-border p-4"
> >
<div className="space-y-1"> <div className="space-y-1">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@ -395,7 +390,7 @@ export function IdentityProvidersPanel({
</div> </div>
{editingProvider.type === "oauth" ? ( {editingProvider.type === "oauth" ? (
<div className="space-y-4 rounded-lg border p-4"> <div className="space-y-4 rounded-lg border border-mail-border p-4">
<div> <div>
<Label>Présélection</Label> <Label>Présélection</Label>
<Select <Select
@ -561,7 +556,7 @@ export function IdentityProvidersPanel({
) : null} ) : null}
{editingProvider.type === "saml" ? ( {editingProvider.type === "saml" ? (
<div className="space-y-4 rounded-lg border p-4"> <div className="space-y-4 rounded-lg border border-mail-border p-4">
<div> <div>
<Label>Metadata URL</Label> <Label>Metadata URL</Label>
<Input <Input
@ -622,7 +617,7 @@ export function IdentityProvidersPanel({
) : null} ) : null}
{editingProvider.type === "ldap" ? ( {editingProvider.type === "ldap" ? (
<div className="space-y-4 rounded-lg border p-4"> <div className="space-y-4 rounded-lg border border-mail-border p-4">
<div> <div>
<Label>Server URI</Label> <Label>Server URI</Label>
<Input <Input
@ -697,32 +692,28 @@ export function IdentityProvidersPanel({
} }
/> />
</div> </div>
<label className="flex items-center justify-between gap-4 rounded-lg border p-3"> <SettingsToggleRow
<span className="text-sm">StartTLS</span> title="StartTLS"
<Switch checked={editingProvider.ldap?.start_tls ?? true}
checked={editingProvider.ldap?.start_tls ?? true} onCheckedChange={(start_tls) =>
onCheckedChange={(start_tls) => updateProvider(editIndex, {
updateProvider(editIndex, { ldap: { ...editingProvider.ldap!, start_tls },
ldap: { ...editingProvider.ldap!, start_tls }, })
}) }
} />
/> <SettingsToggleRow
</label> title="Synchroniser les utilisateurs LDAP"
<label className="flex items-center justify-between gap-4 rounded-lg border p-3"> checked={editingProvider.ldap?.sync_users ?? false}
<span className="text-sm">Synchroniser les utilisateurs LDAP</span> onCheckedChange={(sync_users) =>
<Switch updateProvider(editIndex, {
checked={editingProvider.ldap?.sync_users ?? false} ldap: { ...editingProvider.ldap!, sync_users },
onCheckedChange={(sync_users) => })
updateProvider(editIndex, { }
ldap: { ...editingProvider.ldap!, sync_users }, />
})
}
/>
</label>
</div> </div>
) : null} ) : null}
<div className="space-y-3 rounded-lg border p-4"> <div className="space-y-3 rounded-lg border border-mail-border p-4">
<p className="text-sm font-medium">Restrictions d&apos;accès</p> <p className="text-sm font-medium">Restrictions d&apos;accès</p>
<div> <div>
<Label>Domaines email autorisés</Label> <Label>Domaines email autorisés</Label>

View File

@ -5,7 +5,6 @@ import { Check, Copy } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { import {
Select, Select,
@ -14,8 +13,12 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry" import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry"
import {
SettingsCard,
SettingsField,
SettingsGrid,
} from "@/components/settings/settings-kit"
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form" import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
import { MigrationProjectsPanel } from "@/components/admin/settings/sections/migration-projects-panel" import { MigrationProjectsPanel } from "@/components/admin/settings/sections/migration-projects-panel"
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store" import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
@ -43,104 +46,87 @@ export function MailDomainsSection() {
policySection="mailing" policySection="mailing"
> >
<AutomationTabMasonry columns={2}> <AutomationTabMasonry columns={2}>
<Card> <SettingsCard
<CardHeader className="pb-3"> title="Domaines hébergés"
<CardTitle className="text-sm font-medium">Domaines hébergés</CardTitle> description="Vérification DNS, DKIM et provisioning des boîtes @domaine."
<CardDescription> >
Vérification DNS, DKIM et provisioning des boîtes @domaine. <div className="grid gap-4 md:grid-cols-[1fr_auto] md:items-end">
</CardDescription> <SettingsField label="Nouveau domaine" htmlFor="new-domain">
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-[1fr_auto]">
<div className="space-y-2">
<Label htmlFor="new-domain">Nouveau domaine</Label>
<Input
id="new-domain"
value={domainName}
onChange={(e) => setDomainName(e.target.value)}
placeholder="entreprise.com"
/>
</div>
<div className="flex items-end">
<Button
disabled={!domainName || createDomain.isPending}
onClick={() => {
void createDomain.mutateAsync({ name: domainName }).then(() => setDomainName(""))
}}
>
Ajouter
</Button>
</div>
</div>
<ul className="mt-6 space-y-3">
{domains.map((domain) => (
<DomainRow key={domain.id} domain={domain} />
))}
</ul>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-4">
<div>
<CardTitle className="text-sm font-medium">Notifications suite (SMTP)</CardTitle>
<CardDescription>
Partages de fichiers, mentions, invitations distinct des comptes mail utilisateur.
</CardDescription>
</div>
<Switch
checked={mailing.enabled}
onCheckedChange={(enabled) => setMailing({ enabled })}
/>
</div>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<div>
<Label>Hôte SMTP</Label>
<Input <Input
className="mt-1 h-9" id="new-domain"
className="h-9"
value={domainName}
onChange={(e) => setDomainName(e.target.value)}
placeholder="entreprise.com"
/>
</SettingsField>
<Button
disabled={!domainName || createDomain.isPending}
onClick={() => {
void createDomain.mutateAsync({ name: domainName }).then(() => setDomainName(""))
}}
>
Ajouter
</Button>
</div>
<ul className="space-y-3">
{domains.map((domain) => (
<DomainRow key={domain.id} domain={domain} />
))}
</ul>
</SettingsCard>
<SettingsCard
title="Notifications suite (SMTP)"
description="Partages de fichiers, mentions, invitations — distinct des comptes mail utilisateur."
action={
<Switch
checked={mailing.enabled}
onCheckedChange={(enabled) => setMailing({ enabled })}
/>
}
>
<SettingsGrid columns={2}>
<SettingsField label="Hôte SMTP">
<Input
className="h-9"
value={mailing.smtp_host} value={mailing.smtp_host}
onChange={(e) => setMailing({ smtp_host: e.target.value })} onChange={(e) => setMailing({ smtp_host: e.target.value })}
placeholder="smtp.example.com" placeholder="smtp.example.com"
/> />
</div> </SettingsField>
<div> <SettingsField label="Port">
<Label>Port</Label>
<Input <Input
className="mt-1 h-9" className="h-9"
type="number" type="number"
value={mailing.smtp_port} value={mailing.smtp_port}
onChange={(e) => setMailing({ smtp_port: Number(e.target.value) || 587 })} onChange={(e) => setMailing({ smtp_port: Number(e.target.value) || 587 })}
/> />
</div> </SettingsField>
<div> <SettingsField label="Utilisateur">
<Label>Utilisateur</Label>
<Input <Input
className="mt-1 h-9" className="h-9"
value={mailing.smtp_user} value={mailing.smtp_user}
onChange={(e) => setMailing({ smtp_user: e.target.value })} onChange={(e) => setMailing({ smtp_user: e.target.value })}
/> />
</div> </SettingsField>
<div> <SettingsField label="Mot de passe">
<Label>Mot de passe</Label>
<Input <Input
className="mt-1 h-9" className="h-9"
type="password" type="password"
value={mailing.smtp_password} value={mailing.smtp_password}
onChange={(e) => setMailing({ smtp_password: e.target.value })} onChange={(e) => setMailing({ smtp_password: e.target.value })}
/> />
</div> </SettingsField>
<div> <SettingsField label="Chiffrement">
<Label>Chiffrement</Label>
<Select <Select
value={mailing.tls_mode} value={mailing.tls_mode}
onValueChange={(tls_mode) => onValueChange={(tls_mode) =>
setMailing({ tls_mode: tls_mode as typeof mailing.tls_mode }) setMailing({ tls_mode: tls_mode as typeof mailing.tls_mode })
} }
> >
<SelectTrigger className="mt-1 h-9"> <SelectTrigger className="h-9 w-full min-w-0">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -149,35 +135,32 @@ export function MailDomainsSection() {
<SelectItem value="none">Aucun</SelectItem> <SelectItem value="none">Aucun</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </SettingsField>
<div> <SettingsField label="Adresse d'expédition">
<Label>Adresse d&apos;expédition</Label>
<Input <Input
className="mt-1 h-9" className="h-9"
type="email" type="email"
value={mailing.from_email} value={mailing.from_email}
onChange={(e) => setMailing({ from_email: e.target.value })} onChange={(e) => setMailing({ from_email: e.target.value })}
/> />
</div> </SettingsField>
<div> <SettingsField label="Nom affiché">
<Label>Nom affiché</Label>
<Input <Input
className="mt-1 h-9" className="h-9"
value={mailing.from_name} value={mailing.from_name}
onChange={(e) => setMailing({ from_name: e.target.value })} onChange={(e) => setMailing({ from_name: e.target.value })}
/> />
</div> </SettingsField>
<div className="sm:col-span-2"> <SettingsField label="Reply-To (optionnel)" className="sm:col-span-2">
<Label>Reply-To (optionnel)</Label>
<Input <Input
className="mt-1 h-9" className="h-9"
type="email" type="email"
value={mailing.reply_to ?? ""} value={mailing.reply_to ?? ""}
onChange={(e) => setMailing({ reply_to: e.target.value })} onChange={(e) => setMailing({ reply_to: e.target.value })}
/> />
</div> </SettingsField>
</CardContent> </SettingsGrid>
</Card> </SettingsCard>
</AutomationTabMasonry> </AutomationTabMasonry>
</OrgSettingsSection> </OrgSettingsSection>
@ -238,7 +221,7 @@ function DomainRow({
}, []) }, [])
return ( return (
<li className="rounded-lg border p-4"> <li className="rounded-lg border border-mail-border bg-mail-surface-muted/40 p-4">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<div> <div>
<p className="font-medium"> <p className="font-medium">

View File

@ -16,6 +16,7 @@ import {
} from "@/components/ui/select" } from "@/components/ui/select"
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form" import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
import { TechBrandSelectLabel } from "@/components/admin/settings/tech-brand-select-label" import { TechBrandSelectLabel } from "@/components/admin/settings/tech-brand-select-label"
import { SettingsField, SettingsGrid } from "@/components/settings/settings-kit"
import { import {
type DNSCheckReport, type DNSCheckReport,
type MailDomain, type MailDomain,
@ -185,18 +186,17 @@ export function MigrationProjectsPanel({ domains }: { domains: MailDomain[] }) {
</p> </p>
)} )}
<div className="grid gap-4 md:grid-cols-2"> <SettingsGrid>
<div className="space-y-2"> <SettingsField label="Nom du projet" htmlFor="project-name">
<Label htmlFor="project-name">Nom du projet</Label>
<Input <Input
id="project-name" id="project-name"
className="h-9"
value={projectName} value={projectName}
onChange={(e) => setProjectName(e.target.value)} onChange={(e) => setProjectName(e.target.value)}
placeholder="Migration ACME 2026" placeholder="Migration ACME 2026"
/> />
</div> </SettingsField>
<div className="space-y-2"> <SettingsField label="Source">
<Label>Source</Label>
<Select <Select
value={sourceProvider} value={sourceProvider}
onValueChange={(value) => { onValueChange={(value) => {
@ -209,7 +209,7 @@ export function MigrationProjectsPanel({ domains }: { domains: MailDomain[] }) {
} }
}} }}
> >
<SelectTrigger> <SelectTrigger className="h-9">
<SelectValue> <SelectValue>
<TechBrandSelectLabel brand={sourceProvider}> <TechBrandSelectLabel brand={sourceProvider}>
{sourceProvider === "google" ? "Google Workspace" : "Microsoft 365"} {sourceProvider === "google" ? "Google Workspace" : "Microsoft 365"}
@ -225,11 +225,10 @@ export function MigrationProjectsPanel({ domains }: { domains: MailDomain[] }) {
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </SettingsField>
<div className="space-y-2"> <SettingsField label="Mode d'authentification">
<Label>Mode d&apos;authentification</Label>
<Select value={authMode} onValueChange={setAuthMode}> <Select value={authMode} onValueChange={setAuthMode}>
<SelectTrigger> <SelectTrigger className="h-9">
<SelectValue> <SelectValue>
<TechBrandSelectLabel brand={authMode === "oauth" ? "oauth" : authMode}> <TechBrandSelectLabel brand={authMode === "oauth" ? "oauth" : authMode}>
{AUTH_MODE_LABELS[authMode] ?? authMode} {AUTH_MODE_LABELS[authMode] ?? authMode}
@ -252,11 +251,10 @@ export function MigrationProjectsPanel({ domains }: { domains: MailDomain[] }) {
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </SettingsField>
<div className="space-y-2"> <SettingsField label="Domaine mail (optionnel)">
<Label>Domaine mail (optionnel)</Label>
<Select value={domainId || "__none__"} onValueChange={(v) => setDomainId(v === "__none__" ? "" : v)}> <Select value={domainId || "__none__"} onValueChange={(v) => setDomainId(v === "__none__" ? "" : v)}>
<SelectTrigger> <SelectTrigger className="h-9">
<SelectValue placeholder="Aucun" /> <SelectValue placeholder="Aucun" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -268,8 +266,8 @@ export function MigrationProjectsPanel({ domains }: { domains: MailDomain[] }) {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </SettingsField>
</div> </SettingsGrid>
<div className="mt-4"> <div className="mt-4">
<Button <Button
@ -330,8 +328,8 @@ export function MigrationProjectsPanel({ domains }: { domains: MailDomain[] }) {
</ul> </ul>
{activeProject?.source_provider === "microsoft" && ( {activeProject?.source_provider === "microsoft" && (
<div className="mt-6 space-y-3 rounded-lg border p-4"> <div className="mt-6 space-y-3 rounded-lg border border-mail-border p-4">
<h3 className="font-medium">Consentement admin Microsoft</h3> <h3 className="font-medium">Consentement admin Microsoft</h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Requis pour les migrations Microsoft 365 Requis pour les migrations Microsoft 365
{activeProject.auth_mode === "microsoft_app" {activeProject.auth_mode === "microsoft_app"
@ -378,7 +376,7 @@ export function MigrationProjectsPanel({ domains }: { domains: MailDomain[] }) {
{activeProjectId && ( {activeProjectId && (
<> <>
<div className="mt-6 space-y-3 rounded-lg border p-4"> <div className="mt-6 space-y-3 rounded-lg border border-mail-border p-4">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="font-medium">Pré-vérification DNS (cutover)</h3> <h3 className="font-medium">Pré-vérification DNS (cutover)</h3>
<Button <Button
@ -435,7 +433,7 @@ export function MigrationProjectsPanel({ domains }: { domains: MailDomain[] }) {
/> />
)} )}
<div className="mt-8 space-y-4 rounded-lg border p-4"> <div className="mt-8 space-y-4 rounded-lg border border-mail-border p-4">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="font-medium">Jobs de migration</h3> <h3 className="font-medium">Jobs de migration</h3>
<Button <Button
@ -462,7 +460,7 @@ export function MigrationProjectsPanel({ domains }: { domains: MailDomain[] }) {
)} )}
</div> </div>
<div className="mt-8 space-y-4 rounded-lg border p-4"> <div className="mt-8 space-y-4 rounded-lg border border-mail-border p-4">
<h3 className="font-medium">Invitations utilisateurs</h3> <h3 className="font-medium">Invitations utilisateurs</h3>
<div className="grid gap-4 md:grid-cols-[1fr_auto]"> <div className="grid gap-4 md:grid-cols-[1fr_auto]">
<Input <Input
@ -640,7 +638,7 @@ function SharedDrivesPanel({
const pending = drives.filter((d) => d.status === "pending") const pending = drives.filter((d) => d.status === "pending")
return ( return (
<div className="mt-8 space-y-4 rounded-lg border p-4"> <div className="mt-8 space-y-4 rounded-lg border border-mail-border p-4">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div> <div>
<h3 className="font-medium">Shared Drives Google</h3> <h3 className="font-medium">Shared Drives Google</h3>

View File

@ -5,16 +5,19 @@ import { ChevronDown, Settings2 } from "lucide-react"
import { useState } from "react" import { useState } from "react"
import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry" import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry"
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form" import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
import { FieldGroup } from "@/components/admin/settings/field-group" import {
SettingsCard,
SettingsField,
SettingsGrid,
SettingsToggleRow,
} from "@/components/settings/settings-kit"
import { DeployLockedHint, useDeployFieldLocked } from "@/components/admin/settings/deploy-locked-hint" import { DeployLockedHint, useDeployFieldLocked } from "@/components/admin/settings/deploy-locked-hint"
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store" import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import { isPluginDeployLocked } from "@/lib/admin/deploy-runtime" import { isPluginDeployLocked } from "@/lib/admin/deploy-runtime"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { TechBrandSelectLabel } from "@/components/admin/settings/tech-brand-select-label" import { TechBrandSelectLabel } from "@/components/admin/settings/tech-brand-select-label"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
Collapsible, Collapsible,
@ -133,45 +136,51 @@ function PluginToggleCard({
const [open, setOpen] = useState(defaultOpen) const [open, setOpen] = useState(defaultOpen)
const hasConfig = Boolean(children) 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 ( return (
<Card className="gap-0 py-0"> <SettingsCard
<CardContent className="py-4"> title={
<div className="flex items-start gap-4"> <span className="flex items-center gap-2">
<div className="min-w-0 flex-1"> {name}
<div className="flex items-center gap-2"> {version ? <Badge variant="outline">v{version}</Badge> : null}
<p className="font-medium">{name}</p> </span>
{version ? <Badge variant="outline">v{version}</Badge> : null} }
</div> description={description}
<p className="mt-1 text-sm text-muted-foreground">{description}</p> action={<Switch checked={enabled} disabled={locked} onCheckedChange={onToggle} />}
hint={
hint || (locked && lockSection && lockField) ? (
<>
{hint} {hint}
{locked && lockSection && lockField ? ( {locked && lockSection && lockField ? (
<DeployLockedHint section={lockSection} field={lockField} /> <DeployLockedHint section={lockSection} field={lockField} />
) : null} ) : null}
</div> </>
<Switch checked={enabled} disabled={locked} onCheckedChange={onToggle} /> ) : null
</div> }
divider={false}
{action ? <div className="mt-3">{action}</div> : null} contentClassName="space-y-0 !mt-3 !pt-0"
>
{hasConfig && !action ? ( {body}
<Collapsible open={open} onOpenChange={setOpen} className="mt-3"> </SettingsCard>
<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>
) )
} }
@ -200,9 +209,12 @@ function NextcloudPluginCard() {
onToggle={(v) => setNextcloud({ enabled: v })} onToggle={(v) => setNextcloud({ enabled: v })}
defaultOpen={enabled} defaultOpen={enabled}
> >
<div className="grid gap-4 sm:grid-cols-2"> <SettingsGrid columns={2}>
<FieldGroup className="sm:col-span-2"> <SettingsField
<Label>URL de base</Label> label="URL de base"
className="sm:col-span-2"
hint={urlLocked ? <DeployLockedHint section="nextcloud" field="base_url" /> : undefined}
>
<Input <Input
className="h-9" className="h-9"
value={baseURL} value={baseURL}
@ -210,20 +222,22 @@ function NextcloudPluginCard() {
onChange={(e) => setNextcloud({ base_url: e.target.value })} onChange={(e) => setNextcloud({ base_url: e.target.value })}
placeholder="https://cloud.example.com" placeholder="https://cloud.example.com"
/> />
{urlLocked ? <DeployLockedHint section="nextcloud" field="base_url" /> : null} </SettingsField>
</FieldGroup> <SettingsField
<FieldGroup> label="Utilisateur admin"
<Label>Utilisateur admin</Label> hint={userLocked ? <DeployLockedHint section="nextcloud" field="admin_user" /> : undefined}
>
<Input <Input
className="h-9" className="h-9"
value={adminUser} value={adminUser}
disabled={userLocked} disabled={userLocked}
onChange={(e) => setNextcloud({ admin_user: e.target.value })} onChange={(e) => setNextcloud({ admin_user: e.target.value })}
/> />
{userLocked ? <DeployLockedHint section="nextcloud" field="admin_user" /> : null} </SettingsField>
</FieldGroup> <SettingsField
<FieldGroup> label="Mot de passe admin"
<Label>Mot de passe admin</Label> hint={passLocked ? <DeployLockedHint section="nextcloud" field="admin_password" /> : undefined}
>
<Input <Input
className="h-9" className="h-9"
type="password" type="password"
@ -232,39 +246,36 @@ function NextcloudPluginCard() {
onChange={(e) => setNextcloud({ admin_password: e.target.value })} onChange={(e) => setNextcloud({ admin_password: e.target.value })}
placeholder={passLocked ? "Défini via NC_ADMIN_PASSWORD" : undefined} placeholder={passLocked ? "Défini via NC_ADMIN_PASSWORD" : undefined}
/> />
{passLocked ? <DeployLockedHint section="nextcloud" field="admin_password" /> : null} </SettingsField>
</FieldGroup> </SettingsGrid>
</div>
<div className="space-y-3"> <div className="space-y-2">
<FieldGroup> <div>
<p className="text-sm font-medium">Modules exposés</p> <p className="text-sm font-medium">Modules exposés</p>
<p className="text-xs text-muted-foreground"> <p className="mt-0.5 text-xs text-muted-foreground">
Active ou masque chaque application Nextcloud dans la suite. Active ou masque chaque application Nextcloud dans la suite.
</p> </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>
<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> </PluginToggleCard>
) )
@ -309,8 +320,12 @@ function OnlyOfficePluginCard({
> >
<div className="space-y-4"> <div className="space-y-4">
{enabledLocked ? <DeployLockedHint section="onlyoffice" field="enabled" /> : null} {enabledLocked ? <DeployLockedHint section="onlyoffice" field="enabled" /> : null}
<FieldGroup> <SettingsField
<Label>URL du serveur de documents</Label> label="URL du serveur de documents"
hint={
urlLocked ? <DeployLockedHint section="onlyoffice" field="document_server_url" /> : undefined
}
>
<Input <Input
className="h-9" className="h-9"
value={docURL} value={docURL}
@ -318,10 +333,11 @@ function OnlyOfficePluginCard({
onChange={(e) => setOnlyoffice({ document_server_url: e.target.value })} onChange={(e) => setOnlyoffice({ document_server_url: e.target.value })}
placeholder="https://office.example.com" placeholder="https://office.example.com"
/> />
{urlLocked ? <DeployLockedHint section="onlyoffice" field="document_server_url" /> : null} </SettingsField>
</FieldGroup> <SettingsField
<FieldGroup> label="Secret JWT"
<Label>Secret JWT</Label> hint={jwtLocked ? <DeployLockedHint section="onlyoffice" field="jwt_secret" /> : undefined}
>
<Input <Input
className="h-9" className="h-9"
type="password" type="password"
@ -330,18 +346,18 @@ function OnlyOfficePluginCard({
onChange={(e) => setOnlyoffice({ jwt_secret: e.target.value })} onChange={(e) => setOnlyoffice({ jwt_secret: e.target.value })}
placeholder={jwtLocked ? "Défini via ONLYOFFICE_JWT_SECRET" : undefined} placeholder={jwtLocked ? "Défini via ONLYOFFICE_JWT_SECRET" : undefined}
/> />
{jwtLocked ? <DeployLockedHint section="onlyoffice" field="jwt_secret" /> : null} </SettingsField>
</FieldGroup> <SettingsField
<FieldGroup> label="En-tête JWT"
<Label>En-tête JWT</Label> hint={headerLocked ? <DeployLockedHint section="onlyoffice" field="jwt_header" /> : undefined}
>
<Input <Input
className="h-9" className="h-9"
value={onlyoffice.jwt_header} value={onlyoffice.jwt_header}
disabled={headerLocked} disabled={headerLocked}
onChange={(e) => setOnlyoffice({ jwt_header: e.target.value })} onChange={(e) => setOnlyoffice({ jwt_header: e.target.value })}
/> />
{headerLocked ? <DeployLockedHint section="onlyoffice" field="jwt_header" /> : null} </SettingsField>
</FieldGroup>
</div> </div>
</PluginToggleCard> </PluginToggleCard>
) )
@ -371,16 +387,15 @@ function RichtextPluginCard({
onToggle={onToggle} onToggle={onToggle}
defaultOpen={plugin.enabled} defaultOpen={plugin.enabled}
> >
<div className="grid min-w-0 gap-4"> <SettingsGrid columns={1}>
<FieldGroup className="min-w-0"> <SettingsField label="Mode de stockage">
<Label>Mode de stockage</Label>
<Select <Select
value={richtext.storage_mode} value={richtext.storage_mode}
onValueChange={(storage_mode: "sidecar" | "overwrite") => onValueChange={(storage_mode: "sidecar" | "overwrite") =>
setRichtext({ storage_mode }) setRichtext({ storage_mode })
} }
> >
<SelectTrigger className="w-full min-w-0"> <SelectTrigger className="h-9 w-full min-w-0">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -390,16 +405,15 @@ function RichtextPluginCard({
<SelectItem value="overwrite">Remplacer par .ultidoc.json</SelectItem> <SelectItem value="overwrite">Remplacer par .ultidoc.json</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</FieldGroup> </SettingsField>
<FieldGroup className="min-w-0"> <SettingsField label="Export miroir (optionnel)">
<Label>Export miroir (optionnel)</Label>
<Select <Select
value={richtext.export_mirror_format || "none"} value={richtext.export_mirror_format || "none"}
onValueChange={(v) => onValueChange={(v) =>
setRichtext({ export_mirror_format: v === "none" ? "" : "docx" }) setRichtext({ export_mirror_format: v === "none" ? "" : "docx" })
} }
> >
<SelectTrigger className="w-full min-w-0"> <SelectTrigger className="h-9 w-full min-w-0">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -409,16 +423,16 @@ function RichtextPluginCard({
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</FieldGroup> </SettingsField>
<FieldGroup className="min-w-0"> <SettingsField label="URL WebSocket Hocuspocus (public)">
<Label>URL WebSocket Hocuspocus (public)</Label>
<Input <Input
className="h-9"
value={richtext.hocuspocus_url} value={richtext.hocuspocus_url}
onChange={(e) => setRichtext({ hocuspocus_url: e.target.value })} onChange={(e) => setRichtext({ hocuspocus_url: e.target.value })}
placeholder="ws://localhost:1234" placeholder="ws://localhost:1234"
/> />
</FieldGroup> </SettingsField>
</div> </SettingsGrid>
</PluginToggleCard> </PluginToggleCard>
) )
} }
@ -469,10 +483,5 @@ function ServiceToggle({
checked: boolean checked: boolean
onChange: (v: boolean) => void onChange: (v: boolean) => void
}) { }) {
return ( return <SettingsToggleRow title={label} checked={checked} onCheckedChange={onChange} />
<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>
)
} }

View File

@ -10,7 +10,6 @@ import { useRevokeAdminPublicShare } from "@/lib/api/hooks/use-admin-mutations"
import type { AdminPublicShare } from "@/lib/api/admin-types" import type { AdminPublicShare } from "@/lib/api/admin-types"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { import {
Table, Table,
@ -72,22 +71,20 @@ export function PublicSharesSection() {
/> />
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} /> <SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
<div className="mb-4 flex flex-wrap items-end gap-3"> <AdminListControls
<div className="min-w-[240px] flex-1"> compact
<Label className="text-xs">Recherche</Label> leading={
<Input <Input
className="mt-1 h-9" className="h-9 min-w-40 flex-1 basis-40"
value={q} value={q}
aria-label="Recherche"
onChange={(e) => { onChange={(e) => {
setQ(e.target.value) setQ(e.target.value)
setPage(1) setPage(1)
}} }}
placeholder="Propriétaire, chemin, token, destinataire…" placeholder="Propriétaire, chemin, token…"
/> />
</div> }
</div>
<AdminListControls
page={page} page={page}
pageSize={resolvedPageSize} pageSize={resolvedPageSize}
total={total} total={total}
@ -106,7 +103,7 @@ export function PublicSharesSection() {
itemLabel="partage(s)" itemLabel="partage(s)"
/> />
<div className="overflow-x-auto rounded-lg border"> <div className="overflow-x-auto rounded-lg border border-mail-border">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>

View File

@ -2,13 +2,20 @@
import Link from "next/link" import Link from "next/link"
import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry" import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry"
import { AdminSettingsCard } from "@/components/admin/settings/admin-settings-card" import {
import { FieldGroup } from "@/components/admin/settings/field-group" SettingsCard,
SettingsField,
SettingsGrid,
} from "@/components/settings/settings-kit"
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form" import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store" import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import {
import { Label } from "@/components/ui/label" InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@/components/ui/input-group"
export function QuotasSection() { export function QuotasSection() {
const storageQuotas = useOrgSettingsStore((s) => s.storageQuotas) const storageQuotas = useOrgSettingsStore((s) => s.storageQuotas)
@ -23,120 +30,90 @@ export function QuotasSection() {
policySection={["storage_quotas", "usage_quotas"]} policySection={["storage_quotas", "usage_quotas"]}
> >
<AutomationTabMasonry columns={2}> <AutomationTabMasonry columns={2}>
<AdminSettingsCard <SettingsCard
title="Stockage par défaut" title="Stockage par défaut"
description="Mail, drive et photos. Les quotas individuels se gèrent depuis la fiche utilisateur." description="Mail, drive et photos. Les quotas individuels se gèrent depuis la fiche utilisateur."
> >
<div className="grid gap-4 sm:grid-cols-3"> <SettingsGrid columns={2} className="sm:grid-cols-3">
<QuotaInput <QuotaInput
label="Mail (Go)" label="Mail"
unit="Go"
value={storageQuotas.default_mail_gib} value={storageQuotas.default_mail_gib}
onChange={(v) => setStorageQuotas({ default_mail_gib: v })} onChange={(v) => setStorageQuotas({ default_mail_gib: v })}
/> />
<QuotaInput <QuotaInput
label="Drive (Go)" label="Drive"
unit="Go"
value={storageQuotas.default_drive_gib} value={storageQuotas.default_drive_gib}
onChange={(v) => setStorageQuotas({ default_drive_gib: v })} onChange={(v) => setStorageQuotas({ default_drive_gib: v })}
/> />
<QuotaInput <QuotaInput
label="Photos (Go)" label="Photos"
unit="Go"
value={storageQuotas.default_photos_gib} value={storageQuotas.default_photos_gib}
onChange={(v) => setStorageQuotas({ default_photos_gib: v })} onChange={(v) => setStorageQuotas({ default_photos_gib: v })}
/> />
</div> </SettingsGrid>
<FieldGroup> <SettingsNumberField
<Label>Seuil d&apos;alerte (%)</Label> label="Seuil d'alerte"
<Input unit="%"
className="h-9 max-w-xs" min={50}
type="number" max={100}
min={50} fallback={90}
max={100} className="max-w-xs"
value={storageQuotas.warn_threshold_pct} value={storageQuotas.warn_threshold_pct}
onChange={(e) => onChange={(v) => setStorageQuotas({ warn_threshold_pct: v })}
setStorageQuotas({ warn_threshold_pct: Number(e.target.value) || 90 }) />
}
/>
</FieldGroup>
<Button asChild variant="outline" size="sm"> <Button asChild variant="outline" size="sm">
<Link href="/admin/settings/users">Gérer les quotas par utilisateur</Link> <Link href="/admin/settings/users">Gérer les quotas par utilisateur</Link>
</Button> </Button>
</AdminSettingsCard> </SettingsCard>
<AdminSettingsCard <SettingsCard
title="Intelligence artificielle" title="Intelligence artificielle"
description="Requêtes LLM et tokens consommés par mois." description="Requêtes LLM et tokens consommés par mois."
> >
<div className="grid gap-4 sm:grid-cols-2"> <SettingsGrid columns={2}>
<FieldGroup> <SettingsNumberField
<Label>Requêtes LLM / jour</Label> label="Requêtes LLM"
<Input unit="/ jour"
className="h-9" value={usageQuotas.llm_requests_per_day}
type="number" onChange={(v) => setUsageQuotas({ llm_requests_per_day: v })}
min={0} />
value={usageQuotas.llm_requests_per_day} <SettingsNumberField
onChange={(e) => label="Tokens LLM"
setUsageQuotas({ llm_requests_per_day: Number(e.target.value) || 0 }) unit="/ mois"
} value={usageQuotas.llm_tokens_per_month}
/> onChange={(v) => setUsageQuotas({ llm_tokens_per_month: v })}
</FieldGroup> />
<FieldGroup> </SettingsGrid>
<Label>Tokens LLM / mois</Label> </SettingsCard>
<Input
className="h-9"
type="number"
min={0}
value={usageQuotas.llm_tokens_per_month}
onChange={(e) =>
setUsageQuotas({ llm_tokens_per_month: Number(e.target.value) || 0 })
}
/>
</FieldGroup>
</div>
</AdminSettingsCard>
<AdminSettingsCard <SettingsCard
title="Recherche et automatisations" title="Recherche et automatisations"
description="Recherche web, tokens API et webhooks par utilisateur." description="Recherche web, tokens API et webhooks par utilisateur."
> >
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-3"> <SettingsGrid columns={2} className="lg:grid-cols-1 xl:grid-cols-3">
<FieldGroup> <SettingsNumberField
<Label>Recherches web / jour</Label> label="Recherches web"
<Input unit="/ jour"
className="h-9" value={usageQuotas.search_requests_per_day}
type="number" onChange={(v) => setUsageQuotas({ search_requests_per_day: v })}
min={0} />
value={usageQuotas.search_requests_per_day} <SettingsNumberField
onChange={(e) => label="Tokens API"
setUsageQuotas({ search_requests_per_day: Number(e.target.value) || 0 }) unit="/ utilisateur"
} value={usageQuotas.max_api_tokens_per_user}
/> onChange={(v) => setUsageQuotas({ max_api_tokens_per_user: v })}
</FieldGroup> />
<FieldGroup> <SettingsNumberField
<Label>Tokens API max / utilisateur</Label> label="Webhooks"
<Input unit="/ utilisateur"
className="h-9" value={usageQuotas.max_webhooks_per_user}
type="number" onChange={(v) => setUsageQuotas({ max_webhooks_per_user: v })}
min={0} />
value={usageQuotas.max_api_tokens_per_user} </SettingsGrid>
onChange={(e) => </SettingsCard>
setUsageQuotas({ max_api_tokens_per_user: Number(e.target.value) || 0 })
}
/>
</FieldGroup>
<FieldGroup>
<Label>Webhooks max / utilisateur</Label>
<Input
className="h-9"
type="number"
min={0}
value={usageQuotas.max_webhooks_per_user}
onChange={(e) =>
setUsageQuotas({ max_webhooks_per_user: Number(e.target.value) || 0 })
}
/>
</FieldGroup>
</div>
</AdminSettingsCard>
</AutomationTabMasonry> </AutomationTabMasonry>
</OrgSettingsSection> </OrgSettingsSection>
) )
@ -144,24 +121,62 @@ export function QuotasSection() {
function QuotaInput({ function QuotaInput({
label, label,
unit,
value, value,
onChange, onChange,
}: { }: {
label: string label: string
unit: string
value: number value: number
onChange: (v: number) => void onChange: (v: number) => void
}) { }) {
return ( return (
<FieldGroup> <SettingsNumberField
<Label>{label}</Label> label={label}
<Input unit={unit}
className="h-9" step={0.5}
type="number" value={value}
min={0} onChange={onChange}
step={0.5} />
value={value} )
onChange={(e) => onChange(Number(e.target.value) || 0)} }
/>
</FieldGroup> function SettingsNumberField({
label,
unit,
value,
onChange,
min = 0,
max,
step,
fallback = 0,
className,
}: {
label: string
unit: string
value: number
onChange: (v: number) => void
min?: number
max?: number
step?: number
fallback?: number
className?: string
}) {
return (
<SettingsField label={label}>
<InputGroup className={className}>
<InputGroupInput
type="number"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value) || fallback)}
/>
<InputGroupAddon align="inline-end">
<InputGroupText>{unit}</InputGroupText>
</InputGroupAddon>
</InputGroup>
</SettingsField>
) )
} }

View File

@ -1,15 +1,17 @@
"use client" "use client"
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form" import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
import { AdminSettingsCard } from "@/components/admin/settings/admin-settings-card" import {
SettingsCard,
SettingsField,
SettingsGrid,
SettingsToggleRow,
} from "@/components/settings/settings-kit"
import { DeployLockedHint, useDeployFieldLocked } from "@/components/admin/settings/deploy-locked-hint" import { DeployLockedHint, useDeployFieldLocked } from "@/components/admin/settings/deploy-locked-hint"
import { FieldGroup } from "@/components/admin/settings/field-group"
import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry" import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry"
import { WebSearchProvidersEditor } from "@/components/web-search/web-search-providers-editor" import { WebSearchProvidersEditor } from "@/components/web-search/web-search-providers-editor"
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store" import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { import {
Select, Select,
SelectContent, SelectContent,
@ -50,7 +52,7 @@ export function SearchSection() {
policySection="search" policySection="search"
> >
<AutomationTabMasonry columns={2}> <AutomationTabMasonry columns={2}>
<AdminSettingsCard <SettingsCard
title="Recherche web" title="Recherche web"
description={ description={
<> <>
@ -60,18 +62,12 @@ export function SearchSection() {
</> </>
} }
> >
<label className="flex items-center justify-between gap-4 rounded-lg border p-3"> <SettingsToggleRow
<FieldGroup> title="Imposer la config organisation"
<p className="text-sm font-medium">Imposer la config organisation</p> description="Sinon, chaque utilisateur configure ses propres fournisseurs."
<p className="text-xs text-muted-foreground"> checked={search.enforce_org_search}
Sinon, chaque utilisateur configure ses propres fournisseurs. onCheckedChange={(enforce_org_search) => setSearch({ enforce_org_search })}
</p> />
</FieldGroup>
<Switch
checked={search.enforce_org_search}
onCheckedChange={(enforce_org_search) => setSearch({ enforce_org_search })}
/>
</label>
<WebSearchProvidersEditor <WebSearchProvidersEditor
value={webSearch} value={webSearch}
@ -79,15 +75,14 @@ export function SearchSection() {
providerSecrets={providerSecrets} providerSecrets={providerSecrets}
columns={1} columns={1}
/> />
</AdminSettingsCard> </SettingsCard>
<AdminSettingsCard <SettingsCard
title="Recherche suite" title="Recherche suite"
description="Moteur d'indexation pour la recherche globale (variables SEARCH_ENGINE côté serveur)." description="Moteur d'indexation pour la recherche globale (variables SEARCH_ENGINE côté serveur)."
hint={engineLocked ? <DeployLockedHint section="search" field="suite_engine" /> : null} hint={engineLocked ? <DeployLockedHint section="search" field="suite_engine" /> : null}
> >
<FieldGroup> <SettingsField label="Moteur">
<Label>Moteur</Label>
<Select <Select
value={suiteEngine} value={suiteEngine}
disabled={engineLocked} disabled={engineLocked}
@ -120,24 +115,33 @@ export function SearchSection() {
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</FieldGroup> </SettingsField>
{suiteEngine === "meilisearch" ? ( {suiteEngine === "meilisearch" ? (
<div className="grid min-w-0 gap-4"> <SettingsGrid columns={1}>
<FieldGroup> <SettingsField
<Label>URL Meilisearch</Label> label="URL Meilisearch"
hint={
meiliURLLocked ? (
<DeployLockedHint section="search" field="meilisearch_url" />
) : undefined
}
>
<Input <Input
className="h-9" className="h-9"
value={meiliURL} value={meiliURL}
disabled={meiliURLLocked} disabled={meiliURLLocked}
onChange={(e) => setSearch({ meilisearch_url: e.target.value })} onChange={(e) => setSearch({ meilisearch_url: e.target.value })}
/> />
{meiliURLLocked ? ( </SettingsField>
<DeployLockedHint section="search" field="meilisearch_url" /> <SettingsField
) : null} label="Clé API"
</FieldGroup> hint={
<FieldGroup> meiliKeyLocked ? (
<Label>Clé API</Label> <DeployLockedHint section="search" field="meilisearch_api_key" />
) : undefined
}
>
<Input <Input
className="h-9" className="h-9"
type="password" type="password"
@ -146,29 +150,35 @@ export function SearchSection() {
onChange={(e) => setSearch({ meilisearch_api_key: e.target.value })} onChange={(e) => setSearch({ meilisearch_api_key: e.target.value })}
placeholder={meiliKeyLocked ? "Défini via MEILISEARCH_API_KEY" : undefined} placeholder={meiliKeyLocked ? "Défini via MEILISEARCH_API_KEY" : undefined}
/> />
{meiliKeyLocked ? ( </SettingsField>
<DeployLockedHint section="search" field="meilisearch_api_key" /> </SettingsGrid>
) : null}
</FieldGroup>
</div>
) : null} ) : null}
{suiteEngine === "typesense" ? ( {suiteEngine === "typesense" ? (
<div className="grid min-w-0 gap-4"> <SettingsGrid columns={1}>
<FieldGroup> <SettingsField
<Label>URL Typesense</Label> label="URL Typesense"
hint={
typesenseURLLocked ? (
<DeployLockedHint section="search" field="typesense_url" />
) : undefined
}
>
<Input <Input
className="h-9" className="h-9"
value={typesenseURL} value={typesenseURL}
disabled={typesenseURLLocked} disabled={typesenseURLLocked}
onChange={(e) => setSearch({ typesense_url: e.target.value })} onChange={(e) => setSearch({ typesense_url: e.target.value })}
/> />
{typesenseURLLocked ? ( </SettingsField>
<DeployLockedHint section="search" field="typesense_url" /> <SettingsField
) : null} label="Clé API"
</FieldGroup> hint={
<FieldGroup> typesenseKeyLocked ? (
<Label>Clé API</Label> <DeployLockedHint section="search" field="typesense_api_key" />
) : undefined
}
>
<Input <Input
className="h-9" className="h-9"
type="password" type="password"
@ -177,13 +187,10 @@ export function SearchSection() {
onChange={(e) => setSearch({ typesense_api_key: e.target.value })} onChange={(e) => setSearch({ typesense_api_key: e.target.value })}
placeholder={typesenseKeyLocked ? "Défini via TYPESENSE_API_KEY" : undefined} placeholder={typesenseKeyLocked ? "Défini via TYPESENSE_API_KEY" : undefined}
/> />
{typesenseKeyLocked ? ( </SettingsField>
<DeployLockedHint section="search" field="typesense_api_key" /> </SettingsGrid>
) : null}
</FieldGroup>
</div>
) : null} ) : null}
</AdminSettingsCard> </SettingsCard>
</AutomationTabMasonry> </AutomationTabMasonry>
</OrgSettingsSection> </OrgSettingsSection>
) )

View File

@ -1,12 +1,16 @@
"use client" "use client"
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form" import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
import {
SettingsCard,
SettingsCheckboxRow,
SettingsField,
SettingsGrid,
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 { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { Checkbox } from "@/components/ui/checkbox"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
const METHODS = [ const METHODS = [
{ id: "totp" as const, label: "Application TOTP" }, { id: "totp" as const, label: "Application TOTP" },
@ -30,78 +34,62 @@ export function SecuritySection() {
description="Politiques d'authentification à deux facteurs pour l'organisation." description="Politiques d'authentification à deux facteurs pour l'organisation."
policySection="two_factor" policySection="two_factor"
> >
<Card className="gap-3 py-4"> <AutomationTabMasonry columns={2}>
<CardHeader className="pb-0"> <SettingsCard title="Exigences" description="Quand le second facteur est requis.">
<CardTitle className="text-sm font-medium">Exigences</CardTitle> <SettingsToggleRow
</CardHeader> title="2FA obligatoire pour tous"
<CardContent className="space-y-3"> description="Chaque utilisateur doit configurer un second facteur."
<label className="flex items-center justify-between gap-4 rounded-lg border p-3"> checked={twoFactor.required_for_all}
<div> onCheckedChange={(required_for_all) => setTwoFactor({ required_for_all })}
<p className="text-sm font-medium">2FA obligatoire pour tous</p> />
<p className="text-xs text-muted-foreground"> <SettingsToggleRow
Chaque utilisateur doit configurer un second facteur. title="2FA obligatoire pour les administrateurs"
</p> checked={twoFactor.required_for_admins}
</div> onCheckedChange={(required_for_admins) => setTwoFactor({ required_for_admins })}
<Switch />
checked={twoFactor.required_for_all} </SettingsCard>
onCheckedChange={(required_for_all) => setTwoFactor({ required_for_all })}
/>
</label>
<label className="flex items-center justify-between gap-4 rounded-lg border p-3">
<div>
<p className="text-sm font-medium">2FA obligatoire pour les administrateurs</p>
</div>
<Switch
checked={twoFactor.required_for_admins}
onCheckedChange={(required_for_admins) => setTwoFactor({ required_for_admins })}
/>
</label>
</CardContent>
</Card>
<Card className="gap-3 py-4"> <SettingsCard
<CardHeader className="pb-0"> title="Méthodes autorisées"
<CardTitle className="text-sm font-medium">Méthodes autorisées</CardTitle> description="Seconds facteurs proposés aux utilisateurs."
</CardHeader> >
<CardContent className="space-y-2">
{METHODS.map((method) => ( {METHODS.map((method) => (
<label key={method.id} className="flex items-center gap-2 text-sm"> <SettingsCheckboxRow
<Checkbox key={method.id}
checked={twoFactor.allowed_methods.includes(method.id)} title={method.label}
onCheckedChange={(v) => toggleMethod(method.id, v === true)} checked={twoFactor.allowed_methods.includes(method.id)}
/> onCheckedChange={(v) => toggleMethod(method.id, v)}
{method.label} />
</label>
))} ))}
</CardContent> </SettingsCard>
</Card>
<div className="grid gap-4 sm:grid-cols-2"> <SettingsCard title="Délais" description="Périodes de tolérance et de mémorisation.">
<div> <SettingsGrid columns={2}>
<Label>Période de grâce (jours)</Label> <SettingsField label="Période de grâce (jours)">
<Input <Input
className="mt-1 h-9" className="h-9"
type="number" type="number"
min={0} min={0}
value={twoFactor.grace_period_days} value={twoFactor.grace_period_days}
onChange={(e) => onChange={(e) =>
setTwoFactor({ grace_period_days: Number(e.target.value) || 0 }) setTwoFactor({ grace_period_days: Number(e.target.value) || 0 })
} }
/> />
</div> </SettingsField>
<div> <SettingsField label="Mémoriser l'appareil (jours)">
<Label>Mémoriser l&apos;appareil (jours)</Label> <Input
<Input className="h-9"
className="mt-1 h-9" type="number"
type="number" min={0}
min={0} value={twoFactor.remember_device_days}
value={twoFactor.remember_device_days} onChange={(e) =>
onChange={(e) => setTwoFactor({ remember_device_days: Number(e.target.value) || 0 })
setTwoFactor({ remember_device_days: Number(e.target.value) || 0 }) }
} />
/> </SettingsField>
</div> </SettingsGrid>
</div> </SettingsCard>
</AutomationTabMasonry>
</OrgSettingsSection> </OrgSettingsSection>
) )
} }

View File

@ -1,9 +1,7 @@
"use client" "use client"
import Link from "next/link" import Link from "next/link"
import { Label } from "@/components/ui/label" import { SettingsCard, SettingsToggleRow } from "@/components/settings/settings-kit"
import { Switch } from "@/components/ui/switch"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { import {
toggleUltiAiToolGroup, toggleUltiAiToolGroup,
ULTIAI_TOOL_GROUPS, ULTIAI_TOOL_GROUPS,
@ -21,40 +19,30 @@ export function UltiAiToolsCard({
webSearchSettingsHref = "/mail/settings/automation", webSearchSettingsHref = "/mail/settings/automation",
}: UltiAiToolsCardProps) { }: UltiAiToolsCardProps) {
return ( return (
<Card> <SettingsCard
<CardHeader className="pb-3"> title="Tools UltiAI"
<CardTitle className="text-sm font-medium">Tools UltiAI</CardTitle> description={
<CardDescription> <>
Groupes d&apos;outils MCP exposés à l&apos;assistant. La recherche web supporte Brave, Groupes d&apos;outils MCP exposés à l&apos;assistant. La recherche web supporte Brave,
Bing, DuckDuckGo, SearXNG et API JSON config dans{" "} Bing, DuckDuckGo, SearXNG et API JSON config dans{" "}
<Link href={webSearchSettingsHref} className="underline underline-offset-2"> <Link href={webSearchSettingsHref} className="underline underline-offset-2">
Automatisations Recherche Automatisations Recherche
</Link>{" "} </Link>{" "}
(utilisateur) ou Administration Moteur de recherche (organisation). (utilisateur) ou Administration Moteur de recherche (organisation).
</CardDescription> </>
</CardHeader> }
<CardContent className="space-y-2"> >
{ULTIAI_TOOL_GROUPS.map((group) => { {ULTIAI_TOOL_GROUPS.map((group) => (
const checked = enabledTools.includes(group.id) <SettingsToggleRow
return ( key={group.id}
<label title={group.label}
key={group.id} description={group.description}
className="flex items-start justify-between gap-4 rounded-lg border p-3" checked={enabledTools.includes(group.id)}
> onCheckedChange={(enabled) =>
<div className="min-w-0"> onChange(toggleUltiAiToolGroup(enabledTools, group.id, enabled))
<Label className="text-sm font-medium">{group.label}</Label> }
<p className="mt-0.5 text-xs text-muted-foreground">{group.description}</p> />
</div> ))}
<Switch </SettingsCard>
checked={checked}
onCheckedChange={(enabled) =>
onChange(toggleUltiAiToolGroup(enabledTools, group.id, enabled))
}
/>
</label>
)
})}
</CardContent>
</Card>
) )
} }

View File

@ -2,6 +2,13 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form" import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
import {
SettingsCard,
SettingsField,
SettingsGrid,
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 { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import { import {
MEET_EMAIL_RECIPIENTS_LABELS, MEET_EMAIL_RECIPIENTS_LABELS,
@ -10,9 +17,8 @@ import {
MEET_TRANSCRIPTION_MODE_LABELS, MEET_TRANSCRIPTION_MODE_LABELS,
type MeetOrgPolicySettings, type MeetOrgPolicySettings,
} from "@/lib/meet/meet-settings-types" } from "@/lib/meet/meet-settings-types"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { import {
Select, Select,
@ -21,7 +27,6 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { TechBrandSelectLabel } from "@/components/admin/settings/tech-brand-select-label" import { TechBrandSelectLabel } from "@/components/admin/settings/tech-brand-select-label"
export function UltimeetSection() { export function UltimeetSection() {
@ -51,302 +56,263 @@ export function UltimeetSection() {
policySection="meet" policySection="meet"
beforeSave={() => setMeet(draft)} beforeSave={() => setMeet(draft)}
> >
<Card> <AutomationTabMasonry columns={2}>
<CardHeader className="pb-3"> <SettingsCard
<CardTitle className="text-sm font-medium">Infrastructure</CardTitle> title="Infrastructure"
<CardDescription> description={
Jitsi {effective?.enabled ? "actif" : "inactif"} <>
{effective?.public_url ? `${effective.public_url}` : ""} Jitsi {effective?.enabled ? "actif" : "inactif"}
</CardDescription> {effective?.public_url ? `${effective.public_url}` : ""}
</CardHeader> </>
</Card> }
/>
<div className="space-y-6 rounded-lg border p-4"> <SettingsCard
<label className="flex items-center justify-between gap-4"> title="Transcription"
<div> description="Active les sous-titres Jitsi (live) ou le pipeline différé selon le mode."
<p className="text-sm font-medium">Transcription activée</p> action={
<p className="text-xs text-muted-foreground"> <Switch
Active les sous-titres Jitsi (live) ou le pipeline différé selon le mode. checked={draft.transcription_enabled}
</p> onCheckedChange={(v) => patch({ transcription_enabled: v })}
</div> />
<Switch }
checked={draft.transcription_enabled} >
onCheckedChange={(v) => patch({ transcription_enabled: v })} {draft.transcription_enabled ? (
/> <>
</label> <SettingsGrid columns={2}>
<SettingsField label="Mode">
{draft.transcription_enabled ? (
<>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Mode</Label>
<Select
value={draft.transcription_mode}
onValueChange={(v) =>
patch({ transcription_mode: v as MeetOrgPolicySettings["transcription_mode"] })
}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(MEET_TRANSCRIPTION_MODE_LABELS).map(([id, label]) => (
<SelectItem key={id} value={id}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Moteur</Label>
<Select
value={draft.transcription_engine}
onValueChange={(v) =>
patch({
transcription_engine: v as MeetOrgPolicySettings["transcription_engine"],
})
}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(MEET_TRANSCRIPTION_ENGINE_LABELS).map(([id, label]) => (
<SelectItem key={id} value={id}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<label className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium">Démarrage automatique</p>
<p className="text-xs text-muted-foreground">
Lance la transcription dès l&apos;ouverture de la salle (sinon via bouton
Sous-titres pour les modérateurs).
</p>
</div>
<Switch
checked={draft.auto_start_transcription}
onCheckedChange={(v) => patch({ auto_start_transcription: v })}
/>
</label>
{draft.transcription_engine === "faster_whisper_local" ? (
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>URL Skynet (interne)</Label>
<Input
value={draft.skynet_url}
onChange={(e) => patch({ skynet_url: e.target.value })}
placeholder="http://skynet:8000"
/>
</div>
<div className="space-y-2">
<Label>Modèle Whisper</Label>
<Input
value={draft.whisper_model}
onChange={(e) => patch({ whisper_model: e.target.value })}
placeholder="tiny, base, small…"
/>
</div>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2 sm:col-span-2">
<Label>Fournisseur API</Label>
<Select <Select
value={draft.external_api_provider} value={draft.transcription_mode}
onValueChange={(v) => onValueChange={(v) =>
patch({ patch({ transcription_mode: v as MeetOrgPolicySettings["transcription_mode"] })
external_api_provider:
v as MeetOrgPolicySettings["external_api_provider"],
})
} }
> >
<SelectTrigger className="h-9"> <SelectTrigger className="h-9">
<SelectValue> <SelectValue />
<TechBrandSelectLabel brand={draft.external_api_provider}>
{MEET_EXTERNAL_API_PROVIDER_LABELS[draft.external_api_provider]}
</TechBrandSelectLabel>
</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{Object.entries(MEET_EXTERNAL_API_PROVIDER_LABELS).map(([id, label]) => ( {Object.entries(MEET_TRANSCRIPTION_MODE_LABELS).map(([id, label]) => (
<SelectItem key={id} value={id}> <SelectItem key={id} value={id}>
<TechBrandSelectLabel brand={id}>{label}</TechBrandSelectLabel> {label}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </SettingsField>
<div className="space-y-2 sm:col-span-2">
<Label>URL API</Label>
<Input
value={draft.external_api_url}
onChange={(e) => patch({ external_api_url: e.target.value })}
placeholder="https://api.openai.com/v1/audio/transcriptions"
/>
</div>
<div className="space-y-2 sm:col-span-2">
<Label>Clé API</Label>
<Input
type="password"
value={draft.external_api_key}
onChange={(e) => patch({ external_api_key: e.target.value })}
placeholder="Laisser vide pour conserver la clé enregistrée"
autoComplete="off"
/>
</div>
</div>
)}
</>
) : null}
</div>
{draft.transcription_enabled ? ( <SettingsField label="Moteur">
<div className="space-y-4 rounded-lg border p-4"> <Select
<div> value={draft.transcription_engine}
<p className="text-sm font-medium">Après la réunion</p> onValueChange={(v) =>
<p className="text-xs text-muted-foreground"> patch({
Actions exécutées quand le transcript est reçu par le backend. transcription_engine: v as MeetOrgPolicySettings["transcription_engine"],
</p> })
</div> }
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(MEET_TRANSCRIPTION_ENGINE_LABELS).map(([id, label]) => (
<SelectItem key={id} value={id}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</SettingsField>
</SettingsGrid>
<label className="flex items-center justify-between gap-4"> <SettingsToggleRow
<div> title="Démarrage automatique"
<p className="text-sm font-medium">Enregistrer dans UltiDrive</p> description="Lance la transcription dès l'ouverture de la salle (sinon via bouton Sous-titres pour les modérateurs)."
</div> checked={draft.auto_start_transcription}
<Switch onCheckedChange={(v) => patch({ auto_start_transcription: v })}
/>
{draft.transcription_engine === "faster_whisper_local" ? (
<SettingsGrid columns={2}>
<SettingsField label="URL Skynet (interne)">
<Input
className="h-9"
value={draft.skynet_url}
onChange={(e) => patch({ skynet_url: e.target.value })}
placeholder="http://skynet:8000"
/>
</SettingsField>
<SettingsField label="Modèle Whisper">
<Input
className="h-9"
value={draft.whisper_model}
onChange={(e) => patch({ whisper_model: e.target.value })}
placeholder="tiny, base, small…"
/>
</SettingsField>
</SettingsGrid>
) : (
<SettingsGrid columns={1}>
<SettingsField label="Fournisseur API">
<Select
value={draft.external_api_provider}
onValueChange={(v) =>
patch({
external_api_provider:
v as MeetOrgPolicySettings["external_api_provider"],
})
}
>
<SelectTrigger className="h-9">
<SelectValue>
<TechBrandSelectLabel brand={draft.external_api_provider}>
{MEET_EXTERNAL_API_PROVIDER_LABELS[draft.external_api_provider]}
</TechBrandSelectLabel>
</SelectValue>
</SelectTrigger>
<SelectContent>
{Object.entries(MEET_EXTERNAL_API_PROVIDER_LABELS).map(([id, label]) => (
<SelectItem key={id} value={id}>
<TechBrandSelectLabel brand={id}>{label}</TechBrandSelectLabel>
</SelectItem>
))}
</SelectContent>
</Select>
</SettingsField>
<SettingsField label="URL API">
<Input
className="h-9"
value={draft.external_api_url}
onChange={(e) => patch({ external_api_url: e.target.value })}
placeholder="https://api.openai.com/v1/audio/transcriptions"
/>
</SettingsField>
<SettingsField label="Clé API">
<Input
className="h-9"
type="password"
value={draft.external_api_key}
onChange={(e) => patch({ external_api_key: e.target.value })}
placeholder="Laisser vide pour conserver la clé enregistrée"
autoComplete="off"
/>
</SettingsField>
</SettingsGrid>
)}
</>
) : null}
</SettingsCard>
{draft.transcription_enabled ? (
<SettingsCard
title="Après la réunion"
description="Actions exécutées quand le transcript est reçu par le backend."
>
<SettingsToggleRow
title="Enregistrer dans UltiDrive"
checked={draft.post_actions.drive_enabled} checked={draft.post_actions.drive_enabled}
onCheckedChange={(v) => patchPost({ drive_enabled: v })} onCheckedChange={(v) => patchPost({ drive_enabled: v })}
/> />
</label> {draft.post_actions.drive_enabled ? (
{draft.post_actions.drive_enabled ? ( <SettingsField label="Dossier Drive">
<div className="space-y-2"> <Input
<Label>Dossier Drive</Label> className="h-9"
<Input value={draft.post_actions.drive_folder_path}
value={draft.post_actions.drive_folder_path} onChange={(e) => patchPost({ drive_folder_path: e.target.value })}
onChange={(e) => patchPost({ drive_folder_path: e.target.value })} placeholder="/UltiMeet/Transcripts"
placeholder="/UltiMeet/Transcripts" />
/> </SettingsField>
</div> ) : null}
) : null}
<label className="flex items-center justify-between gap-4"> <SettingsToggleRow
<div> title="Envoyer par mail"
<p className="text-sm font-medium">Envoyer par mail</p> description="Utilise le SMTP organisationnel (réglages Mailing)."
<p className="text-xs text-muted-foreground">
Utilise le SMTP organisationnel (réglages Mailing).
</p>
</div>
<Switch
checked={draft.post_actions.email_enabled} checked={draft.post_actions.email_enabled}
onCheckedChange={(v) => patchPost({ email_enabled: v })} onCheckedChange={(v) => patchPost({ email_enabled: v })}
/> />
</label> {draft.post_actions.email_enabled ? (
{draft.post_actions.email_enabled ? ( <SettingsGrid columns={1}>
<div className="grid gap-4 sm:grid-cols-2"> <SettingsField label="Destinataires">
<div className="space-y-2"> <Select
<Label>Destinataires</Label> value={draft.post_actions.email_recipients}
<Select onValueChange={(v) =>
value={draft.post_actions.email_recipients} patchPost({
onValueChange={(v) => email_recipients: v as MeetOrgPolicySettings["post_actions"]["email_recipients"],
patchPost({ })
email_recipients: v as MeetOrgPolicySettings["post_actions"]["email_recipients"], }
}) >
} <SelectTrigger className="h-9">
> <SelectValue />
<SelectTrigger className="h-9"> </SelectTrigger>
<SelectValue /> <SelectContent>
</SelectTrigger> {Object.entries(MEET_EMAIL_RECIPIENTS_LABELS).map(([id, label]) => (
<SelectContent> <SelectItem key={id} value={id}>
{Object.entries(MEET_EMAIL_RECIPIENTS_LABELS).map(([id, label]) => ( {label}
<SelectItem key={id} value={id}> </SelectItem>
{label} ))}
</SelectItem> </SelectContent>
))} </Select>
</SelectContent> </SettingsField>
</Select> {draft.post_actions.email_recipients === "custom" ? (
</div> <SettingsField label="Adresses (séparées par des virgules)">
{draft.post_actions.email_recipients === "custom" ? ( <Input
<div className="space-y-2 sm:col-span-2"> className="h-9"
<Label>Adresses (séparées par des virgules)</Label> value={draft.post_actions.email_custom_addresses}
<Input onChange={(e) => patchPost({ email_custom_addresses: e.target.value })}
value={draft.post_actions.email_custom_addresses} />
onChange={(e) => patchPost({ email_custom_addresses: e.target.value })} </SettingsField>
/> ) : null}
</div> </SettingsGrid>
) : null} ) : null}
</div>
) : null}
<label className="flex items-center justify-between gap-4"> <SettingsToggleRow
<div> title="Traitement LLM"
<p className="text-sm font-medium">Traitement LLM</p> description="Résume ou transforme le transcript via un fournisseur LLM organisationnel."
<p className="text-xs text-muted-foreground">
Résume ou transforme le transcript via un fournisseur LLM organisationnel.
</p>
</div>
<Switch
checked={draft.post_actions.llm_enabled} checked={draft.post_actions.llm_enabled}
onCheckedChange={(v) => patchPost({ llm_enabled: v })} onCheckedChange={(v) => patchPost({ llm_enabled: v })}
/> />
</label> {draft.post_actions.llm_enabled ? (
{draft.post_actions.llm_enabled ? ( <div className="space-y-4">
<div className="space-y-4"> <SettingsField label="Fournisseur LLM">
<div className="space-y-2"> <Select
<Label>Fournisseur LLM</Label> value={draft.post_actions.llm_provider_id || "__default__"}
<Select onValueChange={(v) =>
value={draft.post_actions.llm_provider_id || "__default__"} patchPost({ llm_provider_id: v === "__default__" ? "" : v })
onValueChange={(v) => }
patchPost({ llm_provider_id: v === "__default__" ? "" : v }) >
} <SelectTrigger className="h-9">
> <SelectValue placeholder="Par défaut (organisation)" />
<SelectTrigger className="h-9"> </SelectTrigger>
<SelectValue placeholder="Par défaut (organisation)" /> <SelectContent>
</SelectTrigger> <SelectItem value="__default__">Par défaut (organisation)</SelectItem>
<SelectContent> {llmProviders.map((p) => (
<SelectItem value="__default__">Par défaut (organisation)</SelectItem> <SelectItem key={p.id} value={p.id}>
{llmProviders.map((p) => ( {p.name || p.id}
<SelectItem key={p.id} value={p.id}> </SelectItem>
{p.name || p.id} ))}
</SelectItem> </SelectContent>
))} </Select>
</SelectContent> </SettingsField>
</Select> <SettingsField label="Prompt">
</div> <Textarea
<div className="space-y-2"> value={draft.post_actions.llm_prompt}
<Label>Prompt</Label> onChange={(e) => patchPost({ llm_prompt: e.target.value })}
<Textarea rows={4}
value={draft.post_actions.llm_prompt} />
onChange={(e) => patchPost({ llm_prompt: e.target.value })} </SettingsField>
rows={4} <SettingsToggleRow
/> title="Envoyer le résultat LLM par mail"
</div>
<label className="flex items-center justify-between gap-4">
<span className="text-sm">Envoyer le résultat LLM par mail</span>
<Switch
checked={draft.post_actions.llm_then_email} checked={draft.post_actions.llm_then_email}
onCheckedChange={(v) => patchPost({ llm_then_email: v })} onCheckedChange={(v) => patchPost({ llm_then_email: v })}
/> />
</label> <SettingsToggleRow
<label className="flex items-center justify-between gap-4"> title="Enregistrer le résultat LLM dans Drive"
<span className="text-sm">Enregistrer le résultat LLM dans Drive</span>
<Switch
checked={draft.post_actions.llm_then_drive} checked={draft.post_actions.llm_then_drive}
onCheckedChange={(v) => patchPost({ llm_then_drive: v })} onCheckedChange={(v) => patchPost({ llm_then_drive: v })}
/> />
</label> </div>
</div> ) : null}
) : null} </SettingsCard>
</div> ) : null}
) : null} </AutomationTabMasonry>
</OrgSettingsSection> </OrgSettingsSection>
) )
} }

View File

@ -73,6 +73,7 @@ export function PublicOfficeEditor({
password: password ?? "", password: password ?? "",
guest_id: guest.guestId, guest_id: guest.guestId,
guest_name: guest.guestName, guest_name: guest.guestName,
display_name: fileName,
}), }),
} }
) )
@ -103,7 +104,7 @@ export function PublicOfficeEditor({
return () => { return () => {
cancelled = true cancelled = true
} }
}, [token, filePath, password, mode]) }, [token, filePath, password, mode, fileName])
const handleEditorError = useCallback((message: string) => { const handleEditorError = useCallback((message: string) => {
setError(message) setError(message)

View File

@ -387,7 +387,6 @@ function PermissionSelect({
}) { }) {
const options = PERMISSION_OPTIONS.filter((o) => isFolder || !o.folderOnly) const options = PERMISSION_OPTIONS.filter((o) => isFolder || !o.folderOnly)
const selected = options.find((o) => o.id === value) ?? options[0] const selected = options.find((o) => o.id === value) ?? options[0]
const SelectedIcon = selected.icon
return ( return (
<Select value={value} onValueChange={(v) => onChange(v as SharePermissionMode)}> <Select value={value} onValueChange={(v) => onChange(v as SharePermissionMode)}>
@ -398,12 +397,7 @@ function PermissionSelect({
className className
)} )}
> >
<SelectValue> <SelectValue>{selected.label}</SelectValue>
<span className="flex items-center gap-1.5">
<SelectedIcon className="h-3.5 w-3.5 shrink-0" aria-hidden />
{selected.label}
</span>
</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{options.map((option) => { {options.map((option) => {
@ -686,7 +680,7 @@ export function ShareDialog() {
size="sm" size="sm"
className="h-auto w-full justify-start gap-1 p-0 text-sm font-medium" className="h-auto w-full justify-start gap-1 p-0 text-sm font-medium"
> >
<SelectValue /> <SelectValue>{selectedLinkAccess.label}</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{LINK_ACCESS_OPTIONS.map((option) => { {LINK_ACCESS_OPTIONS.map((option) => {

View File

@ -0,0 +1,286 @@
"use client"
import type { ReactNode } from "react"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { Checkbox } from "@/components/ui/checkbox"
import { cn } from "@/lib/utils"
/**
* Settings UI kit composants unifiés pour les interfaces de réglages
* (/admin/* et /mail/settings). Centralise cards, champs, lignes à bascule,
* grilles et hints afin d'homogénéiser typographies, paddings et alignements.
*/
/** Surface card commune (réglages admin + mail) — alignée sur la card mail. */
export const SETTINGS_CARD_SURFACE_CLASS = cn(
"rounded-xl border border-mail-border bg-mail-surface shadow-sm",
"dark:bg-mail-surface-elevated dark:shadow-[0_1px_4px_rgba(0,0,0,0.35)]",
)
export const SETTINGS_CARD_TITLE_CLASS = "text-sm font-semibold text-foreground"
export const SETTINGS_CARD_DESCRIPTION_CLASS =
"text-[13px] leading-relaxed text-muted-foreground"
export const SETTINGS_FIELD_LABEL_CLASS = "text-xs font-medium text-muted-foreground"
/** Carte de réglages : en-tête (titre + description + action) puis corps. */
export function SettingsCard({
title,
description,
hint,
action,
badges,
footer,
children,
className,
bodyClassName,
contentClassName,
divider = true,
}: {
title?: ReactNode
description?: ReactNode
/** Élément aligné à droite de l'en-tête (Switch, bouton, badge…). */
action?: ReactNode
/** Ligne d'indications/badges sous la description. */
badges?: ReactNode
hint?: ReactNode
footer?: ReactNode
children?: ReactNode
className?: string
/** Classe sur le wrapper interne (padding). */
bodyClassName?: string
/** Classe sur le conteneur du corps (children). */
contentClassName?: string
/** Séparateur entre en-tête et corps. */
divider?: boolean
}) {
const hasHeader = Boolean(title || description || action || badges || hint)
return (
<section className={cn(SETTINGS_CARD_SURFACE_CLASS, className)}>
<div className={cn("p-5", bodyClassName)}>
{hasHeader ? (
<div className="flex items-start gap-4">
<div className="min-w-0 flex-1">
{title ? <p className={SETTINGS_CARD_TITLE_CLASS}>{title}</p> : null}
{description ? (
<p className={cn(title && "mt-1", SETTINGS_CARD_DESCRIPTION_CLASS)}>
{description}
</p>
) : null}
{badges ? <div className="mt-2 flex flex-wrap gap-2">{badges}</div> : null}
{hint ? <div className="mt-2">{hint}</div> : null}
</div>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
) : null}
{children ? (
<div
className={cn(
hasHeader && "mt-4 pt-4",
hasHeader && divider && "border-t border-mail-border",
"space-y-4",
contentClassName,
)}
>
{children}
</div>
) : null}
{footer ? <div className="mt-4">{footer}</div> : null}
</div>
</section>
)
}
/** Champ de formulaire : label + contrôle + hint/erreur. */
export function SettingsField({
label,
htmlFor,
hint,
error,
required,
children,
className,
labelClassName,
labelAction,
}: {
label?: ReactNode
htmlFor?: string
hint?: ReactNode
error?: ReactNode
required?: boolean
children: ReactNode
className?: string
labelClassName?: string
/** Élément aligné à droite du label (lien, bouton…). */
labelAction?: ReactNode
}) {
return (
<div className={cn("space-y-1.5", className)}>
{label ? (
<div className="flex items-center justify-between gap-2">
<Label htmlFor={htmlFor} className={cn(SETTINGS_FIELD_LABEL_CLASS, labelClassName)}>
{label}
{required ? <span className="ml-0.5 text-destructive">*</span> : null}
</Label>
{labelAction}
</div>
) : null}
{children}
{hint ? (
<div className="text-xs leading-relaxed text-muted-foreground">{hint}</div>
) : null}
{error ? (
<div className="text-xs leading-relaxed text-destructive">{error}</div>
) : null}
</div>
)
}
/** Ligne à bascule : titre + description + Switch. */
export function SettingsToggleRow({
title,
description,
checked,
onCheckedChange,
disabled,
hint,
variant = "bordered",
className,
}: {
title: ReactNode
description?: ReactNode
checked: boolean
onCheckedChange: (checked: boolean) => void
disabled?: boolean
/** Indication sous la ligne (ex. verrou déploiement). */
hint?: ReactNode
variant?: "bordered" | "plain"
className?: string
}) {
const row = (
<label
className={cn(
"flex items-center justify-between gap-4",
variant === "bordered" &&
"rounded-lg border border-mail-border bg-mail-surface-muted/40 px-3.5 py-3",
disabled && "opacity-70",
className,
)}
>
<span className="min-w-0">
<span className="block text-sm font-medium text-foreground">{title}</span>
{description ? (
<span className="mt-0.5 block text-xs leading-relaxed text-muted-foreground">
{description}
</span>
) : null}
</span>
<Switch checked={checked} disabled={disabled} onCheckedChange={onCheckedChange} />
</label>
)
if (!hint) return row
return (
<div className="space-y-1.5">
{row}
{hint}
</div>
)
}
/** Ligne à cocher : titre + description + Checkbox. */
export function SettingsCheckboxRow({
title,
description,
checked,
onCheckedChange,
disabled,
variant = "plain",
className,
}: {
title: ReactNode
description?: ReactNode
checked: boolean
onCheckedChange: (checked: boolean) => void
disabled?: boolean
variant?: "bordered" | "plain"
className?: string
}) {
return (
<label
className={cn(
"flex items-start gap-2.5",
variant === "bordered" &&
"rounded-lg border border-mail-border bg-mail-surface-muted/40 px-3.5 py-3",
disabled && "opacity-70",
className,
)}
>
<Checkbox
checked={checked}
disabled={disabled}
onCheckedChange={(v) => onCheckedChange(v === true)}
className="mt-0.5"
/>
<span className="min-w-0">
<span className="block text-sm text-foreground">{title}</span>
{description ? (
<span className="mt-0.5 block text-xs leading-relaxed text-muted-foreground">
{description}
</span>
) : null}
</span>
</label>
)
}
/** Grille de champs responsive (1 ou 2 colonnes). */
export function SettingsGrid({
columns = 2,
children,
className,
}: {
columns?: 1 | 2
children: ReactNode
className?: string
}) {
return (
<div
className={cn(
"grid min-w-0 gap-4",
columns === 2 && "sm:grid-cols-2",
className,
)}
>
{children}
</div>
)
}
/** Indication brève (texte muted) sous un champ ou une carte. */
export function SettingsHint({
children,
tone = "muted",
className,
}: {
children: ReactNode
tone?: "muted" | "warning" | "danger"
className?: string
}) {
return (
<p
className={cn(
"text-xs leading-relaxed",
tone === "muted" && "text-muted-foreground",
tone === "warning" && "text-amber-600 dark:text-amber-500",
tone === "danger" && "text-destructive",
className,
)}
>
{children}
</p>
)
}

View File

@ -1,5 +1,6 @@
"use client" "use client"
import { useEffect, useState } from "react"
import { ExternalLink, Plus, Trash2 } from "lucide-react" import { ExternalLink, Plus, Trash2 } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@ -51,6 +52,13 @@ export function WebSearchProvidersEditor({
providerSecrets, providerSecrets,
}: WebSearchProvidersEditorProps) { }: WebSearchProvidersEditorProps) {
const options = providerOptions(value, providerSecrets) const options = providerOptions(value, providerSecrets)
const [editingProviderId, setEditingProviderId] = useState<string | null>(null)
useEffect(() => {
if (editingProviderId && !value.providers.some((provider) => provider.id === editingProviderId)) {
setEditingProviderId(null)
}
}, [editingProviderId, value.providers])
function commit(next: ApiSearchSettings) { function commit(next: ApiSearchSettings) {
onChange(ensureSearchSettingsDefaults(next)) onChange(ensureSearchSettingsDefaults(next))
@ -81,10 +89,14 @@ export function WebSearchProvidersEditor({
default_provider_id: value.default_provider_id || provider.id, default_provider_id: value.default_provider_id || provider.id,
providers: [...value.providers, provider], providers: [...value.providers, provider],
}) })
setEditingProviderId(provider.id)
} }
function removeProvider(index: number) { function removeProvider(index: number) {
const removed = value.providers[index] const removed = value.providers[index]
if (editingProviderId === removed?.id) {
setEditingProviderId(null)
}
const providers = value.providers.filter((_, i) => i !== index) const providers = value.providers.filter((_, i) => i !== index)
const remainingOptions = providerOptions({ ...value, providers }, providerSecrets) const remainingOptions = providerOptions({ ...value, providers }, providerSecrets)
let defaultId = value.default_provider_id let defaultId = value.default_provider_id
@ -146,28 +158,73 @@ export function WebSearchProvidersEditor({
const entry = catalogEntry(provider.type) const entry = catalogEntry(provider.type)
const apiKeyConfigured = providerSecrets?.[provider.id]?.configured ?? false const apiKeyConfigured = providerSecrets?.[provider.id]?.configured ?? false
const configured = isSearchProviderConfigured(provider, { apiKeyConfigured }) const configured = isSearchProviderConfigured(provider, { apiKeyConfigured })
const isEditing = editingProviderId === provider.id
const displayName =
provider.name || entry.label || `Fournisseur ${index + 1}`
if (!isEditing) {
return (
<div
key={provider.id}
className="flex w-full items-center justify-between gap-3 rounded-lg border border-border px-4 py-3"
>
<TechBrandSelectLabel
brand={provider.type}
icon={entry.icon}
className="min-w-0 text-sm font-medium"
>
{displayName}
</TechBrandSelectLabel>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setEditingProviderId(provider.id)}
>
Modifier
</Button>
</div>
)
}
return ( return (
<div key={provider.id} className="w-full rounded-lg border border-border py-4"> <div key={provider.id} className="w-full rounded-lg border border-border py-4">
<div className="flex items-start justify-between gap-2 px-4"> <div className="flex items-start justify-between gap-2 px-4">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-medium"> <TechBrandSelectLabel
{provider.name || entry.label || `Fournisseur ${index + 1}`} brand={provider.type}
</p> icon={entry.icon}
className="text-sm font-medium"
>
{displayName}
</TechBrandSelectLabel>
{!configured ? ( {!configured ? (
<p className="text-xs text-muted-foreground">Configuration incomplète</p> <p className="mt-1 text-xs text-muted-foreground">Configuration incomplète</p>
) : apiKeyConfigured && !(provider.api_key ?? "").trim() ? ( ) : apiKeyConfigured && !(provider.api_key ?? "").trim() ? (
<p className="text-xs text-muted-foreground">Clé API enregistrée sur le serveur</p> <p className="mt-1 text-xs text-muted-foreground">
Clé API enregistrée sur le serveur
</p>
) : null} ) : null}
</div> </div>
<Button <div className="flex shrink-0 items-center gap-1">
type="button" <Button
variant="ghost" type="button"
size="icon" variant="ghost"
aria-label="Supprimer le fournisseur" size="sm"
onClick={() => removeProvider(index)} onClick={() => setEditingProviderId(null)}
> >
<Trash2 className="size-4" /> Fermer
</Button> </Button>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Supprimer le fournisseur"
onClick={() => removeProvider(index)}
>
<Trash2 className="size-4" />
</Button>
</div>
</div> </div>
<div className="mt-4 space-y-4 border-t px-4 pt-4"> <div className="mt-4 space-y-4 border-t px-4 pt-4">

View File

@ -178,6 +178,7 @@ const ADMIN_WIDE_SECTIONS: AdminSettingsSectionId[] = [
"search", "search",
"mail-domains", "mail-domains",
"authentication", "authentication",
"security",
"file-policies", "file-policies",
] ]

File diff suppressed because one or more lines are too long