feat(admin-settings): enhance admin settings with new components and layout improvements
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
- Introduced new components for managing admin settings, including AdminListControls, AdminSettingsCard, and TechBrandSelectLabel. - Implemented dynamic loading for admin settings sections to optimize performance. - Enhanced the layout of various admin settings sections for better user experience. - Updated the AiAssistantSection to include LLM provider management and improved model selection. - Refactored authentication settings to streamline configuration and improve accessibility.
This commit is contained in:
parent
3477361db0
commit
9e9fd208ad
16
app/chat/layout.tsx
Normal file
16
app/chat/layout.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: { absolute: "UltiAI" },
|
||||
description: "Assistant IA intégré à la suite Ultimail",
|
||||
icons: {
|
||||
icon: [{ url: "/ultiai-mark.svg", type: "image/svg+xml" }],
|
||||
apple: [{ url: "/ultiai-mark.svg", type: "image/svg+xml" }],
|
||||
shortcut: "/ultiai-mark.svg",
|
||||
},
|
||||
}
|
||||
|
||||
export default function ChatLayout({ children }: { children: ReactNode }) {
|
||||
return children
|
||||
}
|
||||
@ -3,12 +3,11 @@
|
||||
import Link from "next/link"
|
||||
import { Sparkles } from "lucide-react"
|
||||
import { AiChatIframe } from "@/components/ai/ai-chat-iframe"
|
||||
import { useAiConfig, useAiQuota } from "@/lib/api/hooks/use-ai-queries"
|
||||
import { useAiConfig } from "@/lib/api/hooks/use-ai-queries"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
export default function ChatPage() {
|
||||
const { data: config, isLoading, isError } = useAiConfig()
|
||||
const { data: quota } = useAiQuota(Boolean(config?.enabled))
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@ -47,23 +46,10 @@ export default function ChatPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh flex-col">
|
||||
<header className="flex items-center justify-between border-b px-4 py-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Sparkles className="h-4 w-4 text-[#1a73e8]" />
|
||||
UltiAI
|
||||
</div>
|
||||
{quota ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{quota.requests_remaining}/{quota.requests_limit} requêtes aujourd'hui
|
||||
</span>
|
||||
) : null}
|
||||
</header>
|
||||
<AiChatIframe
|
||||
publicPath={config.public_path}
|
||||
context={{ app: "standalone", temporary: false }}
|
||||
className="min-h-0 flex-1 border-0"
|
||||
/>
|
||||
</div>
|
||||
<AiChatIframe
|
||||
publicPath={config.public_path}
|
||||
context={{ app: "standalone", temporary: false }}
|
||||
className="h-dvh w-full border-0"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
113
components/admin/settings/admin-list-controls.tsx
Normal file
113
components/admin/settings/admin-list-controls.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
|
||||
export type AdminListSortOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE_SIZE_OPTIONS = [10, 25, 50, 100] as const
|
||||
|
||||
export function AdminListControls({
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages,
|
||||
pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,
|
||||
sort,
|
||||
sortOptions,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
onSortChange,
|
||||
itemLabel,
|
||||
}: {
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
totalPages: number
|
||||
pageSizeOptions?: readonly number[]
|
||||
sort: string
|
||||
sortOptions: readonly AdminListSortOption[]
|
||||
onPageChange: (page: number) => void
|
||||
onPageSizeChange: (pageSize: number) => void
|
||||
onSortChange: (sort: string) => void
|
||||
itemLabel: string
|
||||
}) {
|
||||
const rangeStart = total === 0 ? 0 : (page - 1) * pageSize + 1
|
||||
const rangeEnd = Math.min(page * pageSize, total)
|
||||
|
||||
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>
|
||||
<Select
|
||||
value={String(pageSize)}
|
||||
onValueChange={(value) => onPageSizeChange(Number(value))}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{pageSizeOptions.map((size) => (
|
||||
<SelectItem key={size} value={String(size)}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="min-w-[200px] flex-1 sm:max-w-xs">
|
||||
<Label className="text-xs">Tri</Label>
|
||||
<Select value={sort} onValueChange={onSortChange}>
|
||||
<SelectTrigger className="mt-1 h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sortOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 sm:justify-end">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{total === 0
|
||||
? `0 ${itemLabel}`
|
||||
: `${rangeStart.toLocaleString("fr-FR")}–${rangeEnd.toLocaleString("fr-FR")} sur ${total.toLocaleString("fr-FR")} ${itemLabel}`}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
>
|
||||
Précédent
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
>
|
||||
Suivant
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
27
components/admin/settings/admin-settings-card.tsx
Normal file
27
components/admin/settings/admin-settings-card.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import type { ReactNode } from "react"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
|
||||
export function AdminSettingsCard({
|
||||
title,
|
||||
description,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
description: ReactNode
|
||||
hint?: ReactNode
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Card className="gap-0 py-0">
|
||||
<CardContent className="py-4">
|
||||
<div>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@ -5,6 +5,7 @@ import { usePathname } from "next/navigation"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
ADMIN_SETTINGS_NAV,
|
||||
isAdminSettingsFullWidthLayoutPath,
|
||||
isAdminSettingsNavActive,
|
||||
isAdminSettingsWideLayoutPath,
|
||||
} from "@/lib/admin-settings/settings-nav"
|
||||
@ -106,11 +107,13 @@ export function AdminSettingsLayout({ children }: { children: React.ReactNode })
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-8">
|
||||
<main className="min-h-0 flex-1 overflow-y-auto px-4 pt-5 sm:px-8">
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto w-full max-w-3xl",
|
||||
isAdminSettingsWideLayoutPath(pathname) && "lg:max-w-6xl"
|
||||
"mx-auto flex min-h-full w-full flex-col",
|
||||
!isAdminSettingsFullWidthLayoutPath(pathname) && "max-w-3xl",
|
||||
isAdminSettingsWideLayoutPath(pathname) && "lg:max-w-6xl",
|
||||
isAdminSettingsFullWidthLayoutPath(pathname) && "max-w-none"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
||||
@ -1,52 +1,95 @@
|
||||
"use client"
|
||||
|
||||
import dynamic from "next/dynamic"
|
||||
import type { ComponentType } from "react"
|
||||
import {
|
||||
resolveAdminSettingsSection,
|
||||
type AdminSettingsSectionId,
|
||||
} from "@/lib/admin-settings/settings-nav"
|
||||
import { AdminAccessGuard } from "@/components/admin/settings/admin-access-guard"
|
||||
import { OverviewSection } from "@/components/admin/settings/sections/overview-section"
|
||||
import { UsersSection } from "@/components/admin/settings/sections/users-section"
|
||||
import { AuthenticationSection } from "@/components/admin/settings/sections/authentication-section"
|
||||
import { SecuritySection } from "@/components/admin/settings/sections/security-section"
|
||||
import { StorageQuotasSection } from "@/components/admin/settings/sections/storage-quotas-section"
|
||||
import { UsageQuotasSection } from "@/components/admin/settings/sections/usage-quotas-section"
|
||||
import { FilePoliciesSection } from "@/components/admin/settings/sections/file-policies-section"
|
||||
import { PublicSharesSection } from "@/components/admin/settings/sections/public-shares-section"
|
||||
import { LlmSection } from "@/components/admin/settings/sections/llm-section"
|
||||
import { SearchSection } from "@/components/admin/settings/sections/search-section"
|
||||
import { PluginsSection } from "@/components/admin/settings/sections/plugins-section"
|
||||
import { NextcloudSection } from "@/components/admin/settings/sections/nextcloud-section"
|
||||
import { MailingSection } from "@/components/admin/settings/sections/mailing-section"
|
||||
import { MailDomainsSection } from "@/components/admin/settings/sections/mail-domains-section"
|
||||
import { OnlyofficeSection } from "@/components/admin/settings/sections/onlyoffice-section"
|
||||
import { RichtextSection } from "@/components/admin/settings/sections/richtext-section"
|
||||
import { AiAssistantSection } from "@/components/admin/settings/sections/ai-assistant-section"
|
||||
import { AgendaSection } from "@/components/admin/settings/sections/agenda-section"
|
||||
import { UltimeetSection } from "@/components/admin/settings/sections/ultimeet-section"
|
||||
import { AuditSection } from "@/components/admin/settings/sections/audit-section"
|
||||
|
||||
const SECTIONS: Record<AdminSettingsSectionId, React.ComponentType> = {
|
||||
overview: OverviewSection,
|
||||
users: UsersSection,
|
||||
authentication: AuthenticationSection,
|
||||
security: SecuritySection,
|
||||
"storage-quotas": StorageQuotasSection,
|
||||
"usage-quotas": UsageQuotasSection,
|
||||
"file-policies": FilePoliciesSection,
|
||||
"public-shares": PublicSharesSection,
|
||||
llm: LlmSection,
|
||||
search: SearchSection,
|
||||
plugins: PluginsSection,
|
||||
nextcloud: NextcloudSection,
|
||||
agenda: AgendaSection,
|
||||
ultimeet: UltimeetSection,
|
||||
mailing: MailingSection,
|
||||
"mail-domains": MailDomainsSection,
|
||||
onlyoffice: OnlyofficeSection,
|
||||
richtext: RichtextSection,
|
||||
"ai-assistant": AiAssistantSection,
|
||||
audit: AuditSection,
|
||||
function loadSection<P = object>(
|
||||
loader: () => Promise<{ default: ComponentType<P> }>
|
||||
) {
|
||||
return dynamic(loader, { ssr: false })
|
||||
}
|
||||
|
||||
const SECTIONS: Record<AdminSettingsSectionId, ComponentType> = {
|
||||
overview: loadSection(() =>
|
||||
import("@/components/admin/settings/sections/overview-section").then((m) => ({
|
||||
default: m.OverviewSection,
|
||||
}))
|
||||
),
|
||||
users: loadSection(() =>
|
||||
import("@/components/admin/settings/sections/users-section").then((m) => ({
|
||||
default: m.UsersSection,
|
||||
}))
|
||||
),
|
||||
authentication: loadSection(() =>
|
||||
import("@/components/admin/settings/sections/authentication-section").then((m) => ({
|
||||
default: m.AuthenticationSection,
|
||||
}))
|
||||
),
|
||||
security: loadSection(() =>
|
||||
import("@/components/admin/settings/sections/security-section").then((m) => ({
|
||||
default: m.SecuritySection,
|
||||
}))
|
||||
),
|
||||
quotas: loadSection(() =>
|
||||
import("@/components/admin/settings/sections/quotas-section").then((m) => ({
|
||||
default: m.QuotasSection,
|
||||
}))
|
||||
),
|
||||
"file-policies": loadSection(() =>
|
||||
import("@/components/admin/settings/sections/file-policies-section").then((m) => ({
|
||||
default: m.FilePoliciesSection,
|
||||
}))
|
||||
),
|
||||
"public-shares": loadSection(() =>
|
||||
import("@/components/admin/settings/sections/public-shares-section").then((m) => ({
|
||||
default: m.PublicSharesSection,
|
||||
}))
|
||||
),
|
||||
llm: loadSection(() =>
|
||||
import("@/components/admin/settings/sections/ai-assistant-section").then((m) => ({
|
||||
default: m.AiAssistantSection,
|
||||
}))
|
||||
),
|
||||
search: loadSection(() =>
|
||||
import("@/components/admin/settings/sections/search-section").then((m) => ({
|
||||
default: m.SearchSection,
|
||||
}))
|
||||
),
|
||||
plugins: loadSection(() =>
|
||||
import("@/components/admin/settings/sections/plugins-section").then((m) => ({
|
||||
default: m.PluginsSection,
|
||||
}))
|
||||
),
|
||||
agenda: loadSection(() =>
|
||||
import("@/components/admin/settings/sections/agenda-section").then((m) => ({
|
||||
default: m.AgendaSection,
|
||||
}))
|
||||
),
|
||||
ultimeet: loadSection(() =>
|
||||
import("@/components/admin/settings/sections/ultimeet-section").then((m) => ({
|
||||
default: m.UltimeetSection,
|
||||
}))
|
||||
),
|
||||
"mail-domains": loadSection(() =>
|
||||
import("@/components/admin/settings/sections/mail-domains-section").then((m) => ({
|
||||
default: m.MailDomainsSection,
|
||||
}))
|
||||
),
|
||||
"ai-assistant": loadSection(() =>
|
||||
import("@/components/admin/settings/sections/ai-assistant-section").then((m) => ({
|
||||
default: m.AiAssistantSection,
|
||||
}))
|
||||
),
|
||||
audit: loadSection(() =>
|
||||
import("@/components/admin/settings/sections/audit-section").then((m) => ({
|
||||
default: m.AuditSection,
|
||||
}))
|
||||
),
|
||||
}
|
||||
|
||||
export function AdminSettingsSectionView({
|
||||
|
||||
11
components/admin/settings/field-group.tsx
Normal file
11
components/admin/settings/field-group.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function FieldGroup({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return <div className={cn("space-y-2.5", className)}>{children}</div>
|
||||
}
|
||||
@ -49,29 +49,33 @@ export function OrgSettingsSection({
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionHeader title={title} description={description} />
|
||||
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
|
||||
{!showPendingBanner ? null : <AdminPendingApiBanner />}
|
||||
{showEffectiveBanner ? <AdminRuntimePanel /> : null}
|
||||
<div className="space-y-6">{children}</div>
|
||||
<div className="flex min-h-full flex-col">
|
||||
<div className="flex-1 space-y-6 pb-6">
|
||||
<SettingsSectionHeader title={title} description={description} />
|
||||
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
|
||||
{!showPendingBanner ? null : <AdminPendingApiBanner />}
|
||||
{showEffectiveBanner ? <AdminRuntimePanel /> : null}
|
||||
{children}
|
||||
</div>
|
||||
{hasSave ? (
|
||||
<div className="mt-6 flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void handleSave()}
|
||||
disabled={!apiSynced || isFetching}
|
||||
>
|
||||
Enregistrer
|
||||
</Button>
|
||||
{saved ? (
|
||||
<span className="text-sm text-green-600 dark:text-green-500">
|
||||
Réglages enregistrés sur le serveur
|
||||
</span>
|
||||
) : null}
|
||||
{error ? <span className="text-sm text-destructive">{error}</span> : null}
|
||||
<div className="sticky bottom-0 z-10 -mx-4 shrink-0 border-t border-border bg-mail-surface/95 px-4 py-4 backdrop-blur supports-[backdrop-filter]:bg-mail-surface/80 sm:-mx-8 sm:px-8 dark:bg-mail-surface-elevated/95">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void handleSave()}
|
||||
disabled={!apiSynced || isFetching}
|
||||
>
|
||||
Enregistrer
|
||||
</Button>
|
||||
{saved ? (
|
||||
<span className="text-sm text-green-600 dark:text-green-500">
|
||||
Réglages enregistrés sur le serveur
|
||||
</span>
|
||||
) : null}
|
||||
{error ? <span className="text-sm text-destructive">{error}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -0,0 +1,51 @@
|
||||
"use client"
|
||||
|
||||
import type { OrgLLMSettings } from "@/lib/admin-settings/org-settings-types"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
||||
export function AdminOrgLlmPolicyCard({
|
||||
draft,
|
||||
setDraft,
|
||||
}: {
|
||||
draft: OrgLLMSettings
|
||||
setDraft: React.Dispatch<React.SetStateAction<OrgLLMSettings>>
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Politique LLM</CardTitle>
|
||||
<CardDescription>
|
||||
Contrôle l'accès aux fournisseurs IA pour les utilisateurs de l'organisation.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<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}
|
||||
onCheckedChange={(enforce_org_providers) =>
|
||||
setDraft((p) => ({ ...p, enforce_org_providers }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Autoriser surcharge utilisateur</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={draft.allow_user_override}
|
||||
onCheckedChange={(allow_user_override) =>
|
||||
setDraft((p) => ({ ...p, allow_user_override }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@ -1,10 +1,16 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { Plus, RefreshCw, Trash2 } from "lucide-react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { RefreshCw } from "lucide-react"
|
||||
import { AiAuthorizedModelPicker } from "@/components/admin/settings/sections/ai-authorized-model-picker"
|
||||
import { UltiAiToolsCard } from "@/components/admin/settings/sections/ultiai-tools-card"
|
||||
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
|
||||
import { DeployLockedHint } from "@/components/admin/settings/deploy-locked-hint"
|
||||
import { useDeployFieldLocked } from "@/components/admin/settings/deploy-locked-hint"
|
||||
import { AdminOrgLlmPolicyCard } from "@/components/admin/settings/sections/admin-org-llm-providers-panel"
|
||||
import { LlmProvidersEditor } from "@/components/llm/llm-providers-editor"
|
||||
import { normalizeLlmProvider } from "@/lib/llm/llm-provider-catalog"
|
||||
import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry"
|
||||
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
|
||||
import type { AiModelCatalogEntry } from "@/lib/admin-settings/org-settings-types"
|
||||
import { useDiscoverOrgLLMModels } from "@/lib/api/hooks/use-admin-llm"
|
||||
@ -22,20 +28,14 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
|
||||
function emptyModelEntry(): AiModelCatalogEntry {
|
||||
return {
|
||||
model_id: "",
|
||||
label: "",
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function AiAssistantSection() {
|
||||
const aiAssistant = useOrgSettingsStore((s) => s.aiAssistant)
|
||||
const setAiAssistant = useOrgSettingsStore((s) => s.setAiAssistant)
|
||||
const setPlugins = useOrgSettingsStore((s) => s.setPlugins)
|
||||
const plugins = useOrgSettingsStore((s) => s.plugins)
|
||||
const llm = useOrgSettingsStore((s) => s.llm)
|
||||
const setLlm = useOrgSettingsStore((s) => s.setLlm)
|
||||
const secrets = useOrgSettingsStore((s) => s.meta?.secrets)
|
||||
const effective = useOrgSettingsStore((s) => s.meta?.effective.ai_assistant)
|
||||
const enabledLocked = useDeployFieldLocked("ai_assistant", "enabled")
|
||||
const publicPathLocked = useDeployFieldLocked("ai_assistant", "public_path")
|
||||
@ -44,14 +44,57 @@ export function AiAssistantSection() {
|
||||
const runtimeEnabled = effective?.enabled ?? false
|
||||
const orgEnabled = aiAssistant.enabled || pluginEnabled
|
||||
|
||||
const [llmDraft, setLlmDraft] = useState(llm)
|
||||
|
||||
useEffect(() => {
|
||||
setLlmDraft({
|
||||
...llm,
|
||||
providers: (llm.providers ?? []).map(normalizeLlmProvider),
|
||||
})
|
||||
}, [llm])
|
||||
|
||||
const [discoverProviderId, setDiscoverProviderId] = useState(llm.default_provider_id)
|
||||
const [discoveredModels, setDiscoveredModels] = useState<string[]>([])
|
||||
const discoverProvider = useMemo(
|
||||
() => llm.providers.find((p) => p.id === discoverProviderId) ?? llm.providers[0],
|
||||
[discoverProviderId, llm.providers],
|
||||
() => llmDraft.providers.find((p) => p.id === discoverProviderId) ?? llmDraft.providers[0],
|
||||
[discoverProviderId, llmDraft.providers],
|
||||
)
|
||||
const discoverModels = useDiscoverOrgLLMModels()
|
||||
|
||||
const defaultModelOptions = useMemo(() => {
|
||||
const ids = new Set<string>()
|
||||
if (aiAssistant.models.length > 0) {
|
||||
for (const entry of aiAssistant.models) {
|
||||
if (entry.enabled && entry.model_id.trim()) ids.add(entry.model_id.trim())
|
||||
}
|
||||
} else {
|
||||
for (const provider of llmDraft.providers) {
|
||||
if (provider.default_model?.trim()) ids.add(provider.default_model.trim())
|
||||
}
|
||||
for (const modelId of discoveredModels) {
|
||||
if (modelId.trim()) ids.add(modelId.trim())
|
||||
}
|
||||
}
|
||||
if (aiAssistant.default_model.trim()) ids.add(aiAssistant.default_model.trim())
|
||||
return Array.from(ids).sort((a, b) => a.localeCompare(b))
|
||||
}, [aiAssistant.models, aiAssistant.default_model, llmDraft.providers, discoveredModels])
|
||||
|
||||
useEffect(() => {
|
||||
if (!discoverProvider?.id) return
|
||||
let cancelled = false
|
||||
void discoverModels
|
||||
.mutateAsync(discoverProvider.id)
|
||||
.then((result) => {
|
||||
if (!cancelled) setDiscoveredModels(result.models ?? [])
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setDiscoveredModels([])
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [discoverProvider?.id])
|
||||
|
||||
async function handleDiscoverModels() {
|
||||
if (!discoverProvider?.id) return
|
||||
setDiscoveredModels([])
|
||||
@ -63,31 +106,14 @@ export function AiAssistantSection() {
|
||||
}
|
||||
}
|
||||
|
||||
function updateModel(index: number, patch: Partial<AiModelCatalogEntry>) {
|
||||
const models = aiAssistant.models.map((entry, i) =>
|
||||
i === index ? { ...entry, ...patch } : entry,
|
||||
)
|
||||
const orgLlmProviderSecrets = secrets?.llm_providers as
|
||||
| Record<string, { configured?: boolean }>
|
||||
| undefined
|
||||
|
||||
function setAuthorizedModels(models: AiModelCatalogEntry[]) {
|
||||
setAiAssistant({ models })
|
||||
}
|
||||
|
||||
function removeModel(index: number) {
|
||||
setAiAssistant({ models: aiAssistant.models.filter((_, i) => i !== index) })
|
||||
}
|
||||
|
||||
function addManualModel() {
|
||||
setAiAssistant({ models: [...aiAssistant.models, emptyModelEntry()] })
|
||||
}
|
||||
|
||||
function addDiscoveredModel(modelId: string) {
|
||||
if (aiAssistant.models.some((entry) => entry.model_id === modelId)) return
|
||||
setAiAssistant({
|
||||
models: [
|
||||
...aiAssistant.models,
|
||||
{ model_id: modelId, label: modelId, enabled: true },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
function setUltiAIEnabled(enabled: boolean) {
|
||||
setAiAssistant({ enabled })
|
||||
setPlugins(
|
||||
@ -100,249 +126,254 @@ export function AiAssistantSection() {
|
||||
return (
|
||||
<OrgSettingsSection
|
||||
title="UltiAI"
|
||||
description="Assistant IA intégré (OpenWebUI) avec gateway LLM, tools et sync Nextcloud."
|
||||
policySection={["ai_assistant", "plugins"]}
|
||||
description="Assistant IA intégré (OpenWebUI), fournisseurs LLM, gateway, tools et sync Nextcloud."
|
||||
policySection={["ai_assistant", "plugins", "llm"]}
|
||||
beforeSave={() => setLlm(llmDraft)}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<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>
|
||||
<AutomationTabMasonry columns={2}>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<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
|
||||
checked={orgEnabled}
|
||||
disabled={enabledLocked}
|
||||
onCheckedChange={setUltiAIEnabled}
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
checked={orgEnabled}
|
||||
disabled={enabledLocked}
|
||||
onCheckedChange={setUltiAIEnabled}
|
||||
/>
|
||||
</div>
|
||||
{enabledLocked ? (
|
||||
<DeployLockedHint section="ai_assistant" field="enabled" />
|
||||
) : null}
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
<Badge variant={orgEnabled ? "default" : "secondary"}>
|
||||
Politique org. {orgEnabled ? "activée" : "désactivée"}
|
||||
</Badge>
|
||||
<Badge variant={runtimeEnabled ? "default" : "outline"}>
|
||||
Runtime Compose {runtimeEnabled ? "actif" : "inactif"}
|
||||
</Badge>
|
||||
</div>
|
||||
{!orgEnabled && !runtimeEnabled ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Activez le plugin UltiAI dans Administration → Plugins, ou définissez{" "}
|
||||
<code className="rounded bg-muted px-1">AI_ASSISTANT_ENABLED=true</code> dans le
|
||||
déploiement, puis redémarrez le backend et OpenWebUI.
|
||||
</p>
|
||||
) : 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>
|
||||
<Input
|
||||
value={aiAssistant.public_path}
|
||||
onChange={(e) => setAiAssistant({ public_path: e.target.value })}
|
||||
placeholder="/ai"
|
||||
disabled={publicPathLocked}
|
||||
/>
|
||||
<DeployLockedHint section="ai_assistant" field="public_path" />
|
||||
</div>
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label>URL interne OpenWebUI</Label>
|
||||
<Input
|
||||
value={aiAssistant.openwebui_internal_url}
|
||||
onChange={(e) => setAiAssistant({ openwebui_internal_url: e.target.value })}
|
||||
placeholder="http://openwebui:8080"
|
||||
disabled={openwebuiLocked}
|
||||
/>
|
||||
<DeployLockedHint section="ai_assistant" field="openwebui_internal_url" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Modèle par défaut</Label>
|
||||
<Input
|
||||
value={aiAssistant.default_model}
|
||||
onChange={(e) => setAiAssistant({ default_model: e.target.value })}
|
||||
placeholder="gpt-4o"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Chemin historique NC</Label>
|
||||
<Input
|
||||
value={aiAssistant.chat_nc_path}
|
||||
onChange={(e) => setAiAssistant({ chat_nc_path: e.target.value })}
|
||||
placeholder="/.ultimail/ai/chats"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 sm:col-span-2">
|
||||
<div>
|
||||
<Label>Embed temporaire par défaut</Label>
|
||||
{enabledLocked ? (
|
||||
<DeployLockedHint section="ai_assistant" field="enabled" />
|
||||
) : null}
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
<Badge variant={orgEnabled ? "default" : "secondary"}>
|
||||
Politique org. {orgEnabled ? "activée" : "désactivée"}
|
||||
</Badge>
|
||||
<Badge variant={runtimeEnabled ? "default" : "outline"}>
|
||||
Runtime Compose {runtimeEnabled ? "actif" : "inactif"}
|
||||
</Badge>
|
||||
</div>
|
||||
{!orgEnabled && !runtimeEnabled ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Les panneaux mail/drive/contacts ne sauvegardent pas l'historique.
|
||||
Activez le plugin UltiAI dans Administration → Plugins, ou définissez{" "}
|
||||
<code className="rounded bg-muted px-1">AI_ASSISTANT_ENABLED=true</code> dans le
|
||||
déploiement, puis redémarrez le backend et OpenWebUI.
|
||||
</p>
|
||||
) : 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>
|
||||
<Input
|
||||
value={aiAssistant.public_path}
|
||||
onChange={(e) => setAiAssistant({ public_path: e.target.value })}
|
||||
placeholder="/ai"
|
||||
disabled={publicPathLocked}
|
||||
/>
|
||||
<DeployLockedHint section="ai_assistant" field="public_path" />
|
||||
</div>
|
||||
<Switch
|
||||
checked={aiAssistant.embed_default_temporary}
|
||||
onCheckedChange={(v) => setAiAssistant({ embed_default_temporary: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 sm:col-span-2">
|
||||
<div>
|
||||
<Label>Sync historique Nextcloud</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Pipeline OpenWebUI → fichiers .ultichat.json sur le drive utilisateur.
|
||||
</p>
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label>URL interne OpenWebUI</Label>
|
||||
<Input
|
||||
value={aiAssistant.openwebui_internal_url}
|
||||
onChange={(e) => setAiAssistant({ openwebui_internal_url: e.target.value })}
|
||||
placeholder="http://openwebui:8080"
|
||||
disabled={openwebuiLocked}
|
||||
/>
|
||||
<DeployLockedHint section="ai_assistant" field="openwebui_internal_url" />
|
||||
</div>
|
||||
<Switch
|
||||
checked={aiAssistant.chat_sync_enabled}
|
||||
onCheckedChange={(v) => setAiAssistant({ chat_sync_enabled: v })}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Modèles autorisés</CardTitle>
|
||||
<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">
|
||||
{llm.providers.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configurez d'abord un fournisseur LLM dans Administration → Fournisseurs LLM.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-end gap-3 rounded-lg border p-3">
|
||||
<div className="min-w-[220px] flex-1 space-y-2">
|
||||
<Label className="text-xs">Découvrir depuis le fournisseur</Label>
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label>Modèle par défaut</Label>
|
||||
{defaultModelOptions.length > 0 ? (
|
||||
<Select
|
||||
value={discoverProvider?.id ?? ""}
|
||||
onValueChange={setDiscoverProviderId}
|
||||
value={aiAssistant.default_model || "__auto__"}
|
||||
onValueChange={(value) =>
|
||||
setAiAssistant({
|
||||
default_model: value === "__auto__" ? "" : value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="Choisir un fournisseur…" />
|
||||
<SelectTrigger className="h-9 w-full min-w-0">
|
||||
<SelectValue placeholder="Choisir un modèle…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{llm.providers.map((provider) => (
|
||||
<SelectItem key={provider.id} value={provider.id}>
|
||||
{provider.name || provider.base_url}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectContent className="max-h-60">
|
||||
<SelectItem value="__auto__">
|
||||
Automatique (fournisseur LLM par défaut)
|
||||
</SelectItem>
|
||||
{defaultModelOptions.map((modelId) => {
|
||||
const catalogLabel = aiAssistant.models.find(
|
||||
(entry) => entry.model_id === modelId,
|
||||
)?.label
|
||||
return (
|
||||
<SelectItem key={modelId} value={modelId}>
|
||||
{catalogLabel?.trim() ? `${catalogLabel} (${modelId})` : modelId}
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!discoverProvider?.id || discoverModels.isPending}
|
||||
onClick={() => void handleDiscoverModels()}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`mr-2 size-4 ${discoverModels.isPending ? "animate-spin" : ""}`}
|
||||
) : (
|
||||
<Input
|
||||
value={aiAssistant.default_model}
|
||||
onChange={(e) => setAiAssistant({ default_model: e.target.value })}
|
||||
placeholder="gpt-4o-mini"
|
||||
/>
|
||||
Découvrir les modèles
|
||||
</Button>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Modèle pré-sélectionné dans UltiAI pour tous les utilisateurs. Configurez un
|
||||
fournisseur LLM ou découvrez les modèles ci-dessous.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{discoverModels.isError ? (
|
||||
<p className="text-sm text-destructive">
|
||||
{discoverModels.error instanceof Error
|
||||
? discoverModels.error.message
|
||||
: "Impossible de lister les modèles sur ce fournisseur. Enregistrez d'abord le fournisseur LLM avec une clé API valide."}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{discoveredModels.length ? (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Modèles disponibles sur l'endpoint</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{discoveredModels.map((modelId) => {
|
||||
const alreadyAdded = aiAssistant.models.some(
|
||||
(entry) => entry.model_id === modelId,
|
||||
)
|
||||
return (
|
||||
<Button
|
||||
key={modelId}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={alreadyAdded ? "secondary" : "outline"}
|
||||
disabled={alreadyAdded}
|
||||
onClick={() => addDiscoveredModel(modelId)}
|
||||
>
|
||||
{alreadyAdded ? "Ajouté" : `+ ${modelId}`}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label>Chemin historique NC</Label>
|
||||
<Input
|
||||
value={aiAssistant.chat_nc_path}
|
||||
onChange={(e) => setAiAssistant({ chat_nc_path: e.target.value })}
|
||||
placeholder="/.ultimail/ai/chats"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 sm:col-span-2">
|
||||
<div>
|
||||
<Label>Embed temporaire par défaut</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Les panneaux mail/drive/contacts ne sauvegardent pas l'historique.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={aiAssistant.embed_default_temporary}
|
||||
onCheckedChange={(v) => setAiAssistant({ embed_default_temporary: v })}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center justify-between gap-4 sm:col-span-2">
|
||||
<div>
|
||||
<Label>Sync historique Nextcloud</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Pipeline OpenWebUI → fichiers .ultichat.json sur le drive utilisateur.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={aiAssistant.chat_sync_enabled}
|
||||
onCheckedChange={(v) => setAiAssistant({ chat_sync_enabled: v })}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Catalogue organisation</Label>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addManualModel}>
|
||||
<Plus className="mr-2 size-4" />
|
||||
Ajouter manuellement
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 sm:col-span-2">
|
||||
<AdminOrgLlmPolicyCard draft={llmDraft} setDraft={setLlmDraft} />
|
||||
|
||||
{aiAssistant.models.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Aucune restriction — tous les modèles LLM configurés restent disponibles.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{aiAssistant.models.map((entry, index) => (
|
||||
<div
|
||||
key={`${entry.model_id}-${index}`}
|
||||
className="grid gap-2 rounded-lg border p-3 sm:grid-cols-[1fr_1fr_auto_auto]"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">ID modèle</Label>
|
||||
<Input
|
||||
className="h-9"
|
||||
value={entry.model_id}
|
||||
onChange={(e) => updateModel(index, { model_id: e.target.value })}
|
||||
placeholder="gpt-4o-mini"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Surnom utilisateur</Label>
|
||||
<Input
|
||||
className="h-9"
|
||||
value={entry.label}
|
||||
onChange={(e) => updateModel(index, { label: e.target.value })}
|
||||
placeholder="GPT-4o Mini"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 self-end pb-1 text-sm">
|
||||
<Switch
|
||||
checked={entry.enabled}
|
||||
onCheckedChange={(enabled) => updateModel(index, { enabled })}
|
||||
/>
|
||||
Autorisé
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="self-end"
|
||||
onClick={() => removeModel(index)}
|
||||
aria-label="Supprimer le modèle"
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Fournisseurs LLM</CardTitle>
|
||||
<CardDescription>
|
||||
Modèles IA organisationnels pour UltiAI, le tri, l'enrichissement contacts et
|
||||
les automatisations.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<LlmProvidersEditor
|
||||
columns={1}
|
||||
providers={llmDraft.providers}
|
||||
defaultProviderId={llmDraft.default_provider_id}
|
||||
providerSecrets={orgLlmProviderSecrets}
|
||||
onProvidersChange={(providers) =>
|
||||
setLlmDraft((prev) => ({ ...prev, providers }))
|
||||
}
|
||||
onDefaultProviderIdChange={(default_provider_id) =>
|
||||
setLlmDraft((prev) => ({ ...prev, default_provider_id }))
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<UltiAiToolsCard
|
||||
enabledTools={aiAssistant.enabled_tools}
|
||||
onChange={(enabled_tools) => setAiAssistant({ enabled_tools })}
|
||||
webSearchSettingsHref="/admin/settings/search"
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Modèles autorisés</CardTitle>
|
||||
<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 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configurez d'abord un fournisseur LLM dans la section ci-dessus.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-end gap-3 rounded-lg border p-3">
|
||||
<div className="min-w-[220px] flex-1 space-y-2">
|
||||
<Label className="text-xs">Découvrir depuis le fournisseur</Label>
|
||||
<Select
|
||||
value={discoverProvider?.id ?? ""}
|
||||
onValueChange={setDiscoverProviderId}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="Choisir un fournisseur…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{llmDraft.providers.map((provider) => (
|
||||
<SelectItem key={provider.id} value={provider.id}>
|
||||
{provider.name || provider.base_url}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!discoverProvider?.id || discoverModels.isPending}
|
||||
onClick={() => void handleDiscoverModels()}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`mr-2 size-4 ${discoverModels.isPending ? "animate-spin" : ""}`}
|
||||
/>
|
||||
Découvrir les modèles
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{discoverModels.isError ? (
|
||||
<p className="text-sm text-destructive">
|
||||
{discoverModels.error instanceof Error
|
||||
? discoverModels.error.message
|
||||
: "Impossible de lister les modèles sur ce fournisseur. Enregistrez d'abord le fournisseur LLM avec une clé API valide."}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{llmDraft.providers.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<Label>Catalogue organisation</Label>
|
||||
<AiAuthorizedModelPicker
|
||||
models={aiAssistant.models}
|
||||
onChange={setAuthorizedModels}
|
||||
availableModelIds={discoveredModels}
|
||||
emptyHint="Aucune restriction — tous les modèles LLM configurés restent disponibles."
|
||||
/>
|
||||
{discoveredModels.length === 0 ? (
|
||||
<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}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AutomationTabMasonry>
|
||||
</OrgSettingsSection>
|
||||
)
|
||||
}
|
||||
|
||||
@ -0,0 +1,204 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useRef, useState } from "react"
|
||||
import { X } from "lucide-react"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import type { AiModelCatalogEntry } from "@/lib/admin-settings/org-settings-types"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const VISIBLE_SUGGESTIONS = 5
|
||||
const SUGGESTION_ROW_HEIGHT_REM = 2.25
|
||||
|
||||
function modelKey(entry: AiModelCatalogEntry) {
|
||||
return entry.model_id
|
||||
}
|
||||
|
||||
function chipLabel(entry: AiModelCatalogEntry) {
|
||||
const label = entry.label.trim()
|
||||
return label && label !== entry.model_id ? label : entry.model_id
|
||||
}
|
||||
|
||||
export function AiAuthorizedModelPicker({
|
||||
models,
|
||||
onChange,
|
||||
availableModelIds,
|
||||
disabled,
|
||||
emptyHint,
|
||||
}: {
|
||||
models: AiModelCatalogEntry[]
|
||||
onChange: (models: AiModelCatalogEntry[]) => void
|
||||
availableModelIds: string[]
|
||||
disabled?: boolean
|
||||
emptyHint?: string
|
||||
}) {
|
||||
const [query, setQuery] = useState("")
|
||||
const [focused, setFocused] = useState(false)
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const blurTimer = useRef<number | null>(null)
|
||||
|
||||
const taken = useMemo(() => new Set(models.map((m) => m.model_id)), [models])
|
||||
|
||||
const suggestions = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
const pool = availableModelIds.filter((id) => !taken.has(id))
|
||||
const matches = q ? pool.filter((id) => id.toLowerCase().includes(q)) : pool
|
||||
return matches.sort((a, b) => a.localeCompare(b))
|
||||
}, [availableModelIds, query, taken])
|
||||
|
||||
const showSuggestions = focused && !disabled && suggestions.length > 0
|
||||
|
||||
function addModel(modelId: string) {
|
||||
const id = modelId.trim()
|
||||
if (!id || taken.has(id)) return
|
||||
onChange([...models, { model_id: id, label: id, enabled: true }])
|
||||
setQuery("")
|
||||
setActiveIndex(0)
|
||||
}
|
||||
|
||||
function removeModel(modelId: string) {
|
||||
onChange(models.filter((entry) => entry.model_id !== modelId))
|
||||
}
|
||||
|
||||
function updateLabel(modelId: string, label: string) {
|
||||
onChange(
|
||||
models.map((entry) =>
|
||||
entry.model_id === modelId ? { ...entry, label } : entry,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function tryAddFromQuery() {
|
||||
const id = query.trim()
|
||||
if (!id) return false
|
||||
if (suggestions[activeIndex]) {
|
||||
addModel(suggestions[activeIndex]!)
|
||||
return true
|
||||
}
|
||||
if (!taken.has(id)) {
|
||||
addModel(id)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={query}
|
||||
disabled={disabled}
|
||||
placeholder="Rechercher ou saisir un modèle…"
|
||||
className="h-9"
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
setActiveIndex(0)
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (blurTimer.current) window.clearTimeout(blurTimer.current)
|
||||
setFocused(true)
|
||||
}}
|
||||
onBlur={() => {
|
||||
blurTimer.current = window.setTimeout(() => setFocused(false), 120)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
tryAddFromQuery()
|
||||
return
|
||||
}
|
||||
if (!showSuggestions || suggestions.length === 0) return
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
setActiveIndex((i) => (i + 1) % suggestions.length)
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault()
|
||||
setActiveIndex((i) => (i - 1 + suggestions.length) % suggestions.length)
|
||||
} else if (e.key === "Escape") {
|
||||
setFocused(false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{showSuggestions ? (
|
||||
<ul
|
||||
className="absolute z-20 mt-1 w-full overflow-y-auto rounded-md border border-border bg-popover py-1 shadow-md"
|
||||
style={{ maxHeight: `${VISIBLE_SUGGESTIONS * SUGGESTION_ROW_HEIGHT_REM}rem` }}
|
||||
>
|
||||
{suggestions.map((modelId, index) => (
|
||||
<li key={modelId}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"block w-full px-3 py-1.5 text-left text-xs hover:bg-muted",
|
||||
index === activeIndex && "bg-muted",
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
addModel(modelId)
|
||||
}}
|
||||
>
|
||||
<span className="block truncate font-mono">{modelId}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{models.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{models.map((entry) => (
|
||||
<span
|
||||
key={modelKey(entry)}
|
||||
className="inline-flex max-w-full items-center gap-1 rounded-full border border-border bg-muted/60 px-2 py-0.5 text-[11px] text-foreground"
|
||||
>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="max-w-[14rem] truncate text-left hover:underline"
|
||||
title={entry.model_id}
|
||||
>
|
||||
{chipLabel(entry)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-72 space-y-2 p-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">ID modèle</Label>
|
||||
<p className="truncate font-mono text-xs text-muted-foreground">
|
||||
{entry.model_id}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Surnom utilisateur</Label>
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
value={entry.label}
|
||||
onChange={(e) => updateLabel(entry.model_id, e.target.value)}
|
||||
placeholder={entry.model_id}
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-full p-0.5 hover:bg-background/80"
|
||||
aria-label={`Retirer ${entry.model_id}`}
|
||||
onClick={() => removeModel(entry.model_id)}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : emptyHint ? (
|
||||
<p className="text-sm text-muted-foreground">{emptyHint}</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,13 +1,17 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useRef } from "react"
|
||||
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
|
||||
import { IdentityProvidersSection } from "@/components/admin/settings/sections/identity-providers-section"
|
||||
import { AdminSettingsCard } from "@/components/admin/settings/admin-settings-card"
|
||||
import { FieldGroup } from "@/components/admin/settings/field-group"
|
||||
import { IdentityProvidersPanel } from "@/components/admin/settings/sections/identity-providers-section"
|
||||
import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry"
|
||||
import { DeployLockedHint, useDeployFieldLocked } from "@/components/admin/settings/deploy-locked-hint"
|
||||
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
||||
export function AuthenticationSection() {
|
||||
const authentik = useOrgSettingsStore((s) => s.authentik)
|
||||
@ -22,95 +26,119 @@ export function AuthenticationSection() {
|
||||
const apiURL = apiLocked ? (effective?.api_url ?? authentik.api_url) : authentik.api_url
|
||||
const clientID = clientLocked ? (effective?.client_id ?? authentik.client_id) : authentik.client_id
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<OrgSettingsSection
|
||||
title="Authentification Authentik"
|
||||
description="SSO, provisionnement des comptes Ultimail et groupes par défaut."
|
||||
policySection="authentik"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle className="text-sm font-medium">Authentik activé</CardTitle>
|
||||
<CardDescription>Connexion via le fournisseur d'identité organisationnel.</CardDescription>
|
||||
{enabledLocked ? <DeployLockedHint section="authentik" field="enabled" /> : null}
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
disabled={enabledLocked}
|
||||
onCheckedChange={(v) => setAuthentik({ enabled: v })}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<Label>URL API Authentik</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
value={apiURL}
|
||||
disabled={apiLocked}
|
||||
onChange={(e) => setAuthentik({ api_url: e.target.value })}
|
||||
placeholder="https://auth.example.com/api/v3"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Slug application</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
value={authentik.slug}
|
||||
onChange={(e) => setAuthentik({ slug: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Client ID OIDC</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
value={clientID}
|
||||
disabled={clientLocked}
|
||||
onChange={(e) => setAuthentik({ client_id: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<Label>Groupes par défaut (séparés par des virgules)</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
value={authentik.default_groups}
|
||||
onChange={(e) => setAuthentik({ default_groups: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center justify-between gap-4 rounded-lg border p-3 sm:col-span-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Forcer le SSO</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Désactive la connexion locale sauf pour les administrateurs.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={authentik.enforce_sso}
|
||||
onCheckedChange={(enforce_sso) => setAuthentik({ enforce_sso })}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between gap-4 rounded-lg border p-3 sm:col-span-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Mot de passe local de secours</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Autoriser un fallback mot de passe si Authentik est indisponible.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={authentik.allow_password_fallback}
|
||||
onCheckedChange={(allow_password_fallback) =>
|
||||
setAuthentik({ allow_password_fallback })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</OrgSettingsSection>
|
||||
const identityBeforeSaveRef = useRef<(() => void) | null>(null)
|
||||
const registerIdentityBeforeSave = useCallback((fn: (() => void) | null) => {
|
||||
identityBeforeSaveRef.current = fn
|
||||
}, [])
|
||||
|
||||
<IdentityProvidersSection />
|
||||
</div>
|
||||
return (
|
||||
<OrgSettingsSection
|
||||
title="Authentification"
|
||||
description="SSO Authentik, provisionnement des comptes Ultimail et fournisseurs d'identité upstream."
|
||||
policySection={["authentik", "identity_providers"]}
|
||||
beforeSave={async () => {
|
||||
identityBeforeSaveRef.current?.()
|
||||
}}
|
||||
>
|
||||
<AutomationTabMasonry columns={2}>
|
||||
<Card className="gap-0 py-0">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium">Authentik</p>
|
||||
<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
|
||||
checked={enabled}
|
||||
disabled={enabledLocked}
|
||||
onCheckedChange={(v) => setAuthentik({ enabled: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-4 border-t pt-4">
|
||||
<FieldGroup>
|
||||
<Label>URL API Authentik</Label>
|
||||
<Input
|
||||
className="h-9"
|
||||
value={apiURL}
|
||||
disabled={apiLocked}
|
||||
onChange={(e) => setAuthentik({ api_url: e.target.value })}
|
||||
placeholder="https://auth.example.com/api/v3"
|
||||
/>
|
||||
{apiLocked ? <DeployLockedHint section="authentik" field="api_url" /> : null}
|
||||
</FieldGroup>
|
||||
|
||||
<div className="grid min-w-0 gap-4">
|
||||
<FieldGroup>
|
||||
<Label>Slug application</Label>
|
||||
<Input
|
||||
className="h-9"
|
||||
value={authentik.slug}
|
||||
onChange={(e) => setAuthentik({ slug: e.target.value })}
|
||||
/>
|
||||
</FieldGroup>
|
||||
<FieldGroup>
|
||||
<Label>Client ID OIDC</Label>
|
||||
<Input
|
||||
className="h-9"
|
||||
value={clientID}
|
||||
disabled={clientLocked}
|
||||
onChange={(e) => setAuthentik({ client_id: e.target.value })}
|
||||
/>
|
||||
{clientLocked ? <DeployLockedHint section="authentik" field="client_id" /> : null}
|
||||
</FieldGroup>
|
||||
</div>
|
||||
|
||||
<FieldGroup>
|
||||
<Label>Groupes par défaut (séparés par des virgules)</Label>
|
||||
<Input
|
||||
className="h-9"
|
||||
value={authentik.default_groups}
|
||||
onChange={(e) => setAuthentik({ default_groups: e.target.value })}
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
<label className="flex items-center justify-between gap-4 rounded-lg border p-3">
|
||||
<FieldGroup>
|
||||
<p className="text-sm font-medium">Forcer le SSO</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Désactive la connexion locale sauf pour les administrateurs.
|
||||
</p>
|
||||
</FieldGroup>
|
||||
<Switch
|
||||
checked={authentik.enforce_sso}
|
||||
onCheckedChange={(enforce_sso) => setAuthentik({ enforce_sso })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between gap-4 rounded-lg border p-3">
|
||||
<FieldGroup>
|
||||
<p className="text-sm font-medium">Mot de passe local de secours</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Autoriser un fallback mot de passe si Authentik est indisponible.
|
||||
</p>
|
||||
</FieldGroup>
|
||||
<Switch
|
||||
checked={authentik.allow_password_fallback}
|
||||
onCheckedChange={(allow_password_fallback) =>
|
||||
setAuthentik({ allow_password_fallback })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AdminSettingsCard
|
||||
title="Fournisseurs d'identité"
|
||||
description="Sources upstream Authentik (OAuth, SAML, LDAP) avec restrictions d'accès."
|
||||
>
|
||||
<IdentityProvidersPanel onRegisterBeforeSave={registerIdentityBeforeSave} />
|
||||
</AdminSettingsCard>
|
||||
</AutomationTabMasonry>
|
||||
</OrgSettingsSection>
|
||||
)
|
||||
}
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { Check, Copy } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { TechBrandSelectLabel } from "@/components/admin/settings/tech-brand-select-label"
|
||||
import { FieldGroup } from "@/components/admin/settings/field-group"
|
||||
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
|
||||
import type { DriveMountOAuthProvider, DriveMountOAuthSettings } from "@/lib/admin-settings/org-settings-types"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@ -11,21 +13,29 @@ import { Label } from "@/components/ui/label"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { buildDriveMountOAuthRedirectURI } from "@/lib/drive/drive-mount-oauth"
|
||||
|
||||
const PROVIDERS: { id: DriveMountOAuthProvider; label: string; hint: string }[] = [
|
||||
const PROVIDERS: {
|
||||
id: DriveMountOAuthProvider
|
||||
label: string
|
||||
hint: string
|
||||
icon: string
|
||||
}[] = [
|
||||
{
|
||||
id: "google",
|
||||
label: "Google Drive",
|
||||
hint: "Console Google Cloud — API Drive, redirect URI ci-dessous",
|
||||
icon: "logos:google-drive",
|
||||
},
|
||||
{
|
||||
id: "dropbox",
|
||||
label: "Dropbox",
|
||||
hint: "App Dropbox — permissions files.metadata.read, files.content.read/write",
|
||||
icon: "logos:dropbox",
|
||||
},
|
||||
{
|
||||
id: "microsoft",
|
||||
label: "Microsoft OneDrive",
|
||||
hint: "Azure AD — Microsoft Graph Files.ReadWrite",
|
||||
icon: "logos:microsoft-onedrive",
|
||||
},
|
||||
]
|
||||
|
||||
@ -38,9 +48,11 @@ const SECRET_KEYS: Record<DriveMountOAuthProvider, "mount_oauth_google" | "mount
|
||||
export function DriveMountOAuthSection({
|
||||
draft,
|
||||
onChange,
|
||||
embedded = false,
|
||||
}: {
|
||||
draft: DriveMountOAuthSettings
|
||||
onChange: (next: DriveMountOAuthSettings) => void
|
||||
embedded?: boolean
|
||||
}) {
|
||||
const secrets = useOrgSettingsStore((s) => s.meta?.secrets)
|
||||
const [redirectUri, setRedirectUri] = useState("")
|
||||
@ -70,16 +82,18 @@ export function DriveMountOAuthSection({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-lg border p-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">Connexion cloud (OAuth)</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Permet aux utilisateurs de monter Google Drive, Dropbox ou OneDrive depuis UltiDrive.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className={embedded ? "space-y-4" : "space-y-4 rounded-lg border p-4"}>
|
||||
{!embedded ? (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">Connexion cloud (OAuth)</h3>
|
||||
<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="mt-1 flex gap-2">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
className="h-9 flex-1 font-mono text-xs"
|
||||
readOnly
|
||||
@ -98,41 +112,44 @@ export function DriveMountOAuthSection({
|
||||
Copier
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Basée sur l'URL actuelle du navigateur. Enregistrez-la chez chaque fournisseur OAuth (Google, Dropbox, Microsoft).
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Basée sur l'URL actuelle du navigateur. Enregistrez-la chez chaque fournisseur OAuth
|
||||
(Google, Dropbox, Microsoft).
|
||||
</p>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
<div className="space-y-4">
|
||||
{PROVIDERS.map(({ id, label, hint }) => {
|
||||
{PROVIDERS.map(({ id, label, hint, icon }) => {
|
||||
const provider = draft[id]
|
||||
const configured = Boolean(secrets?.[SECRET_KEYS[id]]?.configured)
|
||||
return (
|
||||
<div key={id} className="space-y-3 rounded-md border p-3">
|
||||
<label className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
<FieldGroup>
|
||||
<TechBrandSelectLabel icon={icon} className="text-sm font-medium">
|
||||
{label}
|
||||
</TechBrandSelectLabel>
|
||||
<p className="text-xs text-muted-foreground">{hint}</p>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
<Switch
|
||||
checked={provider.enabled}
|
||||
onCheckedChange={(enabled) => updateProvider(id, { enabled })}
|
||||
/>
|
||||
</label>
|
||||
{provider.enabled ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<div className="grid min-w-0 gap-4">
|
||||
<FieldGroup>
|
||||
<Label>Client ID</Label>
|
||||
<Input
|
||||
className="mt-1 h-9 font-mono text-xs"
|
||||
className="h-9 font-mono text-xs"
|
||||
value={provider.client_id}
|
||||
onChange={(e) => updateProvider(id, { client_id: e.target.value })}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
</FieldGroup>
|
||||
<FieldGroup>
|
||||
<Label>Client secret</Label>
|
||||
<Input
|
||||
className="mt-1 h-9 font-mono text-xs"
|
||||
className="h-9 font-mono text-xs"
|
||||
type="password"
|
||||
value={provider.client_secret}
|
||||
onChange={(e) => updateProvider(id, { client_secret: e.target.value })}
|
||||
@ -140,9 +157,9 @@ export function DriveMountOAuthSection({
|
||||
autoComplete="off"
|
||||
/>
|
||||
{configured && !provider.client_secret.trim() ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">Secret configuré</p>
|
||||
<p className="text-xs text-muted-foreground">Secret configuré</p>
|
||||
) : null}
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { FieldGroup } from "@/components/admin/settings/field-group"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
@ -9,7 +10,7 @@ import {
|
||||
useAdminDriveOrgFolders,
|
||||
} from "@/lib/api/hooks/use-admin-drive-queries"
|
||||
|
||||
export function DriveOrgFoldersSection() {
|
||||
export function DriveOrgFoldersSection({ embedded = false }: { embedded?: boolean }) {
|
||||
const folders = useAdminDriveOrgFolders()
|
||||
const { create, remove, sync } = useAdminDriveOrgFolderMutations()
|
||||
const [orgSlug, setOrgSlug] = useState("")
|
||||
@ -17,23 +18,25 @@ export function DriveOrgFoldersSection() {
|
||||
const [syncSlugs, setSyncSlugs] = useState("")
|
||||
|
||||
return (
|
||||
<div className="space-y-6 rounded-lg border p-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">Dossiers d'organisation</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Group folders Nextcloud liés aux organisations Authentik.
|
||||
</p>
|
||||
</div>
|
||||
<div className={embedded ? "space-y-4" : "space-y-6 rounded-lg border p-4"}>
|
||||
{!embedded ? (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">Dossiers d'organisation</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Espaces de stockage internes (group folders Nextcloud) liés aux organisations Authentik.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="grid gap-1.5">
|
||||
<div className="grid min-w-0 gap-4">
|
||||
<FieldGroup>
|
||||
<Label htmlFor="org-slug">Slug organisation</Label>
|
||||
<Input id="org-slug" value={orgSlug} onChange={(e) => setOrgSlug(e.target.value)} placeholder="acme" />
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
</FieldGroup>
|
||||
<FieldGroup>
|
||||
<Label htmlFor="org-mount">Nom du dossier</Label>
|
||||
<Input id="org-mount" value={mountPoint} onChange={(e) => setMountPoint(e.target.value)} placeholder="Acme Corp" />
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
@ -45,8 +48,12 @@ export function DriveOrgFoldersSection() {
|
||||
Créer le dossier
|
||||
</Button>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="sync-orgs">Sync auto (slugs séparés par des virgules)</Label>
|
||||
<FieldGroup>
|
||||
<Label htmlFor="sync-orgs">Provisionnement automatique</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Crée un dossier d'organisation pour chaque slug listé, s'il n'existe pas encore.
|
||||
Les slugs correspondent aux organisations Authentik.
|
||||
</p>
|
||||
<Input
|
||||
id="sync-orgs"
|
||||
value={syncSlugs}
|
||||
@ -63,9 +70,9 @@ export function DriveOrgFoldersSection() {
|
||||
)
|
||||
}
|
||||
>
|
||||
Provisionner
|
||||
Provisionner les dossiers
|
||||
</Button>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
|
||||
<ul className="divide-y rounded-md border text-sm">
|
||||
{(folders.data ?? []).map((folder) => (
|
||||
|
||||
183
components/admin/settings/sections/drive-org-webdav-section.tsx
Normal file
183
components/admin/settings/sections/drive-org-webdav-section.tsx
Normal file
@ -0,0 +1,183 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { FieldGroup } from "@/components/admin/settings/field-group"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
useAdminDriveOrgMountMutations,
|
||||
useAdminDriveOrgMounts,
|
||||
} from "@/lib/api/hooks/use-admin-drive-queries"
|
||||
|
||||
function mountStatusLabel(status: string) {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return "Actif"
|
||||
case "error":
|
||||
return "Erreur"
|
||||
case "pending":
|
||||
return "En attente"
|
||||
default:
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
||||
export function DriveOrgWebDAVSection({ embedded = false }: { embedded?: boolean }) {
|
||||
const mounts = useAdminDriveOrgMounts()
|
||||
const { create, remove } = useAdminDriveOrgMountMutations()
|
||||
|
||||
const [orgSlug, setOrgSlug] = useState("")
|
||||
const [displayName, setDisplayName] = useState("")
|
||||
const [host, setHost] = useState("")
|
||||
const [root, setRoot] = useState("/")
|
||||
const [userName, setUserName] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [secure, setSecure] = useState(true)
|
||||
|
||||
const canCreate =
|
||||
orgSlug.trim() &&
|
||||
displayName.trim() &&
|
||||
host.trim() &&
|
||||
userName.trim() &&
|
||||
password.trim()
|
||||
|
||||
return (
|
||||
<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">
|
||||
Le slug d'organisation sert au rattachement administratif. Le volume est monté globalement
|
||||
dans Nextcloud et apparaît dans UltiDrive pour tous les utilisateurs.
|
||||
</p>
|
||||
|
||||
<div className="grid min-w-0 gap-4 sm:grid-cols-2">
|
||||
<FieldGroup>
|
||||
<Label htmlFor="webdav-org-slug">Slug organisation</Label>
|
||||
<Input
|
||||
id="webdav-org-slug"
|
||||
value={orgSlug}
|
||||
onChange={(e) => setOrgSlug(e.target.value)}
|
||||
placeholder="acme"
|
||||
/>
|
||||
</FieldGroup>
|
||||
<FieldGroup>
|
||||
<Label htmlFor="webdav-display-name">Nom affiché</Label>
|
||||
<Input
|
||||
id="webdav-display-name"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="NAS partagé"
|
||||
/>
|
||||
</FieldGroup>
|
||||
<FieldGroup>
|
||||
<Label htmlFor="webdav-host">Hôte</Label>
|
||||
<Input
|
||||
id="webdav-host"
|
||||
value={host}
|
||||
onChange={(e) => setHost(e.target.value)}
|
||||
placeholder="nas.example.com"
|
||||
/>
|
||||
</FieldGroup>
|
||||
<FieldGroup>
|
||||
<Label htmlFor="webdav-root">Chemin racine</Label>
|
||||
<Input
|
||||
id="webdav-root"
|
||||
value={root}
|
||||
onChange={(e) => setRoot(e.target.value)}
|
||||
placeholder="/remote.php/dav/files/user"
|
||||
/>
|
||||
</FieldGroup>
|
||||
<FieldGroup>
|
||||
<Label htmlFor="webdav-user">Utilisateur</Label>
|
||||
<Input id="webdav-user" value={userName} onChange={(e) => setUserName(e.target.value)} />
|
||||
</FieldGroup>
|
||||
<FieldGroup>
|
||||
<Label htmlFor="webdav-pass">Mot de passe</Label>
|
||||
<Input
|
||||
id="webdav-pass"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</FieldGroup>
|
||||
</div>
|
||||
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={secure} onChange={(e) => setSecure(e.target.checked)} />
|
||||
Connexion HTTPS
|
||||
</label>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!canCreate || create.isPending}
|
||||
onClick={() =>
|
||||
void create.mutateAsync({
|
||||
org_slug: orgSlug.trim(),
|
||||
display_name: displayName.trim(),
|
||||
webdav: {
|
||||
host: host.trim(),
|
||||
root: root.trim() || "/",
|
||||
user: userName.trim(),
|
||||
password,
|
||||
secure,
|
||||
},
|
||||
}).then(() => {
|
||||
setOrgSlug("")
|
||||
setDisplayName("")
|
||||
setHost("")
|
||||
setRoot("/")
|
||||
setUserName("")
|
||||
setPassword("")
|
||||
setSecure(true)
|
||||
})
|
||||
}
|
||||
>
|
||||
Ajouter le montage
|
||||
</Button>
|
||||
|
||||
<ul className="divide-y rounded-md border text-sm">
|
||||
{(mounts.data ?? []).map((mount) => (
|
||||
<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="flex flex-wrap items-center gap-2">
|
||||
<p className="truncate font-medium">{mount.display_name}</p>
|
||||
<Badge variant={mount.status === "active" ? "secondary" : "destructive"} className="text-[10px]">
|
||||
{mountStatusLabel(mount.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{mount.org_slug ?? "—"} · WebDAV · {mount.mount_point}
|
||||
</p>
|
||||
{mount.last_error ? (
|
||||
<p className="text-xs text-destructive">{mount.last_error}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="shrink-0 text-destructive"
|
||||
disabled={remove.isPending}
|
||||
onClick={() => void remove.mutateAsync(mount.id)}
|
||||
>
|
||||
Supprimer
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
{mounts.data?.length === 0 ? (
|
||||
<li className="px-3 py-4 text-center text-muted-foreground">Aucun montage WebDAV d'organisation</li>
|
||||
) : null}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -2,6 +2,9 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
|
||||
import { AdminSettingsCard } from "@/components/admin/settings/admin-settings-card"
|
||||
import { FieldGroup } from "@/components/admin/settings/field-group"
|
||||
import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry"
|
||||
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
@ -15,6 +18,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { DriveOrgFoldersSection } from "@/components/admin/settings/sections/drive-org-section"
|
||||
import { DriveOrgWebDAVSection } from "@/components/admin/settings/sections/drive-org-webdav-section"
|
||||
import { DriveMountOAuthSection } from "@/components/admin/settings/sections/drive-mount-oauth-section"
|
||||
|
||||
export function FilePoliciesSection() {
|
||||
@ -40,120 +44,151 @@ export function FilePoliciesSection() {
|
||||
policySection="file_policies"
|
||||
beforeSave={() => setFilePolicies({ mount_oauth: mountOAuthDraft })}
|
||||
>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label>Taille max upload (Mo)</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
type="number"
|
||||
min={1}
|
||||
value={filePolicies.max_upload_mib}
|
||||
onChange={(e) =>
|
||||
setFilePolicies({ max_upload_mib: Number(e.target.value) || 1 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Expiration liens par défaut (jours)</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
type="number"
|
||||
min={1}
|
||||
value={filePolicies.default_link_expiry_days}
|
||||
onChange={(e) =>
|
||||
setFilePolicies({
|
||||
default_link_expiry_days: Number(e.target.value) || 1,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Rétention corbeille (jours)</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
type="number"
|
||||
min={1}
|
||||
value={filePolicies.retention_trash_days}
|
||||
onChange={(e) =>
|
||||
setFilePolicies({ retention_trash_days: Number(e.target.value) || 1 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Partage externe</Label>
|
||||
<Select
|
||||
value={filePolicies.external_sharing}
|
||||
onValueChange={(external_sharing) =>
|
||||
setFilePolicies({
|
||||
external_sharing: external_sharing as typeof filePolicies.external_sharing,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="disabled">Désactivé</SelectItem>
|
||||
<SelectItem value="authenticated">Utilisateurs authentifiés</SelectItem>
|
||||
<SelectItem value="public_link">Liens publics autorisés</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<Label>Extensions autorisées (vide = toutes)</Label>
|
||||
<Textarea
|
||||
className="mt-1 min-h-[80px] font-mono text-xs"
|
||||
value={filePolicies.allowed_extensions}
|
||||
onChange={(e) => setFilePolicies({ allowed_extensions: e.target.value })}
|
||||
placeholder="pdf, docx, png, jpg"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center justify-between gap-4 rounded-lg border p-3 sm:col-span-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Bloquer les exécutables</p>
|
||||
<p className="text-xs text-muted-foreground">exe, bat, sh, app, etc.</p>
|
||||
<AutomationTabMasonry columns={2}>
|
||||
<AdminSettingsCard
|
||||
title="Politiques UltiDrive"
|
||||
description="Limites d'upload, partage externe, extensions et analyse antivirus."
|
||||
>
|
||||
<div className="grid min-w-0 gap-4">
|
||||
<FieldGroup>
|
||||
<Label>Taille max upload (Mo)</Label>
|
||||
<Input
|
||||
className="h-9"
|
||||
type="number"
|
||||
min={1}
|
||||
value={filePolicies.max_upload_mib}
|
||||
onChange={(e) =>
|
||||
setFilePolicies({ max_upload_mib: Number(e.target.value) || 1 })
|
||||
}
|
||||
/>
|
||||
</FieldGroup>
|
||||
<FieldGroup>
|
||||
<Label>Expiration liens par défaut (jours)</Label>
|
||||
<Input
|
||||
className="h-9"
|
||||
type="number"
|
||||
min={1}
|
||||
value={filePolicies.default_link_expiry_days}
|
||||
onChange={(e) =>
|
||||
setFilePolicies({
|
||||
default_link_expiry_days: Number(e.target.value) || 1,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</FieldGroup>
|
||||
<FieldGroup>
|
||||
<Label>Rétention corbeille (jours)</Label>
|
||||
<Input
|
||||
className="h-9"
|
||||
type="number"
|
||||
min={1}
|
||||
value={filePolicies.retention_trash_days}
|
||||
onChange={(e) =>
|
||||
setFilePolicies({ retention_trash_days: Number(e.target.value) || 1 })
|
||||
}
|
||||
/>
|
||||
</FieldGroup>
|
||||
<FieldGroup>
|
||||
<Label>Partage externe</Label>
|
||||
<Select
|
||||
value={filePolicies.external_sharing}
|
||||
onValueChange={(external_sharing) =>
|
||||
setFilePolicies({
|
||||
external_sharing: external_sharing as typeof filePolicies.external_sharing,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-full min-w-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="disabled">Désactivé</SelectItem>
|
||||
<SelectItem value="authenticated">Utilisateurs authentifiés</SelectItem>
|
||||
<SelectItem value="public_link">Liens publics autorisés</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FieldGroup>
|
||||
<FieldGroup>
|
||||
<Label>Extensions autorisées (vide = toutes)</Label>
|
||||
<Textarea
|
||||
className="min-h-[80px] font-mono text-xs"
|
||||
value={filePolicies.allowed_extensions}
|
||||
onChange={(e) => setFilePolicies({ allowed_extensions: e.target.value })}
|
||||
placeholder="pdf, docx, png, jpg"
|
||||
/>
|
||||
</FieldGroup>
|
||||
</div>
|
||||
<Switch
|
||||
checked={filePolicies.block_executable}
|
||||
onCheckedChange={(block_executable) => setFilePolicies({ block_executable })}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between gap-4 rounded-lg border p-3 sm:col-span-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Analyse antivirus à l'upload</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
VirusTotal — scan synchrone à l'upload Drive et pièces jointes mail
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={filePolicies.virus_scan_enabled}
|
||||
onCheckedChange={(virus_scan_enabled) => setFilePolicies({ virus_scan_enabled })}
|
||||
/>
|
||||
</label>
|
||||
{filePolicies.virus_scan_enabled ? (
|
||||
<div className="sm:col-span-2">
|
||||
<Label>Clé API VirusTotal</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={filePolicies.virustotal_api_key ?? ""}
|
||||
onChange={(e) => setFilePolicies({ virustotal_api_key: e.target.value })}
|
||||
placeholder={vtKeyConfigured ? "•••••••• (laisser vide pour conserver)" : "Coller la clé API"}
|
||||
|
||||
<label className="flex items-center justify-between gap-4 rounded-lg border p-3">
|
||||
<FieldGroup>
|
||||
<p className="text-sm font-medium">Bloquer les exécutables</p>
|
||||
<p className="text-xs text-muted-foreground">exe, bat, sh, app, etc.</p>
|
||||
</FieldGroup>
|
||||
<Switch
|
||||
checked={filePolicies.block_executable}
|
||||
onCheckedChange={(block_executable) => setFilePolicies({ block_executable })}
|
||||
/>
|
||||
{vtKeyConfigured && !(filePolicies.virustotal_api_key ?? "").trim() ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">Clé configurée</p>
|
||||
) : null}
|
||||
{vtKeyMissing ? (
|
||||
<p className="mt-1 text-xs text-amber-600 dark:text-amber-500">
|
||||
Analyse activée sans clé API — les uploads ne seront pas scannés.
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between gap-4 rounded-lg border p-3">
|
||||
<FieldGroup>
|
||||
<p className="text-sm font-medium">Analyse antivirus à l'upload</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
VirusTotal — scan synchrone à l'upload Drive et pièces jointes mail
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<DriveMountOAuthSection draft={mountOAuthDraft} onChange={setMountOAuthDraft} />
|
||||
<DriveOrgFoldersSection />
|
||||
</FieldGroup>
|
||||
<Switch
|
||||
checked={filePolicies.virus_scan_enabled}
|
||||
onCheckedChange={(virus_scan_enabled) => setFilePolicies({ virus_scan_enabled })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{filePolicies.virus_scan_enabled ? (
|
||||
<FieldGroup>
|
||||
<Label>Clé API VirusTotal</Label>
|
||||
<Input
|
||||
className="h-9"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={filePolicies.virustotal_api_key ?? ""}
|
||||
onChange={(e) => setFilePolicies({ virustotal_api_key: e.target.value })}
|
||||
placeholder={
|
||||
vtKeyConfigured ? "•••••••• (laisser vide pour conserver)" : "Coller la clé API"
|
||||
}
|
||||
/>
|
||||
{vtKeyConfigured && !(filePolicies.virustotal_api_key ?? "").trim() ? (
|
||||
<p className="text-xs text-muted-foreground">Clé configurée</p>
|
||||
) : null}
|
||||
{vtKeyMissing ? (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-500">
|
||||
Analyse activée sans clé API — les uploads ne seront pas scannés.
|
||||
</p>
|
||||
) : null}
|
||||
</FieldGroup>
|
||||
) : null}
|
||||
</AdminSettingsCard>
|
||||
|
||||
<AdminSettingsCard
|
||||
title="Connexion cloud (OAuth)"
|
||||
description="Permet aux utilisateurs de monter Google Drive, Dropbox ou OneDrive depuis UltiDrive."
|
||||
>
|
||||
<DriveMountOAuthSection draft={mountOAuthDraft} onChange={setMountOAuthDraft} embedded />
|
||||
</AdminSettingsCard>
|
||||
|
||||
<AdminSettingsCard
|
||||
title="Montages WebDAV d'organisation"
|
||||
description="Serveur WebDAV partagé (NAS, Nextcloud externe) visible par tous les utilisateurs UltiDrive."
|
||||
>
|
||||
<DriveOrgWebDAVSection embedded />
|
||||
</AdminSettingsCard>
|
||||
|
||||
<AdminSettingsCard
|
||||
title="Dossiers d'organisation"
|
||||
description="Espaces de stockage internes (group folders Nextcloud) liés aux organisations Authentik."
|
||||
>
|
||||
<DriveOrgFoldersSection embedded />
|
||||
</AdminSettingsCard>
|
||||
</AutomationTabMasonry>
|
||||
</OrgSettingsSection>
|
||||
)
|
||||
}
|
||||
|
||||
@ -9,8 +9,8 @@ import {
|
||||
TestTube2,
|
||||
Trash2,
|
||||
} from "lucide-react"
|
||||
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
|
||||
import { guideForProvider } from "@/components/admin/settings/guides/identity-provider-guides"
|
||||
import { TechBrandSelectLabel } from "@/components/admin/settings/tech-brand-select-label"
|
||||
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
|
||||
import type {
|
||||
IdentityProvider,
|
||||
@ -49,6 +49,14 @@ import { Switch } from "@/components/ui/switch"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { toast } from "sonner"
|
||||
|
||||
const OAUTH_PRESET_LABELS: Record<OAuthProviderPreset, string> = {
|
||||
google: "Google",
|
||||
github: "GitHub",
|
||||
linkedin: "LinkedIn",
|
||||
microsoft: "Microsoft",
|
||||
custom: "Autre / custom",
|
||||
}
|
||||
|
||||
function splitList(value: string): string[] {
|
||||
return value
|
||||
.split(/[\n,]/)
|
||||
@ -121,7 +129,11 @@ function syncBadge(status: IdentityProvider["sync_status"]) {
|
||||
}
|
||||
}
|
||||
|
||||
export function IdentityProvidersSection() {
|
||||
export function IdentityProvidersPanel({
|
||||
onRegisterBeforeSave,
|
||||
}: {
|
||||
onRegisterBeforeSave?: (fn: (() => void) | null) => void
|
||||
}) {
|
||||
const identityProviders = useOrgSettingsStore((s) => s.identityProviders)
|
||||
const setIdentityProviders = useOrgSettingsStore((s) => s.setIdentityProviders)
|
||||
const meta = useOrgSettingsStore((s) => s.meta)
|
||||
@ -216,13 +228,13 @@ export function IdentityProvidersSection() {
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
onRegisterBeforeSave?.(() => setIdentityProviders(draft))
|
||||
return () => onRegisterBeforeSave?.(null)
|
||||
}, [draft, onRegisterBeforeSave, setIdentityProviders])
|
||||
|
||||
return (
|
||||
<OrgSettingsSection
|
||||
title="Fournisseurs d'identité"
|
||||
description="Sources upstream Authentik (OAuth, SAML, LDAP) avec restrictions d'accès."
|
||||
policySection="identity_providers"
|
||||
beforeSave={() => setIdentityProviders(draft)}
|
||||
>
|
||||
<>
|
||||
<label className="flex items-center justify-between gap-4 rounded-lg border p-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Inscription self-service Authentik</p>
|
||||
@ -323,9 +335,21 @@ export function IdentityProvidersSection() {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="oauth">OAuth (Google, GitHub, LinkedIn…)</SelectItem>
|
||||
<SelectItem value="saml">SAML (Azure AD, Okta…)</SelectItem>
|
||||
<SelectItem value="ldap">LDAP / Active Directory</SelectItem>
|
||||
<SelectItem value="oauth">
|
||||
<TechBrandSelectLabel brand="oauth">
|
||||
OAuth (Google, GitHub, LinkedIn…)
|
||||
</TechBrandSelectLabel>
|
||||
</SelectItem>
|
||||
<SelectItem value="saml">
|
||||
<TechBrandSelectLabel brand="saml">
|
||||
SAML (Azure AD, Okta…)
|
||||
</TechBrandSelectLabel>
|
||||
</SelectItem>
|
||||
<SelectItem value="ldap">
|
||||
<TechBrandSelectLabel brand="ldap">
|
||||
LDAP / Active Directory
|
||||
</TechBrandSelectLabel>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@ -391,14 +415,30 @@ export function IdentityProvidersSection() {
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-9">
|
||||
<SelectValue />
|
||||
<SelectValue>
|
||||
{editingProvider.oauth?.provider ? (
|
||||
<TechBrandSelectLabel brand={editingProvider.oauth.provider}>
|
||||
{OAUTH_PRESET_LABELS[editingProvider.oauth.provider]}
|
||||
</TechBrandSelectLabel>
|
||||
) : null}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="google">Google</SelectItem>
|
||||
<SelectItem value="github">GitHub</SelectItem>
|
||||
<SelectItem value="linkedin">LinkedIn</SelectItem>
|
||||
<SelectItem value="microsoft">Microsoft</SelectItem>
|
||||
<SelectItem value="custom">Autre / custom</SelectItem>
|
||||
<SelectItem value="google">
|
||||
<TechBrandSelectLabel brand="google">Google</TechBrandSelectLabel>
|
||||
</SelectItem>
|
||||
<SelectItem value="github">
|
||||
<TechBrandSelectLabel brand="github">GitHub</TechBrandSelectLabel>
|
||||
</SelectItem>
|
||||
<SelectItem value="linkedin">
|
||||
<TechBrandSelectLabel brand="linkedin">LinkedIn</TechBrandSelectLabel>
|
||||
</SelectItem>
|
||||
<SelectItem value="microsoft">
|
||||
<TechBrandSelectLabel brand="microsoft">Microsoft</TechBrandSelectLabel>
|
||||
</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
<TechBrandSelectLabel brand="custom">Autre / custom</TechBrandSelectLabel>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@ -754,6 +794,6 @@ export function IdentityProvidersSection() {
|
||||
) : null}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</OrgSettingsSection>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,196 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Plus, Trash2 } from "lucide-react"
|
||||
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
|
||||
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
|
||||
import type { ApiLLMProvider } from "@/lib/contacts/discovery-types"
|
||||
import { isOrgLLMProviderKeyConfigured } from "@/lib/api/hooks/use-admin-llm"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
|
||||
function emptyProvider(): ApiLLMProvider {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
name: "",
|
||||
base_url: "https://api.openai.com/v1",
|
||||
api_key: "",
|
||||
default_model: "gpt-4o-mini",
|
||||
}
|
||||
}
|
||||
|
||||
export function LlmSection() {
|
||||
const llm = useOrgSettingsStore((s) => s.llm)
|
||||
const setLlm = useOrgSettingsStore((s) => s.setLlm)
|
||||
const secrets = useOrgSettingsStore((s) => s.meta?.secrets)
|
||||
const [draft, setDraft] = useState(llm)
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(llm)
|
||||
}, [llm])
|
||||
|
||||
function updateProvider(index: number, patch: Partial<ApiLLMProvider>) {
|
||||
setDraft((prev) => {
|
||||
const providers = [...prev.providers]
|
||||
providers[index] = { ...providers[index], ...patch }
|
||||
return { ...prev, providers }
|
||||
})
|
||||
}
|
||||
|
||||
function addProvider() {
|
||||
const p = emptyProvider()
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
providers: [...prev.providers, p],
|
||||
default_provider_id: prev.default_provider_id || p.id,
|
||||
}))
|
||||
}
|
||||
|
||||
function removeProvider(index: number) {
|
||||
setDraft((prev) => {
|
||||
const removed = prev.providers[index]
|
||||
const providers = prev.providers.filter((_, i) => i !== index)
|
||||
let defaultId = prev.default_provider_id
|
||||
if (defaultId === removed?.id) defaultId = providers[0]?.id ?? ""
|
||||
return { ...prev, providers, default_provider_id: defaultId }
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<OrgSettingsSection
|
||||
title="Fournisseurs LLM"
|
||||
description="Modèles IA organisationnels pour le tri, l'enrichissement contacts, UltiAI et les automatisations."
|
||||
policySection="llm"
|
||||
beforeSave={() => setLlm(draft)}
|
||||
>
|
||||
<div className="flex flex-wrap gap-4 rounded-lg border p-4">
|
||||
<label className="flex flex-1 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}
|
||||
onCheckedChange={(enforce_org_providers) =>
|
||||
setDraft((p) => ({ ...p, enforce_org_providers }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-1 items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Autoriser surcharge utilisateur</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={draft.allow_user_override}
|
||||
onCheckedChange={(allow_user_override) =>
|
||||
setDraft((p) => ({ ...p, allow_user_override }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Fournisseur par défaut</Label>
|
||||
<Button variant="outline" size="sm" onClick={addProvider}>
|
||||
<Plus className="mr-2 size-4" />
|
||||
Ajouter
|
||||
</Button>
|
||||
</div>
|
||||
{draft.providers.length > 0 ? (
|
||||
<Select
|
||||
value={draft.default_provider_id}
|
||||
onValueChange={(default_provider_id) =>
|
||||
setDraft((p) => ({ ...p, default_provider_id }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="Choisir…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{draft.providers.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name || p.base_url || p.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Aucun fournisseur configuré. Ajoutez-en un puis enregistrez.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{draft.providers.map((provider, index) => {
|
||||
const keyConfigured = isOrgLLMProviderKeyConfigured(secrets, provider.id)
|
||||
return (
|
||||
<div key={provider.id} className="space-y-3 rounded-lg border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">
|
||||
{provider.name || provider.base_url || `Fournisseur ${index + 1}`}
|
||||
</span>
|
||||
<Button variant="ghost" size="icon" onClick={() => removeProvider(index)}>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Nom</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
value={provider.name}
|
||||
onChange={(e) => updateProvider(index, { name: e.target.value })}
|
||||
placeholder="OpenAI"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">URL de base</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
value={provider.base_url}
|
||||
onChange={(e) => updateProvider(index, { base_url: e.target.value })}
|
||||
placeholder="https://api.openai.com/v1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Clé API</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={provider.api_key ?? ""}
|
||||
onChange={(e) => updateProvider(index, { api_key: e.target.value })}
|
||||
placeholder={
|
||||
keyConfigured ? "•••••••• (laisser vide pour conserver)" : "sk-…"
|
||||
}
|
||||
/>
|
||||
{keyConfigured && !(provider.api_key ?? "").trim() ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">Clé API enregistrée</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Modèle par défaut</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
value={provider.default_model}
|
||||
onChange={(e) => updateProvider(index, { default_model: e.target.value })}
|
||||
placeholder="gpt-4o-mini"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</OrgSettingsSection>
|
||||
)
|
||||
}
|
||||
@ -1,11 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useCallback, useState } from "react"
|
||||
import { Check, Copy } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} 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 { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
|
||||
import { MigrationProjectsPanel } from "@/components/admin/settings/sections/migration-projects-panel"
|
||||
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
|
||||
import {
|
||||
useCreateMailDomain,
|
||||
useMailDomains,
|
||||
@ -17,42 +30,155 @@ export function MailDomainsSection() {
|
||||
const domainsQuery = useMailDomains()
|
||||
const createDomain = useCreateMailDomain()
|
||||
const [domainName, setDomainName] = useState("")
|
||||
const mailing = useOrgSettingsStore((s) => s.mailing)
|
||||
const setMailing = useOrgSettingsStore((s) => s.setMailing)
|
||||
|
||||
const domains = domainsQuery.data?.domains ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<OrgSettingsSection
|
||||
title="Domaines mail hébergés"
|
||||
description="Stalwart — vérification DNS, DKIM et provisioning des boîtes @domaine."
|
||||
title="Mail"
|
||||
description="Domaines hébergés Stalwart, relais SMTP des notifications suite et migration."
|
||||
policySection="mailing"
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-[1fr_auto]">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-domain">Nouveau domaine</Label>
|
||||
<Input
|
||||
id="new-domain"
|
||||
value={domainName}
|
||||
onChange={(e) => setDomainName(e.target.value)}
|
||||
placeholder="entreprise.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button
|
||||
disabled={!domainName || createDomain.isPending}
|
||||
onClick={() => {
|
||||
void createDomain.mutateAsync({ name: domainName }).then(() => setDomainName(""))
|
||||
}}
|
||||
>
|
||||
Ajouter
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<AutomationTabMasonry columns={2}>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Domaines hébergés</CardTitle>
|
||||
<CardDescription>
|
||||
Vérification DNS, DKIM et provisioning des boîtes @domaine.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 md:grid-cols-[1fr_auto]">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-domain">Nouveau domaine</Label>
|
||||
<Input
|
||||
id="new-domain"
|
||||
value={domainName}
|
||||
onChange={(e) => setDomainName(e.target.value)}
|
||||
placeholder="entreprise.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button
|
||||
disabled={!domainName || createDomain.isPending}
|
||||
onClick={() => {
|
||||
void createDomain.mutateAsync({ name: domainName }).then(() => setDomainName(""))
|
||||
}}
|
||||
>
|
||||
Ajouter
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="mt-6 space-y-3">
|
||||
{domains.map((domain) => (
|
||||
<DomainRow key={domain.id} domain={domain} />
|
||||
))}
|
||||
</ul>
|
||||
<ul className="mt-6 space-y-3">
|
||||
{domains.map((domain) => (
|
||||
<DomainRow key={domain.id} domain={domain} />
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle className="text-sm font-medium">Notifications suite (SMTP)</CardTitle>
|
||||
<CardDescription>
|
||||
Partages de fichiers, mentions, invitations — distinct des comptes mail utilisateur.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Switch
|
||||
checked={mailing.enabled}
|
||||
onCheckedChange={(enabled) => setMailing({ enabled })}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label>Hôte SMTP</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
value={mailing.smtp_host}
|
||||
onChange={(e) => setMailing({ smtp_host: e.target.value })}
|
||||
placeholder="smtp.example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Port</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
type="number"
|
||||
value={mailing.smtp_port}
|
||||
onChange={(e) => setMailing({ smtp_port: Number(e.target.value) || 587 })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Utilisateur</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
value={mailing.smtp_user}
|
||||
onChange={(e) => setMailing({ smtp_user: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Mot de passe</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
type="password"
|
||||
value={mailing.smtp_password}
|
||||
onChange={(e) => setMailing({ smtp_password: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Chiffrement</Label>
|
||||
<Select
|
||||
value={mailing.tls_mode}
|
||||
onValueChange={(tls_mode) =>
|
||||
setMailing({ tls_mode: tls_mode as typeof mailing.tls_mode })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="starttls">STARTTLS</SelectItem>
|
||||
<SelectItem value="ssl">SSL/TLS</SelectItem>
|
||||
<SelectItem value="none">Aucun</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Adresse d'expédition</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
type="email"
|
||||
value={mailing.from_email}
|
||||
onChange={(e) => setMailing({ from_email: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Nom affiché</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
value={mailing.from_name}
|
||||
onChange={(e) => setMailing({ from_name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<Label>Reply-To (optionnel)</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
type="email"
|
||||
value={mailing.reply_to ?? ""}
|
||||
onChange={(e) => setMailing({ reply_to: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AutomationTabMasonry>
|
||||
</OrgSettingsSection>
|
||||
|
||||
<MigrationProjectsPanel domains={domains} />
|
||||
@ -60,6 +186,30 @@ export function MailDomainsSection() {
|
||||
)
|
||||
}
|
||||
|
||||
function CopyTxtButton({
|
||||
label,
|
||||
copied,
|
||||
onCopy,
|
||||
}: {
|
||||
label: string
|
||||
copied: boolean
|
||||
onCopy: () => void
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 shrink-0"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={onCopy}
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5 opacity-60" />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function DomainRow({
|
||||
domain,
|
||||
}: {
|
||||
@ -73,6 +223,19 @@ function DomainRow({
|
||||
}) {
|
||||
const verifyTxt = useVerifyMailDomainTXT(domain.id)
|
||||
const verifyMx = useVerifyMailDomainMX(domain.id)
|
||||
const [copiedField, setCopiedField] = useState<"name" | "value" | null>(null)
|
||||
const txtName = `_ultisuite-verify.${domain.name}`
|
||||
|
||||
const copyTxtField = useCallback(async (text: string, field: "name" | "value", successLabel: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopiedField(field)
|
||||
toast.success(successLabel)
|
||||
window.setTimeout(() => setCopiedField(null), 2000)
|
||||
} catch {
|
||||
toast.error("Impossible de copier")
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<li className="rounded-lg border p-4">
|
||||
@ -84,9 +247,26 @@ function DomainRow({
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Statut : {domain.status}</p>
|
||||
{domain.verification_token && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
TXT : <code>_ultisuite-verify.{domain.name}</code> = {domain.verification_token}
|
||||
</p>
|
||||
<div className="mt-2 space-y-1 text-xs text-muted-foreground">
|
||||
<p>Enregistrement TXT :</p>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono">{txtName}</code>
|
||||
<CopyTxtButton
|
||||
label="Copier le nom TXT"
|
||||
copied={copiedField === "name"}
|
||||
onCopy={() => void copyTxtField(txtName, "name", "Nom TXT copié")}
|
||||
/>
|
||||
<span className="px-0.5">=</span>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono">{domain.verification_token}</code>
|
||||
<CopyTxtButton
|
||||
label="Copier la valeur TXT"
|
||||
copied={copiedField === "value"}
|
||||
onCopy={() =>
|
||||
void copyTxtField(domain.verification_token!, "value", "Valeur TXT copiée")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
|
||||
@ -1,126 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
|
||||
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
||||
export function MailingSection() {
|
||||
const mailing = useOrgSettingsStore((s) => s.mailing)
|
||||
const setMailing = useOrgSettingsStore((s) => s.setMailing)
|
||||
|
||||
return (
|
||||
<OrgSettingsSection
|
||||
title="Mailing unifié"
|
||||
description="SMTP pour les notifications suite : partages de fichiers, mentions, invitations."
|
||||
policySection="mailing"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle className="text-sm font-medium">Relais SMTP</CardTitle>
|
||||
<CardDescription>
|
||||
Ex. « Marie vous a partagé un fichier » — distinct des comptes mail utilisateur.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Switch
|
||||
checked={mailing.enabled}
|
||||
onCheckedChange={(enabled) => setMailing({ enabled })}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label>Hôte SMTP</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
value={mailing.smtp_host}
|
||||
onChange={(e) => setMailing({ smtp_host: e.target.value })}
|
||||
placeholder="smtp.example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Port</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
type="number"
|
||||
value={mailing.smtp_port}
|
||||
onChange={(e) => setMailing({ smtp_port: Number(e.target.value) || 587 })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Utilisateur</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
value={mailing.smtp_user}
|
||||
onChange={(e) => setMailing({ smtp_user: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Mot de passe</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
type="password"
|
||||
value={mailing.smtp_password}
|
||||
onChange={(e) => setMailing({ smtp_password: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Chiffrement</Label>
|
||||
<Select
|
||||
value={mailing.tls_mode}
|
||||
onValueChange={(tls_mode) =>
|
||||
setMailing({ tls_mode: tls_mode as typeof mailing.tls_mode })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="starttls">STARTTLS</SelectItem>
|
||||
<SelectItem value="ssl">SSL/TLS</SelectItem>
|
||||
<SelectItem value="none">Aucun</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Adresse d'expédition</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
type="email"
|
||||
value={mailing.from_email}
|
||||
onChange={(e) => setMailing({ from_email: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Nom affiché</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
value={mailing.from_name}
|
||||
onChange={(e) => setMailing({ from_name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<Label>Reply-To (optionnel)</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
type="email"
|
||||
value={mailing.reply_to ?? ""}
|
||||
onChange={(e) => setMailing({ reply_to: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</OrgSettingsSection>
|
||||
)
|
||||
}
|
||||
@ -15,6 +15,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
|
||||
import { TechBrandSelectLabel } from "@/components/admin/settings/tech-brand-select-label"
|
||||
import {
|
||||
type DNSCheckReport,
|
||||
type MailDomain,
|
||||
@ -209,11 +210,19 @@ export function MigrationProjectsPanel({ domains }: { domains: MailDomain[] }) {
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
<SelectValue>
|
||||
<TechBrandSelectLabel brand={sourceProvider}>
|
||||
{sourceProvider === "google" ? "Google Workspace" : "Microsoft 365"}
|
||||
</TechBrandSelectLabel>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="google">Google Workspace</SelectItem>
|
||||
<SelectItem value="microsoft">Microsoft 365</SelectItem>
|
||||
<SelectItem value="google">
|
||||
<TechBrandSelectLabel brand="google">Google Workspace</TechBrandSelectLabel>
|
||||
</SelectItem>
|
||||
<SelectItem value="microsoft">
|
||||
<TechBrandSelectLabel brand="microsoft">Microsoft 365</TechBrandSelectLabel>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@ -221,15 +230,25 @@ export function MigrationProjectsPanel({ domains }: { domains: MailDomain[] }) {
|
||||
<Label>Mode d'authentification</Label>
|
||||
<Select value={authMode} onValueChange={setAuthMode}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
<SelectValue>
|
||||
<TechBrandSelectLabel brand={authMode === "oauth" ? "oauth" : authMode}>
|
||||
{AUTH_MODE_LABELS[authMode] ?? authMode}
|
||||
</TechBrandSelectLabel>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="oauth">OAuth utilisateur</SelectItem>
|
||||
<SelectItem value="oauth">
|
||||
<TechBrandSelectLabel brand="oauth">OAuth utilisateur</TechBrandSelectLabel>
|
||||
</SelectItem>
|
||||
<SelectItem value="google_dwd" disabled={sourceProvider !== "google"}>
|
||||
Google DWD (service account)
|
||||
<TechBrandSelectLabel brand="google">
|
||||
Google DWD (service account)
|
||||
</TechBrandSelectLabel>
|
||||
</SelectItem>
|
||||
<SelectItem value="microsoft_app" disabled={sourceProvider !== "microsoft"}>
|
||||
Microsoft app-only (client credentials)
|
||||
<TechBrandSelectLabel brand="microsoft">
|
||||
Microsoft app-only (client credentials)
|
||||
</TechBrandSelectLabel>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@ -1,127 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
|
||||
import { DeployLockedHint, useDeployFieldLocked } from "@/components/admin/settings/deploy-locked-hint"
|
||||
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
||||
export function NextcloudSection() {
|
||||
const nextcloud = useOrgSettingsStore((s) => s.nextcloud)
|
||||
const setNextcloud = useOrgSettingsStore((s) => s.setNextcloud)
|
||||
const effective = useOrgSettingsStore((s) => s.meta?.effective.nextcloud)
|
||||
|
||||
const enabledLocked = useDeployFieldLocked("nextcloud", "enabled")
|
||||
const urlLocked = useDeployFieldLocked("nextcloud", "base_url")
|
||||
const userLocked = useDeployFieldLocked("nextcloud", "admin_user")
|
||||
const passLocked = useDeployFieldLocked("nextcloud", "admin_password")
|
||||
|
||||
const enabled = enabledLocked ? (effective?.enabled ?? nextcloud.enabled) : nextcloud.enabled
|
||||
const baseURL = urlLocked ? (effective?.base_url ?? nextcloud.base_url) : nextcloud.base_url
|
||||
const adminUser = userLocked ? (effective?.admin_user ?? nextcloud.admin_user) : nextcloud.admin_user
|
||||
|
||||
return (
|
||||
<OrgSettingsSection
|
||||
title="Nextcloud"
|
||||
description="Connexion au serveur Nextcloud pour drive, agenda, contacts et Talk."
|
||||
policySection="nextcloud"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle className="text-sm font-medium">Intégration Nextcloud</CardTitle>
|
||||
<CardDescription>Variables NEXTCLOUD_* côté serveur.</CardDescription>
|
||||
{enabledLocked ? <DeployLockedHint section="nextcloud" field="enabled" /> : null}
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
disabled={enabledLocked}
|
||||
onCheckedChange={(v) => setNextcloud({ enabled: v })}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<Label>URL de base</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
value={baseURL}
|
||||
disabled={urlLocked}
|
||||
onChange={(e) => setNextcloud({ base_url: e.target.value })}
|
||||
placeholder="https://cloud.example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Utilisateur admin</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
value={adminUser}
|
||||
disabled={userLocked}
|
||||
onChange={(e) => setNextcloud({ admin_user: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Mot de passe admin</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
type="password"
|
||||
value={nextcloud.admin_password}
|
||||
disabled={passLocked}
|
||||
onChange={(e) => setNextcloud({ admin_password: e.target.value })}
|
||||
placeholder={passLocked ? "Défini via NC_ADMIN_PASSWORD" : undefined}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Services activés</CardTitle>
|
||||
<CardDescription>Modules suite exposés aux utilisateurs.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<ServiceToggle
|
||||
label="UltiDrive (fichiers)"
|
||||
checked={nextcloud.drive_enabled}
|
||||
onChange={(drive_enabled) => setNextcloud({ drive_enabled })}
|
||||
/>
|
||||
<ServiceToggle
|
||||
label="Agenda"
|
||||
checked={nextcloud.calendar_enabled}
|
||||
onChange={(calendar_enabled) => setNextcloud({ calendar_enabled })}
|
||||
/>
|
||||
<ServiceToggle
|
||||
label="Contacts"
|
||||
checked={nextcloud.contacts_enabled}
|
||||
onChange={(contacts_enabled) => setNextcloud({ contacts_enabled })}
|
||||
/>
|
||||
<ServiceToggle
|
||||
label="Talk (visio)"
|
||||
checked={nextcloud.talk_enabled}
|
||||
onChange={(talk_enabled) => setNextcloud({ talk_enabled })}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</OrgSettingsSection>
|
||||
)
|
||||
}
|
||||
|
||||
function ServiceToggle({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
checked: boolean
|
||||
onChange: (v: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<label className="flex items-center justify-between gap-4 rounded-lg border p-3">
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
<Switch checked={checked} onCheckedChange={onChange} />
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@ -1,82 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
|
||||
import { DeployLockedHint, useDeployFieldLocked } from "@/components/admin/settings/deploy-locked-hint"
|
||||
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
||||
export function OnlyofficeSection() {
|
||||
const onlyoffice = useOrgSettingsStore((s) => s.onlyoffice)
|
||||
const setOnlyoffice = useOrgSettingsStore((s) => s.setOnlyoffice)
|
||||
const effective = useOrgSettingsStore((s) => s.meta?.effective.onlyoffice)
|
||||
|
||||
const enabledLocked = useDeployFieldLocked("onlyoffice", "enabled")
|
||||
const urlLocked = useDeployFieldLocked("onlyoffice", "document_server_url")
|
||||
const jwtLocked = useDeployFieldLocked("onlyoffice", "jwt_secret")
|
||||
const headerLocked = useDeployFieldLocked("onlyoffice", "jwt_header")
|
||||
|
||||
const enabled = enabledLocked ? (effective?.enabled ?? onlyoffice.enabled) : onlyoffice.enabled
|
||||
const docURL = urlLocked
|
||||
? (effective?.document_server_url ?? onlyoffice.document_server_url)
|
||||
: onlyoffice.document_server_url
|
||||
|
||||
return (
|
||||
<OrgSettingsSection
|
||||
title="OnlyOffice"
|
||||
description="Édition collaborative de documents Office dans le navigateur."
|
||||
policySection="onlyoffice"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle className="text-sm font-medium">Document Server</CardTitle>
|
||||
<CardDescription>Variables ONLYOFFICE_* côté serveur.</CardDescription>
|
||||
{enabledLocked ? <DeployLockedHint section="onlyoffice" field="enabled" /> : null}
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
disabled={enabledLocked}
|
||||
onCheckedChange={(v) => setOnlyoffice({ enabled: v })}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label>URL du serveur de documents</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
value={docURL}
|
||||
disabled={urlLocked}
|
||||
onChange={(e) => setOnlyoffice({ document_server_url: e.target.value })}
|
||||
placeholder="https://office.example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Secret JWT</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
type="password"
|
||||
value={onlyoffice.jwt_secret}
|
||||
disabled={jwtLocked}
|
||||
onChange={(e) => setOnlyoffice({ jwt_secret: e.target.value })}
|
||||
placeholder={jwtLocked ? "Défini via ONLYOFFICE_JWT_SECRET" : undefined}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>En-tête JWT</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
value={onlyoffice.jwt_header}
|
||||
disabled={headerLocked}
|
||||
onChange={(e) => setOnlyoffice({ jwt_header: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</OrgSettingsSection>
|
||||
)
|
||||
}
|
||||
@ -1,55 +1,478 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { ChevronDown, Settings2 } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry"
|
||||
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
|
||||
import { DeployLockedHint } from "@/components/admin/settings/deploy-locked-hint"
|
||||
import { FieldGroup } from "@/components/admin/settings/field-group"
|
||||
import { DeployLockedHint, useDeployFieldLocked } from "@/components/admin/settings/deploy-locked-hint"
|
||||
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
|
||||
import { isPluginDeployLocked } from "@/lib/admin/deploy-runtime"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { TechBrandSelectLabel } from "@/components/admin/settings/tech-brand-select-label"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const SIMPLE_PLUGIN_IDS = new Set(["mail-automation", "contact-discovery", "public-share"])
|
||||
const CONFIG_PLUGIN_IDS = new Set(["office-editor", "richtext-editor", "ai-assistant"])
|
||||
|
||||
export function PluginsSection() {
|
||||
const plugins = useOrgSettingsStore((s) => s.plugins)
|
||||
const togglePlugin = useOrgSettingsStore((s) => s.togglePlugin)
|
||||
const deployLocked = useOrgSettingsStore((s) => s.meta?.deployLocked)
|
||||
|
||||
const simplePlugins = plugins.filter((p) => SIMPLE_PLUGIN_IDS.has(p.id))
|
||||
const configPlugins = plugins.filter((p) => CONFIG_PLUGIN_IDS.has(p.id))
|
||||
|
||||
return (
|
||||
<OrgSettingsSection
|
||||
title="Plugins"
|
||||
description="Modules fonctionnels activables pour toute l'organisation."
|
||||
policySection="plugins"
|
||||
description="Modules fonctionnels et intégrations activables pour toute l'organisation."
|
||||
policySection={["plugins", "nextcloud", "onlyoffice", "richtext"]}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{plugins.map((plugin) => {
|
||||
<AutomationTabMasonry columns={2}>
|
||||
<NextcloudPluginCard />
|
||||
|
||||
{simplePlugins.map((plugin) => {
|
||||
const locked = isPluginDeployLocked(deployLocked, plugin.id)
|
||||
return (
|
||||
<Card key={plugin.id}>
|
||||
<CardContent className="flex items-center gap-4 py-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium">{plugin.name}</p>
|
||||
<Badge variant="outline">v{plugin.version}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{plugin.description}</p>
|
||||
{plugin.id === "ai-assistant" && !plugin.enabled ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
N'oubliez pas d'enregistrer après activation. OpenWebUI doit être
|
||||
déployé (<code className="rounded bg-muted px-1">AI_ASSISTANT_ENABLED=true</code>
|
||||
).
|
||||
</p>
|
||||
) : null}
|
||||
{locked ? <DeployLockedHint section="plugins" field={plugin.id} /> : null}
|
||||
</div>
|
||||
<Switch
|
||||
checked={plugin.enabled}
|
||||
disabled={locked}
|
||||
onCheckedChange={(enabled) => togglePlugin(plugin.id, enabled)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<PluginToggleCard
|
||||
key={plugin.id}
|
||||
name={plugin.name}
|
||||
description={plugin.description}
|
||||
version={plugin.version}
|
||||
enabled={plugin.enabled}
|
||||
locked={locked}
|
||||
lockSection="plugins"
|
||||
lockField={plugin.id}
|
||||
onToggle={(enabled) => togglePlugin(plugin.id, enabled)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{configPlugins.map((plugin) => {
|
||||
const locked = isPluginDeployLocked(deployLocked, plugin.id)
|
||||
if (plugin.id === "office-editor") {
|
||||
return (
|
||||
<OnlyOfficePluginCard
|
||||
key={plugin.id}
|
||||
plugin={plugin}
|
||||
locked={locked}
|
||||
onToggle={(enabled) => togglePlugin(plugin.id, enabled)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (plugin.id === "richtext-editor") {
|
||||
return (
|
||||
<RichtextPluginCard
|
||||
key={plugin.id}
|
||||
plugin={plugin}
|
||||
locked={locked}
|
||||
onToggle={(enabled) => togglePlugin(plugin.id, enabled)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<AiAssistantPluginCard
|
||||
key={plugin.id}
|
||||
plugin={plugin}
|
||||
locked={locked}
|
||||
onToggle={(enabled) => togglePlugin(plugin.id, enabled)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</AutomationTabMasonry>
|
||||
</OrgSettingsSection>
|
||||
)
|
||||
}
|
||||
|
||||
function PluginToggleCard({
|
||||
name,
|
||||
description,
|
||||
version,
|
||||
enabled,
|
||||
locked,
|
||||
lockSection,
|
||||
lockField,
|
||||
onToggle,
|
||||
hint,
|
||||
action,
|
||||
children,
|
||||
defaultOpen = false,
|
||||
}: {
|
||||
name: string
|
||||
description: string
|
||||
version?: string
|
||||
enabled: boolean
|
||||
locked?: boolean
|
||||
lockSection?: string
|
||||
lockField?: string
|
||||
onToggle: (enabled: boolean) => void
|
||||
hint?: React.ReactNode
|
||||
action?: React.ReactNode
|
||||
children?: React.ReactNode
|
||||
defaultOpen?: boolean
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
const hasConfig = Boolean(children)
|
||||
|
||||
return (
|
||||
<Card className="gap-0 py-0">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium">{name}</p>
|
||||
{version ? <Badge variant="outline">v{version}</Badge> : null}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
||||
{hint}
|
||||
{locked && lockSection && lockField ? (
|
||||
<DeployLockedHint section={lockSection} field={lockField} />
|
||||
) : null}
|
||||
</div>
|
||||
<Switch checked={enabled} disabled={locked} onCheckedChange={onToggle} />
|
||||
</div>
|
||||
|
||||
{action ? <div className="mt-3">{action}</div> : null}
|
||||
|
||||
{hasConfig && !action ? (
|
||||
<Collapsible open={open} onOpenChange={setOpen} className="mt-3">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="sm" className="gap-1.5 px-2">
|
||||
<Settings2 className="size-3.5" aria-hidden />
|
||||
Configuration
|
||||
<ChevronDown
|
||||
className={cn("size-3.5 transition-transform", open && "rotate-180")}
|
||||
aria-hidden
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-3 space-y-4 border-t pt-4">
|
||||
{children}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function NextcloudPluginCard() {
|
||||
const nextcloud = useOrgSettingsStore((s) => s.nextcloud)
|
||||
const setNextcloud = useOrgSettingsStore((s) => s.setNextcloud)
|
||||
const effective = useOrgSettingsStore((s) => s.meta?.effective.nextcloud)
|
||||
|
||||
const enabledLocked = useDeployFieldLocked("nextcloud", "enabled")
|
||||
const urlLocked = useDeployFieldLocked("nextcloud", "base_url")
|
||||
const userLocked = useDeployFieldLocked("nextcloud", "admin_user")
|
||||
const passLocked = useDeployFieldLocked("nextcloud", "admin_password")
|
||||
|
||||
const enabled = enabledLocked ? (effective?.enabled ?? nextcloud.enabled) : nextcloud.enabled
|
||||
const baseURL = urlLocked ? (effective?.base_url ?? nextcloud.base_url) : nextcloud.base_url
|
||||
const adminUser = userLocked ? (effective?.admin_user ?? nextcloud.admin_user) : nextcloud.admin_user
|
||||
|
||||
return (
|
||||
<PluginToggleCard
|
||||
name="Nextcloud Suite"
|
||||
description="Plateforme drive, agenda, contacts et Talk. Requis pour UltiDrive et les modules associés."
|
||||
enabled={enabled}
|
||||
locked={enabledLocked}
|
||||
lockSection="nextcloud"
|
||||
lockField="enabled"
|
||||
onToggle={(v) => setNextcloud({ enabled: v })}
|
||||
defaultOpen={enabled}
|
||||
>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<FieldGroup className="sm:col-span-2">
|
||||
<Label>URL de base</Label>
|
||||
<Input
|
||||
className="h-9"
|
||||
value={baseURL}
|
||||
disabled={urlLocked}
|
||||
onChange={(e) => setNextcloud({ base_url: e.target.value })}
|
||||
placeholder="https://cloud.example.com"
|
||||
/>
|
||||
{urlLocked ? <DeployLockedHint section="nextcloud" field="base_url" /> : null}
|
||||
</FieldGroup>
|
||||
<FieldGroup>
|
||||
<Label>Utilisateur admin</Label>
|
||||
<Input
|
||||
className="h-9"
|
||||
value={adminUser}
|
||||
disabled={userLocked}
|
||||
onChange={(e) => setNextcloud({ admin_user: e.target.value })}
|
||||
/>
|
||||
{userLocked ? <DeployLockedHint section="nextcloud" field="admin_user" /> : null}
|
||||
</FieldGroup>
|
||||
<FieldGroup>
|
||||
<Label>Mot de passe admin</Label>
|
||||
<Input
|
||||
className="h-9"
|
||||
type="password"
|
||||
value={nextcloud.admin_password}
|
||||
disabled={passLocked}
|
||||
onChange={(e) => setNextcloud({ admin_password: e.target.value })}
|
||||
placeholder={passLocked ? "Défini via NC_ADMIN_PASSWORD" : undefined}
|
||||
/>
|
||||
{passLocked ? <DeployLockedHint section="nextcloud" field="admin_password" /> : null}
|
||||
</FieldGroup>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<FieldGroup>
|
||||
<p className="text-sm font-medium">Modules exposés</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Active ou masque chaque application Nextcloud dans la suite.
|
||||
</p>
|
||||
</FieldGroup>
|
||||
<div className="space-y-2">
|
||||
<ServiceToggle
|
||||
label="UltiDrive (fichiers)"
|
||||
checked={nextcloud.drive_enabled}
|
||||
onChange={(drive_enabled) => setNextcloud({ drive_enabled })}
|
||||
/>
|
||||
<ServiceToggle
|
||||
label="Agenda"
|
||||
checked={nextcloud.calendar_enabled}
|
||||
onChange={(calendar_enabled) => setNextcloud({ calendar_enabled })}
|
||||
/>
|
||||
<ServiceToggle
|
||||
label="Contacts"
|
||||
checked={nextcloud.contacts_enabled}
|
||||
onChange={(contacts_enabled) => setNextcloud({ contacts_enabled })}
|
||||
/>
|
||||
<ServiceToggle
|
||||
label="Talk (visio)"
|
||||
checked={nextcloud.talk_enabled}
|
||||
onChange={(talk_enabled) => setNextcloud({ talk_enabled })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PluginToggleCard>
|
||||
)
|
||||
}
|
||||
|
||||
function OnlyOfficePluginCard({
|
||||
plugin,
|
||||
locked,
|
||||
onToggle,
|
||||
}: {
|
||||
plugin: { name: string; description: string; version: string; enabled: boolean }
|
||||
locked: boolean
|
||||
onToggle: (enabled: boolean) => void
|
||||
}) {
|
||||
const onlyoffice = useOrgSettingsStore((s) => s.onlyoffice)
|
||||
const setOnlyoffice = useOrgSettingsStore((s) => s.setOnlyoffice)
|
||||
const effective = useOrgSettingsStore((s) => s.meta?.effective.onlyoffice)
|
||||
|
||||
const enabledLocked = useDeployFieldLocked("onlyoffice", "enabled")
|
||||
const urlLocked = useDeployFieldLocked("onlyoffice", "document_server_url")
|
||||
const jwtLocked = useDeployFieldLocked("onlyoffice", "jwt_secret")
|
||||
const headerLocked = useDeployFieldLocked("onlyoffice", "jwt_header")
|
||||
|
||||
const enabled = enabledLocked
|
||||
? (effective?.enabled ?? plugin.enabled)
|
||||
: plugin.enabled
|
||||
const docURL = urlLocked
|
||||
? (effective?.document_server_url ?? onlyoffice.document_server_url)
|
||||
: onlyoffice.document_server_url
|
||||
|
||||
return (
|
||||
<PluginToggleCard
|
||||
name={plugin.name}
|
||||
description={plugin.description}
|
||||
version={plugin.version}
|
||||
enabled={enabled}
|
||||
locked={locked || enabledLocked}
|
||||
lockSection="plugins"
|
||||
lockField="office-editor"
|
||||
onToggle={onToggle}
|
||||
defaultOpen={enabled}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{enabledLocked ? <DeployLockedHint section="onlyoffice" field="enabled" /> : null}
|
||||
<FieldGroup>
|
||||
<Label>URL du serveur de documents</Label>
|
||||
<Input
|
||||
className="h-9"
|
||||
value={docURL}
|
||||
disabled={urlLocked}
|
||||
onChange={(e) => setOnlyoffice({ document_server_url: e.target.value })}
|
||||
placeholder="https://office.example.com"
|
||||
/>
|
||||
{urlLocked ? <DeployLockedHint section="onlyoffice" field="document_server_url" /> : null}
|
||||
</FieldGroup>
|
||||
<FieldGroup>
|
||||
<Label>Secret JWT</Label>
|
||||
<Input
|
||||
className="h-9"
|
||||
type="password"
|
||||
value={onlyoffice.jwt_secret}
|
||||
disabled={jwtLocked}
|
||||
onChange={(e) => setOnlyoffice({ jwt_secret: e.target.value })}
|
||||
placeholder={jwtLocked ? "Défini via ONLYOFFICE_JWT_SECRET" : undefined}
|
||||
/>
|
||||
{jwtLocked ? <DeployLockedHint section="onlyoffice" field="jwt_secret" /> : null}
|
||||
</FieldGroup>
|
||||
<FieldGroup>
|
||||
<Label>En-tête JWT</Label>
|
||||
<Input
|
||||
className="h-9"
|
||||
value={onlyoffice.jwt_header}
|
||||
disabled={headerLocked}
|
||||
onChange={(e) => setOnlyoffice({ jwt_header: e.target.value })}
|
||||
/>
|
||||
{headerLocked ? <DeployLockedHint section="onlyoffice" field="jwt_header" /> : null}
|
||||
</FieldGroup>
|
||||
</div>
|
||||
</PluginToggleCard>
|
||||
)
|
||||
}
|
||||
|
||||
function RichtextPluginCard({
|
||||
plugin,
|
||||
locked,
|
||||
onToggle,
|
||||
}: {
|
||||
plugin: { name: string; description: string; version: string; enabled: boolean }
|
||||
locked: boolean
|
||||
onToggle: (enabled: boolean) => void
|
||||
}) {
|
||||
const richtext = useOrgSettingsStore((s) => s.richtext)
|
||||
const setRichtext = useOrgSettingsStore((s) => s.setRichtext)
|
||||
|
||||
return (
|
||||
<PluginToggleCard
|
||||
name={plugin.name}
|
||||
description={`${plugin.description} OnlyOffice reste actif pour tableurs et présentations.`}
|
||||
version={plugin.version}
|
||||
enabled={plugin.enabled}
|
||||
locked={locked}
|
||||
lockSection="plugins"
|
||||
lockField="richtext-editor"
|
||||
onToggle={onToggle}
|
||||
defaultOpen={plugin.enabled}
|
||||
>
|
||||
<div className="grid min-w-0 gap-4">
|
||||
<FieldGroup className="min-w-0">
|
||||
<Label>Mode de stockage</Label>
|
||||
<Select
|
||||
value={richtext.storage_mode}
|
||||
onValueChange={(storage_mode: "sidecar" | "overwrite") =>
|
||||
setRichtext({ storage_mode })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full min-w-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="sidecar">
|
||||
Sidecar (.ultidoc.json à côté de l'original)
|
||||
</SelectItem>
|
||||
<SelectItem value="overwrite">Remplacer par .ultidoc.json</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FieldGroup>
|
||||
<FieldGroup className="min-w-0">
|
||||
<Label>Export miroir (optionnel)</Label>
|
||||
<Select
|
||||
value={richtext.export_mirror_format || "none"}
|
||||
onValueChange={(v) =>
|
||||
setRichtext({ export_mirror_format: v === "none" ? "" : "docx" })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full min-w-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">Aucun</SelectItem>
|
||||
<SelectItem value="docx">
|
||||
<TechBrandSelectLabel brand="docx">DOCX (Microsoft Word)</TechBrandSelectLabel>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FieldGroup>
|
||||
<FieldGroup className="min-w-0">
|
||||
<Label>URL WebSocket Hocuspocus (public)</Label>
|
||||
<Input
|
||||
value={richtext.hocuspocus_url}
|
||||
onChange={(e) => setRichtext({ hocuspocus_url: e.target.value })}
|
||||
placeholder="ws://localhost:1234"
|
||||
/>
|
||||
</FieldGroup>
|
||||
</div>
|
||||
</PluginToggleCard>
|
||||
)
|
||||
}
|
||||
|
||||
function AiAssistantPluginCard({
|
||||
plugin,
|
||||
locked,
|
||||
onToggle,
|
||||
}: {
|
||||
plugin: { name: string; description: string; version: string; enabled: boolean }
|
||||
locked: boolean
|
||||
onToggle: (enabled: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<PluginToggleCard
|
||||
name={plugin.name}
|
||||
description={plugin.description}
|
||||
version={plugin.version}
|
||||
enabled={plugin.enabled}
|
||||
locked={locked}
|
||||
lockSection="plugins"
|
||||
lockField="ai-assistant"
|
||||
onToggle={onToggle}
|
||||
hint={
|
||||
!plugin.enabled ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
OpenWebUI doit être déployé (
|
||||
<code className="rounded bg-muted px-1">AI_ASSISTANT_ENABLED=true</code>
|
||||
).
|
||||
</p>
|
||||
) : null
|
||||
}
|
||||
action={
|
||||
<Button type="button" variant="outline" size="sm" asChild>
|
||||
<Link href="/admin/settings/ai-assistant">Configurer UltiAI →</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ServiceToggle({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
checked: boolean
|
||||
onChange: (v: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<label className="flex items-center justify-between gap-4 rounded-lg border p-3">
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
<Switch checked={checked} onCheckedChange={onChange} />
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import { useMemo, useState } from "react"
|
||||
import { ExternalLink, Link2, Trash2 } from "lucide-react"
|
||||
import { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header"
|
||||
import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner"
|
||||
import { AdminListControls } from "@/components/admin/settings/admin-list-controls"
|
||||
import { useAdminPublicShares } from "@/lib/api/hooks/use-admin-queries"
|
||||
import { useRevokeAdminPublicShare } from "@/lib/api/hooks/use-admin-mutations"
|
||||
import type { AdminPublicShare } from "@/lib/api/admin-types"
|
||||
@ -26,17 +27,33 @@ const ACCESS_MODE_LABELS: Record<string, string> = {
|
||||
internal: "Interne",
|
||||
}
|
||||
|
||||
const PUBLIC_SHARE_SORT_OPTIONS = [
|
||||
{ value: "-created_at", label: "Créé (récent)" },
|
||||
{ value: "created_at", label: "Créé (ancien)" },
|
||||
{ value: "-last_access_at", label: "Dernier accès (récent)" },
|
||||
{ value: "last_access_at", label: "Dernier accès (ancien)" },
|
||||
{ value: "-access_count", label: "Accès (plus)" },
|
||||
{ value: "access_count", label: "Accès (moins)" },
|
||||
{ value: "path", label: "Chemin (A→Z)" },
|
||||
{ value: "-path", label: "Chemin (Z→A)" },
|
||||
{ value: "owner_email", label: "Propriétaire (A→Z)" },
|
||||
{ value: "-owner_email", label: "Propriétaire (Z→A)" },
|
||||
] as const
|
||||
|
||||
export function PublicSharesSection() {
|
||||
const [q, setQ] = useState("")
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(25)
|
||||
const [sort, setSort] = useState("-created_at")
|
||||
|
||||
const queryParams = useMemo(
|
||||
() => ({
|
||||
page,
|
||||
page_size: 25,
|
||||
page_size: pageSize,
|
||||
sort,
|
||||
q: q.trim() || undefined,
|
||||
}),
|
||||
[page, q]
|
||||
[page, pageSize, sort, q]
|
||||
)
|
||||
|
||||
const { data, isFetching, isError, refetch } = useAdminPublicShares(queryParams)
|
||||
@ -44,8 +61,8 @@ export function PublicSharesSection() {
|
||||
|
||||
const shares = data?.shares ?? []
|
||||
const total = data?.pagination.total ?? 0
|
||||
const pageSize = data?.pagination.page_size ?? 25
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
||||
const resolvedPageSize = data?.pagination.page_size ?? pageSize
|
||||
const totalPages = Math.max(1, Math.ceil(total / resolvedPageSize))
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -55,19 +72,40 @@ export function PublicSharesSection() {
|
||||
/>
|
||||
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
|
||||
|
||||
<div className="mb-4 max-w-md">
|
||||
<Label className="text-xs">Recherche</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
value={q}
|
||||
onChange={(e) => {
|
||||
setQ(e.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
placeholder="Propriétaire, chemin, token, destinataire…"
|
||||
/>
|
||||
<div className="mb-4 flex flex-wrap items-end gap-3">
|
||||
<div className="min-w-[240px] flex-1">
|
||||
<Label className="text-xs">Recherche</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
value={q}
|
||||
onChange={(e) => {
|
||||
setQ(e.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
placeholder="Propriétaire, chemin, token, destinataire…"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AdminListControls
|
||||
page={page}
|
||||
pageSize={resolvedPageSize}
|
||||
total={total}
|
||||
totalPages={totalPages}
|
||||
sort={sort}
|
||||
sortOptions={[...PUBLIC_SHARE_SORT_OPTIONS]}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(next) => {
|
||||
setPageSize(next)
|
||||
setPage(1)
|
||||
}}
|
||||
onSortChange={(next) => {
|
||||
setSort(next)
|
||||
setPage(1)
|
||||
}}
|
||||
itemLabel="partage(s)"
|
||||
/>
|
||||
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@ -106,32 +144,6 @@ export function PublicSharesSection() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 ? (
|
||||
<div className="mt-4 flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{total.toLocaleString("fr-FR")} partage(s)
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
Précédent
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
Suivant
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
167
components/admin/settings/sections/quotas-section.tsx
Normal file
167
components/admin/settings/sections/quotas-section.tsx
Normal file
@ -0,0 +1,167 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry"
|
||||
import { AdminSettingsCard } from "@/components/admin/settings/admin-settings-card"
|
||||
import { FieldGroup } from "@/components/admin/settings/field-group"
|
||||
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
|
||||
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
export function QuotasSection() {
|
||||
const storageQuotas = useOrgSettingsStore((s) => s.storageQuotas)
|
||||
const setStorageQuotas = useOrgSettingsStore((s) => s.setStorageQuotas)
|
||||
const usageQuotas = useOrgSettingsStore((s) => s.usageQuotas)
|
||||
const setUsageQuotas = useOrgSettingsStore((s) => s.setUsageQuotas)
|
||||
|
||||
return (
|
||||
<OrgSettingsSection
|
||||
title="Quotas"
|
||||
description="Limites de stockage et d'usage appliquées par défaut aux comptes de l'organisation."
|
||||
policySection={["storage_quotas", "usage_quotas"]}
|
||||
>
|
||||
<AutomationTabMasonry columns={2}>
|
||||
<AdminSettingsCard
|
||||
title="Stockage par défaut"
|
||||
description="Mail, drive et photos. Les quotas individuels se gèrent depuis la fiche utilisateur."
|
||||
>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<QuotaInput
|
||||
label="Mail (Go)"
|
||||
value={storageQuotas.default_mail_gib}
|
||||
onChange={(v) => setStorageQuotas({ default_mail_gib: v })}
|
||||
/>
|
||||
<QuotaInput
|
||||
label="Drive (Go)"
|
||||
value={storageQuotas.default_drive_gib}
|
||||
onChange={(v) => setStorageQuotas({ default_drive_gib: v })}
|
||||
/>
|
||||
<QuotaInput
|
||||
label="Photos (Go)"
|
||||
value={storageQuotas.default_photos_gib}
|
||||
onChange={(v) => setStorageQuotas({ default_photos_gib: v })}
|
||||
/>
|
||||
</div>
|
||||
<FieldGroup>
|
||||
<Label>Seuil d'alerte (%)</Label>
|
||||
<Input
|
||||
className="h-9 max-w-xs"
|
||||
type="number"
|
||||
min={50}
|
||||
max={100}
|
||||
value={storageQuotas.warn_threshold_pct}
|
||||
onChange={(e) =>
|
||||
setStorageQuotas({ warn_threshold_pct: Number(e.target.value) || 90 })
|
||||
}
|
||||
/>
|
||||
</FieldGroup>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/admin/settings/users">Gérer les quotas par utilisateur</Link>
|
||||
</Button>
|
||||
</AdminSettingsCard>
|
||||
|
||||
<AdminSettingsCard
|
||||
title="Intelligence artificielle"
|
||||
description="Requêtes LLM et tokens consommés par mois."
|
||||
>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<FieldGroup>
|
||||
<Label>Requêtes LLM / jour</Label>
|
||||
<Input
|
||||
className="h-9"
|
||||
type="number"
|
||||
min={0}
|
||||
value={usageQuotas.llm_requests_per_day}
|
||||
onChange={(e) =>
|
||||
setUsageQuotas({ llm_requests_per_day: Number(e.target.value) || 0 })
|
||||
}
|
||||
/>
|
||||
</FieldGroup>
|
||||
<FieldGroup>
|
||||
<Label>Tokens LLM / mois</Label>
|
||||
<Input
|
||||
className="h-9"
|
||||
type="number"
|
||||
min={0}
|
||||
value={usageQuotas.llm_tokens_per_month}
|
||||
onChange={(e) =>
|
||||
setUsageQuotas({ llm_tokens_per_month: Number(e.target.value) || 0 })
|
||||
}
|
||||
/>
|
||||
</FieldGroup>
|
||||
</div>
|
||||
</AdminSettingsCard>
|
||||
|
||||
<AdminSettingsCard
|
||||
title="Recherche et automatisations"
|
||||
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">
|
||||
<FieldGroup>
|
||||
<Label>Recherches web / jour</Label>
|
||||
<Input
|
||||
className="h-9"
|
||||
type="number"
|
||||
min={0}
|
||||
value={usageQuotas.search_requests_per_day}
|
||||
onChange={(e) =>
|
||||
setUsageQuotas({ search_requests_per_day: Number(e.target.value) || 0 })
|
||||
}
|
||||
/>
|
||||
</FieldGroup>
|
||||
<FieldGroup>
|
||||
<Label>Tokens API max / utilisateur</Label>
|
||||
<Input
|
||||
className="h-9"
|
||||
type="number"
|
||||
min={0}
|
||||
value={usageQuotas.max_api_tokens_per_user}
|
||||
onChange={(e) =>
|
||||
setUsageQuotas({ max_api_tokens_per_user: Number(e.target.value) || 0 })
|
||||
}
|
||||
/>
|
||||
</FieldGroup>
|
||||
<FieldGroup>
|
||||
<Label>Webhooks max / utilisateur</Label>
|
||||
<Input
|
||||
className="h-9"
|
||||
type="number"
|
||||
min={0}
|
||||
value={usageQuotas.max_webhooks_per_user}
|
||||
onChange={(e) =>
|
||||
setUsageQuotas({ max_webhooks_per_user: Number(e.target.value) || 0 })
|
||||
}
|
||||
/>
|
||||
</FieldGroup>
|
||||
</div>
|
||||
</AdminSettingsCard>
|
||||
</AutomationTabMasonry>
|
||||
</OrgSettingsSection>
|
||||
)
|
||||
}
|
||||
|
||||
function QuotaInput({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
onChange: (v: number) => void
|
||||
}) {
|
||||
return (
|
||||
<FieldGroup>
|
||||
<Label>{label}</Label>
|
||||
<Input
|
||||
className="h-9"
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.5}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value) || 0)}
|
||||
/>
|
||||
</FieldGroup>
|
||||
)
|
||||
}
|
||||
@ -1,89 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
|
||||
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
export function RichtextSection() {
|
||||
const richtext = useOrgSettingsStore((s) => s.richtext)
|
||||
const setRichtext = useOrgSettingsStore((s) => s.setRichtext)
|
||||
|
||||
return (
|
||||
<OrgSettingsSection
|
||||
title="Éditeur rich text"
|
||||
description="TipTap pour les documents texte (docx, odt, md…). OnlyOffice reste actif pour tableurs et présentations."
|
||||
policySection="richtext"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle className="text-sm font-medium">TipTap + Hocuspocus</CardTitle>
|
||||
<CardDescription>
|
||||
Formats word via l'éditeur rich text ; sauvegarde en .ultidoc.json dans Nextcloud.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Switch
|
||||
checked={richtext.enabled}
|
||||
onCheckedChange={(enabled) => setRichtext({ enabled })}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Mode de stockage</Label>
|
||||
<Select
|
||||
value={richtext.storage_mode}
|
||||
onValueChange={(storage_mode: "sidecar" | "overwrite") =>
|
||||
setRichtext({ storage_mode })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="sidecar">Sidecar (.ultidoc.json à côté de l'original)</SelectItem>
|
||||
<SelectItem value="overwrite">Remplacer par .ultidoc.json</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Export miroir (optionnel)</Label>
|
||||
<Select
|
||||
value={richtext.export_mirror_format || "none"}
|
||||
onValueChange={(v) =>
|
||||
setRichtext({ export_mirror_format: v === "none" ? "" : "docx" })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">Aucun</SelectItem>
|
||||
<SelectItem value="docx">DOCX</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label>URL WebSocket Hocuspocus (public)</Label>
|
||||
<Input
|
||||
value={richtext.hocuspocus_url}
|
||||
onChange={(e) => setRichtext({ hocuspocus_url: e.target.value })}
|
||||
placeholder="ws://localhost:1234"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</OrgSettingsSection>
|
||||
)
|
||||
}
|
||||
@ -1,7 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
|
||||
import { AdminSettingsCard } from "@/components/admin/settings/admin-settings-card"
|
||||
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 { WebSearchProvidersEditor } from "@/components/web-search/web-search-providers-editor"
|
||||
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
@ -13,13 +17,15 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { normalizeSearchProviders } from "@/lib/web-search/search-provider-catalog"
|
||||
import { TechBrandSelectLabel } from "@/components/admin/settings/tech-brand-select-label"
|
||||
|
||||
export function SearchSection() {
|
||||
const search = useOrgSettingsStore((s) => s.search)
|
||||
const setSearch = useOrgSettingsStore((s) => s.setSearch)
|
||||
const effective = useOrgSettingsStore((s) => s.meta?.effective.search)
|
||||
const brave = search.web_search.providers[0]
|
||||
const providerSecrets = useOrgSettingsStore((s) => s.meta?.secrets?.web_search_providers)
|
||||
const webSearch = normalizeSearchProviders(search.web_search)
|
||||
|
||||
const engineLocked = useDeployFieldLocked("search", "suite_engine")
|
||||
const meiliURLLocked = useDeployFieldLocked("search", "meilisearch_url")
|
||||
@ -40,19 +46,47 @@ export function SearchSection() {
|
||||
return (
|
||||
<OrgSettingsSection
|
||||
title="Moteur de recherche"
|
||||
description="Index de recherche suite (mail, drive) et recherche web pour l'IA contacts."
|
||||
description="Index de recherche suite (mail, drive) et recherche web (contacts, UltiAI)."
|
||||
policySection="search"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Recherche suite</CardTitle>
|
||||
<CardDescription>
|
||||
Moteur d'indexation pour la recherche globale (variables SEARCH_ENGINE côté serveur).
|
||||
</CardDescription>
|
||||
{engineLocked ? <DeployLockedHint section="search" field="suite_engine" /> : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<AutomationTabMasonry columns={2}>
|
||||
<AdminSettingsCard
|
||||
title="Recherche web"
|
||||
description={
|
||||
<>
|
||||
Fournisseurs pour l'enrichissement IA contacts et le tool UltiAI{" "}
|
||||
<code className="rounded bg-muted px-1">web_search</code>. Les utilisateurs peuvent
|
||||
surcharger cette config dans leurs réglages si l'imposition org. est désactivée.
|
||||
</>
|
||||
}
|
||||
>
|
||||
<label className="flex items-center justify-between gap-4 rounded-lg border p-3">
|
||||
<FieldGroup>
|
||||
<p className="text-sm font-medium">Imposer la config organisation</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Sinon, chaque utilisateur configure ses propres fournisseurs.
|
||||
</p>
|
||||
</FieldGroup>
|
||||
<Switch
|
||||
checked={search.enforce_org_search}
|
||||
onCheckedChange={(enforce_org_search) => setSearch({ enforce_org_search })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<WebSearchProvidersEditor
|
||||
value={webSearch}
|
||||
onChange={(web_search) => setSearch({ web_search })}
|
||||
providerSecrets={providerSecrets}
|
||||
columns={1}
|
||||
/>
|
||||
</AdminSettingsCard>
|
||||
|
||||
<AdminSettingsCard
|
||||
title="Recherche suite"
|
||||
description="Moteur d'indexation pour la recherche globale (variables SEARCH_ENGINE côté serveur)."
|
||||
hint={engineLocked ? <DeployLockedHint section="search" field="suite_engine" /> : null}
|
||||
>
|
||||
<FieldGroup>
|
||||
<Label>Moteur</Label>
|
||||
<Select
|
||||
value={suiteEngine}
|
||||
@ -63,107 +97,94 @@ export function SearchSection() {
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-9">
|
||||
<SelectValue />
|
||||
<SelectTrigger className="h-9 w-full min-w-0">
|
||||
<SelectValue>
|
||||
<TechBrandSelectLabel brand={suiteEngine}>
|
||||
{suiteEngine === "postgres"
|
||||
? "PostgreSQL (full-text)"
|
||||
: suiteEngine === "meilisearch"
|
||||
? "Meilisearch"
|
||||
: "Typesense"}
|
||||
</TechBrandSelectLabel>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="postgres">PostgreSQL (full-text)</SelectItem>
|
||||
<SelectItem value="meilisearch">Meilisearch</SelectItem>
|
||||
<SelectItem value="typesense">Typesense</SelectItem>
|
||||
<SelectItem value="postgres">
|
||||
<TechBrandSelectLabel brand="postgres">PostgreSQL (full-text)</TechBrandSelectLabel>
|
||||
</SelectItem>
|
||||
<SelectItem value="meilisearch">
|
||||
<TechBrandSelectLabel brand="meilisearch">Meilisearch</TechBrandSelectLabel>
|
||||
</SelectItem>
|
||||
<SelectItem value="typesense">
|
||||
<TechBrandSelectLabel brand="typesense">Typesense</TechBrandSelectLabel>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
|
||||
{suiteEngine === "meilisearch" ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="grid min-w-0 gap-4">
|
||||
<FieldGroup>
|
||||
<Label>URL Meilisearch</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
className="h-9"
|
||||
value={meiliURL}
|
||||
disabled={meiliURLLocked}
|
||||
onChange={(e) => setSearch({ meilisearch_url: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
{meiliURLLocked ? (
|
||||
<DeployLockedHint section="search" field="meilisearch_url" />
|
||||
) : null}
|
||||
</FieldGroup>
|
||||
<FieldGroup>
|
||||
<Label>Clé API</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
className="h-9"
|
||||
type="password"
|
||||
value={search.meilisearch_api_key}
|
||||
disabled={meiliKeyLocked}
|
||||
onChange={(e) => setSearch({ meilisearch_api_key: e.target.value })}
|
||||
placeholder={meiliKeyLocked ? "Défini via MEILISEARCH_API_KEY" : undefined}
|
||||
/>
|
||||
</div>
|
||||
{meiliKeyLocked ? (
|
||||
<DeployLockedHint section="search" field="meilisearch_api_key" />
|
||||
) : null}
|
||||
</FieldGroup>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{suiteEngine === "typesense" ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="grid min-w-0 gap-4">
|
||||
<FieldGroup>
|
||||
<Label>URL Typesense</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
className="h-9"
|
||||
value={typesenseURL}
|
||||
disabled={typesenseURLLocked}
|
||||
onChange={(e) => setSearch({ typesense_url: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
{typesenseURLLocked ? (
|
||||
<DeployLockedHint section="search" field="typesense_url" />
|
||||
) : null}
|
||||
</FieldGroup>
|
||||
<FieldGroup>
|
||||
<Label>Clé API</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
className="h-9"
|
||||
type="password"
|
||||
value={search.typesense_api_key}
|
||||
disabled={typesenseKeyLocked}
|
||||
onChange={(e) => setSearch({ typesense_api_key: e.target.value })}
|
||||
placeholder={typesenseKeyLocked ? "Défini via TYPESENSE_API_KEY" : undefined}
|
||||
/>
|
||||
</div>
|
||||
{typesenseKeyLocked ? (
|
||||
<DeployLockedHint section="search" field="typesense_api_key" />
|
||||
) : null}
|
||||
</FieldGroup>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Recherche web (Brave)</CardTitle>
|
||||
<CardDescription>Utilisée pour l'enrichissement IA des contacts.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<label className="flex items-center justify-between gap-4 rounded-lg border p-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Imposer la config organisation</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={search.enforce_org_search}
|
||||
onCheckedChange={(enforce_org_search) => setSearch({ enforce_org_search })}
|
||||
/>
|
||||
</label>
|
||||
<div>
|
||||
<Label>Token API Brave</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
type="password"
|
||||
value={brave?.api_key ?? ""}
|
||||
onChange={(e) =>
|
||||
setSearch({
|
||||
web_search: {
|
||||
default_provider_id: "brave-default",
|
||||
providers: [
|
||||
{
|
||||
id: "brave-default",
|
||||
name: "Brave Search",
|
||||
type: "brave",
|
||||
api_key: e.target.value,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AdminSettingsCard>
|
||||
</AutomationTabMasonry>
|
||||
</OrgSettingsSection>
|
||||
)
|
||||
}
|
||||
|
||||
@ -11,7 +11,6 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
const METHODS = [
|
||||
{ id: "totp" as const, label: "Application TOTP" },
|
||||
{ id: "webauthn" as const, label: "Clés de sécurité (WebAuthn)" },
|
||||
{ id: "sms" as const, label: "SMS" },
|
||||
]
|
||||
|
||||
export function SecuritySection() {
|
||||
|
||||
@ -1,89 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
|
||||
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
||||
export function StorageQuotasSection() {
|
||||
const storageQuotas = useOrgSettingsStore((s) => s.storageQuotas)
|
||||
const setStorageQuotas = useOrgSettingsStore((s) => s.setStorageQuotas)
|
||||
|
||||
return (
|
||||
<OrgSettingsSection
|
||||
title="Quotas de stockage"
|
||||
description="Limites par défaut appliquées aux nouveaux comptes (mail, drive, photos)."
|
||||
policySection="storage_quotas"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Quotas par défaut</CardTitle>
|
||||
<CardDescription>
|
||||
Les quotas individuels se gèrent depuis la fiche utilisateur.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-3">
|
||||
<QuotaInput
|
||||
label="Mail (Go)"
|
||||
value={storageQuotas.default_mail_gib}
|
||||
onChange={(v) => setStorageQuotas({ default_mail_gib: v })}
|
||||
/>
|
||||
<QuotaInput
|
||||
label="Drive (Go)"
|
||||
value={storageQuotas.default_drive_gib}
|
||||
onChange={(v) => setStorageQuotas({ default_drive_gib: v })}
|
||||
/>
|
||||
<QuotaInput
|
||||
label="Photos (Go)"
|
||||
value={storageQuotas.default_photos_gib}
|
||||
onChange={(v) => setStorageQuotas({ default_photos_gib: v })}
|
||||
/>
|
||||
<div className="sm:col-span-3">
|
||||
<Label>Seuil d'alerte (%)</Label>
|
||||
<Input
|
||||
className="mt-1 h-9 max-w-xs"
|
||||
type="number"
|
||||
min={50}
|
||||
max={100}
|
||||
value={storageQuotas.warn_threshold_pct}
|
||||
onChange={(e) =>
|
||||
setStorageQuotas({ warn_threshold_pct: Number(e.target.value) || 90 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/admin/settings/users">Gérer les quotas par utilisateur</Link>
|
||||
</Button>
|
||||
</OrgSettingsSection>
|
||||
)
|
||||
}
|
||||
|
||||
function QuotaInput({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
onChange: (v: number) => void
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<Label>{label}</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.5}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value) || 0)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
60
components/admin/settings/sections/ultiai-tools-card.tsx
Normal file
60
components/admin/settings/sections/ultiai-tools-card.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import {
|
||||
toggleUltiAiToolGroup,
|
||||
ULTIAI_TOOL_GROUPS,
|
||||
} from "@/lib/ai/ultiai-tool-groups"
|
||||
|
||||
type UltiAiToolsCardProps = {
|
||||
enabledTools: string[]
|
||||
onChange: (enabledTools: string[]) => void
|
||||
webSearchSettingsHref?: string
|
||||
}
|
||||
|
||||
export function UltiAiToolsCard({
|
||||
enabledTools,
|
||||
onChange,
|
||||
webSearchSettingsHref = "/mail/settings/automation",
|
||||
}: UltiAiToolsCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Tools UltiAI</CardTitle>
|
||||
<CardDescription>
|
||||
Groupes d'outils MCP exposés à l'assistant. La recherche web supporte Brave,
|
||||
Bing, DuckDuckGo, SearXNG et API JSON — config dans{" "}
|
||||
<Link href={webSearchSettingsHref} className="underline underline-offset-2">
|
||||
Automatisations → Recherche
|
||||
</Link>{" "}
|
||||
(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">
|
||||
<Label className="text-sm font-medium">{group.label}</Label>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{group.description}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onCheckedChange={(enabled) =>
|
||||
onChange(toggleUltiAiToolGroup(enabledTools, group.id, enabled))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@ -22,6 +22,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { TechBrandSelectLabel } from "@/components/admin/settings/tech-brand-select-label"
|
||||
|
||||
export function UltimeetSection() {
|
||||
const meet = useOrgSettingsStore((s) => s.meet)
|
||||
@ -169,12 +170,16 @@ export function UltimeetSection() {
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
<SelectValue>
|
||||
<TechBrandSelectLabel brand={draft.external_api_provider}>
|
||||
{MEET_EXTERNAL_API_PROVIDER_LABELS[draft.external_api_provider]}
|
||||
</TechBrandSelectLabel>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(MEET_EXTERNAL_API_PROVIDER_LABELS).map(([id, label]) => (
|
||||
<SelectItem key={id} value={id}>
|
||||
{label}
|
||||
<TechBrandSelectLabel brand={id}>{label}</TechBrandSelectLabel>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
@ -1,97 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
|
||||
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
||||
export function UsageQuotasSection() {
|
||||
const usageQuotas = useOrgSettingsStore((s) => s.usageQuotas)
|
||||
const setUsageQuotas = useOrgSettingsStore((s) => s.setUsageQuotas)
|
||||
|
||||
return (
|
||||
<OrgSettingsSection
|
||||
title="Quotas d'usage"
|
||||
description="Limites d'utilisation des services IA, recherche web et automatisations par utilisateur."
|
||||
policySection="usage_quotas"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Intelligence artificielle</CardTitle>
|
||||
<CardDescription>Requêtes LLM et tokens consommés par mois.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label>Requêtes LLM / jour</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
type="number"
|
||||
min={0}
|
||||
value={usageQuotas.llm_requests_per_day}
|
||||
onChange={(e) =>
|
||||
setUsageQuotas({ llm_requests_per_day: Number(e.target.value) || 0 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Tokens LLM / mois</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
type="number"
|
||||
min={0}
|
||||
value={usageQuotas.llm_tokens_per_month}
|
||||
onChange={(e) =>
|
||||
setUsageQuotas({ llm_tokens_per_month: Number(e.target.value) || 0 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Recherche et automatisations</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<Label>Recherches web / jour</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
type="number"
|
||||
min={0}
|
||||
value={usageQuotas.search_requests_per_day}
|
||||
onChange={(e) =>
|
||||
setUsageQuotas({ search_requests_per_day: Number(e.target.value) || 0 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Tokens API max / utilisateur</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
type="number"
|
||||
min={0}
|
||||
value={usageQuotas.max_api_tokens_per_user}
|
||||
onChange={(e) =>
|
||||
setUsageQuotas({ max_api_tokens_per_user: Number(e.target.value) || 0 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Webhooks max / utilisateur</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
type="number"
|
||||
min={0}
|
||||
value={usageQuotas.max_webhooks_per_user}
|
||||
onChange={(e) =>
|
||||
setUsageQuotas({ max_webhooks_per_user: Number(e.target.value) || 0 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</OrgSettingsSection>
|
||||
)
|
||||
}
|
||||
242
components/admin/settings/sections/users-bulk-toolbar.tsx
Normal file
242
components/admin/settings/sections/users-bulk-toolbar.tsx
Normal file
@ -0,0 +1,242 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Ban, RotateCcw, Trash2, UserCog, UsersRound, X } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import type { AdminBulkUsersAction, AdminUserGroup, AdminUserRole } from "@/lib/api/admin-types"
|
||||
import { useBulkAdminUsers } from "@/lib/api/hooks/use-admin-mutations"
|
||||
import {
|
||||
USER_ROLE_ICONS,
|
||||
USER_ROLE_LABELS,
|
||||
} from "@/lib/admin/user-role"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const ROLE_OPTIONS: AdminUserRole[] = ["admin", "user", "guest", "suspended"]
|
||||
|
||||
export function UsersBulkToolbar({
|
||||
selectedIds,
|
||||
groups,
|
||||
onClear,
|
||||
}: {
|
||||
selectedIds: string[]
|
||||
groups: AdminUserGroup[]
|
||||
onClear: () => void
|
||||
}) {
|
||||
const bulk = useBulkAdminUsers()
|
||||
const [roleDialogOpen, setRoleDialogOpen] = useState(false)
|
||||
const [groupDialogOpen, setGroupDialogOpen] = useState(false)
|
||||
const [groupAction, setGroupAction] = useState<"add_to_group" | "remove_from_group">(
|
||||
"add_to_group"
|
||||
)
|
||||
const [selectedRole, setSelectedRole] = useState<AdminUserRole>("user")
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<string>("")
|
||||
|
||||
const count = selectedIds.length
|
||||
if (count === 0) return null
|
||||
|
||||
async function run(action: AdminBulkUsersAction, extra?: { role?: AdminUserRole; group_id?: string }) {
|
||||
try {
|
||||
const result = await bulk.mutateAsync({
|
||||
user_ids: selectedIds,
|
||||
action,
|
||||
...extra,
|
||||
})
|
||||
if (result.failed?.length) {
|
||||
toast.warning(
|
||||
`${result.success_count} réussi(s), ${result.failed.length} échec(s)`
|
||||
)
|
||||
} else {
|
||||
toast.success(`${result.success_count} utilisateur(s) mis à jour`)
|
||||
}
|
||||
onClear()
|
||||
} catch {
|
||||
toast.error("Action de masse impossible")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2 rounded-lg border bg-muted/30 px-3 py-2">
|
||||
<Button variant="ghost" size="icon" className="size-8" onClick={onClear}>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
<span className="text-sm font-medium">
|
||||
{count} sélectionné{count > 1 ? "s" : ""}
|
||||
</span>
|
||||
<div className="ml-auto flex flex-wrap gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={bulk.isPending}
|
||||
onClick={() => void run("disable")}
|
||||
>
|
||||
<Ban className="mr-2 size-4" />
|
||||
Suspendre
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={bulk.isPending}
|
||||
onClick={() => void run("reactivate")}
|
||||
>
|
||||
<RotateCcw className="mr-2 size-4" />
|
||||
Réactiver
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={bulk.isPending}
|
||||
onClick={() => setRoleDialogOpen(true)}
|
||||
>
|
||||
<UserCog className="mr-2 size-4" />
|
||||
Changer le type
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={bulk.isPending || groups.length === 0}>
|
||||
<UsersRound className="mr-2 size-4" />
|
||||
Groupes
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setGroupAction("add_to_group")
|
||||
setSelectedGroupId(groups[0]?.id ?? "")
|
||||
setGroupDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
Ajouter à un groupe
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setGroupAction("remove_from_group")
|
||||
setSelectedGroupId(groups[0]?.id ?? "")
|
||||
setGroupDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
Retirer d'un groupe
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive"
|
||||
disabled={bulk.isPending}
|
||||
onClick={() => {
|
||||
if (confirm(`Supprimer ${count} utilisateur(s) ?`)) {
|
||||
void run("delete")
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 size-4" />
|
||||
Supprimer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={roleDialogOpen} onOpenChange={setRoleDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Changer le type</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label>Type d'utilisateur</Label>
|
||||
<Select value={selectedRole} onValueChange={(v) => setSelectedRole(v as AdminUserRole)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ROLE_OPTIONS.map((role) => {
|
||||
const Icon = USER_ROLE_ICONS[role]
|
||||
return (
|
||||
<SelectItem key={role} value={role}>
|
||||
<span className="flex items-center gap-2">
|
||||
<Icon className="size-4" />
|
||||
{USER_ROLE_LABELS[role]}
|
||||
</span>
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRoleDialogOpen(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
void run("set_role", { role: selectedRole })
|
||||
setRoleDialogOpen(false)
|
||||
}}
|
||||
>
|
||||
Appliquer
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={groupDialogOpen} onOpenChange={setGroupDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{groupAction === "add_to_group" ? "Ajouter au groupe" : "Retirer du groupe"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label>Groupe</Label>
|
||||
<Select value={selectedGroupId} onValueChange={setSelectedGroupId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choisir un groupe" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{groups.map((group) => (
|
||||
<SelectItem key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setGroupDialogOpen(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!selectedGroupId}
|
||||
onClick={() => {
|
||||
void run(groupAction, { group_id: selectedGroupId })
|
||||
setGroupDialogOpen(false)
|
||||
}}
|
||||
>
|
||||
Appliquer
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
213
components/admin/settings/sections/users-groups-dialog.tsx
Normal file
213
components/admin/settings/sections/users-groups-dialog.tsx
Normal file
@ -0,0 +1,213 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { Pencil, Plus, Trash2, UsersRound } from "lucide-react"
|
||||
import { useAdminUserGroups } from "@/lib/api/hooks/use-admin-queries"
|
||||
import {
|
||||
useCreateAdminUserGroup,
|
||||
useDeleteAdminUserGroup,
|
||||
useUpdateAdminUserGroup,
|
||||
} from "@/lib/api/hooks/use-admin-mutations"
|
||||
import type { AdminUserGroup } from "@/lib/api/admin-types"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
export function UsersGroupsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) {
|
||||
const [q, setQ] = useState("")
|
||||
const [editorOpen, setEditorOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<AdminUserGroup | null>(null)
|
||||
|
||||
const queryParams = useMemo(
|
||||
() => ({ page: 1, page_size: 100, q: q.trim() || undefined }),
|
||||
[q]
|
||||
)
|
||||
const { data, isFetching } = useAdminUserGroups(queryParams)
|
||||
const groups = data?.groups ?? []
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null)
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
function openEdit(group: AdminUserGroup) {
|
||||
setEditing(group)
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Groupes d'utilisateurs</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Rechercher un groupe"
|
||||
className="h-9"
|
||||
/>
|
||||
<Button className="h-9 shrink-0" onClick={openCreate}>
|
||||
<Plus className="mr-2 size-4" />
|
||||
Créer
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-80 space-y-2 overflow-y-auto rounded-lg border p-2">
|
||||
{isFetching ? (
|
||||
<p className="px-2 py-4 text-sm text-muted-foreground">Chargement…</p>
|
||||
) : groups.length === 0 ? (
|
||||
<p className="px-2 py-4 text-sm text-muted-foreground">Aucun groupe.</p>
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
<GroupRow key={group.id} group={group} onEdit={() => openEdit(group)} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<GroupEditorDialog
|
||||
open={editorOpen}
|
||||
onOpenChange={setEditorOpen}
|
||||
group={editing}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function GroupRow({
|
||||
group,
|
||||
onEdit,
|
||||
}: {
|
||||
group: AdminUserGroup
|
||||
onEdit: () => void
|
||||
}) {
|
||||
const deleteGroup = useDeleteAdminUserGroup()
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-md px-2 py-2 hover:bg-muted/50">
|
||||
<UsersRound className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium">{group.name}</div>
|
||||
{group.description ? (
|
||||
<div className="text-xs text-muted-foreground">{group.description}</div>
|
||||
) : null}
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{group.member_count.toLocaleString("fr-FR")} membre(s)
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<Button variant="ghost" size="icon" className="size-8" onClick={onEdit}>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 text-destructive"
|
||||
onClick={() => {
|
||||
if (confirm(`Supprimer le groupe « ${group.name} » ?`)) {
|
||||
void deleteGroup.mutateAsync(group.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GroupEditorDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
group,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
group: AdminUserGroup | null
|
||||
}) {
|
||||
const createGroup = useCreateAdminUserGroup()
|
||||
const updateGroup = useUpdateAdminUserGroup(group?.id ?? "")
|
||||
const [name, setName] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
|
||||
const isEdit = Boolean(group)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setName(group?.name ?? "")
|
||||
setDescription(group?.description ?? "")
|
||||
}, [open, group])
|
||||
|
||||
async function submit() {
|
||||
const trimmedName = name.trim()
|
||||
if (!trimmedName) return
|
||||
if (isEdit && group) {
|
||||
await updateGroup.mutateAsync({
|
||||
name: trimmedName,
|
||||
description: description.trim(),
|
||||
})
|
||||
} else {
|
||||
await createGroup.mutateAsync({
|
||||
name: trimmedName,
|
||||
description: description.trim() || undefined,
|
||||
})
|
||||
}
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? "Modifier le groupe" : "Nouveau groupe"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>Nom</Label>
|
||||
<Input className="mt-1" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Description (optionnel)</Label>
|
||||
<Textarea
|
||||
className="mt-1 min-h-20"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!name.trim() || createGroup.isPending || updateGroup.isPending}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
{isEdit ? "Enregistrer" : "Créer"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@ -1,10 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { MoreHorizontal, UserPlus } from "lucide-react"
|
||||
import { MoreHorizontal, UserPlus, UsersRound } from "lucide-react"
|
||||
import { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header"
|
||||
import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner"
|
||||
import { useAdminUser, useAdminUsers } from "@/lib/api/hooks/use-admin-queries"
|
||||
import { AdminListControls, type AdminListSortOption } from "@/components/admin/settings/admin-list-controls"
|
||||
import { UsersBulkToolbar } from "@/components/admin/settings/sections/users-bulk-toolbar"
|
||||
import { UsersGroupsDialog } from "@/components/admin/settings/sections/users-groups-dialog"
|
||||
import { useAdminUser, useAdminUserGroups, useAdminUsers } from "@/lib/api/hooks/use-admin-queries"
|
||||
import {
|
||||
useDeleteAdminUser,
|
||||
useDisableAdminUser,
|
||||
@ -25,6 +28,7 @@ import {
|
||||
} from "@/lib/admin/user-role"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
@ -66,34 +70,87 @@ import {
|
||||
|
||||
const ROLE_OPTIONS: AdminUserRole[] = ["admin", "user", "guest", "suspended"]
|
||||
|
||||
const USER_SORT_OPTIONS: AdminListSortOption[] = [
|
||||
{ value: "-created_at", label: "Création (récent)" },
|
||||
{ value: "created_at", label: "Création (ancien)" },
|
||||
{ value: "name", label: "Nom (A→Z)" },
|
||||
{ value: "-name", label: "Nom (Z→A)" },
|
||||
{ value: "email", label: "E-mail (A→Z)" },
|
||||
{ value: "-email", label: "E-mail (Z→A)" },
|
||||
{ value: "-updated_at", label: "Mise à jour (récent)" },
|
||||
{ value: "updated_at", label: "Mise à jour (ancien)" },
|
||||
]
|
||||
|
||||
export function UsersSection() {
|
||||
const [q, setQ] = useState("")
|
||||
const [role, setRole] = useState<string>("all")
|
||||
const [groupId, setGroupId] = useState<string>("all")
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(25)
|
||||
const [sort, setSort] = useState("-created_at")
|
||||
const [inviteOpen, setInviteOpen] = useState(false)
|
||||
const [groupsOpen, setGroupsOpen] = useState(false)
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||||
|
||||
const queryParams = useMemo(
|
||||
() => ({
|
||||
page,
|
||||
page_size: 25,
|
||||
page_size: pageSize,
|
||||
sort,
|
||||
q: q.trim() || undefined,
|
||||
role: role === "all" ? undefined : role,
|
||||
group_id: groupId === "all" ? undefined : groupId,
|
||||
}),
|
||||
[page, q, role]
|
||||
[page, pageSize, sort, q, role, groupId]
|
||||
)
|
||||
|
||||
const { data: groupsData } = useAdminUserGroups({ page: 1, page_size: 200 })
|
||||
const groups = groupsData?.groups ?? []
|
||||
|
||||
const { data, isFetching, isError, refetch } = useAdminUsers(queryParams)
|
||||
const users = data?.users ?? []
|
||||
const users = data?.users
|
||||
const total = data?.pagination.total ?? 0
|
||||
const pageSize = data?.pagination.page_size ?? 25
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
||||
const resolvedPageSize = data?.pagination.page_size ?? pageSize
|
||||
const totalPages = Math.max(1, Math.ceil(total / resolvedPageSize))
|
||||
|
||||
const pageUserIds = useMemo(() => (users ?? []).map((user) => user.id), [users])
|
||||
const pageUserIdsKey = pageUserIds.join(",")
|
||||
const allPageSelected =
|
||||
pageUserIds.length > 0 && pageUserIds.every((id) => selectedIds.includes(id))
|
||||
const somePageSelected =
|
||||
pageUserIds.some((id) => selectedIds.includes(id)) && !allPageSelected
|
||||
|
||||
useEffect(() => {
|
||||
const ids = pageUserIdsKey.length > 0 ? pageUserIdsKey.split(",") : []
|
||||
setSelectedIds((prev) => {
|
||||
const next = prev.filter((id) => ids.includes(id))
|
||||
if (next.length === prev.length && next.every((id, index) => id === prev[index])) {
|
||||
return prev
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [pageUserIdsKey])
|
||||
|
||||
function toggleUser(id: string, checked: boolean) {
|
||||
setSelectedIds((prev) =>
|
||||
checked ? Array.from(new Set([...prev, id])) : prev.filter((value) => value !== id)
|
||||
)
|
||||
}
|
||||
|
||||
function togglePage(checked: boolean) {
|
||||
if (checked) {
|
||||
setSelectedIds((prev) => Array.from(new Set([...prev, ...pageUserIds])))
|
||||
return
|
||||
}
|
||||
setSelectedIds((prev) => prev.filter((id) => !pageUserIds.includes(id)))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionHeader
|
||||
title="Utilisateurs"
|
||||
description="Comptes, types d'utilisateur, invitations et quotas."
|
||||
description="Comptes, groupes, types d'utilisateur, invitations et quotas."
|
||||
/>
|
||||
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
|
||||
|
||||
@ -120,13 +177,7 @@ export function UsersSection() {
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-9">
|
||||
<SelectValue>
|
||||
{role === "all" ? (
|
||||
<UserRoleLabel role="all" />
|
||||
) : (
|
||||
<UserRoleLabel role={role as AdminUserRole} />
|
||||
)}
|
||||
</SelectValue>
|
||||
<SelectValue placeholder="Type d'utilisateur" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
@ -140,18 +191,82 @@ export function UsersSection() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="w-52">
|
||||
<Label className="text-xs">Groupe</Label>
|
||||
<Select
|
||||
value={groupId}
|
||||
onValueChange={(v) => {
|
||||
setGroupId(v)
|
||||
setPage(1)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-9">
|
||||
<SelectValue placeholder="Groupe" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
<span className="flex items-center gap-2">
|
||||
<UsersRound className="size-4 opacity-80" />
|
||||
Tous les groupes
|
||||
</span>
|
||||
</SelectItem>
|
||||
{groups.map((group) => (
|
||||
<SelectItem key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button variant="outline" className="h-9" onClick={() => setGroupsOpen(true)}>
|
||||
<UsersRound className="mr-2 size-4" />
|
||||
Groupes
|
||||
</Button>
|
||||
<Button className="h-9" onClick={() => setInviteOpen(true)}>
|
||||
<UserPlus className="mr-2 size-4" />
|
||||
Inviter
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<UsersBulkToolbar
|
||||
selectedIds={selectedIds}
|
||||
groups={groups}
|
||||
onClear={() => setSelectedIds([])}
|
||||
/>
|
||||
|
||||
<AdminListControls
|
||||
page={page}
|
||||
pageSize={resolvedPageSize}
|
||||
total={total}
|
||||
totalPages={totalPages}
|
||||
sort={sort}
|
||||
sortOptions={USER_SORT_OPTIONS}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(next) => {
|
||||
setPageSize(next)
|
||||
setPage(1)
|
||||
}}
|
||||
onSortChange={(next) => {
|
||||
setSort(next)
|
||||
setPage(1)
|
||||
}}
|
||||
itemLabel="utilisateur(s)"
|
||||
/>
|
||||
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10">
|
||||
<Checkbox
|
||||
checked={allPageSelected ? true : somePageSelected ? "indeterminate" : false}
|
||||
onCheckedChange={(checked) => togglePage(checked === true)}
|
||||
aria-label="Sélectionner la page"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>Utilisateur</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead className="hidden xl:table-cell">Groupes</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">Mail</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">Drive</TableHead>
|
||||
<TableHead className="hidden md:table-cell">ID externe</TableHead>
|
||||
@ -159,17 +274,19 @@ export function UsersSection() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.length === 0 ? (
|
||||
{(users ?? []).length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||
<TableCell colSpan={8} className="text-center text-muted-foreground">
|
||||
Aucun utilisateur trouvé.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
(users ?? []).map((user) => (
|
||||
<UserRow
|
||||
key={user.id}
|
||||
user={user}
|
||||
selected={selectedIds.includes(user.id)}
|
||||
onToggleSelect={(checked) => toggleUser(user.id, checked)}
|
||||
onOpen={() => setSelectedId(user.id)}
|
||||
/>
|
||||
))
|
||||
@ -178,59 +295,57 @@ export function UsersSection() {
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 ? (
|
||||
<div className="mt-4 flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{total.toLocaleString("fr-FR")} utilisateur(s)
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
Précédent
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
Suivant
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<InviteUserDialog open={inviteOpen} onOpenChange={setInviteOpen} />
|
||||
<UsersGroupsDialog open={groupsOpen} onOpenChange={setGroupsOpen} />
|
||||
<UserDetailSheet userId={selectedId} onClose={() => setSelectedId(null)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function UserRow({ user, onOpen }: { user: AdminUser; onOpen: () => void }) {
|
||||
function UserRow({
|
||||
user,
|
||||
selected,
|
||||
onToggleSelect,
|
||||
onOpen,
|
||||
}: {
|
||||
user: AdminUser
|
||||
selected: boolean
|
||||
onToggleSelect: (checked: boolean) => void
|
||||
onOpen: () => void
|
||||
}) {
|
||||
const disableUser = useDisableAdminUser()
|
||||
const reactivateUser = useReactivateAdminUser()
|
||||
const deleteUser = useDeleteAdminUser()
|
||||
|
||||
return (
|
||||
<TableRow className="cursor-pointer" onClick={onOpen}>
|
||||
<TableCell>
|
||||
<TableRow className="cursor-pointer">
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
onCheckedChange={(checked) => onToggleSelect(checked === true)}
|
||||
aria-label={`Sélectionner ${user.email}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell onClick={onOpen}>
|
||||
<div className="font-medium">{user.name || "—"}</div>
|
||||
<div className="text-xs text-muted-foreground">{user.email}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell onClick={onOpen}>
|
||||
<RoleBadge role={resolveUserRole(user)} />
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-xs text-muted-foreground lg:table-cell">
|
||||
<TableCell className="hidden xl:table-cell" onClick={onOpen}>
|
||||
<UserGroupsBadges groups={user.groups ?? []} />
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-xs text-muted-foreground lg:table-cell" onClick={onOpen}>
|
||||
{formatBytes(user.storage?.mail_used_bytes ?? 0)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-xs text-muted-foreground lg:table-cell">
|
||||
<TableCell className="hidden text-xs text-muted-foreground lg:table-cell" onClick={onOpen}>
|
||||
{formatBytes(user.storage?.drive_used_bytes ?? 0)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden max-w-[200px] truncate font-mono text-xs md:table-cell">
|
||||
<TableCell
|
||||
className="hidden max-w-[200px] truncate font-mono text-xs md:table-cell"
|
||||
onClick={onOpen}
|
||||
>
|
||||
{user.external_id}
|
||||
</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
@ -273,6 +388,28 @@ function UserRow({ user, onOpen }: { user: AdminUser; onOpen: () => void }) {
|
||||
)
|
||||
}
|
||||
|
||||
function UserGroupsBadges({ groups }: { groups: AdminUser["groups"] }) {
|
||||
if (!groups?.length) {
|
||||
return <span className="text-xs text-muted-foreground">—</span>
|
||||
}
|
||||
const visible = groups.slice(0, 2)
|
||||
const extra = groups.length - visible.length
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{visible.map((group) => (
|
||||
<Badge key={group.id} variant="outline" className="text-xs font-normal">
|
||||
{group.name}
|
||||
</Badge>
|
||||
))}
|
||||
{extra > 0 ? (
|
||||
<Badge variant="secondary" className="text-xs font-normal">
|
||||
+{extra}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UserRoleLabel({
|
||||
role,
|
||||
className,
|
||||
@ -430,6 +567,12 @@ function UserDetailSheet({
|
||||
<div className="space-y-8 px-6 py-6">
|
||||
<div className="space-y-4">
|
||||
<RoleBadge role={resolveUserRole(user)} />
|
||||
{user.groups?.length ? (
|
||||
<div className="space-y-2">
|
||||
<Label>Groupes</Label>
|
||||
<UserGroupsBadges groups={user.groups} />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
<Label>Type d'utilisateur</Label>
|
||||
<Select
|
||||
@ -437,9 +580,7 @@ function UserDetailSheet({
|
||||
onValueChange={(v) => setSelectedRole(v as AdminUserRole)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue>
|
||||
<UserRoleLabel role={selectedRole} />
|
||||
</SelectValue>
|
||||
<SelectValue placeholder="Type d'utilisateur" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ROLE_OPTIONS.map((option) => (
|
||||
|
||||
35
components/admin/settings/tech-brand-select-label.tsx
Normal file
35
components/admin/settings/tech-brand-select-label.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
"use client"
|
||||
|
||||
import { Icon } from "@iconify/react"
|
||||
import { techBrandIcon } from "@/lib/admin-settings/tech-brand-icons"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function TechBrandSelectLabel({
|
||||
brand,
|
||||
icon,
|
||||
children,
|
||||
className,
|
||||
iconClassName,
|
||||
suffix,
|
||||
}: {
|
||||
brand?: string
|
||||
icon?: string
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
iconClassName?: string
|
||||
suffix?: React.ReactNode
|
||||
}) {
|
||||
const resolved = icon ?? (brand ? techBrandIcon(brand) : undefined)
|
||||
|
||||
return (
|
||||
<span className={cn("inline-flex min-w-0 items-center gap-2", className)}>
|
||||
{resolved ? (
|
||||
<Icon icon={resolved} className={cn("size-4 shrink-0", iconClassName)} aria-hidden />
|
||||
) : null}
|
||||
<span className="truncate">
|
||||
{children}
|
||||
{suffix}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@ -43,8 +43,8 @@ import {
|
||||
import { useEffectiveAgendaSettings } from "@/lib/agenda/use-effective-agenda-settings"
|
||||
import { useIsDemoApp } from "@/lib/demo/use-is-demo-app"
|
||||
import { useThemeModeControls } from "@/lib/demo/use-theme-mode-controls"
|
||||
import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry"
|
||||
import {
|
||||
MAIL_SETTINGS_PAGE_MASONRY_CLASS,
|
||||
MAIL_SETTINGS_PAGE_MASONRY_SECTION_CLASS,
|
||||
} from "@/lib/mail-chrome-classes"
|
||||
import type { MailThemeMode } from "@/lib/mail-settings/types"
|
||||
@ -459,7 +459,7 @@ export function AgendaSettingsFields({
|
||||
)
|
||||
|
||||
if (isPage) {
|
||||
return <div className={MAIL_SETTINGS_PAGE_MASONRY_CLASS}>{fields}</div>
|
||||
return <AutomationTabMasonry columns={2}>{fields}</AutomationTabMasonry>
|
||||
}
|
||||
|
||||
return fields
|
||||
|
||||
@ -1,13 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import type { AiChatContext } from "@/lib/ai/chat-context"
|
||||
import { buildEmbedSearchParams } from "@/lib/ai/chat-context"
|
||||
import { buildEmbedSearchParams, systemPromptFromContext } from "@/lib/ai/chat-context"
|
||||
import { buildAiEmbedUrl, resolveAiEmbedOrigin } from "@/lib/ai/embed-url"
|
||||
import {
|
||||
useAiIframeExternalLinks,
|
||||
useAiIframeNavigation,
|
||||
} from "@/lib/ai/use-ai-iframe-navigation"
|
||||
import { useAiIframeExternalLinks } from "@/lib/ai/use-ai-iframe-navigation"
|
||||
import { useAiConfig, useCreateAiSession } from "@/lib/api/hooks/use-ai-queries"
|
||||
import { useTheme } from "next-themes"
|
||||
|
||||
type AiChatIframeProps = {
|
||||
@ -19,15 +17,64 @@ type AiChatIframeProps = {
|
||||
export function AiChatIframe({ publicPath = "/ai", context, className }: AiChatIframeProps) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||
const { resolvedTheme } = useTheme()
|
||||
const { data: config, isSuccess } = useAiConfig()
|
||||
const createSession = useCreateAiSession()
|
||||
const [sessionToken, setSessionToken] = useState<string | undefined>()
|
||||
const [sessionId, setSessionId] = useState<string | undefined>()
|
||||
const sessionContextKey = useMemo(
|
||||
() =>
|
||||
[
|
||||
context.app,
|
||||
context.temporary,
|
||||
context.messageId,
|
||||
context.accountId,
|
||||
context.drivePath,
|
||||
context.fileId,
|
||||
context.contactId,
|
||||
].join("|"),
|
||||
[
|
||||
context.app,
|
||||
context.temporary,
|
||||
context.messageId,
|
||||
context.accountId,
|
||||
context.drivePath,
|
||||
context.fileId,
|
||||
context.contactId,
|
||||
]
|
||||
)
|
||||
const embedOrigin = useMemo(() => resolveAiEmbedOrigin(publicPath), [publicPath])
|
||||
const src = useMemo(() => {
|
||||
const qs = buildEmbedSearchParams(context)
|
||||
return buildAiEmbedUrl(publicPath, qs)
|
||||
}, [publicPath, context])
|
||||
const enabledTools = config?.enabled_tools ?? []
|
||||
const mcpUrl = config?.mcp_url ?? "/api/v1/ai/mcp"
|
||||
|
||||
const iframeSrc = useMemo(() => {
|
||||
if (!isSuccess || !config?.enabled) return null
|
||||
const qs = buildEmbedSearchParams(context, config.default_model)
|
||||
return buildAiEmbedUrl(publicPath, qs)
|
||||
}, [isSuccess, config, publicPath, context])
|
||||
|
||||
useAiIframeNavigation(iframeRef, publicPath)
|
||||
useAiIframeExternalLinks(embedOrigin)
|
||||
|
||||
useEffect(() => {
|
||||
if (!config?.enabled) return
|
||||
let cancelled = false
|
||||
createSession
|
||||
.mutateAsync(context)
|
||||
.then((session) => {
|
||||
if (cancelled) return
|
||||
setSessionToken(session.token_secret)
|
||||
setSessionId(session.session_id)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setSessionToken(undefined)
|
||||
setSessionId(undefined)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [config?.enabled, sessionContextKey, context, createSession.mutateAsync])
|
||||
|
||||
useEffect(() => {
|
||||
const iframe = iframeRef.current
|
||||
if (!iframe?.contentWindow || !embedOrigin) return
|
||||
@ -40,21 +87,54 @@ export function AiChatIframe({ publicPath = "/ai", context, className }: AiChatI
|
||||
useEffect(() => {
|
||||
const iframe = iframeRef.current
|
||||
if (!iframe?.contentWindow || !embedOrigin) return
|
||||
const systemPrompt = [
|
||||
systemPromptFromContext(context),
|
||||
context.systemPromptExtra,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
iframe.contentWindow.postMessage(
|
||||
{
|
||||
type: "ULTI_CONTEXT_UPDATE",
|
||||
context,
|
||||
systemPrompt: context.systemPromptExtra,
|
||||
systemPrompt: systemPrompt || undefined,
|
||||
},
|
||||
embedOrigin
|
||||
)
|
||||
}, [context, embedOrigin])
|
||||
|
||||
useEffect(() => {
|
||||
const iframe = iframeRef.current
|
||||
if (!iframe?.contentWindow || !embedOrigin || !sessionToken) return
|
||||
iframe.contentWindow.postMessage(
|
||||
{
|
||||
type: "ULTI_SESSION",
|
||||
token_secret: sessionToken,
|
||||
session_id: sessionId,
|
||||
mcp_url: mcpUrl,
|
||||
enabled_tools: enabledTools,
|
||||
default_model: config?.default_model,
|
||||
},
|
||||
embedOrigin
|
||||
)
|
||||
}, [sessionToken, sessionId, mcpUrl, enabledTools, config?.default_model, embedOrigin])
|
||||
|
||||
if (!iframeSrc) {
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
aria-busy="true"
|
||||
aria-label="Chargement UltiAI"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<iframe
|
||||
key={iframeSrc}
|
||||
ref={iframeRef}
|
||||
title="UltiAI"
|
||||
src={src}
|
||||
src={iframeSrc}
|
||||
className={className}
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-downloads"
|
||||
allow="clipboard-read; clipboard-write"
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry"
|
||||
import {
|
||||
MAIL_SETTINGS_PAGE_MASONRY_CLASS,
|
||||
MAIL_SETTINGS_PAGE_MASONRY_SECTION_CLASS,
|
||||
} from "@/lib/mail-chrome-classes"
|
||||
import { useThemeModeControls } from "@/lib/demo/use-theme-mode-controls"
|
||||
@ -345,7 +345,7 @@ export function MailSettingsFields({
|
||||
)
|
||||
|
||||
if (isPage) {
|
||||
return <div className={MAIL_SETTINGS_PAGE_MASONRY_CLASS}>{fields}</div>
|
||||
return <AutomationTabMasonry columns={2}>{fields}</AutomationTabMasonry>
|
||||
}
|
||||
|
||||
return fields
|
||||
|
||||
@ -2,10 +2,12 @@
|
||||
|
||||
import { Children, type ReactNode } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
MAIL_SETTINGS_PAGE_MASONRY_CLASS,
|
||||
MAIL_SETTINGS_PAGE_MASONRY_ITEM_CLASS,
|
||||
} from "@/lib/mail-chrome-classes"
|
||||
|
||||
function MasonryStack({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-4 lg:gap-5">{children}</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AutomationTabMasonry({
|
||||
columns,
|
||||
@ -16,25 +18,43 @@ export function AutomationTabMasonry({
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
if (columns === 1) {
|
||||
return <div className={cn("space-y-4", className)}>{children}</div>
|
||||
}
|
||||
|
||||
const items = Children.toArray(children).filter(Boolean)
|
||||
|
||||
if (columns === 1) {
|
||||
return (
|
||||
<div className={cn("flex w-full flex-col gap-4", className)}>{children}</div>
|
||||
)
|
||||
}
|
||||
|
||||
const left = items.filter((_, index) => index % 2 === 0)
|
||||
const right = items.filter((_, index) => index % 2 === 1)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-4 lg:space-y-0",
|
||||
MAIL_SETTINGS_PAGE_MASONRY_CLASS,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{items.map((child, index) => (
|
||||
<div key={index} className={MAIL_SETTINGS_PAGE_MASONRY_ITEM_CLASS}>
|
||||
{child}
|
||||
</div>
|
||||
))}
|
||||
<div className={cn("w-full", className)}>
|
||||
<div className="flex flex-col gap-4 lg:hidden">
|
||||
{items.map((child, index) => (
|
||||
<div key={index} className="min-w-0">
|
||||
{child}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hidden items-start gap-5 lg:flex">
|
||||
<MasonryStack>
|
||||
{left.map((child, index) => (
|
||||
<div key={index} className="min-w-0">
|
||||
{child}
|
||||
</div>
|
||||
))}
|
||||
</MasonryStack>
|
||||
<MasonryStack>
|
||||
{right.map((child, index) => (
|
||||
<div key={index} className="min-w-0">
|
||||
{child}
|
||||
</div>
|
||||
))}
|
||||
</MasonryStack>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Plus, Trash2 } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
@ -16,25 +15,18 @@ import {
|
||||
useLLMSettings,
|
||||
useUpdateLLMSettings,
|
||||
} from "@/lib/api/hooks/use-contact-discovery"
|
||||
import type { ApiLLMProvider, ApiLLMSettings } from "@/lib/contacts/discovery-types"
|
||||
import type { ApiLLMSettings } from "@/lib/contacts/discovery-types"
|
||||
import { LLMModelSuggestInput } from "@/components/gmail/settings/automation/llm-model-suggest-input"
|
||||
import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry"
|
||||
import { LlmProvidersEditor } from "@/components/llm/llm-providers-editor"
|
||||
import { TechBrandSelectLabel } from "@/components/admin/settings/tech-brand-select-label"
|
||||
import { inferLlmProviderType, llmCatalogEntry, normalizeLlmProvider } from "@/lib/llm/llm-provider-catalog"
|
||||
import {
|
||||
CONTACTS_MUTED_TEXT,
|
||||
CONTACTS_PRIMARY_BTN_CLASS,
|
||||
} from "@/lib/contacts-chrome-classes"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function emptyProvider(): ApiLLMProvider {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
name: "",
|
||||
base_url: "https://api.openai.com/v1",
|
||||
api_key: "",
|
||||
default_model: "gpt-4o-mini",
|
||||
}
|
||||
}
|
||||
|
||||
export function LLMProvidersPanel() {
|
||||
const { data: remote, isLoading } = useLLMSettings()
|
||||
const updateSettings = useUpdateLLMSettings()
|
||||
@ -48,40 +40,11 @@ export function LLMProvidersPanel() {
|
||||
if (remote) {
|
||||
setDraft({
|
||||
...remote,
|
||||
providers: remote.providers ?? [],
|
||||
providers: (remote.providers ?? []).map(normalizeLlmProvider),
|
||||
})
|
||||
}
|
||||
}, [remote])
|
||||
|
||||
function updateProvider(index: number, patch: Partial<ApiLLMProvider>) {
|
||||
setDraft((prev) => {
|
||||
const providers = [...prev.providers]
|
||||
providers[index] = { ...providers[index], ...patch }
|
||||
return { ...prev, providers }
|
||||
})
|
||||
}
|
||||
|
||||
function addProvider() {
|
||||
const p = emptyProvider()
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
providers: [...prev.providers, p],
|
||||
default_provider_id: prev.default_provider_id || p.id,
|
||||
}))
|
||||
}
|
||||
|
||||
function removeProvider(index: number) {
|
||||
setDraft((prev) => {
|
||||
const removed = prev.providers[index]
|
||||
const providers = prev.providers.filter((_, i) => i !== index)
|
||||
let defaultId = prev.default_provider_id
|
||||
if (defaultId === removed?.id) {
|
||||
defaultId = providers[0]?.id ?? ""
|
||||
}
|
||||
return { ...prev, providers, default_provider_id: defaultId }
|
||||
})
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
await updateSettings.mutateAsync(draft)
|
||||
setSaved(true)
|
||||
@ -101,87 +64,30 @@ export function LLMProvidersPanel() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AutomationTabMasonry columns={2}>
|
||||
{draft.providers.map((provider, index) => (
|
||||
<div key={provider.id} className="space-y-3 rounded-lg border border-border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{provider.name || `Fournisseur ${index + 1}`}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeProvider(index)}
|
||||
aria-label="Supprimer"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label className="text-xs">Nom</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
value={provider.name}
|
||||
onChange={(e) => updateProvider(index, { name: e.target.value })}
|
||||
placeholder="OpenAI"
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<Label className="text-xs">URL de base</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
value={provider.base_url}
|
||||
onChange={(e) => updateProvider(index, { base_url: e.target.value })}
|
||||
placeholder="https://api.openai.com/v1"
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<Label className="text-xs">Clé API</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
type="password"
|
||||
value={provider.api_key ?? ""}
|
||||
onChange={(e) => updateProvider(index, { api_key: e.target.value })}
|
||||
placeholder="sk-…"
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<Label className="text-xs">Modèle par défaut</Label>
|
||||
<LLMModelSuggestInput
|
||||
className="mt-1"
|
||||
baseUrl={provider.base_url}
|
||||
apiKey={provider.api_key}
|
||||
value={provider.default_model}
|
||||
onChange={(default_model) => updateProvider(index, { default_model })}
|
||||
placeholder="gpt-4o-mini"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<LlmProvidersEditor
|
||||
providers={draft.providers}
|
||||
defaultProviderId={draft.default_provider_id}
|
||||
onProvidersChange={(providers) => setDraft((prev) => ({ ...prev, providers }))}
|
||||
onDefaultProviderIdChange={(default_provider_id) =>
|
||||
setDraft((prev) => ({ ...prev, default_provider_id }))
|
||||
}
|
||||
renderDefaultModelInput={({ provider, onChange }) => (
|
||||
<LLMModelSuggestInput
|
||||
baseUrl={provider.base_url}
|
||||
apiKey={provider.api_key}
|
||||
value={provider.default_model}
|
||||
onChange={onChange}
|
||||
placeholder="gpt-4o-mini"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<AutomationTabMasonry columns={2}>
|
||||
<div className="space-y-3 rounded-lg border border-border p-4">
|
||||
<h4 className="text-sm font-medium">Découverte de contacts</h4>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label className="text-xs">Fournisseur par défaut</Label>
|
||||
<Select
|
||||
value={draft.default_provider_id}
|
||||
onValueChange={(v) => setDraft((p) => ({ ...p, default_provider_id: v }))}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-9">
|
||||
<SelectValue placeholder="Choisir…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{draft.providers.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name || p.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Modèle pour l'enrichissement</Label>
|
||||
<Label className="text-xs">Fournisseur pour l'enrichissement</Label>
|
||||
<Select
|
||||
value={draft.contact_discovery_provider_id ?? draft.default_provider_id}
|
||||
onValueChange={(v) =>
|
||||
@ -192,11 +98,17 @@ export function LLMProvidersPanel() {
|
||||
<SelectValue placeholder="Même que défaut" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{draft.providers.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name || p.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
{draft.providers.map((p) => {
|
||||
const type = inferLlmProviderType(p)
|
||||
const entry = llmCatalogEntry(type)
|
||||
return (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
<TechBrandSelectLabel brand={type} icon={entry.icon}>
|
||||
{p.name || entry.label}
|
||||
</TechBrandSelectLabel>
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@ -216,10 +128,6 @@ export function LLMProvidersPanel() {
|
||||
</AutomationTabMasonry>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={addProvider}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Ajouter un fournisseur
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={updateSettings.isPending}
|
||||
|
||||
@ -1,68 +1,35 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { ExternalLink } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
useSearchSettings,
|
||||
useUpdateSearchSettings,
|
||||
} from "@/lib/api/hooks/use-contact-discovery"
|
||||
import type { ApiSearchProvider, ApiSearchSettings } from "@/lib/contacts/discovery-types"
|
||||
import type { ApiSearchSettings } from "@/lib/contacts/discovery-types"
|
||||
import { WebSearchProvidersEditor } from "@/components/web-search/web-search-providers-editor"
|
||||
import {
|
||||
CONTACTS_MUTED_TEXT,
|
||||
CONTACTS_PRIMARY_BTN_CLASS,
|
||||
} from "@/lib/contacts-chrome-classes"
|
||||
import { normalizeSearchProviders } from "@/lib/web-search/search-provider-catalog"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const BRAVE_PROVIDER_ID = "brave-default"
|
||||
|
||||
function defaultBraveProvider(): ApiSearchProvider {
|
||||
return {
|
||||
id: BRAVE_PROVIDER_ID,
|
||||
name: "Brave Search",
|
||||
type: "brave",
|
||||
api_key: "",
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDraft(raw: ApiSearchSettings | undefined): ApiSearchSettings {
|
||||
const providers = raw?.providers?.length ? raw.providers : [defaultBraveProvider()]
|
||||
const brave = providers.find((p) => p.type === "brave") ?? defaultBraveProvider()
|
||||
return {
|
||||
default_provider_id: raw?.default_provider_id || brave.id,
|
||||
providers: [brave],
|
||||
}
|
||||
}
|
||||
|
||||
export function SearchProvidersPanel() {
|
||||
const { data: remote, isLoading } = useSearchSettings()
|
||||
const updateSettings = useUpdateSearchSettings()
|
||||
const [draft, setDraft] = useState<ApiSearchSettings>(normalizeDraft(undefined))
|
||||
const [draft, setDraft] = useState<ApiSearchSettings>(normalizeSearchProviders(undefined))
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (remote) {
|
||||
setDraft(normalizeDraft(remote))
|
||||
setDraft(normalizeSearchProviders(remote))
|
||||
}
|
||||
}, [remote])
|
||||
|
||||
const brave = draft.providers[0] ?? defaultBraveProvider()
|
||||
|
||||
function updateBrave(patch: Partial<ApiSearchProvider>) {
|
||||
setDraft((prev) => {
|
||||
const current = prev.providers[0] ?? defaultBraveProvider()
|
||||
const updated = { ...current, ...patch }
|
||||
return {
|
||||
default_provider_id: updated.id,
|
||||
providers: [updated],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
await updateSettings.mutateAsync(draft)
|
||||
const saved = await updateSettings.mutateAsync(draft)
|
||||
setDraft(normalizeSearchProviders(saved))
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
}
|
||||
@ -72,44 +39,16 @@ export function SearchProvidersPanel() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="w-full space-y-6">
|
||||
<div>
|
||||
<h3 className="text-base font-medium">Fournisseurs de recherche</h3>
|
||||
<h3 className="text-base font-medium">Fournisseurs de recherche web</h3>
|
||||
<p className={cn("mt-1 text-sm", CONTACTS_MUTED_TEXT)}>
|
||||
Recherche web utilisée lors de l'amélioration IA des fiches contacts (profils
|
||||
publics, réseaux sociaux, poste, entreprise).
|
||||
Brave, Bing, DuckDuckGo, SearXNG ou API JSON custom — pour l'enrichissement IA des
|
||||
contacts et le tool UltiAI <code className="rounded bg-muted px-1 text-xs">web_search</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-lg border border-border p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium">{brave.name}</span>
|
||||
<a
|
||||
href="https://api.search.brave.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn("inline-flex items-center gap-1 text-xs hover:underline", CONTACTS_MUTED_TEXT)}
|
||||
>
|
||||
Obtenir une clé API
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Token API (X-Subscription-Token)</Label>
|
||||
<Input
|
||||
className="mt-1 h-9"
|
||||
type="password"
|
||||
value={brave.api_key ?? ""}
|
||||
onChange={(e) => updateBrave({ api_key: e.target.value })}
|
||||
placeholder="BSA…"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className={cn("mt-1.5 text-xs", CONTACTS_MUTED_TEXT)}>
|
||||
Les 5 premiers résultats web sont ajoutés au prompt LLM avec un avertissement sur les
|
||||
homonymes. Sans token, l'amélioration IA fonctionne sans recherche en ligne.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<WebSearchProvidersEditor value={draft} onChange={setDraft} />
|
||||
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
|
||||
@ -34,7 +34,7 @@ export function AutomationSettingsSection() {
|
||||
<TabsContent value="llm" className="mt-4">
|
||||
<LLMProvidersPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="search" className="mt-4">
|
||||
<TabsContent value="search" className="mt-4 w-full">
|
||||
<SearchProvidersPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="tokens" className="mt-4">
|
||||
|
||||
359
components/llm/llm-providers-editor.tsx
Normal file
359
components/llm/llm-providers-editor.tsx
Normal file
@ -0,0 +1,359 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { ExternalLink, Plus, Trash2 } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { FieldGroup } from "@/components/admin/settings/field-group"
|
||||
import { TechBrandSelectLabel } from "@/components/admin/settings/tech-brand-select-label"
|
||||
import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry"
|
||||
import type { ApiLLMProvider, ApiLLMProviderType } from "@/lib/contacts/discovery-types"
|
||||
import {
|
||||
emptyLlmProvider,
|
||||
inferLlmProviderType,
|
||||
isLlmProviderConfigured,
|
||||
llmCatalogEntry,
|
||||
LLM_PROVIDER_CATALOG,
|
||||
normalizeLlmProvider,
|
||||
} from "@/lib/llm/llm-provider-catalog"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type LlmProviderSecrets = Record<string, { configured?: boolean } | undefined>
|
||||
|
||||
export type LlmProvidersEditorProps = {
|
||||
providers: ApiLLMProvider[]
|
||||
defaultProviderId: string
|
||||
onProvidersChange: (providers: ApiLLMProvider[]) => void
|
||||
onDefaultProviderIdChange: (id: string) => void
|
||||
className?: string
|
||||
columns?: 1 | 2
|
||||
providerSecrets?: LlmProviderSecrets
|
||||
renderDefaultModelInput?: (props: {
|
||||
provider: ApiLLMProvider
|
||||
index: number
|
||||
onChange: (default_model: string) => void
|
||||
}) => React.ReactNode
|
||||
}
|
||||
|
||||
function providerOptions(
|
||||
providers: ApiLLMProvider[],
|
||||
providerSecrets?: LlmProviderSecrets,
|
||||
) {
|
||||
return providers.map((provider) => ({
|
||||
provider: normalizeLlmProvider(provider),
|
||||
configured: isLlmProviderConfigured(provider, {
|
||||
apiKeyConfigured: providerSecrets?.[provider.id]?.configured,
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
export function LlmProvidersEditor({
|
||||
providers,
|
||||
defaultProviderId,
|
||||
onProvidersChange,
|
||||
onDefaultProviderIdChange,
|
||||
className,
|
||||
columns = 2,
|
||||
providerSecrets,
|
||||
renderDefaultModelInput,
|
||||
}: LlmProvidersEditorProps) {
|
||||
const options = providerOptions(providers, providerSecrets)
|
||||
const [editingProviderId, setEditingProviderId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (editingProviderId && !providers.some((provider) => provider.id === editingProviderId)) {
|
||||
setEditingProviderId(null)
|
||||
}
|
||||
}, [editingProviderId, providers])
|
||||
|
||||
function commit(nextProviders: ApiLLMProvider[]) {
|
||||
onProvidersChange(nextProviders.map(normalizeLlmProvider))
|
||||
}
|
||||
|
||||
function updateProvider(index: number, patch: Partial<ApiLLMProvider>) {
|
||||
const next = [...providers]
|
||||
next[index] = { ...next[index], ...patch }
|
||||
commit(next)
|
||||
}
|
||||
|
||||
function setProviderType(index: number, type: ApiLLMProviderType) {
|
||||
const current = providers[index]
|
||||
const entry = llmCatalogEntry(type)
|
||||
const next = {
|
||||
...emptyLlmProvider(type),
|
||||
id: current?.id ?? emptyLlmProvider(type).id,
|
||||
api_key: current?.api_key ?? "",
|
||||
}
|
||||
const updated = [...providers]
|
||||
updated[index] = next
|
||||
commit(updated)
|
||||
}
|
||||
|
||||
function addProvider() {
|
||||
const provider = emptyLlmProvider("openai")
|
||||
commit([...providers, provider])
|
||||
setEditingProviderId(provider.id)
|
||||
if (!defaultProviderId) {
|
||||
onDefaultProviderIdChange(provider.id)
|
||||
}
|
||||
}
|
||||
|
||||
function removeProvider(index: number) {
|
||||
const removed = providers[index]
|
||||
if (editingProviderId === removed?.id) {
|
||||
setEditingProviderId(null)
|
||||
}
|
||||
const nextProviders = providers.filter((_, i) => i !== index)
|
||||
const remaining = providerOptions(nextProviders, providerSecrets)
|
||||
let nextDefaultId = defaultProviderId
|
||||
if (nextDefaultId === removed?.id) {
|
||||
nextDefaultId =
|
||||
remaining.find((entry) => entry.configured)?.provider.id ??
|
||||
nextProviders[0]?.id ??
|
||||
""
|
||||
}
|
||||
onProvidersChange(nextProviders.map(normalizeLlmProvider))
|
||||
onDefaultProviderIdChange(nextDefaultId)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("w-full space-y-4", className)}>
|
||||
{providers.length > 0 ? (
|
||||
<FieldGroup>
|
||||
<Label className="text-xs">Fournisseur par défaut</Label>
|
||||
<Select
|
||||
value={defaultProviderId || providers[0]?.id || "__none__"}
|
||||
onValueChange={(id) =>
|
||||
onDefaultProviderIdChange(id === "__none__" ? "" : id)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-full min-w-0">
|
||||
<SelectValue placeholder="Choisir…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map(({ provider, configured }) => {
|
||||
const type = inferLlmProviderType(provider)
|
||||
const entry = llmCatalogEntry(type)
|
||||
return (
|
||||
<SelectItem
|
||||
key={provider.id}
|
||||
value={provider.id}
|
||||
disabled={!configured}
|
||||
>
|
||||
<TechBrandSelectLabel
|
||||
brand={type}
|
||||
icon={entry.icon}
|
||||
suffix={!configured ? " (incomplet)" : undefined}
|
||||
>
|
||||
{provider.name || entry.label}
|
||||
</TechBrandSelectLabel>
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{options.some((entry) => !entry.configured) ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Les fournisseurs incomplets restent visibles mais ne peuvent pas être sélectionnés
|
||||
par défaut.
|
||||
</p>
|
||||
) : null}
|
||||
</FieldGroup>
|
||||
) : null}
|
||||
|
||||
<AutomationTabMasonry columns={columns}>
|
||||
{providers.map((provider, index) => {
|
||||
const normalized = normalizeLlmProvider(provider)
|
||||
const type = inferLlmProviderType(normalized)
|
||||
const entry = llmCatalogEntry(type)
|
||||
const apiKeyConfigured = providerSecrets?.[provider.id]?.configured ?? false
|
||||
const configured = isLlmProviderConfigured(provider, { apiKeyConfigured })
|
||||
const isEditing = editingProviderId === provider.id
|
||||
const displayName =
|
||||
normalized.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={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 (
|
||||
<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="min-w-0">
|
||||
<TechBrandSelectLabel
|
||||
brand={type}
|
||||
icon={entry.icon}
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{displayName}
|
||||
</TechBrandSelectLabel>
|
||||
{!configured ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">Configuration incomplète</p>
|
||||
) : apiKeyConfigured && !(provider.api_key ?? "").trim() ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Clé API enregistrée sur le serveur
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setEditingProviderId(null)}
|
||||
>
|
||||
Fermer
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Supprimer le fournisseur"
|
||||
onClick={() => removeProvider(index)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-4 border-t px-4 pt-4">
|
||||
<FieldGroup>
|
||||
<Label className="text-xs">Fournisseur</Label>
|
||||
<Select
|
||||
value={type}
|
||||
onValueChange={(value) =>
|
||||
setProviderType(index, value as ApiLLMProviderType)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-full min-w-0">
|
||||
<SelectValue>
|
||||
<TechBrandSelectLabel brand={type} icon={entry.icon}>
|
||||
{entry.label}
|
||||
</TechBrandSelectLabel>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
{LLM_PROVIDER_CATALOG.map((item) => (
|
||||
<SelectItem key={item.type} value={item.type}>
|
||||
<TechBrandSelectLabel brand={item.type} icon={item.icon}>
|
||||
{item.label}
|
||||
</TechBrandSelectLabel>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">{entry.description}</p>
|
||||
{entry.docsUrl ? (
|
||||
<a
|
||||
href={entry.docsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:underline"
|
||||
>
|
||||
Documentation
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
) : null}
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup>
|
||||
<Label className="text-xs">Nom affiché</Label>
|
||||
<Input
|
||||
className="h-9"
|
||||
value={provider.name}
|
||||
onChange={(e) => updateProvider(index, { name: e.target.value })}
|
||||
placeholder={entry.label}
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup>
|
||||
<Label className="text-xs">URL de base</Label>
|
||||
<Input
|
||||
className="h-9"
|
||||
value={provider.base_url}
|
||||
onChange={(e) => updateProvider(index, { base_url: e.target.value })}
|
||||
placeholder={entry.baseURLPlaceholder ?? entry.defaultBaseURL}
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
{type !== "ollama" ? (
|
||||
<FieldGroup>
|
||||
<Label className="text-xs">Clé API</Label>
|
||||
<Input
|
||||
className="h-9"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={provider.api_key ?? ""}
|
||||
onChange={(e) => updateProvider(index, { api_key: e.target.value })}
|
||||
placeholder={
|
||||
apiKeyConfigured && !(provider.api_key ?? "").trim()
|
||||
? "•••••••• (laisser vide pour conserver)"
|
||||
: type === "openai"
|
||||
? "sk-…"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</FieldGroup>
|
||||
) : null}
|
||||
|
||||
<FieldGroup>
|
||||
<Label className="text-xs">Modèle par défaut</Label>
|
||||
{renderDefaultModelInput ? (
|
||||
renderDefaultModelInput({
|
||||
provider,
|
||||
index,
|
||||
onChange: (default_model) => updateProvider(index, { default_model }),
|
||||
})
|
||||
) : (
|
||||
<Input
|
||||
className="h-9"
|
||||
value={provider.default_model}
|
||||
onChange={(e) => updateProvider(index, { default_model: e.target.value })}
|
||||
placeholder={entry.defaultModel || "gpt-4o-mini"}
|
||||
/>
|
||||
)}
|
||||
</FieldGroup>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</AutomationTabMasonry>
|
||||
|
||||
<Button type="button" variant="outline" size="sm" onClick={addProvider}>
|
||||
<Plus className="mr-2 size-4" />
|
||||
Ajouter un fournisseur
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { emptyLlmProvider }
|
||||
343
components/web-search/web-search-providers-editor.tsx
Normal file
343
components/web-search/web-search-providers-editor.tsx
Normal file
@ -0,0 +1,343 @@
|
||||
"use client"
|
||||
|
||||
import { ExternalLink, Plus, Trash2 } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import type { ApiSearchProvider, ApiSearchSettings } from "@/lib/contacts/discovery-types"
|
||||
import { FieldGroup } from "@/components/admin/settings/field-group"
|
||||
import { TechBrandSelectLabel } from "@/components/admin/settings/tech-brand-select-label"
|
||||
import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry"
|
||||
import {
|
||||
catalogEntry,
|
||||
emptySearchProvider,
|
||||
ensureSearchSettingsDefaults,
|
||||
isSearchProviderConfigured,
|
||||
SEARCH_PROVIDER_CATALOG,
|
||||
} from "@/lib/web-search/search-provider-catalog"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type WebSearchProviderSecrets = Record<string, { configured?: boolean } | undefined>
|
||||
|
||||
type WebSearchProvidersEditorProps = {
|
||||
value: ApiSearchSettings
|
||||
onChange: (value: ApiSearchSettings) => void
|
||||
className?: string
|
||||
columns?: 1 | 2
|
||||
providerSecrets?: WebSearchProviderSecrets
|
||||
}
|
||||
|
||||
function providerOptions(value: ApiSearchSettings, providerSecrets?: WebSearchProviderSecrets) {
|
||||
return value.providers.map((provider) => ({
|
||||
provider,
|
||||
configured: isSearchProviderConfigured(provider, {
|
||||
apiKeyConfigured: providerSecrets?.[provider.id]?.configured,
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
export function WebSearchProvidersEditor({
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
columns = 2,
|
||||
providerSecrets,
|
||||
}: WebSearchProvidersEditorProps) {
|
||||
const options = providerOptions(value, providerSecrets)
|
||||
|
||||
function commit(next: ApiSearchSettings) {
|
||||
onChange(ensureSearchSettingsDefaults(next))
|
||||
}
|
||||
|
||||
function updateProvider(index: number, patch: Partial<ApiSearchProvider>) {
|
||||
const providers = [...value.providers]
|
||||
providers[index] = { ...providers[index], ...patch }
|
||||
commit({ ...value, providers })
|
||||
}
|
||||
|
||||
function setProviderType(index: number, type: ApiSearchProvider["type"]) {
|
||||
const current = value.providers[index]
|
||||
const next = {
|
||||
...emptySearchProvider(type),
|
||||
id: current?.id ?? emptySearchProvider(type).id,
|
||||
name: catalogEntry(type).label,
|
||||
api_key: current?.api_key ?? "",
|
||||
}
|
||||
const providers = [...value.providers]
|
||||
providers[index] = next
|
||||
commit({ ...value, providers })
|
||||
}
|
||||
|
||||
function addProvider() {
|
||||
const provider = emptySearchProvider("brave")
|
||||
commit({
|
||||
default_provider_id: value.default_provider_id || provider.id,
|
||||
providers: [...value.providers, provider],
|
||||
})
|
||||
}
|
||||
|
||||
function removeProvider(index: number) {
|
||||
const removed = value.providers[index]
|
||||
const providers = value.providers.filter((_, i) => i !== index)
|
||||
const remainingOptions = providerOptions({ ...value, providers }, providerSecrets)
|
||||
let defaultId = value.default_provider_id
|
||||
if (defaultId === removed?.id) {
|
||||
defaultId =
|
||||
remainingOptions.find((entry) => entry.configured)?.provider.id ??
|
||||
providers[0]?.id ??
|
||||
""
|
||||
}
|
||||
commit({ default_provider_id: defaultId, providers })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("w-full space-y-4", className)}>
|
||||
{value.providers.length > 0 ? (
|
||||
<FieldGroup>
|
||||
<Label className="text-xs">Fournisseur par défaut</Label>
|
||||
<Select
|
||||
value={value.default_provider_id || value.providers[0]?.id || "__none__"}
|
||||
onValueChange={(default_provider_id) =>
|
||||
commit({
|
||||
...value,
|
||||
default_provider_id: default_provider_id === "__none__" ? "" : default_provider_id,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-full min-w-0">
|
||||
<SelectValue placeholder="Choisir…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map(({ provider, configured }) => (
|
||||
<SelectItem
|
||||
key={provider.id}
|
||||
value={provider.id}
|
||||
disabled={!configured}
|
||||
>
|
||||
<TechBrandSelectLabel
|
||||
brand={provider.type}
|
||||
icon={catalogEntry(provider.type).icon}
|
||||
suffix={!configured ? " (incomplet)" : undefined}
|
||||
>
|
||||
{provider.name || catalogEntry(provider.type).label}
|
||||
</TechBrandSelectLabel>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{options.some((entry) => !entry.configured) ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Les fournisseurs incomplets restent visibles mais ne peuvent pas être sélectionnés
|
||||
par défaut.
|
||||
</p>
|
||||
) : null}
|
||||
</FieldGroup>
|
||||
) : null}
|
||||
|
||||
<AutomationTabMasonry columns={columns}>
|
||||
{value.providers.map((provider, index) => {
|
||||
const entry = catalogEntry(provider.type)
|
||||
const apiKeyConfigured = providerSecrets?.[provider.id]?.configured ?? false
|
||||
const configured = isSearchProviderConfigured(provider, { apiKeyConfigured })
|
||||
return (
|
||||
<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="min-w-0">
|
||||
<p className="text-sm font-medium">
|
||||
{provider.name || entry.label || `Fournisseur ${index + 1}`}
|
||||
</p>
|
||||
{!configured ? (
|
||||
<p className="text-xs text-muted-foreground">Configuration incomplète</p>
|
||||
) : apiKeyConfigured && !(provider.api_key ?? "").trim() ? (
|
||||
<p className="text-xs text-muted-foreground">Clé API enregistrée sur le serveur</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Supprimer le fournisseur"
|
||||
onClick={() => removeProvider(index)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-4 border-t px-4 pt-4">
|
||||
<FieldGroup>
|
||||
<Label className="text-xs">Type</Label>
|
||||
<Select
|
||||
value={provider.type}
|
||||
onValueChange={(type) =>
|
||||
setProviderType(index, type as ApiSearchProvider["type"])
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-full min-w-0">
|
||||
<SelectValue>
|
||||
<TechBrandSelectLabel brand={provider.type} icon={entry.icon}>
|
||||
{entry.label}
|
||||
</TechBrandSelectLabel>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SEARCH_PROVIDER_CATALOG.map((item) => (
|
||||
<SelectItem key={item.type} value={item.type}>
|
||||
<TechBrandSelectLabel brand={item.type} icon={item.icon}>
|
||||
{item.label}
|
||||
</TechBrandSelectLabel>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">{entry.description}</p>
|
||||
{entry.docsUrl ? (
|
||||
<a
|
||||
href={entry.docsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:underline"
|
||||
>
|
||||
Documentation
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
) : null}
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup>
|
||||
<Label className="text-xs">Nom affiché</Label>
|
||||
<Input
|
||||
className="h-9"
|
||||
value={provider.name}
|
||||
onChange={(e) => updateProvider(index, { name: e.target.value })}
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
{entry.requiresApiKey || provider.api_key || apiKeyConfigured ? (
|
||||
<FieldGroup>
|
||||
<Label className="text-xs">
|
||||
{provider.type === "brave" ? "Token API (X-Subscription-Token)" : "Clé API"}
|
||||
</Label>
|
||||
<Input
|
||||
className="h-9"
|
||||
type="password"
|
||||
value={provider.api_key ?? ""}
|
||||
onChange={(e) => updateProvider(index, { api_key: e.target.value })}
|
||||
autoComplete="off"
|
||||
placeholder={
|
||||
apiKeyConfigured && !(provider.api_key ?? "").trim()
|
||||
? "•••••••• (laisser vide pour conserver)"
|
||||
: provider.type === "brave"
|
||||
? "BSA…"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</FieldGroup>
|
||||
) : null}
|
||||
|
||||
{entry.requiresBaseURL || provider.base_url ? (
|
||||
<FieldGroup>
|
||||
<Label className="text-xs">URL de base</Label>
|
||||
<Input
|
||||
className="h-9"
|
||||
value={provider.base_url ?? ""}
|
||||
onChange={(e) => updateProvider(index, { base_url: e.target.value })}
|
||||
placeholder={
|
||||
provider.type === "searxng"
|
||||
? "https://searx.example.org"
|
||||
: "https://api.example.com/search"
|
||||
}
|
||||
/>
|
||||
</FieldGroup>
|
||||
) : null}
|
||||
|
||||
{provider.type === "bing" || provider.type === "searxng" || provider.type === "custom" ? (
|
||||
<FieldGroup>
|
||||
<Label className="text-xs">En-tête d'authentification (optionnel)</Label>
|
||||
<Input
|
||||
className="h-9"
|
||||
value={provider.auth_header ?? ""}
|
||||
onChange={(e) => updateProvider(index, { auth_header: e.target.value })}
|
||||
placeholder={
|
||||
provider.type === "bing" ? "Ocp-Apim-Subscription-Key" : "Authorization"
|
||||
}
|
||||
/>
|
||||
</FieldGroup>
|
||||
) : null}
|
||||
|
||||
{provider.type === "custom" || provider.type === "searxng" || provider.query_param ? (
|
||||
<FieldGroup>
|
||||
<Label className="text-xs">Paramètre de requête</Label>
|
||||
<Input
|
||||
className="h-9"
|
||||
value={provider.query_param ?? "q"}
|
||||
onChange={(e) => updateProvider(index, { query_param: e.target.value })}
|
||||
/>
|
||||
</FieldGroup>
|
||||
) : null}
|
||||
|
||||
{entry.supportsCustomMapping ? (
|
||||
<>
|
||||
<FieldGroup>
|
||||
<Label className="text-xs">Chemin JSON des résultats</Label>
|
||||
<Input
|
||||
className="h-9"
|
||||
value={provider.results_path ?? ""}
|
||||
onChange={(e) => updateProvider(index, { results_path: e.target.value })}
|
||||
placeholder="results ou data.items"
|
||||
/>
|
||||
</FieldGroup>
|
||||
<div className="grid min-w-0 gap-4">
|
||||
<FieldGroup>
|
||||
<Label className="text-xs">Champ titre</Label>
|
||||
<Input
|
||||
className="h-9"
|
||||
value={provider.title_field ?? ""}
|
||||
onChange={(e) => updateProvider(index, { title_field: e.target.value })}
|
||||
/>
|
||||
</FieldGroup>
|
||||
<FieldGroup>
|
||||
<Label className="text-xs">Champ URL</Label>
|
||||
<Input
|
||||
className="h-9"
|
||||
value={provider.url_field ?? ""}
|
||||
onChange={(e) => updateProvider(index, { url_field: e.target.value })}
|
||||
/>
|
||||
</FieldGroup>
|
||||
<FieldGroup>
|
||||
<Label className="text-xs">Champ description</Label>
|
||||
<Input
|
||||
className="h-9"
|
||||
value={provider.description_field ?? ""}
|
||||
onChange={(e) =>
|
||||
updateProvider(index, { description_field: e.target.value })
|
||||
}
|
||||
/>
|
||||
</FieldGroup>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
L'URL peut contenir <code className="rounded bg-muted px-1">{"{query}"}</code>{" "}
|
||||
et <code className="rounded bg-muted px-1">{"{count}"}</code>. Sinon, le paramètre
|
||||
de requête est ajouté automatiquement.
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</AutomationTabMasonry>
|
||||
|
||||
<Button type="button" variant="outline" size="sm" onClick={addProvider}>
|
||||
<Plus className="mr-2 size-4" />
|
||||
Ajouter un fournisseur
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
46
lib/admin-settings/default-plugins.ts
Normal file
46
lib/admin-settings/default-plugins.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import type { PluginEntry } from "@/lib/admin-settings/org-settings-types"
|
||||
|
||||
export const DEFAULT_ORG_PLUGINS: PluginEntry[] = [
|
||||
{
|
||||
id: "mail-automation",
|
||||
name: "Automatisations mail",
|
||||
description: "Règles, webhooks et tri IA sur la réception.",
|
||||
enabled: true,
|
||||
version: "1.0.0",
|
||||
},
|
||||
{
|
||||
id: "contact-discovery",
|
||||
name: "Découverte contacts",
|
||||
description: "Enrichissement IA et signatures détectées.",
|
||||
enabled: true,
|
||||
version: "1.0.0",
|
||||
},
|
||||
{
|
||||
id: "public-share",
|
||||
name: "Partage public Drive",
|
||||
description: "Liens publics et partages externes.",
|
||||
enabled: true,
|
||||
version: "1.0.0",
|
||||
},
|
||||
{
|
||||
id: "office-editor",
|
||||
name: "Édition OnlyOffice",
|
||||
description: "Édition collaborative de documents.",
|
||||
enabled: false,
|
||||
version: "1.0.0",
|
||||
},
|
||||
{
|
||||
id: "richtext-editor",
|
||||
name: "Édition rich text TipTap",
|
||||
description: "Édition rich text TipTap pour documents Word.",
|
||||
enabled: true,
|
||||
version: "1.0.0",
|
||||
},
|
||||
{
|
||||
id: "ai-assistant",
|
||||
name: "UltiAI",
|
||||
description: "Assistant IA intégré avec tools mail, drive, contacts, agenda et recherche web.",
|
||||
enabled: false,
|
||||
version: "1.0.0",
|
||||
},
|
||||
]
|
||||
@ -1,13 +1,31 @@
|
||||
import type { ApiOrgPolicy, ApiOrgSettingsResponse } from "@/lib/api/admin-org-types"
|
||||
import type { OrgPolicySectionKey } from "@/lib/api/admin-org-types"
|
||||
import type { IntegrationEntry, OrgSettingsState, FilePolicySettings, DriveMountOAuthSettings, DriveMountOAuthProviderSettings, IdentityProvidersPolicy, IdentityProvider } from "@/lib/admin-settings/org-settings-types"
|
||||
import { DEFAULT_ULTIAI_ENABLED_TOOLS } from "@/lib/ai/ultiai-tool-groups"
|
||||
import type { IntegrationEntry, OrgSettingsState, FilePolicySettings, DriveMountOAuthSettings, DriveMountOAuthProviderSettings, IdentityProvidersPolicy, IdentityProvider, PluginEntry } from "@/lib/admin-settings/org-settings-types"
|
||||
import { DEFAULT_ORG_PLUGINS } from "@/lib/admin-settings/default-plugins"
|
||||
import { DEFAULT_MEET_POLICY } from "@/lib/meet/meet-settings-types"
|
||||
import { normalizeLlmProvider } from "@/lib/llm/llm-provider-catalog"
|
||||
|
||||
const INTEGRATION_HREFS: Record<string, string> = {
|
||||
authentik: "/admin/settings/authentication",
|
||||
nextcloud: "/admin/settings/nextcloud",
|
||||
onlyoffice: "/admin/settings/onlyoffice",
|
||||
smtp: "/admin/settings/mailing",
|
||||
nextcloud: "/admin/settings/plugins",
|
||||
onlyoffice: "/admin/settings/plugins",
|
||||
smtp: "/admin/settings/mail-domains",
|
||||
}
|
||||
|
||||
function mergePlugins(fromApi: PluginEntry[] | undefined): PluginEntry[] {
|
||||
if (!fromApi?.length) return DEFAULT_ORG_PLUGINS.map((plugin) => ({ ...plugin }))
|
||||
const byId = new Map(fromApi.map((plugin) => [plugin.id, plugin]))
|
||||
const merged = DEFAULT_ORG_PLUGINS.map((plugin) => ({
|
||||
...plugin,
|
||||
...byId.get(plugin.id),
|
||||
}))
|
||||
for (const plugin of fromApi) {
|
||||
if (!DEFAULT_ORG_PLUGINS.some((entry) => entry.id === plugin.id)) {
|
||||
merged.push(plugin)
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
function mergeIntegrations(
|
||||
@ -129,7 +147,9 @@ export function apiOrgPolicyToStore(policy: ApiOrgPolicy): Partial<OrgSettingsSt
|
||||
twoFactor: {
|
||||
required_for_all: policy.two_factor.required_for_all,
|
||||
required_for_admins: policy.two_factor.required_for_admins,
|
||||
allowed_methods: policy.two_factor.allowed_methods,
|
||||
allowed_methods: policy.two_factor.allowed_methods.filter(
|
||||
(m): m is "totp" | "webauthn" => m === "totp" || m === "webauthn"
|
||||
),
|
||||
grace_period_days: policy.two_factor.grace_period_days,
|
||||
remember_device_days: policy.two_factor.remember_device_days,
|
||||
},
|
||||
@ -138,10 +158,12 @@ export function apiOrgPolicyToStore(policy: ApiOrgPolicy): Partial<OrgSettingsSt
|
||||
filePolicies: mergeFilePolicies(policy.file_policies),
|
||||
llm: {
|
||||
...policy.llm,
|
||||
providers: (policy.llm.providers ?? []).map((provider) => ({
|
||||
...provider,
|
||||
api_key: provider.api_key ?? "",
|
||||
})),
|
||||
providers: (policy.llm.providers ?? []).map((provider) =>
|
||||
normalizeLlmProvider({
|
||||
...provider,
|
||||
api_key: provider.api_key ?? "",
|
||||
}),
|
||||
),
|
||||
},
|
||||
search: {
|
||||
...policy.search,
|
||||
@ -164,9 +186,9 @@ export function apiOrgPolicyToStore(policy: ApiOrgPolicy): Partial<OrgSettingsSt
|
||||
enabled: policy.ai_assistant?.enabled ?? false,
|
||||
openwebui_internal_url: policy.ai_assistant?.openwebui_internal_url ?? "",
|
||||
public_path: policy.ai_assistant?.public_path ?? "/ai",
|
||||
embed_default_temporary: policy.ai_assistant?.embed_default_temporary ?? true,
|
||||
embed_default_temporary: policy.ai_assistant?.embed_default_temporary ?? false,
|
||||
default_model: policy.ai_assistant?.default_model ?? "",
|
||||
enabled_tools: policy.ai_assistant?.enabled_tools ?? ["mail", "drive", "contacts", "search"],
|
||||
enabled_tools: policy.ai_assistant?.enabled_tools ?? [...DEFAULT_ULTIAI_ENABLED_TOOLS],
|
||||
chat_sync_enabled: policy.ai_assistant?.chat_sync_enabled ?? true,
|
||||
chat_nc_path: policy.ai_assistant?.chat_nc_path ?? "/.ultimail/ai/chats",
|
||||
models: (policy.ai_assistant?.models ?? []).map((entry) => ({
|
||||
@ -190,7 +212,7 @@ export function apiOrgPolicyToStore(policy: ApiOrgPolicy): Partial<OrgSettingsSt
|
||||
...policy.meet?.post_actions,
|
||||
},
|
||||
},
|
||||
plugins: policy.plugins ?? [],
|
||||
plugins: mergePlugins(policy.plugins),
|
||||
integrations: mergeIntegrations(policy.integrations as IntegrationEntry[]),
|
||||
}
|
||||
}
|
||||
@ -214,7 +236,9 @@ export function storeToApiOrgPolicy(state: OrgSettingsState): ApiOrgPolicy {
|
||||
two_factor: {
|
||||
required_for_all: state.twoFactor.required_for_all,
|
||||
required_for_admins: state.twoFactor.required_for_admins,
|
||||
allowed_methods: state.twoFactor.allowed_methods,
|
||||
allowed_methods: state.twoFactor.allowed_methods.filter(
|
||||
(m): m is "totp" | "webauthn" => m === "totp" || m === "webauthn"
|
||||
),
|
||||
grace_period_days: state.twoFactor.grace_period_days,
|
||||
remember_device_days: state.twoFactor.remember_device_days,
|
||||
},
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { create } from "zustand"
|
||||
import { DEFAULT_ULTIAI_ENABLED_TOOLS } from "@/lib/ai/ultiai-tool-groups"
|
||||
import type { OrgSettingsMeta } from "@/lib/admin-settings/map-api-org-settings"
|
||||
import type {
|
||||
Administrator,
|
||||
@ -23,6 +24,7 @@ import type {
|
||||
IdentityProvidersPolicy,
|
||||
} from "@/lib/admin-settings/org-settings-types"
|
||||
import { DEFAULT_MEET_POLICY } from "@/lib/meet/meet-settings-types"
|
||||
import { DEFAULT_ORG_PLUGINS } from "@/lib/admin-settings/default-plugins"
|
||||
|
||||
const DEFAULT_AUTHENTIK: AuthentikSettings = {
|
||||
enabled: true,
|
||||
@ -137,9 +139,9 @@ const DEFAULT_AI_ASSISTANT: AiAssistantSettings = {
|
||||
enabled: false,
|
||||
openwebui_internal_url: "",
|
||||
public_path: "/ai",
|
||||
embed_default_temporary: true,
|
||||
embed_default_temporary: false,
|
||||
default_model: "",
|
||||
enabled_tools: ["mail", "drive", "contacts", "search"],
|
||||
enabled_tools: [...DEFAULT_ULTIAI_ENABLED_TOOLS],
|
||||
chat_sync_enabled: true,
|
||||
chat_nc_path: "/.ultimail/ai/chats",
|
||||
models: [],
|
||||
@ -155,51 +157,6 @@ const DEFAULT_AGENDA: AgendaOrgPolicySettings = {
|
||||
|
||||
const DEFAULT_MEET: MeetOrgPolicySettings = DEFAULT_MEET_POLICY
|
||||
|
||||
const DEFAULT_PLUGINS: PluginEntry[] = [
|
||||
{
|
||||
id: "mail-automation",
|
||||
name: "Automatisations mail",
|
||||
description: "Règles, webhooks et tri IA sur la réception.",
|
||||
enabled: true,
|
||||
version: "1.0.0",
|
||||
},
|
||||
{
|
||||
id: "contact-discovery",
|
||||
name: "Découverte contacts",
|
||||
description: "Enrichissement IA et signatures détectées.",
|
||||
enabled: true,
|
||||
version: "1.0.0",
|
||||
},
|
||||
{
|
||||
id: "public-share",
|
||||
name: "Partage public Drive",
|
||||
description: "Liens publics et partages externes.",
|
||||
enabled: true,
|
||||
version: "1.0.0",
|
||||
},
|
||||
{
|
||||
id: "office-editor",
|
||||
name: "Édition OnlyOffice",
|
||||
description: "Édition collaborative de documents.",
|
||||
enabled: false,
|
||||
version: "1.0.0",
|
||||
},
|
||||
{
|
||||
id: "richtext-editor",
|
||||
name: "Édition rich text TipTap",
|
||||
description: "Édition rich text TipTap pour documents Word.",
|
||||
enabled: true,
|
||||
version: "1.0.0",
|
||||
},
|
||||
{
|
||||
id: "ai-assistant",
|
||||
name: "UltiAI",
|
||||
description: "Assistant IA intégré avec tools mail, drive et contacts.",
|
||||
enabled: false,
|
||||
version: "1.0.0",
|
||||
},
|
||||
]
|
||||
|
||||
const DEFAULT_INTEGRATIONS: IntegrationEntry[] = [
|
||||
{
|
||||
id: "authentik",
|
||||
@ -215,7 +172,7 @@ const DEFAULT_INTEGRATIONS: IntegrationEntry[] = [
|
||||
description: "Drive, agenda, contacts et Talk.",
|
||||
enabled: false,
|
||||
configured: false,
|
||||
href: "/admin/settings/nextcloud",
|
||||
href: "/admin/settings/plugins",
|
||||
},
|
||||
{
|
||||
id: "onlyoffice",
|
||||
@ -223,7 +180,7 @@ const DEFAULT_INTEGRATIONS: IntegrationEntry[] = [
|
||||
description: "Édition de documents dans le navigateur.",
|
||||
enabled: false,
|
||||
configured: false,
|
||||
href: "/admin/settings/onlyoffice",
|
||||
href: "/admin/settings/plugins",
|
||||
},
|
||||
{
|
||||
id: "smtp",
|
||||
@ -231,7 +188,7 @@ const DEFAULT_INTEGRATIONS: IntegrationEntry[] = [
|
||||
description: "SMTP pour notifications suite (partages, mentions).",
|
||||
enabled: false,
|
||||
configured: false,
|
||||
href: "/admin/settings/mailing",
|
||||
href: "/admin/settings/mail-domains",
|
||||
},
|
||||
]
|
||||
|
||||
@ -321,7 +278,7 @@ export const useOrgSettingsStore = create<
|
||||
aiAssistant: DEFAULT_AI_ASSISTANT,
|
||||
agenda: DEFAULT_AGENDA,
|
||||
meet: DEFAULT_MEET,
|
||||
plugins: DEFAULT_PLUGINS,
|
||||
plugins: DEFAULT_ORG_PLUGINS,
|
||||
integrations: DEFAULT_INTEGRATIONS,
|
||||
meta: null,
|
||||
apiSynced: false,
|
||||
@ -386,6 +343,18 @@ export const useOrgSettingsStore = create<
|
||||
aiAssistant: { ...s.aiAssistant, enabled },
|
||||
}
|
||||
}
|
||||
if (id === "office-editor") {
|
||||
return {
|
||||
plugins,
|
||||
onlyoffice: { ...s.onlyoffice, enabled },
|
||||
}
|
||||
}
|
||||
if (id === "richtext-editor") {
|
||||
return {
|
||||
plugins,
|
||||
richtext: { ...s.richtext, enabled },
|
||||
}
|
||||
}
|
||||
return { plugins }
|
||||
}),
|
||||
setIntegrations: (integrations) => set({ integrations }),
|
||||
|
||||
@ -78,7 +78,7 @@ export type IdentityProvidersPolicy = {
|
||||
export type TwoFactorPolicy = {
|
||||
required_for_all: boolean
|
||||
required_for_admins: boolean
|
||||
allowed_methods: ("totp" | "webauthn" | "sms")[]
|
||||
allowed_methods: ("totp" | "webauthn")[]
|
||||
grace_period_days: number
|
||||
remember_device_days: number
|
||||
}
|
||||
|
||||
@ -1,16 +1,11 @@
|
||||
import type { LucideIcon } from "lucide-react"
|
||||
import {
|
||||
Activity,
|
||||
Bot,
|
||||
Calendar,
|
||||
Video,
|
||||
Cloud,
|
||||
FileCog,
|
||||
FileText,
|
||||
Gauge,
|
||||
HardDrive,
|
||||
Link2,
|
||||
Globe,
|
||||
LayoutDashboard,
|
||||
Mail,
|
||||
Puzzle,
|
||||
@ -26,18 +21,13 @@ export type AdminSettingsSectionId =
|
||||
| "users"
|
||||
| "authentication"
|
||||
| "security"
|
||||
| "storage-quotas"
|
||||
| "usage-quotas"
|
||||
| "quotas"
|
||||
| "file-policies"
|
||||
| "public-shares"
|
||||
| "llm"
|
||||
| "search"
|
||||
| "plugins"
|
||||
| "nextcloud"
|
||||
| "mailing"
|
||||
| "mail-domains"
|
||||
| "onlyoffice"
|
||||
| "richtext"
|
||||
| "ai-assistant"
|
||||
| "agenda"
|
||||
| "ultimeet"
|
||||
@ -81,17 +71,10 @@ export const ADMIN_SETTINGS_NAV: AdminSettingsNavItem[] = [
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
{
|
||||
id: "storage-quotas",
|
||||
label: "Quotas stockage",
|
||||
description: "Limites mail, drive et photos",
|
||||
href: "/admin/settings/storage-quotas",
|
||||
icon: HardDrive,
|
||||
},
|
||||
{
|
||||
id: "usage-quotas",
|
||||
label: "Quotas d'usage",
|
||||
description: "LLM, recherche web et API par utilisateur",
|
||||
href: "/admin/settings/usage-quotas",
|
||||
id: "quotas",
|
||||
label: "Quotas",
|
||||
description: "Stockage, LLM, recherche web et automatisations",
|
||||
href: "/admin/settings/quotas",
|
||||
icon: Gauge,
|
||||
},
|
||||
{
|
||||
@ -108,13 +91,6 @@ export const ADMIN_SETTINGS_NAV: AdminSettingsNavItem[] = [
|
||||
href: "/admin/settings/public-shares",
|
||||
icon: Link2,
|
||||
},
|
||||
{
|
||||
id: "llm",
|
||||
label: "Fournisseurs LLM",
|
||||
description: "Modèles IA organisationnels",
|
||||
href: "/admin/settings/llm",
|
||||
icon: Bot,
|
||||
},
|
||||
{
|
||||
id: "search",
|
||||
label: "Moteur de recherche",
|
||||
@ -125,17 +101,10 @@ export const ADMIN_SETTINGS_NAV: AdminSettingsNavItem[] = [
|
||||
{
|
||||
id: "plugins",
|
||||
label: "Plugins",
|
||||
description: "Modules fonctionnels activables",
|
||||
description: "Nextcloud, modules et intégrations activables",
|
||||
href: "/admin/settings/plugins",
|
||||
icon: Puzzle,
|
||||
},
|
||||
{
|
||||
id: "nextcloud",
|
||||
label: "Nextcloud",
|
||||
description: "Drive, agenda, contacts et Talk",
|
||||
href: "/admin/settings/nextcloud",
|
||||
icon: Cloud,
|
||||
},
|
||||
{
|
||||
id: "agenda",
|
||||
label: "Agenda",
|
||||
@ -152,36 +121,15 @@ export const ADMIN_SETTINGS_NAV: AdminSettingsNavItem[] = [
|
||||
},
|
||||
{
|
||||
id: "mail-domains",
|
||||
label: "Domaines mail",
|
||||
description: "Hébergement Stalwart, DNS et migration",
|
||||
label: "Mail",
|
||||
description: "Domaines hébergés, SMTP et migration",
|
||||
href: "/admin/settings/mail-domains",
|
||||
icon: Globe,
|
||||
},
|
||||
{
|
||||
id: "mailing",
|
||||
label: "Mailing unifié",
|
||||
description: "SMTP des notifications suite",
|
||||
href: "/admin/settings/mailing",
|
||||
icon: Mail,
|
||||
},
|
||||
{
|
||||
id: "onlyoffice",
|
||||
label: "OnlyOffice",
|
||||
description: "Édition collaborative de documents",
|
||||
href: "/admin/settings/onlyoffice",
|
||||
icon: Activity,
|
||||
},
|
||||
{
|
||||
id: "richtext",
|
||||
label: "Éditeur rich text",
|
||||
description: "TipTap pour documents texte",
|
||||
href: "/admin/settings/richtext",
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
id: "ai-assistant",
|
||||
label: "UltiAI",
|
||||
description: "Assistant IA intégré et tools suite",
|
||||
description: "Assistant IA, fournisseurs LLM et tools suite",
|
||||
href: "/admin/settings/ai-assistant",
|
||||
icon: Bot,
|
||||
},
|
||||
@ -210,6 +158,10 @@ export function resolveAdminSettingsSection(
|
||||
segments: string[] | undefined
|
||||
): AdminSettingsSectionId {
|
||||
const slug = segments?.[0]
|
||||
if (slug === "llm") return "ai-assistant"
|
||||
if (slug === "nextcloud" || slug === "onlyoffice" || slug === "richtext") return "plugins"
|
||||
if (slug === "mailing") return "mail-domains"
|
||||
if (slug === "storage-quotas" || slug === "usage-quotas") return "quotas"
|
||||
const match = ADMIN_SETTINGS_NAV.find((item) => {
|
||||
if (item.id === "overview") return !slug || slug === "overview"
|
||||
return item.href.endsWith(`/${slug}`)
|
||||
@ -219,10 +171,19 @@ export function resolveAdminSettingsSection(
|
||||
|
||||
const ADMIN_WIDE_SECTIONS: AdminSettingsSectionId[] = [
|
||||
"overview",
|
||||
"audit",
|
||||
"ai-assistant",
|
||||
"plugins",
|
||||
"quotas",
|
||||
"search",
|
||||
"mail-domains",
|
||||
"authentication",
|
||||
"file-policies",
|
||||
]
|
||||
|
||||
const ADMIN_FULL_WIDTH_SECTIONS: AdminSettingsSectionId[] = [
|
||||
"users",
|
||||
"public-shares",
|
||||
"audit",
|
||||
"llm",
|
||||
]
|
||||
|
||||
export function isAdminSettingsWideLayoutPath(pathname: string | null): boolean {
|
||||
@ -233,3 +194,12 @@ export function isAdminSettingsWideLayoutPath(pathname: string | null): boolean
|
||||
isAdminSettingsNavActive(pathname, item)
|
||||
)
|
||||
}
|
||||
|
||||
export function isAdminSettingsFullWidthLayoutPath(pathname: string | null): boolean {
|
||||
if (!pathname?.startsWith("/admin/settings")) return false
|
||||
return ADMIN_SETTINGS_NAV.some(
|
||||
(item) =>
|
||||
ADMIN_FULL_WIDTH_SECTIONS.includes(item.id) &&
|
||||
isAdminSettingsNavActive(pathname, item)
|
||||
)
|
||||
}
|
||||
|
||||
93
lib/admin-settings/tech-brand-icons.ts
Normal file
93
lib/admin-settings/tech-brand-icons.ts
Normal file
@ -0,0 +1,93 @@
|
||||
/** Iconify icon ids for external tech brands in admin selects. */
|
||||
export function techBrandIcon(brand: string): string | undefined {
|
||||
switch (brand) {
|
||||
case "google":
|
||||
case "google_workspace":
|
||||
case "google_dwd":
|
||||
return "logos:google-icon"
|
||||
case "google_drive":
|
||||
case "googledrive":
|
||||
return "logos:google-drive"
|
||||
case "microsoft":
|
||||
case "microsoft_365":
|
||||
case "microsoft_app":
|
||||
case "onedrive":
|
||||
return "logos:microsoft-icon"
|
||||
case "dropbox":
|
||||
return "logos:dropbox"
|
||||
case "github":
|
||||
return "logos:github-icon"
|
||||
case "linkedin":
|
||||
return "logos:linkedin-icon"
|
||||
case "azure":
|
||||
case "azure_ad":
|
||||
return "logos:microsoft-azure"
|
||||
case "okta":
|
||||
return "logos:okta-icon"
|
||||
case "brave":
|
||||
return "simple-icons:brave"
|
||||
case "bing":
|
||||
return "simple-icons:microsoftbing"
|
||||
case "duckduckgo":
|
||||
return "simple-icons:duckduckgo"
|
||||
case "searxng":
|
||||
return "simple-icons:searxng"
|
||||
case "postgres":
|
||||
case "postgresql":
|
||||
return "simple-icons:postgresql"
|
||||
case "meilisearch":
|
||||
return "simple-icons:meilisearch"
|
||||
case "typesense":
|
||||
return "simple-icons:typesense"
|
||||
case "nextcloud":
|
||||
return "simple-icons:nextcloud"
|
||||
case "onlyoffice":
|
||||
return "simple-icons:onlyoffice"
|
||||
case "openai":
|
||||
case "openai_compatible":
|
||||
return "simple-icons:openai"
|
||||
case "anthropic":
|
||||
return "simple-icons:anthropic"
|
||||
case "mistral":
|
||||
return "simple-icons:mistralai"
|
||||
case "azure_openai":
|
||||
return "logos:microsoft-azure"
|
||||
case "azure_ai_anthropic":
|
||||
return "logos:microsoft-azure"
|
||||
case "aws_bedrock":
|
||||
return "simple-icons:amazonaws"
|
||||
case "google_gemini":
|
||||
case "gemini":
|
||||
return "simple-icons:googlegemini"
|
||||
case "groq":
|
||||
return "simple-icons:groq"
|
||||
case "deepseek":
|
||||
return "simple-icons:deepseek"
|
||||
case "openrouter":
|
||||
return "simple-icons:openrouter"
|
||||
case "together":
|
||||
return "simple-icons:togetherdotai"
|
||||
case "fireworks":
|
||||
return "simple-icons:fireworks"
|
||||
case "xai":
|
||||
return "simple-icons:x"
|
||||
case "ollama":
|
||||
case "ollama_cloud":
|
||||
return "simple-icons:ollama"
|
||||
case "deepgram":
|
||||
return "simple-icons:deepgram"
|
||||
case "ldap":
|
||||
case "active_directory":
|
||||
return "mdi:microsoft-active-directory"
|
||||
case "saml":
|
||||
return "mdi:shield-key"
|
||||
case "oauth":
|
||||
return "mdi:key-chain"
|
||||
case "custom":
|
||||
return "mdi:puzzle-outline"
|
||||
case "docx":
|
||||
return "logos:microsoft-word"
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@ -33,11 +33,23 @@ export type AiPostMessage =
|
||||
| { type: "ULTI_DOCS_APPLY"; payload: unknown }
|
||||
| { type: "ULTI_ASSISTANT_TEXT"; text: string }
|
||||
| { type: "ULTI_THEME"; theme: "light" | "dark" }
|
||||
| {
|
||||
type: "ULTI_SESSION"
|
||||
token_secret?: string
|
||||
mcp_url?: string
|
||||
enabled_tools?: string[]
|
||||
session_id?: string
|
||||
}
|
||||
| { type: "ULTI_OPEN_LINK"; href: string }
|
||||
| { type: "ULTI_TOOL_RESULT"; payload: unknown }
|
||||
|
||||
export function buildEmbedSearchParams(context: AiChatContext): string {
|
||||
export function buildEmbedSearchParams(
|
||||
context: AiChatContext,
|
||||
defaultModel?: string,
|
||||
): string {
|
||||
const params = new URLSearchParams()
|
||||
const model = defaultModel?.trim()
|
||||
if (model) params.set("model", model)
|
||||
if (context.temporary !== false) params.set("temporary-chat", "true")
|
||||
if (context.app) params.set("app", context.app)
|
||||
if (context.messageId) params.set("message_id", context.messageId)
|
||||
@ -52,8 +64,12 @@ export function buildEmbedSearchParams(context: AiChatContext): string {
|
||||
|
||||
export function systemPromptFromContext(context: AiChatContext): string {
|
||||
const lines = [
|
||||
"Tu es UltiAI, l'assistant intégré à la suite Ultimail (mail, drive, contacts).",
|
||||
"Tu es UltiAI, l'assistant intégré à la suite Ultimail (mail, drive, contacts, agenda).",
|
||||
"Réponds en français sauf demande contraire. Utilise les tools disponibles pour agir sur les données utilisateur.",
|
||||
"Recherche suite (index local) via suite_search ; recherche web publique via web_search si configurée.",
|
||||
"Après chaque appel d'outil, réponds toujours en langage naturel : résume le résultat, cite les sources (sujet, chemin, nom), propose la suite.",
|
||||
"Ne termine jamais un tour utilisateur avec uniquement un appel d'outil sans texte explicatif.",
|
||||
"Respecte strictement le paramètre limit des tools (ne demande pas plus de résultats que nécessaire).",
|
||||
]
|
||||
if (context.app === "mail" && context.subject) {
|
||||
lines.push(`Contexte mail — sujet: ${context.subject}`)
|
||||
|
||||
53
lib/ai/ultiai-tool-groups.ts
Normal file
53
lib/ai/ultiai-tool-groups.ts
Normal file
@ -0,0 +1,53 @@
|
||||
export type UltiAiToolGroup = {
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
}
|
||||
|
||||
/** Tool groups exposed to UltiAI via MCP (X-Ulti-Enabled-Tools). */
|
||||
export const ULTIAI_TOOL_GROUPS: UltiAiToolGroup[] = [
|
||||
{
|
||||
id: "mail",
|
||||
label: "Mail",
|
||||
description: "Recherche, lecture, envoi, libellés et suppression de messages.",
|
||||
},
|
||||
{
|
||||
id: "drive",
|
||||
label: "Drive",
|
||||
description: "Fichiers, dossiers, partages et déplacements.",
|
||||
},
|
||||
{
|
||||
id: "contacts",
|
||||
label: "Contacts",
|
||||
description: "Carnets d'adresses et fiches contacts.",
|
||||
},
|
||||
{
|
||||
id: "agenda",
|
||||
label: "Agenda",
|
||||
description: "Calendriers, événements, invitations et visioconférence.",
|
||||
},
|
||||
{
|
||||
id: "search",
|
||||
label: "Recherche suite",
|
||||
description: "Index unifié mail, drive et contacts (pas le web public).",
|
||||
},
|
||||
{
|
||||
id: "web_search",
|
||||
label: "Recherche web",
|
||||
description:
|
||||
"Recherche en ligne (Brave, Bing, SearXNG, DuckDuckGo, API JSON). Même réglages que contacts — onglet Recherche.",
|
||||
},
|
||||
]
|
||||
|
||||
export const DEFAULT_ULTIAI_ENABLED_TOOLS = ULTIAI_TOOL_GROUPS.map((group) => group.id)
|
||||
|
||||
export function toggleUltiAiToolGroup(
|
||||
enabledTools: string[],
|
||||
groupId: string,
|
||||
enabled: boolean,
|
||||
): string[] {
|
||||
const set = new Set(enabledTools)
|
||||
if (enabled) set.add(groupId)
|
||||
else set.delete(groupId)
|
||||
return ULTIAI_TOOL_GROUPS.map((group) => group.id).filter((id) => set.has(id))
|
||||
}
|
||||
@ -45,30 +45,29 @@ export function useAiIframeNavigation(
|
||||
const { pathname, search, hash, origin } = win.location
|
||||
if (embedOrigin && origin !== embedOrigin) return
|
||||
|
||||
const inBase =
|
||||
pathname === base || pathname.startsWith(`${base}/`)
|
||||
|
||||
const inBase = pathname === base || pathname.startsWith(`${base}/`)
|
||||
if (inBase) return
|
||||
|
||||
// Suite landing or explicit suite module — stay on OpenWebUI home.
|
||||
if (pathname === "/" || pathname === "" || isSuiteRoute(pathname)) {
|
||||
win.location.replace(`${base}/${search}${hash}`)
|
||||
return
|
||||
}
|
||||
const target =
|
||||
pathname === "/" || pathname === "" || isSuiteRoute(pathname)
|
||||
? `${base}/${search}${hash}`
|
||||
: `${base}${pathname}${search}${hash}`
|
||||
|
||||
// OpenWebUI route without /ai prefix (e.g. /notes, /workspace) — preserve path.
|
||||
win.location.replace(`${base}${pathname}${search}${hash}`)
|
||||
const targetUrl = target.startsWith("http") ? target : `${origin}${target}`
|
||||
if (win.location.href === targetUrl) return
|
||||
|
||||
// One-shot fix only — polling caused SvelteKit "Redirect loop" → 500: Internal Error
|
||||
win.location.replace(targetUrl)
|
||||
} catch {
|
||||
// Cross-origin — parent cannot read location.
|
||||
}
|
||||
}
|
||||
|
||||
iframe.addEventListener("load", enforceBasePath)
|
||||
const timer = window.setInterval(enforceBasePath, 400)
|
||||
const onLoad = () => window.setTimeout(enforceBasePath, 0)
|
||||
iframe.addEventListener("load", onLoad)
|
||||
|
||||
return () => {
|
||||
iframe.removeEventListener("load", enforceBasePath)
|
||||
window.clearInterval(timer)
|
||||
iframe.removeEventListener("load", onLoad)
|
||||
}
|
||||
}, [iframeRef, publicPath])
|
||||
}
|
||||
|
||||
@ -67,7 +67,7 @@ export type ApiIdentityProvidersPolicy = {
|
||||
export type ApiOrgTwoFactor = {
|
||||
required_for_all: boolean
|
||||
required_for_admins: boolean
|
||||
allowed_methods: ("totp" | "webauthn" | "sms")[]
|
||||
allowed_methods: ("totp" | "webauthn")[]
|
||||
grace_period_days: number
|
||||
remember_device_days: number
|
||||
}
|
||||
|
||||
@ -2,6 +2,11 @@ export type AdminUserStatus = "active" | "disabled" | "invited"
|
||||
|
||||
export type AdminUserRole = "admin" | "user" | "guest" | "suspended"
|
||||
|
||||
export type AdminUserGroupRef = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export type AdminUser = {
|
||||
id: string
|
||||
external_id: string
|
||||
@ -10,6 +15,7 @@ export type AdminUser = {
|
||||
status: AdminUserStatus
|
||||
platform_admin: boolean
|
||||
role: AdminUserRole
|
||||
groups?: AdminUserGroupRef[]
|
||||
storage?: AdminUserStorage
|
||||
invited_at?: string | null
|
||||
disabled_at?: string | null
|
||||
@ -44,6 +50,59 @@ export type AdminUsersListResponse = {
|
||||
pagination: AdminPagination
|
||||
}
|
||||
|
||||
export type AdminUserGroup = {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
member_count: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type AdminUserGroupsListResponse = {
|
||||
groups: AdminUserGroup[]
|
||||
pagination: AdminPagination
|
||||
}
|
||||
|
||||
export type AdminCreateUserGroupRequest = {
|
||||
name: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export type AdminUpdateUserGroupRequest = {
|
||||
name?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export type AdminSetUserGroupMembersRequest = {
|
||||
user_ids: string[]
|
||||
}
|
||||
|
||||
export type AdminBulkUsersAction =
|
||||
| "disable"
|
||||
| "reactivate"
|
||||
| "delete"
|
||||
| "set_role"
|
||||
| "add_to_group"
|
||||
| "remove_from_group"
|
||||
|
||||
export type AdminBulkUsersRequest = {
|
||||
user_ids: string[]
|
||||
action: AdminBulkUsersAction
|
||||
role?: AdminUserRole
|
||||
group_id?: string
|
||||
}
|
||||
|
||||
export type AdminBulkUsersFailure = {
|
||||
user_id: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export type AdminBulkUsersResponse = {
|
||||
success_count: number
|
||||
failed?: AdminBulkUsersFailure[]
|
||||
}
|
||||
|
||||
export type AdminAuditLog = {
|
||||
id: string
|
||||
actor: string
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { apiClient } from "@/lib/api/client"
|
||||
import { useAuthReady } from "@/lib/api/use-auth-ready"
|
||||
import type { DriveOrgFolder } from "@/lib/api/types"
|
||||
import type { DriveMount, DriveOrgFolder } from "@/lib/api/types"
|
||||
|
||||
export function useAdminDriveOrgFolders() {
|
||||
const { ready, authenticated } = useAuthReady()
|
||||
@ -50,3 +50,49 @@ export function useAdminDriveOrgFolderMutations() {
|
||||
|
||||
return { create, update, remove, sync }
|
||||
}
|
||||
|
||||
export function useAdminDriveOrgMounts() {
|
||||
const { ready, authenticated } = useAuthReady()
|
||||
return useQuery({
|
||||
queryKey: ["admin", "drive", "org-mounts"],
|
||||
enabled: ready && authenticated,
|
||||
queryFn: async () => {
|
||||
const res = await apiClient.get<{ mounts: DriveMount[] }>("/admin/drive/org-mounts")
|
||||
return res.mounts ?? []
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export type AdminOrgWebDAVMountBody = {
|
||||
org_slug: string
|
||||
display_name: string
|
||||
webdav: {
|
||||
host: string
|
||||
root: string
|
||||
user: string
|
||||
password: string
|
||||
secure: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export function useAdminDriveOrgMountMutations() {
|
||||
const qc = useQueryClient()
|
||||
const invalidate = () => {
|
||||
qc.invalidateQueries({ queryKey: ["admin", "drive", "org-mounts"] })
|
||||
qc.invalidateQueries({ queryKey: ["drive", "mounts"] })
|
||||
}
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (body: AdminOrgWebDAVMountBody) =>
|
||||
apiClient.post<DriveMount>("/admin/drive/org-mounts", body),
|
||||
onSuccess: invalidate,
|
||||
})
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiClient.delete(`/admin/drive/org-mounts/${encodeURIComponent(id)}`),
|
||||
onSuccess: invalidate,
|
||||
})
|
||||
|
||||
return { create, remove }
|
||||
}
|
||||
|
||||
@ -3,13 +3,19 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { apiClient } from "@/lib/api/client"
|
||||
import type {
|
||||
AdminBulkUsersRequest,
|
||||
AdminBulkUsersResponse,
|
||||
AdminCreateUserGroupRequest,
|
||||
AdminCreateUserRequest,
|
||||
AdminInviteUserRequest,
|
||||
AdminSetQuotaRequest,
|
||||
AdminSetUserGroupMembersRequest,
|
||||
AdminSetUserRoleRequest,
|
||||
AdminUpdateUserGroupRequest,
|
||||
AdminUpdateUserRequest,
|
||||
AdminUser,
|
||||
AdminUserDetail,
|
||||
AdminUserGroup,
|
||||
} from "@/lib/api/admin-types"
|
||||
|
||||
export function useCreateAdminUser() {
|
||||
@ -126,3 +132,62 @@ export function useDeleteAdminUser() {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateAdminUserGroup() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: AdminCreateUserGroupRequest) =>
|
||||
apiClient.post<AdminUserGroup>("/admin/user-groups", body),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["admin", "user-groups"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateAdminUserGroup(groupId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: AdminUpdateUserGroupRequest) =>
|
||||
apiClient.put<AdminUserGroup>(`/admin/user-groups/${groupId}`, body),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["admin", "user-groups"] })
|
||||
void queryClient.invalidateQueries({ queryKey: ["admin", "users"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteAdminUserGroup() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (groupId: string) => apiClient.delete(`/admin/user-groups/${groupId}`),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["admin", "user-groups"] })
|
||||
void queryClient.invalidateQueries({ queryKey: ["admin", "users"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useSetAdminUserGroupMembers(groupId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: AdminSetUserGroupMembersRequest) =>
|
||||
apiClient.put<AdminUserGroup>(`/admin/user-groups/${groupId}/members`, body),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["admin", "user-groups"] })
|
||||
void queryClient.invalidateQueries({ queryKey: ["admin", "users"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useBulkAdminUsers() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: AdminBulkUsersRequest) =>
|
||||
apiClient.post<AdminBulkUsersResponse>("/admin/users/bulk", body),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["admin", "users"] })
|
||||
void queryClient.invalidateQueries({ queryKey: ["admin", "user-groups"] })
|
||||
void queryClient.invalidateQueries({ queryKey: ["admin", "stats"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import type {
|
||||
AdminPublicSharesListResponse,
|
||||
AdminStatsResponse,
|
||||
AdminUserDetail,
|
||||
AdminUserGroupsListResponse,
|
||||
AdminUsersListResponse,
|
||||
} from "@/lib/api/admin-types"
|
||||
import { useAuthReady } from "@/lib/api/use-auth-ready"
|
||||
@ -15,8 +16,16 @@ export type AdminUsersQueryParams = {
|
||||
page?: number
|
||||
page_size?: number
|
||||
q?: string
|
||||
sort?: string
|
||||
status?: string
|
||||
role?: string
|
||||
group_id?: string
|
||||
}
|
||||
|
||||
export type AdminUserGroupsQueryParams = {
|
||||
page?: number
|
||||
page_size?: number
|
||||
q?: string
|
||||
}
|
||||
|
||||
export function useAdminStats() {
|
||||
@ -37,8 +46,24 @@ export function useAdminUsers(params: AdminUsersQueryParams = {}) {
|
||||
page: params.page?.toString(),
|
||||
page_size: params.page_size?.toString(),
|
||||
q: params.q,
|
||||
sort: params.sort,
|
||||
status: params.status,
|
||||
role: params.role,
|
||||
group_id: params.group_id,
|
||||
}),
|
||||
enabled: ready && authenticated,
|
||||
})
|
||||
}
|
||||
|
||||
export function useAdminUserGroups(params: AdminUserGroupsQueryParams = {}) {
|
||||
const { ready, authenticated } = useAuthReady()
|
||||
return useQuery({
|
||||
queryKey: ["admin", "user-groups", params],
|
||||
queryFn: () =>
|
||||
apiClient.get<AdminUserGroupsListResponse>("/admin/user-groups", {
|
||||
page: params.page?.toString(),
|
||||
page_size: params.page_size?.toString(),
|
||||
q: params.q,
|
||||
}),
|
||||
enabled: ready && authenticated,
|
||||
})
|
||||
@ -53,7 +78,9 @@ export function useAdminUser(userId: string | null) {
|
||||
})
|
||||
}
|
||||
|
||||
export function useAdminPublicShares(params: { page?: number; page_size?: number; q?: string } = {}) {
|
||||
export function useAdminPublicShares(
|
||||
params: { page?: number; page_size?: number; q?: string; sort?: string } = {}
|
||||
) {
|
||||
const { ready, authenticated } = useAuthReady()
|
||||
return useQuery({
|
||||
queryKey: ["admin", "public-shares", params],
|
||||
@ -62,6 +89,7 @@ export function useAdminPublicShares(params: { page?: number; page_size?: number
|
||||
page: params.page?.toString(),
|
||||
page_size: params.page_size?.toString(),
|
||||
q: params.q,
|
||||
sort: params.sort,
|
||||
}),
|
||||
enabled: ready && authenticated,
|
||||
})
|
||||
|
||||
@ -7,6 +7,7 @@ import type { AiChatContext } from "@/lib/ai/chat-context"
|
||||
export type AiConfig = {
|
||||
enabled: boolean
|
||||
public_path: string
|
||||
mcp_url?: string
|
||||
embed_default_temporary: boolean
|
||||
default_model: string
|
||||
enabled_tools: string[]
|
||||
@ -29,6 +30,8 @@ export type AiSessionResponse = {
|
||||
embed_url: string
|
||||
token_secret?: string
|
||||
temporary: boolean
|
||||
mcp_url?: string
|
||||
enabled_tools?: string[]
|
||||
}
|
||||
|
||||
export function useAiConfig() {
|
||||
|
||||
@ -790,8 +790,8 @@ export function useUpdateSearchSettings() {
|
||||
return useMutation({
|
||||
mutationFn: (settings: ApiSearchSettings) =>
|
||||
apiClient.put<ApiSearchSettings>('/contacts/discovery/search-settings', settings),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['search-settings'] })
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(['search-settings'], normalizeSearchSettings(data))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,6 +1,25 @@
|
||||
export type ApiLLMProviderType =
|
||||
| "openai"
|
||||
| "anthropic"
|
||||
| "mistral"
|
||||
| "azure_openai"
|
||||
| "azure_ai_anthropic"
|
||||
| "aws_bedrock"
|
||||
| "google_gemini"
|
||||
| "groq"
|
||||
| "deepseek"
|
||||
| "openrouter"
|
||||
| "together"
|
||||
| "fireworks"
|
||||
| "xai"
|
||||
| "ollama"
|
||||
| "ollama_cloud"
|
||||
| "custom"
|
||||
|
||||
export interface ApiLLMProvider {
|
||||
id: string
|
||||
name: string
|
||||
type?: ApiLLMProviderType
|
||||
base_url: string
|
||||
api_key?: string
|
||||
default_model: string
|
||||
@ -17,13 +36,25 @@ export interface ApiLLMModelsResponse {
|
||||
models: string[]
|
||||
}
|
||||
|
||||
export type ApiSearchProviderType = 'brave'
|
||||
export type ApiSearchProviderType =
|
||||
| "brave"
|
||||
| "bing"
|
||||
| "duckduckgo"
|
||||
| "searxng"
|
||||
| "custom"
|
||||
|
||||
export interface ApiSearchProvider {
|
||||
id: string
|
||||
name: string
|
||||
type: ApiSearchProviderType
|
||||
api_key?: string
|
||||
base_url?: string
|
||||
query_param?: string
|
||||
auth_header?: string
|
||||
results_path?: string
|
||||
title_field?: string
|
||||
url_field?: string
|
||||
description_field?: string
|
||||
}
|
||||
|
||||
export interface ApiSearchSettings {
|
||||
|
||||
233
lib/llm/llm-provider-catalog.ts
Normal file
233
lib/llm/llm-provider-catalog.ts
Normal file
@ -0,0 +1,233 @@
|
||||
import type { ApiLLMProvider, ApiLLMProviderType } from "@/lib/contacts/discovery-types"
|
||||
import { techBrandIcon } from "@/lib/admin-settings/tech-brand-icons"
|
||||
|
||||
export type { ApiLLMProviderType }
|
||||
|
||||
export type LLMProviderCatalogEntry = {
|
||||
type: ApiLLMProviderType
|
||||
label: string
|
||||
description: string
|
||||
icon?: string
|
||||
docsUrl?: string
|
||||
defaultBaseURL: string
|
||||
defaultModel: string
|
||||
baseURLPlaceholder?: string
|
||||
}
|
||||
|
||||
export const LLM_PROVIDER_CATALOG: LLMProviderCatalogEntry[] = [
|
||||
{
|
||||
type: "openai",
|
||||
label: "OpenAI",
|
||||
description: "API officielle OpenAI (GPT, o-series).",
|
||||
icon: techBrandIcon("openai"),
|
||||
docsUrl: "https://platform.openai.com/docs/api-reference",
|
||||
defaultBaseURL: "https://api.openai.com/v1",
|
||||
defaultModel: "gpt-4o-mini",
|
||||
},
|
||||
{
|
||||
type: "anthropic",
|
||||
label: "Anthropic",
|
||||
description: "Claude via la couche OpenAI-compatible d'Anthropic.",
|
||||
icon: techBrandIcon("anthropic"),
|
||||
docsUrl: "https://platform.claude.com/docs/en/api/openai-sdk",
|
||||
defaultBaseURL: "https://api.anthropic.com/v1",
|
||||
defaultModel: "claude-sonnet-4-6",
|
||||
},
|
||||
{
|
||||
type: "mistral",
|
||||
label: "Mistral AI",
|
||||
description: "API Mistral (OpenAI-compatible).",
|
||||
icon: techBrandIcon("mistral"),
|
||||
docsUrl: "https://docs.mistral.ai/api/",
|
||||
defaultBaseURL: "https://api.mistral.ai/v1",
|
||||
defaultModel: "mistral-small-latest",
|
||||
},
|
||||
{
|
||||
type: "azure_openai",
|
||||
label: "Azure OpenAI",
|
||||
description: "Modèles OpenAI déployés sur Azure AI Foundry.",
|
||||
icon: techBrandIcon("azure_openai"),
|
||||
docsUrl: "https://learn.microsoft.com/azure/ai-foundry/openai/reference",
|
||||
defaultBaseURL: "https://VOTRE_RESSOURCE.openai.azure.com/openai/v1",
|
||||
defaultModel: "gpt-4o-mini",
|
||||
baseURLPlaceholder: "https://ma-ressource.openai.azure.com/openai/v1",
|
||||
},
|
||||
{
|
||||
type: "azure_ai_anthropic",
|
||||
label: "Anthropic via Azure",
|
||||
description:
|
||||
"Claude sur Microsoft Foundry. Remplacez VOTRE_RESSOURCE ; vérifiez la compatibilité OpenAI de votre déploiement.",
|
||||
icon: techBrandIcon("azure_ai_anthropic"),
|
||||
docsUrl:
|
||||
"https://learn.microsoft.com/azure/ai-foundry/foundry-models/how-to/use-foundry-models-claude",
|
||||
defaultBaseURL: "https://VOTRE_RESSOURCE.services.ai.azure.com/anthropic/v1",
|
||||
defaultModel: "claude-sonnet-4-6",
|
||||
baseURLPlaceholder:
|
||||
"https://ma-ressource.services.ai.azure.com/anthropic/v1",
|
||||
},
|
||||
{
|
||||
type: "aws_bedrock",
|
||||
label: "Anthropic via AWS Bedrock",
|
||||
description: "Claude et autres modèles via Bedrock (OpenAI-compatible).",
|
||||
icon: techBrandIcon("aws_bedrock"),
|
||||
docsUrl: "https://docs.aws.amazon.com/bedrock/latest/userguide/inference-chat-completions-mantle.html",
|
||||
defaultBaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com/openai/v1",
|
||||
defaultModel: "us.anthropic.claude-sonnet-4-6",
|
||||
baseURLPlaceholder: "https://bedrock-runtime.eu-west-1.amazonaws.com/openai/v1",
|
||||
},
|
||||
{
|
||||
type: "google_gemini",
|
||||
label: "Google Gemini",
|
||||
description: "API Gemini en mode OpenAI-compatible.",
|
||||
icon: techBrandIcon("google_gemini"),
|
||||
docsUrl: "https://ai.google.dev/gemini-api/docs/openai",
|
||||
defaultBaseURL: "https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||
defaultModel: "gemini-2.0-flash",
|
||||
},
|
||||
{
|
||||
type: "groq",
|
||||
label: "Groq",
|
||||
description: "Inférence rapide (Llama, Mixtral, etc.).",
|
||||
icon: techBrandIcon("groq"),
|
||||
docsUrl: "https://console.groq.com/docs/openai",
|
||||
defaultBaseURL: "https://api.groq.com/openai/v1",
|
||||
defaultModel: "llama-3.3-70b-versatile",
|
||||
},
|
||||
{
|
||||
type: "deepseek",
|
||||
label: "DeepSeek",
|
||||
description: "Modèles DeepSeek (OpenAI-compatible).",
|
||||
icon: techBrandIcon("deepseek"),
|
||||
docsUrl: "https://api-docs.deepseek.com/",
|
||||
defaultBaseURL: "https://api.deepseek.com/v1",
|
||||
defaultModel: "deepseek-chat",
|
||||
},
|
||||
{
|
||||
type: "openrouter",
|
||||
label: "OpenRouter",
|
||||
description: "Passerelle multi-fournisseurs (Claude, GPT, Llama…).",
|
||||
icon: techBrandIcon("openrouter"),
|
||||
docsUrl: "https://openrouter.ai/docs",
|
||||
defaultBaseURL: "https://openrouter.ai/api/v1",
|
||||
defaultModel: "anthropic/claude-sonnet-4",
|
||||
},
|
||||
{
|
||||
type: "together",
|
||||
label: "Together AI",
|
||||
description: "Modèles open-source hébergés.",
|
||||
icon: techBrandIcon("together"),
|
||||
docsUrl: "https://docs.together.ai/docs/openai-api",
|
||||
defaultBaseURL: "https://api.together.xyz/v1",
|
||||
defaultModel: "meta-llama/Llama-3.3-70B-Instruct-Turbo",
|
||||
},
|
||||
{
|
||||
type: "fireworks",
|
||||
label: "Fireworks AI",
|
||||
description: "Inférence serverless pour modèles open-source.",
|
||||
icon: techBrandIcon("fireworks"),
|
||||
docsUrl: "https://docs.fireworks.ai/tools-sdks/openai-compatibility",
|
||||
defaultBaseURL: "https://api.fireworks.ai/inference/v1",
|
||||
defaultModel: "accounts/fireworks/models/llama-v3p3-70b-instruct",
|
||||
},
|
||||
{
|
||||
type: "xai",
|
||||
label: "xAI (Grok)",
|
||||
description: "API Grok (OpenAI-compatible).",
|
||||
icon: techBrandIcon("xai"),
|
||||
docsUrl: "https://docs.x.ai/docs/guides/chat-completions",
|
||||
defaultBaseURL: "https://api.x.ai/v1",
|
||||
defaultModel: "grok-2-latest",
|
||||
},
|
||||
{
|
||||
type: "ollama",
|
||||
label: "Ollama (local)",
|
||||
description: "Serveur Ollama local avec API OpenAI-compatible.",
|
||||
icon: techBrandIcon("ollama"),
|
||||
docsUrl: "https://github.com/ollama/ollama/blob/main/docs/openai.md",
|
||||
defaultBaseURL: "http://localhost:11434/v1",
|
||||
defaultModel: "llama3.2",
|
||||
},
|
||||
{
|
||||
type: "ollama_cloud",
|
||||
label: "Ollama Cloud",
|
||||
description: "Modèles hébergés sur ollama.com (clé API requise).",
|
||||
icon: techBrandIcon("ollama"),
|
||||
docsUrl: "https://docs.ollama.com/cloud",
|
||||
defaultBaseURL: "https://ollama.com/v1",
|
||||
defaultModel: "gpt-oss:120b",
|
||||
},
|
||||
{
|
||||
type: "custom",
|
||||
label: "Endpoint personnalisé",
|
||||
description: "Toute API compatible OpenAI (/v1/chat/completions).",
|
||||
icon: techBrandIcon("custom"),
|
||||
defaultBaseURL: "",
|
||||
defaultModel: "",
|
||||
baseURLPlaceholder: "https://api.example.com/v1",
|
||||
},
|
||||
]
|
||||
|
||||
export function llmCatalogEntry(type: ApiLLMProviderType): LLMProviderCatalogEntry {
|
||||
return LLM_PROVIDER_CATALOG.find((entry) => entry.type === type) ?? LLM_PROVIDER_CATALOG.at(-1)!
|
||||
}
|
||||
|
||||
export function emptyLlmProvider(type: ApiLLMProviderType = "openai"): ApiLLMProvider {
|
||||
const entry = llmCatalogEntry(type)
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
type,
|
||||
name: entry.label,
|
||||
base_url: entry.defaultBaseURL,
|
||||
api_key: "",
|
||||
default_model: entry.defaultModel,
|
||||
}
|
||||
}
|
||||
|
||||
export function inferLlmProviderType(provider: ApiLLMProvider): ApiLLMProviderType {
|
||||
if (provider.type) return provider.type
|
||||
const url = provider.base_url?.toLowerCase() ?? ""
|
||||
if (url.includes("api.openai.com")) return "openai"
|
||||
if (url.includes("api.anthropic.com")) return "anthropic"
|
||||
if (url.includes("api.mistral.ai")) return "mistral"
|
||||
if (url.includes("openai.azure.com")) return "azure_openai"
|
||||
if (url.includes("services.ai.azure.com/anthropic")) return "azure_ai_anthropic"
|
||||
if (url.includes("bedrock")) return "aws_bedrock"
|
||||
if (url.includes("ollama.com")) return "ollama_cloud"
|
||||
if (url.includes("localhost:11434") || url.includes("127.0.0.1:11434")) return "ollama"
|
||||
if (url.includes("generativelanguage.googleapis.com")) return "google_gemini"
|
||||
if (url.includes("api.groq.com")) return "groq"
|
||||
if (url.includes("api.deepseek.com")) return "deepseek"
|
||||
if (url.includes("openrouter.ai")) return "openrouter"
|
||||
if (url.includes("api.together.xyz")) return "together"
|
||||
if (url.includes("api.fireworks.ai")) return "fireworks"
|
||||
if (url.includes("api.x.ai")) return "xai"
|
||||
return "custom"
|
||||
}
|
||||
|
||||
export function normalizeLlmProvider(provider: ApiLLMProvider): ApiLLMProvider {
|
||||
const type = inferLlmProviderType(provider)
|
||||
const entry = llmCatalogEntry(type)
|
||||
return {
|
||||
...provider,
|
||||
type,
|
||||
name: provider.name?.trim() || entry.label,
|
||||
}
|
||||
}
|
||||
|
||||
export function isLlmProviderConfigured(
|
||||
provider: ApiLLMProvider,
|
||||
options?: { apiKeyConfigured?: boolean },
|
||||
): boolean {
|
||||
const type = inferLlmProviderType(provider)
|
||||
const entry = llmCatalogEntry(type)
|
||||
if (!provider.base_url?.trim() && type !== "custom") {
|
||||
return false
|
||||
}
|
||||
if (type === "ollama") {
|
||||
return Boolean(provider.base_url?.trim())
|
||||
}
|
||||
if (type === "custom") {
|
||||
return Boolean(provider.base_url?.trim())
|
||||
}
|
||||
return Boolean(provider.api_key?.trim() || options?.apiKeyConfigured)
|
||||
}
|
||||
@ -320,15 +320,15 @@ export const MAIL_SETTINGS_CARD_CLASS = cn(
|
||||
"dark:bg-mail-surface-elevated dark:shadow-[0_1px_4px_rgba(0,0,0,0.35)]",
|
||||
)
|
||||
|
||||
/** Masonry 2 colonnes pour sections réglages (affichage, signatures…) en lg+. */
|
||||
export const MAIL_SETTINGS_PAGE_MASONRY_CLASS = "lg:columns-2 lg:gap-5"
|
||||
/** @deprecated Utiliser `AutomationTabMasonry` (2 piles verticales indépendantes). */
|
||||
export const MAIL_SETTINGS_PAGE_MASONRY_CLASS = "w-full"
|
||||
|
||||
export const MAIL_SETTINGS_PAGE_MASONRY_ITEM_CLASS =
|
||||
"lg:mb-5 lg:break-inside-avoid"
|
||||
/** @deprecated Géré par `AutomationTabMasonry`. */
|
||||
export const MAIL_SETTINGS_PAGE_MASONRY_ITEM_CLASS = "min-w-0"
|
||||
|
||||
/** Bloc empilé → card en masonry (variant page affichage). */
|
||||
export const MAIL_SETTINGS_PAGE_MASONRY_SECTION_CLASS = cn(
|
||||
"mail-settings-masonry-section border-border px-0 py-5",
|
||||
"lg:mb-5 lg:break-inside-avoid lg:rounded-xl lg:border lg:border-mail-border lg:bg-mail-surface lg:px-5 lg:py-5 lg:shadow-sm",
|
||||
"lg:rounded-xl lg:border lg:border-mail-border lg:bg-mail-surface lg:px-5 lg:py-5 lg:shadow-sm",
|
||||
"dark:lg:bg-mail-surface-elevated dark:lg:shadow-[0_1px_4px_rgba(0,0,0,0.35)]",
|
||||
)
|
||||
|
||||
@ -65,7 +65,7 @@ export const MAIL_SETTINGS_SEARCH_INDEX: MailSettingsSearchEntry[] = [
|
||||
entry("automation", "rules", "Règles de tri", "automatisation filtre tri forward réponse"),
|
||||
entry("automation", "webhooks", "Webhooks", "http post template payload externe"),
|
||||
entry("automation", "llm", "Fournisseurs LLM", "ia openai tri intelligent llm"),
|
||||
entry("automation", "search-providers", "Fournisseurs de recherche", "web search api recherche"),
|
||||
entry("automation", "search-providers", "Fournisseurs de recherche", "web search api brave bing searxng duckduckgo ultiai contacts"),
|
||||
entry("automation", "api-tokens", "Tokens API", "agent ia accès programmatique fine-grained agenda calendrier"),
|
||||
entry("automation", "agenda-rules", "Règles agenda", "événement calendrier invitation visio participant"),
|
||||
entry("automation", "agenda-webhooks", "Webhooks agenda", "événement calendrier créé modifié supprimé réponse"),
|
||||
|
||||
173
lib/web-search/search-provider-catalog.ts
Normal file
173
lib/web-search/search-provider-catalog.ts
Normal file
@ -0,0 +1,173 @@
|
||||
import type { ApiSearchProvider, ApiSearchSettings } from "@/lib/contacts/discovery-types"
|
||||
import { techBrandIcon } from "@/lib/admin-settings/tech-brand-icons"
|
||||
|
||||
export type ApiSearchProviderType =
|
||||
| "brave"
|
||||
| "bing"
|
||||
| "duckduckgo"
|
||||
| "searxng"
|
||||
| "custom"
|
||||
|
||||
export type SearchProviderCatalogEntry = {
|
||||
type: ApiSearchProviderType
|
||||
label: string
|
||||
description: string
|
||||
icon?: string
|
||||
docsUrl?: string
|
||||
requiresApiKey: boolean
|
||||
requiresBaseURL: boolean
|
||||
supportsCustomMapping: boolean
|
||||
}
|
||||
|
||||
export const SEARCH_PROVIDER_CATALOG: SearchProviderCatalogEntry[] = [
|
||||
{
|
||||
type: "brave",
|
||||
label: "Brave Search",
|
||||
description: "API officielle Brave (X-Subscription-Token).",
|
||||
icon: techBrandIcon("brave"),
|
||||
docsUrl: "https://api.search.brave.com",
|
||||
requiresApiKey: true,
|
||||
requiresBaseURL: false,
|
||||
supportsCustomMapping: false,
|
||||
},
|
||||
{
|
||||
type: "bing",
|
||||
label: "Bing Web Search",
|
||||
description: "Azure Cognitive Services Bing Search v7.",
|
||||
icon: techBrandIcon("bing"),
|
||||
docsUrl: "https://learn.microsoft.com/azure/cognitive-services/bing-web-search/",
|
||||
requiresApiKey: true,
|
||||
requiresBaseURL: false,
|
||||
supportsCustomMapping: false,
|
||||
},
|
||||
{
|
||||
type: "duckduckgo",
|
||||
label: "DuckDuckGo",
|
||||
description: "Scraping HTML léger, sans clé API (best-effort).",
|
||||
icon: techBrandIcon("duckduckgo"),
|
||||
requiresApiKey: false,
|
||||
requiresBaseURL: false,
|
||||
supportsCustomMapping: false,
|
||||
},
|
||||
{
|
||||
type: "searxng",
|
||||
label: "SearXNG",
|
||||
description: "Instance SearXNG auto-hébergée (format JSON).",
|
||||
icon: techBrandIcon("searxng"),
|
||||
docsUrl: "https://docs.searxng.org/",
|
||||
requiresApiKey: false,
|
||||
requiresBaseURL: true,
|
||||
supportsCustomMapping: false,
|
||||
},
|
||||
{
|
||||
type: "custom",
|
||||
label: "API JSON personnalisée",
|
||||
description: "Endpoint GET JSON avec mapping des champs résultats.",
|
||||
icon: techBrandIcon("custom"),
|
||||
requiresApiKey: false,
|
||||
requiresBaseURL: true,
|
||||
supportsCustomMapping: true,
|
||||
},
|
||||
]
|
||||
|
||||
export function catalogEntry(type: ApiSearchProviderType): SearchProviderCatalogEntry {
|
||||
return (
|
||||
SEARCH_PROVIDER_CATALOG.find((entry) => entry.type === type) ??
|
||||
SEARCH_PROVIDER_CATALOG[0]
|
||||
)
|
||||
}
|
||||
|
||||
export function emptySearchProvider(type: ApiSearchProviderType = "brave"): ApiSearchProvider {
|
||||
const entry = catalogEntry(type)
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
name: entry.label,
|
||||
type,
|
||||
api_key: "",
|
||||
base_url: defaultBaseURL(type),
|
||||
query_param: "q",
|
||||
auth_header: defaultAuthHeader(type),
|
||||
results_path: type === "custom" ? "results" : "",
|
||||
title_field: type === "custom" ? "title" : "",
|
||||
url_field: type === "custom" ? "url" : "",
|
||||
description_field: type === "custom" ? "description" : "",
|
||||
}
|
||||
}
|
||||
|
||||
function defaultBaseURL(type: ApiSearchProviderType): string {
|
||||
switch (type) {
|
||||
case "bing":
|
||||
return "https://api.bing.microsoft.com/v7.0/search"
|
||||
case "searxng":
|
||||
return "https://searx.example.org"
|
||||
case "custom":
|
||||
return "https://api.example.com/search"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
function defaultAuthHeader(type: ApiSearchProviderType): string {
|
||||
switch (type) {
|
||||
case "bing":
|
||||
return "Ocp-Apim-Subscription-Key"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureSearchSettingsDefaults(settings: ApiSearchSettings): ApiSearchSettings {
|
||||
const providers = settings.providers ?? []
|
||||
let defaultId = settings.default_provider_id ?? ""
|
||||
|
||||
if (!defaultId && providers.length === 1) {
|
||||
defaultId = providers[0]?.id ?? ""
|
||||
}
|
||||
if (defaultId && !providers.some((provider) => provider.id === defaultId)) {
|
||||
defaultId = providers[0]?.id ?? ""
|
||||
}
|
||||
|
||||
return {
|
||||
...settings,
|
||||
default_provider_id: defaultId,
|
||||
providers,
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeSearchProviders(raw: ApiSearchSettings | undefined): ApiSearchSettings {
|
||||
const providers = raw?.providers?.length
|
||||
? raw.providers.map((provider) => ({
|
||||
...emptySearchProvider(provider.type ?? "brave"),
|
||||
...provider,
|
||||
type: provider.type ?? "brave",
|
||||
}))
|
||||
: []
|
||||
return ensureSearchSettingsDefaults({
|
||||
default_provider_id: raw?.default_provider_id || providers[0]?.id || "",
|
||||
providers,
|
||||
})
|
||||
}
|
||||
|
||||
export function isSearchProviderConfigured(
|
||||
provider: ApiSearchProvider,
|
||||
options?: { apiKeyConfigured?: boolean },
|
||||
): boolean {
|
||||
switch (provider.type) {
|
||||
case "brave":
|
||||
case "bing":
|
||||
return Boolean(provider.api_key?.trim() || options?.apiKeyConfigured)
|
||||
case "duckduckgo":
|
||||
return true
|
||||
case "searxng":
|
||||
return Boolean(provider.base_url?.trim())
|
||||
case "custom":
|
||||
return Boolean(
|
||||
provider.base_url?.trim() &&
|
||||
provider.results_path?.trim() &&
|
||||
provider.title_field?.trim() &&
|
||||
provider.url_field?.trim(),
|
||||
)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user