Guides & specifications
Navigation Flow
As of: 2026-10-19 (detail routes typed {Id:int}, Store #214).
This document maps the site navigation flow for each actor in the MMCA.Store application. Each mermaid diagram shows the pages accessible to that actor and the directional navigation links between them. (Companion to MMCA.ADC's NavigationFlow.md; same per-actor format.)
Actors
| Actor | Access Level | Identification |
|---|---|---|
| Anonymous | Public catalog browse + auth pages | Not authenticated |
| Customer | Anonymous + profile, cart/checkout, own orders | Authenticated, default Customer role (customer_id claim) |
| Admin | Full access: Catalog/Sales/Identity admin CRUD | Authenticated, Admin role |
Roles and enforcement:
Adminis the only elevated role (registration creates aCustomer). The 19 admin pages carry page-level[Authorize(Roles = "Admin")]. The three per-module*RouteAuthorizationTests(MMCA.Store.CI.slnf:40,46,52) regression-gate all 19 in CI; the Identity test governs.Pages.Customers,.Pages.Rolesand the two user-administration namespaces.Pages.Users.UserListand.Pages.Users.UserDetail(IdentityRouteAuthorizationTests.cs:26-32). Customer-facing data is additionally row-scoped server-side (see Authorization Model at the end), so the page gate is defense-in-depth, never the boundary.
1. Anonymous User
Pages accessible without authentication: home, login, register, the two password-reset pages, and the public catalog.
flowchart TD
subgraph Auth["Authentication"]
Login["/login<br/>Login"]
Register["/register<br/>Register"]
ForgotPassword["/forgot-password<br/>Request Reset Link"]
ResetPassword["/reset-password<br/>Set New Password"]
end
subgraph Catalog["Public Catalog"]
Browse["/catalog<br/>Catalog Browse"]
ProductDetail["/catalog/:Id<br/>Product Detail"]
end
Home["/ Store Home"]
Home -->|hero / nav menu| Browse
Home -->|auth links| Login
Home -->|auth links| Register
Login -->|on success| Home
Register -->|on success| Home
Login -->|Forgot password link| ForgotPassword
ForgotPassword -->|back to sign in| Login
ForgotPassword -.->|reset link in the email| ResetPassword
ResetPassword -->|on success| Login
Browse -->|card click| ProductDetail
ProductDetail -->|back| Browse
ProductDetail -->|add to cart| Login
Add-to-cart on the product detail page sits inside an
AuthorizeView; an anonymous visitor is prompted to log in instead.
The product detail page carries a reviews section at the anchor
#reviews, and the browse cards show a star rating. Both are readable anonymously: the published-reviews endpoint isAllowAnonymousand output-cached. Only the review editor inside that section is behind anAuthorizeView.
/forgot-passwordand/reset-passwordare shipped by the framework UI package and carry[AllowAnonymous](ForgotPassword.razor:8,ResetPassword.razor:12). The dashed edge is the reset email: the link carries the email and the token in the URL fragment, which the browser never sends to a server, and the page reads and scrubs it from JS (ResetPassword.razor:125-129; the query-string form still works for older links). Both fields stay editable so a recipient can paste the token by hand.
2. Customer (Authenticated User)
Inherits all anonymous pages. Gains the profile page, the cart drawer (a layout component, not a route), checkout, and their own orders. Unauthenticated visitors deep-linking to /profile or /orders are redirected to login (SSR session-cookie auth keeps [Authorize] enforced on fresh GETs and F5, ADR-022). /orders/{Id:int} is the deliberate exception: the page is [AllowAnonymous] (OrderDetail.razor:11) because the Stripe ?payment=success return is a full-page load that server-side rendering cannot authenticate, so a page gate would bounce a paying customer to /login mid-payment. The protection is API-side: an anonymous read of the order is refused and the page renders only an alert (OrderDetail.razor:6-10).
flowchart TD
subgraph Auth["Authentication"]
Login["/login<br/>Login"]
end
subgraph Catalog["Public Catalog"]
Browse["/catalog<br/>Catalog Browse"]
ProductDetail["/catalog/:Id<br/>Product Detail"]
Reviews["Reviews section<br/>(anchor on the product page)"]
end
subgraph Sales["Cart and Orders"]
Cart["Cart Drawer<br/>(layout component)"]
Orders["/orders<br/>My Orders"]
OrderDetail["/orders/:Id<br/>Order Detail"]
end
subgraph Identity["Profile"]
Profile["/profile<br/>My Profile"]
Sessions["/profile/sessions<br/>Active Sessions, framework page"]
end
Home["/ Store Home"]
Home -->|nav menu| Browse
Home -->|nav menu| Orders
Home -->|nav menu| Profile
Home -->|nav menu| Sessions
Browse -->|card click| ProductDetail
ProductDetail -->|add to cart| Cart
Cart -->|checkout| Orders
Cart -->|Stripe payment| OrderDetail
Orders -->|row click| OrderDetail
OrderDetail -->|back| Orders
ProductDetail -->|reviews section| Reviews
OrderDetail -->|Rate this product, delivered orders| Reviews
A delivered order's lines carry a "Rate this product" link to that product's reviews anchor, which is the shortest path from "my parcel arrived" to writing the review the delivery entitled the customer to. The editor inside the section still checks eligibility server-side (
GET /Reviews/by-product/{productId}/mine), so the link is a shortcut, never the authorization.
/orderslists only the caller's own orders (ownershipSpecificationrow-scoping); an admin on the same route sees all orders. The order detail data is ownership-checked server-side with 404-not-403 semantics so foreign order ids do not leak existence. Abandoned Stripe payments are recovered by theOrphanOrderRecoverycomponent on return.
3. Admin
Inherits all customer pages, plus the admin CRUD surfaces for all three modules and the user and role administration pages. Every page below except the shared /orders pair (see the customer section) carries [Authorize(Roles = "Admin")]; a customer deep-linking to any of them gets Forbidden on both fresh GET and F5.
flowchart TD
subgraph CatalogAdmin["Catalog Admin"]
Categories["/categories<br/>Category List"]
CategoryCreate["/categories/create<br/>Create Category"]
CategoryDetail["/categories/:Id<br/>Category Detail"]
Products["/products<br/>Product List"]
ProductCreate["/products/create<br/>Create Product"]
ProductDetailAdm["/products/:Id<br/>Product Detail"]
ReviewsAdm["/reviews<br/>Review Moderation"]
end
subgraph SalesAdmin["Sales Admin"]
Inventory["/inventory<br/>Inventory List"]
InventoryCreate["/inventory/create<br/>Create Inventory Item"]
InventoryDetail["/inventory/:Id<br/>Inventory Detail"]
Carts["/shoppingcarts<br/>Shopping Cart List"]
CartDetail["/shoppingcarts/:Id<br/>Shopping Cart Detail"]
OrdersAll["/orders<br/>All Orders"]
OrderDetailAdm["/orders/:Id<br/>Order Detail"]
end
subgraph IdentityAdmin["Identity Admin"]
Customers["/customers<br/>Customer List"]
CustomerCreate["/customers/create<br/>Create Customer"]
CustomerDetail["/customers/:Id<br/>Customer Detail"]
Users["/users<br/>User Management"]
UserDetail["/users/:Id<br/>User Detail"]
Roles["/roles<br/>Role Administration"]
RoleEdit["/roles/:Role<br/>Role Permission Editor"]
end
Home["/ Store Home"]
Home -->|nav menu| Categories
Home -->|nav menu| Products
Home -->|nav menu| ReviewsAdm
Home -->|nav menu| Inventory
Home -->|nav menu| Carts
Home -->|nav menu| OrdersAll
Home -->|nav menu| Customers
Home -->|nav menu| Users
Home -->|nav menu, capability gated| Roles
Categories -->|row click| CategoryDetail
Categories -->|create| CategoryCreate
Products -->|row click| ProductDetailAdm
Products -->|create| ProductCreate
Inventory -->|row click| InventoryDetail
Inventory -->|create| InventoryCreate
Carts -->|row click| CartDetail
OrdersAll -->|row click| OrderDetailAdm
Customers -->|row click| CustomerDetail
Customers -->|create| CustomerCreate
ReviewsAdm -->|reviewed product| ProductDetailAdm
Users -->|row click| UserDetail
Roles -->|edit role| RoleEdit
RoleEdit -->|back| Roles
The admin order detail page opens
ShipOrderDialogfor both shipment actions: "Ship order" on a Paid order and "Edit tracking" on a Shipped one. One dialog instance serves both, and the summary panel picks the endpoint. It is a dialog rather than a route, so it does not appear as a node above.
/reviewsis the moderation grid: every review whatever its status, with hide and unhide. The page carries the Admin route guard, and the endpoints behind it carry the finer-grainedcatalog:reviews:moderatepermission, which today only the Admin role holds.
/users,/users/{Id:int},/rolesand/roles/{Role}are Store pages in the Identity UI module whose roster, operator actions and permission editor come from the framework's shared administration components (UserAdminListatUserList.razor:12,RoleAdminListatRoleList.razor:10,RoleAdminEditatRoleEdit.razor:10; ADR-116). Each carries[Authorize(Roles = "Admin")](:2on the two role pages,:5on the two user pages). The Users nav item is gated on the Admin role (IdentityUIModule.cs:23); the Roles item is gated on theroles:managecapability rather than on a role (IdentityUIModule.cs:29-35), because the screen exists to change the role-to-capability map. The two gates differ: a non-Admin role grantedroles:managewould see the Roles item but be answered Forbidden by the page's Admin role guard. Store types the detail route as/users/{Id:int}with no:intconstraint (UserDetail.razor:1).
Route Contract
This table lists every routable page Store itself ships, one row per route, with the access it requires, so a Store architecture test can compare it with the @page routes and their authorization attributes and a route or guard change lands together with its row here. Every Store page lives in a module UI project (Catalog, Sales, Identity); the web and MAUI heads declare no @page of their own. Framework-owned routes are listed under Framework-owned routes in the Authorization Model section instead. The Access column uses exactly three labels, each mapped from the page's @attribute line: Anonymous is [Microsoft.AspNetCore.Authorization.AllowAnonymous], Authenticated is a bare [Microsoft.AspNetCore.Authorization.Authorize], and Admin is [Microsoft.AspNetCore.Authorization.Authorize(Roles = RoleNames.Admin)]. No Store page uses a policy-based guard or places its guard on the partial class in the .razor.cs. Route parameters are written exactly as the @page directive declares them; Store's eight detail routes are typed {Id:int} (Store #214) and {Role} stays an untyped role name, so the table carries the :int constraint wherever the directive does.
| Route | Page | Access |
|---|---|---|
/catalog |
CatalogBrowse | Anonymous |
/catalog/{Id:int} |
CatalogProductDetail | Anonymous |
/categories |
CategoryList | Admin |
/categories/create |
CategoryCreate | Admin |
/categories/{Id:int} |
CategoryDetail | Admin |
/customers |
CustomerList | Admin |
/customers/create |
CustomerCreate | Admin |
/customers/{Id:int} |
CustomerDetail | Admin |
/inventory |
InventoryItemList | Admin |
/inventory/create |
InventoryItemCreate | Admin |
/inventory/{Id:int} |
InventoryItemDetail | Admin |
/orders |
OrderList | Authenticated |
/orders/{Id:int} |
OrderDetail | Anonymous |
/products |
ProductList | Admin |
/products/create |
ProductCreate | Admin |
/products/{Id:int} |
ProductDetail | Admin |
/profile |
Profile | Authenticated |
/reviews |
ReviewList | Admin |
/roles |
RoleList | Admin |
/roles/{Role} |
RoleEdit | Admin |
/shoppingcarts |
ShoppingCartList | Admin |
/shoppingcarts/{Id:int} |
ShoppingCartDetail | Admin |
/users |
UserList | Admin |
/users/{Id:int} |
UserDetail | Admin |
Authorization Model
Three cooperating layers; the API is always the boundary:
- Page-level route guards. The 19 admin pages carry
[Authorize(Roles = "Admin")],/profileand/orderscarry[Authorize], and/orders/{Id:int}is deliberately[AllowAnonymous](see the customer section; the order API is its gate). SSR session-cookie auth (ADR-022,mmca_auth_accessHttpOnly cookie) lets these attributes pass on fresh GETs, F5, and new tabs, so deep links never render a protected shell to the wrong actor. Regression-gated byCatalog/Sales/IdentityRouteAuthorizationTestsin CI (commitc4adff2) for all 19 admin pages, the/userspair included (IdentityRouteAuthorizationTests.cs:26-32);SharedOrderPages_AreNotAdminGatedkeeps the shared order pages off the Admin role (SalesRouteAuthorizationTests.cs:34-47). The full route-by-route list is the Route Contract section above. - API resource ownership (ADR-033).
OwnerOrAdminFilter403s requests whosecustomer_idclaim mismatches the owner parameter, andOwnershipHelper.GetOwnershipSpecification()row-scopes collection queries so customers only ever receive their own carts/orders. Per-mutation checks on orders return 404-not-403 to avoid leaking existence. - In-page conditionals.
AuthorizeViewhides customer-only affordances (add-to-cart) from anonymous visitors and admin-only affordances from customers; these are UX sugar on top of layers 1-2, never the enforcement.
Menu items are rendered per role (the Roles item per capability), so each actor's nav menu contains only the routes shown in their diagram above, plus the framework-owned routes below, which the shared MMCA.Common.UI shell contributes to every host.
Framework-owned routes. These routes come from the MMCA.Common.UI package rather than from a Store module; the UI host routes that assembly alongside its own (UI.Web/Program.cs:279-281). None of them has a row in the Route Contract table:
/(home) is the framework page (MMCA.Common.UI/Pages/Home.razor:1,[AllowAnonymous]at:6), filled with Store's own content throughIHomePageContent; no Store page declares@page "/"./login,/register,/forgot-passwordand/reset-passwordare framework pages too, each[AllowAnonymous](Pages/Auth/Login.razor:23,Register.razor:19,ForgotPassword.razor:8,ResetPassword.razor:12), as are the shell's/not-foundand/forbiddenpages and/auth/oauth-complete, which Store never links to because it configures no external sign-in provider./profile/sessions(active refresh sessions) is rendered in the nav for every signed-in user, Customer and Admin alike, by the frameworkNavMenu(MMCA.Common.UI/Layout/NavMenu.razor:87-90); a host can restrict the link to one role withLayout:SessionsNavRequiredRole(read atNavMenu.razor:252-253), which Store deliberately leaves unset so customers can review and revoke their own sessions./confirm-emailis the anonymous email-confirmation landing page (MMCA.Common.UI/Pages/Auth/ConfirmEmail.razor:1,[AllowAnonymous]at:3). Store serves the framework page rather than a copy of its own (Store commit65b53f5a), and the Identity service points the confirmation link at it (ConfirmationUrl,Identity.Service/appsettings.json:116). It has no nav item: a visitor reaches it only from the link in the confirmation email./notifications,/notifications/inbox(with/notifications/inbox/{Id:int}) and/notifications/sendship in the same package behind the framework's notification-page policies (NotificationList.razor:9,NotificationInbox.razor:7,NotificationSend.razor:10). Store registers no notification services, so both web heads opt in toLayout:HideNotificationPagesWhenUnregistered(UI.Web/appsettings.json:16-18,UI.Web.Client/wwwroot/appsettings.json:16) and the router answers all four routes with the not-found page and a 404 status (MMCA.Common.UI/Routes.razor:16-26,Notifications/NotificationPageGate.cs:29-31). They have no nav item and are not a Store surface. The MAUI head does not set the flag (itsappsettings.jsoncarries onlyModulesandApi), so there the pages remain routable by URL behind those policies.