diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabAccessContextProvider.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabAccessContextProvider.cs index dce46417..081758c9 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabAccessContextProvider.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabAccessContextProvider.cs @@ -11,4 +11,11 @@ public async Task CreateAsync(string action, string resource, Can var flow = await authFlow.BeginAsync(AuthFlowType.ValidateSession, cancellationToken); return await contextFactory.CreateAsync(flow, action, resource, ct: cancellationToken); } + + + public async Task CreateForUserAsync(string action, string resource, UserKey targetUserKey, CancellationToken ct = default) + { + var flow = await authFlow.BeginAsync(AuthFlowType.ValidateSession, ct); + return await contextFactory.CreateAsync(flow, action, resource, resourceId: targetUserKey.Value, ct: ct); + } } diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabAdminGuard.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabAdminGuard.cs index 3be4b207..6556f3d2 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabAdminGuard.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabAdminGuard.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Authorization; using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.IdentityLab.Domain; namespace CodeBeam.UltimateAuth.IdentityLab.Authorization; @@ -17,18 +18,27 @@ context.ActorUserKey is null || return false; } - var roleStore = roleStoreFactory.Create(context.ActorTenant); + var tenant = context.ActorTenant; + var roleStore = roleStoreFactory.Create(tenant); - var adminRole = await roleStore.GetByNameAsync(LabRoles.Admin.ToUpperInvariant(), ct); + var roles = await roleStore.GetByIdsAsync( + [LabRoleIds.Admin], ct); + + var adminRole = roles.FirstOrDefault(x => + x.Id == LabRoleIds.Admin && + x.Tenant == tenant && + !x.IsDeleted); if (adminRole is null) return false; - var userRoleStore = userRoleStoreFactory.Create(context.ActorTenant); + var userRoleStore = userRoleStoreFactory.Create(tenant); - var assignments = await userRoleStore.GetAssignmentsAsync(context.ActorUserKey.Value, ct); + var assignments = await userRoleStore + .GetAssignmentsAsync(context.ActorUserKey.Value, ct); - return assignments.Any(x => x.RoleId == adminRole.Id); + return assignments.Any(x => + x.RoleId == LabRoleIds.Admin); } public async Task EnsureAdminAsync(AccessContext context, CancellationToken ct = default) diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabDemoPermissions.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabDemoPermissions.cs new file mode 100644 index 00000000..caccd783 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabDemoPermissions.cs @@ -0,0 +1,24 @@ +namespace CodeBeam.UltimateAuth.IdentityLab.Authorization; + +public static class LabDemoPermissions +{ + public const string ProductsRead = "demo.products.read"; + public const string ProductsCreate = "demo.products.create"; + public const string ProductsUpdate = "demo.products.update"; + public const string ProductsDelete = "demo.products.delete"; + public const string OrdersCreate = "demo.orders.create"; + + public static readonly IReadOnlyList Manager = + [ + ProductsRead, + ProductsCreate, + ProductsUpdate, + ProductsDelete + ]; + + public static readonly IReadOnlyList Customer = + [ + ProductsRead, + OrdersCreate + ]; +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabRoles.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabRoles.cs index 16c14277..a3dfd637 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabRoles.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabRoles.cs @@ -1,15 +1,24 @@ -namespace CodeBeam.UltimateAuth.IdentityLab.Authorization; - -public static class LabRoles +public static class LabRoles { public const string Admin = "Ladmin"; public const string Owner = "Owner"; public const string User = "User"; + public const string Manager = "Manager"; + public const string Customer = "Customer"; + public static readonly IReadOnlyList SystemRoles = [ Admin, Owner, User ]; + + public static readonly IReadOnlyList DemoRoles = + [ + Manager, + Customer + ]; + + public static bool IsDemoRole(string? name) => DemoRoles.Contains(name ?? "", StringComparer.OrdinalIgnoreCase); } diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/DashboardSidebar.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/DashboardSidebar.razor deleted file mode 100644 index e02d249d..00000000 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/DashboardSidebar.razor +++ /dev/null @@ -1,33 +0,0 @@ - - - SANDBOX - - - - Overview - - - - Members - - - - Sessions & Devices - - - - Roles & Permissions - - - - - - Back to Home - - diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/EssentialCookieNotice.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/EssentialCookieNotice.razor new file mode 100644 index 00000000..a3991619 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/EssentialCookieNotice.razor @@ -0,0 +1,110 @@ +@using MudExtensions +@using Microsoft.JSInterop +@implements IAsyncDisposable + +@inject IJSRuntime JS + + + + + + + + + + +@code { + private const string StorageKey = "identitylab.cookies.notice.v1"; + + private bool _open; + private bool _saving; + private bool _initialized; + private bool _disposed; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender || _initialized) + return; + + try + { + var acknowledged = await JS.InvokeAsync("localStorage.getItem", StorageKey); + + if (_disposed) + return; + + _open = acknowledged != "acknowledged"; + _initialized = true; + + await InvokeAsync(StateHasChanged); + } + catch (JSException) + { + // Storage may be unavailable in restricted browsers. + // Show the notice without persistence. + _open = true; + _initialized = true; + await InvokeAsync(StateHasChanged); + } + catch (JSDisconnectedException) + { + // The circuit has disconnected. + } + } + + private async Task AcknowledgeAsync() + { + if (_saving) + return; + + _saving = true; + + try + { + await JS.InvokeVoidAsync("localStorage.setItem", StorageKey, "acknowledged"); + } + catch (JSException) + { + // Dismiss for this circuit even if storage is unavailable. + } + catch (JSDisconnectedException) + { + // The browser connection was lost. + } + finally + { + _open = false; + _saving = false; + + if (!_disposed) + await InvokeAsync(StateHasChanged); + } + } + + public ValueTask DisposeAsync() + { + _disposed = true; + return ValueTask.CompletedTask; + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/EssentialCookieNotice.razor.css b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/EssentialCookieNotice.razor.css new file mode 100644 index 00000000..003cbd79 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/EssentialCookieNotice.razor.css @@ -0,0 +1,89 @@ +::deep .lab-cookie-popup { + max-width: 100%; + border-radius: 14px; + background: var(--mud-palette-surface); + border: 1px solid var(--mud-palette-lines-default); + color: var(--mud-palette-text-primary); + z-index: 1200; +} + +.lab-cookie-content { + display: flex; + align-items: flex-start; + gap: 14px; + min-width: 0; +} + +.lab-cookie-icon { + width: 42px; + height: 42px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: 12px; + background: var(--mud-palette-primary-hover); + color: var(--mud-palette-primary); +} + +.lab-cookie-copy { + display: flex; + flex-direction: column; + gap: 5px; + max-width: 780px; +} + + .lab-cookie-copy strong { + font-size: .88rem; + font-weight: 750; + } + + .lab-cookie-copy p { + margin: 0; + color: var(--mud-palette-text-secondary); + font-size: .78rem; + line-height: 1.65; + } + + +.lab-cookie-layout { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + width: 100%; +} + +::deep .lab-cookie-button { + flex-shrink: 0; + min-width: 100px; + border-radius: 9px; + font-weight: 650; +} + +@media (max-width: 600px) { + .lab-cookie-layout { + flex-direction: column; + align-items: stretch; + gap: 16px; + } + + ::deep .lab-cookie-button { + width: 100%; + } +} + +@media (max-width: 600px) { + .lab-cookie-content { + gap: 10px; + } + + .lab-cookie-icon { + width: 36px; + height: 36px; + } + + .lab-cookie-copy p { + font-size: .74rem; + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/LabOptionInfo.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/LabOptionInfo.razor new file mode 100644 index 00000000..fa7cb89f --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/LabOptionInfo.razor @@ -0,0 +1,56 @@ +@using MudBlazor + + + + + + + @Title + + + + @Description + + + + + + Configuration + + + + @OptionPath + + + @if (!string.IsNullOrWhiteSpace(CurrentValue)) + { + + Current Value: @CurrentValue + + } + + + + + + + + + + +@code { + [Parameter, EditorRequired] + public string Title { get; set; } = string.Empty; + + [Parameter, EditorRequired] + public string Description { get; set; } = string.Empty; + + [Parameter, EditorRequired] + public string OptionPath { get; set; } = string.Empty; + + [Parameter] + public string? CurrentValue { get; set; } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/PublicFooter.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/PublicFooter.razor index d688b780..692d8999 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/PublicFooter.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/PublicFooter.razor @@ -1,11 +1,13 @@ -
+@using CodeBeam.UltimateAuth.Core.Runtime +@inject IUAuthProductInfoProvider ProductInfoProvider + +
+ +@code { + private string UltimateAuthVersion => ProductInfoProvider.Get().DisplayVersion; +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/PublicFooter.razor.css b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/PublicFooter.razor.css index 437dfd08..07dc4c83 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/PublicFooter.razor.css +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/PublicFooter.razor.css @@ -104,6 +104,17 @@ } } +.lab-footer-versions { + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; + font-size: .7rem; + color: var(--mud-palette-text-secondary); +} + @media (max-width: 380px) { .lab-footer-main { flex-direction: column; diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/PublicHeader.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/PublicHeader.razor index 173438e0..39f5e73c 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/PublicHeader.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/PublicHeader.razor @@ -1,5 +1,4 @@ -@using CodeBeam.UltimateAuth.Sample.Shared.Infrastructure -@inject DarkModeManager DarkModeManager +@inject DarkModeManager DarkModeManager
@@ -17,37 +16,21 @@
-
- +
- - + + - - + Launch Lab Launch -
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor index 5bab31b3..fbb8cedb 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor @@ -1,14 +1,11 @@ @inherits LayoutComponentBase @implements IDisposable -@using CodeBeam.UltimateAuth.Core.Contracts -@using CodeBeam.UltimateAuth.Core.Domain -@using CodeBeam.UltimateAuth.IdentityLab.Authorization -@using CodeBeam.UltimateAuth.IdentityLab.Contracts -@using CodeBeam.UltimateAuth.IdentityLab.Services -@using CodeBeam.UltimateAuth.Sample.Shared.Infrastructure +@using CodeBeam.UltimateAuth.Core.Runtime @inject LabDashboardService DashboardService @inject IUAuthClient UAuthClient @inject DarkModeManager DarkModeManager +@inject ISnackbar Snackbar +@inject IUAuthProductInfoProvider ProductInfoProvider @@ -18,11 +15,7 @@ + + + + + Validate Session + + + + Refresh Session + + + + Session Diagnostics + - + My Identity - + My Sessions - + Sign Out @@ -145,24 +133,23 @@
PERSONAL
- + My Identity - + My Sessions - + + Session Diagnostics + + + My Access - + Authorization Lab @@ -170,23 +157,19 @@ {
SANDBOX MANAGEMENT
- + Users - + Authorization - + Activity - + Settings } @@ -196,32 +179,37 @@ Sign Out + +
PLAYGROUND
+ + + Demo Store +
- - Powered by UltimateAuth + + Powered by UltimateAuth
+ + v @UltimateAuthVersion + .NET @LabVersionInfo.DotNetVersion + @if (AuthState?.Identity?.UserStatus == UserStatus.SelfSuspended) { - + Account Suspended Your account is currently self-suspended. Most account operations are restricted. - Reactivate your account from - My Identity → Account Management. + Reactivate your account from My Identity → Account Management. } @@ -242,74 +230,3 @@ - -@code { - [CascadingParameter] - private UAuthState? AuthState { get; set; } - - private string UserName => AuthState?.Identity?.PrimaryUserName ?? "User"; - private string DisplayName => UserName; - private string AvatarInitials => GetInitials(DisplayName); - private LabDashboardSnapshot? _sandbox; - private UserKey? _loadedUserKey; - private bool _drawerOpen = true; - - private bool ShowManagement => - AuthState?.IsInRole(LabRoles.Admin) == true || - (AuthState?.IsInRole(LabRoles.Owner) == true && - _sandbox?.IsOwner == true); - - protected override void OnInitialized() - { - DarkModeManager.Changed += OnThemeChanged; - } - - protected override async Task OnParametersSetAsync() - { - var userKey = AuthState?.Identity?.UserKey; - - if (userKey is null) - { - _sandbox = null; - _loadedUserKey = null; - return; - } - - if (_loadedUserKey == userKey) - return; - - _loadedUserKey = userKey; - - _sandbox = await DashboardService.GetAsync(userKey.Value); - } - - private void OnThemeChanged() - { - _ = InvokeAsync(StateHasChanged); - } - - private async Task LogoutAsync() - { - await UAuthClient.Flows.LogoutAsync(); - } - - public void Dispose() - { - DarkModeManager.Changed -= OnThemeChanged; - } - - private static string GetInitials(string? displayName) - { - if (string.IsNullOrWhiteSpace(displayName)) - return "?"; - - var parts = displayName.Trim() - .Split(' ', StringSplitOptions.RemoveEmptyEntries); - - var initials = parts.Length > 1 - ? $"{parts[0][0]}{parts[^1][0]}" - : new string(parts[0].Take(2).ToArray()); - - return initials.ToUpperInvariant(); - } -} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor.cs new file mode 100644 index 00000000..8372da36 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor.cs @@ -0,0 +1,144 @@ +// +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.IdentityLab.Contracts; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +#nullable enable + +namespace CodeBeam.UltimateAuth.IdentityLab.Components.Layout; + +public partial class DashboardLayout +{ + [CascadingParameter] + private UAuthState? AuthState { get; set; } + + private string UserName => AuthState?.Identity?.PrimaryUserName ?? "User"; + private string DisplayName => UserName; + private string AvatarInitials => GetInitials(DisplayName); + private LabDashboardSnapshot? _sandbox; + private UserKey? _loadedUserKey; + private bool _drawerOpen = true; + private bool _authOperationBusy; + + private string UltimateAuthVersion => ProductInfoProvider.Get().DisplayVersion; + + private bool ShowManagement => + AuthState?.IsInRole(LabRoles.Admin) == true || + (AuthState?.IsInRole(LabRoles.Owner) == true && + _sandbox?.IsOwner == true); + + protected override void OnInitialized() + { + DarkModeManager.Changed += OnThemeChanged; + } + + protected override async Task OnParametersSetAsync() + { + var userKey = AuthState?.Identity?.UserKey; + + if (userKey is null) + { + _sandbox = null; + _loadedUserKey = null; + return; + } + + if (_loadedUserKey == userKey) + return; + + _loadedUserKey = userKey; + + _sandbox = await DashboardService.GetAsync(userKey.Value); + } + + private async Task ValidateSessionAsync() + { + if (_authOperationBusy) + return; + + _authOperationBusy = true; + + try + { + var result = await UAuthClient.Flows.ValidateAsync(); + + if (result.IsValid) + { + Snackbar.Add("Your current session is valid.", Severity.Success); + return; + } + + var message = result.State switch + { + SessionState.Expired => "Your session has expired. Please sign in again.", + SessionState.DeviceMismatch => "This session is not valid for the current device.", + + _ => $"Session validation returned: {result.State}" + }; + + Snackbar.Add(message, Severity.Warning); + } + catch (Exception) + { + Snackbar.Add("Unable to validate the session. Please try again.", Severity.Error); + } + finally + { + _authOperationBusy = false; + } + } + + private async Task RefreshSessionAsync() + { + if (_authOperationBusy) + return; + + _authOperationBusy = true; + + try + { + await UAuthClient.Flows.RefreshAsync(false); + Snackbar.Add("Refresh request completed.", Severity.Success); + } + catch (Exception) + { + Snackbar.Add("Unable to refresh the session.", Severity.Error); + } + finally + { + _authOperationBusy = false; + } + } + + private void OnThemeChanged() + { + _ = InvokeAsync(StateHasChanged); + } + + private async Task LogoutAsync() + { + await UAuthClient.Flows.LogoutAsync(); + } + + public void Dispose() + { + DarkModeManager.Changed -= OnThemeChanged; + } + + private static string GetInitials(string? displayName) + { + if (string.IsNullOrWhiteSpace(displayName)) + return "?"; + + var parts = displayName.Trim() + .Split(' ', StringSplitOptions.RemoveEmptyEntries); + + var initials = parts.Length > 1 + ? $"{parts[0][0]}{parts[^1][0]}" + : new string(parts[0].Take(2).ToArray()); + + return initials.ToUpperInvariant(); + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AdminPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AdminPage.razor index 25a1ec15..8773e908 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AdminPage.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AdminPage.razor @@ -2,12 +2,6 @@ @layout DashboardLayout @attribute [UAuthAuthorize(Roles = "Ladmin")] -@using CodeBeam.UltimateAuth.Core.Domain -@using CodeBeam.UltimateAuth.IdentityLab.Contracts -@using CodeBeam.UltimateAuth.IdentityLab.Domain -@using CodeBeam.UltimateAuth.IdentityLab.Services -@using CodeBeam.UltimateAuth.IdentityLab.Components.Pages.Dialogs - @inject LabAdminService AdminService @inject IDialogService DialogService @@ -37,8 +31,7 @@ } else if (!string.IsNullOrWhiteSpace(_error)) { - + @_error } @@ -104,8 +97,7 @@ All Sandboxes - + @@ -124,10 +116,7 @@ - + All statuses @@ -150,12 +139,7 @@ - + Sandbox @@ -181,25 +165,20 @@ - + @context.Status - + - + No sandboxes found. diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/AuthorizationLab.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/AuthorizationLab.razor index 396b0b87..0c736ccf 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/AuthorizationLab.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/AuthorizationLab.razor @@ -5,8 +5,7 @@
- +
@@ -15,179 +14,144 @@ - Explore how UltimateAuth protects routes, evaluates - authorization requirements, and controls component visibility. + Explore how UltimateAuth protects routes, evaluates authorization requirements, and controls component visibility.
- - These examples demonstrate authorization behavior in real - applications. Each example will open in a new tab once available. - -
- +
Component Authorization - Explore conditional UI rendering based - on the current user's authorization state. + Explore conditional UI rendering based on the current user's authorization state.
+ + UI authorization controls visibility and navigation. + Sensitive operations must also enforce authorization on the server. + +
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/AuthorizationPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/AuthorizationPage.razor index f57c2d66..8066cf23 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/AuthorizationPage.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/AuthorizationPage.razor @@ -1,579 +1,342 @@ @page "/dashboard/authorization" @layout DashboardLayout - @attribute [UAuthAuthorize] -@using CodeBeam.UltimateAuth.Authorization.Contracts -@using CodeBeam.UltimateAuth.Core.Contracts - +@using CodeBeam.UltimateAuth.IdentityLab.Authorization @inject IUAuthClient UAuthClient @inject ISnackbar Snackbar @inject IDialogService DialogService -Authorization · Identity Lab +Authorization Management · Identity Lab
- -
+
SANDBOX MANAGEMENT -

Authorization

-

Explore roles, permission assignments and access control within your sandbox.

+

Authorization Management

+

Manage roles, understand their grants, and explore the UltimateAuth permission model.

- - - Create Role - -
+ Manage Roles +
-
- - - - Roles - @_totalRoles - Total roles -
- -
- - - - Permissions - @_permissions.Count - Available permissions -
- - @*
- - - - Role Assignments - 3 - Example assignments -
*@ +
Roles@_totalRolesAvailable roles
+
UltimateAuth actions@FrameworkPermissions.CountFramework catalog
+
Identity Lab permissions@DemoPermissions.CountDemo application
+ @if (_error is not null) + { + @_error + } +
-
-

Roles & Permissions

-

- Select a role to inspect its assigned permissions. -

-
+

Role Management

Select a role to inspect its permission scope and manage assignments.

- -
+
-
-

Sandbox Roles

-

Available authorization roles.

-
- +

Roles

System, demo, and custom roles.

+
- + @if (_loading) + { + + }
@foreach (var role in _roles) { - } - @if (!_loading && _roles.Count == 0) { -
- No roles found. -
+
No roles available.
}
- -
- -
+ @if (TotalPages > 1) + { +
+ +
+ }
-
- +
-

@(SelectedRole?.Name ?? "Role") Permissions

-

Permissions assigned to the selected role.

+

@(SelectedRole is null ? "Role Permissions" : SelectedRole.Name)

+

Assigned grants, grouped by domain.

- - - Edit - + Disabled="@(_loading || SelectedRole is null || IsSystemRole(SelectedRole.Name))" + OnClick="EditPermissionsAsync">Manage Permissions
- - @if (SelectedRole is not null) + @if (SelectedRole is null) { - - -
- - @foreach (var permission in FilteredPermissions) - { - var granted = GrantedPermissions.Contains(permission); - -
- - - - - -
- @permission -
- - - @(granted ? "Granted" : "Not granted") - - -
- } - - @if (!FilteredPermissions.Any()) - { -
- No permissions match your search. -
- } -
+
Select a role to view its grants.
} else { -
- Select a role to inspect its permissions. +
+ @RoleKind(SelectedRole.Name) role + @RoleGrantSummary(SelectedRole)
+ @if (HasWildcard(SelectedRole)) + { + + This role contains the wildcard grant (*). It is not limited to the permissions shown in this catalog. + Applicable authorization policies still determine access. + + } + @if (IsSystemRole(SelectedRole.Name)) + { + + System role permissions are managed by Identity Lab, not from this page. + + } + @foreach (var group in GrantedGroups) + { +
+
+ @GroupTitle(group.Name) + @group.Values.Count grant(s) +
+
+ @foreach (var permission in group.Values) + { + @ActionLabel(permission) + } +
+
+ } + @if (!HasWildcard(SelectedRole) && !GrantedGroups.Any()) + { +
No explicit permission grants.
+ } +

Role grants do not bypass sandbox restrictions or other authorization policies.

} -
-
-
- AUTHORIZATION EXPLORER -

Policy Playground

-

- Preview how role selection and access requests - can be evaluated. -

-
+
+

Permission Catalog

Browse known operations by domain. This is a reference catalog, not an access decision.

- -
- -
-
-
-

Access Request

-

Evaluate authorization for your current session.

-
- - -
- -
- - - - @foreach (var permission in _permissions) - { - - @permission - - } - - - - - - - - - - @if (_evaluating) - { - - } - - Evaluate Policy - - -
-
- -
- -
-
-

Evaluation Result

-

Server-side authorization decision.

-
- - -
- - @if (_evaluationResult is null) +
+
+ + + All sources + UltimateAuth + Identity Lab + +
+
+ @foreach (var group in CatalogGroups) { -
- - - - - Ready to evaluate - -

- Select an action and resource to evaluate - access using your current authentication context. -

-
+
+ + @GroupTitle(group.Key)@GroupDescription(group.Key) + @group.Count() actions + +
+ @foreach (var entry in group) + { +
+
@ActionLabel(entry.Value)@entry.Value
+ @(entry.IsDemo ? "Identity Lab" : "UltimateAuth") +
+ } +
+
} - else + @if (!CatalogGroups.Any()) { - - - - @(_evaluationResult.IsAllowed - ? "Access Granted" - : "Access Denied") - - -
- @(_evaluationResult.IsAllowed - ? "The current session is authorized to perform this action." - : "The current session is not authorized to perform this action.") -
- -
- -
- -
- Action - @_evaluatedAction -
- -
- Resource - @_evaluatedResource -
- -
- Decision - - @(_evaluationResult.IsAllowed ? "Allow" : "Deny") - -
- -
- Reauthentication - - @(_evaluationResult.RequiresReauthentication - ? "Required" - : "Not required") - -
- - @if (!string.IsNullOrWhiteSpace(_evaluationResult.DenyReason)) - { -
- Denial Reason - @_evaluationResult.DenyReason -
- } - -
+
No permissions match your filters.
} +
+
- - Authorization is evaluated by UltimateAuth on the server. - The result reflects your current authentication context, - not a simulated role. - - -
- +
+ +
+ Want to evaluate an authorization decision? +

Use My Access to test an operation against your current identity and sandbox policies.

+
+ My Access
-
@code { private const int PageSize = 10; + private sealed record CatalogEntry(string Value, bool IsDemo); + private sealed record GrantGroup(string Name, IReadOnlyList Values); + + private static readonly IReadOnlyList FrameworkPermissions = UAuthPermissionCatalog.GetAll() + .Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToArray(); + private static readonly IReadOnlyList DemoPermissions = LabDemoPermissions.Manager + .Concat(LabDemoPermissions.Customer) + .Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToArray(); + private static readonly IReadOnlyList Catalog = FrameworkPermissions + .Select(x => new CatalogEntry(x, false)) + .Concat(DemoPermissions.Select(x => new CatalogEntry(x, true))) + .GroupBy(x => x.Value, StringComparer.OrdinalIgnoreCase) + .Select(x => x.First()) + .OrderBy(x => x.Value, StringComparer.OrdinalIgnoreCase).ToArray(); private readonly List _roles = []; - private readonly List _permissions = []; - private RoleId? _selectedRoleId; - private string _permissionSearch = string.Empty; - private int _page = 1; private int _totalRoles; - private bool _loading; private string? _error; - - private string? _playgroundAction; - private string _playgroundResource = "users"; - private string? _playgroundResourceId; - - private bool _evaluating; - - private UltimateAuth.Authorization.Contracts.AuthorizationResult? _evaluationResult; - - private string? _evaluatedAction; - private string? _evaluatedResource; - - private int TotalPages => - Math.Max(1, (_totalRoles + PageSize - 1) / PageSize); - - private RoleInfo? SelectedRole => - _roles.FirstOrDefault(x => x.Id == _selectedRoleId); - - private IEnumerable FilteredPermissions => - _permissions.Where(x => - string.IsNullOrWhiteSpace(_permissionSearch) || - x.Contains(_permissionSearch, StringComparison.OrdinalIgnoreCase)); - - private HashSet GrantedPermissions => - SelectedRole is null - ? [] - : PermissionExpander - .Expand( - SelectedRole.Permissions, - UAuthPermissionCatalog.GetAdminPermissions()) - .Select(x => x.Value) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - - protected override async Task OnInitializedAsync() + private string _catalogSearch = string.Empty; + private string _catalogSource = "all"; + + private int TotalPages => Math.Max(1, (_totalRoles + PageSize - 1) / PageSize); + private RoleInfo? SelectedRole => _roles.FirstOrDefault(x => x.Id == _selectedRoleId); + private IEnumerable> CatalogGroups => Catalog + .Where(x => (_catalogSource == "all" || (_catalogSource == "demo") == x.IsDemo) + && (string.IsNullOrWhiteSpace(_catalogSearch) + || x.Value.Contains(_catalogSearch, StringComparison.OrdinalIgnoreCase) + || GroupTitle(Domain(x.Value)).Contains(_catalogSearch, StringComparison.OrdinalIgnoreCase))) + .GroupBy(x => Domain(x.Value)) + .OrderBy(x => x.Key); + + private IReadOnlyList GrantedGroups => SelectedRole is null ? [] : SelectedRole.Permissions + .Select(x => x.Value) + .Where(x => x != "*") + .GroupBy(Domain) + .OrderBy(x => x.Key) + .Select(x => new GrantGroup(x.Key, x.OrderBy(v => v, StringComparer.OrdinalIgnoreCase).ToArray())) + .ToArray(); + + private static bool HasWildcard(RoleInfo role) => role.Permissions.Any(x => x.Value == "*"); + private static bool IsSystemRole(string name) => name is "Ladmin" or "Owner" or "User"; + private static string RoleKind(string name) => IsSystemRole(name) ? "System" : name is "Manager" or "Customer" ? "Demo" : "Custom"; + private static string RoleGrantSummary(RoleInfo role) => HasWildcard(role) ? "Wildcard (*)" : $"{role.Permissions.Count} stored grants"; + private static string Domain(string permission) { - _permissions.AddRange( - UAuthPermissionCatalog.GetAdminPermissions() - .OrderBy(x => x)); - - await LoadRolesAsync(); + var parts = permission.Split('.'); + return parts.Length > 1 && parts[0] == "demo" ? $"demo.{parts[1]}" : parts[0]; + } + private static string GroupTitle(string key) => key switch + { + "users" => "Users & Profiles", + "credentials" => "Credentials", + "sessions" => "Sessions", + "authorization" => "Authorization", + "flows" => "Authentication Flows", + "demo.products" => "Demo Products", + "demo.orders" => "Demo Orders", + _ => key + }; + private static string GroupDescription(string key) => key switch + { + "users" => "User lifecycle, profiles and identifiers", + "credentials" => "Credential and password operations", + "sessions" => "Session chains and revocation", + "authorization" => "Roles, permissions and authorization checks", + "flows" => "Authentication flow operations", + "demo.products" => "Product access in the sample SaaS domain", + "demo.orders" => "Order operations in the sample SaaS domain", + _ => "Available actions and permission keys" + }; + private static string ActionLabel(string value) + { + if (value == "*") return "All permissions"; + var part = value.Split('.').Last(); + if (part == "admin" || part == "self") + { + var pieces = value.Split('.'); + if (pieces.Length > 1) part = pieces[^2] + " (" + part + ")"; + } + return string.Join(" ", part.Replace('_', ' ').Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Select(x => char.ToUpperInvariant(x[0]) + x[1..])); } + protected override async Task OnInitializedAsync() => await LoadRolesAsync(); + private async Task LoadRolesAsync() { - if (_loading) - return; - + if (_loading) return; _loading = true; _error = null; - try { - var result = await UAuthClient.Authorization.QueryRolesAsync( - new RoleQuery - { - PageNumber = _page, - PageSize = PageSize, - SortBy = nameof(RoleInfo.CreatedAt), - Descending = true - }); - + var result = await UAuthClient.Authorization.QueryRolesAsync(new RoleQuery + { + PageNumber = _page, + PageSize = PageSize, + SortBy = nameof(RoleInfo.CreatedAt), + Descending = true + }); if (!result.IsSuccess || result.Value is null) { _error = result.ErrorText ?? "Unable to load roles."; return; } - _roles.Clear(); _roles.AddRange(result.Value.Items); _totalRoles = result.Value.TotalCount; - - if (SelectedRole is null) - _selectedRoleId = _roles.FirstOrDefault()?.Id; - } - catch (Exception) - { - _error = "Authorization service is unavailable."; - } - finally - { - _loading = false; + if (SelectedRole is null) _selectedRoleId = _roles.FirstOrDefault()?.Id; } + catch (Exception) { _error = "Authorization service is unavailable."; } + finally { _loading = false; } } - private async Task OnPageChangedAsync(int page) - { - _page = page; - await LoadRolesAsync(); - } - - private void SelectRole(RoleInfo role) - { - _selectedRoleId = role.Id; - } + private async Task OnPageChangedAsync(int page) { _page = page; await LoadRolesAsync(); } + private void SelectRole(RoleInfo role) => _selectedRoleId = role.Id; private async Task OpenRoleManagementAsync() { - var parameters = new DialogParameters - { - { x => x.PreviewMode, false } - }; - - var dialog = await DialogService.ShowAsync( - "Role Management", - parameters, + var parameters = new DialogParameters { { x => x.PreviewMode, false } }; + var dialog = await DialogService.ShowAsync("Role Management", parameters, UAuthDialog.GetDialogOptions(MaxWidth.Large)); - await dialog.Result; - await LoadRolesAsync(); } private async Task EditPermissionsAsync() { - if (SelectedRole is null) - return; - + if (SelectedRole is null || IsSystemRole(SelectedRole.Name)) return; var parameters = new DialogParameters { { x => x.Role, SelectedRole }, - { x => x.PreviewMode, false } + { x => x.PreviewMode, false }, + { x => x.AvailablePermissions, EditablePermissions } }; - - var dialog = await DialogService.ShowAsync( - "Edit Permissions", - parameters, + var dialog = await DialogService.ShowAsync("Manage Permissions", parameters, UAuthDialog.GetDialogOptions(MaxWidth.Large)); - await dialog.Result; - await LoadRolesAsync(); } - private async Task EvaluatePolicyAsync() - { - if (_evaluating || - string.IsNullOrWhiteSpace(_playgroundAction) || - string.IsNullOrWhiteSpace(_playgroundResource)) - return; - - _evaluating = true; - _evaluationResult = null; - - try - { - var request = new AuthorizationCheckRequest - { - Action = _playgroundAction.Trim(), - Resource = _playgroundResource.Trim(), - ResourceId = string.IsNullOrWhiteSpace(_playgroundResourceId) - ? null - : _playgroundResourceId.Trim() - }; - - var result = await UAuthClient.Authorization.CheckAsync(request); - - if (!result.IsSuccess || result.Value is null) - { - Snackbar.Add( - result.ErrorText ?? "Authorization evaluation failed.", - Severity.Error); - - return; - } - - _evaluationResult = result.Value; - _evaluatedAction = request.Action; - _evaluatedResource = request.Resource; - } - catch (Exception) - { - Snackbar.Add( - "Unable to evaluate authorization.", - Severity.Error); - } - finally - { - _evaluating = false; - } - } + // The management dialog is intentionally limited to admin actions and demo grants. + // The full catalog above is informational; self actions are not automatically editable. + private static readonly IReadOnlyList EditablePermissions = UAuthPermissionCatalog.GetAdminPermissions() + .Concat(DemoPermissions) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToArray(); } diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/AuthorizationPage.razor.css b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/AuthorizationPage.razor.css index ed4dcef6..45b91eb6 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/AuthorizationPage.razor.css +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/AuthorizationPage.razor.css @@ -1,122 +1,100 @@ .lab-authz-page { - padding: 42px 0 80px; + padding: 38px 0 72px; } .lab-authz-heading { display: flex; - align-items: flex-start; justify-content: space-between; + align-items: flex-start; gap: 20px; - margin-bottom: 30px; + margin-bottom: 25px; } .lab-authz-eyebrow { display: block; - margin-bottom: 10px; color: var(--mud-palette-primary); - font-size: .67rem; + font-size: .68rem; font-weight: 800; - letter-spacing: 1.2px; + letter-spacing: 1.1px; + margin-bottom: 8px; } .lab-authz-heading h1 { margin: 0; - font-size: clamp(1.7rem, 3vw, 2.15rem); + font-size: clamp(1.65rem,3vw,2.1rem); font-weight: 800; - letter-spacing: -1px; + letter-spacing: -.7px; } -.lab-authz-heading p, -.lab-authz-section-heading p { - margin: 8px 0 0; +.lab-authz-heading p, .lab-authz-section-heading p { color: var(--mud-palette-text-secondary); - font-size: .84rem; + font-size: .85rem; line-height: 1.6; + margin: 7px 0 0; } .lab-authz-stats { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 18px; - margin-bottom: 24px; + grid-template-columns: repeat(3,minmax(0,1fr)); + gap: 15px; } -.lab-authz-stat, -.lab-authz-card { +.lab-authz-stat, .lab-authz-card, .lab-authz-next-step { background: var(--mud-palette-surface); border: 1px solid var(--mud-palette-lines-default); - border-radius: 16px; + border-radius: 15px; } .lab-authz-stat { + padding: 20px; display: flex; flex-direction: column; - align-items: flex-start; - padding: 23px; -} - -.lab-authz-stat-icon, -.lab-authz-role-icon, -.lab-authz-permission-icon, -.lab-authz-result-empty > span { - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - width: 43px; - height: 43px; - border-radius: 12px; - background: var(--mud-palette-primary-hover); - color: var(--mud-palette-primary); + gap: 5px; } -.lab-authz-stat > span:nth-child(2) { - margin-top: 17px; - color: var(--mud-palette-text-secondary); - font-size: .76rem; -} + .lab-authz-stat span { + font-size: .78rem; + color: var(--mud-palette-text-secondary); + } -.lab-authz-stat strong { - margin-top: 5px; - font-size: 1.5rem; - font-weight: 800; -} + .lab-authz-stat strong { + font-size: 1.55rem; + font-weight: 800; + } -.lab-authz-stat small { - margin-top: 5px; - color: var(--mud-palette-text-secondary); - font-size: .72rem; -} + .lab-authz-stat small { + font-size: .72rem; + color: var(--mud-palette-text-secondary); + } .lab-authz-section-heading { - margin: 34px 0 19px; + margin: 34px 0 16px; } .lab-authz-section-heading h2 { - margin: 0; font-size: 1.15rem; font-weight: 750; - letter-spacing: -.4px; + margin: 0; } .lab-authz-main-grid { display: grid; - grid-template-columns: minmax(280px, .8fr) minmax(0, 1.4fr); - gap: 20px; + grid-template-columns: minmax(280px,.85fr) minmax(0,1.35fr); + gap: 18px; align-items: start; } .lab-authz-card { + padding: 23px; min-width: 0; - padding: 25px; } .lab-authz-card-heading { display: flex; align-items: flex-start; justify-content: space-between; - gap: 14px; - margin-bottom: 23px; + gap: 12px; + margin-bottom: 20px; } .lab-authz-card-heading h3 { @@ -127,30 +105,29 @@ .lab-authz-card-heading p { margin: 5px 0 0; + font-size: .77rem; color: var(--mud-palette-text-secondary); - font-size: .76rem; line-height: 1.5; } .lab-authz-role-list { display: flex; flex-direction: column; - gap: 10px; + gap: 9px; } .lab-authz-role { display: flex; align-items: center; - gap: 13px; + gap: 12px; width: 100%; - padding: 15px; + padding: 13px; border: 1px solid var(--mud-palette-lines-default); - border-radius: 12px; + border-radius: 11px; background: transparent; color: var(--mud-palette-text-primary); text-align: left; cursor: pointer; - transition: border-color .2s ease, background .2s ease; } .lab-authz-role:hover { @@ -162,163 +139,232 @@ background: var(--mud-palette-primary-hover); } +.lab-authz-role-icon { + display: flex; + align-items: center; + justify-content: center; + width: 37px; + height: 37px; + border-radius: 10px; + background: var(--mud-palette-primary-hover); + color: var(--mud-palette-primary); + flex-shrink: 0; +} + .lab-authz-role-content { display: flex; flex: 1; flex-direction: column; - gap: 5px; + gap: 3px; min-width: 0; } .lab-authz-role-content strong { - font-size: .85rem; - font-weight: 750; + font-size: .87rem; } - .lab-authz-role-content small, - .lab-authz-role-content > span { - color: var(--mud-palette-text-secondary); + .lab-authz-role-content small { font-size: .72rem; - line-height: 1.5; + color: var(--mud-palette-text-secondary); } -.lab-authz-permission-list { +.lab-authz-role-summary { display: flex; - flex-direction: column; + gap: 7px; + flex-wrap: wrap; + margin-bottom: 16px; } -.lab-authz-permission { - display: flex; - align-items: center; - gap: 12px; - padding: 15px 0; - border-top: 1px solid var(--mud-palette-lines-default); +.lab-authz-pill { + font-size: .7rem; + color: var(--mud-palette-text-secondary); + border: 1px solid var(--mud-palette-lines-default); + padding: 4px 9px; + border-radius: 100px; + white-space: nowrap; } -.lab-authz-permission-icon { - width: 36px; - height: 36px; - border-radius: 10px; +.lab-authz-grant-group { + border-top: 1px solid var(--mud-palette-lines-default); + padding: 15px 0; } -.lab-authz-permission > div { +.lab-authz-grant-title { display: flex; - flex: 1; - flex-direction: column; - gap: 5px; - min-width: 0; + justify-content: space-between; + gap: 10px; + align-items: center; + margin-bottom: 10px; } -.lab-authz-permission strong { - font-size: .8rem; - font-weight: 700; - overflow-wrap: anywhere; + .lab-authz-grant-title strong { + font-size: .85rem; + } + + .lab-authz-grant-title span { + font-size: .72rem; + color: var(--mud-palette-text-secondary); + } + +.lab-authz-grant-chips { + display: flex; + flex-wrap: wrap; + gap: 7px; } -.lab-authz-permission span { - color: var(--mud-palette-text-secondary); +.lab-authz-grant-chip { font-size: .72rem; + padding: 5px 9px; + border-radius: 8px; + background: var(--mud-palette-background-gray); + overflow-wrap: anywhere; } -.lab-authz-empty { - padding: 35px 10px; - text-align: center; +.lab-authz-footnote { + font-size: .73rem; + line-height: 1.6; color: var(--mud-palette-text-secondary); - font-size: .8rem; + margin: 18px 0 0; } -.lab-authz-playground-heading { - margin-top: 48px; +.lab-authz-catalog-tools { + display: grid; + grid-template-columns: minmax(0,2fr) minmax(180px,1fr); + gap: 12px; + margin-bottom: 20px; + align-items: start; } -.lab-authz-playground-grid { +.lab-authz-catalog-grid { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 20px; - align-items: stretch; + grid-template-columns: repeat(2,minmax(0,1fr)); + gap: 12px; + align-items: start; } -.lab-authz-fields { - display: flex; - flex-direction: column; - gap: 20px; +.lab-authz-catalog-group { + border: 1px solid var(--mud-palette-lines-default); + border-radius: 11px; + overflow: hidden; + min-width: 0; } -.lab-authz-result-card { - display: flex; - flex-direction: column; + .lab-authz-catalog-group summary { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + cursor: pointer; + padding: 16px; + list-style: none; + } + + .lab-authz-catalog-group summary::-webkit-details-marker { + display: none; + } + + .lab-authz-catalog-group summary > span:first-child { + display: flex; + flex-direction: column; + gap: 5px; + min-width: 0; + } + + .lab-authz-catalog-group summary strong { + font-size: .86rem; + } + + .lab-authz-catalog-group summary small { + font-size: .73rem; + color: var(--mud-palette-text-secondary); + line-height: 1.45; + } + +.lab-authz-catalog-items { + border-top: 1px solid var(--mud-palette-lines-default); + padding: 0 14px; } -.lab-authz-result-empty { +.lab-authz-catalog-item { display: flex; - flex: 1; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 30px 12px; - text-align: center; + justify-content: space-between; + align-items: flex-start; + gap: 10px; + padding: 12px 0; } - .lab-authz-result-empty > span { - width: 58px; - height: 58px; - border-radius: 17px; - margin-bottom: 18px; + .lab-authz-catalog-item + .lab-authz-catalog-item { + border-top: 1px solid var(--mud-palette-lines-default); } - .lab-authz-result-empty strong { - font-size: .92rem; - font-weight: 750; + .lab-authz-catalog-item > div { + display: flex; + flex-direction: column; + gap: 5px; + min-width: 0; } - .lab-authz-result-empty p { - max-width: 290px; - margin: 9px 0 0; - color: var(--mud-palette-text-secondary); - font-size: .77rem; - line-height: 1.7; + .lab-authz-catalog-item strong { + font-size: .78rem; } -@media (max-width: 1100px) { - .lab-authz-main-grid { - grid-template-columns: 1fr; + .lab-authz-catalog-item code { + font-size: .69rem; + color: var(--mud-palette-text-secondary); + overflow-wrap: anywhere; } + +.lab-authz-empty { + padding: 26px 12px; + text-align: center; + font-size: .8rem; + color: var(--mud-palette-text-secondary); } -@media (max-width: 760px) { - .lab-authz-page { - padding: 28px 0 52px; - } +.lab-authz-next-step { + display: flex; + align-items: center; + gap: 15px; + padding: 20px; + margin-top: 24px; +} - .lab-authz-heading { - flex-direction: column; + .lab-authz-next-step > div { + flex: 1; + min-width: 0; } - .lab-authz-stats, - .lab-authz-playground-grid { - grid-template-columns: 1fr; - gap: 12px; + .lab-authz-next-step strong { + font-size: .87rem; } - .lab-authz-section-heading { - margin-top: 30px; + .lab-authz-next-step p { + font-size: .76rem; + color: var(--mud-palette-text-secondary); + margin: 5px 0 0; } - .lab-authz-playground-heading { - margin-top: 36px; +@media(max-width:1050px) { + .lab-authz-main-grid { + grid-template-columns: 1fr; } } -@media (max-width: 600px) { - .lab-authz-card { - padding: 20px; +@media(max-width:720px) { + .lab-authz-stats, .lab-authz-catalog-grid, .lab-authz-catalog-tools { + grid-template-columns: 1fr; } - .lab-authz-permission { - flex-wrap: wrap; + .lab-authz-heading, .lab-authz-next-step { + flex-direction: column; + align-items: stretch; } - .lab-authz-role { - padding: 12px; + .lab-authz-page { + padding: 25px 0 50px; + } + + .lab-authz-card { + padding: 17px; } } diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/DemoStorePage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/DemoStorePage.razor new file mode 100644 index 00000000..37102e70 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/DemoStorePage.razor @@ -0,0 +1,327 @@ +@page "/dashboard/demo-store" +@layout DashboardLayout +@attribute [UAuthAuthorize] + +@using CodeBeam.UltimateAuth.IdentityLab.Authorization +@using CodeBeam.UltimateAuth.IdentityLab.Services + +@inject LabDemoStoreService DemoStoreService +@inject ISnackbar Snackbar + +Demo Store · Identity Lab + + + + + + + SAAS PLAYGROUND + Demo Store + Explore how UltimateAuth roles and permissions affect a sample SaaS application. + + + + This is a demonstration application. + Product changes and cart operations are temporary. + They do not affect your Sandbox, system roles or other users. + + + @if (_loading) + { + + } + else if (_error is not null) + { + + @_error + + + + Retry + + } + else + { + + + + Your Demo Access + + + + + Manager: @(CanManage ? "Enabled" : "Disabled") + + + + Customer: @(CanOrder ? "Enabled" : "Disabled") + + + + + Refresh Permissions + + + + + @if (!CanRead) + { + + You do not currently have access to the demo catalog. + Ask your Sandbox Owner to assign the Manager or Customer demo role. + + } + else + { + + + + Products + + + @if (CanCreate) + { + + Add Product + + } + + + + @foreach (var product in _products) + { + + + + + + + + @product.Name + + + + @product.Price.ToString("C") + + + @if (CanUpdate || CanDelete) + { + + @if (CanUpdate) + { + + Edit + + } + + @if (CanDelete) + { + + Delete + + } + + } + + @if (CanOrder) + { + + Add to Cart + + } + + + + + } + + + @if (CanOrder) + { + + + + + Shopping Cart + + + + @_cartCount items + + + + } + } + } + + + + +@code { + private readonly List _products = + [ + new(1, "Wireless Headphones", 89), + new(2, "Smart Watch", 149), + new(3, "Laptop Stand", 39), + new(4, "Desk Lamp", 59) + ]; + + private IReadOnlySet _permissions = + new HashSet(); + + private bool _loading = true; + private bool _busy; + private string? _error; + private int _nextId = 5; + private int _cartCount; + + private bool CanRead => + Has(LabDemoPermissions.ProductsRead); + + private bool CanCreate => + Has(LabDemoPermissions.ProductsCreate); + + private bool CanUpdate => + Has(LabDemoPermissions.ProductsUpdate); + + private bool CanDelete => + Has(LabDemoPermissions.ProductsDelete); + + private bool CanManage => + CanCreate || CanUpdate || CanDelete; + + private bool CanOrder => + Has(LabDemoPermissions.OrdersCreate); + + private bool Has(string permission) => + _permissions.Contains(permission); + + protected override async Task OnInitializedAsync() + { + await RefreshAccessAsync(); + } + + private async Task RefreshAccessAsync() + { + _loading = true; + _error = null; + + try + { + _permissions = + await DemoStoreService.GetPermissionsAsync(); + } + catch (Exception) + { + _permissions = new HashSet(); + _error = "Unable to evaluate demo permissions."; + } + finally + { + _loading = false; + } + } + + private async Task ExecuteAsync( + string permission, + Action operation, + string successMessage) + { + if (_busy) + return; + + _busy = true; + + try + { + await DemoStoreService.RequireAsync(permission); + + operation(); + + Snackbar.Add(successMessage, Severity.Success); + } + catch (UnauthorizedAccessException) + { + Snackbar.Add( + "Permission denied. Your demo role may have changed.", + Severity.Warning); + + await RefreshAccessAsync(); + } + catch (Exception) + { + Snackbar.Add( + "Demo operation failed.", + Severity.Error); + } + finally + { + _busy = false; + } + } + + private Task AddProductAsync() + => ExecuteAsync( + LabDemoPermissions.ProductsCreate, + () => + { + var id = _nextId++; + _products.Add( + new DemoProduct(id, $"Demo Product {id}", 25)); + }, + "Sample product created."); + + private Task EditProductAsync(DemoProduct product) + => ExecuteAsync( + LabDemoPermissions.ProductsUpdate, + () => + { + var index = _products.FindIndex(x => x.Id == product.Id); + + if (index >= 0) + { + _products[index] = product with + { + Price = product.Price + 10 + }; + } + }, + "Sample product price updated."); + + private Task DeleteProductAsync(DemoProduct product) + => ExecuteAsync( + LabDemoPermissions.ProductsDelete, + () => _products.RemoveAll(x => x.Id == product.Id), + "Sample product deleted."); + + private Task AddToCartAsync(DemoProduct product) + => ExecuteAsync( + LabDemoPermissions.OrdersCreate, + () => _cartCount++, + $"{product.Name} added to the demo cart."); +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MyAccessPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MyAccessPage.razor index 8ec16a09..2f27fb8b 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MyAccessPage.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MyAccessPage.razor @@ -1,12 +1,11 @@ @page "/dashboard/access" +@using CodeBeam.UltimateAuth.IdentityLab.Authorization @layout DashboardLayout @attribute [UAuthAuthorize] -@using CodeBeam.UltimateAuth.Authorization.Contracts -@using CodeBeam.UltimateAuth.Core.Contracts - @inject IUAuthClient UAuthClient +@inject SandboxUserService SandboxUserService @inject ISnackbar Snackbar My Access · Identity Lab @@ -20,14 +19,6 @@

My Access

Explore your assigned roles and permissions, then evaluate how UltimateAuth makes authorization decisions.

- - - Demo Preview -
@@ -46,14 +37,19 @@ Assigned Permissions - @(_permissionsLoading ? "..." : _permissions.Count.ToString()) + @(_permissionsLoading ? "..." : _permissions.Any(x => x.Value == "*") ? "Wildcard (*)" : _permissions.Count.ToString()) + Permissions inherited from your roles
- Available Actions - @_availablePermissions.Count - Discoverable authorization actions + + + + + Playground Scenarios + @Scenarios.Count + Predefined authorization checks
@@ -65,33 +61,28 @@

My Roles

Roles assigned to your identity.

- + -
+
@foreach (var role in _roles) { -
- +
- + -
+
@role.Name Assigned @role.AssignedAt.ToLocalTime().ToString("g")
- + Assigned -
} @@ -112,22 +103,12 @@ @if (_totalRoles > RolePageSize) { - + }
- - Your roles determine which operations you may - perform, together with the applicable policies - and authorization context. + + Your roles determine which operations you may perform, together with the applicable policies and authorization context. @@ -173,7 +154,14 @@
- @permission.Value + @(permission.Value == "*" ? "All Permissions (*)" : permission.Value) + + @if (permission.Value == "*") + { + + Wildcard permission. Access remains subject to applicable authorization policies. + + }
@@ -212,40 +200,103 @@
- + Value="@_selectedScenarioId" + ValueChanged="OnScenarioChanged" + AnchorOrigin="Origin.BottomCenter" + TransformOrigin="Origin.TopCenter" + Color="Color.Primary"> - @foreach (var permission in _availablePermissions) + @foreach (var group in Scenarios.GroupBy(x => x.Group)) { - - @permission - + + @foreach (var scenario in group) + { + + @scenario.Title + + } + } + - + @if (RequiresMemberTarget) + { + + + @foreach (var user in AvailableTargets) + { + + + @user.DisplayName + (@user.Username) + @(user.UserKey == AuthState.Identity?.UserKey ? "— You" : "") + @(user.IsOwner ? "— Owner" : "") - + + } + - + @if (_sandboxUsersLoading) + { + + } + else if (_sandboxUsersError is not null) + { + + @_sandboxUsersError + + } + else if (_sandboxUsers.Count == 0) + { + + No sandbox members are available. + + } + } + + + @SelectedScenario.Description + + + + + + Request Preview + + + + Action: @SelectedScenario.Action + + + + Resource: @SelectedScenario.Resource + + + + Target: @(ResolveResourceId(SelectedScenario) ?? "Not specified") + + + - @(_evaluating ? "Evaluating..." : "Evaluate Access") -
@@ -260,61 +311,102 @@ Color="Color.Primary" />
-
- @if (_accessResult is null) - { -
- - - + @if (_accessResult is null) + { +
+ + + - Ready to evaluate + Ready to evaluate + +

+ Select an operation and target to evaluate access. +

+
+ } + else + { +
+ +
+ + +
+

+ @(_accessResult.IsAllowed ? "Access Granted" : "Access Denied") +

-

- Select a permission to check your current access. -

-
- } - else - { - - - - @(_accessResult.IsAllowed - ? "Access Granted" - : "Access Denied") - - - @if (_accessResult.RequiresReauthentication) - { -

Reauthentication is required.

- } - else if (!string.IsNullOrWhiteSpace(_accessResult.DenyReason)) - { -

@_accessResult.DenyReason

- } - else - {

- @(_accessResult.IsAllowed - ? "Your current session is authorized." - : "Your current session is not authorized.") + @if (_accessResult.RequiresReauthentication) + { + Reauthentication is required. + } + else if (_accessResult.IsAllowed) + { + + The authorization request passed the applicable checks. + + } + else + { + @ExplainDecision(_accessResult.DenyReason) + }

+
+
+ +
+ +
+ Decision + + + + @(_accessResult.IsAllowed ? "Allowed" : "Denied") + + +
+ + @if (!_accessResult.IsAllowed) + { +
+ Reason source + + + @GetDecisionSource(_accessResult.DenyReason) + +
+ +
+ Technical reason + + + @(_accessResult.DenyReason ?? "Not specified") + +
} +
+ + + This is an authorization check only. + No operation has been executed. - } -
+ +
+ } @@ -368,6 +460,9 @@
@code { + [CascadingParameter] + private UAuthState AuthState { get; set; } = default!; + private readonly List _roles = []; private readonly List _permissions = []; @@ -383,16 +478,44 @@ private int TotalRolePages => Math.Max(1, (_totalRoles + RolePageSize - 1) / RolePageSize); - private string? _selectedAction; - private string _selectedResource = "users"; - private string? _selectedResourceId; - private UltimateAuth.Authorization.Contracts.AuthorizationResult? _accessResult; private bool _evaluating; - protected override async Task OnInitializedAsync() + private readonly List _sandboxUsers = []; + + private string? _selectedSandboxMemberUserKey; + private bool _sandboxUsersLoading; + private string? _sandboxUsersError; + + private bool RequiresMemberTarget => + SelectedScenario.Target == ScenarioTarget.SandboxMember; + + private IEnumerable AvailableTargets => _sandboxUsers.OrderByDescending(x => x.IsOwner).ThenBy(x => x.DisplayName); + + private UserKey? _loadedUserKey; + + protected override async Task OnParametersSetAsync() { - await Task.WhenAll(LoadRolesAsync(), LoadPermissionsAsync()); + var userKey = AuthState.Identity?.UserKey; + + if (userKey is null) + { + _loadedUserKey = null; + _sandboxUsers.Clear(); + _selectedSandboxMemberUserKey = null; + _accessResult = null; + return; + } + + if (_loadedUserKey == userKey) + return; + + _loadedUserKey = userKey; + + await Task.WhenAll( + LoadRolesAsync(), + LoadPermissionsAsync(), + LoadSandboxUsersAsync(userKey.Value)); } private async Task LoadRolesAsync() @@ -466,17 +589,48 @@ await LoadRolesAsync(); } - private readonly List _availablePermissions = - UAuthPermissionCatalog.GetAdminPermissions() - .OrderBy(x => x) - .ToList(); + // private readonly List _availablePermissions = UAuthPermissionCatalog.GetAdminPermissions().OrderBy(x => x).ToList(); + + private static readonly IReadOnlyList AvailablePermissions = + UAuthPermissionCatalog.GetAll() + .Concat(LabDemoPermissions.Manager) + .Concat(LabDemoPermissions.Customer) + .Distinct(StringComparer.Ordinal) + .OrderBy(x => x, StringComparer.Ordinal) + .ToArray(); + + private string? ResolveResourceId(AccessScenario scenario) + { + return scenario.Target switch + { + ScenarioTarget.Self => AuthState.Identity?.UserKey.Value, + ScenarioTarget.SandboxMember => _selectedSandboxMemberUserKey, + + _ => null + }; + } private async Task EvaluateAccessAsync() { - if (_evaluating || - string.IsNullOrWhiteSpace(_selectedAction) || - string.IsNullOrWhiteSpace(_selectedResource)) + if (_evaluating) + return; + + var scenario = SelectedScenario; + var resourceId = ResolveResourceId(scenario); + + if (scenario.Target != ScenarioTarget.None && + string.IsNullOrWhiteSpace(resourceId)) + { + Snackbar.Add("A valid target is required.", Severity.Warning); return; + } + + if (scenario.Target == ScenarioTarget.SandboxMember && + !AvailableTargets.Any(x => x.UserKey.Value == resourceId)) + { + Snackbar.Add("Select a valid sandbox member.", Severity.Warning); + return; + } _evaluating = true; _accessResult = null; @@ -486,18 +640,14 @@ var result = await UAuthClient.Authorization.CheckAsync( new AuthorizationCheckRequest { - Action = _selectedAction, - Resource = _selectedResource.Trim(), - ResourceId = string.IsNullOrWhiteSpace(_selectedResourceId) - ? null - : _selectedResourceId.Trim() + Action = scenario.Action, + Resource = scenario.Resource, + ResourceId = resourceId }); if (!result.IsSuccess || result.Value is null) { - Snackbar.Add( - result.ErrorText ?? "Access evaluation failed.", - Severity.Error); + Snackbar.Add(result.ErrorText ?? "Access evaluation failed.", Severity.Error); return; } @@ -512,4 +662,180 @@ _evaluating = false; } } + + private enum ScenarioTarget + { + None, + Self, + SandboxMember + } + + private sealed record AccessScenario( + string Id, + string Group, + string Title, + string Description, + string Action, + string Resource, + ScenarioTarget Target = ScenarioTarget.None); + + private static readonly IReadOnlyList Scenarios = + [ + new("profile-read", "My Account", + "View my profile", "Read your own profile.", + UAuthActions.UserProfiles.GetSelf, "users.profile", ScenarioTarget.Self), + + new("permissions-read", "My Account", + "View my permissions", "Read your inherited permissions.", + UAuthActions.Authorization.Permissions.GetSelf, + "authorization.permissions", ScenarioTarget.Self), + + new("sessions-read", "My Account", + "View my sessions", "List your session chains.", + UAuthActions.Sessions.ListChainsSelf, "sessions", ScenarioTarget.Self), + + new("member-profile-read", "Sandbox Administration", + "View member profile", "Read another sandbox member's profile.", + UAuthActions.UserProfiles.GetAdmin, "users.profile", ScenarioTarget.SandboxMember), + + new("member-identifiers-read", "Sandbox Administration", + "View member identifiers", "Read another member's identifiers.", + UAuthActions.UserIdentifiers.GetAdmin, "users.identifiers", ScenarioTarget.SandboxMember), + + new("member-credentials-read", "Sandbox Administration", + "List member credentials", "Read a member's credentials.", + UAuthActions.Credentials.ListAdmin, "credentials", ScenarioTarget.SandboxMember), + + new("member-sessions-read", "Sandbox Administration", + "List member sessions", "Read a member's session chains.", + UAuthActions.Sessions.ListChainsAdmin, "sessions", ScenarioTarget.SandboxMember), + + new("member-profile-update", "Sandbox Administration", + "Update member profile", "Test an Owner-only management operation.", + UAuthActions.UserProfiles.UpdateAdmin, "users.profile", ScenarioTarget.SandboxMember), + + new("member-delete", "Sandbox Administration", + "Delete member", "Test an Owner-only destructive operation. No deletion occurs.", + UAuthActions.Users.DeleteAdmin, "users", ScenarioTarget.SandboxMember), + + new("users-query", "Restricted Operations", + "Query all users", "Intentionally blocked by the Identity Lab sandbox.", + UAuthActions.Users.QueryAdmin, "users"), + + new("roles-assign", "Restricted Operations", + "Assign a role", "Role assignment is restricted by Identity Lab.", + UAuthActions.Authorization.Roles.AssignAdmin, "authorization.roles"), + + new("roles-delete", "Restricted Operations", + "Delete a role", "Role deletion is restricted by Identity Lab.", + UAuthActions.Authorization.Roles.DeleteAdmin, "authorization.roles") + ]; + + private static string ExplainDecision(string? reason) => reason switch + { + "sandbox_operation_restricted" => "This operation is intentionally restricted in Identity Lab.", + + "sandbox_target_required" => "This operation requires a specific target user.", + + "sandbox_targets_required" => "One or more target users must be specified.", + + "sandbox_target_out_of_scope" => "The target user does not belong to your sandbox.", + + "sandbox_owner_required" => "Only the sandbox Owner may perform this operation.", + + "sandbox_membership_required" => "Your account must belong to a sandbox.", + + "sandbox_read_only" => "This sandbox currently allows read-only operations.", + + "sandbox_suspended" => "This sandbox is suspended.", + + "sandbox_actor_required" => "An authenticated user is required.", + + "sandbox_owner_cannot_delete_self" => "The sandbox Owner cannot delete their account while owning a sandbox.", + + null or "" => "The authorization requirements were not satisfied.", + + _ => "The request was denied by an authorization rule." + }; + + private string _selectedScenarioId = "profile-read"; + + private AccessScenario SelectedScenario => Scenarios.First(x => x.Id == _selectedScenarioId); + + private Task OnScenarioChanged(string scenarioId) + { + if (!Scenarios.Any(x => x.Id == scenarioId)) + return Task.CompletedTask; + + _selectedScenarioId = scenarioId; + _accessResult = null; + + if (RequiresMemberTarget && + !AvailableTargets.Any(x => + x.UserKey.Value == _selectedSandboxMemberUserKey)) + { + _selectedSandboxMemberUserKey = + AvailableTargets.FirstOrDefault()?.UserKey.Value; + } + + return Task.CompletedTask; + } + + private Task OnTargetChanged(string? userKey) + { + _selectedSandboxMemberUserKey = userKey; + _accessResult = null; + return Task.CompletedTask; + } + + private async Task LoadSandboxUsersAsync(UserKey actorUserKey) + { + _sandboxUsersLoading = true; + _sandboxUsersError = null; + _selectedSandboxMemberUserKey = null; + _sandboxUsers.Clear(); + + try + { + var snapshot = await SandboxUserService.GetUsersAsync(actorUserKey); + + if (snapshot is null) + { + _sandboxUsersError = "No sandbox is associated with this account."; + return; + } + + _sandboxUsers.AddRange(snapshot.Users); + + // Admin scenarios default to another member when possible. + var preferredTarget = _sandboxUsers + .FirstOrDefault(x => x.UserKey != actorUserKey) + ?? _sandboxUsers.FirstOrDefault(); + + _selectedSandboxMemberUserKey = preferredTarget?.UserKey.Value; + } + catch (UnauthorizedAccessException) + { + _sandboxUsersError = "You are not authorized to browse sandbox members."; + } + catch (Exception) + { + _sandboxUsersError = "Unable to load sandbox members."; + } + finally + { + _sandboxUsersLoading = false; + } + } + + private static string GetDecisionSource(string? reason) + { + if (!string.IsNullOrWhiteSpace(reason) && + reason.StartsWith("sandbox_", StringComparison.Ordinal)) + { + return "Identity Lab Sandbox Policy"; + } + + return "UltimateAuth Authorization"; + } } diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MyAccessPage.razor.css b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MyAccessPage.razor.css index fa1f2767..58d81411 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MyAccessPage.razor.css +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MyAccessPage.razor.css @@ -263,6 +263,133 @@ line-height: 1.7; } +.lab-role-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.lab-role-row { + display: flex; + align-items: center; + gap: 14px; + padding: 16px; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 12px; + min-width: 0; +} + +.lab-role-details { + display: flex; + flex: 1; + flex-direction: column; + gap: 4px; + min-width: 0; +} + + .lab-role-details strong { + font-size: 0.95rem; + font-weight: 600; + } + + .lab-role-details span { + font-size: 0.8rem; + color: var(--mud-palette-text-secondary); + } + +.lab-role-row .mud-chip { + flex-shrink: 0; +} + +.lab-decision-panel { + display: flex; + flex-direction: column; + gap: 20px; + width: 100%; + min-width: 0; +} + +.lab-decision-heading { + display: flex; + align-items: flex-start; + gap: 16px; + padding: 20px; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 14px; + background: var(--mud-palette-surface); +} + + .lab-decision-heading > div { + flex: 1; + min-width: 0; + } + + .lab-decision-heading h3 { + font-size: 1.1rem; + font-weight: 700; + margin: 0 0 8px; + } + + .lab-decision-heading p { + font-size: 0.9rem; + line-height: 1.6; + margin: 0; + color: var(--mud-palette-text-secondary); + } + +.lab-decision-details { + display: flex; + flex-direction: column; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 12px; + overflow: hidden; +} + +.lab-decision-detail { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 12px; + padding: 14px 16px; +} + + .lab-decision-detail + .lab-decision-detail { + border-top: 1px solid var(--mud-palette-lines-default); + } + + .lab-decision-detail > span { + color: var(--mud-palette-text-secondary); + font-size: 0.85rem; + } + + .lab-decision-detail strong { + font-size: 0.9rem; + font-weight: 600; + } + + .lab-decision-detail code { + font-size: 0.8rem; + padding: 5px 9px; + border-radius: 6px; + background: var(--mud-palette-background-gray); + overflow-wrap: anywhere; + } + +@media (max-width: 600px) { + .lab-decision-detail { + align-items: flex-start; + flex-direction: column; + } +} + +@media (max-width: 480px) { + .lab-role-row { + gap: 10px; + padding: 12px; + } +} + @media (max-width: 960px) { .lab-access-grid, .lab-playground-grid { diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MyIdentityPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MyIdentityPage.razor index 9ecfb863..e90a060f 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MyIdentityPage.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MyIdentityPage.razor @@ -1,11 +1,14 @@ @page "/dashboard/identity" @using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.IdentityLab.Services @using CodeBeam.UltimateAuth.Users.Contracts @layout DashboardLayout +@inject SandboxAccountDeletionService SandboxAccountDeletionService +@inject LabDashboardService LabDashboardService @inject IUAuthClient UAuthClient @inject IDialogService DialogService -@attribute [UAuthAuthorize] +@attribute [UAuthAuthorize(Roles = "Ladmin,Owner,User")] My Identity · Identity Lab @@ -49,144 +52,187 @@
-
- - - + @if (_loading) + { + + -
- @_profile?.FirstName @_profile?.LastName - @@ @_profile?.UserName -
+ - + - - @_profile?.Status - -
+ -
-
- Display name - @_profile?.DisplayName -
+ + + } + else if (_loadError is not null) + { + + @_loadError + -
- Username - @_profile?.UserName -
+ + Try Again + + } + else if (_profile is null) + { + + Profile information is currently unavailable. + + } + else + { +
+ + + -
- Email - @_profile?.PrimaryEmail -
+
+ @_profile?.FirstName @_profile?.LastName + @@ @_profile?.UserName +
-
- Primary phone - @(_profile?.PrimaryPhone ?? "Not provided") -
+ -
- User Key - @_profile?.UserKey.Value + + @_profile?.Status +
-
- - - - - Additional Details - - -
+
- Profile key - @_profile?.ProfileKey + Display name + @_profile?.DisplayName
- First name - @FormatValue(_profile?.FirstName) + Username + @_profile?.UserName
- Last name - @FormatValue(_profile?.LastName) + Email + @_profile?.PrimaryEmail
- Birth date - @(_profile?.BirthDate?.ToString("d") ?? "Not provided") + Primary phone + @(_profile?.PrimaryPhone ?? "Not provided")
- Gender - @FormatValue(_profile?.Gender) + User Key + @_profile?.UserKey.Value
+
-
- Bio - @FormatValue(_profile?.Bio) -
+ -
- Language - @FormatValue(_profile?.Language) -
+ + + Additional Details + + + + Edit Profile + + + + +
+
+ Profile key + @_profile?.ProfileKey +
-
- Culture - @FormatValue(_profile?.Culture) -
+
+ First name + @FormatValue(_profile?.FirstName) +
-
- Time zone - @FormatValue(_profile?.TimeZone) -
+
+ Last name + @FormatValue(_profile?.LastName) +
-
- Created at - - @(_profile?.CreatedAt?.ToLocalTime().ToString("g") ?? "—") - -
+
+ Birth date + @(_profile?.BirthDate?.ToString("d") ?? "Not provided") +
-
- Metadata - - @if (_profile?.Metadata is { Count: > 0 } metadata) - { - - @foreach (var entry in metadata) - { - - @entry.Key: @entry.Value - - } - - } - else - { - No metadata - } -
-
-
+
+ Gender + @FormatValue(_profile?.Gender) +
- - Edit Profile - +
+ Bio + @FormatValue(_profile?.Bio) +
+ +
+ Language + @FormatValue(_profile?.Language) +
+ +
+ Culture + @FormatValue(_profile?.Culture) +
+ +
+ Time zone + @FormatValue(_profile?.TimeZone) +
+ +
+ Created at + + @(_profile?.CreatedAt?.ToLocalTime().ToString("g") ?? "—") + +
+ +
+ Metadata + + @if (_profile?.Metadata is { Count: > 0 } metadata) + { + + @foreach (var entry in metadata) + { + + @entry.Key: @entry.Value + + } + + } + else + { + No metadata + } +
+
+
+ } +
@@ -417,6 +463,13 @@ protected override async Task OnInitializedAsync() { await LoadProfileAsync(); + + if (_profile is not null) + { + var snapshot = await LabDashboardService.GetAsync(_profile.UserKey); + _isSandboxOwner = snapshot?.IsOwner == true; + } + await LoadIdentifiersAsync(); } @@ -424,6 +477,7 @@ { _loading = true; _loadError = null; + _profile = null; try { @@ -550,7 +604,8 @@ { { x => x.PreviewMode, false }, { x => x.AuthState, AuthState }, - { x => x.AllowAccountDeletion, !_isSandboxOwner } + { x => x.AllowAccountDeletion, !_isSandboxOwner }, + { x => x.OnDeleteAccountAsync, DeleteMySandboxAccountAsync } }, UAuthDialog.GetDialogOptions(MaxWidth.Medium)); @@ -563,6 +618,16 @@ } } + private async Task DeleteMySandboxAccountAsync() + { + var userKey = AuthState?.Identity?.UserKey; + + if (userKey is null || _isSandboxOwner) + return false; + + return await SandboxAccountDeletionService.DeleteMemberAsync(userKey.Value); + } + private static string GetIdentifierIcon(UserIdentifierType type) => type switch { diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MySessionsPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MySessionsPage.razor index e474690c..5731f856 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MySessionsPage.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MySessionsPage.razor @@ -1,15 +1,13 @@ @page "/dashboard/sessions" @layout DashboardLayout -@attribute [UAuthAuthorize] -@using CodeBeam.UltimateAuth.Client -@using CodeBeam.UltimateAuth.Core.Contracts -@using CodeBeam.UltimateAuth.Core.Domain -@using CodeBeam.UltimateAuth.Users.Contracts + @inject IUAuthClient UAuthClient @inject IDialogService DialogService @inject ISnackbar Snackbar @inject NavigationManager Navigation +@attribute [UAuthAuthorize(Roles = "Ladmin,Owner,User")] + My Sessions · Identity Lab
@@ -226,7 +224,7 @@
@DeviceName(_currentChain)Current · @_currentChain.State
- Sessions
} @@ -234,15 +232,21 @@ {
-
@DeviceName(chain)@chain.State · @chain.ChainId
- Sessions +
+ @DeviceName(chain) + + + @chain.State · @chain.ChainId + +
+ + Sessions +
} @if (_chains.Count(x => !x.IsCurrentDevice) > _treeVisibleCount) { - Show more chains + Show more chains } @if (_totalChains > PageSize) { @@ -357,7 +361,7 @@ { for (var page = 1; page <= TotalPages; page++) { - var result = await UAuthClient.Sessions.GetMyChainsAsync(CreateChainsRequest(_page)); + var result = await UAuthClient.Sessions.GetMyChainsAsync(CreateChainsRequest(page)); if (!result.IsSuccess || result.Value is null) { diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MySessionsPage.razor.css b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MySessionsPage.razor.css index 440fb231..68e0bc9e 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MySessionsPage.razor.css +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MySessionsPage.razor.css @@ -427,3 +427,20 @@ padding: 10px; } } + +.lab-tree-node { + min-width: 0; +} + +.lab-tree-content { + flex: 1; + min-width: 0; +} + + .lab-tree-content strong, + .lab-tree-chain-id { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/SessionDiagnostics.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/SessionDiagnostics.razor new file mode 100644 index 00000000..ce103e79 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/SessionDiagnostics.razor @@ -0,0 +1,430 @@ +@page "/dashboard/session-diagnostics" +@layout DashboardLayout +@implements IAsyncDisposable + +@inject IUAuthClient UAuthClient +@inject LabSessionDiagnosticsPolicyService PolicyService +@inject UAuthClientDiagnostics Diagnostics +@inject ISnackbar Snackbar + +Session Diagnostics · Identity Lab + + + + + + + + ULTIMATEAUTH / SESSION LIFECYCLE + Session Diagnostics + Explore the real lifecycle of your current authentication session. + + + + + + @if (_loading) + { + + } + else if (_error is not null) + { + + @_error + + } + else if (_session is not null && _chain is not null) + { + + + + + + + + Current authentication + + + + Server-managed session lifecycle + + + + + @(_session.IsRevoked ? "Revoked" : IsExpired ? "Expired" : "Session active") + + + + + + + + + + + + + + SESSION EXPIRES IN + + + + + + + + @SessionCountdown + + + + @_session.ExpiresAt.ToLocalTime().ToString("dd MMM yyyy · HH:mm:ss") + + + + + + + + + + + + + LAST RECORDED ACTIVITY + + + + @LastSeenText + + + + Activity timestamps update according to the server touch policy. + + + + + + + + + + + + + + NEXT ELIGIBLE TOUCH + + + + + + + + @TouchCountdown + + + + Minimum interval between recorded touches. + This is not a scheduled request. + + + + + + + + + + + + + + IDLE EXPIRATION + + + + + + + + @IdleCountdown + + + + Based on the last recorded activity. Successful refreshes may reset this deadline. + + + + + + + + + + + + + + NEXT AUTOMATIC REFRESH + + + + @AutomaticRefreshCountdown + + + + Client coordinator · Every 10 minutes + + + + Automatic attempts: @Diagnostics.AutomaticRefreshCount + + + + + + + + + + + + + Session lifetime + + + + @(_policy?.SlidingExpiration == true + ? "Sliding enabled" + : "Fixed expiration") + + + +
+ +
+ +
+
+ + @if (ExtensionWidth > 0) + { +
+
+ } + +
+
+ +
+ + + + ● Elapsed + + + + ○ Remaining + + + @if (ExtensionWidth > 0) + { + + ● Sliding extension + + } + +
+ + + + @_session.CreatedAt.ToLocalTime().ToString("dd MMM HH:mm") + + + + @_session.ExpiresAt.ToLocalTime().ToString("dd MMM HH:mm") + + + + + + + + Original expiration + + + + @(_initialExpiresAt?.ToLocalTime().ToString("dd MMM yyyy · HH:mm") + ?? "Not available") + + + + + + Current expiration + + + + @_session.ExpiresAt.ToLocalTime().ToString("dd MMM yyyy · HH:mm") + + + + + + @if (SlidingExtension > TimeSpan.Zero) + { + + + + Session extended by @SlidingExtensionText + + + } +
+ +
+
+ + + + + + Touch count + + @_chain.TouchCount + + + + + + + Rotations + + @_chain.RotationCount + + + + + + + Chain state + + @_chain.State + + + + + + + + + Authentication Playground + + + + Execute real UltimateAuth authentication flows and inspect their results. + + + + + + Validate session + + + + Refresh session + + + + + + @if (_lastRefresh is not null) + { + + + + + + + + @_lastRefresh.Title + + + + @_lastRefresh.Description + + + + + + + + + } + + + Validation checks session validity. + Refresh follows the active authentication mode and does not necessarily rotate a token. + + + + + + + + + Understanding your session + + + + UltimateAuth separates authentication into a root, a device-associated chain, and individual sessions. + + + + The expiration shown above belongs to the active session returned by the server. + Touch activity and session rotation are independent lifecycle operations. + + + + A valid session may become invalid earlier because of revocation, idle expiration, device checks, or other server policies. + + + + } + +
+
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/SessionDiagnostics.razor.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/SessionDiagnostics.razor.cs new file mode 100644 index 00000000..5eb9be62 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/SessionDiagnostics.razor.cs @@ -0,0 +1,556 @@ +// +#nullable enable +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.IdentityLab.Services; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.IdentityLab.Components.Pages.Dashboard; + +public partial class SessionDiagnostics +{ + private LabSessionDiagnosticsPolicy? _policy; + private SessionChainSummary? _chain; + private SessionInfo? _session; + + private bool _loading = true; + private bool _busy; + private string? _error; + + private sealed record RefreshFeedback(string Title, string Description, Color Color, string Icon); + private RefreshFeedback? _lastRefresh; + + private DateTimeOffset? _initialExpiresAt; + private DateTimeOffset? _previousExpiresAt; + private DateTimeOffset? _lastExtendedAt; + + private bool _syncPending; + private bool _manualRefreshInProgress; + + private DateTimeOffset _now = DateTimeOffset.UtcNow; + private readonly CancellationTokenSource _timerCts = new(); + + protected override async Task OnInitializedAsync() + { + _disposed = false; + Diagnostics.Changed += OnDiagnosticsChanged; + _policy = PolicyService.Get(); + await LoadAsync(); + _ = RunClockAsync(_timerCts.Token); + } + + private bool IsExpired => _session is { } session && session.ExpiresAt <= _now; + + private string SessionCountdown + { + get + { + if (_session is null) + return "—"; + + var remaining = _session.ExpiresAt - _now; + + if (remaining <= TimeSpan.Zero) + return "Expired"; + + if (remaining.TotalDays >= 1) + return $"{(int)remaining.TotalDays}d {remaining.Hours}h"; + + return $"{(int)remaining.TotalHours:00}:" + + $"{remaining.Minutes:00}:" + + $"{remaining.Seconds:00}"; + } + } + + private string LastSeenText + { + get + { + if (_chain?.LastSeenAt is not { } lastSeen) + return "Not available"; + + var elapsed = _now - lastSeen; + + if (elapsed < TimeSpan.Zero) + return "Just now"; + + if (elapsed.TotalMinutes < 1) + return "Just now"; + + if (elapsed.TotalHours < 1) + return $"{(int)elapsed.TotalMinutes} min ago"; + + if (elapsed.TotalDays < 1) + return $"{(int)elapsed.TotalHours} hours ago"; + + return $"{(int)elapsed.TotalDays} days ago"; + } + } + + private void UpdateLifetimeTracking(SessionInfo session) + { + _initialExpiresAt ??= session.CreatedAt.Add(_policy!.SessionLifetime); + + if (_previousExpiresAt is { } previous && session.ExpiresAt > previous) + { + _lastExtendedAt = DateTimeOffset.UtcNow; + } + + _previousExpiresAt = session.ExpiresAt; + } + + private double ElapsedPercent => GetPercent(_session!.CreatedAt, _now, _session.ExpiresAt); + + private double ExtendedPercent => GetPercent(_initialExpiresAt!.Value, _session!.ExpiresAt, _session.ExpiresAt); + + private double RemainingPercent => Math.Max(0, 100 - ElapsedPercent); + + private static double GetPercent(DateTimeOffset start, DateTimeOffset end, DateTimeOffset totalEnd) + { + // This helper will be replaced with a + // shared timeline scale in the final implementation. + return 0; + } + + private double NowPosition => + TimelinePosition(_now); + + private double ExtensionStartPosition => + _initialExpiresAt is { } initial + ? TimelinePosition(initial) + : 100; + + private double ExtensionWidth => + _initialExpiresAt is not null && _session is not null + ? Math.Max(0, 100 - ExtensionStartPosition) + : 0; + + + private readonly SemaphoreSlim _syncGate = new(1, 1); + + private int _observedRefreshResults; + private bool _disposed; + + private int CompletedRefreshResults => + Diagnostics.RefreshTouchedCount + + Diagnostics.RefreshRotatedCount + + Diagnostics.RefreshNoOpCount + + Diagnostics.RefreshReauthRequiredCount + + Diagnostics.RefreshSuccessCount; + + private async Task LoadAsync() + { + if (_disposed) + return; + + _loading = _session is null; + + try + { + await SyncSessionAsync(); + } + finally + { + _loading = false; + } + } + + private async Task LoadSessionDataAsync() + { + var pageNumber = 1; + SessionChainSummary? current = null; + + while (current is null) + { + var result = await UAuthClient.Sessions.GetMyChainsAsync( + new PageRequest + { + PageNumber = pageNumber, + PageSize = 20 + }); + + if (!result.IsSuccess || result.Value is not { } page) + throw new InvalidOperationException( + "Unable to retrieve session chains."); + + current = page.Items.FirstOrDefault( + x => x.IsCurrentDevice); + + if (current is not null || !page.HasNext) + break; + + pageNumber++; + } + + if (current is null) + throw new InvalidOperationException( + "No current session chain was found."); + + var detailResult = + await UAuthClient.Sessions.GetMyChainDetailAsync( + current.ChainId); + + if (!detailResult.IsSuccess || + detailResult.Value is not { } detail) + { + throw new InvalidOperationException( + "Unable to retrieve session details."); + } + + var activeSession = detail.Sessions.FirstOrDefault( + x => current.ActiveSessionId is { } activeId && + x.SessionId == activeId); + + if (activeSession is null) + throw new InvalidOperationException( + "No active session was found."); + + // Reset timeline tracking when the active session changes. + if (_session is not null && + _session.SessionId != activeSession.SessionId) + { + _initialExpiresAt = null; + _previousExpiresAt = null; + _lastExtendedAt = null; + } + + UpdateLifetimeTracking(activeSession); + + _chain = current; + _session = activeSession; + _now = DateTimeOffset.UtcNow; + } + + private async Task ValidateAsync() + { + if (_busy) + return; + + _busy = true; + + try + { + var result = await UAuthClient.Flows.ValidateAsync(); + + if (result.IsValid) + { + Snackbar.Add("Session validated successfully. " + $"Current state: {result.State}.", Severity.Success); + } + else + { + Snackbar.Add("Session validation failed. " + $"Current state: {result.State}.", Severity.Warning); + } + } + catch (Exception) + { + Snackbar.Add("Unable to validate the session. " + "Please try again.", Severity.Error); + } + finally + { + _busy = false; + } + } + + private async Task RefreshAsync() + { + if (_busy) + return; + + _busy = true; + _manualRefreshInProgress = true; + _lastRefresh = null; + + try + { + var result = await UAuthClient.Flows.RefreshAsync(false); + var feedback = GetRefreshFeedback(result.Outcome); + + if (result.Outcome == RefreshOutcome.NoOp && NextEligibleTouchAt is { } next && next > DateTimeOffset.UtcNow) + { + feedback = feedback with + { + Description = + "The refresh request was processed, but the " + + "touch interval has not elapsed. " + + $"Next eligible touch: {next.ToLocalTime():HH:mm:ss}." + }; + } + + if (!result.IsSuccess && result.Outcome != RefreshOutcome.NoOp) + { + feedback = new RefreshFeedback( + "Refresh unsuccessful", + $"Server outcome: {result.Outcome}", + Color.Warning, + Icons.Material.Filled.WarningAmber); + } + + _lastRefresh = feedback; + + Snackbar.Add(feedback.Title, + feedback.Color switch + { + Color.Success => Severity.Success, + Color.Warning => Severity.Warning, + Color.Error => Severity.Error, + _ => Severity.Info + }); + + // Reload the actual session metadata. + // Do not infer touch or rotation from the request. + } + catch (Exception) + { + _lastRefresh = new RefreshFeedback( + "Refresh failed", + "The request could not be completed.", + Color.Error, + Icons.Material.Filled.ErrorOutline); + + Snackbar.Add("Unable to refresh the session.", Severity.Error); + } + finally + { + _manualRefreshInProgress = false; + _busy = false; + } + + await LoadAsync(); + } + + private DateTimeOffset? NextEligibleTouchAt => + _chain?.LastSeenAt is { } lastSeen && + _policy?.TouchInterval is { } interval && + interval > TimeSpan.Zero + ? lastSeen.Add(interval) + : null; + + private DateTimeOffset? IdleExpiresAt => + _chain?.LastSeenAt is { } lastSeen && + _policy?.IdleTimeout is { } timeout && + timeout > TimeSpan.Zero + ? lastSeen.Add(timeout) + : null; + + private string TouchCountdown => + NextEligibleTouchAt is { } next + ? FormatCountdown(next, "Eligible now") + : "Not configured"; + + private string IdleCountdown => + IdleExpiresAt is { } expiry + ? FormatCountdown(expiry, "Idle limit reached") + : "Disabled"; + + private string AutoRefreshIntervalText => + _policy?.AutoRefreshEnabled == true && + _policy.AutoRefreshInterval is { } interval + ? $"{interval.TotalMinutes:0} minutes" + : "Disabled"; + + private string FormatCountdown(DateTimeOffset target, string completedText) + { + var remaining = target - _now; + + if (remaining <= TimeSpan.Zero) + return completedText; + + if (remaining.TotalDays >= 1) + return $"{(int)remaining.TotalDays}d {remaining.Hours}h"; + + return $"{(int)remaining.TotalHours:00}:" + + $"{remaining.Minutes:00}:" + + $"{remaining.Seconds:00}"; + } + + private async Task RunClockAsync(CancellationToken ct) + { + try + { + using var timer = new PeriodicTimer( + TimeSpan.FromSeconds(1)); + + while (await timer.WaitForNextTickAsync(ct)) + { + await InvokeAsync(() => + { + _now = DateTimeOffset.UtcNow; + StateHasChanged(); + }); + } + } + catch (OperationCanceledException) + { + // Component disposed. + } + } + + private string AutomaticRefreshCountdown + { + get + { + if (!Diagnostics.IsRunning) + return "Not running"; + + if (Diagnostics.NextAutomaticRefreshAt is not { } next) + return "Scheduling..."; + + return FormatCountdown(next, "Due now"); + } + } + + private static RefreshFeedback GetRefreshFeedback(RefreshOutcome outcome) + { + return outcome switch + { + RefreshOutcome.NoOp => new( + "No changes needed", + "Refresh was processed, but no session renewal " + + "was necessary at this time.", + Color.Info, + Icons.Material.Outlined.Info), + + RefreshOutcome.Touched => new( + "Session touched", + "Session activity was updated. " + + "Sliding expiration may have extended its lifetime.", + Color.Success, + Icons.Material.Filled.TouchApp), + + RefreshOutcome.Rotated => new( + "Session rotated", + "The server issued a new authentication session.", + Color.Success, + Icons.Material.Filled.Autorenew), + + RefreshOutcome.Success => new( + "Refresh succeeded", + "The authentication refresh completed successfully.", + Color.Success, + Icons.Material.Filled.CheckCircle), + + RefreshOutcome.ReauthRequired => new( + "Reauthentication required", + "The current authentication can no longer be continued.", + Color.Warning, + Icons.Material.Filled.LockClock), + + _ => new( + "Refresh completed", + $"Server outcome: {outcome}", + Color.Info, + Icons.Material.Outlined.Info) + }; + } + + private double TimelinePosition(DateTimeOffset point) + { + if (_session is null) + return 0; + + var start = _session.CreatedAt; + var end = _session.ExpiresAt; + var total = (end - start).TotalMilliseconds; + + if (total <= 0) + return 0; + + return Math.Clamp((point - start).TotalMilliseconds / total * 100, 0, 100); + } + + private void OnDiagnosticsChanged() + { + if (_disposed) + return; + + _ = InvokeAsync(async () => + { + if (_disposed) + return; + + var completed = CompletedRefreshResults; + + if (completed != _observedRefreshResults) + { + _observedRefreshResults = completed; + + if (!_manualRefreshInProgress) + await SyncSessionAsync(); + } + + if (!_disposed) + StateHasChanged(); + }); + } + + private async Task SyncSessionAsync() + { + if (_disposed) + return; + + if (!await _syncGate.WaitAsync(0)) + { + _syncPending = true; + return; + } + + try + { + do + { + _syncPending = false; + + try + { + await LoadSessionDataAsync(); + _error = null; + } + catch (Exception) + { + _error = "Unable to synchronize session diagnostics."; + } + } + while (_syncPending && !_disposed); + } + finally + { + _syncGate.Release(); + } + + if (!_disposed) + await InvokeAsync(StateHasChanged); + } + + private TimeSpan SlidingExtension => + _session is not null && + _initialExpiresAt is { } initial && + _session.ExpiresAt > initial ? _session.ExpiresAt - initial : TimeSpan.Zero; + + private string SlidingExtensionText + { + get + { + var duration = SlidingExtension; + + if (duration <= TimeSpan.Zero) + return "Not extended yet"; + + if (duration.TotalDays >= 1) + return $"+{duration.TotalDays:0.#} days"; + + if (duration.TotalHours >= 1) + return $"+{duration.TotalHours:0.#} hours"; + + return $"+{duration.TotalMinutes:0} minutes"; + } + } + + public ValueTask DisposeAsync() + { + _timerCts.Cancel(); + _timerCts.Dispose(); + Diagnostics.Changed -= OnDiagnosticsChanged; + _disposed = true; + return ValueTask.CompletedTask; + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/SessionDiagnostics.razor.css b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/SessionDiagnostics.razor.css new file mode 100644 index 00000000..e8e78d92 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/SessionDiagnostics.razor.css @@ -0,0 +1,42 @@ +.lab-session-timeline-track { + position: relative; + height: 12px; + border-radius: 999px; + background: var(--mud-palette-action-default-hover); +} + +.lab-session-timeline-elapsed { + position: absolute; + inset: 0 auto 0 0; + background: var(--mud-palette-text-secondary); + border-radius: inherit; +} + +.lab-session-timeline-extension { + position: absolute; + top: 0; + bottom: 0; + background: rgba(13, 148, 136, .65); + border-radius: 999px; +} + +.lab-session-timeline-now { + position: absolute; + top: -6px; + bottom: -6px; + width: 2px; + background: var(--mud-palette-primary); + transform: translateX(-50%); +} + +.lab-session-timeline-extension { + z-index: 1; +} + +.lab-session-timeline-elapsed { + z-index: 2; +} + +.lab-session-timeline-now { + z-index: 3; +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/SettingsPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/SettingsPage.razor index 860ed21d..c1950327 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/SettingsPage.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/SettingsPage.razor @@ -1,7 +1,7 @@ @page "/dashboard/settings" -@using CodeBeam.UltimateAuth.Core.Domain -@using CodeBeam.UltimateAuth.IdentityLab.Domain -@using CodeBeam.UltimateAuth.IdentityLab.Services +@using CodeBeam.UltimateAuth.IdentityLab.Persistence +@using CodeBeam.UltimateAuth.Core.Runtime +@using Microsoft.EntityFrameworkCore @layout DashboardLayout @attribute [UAuthAuthorize] @@ -9,7 +9,11 @@ @inject SandboxService SandboxService @inject SandboxUserService SandboxUserService +@inject SandboxRepairService SandboxRepairService @inject ISnackbar Snackbar +@inject IDialogService DialogService +@inject IUAuthProductInfoProvider ProductInfoProvider +@inject IDbContextFactory DbContextFactory Sandbox Settings · Identity Lab @@ -20,10 +24,7 @@
SANDBOX MANAGEMENT

Sandbox Settings

-

- Review your sandbox configuration, - membership limits, and security boundaries. -

+

Review your sandbox configuration, membership limits and security boundaries.

@@ -37,8 +38,7 @@

General

Basic information about your sandbox.

- +
@@ -49,7 +49,8 @@ Variant="Variant.Outlined" ReadOnly="@(!_isOwner)" MaxLength="150" - Immediate="true" /> + Immediate="true" + HelperText="Names are for display and do not need to be unique." /> - + @if (_isOwner) { - - + @if (_saving) { - + } Save Changes @@ -92,30 +82,18 @@

Membership & Capacity

Available identities in this environment.

- +
Occupied user slots - - - @_memberCount / @_maxUsers - + @_memberCount / @_maxUsers
- - -

- Your sandbox supports up to @_maxUsers accounts, - including the owner. -

+ +

Your sandbox supports up to @_maxUsers accounts, including the owner.

@@ -134,11 +112,7 @@ Owner + Members - + Manage Users @@ -153,8 +127,7 @@

Security Boundaries

Rules that protect sandbox isolation.

- +
@@ -178,9 +151,7 @@ Scoped Administration

Sandbox owners can manage their sandbox through application-level access controls.

- +
@@ -191,17 +162,12 @@ Membership Limit

Each sandbox supports up to @_maxUsers accounts, including the owner.

- + - + Identity Lab applies application-level sandbox access controls. Sandbox isolation must also be enforced by every operation that accesses or modifies shared UltimateAuth resources. @@ -212,34 +178,31 @@

Environment

-

Information about this Identity Lab instance.

+

Application, runtime and sandbox configuration.

- -
- -
- Platform - UltimateAuth -
- -
- Application - Identity Lab -
- -
- Hosting Model - Blazor Server +
+
APPLICATION
+
ApplicationIdentity Lab
+
FrameworkUltimateAuth
+
UltimateAuth Version@UltimateAuthVersion
+
Hosting ModelBlazor Server (Interactive Server)
+ +
RUNTIME
+
.NET Runtime@Environment.Version.ToString()
+
Authentication ModePureOpaque
+
PersistenceEntity Framework Core
+
Database Provider@_databaseProvider
+ +
SANDBOX
+
Status@(_sandbox?.Status.ToString() ?? "Unavailable")
+
Created@(_sandbox?.CreatedAt.ToLocalTime().ToString("g") ?? "Unavailable")
+
Your Access@(_sandbox is null ? "Unavailable" : _isOwner ? "Owner" : "Member")
+
Capacity@_memberCount / @_maxUsers users
Source - - GitHub Repository - + GitHub Repository
@@ -253,48 +216,68 @@

Danger Zone

Destructive operations affecting your sandbox.

- + -
-
- Reset Sandbox -

- Remove sandbox-created resources and restore - the environment to its initial configuration. -

+ @if (_isOwner) + { +
+
+ Repair Sandbox +

Find and remove stale memberships left behind by deleted accounts. Active users and their data will not be modified.

+
+ + + @if (_repairing) + { + + } + Repair Sandbox +
- - Reset Sandbox - -
+
+
+ Reset Sandbox +

Remove sandbox-created resources and restore the environment to its initial configuration.

+
-
-
- Delete Sandbox -

- Permanently delete the sandbox and its associated - application resources. -

+ + Reset Sandbox +
- - Delete Sandbox - -
- +
+
+ Delete Sandbox +

+ Permanently delete the sandbox and its associated application resources. +

+
+ + Delete Sandbox + +
+ } +
@code { private Sandbox? _sandbox; + private string _databaseProvider = "Unavailable"; + + private string UltimateAuthVersion + { + get + { + var info = ProductInfoProvider.Get(); + var version = info.InformationalVersion?.Split('+', 2)[0]; + return string.IsNullOrWhiteSpace(version) ? info.Version : version; + } + } + private UserKey _currentUserKey; @@ -307,6 +290,7 @@ private bool _isOwner; private bool _saving; private bool _loading = true; + private bool _repairing; private bool _hasChanges => _sandbox is not null && !string.Equals(_sandbox.Name, _sandboxName?.Trim(), StringComparison.Ordinal); @@ -396,6 +380,14 @@ _sandbox = await SandboxService.GetByUserAsync(currentUserKey); + await using (var db = await DbContextFactory.CreateDbContextAsync()) + { + var provider = db.Database.ProviderName; + _databaseProvider = provider?.Contains("Npgsql", StringComparison.OrdinalIgnoreCase) == true + ? "PostgreSQL (Npgsql)" + : provider ?? "Unavailable"; + } + if (_sandbox is null) { Snackbar.Add("Sandbox not found.", Severity.Warning); @@ -430,4 +422,56 @@ _loading = false; } } + + private async Task RepairSandboxAsync() + { + if (!_isOwner || _sandbox is null || _repairing) + return; + + _repairing = true; + + try + { + var preview = await SandboxRepairService.PreviewAsync( + _currentUserKey); + + if (preview.StaleMemberships == 0) + { + Snackbar.Add( + "No stale memberships found. Your sandbox is healthy.", + Severity.Success); + return; + } + + var confirmed = await DialogService.ShowMessageBoxAsync( + "Repair Sandbox?", + $"Found {preview.StaleMemberships} stale membership(s). " + + "Remove these records and restore available capacity?", + yesText: "Repair", + cancelText: "Cancel"); + + if (confirmed != true) + return; + + var result = await SandboxRepairService.RepairAsync( + _currentUserKey); + + Snackbar.Add( + $"Repair completed. Removed {result.RemovedMemberships} " + + "stale membership(s).", + Severity.Success); + + await LoadSettingsAsync(_currentUserKey); + } + catch (Exception) + { + Snackbar.Add( + "Unable to repair sandbox. Check server logs.", + Severity.Error); + } + finally + { + _repairing = false; + } + } } diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/SettingsPage.razor.css b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/SettingsPage.razor.css index 06c76b0d..2c0651a2 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/SettingsPage.razor.css +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/SettingsPage.razor.css @@ -214,6 +214,27 @@ line-height: 1.6; } +.lab-settings-environment-group { + margin: 20px 0 8px; + padding-top: 12px; + border-top: 1px solid var(--mud-palette-lines-default); + color: var(--mud-palette-primary); + font-size: .67rem; + font-weight: 800; + letter-spacing: 1px; +} + + .lab-settings-environment-group:first-of-type { + margin-top: 0; + padding-top: 0; + border-top: 0; + } + +.lab-settings-detail-row strong { + overflow-wrap: anywhere; + text-align: right; +} + @media (max-width: 960px) { .lab-settings-grid { grid-template-columns: 1fr; diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/UsersPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/UsersPage.razor index c07ff9fd..5c2cecf4 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/UsersPage.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/UsersPage.razor @@ -93,15 +93,24 @@ AdornmentIcon="@Icons.Material.Filled.Search" Class="lab-users-search" /> - - All statuses - Active - Disabled - + + + All statuses + + + @foreach (var status in Enum.GetValues()) + { + + @status + + } +
@if (!string.IsNullOrWhiteSpace(_error)) @@ -182,6 +191,14 @@ OnClick="@(() => OpenUserDetailsAsync(context))" /> + + + +
- + No users found

Try changing your search or status filter.

@@ -220,19 +235,14 @@ Sandbox isolation

Members belong to a private sandbox. - User management must be scoped to that sandbox, - including all authorization and data operations. + User management must be scoped to that sandbox, including all authorization and data operations.

- + Explore Authorization - @@ -299,7 +309,7 @@ } private string _search = string.Empty; - private string _statusFilter = "all"; + private UserStatus? _statusFilter; private async Task OpenCreateUserDialogAsync() @@ -335,6 +345,7 @@ { { x => x.AuthState, AuthState }, { x => x.UserKey, user.UserKey }, + { x => x.AllowRoleManagement, false }, { x => x.PreviewMode, false } }; @@ -348,6 +359,22 @@ await LoadUsersAsync(actorUserKey.Value); } + private async Task OpenDemoRolesAsync(SandboxUserItem user) + { + var parameters = new DialogParameters + { + { x => x.UserKey, user.UserKey }, + { x => x.DisplayName, user.DisplayName } + }; + + var dialog = await DialogService.ShowAsync( + "Demo Application Roles", + parameters, + UAuthDialog.GetDialogOptions(MaxWidth.Small)); + + await dialog.Result; + } + private IEnumerable FilteredUsers => _users.Where(user => { @@ -357,12 +384,7 @@ user.Username.Contains(_search, StringComparison.OrdinalIgnoreCase) || user.Email.Contains(_search, StringComparison.OrdinalIgnoreCase); - var matchesStatus = _statusFilter switch - { - "active" => user.Status == UserStatus.Active, - "disabled" => user.Status == UserStatus.Disabled, - _ => true - }; + var matchesStatus = !_statusFilter.HasValue || user.Status == _statusFilter.Value; return matchesSearch && matchesStatus; }); diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/DashboardPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/DashboardPage.razor index 8b4c155a..19288fb4 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/DashboardPage.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/DashboardPage.razor @@ -15,34 +15,36 @@ Overview · Identity Lab -
+
-
-
- IDENTITY WORKSPACE -

Welcome to your sandbox.

-

Review your identity, explore authentication and manage your sandbox with UltimateAuth.

-
-
- @if (_loading) { - + } else if (_dashboard is null) { +
+ IDENTITY LAB +

Your workspace

+

Your sandbox information is not available yet.

+
+ @if (_repairStatus?.State == OwnerRepairState.CanRepair) { - + @_repairStatus.Message - Your account is active, but your sandbox setup is incomplete. - You can safely complete the setup without creating another account. + Your account is active, but your sandbox + setup is incomplete. You can complete + the setup without creating another account. Your sandbox overview is currently unavailable. - @if (_repairStatus?.State == OwnerRepairState.NotEligible) - { - Please contact the administrator if the problem persists. - } + Please contact the administrator if the problem persists. } } else { - -
+ +
-

My Lab

-

- A quick look at your authentication - and security context. -

-
-
- -
- -
- - - - - Identity Status - - - @(IsAuthenticated ? "Authenticated" : "Unauthenticated") - - - UltimateAuth authentication state -
- -
- - - - - Authentication Chains - - - @(_dashboard.ChainCount?.ToString() ?? "—") - - - Total authentication chains -
- -
- - - - - My Roles - - - @(_dashboard.RoleCount?.ToString() ?? "—") - - - Assigned authorization roles -
- -
- - + + ULTIMATEAUTH / IDENTITY LAB - My Permissions - - Explore +

Your identity workspace.

- Evaluate your access +

+ One identity. Connected security experiences. +

-
- - + + Live Sandbox + + + + +
+
+
+ + + -
+
+ + YOUR IDENTITY + -
+

@DisplayName

-
-
-

Identity Details

-

- Your current authenticated identity. -

+

@IdentitySubtitle

- -
-
- - - - +
+
- - @(!string.IsNullOrWhiteSpace(_dashboard.DisplayName) - ? _dashboard.DisplayName - : "My Identity") - - - - @(_dashboard.Email ?? "Authenticated account") - + @(IsAuthenticated ? "Authenticated" : "Authentication unavailable") + @(_dashboard.AccountStatus is { Length: > 0 } ? $"Account status: {_dashboard.AccountStatus}" : "UltimateAuth identity state")
+
- - - - @(_dashboard.AccountStatus ?? "Authenticated") - +
+ + Explore Identity + + + View Sessions +
+
-
- Username - @(_dashboard.Username ?? "—") -
+
+ YOUR ENVIRONMENT -
- Email - @(_dashboard.Email ?? "—") +

@_dashboard.SandboxName

+ +
+ Membership + @MembershipLabel
-
- Sandbox Membership +
+ Members - @(_dashboard.IsOwner ? "Owner" : "Member") + @_dashboard.MemberCount / @_dashboard.MaxUsers
-
- Username - @(_dashboard.Username ?? "—") -
- -
- Email - @(_dashboard.Email ?? "—") +
+ Available slots + @_dashboard.RemainingCapacity
-
- Sandbox Membership - @(_dashboard.IsOwner ? "Owner" : "Member") +
+
- - Explore My Identity - - -
- -
- -
-
-

Current Session

-

- Your active authentication context. -

-
- - -
- -
- - - - - -
- Current Authentication - - Your current UltimateAuth session context - -
- - - - - Active - + + Your isolated UltimateAuth playground + +
+
+ +
+
+ + + +
+ + @(_dashboard.ChainCount?.ToString() ?? "—") + + Authentication chains
+
-
- Authentication +
+ + + +
- @(IsAuthenticated ? "Active" : "Unavailable") + @(_dashboard.RoleCount?.ToString() ?? "—") + Assigned roles
+
-
- Session Management - Available +
+ + + +
+ @_dashboard.MemberCount + Sandbox members
+
- - Manage My Sessions - - -
- -
+
+ + + +
+ @_dashboard.RemainingCapacity + Remaining capacity +
+
+
- - @if (ShowManagement) - { -
+ +
+
- - SANDBOX MANAGEMENT + + THE CONNECTED MODEL -

Manage your environment

+

One identity. Multiple dimensions.

- Manage identities, explore authorization, - and review activity within your sandbox. + Your identity, access decisions and sessions are part of one authentication experience.

-
- - + +
+
+
+ + EXPLORE YOUR WORKSPACE - Settings +

Go deeper.

- Review your sandbox configuration, - membership, and capacity. + Each area reveals a different part of + your live UltimateAuth environment.

+
+
- - Sandbox settings +
- } +
- -
-
- @_dashboard.SandboxName + +
+ -

- Your isolated Identity Lab environment. - Manage up to @_dashboard.MaxUsers accounts, including the owner. -

+
+ Powered by UltimateAuth + + A real identity environment built to explore + modern authentication for .NET. +
- - @_dashboard.MemberCount / @_dashboard.MaxUsers users - -
+ + Documentation + +
} @code { - [CascadingParameter] private UAuthState? AuthState { get; set; } private LabDashboardSnapshot? _dashboard; - private UserKey? _loadedUserKey; + private OwnerRepairStatus? _repairStatus; private bool _loading = true; - - private bool IsAuthenticated => AuthState?.Identity?.UserKey is not null; - - private string CurrentUserIdentifier => AuthState?.Identity?.UserKey.Value ?? "Unavailable"; - - private OwnerRepairStatus? _repairStatus; private bool _repairing; + private bool IsAuthenticated => + AuthState?.Identity?.UserKey is not null; + private bool ShowManagement => AuthState?.IsInRole(LabRoles.Admin) == true || (AuthState?.IsInRole(LabRoles.Owner) == true && _dashboard?.IsOwner == true); + private string DisplayName => + !string.IsNullOrWhiteSpace(_dashboard?.DisplayName) + ? _dashboard.DisplayName + : !string.IsNullOrWhiteSpace(_dashboard?.Username) + ? _dashboard.Username + : "My Identity"; + + private string IdentitySubtitle => + !string.IsNullOrWhiteSpace(_dashboard?.Email) + ? _dashboard.Email + : _dashboard?.Username ?? "UltimateAuth account"; + + private string MembershipLabel => + _dashboard?.IsOwner == true ? "Owner" : "Member"; + + private int CapacityPercent => + _dashboard is { MaxUsers: > 0 } dashboard + ? Math.Clamp( + (int)Math.Round( + dashboard.MemberCount * 100.0 / dashboard.MaxUsers), + 0, + 100) + : 0; + protected override async Task OnParametersSetAsync() { var userKey = AuthState?.Identity?.UserKey; @@ -479,7 +482,10 @@ if (_dashboard is null) { - var context = await ContextProvider.CreateAsync("identitylab.owner.repair.status", "identitylab.sandbox"); + var context = await ContextProvider.CreateAsync( + "identitylab.owner.repair.status", + "identitylab.sandbox"); + _repairStatus = await OwnerRepairService.GetStatusAsync(context); } @@ -490,7 +496,9 @@ _dashboard = null; _repairStatus = null; - Snackbar.Add("Unable to load your dashboard.", Severity.Error); + Snackbar.Add( + "Unable to load your dashboard.", + Severity.Error); } finally { @@ -507,9 +515,15 @@ try { - var context = await ContextProvider.CreateAsync("identitylab.owner.repair", "identitylab.sandbox"); + var context = await ContextProvider.CreateAsync( + "identitylab.owner.repair", + "identitylab.sandbox"); + var result = await OwnerRepairService.RepairAsync(context); - Snackbar.Add(result.Message, result.Success ? Severity.Success : Severity.Warning); + + Snackbar.Add( + result.Message, + result.Success ? Severity.Success : Severity.Warning); if (result.Success) { diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/DashboardPage.razor.css b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/DashboardPage.razor.css index 3e95c669..a7dc7310 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/DashboardPage.razor.css +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/DashboardPage.razor.css @@ -1,317 +1,450 @@ -.lab-overview { - padding: 42px 0 80px; +.lab-home { + padding: 32px 0 56px; } -.lab-overview-heading { +.lab-home-heading { display: flex; - align-items: flex-start; + align-items: center; justify-content: space-between; - gap: 20px; - margin-bottom: 42px; + gap: 24px; + margin-bottom: 28px; } -.lab-overview-eyebrow { +.lab-home-eyebrow, +.lab-home-kicker { display: block; - margin-bottom: 10px; + font-size: 11px; + font-weight: 700; + letter-spacing: 1.5px; + text-transform: uppercase; color: var(--mud-palette-primary); - font-size: .67rem; - font-weight: 800; - letter-spacing: 1.2px; } -.lab-overview-heading h1 { - margin: 0; - color: var(--mud-palette-text-primary); - font-size: clamp(1.65rem, 3vw, 2.15rem); - font-weight: 800; - letter-spacing: -1px; +.lab-home-heading h1 { + margin: 8px 0; + font-size: clamp(27px, 3vw, 38px); + font-weight: 750; + letter-spacing: -1.1px; } -.lab-overview-heading p, -.lab-overview-section-title p { - margin: 8px 0 0; +.lab-home-heading p, +.lab-home-section-heading p { + margin: 0; color: var(--mud-palette-text-secondary); - font-size: .86rem; - line-height: 1.6; + font-size: 14px; } -.lab-overview-section-title { - margin-bottom: 20px; +/* HERO */ + +.lab-home-hero { + display: grid; + grid-template-columns: minmax(0, 1.65fr) minmax(260px, 1fr); + gap: 18px; +} + +.lab-home-hero-main, +.lab-home-hero-context { + border: 1px solid var(--mud-palette-lines-default); + border-radius: 18px; + padding: 28px; + min-width: 0; +} + +.lab-home-hero-main { + background: radial-gradient( ellipse at 100% 0%, var(--mud-palette-primary-hover), transparent 55% ), var(--mud-palette-surface); +} + +.lab-home-hero-context { + background: var(--mud-palette-background-gray); +} + +.lab-home-identity { + display: flex; + align-items: center; + gap: 18px; } - .lab-overview-section-title h2 { +.lab-home-identity-copy { + min-width: 0; +} + + .lab-home-identity-copy h2 { + margin: 5px 0 3px; + font-size: 24px; + font-weight: 700; + overflow-wrap: anywhere; + } + + .lab-home-identity-copy p { margin: 0; - font-size: 1.15rem; - font-weight: 750; - letter-spacing: -.4px; + color: var(--mud-palette-text-secondary); + font-size: 13px; + overflow-wrap: anywhere; } -.lab-overview-stats { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 16px; - margin-bottom: 22px; +.lab-home-status { + display: flex; + align-items: center; + gap: 12px; + margin-top: 28px; + padding: 14px 16px; + border-radius: 12px; + background: var(--mud-palette-background-gray); } -.lab-stat-card, -.lab-panel, -.lab-management-card { - background: var(--mud-palette-surface); - border: 1px solid var(--mud-palette-lines-default); - border-radius: 16px; + .lab-home-status > div { + display: flex; + flex-direction: column; + gap: 3px; + } + + .lab-home-status strong { + font-size: 13px; + } + + .lab-home-status span { + font-size: 12px; + color: var(--mud-palette-text-secondary); + } + +.lab-home-hero-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 22px; +} + +.lab-home-hero-context h3 { + margin: 12px 0 24px; + font-size: 19px; + font-weight: 700; + overflow-wrap: anywhere; } -.lab-stat-card { +.lab-home-context-row { display: flex; - flex-direction: column; - align-items: flex-start; - padding: 22px; + justify-content: space-between; + gap: 12px; + margin-bottom: 13px; + font-size: 13px; +} + + .lab-home-context-row span, + .lab-home-context-note { + color: var(--mud-palette-text-secondary); + } + + .lab-home-context-row strong { + font-weight: 650; + } + +.lab-home-capacity-track { + height: 7px; + overflow: hidden; + border-radius: 100px; + margin-top: 22px; + background: var(--mud-palette-lines-default); +} + +.lab-home-capacity-fill { + height: 100%; + border-radius: inherit; + background: var(--mud-palette-primary); + transition: width 250ms ease; +} + +.lab-home-context-note { + display: block; + margin-top: 11px; + font-size: 11px; } -.lab-stat-icon, -.lab-management-icon { +/* METRICS */ + +.lab-home-metrics { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 14px; + margin-top: 18px; +} + +.lab-home-metric { + display: flex; + align-items: center; + gap: 14px; + min-width: 0; + padding: 20px; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 14px; + background: var(--mud-palette-surface); +} + +.lab-home-metric-icon { display: flex; align-items: center; justify-content: center; width: 42px; height: 42px; + flex-shrink: 0; border-radius: 12px; color: var(--mud-palette-primary); background: var(--mud-palette-primary-hover); } -.lab-stat-label { - margin-top: 19px; - color: var(--mud-palette-text-secondary); - font-size: .76rem; +.lab-home-metric > div { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; } -.lab-stat-card strong { - margin-top: 5px; - color: var(--mud-palette-text-primary); - font-size: 1.45rem; - font-weight: 800; - letter-spacing: -.6px; +.lab-home-metric strong { + font-size: 24px; + line-height: 1.2; + font-weight: 750; + font-variant-numeric: tabular-nums; } -.lab-stat-card small { - margin-top: 5px; +.lab-home-metric span:last-child { + font-size: 12px; color: var(--mud-palette-text-secondary); - font-size: .72rem; } -.lab-overview-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 20px; -} +/* SECTIONS */ -.lab-panel { - min-width: 0; - padding: 26px; +.lab-home-section { + margin-top: 44px; } -.lab-panel-heading { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; - margin-bottom: 24px; +.lab-home-section-heading { + margin-bottom: 20px; } - .lab-panel-heading h3 { - margin: 0; - font-size: 1rem; - font-weight: 750; + .lab-home-section-heading h2 { + margin: 8px 0; + font-size: 23px; + font-weight: 700; + letter-spacing: -0.5px; } - .lab-panel-heading p { - margin: 5px 0 0; - font-size: .76rem; - line-height: 1.5; - color: var(--mud-palette-text-secondary); - } +/* CONNECTED MODEL */ -.lab-profile, -.lab-session-item { +.lab-home-model { display: flex; - align-items: center; - gap: 13px; + align-items: stretch; + gap: 10px; } -.lab-profile { - margin-bottom: 22px; +.lab-home-model-node { + display: flex; + align-items: flex-start; + gap: 14px; + flex: 1; + min-width: 0; + padding: 22px 18px; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 15px; + background: var(--mud-palette-surface); + color: var(--mud-palette-text-primary); + text-decoration: none; + transition: border-color 150ms ease, transform 150ms ease; } - .lab-profile > div:nth-child(2), - .lab-session-item > div:nth-child(2) { - display: flex; - flex-direction: column; - gap: 4px; - min-width: 0; - } - - .lab-profile strong, - .lab-session-item strong { - font-size: .83rem; - font-weight: 700; + .lab-home-model-node:hover { + border-color: var(--mud-palette-primary); + transform: translateY(-2px); } - .lab-profile span, - .lab-session-item span { - color: var(--mud-palette-text-secondary); - font-size: .72rem; - } +.lab-home-model-icon { + display: flex; + color: var(--mud-palette-primary); +} -.lab-detail-row { +.lab-home-model-content { display: flex; - justify-content: space-between; - gap: 14px; - padding: 13px 0; - border-top: 1px solid var(--mud-palette-lines-default); - font-size: .79rem; + flex-direction: column; + gap: 5px; + min-width: 0; } - .lab-detail-row span { + .lab-home-model-content strong { + font-size: 15px; + } + + .lab-home-model-content small { + font-size: 12px; color: var(--mud-palette-text-secondary); } - .lab-detail-row strong { - font-weight: 650; - text-align: right; + .lab-home-model-content > span { + margin-top: 8px; + font-size: 12px; + font-weight: 600; + overflow-wrap: anywhere; } -.lab-session-item { - padding: 18px 0; - border-top: 1px solid var(--mud-palette-lines-default); +.lab-home-node-arrow { + margin-left: auto; + color: var(--mud-palette-text-secondary); } -.lab-session-icon { +.lab-home-model-connector { display: flex; align-items: center; justify-content: center; - width: 40px; - height: 40px; - border-radius: 11px; - background: var(--mud-palette-background-gray); + flex-shrink: 0; color: var(--mud-palette-text-secondary); } -.lab-management-heading { - margin-top: 48px; +.lab-home-model-caption { + display: flex; + align-items: center; + gap: 10px; + margin-top: 15px; + color: var(--mud-palette-text-secondary); + font-size: 12px; } -.lab-management-grid { +/* EXPLORE */ + +.lab-home-explore-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 18px; + gap: 14px; } -.lab-management-card { +.lab-home-explore-card { display: flex; flex-direction: column; align-items: flex-start; - padding: 25px; - text-decoration: none; + gap: 12px; + min-width: 0; + min-height: 175px; + padding: 22px; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 15px; + background: var(--mud-palette-surface); color: var(--mud-palette-text-primary); - transition: border-color .2s ease, transform .2s ease; + text-decoration: none; + transition: border-color 150ms ease, transform 150ms ease; } - .lab-management-card:hover { + .lab-home-explore-card:hover { border-color: var(--mud-palette-primary); transform: translateY(-2px); } - .lab-management-card > strong { - margin-top: 20px; - font-size: .95rem; - font-weight: 750; + .lab-home-explore-card strong { + font-size: 15px; } - .lab-management-card p { + .lab-home-explore-card p { + margin: 0; flex: 1; - margin: 9px 0 22px; - color: var(--mud-palette-text-secondary); - font-size: .78rem; + font-size: 12px; line-height: 1.6; + color: var(--mud-palette-text-secondary); } -.lab-management-link { - display: inline-flex; - align-items: center; - gap: 7px; - color: var(--mud-palette-primary); - font-size: .78rem; - font-weight: 700; -} + .lab-home-explore-card > span { + font-size: 12px; + font-weight: 650; + color: var(--mud-palette-primary); + } + +/* FOOTER */ -.lab-sandbox-banner { +.lab-home-footer { display: flex; align-items: center; - justify-content: space-between; - gap: 20px; - margin-top: 22px; - padding: 24px; - border: 1px solid var(--mud-palette-lines-default); - border-radius: 16px; - background: var(--mud-palette-primary-hover); + gap: 16px; + margin-top: 44px; + padding: 22px 0; + border-top: 1px solid var(--mud-palette-lines-default); } - .lab-sandbox-banner strong { - font-size: .9rem; + .lab-home-footer > div { + display: flex; + flex-direction: column; + flex: 1; + gap: 4px; + } + + .lab-home-footer strong { + font-size: 13px; } - .lab-sandbox-banner p { - margin: 5px 0 0; - font-size: .78rem; + .lab-home-footer span { + font-size: 12px; color: var(--mud-palette-text-secondary); } +/* RESPONSIVE */ + @media (max-width: 1100px) { - .lab-overview-stats { + .lab-home-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } } -@media (max-width: 760px) { - .lab-overview { - padding: 28px 0 52px; +@media (max-width: 900px) { + .lab-home-hero { + grid-template-columns: 1fr; } - .lab-overview-heading { + .lab-home-model { flex-direction: column; - margin-bottom: 32px; } - .lab-overview-grid, - .lab-management-grid { - grid-template-columns: 1fr; + .lab-home-model-connector { + transform: rotate(90deg); + height: 20px; } - .lab-management-heading { - margin-top: 36px; + .lab-home-explore-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); } } -@media (max-width: 480px) { - .lab-overview-stats { +@media (max-width: 600px) { + .lab-home { + padding-top: 20px; + } + + .lab-home-heading { + align-items: flex-start; + flex-direction: column; + } + + .lab-home-hero-main, + .lab-home-hero-context { + padding: 20px; + } + + .lab-home-metrics { gap: 10px; } - .lab-stat-card { - padding: 16px; + .lab-home-metric { + padding: 14px; + gap: 10px; } - .lab-stat-card strong { - font-size: 1.2rem; - } + .lab-home-metric-icon { + width: 36px; + height: 36px; + } - .lab-panel { - padding: 20px; + .lab-home-metric strong { + font-size: 21px; } - .lab-sandbox-banner { + .lab-home-explore-grid { + grid-template-columns: 1fr; + } + + .lab-home-footer { align-items: flex-start; - flex-direction: column; + flex-wrap: wrap; } } diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dialogs/DemoRoleDialog.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dialogs/DemoRoleDialog.razor new file mode 100644 index 00000000..2f6d9dcf --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dialogs/DemoRoleDialog.razor @@ -0,0 +1,203 @@ +@using CodeBeam.UltimateAuth.Core.Domain +@using CodeBeam.UltimateAuth.IdentityLab.Authorization +@using CodeBeam.UltimateAuth.IdentityLab.Services + +@inject LabDemoRoleService DemoRoleService +@inject ISnackbar Snackbar + + + + + + Demo Application Roles + + + + + + + + Manage demonstration roles for @DisplayName. + + + + These roles are used exclusively by the sample SaaS + application in Identity Lab. They do not grant Sandbox + administration privileges or modify system roles. + + + @if (_loading) + { + + } + else if (_error is not null) + { + + @_error + + + + Retry + + } + else + { + + + + + + + + Manager + + + Sample product management permissions. + + + + + + + + + + + + Customer + Sample catalog and ordering permissions. + + + + + + + + + @if (_saving) + { + + } + + + Changes are saved immediately. A user may have both demo roles. + Neither role affects Sandbox membership or administrative access. + + } + + + + + + + Close + + + + +@code { + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter, EditorRequired] + public UserKey UserKey { get; set; } + + [Parameter] + public string DisplayName { get; set; } = string.Empty; + + private bool _manager; + private bool _customer; + private bool _loading = true; + private bool _saving; + private string? _error; + + protected override async Task OnInitializedAsync() + { + await LoadAsync(); + } + + private async Task LoadAsync() + { + _loading = true; + _error = null; + + try + { + var roles = await DemoRoleService.GetRolesAsync(UserKey); + + _manager = roles.Contains( + LabRoles.Manager, + StringComparer.OrdinalIgnoreCase); + + _customer = roles.Contains( + LabRoles.Customer, + StringComparer.OrdinalIgnoreCase); + } + catch (Exception) + { + _error = "Unable to load demo role assignments."; + } + finally + { + _loading = false; + } + } + + private Task OnManagerChangedAsync(bool value) + => UpdateRoleAsync(LabRoles.Manager, value); + + private Task OnCustomerChangedAsync(bool value) + => UpdateRoleAsync(LabRoles.Customer, value); + + private async Task UpdateRoleAsync(string role, bool assigned) + { + if (_loading || _saving || _error is not null) + return; + + _saving = true; + + try + { + await DemoRoleService.SetRoleAsync( + UserKey, role, assigned); + + if (role == LabRoles.Manager) + _manager = assigned; + else + _customer = assigned; + + Snackbar.Add( + $"{role} role {(assigned ? "assigned" : "removed")}.", + Severity.Success); + } + catch (UnauthorizedAccessException) + { + Snackbar.Add( + "You are not authorized to manage demo roles.", + Severity.Error); + } + catch (Exception) + { + Snackbar.Add( + "Unable to update the demo role.", + Severity.Error); + } + finally + { + _saving = false; + } + } + + private void Close() + { + if (!_saving) + MudDialog.Close(); + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/LandingPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/LandingPage.razor index beb937b2..bc428dbc 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/LandingPage.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/LandingPage.razor @@ -14,7 +14,7 @@
- +
@@ -22,16 +22,9 @@ THE ULTIMATEAUTH EXPERIENCE
- - - - + + Open Source on GitHub - @@ -63,16 +56,48 @@ - - - Free to explore completely - - Real auth flows - + +
+ +
+
+ +
+ +
+ Real Authentication + Actual identity & session flows +
+
+ +
+ +
+
+ +
+ +
+ Isolated Sandbox + Your own space to experiment +
+
+ +
+ +
+
+ +
+ +
+ Open Source & Free + Built in the open for .NET +
+
+ +
+
@@ -89,7 +114,7 @@
-
AUTHENTICATION PLATFORM
+
AUTH PLATFORM
One identity. Complete control.
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/LandingPage.razor.css b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/LandingPage.razor.css index 47a473dc..550b1646 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/LandingPage.razor.css +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/LandingPage.razor.css @@ -422,6 +422,104 @@ color: var(--mud-palette-primary); } + +.lab-hero-highlights { + display: flex; + align-items: center; + gap: 20px; + margin-top: 36px; + padding: 20px 0; + width: 100%; + max-width: 720px; + border-top: 1px solid var(--mud-palette-lines-default); +} + +.lab-hero-highlight { + display: flex; + align-items: center; + gap: 11px; + min-width: 0; + flex: 1; +} + +.lab-hero-highlight-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 38px; + height: 38px; + border-radius: 11px; + color: var(--mud-palette-primary); + background: var(--mud-palette-primary-hover); +} + +.lab-hero-highlight-content { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + + .lab-hero-highlight-content strong { + font-size: .78rem; + font-weight: 750; + color: var(--mud-palette-text-primary); + line-height: 1.3; + } + + .lab-hero-highlight-content span { + font-size: .69rem; + color: var(--mud-palette-text-secondary); + line-height: 1.45; + } + +.lab-hero-highlight-divider { + flex-shrink: 0; + width: 1px; + height: 34px; + background: var(--mud-palette-lines-default); +} + +@media (max-width: 1100px) { + .lab-hero-highlights { + max-width: 100%; + gap: 14px; + } +} + +@media (max-width: 680px) { + .lab-hero-highlights { + flex-direction: column; + align-items: stretch; + gap: 16px; + margin-top: 28px; + padding-top: 20px; + } + + .lab-hero-highlight { + flex: none; + } + + .lab-hero-highlight-divider { + width: 100%; + height: 1px; + } + + .lab-hero-highlight-icon { + width: 40px; + height: 40px; + } + + .lab-hero-highlight-content strong { + font-size: .82rem; + } + + .lab-hero-highlight-content span { + font-size: .74rem; + } +} + @media (max-width: 599.98px) { .lab-guidelines-footer { flex-direction: column; diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/LoginPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/LoginPage.razor index 3c077146..fc4a05b5 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/LoginPage.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/LoginPage.razor @@ -1,8 +1,9 @@ @page "/login" -@using CodeBeam.UltimateAuth.Core.Contracts @layout PublicLayout @inject IUAuthClient UAuthClient +@inject IOptions Options @inject NavigationManager NavigationManager +@implements IDisposable Sign In · Identity Lab @@ -20,128 +21,215 @@

- @(AuthState.IsAuthenticated ? "You're signed in." : "Welcome back.") + @(AuthState.IsAuthenticated ? "Good to see you again." : "Welcome back.")

@(AuthState.IsAuthenticated - ? "Continue with your current account or sign out to switch accounts." + ? "Pick up right where you left off." : "Sign in to continue to your Identity Lab sandbox.")

-