Admin interface

This commit is contained in:
R3D347HR4Y 2026-06-07 21:55:42 +02:00
parent 9603a9c687
commit 8b9717861c
48 changed files with 4515 additions and 9 deletions

11
app/admin/layout.tsx Normal file
View File

@ -0,0 +1,11 @@
import type { Metadata } from "next"
import { suitePageMetadata } from "@/lib/suite/page-metadata"
export const metadata: Metadata = suitePageMetadata({
app: "admin",
title: "Administration",
})
export default function AdminLayout({ children }: { children: React.ReactNode }) {
return children
}

5
app/admin/page.tsx Normal file
View File

@ -0,0 +1,5 @@
import { redirect } from "next/navigation"
export default function AdminRootPage() {
redirect("/admin/settings")
}

View File

@ -0,0 +1,10 @@
import { AdminSettingsSectionFromSegments } from "@/components/admin/settings/admin-settings-section-view"
export default async function AdminSettingsSectionPage({
params,
}: {
params: Promise<{ section?: string[] }>
}) {
const { section } = await params
return <AdminSettingsSectionFromSegments segments={section} />
}

View File

@ -0,0 +1,16 @@
import { AdminSettingsLayout } from "@/components/admin/settings/admin-settings-layout"
import type { Metadata } from "next"
import { suitePageMetadata } from "@/lib/suite/page-metadata"
export const metadata: Metadata = suitePageMetadata({
app: "admin",
titleSegment: "Réglages",
})
export default function AdminSettingsRootLayout({
children,
}: {
children: React.ReactNode
}) {
return <AdminSettingsLayout>{children}</AdminSettingsLayout>
}

View File

@ -116,8 +116,8 @@
--muted-foreground: oklch(0.708 0 0); --muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0); --accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723); --destructive: oklch(0.65 0.22 25.3);
--destructive-foreground: oklch(0.637 0.237 25.331); --destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.269 0 0); --border: oklch(0.269 0 0);
--input: oklch(0.269 0 0); --input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0); --ring: oklch(0.439 0 0);
@ -692,20 +692,27 @@ html[data-mail-background]:not([data-mail-background='none']) [data-contacts-app
background-color: var(--mail-surface-muted) !important; background-color: var(--mail-surface-muted) !important;
} }
/* Réglages : fond décoratif visible uniquement derrière la sidebar (contenu opaque). */ /* Réglages / administration : fond décoratif visible uniquement derrière la sidebar (contenu opaque). */
html[data-mail-background]:not([data-mail-background='none']) [data-mail-settings-app].ultimail-app { html[data-mail-background]:not([data-mail-background='none']) [data-mail-settings-app].ultimail-app,
html[data-mail-background]:not([data-mail-background='none']) [data-admin-settings-app].ultimail-app {
background-color: var(--app-canvas) !important; background-color: var(--app-canvas) !important;
} }
html[data-mail-background]:not([data-mail-background='none']) html[data-mail-background]:not([data-mail-background='none'])
[data-mail-settings-app] [data-mail-settings-app]
[data-mail-settings-sidebar] { [data-mail-settings-sidebar],
html[data-mail-background]:not([data-mail-background='none'])
[data-admin-settings-app]
[data-admin-settings-sidebar] {
background-color: color-mix(in srgb, var(--app-canvas) 72%, transparent) !important; background-color: color-mix(in srgb, var(--app-canvas) 72%, transparent) !important;
} }
html[data-mail-background]:not([data-mail-background='none']) html[data-mail-background]:not([data-mail-background='none'])
[data-mail-settings-app] [data-mail-settings-app]
:where([data-mail-settings-main]) { :where([data-mail-settings-main]),
html[data-mail-background]:not([data-mail-background='none'])
[data-admin-settings-app]
:where([data-admin-settings-main]) {
background-color: var(--mail-surface) !important; background-color: var(--mail-surface) !important;
} }

View File

@ -0,0 +1,37 @@
import Image from "next/image"
import Link from "next/link"
import { cn } from "@/lib/utils"
type AdminLogoProps = {
className?: string
href?: string | null
variant?: "full" | "mark"
}
export function AdminLogo({
className,
href = "/admin/settings",
variant = "full",
}: AdminLogoProps) {
const img = (
<Image
src="/admin-mark.svg"
alt="Administration"
width={variant === "mark" ? 32 : 160}
height={32}
className={cn(
variant === "mark" ? "h-8 w-8" : "h-8 w-auto",
className
)}
priority
/>
)
if (href === null) return img
return (
<Link href={href} className="inline-flex shrink-0 items-center">
{img}
</Link>
)
}

View File

@ -0,0 +1,54 @@
"use client"
import Link from "next/link"
import { useAuthStore } from "@/lib/api/auth-store"
import { useAuthReady } from "@/lib/api/use-auth-ready"
import { useCurrentUser } from "@/lib/api/hooks/use-current-user"
import { adminScopesFromToken, isPlatformAdminFromToken } from "@/lib/auth/admin"
import { Button } from "@/components/ui/button"
export function AdminAccessGuard({ children }: { children: React.ReactNode }) {
const { ready, authenticated } = useAuthReady()
const token = useAuthStore((s) => s.accessToken)
const { data: me, isFetching: meLoading } = useCurrentUser()
if (!ready) {
return (
<p className="text-sm text-muted-foreground">Chargement de la session</p>
)
}
if (!authenticated) {
return (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/40 dark:bg-amber-950/30 dark:text-amber-200">
<p>Connectez-vous avec un compte administrateur pour accéder à cette interface.</p>
<Button asChild variant="outline" size="sm" className="mt-3">
<Link href="/login">Se connecter</Link>
</Button>
</div>
)
}
if (meLoading && !me) {
return (
<p className="text-sm text-muted-foreground">Vérification des droits administrateur</p>
)
}
const scopes = adminScopesFromToken(token)
const isAdmin =
isPlatformAdminFromToken(token) || scopes.read || me?.platform_admin === true
if (!isAdmin) {
return (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
<p>Accès refusé. Votre compte ne dispose pas des droits d&apos;administration.</p>
<Button asChild variant="outline" size="sm" className="mt-3">
<Link href="/mail">Retour à Ultimail</Link>
</Button>
</div>
)
}
return <>{children}</>
}

View File

@ -0,0 +1,8 @@
export function AdminPendingApiBanner() {
return (
<p className="mb-4 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-900 dark:border-blue-900/40 dark:bg-blue-950/30 dark:text-blue-200">
Chargement des réglages organisationnels depuis le serveur Les modifications
seront disponibles une fois la synchronisation terminée.
</p>
)
}

View File

@ -0,0 +1,111 @@
"use client"
import { useState } from "react"
import { ChevronDown, ChevronRight, Container, Lock } from "lucide-react"
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import { envGroupLabel, groupEnvVars } from "@/lib/admin/deploy-runtime"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
export function AdminRuntimePanel() {
const meta = useOrgSettingsStore((s) => s.meta)
const synced = useOrgSettingsStore((s) => s.apiSynced)
const [envOpen, setEnvOpen] = useState(false)
if (!synced || !meta) return null
const eff = meta.effective
const envGroups = groupEnvVars(meta.envVars ?? [])
const setCount = (meta.envVars ?? []).filter((v) => v.set).length
return (
<div className="mb-4 space-y-3 rounded-lg border border-border bg-muted/30 p-4 text-xs">
<div className="flex items-start gap-2">
<Container className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden />
<div className="min-w-0 flex-1 space-y-1">
<p className="font-medium text-foreground">Configuration runtime (Docker Compose)</p>
<p className="text-muted-foreground">
Les services ci-dessous sont pilotés par les variables d&apos;environnement du
déploiement. Les interrupteurs correspondants dans l&apos;administration sont en
lecture seule.
</p>
<p className="text-muted-foreground">
Recherche <span className="font-medium text-foreground">{eff.search.suite_engine}</span>
{" · "}
Nextcloud {eff.nextcloud.enabled ? "actif" : "inactif"}
{" · "}
OnlyOffice {eff.onlyoffice.enabled ? "actif" : "inactif"}
{eff.immich ? (
<>
{" · "}
Immich {eff.immich.enabled ? "actif" : "inactif"}
</>
) : null}
{eff.jitsi ? (
<>
{" · "}
Jitsi {eff.jitsi.enabled ? "actif" : "inactif"}
</>
) : null}
</p>
</div>
<Badge variant="outline" className="shrink-0 gap-1">
<Lock className="size-3" aria-hidden />
Compose
</Badge>
</div>
<button
type="button"
className="flex w-full items-center gap-1.5 text-left font-medium text-foreground hover:underline"
onClick={() => setEnvOpen((v) => !v)}
>
{envOpen ? (
<ChevronDown className="size-4" aria-hidden />
) : (
<ChevronRight className="size-4" aria-hidden />
)}
Variables d&apos;environnement ({setCount} définies)
</button>
{envOpen ? (
<div className="space-y-4 border-t border-border/60 pt-3">
{Object.entries(envGroups).map(([group, vars]) => (
<div key={group}>
<p className="mb-2 font-medium text-foreground">{envGroupLabel(group)}</p>
<div className="overflow-x-auto rounded-md border bg-background/80">
<table className="w-full text-left">
<thead>
<tr className="border-b text-muted-foreground">
<th className="px-2 py-1.5 font-medium">Variable</th>
<th className="px-2 py-1.5 font-medium">Définie</th>
<th className="px-2 py-1.5 font-medium">Valeur</th>
</tr>
</thead>
<tbody>
{vars.map((v) => (
<tr key={v.name} className="border-b border-border/40 last:border-0">
<td className="px-2 py-1.5 font-mono text-[11px]">{v.name}</td>
<td className="px-2 py-1.5">
<Badge
variant={v.set ? "default" : "secondary"}
className={cn("text-[10px]", !v.set && "opacity-70")}
>
{v.set ? "oui" : "non"}
</Badge>
</td>
<td className="max-w-[200px] truncate px-2 py-1.5 font-mono text-[11px] text-muted-foreground">
{v.secret ? (v.set ? "••••••••" : "—") : (v.value ?? "—")}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
))}
</div>
) : null}
</div>
)
}

View File

@ -0,0 +1,31 @@
"use client"
import { AdminLogo } from "@/components/admin/admin-logo"
import { HeaderAccountActions } from "@/components/suite/header-account-actions"
const SETTINGS_HREF = "/admin/settings"
export function AdminSettingsHeader() {
return (
<header
data-admin-settings-chrome-header
className="flex h-16 w-full shrink-0 items-center gap-0 bg-app-canvas pr-4 sm:gap-2"
>
<div className="hidden h-full w-64 shrink-0 items-center gap-2 pl-4 md:flex lg:w-72">
<AdminLogo className="min-h-8 shrink-0" />
<span className="text-sm font-medium text-muted-foreground">Administration</span>
</div>
<div className="flex shrink-0 items-center pl-2 md:hidden">
<AdminLogo variant="mark" className="h-8 w-8" />
</div>
<div className="flex min-w-0 flex-1 items-center px-1 sm:pl-1 sm:pr-1" />
<HeaderAccountActions
className="ml-auto shrink-0 pl-2 sm:pl-4"
settingsHref={SETTINGS_HREF}
/>
</header>
)
}

View File

@ -0,0 +1,124 @@
"use client"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { cn } from "@/lib/utils"
import {
ADMIN_SETTINGS_NAV,
isAdminSettingsNavActive,
isAdminSettingsWideLayoutPath,
} from "@/lib/admin-settings/settings-nav"
import {
mailNavRowClass,
MAIL_SETTINGS_MAIN_CARD_CLASS,
MAIL_SETTINGS_MAIN_INSET_CLASS,
} from "@/lib/mail-chrome-classes"
import { AdminSettingsHeader } from "@/components/admin/settings/admin-settings-header"
import { OrgSettingsSync } from "@/components/admin/settings/org-settings-sync"
export function AdminSettingsLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
return (
<div
data-admin-settings-app
className="ultimail-app flex h-dvh max-h-dvh flex-col overflow-hidden bg-app-canvas"
>
<AdminSettingsHeader />
<OrgSettingsSync />
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
<aside
data-admin-settings-sidebar
className="hidden w-64 shrink-0 overflow-y-auto bg-app-canvas p-3 md:block lg:w-72"
>
<nav className="space-y-1" aria-label="Sections administration">
{ADMIN_SETTINGS_NAV.map((item) => {
const active = isAdminSettingsNavActive(pathname, item)
const Icon = item.icon
return (
<Link
key={item.id}
href={item.href}
aria-current={active ? "page" : undefined}
className={cn(
"flex w-full items-start gap-3 rounded-lg px-3 py-2.5 transition-colors",
active
? "bg-mail-nav-selected"
: "hover:bg-mail-nav-hover"
)}
>
<Icon
className={cn(
"mt-0.5 size-4 shrink-0 opacity-70",
active ? "text-mail-nav-selected" : "text-muted-foreground"
)}
/>
<span className="min-w-0 flex-1">
<span
className={cn(
"block text-sm font-medium",
active ? "text-mail-nav-selected" : "text-muted-foreground"
)}
>
{item.label}
</span>
<span className="block text-xs font-normal text-muted-foreground">
{item.description}
</span>
</span>
</Link>
)
})}
</nav>
</aside>
<div className={MAIL_SETTINGS_MAIN_INSET_CLASS}>
<div data-admin-settings-main className={MAIL_SETTINGS_MAIN_CARD_CLASS}>
<nav
className="shrink-0 border-b border-border px-2 py-2 md:hidden"
aria-label="Sections administration"
>
<div className="flex gap-1 overflow-x-auto">
{ADMIN_SETTINGS_NAV.map((item) => {
const active = isAdminSettingsNavActive(pathname, item)
const Icon = item.icon
return (
<Link
key={item.id}
href={item.href}
aria-label={item.label}
aria-current={active ? "page" : undefined}
className={cn(
"flex shrink-0 items-center rounded-lg",
active
? cn("gap-2 px-3 py-2", mailNavRowClass({ isSelected: true }))
: cn("size-9 justify-center", mailNavRowClass({ isSelected: false }))
)}
>
<Icon className="size-4 shrink-0 opacity-70" />
{active ? (
<span className="text-sm font-medium">{item.label}</span>
) : null}
</Link>
)
})}
</div>
</nav>
<main className="min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-8">
<div
className={cn(
"mx-auto w-full max-w-3xl",
isAdminSettingsWideLayoutPath(pathname) && "lg:max-w-6xl"
)}
>
{children}
</div>
</main>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,62 @@
"use client"
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 { OnlyofficeSection } from "@/components/admin/settings/sections/onlyoffice-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,
mailing: MailingSection,
onlyoffice: OnlyofficeSection,
audit: AuditSection,
}
export function AdminSettingsSectionView({
sectionId,
}: {
sectionId: AdminSettingsSectionId
}) {
const Section = SECTIONS[sectionId]
return (
<AdminAccessGuard>
<Section />
</AdminAccessGuard>
)
}
export function AdminSettingsSectionFromSegments({
segments,
}: {
segments?: string[]
}) {
const sectionId = resolveAdminSettingsSection(segments)
return <AdminSettingsSectionView sectionId={sectionId} />
}

View File

@ -0,0 +1,28 @@
"use client"
import { Lock } from "lucide-react"
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import { isDeployFieldLocked } from "@/lib/admin/deploy-runtime"
export function DeployLockedHint({
section,
field,
}: {
section: string
field: string
}) {
const deployLocked = useOrgSettingsStore((s) => s.meta?.deployLocked)
if (!isDeployFieldLocked(deployLocked, section, field)) return null
return (
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Lock className="size-3 shrink-0" aria-hidden />
Géré par Docker Compose modifier les variables d&apos;environnement du déploiement.
</p>
)
}
export function useDeployFieldLocked(section: string, field: string): boolean {
const deployLocked = useOrgSettingsStore((s) => s.meta?.deployLocked)
return isDeployFieldLocked(deployLocked, section, field)
}

View File

@ -0,0 +1,76 @@
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header"
import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner"
import { AdminPendingApiBanner } from "@/components/admin/settings/admin-pending-api-banner"
import { AdminRuntimePanel } from "@/components/admin/settings/admin-runtime-panel"
import type { OrgPolicySectionKey } from "@/lib/api/admin-org-types"
import { useOrgSettings } from "@/lib/api/hooks/use-org-settings"
import { useSaveOrgPolicy } from "@/lib/api/hooks/use-save-org-policy"
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
export function OrgSettingsSection({
title,
description,
children,
policySection,
showEffectiveBanner = true,
beforeSave,
}: {
title: string
description?: string
children: React.ReactNode
policySection?: OrgPolicySectionKey | OrgPolicySectionKey[]
showEffectiveBanner?: boolean
beforeSave?: () => void | Promise<void>
}) {
const [saved, setSaved] = useState(false)
const [error, setError] = useState<string | null>(null)
const { isFetching, isError, refetch } = useOrgSettings()
const savePolicy = useSaveOrgPolicy()
const apiSynced = useOrgSettingsStore((s) => s.apiSynced)
const hasSave = Boolean(policySection)
async function handleSave() {
if (!policySection) return
setError(null)
try {
await beforeSave?.()
const sections = Array.isArray(policySection) ? policySection : [policySection]
await savePolicy(sections)
setSaved(true)
setTimeout(() => setSaved(false), 2000)
} catch (err) {
setError(err instanceof Error ? err.message : "Échec de l'enregistrement")
}
}
return (
<>
<SettingsSectionHeader title={title} description={description} />
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
{!apiSynced ? <AdminPendingApiBanner /> : null}
{showEffectiveBanner ? <AdminRuntimePanel /> : null}
<div className="space-y-6">{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>
) : null}
</>
)
}

View File

@ -0,0 +1,27 @@
"use client"
import { useEffect, useRef } from "react"
import { useOrgSettings } from "@/lib/api/hooks/use-org-settings"
import {
apiOrgPolicyToStore,
apiOrgSettingsMeta,
} from "@/lib/admin-settings/map-api-org-settings"
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
export function OrgSettingsSync() {
const { data } = useOrgSettings()
const hydratingRef = useRef(false)
useEffect(() => {
if (!data) return
hydratingRef.current = true
const mapped = apiOrgPolicyToStore(data.policy)
const meta = apiOrgSettingsMeta(data)
useOrgSettingsStore.getState().hydrateFromApi(mapped, meta)
queueMicrotask(() => {
hydratingRef.current = false
})
}, [data])
return null
}

View File

@ -0,0 +1,117 @@
"use client"
import { useState } from "react"
import { Download } from "lucide-react"
import { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header"
import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner"
import { useAdminAuditLogs } from "@/lib/api/hooks/use-admin-queries"
import { apiClient } from "@/lib/api/client"
import { Button } from "@/components/ui/button"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
export function AuditSection() {
const [page, setPage] = useState(1)
const { data, isFetching, isError, refetch } = useAdminAuditLogs({ page, page_size: 50 })
const logs = data?.logs ?? []
const total = data?.pagination.total ?? 0
const pageSize = data?.pagination.page_size ?? 50
const totalPages = Math.max(1, Math.ceil(total / pageSize))
async function exportLogs(format: "csv" | "ndjson") {
const blob = await apiClient.getBlob(`/admin/audit/export?format=${format}&limit=5000`)
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `audit-export.${format === "csv" ? "csv" : "ndjson"}`
a.click()
URL.revokeObjectURL(url)
}
return (
<>
<SettingsSectionHeader
title="Journal d'audit"
description="Historique des actions administratives sur la plateforme."
/>
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
<div className="mb-4 flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => void exportLogs("csv")}>
<Download className="mr-2 size-4" />
Export CSV
</Button>
<Button variant="outline" size="sm" onClick={() => void exportLogs("ndjson")}>
<Download className="mr-2 size-4" />
Export NDJSON
</Button>
</div>
<div className="overflow-x-auto rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead>Acteur</TableHead>
<TableHead>Action</TableHead>
<TableHead className="hidden lg:table-cell">Détails</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{logs.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground">
Aucun événement.
</TableCell>
</TableRow>
) : (
logs.map((log) => (
<TableRow key={log.id}>
<TableCell className="whitespace-nowrap text-xs">
{new Date(log.created_at).toLocaleString("fr-FR")}
</TableCell>
<TableCell className="max-w-[140px] truncate font-mono text-xs">
{log.actor}
</TableCell>
<TableCell className="text-sm">{log.action}</TableCell>
<TableCell className="hidden max-w-md truncate font-mono text-xs lg:table-cell">
{typeof log.details === "string"
? log.details
: JSON.stringify(log.details)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{totalPages > 1 ? (
<div className="mt-4 flex justify-end 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>
) : null}
</>
)
}

View File

@ -0,0 +1,111 @@
"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 AuthenticationSection() {
const authentik = useOrgSettingsStore((s) => s.authentik)
const setAuthentik = useOrgSettingsStore((s) => s.setAuthentik)
const effective = useOrgSettingsStore((s) => s.meta?.effective.authentik)
const enabledLocked = useDeployFieldLocked("authentik", "enabled")
const apiLocked = useDeployFieldLocked("authentik", "api_url")
const clientLocked = useDeployFieldLocked("authentik", "client_id")
const enabled = enabledLocked ? (effective?.enabled ?? authentik.enabled) : authentik.enabled
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 (
<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&apos;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>
)
}

View File

@ -0,0 +1,117 @@
"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 { Textarea } from "@/components/ui/textarea"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
export function FilePoliciesSection() {
const filePolicies = useOrgSettingsStore((s) => s.filePolicies)
const setFilePolicies = useOrgSettingsStore((s) => s.setFilePolicies)
return (
<OrgSettingsSection
title="Politiques fichiers"
description="Règles d'upload, partage externe et rétention pour UltiDrive."
policySection="file_policies"
>
<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>
</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&apos;upload</p>
</div>
<Switch
checked={filePolicies.virus_scan_enabled}
onCheckedChange={(virus_scan_enabled) => setFilePolicies({ virus_scan_enabled })}
/>
</label>
</div>
</OrgSettingsSection>
)
}

View File

@ -0,0 +1,177 @@
"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 { 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 [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 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&apos;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.id}
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
<div className="grid gap-4 lg:grid-cols-2">
{draft.providers.map((provider, index) => (
<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 || `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 })}
/>
</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 })}
/>
</div>
<div>
<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 })}
/>
</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 })}
/>
</div>
</div>
))}
</div>
</OrgSettingsSection>
)
}

View File

@ -0,0 +1,126 @@
"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&apos;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>
)
}

View File

@ -0,0 +1,127 @@
"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>
)
}

View File

@ -0,0 +1,82 @@
"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>
)
}

View File

@ -0,0 +1,164 @@
"use client"
import { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header"
import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner"
import { AdminRuntimePanel } from "@/components/admin/settings/admin-runtime-panel"
import { useAdminStats } from "@/lib/api/hooks/use-admin-queries"
import { formatBytes } from "@/lib/admin/format-bytes"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
export function OverviewSection() {
const { data, isFetching, isError, refetch } = useAdminStats()
const storage = data?.storage
return (
<>
<SettingsSectionHeader
title="Vue d'ensemble"
description="Activité de la plateforme, stockage et configuration runtime."
/>
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
<AdminRuntimePanel />
{data ? (
<div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard label="Utilisateurs" value={data.users.total} hint={`${data.users.active} actifs`} />
<StatCard label="Invitations" value={data.users.invited} hint={`${data.users.disabled} désactivés`} />
<StatCard label="Comptes mail" value={data.services.mail_accounts_total} />
<StatCard label="Messages" value={data.services.messages_total} />
</div>
{storage ? (
<div className="grid gap-4 lg:grid-cols-3">
<StorageCard
label="Mail (plateforme)"
used={storage.mail.used_bytes}
allocated={storage.mail.allocated_bytes}
/>
<StorageCard
label="Drive (plateforme)"
used={storage.drive.used_bytes}
allocated={storage.drive.allocated_bytes}
tracked={storage.drive.tracked}
/>
<StorageCard
label="Photos (plateforme)"
used={storage.photos.used_bytes}
allocated={storage.photos.allocated_bytes}
tracked={storage.photos.tracked}
/>
</div>
) : null}
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Audit (24 h)</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{data.services.audit_events_24h}</p>
<p className="mt-1 text-xs text-muted-foreground">événements enregistrés</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Quotas mail</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{data.quotas.users_near_mail_quota_90pct}</p>
<p className="mt-1 text-xs text-muted-foreground">
utilisateurs à plus de 90 % du quota
</p>
</CardContent>
</Card>
</div>
{data.audit.top_actors_7d.length > 0 ? (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Top acteurs (7 j)</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{data.audit.top_actors_7d.map((item) => (
<div
key={item.actor}
className="flex items-center justify-between text-sm"
>
<span className="truncate font-mono text-xs">{item.actor}</span>
<span className="text-muted-foreground">{item.count}</span>
</div>
))}
</CardContent>
</Card>
) : null}
</div>
) : isFetching ? (
<p className="text-sm text-muted-foreground">Chargement des statistiques</p>
) : null}
</>
)
}
function StatCard({
label,
value,
hint,
}: {
label: string
value: number
hint?: string
}) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{value.toLocaleString("fr-FR")}</p>
{hint ? <p className="mt-1 text-xs text-muted-foreground">{hint}</p> : null}
</CardContent>
</Card>
)
}
function StorageCard({
label,
used,
allocated,
tracked = true,
}: {
label: string
used: number
allocated: number
tracked?: boolean
}) {
const pct = allocated > 0 ? Math.min(100, Math.round((used / allocated) * 100)) : 0
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">{label}</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<p className="text-lg font-semibold">
{formatBytes(used)}
<span className="text-sm font-normal text-muted-foreground">
{" "}
/ {formatBytes(allocated)}
</span>
</p>
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${pct}%` }}
/>
</div>
<p className="text-xs text-muted-foreground">
{tracked ? `${pct} % des quotas alloués` : "Mesure non disponible"}
</p>
</CardContent>
</Card>
)
}

View File

@ -0,0 +1,48 @@
"use client"
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
import { DeployLockedHint } 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 { Badge } from "@/components/ui/badge"
export function PluginsSection() {
const plugins = useOrgSettingsStore((s) => s.plugins)
const togglePlugin = useOrgSettingsStore((s) => s.togglePlugin)
const deployLocked = useOrgSettingsStore((s) => s.meta?.deployLocked)
return (
<OrgSettingsSection
title="Plugins"
description="Modules fonctionnels activables pour toute l'organisation."
policySection="plugins"
>
<div className="space-y-3">
{plugins.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>
{locked ? <DeployLockedHint section="plugins" field={plugin.id} /> : null}
</div>
<Switch
checked={plugin.enabled}
disabled={locked}
onCheckedChange={(enabled) => togglePlugin(plugin.id, enabled)}
/>
</CardContent>
</Card>
)
})}
</div>
</OrgSettingsSection>
)
}

View File

@ -0,0 +1,242 @@
"use client"
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 { 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"
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 {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
const ACCESS_MODE_LABELS: Record<string, string> = {
public: "Lien public",
email: "Invitation e-mail",
internal: "Interne",
}
export function PublicSharesSection() {
const [q, setQ] = useState("")
const [page, setPage] = useState(1)
const queryParams = useMemo(
() => ({
page,
page_size: 25,
q: q.trim() || undefined,
}),
[page, q]
)
const { data, isFetching, isError, refetch } = useAdminPublicShares(queryParams)
const revoke = useRevokeAdminPublicShare()
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))
return (
<>
<SettingsSectionHeader
title="Partages externes"
description="Audit des liens publics et invitations Drive — création, dernier accès et révocation."
/>
<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>
<div className="overflow-x-auto rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Ressource</TableHead>
<TableHead>Propriétaire</TableHead>
<TableHead>Type</TableHead>
<TableHead className="hidden md:table-cell">Créé le</TableHead>
<TableHead className="hidden lg:table-cell">Dernier accès</TableHead>
<TableHead className="w-24 text-right">Accès</TableHead>
<TableHead className="w-28" />
</TableRow>
</TableHeader>
<TableBody>
{shares.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground">
{isFetching ? "Chargement…" : "Aucun partage externe actif."}
</TableCell>
</TableRow>
) : (
shares.map((share) => (
<ShareRow
key={`${share.owner_nc_user_id}-${share.id}`}
share={share}
revoking={revoke.isPending}
onRevoke={() =>
void revoke.mutateAsync({
shareId: share.id,
ownerNcUserId: share.owner_nc_user_id,
})
}
/>
))
)}
</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}
</>
)
}
function ShareRow({
share,
revoking,
onRevoke,
}: {
share: AdminPublicShare
revoking: boolean
onRevoke: () => void
}) {
const modeLabel =
ACCESS_MODE_LABELS[share.access_mode] ??
(share.share_type === 4 ? "Invitation e-mail" : "Lien public")
function handleRevoke() {
const label = share.path || share.token
if (
confirm(
`Révoquer le partage « ${label} » créé par ${share.owner_email} ?\nLe lien ne sera plus accessible.`
)
) {
onRevoke()
}
}
return (
<TableRow>
<TableCell>
<div className="flex items-start gap-2">
<Link2 className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden />
<div className="min-w-0">
<div className="truncate font-medium" title={share.path}>
{basename(share.path) || "—"}
</div>
<div className="truncate text-xs text-muted-foreground" title={share.path}>
{share.path}
</div>
{share.share_with ? (
<div className="mt-0.5 text-xs text-muted-foreground">
{share.share_with_display_name || share.share_with}
</div>
) : null}
</div>
</div>
</TableCell>
<TableCell>
<div className="text-sm">{share.owner_display_name || "—"}</div>
<div className="text-xs text-muted-foreground">{share.owner_email}</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap items-center gap-1">
<Badge variant="secondary">{modeLabel}</Badge>
{share.has_password ? <Badge variant="outline">Mot de passe</Badge> : null}
</div>
</TableCell>
<TableCell className="hidden text-xs text-muted-foreground md:table-cell">
{formatDateTime(share.created_at)}
</TableCell>
<TableCell className="hidden text-xs text-muted-foreground lg:table-cell">
{share.last_access_at ? formatDateTime(share.last_access_at) : "Jamais"}
</TableCell>
<TableCell className="text-right text-sm tabular-nums">
{share.access_count > 0 ? share.access_count.toLocaleString("fr-FR") : "—"}
</TableCell>
<TableCell>
<div className="flex justify-end gap-1">
{share.url ? (
<Button variant="ghost" size="icon" className="size-8" asChild>
<a href={share.url} target="_blank" rel="noopener noreferrer" title="Ouvrir le lien">
<ExternalLink className="size-4" />
</a>
</Button>
) : null}
<Button
variant="ghost"
size="icon"
className="size-8 text-destructive hover:text-destructive"
disabled={revoking}
onClick={handleRevoke}
title="Révoquer le partage"
>
<Trash2 className="size-4" />
</Button>
</div>
</TableCell>
</TableRow>
)
}
function formatDateTime(value?: string | null): string {
if (!value) return "—"
const d = new Date(value)
if (Number.isNaN(d.getTime())) return "—"
return d.toLocaleString("fr-FR", {
dateStyle: "short",
timeStyle: "short",
})
}
function basename(path: string): string {
const trimmed = path.replace(/\/+$/, "")
const idx = trimmed.lastIndexOf("/")
if (idx < 0) return trimmed
return trimmed.slice(idx + 1) || trimmed
}

View File

@ -0,0 +1,169 @@
"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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
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 engineLocked = useDeployFieldLocked("search", "suite_engine")
const meiliURLLocked = useDeployFieldLocked("search", "meilisearch_url")
const meiliKeyLocked = useDeployFieldLocked("search", "meilisearch_api_key")
const typesenseURLLocked = useDeployFieldLocked("search", "typesense_url")
const typesenseKeyLocked = useDeployFieldLocked("search", "typesense_api_key")
const suiteEngine = engineLocked
? ((effective?.suite_engine as typeof search.suite_engine) ?? search.suite_engine)
: search.suite_engine
const meiliURL = meiliURLLocked
? (effective?.meilisearch_url ?? search.meilisearch_url)
: search.meilisearch_url
const typesenseURL = typesenseURLLocked
? (effective?.typesense_url ?? search.typesense_url)
: search.typesense_url
return (
<OrgSettingsSection
title="Moteur de recherche"
description="Index de recherche suite (mail, drive) et recherche web pour l'IA contacts."
policySection="search"
>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Recherche suite</CardTitle>
<CardDescription>
Moteur d&apos;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>
<Label>Moteur</Label>
<Select
value={suiteEngine}
disabled={engineLocked}
onValueChange={(suite_engine) =>
setSearch({
suite_engine: suite_engine as typeof search.suite_engine,
})
}
>
<SelectTrigger className="mt-1 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="postgres">PostgreSQL (full-text)</SelectItem>
<SelectItem value="meilisearch">Meilisearch</SelectItem>
<SelectItem value="typesense">Typesense</SelectItem>
</SelectContent>
</Select>
</div>
{suiteEngine === "meilisearch" ? (
<div className="grid gap-3 sm:grid-cols-2">
<div>
<Label>URL Meilisearch</Label>
<Input
className="mt-1 h-9"
value={meiliURL}
disabled={meiliURLLocked}
onChange={(e) => setSearch({ meilisearch_url: e.target.value })}
/>
</div>
<div>
<Label>Clé API</Label>
<Input
className="mt-1 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>
</div>
) : null}
{suiteEngine === "typesense" ? (
<div className="grid gap-3 sm:grid-cols-2">
<div>
<Label>URL Typesense</Label>
<Input
className="mt-1 h-9"
value={typesenseURL}
disabled={typesenseURLLocked}
onChange={(e) => setSearch({ typesense_url: e.target.value })}
/>
</div>
<div>
<Label>Clé API</Label>
<Input
className="mt-1 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>
</div>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Recherche web (Brave)</CardTitle>
<CardDescription>Utilisée pour l&apos;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>
</OrgSettingsSection>
)
}

View File

@ -0,0 +1,108 @@
"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 { Checkbox } from "@/components/ui/checkbox"
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() {
const twoFactor = useOrgSettingsStore((s) => s.twoFactor)
const setTwoFactor = useOrgSettingsStore((s) => s.setTwoFactor)
function toggleMethod(id: (typeof METHODS)[number]["id"], checked: boolean) {
const methods = checked
? [...new Set([...twoFactor.allowed_methods, id])]
: twoFactor.allowed_methods.filter((m) => m !== id)
setTwoFactor({ allowed_methods: methods })
}
return (
<OrgSettingsSection
title="Sécurité et 2FA"
description="Politiques d'authentification à deux facteurs pour l'organisation."
policySection="two_factor"
>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Exigences</CardTitle>
</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">2FA obligatoire pour tous</p>
<p className="text-xs text-muted-foreground">
Chaque utilisateur doit configurer un second facteur.
</p>
</div>
<Switch
checked={twoFactor.required_for_all}
onCheckedChange={(required_for_all) => setTwoFactor({ required_for_all })}
/>
</label>
<label className="flex items-center justify-between gap-4 rounded-lg border p-3">
<div>
<p className="text-sm font-medium">2FA obligatoire pour les administrateurs</p>
</div>
<Switch
checked={twoFactor.required_for_admins}
onCheckedChange={(required_for_admins) => setTwoFactor({ required_for_admins })}
/>
</label>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Méthodes autorisées</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{METHODS.map((method) => (
<label key={method.id} className="flex items-center gap-2 text-sm">
<Checkbox
checked={twoFactor.allowed_methods.includes(method.id)}
onCheckedChange={(v) => toggleMethod(method.id, v === true)}
/>
{method.label}
</label>
))}
</CardContent>
</Card>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<Label>Période de grâce (jours)</Label>
<Input
className="mt-1 h-9"
type="number"
min={0}
value={twoFactor.grace_period_days}
onChange={(e) =>
setTwoFactor({ grace_period_days: Number(e.target.value) || 0 })
}
/>
</div>
<div>
<Label>Mémoriser l&apos;appareil (jours)</Label>
<Input
className="mt-1 h-9"
type="number"
min={0}
value={twoFactor.remember_device_days}
onChange={(e) =>
setTwoFactor({ remember_device_days: Number(e.target.value) || 0 })
}
/>
</div>
</div>
</OrgSettingsSection>
)
}

View File

@ -0,0 +1,89 @@
"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&apos;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>
)
}

View File

@ -0,0 +1,97 @@
"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>
)
}

View File

@ -0,0 +1,568 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { MoreHorizontal, UserPlus } 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 {
useDeleteAdminUser,
useDisableAdminUser,
useInviteAdminUser,
useReactivateAdminUser,
useSetAdminUserQuota,
useSetAdminUserRole,
useUpdateAdminUser,
} from "@/lib/api/hooks/use-admin-mutations"
import type { AdminUser, AdminUserRole } from "@/lib/api/admin-types"
import { bytesToGib, formatBytes, gibToBytes } from "@/lib/admin/format-bytes"
import {
resolveUserRole,
USER_ROLE_DESCRIPTIONS,
USER_ROLE_FILTER_ALL_ICON,
USER_ROLE_ICONS,
USER_ROLE_LABELS,
} from "@/lib/admin/user-role"
import { cn } from "@/lib/utils"
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 {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
const ROLE_OPTIONS: AdminUserRole[] = ["admin", "user", "guest", "suspended"]
export function UsersSection() {
const [q, setQ] = useState("")
const [role, setRole] = useState<string>("all")
const [page, setPage] = useState(1)
const [inviteOpen, setInviteOpen] = useState(false)
const [selectedId, setSelectedId] = useState<string | null>(null)
const queryParams = useMemo(
() => ({
page,
page_size: 25,
q: q.trim() || undefined,
role: role === "all" ? undefined : role,
}),
[page, q, role]
)
const { data, isFetching, isError, refetch } = useAdminUsers(queryParams)
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))
return (
<>
<SettingsSectionHeader
title="Utilisateurs"
description="Comptes, types d'utilisateur, invitations et quotas."
/>
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
<div className="mb-4 flex flex-wrap items-end gap-3">
<div className="min-w-[200px] 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="E-mail, nom ou ID externe"
/>
</div>
<div className="w-52">
<Label className="text-xs">Type d&apos;utilisateur</Label>
<Select
value={role}
onValueChange={(v) => {
setRole(v)
setPage(1)
}}
>
<SelectTrigger className="mt-1 h-9">
<SelectValue>
{role === "all" ? (
<UserRoleLabel role="all" />
) : (
<UserRoleLabel role={role as AdminUserRole} />
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
<UserRoleLabel role="all" />
</SelectItem>
{ROLE_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
<UserRoleLabel role={option} />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button className="h-9" onClick={() => setInviteOpen(true)}>
<UserPlus className="mr-2 size-4" />
Inviter
</Button>
</div>
<div className="overflow-x-auto rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Utilisateur</TableHead>
<TableHead>Type</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>
<TableHead className="w-12" />
</TableRow>
</TableHeader>
<TableBody>
{users.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground">
Aucun utilisateur trouvé.
</TableCell>
</TableRow>
) : (
users.map((user) => (
<UserRow
key={user.id}
user={user}
onOpen={() => setSelectedId(user.id)}
/>
))
)}
</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")} 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} />
<UserDetailSheet userId={selectedId} onClose={() => setSelectedId(null)} />
</>
)
}
function UserRow({ user, onOpen }: { user: AdminUser; onOpen: () => void }) {
const disableUser = useDisableAdminUser()
const reactivateUser = useReactivateAdminUser()
const deleteUser = useDeleteAdminUser()
return (
<TableRow className="cursor-pointer" onClick={onOpen}>
<TableCell>
<div className="font-medium">{user.name || "—"}</div>
<div className="text-xs text-muted-foreground">{user.email}</div>
</TableCell>
<TableCell>
<RoleBadge role={resolveUserRole(user)} />
</TableCell>
<TableCell className="hidden text-xs text-muted-foreground lg:table-cell">
{formatBytes(user.storage?.mail_used_bytes ?? 0)}
</TableCell>
<TableCell className="hidden text-xs text-muted-foreground lg:table-cell">
{formatBytes(user.storage?.drive_used_bytes ?? 0)}
</TableCell>
<TableCell className="hidden max-w-[200px] truncate font-mono text-xs md:table-cell">
{user.external_id}
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onOpen}>Détails et quotas</DropdownMenuItem>
{resolveUserRole(user) !== "suspended" ? (
<DropdownMenuItem
onClick={() => void disableUser.mutateAsync(user.id)}
>
Suspendre
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={() => void reactivateUser.mutateAsync(user.id)}
>
Réactiver
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => {
if (confirm(`Supprimer définitivement ${user.email} ?`)) {
void deleteUser.mutateAsync(user.id)
}
}}
>
Supprimer
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
)
}
function UserRoleLabel({
role,
className,
}: {
role: AdminUserRole | "all"
className?: string
}) {
const Icon =
role === "all" ? USER_ROLE_FILTER_ALL_ICON : USER_ROLE_ICONS[role]
const label = role === "all" ? "Tous les types" : USER_ROLE_LABELS[role]
return (
<span className={cn("flex items-center gap-2", className)}>
<Icon className="size-4 shrink-0 opacity-80" aria-hidden />
<span>{label}</span>
</span>
)
}
function RoleBadge({ role }: { role: AdminUserRole }) {
const variant =
role === "admin"
? "default"
: role === "user"
? "secondary"
: role === "guest"
? "outline"
: "destructive"
const Icon = USER_ROLE_ICONS[role]
return (
<Badge variant={variant} className="gap-1.5 pr-2.5">
<Icon className="size-3.5" aria-hidden />
{USER_ROLE_LABELS[role] ?? role}
</Badge>
)
}
function InviteUserDialog({
open,
onOpenChange,
}: {
open: boolean
onOpenChange: (open: boolean) => void
}) {
const invite = useInviteAdminUser()
const [email, setEmail] = useState("")
const [name, setName] = useState("")
async function submit() {
await invite.mutateAsync({ email, name: name || undefined })
setEmail("")
setName("")
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Inviter un utilisateur</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div>
<Label>E-mail</Label>
<Input
className="mt-1"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div>
<Label>Nom (optionnel)</Label>
<Input
className="mt-1"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Annuler
</Button>
<Button disabled={!email || invite.isPending} onClick={() => void submit()}>
Envoyer l&apos;invitation
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function UserDetailSheet({
userId,
onClose,
}: {
userId: string | null
onClose: () => void
}) {
const { data: user, isFetching } = useAdminUser(userId)
const updateUser = useUpdateAdminUser(userId ?? "")
const setRole = useSetAdminUserRole(userId ?? "")
const setQuota = useSetAdminUserQuota(userId ?? "")
const [name, setName] = useState("")
const [email, setEmail] = useState("")
const [selectedRole, setSelectedRole] = useState<AdminUserRole>("user")
const [mailGib, setMailGib] = useState("5")
const [driveGib, setDriveGib] = useState("5")
const [photosGib, setPhotosGib] = useState("5")
const open = Boolean(userId)
useEffect(() => {
if (!user) return
setName(user.name ?? "")
setEmail(user.email ?? "")
setSelectedRole(resolveUserRole(user))
if (user.quota) {
setMailGib(String(bytesToGib(user.quota.mail.max_storage_bytes).toFixed(1)))
setDriveGib(String(bytesToGib(user.quota.drive.max_storage_bytes).toFixed(1)))
setPhotosGib(String(bytesToGib(user.quota.photos.max_storage_bytes).toFixed(1)))
}
}, [user])
async function saveProfile() {
if (!userId) return
await updateUser.mutateAsync({ name, email })
}
async function saveRole() {
if (!userId || !user || selectedRole === resolveUserRole(user)) return
await setRole.mutateAsync({ role: selectedRole })
}
async function saveQuotas() {
if (!userId) return
await setQuota.mutateAsync({
mail_max_storage_bytes: gibToBytes(Number(mailGib)),
drive_max_storage_bytes: gibToBytes(Number(driveGib)),
photos_max_storage_bytes: gibToBytes(Number(photosGib)),
})
}
return (
<Sheet open={open} onOpenChange={(v) => !v && onClose()}>
<SheetContent className="flex flex-col gap-0 overflow-y-auto p-0 sm:max-w-lg">
<SheetHeader className="border-b px-6 py-5">
<SheetTitle>Détails utilisateur</SheetTitle>
</SheetHeader>
{isFetching || !user ? (
<p className="px-6 py-5 text-sm text-muted-foreground">Chargement</p>
) : (
<div className="space-y-8 px-6 py-6">
<div className="space-y-4">
<RoleBadge role={resolveUserRole(user)} />
<div className="space-y-2">
<Label>Type d&apos;utilisateur</Label>
<Select
value={selectedRole}
onValueChange={(v) => setSelectedRole(v as AdminUserRole)}
>
<SelectTrigger>
<SelectValue>
<UserRoleLabel role={selectedRole} />
</SelectValue>
</SelectTrigger>
<SelectContent>
{ROLE_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
<UserRoleLabel role={option} />
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{USER_ROLE_DESCRIPTIONS[selectedRole]}
</p>
<Button
size="sm"
variant="secondary"
onClick={() => void saveRole()}
disabled={setRole.isPending || selectedRole === resolveUserRole(user)}
>
Enregistrer le type
</Button>
</div>
<div className="space-y-2">
<Label>Nom</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-2">
<Label>E-mail</Label>
<Input value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
<Button size="sm" onClick={() => void saveProfile()} disabled={updateUser.isPending}>
Mettre à jour le profil
</Button>
</div>
{user.quota ? (
<div className="space-y-5 rounded-lg border bg-muted/20 p-5">
<h3 className="text-sm font-medium">Quotas stockage</h3>
<QuotaField
label="Mail"
used={coerceBytes(user.quota.mail.used_storage_bytes)}
max={coerceBytes(user.quota.mail.max_storage_bytes)}
gib={mailGib}
onGibChange={setMailGib}
count={user.quota.mail.count}
/>
<QuotaField
label="Drive"
used={coerceBytes(user.quota.drive.used_storage_bytes)}
max={coerceBytes(user.quota.drive.max_storage_bytes)}
gib={driveGib}
onGibChange={setDriveGib}
/>
<QuotaField
label="Photos"
used={coerceBytes(user.quota.photos.used_storage_bytes)}
max={coerceBytes(user.quota.photos.max_storage_bytes)}
gib={photosGib}
onGibChange={setPhotosGib}
/>
<Button size="sm" onClick={() => void saveQuotas()} disabled={setQuota.isPending}>
Enregistrer les quotas
</Button>
</div>
) : null}
<p className="font-mono text-xs text-muted-foreground">ID : {user.id}</p>
</div>
)}
</SheetContent>
</Sheet>
)
}
function coerceBytes(value: unknown): number {
const n = typeof value === "number" ? value : Number(value)
return Number.isFinite(n) && n >= 0 ? n : 0
}
function QuotaField({
label,
used,
max,
gib,
onGibChange,
count,
}: {
label: string
used: number
max: number
gib: string
onGibChange: (v: string) => void
count?: number
}) {
const maxBytes = max > 0 ? max : gibToBytes(Number(gib) || 0)
const pct = maxBytes > 0 ? Math.min(100, Math.round((used / maxBytes) * 100)) : 0
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-3 text-xs">
<span className="font-medium text-foreground">{label}</span>
<span className="text-right text-muted-foreground">
{formatBytes(used)} / {formatBytes(maxBytes)}
{count !== undefined ? ` · ${count} messages` : ""}
</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${pct}%` }}
/>
</div>
<div className="flex items-center gap-2">
<Label className="sr-only">Quota {label}</Label>
<Input
type="number"
min={0}
step={0.5}
className="h-9"
value={gib}
onChange={(e) => onGibChange(e.target.value)}
/>
<span className="shrink-0 text-sm text-muted-foreground">Go max</span>
</div>
</div>
)
}

View File

@ -0,0 +1,152 @@
import type { ApiOrgPolicy, ApiOrgSettingsResponse } from "@/lib/api/admin-org-types"
import type { OrgPolicySectionKey } from "@/lib/api/admin-org-types"
import type { IntegrationEntry, OrgSettingsState } from "@/lib/admin-settings/org-settings-types"
const INTEGRATION_HREFS: Record<string, string> = {
authentik: "/admin/settings/authentication",
nextcloud: "/admin/settings/nextcloud",
onlyoffice: "/admin/settings/onlyoffice",
smtp: "/admin/settings/mailing",
}
function mergeIntegrations(
fromApi: IntegrationEntry[] | undefined
): IntegrationEntry[] {
if (!fromApi?.length) return []
return fromApi.map((item) => ({
...item,
href: INTEGRATION_HREFS[item.id] ?? item.href,
}))
}
export function apiOrgPolicyToStore(policy: ApiOrgPolicy): Partial<OrgSettingsState> {
return {
authentik: {
enabled: policy.authentik.enabled,
api_url: policy.authentik.api_url,
slug: policy.authentik.slug,
client_id: policy.authentik.client_id,
enforce_sso: policy.authentik.enforce_sso,
allow_password_fallback: policy.authentik.allow_password_fallback,
default_groups: policy.authentik.default_groups,
},
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,
grace_period_days: policy.two_factor.grace_period_days,
remember_device_days: policy.two_factor.remember_device_days,
},
storageQuotas: { ...policy.storage_quotas },
usageQuotas: { ...policy.usage_quotas },
filePolicies: { ...policy.file_policies },
llm: {
...policy.llm,
providers: policy.llm.providers ?? [],
},
search: {
...policy.search,
web_search: policy.search.web_search ?? {
default_provider_id: "brave-default",
providers: [],
},
},
administrators: policy.administrators ?? [],
nextcloud: { ...policy.nextcloud },
mailing: { ...policy.mailing },
onlyoffice: { ...policy.onlyoffice },
plugins: policy.plugins ?? [],
integrations: mergeIntegrations(policy.integrations as IntegrationEntry[]),
}
}
export function storeToApiOrgPolicy(state: OrgSettingsState): ApiOrgPolicy {
return {
authentik: {
enabled: state.authentik.enabled,
api_url: state.authentik.api_url,
slug: state.authentik.slug,
client_id: state.authentik.client_id,
enforce_sso: state.authentik.enforce_sso,
allow_password_fallback: state.authentik.allow_password_fallback,
default_groups: state.authentik.default_groups,
},
two_factor: {
required_for_all: state.twoFactor.required_for_all,
required_for_admins: state.twoFactor.required_for_admins,
allowed_methods: state.twoFactor.allowed_methods,
grace_period_days: state.twoFactor.grace_period_days,
remember_device_days: state.twoFactor.remember_device_days,
},
storage_quotas: { ...state.storageQuotas },
usage_quotas: { ...state.usageQuotas },
file_policies: { ...state.filePolicies },
llm: {
default_provider_id: state.llm.default_provider_id,
providers: state.llm.providers,
contact_discovery_model: state.llm.contact_discovery_model,
contact_discovery_provider_id: state.llm.contact_discovery_provider_id,
enforce_org_providers: state.llm.enforce_org_providers,
allow_user_override: state.llm.allow_user_override,
},
search: {
suite_engine: state.search.suite_engine,
meilisearch_url: state.search.meilisearch_url,
meilisearch_api_key: state.search.meilisearch_api_key,
typesense_url: state.search.typesense_url,
typesense_api_key: state.search.typesense_api_key,
web_search: state.search.web_search,
enforce_org_search: state.search.enforce_org_search,
},
administrators: state.administrators,
nextcloud: { ...state.nextcloud },
mailing: { ...state.mailing },
onlyoffice: { ...state.onlyoffice },
plugins: state.plugins.map(({ id, name, description, enabled, version }) => ({
id,
name,
description,
enabled,
version,
})),
integrations: state.integrations.map(({ id, name, description, enabled, configured }) => ({
id,
name,
description,
enabled,
configured,
})),
}
}
export function pickApiOrgPolicySections(
state: OrgSettingsState,
sections: OrgPolicySectionKey[]
): Partial<ApiOrgPolicy> {
const full = storeToApiOrgPolicy(state)
const patch: Partial<ApiOrgPolicy> = {}
for (const key of sections) {
patch[key] = full[key] as never
}
return patch
}
export type OrgSettingsMeta = {
effective: ApiOrgSettingsResponse["effective"]
secrets: ApiOrgSettingsResponse["secrets"]
envVars: ApiOrgSettingsResponse["env_vars"]
deployLocked: ApiOrgSettingsResponse["deploy_locked"]
updatedAt: string
updatedBy: string
}
export function apiOrgSettingsMeta(data: ApiOrgSettingsResponse): OrgSettingsMeta {
return {
effective: data.effective,
secrets: data.secrets,
envVars: data.env_vars ?? [],
deployLocked: data.deploy_locked ?? {},
updatedAt: data.updated_at,
updatedBy: data.updated_by,
}
}

View File

@ -0,0 +1,297 @@
"use client"
import { create } from "zustand"
import type { OrgSettingsMeta } from "@/lib/admin-settings/map-api-org-settings"
import type {
Administrator,
AuthentikSettings,
FilePolicySettings,
IntegrationEntry,
MailingSettings,
NextcloudSettings,
OnlyOfficeSettings,
OrgLLMSettings,
OrgSearchSettings,
OrgStorageQuotas,
PluginEntry,
TwoFactorPolicy,
UsageQuotaDefaults,
} from "@/lib/admin-settings/org-settings-types"
const DEFAULT_AUTHENTIK: AuthentikSettings = {
enabled: true,
api_url: "",
slug: "ulti-suite",
client_id: "",
enforce_sso: true,
allow_password_fallback: false,
default_groups: "ulti-users",
}
const DEFAULT_TWO_FACTOR: TwoFactorPolicy = {
required_for_all: false,
required_for_admins: true,
allowed_methods: ["totp", "webauthn"],
grace_period_days: 7,
remember_device_days: 30,
}
const DEFAULT_STORAGE_QUOTAS: OrgStorageQuotas = {
default_mail_gib: 5,
default_drive_gib: 5,
default_photos_gib: 5,
warn_threshold_pct: 90,
}
const DEFAULT_USAGE_QUOTAS: UsageQuotaDefaults = {
llm_requests_per_day: 100,
llm_tokens_per_month: 500_000,
search_requests_per_day: 50,
max_api_tokens_per_user: 10,
max_webhooks_per_user: 20,
}
const DEFAULT_FILE_POLICIES: FilePolicySettings = {
max_upload_mib: 512,
allowed_extensions: "",
block_executable: true,
external_sharing: "authenticated",
default_link_expiry_days: 30,
virus_scan_enabled: false,
retention_trash_days: 30,
}
const DEFAULT_LLM: OrgLLMSettings = {
default_provider_id: "",
providers: [],
enforce_org_providers: false,
allow_user_override: true,
}
const DEFAULT_SEARCH: OrgSearchSettings = {
suite_engine: "postgres",
meilisearch_url: "",
meilisearch_api_key: "",
typesense_url: "",
typesense_api_key: "",
web_search: { default_provider_id: "brave-default", providers: [] },
enforce_org_search: false,
}
const DEFAULT_NEXTCLOUD: NextcloudSettings = {
enabled: false,
base_url: "",
admin_user: "",
admin_password: "",
drive_enabled: true,
calendar_enabled: true,
contacts_enabled: true,
talk_enabled: false,
}
const DEFAULT_MAILING: MailingSettings = {
enabled: false,
smtp_host: "",
smtp_port: 587,
smtp_user: "",
smtp_password: "",
from_email: "noreply@example.com",
from_name: "Ulti Suite",
tls_mode: "starttls",
}
const DEFAULT_ONLYOFFICE: OnlyOfficeSettings = {
enabled: false,
document_server_url: "",
jwt_secret: "",
jwt_header: "Authorization",
}
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",
},
]
const DEFAULT_INTEGRATIONS: IntegrationEntry[] = [
{
id: "authentik",
name: "Authentik",
description: "SSO, groupes et provisionnement des comptes.",
enabled: true,
configured: false,
href: "/admin/settings/authentication",
},
{
id: "nextcloud",
name: "Nextcloud",
description: "Drive, agenda, contacts et Talk.",
enabled: false,
configured: false,
href: "/admin/settings/nextcloud",
},
{
id: "onlyoffice",
name: "OnlyOffice",
description: "Édition de documents dans le navigateur.",
enabled: false,
configured: false,
href: "/admin/settings/onlyoffice",
},
{
id: "smtp",
name: "Mailing unifié",
description: "SMTP pour notifications suite (partages, mentions).",
enabled: false,
configured: false,
href: "/admin/settings/mailing",
},
]
type OrgSettingsActions = {
setAuthentik: (patch: Partial<AuthentikSettings>) => void
setTwoFactor: (patch: Partial<TwoFactorPolicy>) => void
setStorageQuotas: (patch: Partial<OrgStorageQuotas>) => void
setUsageQuotas: (patch: Partial<UsageQuotaDefaults>) => void
setFilePolicies: (patch: Partial<FilePolicySettings>) => void
setLlm: (patch: Partial<OrgLLMSettings>) => void
setSearch: (patch: Partial<OrgSearchSettings>) => void
setNextcloud: (patch: Partial<NextcloudSettings>) => void
setMailing: (patch: Partial<MailingSettings>) => void
setOnlyoffice: (patch: Partial<OnlyOfficeSettings>) => void
setAdministrators: (admins: Administrator[]) => void
addAdministrator: (admin: Administrator) => void
removeAdministrator: (id: string) => void
updateAdministrator: (id: string, patch: Partial<Administrator>) => void
setPlugins: (plugins: PluginEntry[]) => void
togglePlugin: (id: string, enabled: boolean) => void
setIntegrations: (integrations: IntegrationEntry[]) => void
toggleIntegration: (id: string, enabled: boolean) => void
hydrateFromApi: (patch: Partial<{
authentik: AuthentikSettings
twoFactor: TwoFactorPolicy
storageQuotas: OrgStorageQuotas
usageQuotas: UsageQuotaDefaults
filePolicies: FilePolicySettings
llm: OrgLLMSettings
search: OrgSearchSettings
administrators: Administrator[]
nextcloud: NextcloudSettings
mailing: MailingSettings
onlyoffice: OnlyOfficeSettings
plugins: PluginEntry[]
integrations: IntegrationEntry[]
}>, meta?: OrgSettingsMeta) => void
}
export const useOrgSettingsStore = create<
{
authentik: AuthentikSettings
twoFactor: TwoFactorPolicy
storageQuotas: OrgStorageQuotas
usageQuotas: UsageQuotaDefaults
filePolicies: FilePolicySettings
llm: OrgLLMSettings
search: OrgSearchSettings
administrators: Administrator[]
nextcloud: NextcloudSettings
mailing: MailingSettings
onlyoffice: OnlyOfficeSettings
plugins: PluginEntry[]
integrations: IntegrationEntry[]
meta: OrgSettingsMeta | null
apiSynced: boolean
} & OrgSettingsActions
>()((set) => ({
authentik: DEFAULT_AUTHENTIK,
twoFactor: DEFAULT_TWO_FACTOR,
storageQuotas: DEFAULT_STORAGE_QUOTAS,
usageQuotas: DEFAULT_USAGE_QUOTAS,
filePolicies: DEFAULT_FILE_POLICIES,
llm: DEFAULT_LLM,
search: DEFAULT_SEARCH,
administrators: [],
nextcloud: DEFAULT_NEXTCLOUD,
mailing: DEFAULT_MAILING,
onlyoffice: DEFAULT_ONLYOFFICE,
plugins: DEFAULT_PLUGINS,
integrations: DEFAULT_INTEGRATIONS,
meta: null,
apiSynced: false,
setAuthentik: (patch) =>
set((s) => ({ authentik: { ...s.authentik, ...patch } })),
setTwoFactor: (patch) =>
set((s) => ({ twoFactor: { ...s.twoFactor, ...patch } })),
setStorageQuotas: (patch) =>
set((s) => ({ storageQuotas: { ...s.storageQuotas, ...patch } })),
setUsageQuotas: (patch) =>
set((s) => ({ usageQuotas: { ...s.usageQuotas, ...patch } })),
setFilePolicies: (patch) =>
set((s) => ({ filePolicies: { ...s.filePolicies, ...patch } })),
setLlm: (patch) => set((s) => ({ llm: { ...s.llm, ...patch } })),
setSearch: (patch) => set((s) => ({ search: { ...s.search, ...patch } })),
setNextcloud: (patch) =>
set((s) => ({ nextcloud: { ...s.nextcloud, ...patch } })),
setMailing: (patch) =>
set((s) => ({ mailing: { ...s.mailing, ...patch } })),
setOnlyoffice: (patch) =>
set((s) => ({ onlyoffice: { ...s.onlyoffice, ...patch } })),
setAdministrators: (administrators) => set({ administrators }),
addAdministrator: (admin) =>
set((s) => ({ administrators: [...s.administrators, admin] })),
removeAdministrator: (id) =>
set((s) => ({
administrators: s.administrators.filter((a) => a.id !== id),
})),
updateAdministrator: (id, patch) =>
set((s) => ({
administrators: s.administrators.map((a) =>
a.id === id ? { ...a, ...patch } : a
),
})),
setPlugins: (plugins) => set({ plugins }),
togglePlugin: (id, enabled) =>
set((s) => ({
plugins: s.plugins.map((p) => (p.id === id ? { ...p, enabled } : p)),
})),
setIntegrations: (integrations) => set({ integrations }),
toggleIntegration: (id, enabled) =>
set((s) => ({
integrations: s.integrations.map((i) =>
i.id === id ? { ...i, enabled } : i
),
})),
hydrateFromApi: (patch, meta) =>
set((s) => ({
...s,
...patch,
meta: meta ?? s.meta,
apiSynced: true,
})),
}))

View File

@ -0,0 +1,133 @@
import type { ApiLLMSettings, ApiSearchSettings } from "@/lib/contacts/discovery-types"
export type AuthentikSettings = {
enabled: boolean
api_url: string
slug: string
client_id: string
enforce_sso: boolean
allow_password_fallback: boolean
default_groups: string
}
export type TwoFactorPolicy = {
required_for_all: boolean
required_for_admins: boolean
allowed_methods: ("totp" | "webauthn" | "sms")[]
grace_period_days: number
remember_device_days: number
}
export type OrgStorageQuotas = {
default_mail_gib: number
default_drive_gib: number
default_photos_gib: number
warn_threshold_pct: number
}
export type UsageQuotaDefaults = {
llm_requests_per_day: number
llm_tokens_per_month: number
search_requests_per_day: number
max_api_tokens_per_user: number
max_webhooks_per_user: number
}
export type FilePolicySettings = {
max_upload_mib: number
allowed_extensions: string
block_executable: boolean
external_sharing: "disabled" | "authenticated" | "public_link"
default_link_expiry_days: number
virus_scan_enabled: boolean
retention_trash_days: number
}
export type OrgLLMSettings = ApiLLMSettings & {
enforce_org_providers: boolean
allow_user_override: boolean
}
export type OrgSearchEngine = "postgres" | "meilisearch" | "typesense"
export type OrgSearchSettings = {
suite_engine: OrgSearchEngine
meilisearch_url: string
meilisearch_api_key: string
typesense_url: string
typesense_api_key: string
web_search: ApiSearchSettings
enforce_org_search: boolean
}
export type Administrator = {
id: string
email: string
name: string
external_id?: string
scopes: ("read" | "write")[]
added_at: string
}
export type NextcloudSettings = {
enabled: boolean
base_url: string
admin_user: string
admin_password: string
drive_enabled: boolean
calendar_enabled: boolean
contacts_enabled: boolean
talk_enabled: boolean
}
export type MailingSettings = {
enabled: boolean
smtp_host: string
smtp_port: number
smtp_user: string
smtp_password: string
from_email: string
from_name: string
tls_mode: "starttls" | "ssl" | "none"
reply_to?: string
}
export type OnlyOfficeSettings = {
enabled: boolean
document_server_url: string
jwt_secret: string
jwt_header: string
}
export type PluginEntry = {
id: string
name: string
description: string
enabled: boolean
version: string
}
export type IntegrationEntry = {
id: string
name: string
description: string
enabled: boolean
configured: boolean
href?: string
}
export type OrgSettingsState = {
authentik: AuthentikSettings
twoFactor: TwoFactorPolicy
storageQuotas: OrgStorageQuotas
usageQuotas: UsageQuotaDefaults
filePolicies: FilePolicySettings
llm: OrgLLMSettings
search: OrgSearchSettings
administrators: Administrator[]
nextcloud: NextcloudSettings
mailing: MailingSettings
onlyoffice: OnlyOfficeSettings
plugins: PluginEntry[]
integrations: IntegrationEntry[]
}

View File

@ -0,0 +1,191 @@
import type { LucideIcon } from "lucide-react"
import {
Activity,
Bot,
Cloud,
FileCog,
Gauge,
HardDrive,
Link2,
LayoutDashboard,
Mail,
Puzzle,
ScrollText,
Search,
Shield,
ShieldCheck,
Users,
} from "lucide-react"
export type AdminSettingsSectionId =
| "overview"
| "users"
| "authentication"
| "security"
| "storage-quotas"
| "usage-quotas"
| "file-policies"
| "public-shares"
| "llm"
| "search"
| "plugins"
| "nextcloud"
| "mailing"
| "onlyoffice"
| "audit"
export type AdminSettingsNavItem = {
id: AdminSettingsSectionId
label: string
description: string
href: string
icon: LucideIcon
}
export const ADMIN_SETTINGS_NAV: AdminSettingsNavItem[] = [
{
id: "overview",
label: "Vue d'ensemble",
description: "Statistiques et activité de la plateforme",
href: "/admin/settings",
icon: LayoutDashboard,
},
{
id: "users",
label: "Utilisateurs",
description: "Comptes, types d'accès, invitations et quotas",
href: "/admin/settings/users",
icon: Users,
},
{
id: "authentication",
label: "Authentification",
description: "Authentik, SSO et provisionnement",
href: "/admin/settings/authentication",
icon: Shield,
},
{
id: "security",
label: "Sécurité",
description: "Politiques 2FA et exigences d'accès",
href: "/admin/settings/security",
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",
icon: Gauge,
},
{
id: "file-policies",
label: "Politiques fichiers",
description: "Upload, partage et rétention",
href: "/admin/settings/file-policies",
icon: FileCog,
},
{
id: "public-shares",
label: "Partages externes",
description: "Liens publics Drive et audit d'accès",
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",
description: "Index suite et recherche web",
href: "/admin/settings/search",
icon: Search,
},
{
id: "plugins",
label: "Plugins",
description: "Modules fonctionnels activables",
href: "/admin/settings/plugins",
icon: Puzzle,
},
{
id: "nextcloud",
label: "Nextcloud",
description: "Drive, agenda, contacts et Talk",
href: "/admin/settings/nextcloud",
icon: Cloud,
},
{
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: "audit",
label: "Journal d'audit",
description: "Actions administratives et export",
href: "/admin/settings/audit",
icon: ScrollText,
},
]
export function isAdminSettingsNavActive(
pathname: string | null,
item: AdminSettingsNavItem
): boolean {
if (item.href === "/admin/settings") {
return pathname === "/admin/settings" || pathname === "/admin/settings/overview"
}
return (
pathname === item.href || Boolean(pathname?.startsWith(`${item.href}/`))
)
}
export function resolveAdminSettingsSection(
segments: string[] | undefined
): AdminSettingsSectionId {
const slug = segments?.[0]
const match = ADMIN_SETTINGS_NAV.find((item) => {
if (item.id === "overview") return !slug || slug === "overview"
return item.href.endsWith(`/${slug}`)
})
return match?.id ?? "overview"
}
const ADMIN_WIDE_SECTIONS: AdminSettingsSectionId[] = [
"overview",
"users",
"public-shares",
"audit",
"llm",
]
export function isAdminSettingsWideLayoutPath(pathname: string | null): boolean {
if (!pathname?.startsWith("/admin/settings")) return false
return ADMIN_SETTINGS_NAV.some(
(item) =>
ADMIN_WIDE_SECTIONS.includes(item.id) &&
isAdminSettingsNavActive(pathname, item)
)
}

View File

@ -0,0 +1,43 @@
import type { ApiOrgDeployLocked, ApiOrgEnvVar } from "@/lib/api/admin-org-types"
export function isDeployFieldLocked(
deployLocked: ApiOrgDeployLocked | undefined,
section: string,
field: string
): boolean {
const block = deployLocked?.[section]
if (!block?.locked) return false
if (!block.fields?.length) return true
return block.fields.includes(field)
}
export function isPluginDeployLocked(
deployLocked: ApiOrgDeployLocked | undefined,
pluginId: string
): boolean {
return isDeployFieldLocked(deployLocked, "plugins", pluginId)
}
const GROUP_LABELS: Record<string, string> = {
authentik: "Authentik / OIDC",
nextcloud: "Nextcloud",
onlyoffice: "OnlyOffice",
search: "Recherche",
immich: "Immich",
jitsi: "Jitsi Meet",
storage: "Stockage objet",
}
export function envGroupLabel(group: string): string {
return GROUP_LABELS[group] ?? group
}
export function groupEnvVars(vars: ApiOrgEnvVar[]): Record<string, ApiOrgEnvVar[]> {
const grouped: Record<string, ApiOrgEnvVar[]> = {}
for (const v of vars) {
const key = v.group || "other"
grouped[key] = grouped[key] ?? []
grouped[key].push(v)
}
return grouped
}

18
lib/admin/format-bytes.ts Normal file
View File

@ -0,0 +1,18 @@
const UNITS = ["o", "Ko", "Mo", "Go", "To"] as const
export function formatBytes(bytes: number, decimals = 1): string {
if (!Number.isFinite(bytes) || bytes < 0) return "—"
if (bytes === 0) return "0 o"
const k = 1024
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), UNITS.length - 1)
const value = bytes / Math.pow(k, i)
return `${value.toFixed(decimals)} ${UNITS[i]}`
}
export function gibToBytes(gib: number): number {
return Math.round(gib * 1024 * 1024 * 1024)
}
export function bytesToGib(bytes: number): number {
return bytes / (1024 * 1024 * 1024)
}

38
lib/admin/user-role.ts Normal file
View File

@ -0,0 +1,38 @@
import type { LucideIcon } from "lucide-react"
import { ShieldCheck, User, UserRound, UserX, Users } from "lucide-react"
import type { AdminUser, AdminUserRole, AdminUserStatus } from "@/lib/api/admin-types"
export const USER_ROLE_LABELS: Record<AdminUserRole, string> = {
admin: "Administrateur",
user: "Utilisateur",
guest: "Invité",
suspended: "Suspendu",
}
export const USER_ROLE_ICONS: Record<AdminUserRole, LucideIcon> = {
admin: ShieldCheck,
user: User,
guest: UserRound,
suspended: UserX,
}
export const USER_ROLE_FILTER_ALL_ICON = Users
export function resolveUserRole(user: {
role?: AdminUserRole
status?: AdminUserStatus
platform_admin?: boolean
}): AdminUserRole {
if (user.role) return user.role
if (user.status === "disabled") return "suspended"
if (user.status === "invited") return "guest"
if (user.platform_admin) return "admin"
return "user"
}
export const USER_ROLE_DESCRIPTIONS: Record<AdminUserRole, string> = {
admin: "Accès complet à l'administration et à toutes les applications.",
user: "Accès standard à la suite (mail, drive, contacts, etc.).",
guest: "Accès limité au drive : fichiers partagés et contenus envoyés uniquement.",
suspended: "Compte bloqué, aucun accès aux services.",
}

193
lib/api/admin-org-types.ts Normal file
View File

@ -0,0 +1,193 @@
import type { ApiLLMSettings, ApiSearchSettings } from "@/lib/contacts/discovery-types"
export type ApiOrgAuthentik = {
enabled: boolean
api_url: string
slug: string
client_id: string
enforce_sso: boolean
allow_password_fallback: boolean
default_groups: string
}
export type ApiOrgTwoFactor = {
required_for_all: boolean
required_for_admins: boolean
allowed_methods: ("totp" | "webauthn" | "sms")[]
grace_period_days: number
remember_device_days: number
}
export type ApiOrgStorageQuotas = {
default_mail_gib: number
default_drive_gib: number
default_photos_gib: number
warn_threshold_pct: number
}
export type ApiOrgUsageQuotas = {
llm_requests_per_day: number
llm_tokens_per_month: number
search_requests_per_day: number
max_api_tokens_per_user: number
max_webhooks_per_user: number
}
export type ApiOrgFilePolicies = {
max_upload_mib: number
allowed_extensions: string
block_executable: boolean
external_sharing: "disabled" | "authenticated" | "public_link"
default_link_expiry_days: number
virus_scan_enabled: boolean
retention_trash_days: number
}
export type ApiOrgLLMSettings = ApiLLMSettings & {
enforce_org_providers: boolean
allow_user_override: boolean
}
export type ApiOrgSearchSettings = {
suite_engine: "postgres" | "meilisearch" | "typesense"
meilisearch_url: string
meilisearch_api_key: string
typesense_url: string
typesense_api_key: string
web_search: ApiSearchSettings
enforce_org_search: boolean
}
export type ApiOrgAdministrator = {
id: string
email: string
name: string
external_id?: string
scopes: ("read" | "write")[]
added_at: string
}
export type ApiOrgNextcloud = {
enabled: boolean
base_url: string
admin_user: string
admin_password: string
drive_enabled: boolean
calendar_enabled: boolean
contacts_enabled: boolean
talk_enabled: boolean
}
export type ApiOrgMailing = {
enabled: boolean
smtp_host: string
smtp_port: number
smtp_user: string
smtp_password: string
from_email: string
from_name: string
tls_mode: "starttls" | "ssl" | "none"
reply_to?: string
}
export type ApiOrgOnlyoffice = {
enabled: boolean
document_server_url: string
jwt_secret: string
jwt_header: string
}
export type ApiOrgPlugin = {
id: string
name: string
description: string
enabled: boolean
version: string
}
export type ApiOrgIntegration = {
id: string
name: string
description: string
enabled: boolean
configured: boolean
}
export type ApiOrgPolicy = {
authentik: ApiOrgAuthentik
two_factor: ApiOrgTwoFactor
storage_quotas: ApiOrgStorageQuotas
usage_quotas: ApiOrgUsageQuotas
file_policies: ApiOrgFilePolicies
llm: ApiOrgLLMSettings
search: ApiOrgSearchSettings
administrators: ApiOrgAdministrator[]
nextcloud: ApiOrgNextcloud
mailing: ApiOrgMailing
onlyoffice: ApiOrgOnlyoffice
plugins: ApiOrgPlugin[]
integrations: ApiOrgIntegration[]
}
export type ApiOrgEffective = {
authentik: {
enabled: boolean
api_url: string
client_id: string
issuer?: string
}
nextcloud: {
enabled: boolean
base_url: string
admin_user: string
}
onlyoffice: {
enabled: boolean
document_server_url: string
}
search: {
suite_engine: string
meilisearch_url: string
typesense_url: string
}
immich?: {
enabled: boolean
api_url: string
}
jitsi?: {
enabled: boolean
public_url: string
}
}
export type ApiOrgEnvVar = {
name: string
group: string
set: boolean
secret: boolean
value?: string
}
export type ApiOrgDeployLock = {
locked: boolean
reason: string
fields: string[]
}
export type ApiOrgDeployLocked = Record<string, ApiOrgDeployLock>
export type ApiOrgSettingsResponse = {
policy: ApiOrgPolicy
effective: ApiOrgEffective
secrets: Record<string, { configured: boolean }>
env_vars: ApiOrgEnvVar[]
deploy_locked: ApiOrgDeployLocked
updated_at: string
updated_by: string
}
export type ApiOrgSettingsPutRequest = {
policy: Partial<ApiOrgPolicy>
}
export type OrgPolicySectionKey = keyof ApiOrgPolicy

152
lib/api/admin-types.ts Normal file
View File

@ -0,0 +1,152 @@
export type AdminUserStatus = "active" | "disabled" | "invited"
export type AdminUserRole = "admin" | "user" | "guest" | "suspended"
export type AdminUser = {
id: string
external_id: string
email: string
name: string
status: AdminUserStatus
platform_admin: boolean
role: AdminUserRole
storage?: AdminUserStorage
invited_at?: string | null
disabled_at?: string | null
created_at: string
updated_at: string
}
export type AdminServiceQuota = {
count?: number
used_storage_bytes: number
max_storage_bytes: number
}
export type AdminUserQuota = {
mail: AdminServiceQuota
drive: AdminServiceQuota
photos: AdminServiceQuota
}
export type AdminUserDetail = AdminUser & {
quota?: AdminUserQuota
}
export type AdminPagination = {
page: number
page_size: number
total: number
}
export type AdminUsersListResponse = {
users: AdminUser[]
pagination: AdminPagination
}
export type AdminAuditLog = {
id: string
actor: string
action: string
details: unknown
created_at: string
}
export type AdminAuditListResponse = {
logs: AdminAuditLog[]
pagination: AdminPagination
}
export type AdminStatsTopActor = {
actor: string
count: number
}
export type AdminStorageServiceStats = {
used_bytes: number
allocated_bytes: number
tracked?: boolean
}
export type AdminStatsResponse = {
users: {
total: number
active: number
disabled: number
invited: number
}
services: {
mail_accounts_total: number
messages_total: number
audit_events_24h: number
}
quotas: {
users_near_mail_quota_90pct: number
}
storage?: {
mail: AdminStorageServiceStats
drive: AdminStorageServiceStats
photos: AdminStorageServiceStats
}
audit: {
top_actors_7d: AdminStatsTopActor[]
}
}
export type AdminUserStorage = {
mail_used_bytes: number
drive_used_bytes: number
}
export type AdminSetQuotaRequest = {
mail_max_storage_bytes?: number
drive_max_storage_bytes?: number
photos_max_storage_bytes?: number
}
export type AdminCreateUserRequest = {
external_id: string
email: string
name?: string
}
export type AdminInviteUserRequest = {
email: string
name?: string
}
export type AdminUpdateUserRequest = {
email?: string
name?: string
}
export type AdminSetUserRoleRequest = {
role: AdminUserRole
}
export type AdminPublicShare = {
id: string
token: string
path: string
item_type: string
access_mode: string
share_type: number
permissions: number
url: string
expires_at?: string
created_at?: string
owner_nc_user_id: string
owner_email: string
owner_display_name?: string
share_with?: string
share_with_display_name?: string
has_password?: boolean
label?: string
last_access_at?: string | null
access_count: number
}
export type AdminPublicSharesListResponse = {
shares: AdminPublicShare[]
pagination: AdminPagination
}

View File

@ -0,0 +1,128 @@
"use client"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { apiClient } from "@/lib/api/client"
import type {
AdminCreateUserRequest,
AdminInviteUserRequest,
AdminSetQuotaRequest,
AdminSetUserRoleRequest,
AdminUpdateUserRequest,
AdminUser,
AdminUserDetail,
} from "@/lib/api/admin-types"
export function useCreateAdminUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (body: AdminCreateUserRequest) =>
apiClient.post<AdminUser>("/admin/users", body),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["admin", "users"] })
void queryClient.invalidateQueries({ queryKey: ["admin", "stats"] })
},
})
}
export function useInviteAdminUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (body: AdminInviteUserRequest) =>
apiClient.post<AdminUser>("/admin/users/invite", body),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["admin", "users"] })
void queryClient.invalidateQueries({ queryKey: ["admin", "stats"] })
},
})
}
export function useUpdateAdminUser(userId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (body: AdminUpdateUserRequest) =>
apiClient.put<AdminUserDetail>(`/admin/users/${userId}`, body),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["admin", "users"] })
void queryClient.invalidateQueries({ queryKey: ["admin", "users", userId] })
},
})
}
export function useSetAdminUserRole(userId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (body: AdminSetUserRoleRequest) =>
apiClient.put<AdminUserDetail>(`/admin/users/${userId}/role`, body),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["admin", "users"] })
void queryClient.invalidateQueries({ queryKey: ["admin", "users", userId] })
void queryClient.invalidateQueries({ queryKey: ["admin", "stats"] })
},
})
}
export function useSetAdminUserQuota(userId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (body: AdminSetQuotaRequest) =>
apiClient.put<void>(`/admin/users/${userId}/quota`, body),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["admin", "users", userId] })
void queryClient.invalidateQueries({ queryKey: ["admin", "stats"] })
},
})
}
export function useDisableAdminUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (userId: string) =>
apiClient.post<void>(`/admin/users/${userId}/disable`),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["admin", "users"] })
void queryClient.invalidateQueries({ queryKey: ["admin", "stats"] })
},
})
}
export function useReactivateAdminUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (userId: string) =>
apiClient.post<void>(`/admin/users/${userId}/reactivate`),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["admin", "users"] })
void queryClient.invalidateQueries({ queryKey: ["admin", "stats"] })
},
})
}
export function useRevokeAdminPublicShare() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({
shareId,
ownerNcUserId,
}: {
shareId: string
ownerNcUserId: string
}) =>
apiClient.delete(
`/admin/public-shares/${shareId}?owner_nc_user_id=${encodeURIComponent(ownerNcUserId)}`
),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["admin", "public-shares"] })
},
})
}
export function useDeleteAdminUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (userId: string) => apiClient.delete(`/admin/users/${userId}`),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["admin", "users"] })
void queryClient.invalidateQueries({ queryKey: ["admin", "stats"] })
},
})
}

View File

@ -0,0 +1,81 @@
"use client"
import { useQuery } from "@tanstack/react-query"
import { apiClient } from "@/lib/api/client"
import type {
AdminAuditListResponse,
AdminPublicSharesListResponse,
AdminStatsResponse,
AdminUserDetail,
AdminUsersListResponse,
} from "@/lib/api/admin-types"
import { useAuthReady } from "@/lib/api/use-auth-ready"
export type AdminUsersQueryParams = {
page?: number
page_size?: number
q?: string
status?: string
role?: string
}
export function useAdminStats() {
const { ready, authenticated } = useAuthReady()
return useQuery({
queryKey: ["admin", "stats"],
queryFn: () => apiClient.get<AdminStatsResponse>("/admin/stats"),
enabled: ready && authenticated,
})
}
export function useAdminUsers(params: AdminUsersQueryParams = {}) {
const { ready, authenticated } = useAuthReady()
return useQuery({
queryKey: ["admin", "users", params],
queryFn: () =>
apiClient.get<AdminUsersListResponse>("/admin/users", {
page: params.page?.toString(),
page_size: params.page_size?.toString(),
q: params.q,
status: params.status,
role: params.role,
}),
enabled: ready && authenticated,
})
}
export function useAdminUser(userId: string | null) {
const { ready, authenticated } = useAuthReady()
return useQuery({
queryKey: ["admin", "users", userId],
queryFn: () => apiClient.get<AdminUserDetail>(`/admin/users/${userId}`),
enabled: ready && authenticated && Boolean(userId),
})
}
export function useAdminPublicShares(params: { page?: number; page_size?: number; q?: string } = {}) {
const { ready, authenticated } = useAuthReady()
return useQuery({
queryKey: ["admin", "public-shares", params],
queryFn: () =>
apiClient.get<AdminPublicSharesListResponse>("/admin/public-shares", {
page: params.page?.toString(),
page_size: params.page_size?.toString(),
q: params.q,
}),
enabled: ready && authenticated,
})
}
export function useAdminAuditLogs(params: { page?: number; page_size?: number } = {}) {
const { ready, authenticated } = useAuthReady()
return useQuery({
queryKey: ["admin", "audit", params],
queryFn: () =>
apiClient.get<AdminAuditListResponse>("/admin/audit", {
page: params.page?.toString(),
page_size: params.page_size?.toString(),
}),
enabled: ready && authenticated,
})
}

View File

@ -0,0 +1,28 @@
"use client"
import { useQuery } from "@tanstack/react-query"
import { apiClient } from "@/lib/api/client"
import type { AdminUserRole } from "@/lib/api/admin-types"
import { useAuthReady } from "@/lib/api/use-auth-ready"
export type CurrentUser = {
sub: string
email: string
name: string
status: string
platform_admin: boolean
role: AdminUserRole
groups?: string[]
}
export function useCurrentUser() {
const { ready, authenticated } = useAuthReady()
return useQuery({
queryKey: ["current-user"],
queryFn: () => apiClient.get<CurrentUser>("/users/me"),
staleTime: 60_000,
enabled: ready && authenticated,
retry: 1,
})
}

View File

@ -0,0 +1,40 @@
"use client"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { apiClient } from "@/lib/api/client"
import type {
ApiOrgSettingsPutRequest,
ApiOrgSettingsResponse,
} from "@/lib/api/admin-org-types"
import { useAuthReady } from "@/lib/api/use-auth-ready"
import { adminScopesFromToken, isPlatformAdminFromToken } from "@/lib/auth/admin"
import { useAuthStore } from "@/lib/api/auth-store"
export const ORG_SETTINGS_QUERY_KEY = ["admin", "org-settings"] as const
export function useOrgSettings() {
const { ready, authenticated } = useAuthReady()
const token = useAuthStore((s) => s.accessToken)
const scopes = adminScopesFromToken(token)
const isAdmin = isPlatformAdminFromToken(token) || scopes.read
return useQuery({
queryKey: ORG_SETTINGS_QUERY_KEY,
queryFn: () => apiClient.get<ApiOrgSettingsResponse>("/admin/org/settings"),
staleTime: 60_000,
enabled: ready && authenticated && isAdmin,
retry: 1,
})
}
export function useUpdateOrgSettings() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (body: ApiOrgSettingsPutRequest) =>
apiClient.put<ApiOrgSettingsResponse>("/admin/org/settings", body),
onSuccess: (data) => {
queryClient.setQueryData(ORG_SETTINGS_QUERY_KEY, data)
},
})
}

View File

@ -0,0 +1,22 @@
"use client"
import { useCallback } from "react"
import type { OrgPolicySectionKey } from "@/lib/api/admin-org-types"
import { useUpdateOrgSettings } from "@/lib/api/hooks/use-org-settings"
import { pickApiOrgPolicySections, storeToApiOrgPolicy } from "@/lib/admin-settings/map-api-org-settings"
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
export function useSaveOrgPolicy() {
const update = useUpdateOrgSettings()
return useCallback(
async (sections?: OrgPolicySectionKey[]) => {
const state = useOrgSettingsStore.getState()
const policy = sections?.length
? pickApiOrgPolicySections(state, sections)
: storeToApiOrgPolicy(state)
await update.mutateAsync({ policy })
},
[update]
)
}

30
lib/auth/admin.ts Normal file
View File

@ -0,0 +1,30 @@
import { decodeJwtPayload } from "@/lib/auth/jwt-claims"
function normalizeGroups(claims: Record<string, unknown>): string[] {
const raw = claims.groups ?? claims.roles
if (!Array.isArray(raw)) return []
return raw
.filter((g): g is string => typeof g === "string")
.map((g) => g.trim().toLowerCase())
}
/** Platform admin from OIDC groups (`admin`, `role:admin`). */
export function isPlatformAdminFromToken(token: string | null | undefined): boolean {
if (!token) return false
const claims = decodeJwtPayload(token)
if (!claims) return false
return normalizeGroups(claims).some((g) => g === "admin" || g === "role:admin")
}
export function adminScopesFromToken(token: string | null | undefined): {
read: boolean
write: boolean
} {
if (!token) return { read: false, write: false }
if (isPlatformAdminFromToken(token)) return { read: true, write: true }
const groups = normalizeGroups(decodeJwtPayload(token) ?? {})
return {
read: groups.includes("admin:read") || groups.includes("admin:write"),
write: groups.includes("admin:write"),
}
}

View File

@ -30,7 +30,11 @@ export const SUITE_FAVORITE_APPS: FavoriteApp[] = [
href: "/contacts", href: "/contacts",
}, },
{ name: "UltiMeet", icon: suitePublicAsset("/ultimeet-mark.svg") }, { name: "UltiMeet", icon: suitePublicAsset("/ultimeet-mark.svg") },
{ name: "Administration", icon: suitePublicAsset("/admin-mark.svg") }, {
name: "Administration",
icon: suitePublicAsset("/admin-mark.svg"),
href: "/admin/settings",
},
{ {
name: "OpenMaps", name: "OpenMaps",
icon: suitePublicAsset("/openstreetmap-mark.svg"), icon: suitePublicAsset("/openstreetmap-mark.svg"),

View File

@ -2,7 +2,7 @@ import type { Metadata } from "next"
import { displayFileName } from "@/lib/drive/display-file-name" import { displayFileName } from "@/lib/drive/display-file-name"
import { parseDriveSegments } from "@/lib/drive/drive-url" import { parseDriveSegments } from "@/lib/drive/drive-url"
export type SuiteApp = "mail" | "drive" | "contacts" | "suite" export type SuiteApp = "mail" | "drive" | "contacts" | "admin" | "suite"
/** Separator between page segment and product name in document titles. */ /** Separator between page segment and product name in document titles. */
export const SUITE_TITLE_SEP = " - " export const SUITE_TITLE_SEP = " - "
@ -13,6 +13,7 @@ const DESCRIPTIONS: Record<SuiteApp, string> = {
mail: "Client mail Ultimail — suite souveraine", mail: "Client mail Ultimail — suite souveraine",
drive: "Stockage de fichiers UltiDrive — suite Ultimail", drive: "Stockage de fichiers UltiDrive — suite Ultimail",
contacts: "Carnet d'adresses — Ulti Suite", contacts: "Carnet d'adresses — Ulti Suite",
admin: "Console d'administration — Ulti Suite",
suite: "Ultimail, UltiDrive et contacts — interface suite unifiée", suite: "Ultimail, UltiDrive et contacts — interface suite unifiée",
} }
@ -20,10 +21,16 @@ const APP_LABELS: Record<SuiteApp, string> = {
mail: "Ultimail", mail: "Ultimail",
drive: "UltiDrive", drive: "UltiDrive",
contacts: "Ulti Suite", contacts: "Ulti Suite",
admin: "Administration",
suite: "Ulti Suite", suite: "Ulti Suite",
} }
const ICONS: Record<Exclude<SuiteApp, "suite">, Metadata["icons"]> = { const ICONS: Record<Exclude<SuiteApp, "suite">, Metadata["icons"]> = {
admin: {
icon: [{ url: "/admin-mark.svg", type: "image/svg+xml" }],
apple: [{ url: "/admin-mark.svg", type: "image/svg+xml" }],
shortcut: "/admin-mark.svg",
},
mail: { mail: {
icon: [ icon: [
{ url: "/brand/ultimail-header-icon.png", sizes: "109x109", type: "image/png" }, { url: "/brand/ultimail-header-icon.png", sizes: "109x109", type: "image/png" },

File diff suppressed because one or more lines are too long