Aller au contenu

Espaces utilisateur et admin

Référence des pages servies après connexion : l'accueil de l'espace utilisateur (/u), le dashboard d'administration (/admin) et la page Réglages admin (/admin/reglages). Pour le déroulé de connexion qui y mène, voir le how-to Se connecter à Kirexo ; pour la séparation en deux firewalls, voir Architecture des firewalls.

Vue d'ensemble

Page Route Chemin Méthode Controller Protection
Accueil utilisateur app_user_home /u GET src/Controller/AppUserHomeController.php Voter VIEW_OWN + firewall user
Dashboard admin app_admin_dashboard /admin GET src/Controller/AppAdminDashboardController.php ROLE_ADMIN + firewall admin
Réglages admin app_admin_settings /admin/reglages GET, POST src/Controller/AppAdminSettingsController.php ROLE_ADMIN + firewall admin
Détail article admin app_admin_article_show /admin/articles/{id} GET src/Controller/AppAdminArticleShowController.php ROLE_ADMIN + Voter ARTICLE_VIEW + firewall admin

Le dashboard admin est le poste de modération : la liste des articles publiés y est intégrée, il n'existe pas de page liste séparée.

L'accueil utilisateur et le dashboard admin sont chacun la cible de redirection après connexion (default_target_path) de leur firewall respectif. Voir la Référence security.yaml.

Le dashboard admin est alimenté par des services applicatifs synchrones injectés dans le controller (chaîne controller → service → repository, sans bus) ; l'accueil utilisateur, plus fin, lit directement ses carnets via NotebookRepositoryInterface. Voir Architecture.

Accueil de l'espace utilisateur (app_user_home, /u)

Page d'accueil de l'espace connecté, servie par src/Controller/AppUserHomeController.php (controller invokable) et rendue avec templates/user/home.html.twig. Le template étend base.html.twig (et non base_user.html.twig) : il porte sa propre barre du haut, sans l'en-tête partagé. Elle est orientée carnets : barre du haut, tête, onglets Actifs/Archivés et états vides. Les sections carnet, la frise et la recherche sont ajoutées par les lots suivants.

Accès

  • Réservé aux comptes authentifiés sur le firewall user. La règle access_control { path: ^/u, roles: ROLE_USER } exige ROLE_USER.
  • Le compte connecté est injecté dans le controller via #[CurrentUser].
  • L'accès est en outre soumis au Voter UserDataVoter avec l'attribut VIEW_OWN, déclaré par #[IsGranted(UserDataVoter::VIEW_OWN, subject: 'user')] sur le controller. Le sujet du vote est le compte courant lui-même : un utilisateur ne peut consulter que ses propres données. Voir Voter UserDataVoter.

Données passées au template

Le controller ne calcule rien : il transmet le compte courant et ses carnets, lus via NotebookRepositoryInterface (src/Repository/NotebookRepositoryInterface.php).

Variable Twig Source
user #[CurrentUser] User
activeNotebooks NotebookRepositoryInterface::findActiveByOwner($user) — carnets non archivés (archivedAt IS NULL)
archivedNotebooks NotebookRepositoryInterface::findArchivedByOwner($user) — carnets archivés (archivedAt IS NOT NULL)

Voir Carnets : modèle de données et migration mono → multi-carnets.

Barre du haut

Barre sticky (classe ud-topbar) portant, de gauche à droite :

  • le logo Kirexo, lien vers app_user_home ;
  • un sélecteur de thème segmenté clair / sombre / système (conteneur .theme-toggle, boutons [data-theme-mode] pilotés par theme.js) ;
  • l'e-mail du compte courant (user.email, classe ud-user), masqué sous 680 px ;
  • un bouton « Espace admin » (POST vers app_switch_to_admin, jeton CSRF switch_space), rendu uniquement pour les comptes mixtes sous la condition Twig is_granted('CAN_SWITCH_SPACE'). Voir Basculer entre les espaces ;
  • un bouton « Se déconnecter » (POST vers app_logout).

Tête et onglets

  • Une tête : eyebrow « Mon espace », titre « Bonjour {prénom}. » (user.firstName) et un sous-titre.
  • Un bouton « Nouveau carnet » (fantôme), affiché seulement s'il existe au moins un carnet actif. Il est inerte à ce stade (la modale de création est câblée par un lot ultérieur).
  • Une barre d'onglets Actifs / Archivés avec compteurs (activeNotebooks|length, archivedNotebooks|length) et une légende à deux entrées : « Publié » et « Brouillon ». La bascule d'onglet est assurée côté client par le controller Stimulus tabs (assets/controllers/tabs_controller.js), qui synchronise aria-selected sur les onglets et l'attribut hidden sur les panneaux role="tabpanel", sans requête serveur (onglet Actifs sélectionné par défaut). Le parcours utilisateur correspondant est décrit dans le how-to Créer, renommer, archiver et supprimer un carnet.

États et contenu

  • Aucun carnet actif : un état vide (ud-blank) affiche « Aucun carnet pour l'instant. » et un CTA primaire « Créer mon premier carnet » (également inerte à ce stade). Aucun bouton « Importer ».
  • Au moins un carnet actif : une liste minimale (ud-list) rend chaque carnet actif (pastille + notebook.name + notebook.tagline si présent), avec un bouton « Écrire » par carnet. Ce bouton est un formulaire POST vers la route app_article_new paramétrée par le carnet/u/carnets/{id}/articles/nouveau, où {id} est l'UUID v7 du carnet dont le bouton est cliqué (contrainte Requirement::UUID_V7) —, jeton CSRF new_article conservé. L'article ainsi créé est rattaché au carnet source (celui du bouton cliqué), et non plus systématiquement au premier carnet de l'utilisateur : ce comportement transitoire n'existe plus. L'accès est protégé au niveau du controller src/Controller/AppArticleNewController.php : le carnet est résolu depuis {id} par l'EntityValueResolver (404 si aucun carnet ne porte cet id), puis #[IsGranted(NotebookVoter::EDIT, subject: 'notebook')] vérifie l'appartenance (403 si le carnet n'appartient pas au compte courant). Ce bloc de liste est un stub remplacé par la section carnet complète dans un lot ultérieur.
  • Panneau Archivés (#ud-view-archived, masqué par défaut) : s'il n'existe aucun carnet archivé, un état vide (ud-blank) affiche « Aucune archive. » ; sinon, une note d'en-tête (ud-archived-note) rappelle que ces carnets sont hors ligne, puis la liste des carnets archivés — pliés par défaut, en lecture seule, avec les actions Restaurer (POST app_notebook_restore) et Supprimer (POST app_notebook_delete). L'archivage d'un carnet actif se fait via le menu ⋯ (POST app_notebook_archive).

Trois accès de navigation relient la home application (/), la home utilisateur (/u) et l'éditeur d'article (/u/articles/<uid>/edition). Ils sont indépendants de la bascule entre espaces utilisateur et admin, qui relève du header partagé — voir Header et menu utilisateur.

Accès à /u depuis la home application

La home application (app_home, GET /, src/Controller/AppHomeController.php, template templates/home/index.html.twig) affiche un CTA « Aller à mon espace » menant à app_user_home.

  • Rendu sous le titre de la plateforme (.hm-masthead), dans un bloc .hm-sub, sous forme de bouton primaire (btn btn-primary btn-sm, data-testid="home-go-user").
  • Conditionné à app.user : il apparaît dès qu'un compte est connecté, quel que soit son rôle — y compris via un cookie remember_me. La condition Twig est app.user (variable globale), pas is_granted('IS_AUTHENTICATED_FULLY') : la home /u reste par ailleurs gardée par son propre Voter VIEW_OWN.
  • Absent pour un visiteur anonyme.

Le controller ne porte aucune logique dédiée : la condition est un simple test Twig, la cible est résolue par path('app_user_home') (jamais d'URL en dur). Dans l'état vierge de la home (aucun article publié), le CTA « Écrire le premier article » pointe déjà vers app_user_home et remplit le même besoin.

Sortie explicite de l'éditeur vers /u

L'éditeur d'article (/u/articles/<uid>/edition, template templates/user/article/edit.html.twig) porte en haut à gauche de sa barre (.editor-top > .left) un lien de retour vers la home utilisateur :

  • lien <a> (navigation GET simple) vers app_user_home, libellé « Mon espace » précédé d'une icône flèche-retour (aria-hidden), classe editor-back sur un bouton discret btn btn-ghost btn-sm ;
  • data-testid="editor-exit" et aria-label="Revenir à mon espace" pour le ciblage et les lecteurs d'écran.

C'est un pattern « retour vers le hub » (et non une croix « Fermer ») : l'autosave de l'éditeur (controller autosave) sécurise déjà le contenu, donc le retour ne déclenche aucune confirmation destructrice. Le lien est placé côté gauche, zone de navigation/contexte ; les actions primaires (Publier, Aperçu, menu ⋯) restent à droite dans .editor-tools.

Dashboard admin (/admin)

Page d'accueil de l'espace d'administration, servie par src/Controller/AppAdminDashboardController.php et rendue avec templates/admin/dashboard.html.twig (layout base_admin.html.twig). Classes préfixées bo-.

Accès

  • Réservé aux comptes authentifiés sur le firewall admin, porteurs de ROLE_ADMIN — appliqué à la fois par la règle access_control { path: ^/admin, roles: ROLE_ADMIN } et par l'attribut #[IsGranted('ROLE_ADMIN')] sur le controller.
  • Pour un visiteur connecté à l'espace utilisateur, /admin renvoie un 404 (l'admin se masque), via AdminAreaAccessSubscriber. Détails dans Masquer l'admin : 404 plutôt que 403.

Contenu affiché : cockpit de conformité

Le dashboard est le poste de modération centré conformité. Il porte le titre « Modération & conformité » et n'affiche que le contenu public (articles published) : aucun brouillon ni corbeille d'utilisateur. Le controller (src/Controller/AppAdminDashboardController.php) lit ses données directement via ArticleRepositoryInterface, sans service dédié :

  • ArticleRepositoryInterface::findPublished() — les articles publiés, plus récents d'abord (liste + total) ;
  • ArticleRepositoryInterface::findPublishedAnalyzedIds() — les identifiants des articles publiés déjà analysés, normalisés en chaînes RFC 4122 pour un test d'appartenance en O(1) par ligne dans le template (pas de N+1).

Trois indicateurs (bo-stats) sont affichés en tête, dérivés de ces deux lectures :

Indicateur Source
Articles publiés publishedCount = count(findPublished())
Analysés analyzedCount = count(findPublishedAnalyzedIds())
À analyser toAnalyzeCount = publishedCount - analyzedCount

L'indicateur « À analyser » reçoit la classe d'accent bo-stat--accent tant que toAnalyzeCount > 0 : il est mis en avant tant qu'il reste des articles publiés non analysés.

Indicateurs cliquables = filtres de la liste (?filter=)

Les trois indicateurs sont des liens (bo-stat bo-stat--link) qui filtrent la liste des articles affichée en dessous. Le filtre est piloté par le paramètre d'URL ?filter= lu dans le controller ($request->query->getString('filter')) : le filtrage est côté serveur, donc l'état est partageable par URL, compatible avec le bouton Précédent du navigateur, et fonctionne sans JavaScript.

Indicateur Cible filter activeFilter Effet
Articles publiés path('app_admin_dashboard') absent all Réinitialise — affiche tous les articles publiés
Analysés path('app_admin_dashboard', { filter: 'analyzed' }) analyzed analyzed Ne garde que les articles déjà analysés
À analyser path('app_admin_dashboard', { filter: 'to-analyze' }) to-analyze to-analyze Ne garde que les articles restant à analyser
  • Toute valeur de filter absente ou inconnue retombe sur default → liste complète (activeFilter = 'all') : aucune URL forgée ne casse la page.
  • Le filtrage se fait en mémoire dans le controller (array_filter sur findPublished() selon l'appartenance à l'ensemble des IDs analysés), pas par une requête SQL distincte.
  • La carte active est mise en évidence par la classe is-active et l'attribut aria-current="true". L'accent bo-stat--accent sur « À analyser » (tant que toAnalyzeCount > 0) reste indépendant du filtre actif.
  • Le titre de la liste s'adapte au filtre (variable listTitles) : « Articles publiés » (all), « Articles analysés » (analyzed) ou « Articles à analyser » (to-analyze), suivi du compteur articles|length.
  • Les états vides sont contextualisés : « Aucun article publié pour l'instant. » si aucun publié, « Aucun article analysé pour l'instant. » sous le filtre analyzed, « Tous les articles publiés ont été analysés. » sous le filtre to-analyze.

Le parcours de filtrage côté utilisateur est décrit dans le how-to Consulter et analyser la conformité d'un article publié.

Sous les indicateurs, un tableau (bo-table bo-articles) liste directement les articles publiés (filtrés selon ?filter=), avec pour chacun :

Colonne Source
Titre article.title — lien vers le détail (app_admin_article_show), ligne cliquable
Auteur article.author.firstName
Analyse badge « Analysé » (.bo-tag--ok) / « Non analysé » (.bo-tag--muted) selon l'appartenance de article.id.toRfc4122() à analyzedIds
Publié le article.publishedAt, formatée d/m/Y ; si absent

Si aucun article n'est publié, le tableau est remplacé par « Aucun article publié pour l'instant. ».

Le corps du dashboard n'expose ni lien « Réglages » ni bouton de bascule d'espace : ces actions vivent dans le menu utilisateur de l'en-tête partagé (<twig:Header space="admin">). Voir Header et menu utilisateur.

Pas de « Mon espace » dans le header admin

Le lien « Mon espace » (action principale .header__primary vers app_user_home) n'apparaît pas dans le header de l'espace admin : il ferait doublon avec l'entrée « Espace user » de la bascule d'espace du menu utilisateur, qui est le chemin d'accès à l'espace utilisateur depuis l'admin. « Mon espace » n'est rendu que sur l'accueil public (space == 'public'). Voir Header et menu utilisateur et Basculer entre les espaces.

L'admin gère les comptes et la plateforme, jamais le contenu

Le dashboard affiche les articles publiés (indicateurs + liste) pour donner à voir l'activité de modération, mais l'espace d'administration n'expose ni rédaction ni publication d'articles — cela relève de l'espace utilisateur. La consultation reste cantonnée au contenu public : uniquement les articles published, jamais un brouillon ni une corbeille d'utilisateur. La frontière est portée par l'ArticleVoter (voir Détail article admin). Voir Architecture.

Réglages admin (/admin/reglages)

Page de configuration de l'instance, servie par src/Controller/AppAdminSettingsController.php et rendue avec templates/admin/settings.html.twig (layout base_admin.html.twig, classes bo-). Elle porte deux formulaires distincts sur la même route GET/POST (chacun avec son traitement Post/Redirect/Get) : la couleur d'accent de la plateforme et la clé API Claude.

Accès

  • Réservé à ROLE_ADMIN (#[IsGranted('ROLE_ADMIN')]), sur le firewall admin. Aucun Voter dédié : la décision « accéder aux réglages » ne dépasse pas le rôle (pas de ressource à scoper).
  • Comme tout /admin, masquée en 404 pour un visiteur connecté à l'espace utilisateur.

Retour vers la home admin

La page Réglages est une destination (pas une modale) : elle porte donc deux affordances de retour vers le dashboard, toutes deux vers app_admin_dashboard.

  • Un fil d'Ariane (<nav class="bo-breadcrumb" aria-label="Fil d'Ariane">) à 2 maillons : Espace admin › Réglages (maillon courant en aria-current="page"), le maillon « Espace admin » ramenant au dashboard.
  • Un bouton retour explicite en complément : un lien btn btn-secondary btn-sm bo-back (data-testid="settings-back"), précédé d'une icône flèche-retour (aria-hidden), libellé « Retour à l'espace admin », pointant vers le dashboard.

Ce n'est jamais une popin : une page de réglages est une destination à part entière, pas une interruption superposée — d'où le retour par navigation de page. Le pourquoi de ce choix (page vs modale) est discuté dans Architecture des firewalls.

Couleur d'accent

Le formulaire App\Form\AccentSettingType (src/Form/AccentSettingType.php) présente les 6 valeurs de l'enum App\Entity\Enum\AccentColor (src/Entity/Enum/AccentColor.php) sous forme de pastilles radio :

Libellé (AccentColor::label()) Valeur backed (data-accent)
Encre encre
Bordeaux bordeaux
Forêt foret
Prune prune
Rouille rouille
Graphite graphite

La valeur par défaut d'une instance neuve est AccentColor::default()Encre.

Persistance et effet

  • La valeur est persistée dans l'entité singleton App\Entity\Setting (src/Entity/Setting.php) — une seule ligne en base, lue via SettingRepositoryInterface::getOrCreate().
  • À la soumission, le service App\Service\Settings\AccentUpdater (src/Service/Settings/AccentUpdater.php) écrit la nouvelle valeur de façon synchrone (flush dans la requête), puis le controller redirige (motif Post/Redirect/Get) vers app_admin_settings.
  • L'accent est rendu côté serveur sur la balise <html data-accent="…"> de chaque page (fonction Twig platform_accent()), donc le changement est visible immédiatement après la redirection, pour tous les visiteurs.

La procédure pas à pas est décrite dans le how-to Définir la couleur d'accent de la plateforme. La mécanique data-accent et la fonction platform_accent() sont détaillées dans Design system : tokens, thèmes et accent.

Clé API Claude

Le second formulaire (App\Form\ClaudeApiKeySettingType, src/Form/ClaudeApiKeySettingType.php) permet de renseigner la clé API Claude servant à l'analyse IA des articles.

  • Write-only masqué : champ unique apiKey en PasswordType, non mappé, always_empty — la clé n'est jamais pré-remplie ni réaffichée. La saisie transite par le DTO immuable App\Dto\ClaudeApiKeySettingDto (src/Dto/ClaudeApiKeySettingDto.php).
  • Chiffrement : à la soumission, le service App\Service\Settings\ClaudeApiKeyUpdater (src/Service/Settings/ClaudeApiKeyUpdater.php) chiffre la clé via App\Service\Security\ClaudeApiKeyCipher (libsodium sodium_crypto_secretbox) puis persiste le ciphertext dans le champ encryptedClaudeApiKey du singleton Setting. La clé en clair n'est jamais stockée.
  • Clé de chiffrement : injectée depuis le vault Symfony Secrets sous CLAUDE_API_KEY_CIPHER_KEY (jamais en base, jamais en clair). Voir CLAUDE_API_KEY_CIPHER_KEY.
  • État : Setting::hasClaudeApiKey() renvoie true dès qu'un ciphertext non vide est présent. Cet état conditionne l'affichage (« Une clé est configurée » vs « Aucune clé configurée ») et l'activation du bouton « Analyser la conformité » sur le détail d'article.
  • Après enregistrement, le flash « Clé API Claude enregistrée. » s'affiche et le controller redirige (Post/Redirect/Get).

La procédure complète (génération du secret de vault + saisie) est décrite dans Configurer la clé API Claude pour l'analyse IA.

Détail admin des articles publiés

Le dashboard intègre la liste des articles publiés (voir Contenu affiché : cockpit de conformité) ; l'espace admin expose en plus le détail d'un article, lui aussi restreint au contenu publié. L'administration ne voit donc que les articles published, sans aucune capacité de rédaction ni de publication (« Admin ≠ publication »).

Le statut d'analyse affiché dans la liste (badges « Analysé » / « Non analysé ») provient de ArticleRepositoryInterface::findPublishedAnalyzedIds() : le libellé est toujours écrit en toutes lettres (jamais couleur seule — RGAA), la puce colorée étant décorative. La ligne est cliquable à la souris (overlay CSS .bo-row-link::after), le lien du titre restant le seul élément focusable/activable au clavier — pas de role="link" ni de handler JS sur le <tr>.

Détail (/admin/articles/{id})

Servi par src/Controller/AppAdminArticleShowController.php, rendu avec templates/admin/article/show.html.twig.

  • Réservé à ROLE_ADMIN, plus une décision du Voter : denyAccessUnlessGranted(ArticleVoter::VIEW, $article). L'admin n'étant pas l'auteur, l'accès n'est accordé que si l'article est publié — un brouillon ou un article en corbeille d'un autre auteur renvoie 403.
  • Affiche le titre et le contenu publié figé en mode aperçu : le controller rend le Markdown source (révision courante article.currentRevision, sinon article.content) via le service partagé App\Service\Article\MarkdownRenderer (GFM assaini par HtmlSanitizer) et le passe au template en previewHtml, injecté sous |raw dans un conteneur .bo-prose. C'est le même rendu qu'à la publication, pas du texte brut. Sans bouton d'édition ni de publication.
  • Porte le panneau « Analyse de conformité » (templates/admin/article/_analysis.html.twig) : un texte de portée toujours visible (risques juridiques FR, indicatif, pas un avis juridique) et un bouton « Analyser la conformité » — intitulé « Relancer l'analyse » si un rapport existe déjà. Le bouton est désactivé tant que Setting::hasClaudeApiKey() est false (avec un lien vers les Réglages) ; actif une fois la clé API configurée. Voir le how-to Consulter et analyser la conformité d'un article publié.
  • Fil d'Ariane à 2 maillons : Espace admin › titre (maillon courant en aria-current="page"), le maillon « Espace admin » ramenant au dashboard — donc à la liste des publiés, celle-ci étant le dashboard. Le retour dans l'espace admin passe toujours par ce fil d'Ariane, jamais par une popin.

La frontière de sécurité est le Voter, pas le filtre de liste

La règle « l'admin ne voit que published » est portée par l'ArticleVoter (attribut ARTICLE_VIEW, src/Security/Voter/ArticleVoter.php), jamais par un check inline isAdmin(). Le filtre findPublished() de la liste du dashboard est une optimisation de chargement (ne pas charger des brouillons inutiles), pas la frontière d'autorisation — celle-ci reste le Voter, vérifié au détail.

Voter UserDataVoter

Déclaré dans src/Security/Voter/UserDataVoter.php. Autorise un compte à consulter uniquement ses propres données.

Élément Valeur
Attribut VIEW_OWN (constante UserDataVoter::VIEW_OWN)
Sujet supporté une instance de App\Entity\User
Règle de vote accordé si l'identifiant du sujet est égal à celui du compte authentifié

Le vote est refusé si le token ne porte pas une instance de User. Ce Voter est aujourd'hui consommé par AppUserHomeController ; il a vocation à garder toute consultation de données utilisateur cantonnée au compte propriétaire.

Voir aussi