Skip to content

Points d'extension Umbraco

Le coeur du template. SiteComposer reste le point d'entree Umbraco (IComposer), mais n'accueille plus directement les enregistrements du projet : SiteComposer.cs appartient au template (mises a jour via upstream), donc chaque enregistrement ajoute ici serait une ligne en conflit a chaque merge. Les enregistrements du projet vivent dans deux methodes d'extension sur IUmbracoBuilder, appelees depuis SiteComposer.Compose ; les comportements techniques (404, 500, sitemap, robots, login dev) restent des controllers ou des content finders.

Composer (src/Web/SiteComposer.cs)

IComposer unique, decouvert par AddComposers() dans Program.cs. Compose se limite a :

  • builder.AddIntranet() — enregistrements sur des types vivant dans Intranet.Core/Intranet.Connectivity (voir Intranet.Core.Extensions.IntranetBuilderExtensions, src/Intranet.Core/Extensions/IntranetBuilderExtensions.cs) ;
  • builder.AddIntranetWeb() — enregistrements sur des types restes dans Web : content finder 404, recherche, redirection dossiers/pages, filtrage backoffice par skin, annuaire, full-text/PDF (voir Web.Extensions.IntranetWebBuilderExtensions, src/Web/Extensions/IntranetWebBuilderExtensions.cs) ;
  • le provider 2FA du template, laisse en place : new BackOfficeIdentityBuilder(builder.Services).AddTwoFactorProvider<UmbracoUserAppAuthenticator>(UmbracoUserAppAuthenticator.Name).

Point d'entree pour tout nouveau wire-up (DI, notification handlers, content finders...) : ajouter dans AddIntranetWeb() si le type enregistre vit dans Web, dans AddIntranet() s'il vit dans Intranet.Core/Intranet.Connectivity. Ne jamais enregistrer directement dans SiteComposer.Compose. Voir ../architecture/patterns.md.

Filtrage backoffice par skin (Notifications/BlockGridSkinFilterHandler.cs, Notifications/DataTypeSkinFilterHandler.cs)

Deux INotificationAsyncHandler<UmbracoApplicationStartedNotification> qui appliquent le meme principe : le depot est commun aux deux skins, donc les DataTypes livres par uSync contiennent les choix des deux marques ; au demarrage, apres l'import uSync, chaque handler lit Site:Theme et retire de la config du DataType les entrees de l'autre skin. Un deploiement APOL ne propose donc jamais les blocs ni les couleurs PEL, et inversement, sans dupliquer le moindre fichier uSync.

  • BlockGridSkinFilterHandler : nettoie la liste blocks du BlockGrid CustomBlockGridsBase (feaf0da0-...). Cas a part, car il doit resoudre contentElementTypeKey -> alias via IContentTypeService. Les alias specifiques sont dans les jeux ApolBlocks / PelBlocks.
  • DataTypeSkinFilterHandler : generique, pilote par la table Filters. Chaque entree declare le DataType, le nom du tableau de sa config (items pour un ColorPicker, buttons pour un ButtonPicker) et les valeurs propres a chaque skin. Couvre aujourd'hui Approved Color (couleurs de marque) et Button Picker - Background Pattern (motifs de fond, jeux encore vides).

Pour filtrer une nouvelle liste de choix, ajouter une ligne a Filters — pas de nouvelle classe, pas de JS. Pour ajouter un bloc ou une couleur propre a un skin, ajouter son alias / sa valeur au jeu correspondant, sinon il sera propose aux deux. Les entrees absentes des deux jeux sont communes et toujours conservees (blocs partages, neutres blanc/gris/noir).

Les deux handlers sont idempotents : ils ne sauvent le DataType que s'ils ont effectivement retire quelque chose.

Piege a connaitre : ce filtrage modifie le DataType en base. Un export uSync depuis une instance deja demarree reecrit donc la config amputee dans le depot. Ne jamais committer un export uSync fait depuis un site tournant sans verifier ApprovedColor.config et le BlockGrid.

Redirection dossier / page (Notifications/RedirectTargetHandler.cs)

INotificationHandler<RoutingRequestNotification> : au routage, si le noeud demande est un pageFolder (Dossier) ou une freeContentPage (Page generique), lit sa propriete optionnelle redirectTarget (MultiUrlPicker -> Link) et, si elle pointe quelque part, emet un 302 (request.SetRedirect(target.Url)) vers une page, un media (PDF...) ou une URL externe. Remplace la convention Umbraco umbracoRedirect (limitee au contenu). La propriete est lue en untyped (Value<Link>("redirectTarget")) pour ne pas dependre de la regeneration ModelsBuilder. Enregistre dans AddIntranetWeb().

Redirections legacy felix (Program.cs, RewriteOptions)

Redirections 301 des anciennes URLs felix vers les pages migrees, via app.UseRewriter (middleware Microsoft.AspNetCore.Rewrite). L'ancien site vivait sous /fr/accueil (culture fr + noeud home accueil, HideTopLevelNodeFromPath desactive) ; on retire ce prefixe : /fr/accueil/ -> /, /fr/accueil/<slug>/ -> /<slug>/, et tout autre /fr/<x> -> /<x>. Deux regles regex AddRedirect ((?i)^fr/accueil(?:/(.*))?$ puis (?i)^fr(?:/(.*))?$, insensibles a la casse), actives dans tous les environnements (contrairement au rewriteRules.xml IIS reserve staging/prod). Aucune verification d'existence de la cible : une page absente retombe sur le 404 normal. Plomberie de migration a retirer quand le trafic sur les vieux liens s'eteint. Distinct du RedirectTargetHandler (redirections editoriales par noeud, 302).

Recherche (Services/SearchService.cs, portee depuis accm-website)

Recherche plein texte sur l'ExternalIndex Examine (Lucene), rendue cote serveur (pas d'AJAX/API).

  • ISearchService (Interfaces/) / SearchService (Services/) : requete Lucene native multi-champs boostee (nodeName ^5, metaTitle ^3, metaDescription ^2, __nodeName ^4, fullTextContent ^1) + fuzzy ~1 + phrases "...", AND __Published:y, filtre __Path (start node). Filtre les hits (IsVisible, TemplateId>0 -> exclut les dossiers, type non exclu -> exclut les pages 404/500 via ExcludedContentTypes, pas hideFromSitemap, URL non vide), dedoublonne, trie score puis date, cache memoire 2 min, suggestion « did you mean » (Levenshtein, passe fuzzy ~2). Recherche aussi les medias (nodeName/title/description).
  • Decouple du modele genere : SearchWithPage(IPublishedContent searchPage, terms) lit excludeContent/excludeMedia/contentStartNode via .Value<>() (compile sans ModelsBuilder).
  • Helpers/SearchTextHelper.cs (pur, testable) : Normalize/RemoveDiacritics/CreateSnippet/Highlight (<mark>)/LevenshteinDistance/BuildSuggestedQuery. Modeles : Models/SearchResultItem, Models/Responses/SearchResponse, Enums/ElementType.
  • Type par resultat : ElementType = PAGE / MEDIA / EVENT / ARTICLE. SearchService.ResolveContentType mappe l'alias du doctype (eventPage -> EVENT, articlePage -> ARTICLE, sinon PAGE) ; les medias sont taggues MEDIA. Sert au badge + filtre de la vue. Les eventPage remontent (template rendu, non exclus) ; ce type ne fait que les distinguer.
  • Snippets : CreateSnippet centre l'extrait sur la 1re occurrence (avec « … »), la vue le surligne (Highlight -> <mark>). Pour un media, le snippet est tire en priorite du champ indexe fullTextContent (texte/OCR du PDF, lu depuis ISearchResult.Values — necessite que le champ soit stocke dans l'index, ce qui est le cas par defaut Examine FullText), et retombe sur la description du media sinon. Pour une page, il est construit depuis les proprietes de contenu (BuildContentSnippet).
  • Vue Views/SearchPage.cshtml (doctype searchPage, template SearchPage) : form GET ?q=, appelle SearchService.SearchWithPage(Model, q), rend une list-group avec snippets surlignes. Chaque resultat porte un badge de type (texte : « PDF »/« Page »/« Evenement »/« Article ») et data-search-type. Au-dessus, des pastilles de filtre (Tout / Pages / Documents / Evenements / Articles, avec compteurs, affichees seulement si >0) filtrent la liste cote client via scripts/modules/searchFilter.js (toggle .d-none, enregistre dans scripts/index.js, no-op hors page de recherche). Pas d'AJAX : tous les resultats sont deja dans la page.
  • UI d'acces : APOL = popup plein ecran (Views/Partials/elements/searchPopup.cshtml + styles/themes/apol/layout/_searchPopup.scss + scripts/modules/search.js, toggle .js-toggle-popup-search, porte depuis apol-website). PEL = simple lien vers le noeud searchPage (icone recherche du header, resolu par alias). Les deux postent en GET ?q= vers la SearchPage.
  • Non porte d'accm (specifique) : articles headless (HeadlessCmsApiService) et searchBiblioBlock (POST externe).

Champ full-text fullTextContent + PDF/OCR (Examine/FullTextIndexHandler.cs, porte depuis pully)

Champ Examine calcule qui alimente la recherche dans le corps des pages et le texte des PDF. Un rebuild de l'ExternalIndex est requis pour le peupler (dashboard Examine, ou bouton « Relancer l'indexation des PDF » du dashboard « Système » -> POST sentinelle-dashboard/reindex, voir backoffice.md).

  • FullTextIndexHandler (INotificationHandler<UmbracoApplicationStartingNotification>, enregistre via builder.AddNotificationHandler<...>) s'abonne a index.TransformingIndexValues de l'ExternalIndex :
    • categorie Content : agrege le texte des proprietes (BlockGrid/BlockList JSON deblaye des udi/key/layout/..., RTE HTML strippe) ;
    • categorie Media : pour les umbracoExtension == "pdf", resout le chemin physique depuis umbracoFile et extrait le texte.
    • resultat ecrit dans le champ fullTextContent (e.SetValues).
  • Services/PdfTextExtractor.cs (IPdfTextExtractor, singleton) : texte natif via PdfPig ; sinon OCR Tesseract (fra+eng+deu) apres rasterisation par PDFtoImage/SkiaSharp. Cache disque (umbraco/Data/TEMP/PdfTextCache, cle v{CacheVersion}+chemin+mtime) pour ne pas re-OCR a chaque reindex ; lock (Tesseract non thread-safe) ; degradation gracieuse si tessdata absent (natif seul).
    • Reconstruction des mots : l'extraction native utilise page.GetWords() (regroupe les glyphes en mots par position), pas page.Text qui colle les mots quand le PDF n'encode pas de vrais espaces (repli sur page.Text si GetWords ne renvoie rien). Toute modif de la logique d'extraction -> bump CacheVersion (invalide les textes deja caches, sinon l'ancien texte est re-servi a mtime egal).
    • Resilience par page : extraction page par page via document.GetPage(i) dans un try/catch (pas GetPages() qui leve depuis l'enumerateur). Une page/police malformee (ex. InvalidFontFormatException) est loggee en Debug et sautee, le reste du document est extrait ; l'OCR global reste le repli si le texte natif total tombe sous le seuil.
  • Packages : PdfPig, PDFtoImage, Tesseract. Donnees OCR : <ContentRoot>/tessdata/{fra,eng,deu}.traineddata (voir src/Web/tessdata/README.md) + libs natives a installer dans le Dockerfile (libtesseract, libleptonica, libfontconfig1).
  • Attention perf : l'OCR s'execute a l'indexation (1er passage lent pour les PDF scannes) ; le cache disque rend les reindex suivants rapides.

Recherche médias back-office par ancien id Felix (Examine/FelixMediaTreeSearcherFields.cs)

Permet aux éditeurs de retrouver un média dans le media picker du back-office (widgets, section Médias) en tapant son ancien id Felix (propriété felixId de la composition felixMigration, partagée par Image/File/Folder).

  • FelixMediaTreeSearcherFields : UmbracoTreeSearcherFields surcharge GetBackOfficeMediaFields() pour ajouter felixId aux champs interrogés (par défaut la recherche média ne matche que nodeName/id/__Key/umbracoFileSrc) et GetBackOfficeMediaFieldsToLoad() pour remonter la valeur. BackOfficeExamineSearcher construit alors une clause felixId:(term*) sur l'InternalIndex ; taper l'id (ou son préfixe) remonte le média migré.
  • Enregistré dans AddIntranetWeb() : builder.Services.AddUnique<IUmbracoTreeSearcherFields, FelixMediaTreeSearcherFields>() (remplace l'implémentation par défaut d'Umbraco).
  • À supprimer avec la migration Felix (comme la composition felixMigration elle-même). Si les résultats ne remontent pas, reconstruire l'InternalIndex (dashboard Examine).

Page 404 par site (ContentFinders/NotFoundContentFinder.cs)

IContentLastChanceFinder (constructeur primaire : IDomainService, IPublishedContentCache). Logique :

  1. Recupere tous les domaines Umbraco (GetAllAsync(true)).
  2. Trouve celui dont DomainName correspond a l'authority de la requete (teste brut, https://, http://).
  3. Determine la racine du site (RootContentId) ; fallback sur le premier domaine.
  4. Cherche un enfant de la racine dont le content type est NotFoundPage.ModelTypeAlias.
  5. Si trouve, SetPublishedContent ; retourne true si un contenu a ete pose.

Permet une page 404 differente par site dans une instance multi-sites.

Erreurs 500 (Controllers/ErrorController.cs)

Route ~/error/ (declaree dans ReservedPaths), branchee via UseExceptionHandler("/error") (Program.cs, hors dev). Si le status est 500, redirige vers le premier noeud internalServerErrorPage ; sinon redirige vers /.

Sitemap (Controllers/SitemapController.cs)

RenderController, route /sitemap, ResponseCache 600 s. Construit un sitemap XML unique multilingue :

  • resout la racine via le host (match sur les domaines), retombe sur le premier domaine sinon ;
  • part du noeud homePage ; parcourt recursivement les enfants ;
  • n'inclut un noeud que s'il a un url segment, un template (vraie page), n'est pas d'un type exclu (ExcludedTypes = notFoundPage/internalServerErrorPage) et n'est pas masque du sitemap (HideFromSiteMap) ; force https ; lastmod au format W3C. Les dossiers (pageFolder/folder/eventFolder, sans template) sont deja exclus par la regle du template ; les pages 404/500 ont un template, d'ou l'exclusion explicite par type.

robots.txt (Controllers/RobotsController.cs)

Route /robots.txt, cache 24 h. En production : sert uniquement la ligne sitemap: (URL absolue https du sitemap). Hors production : user-agent: * + disallow: / (blocage indexation).

iCal des événements (Controllers/EventsController.cs)

RenderController, route reservee ~/events/ (ReservedPaths). Genere des flux iCalendar (text/calendar, telechargement .ics) : /events/ical = tous les eventPage a venir du site (via IPublishedContentQueryAccessorContentAtRoot().DescendantsOrSelf()), /events/{id}/ical = un evenement. Un VEVENT par event : UID, DTSTART/DTEND (ou VALUE=DATE si allDay), SUMMARY, DESCRIPTION, LOCATION, GEO (lat;lng), URL ; echappement RFC 5545 + CRLF. Boutons « Ajouter a mon calendrier » (EventPage) et « S'abonner » (EventsPage).

Login dev (Controllers/DevLoginController.cs)

#if DEBUG uniquement. Route GET /l. Double garde : compile en DEBUG ET check runtime IWebHostEnvironment.IsDevelopment() (sinon 404). Recupere jusqu'a 500 utilisateurs, prend le premier dont l'email finit par @spektrummedia.com, le connecte (non persistant) et redirige vers /umbraco. Ne jamais retirer une des deux gardes.

Personnel : annuaire & anniversaires (Services/MemberDirectoryService.cs)

IMemberDirectoryService lit les membres intranetMember via IMemberService (scan mémoire, volume modeste) et renvoie des DirectoryMember (DTO découplé des alias). GetDirectory(search) = membres visibles (showInDirectory != false), triés nom/prénom, filtre optionnel ; GetBirthdays(day) = anniversaires jour+mois (défaut aujourd'hui). Seuls les membres IsApproved sont pris en compte (les partis, désactivés par la sync AD, sont exclus de l'annuaire et des anniversaires). Photo résolue depuis MediaPicker3 (défensif). Consommé par les blocs directoryBlock / birthdaysBlock (voir content-model.md). Enregistré dans AddIntranetWeb().

Annuaire : contacts externes (Services/ExternalContactService.cs, Services/DirectoryRules.cs)

Voisin d'IMemberDirectoryService, mais pour du contenu Umbraco plutôt que des membres : l'annuaire réunit personnel (intranetMember) et contacts hors personnel (communes, partenaires, services) saisis comme document types (directoryPage/externalContactFolder/externalContact, voir content-model.md).

  • Intranet.Core.Models.DirectoryContact : read model d'un contact externe (nom, organisation, fonction, coordonnées, photo, nom du dossier parent).
  • Web.Models.DirectoryViewModel : modèle du rendu partagé (collaborateurs + contacts externes) consommé par Views/Partials/blocks/_directory.cshtml.
  • Web.Interfaces.IExternalContactService / Web.Services.ExternalContactService (Scoped, enregistré dans AddIntranetWeb()) : lit les externalContact via IPublishedContentQuery, tri dossier puis nom, recherche déléguée à DirectoryRules.MatchesContact. Le nom du dossier parent (externalContactFolder) sert de titre de groupe à l'affichage ; un contact posé directement sous directoryPage (sans dossier) a un FolderName null et n'affiche aucun titre de groupe.
  • Intranet.Core.Services.DirectoryRules : règles pures, sans dépendance Umbraco (facilement testables) — IsVisibleInDirectory, IsBirthdayVisible, IsBirthdayOn, MatchesContact.

Point de vigilance — les contacts externes sont du contenu, jamais des membres. La synchronisation AD (MemberProvisioningService.DeactivateMissing) désactive tout intranetMember absent de l'annuaire AD ; un contact externe saisi comme membre disparaîtrait donc à la première synchronisation nocturne. De plus, Umbraco impose un e-mail et un username uniques par membre, ce que beaucoup de contacts externes n'ont pas. D'où le choix document type + IExternalContactService, en miroir mais séparé d'IMemberDirectoryService.

Exclusion automatique du sitemap/recherche : externalContactFolder et externalContact n'ont pas de template, ce qui les exclut d'office du sitemap (SitemapController exige TemplateId != null) et de la recherche interne (SearchService filtre sur TemplateId is > 0). Aucun code d'exclusion dédié n'a été nécessaire, comme pour folder/eventFolder.

Login dev membre (Controllers/DevMemberLoginController.cs)

#if DEBUG uniquement, route GET /lm, mêmes doubles gardes que DevLoginController. Seed un petit jeu de membres intranetMember de test (dont un anniversaire « aujourd'hui »), les rattache au groupe Utilisateurs intranet, connecte dev.intranet et redirige vers /. Permet de tester annuaire/anniversaires sans SSO/AD. Ne jamais retirer une des deux gardes.

Verrouillage intranet (Intranet.Core/Middleware/MemberGateMiddleware.cs)

Redirige tout visiteur non authentifié vers le login. Piloté par IntranetAccessOptions (section Intranet d'appsettings), RequireLogin = false par défaut en dev. Placé après UseWebsite() dans le pipeline (Program.cs). Allowlist : backoffice, endpoints techniques, dev logins, fichiers statiques, LoginPath. LoginPath = /login partout (page de login LDAPS, voir section LDAPS ci-dessous) ; en local, où il n'y a pas d'AD, passer par le raccourci DEBUG /lm plutôt que de repointer LoginPath.

Authentification AD par LDAPS (login /login + sync annuaire)

Brique unique d'accès à l'Active Directory de Pully, consommée par deux entrées qui partagent le provisioning. Section Ldap d'appsettings absente/vide = tout est inactif (même convention que Sentry) : /login renvoie 404 et le job est un no-op — rien ne change en dev local (utiliser /lm). Spec : docs/superpowers/specs/2026-07-15-ldaps-auth-design.md.

  • Intranet.Connectivity/Ldap/LdapOptions.cs (section Ldap) : Host, Port (636), BaseDn, ServiceUserDn, ServicePassword (jamais committé : appsettings.local.json ou var d'env Ldap__ServicePassword), CaCertificatePath (CA interne, sinon magasin OS), GroupMappings (groupe AD → groupe membre Umbraco ; appartenir à au moins un groupe mappé = accès), UserFilter, Attributes (mapping attribut AD → propriété intranetMember ; Matricule/Incorporation null par défaut tant que Pully n'a pas confirmé les attributs), SyncPeriodHours (24).
  • Intranet.Connectivity/Ldap/LdapFilterHelper.cs : filtres RFC 4515 (échappement, sAMAccountName/UPN, groupes imbriqués via memberOf:1.2.840.113556.1.4.1941:=), fonctions pures.
  • Intranet.Connectivity/Ldap/LdapDirectoryService.cs (IDirectoryReader + ILdapAuthenticator, singleton) : System.DirectoryServices.Protocols (cross-platform, OK conteneur Linux). ValidateCredentials = résolution du DN via compte de service puis bind LDAPS avec le mot de passe utilisateur (jamais loggué) ; GetUsers = recherche paginée (500/page, l'AD plafonne à 1000) des membres des groupes mappés, dédup par sAMAccountName. Validation TLS contre CaCertificatePath par X509Chain CustomRootTrust (pas de trust-all).
  • Services/MemberProvisioningService.cs (IMemberProvisioningService, scoped) : entrée AD → membre intranetMember (username = sAMAccountName, clé stable). Champs AD écrasés ; ne touche jamais birthDate/photo/showInDirectory (saisie manuelle backoffice) ; matricule/incorporation écrits seulement si l'AD fournit une valeur. Groupes alignés sur GroupMappings uniquement (les groupes hors mapping ne sont pas touchés). DeactivateMissing = IsApproved=false, jamais de suppression.
  • Controllers/IntranetLoginController.cs + Views/IntranetLogin/Index.cshtml : GET|POST /login (route réservée ~/login/ dans ReservedPaths), vue autoportante thémée. Anti-énumération : message générique unique + délai additionnel fixe de 400 ms sur tous les échecs d'authentification (le temps total varie avec les allers-retours LDAP, le délai est un plancher ajouté) ; anti-brute-force = politique de verrouillage de l'AD (chaque tentative est un vrai bind) ; returnUrl validé local ; antiforgery.
  • Intranet.Core/Jobs/DirectorySyncJob.cs (IRecurringBackgroundJob, singleton, résout le provisioning via IServiceScopeFactory) : sync périodique (délai 3 min au boot). Garde-fous anti-vidage : AD injoignable ou 0 utilisateur → log + aucune modification ; une entrée en échec est logguée et sautée (jamais de désactivation à tort : la liste des actifs vient du résultat AD).

Exemple de config (staging Pully — noms de groupes réels à confirmer par le SSI) :

json
"Ldap": {
  "Host": "dc1.pully.local",
  "BaseDn": "DC=pully,DC=local",
  "ServiceUserDn": "CN=svc-intranet,OU=Services,DC=pully,DC=local",
  "GroupMappings": { "PEL-Intranet": "Utilisateurs intranet" }
},
"Intranet": { "RequireLogin": true, "LoginPath": "/login" }

SSO transparent Windows Authentication (Intranet.Core/Middleware/WindowsSsoMiddleware.cs)

L'hébergement PEL étant Windows IIS chez Pully (confirmé 15.07.2026), le SSO à la Felix est implémenté comme couche au-dessus de la brique LDAPS. Activé par Intranet:WindowsSso=true (off par défaut) ET une section Ldap configurée ; sinon totalement inerte (sous Kestrel/dev, aucune identité Windows n'arrive → no-op).

  • Placé avant MemberGateMiddleware dans le pipeline (Program.cs). Si IIS a négocié une identité Windows (Kerberos/NTLM) et qu'aucun membre n'est connecté : extraction du login (DOMAIN\jdupontjdupont, UPN accepté), GetUser LDAP, contrôle des groupes mappés, Provision, SignInAsync — l'agent ne tape jamais de mot de passe.
  • Passif par conception : identité absente, utilisateur inconnu, aucun groupe autorisé ou annuaire injoignable → on laisse passer, et le gate redirige vers le repli /login (formulaire + bind). Le rendu de page n'échoue jamais à cause de l'AD.
  • Coût : un aller-retour LDAP par visiteur non connecté uniquement (le cookie membre couvre la suite).
  • Config IIS requise : activer Windows Authentication sur le site ; comme sur Felix, désactiver Anonymous pour un SSO systématique (l'application n'émet pas de challenge elle-même — anonymous actif = les clients sans négociation retombent sur le formulaire).

Authentification membres Entra ID (APOL)

Brique jumelle de la LDAPS ci-dessus, mais pour le skin APOL : SSO membre via Microsoft Entra ID (M365) plutôt qu'un AD Pully. Section EntraId d'appsettings absente/incomplète (TenantId/ClientId/ClientSecret) = brique inerte (même convention que Ldap/Sentry) : aucun enregistrement DI, l'endpoint /account/microsoft répond 404 — rien ne change sur un déploiement PEL. PEL (LDAP) et APOL (Entra) s'excluent mutuellement par config : un déploiement ne configure que l'une des deux sections.

  • Intranet.Core/Options/EntraAuthOptions.cs (section EntraId) : TenantId, ClientId, ClientSecret (jamais committé : appsettings.local.json ou var d'env EntraId__ClientSecret), CallbackPath (/umbraco-entra-members-signin par défaut). IsConfigured = les trois premiers champs renseignés.
  • Intranet.Core/Security/EntraExternalUser.cs : record d'identité découplé des claims ASP.NET/Umbraco (testable).
  • Intranet.Core/Security/EntraClaimsMapper.cs : lit le ClaimsPrincipal Entra (claims courts v2 ou URI longues) vers un EntraExternalUser. Rejette la connexion (retourne null) si l'identifiant stable ou l'e-mail est absent. Piège vérifié (décompilation 17.5.3) : AddMicrosoftAccount est un handler OAuth2/Graph, pas OpenID Connect — il expose l'object-id Entra via le claim ClaimTypes.NameIdentifier (depuis le id de Graph /me), pas via oid. Le mapper cherche donc oid/objectidentifier puis .../nameidentifier en dernier ; ne jamais retirer ce dernier candidat, sinon Map renvoie null et toute connexion est rejetée. NameIdentifier est aussi la clé externe (ProviderKey) qu'Umbraco utilise, donc ExternalId reste cohérent.
  • Intranet.Core/Security/EntraMemberWriter.cs : écrit les champs issus d'Entra (firstName/lastName/function/phone/mobilePhone) sur le membre. Ne touche jamais birthDate/photo/showInDirectory/showBirthday (saisie manuelle backoffice), même règle que MemberProvisioningService. Avec le handler par défaut AddMicrosoftAccount (Graph), function/phone/mobilePhone ne sont pas fournis par les claims → restent vides en MVP (jusqu'à une éventuelle sync Graph / claims d'extension) ; comme le writer les réécrit (à null) à chaque login, une valeur saisie manuellement au backoffice serait effacée à la connexion suivante (cohérent avec le modèle « champs Entra en lecture seule »).
  • Intranet.Core/Security/EntraMembersExternalLoginProviderOptions.cs (IConfigureNamedOptions<MemberExternalLoginProviderOptions>) : configure l'auto-link JIT (MemberExternalSignInAutoLinkOptions) — un membre intranetMember est créé au premier login, approuvé, rattaché au groupe Utilisateurs intranet. Tout le tenant est autorisé (pas de filtrage par groupe, contrairement au LDAP GroupMappings). OnExternalLogin délègue le mapping à EntraClaimsMapper/EntraMemberWriter. Un seul callback OnExternalLogin (pas OnAutoLinking), et c'est correct — vérifié par décompilation : à la 1re connexion, l'ordre Umbraco est OnAutoLinking (avant persistance, sur le MemberIdentityUser) → CreateAsync (persiste le membre, Key déjà assignée) → OnExternalLogin (même requête, après persistance). Comme on écrit des propriétés de contenu via IMemberService.GetByKey(user.Key), le membre doit exister : OnExternalLogin est donc le bon et seul hook, y compris à la 1re connexion. OnAutoLinking n'expose que l'identité (username/email/rôles), pas un IMember — inutilisable ici. Résout via IServiceScopeFactory (pas d'injection directe d'IMemberService scoped : ces options sont lues via un IOptionsMonitor singleton).
  • Wiring dans AddIntranet() (Intranet.Core/Extensions/IntranetBuilderExtensions.cs) : si EntraId.IsConfigured, enregistre EntraMembersExternalLoginProviderOptions puis builder.AddMemberExternalLogins(...).AddMemberLogin(...).AddMicrosoftAccount(...)external login natif Umbraco (pas de brique maison comme pour LDAPS), mono-tenant (endpoints d'autorisation/token pointés sur le TenantId APOL, v2.0).
  • Controllers/EntraAuthController.cs (~/account/microsoft, route réservée) : GET /account/microsoft déclenche le Challenge OAuth (point d'entrée, utilisé comme Intranet:LoginPath sur le déploiement APOL) ; GET /account/microsoft/callback (point d'arrivée local après que le handler OAuth a traité l'échange de code sur son propre CallbackPath /umbraco-entra-members-signin ; couvert par l'auto-allow LoginPath) échange l'external login info contre une vraie connexion membre (ExternalLoginSignInAsync, bypassTwoFactor: true — Entra impose déjà le MFA en amont, et il n'existe pas de 2FA membre côté site, voir business-rules.md) ; GET /account/microsoft/error affiche Views/EntraAuth/Error.cshtml en cas d'échec/annulation. Les trois actions répondent 404 si EntraId n'est pas configuré.
  • Callback allowlisté dans MemberGateMiddleware (/umbraco-entra-members-signin, hors /umbraco/ donc pas déjà couvert) : le retour OAuth ne doit pas être intercepté par le gate avant que le membre soit connecté. LoginPath (/account/microsoft) est déjà auto-autorisé par le test StartsWith(loginPath) existant.

Annonces (pop-up / bande) — injection layout

Rendu du module Annonces (doctypes dans content-model.md). Injecté via Html.PartialAsync("~/Views/Partials/layout/_announcement.cshtml", Model) (Model = page courante), rendu exactement une fois selon le thème et la présence d'un hero :

  • APOL et pages sans genericSection : dans _MasterLayout, juste après le header.

  • PEL, pages avec genericSection : dans genericSection.cshtml, juste après le hero (le header + le hero forment la zone transparente sur le bokeh, la bande ne doit pas les couper). Le _MasterLayout saute l'injection dans ce cas (theme == "apol" || Model is not IGenericSection).

  • Dispatcher Views/Partials/layout/_announcement.cshtml : résout une seule annonce à afficher, puis délègue au partial de thème layout/{theme}/_announcement.cshtml (Html.ResolveTheme()). Sélection (typée sur AnnouncementsPage/AnnouncementPage) : sous le announcementsPage de la home du site courant (AncestorOrSelf<HomePage>()), garde les announcementPage dont startDate/endDate encadrent DateTime.Now, dont targetNodes est vide ou contient un ancêtre de la page courante, et retient la plus récemment modifiée (UpdateDate desc). Dates lues en Value<DateTime?> (et non via les propriétés générées) : une date vide doit valoir null (= sans borne), ce que le DateTime non-nullable généré ne sait pas exprimer.

  • Rendu par thème : layout/apol/_announcement.cshtml et layout/pel/_announcement.cshtml. Pop-up = modale Bootstrap ; bande = .announcement-band (rouge var(--bs-danger), chevron repliable via Bootstrap collapse). Icônes #icon-close/#icon-chevron-right ajoutées au sprite SVG. Comportement (affichage/fermeture, mémorisation localStorage/sessionStorage) : scripts/modules/announcement.js (voir architecture/frontend-build.md).

Vacances du personnel (PEL) — GetUpcomingVacations

Complément à MemberDirectoryService pour le module Vacances (doctypes/champ dans content-model.md). Map parse le champ membre vacations (JSON du custom editor VacationsEditor) en DirectoryMember.Vacations. IMemberDirectoryService.GetUpcomingVacations(take) balaie les intranetMember approuvés, aplatit leurs périodes, garde celles dont la fin (ou le début à défaut) est aujourd'hui ou plus tard, trie par date, limite à take. Consommé par le bloc vacationsBlock (PEL, filtre de skin) ; la page profil lit GetByKey(...).Vacations pour l'historique. Lecture par simple parse JSON (IJsonSerializer), indépendante de ModelsBuilder.

Extensions de contenu (src/Web/Extensions/)

  • PageExtensions : GetHeadTitle, GetMetaTitle, GetMetaDescription, GetOgTitle, GetOgDescription (resolution par priorite via l'interface generee IPageSettings).
  • DateTimeExtensions (ex. FormatAsW3CDateTime pour le sitemap), MediaWithCropsExtensions, PublishedContentExtensions (ex. GetAbsoluteUrl, GetTemplateAlias, HideFromSiteMap), UmbracoContextExtensions (ex. GetCultureFromDomains).

Contributors

No contributors

Changelog

No recent changes