Files
deepdrft/product-notes/phase-19-user-management-cms.md

33 KiB
Raw Permalink Blame History

Phase 19 — AuthBlocks User Management in the CMS

Status: proposed (rev. 3 — host model corrected by Daniel 2026-06-19). Author: product-designer. Date: 2026-06-19. Implementer: TBD (separate delegation).

Wire all three AuthBlocks account-creation paths into the DeepDrftManager CMS so an admin can run account management from inside the same CMS they already use, and so an invited user can redeem a registration code and create their own account — all on DeepDrftManager (the CMS app, demoapp.deepdrft.com). There are no changes to DeepDrftPublic in this phase.

Daniel's framing: "this is already part of the AuthBlocks library so we just need to wire it up properly." That framing is correct, and the wiring is further along than it implies. Almost the entire integration already landed as a side-effect of the prior AuthBlocks startup separation (PLAN_authblocks_trackmanager.md, landed 2026-05-25) and the login/logout integration; what remains is a thin navigation + public-route-exposure + verification + polish slice, not an integration project.

Host-model correction (Daniel, 2026-06-19 — the crux of this revision). Rev. 2 placed public self-service registration (path 2) on DeepDrftPublic as a cold-start integration. That was wrong. Public registration belongs on the CMS app, exactly where login already lives: the CMS app already hosts a public-facing, unauthenticated /account/login page (reachable without being signed in). The registration redemption page is public-facing in exactly the same way — an unauthenticated route on the CMS app itself. An invited user clicks the email link, lands on the CMS app's public registration route (/account/register), redeems their code, sets a password. No second host, no DeepDrftPublic involvement. The entire rev-2 "public-site track" (wave 19.4) and its open questions (OQ6OQ9) are deleted — they were artifacts of the wrong host assumption.

So all three paths live on DeepDrftManager. The real remaining questions for path 2 are narrow: it is likely already route-reachable (the CMS router discovers /account/register via AdditionalAssemblies, same as it discovers /account/login), so the work is (a) confirming it is correctly unauthenticated (no role gate — verified below, it has none), and (b) giving an unauthenticated visitor the right layout (the lean splash chrome, not the authenticated app shell), mirroring how login should render. SkipperHaven — another app on the same AuthBlocks library — already implements this dual public login/register pattern, and is the canonical reference (§2c).


0. The three account-creation paths (verified against AuthBlocks source) — ALL on the CMS

Verified against C:\Development\AuthBlocks source. Daniel's three-path understanding is correct and complete, and all three are CMS routes:

# Path Component(s) Route Gate Backed by Email?
1 Admin provisions a user directly (bypasses email/code loop) SuperRegister.razor /account/superregister UserAdmin POST api/auth/admin-registerworking No
2 Public self-service — invited user redeems a code and self-registers Register.razor /account/register none (public) POST api/auth/registerworking No (consumes code)
3 Admin provisions a registration token + triggers the invite email NewRegistration.razorNewRegistrationForm.razor /useradmin/registrations/new UserAdmin POST api/pendingregistration/createworking, sends email server-side Yes — real, not stubbed

All three are CMS routes on DeepDrftManager. Paths 1 and 3 are admin-gated (UserAdmin). Path 2 is public-facing, reachable by an unauthenticated visitor — exactly like the CMS /account/login page, which is also unauthenticated and on the same host.

Path 2 has no role gate (verified). Register.razor declares @page "/account/register" + @rendermode InteractiveServer and no [HierarchicalRoleAuthorize] attribute — identical in this respect to Login.razor (@page "/account/login", no gate). It reads UserEmail + RegistrationToken from the query string and pre-fills, so the invite email's deep link lands ready to submit; it calls AuthStateProvider.RegisterAsyncPOST api/auth/register. It is meant to be reached by an unauthenticated visitor.

Path 3's email is real. PendingRegistrationRoutes.Create (AuthBlocksLib/Routes/PendingRegistrationRoutes.cs:62) generates a token, persists the pending registration, builds the invite link ({ReturnHost}?UserEmail=&RegistrationToken=), renders RegistrationEmailTemplate.Create(...), and sends it via IGeneralEmailSender.SendEmailAsync — a Mailtrap-backed MailtrapEmailSender registered in AuthBlocksExtensions (line 109) and configured in DeepDrftAPI from environment/authblocks.json (AuthBlocks:Email:Host / :Token, Program.cs:106109; ApplicationName="DeepDrft", SupportEmail from config). On email-send failure the route rolls back the pending-registration row and returns 500. The full invite→email→redeem loop is functional end-to-end across paths 2 and 3, entirely within the CMS host: an admin provisions (path 3, CMS) → the prospective user receives an email with a code + link → they land on the CMS app's public /account/register (path 2, CMS) with email + token pre-filled → they set a password and the account is created.

Note on {ReturnHost}. The invite email's deep link is built from a configured return host. For the loop to land on the CMS app, that host must point at the CMS origin (demoapp.deepdrft.com), not the public site. Verify this config value in 19.3 (it is the one place the wrong-host assumption could be baked into a config rather than code).

The one genuinely stubbed surface is Reset PasswordUsers.razor:55 (// todo integrate with email for secure reset) has an empty handler and no backing API endpoint exists (AuthRoutes maps login/register/admin-register/refresh/logout/me/roles — no reset route). That is the subject of the separate authblocks-password-reset-brief.md; it must not be filed as a DeepDrft bug.

Two distinct admin "create" verbs — both stay, they are not duplicates. SuperRegister (path 1) creates a live account immediately with a password the admin sets. The registration-token form (path 3) creates a pending invite — no account yet — and lets the user set their own password via email. They serve different needs (provision-now vs. invite-by-email); both belong in the CMS nav. (The older NewUser ModelView create form at /useradmin/users/new still exists as a third bare admin create path, but it is not one of Daniel's three; treat it as redundant with SuperRegister and do not surface it in nav. See OQ2.)


1. What AuthBlocks ships, and how it is packaged

Read from local source at C:\Development\AuthBlocks. The key question — is the user-admin surface consumable or host-bound? — resolves cleanly: it is consumable.

The user-admin surface is a published RCL, despite the "Web" name

AuthBlocksWeb is an Microsoft.NET.Sdk.Razor project (not Sdk.Web) with no Program.cs — it is a Razor Class Library, not a runnable host. pack.ps1 packs it as Cerebellum.AuthBlocks.Web and pushes it to nuget.org. So the user-admin Razor components are distributed as a normal RCL and consumed by reference. No extraction fork is needed — the pages are already in the RCL.

What's in the package (the consumable surface)

Components under AuthBlocksWeb/Components/:

  • Account pages (Pages/Account/):
    • Login.razor/account/login (public — no gate, no @layout; @rendermode InteractiveServer).
    • Register.razor/account/register (path 2 — public self-service via invite code; @rendermode InteractiveServer; no role gate, no @layout; reads UserEmail + RegistrationToken from the query string and pre-fills; calls AuthStateProvider.RegisterAsyncPOST api/auth/register).
    • Logout, AccessDenied.
    • SuperRegister.razor/account/superregister (path 1 — admin creates a live account immediately, role multiselect; gated [HierarchicalRoleAuthorize(UserAdmin)]; calls IAuthApiClient.AdminRegisterAsyncPOST api/auth/admin-register).
  • User admin pages (Pages/UserAdmin/), each @page-routed and gated [HierarchicalRoleAuthorize(SystemRoleConstants.UserAdmin)]:
    • Users/Users.razor/useradmin/users — searchable user grid; per-row Reset Password (stubbed — no backing endpoint), Deactivate/Reactivate, edit modal.
    • Users/NewUser.razor/useradmin/users/new — bare create-user form (redundant with SuperRegister; not one of Daniel's three paths — do not surface in nav).
    • Registrations/Registrations.razor/useradmin/registrations — pending-invite grid, with NewRegistration.razor/useradmin/registrations/new (path 3NewRegistrationForm posts to PendingRegistrationClient.CreatePendingRegistrationPOST api/pendingregistration/create, which mints the token and sends the invite email) and the edit-registration modal.
    • Permissions/Permissions.razor/useradmin/permissions — user↔role assignment.
  • Menu fragments (Components/Layout/): AccountNavMenu, UserAdminMenu (a MudNavGroup with the three user-admin MudNavLinks, itself wrapped in a HierarchicalRoleAuthorizeView so it only renders for UserAdmin+).
  • Shared (Components/Shared/): LogoutButton, StatusMessage.
  • DI entry point (Startup.cs): ConfigureAuthServices(IServiceCollection, string apiBaseUrl) registers the cascading auth state, the JWT client stack, and every user-admin client + ViewModel, all pointed at apiBaseUrl.

The pages lean on Cerebellum.BlazorBlocks.Web for grid scaffolding and MudBlazor for chrome — both already present in the CMS.

The API side is already hosted

The clients those ViewModels use call the AuthBlocks API surface, which DeepDrftAPI already mounts via app.MapAuthBlocks() (Program.cs:184): api/auth/* (incl. admin-register, register, roles), api/users/*, api/roles/*, api/user-roles/*, api/pendingregistration/*. AddAuthBlocks

  • UseAuthBlocksStartupAsync (migrate + seed) are wired, and the Auth DB + secrets live in DeepDrftAPI/environment/. This all landed with the startup separation.

2. What is ALREADY wired in DeepDrftManager (do not redo)

Verified against the current DeepDrftManager source. These are the integration steps a naive plan would propose — and they are already done:

  1. Package reference. DeepDrftManager.csproj:11 references Cerebellum.AuthBlocks.Web (10.3.33), which transitively brings AuthBlocksWeb.Client, AuthBlocksLib, AuthBlocksModels.
  2. Service wiring. Program.cs:35 calls AuthBlocksWeb.Startup.ConfigureAuthServices(builder.Services, contentApiUrl) — the user-admin clients and ViewModels are already in the container, already pointed at DeepDrftAPI. This same wiring also registers the JwtAuthenticationStateProvider that Register.razor (path 2) depends on — so path 2's service dependency is already satisfied (it is the same provider login uses).
  3. Page discovery. Routes.razor:2 sets AdditionalAssemblies="new[] { typeof(AuthBlocksWeb._Imports).Assembly }" and Program.cs:131 mirrors it for endpoint mapping. The Blazor router already discovers every AuthBlocksWeb page, including /account/login, /account/register, /account/superregister, and the /useradmin/* pages. They are route-reachable today by typing the URL — including the public /account/register.
  4. Default layout. Routes.razor:6 sets DefaultLayout="typeof(Layout.CmsLayout)". Since the AuthBlocks pages declare no @layout, they render inside CmsLayout chrome — the authenticated app shell. This is the one wrong thing for the public pages (login, register): an unauthenticated visitor sees the full authenticated CMS shell rather than the lean splash. See §2b.
  5. Role gating already satisfied. The admin pages gate on SystemRoleConstants.UserAdmin. The DeepDrft admin is seeded in role Admin, parent of UserAdmin — hierarchical authorize means the existing admin already passes the UserAdmin gate with no role change, no new seed, no DB edit.
  6. Auth-state + redirect plumbing. AuthorizeRouteView with RedirectToLogin / RedirectToAccessDenied (Routes.razor) already protects the gated surface coherently, and the public pages (no gate) pass straight through it.

Net: an authenticated DeepDrft admin can navigate to /useradmin/users today and the page should render and call DeepDrftAPI; and an unauthenticated visitor can reach /account/register today. The reasons it feels unbuilt: (a) nothing in the CMS UI links to the admin pagesCmsLayout has no nav drawer at all, so the admin surface is invisible (§G1); and (b) the public pages render in the wrong (authenticated-shell) layout for an unauthenticated visitor (§G0/§2b).

This is the crux: the CMS work is not integration, it is exposure + layout-fix + verification + fit-and-finish.


2b. The public-route layout gap (path 2 + login) — the one real public-facing fix

The public pages — /account/login and /account/register — are route-reachable and unauthenticated, but DeepDrftManager's router uses a static DefaultLayout="typeof(Layout.CmsLayout)". Because the AuthBlocks public pages declare no @layout, an unauthenticated visitor lands inside the authenticated app shell (CmsLayout — the dense admin app bar with a Catalogue/Home button, and soon a nav drawer linking to gated admin surfaces). That is the wrong frame: a visitor who is not signed in should see the lean splash chrome the site already uses for its / home splash (CmsHomeLayout), not the admin shell.

DeepDrftManager already has both layouts:

  • CmsLayout — the authenticated app shell (MudThemeProvider + app bar + main content; gains the nav drawer in 19.1).
  • CmsHomeLayout — the lean splash (MudThemeProvider + minimal app bar, centered narrow container), already used by Home.razor (@layout Layout.CmsHomeLayout) for the unauthenticated / splash.

The fix is to render the public auth pages in the lean layout for unauthenticated visitors, and the gated pages in the app shell — exactly the SkipperHaven pattern (§2c). The two clean shapes:

  • G0-a — Auth-state-driven DefaultLayout in Routes.razor (the SkipperHaven pattern; recommended). Make the router's DefaultLayout a function of auth state: unauthenticated → CmsHomeLayout, authenticated → CmsLayout. This is exactly what SkipperHaven does (its Routes.razor swaps AuthenticatedLayout/UnauthenticatedLayout in OnParametersSetAsync off the cascaded AuthenticationState). DeepDrftManager already has both target layouts, so this is a small router change, no new layout to author. Cost: the gated admin pages also resolve their layout via this switch — but an unauthenticated visitor to a gated page is redirected to login by NotAuthorized before layout matters, and an authenticated admin gets CmsLayout, so it composes correctly. Caveat: a logged-in admin who visits /account/register would see it in CmsLayout (the app shell) — acceptable, and arguably correct (an admin poking at the public form is in an admin session).
  • G0-b — Per-page @layout on the public pages. Add @layout CmsHomeLayout to the AuthBlocks public pages. Rejected — not possible without forking the RCL: Login.razor/Register.razor ship inside Cerebellum.AuthBlocks.Web; we cannot edit them, and there is no host-side override for an RCL page's @layout. G0-a is the only no-fork path.

DECIDED direction: G0-a (auth-state-driven DefaultLayout), mirroring SkipperHaven. It is the supported, no-fork way to give the public auth pages the lean layout, it reuses the two layouts DeepDrftManager already has, and it fixes login's layout at the same time as registration's.


2c. SkipperHaven — the canonical pattern, and the concrete DeepDrftManager deltas

SkipperHaven (C:\Development\skipper\SkipperHaven\SkipperHaven) consumes the same AuthBlocks library and already exposes login + register as public/unauthenticated routes with the right layout. The load-bearing piece is its Components/Routes.razor:

  • It declares [Parameter] AuthenticatedLayout (MainApplicationLayout) and [Parameter] UnauthenticatedLayout (MainHomeLayout), takes the cascaded Task<AuthenticationState>, and in OnParametersSetAsync sets _currentLayout to the authenticated layout iff authState.User.Identity?.IsAuthenticated == true, else the unauthenticated layout.
  • AuthorizeRouteView uses DefaultLayout="@_currentLayout" (the resolved switch), not a static type. So the AuthBlocks public pages (login, register — both layout-less) render in MainHomeLayout for a signed-out visitor and the app shell once signed in.
  • Its NotAuthorized renders RedirectToLogin for unauthenticated and an inline "not authorized" for authenticated-but-unprivileged.
  • Wiring is otherwise identical to DeepDrftManager: AuthBlocksWeb.Startup.ConfigureAuthServices(..., apiBaseUrl) in Program.cs, and AddAdditionalAssemblies(typeof(AuthBlocksWeb._Imports).Assembly) on the mapped components. (Skipper also adds AuthBlocksWeb.Client assemblies because it uses the client-rendered auth surface; DeepDrftManager is server-rendered InteractiveServer and does not need the .Client assembly — its single AuthBlocksWeb._Imports entry is sufficient.)

Concrete deltas DeepDrftManager needs to match the pattern (this is the whole public-route slice):

# SkipperHaven DeepDrftManager today Delta
D1 Routes.razor resolves DefaultLayout from auth state (AuthenticatedLayout / UnauthenticatedLayout, switched in OnParametersSetAsync off the cascaded AuthenticationState) Routes.razor uses a static DefaultLayout="typeof(Layout.CmsLayout)" Make DefaultLayout auth-state-driven: cascade Task<AuthenticationState>, resolve _currentLayout = authed ? CmsLayout : CmsHomeLayout, bind DefaultLayout="@_currentLayout". (G0-a.) The only required public-route code change.
D2 Two layouts exist (MainApplicationLayout, MainHomeLayout) Already has both (CmsLayout, CmsHomeLayout) None — no new layout to author. DeepDrftManager is ahead of Skipper here.
D3 ConfigureAuthServices(..., apiBaseUrl) in Program.cs (registers JwtAuthenticationStateProvider etc.) Already wired (Program.cs:35) None.
D4 AuthBlocks _Imports in router AdditionalAssemblies + mapped components Already wired (Routes.razor:2, Program.cs:131) None/account/register is already route-reachable.
D5 NotAuthorizedRedirectToLogin (unauth) / inline message (authed) NotAuthorizedRedirectToLogin (unauth) / RedirectToAccessDenied (authed) None functionally — DeepDrftManager's existing handling is equivalent (it redirects rather than inlines; fine).

So the public-registration "track" reduces to a single change: D1 (auth-state-driven DefaultLayout). Everything else SkipperHaven does is already present in DeepDrftManager. This is why path 2 is no longer its own host track — it is one router edit, parallelizable with (and smaller than) the admin-nav work.


3. The genuine remaining work

G0 — Public-route layout (the path-2 + login fix) — see §2b/§2c

Make Routes.razor's DefaultLayout auth-state-driven (G0-a), so the public /account/login and /account/register pages render in CmsHomeLayout for unauthenticated visitors. Single router change; no new layout (both already exist); no AuthBlocks-source change. This is independent of the admin-nav work below and can run in parallel.

G1 — Navigation: there is no way to reach the admin surface from the UI (the real admin gap)

CmsLayout.razor is an app bar + a single Home MudIconButtonno MudDrawer, no nav menu. The catalogue, releases, upload, and user-admin surfaces are all reachable only by typed URL or in-page buttons. Mounting UserAdminMenu requires a navigation container to mount it into.

Three shapes were considered (diverge-before-converge): G1-a app-bar overflow menu (doesn't scale); G1-b a real MudDrawer nav mounting the existing CMS destinations + the shipped UserAdminMenu fragment; G1-c a maximal dedicated Administration section with its own dashboard (scope creep for v1).

DECIDED: G1-b (Daniel, 2026-06-19). A real MudDrawer nav in CmsLayout (toggle in the app bar) holding the existing primary destinations (Catalogue /catalogue, Releases /releases, Upload /tracks/upload) and the shipped UserAdminMenu fragment (self-gates to UserAdmin+, so it only shows for admins). Surface both admin account paths: path 1 (SuperRegister, /account/superregister) and path 3 (via the UserAdminMenu Registrations link → its New button). Do not surface the redundant bare NewUser (OQ2). It solves the actual gap (no nav) with the least bespoke code, reuses AuthBlocks' own MudNavGroup component verbatim, and gives the CMS the navigation spine it's missing. G1-c's admin dashboard remains deferred; G1-a is the rejected stopgap.

Borrowed precedent: the standard MudBlazor admin-template layout (persistent left MudDrawer + MudNavMenu/MudNavGroup), which UserAdminMenu is already authored against. SkipperHaven's MainApplicationLayout/NavMenu is the same shape on the same library — a second confirmation this is the idiom, not an invention.

G2 — Verification pass (the surface is wired but unproven end-to-end)

Because nothing exercised these pages in the CMS, treat first-light as verification, not assumption. Confirm against a running DeepDrftAPI + Auth DB:

  • /account/register (path 2) renders for an unauthenticated visitor in the lean CmsHomeLayout (post-G0), pre-fills UserEmail + RegistrationToken from the query string, and creates the account (consuming the pending_registration row) on submit.
  • /account/login likewise renders in the lean layout for an unauthenticated visitor (G0 fixes login's layout as a side benefit).
  • /useradmin/users lists users (the UsersClientapi/users/* round-trip works cross-host with the bearer token the CMS holds).
  • /account/superregister (path 1) creates a live account immediately — admin-register is UserAdmin-gated server-side; the admin's token must carry the role claim end-to-end.
  • /useradmin/registrations/new (path 3) provisions a token and sends the invite email — verify the email arrives (Mailtrap), the link/code are correct, the rollback fires on send failure, and critically that the invite link's {ReturnHost} points at the CMS origin so the deep link lands on the CMS /account/register (the place the wrong-host assumption could hide as config — §0 note). This is the surface most likely to surface a config gap (AuthBlocks:Email:Host/:Token + the return host in DeepDrftAPI's environment/authblocks.json).
  • The full path-3→path-2 loop on one host: admin provisions in the CMS → email arrives → invited user opens the deep link → lands on the CMS /account/register (lean layout) → redeems → account created.
  • /useradmin/registrations lists invites; /useradmin/permissions reads + assigns roles.
  • CORS / token presentation: the prior plan widened DeepDrftAPI CORS for the Manager origin for login; confirm the same allowance covers api/users/* / api/pendingregistration/* / api/auth/register (it should — same origin, same policy).

This pass is where any latent break surfaces (a client config typo, a missing role claim, a wrong return host, a package-version mismatch). Real work even though no code may change if it all passes.

G3 — Theming / fit-and-finish

The AuthBlocks pages are MudBlazor-default-styled, authored against AuthBlocks' own theme, not the DeepDrft CMS palette (DeepDrftPalettes.Cms). Both CmsLayout and CmsHomeLayout mount a MudThemeProvider with that palette, so the pages inherit it for free. Scope for v1: accept MudBlazor-default styling inside the CMS palette and only fix outright legibility/contrast breaks (especially the public /account/register + /account/login now rendering in CmsHomeLayout). A deeper bespoke restyle of the AuthBlocks grids is explicitly out of v1 — deferred polish.

G4 — Package version alignment (housekeeping, flag don't gate)

DeepDrftManager references Cerebellum.AuthBlocks.Web 10.3.33; AuthBlocks source is at 10.3.35. Minor lag. Bumping is low-risk but not required for this phase. Note it; Daniel's call on timing.


4. Scope boundaries

In for v1 (one host — DeepDrftManager — two parallel tracks):

Admin-nav track:

  • G1-b: a MudDrawer nav in CmsLayout mounting UserAdminMenu (+ the existing CMS destinations).
  • All three account paths reachable in the CMS: path 1 (SuperRegister, provision-now) and path 3 (/useradmin/registrations/new, invite-by-email) via nav, plus the users/permissions grids; path 2 (/account/register) via the public-route track below.

Public-route track (the corrected, much smaller path-2 work):

  • G0-a: auth-state-driven DefaultLayout in Routes.razor so /account/register (path 2) and /account/login render in the lean CmsHomeLayout for unauthenticated visitors. One router edit (§2c D1); both target layouts already exist. The invite email's deep link is the entry point.

Shared:

  • G2: end-to-end verification of list/create/deactivate users, registrations (incl. the real invite email send + correct return host), permissions, and the path-3→path-2 loop on one host.
  • G3: accept-the-palette theming; fix only legibility breaks (incl. the public pages in CmsHomeLayout).

Deferred (note, don't build):

  • Admin dashboard (G1-c) — a user-admin landing summarizing counts / pending invites. Good later; not a v1 gate.
  • Reset Password — the AuthBlocks Users page stubs it; no backing endpoint exists in AuthRoutes. An upstream AuthBlocks gap, not a DeepDrft wiring task. Daniel is handling it as a separate AuthBlocks-repo effort — see the standalone product-notes/authblocks-password-reset-brief.md. Do not implement password reset inside DeepDrftHome.
  • Bespoke restyle of the AuthBlocks grids to the editorial DeepDrft aesthetic.
  • A visible public "Register" nav link. Registration is invite-only (the email deep link is the entry point); a visible Register link with no self-serve code issuance invites confusion/abuse. Recommend: no nav link; deep link only. (Carried over from the dropped OQ9 — still the right call, now trivially so since the form lives on the CMS host the admin already knows.)
  • G4 version bump — housekeeping, Daniel's call on timing.

Explicitly not needed:

  • Any change to DeepDrftPublic. The corrected host model puts all three paths on the CMS. The public site is untouched. (This deletes the entire rev-2 cold-start track.)
  • Extracting AuthBlocks pages into a new RCL. They ship in Cerebellum.AuthBlocks.Web.
  • New DI/service wiring, new role seeding, new Auth connection string. All present.
  • Editing the AuthBlocks Login/Register pages to set their layout — impossible without forking the RCL, and unnecessary (G0-a handles layout host-side).

5. Phased breakdown (for clean dispatch)

One host (DeepDrftManager), two parallel tracks. The admin-nav track (19.1) exposes the gated admin surfaces; the public-route track (19.2) fixes the public auth pages' layout. They touch different files (CmsLayout.razor vs. Routes.razor) and are independent — kick both off together. Verification (19.3) follows both; theming (19.4) follows and is parallel-ok with verification.

  • 19.1 — CmsLayout navigation (admin-nav track; the main CMS code wave). DECIDED nav shape: G1-b. Add a MudDrawer + toggle to CmsLayout.razor; mount the shipped UserAdminMenu fragment (self-gates to UserAdmin+) and the existing CMS destinations (Catalogue /catalogue, Releases /releases, Upload /tracks/upload). Surface both admin account paths: path 1 (SuperRegister, /account/superregister) and path 3 (/useradmin/registrations/new, via the UserAdminMenu Registrations link → its New button). Do not surface the redundant bare NewUser (OQ2). Scope: CmsLayout.razor (+ a small .razor.css if the drawer needs sizing). No service, API, data, or AuthBlocks-source change.
    • Acceptance: an authenticated Admin sees a nav drawer; the User Administration group appears and links to Users / Registrations / Permissions; a "Create user" affordance reaches SuperRegister; a non-UserAdmin user does not see the group; existing CMS destinations are reachable from the drawer.
  • 19.2 — Public-route layout (public-route track; parallel to 19.1). DECIDED: G0-a. Make Routes.razor's DefaultLayout auth-state-driven, mirroring SkipperHaven (§2c D1): cascade Task<AuthenticationState>, resolve _currentLayout = authed ? CmsLayout : CmsHomeLayout, bind DefaultLayout="@_currentLayout". Scope: DeepDrftManager/Components/Routes.razor only. No new layout (both exist), no package, no service, no AuthBlocks-source change.
    • Acceptance: an unauthenticated visitor to /account/register sees the form in the lean CmsHomeLayout (not the admin app shell), can pre-fill from the deep link, and self-registers; /account/login likewise renders in the lean layout for an unauthenticated visitor; an authenticated admin still gets CmsLayout for the gated pages.
  • 19.3 — End-to-end verification (after 19.1 + 19.2). Exercise G2 against a running DeepDrftAPI. Confirm list/create/deactivate users, invite-email send (path 3) + correct {ReturnHost} → CMS origin, permission round-trips, cross-host token + CORS, and the full path-3→path-2 loop on the single CMS host. File any latent break as a follow-up (likely a one-line config fix — esp. the Mailtrap creds + return host — or an upstream AuthBlocks issue). Mostly test, not code.
  • 19.4 — Theming legibility sweep (after 19.1 + 19.2, parallel-ok with 19.3). Walk each user-admin page in the CMS palette, plus the public /account/register + /account/login in CmsHomeLayout; fix only contrast/legibility breaks. Defer bespoke restyle.

Dependency shape: {19.1, 19.2} → 19.3; 19.4 follows {19.1, 19.2} and is parallel-ok with 19.3. 19.1 and 19.2 are mutually independent (different files) and should kick off together. The path-3→path-2 acceptance in 19.3 needs 19.1 (to generate an invite) and 19.2 (to land the redeem in the lean layout); a token minted directly via the API can verify path 2 ahead of 19.1 if needed.


6. Open questions for Daniel

Resolved (Daniel, 2026-06-19):

  1. Nav shape (G1) — DECIDED G1-b. Real MudDrawer nav mounting UserAdminMenu + existing CMS destinations.
  2. Admin create paths — DECIDED: surface path 1 (SuperRegister) + path 3 (registration-token form); do NOT surface the bare NewUser. Both admin paths stay (provision-now vs. invite-by-email — not duplicates); NewUser is redundant with SuperRegister and hidden from nav.
  3. Reset Password — DECIDED: non-functional in v1, handled separately as an upstream AuthBlocks-repo effort (see authblocks-password-reset-brief.md). The 19.3 verification pass must not file it.
  4. Host model — DECIDED (this revision): all three paths on DeepDrftManager; NO DeepDrftPublic changes. Public registration is a public/unauthenticated CMS route exactly like the CMS login. The public-route work reduces to one router edit (G0-a, §2c D1).
  5. Public-route layout — DECIDED G0-a: auth-state-driven DefaultLayout in Routes.razor, mirroring SkipperHaven; reuses the existing CmsHomeLayout. (G0-b — per-page @layout — rejected: requires forking the RCL.)

Still open:

  1. Admin dashboard (G1-c) — defer or include? Recommend defer. Net-new surface beyond what AuthBlocks ships; v1 should expose the working pages, not build a new one.
  2. Package bump (G4) — now or separate? Bump Cerebellum.AuthBlocks.Web 10.3.33 → 10.3.35 in this pass, or leave it? Recommend leave it unless 19.3 surfaces a fix that needs it.
  3. Logged-in admin visiting /account/register. Under G0-a, an authenticated admin who navigates to the public register page sees it in CmsLayout (the app shell) rather than the lean layout. Recommend accept — it is coherent (an admin in a session sees the admin shell) and the page still works; the primary audience (unauthenticated invitees) gets the lean layout correctly. Flag only if Daniel wants the register page forced lean regardless of session.

None block 19.1 or 19.2.