diff --git a/UltimateAuth.slnx b/UltimateAuth.slnx index 9bef746e..ae17895e 100644 --- a/UltimateAuth.slnx +++ b/UltimateAuth.slnx @@ -1,4 +1,7 @@ + + + diff --git a/apps/identity-lab/CAPABILITY-AUDIT.md b/apps/identity-lab/CAPABILITY-AUDIT.md new file mode 100644 index 00000000..3a9cbcae --- /dev/null +++ b/apps/identity-lab/CAPABILITY-AUDIT.md @@ -0,0 +1,24 @@ +# Identity Lab Phase 1 capability audit + +This audit records the public API boundary used by the three-screen Identity Lab application. Identity Lab is a live application that does not access an UltimateAuth store or persistence model directly. + +| Screen / UI feature | UltimateAuth capability | Public API / type | Usable? | Ownership / decision | +|---|---|---|---|---| +| Landing: isolated playground copy and transition | None required | — | Yes | Identity Lab presentation. The environment uses the real in-memory UltimateAuth bundle. | +| Sign in | Flow-based login | `UAuthLoginForm`, `IUAuthClient.Flows`, `LoginRequest` | Yes | UltimateAuth owns validation, login, session establishment and errors. | +| Create account | User creation | `IUAuthClient.Users.CreateAsync(CreateUserRequest)` | Yes | UltimateAuth owns users and credentials. Identity Lab only collects input. | +| Remember me | No matching public semantic was found | — | No | Omitted rather than emulated. | +| GitHub login | No configured external-provider public flow in the inspected samples | — | No | Omitted rather than presenting a non-functional control. | +| Current identity and authorization state | Auth state snapshot | `UAuthPageBase.AuthState`, `UAuthStateView`, `UAuthAuthorize` | Yes | UltimateAuth-backed identity, tenant, roles and session state. | +| Device/session overview | Root → Chain → Session model | `IUAuthClient.Sessions.GetMyChainsAsync`, `SessionChainSummary` | Yes | A chain is labelled as a device/client context; it is not presented as a raw session. | +| Sign out the current session | Current-session logout | `IUAuthClient.Flows.LogoutAsync()` | Yes | The normal current-device action ends the current session without explicitly revoking its chain. | +| Log out another device | Device logout | `IUAuthClient.Flows.LogoutMyDeviceAsync(LogoutDeviceRequest)` | Yes | Used for non-current chains; UltimateAuth owns the logout and resulting chain/session state. | +| Log out other/all devices | Multi-device logout | `IUAuthClient.Flows.LogoutMyOtherDevicesAsync()`, `LogoutAllMyDevicesAsync()` | Yes, not surfaced in Phase 1 | Public APIs exist, but no corresponding approved Phase 1 control is present. | +| Explicit chain revocation | Chain revocation | `IUAuthClient.Sessions.RevokeMyChainAsync(SessionChainId)` | Yes, not surfaced | Reserved for a future advanced security experience; it is not disguised as everyday logout. | +| Validate session | Session flow validation | `IUAuthClient.Flows.ValidateAsync()` | Yes | UltimateAuth-backed. | +| Client profile/runtime | Client product metadata | `IUAuthClientProductInfoProvider.Get()` | Yes | UltimateAuth-backed. | +| Playground label and storage description | Environment metadata | app configuration | Yes | Identity Lab-owned presentation metadata. No security meaning. | +| All-user count/list | Admin query exists but should only appear after permission-aware product design | `IUAuthClient.Users.QueryAsync(UserQuery)` | Deferred | Not queried in Phase 1; avoids conflating the current-user dashboard with admin access. | +| Global active-session count | No unrestricted aggregate API identified | — | No | Omitted; current user's chain count is shown instead. | +| Authentication/security event feed | No public query API identified | — | No | Omitted; the landing preview describes capabilities rather than displaying fabricated events. | +| Playground creation, expiry and quotas | No Identity Lab persistence/service exists in Phase 1 | — | No | Omitted until genuine playground metadata storage is introduced. | diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/CodeBeam.UltimateAuth.IdentityLab.csproj b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/CodeBeam.UltimateAuth.IdentityLab.csproj new file mode 100644 index 00000000..5cf83b03 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/CodeBeam.UltimateAuth.IdentityLab.csproj @@ -0,0 +1,16 @@ + + + net10.0 + enable + enable + false + + + + + + + + + + diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/App.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/App.razor new file mode 100644 index 00000000..4be05237 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/App.razor @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/MainLayout.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/MainLayout.razor new file mode 100644 index 00000000..40a9ca83 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/MainLayout.razor @@ -0,0 +1,14 @@ +@inherits LayoutComponentBase + + + + UUltimateAuth + + Playground + Documentation + GitHub ↗ + + @Body + + +
An unexpected error occurred. Reload
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Access.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Access.razor new file mode 100644 index 00000000..fd5026dc --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Access.razor @@ -0,0 +1,55 @@ +@page "/access" +@page "/login" +@attribute [UAuthLoginPage] +@inherits UAuthPageBase +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar + +Sign in · Identity Lab +
+
ULTIMATEAUTH

Identity Lab

Explore authentication, authorization and session management
in a live application built with Blazor and UltimateAuth.

+ +
+ @if (!create) + { + + + + + Sign In + Try the seeded accounts admin/admin or user/user. + + + } + else + { + + + + + Create Account + + } +
+
♙ User ManagementReal UltimateAuth users⚿ Live SessionsDevice-aware session chains♢ AuthorizationPolicies and permissions▥ Honest DataNo fabricated security events
+
+ +@code { + private bool create; + private string? username, email, password; + private MudForm form = null!; + + private void HandleLogin(IUAuthTryResult result) + { + if (!result.Success) Snackbar.Add(result.Reason is null ? "Sign in failed." : $"Sign in failed: {result.Reason}.", Severity.Error); + } + + private async Task Register() + { + await form.ValidateAsync(); + if (!form.IsValid) return; + var result = await UAuthClient.Users.CreateAsync(new CodeBeam.UltimateAuth.Users.Contracts.CreateUserRequest { UserName = username, Email = email, Password = password }); + if (result.IsSuccess) { Snackbar.Add("Account created. You can now sign in.", Severity.Success); create = false; } + else Snackbar.Add(result.ErrorText ?? "Account creation failed.", Severity.Error); + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard.razor new file mode 100644 index 00000000..10901fec --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard.razor @@ -0,0 +1,95 @@ +@page "/dashboard" +@attribute [UAuthAuthorize] +@inherits UAuthPageBase +@inject IUAuthClient UAuthClient +@inject IUAuthClientProductInfoProvider ProductInfoProvider +@inject ISnackbar Snackbar + +Dashboard · Identity Lab +
+ +
+
DASHBOARD

Welcome back, @DisplayName.

This is your isolated playground environment for exploring authentication,
authorization and session management with UltimateAuth.

● Playground Environment
+
+
♙
Current user@UserNameUltimateAuth identity
+
▱
Device chains@(chains?.TotalCount.ToString() ?? "—")From GetMyChainsAsync
+
✓
Session state@(AuthState?.Identity?.SessionState?.ToString() ?? "—")Server-controlled
+
♢
Authorization@(AuthState?.Claims.Roles.Count ?? 0)Assigned roles
+
+
+ +

Your device chains

Refresh
+ @if (loading) { } + else if (chains?.Items.Count > 0) + { + + Device / clientLast seenState + + @DeviceName(context)
@(context.IsCurrentDevice ? "Current device" : context.Platform)
+ @FormatDate(context.LastSeenAt) + @context.State + @(context.IsCurrentDevice ? "Sign out" : "Log out device") +
+
+ } + else {
No device chains returned

UltimateAuth has not exposed an active chain for this account yet.

} +
+ +

Authentication state

+
Authenticated
@(AuthState?.IsAuthenticated == true ? "Yes" : "No")
Tenant
@AuthState?.Identity?.Tenant.Value
User key
@AuthState?.Identity?.UserKey.Value
Roles
@Roles
Client profile
@product?.ClientProfile
+
+ +

Quick actions

+
Validate sessionExplore chainsSign out
+
+ +

Playground info

+
Environment
● Isolated
Storage
In-memory
Started
@product?.StartedAt.ToLocalTime().ToString("g")
UltimateAuth
v@product?.Version
+
+
+
+
+ +@code { + private PagedResult? chains; + private UAuthClientProductInfo? product; + private bool loading; + private string DisplayName => AuthState?.Identity?.DisplayName ?? AuthState?.Identity?.PrimaryUserName ?? "Explorer"; + private string UserName => AuthState?.Identity?.PrimaryUserName ?? "—"; + private string Roles => AuthState is null || AuthState.Claims.Roles.Count == 0 ? "None" : string.Join(", ", AuthState.Claims.Roles); + + protected override async Task OnInitializedAsync() + { + product = ProductInfoProvider.Get(); + if (AuthState.IsAuthenticated) + await LoadChains(); + } + + protected override void HandleAuthStateChanged(UAuthStateChangeReason reason) + { + if (reason == UAuthStateChangeReason.Authenticated && chains is null) + _ = InvokeAsync(async () => { await LoadChains(); StateHasChanged(); }); + } + private async Task LoadChains() { loading = true; var result = await UAuthClient.Sessions.GetMyChainsAsync(); chains = result.Value; loading = false; } + private async Task LogoutChain(SessionChainSummary chain) + { + if (chain.IsCurrentDevice) + { + await UAuthClient.Flows.LogoutAsync(); + return; + } + + var request = new CodeBeam.UltimateAuth.Users.Contracts.LogoutDeviceRequest { ChainId = chain.ChainId }; + var result = await UAuthClient.Flows.LogoutMyDeviceAsync(request); + Snackbar.Add(result.IsSuccess ? "Device logged out." : result.ErrorText ?? "Device logout failed.", result.IsSuccess ? Severity.Success : Severity.Error); + await LoadChains(); + } + private async Task Validate() { var result = await UAuthClient.Flows.ValidateAsync(); Snackbar.Add(result.IsValid ? "Session is valid." : $"Session state: {result.State}", result.IsValid ? Severity.Success : Severity.Warning); } + private Task Logout() => UAuthClient.Flows.LogoutAsync(); + private static string DeviceName(SessionChainSummary chain) => string.Join(" · ", new[] { chain.Browser, chain.OperatingSystem, chain.DeviceType }.Where(x => !string.IsNullOrWhiteSpace(x))); + private static string FormatDate(DateTimeOffset? value) => value?.ToLocalTime().ToString("g") ?? "Not reported"; +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Landing.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Landing.razor new file mode 100644 index 00000000..84459572 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Landing.razor @@ -0,0 +1,39 @@ +@page "/" +@inject NavigationManager Navigation + +UltimateAuth Identity Lab +
+
+
+
ULTIMATEAUTH
+

Identity Lab

+

A live playground and sandbox for authentication, authorization and session management.

+

Explore real users, device chains, sessions and permissions in an isolated environment built with Blazor, MudBlazor and UltimateAuth.

+ Start Exploring +
+
+
U UltimateAuth⌕   Playground
+
+ +

Dashboard

Overview of your playground environment.

Identity
Real
Device chains
Live
Auth state
Secure
UltimateAuth capabilities
Authentication flow based
Sessions device aware
Authorization policy driven
+
+
+
+
+
⚗
Safe Playground

Isolated environment for learning and testing.

+
♢
Real Authentication

Powered by UltimateAuth. No mock authentication.

+
♙
Full Scenarios

Users, permissions, devices and session control.

+
</>
Open Source

Explore the code and use it in your own projects.

+
+
A CodeBeam ProductPlayground    Documentation    GitHub ↗
+
+ +@code { + private bool leaving; + private async Task StartExploring() + { + leaving = true; + await Task.Delay(180); + Navigation.NavigateTo("/access"); + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/NotAuthorized.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/NotAuthorized.razor new file mode 100644 index 00000000..273fdeaa --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/NotAuthorized.razor @@ -0,0 +1,2 @@ +@inject NavigationManager Navigation +

Authentication required

Sign in through UltimateAuth to enter this playground.

Sign In
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Routes.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Routes.razor new file mode 100644 index 00000000..be104ad9 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Routes.razor @@ -0,0 +1,22 @@ + + + + + + + + + + +@code { + private readonly MudTheme LabTheme = new() + { + PaletteLight = new PaletteLight + { + Primary = "#087f7b", Secondary = "#11223f", Background = "#f8fbfc", + Surface = "#ffffff", TextPrimary = "#08162f", TextSecondary = "#536581" + }, + Typography = new Typography { Default = new DefaultTypography { FontFamily = ["Inter", "sans-serif"] } }, + LayoutProperties = new LayoutProperties { DefaultBorderRadius = "12px" } + }; +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/_Imports.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/_Imports.razor new file mode 100644 index 00000000..fd729019 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/_Imports.razor @@ -0,0 +1,11 @@ +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using CodeBeam.UltimateAuth.Client +@using CodeBeam.UltimateAuth.Client.Blazor +@using CodeBeam.UltimateAuth.Client.Runtime +@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Core.Domain +@using MudBlazor +@using CodeBeam.UltimateAuth.IdentityLab.Components +@using CodeBeam.UltimateAuth.IdentityLab.Components.Layout diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs new file mode 100644 index 00000000..da9747d3 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs @@ -0,0 +1,43 @@ +using CodeBeam.UltimateAuth.Client.Blazor.Extensions; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.InMemory; +using CodeBeam.UltimateAuth.IdentityLab.Components; +using CodeBeam.UltimateAuth.Sample.Seed.Extensions; +using CodeBeam.UltimateAuth.Server.Extensions; +using MudBlazor.Services; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddRazorComponents().AddInteractiveServerComponents(); +builder.Services.AddMudServices(); +builder.Services.AddUltimateAuthServer(options => +{ + options.Login.MaxFailedAttempts = 5; + options.Login.LockoutDuration = TimeSpan.FromMinutes(1); + options.Identifiers.Behavior.AllowMultipleUsernames = true; +}).AddUltimateAuthInMemory(); +builder.Services.AddUltimateAuthClientBlazor(options => + options.Reauth.Behavior = ReauthBehavior.RaiseEvent); +builder.Services.AddUltimateAuthSampleSeed(); + +var app = builder.Build(); +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); + app.UseHsts(); +} +else +{ + await app.SeedUltimateAuthAsync(); +} + +app.UseHttpsRedirection(); +app.UseStaticFiles(); +app.UseUltimateAuthWithAspNetCore(); +app.UseAntiforgery(); +app.MapUltimateAuthEndpoints(); +app.MapRazorComponents() + .AddInteractiveServerRenderMode() + .AddUltimateAuthRoutes(UAuthAssemblies.BlazorClient()); +app.Run(); diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/appsettings.json b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/appsettings.json new file mode 100644 index 00000000..4474aa93 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/appsettings.json @@ -0,0 +1,4 @@ +{ + "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, + "AllowedHosts": "*" +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/wwwroot/app.css b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/wwwroot/app.css new file mode 100644 index 00000000..5a669493 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/wwwroot/app.css @@ -0,0 +1,2 @@ +:root{--navy:#08162f;--ink:#142542;--teal:#087f7b;--line:#dfe8ef;--muted:#586985;--wash:#f5fafb}*{box-sizing:border-box}html,body{margin:0;font-family:Inter,sans-serif;color:var(--navy);background:#fbfdfe}.lab-appbar{height:70px!important;padding:0 max(5vw,30px)!important;border-bottom:1px solid var(--line);background:rgba(255,255,255,.9)!important;gap:30px}.mud-main-content{padding-top:70px!important}.brand{display:flex;align-items:center;gap:12px;color:var(--navy);text-decoration:none;font-size:20px}.brand-mark,.mini-mark{font-weight:900;color:var(--teal);font-size:28px}.landing-shell{min-height:calc(100vh - 70px);padding:65px 5.5% 25px;background:radial-gradient(circle at 69% 34%,rgba(22,164,160,.13),transparent 33%),linear-gradient(145deg,#fff 68%,#f4fafb);transition:opacity .18s ease,transform .18s ease}.landing-shell.leaving{opacity:0;transform:translateY(5px) scale(.997)}.hero{display:grid;grid-template-columns:40% 60%;align-items:center;min-height:580px;max-width:1420px;margin:auto}.eyebrow{letter-spacing:5px;color:var(--teal);font-weight:700}.hero-copy h1{font-size:76px;line-height:1;margin:24px 0}.hero-copy h1 span{color:var(--teal)}.hero-copy h2{font-size:27px;line-height:1.3;font-weight:500}.hero-copy p{font-size:18px;line-height:1.55;color:var(--muted);max-width:580px}.explore-button{margin-top:25px!important;width:305px;height:62px;font-size:18px!important;background:var(--teal)!important}.hero-preview{margin-left:45px;background:white;border:1px solid #e2eaf0;border-radius:18px;box-shadow:0 25px 60px rgba(19,76,89,.16);transform:perspective(1100px) rotateY(-3deg);overflow:hidden}.preview-top{display:flex;justify-content:space-between;padding:20px 28px;border-bottom:1px solid var(--line)}.preview-body{display:grid;grid-template-columns:145px 1fr;min-height:400px}.preview-body aside{padding:30px 20px;line-height:3;background:#f9fcfd;color:#52647f}.preview-body>div{padding:30px}.preview-cards{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}.preview-cards b,.preview-events{padding:22px;border:1px solid var(--line);border-radius:12px}.preview-cards em{font-style:normal;color:var(--teal);font-size:24px}.preview-events{margin-top:18px;line-height:2}.preview-events span{float:right;color:var(--muted)}.feature-row{display:grid;grid-template-columns:repeat(4,1fr);gap:35px;max-width:1420px;margin:25px auto 55px}.feature-row article{display:flex;gap:18px}.feature-row i{background:#e7f6f5;color:var(--teal);width:58px;height:58px;border-radius:50%;display:grid;place-items:center;font-size:22px;font-style:normal;flex:none}.feature-row p{color:var(--muted);margin:7px 0;line-height:1.4}.landing-shell footer{max-width:1420px;margin:auto;border-top:1px solid var(--line);padding-top:30px;display:flex;justify-content:space-between;color:var(--muted);font-size:13px}.access-shell{min-height:calc(100vh - 70px);padding:65px 20px 35px;background:radial-gradient(circle at center,rgba(21,141,139,.09),transparent 43%);text-align:center}.access-heading h1{font-size:54px;margin:16px 0}.access-heading p{color:var(--muted);font-size:18px;line-height:1.45}.access-card{width:min(520px,100%);margin:30px auto;padding:34px;border:1px solid #edf1f4!important;box-shadow:0 18px 55px rgba(23,55,84,.09)!important;text-align:left}.auth-tabs{display:grid;grid-template-columns:1fr 1fr;background:#f3f6f8;padding:4px;border-radius:9px;margin-bottom:26px}.auth-tabs button{border:0;padding:12px;background:transparent;color:var(--muted);font-weight:600;border-radius:7px}.auth-tabs button.active{background:white;color:var(--teal);box-shadow:0 2px 6px #dce3e8}.auth-submit{height:50px;background:var(--teal)!important}.access-features{max-width:1220px;margin:55px auto 0;display:grid;grid-template-columns:repeat(4,1fr);text-align:left;gap:45px}.access-features span{display:grid;grid-template-columns:34px 1fr;color:var(--teal)}.access-features b,.access-features small{color:var(--navy)}.access-features small{grid-column:2;color:var(--muted);margin-top:6px}.dashboard-shell{display:grid;grid-template-columns:260px 1fr;min-height:calc(100vh - 70px)}.side-nav{border-right:1px solid var(--line);padding:28px 16px;display:flex;flex-direction:column;background:#fcfefe}.side-nav>div:first-child{display:grid;grid-template-columns:42px 1fr}.side-nav small{color:var(--muted);grid-column:2}.lab-icon{grid-row:1/3;font-size:25px;color:var(--teal)}.side-nav nav{margin-top:30px;display:grid;gap:7px}.side-nav a{padding:13px 20px;text-decoration:none;color:var(--ink);border-radius:9px}.side-nav a.selected{background:#e4f4f4;color:#066c69;font-weight:600}.side-bottom{margin-top:auto;display:grid;gap:8px;border-top:1px solid var(--line);padding-top:18px}.side-bottom small{grid-column:auto;padding:18px 20px}.dashboard-content{padding:30px;max-width:1500px;width:100%;margin:auto}.dash-heading{display:flex;justify-content:space-between;align-items:start}.dash-heading span{color:var(--teal);font-size:13px;font-weight:700}.dash-heading h1{font-size:36px;margin:10px 0 4px}.dash-heading p{color:var(--muted);line-height:1.5}.metric-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:15px;margin:24px 0}.metric-grid article,.dash-panel{background:white;border:1px solid var(--line);border-radius:12px}.metric-grid article{padding:22px;display:flex;gap:18px}.metric-grid i{width:50px;height:50px;border-radius:50%;background:#e6f6f2;color:var(--teal);display:grid;place-items:center;font-size:22px;font-style:normal}.metric-grid article div{display:grid}.metric-grid strong{font-size:24px}.metric-grid small{color:var(--muted)}.dashboard-grid{display:grid;grid-template-columns:2fr 1fr;gap:15px}.dash-panel{padding:18px}.panel-title{display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid #edf1f4;margin-bottom:10px}.panel-title h2{font-size:16px}.chains-panel{min-height:310px}.info-list{display:grid;grid-template-columns:1fr 1.4fr;gap:16px;margin:18px 4px}.info-list dt{color:var(--muted)}.info-list dd{margin:0}.truncate{overflow:hidden;text-overflow:ellipsis}.green-dot{color:#08a35b}.quick-actions{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:24px}.empty-state{text-align:center;padding:55px;color:var(--muted)}.center-state{min-height:70vh;display:grid;place-items:center;text-align:center}#blazor-error-ui{display:none;position:fixed;bottom:0;width:100%;padding:12px;background:#fff3cd;z-index:1000} +@media(max-width:950px){.hero{grid-template-columns:1fr}.hero-preview{margin:45px 0 0}.feature-row,.access-features,.metric-grid{grid-template-columns:repeat(2,1fr)}.dashboard-shell{grid-template-columns:1fr}.side-nav{display:none}.dashboard-grid{grid-template-columns:1fr}.hero-copy h1{font-size:58px}}@media(max-width:600px){.lab-appbar .mud-link{display:none}.landing-shell{padding-top:35px}.hero-copy h1{font-size:48px}.hero-preview{display:none}.feature-row,.access-features,.metric-grid{grid-template-columns:1fr}.dash-heading{display:block}.dashboard-content{padding:18px}.quick-actions{grid-template-columns:1fr}.landing-shell footer{display:block}.landing-shell footer span:last-child{display:none}}