Architecture Decision Record · Projektpartnerportal

Geteilter Core-Login, Clean-URL-Portal-Integration und Admin-Chrome-Trennung

accepted 2026-06-17
TL;DR: Das Projektpartnerportal nutzt einen geteilten Core-Login (/user/login) für Endnutzer und Redakteure statt einer eigenen gebrandeten Login-Maske; gin_login wird deinstalliert, sodass /user/login ohnehin im Front-End-Theme byte_theme rendert. Die Self-Service-Seiten erhalten ID-lose Clean-URLs unter /projektportal/* (/projektportal/profil, /projektportal/profil/bearbeiten, /projektportal/suchagent, /projektportal/merkliste); weder User- noch Profil-ID erscheinen in URL oder Titel. Die Rollen-Trennung zur Admin-Shell erfolgt über Access: die vier verwaltungsnahen Rechte wandern von der Rolle authenticated auf content_editor/content_manager. Das Profil-Datenmodell aus ADR-0052 (separate drupal/profile-Entity) bleibt unverändert.

Kontext

Beim Dogfooding der Story P2503-226 (Branch feature/p2503-226-projektpartnerportal) zeigte sich: die Self-Service-Funktionen existieren und sind getestet, fühlen sich aber nicht wie Teil der Webseite an. Drei konkrete Befunde wurden live als Testpartner reproduziert.

Erstens — der Login. ADR-0052-Folgearbeit (Commit 5db0a6c) hatte einen eigenen gebrandeten Portal-Login unter /projektportal/login gebaut, weil gin_login die Core-Route /user/login in das Gin-Admin-Theme zwingt. Damit gab es zwei Login-Wege. Der Product Owner entschied, dass es genau einen geteilten Login geben soll — Endnutzer und Redakteure teilen sich /user/login; eine separate „Login-Maske" ist unerwünscht.

Zweitens — die Auffindbarkeit und die URLs. Das native Profil ist nur über ID-tragende Pfade erreichbar (/user/{uid}/projektpartner, /profile/{id}/edit), und es gab nach dem Login keinen sichtbaren Einstieg zum Profil. Der Product Owner will keine User- oder Profil-IDs in URLs exponieren und einen klaren, in die Seite integrierten Pflege-Einstieg.

Drittens — die Admin-Optik. Jeder eingeloggte Nutzer (auch ein Projektpartner) sah die Verwaltungs-Seitenleiste, das Admin-Theme auf Formularseiten und das Coffee-Werkzeug. Ursache: die site-weite Rolle authenticated trug vier verwaltungsnahe Rechte (access navigation, view the administration theme, access administration pages, access coffee). Es war keine Rechte-Ausweitung (die Einzelseiten bleiben rechtegeprüft), aber es widerspricht dem Ziel „nur Frontend-Hülle".

Das Datenmodell selbst steht nicht zur Debatte: die separate drupal/profile-Entity aus ADR-0052 bleibt. Dieser ADR betrifft die Integrations- und Zugangsschicht darüber — Login, Routing, Rollen-Trennung — und realisiert die in ADR-0052 offen gelassenen Punkte (Admin-Theme-Integration, Navigation).

Entscheidung

1. Ein geteilter Core-Login, gin_login deinstalliert

Der gebrandete Sonder-Login /projektportal/login entfällt (Route, ProjektpartnerLoginController und das Login/Logout-Menü-Link-Plugin gelöscht). Endnutzer und Redakteure melden sich über die Core-Route /user/login an. gin_login wird deinstalliert; ohne dessen Theme-Negotiator rendert /user/login im Default-Theme byte_theme — der Login ist also gebrandet, ohne eine zweite Maske zu sein. Der Verifizierungs-Redirect nach Double-Opt-In zeigt jetzt auf user.login. Migrierte Erst-Logins mit abgelaufenem Passwort werden weiterhin vom password_policy-REQUEST-Subscriber abgefangen.

2. ID-lose Clean-URLs unter /projektportal/*

Die Self-Service-Pflege läuft über owner-scoped Routen ohne ID-Parameter: /projektportal/profil (Ansicht) und /projektportal/profil/bearbeiten (Pflege). Ein ProjektpartnerProfileController löst das eigene Profil über ProfileStorageInterface::loadByUser($currentUser, 'projektpartner') auf — ein klassisches IDOR ist damit architektonisch ausgeschlossen. Seitentitel tragen keine ID mehr („Mein Projektpartnerprofil" statt „… #9"). Der vom profile-Modul erzeugte ID-Tab auf /user/{uid} wird per hook_menu_local_tasks_alter ausgeblendet. Der Suchagent zieht von /mein-profil/suchagent auf /projektportal/suchagent; die Merkliste liegt bereits unter /projektportal/merkliste. Damit ist alles Self-Service unter einem Präfix.

3. Portal-Startseite als Canvas-Seite, Login-Redirect dorthin

Nach dem Login landet ein Projektpartner auf der redaktionell gepflegten Canvas-Startseite /projektportal (Kacheln zu Profil, Suchagent, Merkliste, Abmelden, Konto löschen). Der Redirect wird über einen an hook_form_user_login_form_alter angehängten Submit-Handler gesetzt und nur für die Rolle projektpartner ausgelöst; Redakteure behalten ihren Standard-Landepunkt. Die Pflege der Kacheln ist Inhaltsarbeit (Canvas-Local-Curation-Entscheidung vom 2026-05-17), kein Code.

4. Rollen-Trennung zur Admin-Shell über Access

Die vier verwaltungsnahen Rechte werden von der Rolle authenticated entfernt und auf content_editor und content_manager verschoben (administrator trägt is_admin: true). Damit sehen Projektpartner keine Admin-Navigation, kein Admin-Theme und kein Coffee mehr; /admin bleibt für sie leer/403. Da Drupal-Rollen rein additiv sind und es kein rollenweises „Entziehen" gibt, ist das Verschieben (statt Streichen) der korrekte Weg, der den Redakteuren ihren legitimen Verwaltungszugang erhält. skip CAPTCHA und bypass honeypot protection bleiben bewusst auf authenticated (gängige Einstellung für eingeloggte Nutzer).

5. Bewerbungsformular vorbefüllt aus dem Profil

Das RSM-Bewerbungsformular (solcom_project_offer_webform_submission_form_alter) liest die persönlichen Felder jetzt über Profile::loadByUser() aus der projektpartner-Profil-Entity statt vom User-Entity (wo sie seit ADR-0052 nicht mehr liegen — der vorige Prefill war faktisch wirkungslos). Befüllte Felder werden für eingeloggte Partner vorbelegt und per #access = FALSE ausgeblendet — der Partner muss sich beim Bewerben nicht erneut vorstellen. E-Mail bleibt sichtbar, field_ppp_id bleibt am User (konsistent mit ADR-0052).

Abgelehnte Alternativen

Gebrandete Portal-Login-Maske beibehalten (Stand Commit 5db0a6c)

Den eigenen /projektportal/login behalten und gin_login nur konfigurieren. Abgelehnt, weil dann zwei parallele Login-Wege existieren (Verwirrung, doppelte Pflege, Redirect-Sonderfälle) und der eigentliche Auslöser — gin_login zwingt /user/login ins Admin-Theme — bestehen bleibt. Die Deinstallation von gin_login löst das Branding-Problem an der Wurzel und macht die Sonder-Route überflüssig.

ID-Routen des profile-Moduls direkt bewerben

Den Partnern einfach die nativen /user/{uid}/projektpartner- bzw. /profile/{id}/edit-Routen verlinken. Abgelehnt, weil der Product Owner keine numerischen IDs in URLs exponieren will und ein owner-scoped Clean-URL-Controller dieselbe native Profile-Form wiederverwendet, ohne IDs zu zeigen.

Vier Rechte ersatzlos von authenticated streichen

Die vier Rechte einfach entfernen, statt sie zu verschieben. Abgelehnt, weil dieselbe Rolle site-weit auch von Redakteuren getragen wird; ein ersatzloses Streichen nähme ihnen den legitimen Verwaltungszugang. Drupal-Rollen sind additiv — der saubere Weg ist, die Rechte auf die Rollen zu verschieben, die sie brauchen.

Prefill-/Suppression-Logik als eigener Service

Den Bewerbungs-Prefill aus dem Hook in einen dedizierten Service auslagern. Für diesen Diff zurückgestellt (advisory, kein Blocker): der Hook ist die idiomatische Stelle für ein form_alter, und eine Extraktion wäre Scope-Erweiterung ohne aktuellen Wiederverwendungsbedarf. Bei einem zweiten Konsumenten neu zu bewerten.

Konsequenzen

  • Eine Login-Oberfläche. /user/login ist der einzige Login; gebrandet via byte_theme, weil gin_login deinstalliert ist. Composer-/Modul-Footprint sinkt um ein Contrib-Modul.
  • Keine IDs in Self-Service-URLs. Profil, Suchagent und Merkliste liegen unter /projektportal/* ohne User-/Profil-ID; der ID-Tab auf der User-Seite ist ausgeblendet.
  • Projektpartner sind aus der Admin-Shell ausgesperrt — keine Navigation-Sidebar, kein Admin-Theme, kein Coffee, /admin → 403. Verifiziert per Browser als Testpartner.
  • Bewerbungs-Prefill funktioniert wieder (war seit ADR-0052 ein stiller Bug) und blendet bereits hinterlegte Daten aus.
  • Migrations-Erst-Login bleibt intakt: der password_policy-Subscriber greift auf REQUEST-Ebene vor dem Post-Login-Redirect.
  • Test-Abdeckung: neue Functional-Tests für Clean-URL-Routen (Owner-Zugriff, 403 für Nicht-Partner, keine ID im Pfad), End-to-End-Login-Redirect, Admin-Shell-403 und das Verschwinden des ID-Tabs; neuer Kernel-Test für den Profil-Prefill (Reproduktion des ADR-0052-Bugs); bestehende Mail-Flow-Assertion auf user.login angepasst.
  • Abhängigkeit vom Bundle-Namen: der Tab-Filter und der Profil-Controller hängen daran, dass der Profil-Typ projektpartner heißt; eine Umbenennung würde sie still no-op machen.
  • Verbleibende Drift im Dev-Stand: die authenticated-Rechte-Änderung wurde gezielt per Drush angewandt und ist in config/sync abgebildet; ein voller cim wurde wegen unabhängiger vorbestehender Config-Drift bewusst nicht gefahren — diese ist separat zu bereinigen.

Offene Punkte

Die Canvas-Startseite /projektportal ist redaktionell zu befüllen (Kacheln + Alias). Bis sie existiert, läuft der Post-Login-Redirect ins Leere — Reihenfolge im Deployment beachten.
Vorbestehende Config-Drift zwischen config/sync und Dev-DB (Profilfelder, Displays, easy_email) ist unabhängig von dieser Story und sollte in einem eigenen Schritt sauber abgeglichen werden.
Ob die alten Pfade (/mein-profil/suchagent) per Redirect erhalten werden müssen, hängt davon ab, ob bereits Lesezeichen/Mails im Umlauf sind — vor Go-Live zu klären.

Nachtrag (2026-06-17): gebrandete Login-Landingpage als Canvas-Seite

Auf Wunsch des Product Owners, auf der Login-Oberfläche redaktionellen Inhalt (Begrüßung, Hinweistexte) pflegen zu können, wurde eine gebrandete Login-Landingpage unter /projektportal/login ergänzt. Das widerruft Entscheidung 1 nicht. Die abgelehnte Alternative betraf einen eigenen Auth-Pfad mit Code-Controller (ProjektpartnerLoginController, eigene Route); dieser bleibt entfernt. Die neue Seite ist eine canvas_page-Entity, die Drupals Core-Login-Block (block.user_login_block) als Canvas-Komponente zwischen frei editierbarem Inhalt einbettet — eine redaktionelle Hülle um dasselbe Core-Formular, keine zweite Authentifizierungs-Route. Das eigentliche Login wird weiterhin von UserLoginForm über die Core-Mechanik verarbeitet; /user/login bleibt für Redakteure/Admins unverändert der nackte Einstieg. Damit existieren zwei Einstiege nebeneinander, aber nur eine Auth-Route.

Der anonyme „Anmelden"-Link im Account-Menü zeigt jetzt auf /projektportal/login statt /user/login. Umgesetzt über die Subklasse ProjektpartnerLoginLogoutMenuLink (überschreibt getUrlObject() nur für Anonyme) plus hook_menu_links_discovered_alter, das die class des Core-user.logout-Links umstellt. Der vom Parent geerbte Cache-Context user.roles:authenticated toggelt korrekt zwischen „Anmelden" (anonym) und „Abmelden" → /user/logout (eingeloggt).

Dabei aufgedeckter Produktiv-Bug. Der in Entscheidung 3 beschriebene Post-Login-Redirect las die User-ID aus dem falschen FormState-Bag (getValue('uid') statt get('uid')). Core legt die uid ausschließlich im Storage-Bag ab — der Handler kehrte daher immer still zurück, und der Redirect nach /projektportal feuerte nie, auch nicht über /user/login. Maskiert wurde das durch einen Unit-Test, der seine eigene Annahme mockte (setValue('uid')) und zudem die nicht existierende Methode getRedirectUrl() aufrief. Beides korrigiert und erstmals echt getestet. Zusätzlich überschreibt der ?destination-Parameter, den der user_login_block per Lazy-Builder selbst-referenziell setzt, den setRedirectUrl()-Wert über den RedirectResponseSubscriber; der Submit-Handler entfernt diesen selbst-referenziellen Parameter, damit der Portal-Redirect greift. Browser-verifiziert (anonym → Login → /projektportal) und durch Functional-Tests abgedeckt.

Wie die Portal-Startseite ist auch die Login-Seite DB-lokale Canvas-Kuratierung (kein config/sync, kein Recipe). Auf einer frischen Datenbank existiert /projektportal/login nicht und ist dort neu zu kuratieren — derselbe Deployment-Vorbehalt wie beim Hub /projektportal (siehe Offene Punkte).