Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
dbe7ed0
feat: apply AppFoundation Screens design to Identity UI
andregoepel Jul 6, 2026
82d16eb
feat: match auth pages to the AppFoundation login design
andregoepel Jul 6, 2026
c8cbaaf
fix(sample): render account pages interactively so login works
andregoepel Jul 6, 2026
1608b40
refactor: move feature-flag types to .Features namespace; flow Cancel…
andregoepel Jul 6, 2026
1d7c8c9
fix(sample): update Program.cs using for moved feature-gate extension
andregoepel Jul 6, 2026
79d0a5f
feat: static labels on account & admin forms to match the design
andregoepel Jul 6, 2026
19be432
fix(ui): correct account & admin card layouts to match the design
andregoepel Jul 6, 2026
de3e679
feat(ui): confirmed badge, password grid, role status badge
andregoepel Jul 6, 2026
bcf48c6
fix(ui): dark-mode button text, label sizing, toggle, 2FA, passkeys, …
andregoepel Jul 6, 2026
6527d0e
fix(ui): disabled fields, danger button, grid hover/width, row overfl…
andregoepel Jul 6, 2026
84f2bab
docs: add AppFoundation design guidelines
andregoepel Jul 6, 2026
494549a
fix(ui): alert icon/text, muted descriptions, sidebar labels
andregoepel Jul 6, 2026
cae3bbf
feat(ui): app-name brand, route breadcrumb, styled row overflow menu
andregoepel Jul 6, 2026
5d56195
fix(ui): remove white corners on context-menu item hover (dark mode)
andregoepel Jul 6, 2026
1cf0ea2
fix(ui): grid cell padding and badge pill rendering
andregoepel Jul 6, 2026
2e80119
feat(ui): grid filter toolbar + User Cleanup layout polish
andregoepel Jul 6, 2026
fe7594e
feat(ui): responsive shell — off-canvas sidebar + mobile breakpoints
andregoepel Jul 6, 2026
db8af99
style: apply csharpier formatting to RoleStore
andregoepel Jul 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions samples/MartenIdentity.Aspire.Web/Components/App.razor
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,30 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<link rel="icon" type="image/png" href="favicon.png" />
<!-- AppFoundation fonts (Manrope / Space Grotesk / JetBrains Mono). -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" />
<!-- Radzen Material theme (base variant works with the components used by the RCL). -->
<link rel="stylesheet" href="_content/Radzen.Blazor/css/material-base.css" />
<!-- AppFoundation token layer: remaps Radzen --rz-* variables onto the emerald palette. Must load after material-base. -->
<link rel="stylesheet" href="_content/AndreGoepel.Marten.Identity.Blazor/css/appfoundation.css" />
<link rel="stylesheet" href="app.css" />
<HeadOutlet />
<!-- Resolve + apply the persisted theme before first paint to avoid a flash. -->
<script src="_content/AndreGoepel.Marten.Identity.Blazor/js/appfoundation-theme.js"></script>
<!-- Off-canvas sidebar toggle for compact/mobile widths. -->
<script src="_content/AndreGoepel.Marten.Identity.Blazor/js/appfoundation-nav.js"></script>
<HeadOutlet @rendermode="InteractiveServer" />
</head>

<body>
<Routes />
@* Global interactive server render mode. The RCL's Login/LoginForm pages don't
self-declare a render mode but require interactivity (the SignInHandoff cookie
handoff runs in OnAfterRenderAsync, and Radzen inputs like "Remember me" only
bind interactively). Pages in the RCL that redeclare @rendermode InteractiveServer
coexist fine with this global mode. *@
<Routes @rendermode="InteractiveServer" />

<div id="blazor-error-ui">
An unhandled error has occurred.
Expand Down
197 changes: 159 additions & 38 deletions samples/MartenIdentity.Aspire.Web/Components/Layout/MainLayout.razor
Original file line number Diff line number Diff line change
@@ -1,49 +1,170 @@
@inherits LayoutComponentBase

@inject IConfiguration Configuration
@inject NavigationManager NavigationManager

@* Radzen overlay hosts. They live in an interactive island so DialogService /
NotificationService calls from the (also interactive) admin & manage pages work. *@
<RadzenDialog @rendermode="InteractiveServer" />
<RadzenNotification @rendermode="InteractiveServer" />
<RadzenTooltip @rendermode="InteractiveServer" />
<RadzenContextMenu @rendermode="InteractiveServer" />

<RadzenLayout>
<RadzenHeader>
<RadzenStack Orientation="Orientation.Horizontal"
AlignItems="AlignItems.Center"
Gap="1rem"
Class="rz-px-4 rz-py-2">
<RadzenLink Path="/" Text="Marten Identity Sample" Class="rz-color-white" />
<div style="flex: 1"></div>
<AuthorizeView>
<Authorized>
<RadzenLink Path="Account/Manage/Profile" Text="My account" Class="rz-color-white" />
<AuthorizeView Roles="@Roles.Administrator" Context="admin">
<RadzenLink Path="Administration/Users" Text="Users" Class="rz-color-white" />
<RadzenLink Path="Administration/Roles" Text="Roles" Class="rz-color-white" />
</AuthorizeView>
<RadzenText TextStyle="TextStyle.Body2" Class="rz-color-white rz-m-0">
@context.User.Identity?.Name
</RadzenText>
<form action="Account/Logout" method="post">
<AntiforgeryToken />
<input type="hidden" name="returnUrl" value="/" />
<RadzenButton ButtonType="ButtonType.Submit"
Text="Log out"
Size="ButtonSize.Small"
ButtonStyle="ButtonStyle.Light" />
</form>
</Authorized>
<NotAuthorized>
<RadzenLink Path="Account/Login" Text="Log in" Class="rz-color-white" />
<RadzenLink Path="Account/Register" Text="Register" Class="rz-color-white" />
</NotAuthorized>
</AuthorizeView>
</RadzenStack>
</RadzenHeader>
<RadzenBody>
<div class="rz-p-4">
<div class="af-shell" data-nav-open="false">
<div class="af-backdrop" onclick="afNav.close()"></div>
<nav class="af-sidebar">
<div class="af-brand">
<div class="af-brand-mark">@AppInitial</div>
<span class="af-brand-name">@AppName</span>
</div>

<NavLink class="af-nav-item af-home" href="" Match="NavLinkMatch.All">⌂&nbsp;&nbsp;Home</NavLink>

<AuthorizeView>
<Authorized>
<div class="af-nav-section">ACCOUNT</div>
<div class="af-nav-group">
<NavLink class="af-nav-item" href="Account/Manage/Profile">Profile</NavLink>
<NavLink class="af-nav-item" href="Account/Manage/Email">Email</NavLink>
<NavLink class="af-nav-item" href="Account/Manage/ChangePassword">Password</NavLink>
<NavLink class="af-nav-item" href="Account/Manage/TwoFactorAuthentication">Two-factor authentication</NavLink>
<NavLink class="af-nav-item" href="Account/Manage/Passkeys">Passkeys</NavLink>
<NavLink class="af-nav-item" href="Account/Manage/PersonalData">Personal data</NavLink>
</div>

<AuthorizeView Roles="@Roles.Administrator" Context="admin">
<div class="af-nav-section">ADMINISTRATION</div>
<div class="af-nav-group">
<NavLink class="af-nav-item" href="Administration/Users">Users</NavLink>
<NavLink class="af-nav-item" href="Administration/Roles">Roles</NavLink>
<NavLink class="af-nav-item" href="Administration/UserCleanup">User cleanup</NavLink>
</div>
</AuthorizeView>
</Authorized>
</AuthorizeView>

<div class="af-sidebar-footer">
<span class="af-sidebar-note">© 2026 @AppName</span>
</div>
</nav>

<div class="af-main">
<div class="af-topbar">
<div class="af-topbar-left">
<button class="af-hamburger" type="button" onclick="afNav.toggle()" aria-label="Toggle navigation">☰</button>
<span class="af-topbar-title">@Breadcrumb()</span>
</div>
<div class="af-topbar-actions">
<ThemeToggle />
<AuthorizeView>
<Authorized>
<div class="af-user-chip">
<span class="af-avatar">@Initials(context.User.Identity?.Name)</span>
<span class="af-user-email">@context.User.Identity?.Name</span>
</div>
<form action="Account/Logout" method="post">
<AntiforgeryToken />
<input type="hidden" name="returnUrl" value="/" />
<RadzenButton ButtonType="ButtonType.Submit"
Text="Log out"
Size="ButtonSize.Small"
ButtonStyle="ButtonStyle.Light" />
</form>
</Authorized>
<NotAuthorized>
<RadzenLink Path="Account/Login" Text="Log in" />
<RadzenLink Path="Account/Register" Text="Register" />
</NotAuthorized>
</AuthorizeView>
</div>
</div>

<div class="af-content">
@Body
</div>
</RadzenBody>
</RadzenLayout>
</div>
</div>

@code {
// Configured application name (falls back to a sensible default).
private string AppName => Configuration["AppName"] ?? "Marten Identity";

private string AppInitial => string.IsNullOrEmpty(AppName) ? "?" : AppName[..1].ToUpperInvariant();

// Section / page breadcrumb shown in the topbar, derived from the route.
private static readonly Dictionary<string, string> Breadcrumbs = new(StringComparer.OrdinalIgnoreCase)
{
[""] = "Home",
["account/manage/profile"] = "Account / Profile",
["account/manage/email"] = "Account / Email",
["account/manage/changepassword"] = "Account / Password",
["account/manage/setpassword"] = "Account / Password",
["account/manage/twofactorauthentication"] = "Account / Two-factor authentication",
["account/manage/enableauthenticator"] = "Account / Two-factor authentication",
["account/manage/generaterecoverycodes"] = "Account / Two-factor authentication",
["account/manage/disable2fa"] = "Account / Two-factor authentication",
["account/manage/passkeys"] = "Account / Passkeys",
["account/manage/personaldata"] = "Account / Personal data",
["account/manage/deletepersonaldata"] = "Account / Personal data",
["administration/users"] = "Administration / Users",
["administration/roles"] = "Administration / Roles",
["administration/usercleanup"] = "Administration / User cleanup",
};

private string Breadcrumb()
{
var path = NavigationManager.ToBaseRelativePath(NavigationManager.Uri);
var query = path.IndexOf('?');
if (query >= 0)
{
path = path[..query];
}
path = path.TrimEnd('/');

if (Breadcrumbs.TryGetValue(path, out var crumb))
{
return crumb;
}

var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length == 0)
{
return "Home";
}

var page = Humanize(segments[^1]);
return segments[0].ToLowerInvariant() switch
{
"account" => $"Account / {page}",
"administration" => $"Administration / {page}",
_ => page,
};
}

// "PersonalData" -> "Personal data" for routes not in the lookup.
private static string Humanize(string segment)
{
var spaced = System.Text.RegularExpressions.Regex.Replace(segment, "(?<=[a-z0-9])(?=[A-Z])", " ");
return spaced.Length == 0 ? spaced : char.ToUpperInvariant(spaced[0]) + spaced[1..].ToLowerInvariant();
}

// Two-letter avatar initials derived from the account name / email local part.
private static string Initials(string? name)
{
if (string.IsNullOrWhiteSpace(name))
{
return "?";
}

var at = name.IndexOf('@');
var local = at > 0 ? name[..at] : name;
var parts = local.Split(['.', '-', '_', ' '], StringSplitOptions.RemoveEmptyEntries);

return parts.Length switch
{
0 => "?",
1 => parts[0][..Math.Min(2, parts[0].Length)].ToUpperInvariant(),
_ => $"{parts[0][0]}{parts[1][0]}".ToUpperInvariant(),
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
@* Light / auto / dark theme switch. Interactive so button clicks reach the
browser-side appFoundationTheme helper that owns data-theme + localStorage. *@
@rendermode InteractiveServer
@using Microsoft.JSInterop
@inject IJSRuntime JS

<div class="af-theme-toggle" role="group" aria-label="Theme">
@foreach (var mode in Modes)
{
<button type="button"
class="af-theme-btn @(mode.Key == _current ? "af-active" : string.Empty)"
title="@mode.Title"
aria-label="@mode.Title"
aria-pressed="@(mode.Key == _current)"
@onclick="() => SetAsync(mode.Key)">
@((MarkupString)mode.Svg)
</button>
}
</div>

@code {
private string _current = "auto";

private sealed record Mode(string Key, string Svg, string Title);

private const string Sun =
"<svg viewBox=\"0 0 24 24\" width=\"14\" height=\"14\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"4\"/><path d=\"M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4\"/></svg>";

private const string Monitor =
"<svg viewBox=\"0 0 24 24\" width=\"14\" height=\"14\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"3\" y=\"4\" width=\"18\" height=\"12\" rx=\"2\"/><path d=\"M8 20h8M12 16v4\"/></svg>";

private const string Moon =
"<svg viewBox=\"0 0 24 24\" width=\"14\" height=\"14\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z\"/></svg>";

private static readonly Mode[] Modes =
[
new("light", Sun, "Light"),
new("auto", Monitor, "Match system"),
new("dark", Moon, "Dark"),
];

protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender)
{
return;
}

try
{
_current = await JS.InvokeAsync<string>("appFoundationTheme.get");
StateHasChanged();
}
catch (JSException)
{
// Script not ready yet (e.g. during prerender) — keep the default.
}
}

private async Task SetAsync(string mode) =>
_current = await JS.InvokeAsync<string>("appFoundationTheme.set", mode);
}
3 changes: 3 additions & 0 deletions samples/MartenIdentity.Aspire.Web/Components/Pages/Home.razor
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
@page "/"
@* The library's cookie-login middleware redirects here (DefaultRedirect = "/dashboard")
after a successful sign-in when no explicit ReturnUrl was supplied. *@
@page "/dashboard"

<PageTitle>Marten Identity Sample</PageTitle>

Expand Down
1 change: 1 addition & 0 deletions samples/MartenIdentity.Aspire.Web/Program.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using AndreGoepel.Marten.Identity;
using AndreGoepel.Marten.Identity.Blazor;
using AndreGoepel.Marten.Identity.Blazor.Components.Account;
using AndreGoepel.Marten.Identity.Blazor.Features;
using AndreGoepel.Marten.Identity.Users;
using JasperFx;
using Marten;
Expand Down
1 change: 1 addition & 0 deletions samples/MartenIdentity.Aspire.Web/appsettings.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"AppName": "Marten Identity",
"Logging": {
"LogLevel": {
"Default": "Information",
Expand Down
11 changes: 1 addition & 10 deletions samples/MartenIdentity.Aspire.Web/wwwroot/app.css
Original file line number Diff line number Diff line change
@@ -1,15 +1,6 @@
html, body {
margin: 0;
font-family: var(--rz-text-font-family, "Roboto", sans-serif);
}

.rz-header {
background: var(--rz-primary, #3f51b5);
}

.rz-header a,
.rz-header .rz-text {
color: #fff;
font-family: var(--rz-text-font-family, "Manrope", sans-serif);
}

#blazor-error-ui {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,27 +11,26 @@

<IdentityPageTitle Title="Forgot your password?" />

<SectionContent SectionName="login-tagline">We'll send a password reset link.</SectionContent>

<RadzenText TagName="TagName.H1" TextStyle="TextStyle.H4">Forgot your password?</RadzenText>
<div class="af-login-head">
<RadzenText TagName="TagName.H1" TextStyle="TextStyle.H4" Text="Forgot your password?" />
<RadzenText TextStyle="TextStyle.Body2" class="af-login-sub" Text="We'll send a password reset link." />
</div>

<RadzenTemplateForm TItem="InputModel" Data="@Input" Submit="@OnValidSubmitAsync">
<RadzenStack Gap="1.25rem">

<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" Gap="1rem">
<RadzenLabel Text="Email" Component="Email" Style="width: 8rem; flex-shrink: 0;" />
<RadzenStack Gap="0.25rem" Style="flex: 1;">
<RadzenTextBox @bind-Value="@Input.Email"
Name="Email"
Placeholder="name@example.com"
AutoCompleteType="AutoCompleteType.Email"
Style="width:100%" />
<RadzenRequiredValidator Component="Email" Text="Email is required" />
<RadzenEmailValidator Component="Email" Text="Please enter a valid email address" />
</RadzenStack>
<RadzenStack Gap="0.4rem">
<RadzenLabel Text="Email" Component="Email" />
<RadzenTextBox @bind-Value="@Input.Email"
Name="Email"
Placeholder="name@example.com"
AutoCompleteType="AutoCompleteType.Email"
Style="width:100%" />
<RadzenRequiredValidator Component="Email" Text="Email is required" />
<RadzenEmailValidator Component="Email" Text="Please enter a valid email address" />
</RadzenStack>

<div class="login-action-row">
<div class="af-login-actions">
<RadzenButton ButtonType="ButtonType.Submit"
Text="Reset password"
ButtonStyle="ButtonStyle.Primary"
Expand All @@ -42,6 +41,12 @@
</RadzenStack>
</RadzenTemplateForm>

<div class="af-login-footer-links">
<span class="af-login-resend">
<RadzenLink Path="Account/Login" Text="Back to log in" />
</span>
</div>

@code {
private InputModel Input { get; set; } = new();
private bool _isProcessing;
Expand Down
Loading