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
- Installation et configuration
- Structure du projet
- Thème et styles
- Blocks shadcn
- Composants shadcn/ui
- Charts
- Pages admin
- Intégration React Query
- Formulaires avec RHF + zod
Installation et configuration
Dépendances à installer
cd ui
npm installLes 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é TSThè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 configBlocks shadcn
Installation des blocks
cd ui
npx shadcn@latest add dashboard-01
npx shadcn@latest add login-01Ces commandes vont :
- Créer les composants nécessaires dans
components/ - Installer les dépendances manquantes (si nécessaire)
- 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,loadingpour 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
useEffectcomplète (non affichée ici) gèretokeneterrorretourné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 chartConfiguration 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-mergeCharts
Installation recharts
npm install rechartsExemple : 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 gestion des
- 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
- Variables d'environnement : Créer
.env.localavecNEXT_PUBLIC_API_URL=http://localhost:18500(stack Docker) ouhttp://localhost:8000(API locale viauvicorn) - Fonts : Installer les polices Google Fonts (Plus Jakarta Sans, Lora, Roboto Mono) ou utiliser les fallbacks système
- Dark mode : Implémenter un toggle dans la Navbar pour basculer la classe
.darksur<html> - 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) etAccessGuard(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 → CredentialsGET /api/admin/providers/credentials→ 500 (Fernet) :LLM_CREDENTIALS_ENC_KEYinvalide/incohérente- MinIO “Invalid credentials / length…” : mot de passe trop court (≥ 8 requis)