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

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
│       ├── chat/page.tsx          # Assistant
│       ├── 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