Skip to content

Client and layout

Coworkee.Client.Blazor is a complete WebAssembly shell: layout, navigation, user menu, notifications, admin pages for users, groups, roles, settings, services, themes, mail templates and audit log. The app adds its pages and menu entries.

MyApp.Web.Client/Program.cs
var builder = WebAssemblyHostBuilder.CreateDefault(args);
var baseAddress = new Uri(builder.HostEnvironment.BaseAddress);
builder.Services.AddCoworkeeClient(baseAddress, options =>
{
    options.AppTitle = "MyApp";
    options.AppDescription = "Clean Architecture starter for Blazor, built on Coworkee modules.";
    options.AboutLinks.Add(new AboutLink("Documentation", "https://fgilde.github.io/CoworkeeLib/"));
});
builder.Services.AddHttpClient<ICatalogApi, CatalogApi>(client => client.BaseAddress = baseAddress);
builder.Services.AddSingleton<INavigationContributor, MyAppNavigation>();
builder.Services.Configure<NavigationMenuOptions>(MyAppNavigation.Order);
var host = builder.Build();
await host.Services.InitializeCoworkeeClientAsync();
await host.RunAsync();
Routes.razor
<Router AppAssembly="typeof(Program).Assembly" AdditionalAssemblies="new[] { typeof(CoworkeeClientOptions).Assembly }">
    <Found Context="routeData">
        <AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(CoworkeeLayout)" />
    </Found>
</Router>

Dashboard of the template

internal sealed class MyAppNavigation : INavigationContributor
{
    private const string CatalogManagement = "Catalog Management";

    public static void Order(NavigationMenuOptions menu) => menu
        .OrderGroup("Personal", 0)
        .OrderGroup(NavigationGroups.Administration, 2)
        .OrderGroup(CatalogManagement, 3);

    public IEnumerable<CoworkeeNavItem> Items =>
    [
        new("Dashboard", "/dashboard", Icons.Material.Outlined.Dashboard, CatalogPermissions.Dashboards.View, Group: "Personal"),
        new("Products", "/catalog/products", Icons.Material.Outlined.ViewCarousel, CatalogPermissions.Products.View, Group: CatalogManagement),
        new("Brands", "/catalog/brands", Icons.Material.Outlined.Sell, CatalogPermissions.Brands.View, Group: CatalogManagement, Order: 1),
    ];
}
NavigationMenuOptions
OrderGroup(name, order) position of a group; unknown groups come last, by name
Hide(hrefs) removes entries, also the built-in admin pages
ShowHome, HomeTitle the home link at the top
IconForGroup(name, icon) the icon of a group node
Place(href, group, title?, order?) moves a link of the library into another group; a group path like Administration/Localization nests

The admin pages of the library sit in groups below Administration: NavigationGroups.Identity (users, groups, roles, applications, scopes), NavigationGroups.System (settings, mail settings, services, tenants, editions, themes, backups), NavigationGroups.Communication (mail templates, mail log), NavigationGroups.Localization (languages, translations) and NavigationGroups.Monitoring (audit log, AI tool calls). Nested groups sort within their top level group, which keeps the place OrderGroup gives it.

The menu is a MudExTreeView: groups are nodes, the filter box highlights matches and opens their groups, and the view can switch between tree, list and flat list. Pinned, the drawer stays as an icon strip under the app bar and the menu button widens it; unpinned, it opens as an overlay and hides again. Whether one group or several stay open is a second toggle. Both choices are stored in the browser.

Entries with HostOnly: true serve the whole installation and show only to users of the system organisation; the library marks Tenants, Editions, Applications, Scopes, Services and Backups that way, and the settings page shows its typed tabs and the System tab only there. The client knows the organisation from /bff/user (SystemTenant, from the system_tenant claim of the auth server); sign-ins from before that claim show everything as before.

For pure UI cases an entry can decide itself: Visible: sp => sp.GetRequiredService<MyTips>().HasAnyAsync() hides it when the callback answers false. The menu and the command palette ask it whenever they build (after sign-in, permission and feature changes), so keep it cheap and cache what it loads. It only hides; when the server must agree, use a computed permission instead.

Collapsed navigation

App bar

The app bar shows the search button of the command palette (Ctrl+K), the theme menu, right to left switch, dark mode, notifications, user menu and the about dialog; on phones the secondary entries share one menu, see Phones, installable app and push. Add your own components:

internal sealed class LanguageBar : IAppBarContributor
{
    public IEnumerable<AppBarItem> Items => [new AppBarItem(typeof(LanguageSwitch), Order: 10)];
}

About dialog

Calling your API

Typed clients derive from ApiClientBase. It sends the CSRF header the BFF requires and turns problem details into ApiException:

internal sealed class CatalogApi(HttpClient http) : ApiClientBase(http), ICatalogApi
{
    public Task<DashboardDto> GetDashboardAsync(CancellationToken cancellationToken = default) => GetAsync<DashboardDto>("api/v1/dashboard", cancellationToken);

    public Task DeleteBrandsAsync(IReadOnlyList<Guid> ids, CancellationToken cancellationToken = default) =>
        SendAsync(HttpMethod.Post, "api/v1/brands/delete", new IdsRequest(ids), cancellationToken);
}

Snackbar.RunAsync(() => api.SaveAsync(...), "Saved") runs a call and shows success or the validation messages of a failure.

Current user

ClientUser.From(user) reads the signed-in user from a ClaimsPrincipal (IsAuthenticated, UserId, TenantId, Email, DisplayName); with the cascading authentication state that is all a page needs. Services and pages without it inject the scoped CurrentUserInfo (registered by AddCoworkeeClient):

var me = await currentUser.GetAsync();
if (me.UserId is { } userId) { ... }
currentUser.Changed += user => ...; // sign-in, sign-out, permission reissue

Do not parse the sub claim yourself.

People

UserAvatar shows a user's picture or initials, UserCard picture and name with an optional second line. Both load in batches and update for everyone the moment someone changes name or picture on the profile page (tenant:users realtime topic).

Profile page

<UserCard UserId="entry.ActorId">@entry.CreatedAt.ToString("g")</UserCard>
<UserAvatar UserId="message.FromUserId" Size="Size.Small" ShowTooltip="true" />

Account page

/profile holds the tabs Profile (picture, name, phone, address), Security, Notifications and Settings; /profile/{key} opens one directly. Register a ProfileTab to add your own:

builder.Services.AddSingleton(new ProfileTab("Documents", "documents", typeof(MyDocuments), DocumentPermissions.Documents.View));

Building blocks

Component Use
PageHeader title, description, actions on the right; sets the browser title. Pass text keys (Title="Brands"), it translates them itself; so does CreateText of CoworkeeDataTable
StickyMergeTabs wraps a <MudTabs>: its tab bar sticks below the app bar and shrinks to its tabs, so a sticky MudExObjectEditForm toolbar in the panel shows next to it; not on narrow screens
PermissionGate, PermissionView render content only with a permission
RealtimeSubscription calls back when the server publishes on a topic, e.g. type:Brand
EntityWatch keeps a detail page in step with its entry, goes back when it is deleted
NotAllowed what the router shows instead of a forbidden page, with a switch to a role that grants it
ActiveRoleMenu, ChatNotifier in the user menu: the active role and chat snackbars
CoworkeeDataTable<T> data tables
ShowEditAsync edit dialog generated from a model
AuditTimeline, VersionHistory change history of an entity
ResourcePermissionsPanel grants on a single resource