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);
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
--accent: oklch(0.269 0 0);
|
--accent: oklch(0.269 0 0);
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
--destructive: oklch(0.396 0.141 25.723);
|
--destructive: oklch(0.65 0.22 25.3);
|
||||||
--destructive-foreground: oklch(0.637 0.237 25.331);
|
--destructive-foreground: oklch(0.985 0 0);
|
||||||
--border: oklch(0.269 0 0);
|
--border: oklch(0.269 0 0);
|
||||||
--input: oklch(0.269 0 0);
|
--input: oklch(0.269 0 0);
|
||||||
--ring: oklch(0.439 0 0);
|
--ring: oklch(0.439 0 0);
|
||||||
@ -692,20 +692,27 @@ html[data-mail-background]:not([data-mail-background='none']) [data-contacts-app
|
|||||||
background-color: var(--mail-surface-muted) !important;
|
background-color: var(--mail-surface-muted) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Réglages : fond décoratif visible uniquement derrière la sidebar (contenu opaque). */
|
/* Réglages / administration : fond décoratif visible uniquement derrière la sidebar (contenu opaque). */
|
||||||
html[data-mail-background]:not([data-mail-background='none']) [data-mail-settings-app].ultimail-app {
|
html[data-mail-background]:not([data-mail-background='none']) [data-mail-settings-app].ultimail-app,
|
||||||
|
html[data-mail-background]:not([data-mail-background='none']) [data-admin-settings-app].ultimail-app {
|
||||||
background-color: var(--app-canvas) !important;
|
background-color: var(--app-canvas) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-mail-background]:not([data-mail-background='none'])
|
html[data-mail-background]:not([data-mail-background='none'])
|
||||||
[data-mail-settings-app]
|
[data-mail-settings-app]
|
||||||
[data-mail-settings-sidebar] {
|
[data-mail-settings-sidebar],
|
||||||
|
html[data-mail-background]:not([data-mail-background='none'])
|
||||||
|
[data-admin-settings-app]
|
||||||
|
[data-admin-settings-sidebar] {
|
||||||
background-color: color-mix(in srgb, var(--app-canvas) 72%, transparent) !important;
|
background-color: color-mix(in srgb, var(--app-canvas) 72%, transparent) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-mail-background]:not([data-mail-background='none'])
|
html[data-mail-background]:not([data-mail-background='none'])
|
||||||
[data-mail-settings-app]
|
[data-mail-settings-app]
|
||||||
:where([data-mail-settings-main]) {
|
:where([data-mail-settings-main]),
|
||||||
|
html[data-mail-background]:not([data-mail-background='none'])
|
||||||
|
[data-admin-settings-app]
|
||||||
|
:where([data-admin-settings-main]) {
|
||||||
background-color: var(--mail-surface) !important;
|
background-color: var(--mail-surface) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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",
|
href: "/contacts",
|
||||||
},
|
},
|
||||||
{ name: "UltiMeet", icon: suitePublicAsset("/ultimeet-mark.svg") },
|
{ name: "UltiMeet", icon: suitePublicAsset("/ultimeet-mark.svg") },
|
||||||
{ name: "Administration", icon: suitePublicAsset("/admin-mark.svg") },
|
{
|
||||||
|
name: "Administration",
|
||||||
|
icon: suitePublicAsset("/admin-mark.svg"),
|
||||||
|
href: "/admin/settings",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "OpenMaps",
|
name: "OpenMaps",
|
||||||
icon: suitePublicAsset("/openstreetmap-mark.svg"),
|
icon: suitePublicAsset("/openstreetmap-mark.svg"),
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import type { Metadata } from "next"
|
|||||||
import { displayFileName } from "@/lib/drive/display-file-name"
|
import { displayFileName } from "@/lib/drive/display-file-name"
|
||||||
import { parseDriveSegments } from "@/lib/drive/drive-url"
|
import { parseDriveSegments } from "@/lib/drive/drive-url"
|
||||||
|
|
||||||
export type SuiteApp = "mail" | "drive" | "contacts" | "suite"
|
export type SuiteApp = "mail" | "drive" | "contacts" | "admin" | "suite"
|
||||||
|
|
||||||
/** Separator between page segment and product name in document titles. */
|
/** Separator between page segment and product name in document titles. */
|
||||||
export const SUITE_TITLE_SEP = " - "
|
export const SUITE_TITLE_SEP = " - "
|
||||||
@ -13,6 +13,7 @@ const DESCRIPTIONS: Record<SuiteApp, string> = {
|
|||||||
mail: "Client mail Ultimail — suite souveraine",
|
mail: "Client mail Ultimail — suite souveraine",
|
||||||
drive: "Stockage de fichiers UltiDrive — suite Ultimail",
|
drive: "Stockage de fichiers UltiDrive — suite Ultimail",
|
||||||
contacts: "Carnet d'adresses — Ulti Suite",
|
contacts: "Carnet d'adresses — Ulti Suite",
|
||||||
|
admin: "Console d'administration — Ulti Suite",
|
||||||
suite: "Ultimail, UltiDrive et contacts — interface suite unifiée",
|
suite: "Ultimail, UltiDrive et contacts — interface suite unifiée",
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -20,10 +21,16 @@ const APP_LABELS: Record<SuiteApp, string> = {
|
|||||||
mail: "Ultimail",
|
mail: "Ultimail",
|
||||||
drive: "UltiDrive",
|
drive: "UltiDrive",
|
||||||
contacts: "Ulti Suite",
|
contacts: "Ulti Suite",
|
||||||
|
admin: "Administration",
|
||||||
suite: "Ulti Suite",
|
suite: "Ulti Suite",
|
||||||
}
|
}
|
||||||
|
|
||||||
const ICONS: Record<Exclude<SuiteApp, "suite">, Metadata["icons"]> = {
|
const ICONS: Record<Exclude<SuiteApp, "suite">, Metadata["icons"]> = {
|
||||||
|
admin: {
|
||||||
|
icon: [{ url: "/admin-mark.svg", type: "image/svg+xml" }],
|
||||||
|
apple: [{ url: "/admin-mark.svg", type: "image/svg+xml" }],
|
||||||
|
shortcut: "/admin-mark.svg",
|
||||||
|
},
|
||||||
mail: {
|
mail: {
|
||||||
icon: [
|
icon: [
|
||||||
{ url: "/brand/ultimail-header-icon.png", sizes: "109x109", type: "image/png" },
|
{ url: "/brand/ultimail-header-icon.png", sizes: "109x109", type: "image/png" },
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user