Administration

Layout admin, navigation, tableau de bord, gestion des utilisateurs et registre d'extensions.

features/admin fournit l'interface d'administration : layout, navigation, tableau de bord, gestion des utilisateurs et des rôles. Elle expose un registre runtime qui permet d'ajouter des widgets, des entrées de navigation et des pages sans modifier le core.

Pour l'usage pas à pas, voir Étendre l'admin. Pour distribuer une extension en package npm, voir Créer un module.

Import

// Gardes RSC — sous-chemin dédié (importe next/navigation et next/headers)
import { protectAdmin, isAdmin } from '@lrvo/core/features/admin/protect';

// Registre et navigation — neutre, importable depuis un module
import {
  buildNavigationSections, buildBottomNavItems, getNavItemBasePath, findActiveNavContext,
  registerWidget, registerWidgetFetcher, registerNavItem, registerNavSection,
  registerPage, registerPageMeta, registerTopbarItem,
} from '@lrvo/core/features/admin';

// Composants client
import { AdminHeader, useLrvoConfig } from '@lrvo/core/features/admin/components';

@lrvo/core/features/admin est neutre vis-à-vis de la frontière serveur/client : c'est ce qui permet à un module de l'importer depuis son client.js comme depuis son register-server.js. .../protect est serveur strict et n'est jamais ré-exporté par le barrel, parce qu'il importe next/headers au top-level. Voir Frontières serveur/client.

Pages intégrées

RoutePage
/admin/dashboardTableau de bord et widgets.
/admin/usersListe, création et gestion des utilisateurs.
/admin/rolesRôles et permissions.
/admin/profileProfil de l'utilisateur connecté : informations, photo, sécurité, sessions, apparence.
/admin/confirm-email-changeConfirmation de changement d'adresse.

Gardes

protectAdmin(options?)

Garde serveur. Redirige si l'utilisateur n'est pas connecté ou n'a pas admin.access. Retourne la session.

const session = await protectAdmin();
OptionDéfautRôle
redirectTo'/auth/login'Redirection si non authentifié.
forbiddenRedirect'/'Redirection si authentifié mais non autorisé.

isAdmin()

Retourne true si l'utilisateur courant a admin.access. À utiliser pour masquer un élément, pas pour protéger une route : la protection passe par protectAdmin ou par le champ auth d'une route API.

buildNavigationSections(pathname, userPermissions?)

Construit les sections de la sidebar depuis le registre et marque l'entrée active selon pathname. Les items dont la permission n'est pas dans userPermissions sont exclus ; une section dont tous les items sont exclus disparaît.

const permissions = await getUserPermissions(session.user.id);
const sections = buildNavigationSections('/admin/users', permissions);
// [{ id, title, icon, items: [{ name, href, icon, current }] }]

buildBottomNavItems, getNavItemBasePath, findActiveNavContext

Helpers utilisés par le shell pour les items épinglés en bas, la déduction du basePath et la résolution du contexte actif.

Registre d'extensions

registerWidgetFetcher(id, fetcher, options?)

Enregistre la fonction serveur qui collecte les données d'un widget.

ParamètreTypeRôle
idstringIdentifiant, doit correspondre à celui du widget.
fetcherasync () => objectRetourne les données passées au composant.
options.permissionstringSans cette permission, le fetcher n'est pas exécuté.

permission répète celle de registerWidget, et ce n'est pas une redondance. Le filtrage du rendu masque la tuile mais n'empêche pas la collecte : sans cette option, la charge utile part dans le flux RSC et se retrouve dans la source de la page pour quiconque atteint /admin, tuile masquée ou non.

La permission ne peut pas être lue depuis le registre des composants. Celui-ci est peuplé par des modules 'use client' ; à la première requête d'un processus, la passe RSC tourne avant qu'aucun d'eux ne soit évalué, et le registre est encore vide. Un filtre qui s'appuierait dessus donnerait un tableau de bord partiel au premier chargement et complet aux suivants.

L'omettre laisse le fetcher tourner pour tout le monde. C'est le bon choix pour une tuile qui n'expose qu'un état de sous-système, et le mauvais dès qu'elle expose un décompte, un nom ou un montant.

registerWidget({ id, Component, order?, permission?, alert?, devkit? })

Enregistre le composant client affiché dans le tableau de bord.

ParamètreTypeRôle
idstringIdentifiant, doit correspondre au fetcher.
ComponentReactComponentComposant client.
ordernumberPosition dans la grille. Défaut : 0.
permissionstringLe widget est masqué sans cette permission.
alertbooleanDéclare une tuile qui rend null quand elle n'a rien à signaler. Défaut : false.
devkitbooleanRéserve la tuile au développement : elle n'est rendue que si LRVO_DEVKIT=true. Défaut : false.

Le composant reçoit data (retour du fetcher), loading et sparse. Si le fetcher échoue, data vaut null et loading reste false : traiter ce cas explicitement plutôt que d'afficher un chargement infini.

sparse vaut true quand le tableau de bord porte moins de trois tuiles permanentes, c'est-à-dire moins de trois tuiles déclarées sans alert. Une tuile d'alerte s'en sert pour reprendre sa forme informative au lieu de se taire : une tuile ne sait pas ce qui l'entoure, seul le tableau de bord le sait. Une tuile ordinaire l'ignore.

Un widget peut aussi n'avoir aucun fetcher. Son composant reçoit alors data === null en permanence, ce qui est le comportement attendu pour une tuile entièrement statique. La tuile links du core est dans ce cas.

Une tuile devkit est écartée par le tableau de bord, pas par le registre. LRVO_DEVKIT ne se lit que sur le serveur, alors que le registre des composants est peuplé dans le navigateur : la décision descend en prop jusqu'à DashboardPage, qui retire la tuile avant de compter les tuiles permanentes. getWidgets() la retourne donc toujours, avec son drapeau devkit.

Disposition du tableau de bord

Les tuiles sont posées en maçonnerie, par un flux multi-colonnes et non par une grille. Les hauteurs sont trop inégales pour une grille : une tuile de facturation avec son graphique fait le triple d'une tuile de version, et en grille la hauteur de rangée est celle de la plus haute, ce qui creuse un vide sous toutes les autres. En maçonnerie, chaque tuile s'empile sous la précédente de sa colonne.

Deux conséquences pour qui écrit un widget :

  • L'ordre de lecture est vertical, par colonne. order décide toujours de la séquence, seule sa projection à l'écran change. Ne pas compter sur une tuile qui serait « à droite » d'une autre.
  • Une tuile ne doit porter ni hauteur fixe ni largeur propre. Elle occupe la largeur de sa colonne et prend la hauteur de son contenu. Le conteneur pose déjà l'anti-césure et l'espacement vertical.

Pourquoi l'enveloppe de tuile est un inline-block

Chaque tuile est enveloppée dans un break-inside-avoid inline-block w-full align-top. La classe d'anti-césure seule ne suffit pas : break-inside: avoid n'est qu'une préférence, et Gecko continue de traiter l'enveloppe comme fragmentable. À l'équilibrage des colonnes, il répartit alors la hauteur manquante sur les points de coupure internes de la tuile, ce qui ouvre un trou au milieu du contenu. Le défaut grandit quand le nombre de colonnes augmente pour un nombre de tuiles constant : avec quatre tuiles au tableau de bord, 41 px entre deux liens rapides sur quatre colonnes, 17 px sur trois, rien sur deux ou une. Chromium n'était pas touché.

Un inline-block est monolithique au sens de la spécification de fragmentation : aucun moteur n'a le droit de le couper, il n'y a donc plus rien à répartir à l'intérieur. w-full lui rend la largeur de colonne qu'il perd en devenant inline, align-top neutralise l'alignement sur la ligne de base. Ne pas retirer ces trois classes en croyant qu'elles font double emploi avec l'anti-césure.

L'enveloppe porte en plus empty:hidden. Une tuile qui rend null, faute de donnée à laquelle se fier ou parce qu'elle n'a rien à signaler, ne laisse aucun nœud enfant : sans cette classe, son enveloppe garderait sa marge basse et creuserait un trou de 24 px au milieu de la colonne.

Widgets du tableau de bord

Le core fournit trois tuiles, et elles ferment la marche : 210 à 220 pour ses tuiles de mesure, 250 pour les liens. Ce qui est propre au produit, apporté par un module ou par le projet, passe donc devant ce qui est commun à toutes les installations.

BandePour qui
1 à 99Modules.
100 à 190Widgets de projet.
200 à 250Le core, réservé.

Deux widgets de même ordre se départagent par l'ordre d'import, qui n'est pas stable : choisir un ordre libre plutôt que de compter là-dessus.

idOrdrePermissionContenuAffichéeFraîcheur
health210-Base de données, stockage, courriel.Hors de l'état sain, ou tableau de bord clairsemé.Vérifié à chaque rendu.
update220-Version installée, et version disponible sur le registre.Hors de l'état à jour, ou tableau de bord clairsemé.Registre en direct, cache de 6 h.
links250-Documentation du framework.Si LRVO_DEVKIT=true.Statique, sans fetcher.

La tuile links ne porte que la documentation, et c'est une contrainte, pas un oubli. Le lien vers le site public vit dans l'en-tête de l'admin, voir Bouton « Voir le site ». Une vitrine lrvo.app se lirait comme un doublon de ce bouton, et l'adresse de support de l'éditeur du framework n'a rien à faire dans le tableau de bord d'une application livrée à son propriétaire, qui la confondrait avec LRVO_SUPPORT_EMAIL, la sienne. Ce qui relève du projet se déclare en widget de projet, voir Étendre l'admin.

Elle est déclarée avec devkit: true, et ne s'affiche donc que sous LRVO_DEVKIT=true. La documentation du framework s'adresse à qui développe l'application, pas à qui l'administre une fois livrée : en production, où le devkit reste éteint, la tuile n'existe pas.

health et update sont des tuiles d'alerte, déclarées avec alert: true : elles rendent null tant que tout va bien. Trois lignes vertes et un numéro de version à jour ne se relisent pas après le premier jour, et une tuile permanente qui ne change jamais cesse d'être lue le jour où elle change. Le silence vaut « rien à faire », et tout ce qui n'est pas un « rien à faire » démontré reste affiché : un diagnostic indisponible ou une vérification qui n'a pas conclu ne sont pas des états sains, ils s'affichent.

Le repli des tuiles d'alerte

Se taire suppose qu'il reste autre chose à regarder. Sur une installation fraîche, le core n'affiche aucune tuile permanente, une seule si le devkit est actif : un tableau de bord vide ou d'une seule tuile se lit comme une page qui n'a pas fini de charger.

Le tableau de bord compte donc ses tuiles permanentes, celles qui n'ont pas alert: true. En dessous de trois, il passe sparse à chaque widget, et les deux tuiles d'alerte reprennent leur forme informative : les trois lignes d'état pour health, la version installée et sa note pour update. Le seuil vit dans DashboardPage.client.js.

Le décompte exclut les tuiles d'alerte, et ce n'est pas un détail : les compter reviendrait à faire dépendre leur affichage d'elles-mêmes. Il porte sur les widgets enregistrés, permis et non écartés par devkit, pas sur ceux qui rendront quelque chose : React ne peut pas savoir avant le rendu ce qu'un widget tiers décidera d'afficher. Une tuile de module qui rend null faute de donnée compte donc quand même comme permanente.

Aucune de ces tuiles ne porte de permission. Quiconque atteint /admin a déjà passé protectAdmin(), qui exige admin.access : exiger cette même permission au niveau du widget serait redondant. Elles n'exposent qu'un booléen par sous-système, un numéro de version et des liens. Une tuile qui exposerait un décompte, un nom ou un montant devrait, elle, porter sa permission des deux côtés, sur le composant comme sur le fetcher.

Quand la donnée est collectée

/admin/[[...admin]] est l'attrape-tout de toute l'administration, mais la collecte ne se déclenche que sur le tableau de bord. Toute autre page reçoit un objet vide : elle n'affiche aucune tuile, et faire travailler les fetchers pour jeter leur résultat coûterait une requête par module à chaque navigation.

Les permissions sont donc résolues avant la collecte, et non en parallèle : ce sont elles qui décident quels fetchers ont le droit de tourner.

health

Trois vérifications, choisies pour ce qui peut réellement casser pendant que le serveur tourne, et pour leur coût de quelques millisecondes.

VérificationVert siSinon
Base de donnéestestConnection() répond.Rouge.
StockageLes quatre variables LRVO_STORAGE_* sont renseignées.Orange.
CourrielLRVO_EMAIL_FROM_ADDRESS et la variable propre au fournisseur actif sont renseignées : LRVO_EMAIL_RESEND_APIKEY sous Resend, LRVO_EMAIL_SMTP_HOST sous SMTP.Orange.

Stockage et courriel restent en orange et jamais en rouge : un projet sans téléversement ni courriel transactionnel est une configuration légitime.

La tuile n'apparaît qu'à partir d'un orange. Les trois vérifications sont alors affichées, vertes comprises : c'est le contraste entre les lignes qui dit où chercher. Le fetcher écarté par le Promise.allSettled de la collecte donne une tuile qui affiche « Diagnostic indisponible » plutôt que rien, un diagnostic manquant n'étant pas un diagnostic sain.

Conséquence à connaître avant de s'inquiéter d'une tuile absente : un projet qui n'a configuré ni stockage ni courriel la verra en permanence, un projet complet ne la verra jamais.

La tuile n'appelle aucune vérification de lrvo-doctor. checkEnv et checkVersions font un import dynamique de chaque module installé, et checkAudit lance npm audit en sous-processus bloquant : ces trois-là coûtent de la seconde et n'ont pas leur place dans le rendu d'une page. Elles restent du ressort de la CLI. À l'inverse, la vérification de la base est presque tautologique, puisque protectAdmin() a déjà interrogé la base en amont : elle ne peut virer au rouge que si le pool meurt entre les deux.

update

La tuile interroge le registre privé en direct, à chaque rendu, avec un cache de 6 h assuré par le Data Cache de Next. Elle ne lance jamais de sous-processus npm.

Le token vient du .npmrc du projet, puis de celui de l'utilisateur, via resolveRegistryToken() de core/registry. Le champ source du résultat dit d'où vient la comparaison :

sourceSignification
registryRéponse fraîche du registre.
cacheRepli sur app/.lrvo/update-check.json, écrit par la CLI. La donnée date du dernier build.
unknownNi token ni cache. La tuile affiche la version installée et dit que la vérification est indisponible.

unknown est un état courant en production, où le .npmrc est souvent retiré de l'image finale. La tuile l'affiche tel quel plutôt que d'annoncer « à jour » sur la foi d'une comparaison qui n'a pas eu lieu.

Ce qui décide de son affichage :

RésultatTuile
updateAvailable avec une version cible« Mise à jour disponible », en vert, avec la commande à lancer.
source: 'unknown', ou fetcher écarté« Version du core », note « vérification indisponible ».
source: 'registry' ou 'cache', sans mise à jourRien. La comparaison a eu lieu et ne demande aucune action.
LRVO_UPDATE_CHECK=falseRien. Ne plus vérifier est déjà ce que la configuration demande.

La version installée reste lisible par npm ls @lrvo/core, et lrvo-update la rapporte dès qu'une montée est possible. La tuile ne se charge donc pas d'informer, seulement d'alerter.

LRVO_UPDATE_CHECK=false désactive l'appel réseau. Voir Variables d'environnement.

registerNavSection({ id, title, icon, order? })

ParamètreTypeRôle
idstringIdentifiant de la section.
titlestringTitre affiché.
iconstringNom d'icône, voir Icônes.
ordernumberOrdre d'affichage. Défaut : 0.

registerNavItem({ id, label, icon, href, basePath?, sectionId?, order?, position?, permission? })

ParamètreTypeRôle
idstringIdentifiant de l'entrée.
labelstringTexte affiché.
iconstringNom d'icône.
hrefstringCible du clic.
basePathstringPréfixe des routes considérées « sous » cette entrée, pour l'item actif et le fil d'Ariane. Auto-déduit : si href finit par /list, le suffixe est retiré ; sinon basePath = href. À préciser seulement quand l'auto-déduction ne suffit pas.
sectionIdstringSection parente. Défaut : 'main'.
ordernumberOrdre d'affichage. Défaut : 0.
positionstring'bottom' épingle l'entrée en bas de la sidebar.
permissionstringL'entrée est masquée sans cette permission.

registerPage({ slug, Component, title?, breadcrumbLabel? })

Enregistre une page rendue sous /admin/<slug>.

ParamètreTypeRôle
slugstringSegment d'URL sous /admin/.
ComponentReactComponentComposant client.
titlestringTitre affiché dans le fil d'Ariane client.
breadcrumbLabelstringLabel du fil d'Ariane. Défaut : title.

registerPageMeta({ slug, title, description? })

Enregistre le <title> d'onglet et la <meta name="description">, côté serveur.

registerPage est appelée depuis des fichiers .client.js qui ne s'exécutent que dans le bundle navigateur. Le generateMetadata de Next.js tourne côté serveur, avant : il ne verra jamais un titre déclaré côté client. D'où ce second enregistrement.

Le titre final est "<title> — <appName>". Sans meta pour le slug, il retombe sur "Admin — <appName>".

Où l'appeler :

  • Modules : dans register-server.js, à côté des registerNavItem.
  • Projet consommateur : dans un fichier neutre, sans 'use client', importé en side-effect par app/layout.js.

registerTopbarItem({ id, Component, order?, permission? })

Pose un composant dans la barre supérieure de l'admin, à gauche du menu utilisateur : une cloche de notifications, un sélecteur de langue. L'emplacement existe dans les deux coquilles, sidebar et topbar.

ParamètreTypeRôle
idstringIdentifiant unique. Un second enregistrement du même id remplace le premier.
ComponentReactComponentComposant client, rendu sans props.
ordernumberPosition de gauche à droite. Défaut : 0.
permissionstringL'élément est masqué sans cette permission.

Comme registerWidget, l'appel se fait depuis le point d'entrée client d'un module (client.js) : le composant est rendu dans le navigateur. Il vit sur la barre, et prend donc les jetons chrome-* plutôt que ceux des surfaces.

// client.js d'un module
import { registerTopbarItem } from '@lrvo/core/features/admin';
import NotificationBell from './admin/NotificationBell.client.js';

registerTopbarItem({ id: 'notifications', Component: NotificationBell, order: 10 });

Lecture du registre

Le shell d'admin consomme le registre par ces fonctions. Un module n'en a normalement pas besoin, elles servent à un projet qui compose sa propre interface plutôt que d'utiliser AdminShell.

FonctionRetour
getWidgets()Widgets enregistrés, triés par order, sans filtrage de permission ni de devkit.
collectWidgetData(granted?)Exécute en parallèle les fetchers que granted, la liste des permissions de l'utilisateur, autorise, et retourne { [id]: data }. Sans argument, exécute tout. Un fetcher qui échoue est omis de l'objet, les autres passent quand même : c'est ce qui donne data === null côté composant.
getNavSections() / getNavItems()Sections et entrées, triées par order, sans filtrage de permission. buildNavigationSections() fait le filtrage.
getPage(slug) / getPages()Pages enregistrées.
getPageMeta(slug)Métadonnées de page enregistrées côté serveur.
getTopbarItems()Emplacements de la barre supérieure, triés par order, sans filtrage de permission.

Item actif et fil d'Ariane

L'item actif est celui dont le basePath matche le préfixe le plus long du pathname. Sur /admin/posts/blogue/edit/1, l'item « Posts » (basePath /admin/posts/blogue) est souligné ; sur /admin/posts/blogue/taxonomies/categories, c'est « Catégories » qui gagne, son basePath étant plus spécifique.

Le fil d'Ariane se construit à partir de cet item :

  • [icône] > <section.title> > <item.label> quand la section contient plusieurs items.
  • [icône] > <section.title> quand la section n'en contient qu'un. C'est le cas du « lien direct » de la sidebar : la section s'affiche comme un bouton unique portant son titre.
  • Suivi de > Nouveau ou > Modification quand l'URL contient /new ou /edit/:id après le basePath.

Pour personnaliser ces labels, enregistrer une page avec un slug <root>:new ou <root>:edit, où <root> est le premier segment après /admin/ :

registerPage({ slug: 'posts:edit', breadcrumbLabel: "Modifier l'article" });
registerPage({ slug: 'posts:new',  breadcrumbLabel: 'Nouvel article' });

Composants d'admin

@lrvo/core/features/admin/components expose les composants client du shell.

ComposantRôle
AdminHeaderEn-tête de page : titre, description, bouton retour, action.
AdminShellShell complet. Rend la coquille choisie par l'option shell du thème.
AdminSidebar, AdminTopParties de la coquille sidebar. La sidebar sert aussi de tiroir mobile à la coquille topbar.
ThemeToggleBascule de thème.
UserCreateModal, UserEditModal, RoleEditModalModales de gestion.
LrvoConfigProviderFournit le contexte de config. Monté par AdminShell ; à monter soi-même seulement pour une interface qui remplace le shell.
useLrvoConfig()Lit la config descendue par AdminLayout : seo, siteName, seoSeparator, seoTitleTemplate, siteLink, shell, chrome.

AdminHeader

PropTypeRôle
titlestringTitre de la page.
descriptionstringSous-titre. Optionnel.
badgeReactNodeBadge d'état, rendu à côté du titre. Il ne va pas dans action : voir Design system.
backHrefstringAffiche un bouton retour vers cette URL.
backLabelstringLabel du bouton retour. Défaut : ← Retour.
actionReactNodeÉlément à droite, typiquement un bouton de création. Trois contrôles maximum, tous à la taille md.

useLrvoConfig()

Les composants client de l'admin ne lisent jamais process.env : une variable sans préfixe NEXT_PUBLIC_ n'existe pas dans le bundle navigateur. AdminLayout lit les flags côté serveur et les fait descendre par le contexte de config d'AdminShell.

const { seo, siteName, seoSeparator, seoTitleTemplate, siteLink, shell, chrome } = useLrvoConfig();

shell et chrome viennent du thème du projet.

Coquilles

shellDisposition
sidebarSidebar de 230 px à gauche, barre de 48 px avec le fil d'Ariane et le menu utilisateur.
topbarBarre de 56 px : marque, navigation, emplacements, menu utilisateur. Le contenu prend toute la largeur.

La coquille topbar lit les mêmes sections que la sidebar. Une section à un seul item y reste un lien, une section à plusieurs items devient un menu déroulant. Sous 1024 px, la navigation ne tient plus sur une ligne : le bouton de menu ouvre le tiroir de la sidebar, commun aux deux coquilles.

La marque est le logo de LRVO_LOGO_URL quand il est configuré, sinon le nom de l'application. Sur une barre foncée (chrome: 'dark', ou le mode sombre), c'est LRVO_LOGO_URL_DARK qui s'affiche.

Bouton « Voir le site »

Une icône collée à droite du nom de l'application, dans l'en-tête de la sidebar et dans la barre du haut en mobile. Elle ouvre la racine du site, /, dans un nouvel onglet.

Elle n'apparaît que s'il existe un site public. AdminLayout calcule siteLink côté serveur :

LRVO_ADMIN_SITE_LINKBouton
trueToujours affiché, par exemple pour un site public écrit à la main dans app/(public)/.
falseToujours masqué, même avec Pages ou Posts.
Absente, ou toute autre valeurAffiché dès qu'un résolveur de contenu public est enregistré au boot, voir Contenu public. C'est le cas de @lrvo/module-pages et @lrvo/module-posts.

Un core seul n'a pas de site public : la racine du starter n'est qu'un placeholder, et toute autre URL répond 404. Le bouton reste donc masqué par défaut.

DevKit

Section de l'admin réservée au développement : galerie de composants et catalogue d'icônes. Activée par LRVO_DEVKIT=true, jamais rendue autrement.

Le même flag affiche les tuiles du tableau de bord déclarées avec devkit: true, dont la tuile links du core. Voir Widgets du tableau de bord.

RouteContenu
/admin/devkit/componentsGalerie des composants partagés.
/admin/devkit/iconsCatalogue d'icônes.

Ajouter un widget core

Les widgets intégrés au core suivent le même patron, avec une étape de plus : les déclarer dans les index d'auto-enregistrement.

// src/features/admin/widgets/myWidget.server.js
import { registerWidgetFetcher } from '../registry.js';
registerWidgetFetcher('myWidget', async () => ({ /* … */ }), { permission: 'my.permission' });

// src/features/admin/widgets/index.server.js
import './myWidget.server.js';
// src/features/admin/widgets/myWidget.client.js
'use client';
import { registerWidget } from '../registry.js';
registerWidget({ id: 'myWidget', Component: MyWidget, order: 210 });

// src/features/admin/widgets/index.client.js
import './myWidget.client.js';

Oublier une ligne dans l'un des deux index fait disparaître la tuile sans aucune erreur, ni au build ni à l'exécution. C'est le raté le plus fréquent : vérifier les deux index avant de chercher ailleurs.

Sur cette page