feat(admin-settings): refactor admin settings components for improved usability and consistency
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
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:
parent
9e9fd208ad
commit
8f81d7aba1
@ -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,20 +46,24 @@ 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}`
|
||||||
|
|
||||||
return (
|
const pageSizeSelect = (
|
||||||
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
<Select value={String(pageSize)} onValueChange={(value) => onPageSizeChange(Number(value))}>
|
||||||
<div className="flex flex-wrap items-end gap-3">
|
<SelectTrigger
|
||||||
<div className="w-36">
|
className={cn("h-9", compact ? "w-18 shrink-0" : "mt-1")}
|
||||||
<Label className="text-xs">Par page</Label>
|
aria-label="Éléments par page"
|
||||||
<Select
|
|
||||||
value={String(pageSize)}
|
|
||||||
onValueChange={(value) => onPageSizeChange(Number(value))}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger className="mt-1 h-9">
|
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@ -65,11 +74,17 @@ export function AdminListControls({
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
)
|
||||||
<div className="min-w-[200px] flex-1 sm:max-w-xs">
|
|
||||||
<Label className="text-xs">Tri</Label>
|
const sortSelect = (
|
||||||
<Select value={sort} onValueChange={onSortChange}>
|
<Select value={sort} onValueChange={onSortChange}>
|
||||||
<SelectTrigger className="mt-1 h-9">
|
<SelectTrigger
|
||||||
|
className={cn(
|
||||||
|
"h-9",
|
||||||
|
compact ? "min-w-36 max-w-48 flex-1 basis-36" : "mt-1",
|
||||||
|
)}
|
||||||
|
aria-label="Tri"
|
||||||
|
>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@ -80,15 +95,32 @@ export function AdminListControls({
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
)
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 sm:justify-end">
|
const pagination = compact ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
<div className="flex shrink-0 gap-1">
|
||||||
{total === 0
|
<Button
|
||||||
? `0 ${itemLabel}`
|
variant="outline"
|
||||||
: `${rangeStart.toLocaleString("fr-FR")}–${rangeEnd.toLocaleString("fr-FR")} sur ${total.toLocaleString("fr-FR")} ${itemLabel}`}
|
size="icon"
|
||||||
</p>
|
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">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@ -107,6 +139,40 @@ export function AdminListControls({
|
|||||||
Suivant
|
Suivant
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 (
|
||||||
|
<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="w-36">
|
||||||
|
<Label className="text-xs">Par page</Label>
|
||||||
|
{pageSizeSelect}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-[200px] flex-1 sm:max-w-xs">
|
||||||
|
<Label className="text-xs">Tri</Label>
|
||||||
|
{sortSelect}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 sm:justify-end">
|
||||||
|
<p className="text-sm text-muted-foreground">{rangeLabel}</p>
|
||||||
|
{pagination}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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'accès aux fournisseurs IA pour les utilisateurs de l'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">
|
|
||||||
<label className="flex items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<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'autres clés API.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={draft.enforce_org_providers}
|
checked={draft.enforce_org_providers}
|
||||||
onCheckedChange={(enforce_org_providers) =>
|
onCheckedChange={(enforce_org_providers) =>
|
||||||
setDraft((p) => ({ ...p, enforce_org_providers }))
|
setDraft((p) => ({ ...p, enforce_org_providers }))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</label>
|
<SettingsToggleRow
|
||||||
<label className="flex items-center justify-between gap-4">
|
title="Autoriser surcharge utilisateur"
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium">Autoriser surcharge utilisateur</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={draft.allow_user_override}
|
checked={draft.allow_user_override}
|
||||||
onCheckedChange={(allow_user_override) =>
|
onCheckedChange={(allow_user_override) =>
|
||||||
setDraft((p) => ({ ...p, allow_user_override }))
|
setDraft((p) => ({ ...p, allow_user_override }))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</label>
|
</SettingsCard>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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">
|
|
||||||
Les utilisateurs ne peuvent plus changer le mode clair/sombre.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={draft.enforce_org_theme}
|
checked={draft.enforce_org_theme}
|
||||||
onCheckedChange={(v) => setDraft((p) => ({ ...p, enforce_org_theme: v }))}
|
onCheckedChange={(v) => setDraft((p) => ({ ...p, enforce_org_theme: v }))}
|
||||||
/>
|
/>
|
||||||
</label>
|
<SettingsField label="Thème par défaut">
|
||||||
<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>
|
|
||||||
<Switch
|
|
||||||
checked={draft.enforce_org_video_provider}
|
checked={draft.enforce_org_video_provider}
|
||||||
onCheckedChange={(v) =>
|
onCheckedChange={(v) => setDraft((p) => ({ ...p, enforce_org_video_provider: v }))}
|
||||||
setDraft((p) => ({ ...p, enforce_org_video_provider: v }))
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
<SettingsField label="Fournisseur visio par défaut">
|
||||||
<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'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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,64 +136,67 @@ 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>
|
|
||||||
<CardDescription>
|
|
||||||
Active le plugin UltiAI pour toute l'organisation. Le service OpenWebUI doit
|
|
||||||
aussi être déployé.
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<Switch
|
<Switch
|
||||||
checked={orgEnabled}
|
checked={orgEnabled}
|
||||||
disabled={enabledLocked}
|
disabled={enabledLocked}
|
||||||
onCheckedChange={setUltiAIEnabled}
|
onCheckedChange={setUltiAIEnabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
}
|
||||||
{enabledLocked ? (
|
badges={
|
||||||
<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>
|
</>
|
||||||
|
}
|
||||||
|
hint={
|
||||||
|
<>
|
||||||
|
{enabledLocked ? <DeployLockedHint section="ai_assistant" field="enabled" /> : null}
|
||||||
{!orgEnabled && !runtimeEnabled ? (
|
{!orgEnabled && !runtimeEnabled ? (
|
||||||
<p className="text-xs text-muted-foreground">
|
<SettingsHint>
|
||||||
Activez le plugin UltiAI dans Administration → Plugins, ou définissez{" "}
|
Activez le plugin UltiAI dans Administration → Plugins, ou définissez{" "}
|
||||||
<code className="rounded bg-muted px-1">AI_ASSISTANT_ENABLED=true</code> dans le
|
<code className="rounded bg-muted px-1">AI_ASSISTANT_ENABLED=true</code> dans le
|
||||||
déploiement, puis redémarrez le backend et OpenWebUI.
|
déploiement, puis redémarrez le backend et OpenWebUI.
|
||||||
</p>
|
</SettingsHint>
|
||||||
) : null}
|
) : null}
|
||||||
</CardHeader>
|
</>
|
||||||
<CardContent className="grid gap-4 sm:grid-cols-2">
|
}
|
||||||
<div className="space-y-2 sm:col-span-2">
|
>
|
||||||
<Label>Chemin public (proxy)</Label>
|
<SettingsField
|
||||||
|
label="Chemin public (proxy)"
|
||||||
|
hint={<DeployLockedHint section="ai_assistant" field="public_path" />}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
|
className="h-9"
|
||||||
value={aiAssistant.public_path}
|
value={aiAssistant.public_path}
|
||||||
onChange={(e) => setAiAssistant({ public_path: e.target.value })}
|
onChange={(e) => setAiAssistant({ public_path: e.target.value })}
|
||||||
placeholder="/ai"
|
placeholder="/ai"
|
||||||
disabled={publicPathLocked}
|
disabled={publicPathLocked}
|
||||||
/>
|
/>
|
||||||
<DeployLockedHint section="ai_assistant" field="public_path" />
|
</SettingsField>
|
||||||
</div>
|
<SettingsField
|
||||||
<div className="space-y-2 sm:col-span-2">
|
label="URL interne OpenWebUI"
|
||||||
<Label>URL interne OpenWebUI</Label>
|
hint={<DeployLockedHint section="ai_assistant" field="openwebui_internal_url" />}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
|
className="h-9"
|
||||||
value={aiAssistant.openwebui_internal_url}
|
value={aiAssistant.openwebui_internal_url}
|
||||||
onChange={(e) => setAiAssistant({ openwebui_internal_url: e.target.value })}
|
onChange={(e) => setAiAssistant({ openwebui_internal_url: e.target.value })}
|
||||||
placeholder="http://openwebui:8080"
|
placeholder="http://openwebui:8080"
|
||||||
disabled={openwebuiLocked}
|
disabled={openwebuiLocked}
|
||||||
/>
|
/>
|
||||||
<DeployLockedHint section="ai_assistant" field="openwebui_internal_url" />
|
</SettingsField>
|
||||||
</div>
|
<SettingsField
|
||||||
<div className="space-y-2 sm:col-span-2">
|
label="Modèle par défaut"
|
||||||
<Label>Modèle par défaut</Label>
|
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 ? (
|
{defaultModelOptions.length > 0 ? (
|
||||||
<Select
|
<Select
|
||||||
value={aiAssistant.default_model || "__auto__"}
|
value={aiAssistant.default_model || "__auto__"}
|
||||||
@ -219,63 +227,42 @@ export function AiAssistantSection() {
|
|||||||
</Select>
|
</Select>
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<Input
|
||||||
|
className="h-9"
|
||||||
value={aiAssistant.default_model}
|
value={aiAssistant.default_model}
|
||||||
onChange={(e) => setAiAssistant({ default_model: e.target.value })}
|
onChange={(e) => setAiAssistant({ default_model: e.target.value })}
|
||||||
placeholder="gpt-4o-mini"
|
placeholder="gpt-4o-mini"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-muted-foreground">
|
</SettingsField>
|
||||||
Modèle pré-sélectionné dans UltiAI pour tous les utilisateurs. Configurez un
|
<SettingsField label="Chemin historique NC">
|
||||||
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
|
<Input
|
||||||
|
className="h-9"
|
||||||
value={aiAssistant.chat_nc_path}
|
value={aiAssistant.chat_nc_path}
|
||||||
onChange={(e) => setAiAssistant({ chat_nc_path: e.target.value })}
|
onChange={(e) => setAiAssistant({ chat_nc_path: e.target.value })}
|
||||||
placeholder="/.ultimail/ai/chats"
|
placeholder="/.ultimail/ai/chats"
|
||||||
/>
|
/>
|
||||||
</div>
|
</SettingsField>
|
||||||
<div className="flex items-center justify-between gap-4 sm:col-span-2">
|
<SettingsToggleRow
|
||||||
<div>
|
title="Embed temporaire par défaut"
|
||||||
<Label>Embed temporaire par défaut</Label>
|
description="Les panneaux mail/drive/contacts ne sauvegardent pas l'historique."
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Les panneaux mail/drive/contacts ne sauvegardent pas l'historique.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={aiAssistant.embed_default_temporary}
|
checked={aiAssistant.embed_default_temporary}
|
||||||
onCheckedChange={(v) => setAiAssistant({ embed_default_temporary: v })}
|
onCheckedChange={(v) => setAiAssistant({ embed_default_temporary: v })}
|
||||||
/>
|
/>
|
||||||
</div>
|
<SettingsToggleRow
|
||||||
<div className="flex items-center justify-between gap-4 sm:col-span-2">
|
title="Sync historique Nextcloud"
|
||||||
<div>
|
description="Pipeline OpenWebUI → fichiers .ultichat.json sur le drive utilisateur."
|
||||||
<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}
|
checked={aiAssistant.chat_sync_enabled}
|
||||||
onCheckedChange={(v) => setAiAssistant({ chat_sync_enabled: v })}
|
onCheckedChange={(v) => setAiAssistant({ chat_sync_enabled: v })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</SettingsCard>
|
||||||
</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'enrichissement contacts et
|
|
||||||
les automatisations.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<LlmProvidersEditor
|
<LlmProvidersEditor
|
||||||
columns={1}
|
columns={1}
|
||||||
providers={llmDraft.providers}
|
providers={llmDraft.providers}
|
||||||
@ -288,8 +275,7 @@ export function AiAssistantSection() {
|
|||||||
setLlmDraft((prev) => ({ ...prev, default_provider_id }))
|
setLlmDraft((prev) => ({ ...prev, default_provider_id }))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</SettingsCard>
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UltiAiToolsCard
|
<UltiAiToolsCard
|
||||||
@ -298,23 +284,17 @@ 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
|
|
||||||
autorisés sont visibles pour les utilisateurs. Le surnom remplace le nom technique.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{llmDraft.providers.length === 0 ? (
|
{llmDraft.providers.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Configurez d'abord un fournisseur LLM dans la section ci-dessus.
|
Configurez d'abord un fournisseur LLM dans la section ci-dessus.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-wrap items-end gap-3 rounded-lg border p-3">
|
<div className="flex flex-wrap items-end gap-3 rounded-lg border border-mail-border bg-mail-surface-muted/40 p-3">
|
||||||
<div className="min-w-[220px] flex-1 space-y-2">
|
<SettingsField label="Découvrir depuis le fournisseur" className="min-w-[220px] flex-1">
|
||||||
<Label className="text-xs">Découvrir depuis le fournisseur</Label>
|
|
||||||
<Select
|
<Select
|
||||||
value={discoverProvider?.id ?? ""}
|
value={discoverProvider?.id ?? ""}
|
||||||
onValueChange={setDiscoverProviderId}
|
onValueChange={setDiscoverProviderId}
|
||||||
@ -330,7 +310,7 @@ export function AiAssistantSection() {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</SettingsField>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@ -347,32 +327,31 @@ export function AiAssistantSection() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{discoverModels.isError ? (
|
{discoverModels.isError ? (
|
||||||
<p className="text-sm text-destructive">
|
<SettingsHint tone="danger">
|
||||||
{discoverModels.error instanceof Error
|
{discoverModels.error instanceof Error
|
||||||
? discoverModels.error.message
|
? discoverModels.error.message
|
||||||
: "Impossible de lister les modèles sur ce fournisseur. Enregistrez d'abord le fournisseur LLM avec une clé API valide."}
|
: "Impossible de lister les modèles sur ce fournisseur. Enregistrez d'abord le fournisseur LLM avec une clé API valide."}
|
||||||
</p>
|
</SettingsHint>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{llmDraft.providers.length > 0 ? (
|
{llmDraft.providers.length > 0 ? (
|
||||||
<div className="space-y-2">
|
<SettingsField
|
||||||
<Label>Catalogue organisation</Label>
|
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
|
<AiAuthorizedModelPicker
|
||||||
models={aiAssistant.models}
|
models={aiAssistant.models}
|
||||||
onChange={setAuthorizedModels}
|
onChange={setAuthorizedModels}
|
||||||
availableModelIds={discoveredModels}
|
availableModelIds={discoveredModels}
|
||||||
emptyHint="Aucune restriction — tous les modèles LLM configurés restent disponibles."
|
emptyHint="Aucune restriction — tous les modèles LLM configurés restent disponibles."
|
||||||
/>
|
/>
|
||||||
{discoveredModels.length === 0 ? (
|
</SettingsField>
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Découvrez les modèles depuis un fournisseur pour remplir l'autocomplétion,
|
|
||||||
ou saisissez un ID manuellement puis Entrée.
|
|
||||||
</p>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</SettingsCard>
|
||||||
) : null}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</AutomationTabMasonry>
|
</AutomationTabMasonry>
|
||||||
</OrgSettingsSection>
|
</OrgSettingsSection>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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,26 +43,22 @@ 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">
|
|
||||||
Connexion via le fournisseur d'identité organisationnel.
|
|
||||||
</p>
|
|
||||||
{enabledLocked ? <DeployLockedHint section="authentik" field="enabled" /> : null}
|
|
||||||
</div>
|
|
||||||
<Switch
|
<Switch
|
||||||
checked={enabled}
|
checked={enabled}
|
||||||
disabled={enabledLocked}
|
disabled={enabledLocked}
|
||||||
onCheckedChange={(v) => setAuthentik({ enabled: v })}
|
onCheckedChange={(v) => setAuthentik({ enabled: v })}
|
||||||
/>
|
/>
|
||||||
</div>
|
}
|
||||||
|
>
|
||||||
<div className="mt-4 space-y-4 border-t pt-4">
|
<SettingsField
|
||||||
<FieldGroup>
|
label="URL API Authentik"
|
||||||
<Label>URL API Authentik</Label>
|
hint={apiLocked ? <DeployLockedHint section="authentik" field="api_url" /> : undefined}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
className="h-9"
|
className="h-9"
|
||||||
value={apiURL}
|
value={apiURL}
|
||||||
@ -68,76 +66,62 @@ export function AuthenticationSection() {
|
|||||||
onChange={(e) => setAuthentik({ api_url: e.target.value })}
|
onChange={(e) => setAuthentik({ api_url: e.target.value })}
|
||||||
placeholder="https://auth.example.com/api/v3"
|
placeholder="https://auth.example.com/api/v3"
|
||||||
/>
|
/>
|
||||||
{apiLocked ? <DeployLockedHint section="authentik" field="api_url" /> : null}
|
</SettingsField>
|
||||||
</FieldGroup>
|
|
||||||
|
|
||||||
<div className="grid min-w-0 gap-4">
|
<SettingsGrid columns={1}>
|
||||||
<FieldGroup>
|
<SettingsField label="Slug application">
|
||||||
<Label>Slug application</Label>
|
|
||||||
<Input
|
<Input
|
||||||
className="h-9"
|
className="h-9"
|
||||||
value={authentik.slug}
|
value={authentik.slug}
|
||||||
onChange={(e) => setAuthentik({ slug: e.target.value })}
|
onChange={(e) => setAuthentik({ slug: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</FieldGroup>
|
</SettingsField>
|
||||||
<FieldGroup>
|
<SettingsField
|
||||||
<Label>Client ID OIDC</Label>
|
label="Client ID OIDC"
|
||||||
|
hint={
|
||||||
|
clientLocked ? <DeployLockedHint section="authentik" field="client_id" /> : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
className="h-9"
|
className="h-9"
|
||||||
value={clientID}
|
value={clientID}
|
||||||
disabled={clientLocked}
|
disabled={clientLocked}
|
||||||
onChange={(e) => setAuthentik({ client_id: e.target.value })}
|
onChange={(e) => setAuthentik({ client_id: e.target.value })}
|
||||||
/>
|
/>
|
||||||
{clientLocked ? <DeployLockedHint section="authentik" field="client_id" /> : null}
|
</SettingsField>
|
||||||
</FieldGroup>
|
</SettingsGrid>
|
||||||
</div>
|
|
||||||
|
|
||||||
<FieldGroup>
|
<SettingsField label="Groupes par défaut (séparés par des virgules)">
|
||||||
<Label>Groupes par défaut (séparés par des virgules)</Label>
|
|
||||||
<Input
|
<Input
|
||||||
className="h-9"
|
className="h-9"
|
||||||
value={authentik.default_groups}
|
value={authentik.default_groups}
|
||||||
onChange={(e) => setAuthentik({ default_groups: e.target.value })}
|
onChange={(e) => setAuthentik({ default_groups: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</FieldGroup>
|
</SettingsField>
|
||||||
|
|
||||||
<label className="flex items-center justify-between gap-4 rounded-lg border p-3">
|
<SettingsToggleRow
|
||||||
<FieldGroup>
|
title="Forcer le SSO"
|
||||||
<p className="text-sm font-medium">Forcer le SSO</p>
|
description="Désactive la connexion locale sauf pour les administrateurs."
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Désactive la connexion locale sauf pour les administrateurs.
|
|
||||||
</p>
|
|
||||||
</FieldGroup>
|
|
||||||
<Switch
|
|
||||||
checked={authentik.enforce_sso}
|
checked={authentik.enforce_sso}
|
||||||
onCheckedChange={(enforce_sso) => setAuthentik({ enforce_sso })}
|
onCheckedChange={(enforce_sso) => setAuthentik({ enforce_sso })}
|
||||||
/>
|
/>
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="flex items-center justify-between gap-4 rounded-lg border p-3">
|
<SettingsToggleRow
|
||||||
<FieldGroup>
|
title="Mot de passe local de secours"
|
||||||
<p className="text-sm font-medium">Mot de passe local de secours</p>
|
description="Autoriser un fallback mot de passe si Authentik est indisponible."
|
||||||
<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}
|
checked={authentik.allow_password_fallback}
|
||||||
onCheckedChange={(allow_password_fallback) =>
|
onCheckedChange={(allow_password_fallback) =>
|
||||||
setAuthentik({ allow_password_fallback })
|
setAuthentik({ allow_password_fallback })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</label>
|
</SettingsCard>
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<AdminSettingsCard
|
<SettingsCard
|
||||||
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>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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'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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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'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'organisation pour chaque slug listé, s'il n'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'organisation</li>
|
<li className="px-3 py-4 text-center text-muted-foreground">Aucun dossier d'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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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'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'organisation sert au rattachement administratif. Le volume est monté globalement
|
Le slug d'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'organisation</li>
|
<li className="px-3 py-4 text-center text-muted-foreground">Aucun montage WebDAV d'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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,15 +54,15 @@ 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}
|
||||||
@ -61,11 +70,14 @@ export function FilePoliciesSection() {
|
|||||||
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>
|
||||||
|
<SettingsField label="Expiration liens par défaut">
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroupInput
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
value={filePolicies.default_link_expiry_days}
|
value={filePolicies.default_link_expiry_days}
|
||||||
@ -75,11 +87,14 @@ export function FilePoliciesSection() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</FieldGroup>
|
<InputGroupAddon align="inline-end">
|
||||||
<FieldGroup>
|
<InputGroupText>jours</InputGroupText>
|
||||||
<Label>Rétention corbeille (jours)</Label>
|
</InputGroupAddon>
|
||||||
<Input
|
</InputGroup>
|
||||||
className="h-9"
|
</SettingsField>
|
||||||
|
<SettingsField label="Rétention corbeille">
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroupInput
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
value={filePolicies.retention_trash_days}
|
value={filePolicies.retention_trash_days}
|
||||||
@ -87,9 +102,13 @@ export function FilePoliciesSection() {
|
|||||||
setFilePolicies({ retention_trash_days: Number(e.target.value) || 1 })
|
setFilePolicies({ retention_trash_days: Number(e.target.value) || 1 })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</FieldGroup>
|
<InputGroupAddon align="inline-end">
|
||||||
<FieldGroup>
|
<InputGroupText>jours</InputGroupText>
|
||||||
<Label>Partage externe</Label>
|
</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>
|
|
||||||
</FieldGroup>
|
|
||||||
<Switch
|
|
||||||
checked={filePolicies.block_executable}
|
checked={filePolicies.block_executable}
|
||||||
onCheckedChange={(block_executable) => setFilePolicies({ 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'upload</p>
|
description="VirusTotal — scan synchrone à l'upload Drive et pièces jointes mail"
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
VirusTotal — scan synchrone à l'upload Drive et pièces jointes mail
|
|
||||||
</p>
|
|
||||||
</FieldGroup>
|
|
||||||
<Switch
|
|
||||||
checked={filePolicies.virus_scan_enabled}
|
checked={filePolicies.virus_scan_enabled}
|
||||||
onCheckedChange={(virus_scan_enabled) => setFilePolicies({ 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>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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">
|
|
||||||
Flow ulti-enrollment : autoriser la création de compte locale en parallèle du SSO entreprise.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={draft.allow_self_enrollment}
|
checked={draft.allow_self_enrollment}
|
||||||
onCheckedChange={(allow_self_enrollment) =>
|
onCheckedChange={(allow_self_enrollment) =>
|
||||||
setDraft((prev) => ({ ...prev, 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,9 +692,8 @@ 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, {
|
||||||
@ -707,10 +701,8 @@ export function IdentityProvidersPanel({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</label>
|
<SettingsToggleRow
|
||||||
<label className="flex items-center justify-between gap-4 rounded-lg border p-3">
|
title="Synchroniser les utilisateurs LDAP"
|
||||||
<span className="text-sm">Synchroniser les utilisateurs LDAP</span>
|
|
||||||
<Switch
|
|
||||||
checked={editingProvider.ldap?.sync_users ?? false}
|
checked={editingProvider.ldap?.sync_users ?? false}
|
||||||
onCheckedChange={(sync_users) =>
|
onCheckedChange={(sync_users) =>
|
||||||
updateProvider(editIndex, {
|
updateProvider(editIndex, {
|
||||||
@ -718,11 +710,10 @@ export function IdentityProvidersPanel({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</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'accès</p>
|
<p className="text-sm font-medium">Restrictions d'accès</p>
|
||||||
<div>
|
<div>
|
||||||
<Label>Domaines email autorisés</Label>
|
<Label>Domaines email autorisés</Label>
|
||||||
|
|||||||
@ -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,25 +46,20 @@ 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
|
<Input
|
||||||
id="new-domain"
|
id="new-domain"
|
||||||
|
className="h-9"
|
||||||
value={domainName}
|
value={domainName}
|
||||||
onChange={(e) => setDomainName(e.target.value)}
|
onChange={(e) => setDomainName(e.target.value)}
|
||||||
placeholder="entreprise.com"
|
placeholder="entreprise.com"
|
||||||
/>
|
/>
|
||||||
</div>
|
</SettingsField>
|
||||||
<div className="flex items-end">
|
|
||||||
<Button
|
<Button
|
||||||
disabled={!domainName || createDomain.isPending}
|
disabled={!domainName || createDomain.isPending}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@ -71,76 +69,64 @@ export function MailDomainsSection() {
|
|||||||
Ajouter
|
Ajouter
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul className="mt-6 space-y-3">
|
<ul className="space-y-3">
|
||||||
{domains.map((domain) => (
|
{domains.map((domain) => (
|
||||||
<DomainRow key={domain.id} domain={domain} />
|
<DomainRow key={domain.id} domain={domain} />
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</CardContent>
|
</SettingsCard>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
<SettingsCard
|
||||||
<CardHeader className="pb-3">
|
title="Notifications suite (SMTP)"
|
||||||
<div className="flex items-center justify-between gap-4">
|
description="Partages de fichiers, mentions, invitations — distinct des comptes mail utilisateur."
|
||||||
<div>
|
action={
|
||||||
<CardTitle className="text-sm font-medium">Notifications suite (SMTP)</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Partages de fichiers, mentions, invitations — distinct des comptes mail utilisateur.
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<Switch
|
<Switch
|
||||||
checked={mailing.enabled}
|
checked={mailing.enabled}
|
||||||
onCheckedChange={(enabled) => setMailing({ enabled })}
|
onCheckedChange={(enabled) => setMailing({ enabled })}
|
||||||
/>
|
/>
|
||||||
</div>
|
}
|
||||||
</CardHeader>
|
>
|
||||||
<CardContent className="grid gap-4 sm:grid-cols-2">
|
<SettingsGrid columns={2}>
|
||||||
<div>
|
<SettingsField label="Hôte SMTP">
|
||||||
<Label>Hôte SMTP</Label>
|
|
||||||
<Input
|
<Input
|
||||||
className="mt-1 h-9"
|
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'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">
|
||||||
|
|||||||
@ -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'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,7 +328,7 @@ 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
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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,28 +136,10 @@ function PluginToggleCard({
|
|||||||
const [open, setOpen] = useState(defaultOpen)
|
const [open, setOpen] = useState(defaultOpen)
|
||||||
const hasConfig = Boolean(children)
|
const hasConfig = Boolean(children)
|
||||||
|
|
||||||
return (
|
const body = action ? (
|
||||||
<Card className="gap-0 py-0">
|
action
|
||||||
<CardContent className="py-4">
|
) : hasConfig ? (
|
||||||
<div className="flex items-start gap-4">
|
<Collapsible open={open} onOpenChange={setOpen}>
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<p className="font-medium">{name}</p>
|
|
||||||
{version ? <Badge variant="outline">v{version}</Badge> : null}
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
|
||||||
{hint}
|
|
||||||
{locked && lockSection && lockField ? (
|
|
||||||
<DeployLockedHint section={lockSection} field={lockField} />
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<Switch checked={enabled} disabled={locked} onCheckedChange={onToggle} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{action ? <div className="mt-3">{action}</div> : null}
|
|
||||||
|
|
||||||
{hasConfig && !action ? (
|
|
||||||
<Collapsible open={open} onOpenChange={setOpen} className="mt-3">
|
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<Button type="button" variant="ghost" size="sm" className="gap-1.5 px-2">
|
<Button type="button" variant="ghost" size="sm" className="gap-1.5 px-2">
|
||||||
<Settings2 className="size-3.5" aria-hidden />
|
<Settings2 className="size-3.5" aria-hidden />
|
||||||
@ -165,13 +150,37 @@ function PluginToggleCard({
|
|||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent className="mt-3 space-y-4 border-t pt-4">
|
<CollapsibleContent className="mt-3 space-y-4 border-t border-mail-border pt-4">
|
||||||
{children}
|
{children}
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsCard
|
||||||
|
title={
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{name}
|
||||||
|
{version ? <Badge variant="outline">v{version}</Badge> : null}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
description={description}
|
||||||
|
action={<Switch checked={enabled} disabled={locked} onCheckedChange={onToggle} />}
|
||||||
|
hint={
|
||||||
|
hint || (locked && lockSection && lockField) ? (
|
||||||
|
<>
|
||||||
|
{hint}
|
||||||
|
{locked && lockSection && lockField ? (
|
||||||
|
<DeployLockedHint section={lockSection} field={lockField} />
|
||||||
) : null}
|
) : null}
|
||||||
</CardContent>
|
</>
|
||||||
</Card>
|
) : null
|
||||||
|
}
|
||||||
|
divider={false}
|
||||||
|
contentClassName="space-y-0 !mt-3 !pt-0"
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</SettingsCard>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -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,18 +246,16 @@ 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>
|
||||||
<div className="space-y-2">
|
|
||||||
<ServiceToggle
|
<ServiceToggle
|
||||||
label="UltiDrive (fichiers)"
|
label="UltiDrive (fichiers)"
|
||||||
checked={nextcloud.drive_enabled}
|
checked={nextcloud.drive_enabled}
|
||||||
@ -265,7 +277,6 @@ function NextcloudPluginCard() {
|
|||||||
onChange={(talk_enabled) => setNextcloud({ talk_enabled })}
|
onChange={(talk_enabled) => setNextcloud({ talk_enabled })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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'alerte (%)</Label>
|
label="Seuil d'alerte"
|
||||||
<Input
|
unit="%"
|
||||||
className="h-9 max-w-xs"
|
|
||||||
type="number"
|
|
||||||
min={50}
|
min={50}
|
||||||
max={100}
|
max={100}
|
||||||
|
fallback={90}
|
||||||
|
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"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
value={usageQuotas.llm_requests_per_day}
|
value={usageQuotas.llm_requests_per_day}
|
||||||
onChange={(e) =>
|
onChange={(v) => setUsageQuotas({ llm_requests_per_day: v })}
|
||||||
setUsageQuotas({ llm_requests_per_day: Number(e.target.value) || 0 })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</FieldGroup>
|
<SettingsNumberField
|
||||||
<FieldGroup>
|
label="Tokens LLM"
|
||||||
<Label>Tokens LLM / mois</Label>
|
unit="/ mois"
|
||||||
<Input
|
|
||||||
className="h-9"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
value={usageQuotas.llm_tokens_per_month}
|
value={usageQuotas.llm_tokens_per_month}
|
||||||
onChange={(e) =>
|
onChange={(v) => setUsageQuotas({ llm_tokens_per_month: v })}
|
||||||
setUsageQuotas({ llm_tokens_per_month: Number(e.target.value) || 0 })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</FieldGroup>
|
</SettingsGrid>
|
||||||
</div>
|
</SettingsCard>
|
||||||
</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"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
value={usageQuotas.search_requests_per_day}
|
value={usageQuotas.search_requests_per_day}
|
||||||
onChange={(e) =>
|
onChange={(v) => setUsageQuotas({ search_requests_per_day: v })}
|
||||||
setUsageQuotas({ search_requests_per_day: Number(e.target.value) || 0 })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</FieldGroup>
|
<SettingsNumberField
|
||||||
<FieldGroup>
|
label="Tokens API"
|
||||||
<Label>Tokens API max / utilisateur</Label>
|
unit="/ utilisateur"
|
||||||
<Input
|
|
||||||
className="h-9"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
value={usageQuotas.max_api_tokens_per_user}
|
value={usageQuotas.max_api_tokens_per_user}
|
||||||
onChange={(e) =>
|
onChange={(v) => setUsageQuotas({ max_api_tokens_per_user: v })}
|
||||||
setUsageQuotas({ max_api_tokens_per_user: Number(e.target.value) || 0 })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</FieldGroup>
|
<SettingsNumberField
|
||||||
<FieldGroup>
|
label="Webhooks"
|
||||||
<Label>Webhooks max / utilisateur</Label>
|
unit="/ utilisateur"
|
||||||
<Input
|
|
||||||
className="h-9"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
value={usageQuotas.max_webhooks_per_user}
|
value={usageQuotas.max_webhooks_per_user}
|
||||||
onChange={(e) =>
|
onChange={(v) => setUsageQuotas({ max_webhooks_per_user: v })}
|
||||||
setUsageQuotas({ max_webhooks_per_user: Number(e.target.value) || 0 })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</FieldGroup>
|
</SettingsGrid>
|
||||||
</div>
|
</SettingsCard>
|
||||||
</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"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
step={0.5}
|
step={0.5}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(Number(e.target.value) || 0)}
|
onChange={onChange}
|
||||||
/>
|
/>
|
||||||
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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">
|
|
||||||
Sinon, chaque utilisateur configure ses propres fournisseurs.
|
|
||||||
</p>
|
|
||||||
</FieldGroup>
|
|
||||||
<Switch
|
|
||||||
checked={search.enforce_org_search}
|
checked={search.enforce_org_search}
|
||||||
onCheckedChange={(enforce_org_search) => setSearch({ 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}
|
) : null}
|
||||||
</FieldGroup>
|
</SettingsCard>
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</AdminSettingsCard>
|
|
||||||
</AutomationTabMasonry>
|
</AutomationTabMasonry>
|
||||||
</OrgSettingsSection>
|
</OrgSettingsSection>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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,57 +34,40 @@ 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">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium">2FA obligatoire pour tous</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Chaque utilisateur doit configurer un second facteur.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={twoFactor.required_for_all}
|
checked={twoFactor.required_for_all}
|
||||||
onCheckedChange={(required_for_all) => setTwoFactor({ required_for_all })}
|
onCheckedChange={(required_for_all) => setTwoFactor({ required_for_all })}
|
||||||
/>
|
/>
|
||||||
</label>
|
<SettingsToggleRow
|
||||||
<label className="flex items-center justify-between gap-4 rounded-lg border p-3">
|
title="2FA obligatoire pour les administrateurs"
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium">2FA obligatoire pour les administrateurs</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={twoFactor.required_for_admins}
|
checked={twoFactor.required_for_admins}
|
||||||
onCheckedChange={(required_for_admins) => setTwoFactor({ required_for_admins })}
|
onCheckedChange={(required_for_admins) => setTwoFactor({ required_for_admins })}
|
||||||
/>
|
/>
|
||||||
</label>
|
</SettingsCard>
|
||||||
</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}
|
||||||
|
title={method.label}
|
||||||
checked={twoFactor.allowed_methods.includes(method.id)}
|
checked={twoFactor.allowed_methods.includes(method.id)}
|
||||||
onCheckedChange={(v) => toggleMethod(method.id, v === true)}
|
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}
|
||||||
@ -88,11 +75,10 @@ export function SecuritySection() {
|
|||||||
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'appareil (jours)</Label>
|
|
||||||
<Input
|
<Input
|
||||||
className="mt-1 h-9"
|
className="h-9"
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
value={twoFactor.remember_device_days}
|
value={twoFactor.remember_device_days}
|
||||||
@ -100,8 +86,10 @@ export function SecuritySection() {
|
|||||||
setTwoFactor({ remember_device_days: Number(e.target.value) || 0 })
|
setTwoFactor({ remember_device_days: Number(e.target.value) || 0 })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</SettingsField>
|
||||||
</div>
|
</SettingsGrid>
|
||||||
|
</SettingsCard>
|
||||||
|
</AutomationTabMasonry>
|
||||||
</OrgSettingsSection>
|
</OrgSettingsSection>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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'outils MCP exposés à l'assistant. La recherche web supporte Brave,
|
Groupes d'outils MCP exposés à l'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) => {
|
|
||||||
const checked = enabledTools.includes(group.id)
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
key={group.id}
|
|
||||||
className="flex items-start justify-between gap-4 rounded-lg border p-3"
|
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
{ULTIAI_TOOL_GROUPS.map((group) => (
|
||||||
<Label className="text-sm font-medium">{group.label}</Label>
|
<SettingsToggleRow
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground">{group.description}</p>
|
key={group.id}
|
||||||
</div>
|
title={group.label}
|
||||||
<Switch
|
description={group.description}
|
||||||
checked={checked}
|
checked={enabledTools.includes(group.id)}
|
||||||
onCheckedChange={(enabled) =>
|
onCheckedChange={(enabled) =>
|
||||||
onChange(toggleUltiAiToolGroup(enabledTools, group.id, enabled))
|
onChange(toggleUltiAiToolGroup(enabledTools, group.id, enabled))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</label>
|
))}
|
||||||
)
|
</SettingsCard>
|
||||||
})}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,35 +56,31 @@ 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"}
|
Jitsi {effective?.enabled ? "actif" : "inactif"}
|
||||||
{effective?.public_url ? ` — ${effective.public_url}` : ""}
|
{effective?.public_url ? ` — ${effective.public_url}` : ""}
|
||||||
</CardDescription>
|
</>
|
||||||
</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">
|
|
||||||
Active les sous-titres Jitsi (live) ou le pipeline différé selon le mode.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
<Switch
|
||||||
checked={draft.transcription_enabled}
|
checked={draft.transcription_enabled}
|
||||||
onCheckedChange={(v) => patch({ transcription_enabled: v })}
|
onCheckedChange={(v) => patch({ transcription_enabled: v })}
|
||||||
/>
|
/>
|
||||||
</label>
|
}
|
||||||
|
>
|
||||||
{draft.transcription_enabled ? (
|
{draft.transcription_enabled ? (
|
||||||
<>
|
<>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<SettingsGrid columns={2}>
|
||||||
<div className="space-y-2">
|
<SettingsField label="Mode">
|
||||||
<Label>Mode</Label>
|
|
||||||
<Select
|
<Select
|
||||||
value={draft.transcription_mode}
|
value={draft.transcription_mode}
|
||||||
onValueChange={(v) =>
|
onValueChange={(v) =>
|
||||||
@ -97,10 +98,9 @@ export function UltimeetSection() {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</SettingsField>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<SettingsField label="Moteur">
|
||||||
<Label>Moteur</Label>
|
|
||||||
<Select
|
<Select
|
||||||
value={draft.transcription_engine}
|
value={draft.transcription_engine}
|
||||||
onValueChange={(v) =>
|
onValueChange={(v) =>
|
||||||
@ -120,46 +120,38 @@ export function UltimeetSection() {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</SettingsField>
|
||||||
</div>
|
</SettingsGrid>
|
||||||
|
|
||||||
<label className="flex items-center justify-between gap-4">
|
<SettingsToggleRow
|
||||||
<div>
|
title="Démarrage automatique"
|
||||||
<p className="text-sm font-medium">Démarrage automatique</p>
|
description="Lance la transcription dès l'ouverture de la salle (sinon via bouton Sous-titres pour les modérateurs)."
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Lance la transcription dès l'ouverture de la salle (sinon via bouton
|
|
||||||
Sous-titres pour les modérateurs).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={draft.auto_start_transcription}
|
checked={draft.auto_start_transcription}
|
||||||
onCheckedChange={(v) => patch({ auto_start_transcription: v })}
|
onCheckedChange={(v) => patch({ auto_start_transcription: v })}
|
||||||
/>
|
/>
|
||||||
</label>
|
|
||||||
|
|
||||||
{draft.transcription_engine === "faster_whisper_local" ? (
|
{draft.transcription_engine === "faster_whisper_local" ? (
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<SettingsGrid columns={2}>
|
||||||
<div className="space-y-2">
|
<SettingsField label="URL Skynet (interne)">
|
||||||
<Label>URL Skynet (interne)</Label>
|
|
||||||
<Input
|
<Input
|
||||||
|
className="h-9"
|
||||||
value={draft.skynet_url}
|
value={draft.skynet_url}
|
||||||
onChange={(e) => patch({ skynet_url: e.target.value })}
|
onChange={(e) => patch({ skynet_url: e.target.value })}
|
||||||
placeholder="http://skynet:8000"
|
placeholder="http://skynet:8000"
|
||||||
/>
|
/>
|
||||||
</div>
|
</SettingsField>
|
||||||
<div className="space-y-2">
|
<SettingsField label="Modèle Whisper">
|
||||||
<Label>Modèle Whisper</Label>
|
|
||||||
<Input
|
<Input
|
||||||
|
className="h-9"
|
||||||
value={draft.whisper_model}
|
value={draft.whisper_model}
|
||||||
onChange={(e) => patch({ whisper_model: e.target.value })}
|
onChange={(e) => patch({ whisper_model: e.target.value })}
|
||||||
placeholder="tiny, base, small…"
|
placeholder="tiny, base, small…"
|
||||||
/>
|
/>
|
||||||
</div>
|
</SettingsField>
|
||||||
</div>
|
</SettingsGrid>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<SettingsGrid columns={1}>
|
||||||
<div className="space-y-2 sm:col-span-2">
|
<SettingsField label="Fournisseur API">
|
||||||
<Label>Fournisseur API</Label>
|
|
||||||
<Select
|
<Select
|
||||||
value={draft.external_api_provider}
|
value={draft.external_api_provider}
|
||||||
onValueChange={(v) =>
|
onValueChange={(v) =>
|
||||||
@ -184,76 +176,61 @@ export function UltimeetSection() {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</SettingsField>
|
||||||
<div className="space-y-2 sm:col-span-2">
|
<SettingsField label="URL API">
|
||||||
<Label>URL API</Label>
|
|
||||||
<Input
|
<Input
|
||||||
|
className="h-9"
|
||||||
value={draft.external_api_url}
|
value={draft.external_api_url}
|
||||||
onChange={(e) => patch({ external_api_url: e.target.value })}
|
onChange={(e) => patch({ external_api_url: e.target.value })}
|
||||||
placeholder="https://api.openai.com/v1/audio/transcriptions"
|
placeholder="https://api.openai.com/v1/audio/transcriptions"
|
||||||
/>
|
/>
|
||||||
</div>
|
</SettingsField>
|
||||||
<div className="space-y-2 sm:col-span-2">
|
<SettingsField label="Clé API">
|
||||||
<Label>Clé API</Label>
|
|
||||||
<Input
|
<Input
|
||||||
|
className="h-9"
|
||||||
type="password"
|
type="password"
|
||||||
value={draft.external_api_key}
|
value={draft.external_api_key}
|
||||||
onChange={(e) => patch({ external_api_key: e.target.value })}
|
onChange={(e) => patch({ external_api_key: e.target.value })}
|
||||||
placeholder="Laisser vide pour conserver la clé enregistrée"
|
placeholder="Laisser vide pour conserver la clé enregistrée"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
</div>
|
</SettingsField>
|
||||||
</div>
|
</SettingsGrid>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</SettingsCard>
|
||||||
|
|
||||||
{draft.transcription_enabled ? (
|
{draft.transcription_enabled ? (
|
||||||
<div className="space-y-4 rounded-lg border p-4">
|
<SettingsCard
|
||||||
<div>
|
title="Après la réunion"
|
||||||
<p className="text-sm font-medium">Après la réunion</p>
|
description="Actions exécutées quand le transcript est reçu par le backend."
|
||||||
<p className="text-xs text-muted-foreground">
|
>
|
||||||
Actions exécutées quand le transcript est reçu par le backend.
|
<SettingsToggleRow
|
||||||
</p>
|
title="Enregistrer dans UltiDrive"
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="flex items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium">Enregistrer dans UltiDrive</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
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 ? (
|
||||||
<div className="space-y-2">
|
<SettingsField label="Dossier Drive">
|
||||||
<Label>Dossier Drive</Label>
|
|
||||||
<Input
|
<Input
|
||||||
|
className="h-9"
|
||||||
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</SettingsField>
|
||||||
) : 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 ? (
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<SettingsGrid columns={1}>
|
||||||
<div className="space-y-2">
|
<SettingsField label="Destinataires">
|
||||||
<Label>Destinataires</Label>
|
|
||||||
<Select
|
<Select
|
||||||
value={draft.post_actions.email_recipients}
|
value={draft.post_actions.email_recipients}
|
||||||
onValueChange={(v) =>
|
onValueChange={(v) =>
|
||||||
@ -273,35 +250,28 @@ export function UltimeetSection() {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</SettingsField>
|
||||||
{draft.post_actions.email_recipients === "custom" ? (
|
{draft.post_actions.email_recipients === "custom" ? (
|
||||||
<div className="space-y-2 sm:col-span-2">
|
<SettingsField label="Adresses (séparées par des virgules)">
|
||||||
<Label>Adresses (séparées par des virgules)</Label>
|
|
||||||
<Input
|
<Input
|
||||||
|
className="h-9"
|
||||||
value={draft.post_actions.email_custom_addresses}
|
value={draft.post_actions.email_custom_addresses}
|
||||||
onChange={(e) => patchPost({ email_custom_addresses: e.target.value })}
|
onChange={(e) => patchPost({ email_custom_addresses: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</SettingsField>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</SettingsGrid>
|
||||||
) : null}
|
) : 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">
|
||||||
<div className="space-y-2">
|
<SettingsField label="Fournisseur LLM">
|
||||||
<Label>Fournisseur LLM</Label>
|
|
||||||
<Select
|
<Select
|
||||||
value={draft.post_actions.llm_provider_id || "__default__"}
|
value={draft.post_actions.llm_provider_id || "__default__"}
|
||||||
onValueChange={(v) =>
|
onValueChange={(v) =>
|
||||||
@ -320,33 +290,29 @@ export function UltimeetSection() {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</SettingsField>
|
||||||
<div className="space-y-2">
|
<SettingsField label="Prompt">
|
||||||
<Label>Prompt</Label>
|
|
||||||
<Textarea
|
<Textarea
|
||||||
value={draft.post_actions.llm_prompt}
|
value={draft.post_actions.llm_prompt}
|
||||||
onChange={(e) => patchPost({ llm_prompt: e.target.value })}
|
onChange={(e) => patchPost({ llm_prompt: e.target.value })}
|
||||||
rows={4}
|
rows={4}
|
||||||
/>
|
/>
|
||||||
</div>
|
</SettingsField>
|
||||||
<label className="flex items-center justify-between gap-4">
|
<SettingsToggleRow
|
||||||
<span className="text-sm">Envoyer le résultat LLM par mail</span>
|
title="Envoyer le résultat LLM par mail"
|
||||||
<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}
|
||||||
</div>
|
</SettingsCard>
|
||||||
) : null}
|
) : null}
|
||||||
|
</AutomationTabMasonry>
|
||||||
</OrgSettingsSection>
|
</OrgSettingsSection>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
286
components/settings/settings-kit.tsx
Normal file
286
components/settings/settings-kit.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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,19 +158,63 @@ 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>
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setEditingProviderId(null)}
|
||||||
|
>
|
||||||
|
Fermer
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@ -169,6 +225,7 @@ export function WebSearchProvidersEditor({
|
|||||||
<Trash2 className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
</Button>
|
</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">
|
||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
|
|||||||
@ -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
Loading…
Reference in New Issue
Block a user