From c3a078be91382dcd39ebdc0d5d5b6eefaade7493 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Wed, 28 Oct 2026 00:35:40 +0300 Subject: [PATCH 1/9] Identity Lab: Polish --- .../Pages/Dashboard/MyIdentityPage.razor | 15 ++- .../Pages/Dashboard/SettingsPage.razor | 83 +++++++++++++ .../Pages/Dashboard/UsersPage.razor | 28 +++-- .../Program.cs | 4 +- .../Services/SandboxAccountDeletionService.cs | 54 ++++++++ .../Services/SandboxRepairService.cs | 115 ++++++++++++++++++ .../Services/SandboxService.cs | 2 + .../Services/SandboxUserService.cs | 7 +- .../Dialogs/AccountStatusDialog.razor.cs | 33 +++-- 9 files changed, 314 insertions(+), 27 deletions(-) create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxAccountDeletionService.cs create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxRepairService.cs 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..642dabbc 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,7 +1,9 @@ @page "/dashboard/identity" @using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.IdentityLab.Services @using CodeBeam.UltimateAuth.Users.Contracts @layout DashboardLayout +@inject SandboxAccountDeletionService SandboxAccountDeletionService @inject IUAuthClient UAuthClient @inject IDialogService DialogService @@ -550,7 +552,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 +566,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/SettingsPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/SettingsPage.razor index 860ed21d..954cf1fb 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 @@ -9,7 +9,9 @@ @inject SandboxService SandboxService @inject SandboxUserService SandboxUserService +@inject SandboxRepairService SandboxRepairService @inject ISnackbar Snackbar +@inject IDialogService DialogService Sandbox Settings · Identity Lab @@ -257,6 +259,34 @@ Color="Color.Error" /> + @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 @@ -307,6 +337,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); @@ -430,4 +461,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/UsersPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/UsersPage.razor index c07ff9fd..bc25d16c 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)) @@ -299,7 +308,7 @@ } private string _search = string.Empty; - private string _statusFilter = "all"; + private UserStatus? _statusFilter; private async Task OpenCreateUserDialogAsync() @@ -357,12 +366,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/Program.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs index 8260d525..cb5eb621 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs @@ -5,7 +5,6 @@ using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Infrastructure; -using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.EntityFrameworkCore; using CodeBeam.UltimateAuth.IdentityLab.Authorization; using CodeBeam.UltimateAuth.IdentityLab.Components; @@ -31,6 +30,7 @@ // Add services to the container. builder.Services.AddRazorComponents() .AddInteractiveServerComponents(); + //.AddCircuitOptions(o => o.DetailedErrors = true); builder.Services.AddMudServices(o => { o.SnackbarConfiguration.PreventDuplicates = false; @@ -98,8 +98,10 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxAccountDeletionService.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxAccountDeletionService.cs new file mode 100644 index 00000000..6fab7ee8 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxAccountDeletionService.cs @@ -0,0 +1,54 @@ +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.IdentityLab.Domain; +using CodeBeam.UltimateAuth.IdentityLab.Persistence; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.IdentityLab.Services; + +public sealed class SandboxAccountDeletionService(IDbContextFactory dbFactory, IUAuthClient client, ILogger logger) +{ + public async Task DeleteMemberAsync( + UserKey authenticatedUserKey, + CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var membership = await db.SandboxMembers + .AsNoTracking() + .SingleOrDefaultAsync( + x => x.UserKey == authenticatedUserKey, + ct); + + if (membership is null || + membership.Role != SandboxMemberRole.Member) + { + return false; + } + + // This operation must execute as the authenticated user. + var result = await client.Users.DeleteMeAsync(); + + if (!result.IsSuccess) + return false; + + var currentMembership = await db.SandboxMembers + .SingleOrDefaultAsync( + x => x.UserKey == authenticatedUserKey, + ct); + + if (currentMembership is not null) + { + db.SandboxMembers.Remove(currentMembership); + await db.SaveChangesAsync(ct); + } + + logger.LogInformation( + "Deleted member {UserKey} and removed sandbox membership.", + authenticatedUserKey.Value); + + return true; + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxRepairService.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxRepairService.cs new file mode 100644 index 00000000..c328f9bc --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxRepairService.cs @@ -0,0 +1,115 @@ +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.IdentityLab.Domain; +using CodeBeam.UltimateAuth.IdentityLab.Persistence; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.EntityFrameworkCore; +using System.Data; + +namespace CodeBeam.UltimateAuth.IdentityLab.Services; + +public sealed record SandboxRepairPreview( + int TotalMemberships, + int StaleMemberships); + +public sealed record SandboxRepairResult(int RemovedMemberships); + +public sealed class SandboxRepairService(IDbContextFactory dbFactory, IUserSummaryQueryStoreFactory userSummaryQueryStoreFactory, ILogger logger) +{ + public async Task PreviewAsync(UserKey ownerUserKey, CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var sandbox = await db.Sandboxes + .AsNoTracking() + .SingleOrDefaultAsync( + x => x.OwnerUserKey == ownerUserKey, + ct) + ?? throw new UnauthorizedAccessException( + "Sandbox owner required."); + + var members = await db.SandboxMembers + .AsNoTracking() + .Where(x => x.SandboxId == sandbox.Id) + .ToListAsync(ct); + + var candidates = members + .Where(x => + x.Role == SandboxMemberRole.Member && + x.UserKey != sandbox.OwnerUserKey) + .ToArray(); + + var userStore = userSummaryQueryStoreFactory.Create( + TenantKeys.Single); + + var users = await userStore.GetByKeysAsync( + candidates.Select(x => x.UserKey).ToArray(), + ct); + + var validKeys = users + .Select(x => x.UserKey) + .ToHashSet(); + + return new SandboxRepairPreview( + members.Count, + candidates.Count(x => !validKeys.Contains(x.UserKey))); + } + + public async Task RepairAsync(UserKey ownerUserKey, CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + await using var transaction = await db.Database + .BeginTransactionAsync(IsolationLevel.Serializable, ct); + + var sandbox = await db.Sandboxes + .SingleOrDefaultAsync( + x => x.OwnerUserKey == ownerUserKey, + ct) + ?? throw new UnauthorizedAccessException( + "Sandbox owner required."); + + if (sandbox.Status != SandboxStatus.Active) + throw new InvalidOperationException( + "Sandbox is not active."); + + var candidates = await db.SandboxMembers + .Where(x => + x.SandboxId == sandbox.Id && + x.Role == SandboxMemberRole.Member && + x.UserKey != sandbox.OwnerUserKey) + .ToListAsync(ct); + + var userStore = userSummaryQueryStoreFactory.Create( + TenantKeys.Single); + + var users = await userStore.GetByKeysAsync( + candidates.Select(x => x.UserKey).ToArray(), + ct); + + var validKeys = users + .Select(x => x.UserKey) + .ToHashSet(); + + var staleMembers = candidates + .Where(x => !validKeys.Contains(x.UserKey)) + .ToArray(); + + if (staleMembers.Length > 0) + { + db.SandboxMembers.RemoveRange(staleMembers); + await db.SaveChangesAsync(ct); + } + + await transaction.CommitAsync(ct); + + logger.LogInformation( + "Sandbox repair completed. Owner={Owner}, " + + "Sandbox={SandboxId}, Removed={Count}", + ownerUserKey.Value, + sandbox.Id, + staleMembers.Length); + + return new SandboxRepairResult(staleMembers.Length); + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxService.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxService.cs index 8d598784..afcb1049 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxService.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxService.cs @@ -1,6 +1,8 @@ using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.IdentityLab.Domain; using CodeBeam.UltimateAuth.IdentityLab.Persistence; +using CodeBeam.UltimateAuth.Users.Contracts; using Microsoft.EntityFrameworkCore; namespace CodeBeam.UltimateAuth.IdentityLab.Services; diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxUserService.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxUserService.cs index fa24dae4..e18cc6f2 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxUserService.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxUserService.cs @@ -50,14 +50,13 @@ public sealed class SandboxUserService(SandboxService sandboxService, IUAuthClie { if (!userMap.TryGetValue(member.UserKey, out var user)) { - throw new InvalidOperationException($"Unable to load user {member.UserKey.Value}."); + logger.LogWarning("Sandbox member {UserKey} in sandbox {SandboxId} has no active user summary.", member.UserKey.Value ,sandbox.Id); + continue; } users.Add(new SandboxUserItem( UserKey: member.UserKey, - DisplayName: user.DisplayName - ?? user.UserName - ?? "Unknown user", + DisplayName: user.DisplayName ?? user.UserName ?? "Unknown user", Username: user.UserName ?? "", Email: user.PrimaryEmail ?? "", IsOwner: member.Role == SandboxMemberRole.Owner, diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/AccountStatusDialog.razor.cs b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/AccountStatusDialog.razor.cs index a604c9ca..b3856897 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/AccountStatusDialog.razor.cs +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/AccountStatusDialog.razor.cs @@ -20,6 +20,9 @@ public partial class AccountStatusDialog [Parameter] public bool AllowAccountDeletion { get; set; } = true; + [Parameter] + public Func>? OnDeleteAccountAsync { get; set; } + private bool _isSubmitting; private string AccountName => @@ -148,20 +151,32 @@ private async Task DeleteAccountAsync() try { - var result = await UAuthClient.Users.DeleteMeAsync(); + bool success; - if (!result.IsSuccess) + if (OnDeleteAccountAsync is not null) { - Snackbar.Add( - result.ErrorText ?? "Failed to delete account.", - Severity.Error); - return; + success = await OnDeleteAccountAsync(); } + else + { + var result = await UAuthClient.Users.DeleteMeAsync(); - Snackbar.Add( - "Your account has been deleted.", - Severity.Success); + if (!result.IsSuccess) + { + Snackbar.Add(result.ErrorText ?? "Failed to delete account.", Severity.Error); + return; + } + + success = true; + } + + if (!success) + { + Snackbar.Add("Failed to delete account.", Severity.Error); + return; + } + Snackbar.Add("Your account has been deleted.", Severity.Success); MudDialog.Close(DialogResult.Ok(true)); } catch (Exception) From 5607c290e5026ff9ffbf2f654860b30dba8c0836 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Wed, 28 Oct 2026 22:33:06 +0300 Subject: [PATCH 2/9] Other Fixes --- .../Authorization/LabAccessContextProvider.cs | 7 + .../Authorization/LabAdminGuard.cs | 20 +- .../Authorization/LabDemoPermissions.cs | 24 ++ .../Authorization/LabRoles.cs | 15 +- .../Components/Layout/DashboardLayout.razor | 6 + .../Pages/Dashboard/DemoStorePage.razor | 327 ++++++++++++++++++ .../Pages/Dashboard/MyIdentityPage.razor | 8 + .../Pages/Dashboard/UsersPage.razor | 37 +- .../Pages/Dialogs/DemoRoleDialog.razor | 203 +++++++++++ .../Components/Pages/RegisterPage.razor | 2 +- .../Components/_Imports.razor | 1 + .../Contracts/LabRoleIds.cs | 12 +- .../Policy/LabApplicationPolicy.cs | 41 +-- .../Policy/SandboxUAuthAccessPolicy.cs | 27 +- .../Program.cs | 4 + .../Seed/LabDemoRoleSeedContributor.cs | 69 ++++ .../Services/LabAdminService.cs | 25 +- .../Services/LabDemoRoleService.cs | 127 +++++++ .../Services/LabDemoStoreService.cs | 107 ++++++ .../Services/LabProvisioningRollbackGate.cs | 41 +++ .../Services/LabRootResolver.cs | 18 +- .../Services/LabUserProvisioningService.cs | 57 +++ 22 files changed, 1104 insertions(+), 74 deletions(-) create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabDemoPermissions.cs create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/DemoStorePage.razor create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dialogs/DemoRoleDialog.razor create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Seed/LabDemoRoleSeedContributor.cs create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabDemoRoleService.cs create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabDemoStoreService.cs create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabProvisioningRollbackGate.cs 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/Layout/DashboardLayout.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor index 5bab31b3..b1907463 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor @@ -191,6 +191,12 @@ } +
PLAYGROUND
+ + + Demo Store + + Sign Out 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/MyIdentityPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MyIdentityPage.razor index 642dabbc..f8131daa 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 @@ -4,6 +4,7 @@ @using CodeBeam.UltimateAuth.Users.Contracts @layout DashboardLayout @inject SandboxAccountDeletionService SandboxAccountDeletionService +@inject LabDashboardService LabDashboardService @inject IUAuthClient UAuthClient @inject IDialogService DialogService @@ -419,6 +420,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(); } 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 bc25d16c..d41da843 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 @@ -191,6 +191,14 @@ OnClick="@(() => OpenUserDetailsAsync(context))" /> + + + +
- + No users found

Try changing your search or status filter.

@@ -229,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 - @@ -357,6 +358,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 => { 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/RegisterPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/RegisterPage.razor index 8d7fa3e4..57e1e77c 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/RegisterPage.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/RegisterPage.razor @@ -212,7 +212,7 @@ try { - var result = await RegistrationService.RegisterAsync(new LabRegistrationRequest(_username.Trim(), _email.Trim(), _password.Trim())); + var result = await RegistrationService.RegisterAsync(new LabRegistrationRequest(_username.Trim(), _email.Trim(), _password)); if (!result.IsSuccess) { diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/_Imports.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/_Imports.razor index 233c5407..5d779c45 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/_Imports.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/_Imports.razor @@ -11,6 +11,7 @@ @using CodeBeam.UltimateAuth.IdentityLab.Components @using CodeBeam.UltimateAuth.IdentityLab.Components.Layout @using CodeBeam.UltimateAuth.IdentityLab.Components.Custom +@using CodeBeam.UltimateAuth.IdentityLab.Components.Pages.Dialogs @using CodeBeam.UltimateAuth.Client @using CodeBeam.UltimateAuth.Client.Blazor diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/LabRoleIds.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/LabRoleIds.cs index 724f635e..675ad1c3 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/LabRoleIds.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/LabRoleIds.cs @@ -4,12 +4,10 @@ namespace CodeBeam.UltimateAuth.IdentityLab.Authorization; public static class LabRoleIds { - public static readonly RoleId Admin = RoleId.From( - Guid.Parse("a91a7b31-1b50-4e91-8a71-98b2e6d0b001")); + public static readonly RoleId Admin = RoleId.From(Guid.Parse("a91a7b31-1b50-4e91-8a71-98b2e6d0b001")); + public static readonly RoleId Owner = RoleId.From(Guid.Parse("a91a7b31-1b50-4e91-8a71-98b2e6d0b002")); + public static readonly RoleId User = RoleId.From(Guid.Parse("a91a7b31-1b50-4e91-8a71-98b2e6d0b003")); - public static readonly RoleId Owner = RoleId.From( - Guid.Parse("a91a7b31-1b50-4e91-8a71-98b2e6d0b002")); - - public static readonly RoleId User = RoleId.From( - Guid.Parse("a91a7b31-1b50-4e91-8a71-98b2e6d0b003")); + public static readonly RoleId Manager = RoleId.From(Guid.Parse("a91a7b31-1b50-4e91-8a71-98b2e6d0b004")); + public static readonly RoleId Customer = RoleId.From(Guid.Parse("a91a7b31-1b50-4e91-8a71-98b2e6d0b005")); } diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Policy/LabApplicationPolicy.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Policy/LabApplicationPolicy.cs index e337d5c9..d40505a9 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Policy/LabApplicationPolicy.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Policy/LabApplicationPolicy.cs @@ -4,46 +4,35 @@ namespace CodeBeam.UltimateAuth.IdentityLab.Services; -public sealed class LabApplicationPolicy(ILabRootResolver rootResolver) : IAccessPolicy +public sealed class LabApplicationPolicy() : IAccessPolicy { public bool AppliesTo(AccessContext context) - => context.Action.StartsWith( - "authorization.roles.", - StringComparison.Ordinal); + => context.Action.StartsWith("authorization.roles.", StringComparison.Ordinal); public ValueTask DecideAsync(AccessContext context, CancellationToken cancellationToken = default) { - // Trusted internal operations. + // Trusted framework/system operations only. if (context.IsSystemActor) return ValueTask.FromResult(AccessDecision.Allow()); - if (!context.IsAuthenticated || - context.ActorUserKey is null) + if (!context.IsAuthenticated || context.ActorUserKey is null) { return ValueTask.FromResult(AccessDecision.Deny("lab_authentication_required")); } - // Ladmin has unrestricted application-level access. - if (rootResolver.IsLadmin(context)) - return ValueTask.FromResult(AccessDecision.Allow()); - - // Role reading is available to authenticated users. - if (IsReadAction(context.Action)) - return ValueTask.FromResult(AccessDecision.Allow()); - - // TODO (v0.2.0): - // Support sandbox-scoped role assignment and removal. + // Public role mutation is disabled for every user, + // including Ladmin. // - // Requirements: - // - Owner may manage roles only for users in their sandbox. - // - Ladmin, Owner and User are protected application roles. - // - Owner cannot grant or revoke Ladmin privileges. - // - Global role definitions and permissions remain protected. - // - Validate the target user and target role using trusted - // operation metadata supplied by UltimateAuth. - // - Enforce all checks through IAccessAuthority. + // Identity Lab system roles are managed through trusted + // provisioning. Demo roles are managed through the + // sandbox-scoped LabDemoRoleService. + if (!IsReadAction(context.Action)) + { + return ValueTask.FromResult(AccessDecision.Deny("lab_role_management_restricted")); + } - return ValueTask.FromResult(AccessDecision.Deny("lab_role_management_restricted")); + // Preserve existing role-reading behavior. + return ValueTask.FromResult(AccessDecision.Allow()); } private static bool IsReadAction(string action) diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Policy/SandboxUAuthAccessPolicy.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Policy/SandboxUAuthAccessPolicy.cs index 0b26df59..1303ee42 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Policy/SandboxUAuthAccessPolicy.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Policy/SandboxUAuthAccessPolicy.cs @@ -9,7 +9,7 @@ namespace CodeBeam.UltimateAuth.IdentityLab.Policy; -public sealed class SandboxUAuthAccessPolicy(IDbContextFactory dbFactory, ILabRootResolver rootResolver) : IAccessPolicy +public sealed class SandboxUAuthAccessPolicy(IDbContextFactory dbFactory, ILabRootResolver rootResolver, LabProvisioningRollbackGate rollbackGate) : IAccessPolicy { public bool AppliesTo(AccessContext context) => IsProtectedAction(context.Action); @@ -123,6 +123,7 @@ public async ValueTask DecideAsync(AccessContext context, Cancel .AsNoTracking() .AnyAsync(x => x.UserKey == targetKey && x.SandboxId == actor.SandboxId, cancellationToken); + if (action == UAuthActions.Users.DeleteAdmin) { var targetIsOwner = await db.SandboxMembers @@ -134,11 +135,33 @@ public async ValueTask DecideAsync(AccessContext context, Cancel cancellationToken); if (targetIsOwner) - return AccessDecision.Deny("sandbox_owner_cannot_be_deleted"); + return AccessDecision.Deny( + "sandbox_owner_cannot_be_deleted"); + + if (!belongsToSandbox && + actor.Role == SandboxMemberRole.Owner && + actor.Status == SandboxStatus.Active) + { + // Never use rollback to delete an existing sandbox member. + var hasAnyMembership = await db.SandboxMembers + .AsNoTracking() + .AnyAsync(x => x.UserKey == targetKey, + cancellationToken); + + if (!hasAnyMembership && + rollbackGate.Consume( + context.Attributes, + actorKey, + targetKey)) + { + return AccessDecision.Allow(); + } + } } if (!belongsToSandbox) return AccessDecision.Deny("sandbox_target_out_of_scope"); + } return AccessDecision.Allow(); diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs index cb5eb621..639b2d8d 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs @@ -102,9 +102,12 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.Configure(builder.Configuration.GetSection(LabAdminOptions.SectionName)); @@ -112,6 +115,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddLogging(); var app = builder.Build(); diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Seed/LabDemoRoleSeedContributor.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Seed/LabDemoRoleSeedContributor.cs new file mode 100644 index 00000000..434215bd --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Seed/LabDemoRoleSeedContributor.cs @@ -0,0 +1,69 @@ +using CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.IdentityLab.Authorization; + +namespace CodeBeam.UltimateAuth.IdentityLab.Seed; + +public sealed class LabDemoRoleSeedContributor(IRoleStoreFactory roleStoreFactory, IClock clock) : ISeedContributor +{ + public int Order => 10; + + public async Task SeedAsync(TenantKey tenant, CancellationToken ct = default) + { + var roleStore = roleStoreFactory.Create(tenant); + + var definitions = new[] + { + ( + Name: LabRoles.Manager, + Id: LabRoleIds.Manager, + Permissions: LabDemoPermissions.Manager + ), + ( + Name: LabRoles.Customer, + Id: LabRoleIds.Customer, + Permissions: LabDemoPermissions.Customer + ) + }; + + foreach (var definition in definitions) + { + var existing = await roleStore.GetByNameAsync(definition.Name.ToUpperInvariant(), ct); + + if (existing is not null) + { + if (existing.Id != definition.Id || existing.IsDeleted) + { + throw new InvalidOperationException($"Demo role '{definition.Name}' has an invalid definition."); + } + + var expectedPermissions = definition.Permissions.Select(Permission.From).ToHashSet(); + + if (!existing.Permissions.ToHashSet() + .SetEquals(expectedPermissions)) + { + var expectedVersion = existing.Version; + + existing.SetPermissions( + expectedPermissions, + clock.UtcNow); + + await roleStore.SaveAsync( + existing, + expectedVersion, + ct); + } + + continue; + } + + var permissions = definition.Permissions.Select(Permission.From).ToHashSet(); + var role = Role.Create(definition.Id, tenant, definition.Name, permissions, clock.UtcNow); + + await roleStore.AddAsync(role, ct); + + } + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabAdminService.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabAdminService.cs index d0ff6974..81780dd4 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabAdminService.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabAdminService.cs @@ -1,7 +1,7 @@ using CodeBeam.UltimateAuth.Authorization; -using CodeBeam.UltimateAuth.Client; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.IdentityLab.Authorization; using CodeBeam.UltimateAuth.IdentityLab.Contracts; using CodeBeam.UltimateAuth.IdentityLab.Domain; using CodeBeam.UltimateAuth.IdentityLab.Persistence; @@ -17,21 +17,24 @@ public async Task IsAdminAsync(UserKey actorUserKey, CancellationToken ct { var tenant = TenantKeys.Single; - var assignments = await userRoleStoreFactory.Create(tenant).GetAssignmentsAsync(actorUserKey, ct); + var roles = await roleStoreFactory + .Create(tenant) + .GetByIdsAsync([LabRoleIds.Admin], ct); - if (assignments.Count == 0) - return false; + var adminRole = roles.FirstOrDefault(x => + x.Id == LabRoleIds.Admin && + x.Tenant == tenant && + !x.IsDeleted); - var roleIds = assignments - .Select(x => x.RoleId) - .Distinct() - .ToArray(); + if (adminRole is null) + return false; - var roles = await roleStoreFactory + var assignments = await userRoleStoreFactory .Create(tenant) - .GetByIdsAsync(roleIds, ct); + .GetAssignmentsAsync(actorUserKey, ct); - return roles.Any(x => string.Equals(x.Name, "Ladmin", StringComparison.OrdinalIgnoreCase)); + return assignments.Any(x => + x.RoleId == LabRoleIds.Admin); } private async Task RequireAdminAsync(UserKey actorUserKey, CancellationToken ct) diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabDemoRoleService.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabDemoRoleService.cs new file mode 100644 index 00000000..d3ffb645 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabDemoRoleService.cs @@ -0,0 +1,127 @@ +using CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.IdentityLab.Authorization; +using CodeBeam.UltimateAuth.IdentityLab.Domain; +using CodeBeam.UltimateAuth.IdentityLab.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.IdentityLab.Services; + +public sealed class LabDemoRoleService(LabAccessContextProvider contextProvider, IDbContextFactory dbFactory, + IRoleStoreFactory roleStoreFactory, IUserRoleStoreFactory userRoleStoreFactory) +{ + public async Task SetRoleAsync(UserKey targetUserKey, string roleName, bool assigned, CancellationToken ct = default) + { + // Never accept arbitrary roles. + if (!LabRoles.IsDemoRole(roleName)) + throw new UnauthorizedAccessException("Only demo roles can be managed here."); + + var context = await contextProvider.CreateAsync(UAuthActions.Authorization.Roles.AssignAdmin, "authorization.roles", ct); + + if (!context.IsAuthenticated || + context.IsSystemActor || + context.ActorUserKey is not UserKey actorKey) + { + throw new UnauthorizedAccessException(); + } + + await using var db = await dbFactory.CreateDbContextAsync(ct); + + // Resolve ownership from the Sandbox database, + // never from global authorization roles. + var owner = await db.SandboxMembers + .AsNoTracking() + .Where(x => + x.UserKey == actorKey && + x.Role == SandboxMemberRole.Owner && + x.Sandbox.Status == SandboxStatus.Active) + .Select(x => new { x.SandboxId }) + .SingleOrDefaultAsync(ct); + + if (owner is null) + throw new UnauthorizedAccessException( + "Active sandbox ownership is required."); + + // The target must be a regular member of this sandbox. + var targetAllowed = await db.SandboxMembers + .AsNoTracking() + .AnyAsync(x => + x.SandboxId == owner.SandboxId && + x.UserKey == targetUserKey && + x.Role == SandboxMemberRole.Member, + ct); + + if (!targetAllowed) + throw new UnauthorizedAccessException("Target must be a member of your sandbox."); + + var roleStore = roleStoreFactory.Create(TenantKeys.Single); + var userRoleStore = userRoleStoreFactory.Create(TenantKeys.Single); + + var expectedId = roleName.Equals(LabRoles.Manager, + StringComparison.OrdinalIgnoreCase) + ? LabRoleIds.Manager + : LabRoleIds.Customer; + + var role = await roleStore.GetByNameAsync(roleName.ToUpperInvariant(), ct); + + if (role is null || role.IsDeleted || role.Id != expectedId) + { + throw new InvalidOperationException("Demo role is not correctly configured."); + } + + var assignments = await userRoleStore.GetAssignmentsAsync(targetUserKey, ct); + + var alreadyAssigned = assignments.Any(x => x.RoleId == role.Id); + + if (assigned && !alreadyAssigned) + { + await userRoleStore.AssignAsync(targetUserKey, role.Id, DateTimeOffset.UtcNow, ct); + } + else if (!assigned && alreadyAssigned) + { + await userRoleStore.RemoveAsync(targetUserKey, role.Id, ct); + } + } + + public async Task> GetRolesAsync(UserKey targetUserKey, CancellationToken ct = default) + { + var context = await contextProvider.CreateAsync(UAuthActions.Authorization.Roles.GetAdmin, "authorization.roles", ct); + + if (!context.IsAuthenticated || context.IsSystemActor || context.ActorUserKey is not UserKey actorKey) + { + throw new UnauthorizedAccessException(); + } + + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var allowed = await db.SandboxMembers + .AsNoTracking() + .AnyAsync(actor => + actor.UserKey == actorKey && + actor.Role == SandboxMemberRole.Owner && + actor.Sandbox.Status == SandboxStatus.Active && + db.SandboxMembers.Any(target => + target.SandboxId == actor.SandboxId && + target.UserKey == targetUserKey && + target.Role == SandboxMemberRole.Member), + ct); + + if (!allowed) + throw new UnauthorizedAccessException(); + + var store = userRoleStoreFactory.Create(TenantKeys.Single); + var assignments = await store.GetAssignmentsAsync(targetUserKey, ct); + + var result = new List(); + + if (assignments.Any(x => x.RoleId == LabRoleIds.Manager)) + result.Add(LabRoles.Manager); + + if (assignments.Any(x => x.RoleId == LabRoleIds.Customer)) + result.Add(LabRoles.Customer); + + return result; + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabDemoStoreService.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabDemoStoreService.cs new file mode 100644 index 00000000..c0ba82dd --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabDemoStoreService.cs @@ -0,0 +1,107 @@ +using CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.IdentityLab.Authorization; +using CodeBeam.UltimateAuth.IdentityLab.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.IdentityLab.Services; + +public sealed record DemoProduct(int Id, string Name, decimal Price); + +public sealed class LabDemoStoreService( + LabAccessContextProvider contextProvider, + IDbContextFactory dbFactory, + IRoleStoreFactory roleStoreFactory, + IUserRoleStoreFactory userRoleStoreFactory) +{ + public async Task> GetPermissionsAsync( + CancellationToken ct = default) + { + var context = await contextProvider.CreateAsync( + LabDemoPermissions.ProductsRead, + "demo.products", + ct); + + if (!context.IsAuthenticated || + context.ActorUserKey is not UserKey actorKey) + { + return new HashSet(); + } + + await using var db = + await dbFactory.CreateDbContextAsync(ct); + + // A suspended sandbox cannot use the playground. + var activeMember = await db.SandboxMembers + .AsNoTracking() + .AnyAsync(x => + x.UserKey == actorKey && + x.Sandbox.Status == + CodeBeam.UltimateAuth.IdentityLab.Domain.SandboxStatus.Active, + ct); + + if (!activeMember) + return new HashSet(); + + var userRoleStore = + userRoleStoreFactory.Create(TenantKeys.Single); + + var assignments = + await userRoleStore.GetAssignmentsAsync(actorKey, ct); + + var demoIds = new[] + { + LabRoleIds.Manager, + LabRoleIds.Customer + }; + + var assignedIds = assignments + .Select(x => x.RoleId) + .Where(demoIds.Contains) + .Distinct() + .ToArray(); + + var roleStore = roleStoreFactory.Create(TenantKeys.Single); + var roles = await roleStore.GetByIdsAsync(assignedIds, ct); + + var permissions = new HashSet( + StringComparer.OrdinalIgnoreCase); + + foreach (var role in roles) + { + if (role.IsDeleted) + continue; + + // Fixed role identity: no other global roles participate. + if (role.Id != LabRoleIds.Manager && + role.Id != LabRoleIds.Customer) + continue; + + foreach (var permission in role.Permissions) + { + if (permission.IsWildcard || permission.IsPrefix) + continue; + + if (LabDemoPermissions.Manager.Contains(permission.Value) || + LabDemoPermissions.Customer.Contains(permission.Value)) + { + permissions.Add(permission.Value); + } + } + } + + return permissions; + } + + public async Task RequireAsync( + string permission, + CancellationToken ct = default) + { + var permissions = await GetPermissionsAsync(ct); + + if (!permissions.Contains(permission)) + throw new UnauthorizedAccessException( + $"Demo permission '{permission}' is required."); + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabProvisioningRollbackGate.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabProvisioningRollbackGate.cs new file mode 100644 index 00000000..50e42ca9 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabProvisioningRollbackGate.cs @@ -0,0 +1,41 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.IdentityLab.Services; + +public sealed class LabProvisioningRollbackGate +{ + internal const string AttributeKey = "identitylab.internal.provisioning.rollback"; + + private readonly Dictionary + _pending = new(ReferenceEqualityComparer.Instance); + + internal object Register(UserKey actor, UserKey target) + { + var token = new object(); + _pending.Add(token, (actor, target)); + return token; + } + + internal bool Consume( + IReadOnlyDictionary attributes, + UserKey actor, + UserKey target) + { + if (!attributes.TryGetValue(AttributeKey, out var token)) + return false; + + if (!_pending.TryGetValue(token, out var expected)) + return false; + + if (expected.Actor != actor || expected.Target != target) + return false; + + _pending.Remove(token); + return true; + } + + internal void Revoke(object token) + { + _pending.Remove(token); + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRootResolver.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRootResolver.cs index 0dc00545..785d14fe 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRootResolver.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRootResolver.cs @@ -1,7 +1,6 @@ using CodeBeam.UltimateAuth.Authorization.Contracts; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.IdentityLab.Authorization; -using CodeBeam.UltimateAuth.IdentityLab.Domain; namespace CodeBeam.UltimateAuth.IdentityLab.Services; @@ -9,17 +8,18 @@ public sealed class LabRootResolver : ILabRootResolver { public bool IsLadmin(AccessContext context) { - if (!context.IsAuthenticated || context.ActorUserKey is null) + if (!context.IsAuthenticated || + context.IsSystemActor || + context.ActorUserKey is null || + context.ActorTenant.IsSystem || + context.ActorTenant.IsUnresolved || + context.IsCrossTenant) { return false; } - if (!context.Attributes.TryGetValue("roles", out var value)) - { - return false; - } - - var result = value is IEnumerable roleIds && roleIds.Contains(LabRoleIds.Admin); - return result; + return context.Attributes.TryGetValue("roles", out var value) + && value is IEnumerable roleIds + && roleIds.Contains(LabRoleIds.Admin); } } diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabUserProvisioningService.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabUserProvisioningService.cs index 91547940..858f630f 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabUserProvisioningService.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabUserProvisioningService.cs @@ -1,4 +1,6 @@ using CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.IdentityLab.Authorization; @@ -15,6 +17,7 @@ public sealed class LabUserProvisioningService( IUserApplicationService userApplicationService, IRoleStoreFactory roleStoreFactory, IUserRoleStoreFactory userRoleStoreFactory, + LabProvisioningRollbackGate rollbackGate, ILogger logger) { public async Task CreateMemberAsync( @@ -84,8 +87,13 @@ context.ActorUserKey is not UserKey authenticatedUserKey || return result; } + var newUserKey = UserKey.FromString(result.UserKey); + var rollbackToken = rollbackGate.Register( + authenticatedUserKey, + newUserKey); + try { var userRoleStore = userRoleStoreFactory.Create( @@ -115,10 +123,59 @@ await sandboxService.AddMemberAsync( "Partial provisioning failure for user {UserKey}", newUserKey.Value); + try + { + // Do not reuse the cancelled provisioning token. + using var cleanupCts = + new CancellationTokenSource(TimeSpan.FromSeconds(15)); + + var deleteContext = await contextProvider.CreateForUserAsync( + UAuthActions.Users.DeleteAdmin, + "users", + newUserKey, + cleanupCts.Token); + + if (deleteContext.ActorUserKey != authenticatedUserKey || + !deleteContext.IsAuthenticated || + deleteContext.IsSystemActor) + { + throw new UnauthorizedAccessException( + "Rollback actor mismatch."); + } + + var trustedDeleteContext = deleteContext.WithAttribute( + LabProvisioningRollbackGate.AttributeKey, + rollbackToken); + + await userApplicationService.DeleteUserAsync( + trustedDeleteContext, + new DeleteUserRequest + { + Mode = DeleteMode.Hard + }, + cleanupCts.Token); + + logger.LogInformation( + "Provisioning rollback completed for {UserKey}", + newUserKey.Value); + } + catch (Exception rollbackEx) + { + logger.LogCritical( + rollbackEx, + "Provisioning rollback failed for {UserKey}. " + + "Manual reconciliation may be required.", + newUserKey.Value); + } + throw new LabPartialProvisioningException( newUserKey.Value, ex); } + finally + { + rollbackGate.Revoke(rollbackToken); + } return result; } From c89ff5a8522cf789e54039366e9938631e7f4f32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Thu, 29 Oct 2026 00:29:51 +0300 Subject: [PATCH 3/9] Layout, Login and Register Rage Design Improvements --- .../Components/Custom/DashboardSidebar.razor | 33 -- .../Components/Custom/PublicFooter.razor | 33 +- .../Components/Custom/PublicHeader.razor | 31 +- .../Components/Layout/DashboardLayout.razor | 41 +- .../Pages/Dashboard/MyAccessPage.razor | 47 +- .../Components/Pages/LandingPage.razor | 15 +- .../Components/Pages/LoginPage.razor | 424 ++++++++++++++---- .../Components/Pages/LoginPage.razor.css | 181 ++++++++ .../Components/Pages/RegisterPage.razor | 325 ++++++++++---- .../Components/Pages/RegisterPage.razor.css | 23 + .../Components/_Imports.razor | 8 + .../Contracts/LabRegistrationRequest.cs | 5 +- .../Program.cs | 8 +- .../Seed/LabAdminSeedContributor.cs | 2 - .../Services/LabRegistrationService.cs | 17 +- .../Components/Dialogs/CreateUserDialog.razor | 7 +- .../Components/Dialogs/CredentialDialog.razor | 7 +- .../Components/Dialogs/PermissionDialog.razor | 23 +- .../Components/Dialogs/ProfileDialog.razor | 7 +- .../Components/Dialogs/ResetDialog.razor | 12 +- .../Components/Dialogs/UserDetailDialog.razor | 7 +- .../Components/Dialogs/UserRoleDialog.razor | 31 +- 22 files changed, 880 insertions(+), 407 deletions(-) delete mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/DashboardSidebar.razor 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/PublicFooter.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/PublicFooter.razor index d688b780..ceca3aa9 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/PublicFooter.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/PublicFooter.razor @@ -4,8 +4,7 @@ - - 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. @@ -212,20 +202,20 @@
- + SearchBox="true"> @foreach (var permission in _availablePermissions) { - + @permission - + } - + - - - @(_accessResult.IsAllowed - ? "Access Granted" - : "Access Denied") - + + @(_accessResult.IsAllowed ? "Access Granted" : "Access Denied") @if (_accessResult.RequiresReauthentication) { @@ -299,11 +281,7 @@ } else { -

- @(_accessResult.IsAllowed - ? "Your current session is authorized." - : "Your current session is not authorized.") -

+

@(_accessResult.IsAllowed ? "Your current session is authorized." : "Your current session is not authorized.")

}
@@ -313,8 +291,7 @@ 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..659c4f42 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 - @@ -89,7 +82,7 @@
-
AUTHENTICATION PLATFORM
+
AUTH PLATFORM
One identity. Complete control.
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..345e7e53 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,213 @@

- @(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.")

-
@@ -140,7 +260,10 @@
Roles & permissions -

Experiment with access control inside your sandbox.

+

+ Experiment with access control inside + your sandbox. +

@@ -162,8 +285,17 @@ private string _email = string.Empty; private string _password = string.Empty; private string _confirmPassword = string.Empty; - private bool _showPassword; + + private string _firstName = string.Empty; + private string _lastName = string.Empty; + private DateTime? _birthDate; + private bool _isSubmitting; + private bool _registered; + private bool _requiresRepair; + private bool _showOptionalFields; + + private string? _errorMessage; private MudForm? _form; private static string? ValidateEmail(string value) @@ -171,9 +303,10 @@ if (string.IsNullOrWhiteSpace(value)) return "Email address is required."; - return new System.ComponentModel.DataAnnotations.EmailAddressAttribute().IsValid(value) - ? null - : "Enter a valid email address."; + return new System.ComponentModel.DataAnnotations + .EmailAddressAttribute().IsValid(value) + ? null + : "Enter a valid email address."; } private static string? ValidatePassword(string value) @@ -192,41 +325,67 @@ if (string.IsNullOrWhiteSpace(value)) return "Please confirm your password."; - if (value != _password) - return "Passwords do not match."; - - return null; + return value == _password + ? null + : "Passwords do not match."; } private async Task SubmitAsync() { - if (_form is null || _isSubmitting) + if (_form is null || _isSubmitting || _registered) return; + _errorMessage = null; + _requiresRepair = false; + await _form.ValidateAsync(); if (!_form.IsValid) return; + if (_birthDate is { } birthDate && + birthDate.Date > DateTime.Today) + { + _errorMessage = "Date of birth cannot be in the future."; + return; + } + _isSubmitting = true; try { - var result = await RegistrationService.RegisterAsync(new LabRegistrationRequest(_username.Trim(), _email.Trim(), _password)); + var request = new LabRegistrationRequest( + Username: _username.Trim(), + Email: _email.Trim(), + Password: _password, + FirstName: string.IsNullOrWhiteSpace(_firstName) + ? null + : _firstName.Trim(), + LastName: string.IsNullOrWhiteSpace(_lastName) + ? null + : _lastName.Trim(), + BirthDate: _birthDate is { } date + ? DateOnly.FromDateTime(date) + : null); + + var result = await RegistrationService.RegisterAsync(request); if (!result.IsSuccess) { - Snackbar.Add(result.ErrorMessage ?? "Registration failed.", Severity.Error); + _errorMessage = result.ErrorMessage + ?? "Registration could not be completed."; + + _requiresRepair = result.RequiresRepair; return; } - Snackbar.Add("Your sandbox has been created successfully.", Severity.Success); - - Navigation.NavigateTo("/login"); + _registered = true; } catch (Exception) { - Snackbar.Add("An unexpected error occurred during registration.", Severity.Error); + _errorMessage = + "An unexpected error occurred. If you already submitted " + + "your registration, try signing in before registering again."; } finally { diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/RegisterPage.razor.css b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/RegisterPage.razor.css index b2dbfd76..f24aca85 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/RegisterPage.razor.css +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/RegisterPage.razor.css @@ -249,6 +249,29 @@ color: var(--mud-palette-text-secondary); } +.lab-login-grid { + align-items: start; +} + +.lab-login-form-section, +.lab-login-showcase { + min-width: 0; +} + +@media (min-width: 960px) { + .lab-login-showcase { + align-self: start; + position: sticky; + top: 24px; + } +} + +@media (max-width: 959.98px) { + .lab-login-showcase { + position: static; + } +} + @media (max-width: 600px) { .lab-login-intro { margin-bottom: 22px; diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/_Imports.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/_Imports.razor index 5d779c45..3476f00a 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/_Imports.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/_Imports.razor @@ -7,16 +7,24 @@ @using static Microsoft.AspNetCore.Components.Web.RenderMode @using Microsoft.AspNetCore.Components.Web.Virtualization @using Microsoft.JSInterop +@using Microsoft.Extensions.Options @using CodeBeam.UltimateAuth.IdentityLab +@using CodeBeam.UltimateAuth.IdentityLab.Contracts +@using CodeBeam.UltimateAuth.IdentityLab.Services @using CodeBeam.UltimateAuth.IdentityLab.Components @using CodeBeam.UltimateAuth.IdentityLab.Components.Layout @using CodeBeam.UltimateAuth.IdentityLab.Components.Custom @using CodeBeam.UltimateAuth.IdentityLab.Components.Pages.Dialogs +@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Core.Domain +@using CodeBeam.UltimateAuth.Server.Options @using CodeBeam.UltimateAuth.Client @using CodeBeam.UltimateAuth.Client.Blazor +@using CodeBeam.UltimateAuth.Users.Contracts @using CodeBeam.UltimateAuth.Sample.Shared.Common +@using CodeBeam.UltimateAuth.Sample.Shared.Infrastructure @using CodeBeam.UltimateAuth.Sample.Shared.Components @using CodeBeam.UltimateAuth.Sample.Shared.Components.Dialogs @using CodeBeam.UltimateAuth.Sample.Shared.Components.Page diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/LabRegistrationRequest.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/LabRegistrationRequest.cs index 2d4593af..6f2665d2 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/LabRegistrationRequest.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/LabRegistrationRequest.cs @@ -3,4 +3,7 @@ public sealed record LabRegistrationRequest( string Username, string Email, - string Password); + string Password, + string? FirstName = null, + string? LastName = null, + DateOnly? BirthDate = null); diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs index 639b2d8d..5d5402ba 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs @@ -22,12 +22,6 @@ var builder = WebApplication.CreateBuilder(args); -// TODO(Part 3): Protect system roles through UltimateAuth request guards. -// TODO(Part 3): Add atomic provisioning and recovery for partial failures. -// TODO(Part 3): Enforce sandbox ownership and user limits across all applicable UltimateAuth operations. -// TODO(Part 3): Implement authenticated sandbox repair. - -// Add services to the container. builder.Services.AddRazorComponents() .AddInteractiveServerComponents(); //.AddCircuitOptions(o => o.DetailedErrors = true); @@ -55,7 +49,7 @@ //o.Session.IdleTimeout = TimeSpan.FromSeconds(15); //o.Token.AccessTokenLifetime = TimeSpan.FromSeconds(30); //o.Token.RefreshTokenLifetime = TimeSpan.FromSeconds(32); - o.Login.MaxFailedAttempts = 5; + o.Login.MaxFailedAttempts = 3; o.Login.LockoutDuration = TimeSpan.FromMinutes(1); o.Identifiers.Behavior.AllowMultipleUsernames = true; diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Seed/LabAdminSeedContributor.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Seed/LabAdminSeedContributor.cs index 7b1858fc..eee11671 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Seed/LabAdminSeedContributor.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Seed/LabAdminSeedContributor.cs @@ -170,8 +170,6 @@ await store.AddAsync( ct); } - // TODO(Part 3 - Security): - // Owner wildcard permission is restricked with SandboxUAuthAccessPolicy, but not tested. private async Task EnsureSystemRolesAsync(TenantKey tenant, CancellationToken ct) { var roleStore = roleStoreFactory.Create(tenant); diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRegistrationService.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRegistrationService.cs index 19c3a3a9..44fbbbdb 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRegistrationService.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRegistrationService.cs @@ -27,13 +27,20 @@ public async Task RegisterAsync(LabRegistrationRequest re var trustedContext = context.WithAttribute(LabProvisioningGate.AttributeKey, LabProvisioningGate.Token); - var createResult = await userApplicationService.CreateUserAsync( - trustedContext, + + var createResult = await userApplicationService.CreateUserAsync(trustedContext, new CreateUserRequest { UserName = request.Username.Trim(), Email = request.Email.Trim(), - Password = request.Password + Password = request.Password, + FirstName = string.IsNullOrWhiteSpace(request.FirstName) + ? null + : request.FirstName.Trim(), + LastName = string.IsNullOrWhiteSpace(request.LastName) + ? null + : request.LastName.Trim(), + BirthDate = request.BirthDate }, ct); @@ -45,10 +52,6 @@ public async Task RegisterAsync(LabRegistrationRequest re var userKey = UserKey.FromString(createResult.UserKey); - // TODO(Part 3): Replace direct role-store provisioning with - // a trusted UltimateAuth registration/provisioning extension. - // Handle partial failures and recovery atomically where possible. - try { var tenant = TenantKeys.Single; diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CreateUserDialog.razor b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CreateUserDialog.razor index 076a318a..7ef9205a 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CreateUserDialog.razor +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CreateUserDialog.razor @@ -134,14 +134,15 @@ Cancel
- + OnClick="CreateUserAsync" + AutoDelay="0"> Create User - + diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CredentialDialog.razor b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CredentialDialog.razor index 2e7076e8..9f32bd10 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CredentialDialog.razor +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CredentialDialog.razor @@ -132,14 +132,15 @@ Cancel - + OnClick="ChangePasswordAsync" + AutoDelay="0"> @(UserKey is null ? "Change Password" : "Set Password") - + diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/PermissionDialog.razor b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/PermissionDialog.razor index 45c130f8..f4afe7c0 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/PermissionDialog.razor +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/PermissionDialog.razor @@ -1,5 +1,4 @@ -@using CodeBeam.UltimateAuth.Authorization.Contracts -@inject IUAuthClient UAuthClient +@inject IUAuthClient UAuthClient @inject ISnackbar Snackbar @@ -22,11 +21,8 @@
@if (PreviewMode) { - - Preview mode is active. You can explore permission - selection, but changes cannot be saved. + + Preview mode is active. You can explore permission selection, but changes cannot be saved. } @@ -114,13 +110,11 @@
- + Permission groups are organized by resource. Selecting a group selects its catalog permissions. - Changes take effect according to the authorization - evaluation and caching policies of the application. + Changes take effect according to the authorization evaluation and caching policies of the application.
@@ -135,14 +129,15 @@ Cancel - + OnClick="SaveAsync" + AutoDelay="0"> Save Permissions - +
diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/ProfileDialog.razor b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/ProfileDialog.razor index c1ee46fa..140615ad 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/ProfileDialog.razor +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/ProfileDialog.razor @@ -180,14 +180,15 @@ Cancel - + OnClick="SaveAsync" + AutoDelay="0"> Save Changes - + diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/ResetDialog.razor b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/ResetDialog.razor index bcd8505e..45612140 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/ResetDialog.razor +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/ResetDialog.razor @@ -37,10 +37,7 @@ Size="Size.Large" /> Forgot your password? - - Enter your username or email address to request - a password reset code. - + Enter your username or email address to request a password reset code. - + OnClick="RequestResetAsync" + AutoDelay="0"> Request Reset Code - + } else { diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UserDetailDialog.razor b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UserDetailDialog.razor index 25be39b8..82e9663d 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UserDetailDialog.razor +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UserDetailDialog.razor @@ -123,13 +123,14 @@ } - + OnClick="ChangeStatusAsync" + AutoDelay="0"> Update Status - + diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UserRoleDialog.razor b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UserRoleDialog.razor index dce6794e..c12f19cb 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UserRoleDialog.razor +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UserRoleDialog.razor @@ -1,5 +1,4 @@ -@using CodeBeam.UltimateAuth.Authorization.Contracts -@inject IUAuthClient UAuthClient +@inject IUAuthClient UAuthClient @inject IDialogService DialogService @inject ISnackbar Snackbar @@ -23,22 +22,18 @@
@if (PreviewMode) { - + Preview mode is active. Role assignments cannot be changed. } @if (_isLoading) { - + } else if (_loadError is not null) { - + @_loadError } @@ -46,9 +41,7 @@ {
- +
Assigned Roles Roles currently granted to this identity. @@ -58,9 +51,7 @@ @if (_roles.Count == 0) {
- + No roles assigned to this user.
} @@ -111,14 +102,14 @@ } - Assign Role - +
@if (AvailableRoles.Count == 0) @@ -130,12 +121,10 @@
- + Role assignments affect authorization decisions. - Changes may require a new authentication or authorization - evaluation to be reflected in active sessions. + Changes may require a new authentication or authorization evaluation to be reflected in active sessions.
} From 3529a68e7788377af95a265b13a89e0f04afe264 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Thu, 29 Oct 2026 14:30:59 +0300 Subject: [PATCH 4/9] Session Diagnostics Page --- .../Components/Custom/LabOptionInfo.razor | 56 ++ .../Components/Layout/DashboardLayout.razor | 148 ++--- .../Layout/DashboardLayout.razor.cs | 142 +++++ .../Pages/Dashboard/SessionDiagnostics.razor | 430 ++++++++++++++ .../Dashboard/SessionDiagnostics.razor.cs | 556 ++++++++++++++++++ .../Dashboard/SessionDiagnostics.razor.css | 42 ++ .../Components/_Imports.razor | 1 + .../Program.cs | 15 +- .../LabSessionDiagnosticsPolicyService.cs | 32 + .../Infrastructure/SessionCoordinator.cs | 7 + .../Diagnostics/UAuthClientDiagnostics.cs | 23 + 11 files changed, 1328 insertions(+), 124 deletions(-) create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/LabOptionInfo.razor create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor.cs create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/SessionDiagnostics.razor create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/SessionDiagnostics.razor.cs create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/SessionDiagnostics.razor.css create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabSessionDiagnosticsPolicyService.cs 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/Layout/DashboardLayout.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor index 006c92af..90de0d43 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor @@ -3,6 +3,7 @@ @inject LabDashboardService DashboardService @inject IUAuthClient UAuthClient @inject DarkModeManager DarkModeManager +@inject ISnackbar Snackbar @@ -12,11 +13,7 @@
+ + + + + Validate Session + + + + Refresh Session + + + + Session Diagnostics + - + My Identity - + My Sessions - + Sign Out @@ -139,24 +131,23 @@
PERSONAL
- + My Identity - + My Sessions - + + Session Diagnostics + + + My Access - + Authorization Lab @@ -197,7 +188,7 @@
- + Powered by UltimateAuth
@@ -233,74 +224,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..8f54d828 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor.cs @@ -0,0 +1,142 @@ +// +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 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/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/_Imports.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/_Imports.razor index 3476f00a..40979afb 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/_Imports.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/_Imports.razor @@ -21,6 +21,7 @@ @using CodeBeam.UltimateAuth.Server.Options @using CodeBeam.UltimateAuth.Client @using CodeBeam.UltimateAuth.Client.Blazor +@using CodeBeam.UltimateAuth.Client.Diagnostics @using CodeBeam.UltimateAuth.Users.Contracts @using CodeBeam.UltimateAuth.Sample.Shared.Common diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs index 5d5402ba..b4c9da0e 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs @@ -43,12 +43,7 @@ // Identity Lab uses its own trusted creation align with Sandbox management. o.Endpoints.DisabledActions.Add(UAuthActions.Users.CreateAnonymous); o.Diagnostics.EnableRefreshDetails = true; - //o.Session.MaxLifetime = TimeSpan.FromSeconds(32); - //o.Session.Lifetime = TimeSpan.FromSeconds(32); - //o.Session.TouchInterval = TimeSpan.FromSeconds(9); - //o.Session.IdleTimeout = TimeSpan.FromSeconds(15); - //o.Token.AccessTokenLifetime = TimeSpan.FromSeconds(30); - //o.Token.RefreshTokenLifetime = TimeSpan.FromSeconds(32); + o.Session.IdleTimeout = TimeSpan.FromDays(4); o.Login.MaxFailedAttempts = 3; o.Login.LockoutDuration = TimeSpan.FromMinutes(1); o.Identifiers.Behavior.AllowMultipleUsernames = true; @@ -59,18 +54,17 @@ }) .AddUltimateAuthEntityFrameworkCore(db => { - db.UseNpgsql(connectionString, - options => options.MigrationsAssembly(typeof(Program).Assembly.GetName().Name)); + db.UseNpgsql(connectionString, options => options.MigrationsAssembly(typeof(Program).Assembly.GetName().Name)); }); builder.Services.AddUltimateAuthClientBlazor(o => { - //o.AutoRefresh.Interval = TimeSpan.FromSeconds(5); + o.AutoRefresh.Enabled = true; + o.AutoRefresh.Interval = TimeSpan.FromMinutes(10); o.Reauth.Behavior = ReauthBehavior.RaiseEvent; o.Resource.Credentials = UAuthResourceCredentialsMode.SameOrigin; }); - builder.Services.AddDbContextFactory(options => { options.UseNpgsql(connectionString, npgsql => @@ -98,6 +92,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabSessionDiagnosticsPolicyService.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabSessionDiagnosticsPolicyService.cs new file mode 100644 index 00000000..24bdc1e2 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabSessionDiagnosticsPolicyService.cs @@ -0,0 +1,32 @@ +using CodeBeam.UltimateAuth.Client.Options; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.IdentityLab.Services; + +public sealed record LabSessionDiagnosticsPolicy( + TimeSpan SessionLifetime, + TimeSpan? TouchInterval, + TimeSpan? IdleTimeout, + TimeSpan? MaxLifetime, + bool SlidingExpiration, + bool AutoRefreshEnabled, + TimeSpan? AutoRefreshInterval); + +public sealed class LabSessionDiagnosticsPolicyService(IOptions serverOptions, IOptions clientOptions) +{ + public LabSessionDiagnosticsPolicy Get() + { + var session = serverOptions.Value.Session; + var autoRefresh = clientOptions.Value.AutoRefresh; + + return new LabSessionDiagnosticsPolicy( + session.Lifetime, + session.TouchInterval, + session.IdleTimeout, + session.MaxLifetime, + session.SlidingExpiration, + autoRefresh.Enabled, + autoRefresh.Interval); + } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/SessionCoordinator.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/SessionCoordinator.cs index fb90ef57..2c0ad536 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/SessionCoordinator.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/SessionCoordinator.cs @@ -53,7 +53,9 @@ private async Task RunAsync(CancellationToken ct) { while (!ct.IsCancellationRequested) { + _diagnostics.SetNextAutomaticRefresh(_clock.UtcNow.Add(interval)); await Task.Delay(interval, ct); + _diagnostics.SetNextAutomaticRefresh(null); await TickAsync(); if (_diagnostics.IsTerminated) @@ -62,6 +64,11 @@ private async Task RunAsync(CancellationToken ct) } catch (OperationCanceledException) { + // Normal coordinator shutdown. + } + finally + { + _diagnostics.SetNextAutomaticRefresh(null); } } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Diagnostics/UAuthClientDiagnostics.cs b/src/client/CodeBeam.UltimateAuth.Client/Diagnostics/UAuthClientDiagnostics.cs index 03008f9c..2276d67d 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Diagnostics/UAuthClientDiagnostics.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Diagnostics/UAuthClientDiagnostics.cs @@ -8,6 +8,7 @@ namespace CodeBeam.UltimateAuth.Client.Diagnostics; public sealed class UAuthClientDiagnostics { private int _terminatedCount; + private long _nextAutomaticRefreshTicks; ///
/// Occurs when any diagnostic information changes, allowing subscribers to react to updates in the client's state. @@ -120,6 +121,19 @@ StartedAt is null ? (StoppedAt ?? TerminatedAt) - StartedAt : DateTimeOffset.UtcNow - StartedAt); + /// + /// Gets the next scheduled automatic refresh attempt. + /// Null when no attempt is currently scheduled. + /// + public DateTimeOffset? NextAutomaticRefreshAt + { + get + { + var ticks = Interlocked.Read(ref _nextAutomaticRefreshTicks); + return ticks == 0 ? null : new DateTimeOffset(ticks, TimeSpan.Zero); + } + } + internal void MarkStarted() { StartedAt = DateTimeOffset.UtcNow; @@ -135,6 +149,7 @@ internal void MarkStopped() { StoppedAt = DateTimeOffset.UtcNow; StopCount++; + SetNextAutomaticRefresh(null); Changed?.Invoke(); } @@ -186,7 +201,15 @@ internal void MarkTerminated(CoordinatorTerminationReason reason) IsTerminated = true; TerminatedAt = DateTimeOffset.UtcNow; TerminationReason = reason; + SetNextAutomaticRefresh(null); Interlocked.Increment(ref _terminatedCount); Changed?.Invoke(); } + + internal void SetNextAutomaticRefresh(DateTimeOffset? scheduledAt) + { + var ticks = scheduledAt?.UtcTicks ?? 0; + Interlocked.Exchange(ref _nextAutomaticRefreshTicks, ticks); + Changed?.Invoke(); + } } From 506128023d251bb5dedb162d46bd871157a6678d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Thu, 29 Oct 2026 15:15:16 +0300 Subject: [PATCH 5/9] Fix Tests & Add Identifier Availability --- .../Common/IdentifierAvailabilityState.cs | 11 + .../Custom/IdentifierAvailabilityHint.razor | 67 ++++++ .../Components/Dialogs/CreateUserDialog.razor | 10 +- .../Dialogs/CreateUserDialog.razor.cs | 200 +++++++++++++++++- .../Diagnostics/UAuthClientDiagnostics.cs | 10 +- .../Client/ClientDiagnosticsTests.cs | 34 ++- 6 files changed, 321 insertions(+), 11 deletions(-) create mode 100644 samples/CodeBeam.UltimateAuth.Sample.Shared/Common/IdentifierAvailabilityState.cs create mode 100644 samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Custom/IdentifierAvailabilityHint.razor diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Common/IdentifierAvailabilityState.cs b/samples/CodeBeam.UltimateAuth.Sample.Shared/Common/IdentifierAvailabilityState.cs new file mode 100644 index 00000000..3a4524a8 --- /dev/null +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Common/IdentifierAvailabilityState.cs @@ -0,0 +1,11 @@ +namespace CodeBeam.UltimateAuth.Sample.Shared; + +public enum IdentifierAvailabilityState +{ + Empty, + Checking, + Available, + Unavailable, + Invalid, + Error +} diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Custom/IdentifierAvailabilityHint.razor b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Custom/IdentifierAvailabilityHint.razor new file mode 100644 index 00000000..64b6845e --- /dev/null +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Custom/IdentifierAvailabilityHint.razor @@ -0,0 +1,67 @@ +@using MudBlazor + +@if (State != IdentifierAvailabilityState.Empty) +{ + + + @switch (State) + { + case IdentifierAvailabilityState.Checking: + + + Checking availability... + + break; + + case IdentifierAvailabilityState.Available: + + + @Label is available + + break; + + case IdentifierAvailabilityState.Unavailable: + + + @Label is already in use + + break; + + case IdentifierAvailabilityState.Invalid: + + + Enter a valid @Label.ToLowerInvariant(). + + break; + + case IdentifierAvailabilityState.Error: + + + Unable to check @Label.ToLowerInvariant() availability. + + break; + } + + +} + +@code { + [Parameter] + public IdentifierAvailabilityState State { get; set; } + + [Parameter] + public string Label { get; set; } = "Identifier"; +} diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CreateUserDialog.razor b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CreateUserDialog.razor index 7ef9205a..28851e29 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CreateUserDialog.razor +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CreateUserDialog.razor @@ -1,5 +1,6 @@ @inject IUAuthClient UAuthClient @inject ISnackbar Snackbar +@implements IDisposable @@ -55,6 +56,7 @@ + + + + @@ -115,8 +122,7 @@
- Password requirements are enforced by the - configured UltimateAuth credential policy. + Password requirements are enforced by the configured UltimateAuth credential policy.
diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CreateUserDialog.razor.cs b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CreateUserDialog.razor.cs index 683e4c40..936d657a 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CreateUserDialog.razor.cs +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CreateUserDialog.razor.cs @@ -1,12 +1,11 @@ - -using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Contracts; using Microsoft.AspNetCore.Components; using MudBlazor; using System.Net.Mail; namespace CodeBeam.UltimateAuth.Sample.Shared.Components.Dialogs; -public partial class CreateUserDialog +public partial class CreateUserDialog : IDisposable { private MudForm _form = null!; @@ -47,6 +46,12 @@ private async Task CreateUserAsync() if (!_form.IsValid) return; + if (!IdentifiersAvailable) + { + Snackbar.Add("Please wait until username and email availability checks are complete.", Severity.Warning); + return; + } + if (_password != _passwordCheck) { Snackbar.Add("Passwords don't match.", Severity.Error); @@ -139,4 +144,193 @@ private void Cancel() if (!_isSubmitting) MudDialog.Cancel(); } + + + private IdentifierAvailabilityState _usernameAvailability = + IdentifierAvailabilityState.Empty; + + private IdentifierAvailabilityState _emailAvailability = + IdentifierAvailabilityState.Empty; + + private CancellationTokenSource? _usernameCheckCts; + private CancellationTokenSource? _emailCheckCts; + + private int _usernameVersion; + private int _emailVersion; + + private bool _disposed; + + private bool IdentifiersAvailable => + _usernameAvailability == IdentifierAvailabilityState.Available && + _emailAvailability == IdentifierAvailabilityState.Available; + + private Task OnUsernameChanged() + { + _usernameCheckCts?.Cancel(); + + var version = ++_usernameVersion; + + _usernameAvailability = string.IsNullOrWhiteSpace(_username) + ? IdentifierAvailabilityState.Empty + : IdentifierAvailabilityState.Checking; + + if (string.IsNullOrWhiteSpace(_username)) + return Task.CompletedTask; + + var cts = new CancellationTokenSource(); + _usernameCheckCts = cts; + + _ = CheckIdentifierAsync( + UserIdentifierType.Username, + _username.Trim(), + version, + cts); + + return Task.CompletedTask; + } + + private Task OnEmailChanged() + { + _emailCheckCts?.Cancel(); + + var version = ++_emailVersion; + + _emailAvailability = string.IsNullOrWhiteSpace(_email) + ? IdentifierAvailabilityState.Empty + : IdentifierAvailabilityState.Checking; + + if (string.IsNullOrWhiteSpace(_email)) + return Task.CompletedTask; + + if (ValidateEmail(_email) is not null) + { + _emailAvailability = IdentifierAvailabilityState.Invalid; + return Task.CompletedTask; + } + + var cts = new CancellationTokenSource(); + _emailCheckCts = cts; + + _ = CheckIdentifierAsync( + UserIdentifierType.Email, + _email.Trim(), + version, + cts); + + return Task.CompletedTask; + } + + private async Task CheckIdentifierAsync( + UserIdentifierType type, + string value, + int version, + CancellationTokenSource cts) + { + try + { + var token = cts.Token; + + await Task.Delay(450, token); + + if (token.IsCancellationRequested || _disposed) + return; + + var result = await UAuthClient.Identifiers.CheckAvailabilityAsync( + new CheckUserIdentifierAvailabilityRequest + { + Type = type, + Value = value + }); + + if (token.IsCancellationRequested || _disposed) + return; + + var state = !result.IsSuccess || result.Value is null + ? IdentifierAvailabilityState.Error + : !result.Value.IsValid + ? IdentifierAvailabilityState.Invalid + : result.Value.IsAvailable + ? IdentifierAvailabilityState.Available + : IdentifierAvailabilityState.Unavailable; + + await InvokeAsync(() => + { + if (_disposed || token.IsCancellationRequested) + return; + + if (type == UserIdentifierType.Username) + { + if (version != _usernameVersion) + return; + + _usernameAvailability = state; + } + else + { + if (version != _emailVersion) + return; + + _emailAvailability = state; + } + + StateHasChanged(); + }); + } + catch (OperationCanceledException) + { + // A newer input superseded this check. + } + catch (Exception) + { + if (!_disposed && !cts.IsCancellationRequested) + { + await InvokeAsync(() => + { + if (_disposed) + return; + + if (type == UserIdentifierType.Username && + version == _usernameVersion) + { + _usernameAvailability = IdentifierAvailabilityState.Error; + } + else if (type == UserIdentifierType.Email && + version == _emailVersion) + { + _emailAvailability = IdentifierAvailabilityState.Error; + } + + StateHasChanged(); + }); + } + } + finally + { + // The asynchronous operation owns its CTS. + // Dispose only after the operation has finished. + if (type == UserIdentifierType.Username) + { + if (ReferenceEquals(_usernameCheckCts, cts)) + _usernameCheckCts = null; + } + else + { + if (ReferenceEquals(_emailCheckCts, cts)) + _emailCheckCts = null; + } + + cts.Dispose(); + } + } + + public void Dispose() + { + _disposed = true; + + _usernameCheckCts?.Cancel(); + _emailCheckCts?.Cancel(); + + // CTS instances are disposed by their own + // CheckIdentifierAsync finally blocks. + } } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Diagnostics/UAuthClientDiagnostics.cs b/src/client/CodeBeam.UltimateAuth.Client/Diagnostics/UAuthClientDiagnostics.cs index 2276d67d..21f4186e 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Diagnostics/UAuthClientDiagnostics.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Diagnostics/UAuthClientDiagnostics.cs @@ -149,7 +149,7 @@ internal void MarkStopped() { StoppedAt = DateTimeOffset.UtcNow; StopCount++; - SetNextAutomaticRefresh(null); + Interlocked.Exchange(ref _nextAutomaticRefreshTicks, 0); Changed?.Invoke(); } @@ -201,7 +201,7 @@ internal void MarkTerminated(CoordinatorTerminationReason reason) IsTerminated = true; TerminatedAt = DateTimeOffset.UtcNow; TerminationReason = reason; - SetNextAutomaticRefresh(null); + Interlocked.Exchange(ref _nextAutomaticRefreshTicks, 0); Interlocked.Increment(ref _terminatedCount); Changed?.Invoke(); } @@ -209,7 +209,9 @@ internal void MarkTerminated(CoordinatorTerminationReason reason) internal void SetNextAutomaticRefresh(DateTimeOffset? scheduledAt) { var ticks = scheduledAt?.UtcTicks ?? 0; - Interlocked.Exchange(ref _nextAutomaticRefreshTicks, ticks); - Changed?.Invoke(); + var previous = Interlocked.Exchange(ref _nextAutomaticRefreshTicks, ticks); + + if (previous != ticks) + Changed?.Invoke(); } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/ClientDiagnosticsTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/ClientDiagnosticsTests.cs index f3d7dd4c..be8b92c9 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/ClientDiagnosticsTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/ClientDiagnosticsTests.cs @@ -80,14 +80,21 @@ public void MarkTerminated_ShouldSetTerminationState_AndIncrementCounter() public void ChangedEvent_ShouldFire_OnStateChanges() { var diagnostics = new UAuthClientDiagnostics(); - int changeCount = 0; + var changeCount = 0; diagnostics.Changed += () => changeCount++; diagnostics.MarkStarted(); + Assert.Equal(1, changeCount); + diagnostics.MarkManualRefresh(); + Assert.Equal(2, changeCount); + diagnostics.MarkRefreshTouched(); - diagnostics.MarkTerminated(CoordinatorTerminationReason.ReauthRequired); + Assert.Equal(3, changeCount); + + diagnostics.MarkTerminated( + CoordinatorTerminationReason.ReauthRequired); Assert.Equal(4, changeCount); } @@ -102,4 +109,27 @@ public void MarkTerminated_ShouldBeIdempotent_ForStateButCountShouldIncrease() Assert.True(diagnostics.IsTerminated); Assert.Equal(2, diagnostics.TerminatedCount); } + + [Fact] + public void SetNextAutomaticRefresh_ShouldNotifyOnlyWhenChanged() + { + var diagnostics = new UAuthClientDiagnostics(); + var changeCount = 0; + + diagnostics.Changed += () => changeCount++; + + var scheduledAt = DateTimeOffset.UtcNow.AddMinutes(5); + + diagnostics.SetNextAutomaticRefresh(scheduledAt); + Assert.Equal(1, changeCount); + + diagnostics.SetNextAutomaticRefresh(scheduledAt); + Assert.Equal(1, changeCount); + + diagnostics.SetNextAutomaticRefresh(null); + Assert.Equal(2, changeCount); + + diagnostics.SetNextAutomaticRefresh(null); + Assert.Equal(2, changeCount); + } } From c0e365c3605324f0f916447d3cae8f99f76aae2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Thu, 29 Oct 2026 20:52:50 +0300 Subject: [PATCH 6/9] Identifier Availability (Part 2) & Overview and MyIdentity Pages --- .../Pages/Dashboard/MyIdentityPage.razor | 258 +++++--- .../Components/Pages/DashboardPage.razor | 608 +++++++++--------- .../Components/Pages/DashboardPage.razor.css | 499 ++++++++------ .../Components/Pages/RegisterPage.razor | 300 ++++++++- .../Policy/SandboxUAuthAccessPolicy.cs | 5 + .../Program.cs | 1 + .../LabRegistrationAvailabilityService.cs | 104 +++ .../Services/LabRegistrationService.cs | 14 +- .../Custom/IdentifierAvailabilityHint.razor | 25 +- .../Components/Dialogs/CreateUserDialog.razor | 2 + 10 files changed, 1189 insertions(+), 627 deletions(-) create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRegistrationAvailabilityService.cs 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 f8131daa..d2c7fe2e 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 @@ -52,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 + } +
+
+
+ } +
@@ -434,6 +477,7 @@ { _loading = true; _loadError = null; + _profile = null; try { 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/RegisterPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/RegisterPage.razor index 31f2d3e0..35140163 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/RegisterPage.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/RegisterPage.razor @@ -1,6 +1,10 @@ @page "/register" @layout PublicLayout +@implements IDisposable + @inject LabRegistrationService RegistrationService +@inject LabRegistrationAvailabilityService AvailabilityService +@inject ISnackbar Snackbar @inject NavigationManager Navigation Create Sandbox · Identity Lab @@ -79,28 +83,35 @@ + Disabled="_isSubmitting" + HelperText="@GetAvailabilityMessage(_usernameAvailability, "Username")" /> + Disabled="_isSubmitting" + HelperText="@GetAvailabilityMessage(_emailAvailability, "Email")" /> state switch + { + LabIdentifierAvailability.Available => Color.Success, + LabIdentifierAvailability.Taken => Color.Error, + LabIdentifierAvailability.Invalid => Color.Error, + LabIdentifierAvailability.Unavailable => Color.Warning, + _ => Color.Secondary + }; + + private static string GetAvailabilityMessage(LabIdentifierAvailability state, string label) + => state switch + { + LabIdentifierAvailability.Checking => + "Checking availability...", + + LabIdentifierAvailability.Available => + $"{label} is available.", + + LabIdentifierAvailability.Taken => + $"{label} is already in use.", + + LabIdentifierAvailability.Invalid => + $"Enter a valid {label.ToLowerInvariant()}.", + + LabIdentifierAvailability.Unavailable => + "Availability could not be checked. Please try again.", + + _ => string.Empty + }; + + private Task OnUsernameChangedAsync() + { + _usernameCheckCts?.Cancel(); + + var version = ++_usernameVersion; + var value = _username.Trim(); + + if (string.IsNullOrWhiteSpace(value)) + { + _usernameAvailability = LabIdentifierAvailability.Unknown; + return Task.CompletedTask; + } + + _usernameAvailability = LabIdentifierAvailability.Checking; + + var cts = new CancellationTokenSource(); + _usernameCheckCts = cts; + + _ = CheckUsernameAsync(value, version, cts); + + return Task.CompletedTask; + } + + private Task OnEmailChangedAsync() + { + _emailCheckCts?.Cancel(); + + var version = ++_emailVersion; + var value = _email.Trim(); + + if (string.IsNullOrWhiteSpace(value)) + { + _emailAvailability = LabIdentifierAvailability.Unknown; + return Task.CompletedTask; + } + + if (ValidateEmail(value) is not null) + { + _emailAvailability = LabIdentifierAvailability.Invalid; + return Task.CompletedTask; + } + + _emailAvailability = LabIdentifierAvailability.Checking; + + var cts = new CancellationTokenSource(); + _emailCheckCts = cts; + + _ = CheckEmailAsync(value, version, cts); + + return Task.CompletedTask; + } + + private async Task CheckUsernameAsync( + string value, + int version, + CancellationTokenSource cts) + { + var token = cts.Token; + + try + { + await Task.Delay(500, token); + + var result = await AvailabilityService.CheckAsync( + UserIdentifierType.Username, + value, + token); + + if (_disposed || token.IsCancellationRequested || + version != _usernameVersion) + return; + + await InvokeAsync(() => + { + if (!_disposed && + !token.IsCancellationRequested && + version == _usernameVersion) + { + _usernameAvailability = result; + StateHasChanged(); + } + }); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + } + catch + { + if (!_disposed && version == _usernameVersion) + { + await InvokeAsync(() => + { + if (!_disposed && version == _usernameVersion) + { + _usernameAvailability = + LabIdentifierAvailability.Unavailable; + + StateHasChanged(); + } + }); + } + } + finally + { + if (ReferenceEquals(_usernameCheckCts, cts)) + _usernameCheckCts = null; + + cts.Dispose(); + } + } + + private async Task CheckEmailAsync( + string value, + int version, + CancellationTokenSource cts) + { + var token = cts.Token; + + try + { + await Task.Delay(500, token); + + var result = await AvailabilityService.CheckAsync( + UserIdentifierType.Email, + value, + token); + + if (_disposed || token.IsCancellationRequested || + version != _emailVersion) + return; + + await InvokeAsync(() => + { + if (!_disposed && + !token.IsCancellationRequested && + version == _emailVersion) + { + _emailAvailability = result; + StateHasChanged(); + } + }); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + } + catch + { + if (!_disposed && version == _emailVersion) + { + await InvokeAsync(() => + { + if (!_disposed && version == _emailVersion) + { + _emailAvailability = + LabIdentifierAvailability.Unavailable; + + StateHasChanged(); + } + }); + } + } + finally + { + if (ReferenceEquals(_emailCheckCts, cts)) + _emailCheckCts = null; + + cts.Dispose(); + } + } + private async Task SubmitAsync() { - if (_form is null || _isSubmitting || _registered) + if (_form is null || _isSubmitting || _registered || _disposed) return; _errorMessage = null; @@ -352,28 +576,53 @@ _isSubmitting = true; + // Pending debounce sonuçlarının form durumunu değiştirmesini önle. + ++_usernameVersion; + ++_emailVersion; + + _usernameCheckCts?.Cancel(); + _emailCheckCts?.Cancel(); + try { + var username = _username.Trim(); + var email = _email.Trim(); + + var usernameAvailability = await AvailabilityService.CheckAsync(UserIdentifierType.Username, username); + + var emailAvailability = await AvailabilityService.CheckAsync(UserIdentifierType.Email, email); + + _usernameAvailability = usernameAvailability; + _emailAvailability = emailAvailability; + + if (usernameAvailability == LabIdentifierAvailability.Taken || + emailAvailability == LabIdentifierAvailability.Taken) + { + _errorMessage = "The username or email address is already in use. Please choose different credentials."; + + return; + } + + if (usernameAvailability != LabIdentifierAvailability.Available || emailAvailability != LabIdentifierAvailability.Available) + { + _errorMessage = "We could not verify your username or email address. Please check your details and try again."; + + return; + } + var request = new LabRegistrationRequest( - Username: _username.Trim(), - Email: _email.Trim(), + Username: username, + Email: email, Password: _password, - FirstName: string.IsNullOrWhiteSpace(_firstName) - ? null - : _firstName.Trim(), - LastName: string.IsNullOrWhiteSpace(_lastName) - ? null - : _lastName.Trim(), - BirthDate: _birthDate is { } date - ? DateOnly.FromDateTime(date) - : null); + FirstName: string.IsNullOrWhiteSpace(_firstName) ? null : _firstName.Trim(), + LastName: string.IsNullOrWhiteSpace(_lastName) ? null : _lastName.Trim(), + BirthDate: _birthDate is { } date ? DateOnly.FromDateTime(date) : null); var result = await RegistrationService.RegisterAsync(request); if (!result.IsSuccess) { - _errorMessage = result.ErrorMessage - ?? "Registration could not be completed."; + _errorMessage = result.ErrorMessage ?? "Registration could not be completed."; _requiresRepair = result.RequiresRepair; return; @@ -383,13 +632,24 @@ } catch (Exception) { - _errorMessage = - "An unexpected error occurred. If you already submitted " + - "your registration, try signing in before registering again."; + _errorMessage = "An unexpected error occurred. If you already submitted your registration, try signing in before registering again."; } finally { _isSubmitting = false; + Snackbar.Add(_errorMessage ?? string.Empty, Severity.Error); } } + + public void Dispose() + { + _disposed = true; + + ++_usernameVersion; + ++_emailVersion; + + // Each running task owns disposal of its CTS. + _usernameCheckCts?.Cancel(); + _emailCheckCts?.Cancel(); + } } diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Policy/SandboxUAuthAccessPolicy.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Policy/SandboxUAuthAccessPolicy.cs index 1303ee42..822d70c7 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Policy/SandboxUAuthAccessPolicy.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Policy/SandboxUAuthAccessPolicy.cs @@ -28,6 +28,11 @@ public async ValueTask DecideAsync(AccessContext context, Cancel : AccessDecision.Deny("lab_registration_required"); } + if (action == UAuthActions.UserIdentifiers.CheckAvailability && !context.IsAuthenticated) + { + return AccessDecision.Allow(); + } + if (action.EndsWith(".anonymous", StringComparison.Ordinal)) return AccessDecision.Allow(); diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs index b4c9da0e..1e6c15e7 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs @@ -93,6 +93,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRegistrationAvailabilityService.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRegistrationAvailabilityService.cs new file mode 100644 index 00000000..84bfc315 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRegistrationAvailabilityService.cs @@ -0,0 +1,104 @@ +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.IdentityLab.Authorization; +using CodeBeam.UltimateAuth.Server.Services; +using CodeBeam.UltimateAuth.Users.Contracts; +using System.Threading.RateLimiting; + +namespace CodeBeam.UltimateAuth.IdentityLab.Services; + +public sealed class LabRegistrationAvailabilityService(LabAccessContextProvider contextProvider, IUserIdentifierAvailabilityService availabilityService) : IDisposable +{ + private static readonly FixedWindowRateLimiter GlobalLimiter = new( + new FixedWindowRateLimiterOptions + { + PermitLimit = 300, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0, + AutoReplenishment = true + }); + + private readonly FixedWindowRateLimiter _circuitLimiter = new( + new FixedWindowRateLimiterOptions + { + PermitLimit = 30, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0, + AutoReplenishment = true + }); + + public async Task CheckAsync(UserIdentifierType type, string value, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (type is not (UserIdentifierType.Username or UserIdentifierType.Email)) + return LabIdentifierAvailability.Invalid; + + if (string.IsNullOrWhiteSpace(value)) + return LabIdentifierAvailability.Invalid; + + value = value.Trim(); + + // Bound the request before reaching normalization and persistence. + if (value.Length > 256) + return LabIdentifierAvailability.Invalid; + + using var circuitLease = _circuitLimiter.AttemptAcquire(); + if (!circuitLease.IsAcquired) + return LabIdentifierAvailability.Unavailable; + + using var globalLease = GlobalLimiter.AttemptAcquire(); + if (!globalLease.IsAcquired) + return LabIdentifierAvailability.Unavailable; + + try + { + var context = await contextProvider.CreateAsync( + UAuthActions.UserIdentifiers.CheckAvailability, + "users", + ct); + + // This is a server-side registration-only call. + // It does not expose the general UltimateAuth endpoint. + var result = await availabilityService.CheckAsync( + context, + new CheckUserIdentifierAvailabilityRequest + { + Type = type, + Value = value + }, + ct); + + if (!result.IsValid) + return LabIdentifierAvailability.Invalid; + + return result.IsAvailable + ? LabIdentifierAvailability.Available + : LabIdentifierAvailability.Taken; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch + { + // Never report "Available" when the check failed. + return LabIdentifierAvailability.Unavailable; + } + } + + public void Dispose() + { + _circuitLimiter.Dispose(); + } +} + +public enum LabIdentifierAvailability +{ + Unknown, + Checking, + Available, + Taken, + Invalid, + Unavailable +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRegistrationService.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRegistrationService.cs index 44fbbbdb..6e7f4994 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRegistrationService.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRegistrationService.cs @@ -1,5 +1,4 @@ using CodeBeam.UltimateAuth.Authorization; -using CodeBeam.UltimateAuth.Client; using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; @@ -92,4 +91,17 @@ public async Task RegisterAsync(LabRegistrationRequest re return LabRegistrationResult.Failed("Your account was created, but sandbox setup could not be completed. Please contact support before registering again.", requiresRepair: true); } } + + public async Task CheckAvailabilityAsync(UserIdentifierType type, string value, CancellationToken ct = default) + { + var context = await contextProvider.CreateAsync(UAuthActions.UserIdentifiers.CheckAvailability, "users", ct); + + return await userApplicationService.CheckIdentifierAvailabilityAsync(context, + new CheckUserIdentifierAvailabilityRequest + { + Type = type, + Value = value + }, + ct); + } } diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Custom/IdentifierAvailabilityHint.razor b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Custom/IdentifierAvailabilityHint.razor index 64b6845e..d0e54db6 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Custom/IdentifierAvailabilityHint.razor +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Custom/IdentifierAvailabilityHint.razor @@ -2,53 +2,40 @@ @if (State != IdentifierAvailabilityState.Empty) { - + @switch (State) { case IdentifierAvailabilityState.Checking: - + Checking availability... break; case IdentifierAvailabilityState.Available: - + @Label is available break; case IdentifierAvailabilityState.Unavailable: - + @Label is already in use break; case IdentifierAvailabilityState.Invalid: - + Enter a valid @Label.ToLowerInvariant(). break; case IdentifierAvailabilityState.Error: - + Unable to check @Label.ToLowerInvariant() availability. diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CreateUserDialog.razor b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CreateUserDialog.razor index 28851e29..d88eb0a0 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CreateUserDialog.razor +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CreateUserDialog.razor @@ -106,6 +106,7 @@ Margin="Margin.Dense" Required="true" RequiredError="Password is required." + autocomplete="new-password" Disabled="@_isSubmitting" />
From a2414122e412b5415fb4a1c70b47905607bdc5ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Thu, 29 Oct 2026 22:48:04 +0300 Subject: [PATCH 7/9] MyAccess Page --- .../Pages/Dashboard/MyAccessPage.razor | 543 ++++++++++++++---- .../Pages/Dashboard/MyAccessPage.razor.css | 127 ++++ .../Pages/Dashboard/MyIdentityPage.razor | 2 +- .../Pages/Dashboard/MySessionsPage.razor | 28 +- .../Pages/Dashboard/MySessionsPage.razor.css | 17 + .../Components/Pages/LoginPage.razor | 6 +- .../Components/_Imports.razor | 2 + 7 files changed, 613 insertions(+), 112 deletions(-) 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 8f683096..a8b7a79b 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 -
} @@ -163,7 +154,14 @@
- @permission.Value + @(permission.Value == "*" ? "All Permissions (*)" : permission.Value) + + @if (permission.Value == "*") + { + + Wildcard permission. Access remains subject to applicable authorization policies. + + }
@@ -203,39 +201,102 @@
+ 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") -
@@ -250,43 +311,97 @@ Color="Color.Primary" />
-
- @if (_accessResult is null) - { -
- - - + @if (_accessResult is null) + { +
+ + + - Ready to evaluate + Ready to evaluate -

- Select a permission to check your current access. -

+

+ Select an operation and target to evaluate access. +

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

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

+ +

+ @if (_accessResult.RequiresReauthentication) + { + Reauthentication is required. + } + else if (_accessResult.IsAllowed) + { + + The authorization request passed the applicable checks. + + } + else + { + @ExplainDecision(_accessResult.DenyReason) + } +

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

Reauthentication is required.

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

@_accessResult.DenyReason

- } - else +
+ +
+ Decision + + + + @(_accessResult.IsAllowed ? "Allowed" : "Denied") + + +
+ + @if (!_accessResult.IsAllowed) { -

@(_accessResult.IsAllowed ? "Your current session is authorized." : "Your current session is not authorized.")

+
+ Reason source + + + @GetDecisionSource(_accessResult.DenyReason) + +
+ +
+ Technical reason + + + @(_accessResult.DenyReason ?? "Not specified") + +
} +
+ + + This is an authorization check only. + No operation has been executed. - } -
+ +
+ } @code { + [CascadingParameter] + private UAuthState AuthState { get; set; } = default!; + private readonly List _roles = []; private readonly List _permissions = []; @@ -360,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() @@ -443,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; @@ -463,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; } @@ -489,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 d2c7fe2e..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 @@ -8,7 +8,7 @@ @inject IUAuthClient UAuthClient @inject IDialogService DialogService -@attribute [UAuthAuthorize] +@attribute [UAuthAuthorize(Roles = "Ladmin,Owner,User")] My Identity · Identity Lab 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/LoginPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/LoginPage.razor index 345e7e53..fc4a05b5 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/LoginPage.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/LoginPage.razor @@ -68,14 +68,16 @@
-
+ + + @*
Authenticated session

We recognize your account. Your Identity Lab workspace is ready whenever you are.

-
+
*@ Date: Thu, 29 Oct 2026 23:46:20 +0300 Subject: [PATCH 8/9] Users & Authorization Lab Page and Show Version Info --- .../Components/Custom/PublicFooter.razor | 20 +++- .../Components/Custom/PublicFooter.razor.css | 11 ++ .../Components/Layout/DashboardLayout.razor | 8 +- .../Layout/DashboardLayout.razor.cs | 2 + .../Pages/Dashboard/AuthorizationLab.razor | 110 ++++++------------ .../Pages/Dashboard/MyAccessPage.razor | 2 +- .../Pages/Dashboard/UsersPage.razor | 5 +- .../Services/LabVersionInfo.cs | 25 ++++ .../Components/Dialogs/UserDetailDialog.razor | 43 +++---- .../Dialogs/UserDetailDialog.razor.cs | 3 + .../Dialogs/UserDetailDialog.razor.css | 3 +- .../Runtime/UAuthProductInfo.cs | 13 +++ .../Runtime/UAuthServerProductInfo.cs | 12 ++ .../Runtime/UAuthClientProductInfo.cs | 19 +++ 14 files changed, 166 insertions(+), 110 deletions(-) create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabVersionInfo.cs 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 ceca3aa9..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,4 +1,7 @@ -
+@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/Layout/DashboardLayout.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor index 90de0d43..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,9 +1,11 @@ @inherits LayoutComponentBase @implements IDisposable +@using CodeBeam.UltimateAuth.Core.Runtime @inject LabDashboardService DashboardService @inject IUAuthClient UAuthClient @inject DarkModeManager DarkModeManager @inject ISnackbar Snackbar +@inject IUAuthProductInfoProvider ProductInfoProvider @@ -189,8 +191,12 @@
- Powered by UltimateAuth + Powered by UltimateAuth
+ + v @UltimateAuthVersion + .NET @LabVersionInfo.DotNetVersion +
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 index 8f54d828..8372da36 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor.cs +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor.cs @@ -22,6 +22,8 @@ public partial class DashboardLayout 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 && 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/MyAccessPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MyAccessPage.razor index a8b7a79b..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 @@ -44,7 +44,7 @@
- + Playground Scenarios 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 d41da843..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 @@ -97,10 +97,10 @@ @bind-Value="_statusFilter" Variant="Variant.Outlined" Margin="Margin.Dense" - Label="Status" + Placeholder="Status" Class="lab-users-filter"> - + All statuses @@ -345,6 +345,7 @@ { { x => x.AuthState, AuthState }, { x => x.UserKey, user.UserKey }, + { x => x.AllowRoleManagement, false }, { x => x.PreviewMode, false } }; diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabVersionInfo.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabVersionInfo.cs new file mode 100644 index 00000000..6203288b --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabVersionInfo.cs @@ -0,0 +1,25 @@ +using System.Reflection; +using CodeBeam.UltimateAuth.Client; + +namespace CodeBeam.UltimateAuth.IdentityLab.Services; + +public static class LabVersionInfo +{ + public static string DotNetVersion { get; } = Environment.Version.Major.ToString() + "." + Environment.Version.Minor.ToString() + "." + Environment.Version.Build.ToString(); + + private static string GetVersion(Assembly assembly) + { + var informationalVersion = assembly + .GetCustomAttribute() + ?.InformationalVersion; + + if (!string.IsNullOrWhiteSpace(informationalVersion)) + return informationalVersion.Split('+')[0]; + + var version = assembly.GetName().Version; + + return version is null + ? "Unknown" + : $"{version.Major}.{version.Minor}.{version.Build}"; + } +} diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UserDetailDialog.razor b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UserDetailDialog.razor index 82e9663d..e19b5b71 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UserDetailDialog.razor +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UserDetailDialog.razor @@ -116,19 +116,14 @@ Disabled="@(_isSubmitting || PreviewMode)"> @foreach (var status in Enum.GetValues()) { - + @status } - + Update Status
@@ -141,40 +136,28 @@
- + Profile - + Identifiers - + Credentials - + Sessions - - Roles - + @if (AllowRoleManagement) + { + + Roles + + }
}
diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UserDetailDialog.razor.cs b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UserDetailDialog.razor.cs index 5d033e09..ba89bd17 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UserDetailDialog.razor.cs +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UserDetailDialog.razor.cs @@ -26,6 +26,9 @@ public partial class UserDetailDialog [Parameter] public UserKey UserKey { get; set; } + [Parameter] + public bool AllowRoleManagement { get; set; } = true; + [Parameter] public bool PreviewMode { get; set; } diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UserDetailDialog.razor.css b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UserDetailDialog.razor.css index adc07a85..be2eed37 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UserDetailDialog.razor.css +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UserDetailDialog.razor.css @@ -131,7 +131,7 @@ .lab-user-detail-status { display: flex; - align-items: flex-start; + align-items: center; gap: 12px; } @@ -142,7 +142,6 @@ .lab-user-detail-status ::deep .mud-button-root { flex-shrink: 0; - min-height: 40px; } .lab-user-detail-management { diff --git a/src/CodeBeam.UltimateAuth.Core/Runtime/UAuthProductInfo.cs b/src/CodeBeam.UltimateAuth.Core/Runtime/UAuthProductInfo.cs index 7e4b3841..d4581dd6 100644 --- a/src/CodeBeam.UltimateAuth.Core/Runtime/UAuthProductInfo.cs +++ b/src/CodeBeam.UltimateAuth.Core/Runtime/UAuthProductInfo.cs @@ -6,6 +6,19 @@ public sealed class UAuthProductInfo public string Version { get; init; } = default!; public string? InformationalVersion { get; init; } + public string DisplayVersion + { + get + { + var version = InformationalVersion?.Split('+', 2)[0]; + + return string.IsNullOrWhiteSpace(version) + ? Version + : version; + } + } + + public DateTimeOffset StartedAt { get; init; } public string RuntimeId { get; init; } = Guid.NewGuid().ToString("n"); } diff --git a/src/CodeBeam.UltimateAuth.Server/Runtime/UAuthServerProductInfo.cs b/src/CodeBeam.UltimateAuth.Server/Runtime/UAuthServerProductInfo.cs index b327dfe4..f26afa1a 100644 --- a/src/CodeBeam.UltimateAuth.Server/Runtime/UAuthServerProductInfo.cs +++ b/src/CodeBeam.UltimateAuth.Server/Runtime/UAuthServerProductInfo.cs @@ -8,6 +8,18 @@ public sealed class UAuthServerProductInfo public string Version { get; init; } = default!; public string? InformationalVersion { get; init; } + public string DisplayVersion + { + get + { + var version = InformationalVersion?.Split('+', 2)[0]; + + return string.IsNullOrWhiteSpace(version) + ? Version + : version; + } + } + public DateTimeOffset StartedAt { get; init; } public string RuntimeId { get; init; } = Guid.NewGuid().ToString("n"); diff --git a/src/client/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfo.cs b/src/client/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfo.cs index ffc919af..d7675c0a 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfo.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfo.cs @@ -23,6 +23,25 @@ public sealed class UAuthClientProductInfo ///
public string? InformationalVersion { get; init; } + /// + /// Gets the display version string, preferring InformationalVersion split at '+' over the Version property. + /// + /// + /// InformationalVersion is preferred as it may contain additional metadata. + /// If InformationalVersion is null or empty, or if splitting at '+' yields no first part, the Version property is returned. + /// + public string DisplayVersion + { + get + { + var version = InformationalVersion?.Split('+', 2)[0]; + + return string.IsNullOrWhiteSpace(version) + ? Version + : version; + } + } + /// /// Gets the client profile associated with the UltimateAuth client. This is a required property that must be initialized with a valid UAuthClientProfile value. From 508ae6481a8d7839ffc47bd1036b8e9b34c8a0bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Fri, 30 Oct 2026 10:32:46 +0300 Subject: [PATCH 9/9] Add Essential Cookie Information Popup & Settings Page and Minor Enhancements --- .../Custom/EssentialCookieNotice.razor | 110 +++ .../Custom/EssentialCookieNotice.razor.css | 89 +++ .../Components/Pages/AdminPage.razor | 35 +- .../Pages/Dashboard/AuthorizationPage.razor | 695 ++++++------------ .../Dashboard/AuthorizationPage.razor.css | 358 +++++---- .../Pages/Dashboard/SettingsPage.razor | 209 +++--- .../Pages/Dashboard/SettingsPage.razor.css | 21 + .../Components/Pages/LandingPage.razor | 52 +- .../Components/Pages/LandingPage.razor.css | 98 +++ .../Components/Routes.razor | 2 + .../Components/_Imports.razor | 1 + .../Components/Custom/NotAuthorized.razor | 78 +- .../Components/Custom/NotAuthorized.razor.css | 111 +++ .../Dialogs/PermissionDialog.razor.cs | 9 +- 14 files changed, 1063 insertions(+), 805 deletions(-) create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/EssentialCookieNotice.razor create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/EssentialCookieNotice.razor.css create mode 100644 samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Custom/NotAuthorized.razor.css 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/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/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/SettingsPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/SettingsPage.razor index 954cf1fb..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] @@ -12,6 +12,8 @@ @inject SandboxRepairService SandboxRepairService @inject ISnackbar Snackbar @inject IDialogService DialogService +@inject IUAuthProductInfoProvider ProductInfoProvider +@inject IDbContextFactory DbContextFactory Sandbox Settings · Identity Lab @@ -22,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.

@@ -39,8 +38,7 @@

General

Basic information about your sandbox.

- +
@@ -51,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 @@ -94,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.

@@ -136,11 +112,7 @@ Owner + Members - + Manage Users @@ -155,8 +127,7 @@

Security Boundaries

Rules that protect sandbox isolation.

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

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

- +
@@ -193,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. @@ -214,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
@@ -255,8 +216,7 @@

Danger Zone

Destructive operations affecting your sandbox.

- + @if (_isOwner) @@ -264,67 +224,60 @@
Repair Sandbox -

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

+

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 -

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

+
+
+ Reset Sandbox +

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

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

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

+
-
-
- Delete Sandbox -

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

+ + Delete Sandbox +
- - - 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; @@ -427,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); 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/LandingPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/LandingPage.razor index 659c4f42..bc428dbc 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/LandingPage.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/LandingPage.razor @@ -56,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 +
+
+ +
+ 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/Routes.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Routes.razor index e91bc9ee..0ec015fa 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Routes.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Routes.razor @@ -15,6 +15,8 @@ + + diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/_Imports.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/_Imports.razor index 65a51a94..38cdf158 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/_Imports.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/_Imports.razor @@ -9,6 +9,7 @@ @using Microsoft.JSInterop @using Microsoft.Extensions.Options @using CodeBeam.UltimateAuth.IdentityLab +@using CodeBeam.UltimateAuth.IdentityLab.Domain @using CodeBeam.UltimateAuth.IdentityLab.Contracts @using CodeBeam.UltimateAuth.IdentityLab.Services @using CodeBeam.UltimateAuth.IdentityLab.Components diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Custom/NotAuthorized.razor b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Custom/NotAuthorized.razor index d8eb7138..d999bf2f 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Custom/NotAuthorized.razor +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Custom/NotAuthorized.razor @@ -1,27 +1,69 @@ @inject NavigationManager Nav - - - - + +
- Access Denied + - - You don’t have permission to view this page. - If you think this is a mistake, sign in with a different account or request access. - + - - Sign In - Go Back - +
+ + 403 · ACCESS RESTRICTED +
- + + Access Denied + - - UltimateAuth protects this resource based on your session and permissions. + + Your current account doesn't have permission + to access this resource. - -
+ +
+ + + + Access is controlled by your assigned permissions, authorization policies and current session. + You may need a different account or additional access. + +
+ +
+ + Sign In + + + + Go Back + +
+ + + + + + + Identity Lab · Secure sandbox environment + + +
diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Custom/NotAuthorized.razor.css b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Custom/NotAuthorized.razor.css new file mode 100644 index 00000000..31a095fc --- /dev/null +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Custom/NotAuthorized.razor.css @@ -0,0 +1,111 @@ +::deep .lab-denied-page { + min-height: 100vh; + padding: 32px 16px; + background: radial-gradient( ellipse at 50% 15%, rgba(22, 163, 74, .07), transparent 55% ); +} + +.lab-denied-container { + width: 100%; + max-width: 520px; + margin: auto; +} + +::deep .lab-denied-card { + padding: 44px 40px 28px; + border-radius: 20px; + background: var(--mud-palette-surface); + border: 1px solid var(--mud-palette-lines-default); + text-align: center; +} + +.lab-denied-logo { + display: flex; + justify-content: center; + margin-bottom: 24px; +} + +.lab-denied-status { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 7px 12px; + border-radius: 30px; + background: rgba(22, 163, 74, .09); + color: #16a34a; + font-size: .68rem; + font-weight: 800; + letter-spacing: .7px; + margin-bottom: 18px; +} + +.lab-denied-title { + font-weight: 800; + letter-spacing: -.8px; + margin-bottom: 14px; +} + +.lab-denied-description { + max-width: 390px; + margin: 0 auto; + color: var(--mud-palette-text-secondary); + line-height: 1.8; +} + +.lab-denied-explanation { + display: flex; + align-items: flex-start; + gap: 12px; + margin-top: 28px; + padding: 16px; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 12px; + background: var(--mud-palette-background-gray); + text-align: left; +} + + .lab-denied-explanation span { + color: var(--mud-palette-text-secondary); + font-size: .76rem; + line-height: 1.7; + } + +.lab-denied-actions { + display: flex; + justify-content: center; + gap: 12px; + margin-top: 28px; +} + +.lab-denied-button { + min-width: 140px; + text-transform: none; + font-weight: 650; + border-radius: 9px; +} + +.lab-denied-footer { + display: flex; + justify-content: center; + align-items: center; + gap: 7px; + margin-top: 32px; + padding-top: 22px; + border-top: 1px solid var(--mud-palette-lines-default); + color: var(--mud-palette-text-secondary); + font-size: .7rem; +} + +@media (max-width: 600px) { + .lab-denied-card { + padding: 32px 22px 24px; + } + + .lab-denied-actions { + flex-direction: column; + } + + .lab-denied-button { + width: 100%; + } +} diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/PermissionDialog.razor.cs b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/PermissionDialog.razor.cs index d82e214a..c1a66b7f 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/PermissionDialog.razor.cs +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/PermissionDialog.razor.cs @@ -13,10 +13,13 @@ public partial class PermissionDialog public RoleInfo? Role { get; set; } [Parameter] - public bool PreviewMode { get; set; } + public string PreviewRoleName { get; set; } = "Editor"; [Parameter] - public string PreviewRoleName { get; set; } = "Editor"; + public IReadOnlyList? AvailablePermissions { get; set; } + + [Parameter] + public bool PreviewMode { get; set; } private List _groups = []; private HashSet _originalSelection = []; @@ -52,7 +55,7 @@ public partial class PermissionDialog protected override void OnInitialized() { - var catalog = UAuthPermissionCatalog.GetAdminPermissions(); + var catalog = AvailablePermissions ?? UAuthPermissionCatalog.GetAdminPermissions(); HashSet selected;