Admin interface
This commit is contained in:
parent
9603a9c687
commit
8b9717861c
11
app/admin/layout.tsx
Normal file
11
app/admin/layout.tsx
Normal 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
5
app/admin/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
export default function AdminRootPage() {
|
||||
redirect("/admin/settings")
|
||||
}
|
||||
10
app/admin/settings/[[...section]]/page.tsx
Normal file
10
app/admin/settings/[[...section]]/page.tsx
Normal 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} />
|
||||
}
|
||||
16
app/admin/settings/layout.tsx
Normal file
16
app/admin/settings/layout.tsx
Normal 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>
|
||||
}
|
||||
@ -116,8 +116,8 @@
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.396 0.141 25.723);
|
||||
--destructive-foreground: oklch(0.637 0.237 25.331);
|
||||
--destructive: oklch(0.65 0.22 25.3);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(0.269 0 0);
|
||||
--input: oklch(0.269 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;
|
||||
}
|
||||
|
||||
/* Réglages : 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 {
|
||||
/* 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-admin-settings-app].ultimail-app {
|
||||
background-color: var(--app-canvas) !important;
|
||||
}
|
||||
|
||||
html[data-mail-background]:not([data-mail-background='none'])
|
||||
[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;
|
||||
}
|
||||
|
||||
html[data-mail-background]:not([data-mail-background='none'])
|
||||
[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;
|
||||
}
|
||||
|
||||
|
||||
37
components/admin/admin-logo.tsx
Normal file
37
components/admin/admin-logo.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
54
components/admin/settings/admin-access-guard.tsx
Normal file
54
components/admin/settings/admin-access-guard.tsx
Normal 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'administration.</p>
|
||||
<Button asChild variant="outline" size="sm" className="mt-3">
|
||||
<Link href="/mail">Retour à Ultimail</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
8
components/admin/settings/admin-pending-api-banner.tsx
Normal file
8
components/admin/settings/admin-pending-api-banner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
111
components/admin/settings/admin-runtime-panel.tsx
Normal file
111
components/admin/settings/admin-runtime-panel.tsx
Normal 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'environnement du
|
||||
déploiement. Les interrupteurs correspondants dans l'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'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>
|
||||
)
|
||||
}
|
||||
31
components/admin/settings/admin-settings-header.tsx
Normal file
31
components/admin/settings/admin-settings-header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
124
components/admin/settings/admin-settings-layout.tsx
Normal file
124
components/admin/settings/admin-settings-layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
62
components/admin/settings/admin-settings-section-view.tsx
Normal file
62
components/admin/settings/admin-settings-section-view.tsx
Normal 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} />
|
||||
}
|
||||
28
components/admin/settings/deploy-locked-hint.tsx
Normal file
28
components/admin/settings/deploy-locked-hint.tsx
Normal 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'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)
|
||||
}
|
||||
76
components/admin/settings/org-settings-form.tsx
Normal file
76
components/admin/settings/org-settings-form.tsx
Normal 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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
27
components/admin/settings/org-settings-sync.tsx
Normal file
27
components/admin/settings/org-settings-sync.tsx
Normal 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
|
||||
}
|
||||
117
components/admin/settings/sections/audit-section.tsx
Normal file
117
components/admin/settings/sections/audit-section.tsx
Normal 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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
111
components/admin/settings/sections/authentication-section.tsx
Normal file
111
components/admin/settings/sections/authentication-section.tsx
Normal 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'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>
|
||||
)
|
||||
}
|
||||
117
components/admin/settings/sections/file-policies-section.tsx
Normal file
117
components/admin/settings/sections/file-policies-section.tsx
Normal 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'upload</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={filePolicies.virus_scan_enabled}
|
||||
onCheckedChange={(virus_scan_enabled) => setFilePolicies({ virus_scan_enabled })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</OrgSettingsSection>
|
||||
)
|
||||
}
|
||||
177
components/admin/settings/sections/llm-section.tsx
Normal file
177
components/admin/settings/sections/llm-section.tsx
Normal 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'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>
|
||||
)
|
||||
}
|
||||
126
components/admin/settings/sections/mailing-section.tsx
Normal file
126
components/admin/settings/sections/mailing-section.tsx
Normal 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'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>
|
||||
)
|
||||
}
|
||||
127
components/admin/settings/sections/nextcloud-section.tsx
Normal file
127
components/admin/settings/sections/nextcloud-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
82
components/admin/settings/sections/onlyoffice-section.tsx
Normal file
82
components/admin/settings/sections/onlyoffice-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
164
components/admin/settings/sections/overview-section.tsx
Normal file
164
components/admin/settings/sections/overview-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
48
components/admin/settings/sections/plugins-section.tsx
Normal file
48
components/admin/settings/sections/plugins-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
242
components/admin/settings/sections/public-shares-section.tsx
Normal file
242
components/admin/settings/sections/public-shares-section.tsx
Normal 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
|
||||
}
|
||||
169
components/admin/settings/sections/search-section.tsx
Normal file
169
components/admin/settings/sections/search-section.tsx
Normal 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'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'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>
|
||||
)
|
||||
}
|
||||
108
components/admin/settings/sections/security-section.tsx
Normal file
108
components/admin/settings/sections/security-section.tsx
Normal 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'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>
|
||||
)
|
||||
}
|
||||
@ -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'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>
|
||||
)
|
||||
}
|
||||
97
components/admin/settings/sections/usage-quotas-section.tsx
Normal file
97
components/admin/settings/sections/usage-quotas-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
568
components/admin/settings/sections/users-section.tsx
Normal file
568
components/admin/settings/sections/users-section.tsx
Normal 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'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'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'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>
|
||||
)
|
||||
}
|
||||
152
lib/admin-settings/map-api-org-settings.ts
Normal file
152
lib/admin-settings/map-api-org-settings.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
297
lib/admin-settings/org-settings-store.ts
Normal file
297
lib/admin-settings/org-settings-store.ts
Normal 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,
|
||||
})),
|
||||
}))
|
||||
133
lib/admin-settings/org-settings-types.ts
Normal file
133
lib/admin-settings/org-settings-types.ts
Normal 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[]
|
||||
}
|
||||
191
lib/admin-settings/settings-nav.ts
Normal file
191
lib/admin-settings/settings-nav.ts
Normal 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)
|
||||
)
|
||||
}
|
||||
43
lib/admin/deploy-runtime.ts
Normal file
43
lib/admin/deploy-runtime.ts
Normal 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
18
lib/admin/format-bytes.ts
Normal 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
38
lib/admin/user-role.ts
Normal 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
193
lib/api/admin-org-types.ts
Normal 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
152
lib/api/admin-types.ts
Normal 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
|
||||
}
|
||||
128
lib/api/hooks/use-admin-mutations.ts
Normal file
128
lib/api/hooks/use-admin-mutations.ts
Normal 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"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
81
lib/api/hooks/use-admin-queries.ts
Normal file
81
lib/api/hooks/use-admin-queries.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
28
lib/api/hooks/use-current-user.ts
Normal file
28
lib/api/hooks/use-current-user.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
40
lib/api/hooks/use-org-settings.ts
Normal file
40
lib/api/hooks/use-org-settings.ts
Normal 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)
|
||||
},
|
||||
})
|
||||
}
|
||||
22
lib/api/hooks/use-save-org-policy.ts
Normal file
22
lib/api/hooks/use-save-org-policy.ts
Normal 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
30
lib/auth/admin.ts
Normal 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"),
|
||||
}
|
||||
}
|
||||
@ -30,7 +30,11 @@ export const SUITE_FAVORITE_APPS: FavoriteApp[] = [
|
||||
href: "/contacts",
|
||||
},
|
||||
{ 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",
|
||||
icon: suitePublicAsset("/openstreetmap-mark.svg"),
|
||||
|
||||
@ -2,7 +2,7 @@ import type { Metadata } from "next"
|
||||
import { displayFileName } from "@/lib/drive/display-file-name"
|
||||
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. */
|
||||
export const SUITE_TITLE_SEP = " - "
|
||||
@ -13,6 +13,7 @@ const DESCRIPTIONS: Record<SuiteApp, string> = {
|
||||
mail: "Client mail Ultimail — suite souveraine",
|
||||
drive: "Stockage de fichiers UltiDrive — suite Ultimail",
|
||||
contacts: "Carnet d'adresses — Ulti Suite",
|
||||
admin: "Console d'administration — Ulti Suite",
|
||||
suite: "Ultimail, UltiDrive et contacts — interface suite unifiée",
|
||||
}
|
||||
|
||||
@ -20,10 +21,16 @@ const APP_LABELS: Record<SuiteApp, string> = {
|
||||
mail: "Ultimail",
|
||||
drive: "UltiDrive",
|
||||
contacts: "Ulti Suite",
|
||||
admin: "Administration",
|
||||
suite: "Ulti Suite",
|
||||
}
|
||||
|
||||
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: {
|
||||
icon: [
|
||||
{ url: "/brand/ultimail-header-icon.png", sizes: "109x109", type: "image/png" },
|
||||
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user