Aller au contenu

Construire son propre admin

Choisir entre l'admin Fraza, un admin construit par le projet et pas d'admin du tout, puis monter son propre cadre autour des écrans des modules, ou écrire tous ses écrans sur les fonctions des modules.

L'admin Fraza est prête dès l'installation, et la plupart des projets s'en contentent. Un projet qui veut son propre design, sa propre navigation ou des écrans faits pour son métier construit le sien, avec les mêmes briques que l'admin Fraza utilise. Ce guide s'adresse à ce projet-là.

Le modèle est celui de l'espace client : le core fournit les données, les actions, les permissions et les écrans des modules, le projet décide de la mise en page. Voir Le site, l'espace client et l'admin.

Trois choix

Le choix se déclare dans le package.json du projet, sous fraza.admin :

{
  "fraza": { "admin": "custom" }
}
ValeurCe que le projet obtientFRAZA_ADMIN
frazaL'admin Fraza, dans app/(admin)/admin. C'est le défaut : un projet sans le champ a cette admin.true
customUn admin construit par le projet, à l'adresse de son choix.true
nonePas d'admin : le site n'a pas d'équipe.false

npx @fraza/start pose la question à la création du projet, puis demande l'adresse de l'admin. Les drapeaux --admin=fraza|custom|none et --admin-path=/espace répondent d'avance.

Le champ décide des fichiers que fraza-doctor attend. Avec custom ou none, il n'exige plus les fichiers de l'admin Fraza, et fraza-doctor --fix ne les recrée pas, même lors d'une mise à jour.

FRAZA_ADMIN, lui, décide de ce qui répond. Il dit si le site a une équipe : des comptes d'équipe, qui détiennent admin.access, et les routes d'API de l'admin (auth: 'admin'). Un admin construit par le projet a besoin des deux : la variable reste à true. Le doctor signale un choix qui contredit la variable.

Deux façons de construire

Un admin construit par le projet se situe quelque part entre deux extrêmes, et peut mélanger les deux :

  • son cadre, les écrans des modules : le projet écrit la mise en page, la navigation et l'en-tête, et rend dedans les écrans que les modules enregistrent déjà (factures, articles, formulaires). C'est le chemin court : changer le design ne demande pas de réécrire l'éditeur de factures ;
  • tous ses écrans : le projet écrit chaque écran, et appelle les fonctions que chaque module exporte sous ./server. C'est le chemin pour un admin fait sur mesure.

Une page propre au projet se pose à côté des écrans des modules, dans son propre dossier de route. Les deux cohabitent sans réglage.

Choisir le chemin

Une admin, Fraza ou construite par le projet, vit au chemin de FRAZA_ADMIN_PATH, /admin par défaut. Pour l'admin Fraza, fraza-doctor --fix place ses fichiers à ce chemin : voir Changer l'adresse de l'admin. Un admin du projet monte sa route au même chemin.

.env.local

FRAZA_ADMIN_PATH=/gestion

La navigation, les liens des écrans, les courriels et les notifications des modules suivent cette variable : un avis « Nouvelle soumission » mène à /gestion/forms/submissions/12. Next.js ne laisse pas une page lire le chemin où elle est montée, d'où la variable, comme FRAZA_ACCOUNT_PATH pour l'espace client.

Son cadre, les écrans des modules

Trois fichiers suffisent. npx @fraza/start --admin=custom les écrit sous app/(admin)<chemin>/ et components/admin/, avec un quatrième, error.js, qui reprend l'écran d'erreur de l'admin Fraza (@fraza/core/features/admin/error) en attendant celui du projet.

Le layout

// app/(admin)/admin/layout.js
import FrazaModulesClient from '@/app/.fraza/admin.client.js';
import FrazaBlocksClient from '@/app/.fraza/blocks.client.js';
import { protectAdmin } from '@fraza/core/features/admin/protect';
import { getAdminBrand, getAdminConfig, getAdminNavigation } from '@fraza/core/features/admin/server';
import { FrazaConfigProvider, NotificationBell, UserMenu } from '@fraza/core/features/admin/components';
import { logoutAction } from '@fraza/core/features/auth/actions';
import AdminNav from '@components/admin/admin-nav';

export default async function Layout({ children }) {
  const session = await protectAdmin();
  const { appName } = getAdminBrand();
  const { sections } = await getAdminNavigation(session);
  const { name, email, image } = session.user;

  return (
    <>
      <FrazaModulesClient />
      <FrazaBlocksClient />
      <FrazaConfigProvider value={getAdminConfig()}>
        <div className="flex min-h-screen bg-canvas text-fg">
          <aside className="w-60 border-r border-line bg-surface p-4">
            <p className="font-semibold">{appName}</p>
            <AdminNav sections={sections} />
          </aside>
          <div className="flex-1">
            <header className="flex h-[60px] items-center justify-end gap-2 border-b border-line px-6">
              <NotificationBell tone="surface" />
              <UserMenu user={{ name, email, image }} onLogout={logoutAction} />
            </header>
            <main className="p-6">{children}</main>
          </div>
        </div>
      </FrazaConfigProvider>
    </>
  );
}

Chaque pièce a sa raison :

  • protectAdmin() renvoie vers la connexion sans session, et vers la destination client sans admin.access. C'est la garde de toute page de l'admin.
  • FrazaModulesClient et FrazaBlocksClient enregistrent, dans le navigateur, les écrans des modules et les blocs de l'éditeur. Sans eux, un écran de module lève « has no client entry ». Ils se rendent dans le layout de l'admin et pas dans le layout racine : chaque page publique téléchargerait sinon le code de l'admin.
  • FrazaConfigProvider donne aux écrans des modules ce qu'ils lisent du serveur : le fuseau du site, le SEO, le chemin de l'admin. getAdminConfig() le calcule.
  • getAdminNavigation(session) rend les sections et les entrées que ce compte a le droit de voir, déjà filtrées par permission, avec leurs liens au bon chemin. Il rend aussi bottomItems, les entrées du pied de la navigation, pageTitles, les titres des pages ouvertes à ce compte, pour un fil d'Ariane, et permissions. Aucune entrée n'est marquée active : un layout ne reçoit pas l'adresse, c'est la navigation, côté client, qui la lit.

Les imports passent par l'alias @/ : le dossier de l'admin suit FRAZA_ADMIN_PATH, et un chemin relatif changerait de profondeur avec lui.

NotificationBell et UserMenu sont facultatifs. tone="surface" pose la cloche sur une barre claire, tone="chrome" sur la barre de l'admin Fraza.

La page

// app/(admin)/admin/[[...admin]]/page.js
import { AdminScreen, resolveAdminPage } from '@fraza/core/features/admin/server';
import { getAppName } from '@fraza/core/shared/config';

export async function generateMetadata({ params }) {
  const page = await resolveAdminPage(await params);
  return { title: page ? `${page.meta.title} — ${getAppName()}` : 'Page introuvable' };
}

export default async function Page({ params }) {
  const { admin = [] } = await params;
  return <AdminScreen segments={admin} />;
}

AdminScreen rend l'écran enregistré pour le premier segment : /gestion/billing/invoices ouvre l'écran billing, qui lit la suite de l'adresse. Un segment inconnu, ou un écran dont le compte n'a pas la permission, répond 404 : la même réponse, pour que l'adresse ne dise pas ce qui existe.

Le segment [[...admin]] est facultatif : l'adresse de l'admin seule ouvre le tableau de bord. Une route d'un autre nom passe ses segments tels quels à AdminScreen et à resolveAdminPage.

La navigation

// components/admin/admin-nav.js
'use client';

import Link from 'next/link';
import { usePathname } from 'next/navigation';

export default function AdminNav({ sections }) {
  const pathname = usePathname();
  return (
    <nav aria-label="Administration">
      {sections.map(section => (
        <div key={section.id}>
          <p>{section.title}</p>
          {section.items.map(item => (
            <Link
              key={item.id}
              href={item.href}
              aria-current={pathname === item.href || pathname.startsWith(`${item.basePath}/`) ? 'page' : undefined}
            >
              {item.name}
            </Link>
          ))}
        </div>
      ))}
    </nav>
  );
}

La comparaison s'arrête à la barre oblique : sans elle, /gestion/forms marquerait aussi /gestion/forms-archive comme actif.

item.icon porte le nom d'une icône de l'admin. getAdminIcon(name) de @fraza/core/features/admin la résout, pour qui veut les afficher.

Tous ses écrans

Chaque module exporte sous ./server ses lectures et ses écritures. Une écriture fait tout le travail que l'admin Fraza ferait : émettre une facture l'envoie au client, supprimer un article retire le contenu public du cache. Le projet appelle ces fonctions dans une server action :

// app/(admin)/admin/factures/actions.js
'use server';

import { requirePermission } from '@fraza/core/features/admin/protect';
import { issueInvoice } from '@fraza-pro/module-billing/server';

export async function emettre(invoiceId) {
  await requirePermission('billing.manage');
  return issueInvoice(invoiceId);
}

Les fonctions de ./server ne vérifient aucune permission. Elles servent aussi aux tâches planifiées et aux webhooks, où personne n'est connecté. La garde appartient à l'appelant : requirePermission() dans une server action, ou la permission d'une route d'API. requirePermission() lève une AdminAccessError dont le message s'affiche tel quel : site sans équipe, pas de session, pas d'admin.access, ou pas la permission demandée.

La liste des fonctions de chaque module est dans sa section, sous Modules.

Les comptes de l'équipe se gèrent de la même façon, avec @fraza/core/features/auth/team : listUsers(), createUserAsAdmin(), changeUserEmailAsAdmin(), sendPasswordLinkAsAdmin(), et les rôles de @fraza/core/users. Voir Authentification.

Un écran client peut aussi appeler les routes d'API des modules, /za/api/admin/..., comme le font les écrans de l'admin Fraza. Elles exigent admin.access et la permission de la route.

Les liens vers l'admin

Un module ne connaît pas les adresses d'un admin construit par le projet. Il demande un lien au core, qui le construit sous FRAZA_ADMIN_PATH :

import { adminHref } from '@fraza/core/features/admin';

adminHref('billing', 'invoices', 42); // '/gestion/billing/invoices/42'

Un admin dont les adresses ne suivent pas ce découpage le dit au core une fois, au démarrage :

// instrumentation.js, après initializeFraza()
import { setAdminHrefResolver } from '@fraza/core/features/admin';

setAdminHrefResolver((segments) => {
  if (segments[0] === 'billing' && segments[1] === 'invoices') return `/gestion/factures/${segments[2] ?? ''}`;
  return undefined; // le défaut
});

Côté navigateur, useAdminHref() et useAdminPath() de @fraza/core/features/admin/components construisent les mêmes liens, à partir de la config que FrazaConfigProvider diffuse.

Pas d'admin du tout

Avec "admin": "none" et FRAZA_ADMIN=false, le site n'a pas d'équipe : aucun compte n'est un compte d'équipe, les routes de l'admin répondent 404, et adminHref() renvoie null. Les modules continuent de servir leurs pages publiques, leurs tâches planifiées et leurs webhooks. Un module ne met aucun lien vers l'admin dans un courriel quand adminHref() renvoie null.

Vérifier

  1. Lancer npx fraza-doctor : aucun constat sur app/(admin)/admin/… avec custom ou none, et aucun avertissement contract.surface.
  2. Ouvrir l'adresse de l'admin dans une fenêtre privée : la page de connexion s'affiche.
  3. Se connecter avec un compte d'équipe : le cadre du projet s'affiche, avec le tableau de bord.
  4. Ouvrir une entrée de la navigation d'un module : son écran s'affiche dans le cadre.
  5. Changer FRAZA_ADMIN_PATH, déplacer le dossier de la route au même chemin, puis relancer : la navigation suit.

Notes

L'admin Fraza et un admin du projet peuvent cohabiter, à deux chemins. FRAZA_ADMIN_PATH porte celui de l'admin Fraza, que fraza.admin laisse à fraza. L'admin du projet monte sa route à un autre chemin, et appelle setAdminHrefResolver() si les liens des modules doivent mener chez elle.

Un écran de module suppose FrazaConfigProvider. Rendu sans lui, il ne connaît ni le fuseau du site ni le chemin de l'admin, et ses liens retombent sur /admin.

Le thème s'applique aussi. Les jetons de couleur du theme.js du projet habillent un cadre maison comme l'admin Fraza. Les options admin.shell et admin.chrome ne concernent que l'admin Fraza. Voir Personnaliser l'apparence.

Sur cette page