diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx new file mode 100644 index 0000000..30becaf --- /dev/null +++ b/app/admin/layout.tsx @@ -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 +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..d7925e6 --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation" + +export default function AdminRootPage() { + redirect("/admin/settings") +} diff --git a/app/admin/settings/[[...section]]/page.tsx b/app/admin/settings/[[...section]]/page.tsx new file mode 100644 index 0000000..d8f36c4 --- /dev/null +++ b/app/admin/settings/[[...section]]/page.tsx @@ -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 +} diff --git a/app/admin/settings/layout.tsx b/app/admin/settings/layout.tsx new file mode 100644 index 0000000..e00a840 --- /dev/null +++ b/app/admin/settings/layout.tsx @@ -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 {children} +} diff --git a/app/globals.css b/app/globals.css index 945430c..fa6b096 100644 --- a/app/globals.css +++ b/app/globals.css @@ -116,8 +116,8 @@ --muted-foreground: oklch(0.708 0 0); --accent: oklch(0.269 0 0); --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.396 0.141 25.723); - --destructive-foreground: oklch(0.637 0.237 25.331); + --destructive: oklch(0.65 0.22 25.3); + --destructive-foreground: oklch(0.985 0 0); --border: oklch(0.269 0 0); --input: oklch(0.269 0 0); --ring: oklch(0.439 0 0); @@ -692,20 +692,27 @@ html[data-mail-background]:not([data-mail-background='none']) [data-contacts-app background-color: var(--mail-surface-muted) !important; } -/* Réglages : fond décoratif visible uniquement derrière la sidebar (contenu opaque). */ -html[data-mail-background]:not([data-mail-background='none']) [data-mail-settings-app].ultimail-app { +/* Réglages / administration : fond décoratif visible uniquement derrière la sidebar (contenu opaque). */ +html[data-mail-background]:not([data-mail-background='none']) [data-mail-settings-app].ultimail-app, +html[data-mail-background]:not([data-mail-background='none']) [data-admin-settings-app].ultimail-app { background-color: var(--app-canvas) !important; } html[data-mail-background]:not([data-mail-background='none']) [data-mail-settings-app] - [data-mail-settings-sidebar] { + [data-mail-settings-sidebar], +html[data-mail-background]:not([data-mail-background='none']) + [data-admin-settings-app] + [data-admin-settings-sidebar] { background-color: color-mix(in srgb, var(--app-canvas) 72%, transparent) !important; } html[data-mail-background]:not([data-mail-background='none']) [data-mail-settings-app] - :where([data-mail-settings-main]) { + :where([data-mail-settings-main]), +html[data-mail-background]:not([data-mail-background='none']) + [data-admin-settings-app] + :where([data-admin-settings-main]) { background-color: var(--mail-surface) !important; } diff --git a/components/admin/admin-logo.tsx b/components/admin/admin-logo.tsx new file mode 100644 index 0000000..b61f7be --- /dev/null +++ b/components/admin/admin-logo.tsx @@ -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 = ( + Administration + ) + + if (href === null) return img + + return ( + + {img} + + ) +} diff --git a/components/admin/settings/admin-access-guard.tsx b/components/admin/settings/admin-access-guard.tsx new file mode 100644 index 0000000..6ebb100 --- /dev/null +++ b/components/admin/settings/admin-access-guard.tsx @@ -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 ( +

Chargement de la session…

+ ) + } + + if (!authenticated) { + return ( +
+

Connectez-vous avec un compte administrateur pour accéder à cette interface.

+ +
+ ) + } + + if (meLoading && !me) { + return ( +

Vérification des droits administrateur…

+ ) + } + + const scopes = adminScopesFromToken(token) + const isAdmin = + isPlatformAdminFromToken(token) || scopes.read || me?.platform_admin === true + + if (!isAdmin) { + return ( +
+

Accès refusé. Votre compte ne dispose pas des droits d'administration.

+ +
+ ) + } + + return <>{children} +} diff --git a/components/admin/settings/admin-pending-api-banner.tsx b/components/admin/settings/admin-pending-api-banner.tsx new file mode 100644 index 0000000..a98484b --- /dev/null +++ b/components/admin/settings/admin-pending-api-banner.tsx @@ -0,0 +1,8 @@ +export function AdminPendingApiBanner() { + return ( +

+ Chargement des réglages organisationnels depuis le serveur… Les modifications + seront disponibles une fois la synchronisation terminée. +

+ ) +} diff --git a/components/admin/settings/admin-runtime-panel.tsx b/components/admin/settings/admin-runtime-panel.tsx new file mode 100644 index 0000000..4dfb41c --- /dev/null +++ b/components/admin/settings/admin-runtime-panel.tsx @@ -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 ( +
+
+ +
+

Configuration runtime (Docker Compose)

+

+ 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. +

+

+ Recherche {eff.search.suite_engine} + {" · "} + 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} +

+
+ + + Compose + +
+ + + + {envOpen ? ( +
+ {Object.entries(envGroups).map(([group, vars]) => ( +
+

{envGroupLabel(group)}

+
+ + + + + + + + + + {vars.map((v) => ( + + + + + + ))} + +
VariableDéfinieValeur
{v.name} + + {v.set ? "oui" : "non"} + + + {v.secret ? (v.set ? "••••••••" : "—") : (v.value ?? "—")} +
+
+
+ ))} +
+ ) : null} +
+ ) +} diff --git a/components/admin/settings/admin-settings-header.tsx b/components/admin/settings/admin-settings-header.tsx new file mode 100644 index 0000000..32bbe3a --- /dev/null +++ b/components/admin/settings/admin-settings-header.tsx @@ -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 ( +
+
+ + Administration +
+ +
+ +
+ +
+ + +
+ ) +} diff --git a/components/admin/settings/admin-settings-layout.tsx b/components/admin/settings/admin-settings-layout.tsx new file mode 100644 index 0000000..a93062d --- /dev/null +++ b/components/admin/settings/admin-settings-layout.tsx @@ -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 ( +
+ + + +
+ + +
+
+ + +
+
+ {children} +
+
+
+
+
+
+ ) +} diff --git a/components/admin/settings/admin-settings-section-view.tsx b/components/admin/settings/admin-settings-section-view.tsx new file mode 100644 index 0000000..120fd00 --- /dev/null +++ b/components/admin/settings/admin-settings-section-view.tsx @@ -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 = { + 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 ( + +
+ + ) +} + +export function AdminSettingsSectionFromSegments({ + segments, +}: { + segments?: string[] +}) { + const sectionId = resolveAdminSettingsSection(segments) + return +} diff --git a/components/admin/settings/deploy-locked-hint.tsx b/components/admin/settings/deploy-locked-hint.tsx new file mode 100644 index 0000000..cbe8091 --- /dev/null +++ b/components/admin/settings/deploy-locked-hint.tsx @@ -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 ( +

+ + Géré par Docker Compose — modifier les variables d'environnement du déploiement. +

+ ) +} + +export function useDeployFieldLocked(section: string, field: string): boolean { + const deployLocked = useOrgSettingsStore((s) => s.meta?.deployLocked) + return isDeployFieldLocked(deployLocked, section, field) +} diff --git a/components/admin/settings/org-settings-form.tsx b/components/admin/settings/org-settings-form.tsx new file mode 100644 index 0000000..a7de5b4 --- /dev/null +++ b/components/admin/settings/org-settings-form.tsx @@ -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 +}) { + const [saved, setSaved] = useState(false) + const [error, setError] = useState(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 ( + <> + + refetch()} /> + {!apiSynced ? : null} + {showEffectiveBanner ? : null} +
{children}
+ {hasSave ? ( +
+ + {saved ? ( + + Réglages enregistrés sur le serveur + + ) : null} + {error ? {error} : null} +
+ ) : null} + + ) +} diff --git a/components/admin/settings/org-settings-sync.tsx b/components/admin/settings/org-settings-sync.tsx new file mode 100644 index 0000000..360fac5 --- /dev/null +++ b/components/admin/settings/org-settings-sync.tsx @@ -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 +} diff --git a/components/admin/settings/sections/audit-section.tsx b/components/admin/settings/sections/audit-section.tsx new file mode 100644 index 0000000..7f86cd6 --- /dev/null +++ b/components/admin/settings/sections/audit-section.tsx @@ -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 ( + <> + + refetch()} /> + +
+ + +
+ +
+ + + + Date + Acteur + Action + Détails + + + + {logs.length === 0 ? ( + + + Aucun événement. + + + ) : ( + logs.map((log) => ( + + + {new Date(log.created_at).toLocaleString("fr-FR")} + + + {log.actor} + + {log.action} + + {typeof log.details === "string" + ? log.details + : JSON.stringify(log.details)} + + + )) + )} + +
+
+ + {totalPages > 1 ? ( +
+ + +
+ ) : null} + + ) +} diff --git a/components/admin/settings/sections/authentication-section.tsx b/components/admin/settings/sections/authentication-section.tsx new file mode 100644 index 0000000..6da7301 --- /dev/null +++ b/components/admin/settings/sections/authentication-section.tsx @@ -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 ( + + + +
+
+ Authentik activé + Connexion via le fournisseur d'identité organisationnel. + {enabledLocked ? : null} +
+ setAuthentik({ enabled: v })} + /> +
+
+ +
+ + setAuthentik({ api_url: e.target.value })} + placeholder="https://auth.example.com/api/v3" + /> +
+
+ + setAuthentik({ slug: e.target.value })} + /> +
+
+ + setAuthentik({ client_id: e.target.value })} + /> +
+
+ + setAuthentik({ default_groups: e.target.value })} + /> +
+ + +
+
+
+ ) +} diff --git a/components/admin/settings/sections/file-policies-section.tsx b/components/admin/settings/sections/file-policies-section.tsx new file mode 100644 index 0000000..2507949 --- /dev/null +++ b/components/admin/settings/sections/file-policies-section.tsx @@ -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 ( + +
+
+ + + setFilePolicies({ max_upload_mib: Number(e.target.value) || 1 }) + } + /> +
+
+ + + setFilePolicies({ + default_link_expiry_days: Number(e.target.value) || 1, + }) + } + /> +
+
+ + + setFilePolicies({ retention_trash_days: Number(e.target.value) || 1 }) + } + /> +
+
+ + +
+
+ +