Onboarding guide
15. Common UI Framework (MudBlazor components, theme, base pages)
What this chapter covers. MMCA.Common.UI is the Blazor presentation package, the one
layer in the framework that, like Grpc, is allowed to reference Shared only (primer §1).
It depends on no Application/Domain/Infrastructure type so it can compile into a Blazor WebAssembly
bundle, and it ships the reusable building blocks every consumer UI assembles into pages: a
server-paged data-grid list-page base class, the brand MudBlazor theme, a typed HTTP
service base for talking to the WebAPI, the client-side authentication + token-refresh boundary,
list-page state preservation across navigation, a pluggable UI-module contract, and a
turnkey notification inbox / push feature. This is the framework half of the "write-once UI,
render everywhere" story from primer §2, the per-app and per-module Razor pages (ADC's
Conference/Engagement/Identity UIs, group 21) derive from and consume these primitives, and the
same components render across Blazor Server, WebAssembly, and the .NET MAUI hybrid host with no
per-platform reimplementation. [Rubric §18, UI Architecture & Component Design] (assesses
component reuse, separation of presentation from data access, and a coherent composition model);
nearly every type here exists so a consumer page is composed, not hand-rolled.
The data-access boundary: IEntityService over a named HttpClient. A UI never calls
HttpClient directly. It depends on
IEntityService<TEntityDTO, TIdentifierType>,
the CRUD contract (GetAllAsync, GetPagedAsync, GetByIdAsync, AddAsync, UpdateAsync,
DeleteAsync, plus a GetAllForLookupAsync for dropdowns), and gets behaviour for free from the
abstract
EntityServiceBase<TEntityDTO, TIdentifierType>.
That base derives from
AuthenticatedServiceBase, which
owns the two cross-cutting concerns of an outbound API call: a Polly exponential-backoff retry
policy (3 retries on HttpRequestException or 5xx, backoff 2s/4s/8s,
AuthenticatedServiceBase.cs:22-25) and a helper that stamps the JWT Bearer token onto a freshly
created "APIClient" HttpClient from IHttpClientFactory
(AuthenticatedServiceBase.cs:36-55). The named "APIClient" is wired once in
AddUIShared with its base address and an
AuthDelegatingHandler message handler
(DependencyInjection.cs:61-74). Responses come back wrapped in the same
PagedCollectionResult<T> /
CollectionResult<T> envelopes the API
returns, and EntityServiceBase.SendRequestAsync pulls domain/validation errors out of a failed
response body via
ServiceExceptionHelper before
EnsureSuccessStatusCode can throw a contextless exception (EntityServiceBase.cs:185-189), so a
back-end Result.Failure surfaces to the page as a typed, displayable error. [Rubric §3, Clean Architecture] and [Rubric §9, API & Contract Design]: the UI binds to a DTO contract and an
interface, never to the server's internals, and the wire envelope is uniform across every entity.
The list page: DataGridListPageBase<TDto>. This is the most concept-dense type in the group
and the centrepiece of the "compose, don't repeat" thesis. Every list screen in every consumer app
derives from
DataGridListPageBase<TDto>, a
ComponentBase that encapsulates what was otherwise copy-pasted onto each page: server-side paging
against MudDataGrid<T>, CancellationTokenSource lifecycle, loading state, filter/sort extraction
from MudBlazor's GridState<T>, error surfacing through ISnackbar, viewport-driven mobile vs.
desktop rendering (it implements IBrowserViewportObserver, switching to a card list below the
960 px sidebar-collapse breakpoint), and a careful IAsyncDisposable/IDisposable teardown
(DataGridListPageBase.cs:20). Crucially it also solves a Blazor render-mode problem: it persists
the grid data captured during SSR pre-render via PersistentComponentState into a
PersistedGridState record, then restores it
on the first interactive ServerData call (DataGridListPageBase.cs:122-150) so the InteractiveAuto
SSR→Server→WASM transition doesn't flash an empty grid and re-fetch. [Rubric §23, Front-End Performance & Rendering] (assesses render efficiency, avoiding redundant fetches/round-trips), this
state-persistence dance is exactly that concern made concrete; the inline comments cite the
MudDataGrid v9 pager quirks the code works around (see also the team memory on the
RowsPerPage/CurrentPage setter bug).
State preservation across navigation: ListPageStateService + query state. Paging, sort, and
filter are kept in the URL query string as the source of truth (so deep-links and browser
back/forward replay correctly), while the noisier scroll position lives in
ListPageStateService, a per-circuit
scoped service backed by an in-memory dictionary that mirrors entries through sessionStorage
(via a nav-interop.js module) so state survives circuit teardowns, forceLoad navigations, and
the SSR→WASM transition (ListPageStateService.cs:98-162). The immutable
ListPageState record carries page/pageSize/scroll
/sort/filters and is updated with with expressions. The companion
ListPageQueryStateService handles the
URL half, and NavigationHistoryService
tracks an in-app history stack for "back" affordances. [Rubric §19, State Management & Data Flow]
(assesses a deliberate, scoped state model rather than ambient globals), note these are registered
Scoped so each Blazor circuit/user session gets its own instance, and [Rubric §25, Navigation, Routing & Information Architecture] for the history/return-URL handling (see
ReturnUrlProtector and the
RoutePaths/NavItem
route catalogue).
Authentication and the token-refresh boundary. Client-side auth is contracted by
IAuthUIService (login, register, OAuth-code
exchange, logout, refresh, change-password) and implemented by
AuthUIService, which calls the WebAPI auth/*
endpoints, persists tokens through
ITokenStorageService, and pushes auth-state
changes through
JwtAuthenticationStateProvider
so Blazor's AuthorizeView/<CascadingAuthenticationState> reacts instantly (AuthUIService.cs:8-18).
The clever part is the host-polymorphic token refresh: the single
ITokenRefresher abstraction has two
implementations chosen per host,
SameOriginProxyTokenRefresher
for the browser (the refresh token lives in an HttpOnly cookie, rotation happens server-side via
a same-origin /auth/session/token proxy, so JS never sees the refresh token) and
DirectApiTokenRefresher for MAUI (the
refresh token sits in OS SecureStorage and is exchanged directly against auth/refresh)
(ITokenRefresher.cs:3-12). [Rubric §26, Front-End Security] (assesses token handling, XSS
exposure, secret storage): keeping the refresh token out of JS-reachable storage in the browser is a
deliberate XSS-mitigation; the OAuth code-exchange indirection keeps tokens out of the address
bar (ADR-004 covers the cross-service JWKS validation these tokens flow into). The
ISessionCookieSync /
JsFetchSessionCookieSync pair mirrors
the in-memory access token into the HttpOnly cookie that the SSR prerender reads, so the very first
server render of an authenticated page already knows who you are.
Design system and theming: MMCATheme + BrandColors. Visual consistency is centralized in
one static MMCATheme MudTheme (light and dark
palettes, Inter typography scale, a 6 px border radius, MMCATheme.cs:11), applied via
MudThemeProvider from the shared MmcaThemeProviders component that the root layout renders once
(MmcaThemeProviders.razor:11). The brand palette is sourced from a single C# source of truth,
BrandColors; the CSS custom properties in
app.css must mirror it, and a BrandColorTokenTests fitness test asserts the two stay in sync
(MMCATheme.cs:15-17). The colour choices carry explicit WCAG-contrast reasoning in the comments
(e.g. teal bumped to #00796B for a ~5.3:1 ratio on light surfaces, MMCATheme.cs:21-24).
[Rubric §20, Design System, Theming & Consistency] (assesses a single source of truth for tokens,
dark-mode support, consistent typography) is the home category here, and [Rubric §21, Accessibility (a11y)] is woven into the palette itself, colour decisions are made to clear the
4.5:1 normal-text floor, complementing the axe-core WCAG 2.1 AA scans run in the UI E2E suite.
BreakpointConstants names the responsive
thresholds that DataGridListPageBase and consumer layouts switch on. [Rubric §22, Responsive & Cross-Browser/Device].
Internationalization: one culture cookie, end to end. The framework now serves en-US (default)
and Spanish (es), and the hard part is not the translation files, it is making one culture decision
agree across the InteractiveAuto split (SSR prerender → InteractiveServer circuit →
InteractiveWebAssembly client) and across the cross-origin REST services behind the Gateway, with no
flash of the wrong language and no prerender/hydration mismatch (ADR-027, which supersedes the prior
single-locale stance of ADR-011). A single non-HttpOnly culture cookie is the source of truth: the WASM
client reads it on startup through
MmcaCultureBootstrap.SetBrowserCultureAsync,
which sets CultureInfo.DefaultThreadCurrent[UI]Culture before RunAsync() (falling back to
SupportedCultures.Default,
MmcaCultureBootstrap.cs:22-34) so prerender and hydration render the same language. Outbound API calls
then forward the active culture as an Accept-Language header via
CultureDelegatingHandler
(CultureDelegatingHandler.cs:20-25), wired into the "APIClient" pipeline in
AddUIShared (DependencyInjection.cs:58,74),
because the cross-origin Gateway does not carry the cookie through to the services, so that header is
what makes a backend Result.Failure come back localized. View and chrome strings are externalized to
co-located .resx looked up by IStringLocalizer<T> (AddLocalization() at DependencyInjection.cs:40);
SharedResource (SharedResource.cs:9) is the marker
type whose IStringLocalizer<SharedResource> anchors the cross-cutting chrome strings the shared layout
renders (MainLayout.razor:12).
SupportedCultures (group 12) is the canonical
allowlist; adding a locale is adding a .es.resx sibling and one allowlist entry, not new
infrastructure. [Rubric §27, Internationalization] (assesses externalized strings, a culture flow that
survives the render-mode boundary, and server-side localized errors) is the home category here.
Per-user preference persistence. A signed-in user's culture choice follows them across devices: it
is persisted to the Identity profile (User.PreferredCulture) through
IUserPreferenceWriter /
ApiUserPreferenceWriter, which PUTs to
auth/preferences over the shared "APIClient" (ApiUserPreferenceWriter.cs:34-35). The write is
strictly best-effort and anonymous-no-op (ApiUserPreferenceWriter.cs:25-29): the cookie/localStorage
is the device-local runtime channel, the DB value is the cross-device source of truth, and a failed or
skipped persist never breaks the in-page switch. The write payload is the private
UserPreferencesRequest record
(ApiUserPreferenceWriter.cs:19); the login-time read side,
ApiUserPreferenceReader behind
IUserPreferenceReader, GETs the same
endpoint and returns the immutable
UserPreferences record, whose null fields mean
"leave unchanged" (ApiUserPreferenceReader.cs:18-35). [Rubric §19, State Management & Data Flow] (a deliberate, scoped channel for user
state rather than ambient globals).
Dark mode: binding the palette that was always there.
MMCATheme has always declared a complete PaletteDark
(MMCATheme.cs:50-86) beside the light palette, but the provider was once hard-wired to light: designed,
then never connected. ThemeService (ThemeService.cs:16,
registered Scoped in AddUIShared at DependencyInjection.cs:83) now owns the preference. The shared
MainLayout renders one MmcaThemeProviders component (MainLayout.razor:14) that binds
<MudThemeProvider Theme="MMCATheme.Instance" @bind-IsDarkMode> (MmcaThemeProviders.razor:11), and the
service persists the choice to a non-HttpOnly cookie + localStorage through a theme.js interop module
(ThemeService.cs:53-59), defaulting to the OS prefers-color-scheme (its systemPrefersDark interop)
only when nothing is stored (ThemeService.cs:42-45). It raises an OnChange event (ThemeService.cs:28)
so the app-bar toggle and the layout stay in sync, and the same per-user pipeline carries the choice to
User.PreferredTheme (ADR-028, which reuses ADR-027's cookie/profile/bootstrap machinery rather than
inventing a parallel one). The ThemeToggle component ships in that shared MainLayout beside the
CultureSwitcher in the appbar-icon-actions slot (MainLayout.razor:32-35), so every consumer host
gets both controls with no per-host wiring. [Rubric §20, Design System, Theming & Consistency]
(dark-mode support over a single token source) and [Rubric §19, State Management] are the home
categories. Honest caveat: unlike locale, the no-flash SSR bootstrap is not yet wired for theme,
MmcaThemeProviders calls ThemeService.InitializeAsync from OnAfterRenderAsync(firstRender) and
deliberately does not read the stored value during SSR prerender (MmcaThemeProviders.razor:21-30,
ThemeService.cs:34-49), so the bound mode is corrected just after hydration and a brief wrong-theme
flash on first paint is currently possible (tracked as an ADR-028 follow-up).
Pluggable UI modules: IUIModule. The module system that organizes the back end (primer §2;
IModule, group 14) has a UI counterpart in
IUIModule. Each consumer module exposes its
navigation entries (NavItem list), the Assembly
holding its Razor pages (so the host can AddAdditionalAssemblies for route discovery), and
optional app-bar / root-layout component types (IUIModule.cs:10-23). The host enumerates the
registered IUIModules to build the sidebar and discover routes, so adding a feature module wires
its pages and menu items into the shell with no edits to the shell itself, the vertical-slice and
open/closed ideas applied to the front end. [Rubric §18, UI Architecture] and [Rubric §1, SOLID] (Open/Closed).
A complete vertical slice shipped in the framework: notifications. Unlike the rest of the
package (which is base classes consumers extend), the Notifications area is a finished feature
that any app can switch on: the
NotificationUIModule (an IUIModule)
contributes the inbox route and an app-bar bell; the
NotificationInbox/NotificationList/NotificationSend
Razor components render it; NotificationInboxService
(behind INotificationInboxUIService)
fetches the inbox over HTTP; PushNotificationService
(behind IPushNotificationUIService)
manages opt-in; and NotificationHubService
holds a SignalR connection to the API's notification hub, reconnecting with exponential backoff
and invoking a callback that surfaces incoming notifications as MudBlazor snackbars
(NotificationHubService.cs:9-43). Shared mutable UI state lives in
NotificationState; the whole feature is
wired by its own
DependencyInjection in the Notifications
namespace, kept separate so apps that don't want real-time notifications never pay for the SignalR
plumbing.
How it all wires up at startup. A host's Program.cs calls
AddUIShared(configuration) (a C#
extension(IServiceCollection) member, per primer §4) once: it binds and validates on start
ApiSettings (fail-fast on missing config,
DependencyInjection.cs:29-33), binds LayoutSettings
without validation (deliberately optional, defaulting to BrandName = "MMCA" / empty footer,
DependencyInjection.cs:36-37, LayoutSettings.cs:7-17),
registers the "APIClient" HttpClient with its auth and culture handlers, and TryAdds the auth service, the
list-page state services, the navigation history service, the ThemeService,
and a default IOAuthUISettings that downstream apps override, then finally
AddDeviceCapabilityDefaults() so a form-factor contract resolves on every head (ADR-042)
(DependencyInjection.cs:27-97). The use of TryAdd* is what lets a consumer pre-register its own
implementations before this runs. Server hosts additionally call AddClientAuthSessionCookieSync
and the WebApplicationExtensions.UseAuthenticatedNoStore
middleware, which emits Cache-Control: no-store on authenticated HTML responses so a logged-out
user pressing Back never sees a previous user's logged-in page from the bfcache
(WebApplicationExtensions.cs:25-45), another [Rubric §26, Front-End Security] touch. The
small Level-0 supporting cast, ErrorMessages,
NavSection,
NotificationRoutePaths,
UIModuleConfiguration,
IHomePageContent,
JwtTokenInfo,
MauiBackNavigationBridge and friends,
fills in route catalogues, home-page content, and MAUI back-button bridging that the higher-level
services lean on; form-factor detection has since graduated into its own device-capability layer
(IFormFactor and friends, ADR-042, group 26). The presentational helper
MoneyExtensions formats
Money for display (culture-invariant, so $12.50 USD
never becomes $12,50 USD on a European host), display concerns kept out of the domain value
object, exactly where Clean Architecture wants them.
Read the per-type sections that follow for the mechanics. The companion consumer-side UI lives in the ADC module-UI chapters (group 21), and the bUnit/Playwright tests that exercise this package are covered in the testing chapter (group 25).
BreakpointConstants
MMCA.Common.UI ·
MMCA.Common.UI.Common·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/BreakpointConstants.cs:9· Level 0 · class (static)
- What it is: A single static helper that answers "is this viewport a mobile viewport?" so C# viewport detection lines up with the CSS media-query boundary used across the design system.
- Depends on:
MudBlazor.Breakpoint(NuGet); the CSS@mediaboundary at 960px in the shared stylesheet. - Concept introduced:
[Rubric §22, Responsive & Cross-Browser]assesses whether a codebase has one authoritative breakpoint definition rather than magic numbers scattered per component; this class embodies it by centralising the mobile/desktop split in one predicate. The class comment (BreakpointConstants.cs:12) documents the "< 960 px" threshold so the C# side and the CSS side share one number. - Walkthrough: The only member is
IsMobileBreakpoint(Breakpoint breakpoint)(BreakpointConstants.cs:16), an expression-bodied method returningtruewhen the MudBlazor breakpoint isXsorSm. That is the sole condition; anythingMdor wider is treated as desktop. - Why it's built this way: Static and dependency-free so any Razor component can call it without DI, and so changing the mobile threshold is a one-line edit paired with one CSS rule rather than a hunt through component code.
- Where it's used: DataGridListPageBase<TDto> and other layout-aware components that switch between a desktop
MudDataGridand a mobile card layout on viewport change.
IApiSettings
MMCA.Common.UI ·
MMCA.Common.UI.Common.Settings·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Settings/IApiSettings.cs:6· Level 0 · interface
- What it is: The read-only contract describing where the WebAPI backend lives, with a deliberate split between the URL the server uses and the URL served to the browser.
- Depends on: Nothing; implemented by ApiSettings and consumed by the named
HttpClientsetup in DependencyInjection. - Concept introduced:
[Rubric §12, Performance & Scalability]assesses whether the deployment can avoid unnecessary network hops; the dual-endpoint idea directly serves it.ApiEndpoint(IApiSettings.cs:9) is the base URL the host uses for its own calls, whileWasmApiEndpoint(IApiSettings.cs:17) is the endpoint pushed to the WebAssembly client over/client-config. The doc comment states the intent: the server may use an internal URL (faster, no public DNS) while the browser uses the external URL, andWasmApiEndpointfalls back toApiEndpointwhen null. - Walkthrough: Two nullable string getters only:
ApiEndpointandWasmApiEndpoint. Both arestring?; the interface makes no promise that either is populated, so validation lives on the concrete class. - Why it's built this way: An interface (not the concrete class) is what HTTP-client configuration binds against, keeping that setup mockable in tests and decoupled from the options binding mechanism.
- Where it's used: ApiSettings implements it; the
/client-configbootstrap servesWasmApiEndpointto the browser. - Caveats / not-in-source: The current DependencyInjection
AddUISharedregisters a single named"APIClient"HttpClient bound toApiEndpoint; theWasmApiEndpointfallback and its browser delivery happen outside this method (the/client-configendpoint), so this file only defines the contract, not the two-client wiring.
IHomePageContent
MMCA.Common.UI ·
MMCA.Common.UI.Common.Interfaces·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Interfaces/IHomePageContent.cs:8· Level 0 · interface
- What it is: A hook that lets each consuming application supply its own landing-page component at the
/route without forking the shared routing or layout. - Depends on:
Microsoft.AspNetCore.Components.DynamicComponent(referenced in the doc comment) and each app's own landing-page Razor component. - Concept introduced:
[Rubric §18, UI Architecture]assesses how well shared UI infrastructure adapts to per-app content without duplication; this interface embodies it. The/route is defined once in the shared package and renders aDynamicComponentbound toComponentType, so a new app plugs in a home page by registering an implementation rather than editing the route. - Walkthrough: Two read-only members:
ComponentType(IHomePageContent.cs:11), theSystem.Typeof the Razor component to render as the home-page body, andPageTitle(IHomePageContent.cs:14), the browser-tab title.ComponentTypeis passed toDynamicComponentat runtime, so the shared page needs no compile-time reference to the app-specific component. - Why it's built this way: Runtime
Typebinding viaDynamicComponentkeeps the shared package free of any dependency on downstream landing pages. - Where it's used: The shared home-page component in
MMCA.Common.UI; each consumer (ADC, Store) registers its own implementation in DI.
LayoutSettings
MMCA.Common.UI ·
MMCA.Common.UI.Common.Settings·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Settings/LayoutSettings.cs:7· Level 0 · class (sealed)
- What it is: Strongly-typed options for light white-labeling: the navbar brand text and the footer text, bound from the
"Layout"configuration section. - Depends on:
Microsoft.Extensions.Configuration(via options binding); the shared navbar and footer components. - Concept introduced:
[Rubric §10, Cross-Cutting Concerns]assesses whether presentation constants are externalised rather than hard-coded; this class embodies it by moving brand/footer copy into configuration. - Walkthrough:
SectionName = "Layout"(LayoutSettings.cs:10) names the bound section.BrandName(LayoutSettings.cs:13) defaults to"MMCA"and shows in the top-left navbar link;FooterText(LayoutSettings.cs:16) defaults tostring.Empty. Both areinit-only. - Why it's built this way: Sealed and
init-only for immutability after binding; the defaults mean a host with noLayoutsection still renders sensibly, so rebranding is a config change, not a code change. - Where it's used: Bound in DependencyInjection
AddUIShared(DependencyInjection.cs:36); read by navbar/footer components throughIOptions<LayoutSettings>.
NavSection
MMCA.Common.UI ·
MMCA.Common.UI.Common·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/NavSection.cs:7· Level 0 · enum
- What it is: Classifies a navigation entry into one of three sidebar groups by audience: everyone, authenticated users, or administrators.
- Depends on: Nothing; consumed by NavItem and the navbar component.
- Concept introduced:
[Rubric §25, Navigation & Information Architecture]assesses whether the menu structure is audience-aware and declarative; this enum embodies it.[Rubric §11, Security]also applies, since section membership feeds the role-gated rendering of the sidebar. - Walkthrough: Three values in declaration order:
General(NavSection.cs:10) for anonymous plus authenticated items,User(NavSection.cs:13) for authenticated non-admin items,Admin(NavSection.cs:16) for admin/organizer items. The comment atNavSection.cs:5notes sections render in declaration order, so the enum order is an implicit rendering contract. - Why it's built this way: An enum (not a string) gives exhaustive switch coverage in the renderer and rules out typos when a module registers a nav item.
- Where it's used: The
Sectionparameter on NavItem; the navbar groups and filters items byNavSection.
NotificationRoutePaths
MMCA.Common.UI ·
MMCA.Common.UI.Common·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/NotificationRoutePaths.cs:6· Level 0 · class (static)
- What it is: The route-path constants for the notification UI feature: the notifications list, the admin send page, and the inbox.
- Depends on: Nothing; referenced by notification Razor components and nav-item registration.
- Concept introduced: Same "one source of truth for route strings" idea as RoutePaths; this class is the notification-scoped instance of it, following the codebase convention that module-specific paths live in their own
*RoutePathsclass rather than in the shared RoutePaths. - Walkthrough: Three
static readonlystrings:Notifications = "/notifications"(NotificationRoutePaths.cs:8),NotificationSend = "/notifications/send"(NotificationRoutePaths.cs:9), andNotificationInbox = "/notifications/inbox"(NotificationRoutePaths.cs:10). - Why it's built this way: Isolated from RoutePaths so an app that never enables notifications does not carry an irrelevant constant set, and so notification routes evolve independently.
- Where it's used: Notification page components; navbar registration for the notification bell and the admin "send" link.
RoutePaths
MMCA.Common.UI ·
MMCA.Common.UI.Common·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/RoutePaths.cs:7· Level 0 · class (static)
- What it is: The shared route-path constants owned by the common UI package. Module-specific routes stay in their own
*RoutePathsclasses. - Depends on: Nothing; referenced by
@pagedirectives andNavigationManager.NavigateTocalls. - Concept introduced:
[Rubric §25, Navigation & Information Architecture]also covers URL/route hygiene; centralising cross-cutting routes here keeps literal path strings from scattering across components. - Walkthrough: Currently one member,
Home = "/"(RoutePaths.cs:9), astatic readonlystring. The class comment (RoutePaths.cs:5) states additional shared routes are added here as the package grows. - Why it's built this way:
static readonly(notconst) is sufficient because these strings are used in navigation calls rather than attribute arguments; one shared class avoids hard-coded duplicates. - Where it's used: Navigation calls to the home route across the shared UI and consuming apps.
UIModuleConfiguration
MMCA.Common.UI ·
MMCA.Common.UI.Common.Settings·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Settings/UIModuleConfiguration.cs:10· Level 0 · class (static)
- What it is: A configuration reader that answers "is this UI module enabled?" against the same
Modulessection the server layer uses, so UI registration and server registration stay in step. - Depends on:
Microsoft.Extensions.Configuration.IConfiguration; conceptually paired with the Application layer's ModulesSettings, which reads the same section. - Concept introduced:
[Rubric §10, Cross-Cutting Concerns]assesses whether a single toggle governs a concern end to end; this helper embodies it by readingModules:{name}:Enabledso there is one switch, not one server switch and a separate UI switch. - Walkthrough:
ModulesSectionName = "Modules"(UIModuleConfiguration.cs:12) is the private section name.IsModuleEnabled(IConfiguration configuration, string moduleName)(UIModuleConfiguration.cs:18) readsModules:{moduleName}; it returnstruewhen that section does not exist and otherwise readsEnabledwith a default oftrue(UIModuleConfiguration.cs:20-21). Only an explicitEnabled: falseturns a module off. - Why it's built this way: Static because it is a pure configuration read with no DI dependency; default-enabled preserves backward compatibility for deployments that predate a
Modulessection. - Where it's used: Per-module UI registration extensions guard their service and route registrations with
IsModuleEnabled.
UISharedAssemblyReference
MMCA.Common.UI ·
MMCA.Common.UI·MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:123· Level 0 · class
- What it is: An empty marker class whose only purpose is to give code a stable
typeof(...).Assemblyhandle on the UI assembly for reflection-based scanning (for example Scrutor component discovery). - Depends on: Nothing.
- Concept introduced: The assembly-marker pattern: rather than passing a fragile assembly-name string, code references a known type in the target assembly and reads
.Assemblyoff it. Each layer in the framework has an equivalent marker; this is the UI layer's. - Walkthrough: A one-line class declaration,
public class UISharedAssemblyReference;(DependencyInjection.cs:123), sharing the file with the DependencyInjection extension class but declared at namespace scope beneath it. - Why it's built this way: A dedicated marker type makes assembly-scanning call sites refactor-safe (the compiler tracks the type reference) and self-documenting.
- Where it's used: Assembly-scanning registrations that enumerate UI components and services.
WebApplicationExtensions
MMCA.Common.UI ·
MMCA.Common.UI.Extensions·MMCA.Common/Source/Presentation/MMCA.Common.UI/Extensions/WebApplicationExtensions.cs:9· Level 0 · class (static)
- What it is: Provides
UseAuthenticatedNoStore(), middleware that stampsCache-Control: no-storeon HTML responses to authenticated users so the browser back-forward cache cannot restore a logged-in page after logout. - Depends on:
Microsoft.AspNetCore.Builder.IApplicationBuilderandMicrosoft.AspNetCore.Http; it readsHttpContext.User.Identity.IsAuthenticated. - Concept introduced:
[Rubric §26, Front-End Security]assesses defenses against stale-session exposure in the browser; this middleware embodies it. Back-forward cache (bfcache) restores a full DOM snapshot without a new request, so withoutno-storea user who logs out and presses Back could see the previous authenticated HTML. The doc comment (WebApplicationExtensions.cs:13-24) spells out both goals: block bfcache restore and force a fresh server render on authenticated back-navigation. - Walkthrough: The single member is declared inside an
extension(IApplicationBuilder app)block (WebApplicationExtensions.cs:11), the C# preview extension syntax (see primer §4).UseAuthenticatedNoStore()(WebApplicationExtensions.cs:25) registers an inline middleware that hooksResponse.OnStarting(WebApplicationExtensions.cs:29); the callback only sets headers when the user is authenticated and the content type starts withtext/html(WebApplicationExtensions.cs:31-33), then writesCache-Control: no-store, no-cache, must-revalidate, max-age=0andPragma: no-cache(WebApplicationExtensions.cs:35-36). Anonymous pages stay bfcache-eligible. - Why it's built this way: Scoping the header to authenticated HTML avoids penalising anonymous-page performance; the comment (
WebApplicationExtensions.cs:23) warns it must be registered beforeMapRazorComponentsso it wraps every page response. - Where it's used: Each consuming host's startup pipeline, after authentication is wired.
ApiSettings
MMCA.Common.UI ·
MMCA.Common.UI.Common.Settings·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Settings/ApiSettings.cs:9· Level 1 · class (sealed)
- What it is: The concrete options object bound to the
"Api"configuration section and the sole implementation of IApiSettings. - Depends on: IApiSettings;
System.ComponentModel.DataAnnotationsfor the[Required]attribute. - Concept introduced:
[Rubric §33, Developer Experience]assesses fail-fast configuration; this class embodies it.ApiEndpoint(ApiSettings.cs:16) carries[Required](ApiSettings.cs:15), and because DependencyInjection binds it withValidateDataAnnotations().ValidateOnStart(), a missing endpoint fails the host at startup rather than at the first HTTP call. - Walkthrough:
SectionName = "Api"(ApiSettings.cs:12) names the bound section.ApiEndpointis a requiredinitstring;WasmApiEndpoint(ApiSettings.cs:19) is an optionalinitstring carrying<inheritdoc/>from the interface. - Why it's built this way: Sealed and
init-only for immutability after binding; validation attributes on the concrete class let the DI layer opt into startup validation. - Where it's used: Bound and validated in DependencyInjection
AddUIShared(DependencyInjection.cs:30-33) and read viaIOptions<ApiSettings>when the named HttpClient sets its base address.
NavItem
MMCA.Common.UI ·
MMCA.Common.UI.Common·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/NavItem.cs:17· Level 1 · record
- What it is: An immutable description of one sidebar entry a UI module contributes: title, href, icon, optional role/claim gates, its NavSection, an optional collapsible group, and an optional localization resource type.
- Depends on: NavSection;
System.Type(BCL) for the optional resource type. - Concept introduced:
[Rubric §25, Navigation & Information Architecture]assesses modular, role-aware navigation;NavItemembodies it because modules contribute items and the sidebar renders them filtered by role and claim, mirroring the server-side IModule "modules contribute their own surface" pattern.[Rubric §27, Internationalization]also applies: the record supports localized menu titles (ADR-027). - Walkthrough: A positional record with eight parameters (
NavItem.cs:17):Title,Href,Icon, thenRequiredRole = null(render only for that role),RequiredClaim = null(render only for that claim type),Section = NavSection.General,Group = null(nest inside a collapsibleMudNavGroup), andTitleResource = null. The doc comment (NavItem.cs:10-15) explains the localization rule: whenTitleResourceis set,TitleandGroupare treated as resource KEYS resolved against that resource type at render time (per-circuit, so the menu follows the active culture); when the key is missing orTitleResourceis null, the raw string renders as before, keeping existing literal-titled items working. - Why it's built this way: A record gives value semantics and concise construction; making localization opt-in through a nullable
TitleResourcemeans the ADR-027 support was added without breaking any existing literal-titled registration. - Where it's used: Returned in the
NavItemslist of each IUIModule; rendered by the shared sidebar/menu component.
IEntityService<TEntityDTO, TIdentifierType>
MMCA.Common.UI ·
MMCA.Common.UI.Common.Interfaces·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Interfaces/IEntityService.cs:12· Level 2 · interface
- What it is: The generic CRUD service contract every UI module page injects to talk to its API endpoints, so components depend on an abstraction rather than a raw
HttpClientcall. - Depends on: IBaseDTO<TIdentifierType> (the
TEntityDTOconstraint atIEntityService.cs:13) and BaseLookup<TIdentifierType> (the lookup return type); implemented by EntityServiceBase<TEntityDTO, TIdentifierType>. - Concept introduced:
[Rubric §18, UI Architecture]assesses clean separation between components and their data access; this interface embodies it.[Rubric §9, API & Contract Design]also applies, since the method set mirrors the REST surface (list, paged query, lookup, get-by-id, create, update, delete) the API exposes. Two generic constraints (IEntityService.cs:13-14) bind the DTO to IBaseDTO<TIdentifierType> and requireTIdentifierType : notnull. - Walkthrough: Seven async members.
GetAllAsync(IEntityService.cs:17) with optional FK and child inclusion flags.GetPagedAsync(IEntityService.cs:23) takes a filter dictionary of(Operator, Value)pairs plus page number/size and sort column/direction, and returns an(Items, TotalItems)tuple for server-side paging.GetAllForLookupAsync(IEntityService.cs:33) returns lightweightId + NameBaseLookup<TIdentifierType> items for dropdowns.GetByIdAsync(IEntityService.cs:38) returns null on a 404.AddAsync(IEntityService.cs:44) returns the server-assigned DTO including its generated id.UpdateAsync(IEntityService.cs:49) andDeleteAsync(IEntityService.cs:54) returnboolsuccess. - Why it's built this way: An interface keeps Blazor components testable (mock the service) and hides the API URL structure behind a typed surface; the paged-query signature exists so grids never fetch a whole table.
- Where it's used: Implemented by EntityServiceBase<TEntityDTO, TIdentifierType> (Level 3); consumed by every module's CRUD page components, including those built on DataGridListPageBase<TDto>.
IUIModule
MMCA.Common.UI ·
MMCA.Common.UI.Common.Interfaces·MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Interfaces/IUIModule.cs:10· Level 2 · interface
- What it is: The UI-side counterpart to the server IModule: each pluggable UI module declares its navigation, its assembly (for Blazor route discovery), and optional components it injects into the app bar and root layout.
- Depends on: NavItem;
System.Reflection.Assembly(BCL). - Concept introduced:
[Rubric §18, UI Architecture]assesses pluggable, discoverable UI composition; this interface embodies it. The Blazor host feeds each module'sAssemblytoAddAdditionalAssembliesso routes are discovered at runtime, mirroring how the server discovers IModule implementations.[Rubric §25, Navigation & Information Architecture]also applies, since nav items are contributed by modules rather than hard-coded in the layout. - Walkthrough:
NavItems(IUIModule.cs:13) is the module's list of NavItem entries for the shared sidebar.Assembly(IUIModule.cs:16) is the Razor-component assembly used for route discovery.AppBarComponentTypes(IUIModule.cs:19) andLayoutComponentTypes(IUIModule.cs:22) are default-implemented to return an empty array ([]), so a module only overrides them when it contributes app-bar widgets (for example a cart icon) or root-layout overlays (for example drawers). - Why it's built this way: Default interface members mean a simple module implements two properties, not four; the assembly handle keeps route discovery reflection-based so adding a module needs no central route edit.
- Where it's used: Implemented by each app's UI module classes (Conference/Engagement/Identity/Notification UI modules); consumed by the Blazor host during composition.
MoneyExtensions
MMCA.Common.UI ·
MMCA.Common.UI.Extensions·MMCA.Common/Source/Presentation/MMCA.Common.UI/Extensions/MoneyExtensions.cs:9· Level 5 · class (static)
- What it is: Two formatting helpers that turn Money value objects into user-facing price strings: a single price and a price range.
- Depends on: Money and its Currency (via
MMCA.Common.Shared.ValueObjects);System.Globalization.CultureInfo. - Concept introduced:
[Rubric §20, Design System & Theming]covers consistent, presentational formatting; keeping display formatting here rather than on the domain Money embodies the Clean Architecture rule that the domain stays display-agnostic. - Walkthrough:
ToDisplayString(this Money price)(MoneyExtensions.cs:12) formats the amount with"N2"underCultureInfo.InvariantCultureand appends the currency code, yielding e.g.$12.50 USD.ToDisplayRange(this IReadOnlyCollection<Money> prices)(MoneyExtensions.cs:19) returns an empty string for an empty collection (MoneyExtensions.cs:21-24), computes min and max amounts (MoneyExtensions.cs:26-27), takes the currency code from the first element (MoneyExtensions.cs:28), and renders a single price when min equals max or a range otherwise (MoneyExtensions.cs:30-32). - Why it's built this way:
CultureInfo.InvariantCultureguarantees a stable$12.50style regardless of server locale (never$12,50); collapsing an equal min/max into one price avoids showing a pointless$10.00 - $10.00. - Where it's used: Price display in product-listing and cart components in the Store UI.
- Caveats / not-in-source:
ToDisplayRangereads the currency fromprices.First()and does not verify all prices share a currency; a mixed-currency collection would render the range with the first element's code. That single-currency assumption is an application-level invariant, not enforced here.
DependencyInjection
MMCA.Common.UI ·
MMCA.Common.UI·MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:19· Level 7 · class (static, withextension(IServiceCollection)block)
- What it is: The one-call registration surface every UI host (Blazor Server, WebAssembly, MAUI) invokes to wire the shared UI infrastructure: API settings, the localization stack, the authenticated HttpClient, auth and state services, theme, per-user preferences, OAuth defaults, and device-capability defaults.
- Depends on: ApiSettings / LayoutSettings; AuthDelegatingHandler and the culture handler; IAuthUIService, ListPageStateService, ListPageQueryStateService, NavigationHistoryService, ThemeService; the localization decorators and
MudBlazor.MudLocalizer; IFormFactor (viaAddWasmFormFactor);Microsoft.Extensions.Configuration,DependencyInjection,Localization, andOptions. - Concept introduced: This is the composition root for the UI layer, and it leans on the C# preview
extension(IServiceCollection services)syntax (DependencyInjection.cs:21) instead of classicthis-parameter extension methods (see primer §4).[Rubric §15, Best Practices & Code Quality](consistent DI patterns across the codebase) and[Rubric §33, Developer Experience](fail-fast startup) both apply;[Rubric §27, Internationalization]and[Rubric §20, Design System & Theming]apply through the localization and theme registrations (ADR-027, ADR-028). - Walkthrough: Three methods live in the extension block.
AddUIShared(IConfiguration configuration)(DependencyInjection.cs:27): binds ApiSettings with.ValidateDataAnnotations().ValidateOnStart()so a missingApiEndpointfails startup (DependencyInjection.cs:30-33); binds LayoutSettings without validation (DependencyInjection.cs:36-37). Adds resource localization (AddLocalization,DependencyInjection.cs:40) and decoratesIStringLocalizerFactorywithPseudoStringLocalizerFactory(DependencyInjection.cs:47) for the pseudo-locale (ADR-027, inert under every other culture).TryAddTransientregisters aResxMudLocalizerfor MudBlazor's built-in component text (DependencyInjection.cs:53). Registers AuthDelegatingHandler andCultureDelegatingHandleras transient (DependencyInjection.cs:57-58), then the named"APIClient"HttpClient (DependencyInjection.cs:61) whose factory readsApiSettings.ApiEndpoint, throws if it is blank (DependencyInjection.cs:64-67), sets the base address andAccept: application/json, and chains both message handlers (DependencyInjection.cs:73-74) so every outgoing call carries the bearer token and the active UI culture asAccept-Language. A run ofTryAddcalls follows so multiple hosts can compose without duplicate registrations:IAuthUIService(DependencyInjection.cs:77), ListPageStateService and ListPageQueryStateService (DependencyInjection.cs:78-79), NavigationHistoryService (DependencyInjection.cs:80), ThemeService (DependencyInjection.cs:83, ADR-028 day/dark preference), the per-user preference reader/writer (DependencyInjection.cs:86-87, ADR-027/028), a default no-opIOAuthUISettings(DependencyInjection.cs:91), and finallyAddDeviceCapabilityDefaults()(DependencyInjection.cs:95, ADR-042) so every capability contract resolves on every head.AddClientAuthSessionCookieSync()(DependencyInjection.cs:105):TryAddScopedforISessionCookieSync -> JsFetchSessionCookieSync, the bridge that mirrors the client's in-memory tokens into the HttpOnly cookie read by server-side SSR prerender. Called from both the Blazor Server head and the WASM client.AddWasmFormFactor()(DependencyInjection.cs:117): registers the WebAssembly IFormFactor implementation; the Blazor Server and MAUI heads register their own instead.
- Why it's built this way:
TryAddthroughout makes the method safe to call from multiple composing hosts; the comments note deliberate ordering choices, for example that device-capability defaults register first so MAUI/browser heads override afterward with last-registration-wins (DependencyInjection.cs:93-95), and that theMudLocalizerTryAddis authoritative becauseAddMudServicesregisters none of its own (DependencyInjection.cs:49-52). - Where it's used: Called once at startup by each consuming UI host; the auth/culture handlers it registers are consumed by every EntityServiceBase<TEntityDTO, TIdentifierType>-derived service through the
"APIClient"HttpClient.
LoginModel
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/LoginModel.cs:9· Level 0 · class (sealed)
- What it is: the
EditFormbacking model for the Login page, two string properties (Email,Password) carrying DataAnnotations for field-level validation. - Depends on:
System.ComponentModel.DataAnnotations(BCL):[Required],[EmailAddress]. Nothing first-party. - Concept introduced, the form-backing model +
DataAnnotationsValidator.[Rubric §24, Forms, Validation & UX Safety](assesses whether forms validate at the field level with clear, inline messages before submit) and[Rubric §26, Front-End Security](assesses that client-side checks are a UX convenience, not the trust boundary). A BlazorEditFormbinds to a plain model; a<DataAnnotationsValidator />reads the attributes and surfaces a per-field message as the user types, so the submit handler only fires on a valid form. The doc comment (LoginModel.cs:5-7) is explicit that the server remains the authority on whether the credentials are actually valid, the form just prevents an obviously-malformed request. - Walkthrough: two
get; set;properties:Email(line 13),[Required(ErrorMessage = "Email is required")]+[EmailAddress(ErrorMessage = "Enter a valid email address")], defaulting tostring.Empty.Password(line 16),[Required(ErrorMessage = "Password is required")]; deliberately no complexity rule here, login validates an existing credential, not a new one.
- Why it's built this way:
sealedand mutable (set, notinit) becauseEditFormtwo-way-binds each input to the model; the messages are authored inline so each field shows one clear verdict. - Where it's used: instantiated as
_modeland bound byLogin.razor(<EditForm Model="_model" OnValidSubmit="HandleLoginAsync">+<DataAnnotationsValidator />,Login.razor:31-32, field at line 129); on valid submit the page hands the credentials to the injectedIAuthUIServiceas aLoginRequest(Login.razor:172). Sibling ofRegisterModel.
MudTranslations
MMCA.Common.UI ·
MMCA.Common.UI.Resources·MMCA.Common/Source/Presentation/MMCA.Common.UI/Resources/MudTranslations.cs:10· Level 0 · class (sealed)
- What it is: an empty marker class that anchors a
.resxresource pair for MudBlazor's own built-in component text, the data-grid pager and filter menus, pickers, table editing, pagination, snackbar/alert close buttons, and input adornments (ADR-027). - Depends on: nothing first-party (the type has no members). Its meaning comes from its co-located resources, whose keys mirror MudBlazor's own
LanguageResourcekeys (v9.6.0) with the English values copied verbatim so en-US behavior is unchanged, and fromResxMudLocalizer, which injectsIStringLocalizer<MudTranslations>and hands those strings to MudBlazor's localization interceptor. - Concept reinforced, the resource-anchor type. The anchor-type idiom is introduced in full at
SharedResource: ASP.NET Core'sIStringLocalizer<T>resolves keys against the.resxwhose base name matchesT, so a dedicated empty class becomes the name of a shared string table.MudTranslationsis the second anchor, scoped to third-party (MudBlazor) chrome rather than app chrome.[Rubric §27, Internationalization](assesses whether all user-visible copy, including the component library's, follows the active culture) and[Rubric §20, Design System & Theming](assesses a consistent design system; the pager reading "Filas por página" instead of "Rows per page" undereskeeps the whole surface coherent). - Walkthrough: there are no members. The whole contract is "be a public sealed type named
MudTranslationsin this namespace, with sibling.resxfiles whose keys match MudBlazor'sLanguageResource." The doc comment (MudTranslations.cs:3-9) records the verbatim-English-mirror invariant. - Why it's built this way: MudBlazor localizes its built-in strings through an injectable
MudLocalizer, but only for non-English cultures, and it needs some resource base to read from; a separate anchor keeps the library's keys in their own table (mirroring the upstream names one-to-one), cleanly apart from the app's ownSharedResourcechrome. This is the ADR-027 way to translate a dependency you do not own. - Where it's used: injected as
IStringLocalizer<MudTranslations>byResxMudLocalizer, which is registered as MudBlazor'sMudLocalizerinDependencyInjection.AddUIShared(DependencyInjection.cs:53). - Caveats / not-in-source: the
.resxfiles and their per-key match to MudBlazor v9.6.0'sLanguageResourceare resources, not.cs; individual key contents are not enumerated here.
PasswordComplexityAttribute
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/PasswordComplexityAttribute.cs:12· Level 0 · class (sealed attribute)
- What it is: a custom
ValidationAttributethat enforces the Register form's password-strength rule, at least 8 characters including an uppercase, a lowercase, a digit, and a special (non-alphanumeric) character. - Depends on:
System.ComponentModel.DataAnnotations(ValidationAttribute,ValidationResult,ValidationContext) andchar.IsUpper/IsLower/IsDigit/IsLetterOrDigit(BCL). Nothing first-party. - Concept introduced, extending DataAnnotations with a domain rule.
[Rubric §24, Forms, Validation & UX Safety](assesses client-side validation parity with the server). Beyond the built-in[Required]/[EmailAddress], a bespoke rule subclassesValidationAttributeand overridesIsValid. The doc comment (PasswordComplexityAttribute.cs:5-9) states the intent: mirror the server's rule so theEditFormgives the same verdict the API would. The downstream server-side story, how an accepted password is then hashed, is ADR-032 (PBKDF2-HMAC-SHA512 with legacy-hash backward compatibility); this attribute is only the client-side gate, never the security boundary. - Walkthrough:
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)](line 11), applied as[PasswordComplexity]on a property.- Constructor (lines 14-17) seeds the base
ErrorMessagewith the full human-readable rule. IsValid(object?, ValidationContext)(lines 19-39): returnsValidationResult.Successfor null/empty input (lines 21-24), deliberately deferring the "missing" message toRequiredAttributeso the field shows one message, not two; otherwise evaluates five LINQ predicates (Length >= 8,Any(char.IsUpper),Any(char.IsLower),Any(char.IsDigit),Any(c => !char.IsLetterOrDigit(c)), lines 26-30) and, on failure, returns aValidationResultscoped to the member name (lines 37-38) so the message attaches to the right field.
- Why it's built this way: a
ValidationAttributeplugs straight into the sameDataAnnotationsValidatorthat drives the rest of the form, so the complexity rule participates in the standard EditForm lifecycle with no extra wiring; emptiness is delegated to[Required]to avoid duplicate messages on one field. - Where it's used: applied to
RegisterModel.Password(RegisterModel,RegisterModel.cs:22); evaluated by the<DataAnnotationsValidator />inRegister.razor(Register.razor:26). - Caveats / not-in-source: the doc comment claims parity with the server's rule; this file only encodes the client check, so whether the server rule is byte-identical is not verifiable from this source.
PersistedGridState
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Common·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/DataGridListPageBase.cs:778· Level 0 · record (sealed, private)
- What it is: a tiny serializable record
(List<TDto> Items, int TotalItems)that carries the grid's already-fetched data from the SSR pre-render pass into the interactive circuit, so the first interactiveServerDatacall can return instantly instead of re-hitting the API. - Depends on:
Microsoft.AspNetCore.Components.PersistentComponentState(the Blazor mechanism that serializes it). Nested privately insideDataGridListPageBase<TDto>. - Concept introduced,
PersistentComponentStateto skip the double fetch.[Rubric §19, State Management & Data Flow]and[Rubric §23, Front-End Performance & Rendering](assesses avoiding redundant work across render-mode transitions). Under InteractiveAuto a page renders multiple times, static SSR then interactive Server then WASM, and naively each transition would re-run the data fetch. Blazor'sPersistentComponentStateserializes chosen data into the pre-rendered HTML and rehydrates it in the interactive circuit;PersistedGridStateis the payload for the grid's data slice, so the visible "fetch, cancel, re-fetch" flicker of the render-mode handoff disappears. - Walkthrough: declared as
private sealed record PersistedGridState(List<TDto> Items, int TotalItems)(line 778). On the persisting side, the base'sRegisterOnPersistingcallback (DataGridListPageBase.cs:140-150) writesnew PersistedGridState([.. _lastSuccessfulGridData.Items], _lastSuccessfulGridData.TotalItems)(line 145) keyed bygrid:{GetType().FullName}(built at line 127). On the restoring side, the synchronousOnInitialized(lines 127-131) callsApplicationState.TryTakeFromJson<PersistedGridState>(persistKey, out var restored)and, if present, rebuilds aGridData<TDto>(line 130) that the firstLoadServerDataAsyncreturns directly. - Why it's built this way:
privatebecause the persistence is purely an implementation detail of the base class;sealed recordfor JSON-serialization friendliness and value semantics; the items are materialized into a freshList<TDto>([.. …]) so the persisted snapshot is decoupled from the live grid data. - Where it's used: exclusively inside
DataGridListPageBase<TDto>; every derived list page inherits the behavior for free. - Caveats / not-in-source: the record is nested at the bottom of the file (line 778) though it is a Level-0 collaborator; the restore runs in the synchronous
OnInitialized, and the persisting callback is registered with an explicitMicrosoft.AspNetCore.Components.Web.RenderMode.InteractiveAuto(line 150) to satisfy the framework's "callback must be associated with a render mode" rule during the static prerender pass, because the page inherits its render mode from<Routes @rendermode="InteractiveAuto">rather than declaring one itself.
PseudoLocalizer
MMCA.Common.UI ·
MMCA.Common.UI.Globalization·MMCA.Common/Source/Presentation/MMCA.Common.UI/Globalization/PseudoLocalizer.cs:20· Level 0 · class (static)
- What it is: a pure string transform that "pseudo-localizes" text, it accents every letter, pads the result by roughly 40% to simulate real-translation expansion, and wraps it in
[!! … !!]bracket sentinels, while leaving composite-format placeholders ({0},{name}) byte-identical so the string can still be formatted with arguments (ADR-027 §8). - Depends on:
System.Text.StringBuilderandchar.IsLetter(BCL). Nothing first-party. It is consumed byPseudoStringLocalizer. - Concept introduced, pseudo-localization as an i18n fitness test.
[Rubric §27, Internationalization](assesses whether the app is genuinely translation-ready, not just wired for one extra language) and[Rubric §28, Front-End Testing](assesses whether i18n defects are caught automatically). Pseudo-localization is a development-time technique that surfaces three classes of bug in a single visual pass, without needing a real second translation: (1) any string that stays plain ASCII was hard-coded rather than pulled from a resource, and stands out beside the accented text; (2) any UI that truncates the padded text has a fixed-width layout that a real (longer) translation would break; (3) any label built by concatenating fragments shows one sentinel per fragment, exposing the joins that translate badly. The MMCA CI pipeline runs a pseudo-loc E2E gate over this exact transform. - Walkthrough:
- Three constants (lines 22-24):
OpenSentinel = "[!! ",CloseSentinel = " !!]", andCombiningAcute(the combining acute accent code point) appended after each base glyph so the letter stays readable while visibly altered. Transform(string value)(lines 30-74): returns null/empty input unchanged (lines 32-35); pre-sizes aStringBuilderwith slack for the padding (line 37) and appends the open sentinel (line 38); then walks each character (lines 42-66) tracking aninsidePlaceholderflag toggled by{and}(lines 46-53) so placeholder bodies are copied verbatim, and for every letter outside a placeholder appends the combining accent and increments aletterscounter (lines 56-62); finally computes the pad length asMax(1, letters * 2 / 5)(40%, line 69), appends that many `` characters (lines 70-71) and the close sentinel (line 72), and returns the string (line 73).
- Three constants (lines 22-24):
- Why it's built this way: keeping the transform pure and static (input string to output string, no culture check inside) makes it trivially unit-testable and lets the culture gating live one layer up in
PseudoStringLocalizer. Preserving{…}placeholders is essential: transforming them would corruptstring.Format, so pseudo-loc must accent the template and only then substitute arguments (see the two-step inPseudoStringLocalizer). - Where it's used: called by
PseudoStringLocalizeron every resolved string when the current UI culture is the pseudo locale (SupportedCultures.PseudoLocale,"qps-Ploc"); inert otherwise.
RegisterModel
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/RegisterModel.cs:9· Level 0 · class (sealed)
- What it is: the
EditFormbacking model for the Register page, name/email/password fields with DataAnnotations plus six optional address fields. - Depends on:
System.ComponentModel.DataAnnotations([Required],[EmailAddress],[Compare]) and the sibling first-partyPasswordComplexityAttribute. - Concept reinforced, multi-field form validation with a cross-field compare.
[Rubric §24, Forms, Validation & UX Safety]. This builds on theLoginModelshape with three richer rules:[PasswordComplexity]on the password,[Compare(nameof(Password))]on the confirmation (cross-field equality), and an address block left attribute-free (optional). The doc comment (RegisterModel.cs:5-7) notes the annotations mirror the server's rules so client and server agree. - Walkthrough:
FirstName/LastName(lines 12,15), each[Required].Email(line 19),[Required]+[EmailAddress].Password(line 23),[Required]+[PasswordComplexity](line 22).ConfirmPassword(line 27),[Required]+[Compare(nameof(Password), ErrorMessage = "Passwords do not match")](line 26), the cross-field check.AddressLine1plus nullableAddressLine2/City/State/ZipCode/Country(lines 30-35), no validation attributes; the inline comment (line 29) states an empty Line 1 means "no address supplied".
- Why it's built this way: the address fields stay attribute-free so a user can register without supplying one; the model is a flat view-model that the page projects onto the wire DTO at submit time rather than reusing the domain type directly.
- Where it's used: instantiated as
_modeland bound byRegister.razor(<EditForm Model="_model" OnValidSubmit="HandleRegisterAsync">,Register.razor:25, field at line 121); on valid submit the page projects it into aRegisterRequest(Register.razor:145), with the address fields folded into anAddressvia aBuildAddress()helper (Register.razor:125,Address.Createat line 130). The accepted password is hashed server-side per ADR-032.
SharedResource
MMCA.Common.UI ·
MMCA.Common.UI.Resources·MMCA.Common/Source/Presentation/MMCA.Common.UI/Resources/SharedResource.cs:9· Level 0 · class (sealed)
- What it is: an empty marker class that anchors
IStringLocalizer<SharedResource>over its co-located.resxfiles, the single home for cross-cutting UI chrome strings (ADR-027). - Depends on: nothing first-party (the type is empty). Its meaning comes from the co-located resources
SharedResource.resx(the en default) andSharedResource.es.resx(Spanish), and from the ASP.NET Core localization stack that bindsIStringLocalizer<T>to the.resxnamed afterT. - Concept introduced, the resource-anchor type.
[Rubric §27, Internationalization](assesses whether user-facing copy is externalized to per-culture resources keyed stably, not hard-coded). ASP.NET Core'sIStringLocalizer<T>convention resolves keys against the resource file whose base name matches the typeT. So a dedicated empty class becomes the name that ties many components to one shared string table: injectingIStringLocalizer<SharedResource>anywhere reads the same dotted, stable keys (e.g.Common.Error.Load,Grid.Snackbar.LoadCancelled). The doc comment (SharedResource.cs:3-7) enumerates the chrome it covers: buttons, layout labels, snackbar/error templates, and the culture- and theme-switcher text. Its counterpart for library chrome isMudTranslations. - Walkthrough: there are no members. The whole contract is "be a public sealed type named
SharedResourcein this namespace, with sibling.resxfiles." The work lives in the.resxkey/value pairs and the localization middleware that resolves them by culture. - Why it's built this way: a marker type is the idiomatic ASP.NET Core way to scope a shared resource table without inventing a real class; one anchor keeps the chrome strings in a single table every component shares (ADR-027 supersedes the prior single-locale stance of ADR-011).
- Where it's used: injected as
IStringLocalizer<SharedResource>byDataGridListPageBase<TDto>(DataGridListPageBase.cs:23) for its cancellation snackbar, and handed toErrorMessages.Configurefrom the root layout (MainLayout.razor:102) so the static helper resolves the same table; broadly consumed by the layout, the culture switcher, and the theme toggle components. - Caveats / not-in-source: the
.resxfiles (SharedResource.resx,SharedResource.es.resx) are resources, not.cs; their per-key contents are not enumerated here.
PseudoStringLocalizer
MMCA.Common.UI ·
MMCA.Common.UI.Globalization·MMCA.Common/Source/Presentation/MMCA.Common.UI/Globalization/PseudoStringLocalizer.cs:13· Level 1 · class (sealed)
- What it is: an
IStringLocalizerdecorator that pseudo-localizes every resolved string, but only when the current UI culture is the pseudo locale; under every other culture it delegates unchanged to the wrapped localizer, so it is inert in production (ADR-027 §8). - Depends on:
PseudoLocalizer(the transform, Level 0),SupportedCultures(itsPseudoLocale/IsPseudoLocalefromMMCA.Common.Shared.Globalization), andIStringLocalizer/LocalizedString/CultureInfo(BCL/NuGet). Constructed with aninnerIStringLocalizervia a primary constructor. - Concept introduced, the decorator that gates on culture.
[Rubric §2, Design Patterns](assesses idiomatic use of patterns; this is a textbook Decorator, same interface in and out, wrapping behavior around a delegate) and[Rubric §27, Internationalization]. The key design move is that pseudo-localization is a cross-cutting transform applied to the localizer, not to any call site: because it implementsIStringLocalizerand forwards toinner, it can be slid underneath everyIStringLocalizer<T>in the app at once by decorating the factory (PseudoStringLocalizerFactory), with zero changes to consumers. - Walkthrough:
IsPseudoActive(lines 16-17), a private static bool that returnsSupportedCultures.IsPseudoLocale(CultureInfo.CurrentUICulture.Name), the single gate every member checks.this[string name](lines 20-29): resolvesinner[name], then, if pseudo is active, returns a newLocalizedStringwhose value isPseudoLocalizer.Transform(localized.Value)while preservingResourceNotFound/SearchedLocation(line 26); otherwise returns the inner value untouched.this[string name, params object[] arguments](lines 32-48): when pseudo is inactive, delegates straight toinner[name, arguments](lines 36-39); when active it does the two-step that makes placeholders survive, transform the raw template first (line 44), thenstring.Formatthe accented template with the arguments (line 45), so the substituted values are never accented or padded.GetAllStrings(bool includeParentCultures)(lines 51-57): maps the transform over every string when active, passes them through otherwise.
- Why it's built this way: gating inside the decorator (rather than conditionally registering it) keeps DI wiring unconditional and simple, the decorator is always present and simply does nothing outside the pseudo locale, which is only ever activatable in Development. Splitting the pure transform (
PseudoLocalizer) from the culture-aware decorator keeps each single-responsibility and independently testable (§1 SOLID). - Where it's used: produced by
PseudoStringLocalizerFactoryaround every localizer the inner factory creates, so it transparently wrapsIStringLocalizer<SharedResource>,IStringLocalizer<MudTranslations>, and every other localizer in the host.
ResxMudLocalizer
MMCA.Common.UI ·
MMCA.Common.UI.Globalization·MMCA.Common/Source/Presentation/MMCA.Common.UI/Globalization/ResxMudLocalizer.cs:17· Level 1 · class (sealed, internal)
- What it is: MudBlazor's
MudLocalizerimplementation that resolves the library's built-in component text from theMudTranslationsresource pair, so MudBlazor chrome (pager, filter menus, pickers, close buttons) follows the active UI culture (ADR-027). - Depends on:
MudBlazor.MudLocalizer(the abstract base, NuGet),IStringLocalizer<MudTranslations>(injected via primary constructor), andMudTranslations(Level 0). Nothing else first-party. - Concept introduced, adapting a third-party localization hook.
[Rubric §2, Design Patterns](this is an Adapter, bridging MudBlazor'sMudLocalizercontract to the ASP.NET CoreIStringLocalizerworld) and[Rubric §27, Internationalization]. MudBlazor exposes exactly one extension point for translating its built-in strings: subclassMudLocalizerand override its indexer. This adapter routes that indexer straight toIStringLocalizer<MudTranslations>. MudBlazor's ownDefaultLocalizationInterceptorconsults this localizer only for non-English cultures and falls back to its built-in English whenever the returnedLocalizedString.ResourceNotFoundis true (per the doc comment,ResxMudLocalizer.cs:9-12), so any untranslated key degrades gracefully. - Walkthrough: a one-line class.
internal sealed class ResxMudLocalizer(IStringLocalizer<MudTranslations> localizer) : MudLocalizer(line 17) with a singlepublic override LocalizedString this[string key] => localizer[key];(line 19). The doc comment (lines 13-15) also notes that because resolution flows through the DIIStringLocalizerFactory, thePseudoStringLocalizerFactorydecorator applies here too, so under the pseudo locale MudBlazor's chrome pseudo-localizes alongside the application text. - Why it's built this way:
internalbecause it is pure host wiring no consumer needs to name; delegating to the injectedIStringLocalizer<MudTranslations>reuses the exact same.resx/factory pipeline as app strings (one localization mechanism, not two), which is what lets pseudo-loc reach MudBlazor for free. - Where it's used: registered as MudBlazor's
MudLocalizerinDependencyInjection.AddUISharedviaservices.TryAddTransient<MudBlazor.MudLocalizer, ResxMudLocalizer>()(DependencyInjection.cs:53).TryAddis authoritative becauseAddMudServicesdoes not register aMudLocalizerof its own (guarded by a DI-resolution test, per the comment atDependencyInjection.cs:50-52), regardless of host registration order.
ErrorMessages
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Common·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/ErrorMessages.cs:17· Level 2 · class (static)
- What it is: a centralized factory of user-facing snackbar message strings (load/save/delete/not-found/validation/action), so every page code-behind reports an outcome with identical phrasing, resolved through a shared localizer when one is configured (ADR-027).
- Depends on:
IStringLocalizer/LocalizedString(Microsoft.Extensions.Localization, NuGet),string.FormatwithCultureInfo.CurrentCulture(BCL), and the first-partyDomainInvariantViolationException(the one exception whose message is shown). The localizer it is handed is anIStringLocalizer<SharedResource>(per the doc comment,ErrorMessages.cs:25), so it shares theSharedResource.resxkeys. - Concept introduced, the static-helper-with-injected-localizer bridge plus a safe-exception carve-out.
[Rubric §27, Internationalization](assesses whether user-facing copy resolves per UI culture from resources rather than hard-coded English),[Rubric §16, Maintainability](assesses whether a wording change is localized to one place), and[Rubric §24, Forms, Validation & UX Safety](assesses that raw error text is not leaked to the user). This type is the boundary where a static helper (callable from any page without DI) is back-filled with a culture-aware localizer: each method calls a privateLocalize(key, fallbackFormat, args)that returns the localized value when the localizer is set and the key resolves, else the inline English fallback, so the static call sites never change yet the output follows the current culture. The load-bearing subtlety is the exception carve-out: aDomainInvariantViolationExceptionhas itsMessageshown verbatim (becauseServiceExceptionHelperrethrows the API's Problem Details errors as that type and their text is curated, server-localized domain wording, ADR-027 Decisions 3 and 5), while every other exception'sMessageis deliberately not surfaced (raw exception text is neither localizable nor safe to show, ADR-027 Decision 9). - Walkthrough: a static class holding one mutable localizer field plus pure builders:
_localizer(line 19), a nullableIStringLocalizer?, null until configured.Configure(IStringLocalizer localizer)(line 26), the one-time wiring point: assigns_localizer; idempotent; called from the root layout (see Where it's used).Localize(key, fallbackFormat, args)(lines 28-40), the resolution core: if_localizeris set and the lookup'sResourceNotFoundis false, returnslocalized.Value(lines 30-37); elsestring.Format(CultureInfo.CurrentCulture, fallbackFormat, args)(line 39).LoadError/SaveError/DeleteError(lines 52-67), the three CRUD failure paths, eachex is DomainInvariantViolationException ? ex.Message : Localize("Common.Error.Load"/Save/Delete, "Error loading {0}.", entityName, ex.Message)(lines 52-55 and the two<inheritdoc>siblings): the curated domain message reaches the user, otherwise the localized entity-name template does.ActionError(Exception ex, string localizedFallback)(lines 77-78), the whole-sentence variant for pages whose fallback is a full sentence key of their own resource pair rather than an entity-noun template: same carve-out, but the non-domain branch returns the caller's already-localizedlocalizedFallback.DeleteFailed(lines 80-81, keyCommon.Error.DeleteFailed), the "API returned a non-error but the delete didn't happen" case, distinct fromDeleteError(which carries an exception).NotFound(lines 83-84, keyCommon.Error.NotFound), interpolates the missing id.ValidationError(lines 86-87, keyCommon.Error.Validation), a parameterless property, the only fixed message.Success(string entityName, string action)(lines 98-99, keyCommon.Success) is marked[Obsolete](line 97): it composes "{0} {1} successfully." from a noun and an English verb fragment, which cannot be translated correctly (Spanish gender/word agreement, "creado" vs "creada", breaks), a §27 red flag called out in its own doc comment (lines 89-95). The#pragma warning disable S1133around it (lines 96, 100) is the migration mechanism itself: the obsoletion turns every remaining call site into a build error underTreatWarningsAsErrorsduring the lockstep sweep, and the member is removed once all consumers migrate to a whole-sentence resource key.
- Why it's built this way: keeping the API static means existing call sites (
ErrorMessages.LoadError(Title, ex)) do not move, while theConfigureindirection adds localization without a breaking signature change. Surfacing only theDomainInvariantViolationExceptionmessage lets curated domain rules reach the user while raw infrastructure errors stay generic, culture-correct, and safe; a page that needs a richer failure message shapes it throughServiceExceptionHelper(which produces that exception type) and its own resource pair. - Where it's used: backed once per circuit/host by
ErrorMessages.Configure(L)in the root layout (MMCA.Common/Source/Presentation/MMCA.Common.UI/Layout/MainLayout.razor:102); called byDataGridListPageBase<TDto>on a fetch failure (DataGridListPageBase.cs:493,549) and by every entity page code-behind across both ADC and Store viaSnackbar.Add(...). - Caveats / not-in-source: the
.resxpayloads (SharedResource.resx,SharedResource.es.resx) are resources, not.cs; their per-key contents are not enumerated here.ServiceExceptionHelper's rethrow-as-DomainInvariantViolationExceptionbehavior is referenced in the doc comment but lives in another file; this file only consumes that type.
PseudoStringLocalizerFactory
MMCA.Common.UI ·
MMCA.Common.UI.Globalization·MMCA.Common/Source/Presentation/MMCA.Common.UI/Globalization/PseudoStringLocalizerFactory.cs:11· Level 2 · class (sealed)
- What it is: an
IStringLocalizerFactorydecorator that wraps every localizer the inner factory produces in aPseudoStringLocalizer, so decorating this one factory pseudo-localizes everyIStringLocalizer<T>andIStringLocalizerin the host at once (ADR-027 §8). - Depends on:
PseudoStringLocalizer(Level 1) andIStringLocalizerFactory/IStringLocalizer(Microsoft.Extensions.Localization, NuGet). Constructed with theinnerfactory via a primary constructor. - Concept introduced, decorate the factory to reach every product.
[Rubric §2, Design Patterns](Decorator applied at the factory level) and[Rubric §10, Cross-Cutting Concerns](assesses whether cross-cutting behavior is injected in one place rather than scattered). BecauseStringLocalizer<T>resolves its backing localizer through theIStringLocalizerFactory, wrapping the factory means every localizer the DI container ever hands out is already pseudo-aware, no per-type registration, no consumer change. This is the same "decorate the boundary, not the callers" idea the CQRS pipeline uses (see primer §2), applied to localization. - Walkthrough: two forwarding overrides, each wrapping the inner factory's product:
Create(Type resourceSource)(lines 14-15):new PseudoStringLocalizer(inner.Create(resourceSource)), the path used byIStringLocalizer<T>.Create(string baseName, string location)(lines 18-19):new PseudoStringLocalizer(inner.Create(baseName, location)), the path used by name-based localizers.
- Why it's built this way: registering the wrapper on the factory is the minimal, DI-idiomatic way to make pseudo-loc universal; combined with the culture gate inside
PseudoStringLocalizer, it can be registered unconditionally because it is inert under every non-pseudo culture, so production wiring is not conditional on environment. - Where it's used: registered via
services.Decorate<IStringLocalizerFactory, PseudoStringLocalizerFactory>()(Scrutor) inDependencyInjection.AddUIShared(DependencyInjection.cs:47), immediately afterAddLocalization()(line 40). Its reach includes MudBlazor chrome throughResxMudLocalizer, which resolves itsIStringLocalizer<MudTranslations>through this same factory.
DataGridListPageBase<TDto>
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Common·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/DataGridListPageBase.cs:20· Level 3 · class (abstract)
- What it is: the abstract Blazor base for every server-paged
MudDataGrid<TDto>list page. It folds the otherwise-copy-pasted concerns, cancellation lifecycle, loading flag, mobile/desktop viewport detection, filter/sort extraction, error reporting, scroll restore, density toggle, URL+session+prerender state plumbing, and disposal, into one reusable component. - Depends on:
ErrorMessages(Level 2),SharedResource(Level 0, injected asIStringLocalizer<SharedResource>),ListPageState(Level 0),PersistedGridState(Level 0, nested),ListPageQueryStateService(Level 1),ListPageStateService(Level 1),BreakpointConstants(Level 0); MudBlazor'sMudDataGrid<T>,GridState<T>,GridData<T>,IBrowserViewportObserver/IBrowserViewportService(NuGet); Blazor'sPersistentComponentState,NavigationManager,IJSRuntime(framework). - Concept introduced, a behavior-rich Blazor base component.
[Rubric §18, UI Architecture & Component Design](assesses reuse; every list page inherits this behavior with zero copy-paste) and[Rubric §23, Front-End Performance & Rendering](assesses server-side paging, only the requested page is fetched, never the whole table, plus the prerender-cache that skips a redundant fetch). It also embodies several hard-won quality notes: theMudDataGrid v9 RowsPerPagebug (the v9 parameter setter clobbersCurrentPage), the disposed-CTS race (a debounced reload firing after disposal threwObjectDisposedExceptionand stuck theblazor-error-uibanner onto the next page), and the stale-write race (a late grid-state save landing after navigation stamped grid params onto the next page's URL and disposed it), all worked around here, touching[Rubric §22, Responsive & Cross-Browser]and[Rubric §28, Front-End Testing](these were E2E-discovered regressions). Its cancellation snackbar reads a localized string fromSharedResource, the[Rubric §27, Internationalization]angle. - Walkthrough: in teaching order:
- Injected services & abstract surface (lines 22-32),
ISnackbar(line 22),IStringLocalizer<SharedResource>(line 23, the localized cancel message),IBrowserViewportService(line 24), the two state services (lines 25-26),NavigationManager(line 27),IJSRuntime(line 28),PersistentComponentState(line 29); derived pages supply the abstractTitle(line 32) and may overrideGridRef(line 112),SaveFilters/RestoreFilters(lines 99,102), andOnMobileDataRequestedAsync(line 693). - State fields (lines 75-90), the CTS (line 75), the
_disposedguard (line 76), the prerender caches (_persistedGridData/_lastSuccessfulGridData, lines 79-80), the scroll module/DotNetObjectReference(lines 77,81), the saved-state mirror fields (_savedPage,_savedPageSize,_savedSortColumn, …, lines 83-86), and the_ownRoutePathpin (line 678). The bindableCurrentPageState(line 48),RowsPerPageState(line 58, defaulting to 10 to match MudDataGrid v9), andDenseGrid(line 67) sit above;PrerenderFetchTimeoutMs = 5000(line 73) bounds the SSR fetch. OnInitialized(lines 121-202), synchronously (a) restores anyPersistedGridStatefromPersistentComponentState(lines 127-131), (b) registers the persisting callback with an explicitRenderMode.InteractiveAuto(lines 140-150; the explicit mode is required because the page inherits its render mode from<Routes @rendermode="InteractiveAuto">and the framework otherwise cannot associate the callback), (c) pins_ownRoutePathto this page's route (line 157, the stale-write guard's anchor), (d) reads the URL viaListPageQueryStateService(line 159) and falls back to the in-memoryListPageStateServicesnapshot when the URL is pristine (lines 161-170), (e) primesCurrentPageState/RowsPerPageState/DenseGrid(lines 172-184) so the grid's firstServerDatacall fetches the right page directly, and (f) subscribes toLocationChanged(line 198).OnLocationChanged(lines 204-242), reacts only to same-path back/forward (different paths are handled by disposal, lines 214-218), re-reads the URL, appliesCurrentPageto the live grid via the BL0005-suppressedApplyCurrentPageFromUrl(lines 244-251), and reloads (line 238).OnAfterRenderAsync(firstRender)(lines 275-331), on first render, hydrates session state now that interop is available (HydrateFromSessionAsync, line 283), runs the cross-circuit fallback (needsSessionRestore, lines 289-294), subscribes to viewport changes (line 303), importslist-page-scroll.js(lines 305-307) and enables debounced scroll tracking via aDotNetObjectReference(lines 308-313), then callsRestoreGridStateAsync(line 315). On every render it restores a pending scroll position once the grid has stopped loading (lines 324-328).LoadServerDataAsync(state, fetchAsync, …)(lines 425-501), the heart of the desktop path: resets the CTS (line 431), returns the prerender cache on the first interactive call (lines 435-450, skipping a round-trip), extracts filters/sort fromGridState<TDto>with a saved-sort fallback (lines 455-467), bounds the fetch withCreateFetchCts(lines 509-518, during non-interactive prerender itCancelAfter(PrerenderFetchTimeoutMs)at line 514 so a cold/unreachable backend cannot block the page load indefinitely), mapsOperationCanceledExceptionto an empty grid plus a localizedLocalizer["Grid.Snackbar.LoadCancelled"]snackbar (lines 483-490), and maps any otherExceptionto an empty grid plusErrorMessages.LoadError(line 493).LoadMobileDataAsync(lines 524-558), the mobile-card equivalent, error path also viaErrorMessages.LoadError(line 549).ResetCancellationTokenAsync(lines 560-582), swaps in a fresh CTS first, then tears down the previous one, toleratingObjectDisposedException(lines 577-580, the disposed-CTS race fix noted above).SaveCurrentState(lines 598-635), guarded byIsOwnRouteCurrent()(line 602, the stale-write drop) then writes the newListPageStateto all three channels: the in-memory service (line 622), the URL (ReplaceState, line 627, with_suppressNextLocationChangedset at line 626 so it does not re-trigger its ownLocationChangedhandler), and sessionStorage (PersistToSessionAsync, line 633, skipped during the deferred-hydration window).ToggleDensity/PersistDensity(lines 642-647 and 655-675), the density toggle: flipsDenseGridand mirrors just that field through the same in-memory + URL + sessionStorage tail (with the sameIsOwnRouteCurrentguard, line 658), so a density change made before the grid's firstServerDatasave is not lost.RestoreGridStateAsync/RestoreCurrentPageAfterRowsPerPageReset(lines 384-410 and 349-356), the MudDataGrid v9 workaround: forceSetRowsPerPageAsync(size, resetPage: false)(line 395) if the parameter setter did not take, then re-restoreCurrentPagefrom_savedPagebecause the v9 setter resets it to 0.DisposeAsync/Dispose(lines 698-772), unsubscribesLocationChanged, disables scroll tracking, unsubscribes the viewport observer, and cancels+disposes the CTS, all guarded against shutdown-time JS races (JSDisconnectedException/JSException, lines 716-723).
- Injected services & abstract surface (lines 22-32),
- Why it's built this way: every concern here was independently re-implemented (and re-broken) on individual pages before being lifted into one base; consolidating them means a single fix (the v9 paging bug, the prerender timeout, the disposed-CTS race, the stale-write race) propagates to every list page at once. The four-channel persistence (URL + memory + sessionStorage + prerender cache) covers the full matrix of how a user can leave and return to a list.
- Where it's used: base class for every list page in ADC (
EventListPage,SessionListPage,SpeakerListPage, …) and Store. - Caveats / not-in-source: two
BL0005suppressions (lines 244, 349) setgrid.CurrentPagefrom outside the component; the justification (MudDataGrid v9 exposes no public method for arbitrary-page navigation and the setter is well-behaved) is inlined. The persisted-grid optimization assumes the backend is warm in production; under a cold backend the prerender fetch times out at 5s (PrerenderFetchTimeoutMs) and the interactive pass refills the grid. Thelist-page-scroll.jsmodule (enableScrollTracking/setScrollPosition) is JavaScript underwwwroot; this.csfile only invokes it by name, so its behavior is not verifiable from this source.
CultureDelegatingHandler
MMCA.Common.UI ·
MMCA.Common.UI.Services·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/CultureDelegatingHandler.cs:13· Level 0 · class (sealed)
- What it is: a
DelegatingHandlerthat stamps the active UI culture onto every outgoing API request as anAccept-Languageheader, so the server localizes its error messages to the user's chosen language (ADR-027). - Depends on: BCL only (
System.Globalization.CultureInfo,System.Net.Http.Headers,System.Net.Http.DelegatingHandler). - Concept introduced, the outbound i18n channel across origins.
[Rubric §27, Internationalization]assesses whether the app carries locale end to end rather than localizing only the shell. The doc comment (CultureDelegatingHandler.cs:6-11) states the reason this handler must exist: the cross-origin Gateway does not forward the culture cookie to the backend services, so the header is the one channel that makes backend (Result) failure text come back in the selected language.[Rubric §18, UI Architecture]also applies: cross-cutting request behavior lives in an HttpClient pipeline stage, not scattered through call sites. - Walkthrough:
SendAsync(CultureDelegatingHandler.cs:16) readsCultureInfo.CurrentUICulture.Name(line 20); when it is non-blank it clears any existingAcceptLanguagevalues and adds a singleStringWithQualityHeaderValuefor that culture (lines 21-25), then defers tobase.SendAsync(line 27). It is synchronous apart from returning the base task (noasyncstate machine). - Why it's built this way: a message handler runs once per request regardless of which page or
service issued it, so the locale contract is enforced uniformly. Registered in the
"APIClient"HttpClient pipeline viaAddHttpMessageHandler. - Where it's used: the
"APIClient"named HttpClient shared by the UI service base classes in this group (AuthenticatedServiceBase,EntityServiceBase<TEntityDTO, TIdentifierType>, and the preference readers/writers).
IUserPreferenceWriter
MMCA.Common.UI ·
MMCA.Common.UI.Services·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/IUserPreferenceWriter.cs:9· Level 0 · interface
- What it is: the contract for persisting a signed-in user's culture/theme choice to the backend so it follows them across devices (ADR-027 / ADR-028).
- Depends on: nothing first-party.
- Concept introduced, best-effort server persistence over a cookie runtime channel.
[Rubric §19, State Management]assesses where UI state actually lives and how it survives sessions. The doc comment (IUserPreferenceWriter.cs:3-7) is precise about the design contract: the cookie and localStorage remain the runtime channel that drives the live switch, so this server persist is a durability upgrade only. Implementations must be best-effort and a no-op for anonymous users, so a failed or skipped persist never breaks the in-page toggle.[Rubric §26, Front-End Security]is implied by the anonymous-user carve-out (no profile write without a token). - Walkthrough: one method,
SaveAsync(string? culture, string? theme, CancellationToken)(IUserPreferenceWriter.cs:18). Either argument may benull, meaning "leave that preference unchanged", so a theme-only change does not clobber the stored culture. - Why it's built this way: separating the write contract from its implementation lets a host that
lacks the
auth/preferencesendpoint (the Helpdesk seed) simply not register a writer, while full apps bindApiUserPreferenceWriter. - Where it's used: implemented by
ApiUserPreferenceWriter; called from the theme toggle and culture picker after a successful in-page switch.
ListPageState
MMCA.Common.UI ·
MMCA.Common.UI.Services·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/ListPageStateService.cs:9· Level 0 · record (sealed)
- What it is: an immutable snapshot of a list page's UI state (paging, scroll, sort, density, and named filters) that is captured and restored around navigation.
- Depends on: nothing first-party (uses only BCL collection types).
- Concept introduced, the list-page state snapshot.
[Rubric §19, State Management]assesses whether transient UI state is modeled explicitly rather than reconstructed by luck.[Rubric §25, Navigation & IA]assesses whether back/forward and refresh land the user where they were. This record is the vocabulary both mechanisms share: an in-memory service (ListPageStateService) and a URL codec (ListPageQueryStateService) both read and produce it. - Walkthrough: seven
initproperties.Page(ListPageStateService.cs:12) is the MudDataGrid 0-indexed page;PageSize(line 15) the rows per page;MobilePage(line 18) the card-list 1-indexed page (defaulting to1);ScrollPosition(line 21) the document scroll offset in pixels;SortColumn(line 27) the activeSortByproperty name (null/empty when unsorted);SortDescending(line 33) the direction, ignored when there is no sort column;DenseGrid(line 41) the opt-in compact density; andFilters(line 47) a read-onlystring -> stringdictionary of page-specific named filters, defaulting to an empty dictionary. Being arecord, updates usewithexpressions (asListPageStateService.UpdateScrollPositiondoes). - Why it's built this way: immutability makes the snapshot safe to hand between the in-memory cache,
sessionStorage, and the URL without defensive copies; theinit-only shape means a restored state can never be half-mutated by a stray write. - Where it's used: produced/consumed by
ListPageStateServiceandListPageQueryStateService; bound byDataGridListPageBase<TDto>viaDense="@DenseGrid"and the paging/sort hooks.
ThemeService
MMCA.Common.UI ·
MMCA.Common.UI.Services·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/ThemeService.cs:16· Level 0 · class (sealed)
- What it is: the circuit-scoped owner of the Day/Dark theme preference (ADR-028). It holds the
current mode, persists it to a non-HttpOnly cookie plus localStorage (via
theme.js), and raises an event so the rootMudThemeProviderand the app-bar toggle stay in sync. - Depends on:
Microsoft.JSInterop(IJSRuntime,IJSObjectReference,JSDisconnectedException); implementsIAsyncDisposable. - Concept introduced, JS-backed UI preference with an event notification.
[Rubric §20, Design System & Theming]assesses a single theme source rather than per-page overrides: this service is that single owner of the light/dark toggle.[Rubric §19, State Management]assesses render-mode-safe initialization: the doc comment (ThemeService.cs:10-13) warns that JS interop is only available after the first interactive render, soInitializeAsyncmust run fromOnAfterRenderAsync(firstRender: true), never during SSR prerender. - Walkthrough
- State:
IsDarkMode(ThemeService.cs:22, private-set) andIsInitialized(line 25) plus theOnChangeevent (line 28) subscribers re-render on. InitializeAsync(ThemeService.cs:34): idempotent (returns early onceIsInitialized), imports thetheme.jsmodule, reads the stored value viaget; if a value exists it matches"dark"case-insensitively, otherwise it falls back to the OSsystemPrefersDark(lines 42-45), then setsIsInitializedand firesOnChange.SetDarkModeAsync(bool)(ThemeService.cs:53): updates the flag, persists"dark"/"light"via the module'sset, and notifies.ToggleAsync(line 62) isSetDarkModeAsync(!IsDarkMode).GetModuleAsync(ThemeService.cs:64): lazily imports and caches the module reference.DisposeAsync(ThemeService.cs:68): disposes the cached module, swallowingJSDisconnectedExceptionbecause a closed circuit has nothing left to dispose.
- State:
- Why it's built this way: a cookie (readable server-side) plus localStorage lets the server
prerender the correct theme with no flash, while the event keeps every live subscriber consistent from
one source. The first-visit default deferring to
prefers-color-schemerespects the OS setting. - Where it's used: injected into the root layout's
MudThemeProviderand the app-bar theme toggle; its persisted choice is whatIUserPreferenceWriterlater mirrors to the backend for cross-device durability.
UserPreferences
MMCA.Common.UI ·
MMCA.Common.UI.Services·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/UserPreferences.cs:9· Level 0 · record (sealed)
- What it is: a two-field DTO carrying a user's persisted UI preferences (ADR-027 / ADR-028), where
a
nullfield means "not chosen, use the request default or OS preference". - Depends on: nothing first-party.
- Concept: a trivial positional record, the read-side counterpart to the write parameters of
IUserPreferenceWriter.[Rubric §9, API & Contract Design](a small, explicit contract on the wire) is the only category that meaningfully applies. - Walkthrough:
sealed record UserPreferences(string? Culture, string? Theme)(UserPreferences.cs:9); the doc comment (lines 3-8) fixes the null-means-unset semantics. - Where it's used: returned by
IUserPreferenceReader.GetAsyncand deserialized fromauth/preferencesbyApiUserPreferenceReader(which keeps a sharedEmpty = new(null, null)instance).
UserPreferencesRequest
MMCA.Common.UI ·
MMCA.Common.UI.Services·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/ApiUserPreferenceWriter.cs:19· Level 0 · record (private sealed, nested)
- What it is: the write-side payload PUT to
auth/preferences, a private nested record insideApiUserPreferenceWriterwith the same two nullable fields asUserPreferences. - Depends on: nothing first-party; serialized by
System.Net.Http.Json. - Walkthrough:
private sealed record UserPreferencesRequest(string? Culture, string? Theme)(ApiUserPreferenceWriter.cs:19). It is separate fromUserPreferencesonly because it is the request body, not the response body; the fields are identical. - Why it's built this way: keeping the request type private to the writer signals it is an implementation detail of the PUT and not a shared contract.
- Where it's used: constructed once inside
ApiUserPreferenceWriter.SaveAsync(ApiUserPreferenceWriter.cs:36).
ApiUserPreferenceWriter
MMCA.Common.UI ·
MMCA.Common.UI.Services·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/ApiUserPreferenceWriter.cs:15· Level 1 · class (sealed)
- What it is: the default
IUserPreferenceWriter: it PUTs aUserPreferencesRequesttoauth/preferencesover the shared"APIClient", no-ops for anonymous users, and swallows transport errors so persistence stays best-effort. - Depends on:
IUserPreferenceWriter,ITokenStorageService; BCLIHttpClientFactoryandSystem.Net.Http.Json. - Concept, best-effort side-channel persistence.
[Rubric §19, State Management](the durable server copy is layered on top of the authoritative cookie channel) and[Rubric §26, Front-End Security](no write is attempted without a signed-in token). The doc comment (ApiUserPreferenceWriter.cs:6-11) makes the fallback explicit: hosts without the endpoint just do not register this writer. - Walkthrough:
SaveAsync(ApiUserPreferenceWriter.cs:22) first reads the access token viaITokenStorageService; a null/blank token returns immediately (anonymous users have no profile, lines 25-29). Otherwise it creates the"APIClient"and PUTs the request (lines 33-37), catchingHttpRequestException(line 39) andTaskCanceledException(line 43) and swallowing both, because the cookie/localStorage already hold the choice for this device. - Why it's built this way: the shared
"APIClient"already attaches the bearer token and Accept-Language (viaCultureDelegatingHandler), so the writer adds no auth logic of its own; swallowing transport faults keeps a background persistence concern from ever surfacing as a page error. - Where it's used: registered as
IUserPreferenceWriterin full apps; invoked after aThemeServicetoggle or culture change.
AuthenticatedServiceBase
MMCA.Common.UI ·
MMCA.Common.UI.Services·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/AuthenticatedServiceBase.cs:14· Level 1 · class (abstract)
- What it is: the shared base for Blazor circuit-scoped HTTP services. It supplies a ready-built
Polly retry policy and a
CreateAuthenticatedClientAsync()helper that manually attaches the JWT bearer token, working around a DI-scope issue in Blazor Server. - Depends on:
ITokenStorageService;Polly/Polly.Retry(NuGet) andSystem.Net.Http.Headers(BCL). - Concept introduced, manual token attachment for the circuit scope.
[Rubric §19, State Management](auth state read from the circuit-scoped store) and[Rubric §29, Resilience & Business Continuity](a uniform retry policy on transient API failures). The doc comment (AuthenticatedServiceBase.cs:30-35) explains the workaround:IHttpClientFactorybuilds itsDelegatingHandlers in a separate DI scope from the Blazor circuit, so the cookie/JS-backedAuthDelegatingHandlercannot reach the circuit'sIJSRuntimeto read the in-memory access token. This base sidesteps that by reading the token from the circuit-scopedITokenStorageServiceand settingAuthorizationdirectly. - Walkthrough
RetryPolicy(AuthenticatedServiceBase.cs:22): astatic readonlyPolly policy handlingHttpRequestExceptionor any 5xx result, with three retries at exponential backoff (2s, 4s, 8s viaMath.Pow(2, attempt)).- Constructor guards: both injected dependencies are null-checked into
_httpClientFactory/_tokenStorageService(lines 27-28). CreateAuthenticatedClientAsync(AuthenticatedServiceBase.cs:36): creates the"APIClient", reads the access token, and (when present) setsAuthorization: Bearer <token>(lines 38-47); anInvalidOperationExceptionfrom JS interop during SSR prerender is caught and the client is returned without a token (lines 49-52).
- Why it's built this way: centralizing retry and token attachment means every derived service gets resilience and auth for free and consistently; catching the prerender exception keeps a service usable during server-side render.
- Where it's used: base of
EntityServiceBase<TEntityDTO, TIdentifierType>andChildEntityServiceBase, and thus of every module UI service in the downstream apps.
IUserPreferenceReader
MMCA.Common.UI ·
MMCA.Common.UI.Services·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/IUserPreferenceReader.cs:9· Level 1 · interface
- What it is: the read contract for a returning user's persisted culture/theme, applied at login so a choice made on one device follows to another (ADR-027 / ADR-028).
- Depends on:
UserPreferences. - Concept, best-effort read that never blocks login.
[Rubric §19, State Management](cross-device preference reconciliation) and[Rubric §27, Internationalization](a returning user's locale is restored, not re-guessed). The doc comment (IUserPreferenceReader.cs:3-8) mandates the failure mode: return an emptyUserPreferences(bothnull) for anonymous users or on any error, so a failed read is invisible to the login flow. - Walkthrough: one method,
GetAsync(CancellationToken)returningTask<UserPreferences>(IUserPreferenceReader.cs:13). - Where it's used: implemented by
ApiUserPreferenceReader; called by the login reconciliation step that then applies the stored culture/theme throughThemeServiceand the culture cookie.
ListPageQueryStateService
MMCA.Common.UI ·
MMCA.Common.UI.Services·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/ListPageQueryStateService.cs:28· Level 1 · class (sealed)
- What it is: the URL codec for
ListPageState. It encodes state into short query keys and decodes them back, giving list pages deterministic, shareable, bookmarkable filter/sort/paging state through the address bar. - Depends on:
ListPageState; ASP.NET CoreNavigationManager,Microsoft.AspNetCore.WebUtilities.QueryHelpers,Microsoft.Extensions.Primitives.StringValues, andSystem.Globalization. - Concept introduced, address-bar as state store.
[Rubric §25, Navigation & IA](browser back/forward/refresh and shareable links restore the exact view) and[Rubric §19, State Management](the URL is a first-class, durable state channel). The doc comment (ListPageQueryStateService.cs:14-26) documents the reserved keys, deliberately short because they end up in shared links:pdesktop page,pspage size,mpmobile page,ssort column,sdsort direction (desconly),ddense (1only),qfree-text search, andf:<name>for any other filter. Default values are omitted so a pristine list page has a clean URL. - Walkthrough
ReadCurrent(ListPageQueryStateService.cs:45): resolves the absolute URI from the injectedNavigationManagerand hands its query toParseQueryString.ParseQueryString(string?)(ListPageQueryStateService.cs:56): astaticpure helper (testable without aNavigationManager). It parses viaQueryHelpers.ParseQuery, reads the integer keys with the culture-invariantTryGetInthelper (lines 60-62, 212-222), resolves the optional sort column, matchessdagainstdesccase-insensitively (lines 74-78), matchesdagainst the literal1ordinally (lines 80-84), then reassembles the filter dictionary, mapping the reservedqto thesearchfilter name and stripping thef:prefix from the rest (lines 86-103).BuildPath(string, ListPageState)(ListPageQueryStateService.cs:122): astaticinverse that emits only non-default values (page > 0, mobile page > 1, a present sort column, dense true, non-empty filters), formatting integers withCultureInfo.InvariantCulture, and returns the barebasePathwhen nothing needs encoding.ReplaceState(string, ListPageState)(ListPageQueryStateService.cs:196): writes the encoded URL withNavigationOptions.ReplaceHistoryEntry = trueso filter churn does not pollute the back stack. Critically, it drops the write when the current path no longer matchesbasePath(lines 201-206): the remarks (lines 186-195) record an E2E-diagnosed bug where a deferred grid-state write (debounced search, a lateServerDatacompletion) landed after the user had navigated away, stamping grid params onto the next page's URL and canceling that page's first data fetch mid-load.
- Why it's built this way: making the parse/build methods
staticand pure keeps them unit-testable and side-effect-free; anchoringReplaceStateto the owningbasePathis the guard that turns an inherently deferred write into a safe one. - Where it's used: paired with
ListPageStateServiceinsideDataGridListPageBase<TDto>; the URL is the shareable channel while the session store is the fast in-memory one. - Caveats / not-in-source:
ParseQueryStringandBuildPathare inverses only for the keys they model; unrecognized query keys are ignored on read and never emitted on build.
ListPageStateService
MMCA.Common.UI ·
MMCA.Common.UI.Services·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/ListPageStateService.cs:58· Level 1 · class (sealed)
- What it is: the per-circuit scoped keeper of
ListPageStateacross in-app navigation. A synchronous in-memory dictionary is the fast path; two async methods mirror entries throughsessionStorageso state survives circuit teardowns,forceLoadnavigations, and the SSR to WASM render-mode transition. - Depends on:
ListPageState;Microsoft.JSInterop(IJSRuntime,IJSObjectReference,JSDisconnectedException,JSException). - Concept, dual-layer (memory + sessionStorage) UI state.
[Rubric §19, State Management](explicit survival across render modes and reloads) and[Rubric §23, Front-End Performance](a synchronous in-memory read on the render path, JS interop only on the slower hydrate/persist edges). The doc comment (ListPageStateService.cs:50-57) frames the memory dictionary as the fast path and thesessionStoragemirror (via the lazily importednav-interop.js) as the durability layer. - Walkthrough
- Fields: the private
Dictionary<string, ListPageState>keyed by route path (ListPageStateService.cs:63), a lazily imported_modulereference, and theSessionKeyPrefix/ModulePathconstants (lines 60-64). GetState(routePath)(ListPageStateService.cs:71): synchronousGetValueOrDefault, safe to call fromOnInitializedduring prerender.SaveState(line 79) andUpdateScrollPosition(line 87): the latter is a scroll fast path that uses awithexpression to patch onlyScrollPosition, creating a minimalListPageStateif the grid has not yet saved one.HydrateFromSessionAsync(routePath)(ListPageStateService.cs:98): loads persisted state fromsessionStorageinto memory, meant forOnAfterRenderAsync(firstRender: true). Every JS call is guarded:InvalidOperationException(prerender),JSDisconnectedException(circuit torn down), andJSException(storage failures like Safari Private mode / quota) are each caught and treated as a silent no-op (lines 114-125).PersistToSessionAsync(routePath)(ListPageStateService.cs:133): writes the current in-memory state back throughsessionSet, with the same three-way exception guard.GetModuleAsync(ListPageStateService.cs:164): imports and cachesnav-interop.js, returningnull(rather than throwing) when interop is unavailable, so callers degrade gracefully.
- Fields: the private
- Why it's built this way: reading state synchronously keeps the render path off the JS interop
round-trip, while the defensive try/catch around every interop call means a storage or lifecycle
failure never breaks the calling page. Lazy module import avoids paying for
nav-interop.jsuntil a list page needs it. - Where it's used: injected into
DataGridListPageBase<TDto>alongsideListPageQueryStateService; together they restore a list page's exact position after navigation.
MmcaCultureBootstrap
MMCA.Common.UI ·
MMCA.Common.UI.Services·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/MmcaCultureBootstrap.cs:14· Level 1 · class (static)
- What it is: the Blazor WebAssembly culture bootstrap (ADR-027). It reads the same ASP.NET culture cookie the server used during SSR prerender and sets the thread default cultures before the WASM host runs, so the interactive client renders in the language the server prerendered, with no locale flash and no prerender/hydration mismatch.
- Depends on:
SupportedCultures;Microsoft.JSInteropandSystem.Globalization. - Concept, prerender/hydration locale parity.
[Rubric §27, Internationalization](the client and server agree on locale from the first frame) and[Rubric §23, Front-End Performance](avoiding a visible re-render when the client would otherwise default to a different culture). The doc comment (MmcaCultureBootstrap.cs:7-13) states the calling contract: invoke it in the.ClientProgram.csafterbuilder.Build()and beforehost.RunAsync(). - Walkthrough:
SetBrowserCultureAsync(IJSRuntime)(MmcaCultureBootstrap.cs:22) null-checks the runtime, importsculture.js, and reads the cookie value viagetCulture(lines 26-28). It then validates that value withSupportedCultures.IsSupported, falling back toSupportedCultures.Defaultwhen unsupported (line 30), and assigns the resolvedCultureInfoto bothCultureInfo.DefaultThreadCurrentCultureandDefaultThreadCurrentUICulture(lines 31-33). The imported module is disposed viaawait using. - Why it's built this way: setting the default thread cultures before the host starts means every
component the WASM host creates inherits the correct locale, rather than each component correcting
itself after render. Routing the allow-list decision through
SupportedCultureskeeps the client and server on one source of truth for which locales exist. - Where it's used: the WASM
.Clientstartup of the downstream apps; the server-side counterpart is the culture cookie set by the API, and the outbound header isCultureDelegatingHandler.
ApiUserPreferenceReader
MMCA.Common.UI ·
MMCA.Common.UI.Services·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/ApiUserPreferenceReader.cs:14· Level 2 · class (sealed)
- What it is: the default
IUserPreferenceReader: it GETsauth/preferencesover the shared"APIClient"and returnsUserPreferences, degrading to empty preferences for anonymous users or on any transport error. - Depends on:
IUserPreferenceReader,UserPreferences,ITokenStorageService;IHttpClientFactoryandSystem.Net.Http.Json. - Concept: the read-side mirror of
ApiUserPreferenceWriter, same best-effort discipline.[Rubric §19, State Management](login-time cross-device reconciliation) and[Rubric §26, Front-End Security](no fetch without a token). - Walkthrough: a shared
static readonly UserPreferences Empty = new(null, null)(ApiUserPreferenceReader.cs:18) is the degraded return.GetAsync(line 21) reads the access token; a blank token returnsEmpty(lines 23-27); otherwise it creates the"APIClient", GETs the JSON, returns the deserialized value orEmpty(lines 31-35), and catchesHttpRequestException(line 37) andTaskCanceledException(line 41), returningEmptyfrom both. - Why it's built this way: the single reusable
Emptyinstance avoids allocating a no-preference object on every anonymous or failed call, and swallowing transport faults keeps a returning-user optimization from ever blocking login. - Where it's used: registered as
IUserPreferenceReaderin full apps; its result is applied toThemeServiceand the culture cookie at login.
ServiceExceptionHelper
MMCA.Common.UI ·
MMCA.Common.UI.Services·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/ServiceExceptionHelper.cs:11· Level 2 · class (static)
- What it is: a static helper that inspects a non-success HTTP response for the WebAPI's
ProblemDetails-style error payloads and re-throws them as
DomainInvariantViolationException, so UI pages can display the original server error message instead of a generic transport failure. - Depends on:
DomainInvariantViolationException(viaMMCA.Common.Shared.Exceptions);System.Text.Json. - Concept introduced, translating the server error contract at the UI edge.
[Rubric §9, API & Contract Design](clients rely on the server's structured error shape) and[Rubric §24, Forms/Validation/UX Safety](the user sees the real validation message, not "server error"). The server returns RFC 9457 ProblemDetails; this helper branches on thetitlefield and extracts the human message for each known kind. - Walkthrough:
ThrowIfDomainExceptionAsync(HttpResponseMessage, CancellationToken)(ServiceExceptionHelper.cs:17) null-checks the response, returns early on a null/blank body (lines 21-26), and tries to parse the body as JSON, returning quietly when it is not JSON (a bare 401 challenge or HTML error page, lines 28-38) so the caller'sEnsureSuccessStatusCode()handles it. It then readstitle(lines 44-47) and branches ordinally:"Domain Exception"extractsdetail(ExtractDetailMessage, line 60);"Validation Exception"joins theerrorsobject's messages (ExtractValidationMessage, line 65);"Operation failed"joins theerrorsarray'smessagefields (ExtractOperationFailedMessage, line 85, viaCollectErrorMessages, line 100). Each throws aDomainInvariantViolationExceptioncarrying the extracted text (lines 49-56). - Why it's built this way: parsing the body before
EnsureSuccessStatusCode()is what lets a domain/validation failure surface as a meaningful message rather than a genericHttpRequestException; the JSON-parse guard means a non-JSON error page never crashes the helper. - Where it's used: called by
EntityServiceBase<TEntityDTO, TIdentifierType>andChildEntityServiceBaseon every non-success response before they enforce the status code.
ChildEntityServiceBase
MMCA.Common.UI ·
MMCA.Common.UI.Services·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/ChildEntityServiceBase.cs:17· Level 3 · class (abstract)
- What it is: the base HTTP service for join/child entities that support POST (add) and DELETE
(remove) but no standalone reads, the many-to-many sibling of
EntityServiceBase<TEntityDTO, TIdentifierType>. - Depends on:
AuthenticatedServiceBase(base),ServiceExceptionHelper,ITokenStorageService;System.Net.Http.JsonandSystem.Net. - Concept, the two-verb join-entity service.
[Rubric §18, UI Architecture](join operations behind a typed service, not rawHttpClientin components) and[Rubric §9, API & Contract Design](a DELETE that distinguishes not-found from failure). It inherits the authenticated client and retry behavior fromAuthenticatedServiceBaseand extracts domain errors viaServiceExceptionHelper, since join endpoints sit behind[Authorize]like their parent CRUD endpoints. - Walkthrough
- Constructor takes the two DI dependencies plus a relative
endpointstring, forwarding the first two toAuthenticatedServiceBase(ChildEntityServiceBase.cs:17-20). PostAsync<TRequest>(request, ct)(ChildEntityServiceBase.cs:24): builds an authenticated client, POSTs the payload, and on a non-success response callsServiceExceptionHelper.ThrowIfDomainExceptionAsyncbeforeEnsureSuccessStatusCode()(lines 26-35).DeleteByIdAsync(id, ct)(ChildEntityServiceBase.cs:39): DELETEsendpoint/id, returnsfalseonHttpStatusCode.NotFound(lines 45-48), otherwise runs the same domain-error extraction and returnstrue(lines 50-56).
- Constructor takes the two DI dependencies plus a relative
- Why it's built this way: modeling a join as add/remove-only (no GET) matches its lack of identity
as a standalone resource, and returning
falsefor a missing row lets a caller treat "already gone" as success rather than an error. - Where it's used: subclassed by module-specific join services that supply their endpoint and add
typed
AddAsync/DeleteAsyncwrappers overPostAsync/DeleteByIdAsync.
EntityServiceBase<TEntityDTO, TIdentifierType>
MMCA.Common.UI ·
MMCA.Common.UI.Services·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/EntityServiceBase.cs:23· Level 3 · class (abstract)
- What it is: the abstract base for every module UI CRUD service. It implements
IEntityService<TEntityDTO, TIdentifierType>over the named"APIClient", with a Polly retry policy, automatic domain-error extraction, and one central HTTP dispatch method. - Depends on:
AuthenticatedServiceBase(base),IEntityService<TEntityDTO, TIdentifierType>,IBaseDTO<TIdentifierType>,BaseLookup<TIdentifierType>,CollectionResult<T>,PagedCollectionResult<T>,PaginationMetadata,ServiceExceptionHelper,ITokenStorageService. - Concept introduced, the Blazor UI service layer as a typed HTTP client.
[Rubric §18, UI Architecture](components depend on an interface, not rawHttpClient) and[Rubric §19, State Management](data flows through typed services).[Rubric §29, Resilience]applies through the inherited retry, and[Rubric §24, Forms/Validation/UX Safety]through the pre-EnsureSuccessStatusCodedomain-error extraction. The generic constraints bindTEntityDTOtoIBaseDTO<TIdentifierType>andTIdentifierTypetonotnull(EntityServiceBase.cs:27-28), soGetEntityIdcan readentity.Idgenerically (line 158). - Walkthrough
GetAllAsync(EntityServiceBase.cs:32): appendsincludeFKs/includeChildren, unwraps aPagedCollectionResult<T>, and returns itsItems.GetPagedAsync(EntityServiceBase.cs:51): composes the full paging/sort/filter query string,Uri.EscapeDataString-encoding every property, operator, and value, formats the integer parts withCultureInfo.InvariantCulture, GETs/paged, and returns items plusPaginationMetadata.TotalItemCount.GetAllForLookupAsync(EntityServiceBase.cs:90): GETs/lookupand unwraps aCollectionResult<T>ofBaseLookup<TIdentifierType>.GetByIdAsync(line 102,treatNotFoundAsDefault: true),AddAsync(line 120,throwIfNull: true),UpdateAsync(line 132,expectContent: false),DeleteAsync(line 145,expectContent: false): the CRUD verbs, each delegating to the central dispatcher with a flag tuned to its response shape.SendRequestAsync<T>(EntityServiceBase.cs:171): the one dispatch method. It builds an authenticated client, runs the HTTP action through the inheritedRetryPolicy(line 180), converts 404 todefaultwhentreatNotFoundAsDefault(line 182), extracts domain errors viaServiceExceptionHelperbeforeEnsureSuccessStatusCode()(lines 186-189), skips deserialization whenexpectContentis false (lines 191-192), and can throw when a required result is null (lines 196-197).
- Why it's built this way: routing every verb through one dispatcher means retry, auth, and
structured-error handling are applied identically across all CRUD, and subclasses override only for
domain-specific operations. The
virtualmethods leave that door open without forcing it. - Where it's used: base class of the module UI services (Conference, Identity, Engagement) in the
downstream apps; those services back the pages built on
DataGridListPageBase<TDto>.
IOAuthUISettings
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/IOAuthUISettings.cs:9· Level 0 · interface
- What it is: the UI-layer contract that declares which external OAuth providers are available so the shared login page can conditionally render social-login buttons.
- Depends on: nothing first-party.
- Concept introduced, safe-by-default via default interface members.
[Rubric §18, UI Architecture](assesses how presentation configuration is surfaced without leaking backend concerns) and[Rubric §26, Front-End Security](assesses that optional auth surfaces are opt-in). Both members are default interface members:bool GoogleEnabled => false(IOAuthUISettings.cs:12) andbool GitHubEnabled => false(IOAuthUISettings.cs:15). An app that registers no implementation, or the no-opDefaultOAuthUISettings, gets "no social login": the buttons stay hidden. Turning a provider on is additive, an implementation returnstruefor the property it enables, with no change to the shared login component. - Walkthrough: two boolean getter members, both defaulting to
false. The login Razor component readsIOAuthUISettingsfrom DI to decide whether to render each provider's button. - Why it's built this way: default interface members remove the need for a separate no-op class while still shipping a usable, secure default (social login off until deliberately enabled).
- Where it's used: implemented by the no-op
DefaultOAuthUISettingsand the config-drivenConfigurationOAuthUISettings; consumed by the login page.
ISessionCookieSync
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/ISessionCookieSync.cs:8· Level 0 · interface
- What it is: the contract for keeping the browser's HttpOnly auth cookie in step with the client's in-memory tokens, so a server-side prerender can recognize an already-authenticated user.
- Depends on: nothing first-party.
- Concept introduced, the prerender/interactive cookie boundary.
[Rubric §18, UI Architecture](assesses how the SSR prerender pass and the interactive circuit share auth state) and[Rubric §26, Front-End Security](assesses that the refresh secret stays in an HttpOnly cookie, not JS). The doc comment (ISessionCookieSync.cs:3-7) states the exact failure this prevents: the interactive circuit's in-memory access token is unreachable from the server, so without a synced cookie a right-click "Open in new tab" on an[Authorize]page (which prerenders on the server) redirects to/login. This is the client half of the dual-fetch auth model (ADR-004). - Walkthrough: two methods,
SyncAsync(string accessToken, string refreshToken)(ISessionCookieSync.cs:10), called after login and each refresh to write the cookie, andClearAsync()(ISessionCookieSync.cs:12), called on logout to delete it. - Why it's built this way: keeping this an interface lets each host supply the right mechanism, a
browser fetch on the web heads (
JsFetchSessionCookieSync) and a no-op on MAUI (no SSR, no cookie). - Where it's used: implemented by
JsFetchSessionCookieSync; driven byWasmTokenStorageServiceat login and logout.
ITokenRefresher
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/ITokenRefresher.cs:13· Level 0 · interface
- What it is: the contract that acquires a fresh JWT access token, abstracting over where the refresh credential lives per host.
- Depends on: nothing first-party.
- Concept introduced, host-agnostic token refresh.
[Rubric §11, Security]and[Rubric §26, Front-End Security](both assess that the refresh token, the high-value secret, is handled by the safest mechanism per platform). The doc comment (ITokenRefresher.cs:3-12) names the two concrete paths this single method hides: on the browser hosts (Server + WASM),SameOriginProxyTokenRefreshercalls the same-origin/auth/session/tokenendpoint where the refresh token sits in an HttpOnly cookie and rotates server-side (never exposed to JS); on MAUI,DirectApiTokenRefresherexchanges the refresh token held in OS SecureStorage directly againstauth/refresh. - Walkthrough: one method,
Task<string?> AcquireAccessTokenAsync(CancellationToken = default)(ITokenRefresher.cs:20). It returns a fresh access token, ornullwhen no valid session exists (missing, expired, or revoked credential), a clean null convention so callers redirect to login rather than catch exceptions. - Why it's built this way: a one-method contract with a null-means-reauthenticate convention lets the storage layer stay identical across hosts while the refresh-token persistence differs at the edges (ADR-004).
- Where it's used: implemented by
SameOriginProxyTokenRefresherandDirectApiTokenRefresher; consumed byWasmTokenStorageServiceandAuthUIService.
ITokenStorageService
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/ITokenStorageService.cs:8· Level 0 · interface
- What it is: the platform-agnostic contract for persisting the JWT access/refresh pair, letting each host use the safe storage mechanism for its platform.
- Depends on: nothing first-party.
- Concept introduced, platform-abstracted token persistence.
[Rubric §26, Front-End Security]and[Rubric §11, Security](assess that tokens are held in the safest store per platform). The doc comment (ITokenStorageService.cs:3-7) fixes the policy the implementations honor: browser hosts keep the access token in memory and mirror the refresh token to an HttpOnly cookie, neverlocalStorage; MAUI uses OS SecureStorage. Managing both tokens through one abstraction means no page component ever touches a raw storage API. - Walkthrough: four methods,
GetAccessTokenAsync()(ITokenStorageService.cs:11) andGetRefreshTokenAsync()(ITokenStorageService.cs:14) each returningTask<string?>(async because SecureStorage is async on MAUI);SetTokensAsync(accessToken, refreshToken)(ITokenStorageService.cs:17), an atomic write of both after login or refresh; andClearTokensAsync()(ITokenStorageService.cs:20) on logout. - Why it's built this way: an interface (not a base class) keeps the platform-specific implementation in its own host with no shared code dependency; writing both tokens together avoids partial-update bugs (a fresh access token paired with a stale refresh token).
- Where it's used: implemented by
WasmTokenStorageService(and a Blazor Server siblingServerTokenStorageServicenoted in the WASM doc comment); read byAuthDelegatingHandler,JwtAuthenticationStateProvider, andAuthUIService.
JwtTokenInfo
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/JwtTokenInfo.cs:9· Level 0 · class (static)
- What it is: a static helper that inspects a JWT client-side (expiry only, no signature check) so token storage can decide when to re-acquire an access token.
- Depends on: BCL only (
System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler). - Concept introduced, deliberate signature-free client inspection.
[Rubric §26, Front-End Security](assesses that trust decisions stay server-side) and[Rubric §12, Performance & Scalability](assesses avoiding a doomed round-trip). The doc comment (JwtTokenInfo.cs:5-7) is explicit: there is no signature validation here, the API validates every request. The only job is to read expiry locally and refresh proactively, avoiding an API call that would come back 401. - Walkthrough:
IsFresh(string? token, TimeSpan skew)(JwtTokenInfo.cs:16): returnsfalseimmediately for null/blank (JwtTokenInfo.cs:18-21); returnsfalseifCanReadTokensays the string is not a readable JWT (JwtTokenInfo.cs:24-27); otherwise returns whetherReadJwtToken(token).ValidTo > DateTime.UtcNow + skew(JwtTokenInfo.cs:31), so a token withinskewof expiry is already treated as stale. A narrow catch ofArgumentException/FormatException(JwtTokenInfo.cs:33-36) yieldsfalseon a malformed token rather than throwing. - Why it's built this way: a pure static method with no dependencies is trivially unit-testable by
passing token strings and needs no DI. The
skewargument makes proactive refresh a caller policy, not a hard-coded constant. - Where it's used:
WasmTokenStorageService.GetAccessTokenAsyncgates its in-memory access token onJwtTokenInfo.IsFresh(_accessToken, ExpirySkew)before returning it (WasmTokenStorageService.cs:22).
AuthDelegatingHandler
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/AuthDelegatingHandler.cs:9· Level 1 · class (sealed)
- What it is: an
HttpClientmessage handler that attaches the stored JWT Bearer token to every outgoing API request. - Depends on:
ITokenStorageService(Level 0); BCL (System.Net.Http.Headers). - Concept introduced, the delegating-handler auth interceptor.
[Rubric §11, Security]and[Rubric §18, UI Architecture](assess where the outbound auth header is centralized). ADelegatingHandleris theHttpClientanalogue of ASP.NET middleware: it wraps a request before it goes on the wire. This one reads the access token fromITokenStorageServiceand setsAuthorization: Bearer {token}, so no call site has to remember to authenticate. - Walkthrough:
SendAsync(AuthDelegatingHandler.cs:13): awaitsGetAccessTokenAsync(AuthDelegatingHandler.cs:17); if the token is non-blank, setsrequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token)(AuthDelegatingHandler.cs:18-21); then delegates tobase.SendAsync(AuthDelegatingHandler.cs:23). With no token the request goes unauthenticated (the API answers 401 where auth is required). - Why it's built this way: centralizing the header on the handler keeps every service call
uniformly authenticated without per-call code. The class is
sealedand constructor-injects its one dependency. - Where it's used: registered in the
"APIClient"named-client pipeline viaAddHttpMessageHandler(per its doc comment,AuthDelegatingHandler.cs:5-7). Note thatAuthUIServicesets the header manually on some calls because of a Blazor Server DI scope issue (AuthUIService.cs:263).
ConfigurationOAuthUISettings
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/ConfigurationOAuthUISettings.cs:13· Level 1 · class (sealed)
- What it is: an
IOAuthUISettingsimplementation that reads provider availability from theOAuthconfiguration section, covering both host shapes (server and WASM) with one class. - Depends on:
IOAuthUISettings(Level 0); NuGet (Microsoft.Extensions.Configuration.IConfiguration). - Concept introduced, config-driven provider gating that never ships the client id to the browser.
[Rubric §18, UI Architecture](assesses configuration-driven UI without backend leakage) and[Rubric §26, Front-End Security](assesses that a secret-bearing key stays server-side). The doc comment (ConfigurationOAuthUISettings.cs:5-12) explains the dual shape: a server host declares a provider enabled when itsOAuth:{Provider}:ClientIdis configured; a WASM client instead receives a pre-computedOAuth:{Provider}Enabledflag through its runtime config (/client-config), which never carries the client id itself. - Walkthrough: the constructor (
ConfigurationOAuthUISettings.cs:21) null-guardsconfiguration, reads theOAuthsection, and computesGoogleEnabled/GitHubEnabledonce (ConfigurationOAuthUISettings.cs:25-27) into get-only properties (ConfigurationOAuthUISettings.cs:16,19).IsProviderEnabled(ConfigurationOAuthUISettings.cs:30) returnstruewhen either the{Provider}Enabledflag parses totrueor a non-empty{Provider}:ClientIdis present (ConfigurationOAuthUISettings.cs:32-33), so the flag path (WASM) and the client-id path (server) both light up the button. - Why it's built this way: folding both host shapes into one predicate avoids two near-identical settings classes and keeps the "browser never sees the client id" rule in one place; computing the flags in the constructor makes the instance immutable and cheap to read.
- Where it's used: registered as a singleton (per its doc comment,
ConfigurationOAuthUISettings.cs:7) to replace the no-opDefaultOAuthUISettings; consumed by the login page.
DefaultOAuthUISettings
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/DefaultOAuthUISettings.cs:7· Level 1 · class (internal sealed)
- What it is: the no-op
IOAuthUISettingsimplementation that disables all OAuth providers, a single-line type:internal sealed class DefaultOAuthUISettings : IOAuthUISettings;(DefaultOAuthUISettings.cs:7). - Depends on:
IOAuthUISettings(Level 0). - Concept, the Null Object / default-registration pattern.
[Rubric §2, Design Patterns](assesses using a benign default rather than a nullable dependency). BecauseIOAuthUISettingssupplies default members returningfalse, this class needs no body: it inherits "all providers off". Registering it guarantees the interface is always resolvable, so the login page can inject it unconditionally; a downstream app overrides the registration withConfigurationOAuthUISettingsto enable providers. - Walkthrough: no members. All behavior comes from the interface's default members.
- Where it's used: the framework's fallback registration; superseded by
ConfigurationOAuthUISettingswhen an app configures OAuth.
DirectApiTokenRefresher
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/DirectApiTokenRefresher.cs:11· Level 1 · class (sealed)
- What it is: the MAUI
ITokenRefresher: it exchanges the refresh token held in OS SecureStorage directly against the API'sauth/refreshendpoint and persists the rotated pair back to storage. - Depends on:
ITokenRefresher(Level 0),ITokenStorageService(Level 0),RefreshTokenRequest,AuthenticationResponse; BCL/NuGet (IHttpClientFactory,System.Net.Http.Json). - Concept, per-host refresh strategy.
[Rubric §11, Security](assesses matching the refresh mechanism to the platform's threat surface). The doc comment (DirectApiTokenRefresher.cs:6-9) justifies handling the refresh token directly: MAUI has no browser DOM and therefore no XSS surface, so exchanging a SecureStorage-held token straight against the cross-origin API is acceptable. The browser hosts useSameOriginProxyTokenRefresherinstead. - Walkthrough:
AcquireAccessTokenAsync(DirectApiTokenRefresher.cs:17): reads both tokens (DirectApiTokenRefresher.cs:19-20); returnsnullif either is missing (DirectApiTokenRefresher.cs:22-25); POSTs aRefreshTokenRequestto the relativeauth/refresh(DirectApiTokenRefresher.cs:27-29); on a non-success status returnsnull(DirectApiTokenRefresher.cs:31-34); otherwise readsAuthenticationResponse, returnsnullon a blank access token, then persists the rotated pair viaSetTokensAsyncand returns the new access token (DirectApiTokenRefresher.cs:36-43). - Why it's built this way: constructor-injecting the storage service and HTTP factory keeps the
refresher stateless; the null-on-failure convention matches
ITokenRefresherso a caller treats null as "re-login". - Where it's used: registered as the
ITokenRefresheron the MAUI host; reached throughAuthUIService.TryRefreshTokenAsync.
JsFetchSessionCookieSync
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/JsFetchSessionCookieSync.cs:11· Level 1 · class (sealed)
- What it is: the
ISessionCookieSyncimplementation that syncs the HttpOnly auth cookie by firing a browserfetchthrough JS interop. - Depends on:
ISessionCookieSync(Level 0); NuGet (Microsoft.JSInterop.IJSRuntime). - Concept, browser-issued cookie writes.
[Rubric §26, Front-End Security]and[Rubric §18, UI Architecture](assess crossing the Server/WASM prerender boundary safely). The doc comment (JsFetchSessionCookieSync.cs:5-9) explains why the fetch is issued from the browser and not the server: only then does the resultingSet-Cookieland in the user's cookie jar, and it works in both Blazor Server interactive mode and WebAssembly. When JS interop is unavailable (SSR prerender, a render-mode transition), the calls fall silent rather than throw. - Walkthrough:
SyncAsync(JsFetchSessionCookieSync.cs:16) invokesmmcaAuthCookie.setwith both tokens;ClearAsync(JsFetchSessionCookieSync.cs:28) invokesmmcaAuthCookie.clear. Both wrap the interop call and swallow the interop-unavailable exception family via the sharedIsInteropUnavailablepredicate (JsFetchSessionCookieSync.cs:13-14), which matchesInvalidOperationException,JSDisconnectedException,JSException, andOperationCanceledException. The catch comments note the cookie will be re-synced on the next write. - Why it's built this way: keeping the JS mechanics behind the interface lets MAUI drop in a no-op; swallowing interop failures during prerender keeps a login flow from crashing when the circuit is not yet interactive.
- Where it's used: registered on the web heads as
ISessionCookieSync; driven byWasmTokenStorageServiceat login and logout.
JwtAuthenticationStateProvider
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/JwtAuthenticationStateProvider.cs:12· Level 1 · class (sealed)
- What it is: a custom Blazor
AuthenticationStateProviderthat derives auth state from the JWT held byITokenStorageService, reading claims client-side for responsiveness while the API validates fully on every request. - Depends on:
ITokenStorageService(Level 0); NuGet/BCL (Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider,System.Security.Claims,JwtSecurityTokenHandler). - Concept introduced, client-side auth-state projection.
[Rubric §18, UI Architecture](assesses how Blazor'sAuthorizeView/CascadingAuthenticationStatelearns who is signed in),[Rubric §11, Security](assesses that client-side claims drive only rendering, not trust), and[Rubric §19, State Management](assesses pushing state changes without a page reload). The doc comment (JwtAuthenticationStateProvider.cs:7-11) states the split: claims are extracted client-side without server validation to keep the UI responsive; the WebAPI does the real validation. - Walkthrough
- A shared
AnonymousState(JwtAuthenticationStateProvider.cs:14-15) is an emptyClaimsPrincipal, the fallback for every unauthenticated path. GetAuthenticationStateAsync(JwtAuthenticationStateProvider.cs:22): reads the token; returns anonymous on blank (:27-30), on an unreadable token (CanReadToken,:33-36), or on an expired token (ValidTo < DateTime.UtcNow,:39-42). Otherwise it builds aClaimsIdentitywith the"jwt"authentication type (:45), which is what makesIsAuthenticated == true, and returns the principal. A barecatch(:49-52) falls back to anonymous on any failure (corrupt data, interop unavailable).NotifyUserAuthentication(string token)(:59) builds a principal from the token and callsNotifyAuthenticationStateChangedsoCascadingAuthenticationStateconsumers update immediately after login/refresh, with no page reload;NotifyUserLogout()(:71) pushesAnonymousState.
- A shared
- Why it's built this way: deriving state from the stored token (rather than a server round-trip)
keeps the UI instant, and the explicit notify methods let
AuthUIServicedrive state transitions on login, refresh, and logout. The"jwt"auth-type string is load-bearing: an identity built with no auth type reportsIsAuthenticated == false. - Where it's used: registered as the Blazor
AuthenticationStateProvider;AuthUIServicepattern-matches it to callNotifyUserAuthentication/NotifyUserLogout.
SameOriginProxyTokenRefresher
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/SameOriginProxyTokenRefresher.cs:11· Level 1 · class (sealed)
- What it is: the browser (Blazor Server + WebAssembly)
ITokenRefresher: it calls the same-originPOST /auth/session/tokenendpoint via JSfetchso the browser sends its HttpOnly cookies and the UI host refreshes server-side, returning only the access token. - Depends on:
ITokenRefresher(Level 0); NuGet (Microsoft.JSInterop.IJSRuntime). - Concept, refresh-token isolation from JS.
[Rubric §11, Security]and[Rubric §26, Front-End Security](assess that the refresh token never enters JS-reachable memory). The doc comment (SameOriginProxyTokenRefresher.cs:5-10) explains the mechanism: the JS fetch usescredentials:'same-origin', which sends the HttpOnly auth cookie to the same-origin UI host; the host validates-or-refreshes server-side and hands back only the access token. This is the browser half of the dual-fetch model (ADR-004). - Walkthrough:
AcquireAccessTokenAsync(SameOriginProxyTokenRefresher.cs:13) invokesmmcaAuthSession.getToken(:17), returningnullfor a blank result (:18). It catches the JS interop exception family (InvalidOperationException,JSDisconnectedException,JSException,OperationCanceledException,:20-25) and returnsnull, the comment noting the server-side cookie path covers SSR-prerender and disconnected-circuit phases. - Why it's built this way: routing the refresh through a same-origin JS fetch keeps the high-value refresh token in the HttpOnly cookie and out of JS memory, exactly the isolation §26 rewards.
- Where it's used: registered as the
ITokenRefresheron the web server and WASM hosts; consumed byWasmTokenStorageServiceandAuthUIService.
WasmTokenStorageService
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/WasmTokenStorageService.cs:11· Level 1 · class (sealed)
- What it is: the WebAssembly
ITokenStorageService: it holds the access token in memory only (neverlocalStorage) and hydrates or refreshes it on demand from the HttpOnly cookies through anITokenRefresher. - Depends on:
ITokenStorageService(Level 0),ISessionCookieSync(Level 0),ITokenRefresher(Level 0),JwtTokenInfo(Level 0). - Concept introduced, in-memory-plus-cookie token custody with single-flight refresh.
[Rubric §26, Front-End Security](assesses keeping the access token out of persistent, JS-readable storage),[Rubric §11, Security](assesses that the refresh token is never client-readable),[Rubric §12, Performance & Scalability], and[Rubric §19, State Management](assess deduplicating concurrent token acquisition). The doc comment (WasmTokenStorageService.cs:3-10) states the model: cookie-only, the access token lives in memory and is rehydrated from the HttpOnly cookies via the same-origin/auth/session/tokenendpoint; the refresh token is never readable by JS; and the class is hoisted from the app WASM clients because it carries no app-specific state (its Blazor Server sibling isServerTokenStorageService). - Walkthrough
- Fields: a static
ExpirySkewof 30 seconds (WasmTokenStorageService.cs:15), the in-memory_accessToken(:17), and an_hydrateInFlighttask handle (:18) that backs the single-flight guard. GetAccessTokenAsync(:20): returns the cached token immediately ifJwtTokenInfo.IsFresh(_accessToken, ExpirySkew)(:22-25); otherwise it starts (or joins) oneHydrateAsyncvia_hydrateInFlight ??= HydrateAsync()so concurrent callers (the delegating handler, auth-state provider, SignalR) share a single acquisition (:27-36), clearing the handle in afinally.GetRefreshTokenAsync(:40): always returnsnull, the refresh token lives only in the HttpOnly cookie.SetTokensAsync(:42): stores the access token in memory and seeds the HttpOnly cookies viaISessionCookieSync.SyncAsync; the comment notes the refresh token transits JS only for that one same-origin POST and is never persisted (:44-47).ClearTokensAsync(:50): nulls the in-memory token and clears the cookies.HydrateAsync(:56): callsITokenRefresher.AcquireAccessTokenAsync, caches the result in_accessToken, and returns it.
- Fields: a static
- Why it's built this way: holding the access token in process memory (not
localStorage) shrinks the XSS blast radius, and the single-flight_hydrateInFlightguard prevents a thundering herd of parallel refreshes when several components ask for a token at once (ADR-004). - Where it's used: registered as the
ITokenStorageServiceon the WASM host; read byAuthDelegatingHandler,JwtAuthenticationStateProvider, andAuthUIService. - Caveats / not-in-source: the
finallyclears_hydrateInFlightafter the first awaiter completes, so single-flight coalesces callers that overlap the acquisition window, not every call across the token's lifetime; a caller arriving after the window starts a fresh hydration.
IAuthUIService
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/IAuthUIService.cs:9· Level 5 · interface
- What it is: the client-side authentication contract that ties together token storage, HTTP calls
to the
auth/*WebAPI endpoints, and Blazor auth-state notifications. - Depends on:
AuthenticationResponse,LoginRequest,RegisterRequest(viaMMCA.Common.Shared.Auth). - Concept, the UI-layer auth boundary.
[Rubric §3, Clean Architecture](assesses that the UI auth surface depends only on Shared DTOs, never Application/Domain) and[Rubric §11, Security](assesses token handling behind a service abstraction, not in page components). This interface lives inMMCA.Common.UIand references onlyMMCA.Common.Shared.Authrequest/response records, so page components talk to it without pulling in any backend layer. - Walkthrough: a
LastErrorstring property (IAuthUIService.cs:12, the last failure message, or null), plusLoginAsync(:15),RegisterAsync(:18),ExchangeOAuthCodeAsync(:25, which swaps a single-use OAuth completion code for the token pair viaauth/oauth/exchange, keeping tokens out of the address bar),LogoutAsync(:28),TryRefreshTokenAsync(:31), andChangePasswordAsync(:34). TheLoginAsync/RegisterAsync/ExchangeOAuthCodeAsyncmethods return a nullableAuthenticationResponse(null on failure). - Why it's built this way: exposing auth as a UI-layer contract keeps components free of HTTP and token mechanics and preserves the layered dependency rule (UI depends on Shared only).
- Where it's used: implemented by
AuthUIService; injected into the login, register, profile, and session-refresh Blazor components.
AuthUIService
MMCA.Common.UI ·
MMCA.Common.UI.Services.Auth·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/AuthUIService.cs:15· Level 6 · class (sealed)
- What it is: the concrete
IAuthUIService: it drives the full client auth lifecycle (login, register, OAuth exchange, logout, refresh, password change) by calling theauth/*endpoints, persisting tokens viaITokenStorageService, and pushing state throughJwtAuthenticationStateProvider. - Depends on:
IAuthUIService(Level 5),ITokenStorageService(Level 0),ITokenRefresher(Level 0),JwtAuthenticationStateProvider(Level 1),IPushRegistrationService,AuthenticationResponse,LoginRequest,RegisterRequest,OAuthCodeExchangeRequest,ChangePasswordRequest; NuGet/BCL (IHttpClientFactory,System.Net.Http.Json,ProblemDetails, BlazorAuthenticationStateProvider). - Concept, centralized UI auth orchestration.
[Rubric §11, Security]and[Rubric §26, Front-End Security](assess that token storage/refresh flow through service abstractions, never raw storage in page code),[Rubric §19, State Management](assesses coordinating auth-state notifications), and[Rubric §29, Resilience & Business Continuity](assesses best-effort side effects that never block the primary flow). The doc comment (AuthUIService.cs:9-13) notes it also guardsInvalidOperationExceptionaround JS interop during SSR prerender. - Walkthrough
LoginAsync(AuthUIService.cs:26) andRegisterAsync(:72) follow one shape: POST the request toauth/login/auth/register; on a non-success status, read aProblemDetailsbody intoLastError(falling back to a generic message) and returnnull(:32-46,:78-92); on success, readAuthenticationResponse, bail on a blank access token, persist the pair viaSetTokensAsyncinside atry/catch (InvalidOperationException)for prerender, then callNotifyUserAuthenticationwhen the provider is aJwtAuthenticationStateProvider(:48-69,:94-114).ExchangeOAuthCodeAsync(:117): rejects a blank code up front (:121-125), then POSTs anOAuthCodeExchangeRequesttoauth/oauth/exchangeand follows the same success/failure handling, keeping tokens out of the URL.LogoutAsync(:172): first best-effortpushRegistration.UnregisterAsync()while the token is still valid (native-push cleanup, ADR-044), wrapped in aCA1031-suppressed catch so a failure never blocks sign-out (:177-186); then a best-effort authenticatedauth/revokePOST (:188-205); thenClearTokensAsyncandNotifyUserLogout(:207-219).TryRefreshTokenAsync(:222): delegates toITokenRefresher.AcquireAccessTokenAsync; a null result means the session cannot be refreshed, so it clears tokens, notifies logout, and returnsfalse; a token notifies authentication and returnstrue(:227-253).ChangePasswordAsync(:256): manually attaches the Bearer token from circuit-scoped storage (the comment at:263notesAuthDelegatingHandlerhas Blazor Server scope issues), then PUTs aChangePasswordRequesttoauth/passwordand returns the success flag.
- Why it's built this way: routing every auth operation through one service keeps components free
of HTTP and token mechanics; the pervasive
InvalidOperationExceptionguards keep an operation from crashing when JS interop is unavailable during prerender; and the best-effort push/revoke steps ensure sign-out always completes locally even when a remote call fails (ADR-044). - Where it's used: registered as the
IAuthUIServiceimplementation on the web and MAUI heads; injected into login, register, profile, and session-refresh components. TheNoOpAuthUIServicein the component gallery is the backend-less stand-in for gallery rendering. - Caveats / not-in-source: several catch blocks around
ProblemDetailsparsing andauth/revokeswallow all exceptions deliberately (a failed error-detail read or revoke must not derail the flow); the concreteAuthenticationStateProvideris injected by its base type and pattern-matched toJwtAuthenticationStateProviderat each notification site, so a different provider registration would silently skip the notify calls.
BackNavigationResult
MMCA.Common.UI ·
MMCA.Common.UI.Services.Navigation·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Navigation/MauiBackNavigationBridge.cs:19· Level 0 · record (sealed)
- What it is: The outcome of a hardware-back or WebView-back attempt routed through
MauiBackNavigationBridge: whether the WebView consumed the gesture and whether the WebView is sitting at the root of its history stack. - Depends on: Nothing first-party; it is a two-field positional record. Produced and consumed by
MauiBackNavigationBridge. - Concept introduced: This is the value returned across the JS interop boundary.
nav-interop.js'stryGoBack()returns a shape that deserializes intoBackNavigationResult, so the record is also the wire contract for that single interop call. - Walkthrough:
public sealed record BackNavigationResult(bool Handled, bool AtRoot)(MauiBackNavigationBridge.cs:19).Handledistruewhen the WebView's history stack had a previous entry andhistory.back()fired;AtRootistruewhen there was no previous entry. The two flags together let a MAUIContentPagedecide whether to exit the app (Android convention: exit when back is pressed at the root). - Why it's built this way: A
sealed recordgives structural equality and a positional deconstruction for free, and it is the smallest thing that can carry the two facts the native host needs. Modeling the result as data (rather than throwing or mutating shared state) keeps the interop call pure and trivially testable. - Where it's used: Returned by
MauiBackNavigationBridge.HandleBackPressedAsync; consumed by MAUI hostContentPage.OnBackButtonPressedhandlers.
BrandColors
MMCA.Common.UI ·
MMCA.Common.UI.Theme·MMCA.Common/Source/Presentation/MMCA.Common.UI/Theme/BrandColors.cs:10· Level 0 · class (static)
- What it is: The single C# source of truth for the brand palette: three hex constants that
MMCAThemereads for both its light and dark MudBlazor variants. - Depends on: Nothing first-party. It is mirrored by the CSS custom properties
--mmca-primaryand--mmca-primary-darkinwwwroot/app.css. - Concept introduced: a fitness-tested duplication. [Rubric §20, Design System & Theming] assesses whether visual tokens are centralized rather than scattered as literals; here the palette lives in exactly one C# class. [Rubric §34, Architecture Governance & Documentation] assesses whether necessary duplication is monitored: because C# cannot read CSS at build time, the same colors must exist in both
BrandColorsandapp.css, andBrandColorTokenTests(inMMCA.Common.UI.Tests) asserts the two stay in sync so the copy cannot silently drift (BrandColors.cs:6-8). - Walkthrough: Three
public const stringfields:Primary = "#1565C0"(CSS--mmca-primary,BrandColors.cs:13),PrimaryDark = "#0D47A1"(CSS--mmca-primary-dark, line 16), andPrimaryLight = "#42A5F5"(accents and dark-mode contrast, line 19). - Why it's built this way:
const(notstatic readonly) means the values can appear in contexts that require compile-time constants; the governance is the fitness test, not the language keyword. Keeping the palette in one class means a rebrand touches one file plus the mirrored CSS. - Where it's used:
MMCAThemelight and dark palettes;BrandColorTokenTests; any component that references a brand color programmatically.
NotificationState
MMCA.Common.UI ·
MMCA.Common.UI.Services.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/NotificationState.cs:8· Level 0 · class (sealed)
- What it is: The scoped shared state for the notification unread count, and the coordination point between the notification bell, real-time push, and background polling.
- Depends on: Nothing first-party; it holds an
intcount and twoEventHandlerevents. Consumed byNotificationBell. - Concept introduced: a scoped state store with an active-poller guard. [Rubric §19, State Management] assesses how shared UI state is owned and observed without threading it through the component tree.
NotificationStateis registered scoped, so each Blazor circuit gets its own instance; components subscribe to its events instead of receiving cascading parameters. The subtle part is duplicate suppression:NotificationBellcan render in more than one DOM location (desktop header and mobile drawer), and every instance would otherwise start its own poll loop.TryRegisterPollerusesInterlocked.Incrementso only the first caller returnstrue(NotificationState.cs:51); the rest skip polling. - Walkthrough: Fields and members in teaching order:
_pollerCount(theInterlockedreference count, line 10);UnreadCountwith a private setter (line 13);OnChangeandOnRefreshRequestedevents (EventHandler, lines 16 and 22).SetUnreadCount(int)sets an absolute value and raisesOnChangeonly when the value actually changes (lines 25-34);IncrementUnreadCount()bumps by one for an optimistic real-time update (lines 37-41);RequestRefresh()raisesOnRefreshRequestedso a subscriber refetches the authoritative count (line 44);TryRegisterPoller()/UnregisterPoller()bracket the active-poller lifetime (lines 51 and 54). - Why it's built this way: Scoped because the count is per-user-session; event-based because subscribers live at arbitrary render-tree depth. The private setter keeps mutation funneled through the three named methods so every change goes through the change-notification path.
- Where it's used: Injected into
NotificationBell; driven byNotificationHubServicepush events (which callIncrementUnreadCount/RequestRefresh).
ReturnUrlProtector
MMCA.Common.UI ·
MMCA.Common.UI.Services.Navigation·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Navigation/ReturnUrlProtector.cs:9· Level 0 · class (static)
- What it is: A pure sanitizer for
returnUrlquery parameters: it accepts only same-origin relative paths and replaces anything else with a safe fallback, closing the open-redirect hole in every post-login redirect. - Depends on:
System.Uri(BCL) for the final relative-URI parse guard. - Concept introduced: open-redirect defense. [Rubric §26, Front-End Security] assesses whether user-controlled navigation targets are validated before use. An open redirect lets an attacker craft
/login?returnUrl=https://evil.com, so the victim lands on an attacker site after authenticating (a classic phishing amplifier).Sanitizerejects every off-host form rather than trying to allow-list attacks. - Walkthrough:
Sanitize(string? candidate, string fallback = "/")runs a sequence of cheap, regex-free guards (regex here would invite ReDoS): empty/null returns the fallback (ReturnUrlProtector.cs:20); the path must start with a single/(line 27); it must not start with//or/\, which browsers treat as the start of an authority and would send the user off-host (line 34); no backslash anywhere, since some browsers normalize\to/(line 41); no control characters, which are header-injection and response-splitting vectors (line 48); and finally it must parse as a well-formed relative URI (line 54). Any failure returnsfallback. - Why it's built this way: A static pure function with the candidate as its only input is trivially unit-testable across every attack vector, and the ordered inline checks read as a documented threat model. It is called centrally so no page hand-rolls its own redirect validation.
- Where it's used: The login page reads
returnUrlfrom the query string and sanitizes before redirecting;NavigationHistoryService.GoBackAsyncalso runs the fallback path through it (NavigationHistoryService.cs:82).
MauiBackNavigationBridge
MMCA.Common.UI ·
MMCA.Common.UI.Services.Navigation·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Navigation/MauiBackNavigationBridge.cs:28· Level 1 · class (static)
- What it is: A static bridge that routes a native MAUI back gesture (Android hardware back, iOS swipe) into the BlazorWebView's internal history stack, so pressing back inside a hybrid app behaves like a web back rather than tearing down the page.
- Depends on:
BackNavigationResult(its return type);Microsoft.JSInterop.IJSRuntimeand thenav-interop.jsmodule (externals). - Concept introduced: MAUI-to-WebView interop. [Rubric §22, Responsive & Cross-Browser] extends to hybrid hosts here: the same Blazor UI runs inside a MAUI WebView, and native chrome events must be reconciled with web navigation. The call is meant to run from
ContentPage.OnBackButtonPressedviaBlazorWebView.TryDispatchAsyncso it executes on the renderer thread with access to the WebView'sIJSRuntime(MauiBackNavigationBridge.cs:22-27). - Walkthrough:
HandleBackPressedAsync(IJSRuntime js)(line 38) null-checks the runtime, dynamically imports./_content/MMCA.Common.UI/nav-interop.js(ModulePath, line 30), and invokes itstryGoBack()helper, deserializing the result into aBackNavigationResult(lines 44-46). Every interop failure mode is caught explicitly (InvalidOperationExceptionwhen Blazor is not yet hydrated,JSDisconnectedException,JSException) and collapses tonew BackNavigationResult(Handled: false, AtRoot: true)(lines 48-60), so a not-yet-ready WebView reports "at root, not handled" and the host falls back to its default back behavior. - Why it's built this way: A static helper with no state fits a one-shot interop call; returning a data record instead of throwing keeps the native handler branch-free. Swallowing the three JS exception types into a safe default means an unhydrated or disconnected circuit never crashes the native back button.
- Where it's used: MAUI host projects call it from their page back-button handler; the returned
BackNavigationResulttells the host whether to exit the app. - Caveats / not-in-source: The
nav-interop.jstryGoBack()implementation and the MAUI host wiring live outside this unit; only the C# side of the bridge is visible here.
MobileInfiniteScrollList<TItem>
MMCA.Common.UI ·
MMCA.Common.UI.Components·MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/MobileInfiniteScrollList.razor.cs:15· Level 1 · class (generic component)
- What it is: The code-behind for the mobile card list that fetches pages on demand as the user scrolls, using an IntersectionObserver sentinel, and caps how many items ever reach the DOM.
- Depends on:
IJSRuntime, MudBlazor'sISnackbar, andIStringLocalizer<SharedResource>(all[Inject]ed, lines 17-19); theinfinite-scroll.jsinterop module;IAsyncDisposable(BCL). TheFetchPagedelegate typically wraps a first-party UI service such asEntityServiceBase<TEntityDTO, TIdentifierType>. - Concept introduced: sentinel-driven infinite scroll with a rendered-item cap. [Rubric §23, Front-End Performance] assesses whether the UI bounds work and memory under large result sets. Rather than render everything, the component observes a single DOM sentinel; when it scrolls into view, JS calls back into .NET to fetch the next page. Crucially it stops fetching once
_items.CountreachesMaxRenderedItems(default500, line 41), so DOM growth and memory stay bounded even for huge lists (MobileInfiniteScrollList.razor.cs:145-147). [Rubric §24, Forms/Validation/UX Safety] also applies: a failed load surfaces a localized, sanitized snackbar (L["Grid.Snackbar.LoadFailed"], line 162) instead of raw exception text (ADR-027). - Walkthrough: Parameters: the required
CardTemplaterender fragment andFetchPagedelegate (Func<int,int,CancellationToken,Task<(IReadOnlyList<TItem>,int)>>, lines 21-27),PageSize(default 10, line 28),MaxRenderedItems(line 41). State fields track pagination and lifecycle (_items,_currentPage,_hasMore,_loadError, the_ctscancellation source, and the_observerId, lines 43-56).OnInitializedAsyncloads page one (line 58);OnAfterRenderAsyncattaches the observer once items exist and there is more to load (lines 64-70);AttachObserverAsyncimportsinfinite-scroll.jsand callsobservewith aDotNetObjectReference(lines 72-86). The JS-invokableOnSentinelVisible(line 105) guards against re-entrancy (_isLoadingMore,_hasMore,_disposed) and dispatchesLoadNextPageAsyncon the renderer viaInvokeAsync.LoadNextPageAsynccancels any superseded fetch before starting a newCancellationTokenSource(lines 130-136), decrements the page on cancel/error, and recomputes_hasMoreagainst both the total and the cap (line 147).ResetAsyncclears state and reloads from page one when filters change (lines 181-198);DisposeAsynccancels, detaches the observer, and disposes the JS module and .NET reference (lines 200-232). - Why it's built this way: The IntersectionObserver lives in JS because that is where the browser exposes it; the .NET side stays declarative. Per-fetch cancellation prevents a slow earlier page from overwriting a newer one, and the rendered-item cap is a deliberate performance ceiling rather than truly unbounded scroll. Full
IAsyncDisposablecleanup avoids leaking observers and interop references across navigations. - Where it's used: Mobile list views in consuming apps that supply a
CardTemplateand a pagedFetchPage; the desktop equivalent is the grid-based list page base. - Caveats / not-in-source: The
infinite-scroll.jsobserve/unobserveimplementation is outside this unit.
NavigationHistoryService
MMCA.Common.UI ·
MMCA.Common.UI.Services.Navigation·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Navigation/NavigationHistoryService.cs:12· Level 1 · class (sealed)
- What it is: A per-circuit service that bridges Blazor's
NavigationManagerwith the browser history API so a "Back" button can perform a realhistory.back()when an in-history entry exists, and fall back to an explicit route otherwise. - Depends on:
ReturnUrlProtector(sanitizes the fallback);NavigationManagerandIJSRuntime(constructor-injected, line 12); thenav-interop.jsmodule. - Concept introduced: honoring real browser history. [Rubric §25, Navigation & Information Architecture] assesses predictable, source-aware navigation. A hard-coded "back to list" link ignores where the user actually came from; this service instead asks the browser whether a previous entry exists and navigates to it, only falling back to a route when it does not.
- Walkthrough:
CanGoBackAsync()imports the module and returnshistoryLength() > 1(lines 23-48), swallowing interop failures (SSR prerender, disconnect) asfalse.GoBackAsync(string fallback = "/")callshistoryBackwhen history is available (lines 55-80) and, on any interop failure or when history is empty, falls through tonavigation.NavigateTo(ReturnUrlProtector.Sanitize(fallback))(line 82) so the fallback route itself is open-redirect-safe.GetModuleAsyncmemoizes the importedIJSObjectReferencein_module(lines 85-105). - Why it's built this way: Sealed and scoped per circuit because the cached JS module reference and history semantics are per-connection. Routing the fallback through
ReturnUrlProtectormeans even the "safe" branch cannot be turned into a redirect vector. The layered exception handling guaranteesGoBackAsyncalways ends in a navigation. - Where it's used: Injected into detail-page "Back" buttons; the same history primitives back the MAUI hardware-back path.
BlazorCspPolicyProvider
MMCA.Common.UI.Web ·
MMCA.Common.UI.Web.Security·MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/Security/BlazorCspPolicyProvider.cs:21· Level 2 · class (internal, sealed)
- What it is: The Content-Security-Policy provider for a Blazor Web host: it builds the CSP once at startup, pinning
connect-srcto'self'plus the configured API/Gateway origin, and hands it to the shared security-headers middleware. - Depends on:
ICspPolicyProviderandCspPolicy(the abstraction and value it implements/returns),SecurityHeadersMiddleware(its consumer), andApiSettings(the endpoint source,BlazorCspPolicyProvider.cs:6);IWebHostEnvironmentandSystem.Uri(BCL). - Concept introduced: a computed, fail-open CSP. [Rubric §26, Front-End Security] assesses whether the browser is told which origins may execute scripts and open connections. This provider locks
script-srcto'self' 'wasm-unsafe-eval'andconnect-srcto'self'plus the exact API origin and its WebSocket origin, so an injected script cannot exfiltrate to an arbitrary host. The deliberate design choice is fail-open on the policy value, not on enforcement: if the endpoint cannot be resolved, the CSP degrades to a permissiveReport-Onlypolicy so a misconfiguration can never hard-break production (BlazorCspPolicyProvider.cs:14-17). - Walkthrough: The constructor computes the policy once (registered as a singleton) via
BuildCsp(lines 26-31);GetPolicy(HttpContext)just returns the cachedCspPolicy(line 34).BuildCspreadsWasmApiEndpoint ?? ApiEndpoint(line 40) and, if it is missing or not an absolute http(s) URI, returns aReport-Only, non-enforced policy (Enforce: false, line 49), the scheme check matters on Linux, where a rooted path parses as afile://URI. When the origin is valid it derivesscheme://host:portplus the matchingws/wssorigin for the SignalR notification hub (lines 53-55). In Development only it additionally allowshttp://localhost:*/ws://localhost:*for Visual Studio Browser Link and Hot Reload (lines 61-64), andBuildPolicyadds'unsafe-inline'toscript-srcfor the injected Hot Reload bootstrap (line 75), so the hardened production policy is never loosened.img-srcallows any https source because profile pictures come from arbitrary hosts, while the exfiltration-relevantscript-src/connect-srcstay locked (lines 69-82). - Why it's built this way: Computing once and caching keeps per-request cost at zero.
internalbecause it is registered throughAddCommonBlazorCsp()and never touched directly by consumers. Fail-open policy value plus fail-closed intent (Report-Only when unsure) is the pragmatic middle: security teams still get violation reports without risking an outage from a bad connection string. - Where it's used: Registered by
AddCommonBlazorCsp()beforeAddCommonSecurityHeaders; the policy it returns is emitted bySecurityHeadersMiddleware.
ChannelSubscription
MMCA.Common.UI ·
MMCA.Common.UI.Services.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/NotificationHubService.cs:329· Level 2 · class (private, sealed, nested)
- What it is: The disposable handle returned when a caller subscribes to a live channel on
NotificationHubService; disposing it removes the handler from the channel's subscriber list. - Depends on: Its owning
NotificationHubService(back-reference), a channel key string, and aFunc<string, string, Task>handler; implementsIDisposable. - Concept introduced: subscription-as-token. This is the classic "return an
IDisposableto unsubscribe" pattern. Instead of exposing anUnsubscribe(handler)method (which forces callers to hold and match the exact delegate),OnChannelEventreturns aChannelSubscription; when the component disposes it, the subscription calls back into the owner to unregister itself. It captures the owner, theChannelKey, and theHandler(NotificationHubService.cs:331-333). - Walkthrough: A primary-constructor nested class (line 329) exposing
ChannelKeyandHandleras read-only properties;Dispose()simply callsowner.RemoveSubscription(this)(line 335), which locks the shared_channelSyncand removes the entry (and prunes the channel list when it empties, lines 290-303). - Why it's built this way: Nesting it privately inside
NotificationHubServicekeeps the subscription bookkeeping encapsulated: only the hub service can construct one, and only it knows how to remove one. TheIDisposableshape lets Blazor components tie unsubscription to their own lifetime withusingor an explicit dispose. - Where it's used: Constructed and returned by
NotificationHubService.OnChannelEvent; disposed by the component that subscribed.
INotificationInboxUIService
MMCA.Common.UI ·
MMCA.Common.UI.Services.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/INotificationInboxUIService.cs:9· Level 2 · interface
- What it is: The UI-side contract for the notification inbox: paged retrieval, unread count, mark-one-read, and mark-all-read.
- Depends on:
PagedCollectionResult<T>andUserNotificationDTO(its return shapes). - Concept introduced: the UI service abstraction. [Rubric §18, UI Architecture & Component Design] assesses whether components talk to typed services rather than raw
HttpClient. Components depend on this interface, not on the HTTP implementation, so a bell or inbox page can be tested against a stub. [Rubric §9, API & Contract Design] shows in the paged inbox signature: the inbox is fetched a page at a time, never as one unbounded dump. - Walkthrough: Four members (
INotificationInboxUIService.cs:12-21):GetInboxAsync(pageNumber = 1, pageSize = 20, ct)returns a nullablePagedCollectionResult<T>;GetUnreadCountAsync(ct)returns anint;MarkReadAsync(id, ct)andMarkAllReadAsync(ct)are the two mutations. - Why it's built this way: A thin interface at the presentation edge keeps components decoupled from transport and makes the inbox mockable in bUnit tests.
- Where it's used: Implemented by
NotificationInboxService; consumed byNotificationBell(for the unread count) and the inbox page.
IPushNotificationUIService
MMCA.Common.UI ·
MMCA.Common.UI.Services.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/IPushNotificationUIService.cs:9· Level 2 · interface
- What it is: The UI-side contract for admin push operations: broadcast a notification and read paginated send history.
- Depends on:
PagedCollectionResult<T>,PushNotificationDTO, andSendPushNotificationRequest. - Concept introduced: The same UI-service-abstraction idea as
INotificationInboxUIService; [Rubric §18, UI Architecture]. The difference is audience: this is the organizer/admin surface (send + history), not the per-user inbox. - Walkthrough: Two members (
IPushNotificationUIService.cs:12-15):SendAsync(SendPushNotificationRequest, ct)returns the createdPushNotificationDTO;GetHistoryAsync(pageNumber = 1, pageSize = 10, ct)returns a paged history. - Why it's built this way: Splitting the admin contract from the inbox contract keeps each component's dependency surface minimal and lets apps that never send notifications avoid referencing the send path.
- Where it's used: Implemented by
PushNotificationService; consumed by the admin send/history pages.
MMCATheme
MMCA.Common.UI ·
MMCA.Common.UI.Theme·MMCA.Common/Source/Presentation/MMCA.Common.UI/Theme/MMCATheme.cs:9· Level 2 · class (static)
- What it is: The single application-wide MudBlazor
MudThemeinstance, defining the brand palette (light and dark), typography, and layout radius, applied once viaMudThemeProviderin the root layout. - Depends on:
BrandColors(the palette source of truth); MudBlazor (NuGet). - Concept introduced: one theme, accessibility-justified. [Rubric §20, Design System & Theming] assesses whether the app has a single coherent theme rather than per-page overrides;
MMCATheme.Instanceis that one object (MMCATheme.cs:11). [Rubric §21, Accessibility] is unusually visible here: several color choices carry inline WCAG 2.1 AA contrast math.Secondarywas moved from Teal 600#00897B(4.0:1, below the 4.5:1 floor for normal text) to Teal 7005.3:1) for muted helper text (lines 21-24);#00796B(WarningContrastTextis darkened to#212121because white on amber#F57F17is only ~2.65:1 and failed the gated axe scan on filled Warning chips (lines 31-35); and in the dark palettePrimaryContrastTextandErrorContrastTextare set to near-black for the same reason (lines 57-73). The dark palette itself lightens the primary for contrast on dark surfaces and drivesMudThemeProvider'sIsDarkMode(lines 50-54). - Walkthrough: A single
static MudTheme Instance { get; }initialized with aPaletteLight(lines 13-49), aPaletteDark(lines 50-86), aTypographyblock (Inter font stack plus heading sizes/weights, lines 87-139), andLayoutPropertieswithDefaultBorderRadius = "6px"(lines 140-143). The light and darkPrimary/PrimaryDarken/PrimaryLightenentries read straight fromBrandColors. - Why it's built this way: A static readonly instance means the theme is constructed once and shared by every
MudThemeProvider. Sourcing the brand hues fromBrandColors(rather than re-typing hex) is what letsBrandColorTokenTestspolice C#/CSS drift. The per-color contrast comments turn accessibility decisions into reviewable, testable source rather than tribal knowledge. - Where it's used: Applied in the root layout of the Blazor Web and MAUI hosts via
MudThemeProvider Theme="MMCATheme.Instance".
NotificationHubService
MMCA.Common.UI ·
MMCA.Common.UI.Services.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/NotificationHubService.cs:24· Level 2 · class (sealed, partial)
- What it is: The client-side SignalR connection manager: it opens a connection to
/hubs/notificationsafter login, invokes a callback for received notifications, and also carries ephemeral live-channel events that components join and subscribe to. - Depends on:
ApiSettings(for the hub URL) andITokenStorageService(for the bearer token);ChannelSubscription(its subscription handle);Microsoft.AspNetCore.SignalR.Client(NuGet);IAsyncDisposable. - Concept introduced: resilient client-side real-time with re-joinable channels. [Rubric §6, CQRS & Event-Driven] extends to the browser here: the server pushes notifications and channel events over SignalR instead of the client polling for everything. [Rubric §29, Resilience & Business Continuity] shows in two mechanisms: the initial connect retries with exponential backoff (up to
MaxRetries = 3, starting at 2s, doubling each attempt,NotificationHubService.cs:26-31,92-121) so a token-not-yet-ready or API-still-starting race recovers, andWithAutomaticReconnect()(line 75) keeps long sessions alive. The load-bearing subtlety is that SignalR group membership does not survive a reconnect, so joined channels are tracked in_joinedChannelsand re-joined automatically onReconnected(lines 88-90,RejoinChannelsAsynclines 262-288). - Walkthrough: The constructor builds the hub URL from
ApiSettings.ApiEndpoint + "/hubs/notifications"(lines 48-57).StartAsyncbuilds theHubConnectionwith anAccessTokenProviderthat pulls the current token (line 74), registers theReceiveNotificationhandler that fans out to the settableNotificationCallback(lines 78-84), registersReceiveChannelEvent→DispatchChannelEventAsync(line 86), wires theReconnectedre-join (line 90), then runs the retry loop (lines 92-121).JoinChannelAsyncrecords the channel under aLock, ensures the connection is up, and invokes the serverJoinChannel(lines 131-155);LeaveChannelAsyncis the mirror (lines 163-185).OnChannelEventregisters a handler and returns a disposableChannelSubscription(lines 196-214), multicast, so an invisible listener and a page can watch the same channel.DispatchChannelEventAsyncsnapshots subscribers under the lock and invokes each in isolation, logging (never rethrowing) a failing handler so one bad subscriber cannot starve the rest (lines 235-260).StopAsync/DisposeAsynctear the connection down (lines 219-233). Structured logging uses[LoggerMessage]source generation, which is why the class ispartial(lines 305-327). - Why it's built this way: Sealed and scoped per circuit because a connection and its channel membership are per-user-session. Best-effort semantics (join/leave/handler failures are logged, not thrown) match the reality that live updates are a convenience layered over the authoritative API, not a correctness guarantee. Isolating handler invocations protects the fan-out.
- Where it's used: Registered as a scoped service in Blazor UI hosts; started after login and stopped on logout; its notification callback drives
NotificationStateand MudBlazor snackbars.
NotificationBell
MMCA.Common.UI ·
MMCA.Common.UI.Components.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/Notifications/NotificationBell.razor.cs:14· Level 3 · class (component)
- What it is: The code-behind for the header notification bell: it renders the unread badge from the scoped
NotificationState, and the first rendered instance elects itself the single active poller so duplicate bell placements never duplicate API traffic. - Depends on:
NotificationState(badge state and the poller guard),INotificationInboxUIService(unread count),NotificationRoutePaths(inbox route),NavigationManager, andIStringLocalizer<SharedResource>([Inject]ed, lines 18-21); implementsIDisposable. - Concept introduced: single-poller election across duplicate renders. [Rubric §19, State Management] assesses coordinated shared UI state. A bell can appear in both the desktop header and the mobile drawer at once; without coordination each would run its own 30-second poll and its own navigation refresh.
NotificationBellcallsState.TryRegisterPoller()on first render (line 40) and only the winner starts thePeriodicTimerand subscribes toLocationChanged(lines 41-48). [Rubric §23, Front-End Performance] applies too: this halves-or-better the badge's API load in dual-placement layouts. - Walkthrough:
PollIntervalis 30 seconds (line 16).OnAfterRenderAsync(firstRender)subscribes toState.OnChange/State.OnRefreshRequested(lines 35-36), then attempts poller registration; the winner refreshes immediately and startsPollLoopAsyncoff aPeriodicTimer(lines 44-48).PollLoopAsyncawaitsWaitForNextTickAsyncon the_ctstoken and swallows the expected cancellation on dispose (lines 51-64).OnLocationChangedandHandleRefreshRequestedboth fire a discardedRefreshUnreadCountAsync(lines 68-77), a deliberate discard, since the refresh catches its own failures and this avoids the async-void process-crash mode (VSTHRD100, see the source comment).RefreshUnreadCountAsyncfetches the count, then marshalsState.SetUnreadCount+StateHasChangedback onto the renderer viaInvokeAsync, catching cancellation/disposal/network errors so the badge just holds its last value (lines 79-110).Dispose(bool)unsubscribes, unregisters the poller if it was the active one, and disposes the timer and_cts(lines 134-154). - Why it's built this way: The poller-election guard keeps a genuinely useful UX affordance (a live badge) from becoming a request amplifier. The careful catch-all-and-discard pattern is the correct way to launch fire-and-forget refreshes from synchronous event handlers without risking an unobserved async-void crash. Full
IDisposablecleanup releases the timer, token source, and event subscriptions on navigation. - Where it's used: Rendered in app layout headers/drawers; clicking it navigates to
NotificationRoutePaths.NotificationInbox(line 132).
NotificationInboxService
MMCA.Common.UI ·
MMCA.Common.UI.Services.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/NotificationInboxService.cs:12· Level 3 · class (sealed)
- What it is: The HTTP implementation of the notification inbox contract: it calls the
notifications/inboxWebAPI resource for paged retrieval, unread count, and the two mark-read operations. - Depends on:
AuthenticatedServiceBase(its base, supplying the authenticated client, retry policy, and error helper),INotificationInboxUIService(the contract it implements),ITokenStorageService,PagedCollectionResult<T>,UserNotificationDTO, andServiceExceptionHelper(via the base, for surfacing server errors). - Concept introduced: a typed HTTP UI service over a non-CRUD resource. [Rubric §18, UI Architecture] assesses UI-to-API access through typed services. This is the same HTTP-service shape as
EntityServiceBase<TEntityDTO, TIdentifierType>, but for a resource whose verbs are read and mark (not create/update). Each call builds an authenticated client from the base, wraps the send in the sharedRetryPolicy, and routes non-success responses throughServiceExceptionHelper.ThrowIfDomainExceptionAsyncso a domain error reaches the user as its real message. - Walkthrough: A primary constructor forwards
IHttpClientFactoryandITokenStorageServicetoAuthenticatedServiceBase(lines 12-15).GetInboxAsyncbuilds an invariant-culture query string and deserializes aPagedCollectionResult<T>(lines 20-39).GetUnreadCountAsyncreturns0on any non-success response (a badge should degrade quietly, not throw, lines 42-56).MarkReadAsyncPUTs to{id}/readandMarkAllReadAsyncPUTs toread-all, both surfacing domain errors via the helper (lines 59-90). - Why it's built this way: Inheriting from
AuthenticatedServiceBaseremoves per-method boilerplate for auth, retry, and error translation. The deliberate exception is the unread count, which returns0rather than throwing so a transient failure never breaks the header badge. - Where it's used: Registered against
INotificationInboxUIService; consumed byNotificationBelland the inbox page.
PushNotificationService
MMCA.Common.UI ·
MMCA.Common.UI.Services.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/PushNotificationService.cs:13· Level 4 · class (sealed)
- What it is: The HTTP implementation of the admin push contract: send a notification and read paginated send history against the
notificationsWebAPI resource. - Depends on:
EntityServiceBase<TEntityDTO, TIdentifierType>(its base, typed onPushNotificationDTO/PushNotificationIdentifierType),IPushNotificationUIService(the contract),ITokenStorageService,PagedCollectionResult<T>,PushNotificationDTO, andSendPushNotificationRequest. - Concept introduced: The base-class HTTP service pattern taken to its cleanest form; [Rubric §18, UI Architecture]. Where
NotificationInboxServicehand-builds each request, this one leans onEntityServiceBase'sSendRequestAsynchelper so each method is a single expression. - Walkthrough: The primary constructor passes the resource name
"notifications"plus the factory and token service toEntityServiceBase<PushNotificationDTO, PushNotificationIdentifierType>(lines 13-17), which exposesEndpoint.SendAsyncPOSTs the request viaPostAsJsonAsyncand returns the created DTO withthrowIfNull: true(lines 20-29).GetHistoryAsyncbuilds an invariant-culturepageNumber/pageSizequery and deserializes aPagedCollectionResult<T>(lines 32-41). - Why it's built this way: Delegating transport, auth, retry, and error handling to
EntityServiceBasekeeps this class down to two expression-bodied methods, matching the framework's "UI services are typed HTTP clients, never rawHttpClient" convention. - Where it's used: Registered against
IPushNotificationUIService; injected into the admin send and history pages.
NotificationInbox
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Notifications/NotificationInbox.razor.cs:17· Level 3 · class
- What it is: the code-behind for the per-user notification inbox, routed at
@page "/notifications/inbox"(NotificationInbox.razor:1). It fetches the signed-in user's notifications a page at a time, renders each as a read/unread card, lets the user mark items read individually or all at once, and reloads the current page when a real-time push asks it to refresh. - Depends on: first-party: INotificationInboxUIService (the typed read-side HTTP service), NotificationState (the per-circuit unread-count store and refresh signal), UserNotificationDTO (the row shape), PagedCollectionResult<T> (the paged envelope the inbox service returns), ErrorMessages (centralized snackbar copy), and SharedResource (the resx anchor type for the injected localizer). Externals:
MudBlazor(ISnackbar,BreadcrumbItem,Icons),Microsoft.AspNetCore.Components([Inject],OnInitializedAsync,InvokeAsync,StateHasChanged),Microsoft.Extensions.Localization(IStringLocalizer<T>), BCLCancellationTokenSource/IDisposable/Math.Ceiling/DateTime.UtcNow. - Concept introduced, the Blazor code-behind page pattern (
.razor+.razor.cspartial class). The three notification pages in this file family are authored as partial classes split across two files: the.razorholds declarative MudBlazor markup, the.razor.csholds the C# (public partial class NotificationInbox, line 17), injected services, state fields, and event handlers. The framework instantiates the component, callsOnInitializedAsync(line 39) once, and re-renders when handlers mutate fields. Several patterns recur across all three pages and are worth learning here once:- Disposal-safe async with a per-component
CancellationTokenSource. Areadonly CancellationTokenSource _cts(line 26) is created with the component and passed to every service call (_cts.Token).Dispose(bool)(lines 180-192) cancels and disposes it via the classic dispose-pattern guard (_disposedflag, line 178). Every async handler swallowsOperationCanceledExceptionsilently (e.g. lines 93-96) because that is the expected outcome when the user navigates away mid-fetch; only genuine exceptions reach the snackbar. IsLoading/IsSavingbusy flags (lines 32-33) gate the UI: the markup shows a progress indicator while loading and disables the action buttons while saving, preventing double-submits.- Real-time push refresh via an event subscription. In
OnInitializedAsyncthe page subscribes toNotificationState.OnRefreshRequested(line 49) and unsubscribes inDispose(bool)(line 186). When a push arrives,HandleRefreshRequested(lines 54-62) bounces onto the render thread withInvokeAsync(RefreshFromPushAsync)(line 61), andRefreshFromPushAsync(lines 64-75) coalesces overlapping refreshes (it returns early when already loading or disposed, lines 68-71) then reloads and callsStateHasChanged(line 74). This keeps the inbox live without polling. - Centralized, localized error copy via ErrorMessages, e.g.
ErrorMessages.LoadError(L["Entity.Notifications"], ex)(line 99), instead of inline strings. [Rubric §18, UI Architecture & Component Design]assesses component cohesion and separation of concerns; this page keeps presentation in.razorand behavior in.razor.cs, talks only to an injected abstraction (INotificationInboxUIService) rather thanHttpClientdirectly, and is single-responsibility (inbox only).[Rubric §19, State Management & Data Flow]assesses how UI state is held and shared; the page owns transient view state in private fields (_notifications,_currentPage,_totalPages, lines 35-37) but writes the shared unread count back into the scoped NotificationState and reads its refresh signal, keeping local state local and shared state shared.[Rubric §21, Accessibility (a11y)]assesses keyboard/screen-reader support; the mark-read control is a MudBlazor icon button carrying an explicit localizedaria-labelin the markup (NotificationInbox.razor) so the icon-only action is announced.[Rubric §27, Internationalization & Localization]assesses whether user-facing text resolves per-culture from a single catalog. This page holds no literal English: an injectedIStringLocalizer<SharedResource> L(line 24) resolves the title, empty-state and mark-all labels, and every snackbar (L["Notif.AllMarkedRead"], line 162). The breadcrumb trail is deliberately built insideOnInitializedAsync(lines 43-47), not in a field initializer, so the injected localizer is available and the labels re-resolve per circuit under the active culture (the comment on lines 41-42 cites ADR-027).
- Disposal-safe async with a per-component
- Walkthrough,
PageSize(line 19) is aconst int 20; the page is fixed-size server-paginated, not infinite-scroll.- Injected members:
InboxService,NotificationState,Snackbar,L(lines 21-24) via[Inject]auto-properties (the= default!;silences nullability, DI guarantees non-null). Title(line 28) is a computed property readingL["Notif.Inbox.Title"].Value;_breadcrumbs(line 30) starts empty and is populated inOnInitializedAsync, the leaf crumbdisabled: truemarks the current page.OnInitializedAsync(line 39) builds the localized Home to Inbox trail (lines 43-47), subscribes toNotificationState.OnRefreshRequested(line 49), then callsLoadNotificationsAsync(line 51).LoadNotificationsAsync(lines 77-105): setsIsLoading, callsGetInboxAsync(_currentPage, PageSize, token)(line 82), materializesresult.Itemsinto_notifications(line 85), and computes_totalPagesfromresult.PaginationMetadata.TotalItemCountwithMath.Ceiling(line 86), clamped to a floor of 1 (lines 87-90) so the pager never shows zero pages.OnPageChangedAsync(lines 107-111): records the page and reloads.MarkReadAsync(notification)(lines 113-143): callsInboxService.MarkReadAsync(line 118), then optimistically patches local state: finds the row (FindIndex, line 121) and replaces it withnotification with { IsRead = true, ReadOn = DateTime.UtcNow }(line 124, arecord with-expression), then refetches the authoritative unread count viaGetUnreadCountAsyncand pushes it intoNotificationState.SetUnreadCount(lines 128-129) so the bell badge updates without a full reload.MarkAllReadAsync(lines 145-176): one service call (line 150), loops the local list flipping unread rows to read (lines 153-159), thenSetUnreadCount(0)(line 161) and a localized success snackbar (line 162).
- Why it's built this way: the page is a thin view over INotificationInboxUIService; all HTTP/JSON lives in the service so the component stays testable with a stub. The optimistic local-state patch (rather than re-fetching the whole page on every mark-read) keeps the UI responsive while still reconciling the shared badge count from the server, and the event-driven refresh keeps the list current when a real-time push lands. This whole notification UI ships from
MMCA.Common.UIprecisely so every consumer app gets the inbox for free, a reusable building block, the charter of this group. - Where it's used: rendered at
/notifications/inboxfor authenticated users; the route and nav entry are contributed by NotificationUIModule. The companion notification-bell component reads the same NotificationState this page writes, and the layout-mountedNotificationListenerpushes theOnRefreshRequestedsignal this page consumes. Its siblings are the admin pages NotificationList and NotificationSend.
NotificationList
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Notifications/NotificationList.razor.cs:16· Level 3 · class
- What it is: the code-behind for the admin/organizer push-notification history page, routed at
@page "/notifications"(NotificationList.razor:1). It loads previously sent broadcast notifications and renders them in a status table; a button routes onward to the compose page. - Depends on: first-party: IPushNotificationUIService (the send/history HTTP service), PushNotificationDTO (the table row shape, carrying
RecipientCount/Status/CreatedOn), NotificationRoutePaths (route constants), ErrorMessages, and SharedResource (localizer anchor). Externals:MudBlazor(ISnackbar,BreadcrumbItem,Icons),Microsoft.AspNetCore.Components(NavigationManager,[Inject]),Microsoft.Extensions.Localization. - Concept reinforced, the same code-behind page shape as NotificationInbox. Same
[Inject]services (lines 18-21), samereadonly CancellationTokenSource _cts+ dispose-pattern (lines 23, 78-95), sameIsLoadinggate (line 29), sameOperationCanceledException-swallowing load (lines 60-63), same localizedErrorMessages.LoadErrorsnackbar (line 66). It differs only in what it loads and how it renders.[Rubric §25, Navigation, Routing & Information Architecture]assesses route structure and inter-page flow; navigation here is centralized through NotificationRoutePaths constants (NavigateToSendsends toNotificationRoutePaths.NotificationSend, line 74) rather than hard-coded URL strings, so route changes happen in one place.[Rubric §27, Internationalization & Localization], beyond the localized title and breadcrumbs (lines 25, 43-47), this page adds a small status-localization helper:DisplayStatus(string status)(lines 34-38) looks upL[$"Notif.Status.{status}"]and, when the key is missing (localized.ResourceNotFound), falls back to the raw wire value (line 37). The status comparison stays on the untranslated wire string while only the displayed chip text localizes, a clean separation of transport value from presentation (the comment on line 33 cites ADR-027).
- Walkthrough,
- Injected
NotificationService,NavigationManager,Snackbar,L(lines 18-21);TitlereadsL["Notif.List.Title"].Value(line 25);_breadcrumbsbuilt Home to Push-Notifications inOnInitializedAsync(lines 43-47). _notificationsis anIReadOnlyCollection<PushNotificationDTO>initialized empty (line 31).OnInitializedAsync(line 40) sends toLoadNotificationsAsync.LoadNotificationsAsync(lines 52-72): callsGetHistoryAsync(pageNumber: 1, pageSize: 50, token)(line 57) and copiesresult.Itemsinto_notifications, defaulting to[]when the result or its items are null (line 58). This page requests a single fixed 50-row page and lets MudBlazor's client-side pager paginate that buffer locally, unlike the inbox there is no server round-trip per page.NavigateToSend(line 74):NavigationManager.NavigateTo(NotificationRoutePaths.NotificationSend), bound to the "Send New Notification" button.
- Injected
- Why it's built this way: broadcast history is low-volume admin data, so a single 50-row fetch with client-side paging is simpler and adequate (no server-side paging plumbing). Keeping HTTP behind IPushNotificationUIService mirrors the inbox page and keeps the component a thin view. See NotificationInbox for the shared
[Rubric §18, UI Architecture & Component Design]story. - Where it's used: rendered at
/notificationsfor organizer/admin roles; entry and route contributed by NotificationUIModule. Sibling of the compose page NotificationSend, to which it links.
NotificationSend
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Notifications/NotificationSend.razor.cs:16· Level 3 · class
- What it is: the code-behind for the compose-and-broadcast form, routed at
@page "/notifications/send"(NotificationSend.razor:1). It collects a title and body, validates them via aMudForm, sends a single broadcast to all recipients through the Notification API, then reports the recipient count and returns to the list. - Depends on: first-party: IPushNotificationUIService (the
SendAsyncHTTP call), SendPushNotificationRequest (the request record built from the form fields), PushNotificationDTO (the send result carryingRecipientCount), NotificationRoutePaths, ErrorMessages, and SharedResource. Externals:MudBlazor(MudForm,ISnackbar,BreadcrumbItem,Icons),Microsoft.AspNetCore.Components(NavigationManager),Microsoft.Extensions.Localization. - Concept introduced,
MudForm-driven validation with a@refhandle. This is the family's form page. The markup declares a<MudForm @ref="_form">with two required text fields carrying localizedRequiredErrorand length constraints (NotificationSend.razor). The C# holds the form by reference (MudForm? _form, line 34) and, on submit, explicitly drives validation before sending:await _form.ValidateAsync()then a guard on_form.IsValid(lines 50-55). This is the imperative half of MudBlazor's two-way validation contract, declarative rules in markup, an explicitValidateAsyncgate in code so an invalid form never reaches the API. The bound fields are plainstring _notificationTitle/_notificationBody(lines 32-33), not a model object, because the form is tiny; note they are deliberately not named_titleso they do not collide with the localizedTitlepage property (the comment on line 31 cites SonarAnalyzer S4275).[Rubric §24, Forms, Validation & UX Safety]assesses input validation, double-submit protection, and feedback. This page embodies all three: client-side required/length validation, anIsSavingflag (line 29) that disables the Send button while a send is in flight to block double submits, and a localized warning snackbarErrorMessages.ValidationError(line 53) on a failed gate plus a success snackbar naming the recipient count.[Rubric §27, Internationalization & Localization], every string here resolves throughIStringLocalizer<SharedResource> L(line 21): the compose labels, the required-error text, and the success messageL["Notif.Send.SentTo", result.RecipientCount](line 65), which passes the count as a format argument so pluralization/word-order stay in the resource file. Like its siblings the breadcrumb trail is built in an initialization hook, here the synchronousOnInitialized(lines 36-43, the comment cites ADR-027), so the injected localizer is available.
- Walkthrough,
- Injected
NotificationService,NavigationManager,Snackbar,L(lines 18-21);_breadcrumbsHome to Push-Notifications to Send (lines 38-43, the middle crumb is a real link viaNotificationRoutePaths.Notifications). SendNotificationAsync(lines 45-81): null-guards_form(lines 47-48); validates (lines 50-55, warning snackbarErrorMessages.ValidationErroron failure); underIsSaving, buildsnew SendPushNotificationRequest(_notificationTitle, _notificationBody)(line 60) and awaitsSendAsync(request, token)(line 61); on a non-null PushNotificationDTO result, raises a success snackbar interpolatingresult.RecipientCount(line 65) and navigates back to the list (line 66).- Same disposal-safe pattern as its siblings,
_ctscancelled inDispose(lines 87-98); the cancel-catch comment here additionally notes theInteractiveAutorender-mode transition (line 71), the case where the WebAssembly runtime takes over mid-call. NavigateToList(line 83): the Cancel button's handler, back toNotificationRoutePaths.Notifications.
- Injected
- Why it's built this way: a deliberately small form: no edit/unsaved-changes guard (it is create-only and one-shot), validation kept in
MudFormrather than a FluentValidation round-trip because the only rules are required + length, and HTTP kept behind IPushNotificationUIService so the component is unit-testable. The send is fire-and-confirm, the server fans out to recipients via the SignalR push pipeline (see Group 10) and returns only the aggregate count. - Where it's used: rendered at
/notifications/sendfor organizer/admin roles; reached from the "Send New Notification" button on NotificationList. The server-side validator for SendPushNotificationRequest enforces the same rules a second time.
NotificationUIModule
MMCA.Common.UI ·
MMCA.Common.UI.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Notifications/NotificationUIModule.cs:14· Level 4 · class (sealed)
- What it is: the notification feature's
IUIModuledescriptor: it contributes the inbox + admin nav items, the app-bar bell component, and the layout-level real-time listener component, plus its own assembly for component discovery. - Depends on: IUIModule (the contract it implements, Level 2), NavItem (Level 1), NavSection (Level 0), NotificationRoutePaths (Level 0), and RoleNames (Level 0); the
NotificationBellandNotificationListenerRazor components (same package);MudBlazorIconsfor the material icons;System.ReflectionforAssembly. - Concept introduced, the UI module pattern (client-side counterpart to
IModule).[Rubric §25, Navigation, Routing & Information Architecture](assesses modular, role-aware navigation composition). Just as server modules implement IModule, each UI feature implementsIUIModuleto declare, not hard-code into a central menu, its nav items, app-bar components, and layout components. The host discovers allIUIModulesingletons and assembles the shell, so adding a feature never edits the shared layout.[Rubric §18, UI Architecture & Component Design]and[Rubric §11, Security]also apply: nav items carry role gates so the admin "Push Notifications" entry only renders for organizers. - Walkthrough
NavItems(lines 16-20), twoNavItems: an inbox entry pointing atNotificationRoutePaths.NotificationInboxinNavSection.User(any authenticated user, line 18) and aRoleNames.Organizer-gated "Push Notifications" admin entry inNavSection.Admingrouped under "Notifications" (line 19).AppBarComponentTypes(line 22),[typeof(NotificationBell)], injected into the top bar.LayoutComponentTypes(line 24),[typeof(NotificationListener)], mounted once in the layout to own the SignalR callback wiring.Assembly(line 26),typeof(NotificationUIModule).Assembly, used by the host to scan this package's routable components.
- Why it's built this way: declarative contribution (collections of nav items / component
Types) keeps the shell open for extension and closed for modification; expressing role gates on the nav item keeps authorization next to the thing it protects. - Where it's used: registered as an
IUIModulesingleton by AddNotificationUI; enumerated by the host's navigation and shell composition at startup.
ServerTokenStorageService
MMCA.Common.UI.Web ·
MMCA.Common.UI.Web.Services·MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/Services/ServerTokenStorageService.cs:17· Level 4 · class (sealed)
- What it is: the Blazor Server implementation of ITokenStorageService, a cookie-only token store (no
localStorage). During SSR prerender it reads the access token from the HttpOnly session cookie; on the live interactive circuit it holds the access token in memory only and re-hydrates it from that cookie through a same-origin refresh endpoint. The HttpOnly refresh token is never readable by JavaScript. - Depends on: first-party: ITokenStorageService (the interface it implements), CookieTokenReader (reads access/refresh tokens out of the request cookies), ISessionCookieSync (writes/clears the HttpOnly session cookies), ITokenRefresher (acquires a fresh access token from the same-origin endpoint), and JwtTokenInfo (client-side expiry inspection). Its WASM counterpart is WasmTokenStorageService. Externals:
Microsoft.AspNetCore.Http(IHttpContextAccessor,HttpContext), BCLTask/TimeSpan. - Concept introduced, the two-world token store (SSR request vs. interactive circuit). A Blazor Web app runs a page twice: first as a server-side prerender inside a live HTTP request (an
HttpContextexists, JS interop does not), then as a stateful SignalR circuit with noHttpContext(JS interop is available). A single token store must serve both worlds, and this class does it by branching onhttpContextAccessor.HttpContext is not null:- SSR prerender (line 32): the request's HttpOnly cookie is the source of truth (it may have just been refreshed in place by the
UseCookieSessionRefreshmiddleware), soGetAccessTokenAsyncreturnscookieTokenReader.ReadAccessToken()(line 34) with no interop. - Interactive circuit (lines 37-52): the token lives in the
_accessTokenfield (line 25). If JwtTokenInfo.IsFreshsays it is still valid beyond a 30-second skew (ExpirySkew, line 23) it is returned directly (lines 39-41); otherwise it is re-acquired from the HttpOnly cookie via the same-origin refresh endpoint. [Rubric §26, Front-End Security]assesses protection of credentials in the browser. The design is a deliberate XSS-hardening choice: the long-lived refresh token stays in an HttpOnly cookie (unreachable from JS), the access token is held only in circuit memory and never persisted tolocalStorage, and the refresh token transits JS exactly once, for the same-origin POST that seeds the cookies at login (SetTokensAsync, lines 62-68).[Rubric §11, Security]assesses the wider auth model; this store is one edge of the dual-fetch/JWKS session design (ADR-022), the piece that decides where a token is read on each side of the prerender boundary.
- SSR prerender (line 32): the request's HttpOnly cookie is the source of truth (it may have just been refreshed in place by the
- Walkthrough,
- The primary constructor (lines 17-21) takes
IHttpContextAccessor, CookieTokenReader, ISessionCookieSync, and ITokenRefresher; it issealed, carrying no app-specific state (the XML doc notes it was hoisted out of the app hosts so both consumer apps share one copy). - Fields:
ExpirySkew(line 23, astatic readonly TimeSpanof 30 seconds), the in-memory_accessToken(line 25), and_hydrateInFlight(line 26), aTask<string?>?used for single-flight de-duplication. GetAccessTokenAsync(lines 28-53): the SSR/circuit branch described above. The refresh path uses a single-flight guard,_hydrateInFlight ??= HydrateAsync()(line 44), so that concurrent callers on one circuit (the delegating handler, the auth-state provider, the SignalR connection) share one acquisition rather than stampeding the refresh endpoint; thefinallyclears the field (line 51) so the next expiry triggers a new fetch.GetRefreshTokenAsync(lines 55-60): returns the cookie value only during SSR; on the circuit the HttpOnly refresh token is unreadable, so it returnsnull.SetTokensAsync(lines 62-68): caches the access token in memory (line 63) and callssessionCookieSync.SyncAsync(accessToken, refreshToken)(line 67) to seed the HttpOnly cookies at login.ClearTokensAsync(lines 70-74): nulls the in-memory token and callssessionCookieSync.ClearAsync()on logout.HydrateAsync(lines 76-80): the private refresh,_accessToken = await tokenRefresher.AcquireAccessTokenAsync()(line 78), caches and returns the new token.
- The primary constructor (lines 17-21) takes
- Why it's built this way: Blazor Server's split lifecycle means a naive "read a token from storage" store would either break during prerender (no JS) or leak the refresh token to JS if it used
localStorage. Branching onHttpContextpresence and keeping the refresh token cookie-only resolves both, and the single-flight hydrate keeps the refresh endpoint from being hammered by the several components that all need the bearer token at once. See ADR-022 for the session-token design. - Where it's used: registered as the scoped
ITokenStorageServicefor Blazor Web (server-interactive) hosts byAddCommonServerTokenStorage()in DependencyInjection; the app'sProgram.cscalls that instead of registering a local copy.
DependencyInjection
MMCA.Common.UI ·
MMCA.Common.UI.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Notifications/DependencyInjection.cs:11· Level 5 · class (static)
- What it is: the notification-UI registration entry point: a single
AddNotificationUI()extension onIServiceCollectionthat wires every notification UI service, the shared state, the SignalR client, and theIUIModuledescriptor. - Depends on: IPushNotificationUIService + PushNotificationService, INotificationInboxUIService + NotificationInboxService, NotificationState, NotificationHubService, and IUIModule + NotificationUIModule;
Microsoft.Extensions.DependencyInjection. - Concept: the C#
extension(IServiceCollection)registration idiom (see primer).[Rubric §33, Developer Experience & Inner Loop](assesses one-call feature wiring) and[Rubric §3, Clean Architecture](the feature's DI is co-located with the feature, not scattered into a host'sProgram.cs). Note this is one of several distinctDependencyInjectionclasses in the UI package; it is specifically the Notifications registrar, the sibling of theMMCA.Common.UI.Webhost registrar below. - Walkthrough: inside the
extension(IServiceCollection services)block (line 13),AddNotificationUI()(line 19) registers, in order:IPushNotificationUIService → PushNotificationService(scoped, line 22),INotificationInboxUIService → NotificationInboxService(scoped, line 25),NotificationState(scoped, one unread-count owner per circuit, line 28),NotificationHubService(scoped SignalR client, line 31), andIUIModule → NotificationUIModuleas a singleton (line 34, because the module descriptor is immutable shell metadata, not per-circuit state), then returnsservicesfor chaining (line 36). - Why it's built this way: the deliberate scoped-vs-singleton split matters: the HTTP services, state, and hub connection are per-Blazor-circuit, while the nav/shell descriptor is process-wide; bundling them behind one extension keeps host startup to a single
services.AddNotificationUI()call. - Where it's used: called from each consuming host's
Program.cs(Blazor Web and MAUI) that opts into the notification UI feature; complements the mainMMCA.Common.UIshared registration.
DependencyInjection
MMCA.Common.UI.Web ·
MMCA.Common.UI.Web·MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/DependencyInjection.cs:14· Level 5 · class (static)
- What it is: the registration extensions for the server-side Blazor Web host pieces this package ships, three
IServiceCollectionmethods a host calls fromProgram.csinstead of registering app-local copies of the token store, the CSP provider, and the form factor. - Depends on: first-party: ServerTokenStorageService and ITokenStorageService (the token-store registration), BlazorCspPolicyProvider and ICspPolicyProvider (the CSP registration), and WebFormFactor + IFormFactor (the form-factor registration). Externals:
Microsoft.Extensions.DependencyInjection(IServiceCollection,AddScoped,AddSingleton,AddHttpContextAccessor). - Concept: uses the C# preview
extension(IServiceCollection services)block (line 16, see primer) rather than classicthis-parameter extension methods, the package-wide DI-registration idiom. The three methods inside the block are semantically ordinary extension methods declared in the new form.[Rubric §15, Best Practices & Code Quality]assesses idiom consistency; everyMMCA.Common.*package registers services through the sameextension(IServiceCollection)shape, and this class follows it.[Rubric §26, Front-End Security]assesses browser-side hardening;AddCommonBlazorCsp()wires the dynamic Content-Security-Policy that pinsconnect-srcto the configured API/Gateway origin, and the doc comment encodes a load-bearing ordering rule (call it beforeAddCommonSecurityHeaders) so this provider wins over the default static one, which is registered withTryAdd.
- Walkthrough,
AddCommonServerTokenStorage()(lines 26-30): callsservices.AddHttpContextAccessor()(line 28, the accessor ServerTokenStorageService needs to detect the SSR-vs-circuit boundary), then registers ServerTokenStorageService as the scoped ITokenStorageService (line 29). Scoped is correct here: a Blazor circuit is a DI scope, so the in-memory access token is per-user-session state.AddCommonBlazorCsp()(lines 39-40): registers BlazorCspPolicyProvider as a singleton ICspPolicyProvider (the policy is computed once fromApiSettings, so a singleton is right). Because it usesAddSingleton(notTryAdd), it deterministically replaces the default provider, which is why the "register beforeAddCommonSecurityHeaders" ordering matters.AddCommonWebFormFactor()(lines 47-48): registers WebFormFactor as a singleton IFormFactor; it reports "Web" plus the server OS description. The XML doc notes the WASM client registersAddWasmFormFactor()fromMMCA.Common.UIinstead, so the sameIFormFactorabstraction resolves differently per host kind.
- Why it's built this way: all three pieces are host-level infrastructure that carried no app-specific state, so they were hoisted into
MMCA.Common.UI.Weband exposed as one-line registrations, keeping every consumer'sProgram.csfree of duplicated token-store, CSP, and form-factor wiring (the reusable-building-blocks charter of this group). The comment onAddCommonServerTokenStoragealso names its companions inMMCA.Common.API(AddServerAuthSessionCookie/UseCookieSessionRefresh) and the requiredITokenRefresherregistration, so the full session-cookie plumbing is discoverable from one place (ADR-022). - Where it's used: called from the
Program.csof the server-interactive Blazor Web hosts in the consumer apps (MMCA.ADC, MMCA.Store) to register the shared token store, CSP provider, and form factor.
NotificationInbox
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Notifications/NotificationInbox.razor.cs:15· Level 3 · class
- What it is: the code-behind for the per-user notification inbox, routed at
@page "/notifications/inbox"(NotificationInbox.razor:1). It fetches the signed-in user's notifications a page at a time, renders each as a read/unread card, and lets the user mark items read individually or all at once. - Depends on: first-party: INotificationInboxUIService (the typed read-side HTTP service), NotificationState (the per-circuit unread-count store), UserNotificationDTO (the row shape), PagedCollectionResult<T> (the paged envelope the inbox service returns), ErrorMessages (centralized snackbar copy), and SharedResource (the resx anchor type for the injected localizer). Externals:
MudBlazor(ISnackbar,BreadcrumbItem,MudPagination,MudCard,MudProgressLinear,MudIconButton),Microsoft.AspNetCore.Components([Inject],OnInitializedAsync),Microsoft.Extensions.Localization(IStringLocalizer<T>), BCLCancellationTokenSource/IDisposable/Math.Ceiling. - Concept introduced, the Blazor code-behind page pattern (
.razor+.razor.cspartial class). The three notification pages in this file family are authored as partial classes split across two files: the.razorholds declarative MudBlazor markup, the.razor.csholds the C# (public partial class NotificationInbox, line 15), injected services, state fields, and event handlers. The framework instantiates the component, callsOnInitializedAsync(line 37) once, and re-renders when handlers mutate fields. Three patterns recur across all three pages and are worth learning here once:- Disposal-safe async with a per-component
CancellationTokenSource. Areadonly CancellationTokenSource _cts(line 24) is created with the component and passed to every service call (_cts.Token).Dispose(bool)(lines 153-164) cancels and disposes it via the classic dispose-pattern guard (_disposedflag, line 151). Every async handler swallowsOperationCanceledExceptionsilently (e.g. lines 66-69) because that is the expected outcome when the user navigates away mid-fetch, only genuine exceptions reach the snackbar. IsLoading/IsSavingbusy flags (lines 30-31) gate the UI: the markup shows aMudProgressLinearwhile loading (NotificationInbox.razor:21-24) and disables the action buttons while saving, preventing double-submits.- Centralized, localized error copy via ErrorMessages, e.g.
ErrorMessages.LoadError(L["Entity.Notifications"], ex)(line 72), instead of inline strings. [Rubric §18, UI Architecture & Component Design]assesses component cohesion and separation of concerns; this page keeps presentation in.razorand behavior in.razor.cs, talks only to an injected abstraction (INotificationInboxUIService) rather thanHttpClientdirectly, and is single-responsibility (inbox only).[Rubric §19, State Management & Data Flow]assesses how UI state is held and shared; the page owns transient view state in private fields (_notifications,_currentPage,_totalPages, lines 33-35) but writes the shared unread count back into the scoped NotificationState so the nav-bar bell stays in sync, local state local, shared state shared.[Rubric §21, Accessibility (a11y)]assesses keyboard/screen-reader support; the mark-read control is aMudIconButtoncarrying an explicit localizedaria-label="@L["Notif.MarkRead.Aria"]"(NotificationInbox.razor:55) so the icon-only action is announced.[Rubric §27, Internationalization & Localization]assesses whether user-facing text resolves per-culture from a single catalog. This page holds no literal English: an injectedIStringLocalizer<SharedResource> L(line 22) resolves the title, empty-state and mark-all labels, and every snackbar (L["Notif.AllMarkedRead"], line 135). The breadcrumb trail is deliberately built insideOnInitializedAsync(lines 41-45), not in a field initializer, so the injected localizer is available and the labels re-resolve per circuit under the active culture (the comment on line 39-40 cites ADR-027).
- Disposal-safe async with a per-component
- Walkthrough,
PageSize(line 17) is aconst int 20; the page is fixed-size server-paginated, not infinite-scroll.- Injected members:
InboxService,NotificationState,Snackbar,L(lines 19-22) via[Inject]auto-properties (the= default!;silences nullability, DI guarantees non-null). Title(line 26) is a computed property readingL["Notif.Inbox.Title"].Value;_breadcrumbs(line 28) starts empty and is populated inOnInitializedAsync, the leaf crumbdisabled: truemarks the current page.OnInitializedAsync(line 37) builds the localized Home to Inbox trail (lines 41-45), then callsLoadNotificationsAsync(line 47).LoadNotificationsAsync(lines 50-78): setsIsLoading, callsGetInboxAsync(_currentPage, PageSize, token)(line 55), materializesresult.Itemsinto_notifications(line 58), and computes_totalPagesfromresult.PaginationMetadata.TotalItemCountwithMath.Ceiling(line 59), clamped to a floor of 1 (lines 60-63) so the pager never shows zero pages.OnPageChangedAsync(lines 80-84): bound toMudPagination.SelectedChanged(NotificationInbox.razor:75); records the page and reloads.MarkReadAsync(notification)(lines 86-116): callsInboxService.MarkReadAsync(line 91), then optimistically patches local state, finds the row (FindIndex, line 94) and replaces it withnotification with { IsRead = true, ReadOn = DateTime.UtcNow }(line 97, arecord with-expression), then refetches the authoritative unread count viaGetUnreadCountAsyncand pushes it intoNotificationState.SetUnreadCount(lines 101-102) so the bell badge updates without a full reload.MarkAllReadAsync(lines 118-149): one service call (line 123), loops the local list flipping unread rows to read (lines 126-132), thenSetUnreadCount(0)(line 134) and a localized success snackbar (line 135).
- Why it's built this way: the page is a thin view over INotificationInboxUIService; all HTTP/JSON lives in the service so the component stays testable with a stub. The optimistic local-state patch (rather than re-fetching the whole page on every mark-read) keeps the UI responsive while still reconciling the shared badge count from the server. This whole notification UI ships from
MMCA.Common.UIprecisely so every consumer app gets the inbox for free, a reusable building block, the charter of this group. - Where it's used: rendered at
/notifications/inboxfor authenticated users; the route and nav entry are contributed by the notification UI module. The companion notification-bell component reads the same NotificationState this page writes. Its siblings are the admin pages NotificationList and NotificationSend.
NotificationList
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Notifications/NotificationList.razor.cs:16· Level 3 · class
- What it is: the code-behind for the admin/organizer push-notification history page, routed at
@page "/notifications"(NotificationList.razor:1). It loads previously sent broadcast notifications and renders them in a status table; a button routes onward to the compose page. - Depends on: first-party: IPushNotificationUIService (the send/history HTTP service), PushNotificationDTO (the table row shape, carrying
RecipientCount/Status/CreatedOn), NotificationRoutePaths (route constants), ErrorMessages, and SharedResource (localizer anchor). Externals:MudBlazor(MudTable,MudTablePager,MudChip,ISnackbar,BreadcrumbItem),Microsoft.AspNetCore.Components(NavigationManager,[Inject]),Microsoft.Extensions.Localization. - Concept reinforced, the same code-behind page shape as NotificationInbox. Same
[Inject]services (lines 18-21), samereadonly CancellationTokenSource _cts+ dispose-pattern (lines 23, 78-95), sameIsLoadinggate (line 29), sameOperationCanceledException-swallowing load (lines 60-63), same localizedErrorMessages.LoadErrorsnackbar (line 66). It differs only in what it loads and how it renders.[Rubric §25, Navigation, Routing & Information Architecture]assesses route structure and inter-page flow; navigation here is centralized through NotificationRoutePaths constants (NavigateToSendsends toNotificationRoutePaths.NotificationSend, line 74) rather than hard-coded URL strings, so route changes happen in one place.[Rubric §27, Internationalization & Localization], beyond the localized title and breadcrumbs (lines 25, 43-47), this page adds a small status-localization helper:DisplayStatus(string status)(lines 34-38) looks upL[$"Notif.Status.{status}"]and, when the key is missing (localized.ResourceNotFound), falls back to the raw wire value. The status comparison stays on the untranslated wire string (context.Status == "Sent"in the markup) while only the displayed chip text localizes, a clean separation of transport value from presentation (ADR-027).
- Walkthrough,
- Injected
NotificationService,NavigationManager,Snackbar,L(lines 18-21);TitlereadsL["Notif.List.Title"].Value(line 25);_breadcrumbsbuilt Home to Push-Notifications inOnInitializedAsync(lines 43-47). _notificationsis anIReadOnlyCollection<PushNotificationDTO>initialized empty (line 31).OnInitializedAsync(line 40) sends toLoadNotificationsAsync.LoadNotificationsAsync(lines 52-72): callsGetHistoryAsync(pageNumber: 1, pageSize: 50, token)(line 57) and copiesresult.Itemsinto_notifications, defaulting to[]when the result or its items are null (line 58). This page requests a single fixed 50-row page and lets MudBlazor's client-sideMudTablePager(NotificationList.razor:61) paginate that buffer locally, unlike the inbox, there is no server round-trip per page.NavigateToSend(line 74):NavigationManager.NavigateTo(NotificationRoutePaths.NotificationSend), bound to the "Send New Notification" button.- The markup colors the
Statuscell with aMudChip, greenSent, redFailed, amber otherwise (NotificationList.razor:45-56), each renderingDisplayStatus(context.Status)as its label.
- Injected
- Why it's built this way: broadcast history is low-volume admin data, so a single 50-row fetch with client-side paging is simpler and adequate (no server-side paging plumbing). Keeping HTTP behind IPushNotificationUIService mirrors the inbox page and keeps the component a thin view. See NotificationInbox for the shared
[Rubric §18, UI Architecture & Component Design]story. - Where it's used: rendered at
/notificationsfor organizer/admin roles; entry and route contributed by the notification UI module. Sibling of the compose page NotificationSend, to which it links.
NotificationSend
MMCA.Common.UI ·
MMCA.Common.UI.Pages.Notifications·MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Notifications/NotificationSend.razor.cs:16· Level 3 · class
- What it is: the code-behind for the compose-and-broadcast form, routed at
@page "/notifications/send"(NotificationSend.razor:1). It collects a title and body, validates them via aMudForm, sends a single broadcast to all recipients through the Notification API, then reports the recipient count and returns to the list. - Depends on: first-party: IPushNotificationUIService (the
SendAsyncHTTP call), SendPushNotificationRequest (the request record built from the form fields), PushNotificationDTO (the send result carryingRecipientCount), NotificationRoutePaths, ErrorMessages, and SharedResource. Externals:MudBlazor(MudForm,MudTextField,ISnackbar,BreadcrumbItem),Microsoft.AspNetCore.Components(NavigationManager),Microsoft.Extensions.Localization. - Concept introduced,
MudForm-driven validation with a@refhandle. This is the family's form page. The markup declares<MudForm @ref="_form">(NotificationSend.razor:19) with twoMudTextFields carryingRequired="true"+ localizedRequiredErrorandMaxLength/Counterconstraints (NotificationSend.razor:20-39). The C# holds the form by reference (MudForm? _form, line 34) and, on submit, explicitly drives validation before sending:await _form.ValidateAsync()then a guard on_form.IsValid(lines 50-55). This is the imperative half of MudBlazor's two-way validation contract, declarative rules in markup, an explicitValidateAsyncgate in code so an invalid form never reaches the API. The bound fields are plainstring _notificationTitle/_notificationBody(lines 32-33), not a model object, because the form is tiny; note they are deliberately not named_titleso they do not collide with the localizedTitlepage property (the comment on line 31 cites SonarAnalyzer S4275).[Rubric §24, Forms, Validation & UX Safety]assesses input validation, double-submit protection, and feedback. This page embodies all three: client-side required/length validation withImmediate="true"live feedback (NotificationSend.razor:26,37), anIsSavingflag (line 29) that disables the Send button and flips its label to the localized "Sending..." (NotificationSend.razor:47-48) to block double submits, and a localized warning snackbarErrorMessages.ValidationError(line 53) on a failed gate plus a success snackbar naming the recipient count.[Rubric §27, Internationalization & Localization], every string here resolves throughIStringLocalizer<SharedResource> L(line 21): the compose labels, the required-error text, and the success messageL["Notif.Send.SentTo", result.RecipientCount](line 65), which passes the count as a format argument so pluralization/word-order stay in the resource file (ADR-027). Like its siblings the breadcrumb trail is built in an initialization hook, here the synchronousOnInitialized(lines 36-43), so the injected localizer is available.
- Walkthrough,
- Injected
NotificationService,NavigationManager,Snackbar,L(lines 18-21);_breadcrumbsHome to Push-Notifications to Send (lines 38-43, the middle crumb is a real link viaNotificationRoutePaths.Notifications). SendNotificationAsync(lines 45-81): null-guards_form(lines 47-48); validates (lines 50-55, warning snackbarErrorMessages.ValidationErroron failure); underIsSaving, buildsnew SendPushNotificationRequest(_notificationTitle, _notificationBody)(line 60) and awaitsSendAsync(request, token)(line 61); on a non-null PushNotificationDTO result, raises a success snackbar interpolatingresult.RecipientCount(line 65) and navigates back to the list (line 66).- Same disposal-safe pattern as its siblings,
_ctscancelled inDispose(lines 87-98); the cancel-catch comment here additionally notes theInteractiveAutorender-mode transition (line 71), the case where the WebAssembly runtime takes over mid-call. NavigateToList(line 83): the Cancel button's handler, back toNotificationRoutePaths.Notifications.
- Injected
- Why it's built this way: a deliberately small form: no edit/unsaved-changes guard (it is create-only and one-shot), validation kept in
MudFormrather than a FluentValidation round-trip because the only rules are required + length, and HTTP kept behind IPushNotificationUIService so the component is unit-testable. The send is fire-and-confirm, the server fans out to recipients via the SignalR push pipeline (see Group 10) and returns only the aggregate count. - Where it's used: rendered at
/notifications/sendfor organizer/admin roles; reached from the "Send New Notification" button on NotificationList. The server-side validator for SendPushNotificationRequest (notifications group) enforces the same rules a second time.
ServerTokenStorageService
MMCA.Common.UI.Web ·
MMCA.Common.UI.Web.Services·MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/Services/ServerTokenStorageService.cs:17· Level 4 · class
- What it is: the Blazor Server implementation of ITokenStorageService, a cookie-only token store (no
localStorage). During SSR prerender it reads the access token from the HttpOnly session cookie; on the live interactive circuit it holds the access token in memory only and re-hydrates it from that cookie through a same-origin refresh endpoint. The HttpOnly refresh token is never readable by JavaScript. - Depends on: first-party: ITokenStorageService (the interface it implements), CookieTokenReader (reads access/refresh tokens out of the request cookies), ISessionCookieSync (writes/clears the HttpOnly session cookies), ITokenRefresher (acquires a fresh access token from the same-origin endpoint), and JwtTokenInfo (client-side expiry inspection). Its WASM counterpart is WasmTokenStorageService. Externals:
Microsoft.AspNetCore.Http(IHttpContextAccessor,HttpContext), BCLTask/TimeSpan. - Concept introduced, the two-world token store (SSR request vs. interactive circuit). A Blazor Web app runs a page twice: first as a server-side prerender inside a live HTTP request (an
HttpContextexists, JS interop does not), then as a stateful SignalR circuit with noHttpContext(JS interop is available). A single token store must serve both worlds, and this class does it by branching onhttpContextAccessor.HttpContext is not null:- SSR prerender (line 32): the request's HttpOnly cookie is the source of truth (it may have just been refreshed in place by the
UseCookieSessionRefreshmiddleware), soGetAccessTokenAsyncreturnscookieTokenReader.ReadAccessToken()(line 34) with no interop. - Interactive circuit (lines 37-52): the token lives in the
_accessTokenfield (line 25). If JwtTokenInfo.IsFresh says it is still valid beyond a 30-second skew (ExpirySkew, line 23) it is returned directly (lines 38-41); otherwise it is re-acquired from the HttpOnly cookie via the same-origin refresh endpoint. [Rubric §26, Front-End Security]assesses protection of credentials in the browser. The design is a deliberate XSS-hardening choice: the long-lived refresh token stays in an HttpOnly cookie (unreachable from JS), the access token is held only in circuit memory and never persisted tolocalStorage, and the refresh token transits JS exactly once, for the same-origin POST that seeds the cookies at login (SetTokensAsync, lines 62-68).[Rubric §11, Security]assesses the wider auth model; this store is one edge of the dual-fetch/JWKS session design (ADR-022), the piece that decides where a token is read on each side of the prerender boundary.
- SSR prerender (line 32): the request's HttpOnly cookie is the source of truth (it may have just been refreshed in place by the
- Walkthrough,
- The primary constructor (lines 17-21) takes
IHttpContextAccessor, CookieTokenReader, ISessionCookieSync, and ITokenRefresher; it issealed, carrying no app-specific state (the XML doc notes it was hoisted out of the app hosts so both consumer apps share one copy). - Fields:
ExpirySkew(line 23, astatic readonly TimeSpanof 30 seconds), the in-memory_accessToken(line 25), and_hydrateInFlight(line 26), aTask<string?>?used for single-flight de-duplication. GetAccessTokenAsync(lines 28-53): the SSR/circuit branch described above. The refresh path uses a single-flight guard,_hydrateInFlight ??= HydrateAsync()(line 44), so that concurrent callers on one circuit (the delegating handler, the auth-state provider, the SignalR connection) share one acquisition rather than stampeding the refresh endpoint; thefinallyclears the field (line 51) so the next expiry triggers a new fetch.GetRefreshTokenAsync(lines 55-60): returns the cookie value only during SSR; on the circuit the HttpOnly refresh token is unreadable, so it returnsnull.SetTokensAsync(lines 62-68): caches the access token in memory (line 63) and callssessionCookieSync.SyncAsync(accessToken, refreshToken)(line 67) to seed the HttpOnly cookies at login.ClearTokensAsync(lines 70-74): nulls the in-memory token and callssessionCookieSync.ClearAsync()on logout.HydrateAsync(lines 76-80): the private refresh,_accessToken = await tokenRefresher.AcquireAccessTokenAsync()(line 78), caches and returns the new token.
- The primary constructor (lines 17-21) takes
- Why it's built this way: Blazor Server's split lifecycle means a naive "read a token from storage" store would either break during prerender (no JS) or leak the refresh token to JS if it used
localStorage. Branching onHttpContextpresence and keeping the refresh token cookie-only resolves both, and the single-flight hydrate keeps the refresh endpoint from being hammered by the several components that all need the bearer token at once. See ADR-022 for the session-token design. - Where it's used: registered as the scoped
ITokenStorageServicefor Blazor Web (server-interactive) hosts byAddCommonServerTokenStorage()in DependencyInjection; the app'sProgram.cscalls that instead of registering a local copy.
DependencyInjection
MMCA.Common.UI.Web ·
MMCA.Common.UI.Web·MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/DependencyInjection.cs:13· Level 5 · class
- What it is: the registration extensions for the server-side Blazor Web host pieces this package ships, two
IServiceCollectionmethods that a host calls fromProgram.csinstead of registering app-local copies of the token store and CSP provider. - Depends on: first-party: ServerTokenStorageService and ITokenStorageService (the token-store registration), BlazorCspPolicyProvider and ICspPolicyProvider (the CSP registration). Externals:
Microsoft.Extensions.DependencyInjection(IServiceCollection,AddScoped,AddSingleton,AddHttpContextAccessor). - Concept: uses the C# preview
extension(IServiceCollection services)block (line 15, see primer §4) rather than classicthis-parameter extension methods, the package-wide DI-registration idiom. The two methods inside the block are semantically ordinary extension methods declared in the new form.[Rubric §15, Best Practices & Code Quality]assesses idiom consistency; everyMMCA.Common.*package registers services through the sameextension(IServiceCollection)shape, and this class follows it.[Rubric §26, Front-End Security]assesses browser-side hardening;AddCommonBlazorCsp()wires the dynamic Content-Security-Policy that pinsconnect-srcto the configured API/Gateway origin, and the doc comment encodes a load-bearing ordering rule (call it beforeAddCommonSecurityHeaders) so this provider wins over the default static one, which is registered withTryAdd.
- Walkthrough,
AddCommonServerTokenStorage()(lines 25-29): callsservices.AddHttpContextAccessor()(line 27, the accessor ServerTokenStorageService needs to detect the SSR-vs-circuit boundary), then registers ServerTokenStorageService as the scoped ITokenStorageService (line 28). Scoped is correct here: a Blazor circuit is a DI scope, so the in-memory access token is per-user-session state.AddCommonBlazorCsp()(lines 38-39): registers BlazorCspPolicyProvider as a singleton ICspPolicyProvider (the policy is computed once fromApiSettings, so a singleton is right). Because it usesAddSingleton(notTryAdd), it deterministically replaces the default provider, which is why the "register beforeAddCommonSecurityHeaders" ordering matters.
- Why it's built this way: both pieces are host-level infrastructure that carried no app-specific state, so they were hoisted into
MMCA.Common.UI.Weband exposed as one-line registrations, keeping every consumer'sProgram.csfree of duplicated token-store and CSP wiring (the reusable-building-blocks charter of this group). The comment onAddCommonServerTokenStoragealso names its companions inMMCA.Common.API(AddServerAuthSessionCookie/UseCookieSessionRefresh) and the requiredITokenRefresherregistration, so the full session-cookie plumbing is discoverable from one place (ADR-022). - Where it's used: called from the
Program.csof the server-interactive Blazor Web hosts in the consumer apps (MMCA.ADC, MMCA.Store) to register the shared token store and CSP provider.
⬅ Module System, Composition & Configuration • Index • Aspire Orchestration & Service Defaults ➡