Aurora Nexus
Aurora NexusConfiguration

Guide d'implémentation UI Admin – Aurora Nexus

(depuis `ADMIN_UI_GUIDE.md`)

Note : le dépôt est historiquement nommé “Aurora RAG” dans le code, mais le nom produit est Aurora Nexus.

Ce guide couvre l’interface Next.js 14 App Router multilingue (FR/EN/ES/DE/PT) qui pilote l’assistant, l’upload storytelling, la recherche documentaire, la page “Mon compte” et tout l’espace admin (users, permissions, source apps, préférences, stats). Il détaille les dépendances, la structure des pages [locale]/…, les composants shadcn/ui custom et les bonnes pratiques de sécurité (AccessGuard, permissions, tokens).

Pour le workflow “Assistant RAG (admin) + Test RAG”, voir : docs/03_Configuration/15-Assistant-RAG-Test-RAG.md.

Table des matières

  1. Installation et configuration
  2. Structure du projet
  3. Thème et styles
  4. Blocks shadcn
  5. Composants shadcn/ui
  6. Charts
  7. Pages admin
  8. Intégration React Query
  9. Formulaires avec RHF + zod

Installation et configuration

Dépendances à installer

cd ui
npm install

Les dépendances (prod + dev) sont listées dans ui/package.json. Inutile de les installer une à une.

Configuration Next.js App Router

  • ui/app/layout.tsx : shell racine (police, <html>), importe ./globals.css.
  • ui/app/[locale]/layout.tsx : wrapper par locale → ClientIntlProvider, AppShell, Providers.
// ui/app/[locale]/layout.tsx
import '../globals.css'
import { Inter } from 'next/font/google'
import { PropsWithChildren } from 'react'
import { ClientIntlProvider } from '@/components/providers/ClientIntlProvider'
import { Providers } from '@/components/providers/Providers'
import AppShell from '@/components/layout/AppShell'

const inter = Inter({ subsets: ['latin'] })

export default function LocaleLayout({
  children,
  params: { locale },
}: PropsWithChildren<{ params: { locale: string } }>) {
  return (
    <html lang={locale} suppressHydrationWarning>
      <body className={inter.className}>
        <ClientIntlProvider locale={locale}>
          <Providers>
            <AppShell>{children}</AppShell>
          </Providers>
        </ClientIntlProvider>
      </body>
    </html>
  )
}

Providers regroupe React Query (@tanstack/react-query), Toaster (sonner) et peut accueillir next-themes si besoin.


Structure du projet

ui/
├── app/
│   ├── layout.tsx                 # Layout racine
│   └── [locale]/                  # Routes localisées
│       ├── layout.tsx             # AppShell + Providers + i18n
│       ├── page.tsx               # Assistant (landing)
│       ├── account/page.tsx       # KPIs personnels + permissions
│       ├── admin/
│       │   ├── page.tsx           # Vue globale
│       │   ├── queries/page.tsx   # Monitoring requêtes
│       │   ├── users/page.tsx
│       │   ├── settings/page.tsx
│       │   ├── source-apps/page.tsx
│       │   └── assistant-rag/page.tsx  # Assistant RAG (admin) + Test RAG
│       ├── documents/
│       │   ├── layout.tsx
│       │   ├── page.tsx           # Listing + filtres
│       │   └── upload/page.tsx    # Storytelling pipeline
│       ├── search/page.tsx        # Recherche globale (documents)
│       └── support/page.tsx
├── components/
│   ├── layout/AppShell.tsx        # Sidebar + header + KPIs
│   ├── auth/
│   │   ├── LoginForm.tsx
│   │   └── AccessGuard.tsx        # Remplace RoleGuard (RBAC fin)
│   ├── admin/…                    # ApiKeysManager, SourceAppManager, PermissionsEditor…
│   ├── documents/DocumentPublicLinksDialog.tsx
│   ├── language-switcher.tsx
│   ├── providers/
│   │   └── ClientIntlProvider.tsx
│   └── ui/                        # Composants shadcn/ui
├── hooks/usePreferences.ts
├── stores/authStore.ts
├── lib/api.ts, lib/documents.ts, lib/preferences.ts
├── i18n/ + messages/              # next-intl config + dictionnaires
├── middleware.ts                  # Détection locale
├── tests/                         # RTL
└── types/                         # Pydantic mirror côté TS

Thème et styles

globals.css

Créer ui/app/globals.css avec le thème complet :

@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  :root {
    --background: hsl(20 5.8824% 90%);
    --foreground: hsl(217.2414 32.5843% 17.4510%);
    --card: hsl(60 4.7619% 95.8824%);
    --card-foreground: hsl(217.2414 32.5843% 17.4510%);
    --popover: hsl(60 4.7619% 95.8824%);
    --popover-foreground: hsl(217.2414 32.5843% 17.4510%);
    --primary: hsl(238.7324 83.5294% 66.6667%);
    --primary-foreground: hsl(0 0% 100%);
    --secondary: hsl(24.0000 5.7471% 82.9412%);
    --secondary-foreground: hsl(215 13.7931% 34.1176%);
    --muted: hsl(20 5.8824% 90%);
    --muted-foreground: hsl(220 8.9362% 46.0784%);
    --accent: hsl(292.5000 44.4444% 92.9412%);
    --accent-foreground: hsl(216.9231 19.1176% 26.6667%);
    --destructive: hsl(0 84.2365% 60.1961%);
    --destructive-foreground: hsl(0 0% 100%);
    --border: hsl(24.0000 5.7471% 82.9412%);
    --input: hsl(24.0000 5.7471% 82.9412%);
    --ring: hsl(238.7324 83.5294% 66.6667%);
    --chart-1: hsl(238.7324 83.5294% 66.6667%);
    --chart-2: hsl(243.3962 75.3555% 58.6275%);
    --chart-3: hsl(244.5205 57.9365% 50.5882%);
    --chart-4: hsl(243.6522 54.5024% 41.3725%);
    --chart-5: hsl(242.1687 47.4286% 34.3137%);
    --sidebar: hsl(24.0000 5.7471% 82.9412%);
    --sidebar-foreground: hsl(217.2414 32.5843% 17.4510%);
    --sidebar-primary: hsl(238.7324 83.5294% 66.6667%);
    --sidebar-primary-foreground: hsl(0 0% 100%);
    --sidebar-accent: hsl(292.5000 44.4444% 92.9412%);
    --sidebar-accent-foreground: hsl(216.9231 19.1176% 26.6667%);
    --sidebar-border: hsl(24.0000 5.7471% 82.9412%);
    --sidebar-ring: hsl(238.7324 83.5294% 66.6667%);
    --font-sans: Plus Jakarta Sans, sans-serif;
    --font-serif: Lora, serif;
    --font-mono: Roboto Mono, monospace;
    --radius: 1.25rem;
    --shadow-x: 2px;
    --shadow-y: 2px;
    --shadow-blur: 10px;
    --shadow-spread: 4px;
    --shadow-opacity: 0.18;
    --shadow-color: hsl(240 4% 60%);
    --shadow-2xs: 2px 2px 10px 4px hsl(240 4% 60% / 0.09);
    --shadow-xs: 2px 2px 10px 4px hsl(240 4% 60% / 0.09);
    --shadow-sm: 2px 2px 10px 4px hsl(240 4% 60% / 0.18), 2px 1px 2px 3px hsl(240 4% 60% / 0.18);
    --shadow: 2px 2px 10px 4px hsl(240 4% 60% / 0.18), 2px 1px 2px 3px hsl(240 4% 60% / 0.18);
    --shadow-md: 2px 2px 10px 4px hsl(240 4% 60% / 0.18), 2px 2px 4px 3px hsl(240 4% 60% / 0.18);
    --shadow-lg: 2px 2px 10px 4px hsl(240 4% 60% / 0.18), 2px 4px 6px 3px hsl(240 4% 60% / 0.18);
    --shadow-xl: 2px 2px 10px 4px hsl(240 4% 60% / 0.18), 2px 8px 10px 3px hsl(240 4% 60% / 0.18);
    --shadow-2xl: 2px 2px 10px 4px hsl(240 4% 60% / 0.45);
    --tracking-normal: 0em;
    --spacing: 0.25rem;
  }

  .dark {
    --background: hsl(30 11.1111% 10.5882%);
    --foreground: hsl(214.2857 31.8182% 91.3725%);
    --card: hsl(25.7143 8.6420% 15.8824%);
    --card-foreground: hsl(214.2857 31.8182% 91.3725%);
    --popover: hsl(25.7143 8.6420% 15.8824%);
    --popover-foreground: hsl(214.2857 31.8182% 91.3725%);
    --primary: hsl(234.4538 89.4737% 73.9216%);
    --primary-foreground: hsl(30 11.1111% 10.5882%);
    --secondary: hsl(25.7143 6.4220% 21.3725%);
    --secondary-foreground: hsl(216.0000 12.1951% 83.9216%);
    --muted: hsl(30 10.7143% 10.9804%);
    --muted-foreground: hsl(217.8947 10.6145% 64.9020%);
    --accent: hsl(25.7143 5.1095% 26.8627%);
    --accent-foreground: hsl(216.0000 12.1951% 83.9216%);
    --destructive: hsl(0 84.2365% 60.1961%);
    --destructive-foreground: hsl(30 11.1111% 10.5882%);
    --border: hsl(25.7143 6.4220% 21.3725%);
    --input: hsl(25.7143 6.4220% 21.3725%);
    --ring: hsl(234.4538 89.4737% 73.9216%);
    --chart-1: hsl(234.4538 89.4737% 73.9216%);
    --chart-2: hsl(238.7324 83.5294% 66.6667%);
    --chart-3: hsl(243.3962 75.3555% 58.6275%);
    --chart-4: hsl(244.5205 57.9365% 50.5882%);
    --chart-5: hsl(243.6522 54.5024% 41.3725%);
    --sidebar: hsl(25.7143 6.4220% 21.3725%);
    --sidebar-foreground: hsl(214.2857 31.8182% 91.3725%);
    --sidebar-primary: hsl(234.4538 89.4737% 73.9216%);
    --sidebar-primary-foreground: hsl(30 11.1111% 10.5882%);
    --sidebar-accent: hsl(25.7143 5.1095% 26.8627%);
    --sidebar-accent-foreground: hsl(216.0000 12.1951% 83.9216%);
    --sidebar-border: hsl(25.7143 6.4220% 21.3725%);
    --sidebar-ring: hsl(234.4538 89.4737% 73.9216%);
    --font-sans: Plus Jakarta Sans, sans-serif;
    --font-serif: Lora, serif;
    --font-mono: Roboto Mono, monospace;
    --radius: 1.25rem;
    --shadow-x: 2px;
    --shadow-y: 2px;
    --shadow-blur: 10px;
    --shadow-spread: 4px;
    --shadow-opacity: 0.18;
    --shadow-color: hsl(0 0% 0%);
    --shadow-2xs: 2px 2px 10px 4px hsl(0 0% 0% / 0.09);
    --shadow-xs: 2px 2px 10px 4px hsl(0 0% 0% / 0.09);
    --shadow-sm: 2px 2px 10px 4px hsl(0 0% 0% / 0.18), 2px 1px 2px 3px hsl(0 0% 0% / 0.18);
    --shadow: 2px 2px 10px 4px hsl(0 0% 0% / 0.18), 2px 1px 2px 3px hsl(0 0% 0% / 0.18);
    --shadow-md: 2px 2px 10px 4px hsl(0 0% 0% / 0.18), 2px 2px 4px 3px hsl(0 0% 0% / 0.18);
    --shadow-lg: 2px 2px 10px 4px hsl(0 0% 0% / 0.18), 2px 4px 6px 3px hsl(0 0% 0% / 0.18);
    --shadow-xl: 2px 2px 10px 4px hsl(0 0% 0% / 0.18), 2px 8px 10px 3px hsl(0 0% 0% / 0.18);
    --shadow-2xl: 2px 2px 10px 4px hsl(0 0% 0% / 0.45);
  }

  * {
    @apply border-border;
  }

  body {
    @apply bg-background text-foreground;
  }
}

tailwind.config.ts

Créer ou adapter ui/tailwind.config.ts :

import type { Config } from "tailwindcss"

const config: Config = {
  darkMode: ["class"],
  content: [
    "./app/**/*.{ts,tsx}",
    "./components/**/*.{ts,tsx}",
    "./pages/**/*.{ts,tsx}",
  ],
  theme: {
    extend: {
      colors: {
        background: "hsl(var(--background))",
        foreground: "hsl(var(--foreground))",
        card: "hsl(var(--card))",
        "card-foreground": "hsl(var(--card-foreground))",
        popover: "hsl(var(--popover))",
        "popover-foreground": "hsl(var(--popover-foreground))",
        primary: "hsl(var(--primary))",
        "primary-foreground": "hsl(var(--primary-foreground))",
        secondary: "hsl(var(--secondary))",
        "secondary-foreground": "hsl(var(--secondary-foreground))",
        muted: "hsl(var(--muted))",
        "muted-foreground": "hsl(var(--muted-foreground))",
        accent: "hsl(var(--accent))",
        "accent-foreground": "hsl(var(--accent-foreground))",
        destructive: "hsl(var(--destructive))",
        "destructive-foreground": "hsl(var(--destructive-foreground))",
        border: "hsl(var(--border))",
        input: "hsl(var(--input))",
        ring: "hsl(var(--ring))",
        sidebar: "hsl(var(--sidebar))",
        "sidebar-foreground": "hsl(var(--sidebar-foreground))",
        "sidebar-primary": "hsl(var(--sidebar-primary))",
        "sidebar-primary-foreground": "hsl(var(--sidebar-primary-foreground))",
        "sidebar-accent": "hsl(var(--sidebar-accent))",
        "sidebar-accent-foreground": "hsl(var(--sidebar-accent-foreground))",
        "sidebar-border": "hsl(var(--sidebar-border))",
        "sidebar-ring": "hsl(var(--sidebar-ring))",
      },
      borderRadius: {
        lg: "var(--radius)",
        md: "calc(var(--radius) - 2px)",
        sm: "calc(var(--radius) - 4px)",
      },
      boxShadow: {
        xs: "var(--shadow-xs)",
        sm: "var(--shadow-sm)",
        DEFAULT: "var(--shadow)",
        md: "var(--shadow-md)",
        lg: "var(--shadow-lg)",
        xl: "var(--shadow-xl)",
        "2xl": "var(--shadow-2xl)",
      },
      fontFamily: {
        sans: ["var(--font-sans)"],
        mono: ["var(--font-mono)"],
        serif: ["var(--font-serif)"],
      },
    },
  },
  plugins: [],
}

export default config

Blocks shadcn

Installation des blocks

cd ui
npx shadcn@latest add dashboard-01
npx shadcn@latest add login-01

Ces commandes vont :

  1. Créer les composants nécessaires dans components/
  2. Installer les dépendances manquantes (si nécessaire)
  3. Adapter les imports selon votre structure

Adaptation du dashboard-01

Le block dashboard-01 inclut une sidebar que nous encapsulons déjà via AppShell. Le layout admin vit désormais dans ui/app/[locale]/admin/layout.tsx :

'use client'

import { AccessGuard } from '@/components/auth/AccessGuard'
import AppShell from '@/components/layout/AppShell'

export default function AdminLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <AccessGuard permissions={['admin:access']}>
      <AppShell>{children}</AppShell>
    </AccessGuard>
  )
}

Adaptation du login-01 (Northern + Google OAuth)

Nous avons conservé la logique du block login-01, mais le formulaire est désormais un composant maison LoginForm qui :

  • applique le thème Northern Light (card bords arrondis, ombres personnalisées) ;
  • affiche une composition deux colonnes (formulaire + panneau logo sur fond #ddd9c4) ;
  • expose un unique bouton OAuth « Continuer avec Google » (les autres fournisseurs sont masqués) ;
  • accepte des props onGoogleLogin, googleDisabled, loading pour piloter les états.

Nouveaux helpers de champ

Créer ui/components/ui/field.tsx pour encapsuler Field, FieldGroup, FieldLabel, etc. (utilisés par le formulaire) :

'use client'

import * as React from 'react'
import { cn } from '@/lib/utils'

export const FieldGroup = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
  ({ className, ...props }, ref) => <div ref={ref} className={cn('grid gap-6', className)} {...props} />
)
// Field, FieldLabel, FieldDescription, FieldSeparator suivent la même idée :
// wrappers légers qui appliquent la typographie Northern et servent de slots.

Composant LoginForm

ui/components/auth/LoginForm.tsx orchestre le layout Northern, les validations Zod et le bouton Google :

export function LoginForm({ onSubmit, onGoogleLogin, googleDisabled, loading }: LoginFormProps) {
  const form = useForm<LoginFormValues>({ resolver: zodResolver(loginSchema), defaultValues: { email: '', password: '' } })

  return (
    <div className="flex flex-col gap-6">
      <Card className="overflow-hidden border-none bg-card shadow-2xl shadow-primary/10">
        <CardContent className="grid p-0 md:grid-cols-2">
          <Form {...form}>
            <form onSubmit={form.handleSubmit(onSubmit)} className="flex h-full flex-col justify-center p-6 md:p-10">
              <FieldGroup>
                {/* titre Northern */}
                <Button type="submit" className="h-11 w-full" disabled={loading}>
                  {loading ? 'Connexion…' : 'Se connecter'}
                </Button>
                {onGoogleLogin ? (
                  <>
                    <FieldSeparator>ou via Google</FieldSeparator>
                    <Field>
                      <Button variant="outline" onClick={onGoogleLogin} disabled={googleDisabled}>
                        {googleDisabled ? <Loader2 className="h-4 w-4 animate-spin" /> : <GoogleIcon className="h-4 w-4 text-primary" />}
                        Continuer avec Google
                      </Button>
                    </Field>
                  </>
                ) : null}
              </FieldGroup>
            </form>
          </Form>
          {/* Panneau logo sur fond #ddd9c4 */}
          <div className="hidden min-h-full md:flex">
            <div className="flex flex-1 items-center justify-center bg-[#ddd9c4] p-10">
              <Image src="/images/logo_auroramind_fond_blanc.png" alt="Aurora Mind" width={320} height={176} priority />
            </div>
          </div>
        </CardContent>
      </Card>
      <FieldDescription className="px-6 text-center text-xs">
        En continuant, vous acceptez nos Conditions…
      </FieldDescription>
    </div>
  )
}

Page /login

ui/app/login/page.tsx instancie ce composant et branche à la fois le login credential et la redirection Google :

'use client'

import { Suspense, useEffect, useMemo, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { toast } from 'sonner'
import { Loader2 } from 'lucide-react'

import { LoginForm, type LoginFormValues } from '@/components/auth/LoginForm'
import { authStore } from '@/stores/authStore'

function LoginPageInner() {
  const router = useRouter()
  const searchParams = useSearchParams()
  const login = authStore((state) => state.login)
  const loginWithToken = authStore((state) => state.loginWithToken)
  const isLoading = authStore((state) => state.isLoading)
  const [oauthProcessing, setOauthProcessing] = useState(false)

  const googleLoginUrl = useMemo(() => `${process.env.NEXT_PUBLIC_API_URL?.replace(/\\/$/, '') || ''}/api/auth/google/login`, [])

  const handleSubmit = async (values: LoginFormValues) => {
    await login(values.email, values.password)
    toast.success('Connexion réussie')
    router.push('/admin')
  }

  const handleGoogleLogin = () => {
    window.location.href = googleLoginUrl
  }

  return (
    <div className="flex min-h-svh flex-col items-center justify-center bg-background p-6 md:p-10">
      <LoginForm onSubmit={handleSubmit} loading={isLoading} onGoogleLogin={handleGoogleLogin} googleDisabled={oauthProcessing} />
    </div>
  )
}

export default function LoginPage() {
  return (
    <Suspense fallback={<div className="flex min-h-svh items-center justify-center bg-background"><Loader2 className="h-6 w-6 animate-spin text-primary" /></div>}>
      <LoginPageInner />
    </Suspense>
  )
}

Note : la logique useEffect complète (non affichée ici) gère token et error retournés par /api/auth/google/login/login?token=….


Composants shadcn/ui

Installation des composants nécessaires

cd ui
npx shadcn@latest add button
npx shadcn@latest add card
npx shadcn@latest add table
npx shadcn@latest add dialog
npx shadcn@latest add input
npx shadcn@latest add select
npx shadcn@latest add slider
npx shadcn@latest add switch
npx shadcn@latest add badge
npx shadcn@latest add label
npx shadcn@latest add tabs
npx shadcn@latest add chart

Configuration shadcn/ui

Créer ui/components.json :

{
  "$schema": "https://ui.shadcn.com/schema.json",
  "style": "default",
  "rsc": true,
  "tsx": true,
  "tailwind": {
    "config": "tailwind.config.ts",
    "css": "app/globals.css",
    "baseColor": "slate",
    "cssVariables": true
  },
  "aliases": {
    "components": "@/components",
    "utils": "@/lib/utils"
  }
}

Créer ui/lib/utils.ts :

import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

Installer les dépendances manquantes :

npm install clsx tailwind-merge

Charts

Installation recharts

npm install recharts

Exemple : Line Chart (documents indexés)

Créer ui/components/charts/DocumentsChart.tsx :

'use client'

import { TrendingUp } from "lucide-react"
import { CartesianGrid, Line, LineChart, XAxis } from "recharts"
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"
import {
  ChartConfig,
  ChartContainer,
  ChartTooltip,
  ChartTooltipContent,
} from "@/components/ui/chart"
import { useQuery } from "@tanstack/react-query"
import { apiFetch } from "@/lib/api"

const chartConfig = {
  documents: {
    label: "Documents",
    color: "var(--chart-1)",
  },
} satisfies ChartConfig

export function DocumentsChart() {
  const { data, isLoading } = useQuery({
    queryKey: ['admin', 'stats', 'documents-timeline'],
    queryFn: () => apiFetch<{ date: string; count: number }[]>('/api/admin/stats/documents-timeline?days=30'),
  })

  if (isLoading) {
    return <Card><CardContent className="h-[300px] flex items-center justify-center">Chargement...</CardContent></Card>
  }

  const chartData = data?.map(item => ({
    date: new Date(item.date).toLocaleDateString('fr-FR', { month: 'short', day: 'numeric' }),
    documents: item.count,
  })) || []

  return (
    <Card>
      <CardHeader>
        <CardTitle>Évolution des documents indexés</CardTitle>
        <CardDescription>30 derniers jours</CardDescription>
      </CardHeader>
      <CardContent>
        <ChartContainer config={chartConfig}>
          <LineChart
            accessibilityLayer
            data={chartData}
            margin={{
              left: 12,
              right: 12,
            }}
          >
            <CartesianGrid vertical={false} />
            <XAxis
              dataKey="date"
              tickLine={false}
              axisLine={false}
              tickMargin={8}
            />
            <ChartTooltip
              cursor={false}
              content={<ChartTooltipContent hideLabel />}
            />
            <Line
              dataKey="documents"
              type="natural"
              stroke="var(--color-documents)"
              strokeWidth={2}
              dot={false}
            />
          </LineChart>
        </ChartContainer>
      </CardContent>
      <CardFooter className="flex-col items-start gap-2 text-sm">
        <div className="flex gap-2 leading-none font-medium">
          Total: {data?.reduce((acc, item) => acc + item.count, 0) || 0} documents
        </div>
      </CardFooter>
    </Card>
  )
}

Exemple : Bar Chart (requêtes par jour)

Créer ui/components/charts/QueriesChart.tsx :

'use client'

import { TrendingUp } from "lucide-react"
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"
import {
  ChartConfig,
  ChartContainer,
  ChartTooltip,
  ChartTooltipContent,
} from "@/components/ui/chart"
import { useQuery } from "@tanstack/react-query"
import { apiFetch } from "@/lib/api"

const chartConfig = {
  queries: {
    label: "Requêtes",
    color: "var(--chart-2)",
  },
} satisfies ChartConfig

export function QueriesChart() {
  const { data, isLoading } = useQuery({
    queryKey: ['admin', 'stats', 'queries-daily'],
    queryFn: () => apiFetch<{ date: string; count: number }[]>('/api/admin/stats/queries-daily?days=7'),
  })

  if (isLoading) {
    return <Card><CardContent className="h-[300px] flex items-center justify-center">Chargement...</CardContent></Card>
  }

  const chartData = data?.map(item => ({
    date: new Date(item.date).toLocaleDateString('fr-FR', { weekday: 'short' }),
    queries: item.count,
  })) || []

  return (
    <Card>
      <CardHeader>
        <CardTitle>Requêtes par jour</CardTitle>
        <CardDescription>7 derniers jours</CardDescription>
      </CardHeader>
      <CardContent>
        <ChartContainer config={chartConfig}>
          <BarChart accessibilityLayer data={chartData}>
            <CartesianGrid vertical={false} />
            <XAxis
              dataKey="date"
              tickLine={false}
              tickMargin={10}
              axisLine={false}
            />
            <ChartTooltip
              cursor={false}
              content={<ChartTooltipContent hideLabel />}
            />
            <Bar dataKey="queries" fill="var(--color-queries)" radius={8} />
          </BarChart>
        </ChartContainer>
      </CardContent>
      <CardFooter className="flex-col items-start gap-2 text-sm">
        <div className="flex gap-2 leading-none font-medium">
          Total: {data?.reduce((acc, item) => acc + item.count, 0) || 0} requêtes
        </div>
      </CardFooter>
    </Card>
  )
}

Autres charts

Suivre le même pattern pour :

  • UsersActivityChart.tsx : Area chart interactif (code fourni par l'utilisateur)
  • SourcesChart.tsx : Pie chart interactif (code fourni)
  • CostsChart.tsx : Bar chart horizontal (code fourni)
  • LatencyChart.tsx : Line chart latence moyenne

Pages admin

Page “Paramètres” (/{locale}/admin/settings)

La page Paramètres est pilotée par ui/components/admin/SettingsPanel.tsx :

  • Navigation menu gauche (desktop) / select (mobile) sur 12 sections : Général, Logs, Rétention, Cache LLM, Modèles & providers, Clés API, Modèles IA, RAG & assistant, Assistant, Pipeline ingestion, Services connectés, Historique.
  • La section Services connectés regroupe :
    • la gestion des source_app / workspaces,
    • la gestion des applications appelantes (caller_app) si activé.
  • La section Modèles IA expose une carte ASSISTANT_CORE (prompt système global versionné) avec aperçu du prompt courant + édition/versionning.

Dashboard admin

Créer ui/app/admin/page.tsx :

'use client'

import { useQuery } from '@tanstack/react-query'
import { apiFetch } from '@/lib/api'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { DocumentsChart } from '@/components/charts/DocumentsChart'
import { QueriesChart } from '@/components/charts/QueriesChart'
import { UsersActivityChart } from '@/components/charts/UsersActivityChart'
import { SourcesChart } from '@/components/charts/SourcesChart'
import { CostsChart } from '@/components/charts/CostsChart'
import { LatencyChart } from '@/components/charts/LatencyChart'
import { FileText, MessageSquare, Users, DollarSign } from 'lucide-react'

interface Stats {
  documents_count: number
  queries_today: number
  active_users_7d: number
  monthly_api_cost: number
}

export default function AdminDashboard() {
  const { data: stats, isLoading } = useQuery({
    queryKey: ['admin', 'stats'],
    queryFn: () => apiFetch<Stats>('/api/admin/stats'),
  })

  return (
    <div className="space-y-6 p-6">
      <div>
        <h1 className="text-3xl font-bold">Tableau de bord</h1>
        <p className="text-muted-foreground">Vue d'ensemble de la plateforme</p>
      </div>

      {/* Cartes métriques */}
      <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
        <Card>
          <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
            <CardTitle className="text-sm font-medium">Documents indexés</CardTitle>
            <FileText className="h-4 w-4 text-muted-foreground" />
          </CardHeader>
          <CardContent>
            <div className="text-2xl font-bold">{stats?.documents_count || 0}</div>
          </CardContent>
        </Card>

        <Card>
          <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
            <CardTitle className="text-sm font-medium">Requêtes aujourd'hui</CardTitle>
            <MessageSquare className="h-4 w-4 text-muted-foreground" />
          </CardHeader>
          <CardContent>
            <div className="text-2xl font-bold">{stats?.queries_today || 0}</div>
          </CardContent>
        </Card>

        <Card>
          <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
            <CardTitle className="text-sm font-medium">Utilisateurs actifs (7j)</CardTitle>
            <Users className="h-4 w-4 text-muted-foreground" />
          </CardHeader>
          <CardContent>
            <div className="text-2xl font-bold">{stats?.active_users_7d || 0}</div>
          </CardContent>
        </Card>

        <Card>
          <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
            <CardTitle className="text-sm font-medium">Coût API mensuel</CardTitle>
            <DollarSign className="h-4 w-4 text-muted-foreground" />
          </CardHeader>
          <CardContent>
            <div className="text-2xl font-bold">{stats?.monthly_api_cost?.toFixed(2) || '0.00'} €</div>
          </CardContent>
        </Card>
      </div>

      {/* Charts */}
      <div className="grid gap-4 md:grid-cols-2">
        <DocumentsChart />
        <QueriesChart />
        <UsersActivityChart />
        <SourcesChart />
        <CostsChart />
        <LatencyChart />
      </div>
    </div>
  )
}

Intégration React Query

Wrapper API

Créer ui/lib/api.ts :

import { authStore } from '@/stores/authStore'

const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:18500'

export async function apiFetch<T>(
  endpoint: string,
  options: RequestInit = {}
): Promise<T> {
  const { token } = authStore.getState()
  const headers = new Headers(options.headers)
  
  if (token) {
    headers.set('Authorization', `Bearer ${token}`)
  }
  headers.set('Content-Type', 'application/json')
  
  const res = await fetch(`${API_BASE}${endpoint}`, {
    ...options,
    headers,
  })
  
  if (res.status === 401) {
    authStore.getState().logout()
    if (typeof window !== 'undefined') {
      window.location.href = '/login'
    }
    throw new Error('Unauthorized')
  }
  
  if (!res.ok) {
    const error = await res.text()
    throw new Error(error || res.statusText)
  }
  
  return res.json() as Promise<T>
}

Exemple d'utilisation

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiFetch } from '@/lib/api'
import { toast } from 'sonner'

// Query
const { data, isLoading, error } = useQuery({
  queryKey: ['admin', 'users'],
  queryFn: () => apiFetch<{ items: User[], total: number }>('/api/admin/users?page=1&limit=20'),
})

// Mutation
const queryClient = useQueryClient()
const { mutate } = useMutation({
  mutationFn: (user: UserCreate) => apiFetch('/api/admin/users', {
    method: 'POST',
    body: JSON.stringify(user),
  }),
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
    toast.success('Utilisateur créé')
  },
  onError: (error: Error) => {
    toast.error(error.message)
  },
})

Formulaires avec RHF + zod

Exemple : Formulaire utilisateur

Créer ui/components/admin/UserForm.tsx :

'use client'

import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import * as z from 'zod'
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from '@/components/ui/dialog'
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { apiFetch } from '@/lib/api'
import { toast } from 'sonner'

const userSchema = z.object({
  email: z.string().email('Email invalide'),
  password: z.string().min(12, 'Le mot de passe doit contenir au moins 12 caractères'),
  passwordConfirm: z.string(),
  full_name: z.string().min(1, 'Le nom est requis'),
  role: z.enum(['admin', 'user']),
}).refine((data) => data.password === data.passwordConfirm, {
  message: 'Les mots de passe ne correspondent pas',
  path: ['passwordConfirm'],
})

type UserFormValues = z.infer<typeof userSchema>

interface UserFormProps {
  open: boolean
  onOpenChange: (open: boolean) => void
  user?: { id: string; email: string; full_name: string; role: string }
}

export function UserForm({ open, onOpenChange, user }: UserFormProps) {
  const queryClient = useQueryClient()
  const form = useForm<UserFormValues>({
    resolver: zodResolver(userSchema),
    defaultValues: {
      email: user?.email || '',
      password: '',
      passwordConfirm: '',
      full_name: user?.full_name || '',
      role: (user?.role as 'admin' | 'user') || 'user',
    },
  })

  const { mutate, isPending } = useMutation({
    mutationFn: (data: UserFormValues) => {
      if (user) {
        return apiFetch(`/api/admin/users/${user.id}`, {
          method: 'PUT',
          body: JSON.stringify({
            full_name: data.full_name,
            role: data.role,
          }),
        })
      } else {
        return apiFetch('/api/admin/users', {
          method: 'POST',
          body: JSON.stringify({
            email: data.email,
            password: data.password,
            full_name: data.full_name,
            role: data.role,
            permissions: [],
          }),
        })
      }
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
      toast.success(user ? 'Utilisateur mis à jour' : 'Utilisateur créé')
      form.reset()
      onOpenChange(false)
    },
    onError: (error: Error) => {
      toast.error(error.message)
    },
  })

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>{user ? 'Modifier' : 'Créer'} un utilisateur</DialogTitle>
          <DialogDescription>
            {user ? 'Modifiez les informations de l\'utilisateur' : 'Créez un nouvel utilisateur'}
          </DialogDescription>
        </DialogHeader>
        <Form {...form}>
          <form onSubmit={form.handleSubmit((data) => mutate(data))} className="space-y-4">
            {!user && (
              <>
                <FormField
                  control={form.control}
                  name="email"
                  render={({ field }) => (
                    <FormItem>
                      <FormLabel>Email</FormLabel>
                      <FormControl>
                        <Input {...field} type="email" />
                      </FormControl>
                      <FormMessage />
                    </FormItem>
                  )}
                />
                <FormField
                  control={form.control}
                  name="password"
                  render={({ field }) => (
                    <FormItem>
                      <FormLabel>Mot de passe</FormLabel>
                      <FormControl>
                        <Input {...field} type="password" />
                      </FormControl>
                      <FormMessage />
                    </FormItem>
                  )}
                />
                <FormField
                  control={form.control}
                  name="passwordConfirm"
                  render={({ field }) => (
                    <FormItem>
                      <FormLabel>Confirmer le mot de passe</FormLabel>
                      <FormControl>
                        <Input {...field} type="password" />
                      </FormControl>
                      <FormMessage />
                    </FormItem>
                  )}
                />
              </>
            )}
            <FormField
              control={form.control}
              name="full_name"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Nom complet</FormLabel>
                  <FormControl>
                    <Input {...field} />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
            <FormField
              control={form.control}
              name="role"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Rôle</FormLabel>
                  <Select onValueChange={field.onChange} defaultValue={field.value}>
                    <FormControl>
                      <SelectTrigger>
                        <SelectValue />
                      </SelectTrigger>
                    </FormControl>
                    <SelectContent>
                      <SelectItem value="user">Utilisateur</SelectItem>
                      <SelectItem value="admin">Administrateur</SelectItem>
                    </SelectContent>
                  </Select>
                  <FormMessage />
                </FormItem>
              )}
            />
            <DialogFooter>
              <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
                Annuler
              </Button>
              <Button type="submit" disabled={isPending}>
                {isPending ? 'Enregistrement...' : user ? 'Modifier' : 'Créer'}
              </Button>
            </DialogFooter>
          </form>
        </Form>
      </DialogContent>
    </Dialog>
  )
}

Notes importantes

  1. Variables d'environnement : Créer .env.local avec NEXT_PUBLIC_API_URL=http://localhost:18500 (stack Docker) ou http://localhost:8000 (API locale via uvicorn)
  2. Fonts : Installer les polices Google Fonts (Plus Jakarta Sans, Lora, Roboto Mono) ou utiliser les fallbacks système
  3. Dark mode : Implémenter un toggle dans la Navbar pour basculer la classe .dark sur <html>
  4. Responsive : Tous les composants doivent être responsive (utiliser les classes Tailwind md:, lg:, etc.)

Checklist d'implémentation

  • Installer toutes les dépendances
  • Configurer Next.js App Router
  • Copier le thème CSS dans globals.css
  • Configurer tailwind.config.ts
  • Installer les blocks shadcn (dashboard-01, login-01)
  • Installer les composants shadcn/ui nécessaires
  • Créer authStore.ts (voir AUTH_FLOW.md)
  • Créer AuthGuard (session) et AccessGuard (permissions)
  • Créer les charts (adapter les codes fournis)
  • Créer les pages admin
  • Créer les composants admin (tables, formulaires)
  • Intégrer React Query partout
  • Tester le flux complet

Troubleshooting (Dépannage)

Le backoffice Aurora Nexus repose sur une séparation Infra (.env) vs Métier (en base via UI Admin), notamment pour les providers/credentials LLM (BYOK).

  • Guide complet : TROUBLESHOOTING.md
  • Cas typiques :
    • POST /api/query → 500 + No cookie auth credentials found : clé OpenRouter invalide/absente côté UI Admin → Providers → Credentials
    • GET /api/admin/providers/credentials → 500 (Fernet) : LLM_CREDENTIALS_ENC_KEY invalide/incohérente
    • MinIO “Invalid credentials / length…” : mot de passe trop court (≥ 8 requis)

On this page