From b06e1437ff7772bcb7bfd3d7168c3b66553cc738 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?=
Date: Thu, 22 Oct 2026 11:58:40 +0300
Subject: [PATCH 01/10] New App: Identity Lab Functionality (Part 2)
---
.../Authorization/ILabAdminGuard.cs | 9 +
.../Authorization/LabAccessContextProvider.cs | 14 ++
.../LabAdminAuthorizationFilter.cs | 27 +++
.../Authorization/LabAdminGuard.cs | 41 ++++
.../Authorization/LabAdminIdentity.cs | 8 +
.../Authorization/LabAdminOptions.cs | 11 +
.../Authorization/LabRoles.cs | 6 +
.../Contracts/SandboxAccessResult.cs | 31 +++
.../Contracts/SandboxQuery.cs | 15 ++
.../Contracts/SandboxStatistics.cs | 8 +
.../Contracts/SandboxSummary.cs | 13 ++
.../Domain/Sandbox.cs | 20 ++
.../Domain/SandboxMember.cs | 24 ++
.../Domain/SandboxSettings.cs | 10 +
.../Domain/SandboxStatus.cs | 8 +
.../Endpoints/LabAdminEndpointExtensions.cs | 48 ++++
...20261022063638_InitIdentityLab.Designer.cs | 142 ++++++++++++
.../LabDb/20261022063638_InitIdentityLab.cs | 100 +++++++++
.../LabDb/LabDbContextModelSnapshot.cs | 139 ++++++++++++
.../Persistence/LabDbContext.cs | 19 ++
.../Persistence/SandboxConfiguration.cs | 52 +++++
.../Persistence/SandboxMemberConfiguration.cs | 46 ++++
.../SandboxSettingsConfiguration.cs | 22 ++
.../Program.cs | 33 ++-
.../Seed/LabAdminSeedContributor.cs | 205 ++++++++++++++++++
.../Seed/LabSeedExtensions.cs | 14 ++
.../Services/SandboxAccess.cs | 22 ++
.../Services/SandboxAccessService.cs | 70 ++++++
.../Services/SandboxAdminService.cs | 204 +++++++++++++++++
.../Services/SandboxService.cs | 134 ++++++++++++
30 files changed, 1494 insertions(+), 1 deletion(-)
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/ILabAdminGuard.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabAccessContextProvider.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabAdminAuthorizationFilter.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabAdminGuard.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabAdminIdentity.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabAdminOptions.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabRoles.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/SandboxAccessResult.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/SandboxQuery.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/SandboxStatistics.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/SandboxSummary.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Domain/Sandbox.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Domain/SandboxMember.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Domain/SandboxSettings.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Domain/SandboxStatus.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Endpoints/LabAdminEndpointExtensions.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/LabDb/20261022063638_InitIdentityLab.Designer.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/LabDb/20261022063638_InitIdentityLab.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/LabDb/LabDbContextModelSnapshot.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Persistence/LabDbContext.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Persistence/SandboxConfiguration.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Persistence/SandboxMemberConfiguration.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Persistence/SandboxSettingsConfiguration.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Seed/LabAdminSeedContributor.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Seed/LabSeedExtensions.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxAccess.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxAccessService.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxAdminService.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxService.cs
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/ILabAdminGuard.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/ILabAdminGuard.cs
new file mode 100644
index 00000000..dae84f5a
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/ILabAdminGuard.cs
@@ -0,0 +1,9 @@
+using CodeBeam.UltimateAuth.Core.Contracts;
+
+namespace CodeBeam.UltimateAuth.IdentityLab.Authorization;
+
+public interface ILabAdminGuard
+{
+ Task IsAdminAsync(AccessContext context, CancellationToken ct = default);
+ Task EnsureAdminAsync(AccessContext context, CancellationToken ct = default);
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabAccessContextProvider.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabAccessContextProvider.cs
new file mode 100644
index 00000000..dce46417
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabAccessContextProvider.cs
@@ -0,0 +1,14 @@
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Server.Auth;
+
+namespace CodeBeam.UltimateAuth.IdentityLab.Authorization;
+
+public sealed class LabAccessContextProvider(IAuthFlow authFlow, IAccessContextFactory contextFactory)
+{
+ public async Task CreateAsync(string action, string resource, CancellationToken cancellationToken = default)
+ {
+ var flow = await authFlow.BeginAsync(AuthFlowType.ValidateSession, cancellationToken);
+ return await contextFactory.CreateAsync(flow, action, resource, ct: cancellationToken);
+ }
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabAdminAuthorizationFilter.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabAdminAuthorizationFilter.cs
new file mode 100644
index 00000000..220e5f06
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabAdminAuthorizationFilter.cs
@@ -0,0 +1,27 @@
+using CodeBeam.UltimateAuth.IdentityLab.Authorization;
+
+namespace CodeBeam.UltimateAuth.IdentityLab.Endpoints;
+
+public sealed class LabAdminAuthorizationFilter(LabAccessContextProvider contextProvider, ILabAdminGuard adminGuard) : IEndpointFilter
+{
+ public async ValueTask InvokeAsync(EndpointFilterInvocationContext invocation,
+ EndpointFilterDelegate next)
+ {
+ var context = await contextProvider.CreateAsync(
+ "identitylab.admin",
+ "identitylab",
+ invocation.HttpContext.RequestAborted);
+
+ if (!context.IsAuthenticated || context.ActorUserKey is null)
+ return Results.Unauthorized();
+
+ if (!await adminGuard.IsAdminAsync(
+ context,
+ invocation.HttpContext.RequestAborted))
+ {
+ return Results.Forbid();
+ }
+
+ return await next(invocation);
+ }
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabAdminGuard.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabAdminGuard.cs
new file mode 100644
index 00000000..3be4b207
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabAdminGuard.cs
@@ -0,0 +1,41 @@
+using CodeBeam.UltimateAuth.Authorization;
+using CodeBeam.UltimateAuth.Core.Contracts;
+
+namespace CodeBeam.UltimateAuth.IdentityLab.Authorization;
+
+public sealed class LabAdminGuard(IRoleStoreFactory roleStoreFactory, IUserRoleStoreFactory userRoleStoreFactory) : ILabAdminGuard
+{
+ public async Task IsAdminAsync(AccessContext context, CancellationToken ct = default)
+ {
+ if (!context.IsAuthenticated ||
+ context.IsSystemActor ||
+ context.ActorUserKey is null ||
+ context.ActorTenant.IsSystem ||
+ context.ActorTenant.IsUnresolved ||
+ context.IsCrossTenant)
+ {
+ return false;
+ }
+
+ var roleStore = roleStoreFactory.Create(context.ActorTenant);
+
+ var adminRole = await roleStore.GetByNameAsync(LabRoles.Admin.ToUpperInvariant(), ct);
+
+ if (adminRole is null)
+ return false;
+
+ var userRoleStore = userRoleStoreFactory.Create(context.ActorTenant);
+
+ var assignments = await userRoleStore.GetAssignmentsAsync(context.ActorUserKey.Value, ct);
+
+ return assignments.Any(x => x.RoleId == adminRole.Id);
+ }
+
+ public async Task EnsureAdminAsync(AccessContext context, CancellationToken ct = default)
+ {
+ if (!await IsAdminAsync(context, ct))
+ {
+ throw new UnauthorizedAccessException("LabAdmin permission is required.");
+ }
+ }
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabAdminIdentity.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabAdminIdentity.cs
new file mode 100644
index 00000000..0476d3e3
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabAdminIdentity.cs
@@ -0,0 +1,8 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+
+namespace CodeBeam.UltimateAuth.IdentityLab.Authorization;
+
+public static class LabAdminIdentity
+{
+ public static readonly UserKey UserKey = UserKey.FromGuid(Guid.Parse("1a6d0c8e-8b93-4c74-a1c9-3d5f9276b410"));
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabAdminOptions.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabAdminOptions.cs
new file mode 100644
index 00000000..d3dc0385
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabAdminOptions.cs
@@ -0,0 +1,11 @@
+namespace CodeBeam.UltimateAuth.IdentityLab.Authorization;
+
+public sealed class LabAdminOptions
+{
+ public const string SectionName = "LabAdmin";
+
+ public string Username { get; set; } = "Ladmin";
+ public string Email { get; set; } = string.Empty;
+ public string DisplayName { get; set; } = "Lab Administrator";
+ public string Password { get; set; } = string.Empty;
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabRoles.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabRoles.cs
new file mode 100644
index 00000000..ed5adcc2
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabRoles.cs
@@ -0,0 +1,6 @@
+namespace CodeBeam.UltimateAuth.IdentityLab.Authorization;
+
+public static class LabRoles
+{
+ public const string Admin = "LabAdmin";
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/SandboxAccessResult.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/SandboxAccessResult.cs
new file mode 100644
index 00000000..b917cf37
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/SandboxAccessResult.cs
@@ -0,0 +1,31 @@
+using CodeBeam.UltimateAuth.IdentityLab.Domain;
+
+namespace CodeBeam.UltimateAuth.IdentityLab.Contracts;
+
+public enum SandboxAccessFailure
+{
+ None = 0,
+ InvalidUser,
+ NotMember,
+ NotFound,
+ Passive,
+ Suspended,
+ OwnerRequired
+}
+
+public sealed record SandboxAccessResult(bool Allowed, SandboxAccessFailure Failure, Guid? SandboxId, SandboxMemberRole? Role, SandboxStatus? Status)
+{
+ public static SandboxAccessResult Success(
+ Guid sandboxId,
+ SandboxMemberRole role,
+ SandboxStatus status)
+ => new(true, SandboxAccessFailure.None,
+ sandboxId, role, status);
+
+ public static SandboxAccessResult Denied(
+ SandboxAccessFailure failure,
+ Guid? sandboxId = null,
+ SandboxMemberRole? role = null,
+ SandboxStatus? status = null)
+ => new(false, failure, sandboxId, role, status);
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/SandboxQuery.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/SandboxQuery.cs
new file mode 100644
index 00000000..99b3fde6
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/SandboxQuery.cs
@@ -0,0 +1,15 @@
+using CodeBeam.UltimateAuth.IdentityLab.Domain;
+
+namespace CodeBeam.UltimateAuth.IdentityLab.Contracts;
+
+public sealed record SandboxQuery
+{
+ public string? Search { get; init; }
+ public SandboxStatus? Status { get; init; }
+
+ public int Page { get; init; } = 1;
+ public int PageSize { get; init; } = 20;
+
+ public string? SortBy { get; init; }
+ public bool Descending { get; init; } = true;
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/SandboxStatistics.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/SandboxStatistics.cs
new file mode 100644
index 00000000..37b926cb
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/SandboxStatistics.cs
@@ -0,0 +1,8 @@
+namespace CodeBeam.UltimateAuth.IdentityLab.Contracts;
+
+public sealed record SandboxStatistics(
+ int Total,
+ int Active,
+ int Passive,
+ int Suspended,
+ int TotalMembers);
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/SandboxSummary.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/SandboxSummary.cs
new file mode 100644
index 00000000..c2af59ed
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/SandboxSummary.cs
@@ -0,0 +1,13 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.IdentityLab.Domain;
+
+namespace CodeBeam.UltimateAuth.IdentityLab.Contracts;
+
+public sealed record SandboxSummary(
+ Guid Id,
+ string Name,
+ UserKey OwnerUserKey,
+ SandboxStatus Status,
+ int MemberCount,
+ int MaxUsers,
+ DateTimeOffset CreatedAt);
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Domain/Sandbox.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Domain/Sandbox.cs
new file mode 100644
index 00000000..4a5a3528
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Domain/Sandbox.cs
@@ -0,0 +1,20 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+
+namespace CodeBeam.UltimateAuth.IdentityLab.Domain;
+
+public sealed class Sandbox
+{
+ public Guid Id { get; set; } = Guid.NewGuid();
+
+ public string Name { get; set; } = string.Empty;
+
+ public UserKey OwnerUserKey { get; set; }
+
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
+
+ public SandboxStatus Status { get; set; } = SandboxStatus.Active;
+
+ public SandboxSettings Settings { get; set; } = default!;
+
+ public ICollection Members { get; set; } = new List();
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Domain/SandboxMember.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Domain/SandboxMember.cs
new file mode 100644
index 00000000..a28f01ec
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Domain/SandboxMember.cs
@@ -0,0 +1,24 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+
+namespace CodeBeam.UltimateAuth.IdentityLab.Domain;
+
+public sealed class SandboxMember
+{
+ public Guid Id { get; set; } = Guid.NewGuid();
+
+ public Guid SandboxId { get; set; }
+
+ public UserKey UserKey { get; set; }
+
+ public SandboxMemberRole Role { get; set; }
+
+ public DateTimeOffset JoinedAt { get; set; } = DateTimeOffset.UtcNow;
+
+ public Sandbox Sandbox { get; set; } = default!;
+}
+
+public enum SandboxMemberRole
+{
+ Member = 0,
+ Owner = 1
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Domain/SandboxSettings.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Domain/SandboxSettings.cs
new file mode 100644
index 00000000..3cf3b088
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Domain/SandboxSettings.cs
@@ -0,0 +1,10 @@
+namespace CodeBeam.UltimateAuth.IdentityLab.Domain;
+
+public sealed class SandboxSettings
+{
+ public Guid SandboxId { get; set; }
+
+ public int MaxUsers { get; set; } = 5;
+
+ public Sandbox Sandbox { get; set; } = default!;
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Domain/SandboxStatus.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Domain/SandboxStatus.cs
new file mode 100644
index 00000000..5a7ee686
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Domain/SandboxStatus.cs
@@ -0,0 +1,8 @@
+namespace CodeBeam.UltimateAuth.IdentityLab.Domain;
+
+public enum SandboxStatus
+{
+ Active = 0,
+ Passive = 1,
+ Suspended = 2
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Endpoints/LabAdminEndpointExtensions.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Endpoints/LabAdminEndpointExtensions.cs
new file mode 100644
index 00000000..f32b4b57
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Endpoints/LabAdminEndpointExtensions.cs
@@ -0,0 +1,48 @@
+using CodeBeam.UltimateAuth.IdentityLab.Authorization;
+using CodeBeam.UltimateAuth.IdentityLab.Contracts;
+using CodeBeam.UltimateAuth.IdentityLab.Services;
+
+namespace CodeBeam.UltimateAuth.IdentityLab.Endpoints;
+
+public static class LabAdminEndpointExtensions
+{
+ public static IEndpointRouteBuilder MapLabAdminEndpoints(this IEndpointRouteBuilder endpoints)
+ {
+ var group = endpoints.MapGroup("/api/lab/admin").AddEndpointFilter();
+
+ group.MapGet("/sandboxes", async (
+ [AsParameters] SandboxQuery query,
+ LabAccessContextProvider contextProvider,
+ SandboxAdminService adminService,
+ CancellationToken ct) =>
+ {
+ var context = await contextProvider.CreateAsync(
+ "identitylab.sandboxes.admin",
+ "identitylab.sandboxes",
+ ct);
+
+ var result = await adminService.QueryAsync(
+ context, query, ct);
+
+ return Results.Ok(result);
+ });
+
+ group.MapGet("/statistics", async (
+ LabAccessContextProvider contextProvider,
+ SandboxAdminService adminService,
+ CancellationToken ct) =>
+ {
+ var context = await contextProvider.CreateAsync(
+ "identitylab.statistics.admin",
+ "identitylab.statistics",
+ ct);
+
+ var result = await adminService.GetStatisticsAsync(
+ context, ct);
+
+ return Results.Ok(result);
+ });
+
+ return endpoints;
+ }
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/LabDb/20261022063638_InitIdentityLab.Designer.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/LabDb/20261022063638_InitIdentityLab.Designer.cs
new file mode 100644
index 00000000..cabc7d9c
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/LabDb/20261022063638_InitIdentityLab.Designer.cs
@@ -0,0 +1,142 @@
+//
+using System;
+using CodeBeam.UltimateAuth.IdentityLab.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace CodeBeam.UltimateAuth.IdentityLab.Migrations.LabDb
+{
+ [DbContext(typeof(LabDbContext))]
+ [Migration("20261022063638_InitIdentityLab")]
+ partial class InitIdentityLab
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.12")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("CodeBeam.UltimateAuth.IdentityLab.Domain.Sandbox", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(150)
+ .HasColumnType("character varying(150)");
+
+ b.Property("OwnerUserKey")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasDefaultValue("Active");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OwnerUserKey")
+ .IsUnique();
+
+ b.ToTable("LabSandboxes", (string)null);
+ });
+
+ modelBuilder.Entity("CodeBeam.UltimateAuth.IdentityLab.Domain.SandboxMember", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("JoinedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Role")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("SandboxId")
+ .HasColumnType("uuid");
+
+ b.Property("UserKey")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserKey")
+ .IsUnique();
+
+ b.HasIndex("SandboxId", "Role");
+
+ b.ToTable("LabSandboxMembers", (string)null);
+ });
+
+ modelBuilder.Entity("CodeBeam.UltimateAuth.IdentityLab.Domain.SandboxSettings", b =>
+ {
+ b.Property("SandboxId")
+ .HasColumnType("uuid");
+
+ b.Property("MaxUsers")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(5);
+
+ b.HasKey("SandboxId");
+
+ b.ToTable("LabSandboxSettings", null, t =>
+ {
+ t.HasCheckConstraint("CK_LabSandboxSettings_MaxUsers", "\"MaxUsers\" >= 1");
+ });
+ });
+
+ modelBuilder.Entity("CodeBeam.UltimateAuth.IdentityLab.Domain.SandboxMember", b =>
+ {
+ b.HasOne("CodeBeam.UltimateAuth.IdentityLab.Domain.Sandbox", "Sandbox")
+ .WithMany("Members")
+ .HasForeignKey("SandboxId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Sandbox");
+ });
+
+ modelBuilder.Entity("CodeBeam.UltimateAuth.IdentityLab.Domain.SandboxSettings", b =>
+ {
+ b.HasOne("CodeBeam.UltimateAuth.IdentityLab.Domain.Sandbox", "Sandbox")
+ .WithOne("Settings")
+ .HasForeignKey("CodeBeam.UltimateAuth.IdentityLab.Domain.SandboxSettings", "SandboxId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Sandbox");
+ });
+
+ modelBuilder.Entity("CodeBeam.UltimateAuth.IdentityLab.Domain.Sandbox", b =>
+ {
+ b.Navigation("Members");
+
+ b.Navigation("Settings")
+ .IsRequired();
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/LabDb/20261022063638_InitIdentityLab.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/LabDb/20261022063638_InitIdentityLab.cs
new file mode 100644
index 00000000..8b2a78e5
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/LabDb/20261022063638_InitIdentityLab.cs
@@ -0,0 +1,100 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace CodeBeam.UltimateAuth.IdentityLab.Migrations.LabDb
+{
+ ///
+ public partial class InitIdentityLab : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "LabSandboxes",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ Name = table.Column(type: "character varying(150)", maxLength: 150, nullable: false),
+ OwnerUserKey = table.Column(type: "character varying(128)", maxLength: 128, nullable: false),
+ CreatedAt = table.Column(type: "timestamp with time zone", nullable: false),
+ Status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false, defaultValue: "Active")
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_LabSandboxes", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "LabSandboxMembers",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ SandboxId = table.Column(type: "uuid", nullable: false),
+ UserKey = table.Column(type: "character varying(128)", maxLength: 128, nullable: false),
+ Role = table.Column(type: "character varying(32)", maxLength: 32, nullable: false),
+ JoinedAt = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_LabSandboxMembers", x => x.Id);
+ table.ForeignKey(
+ name: "FK_LabSandboxMembers_LabSandboxes_SandboxId",
+ column: x => x.SandboxId,
+ principalTable: "LabSandboxes",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "LabSandboxSettings",
+ columns: table => new
+ {
+ SandboxId = table.Column(type: "uuid", nullable: false),
+ MaxUsers = table.Column(type: "integer", nullable: false, defaultValue: 5)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_LabSandboxSettings", x => x.SandboxId);
+ table.CheckConstraint("CK_LabSandboxSettings_MaxUsers", "\"MaxUsers\" >= 1");
+ table.ForeignKey(
+ name: "FK_LabSandboxSettings_LabSandboxes_SandboxId",
+ column: x => x.SandboxId,
+ principalTable: "LabSandboxes",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_LabSandboxes_OwnerUserKey",
+ table: "LabSandboxes",
+ column: "OwnerUserKey",
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_LabSandboxMembers_SandboxId_Role",
+ table: "LabSandboxMembers",
+ columns: new[] { "SandboxId", "Role" });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_LabSandboxMembers_UserKey",
+ table: "LabSandboxMembers",
+ column: "UserKey",
+ unique: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "LabSandboxMembers");
+
+ migrationBuilder.DropTable(
+ name: "LabSandboxSettings");
+
+ migrationBuilder.DropTable(
+ name: "LabSandboxes");
+ }
+ }
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/LabDb/LabDbContextModelSnapshot.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/LabDb/LabDbContextModelSnapshot.cs
new file mode 100644
index 00000000..467e0902
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/LabDb/LabDbContextModelSnapshot.cs
@@ -0,0 +1,139 @@
+//
+using System;
+using CodeBeam.UltimateAuth.IdentityLab.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace CodeBeam.UltimateAuth.IdentityLab.Migrations.LabDb
+{
+ [DbContext(typeof(LabDbContext))]
+ partial class LabDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.12")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("CodeBeam.UltimateAuth.IdentityLab.Domain.Sandbox", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(150)
+ .HasColumnType("character varying(150)");
+
+ b.Property("OwnerUserKey")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasDefaultValue("Active");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OwnerUserKey")
+ .IsUnique();
+
+ b.ToTable("LabSandboxes", (string)null);
+ });
+
+ modelBuilder.Entity("CodeBeam.UltimateAuth.IdentityLab.Domain.SandboxMember", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("JoinedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Role")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("SandboxId")
+ .HasColumnType("uuid");
+
+ b.Property("UserKey")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserKey")
+ .IsUnique();
+
+ b.HasIndex("SandboxId", "Role");
+
+ b.ToTable("LabSandboxMembers", (string)null);
+ });
+
+ modelBuilder.Entity("CodeBeam.UltimateAuth.IdentityLab.Domain.SandboxSettings", b =>
+ {
+ b.Property("SandboxId")
+ .HasColumnType("uuid");
+
+ b.Property("MaxUsers")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(5);
+
+ b.HasKey("SandboxId");
+
+ b.ToTable("LabSandboxSettings", null, t =>
+ {
+ t.HasCheckConstraint("CK_LabSandboxSettings_MaxUsers", "\"MaxUsers\" >= 1");
+ });
+ });
+
+ modelBuilder.Entity("CodeBeam.UltimateAuth.IdentityLab.Domain.SandboxMember", b =>
+ {
+ b.HasOne("CodeBeam.UltimateAuth.IdentityLab.Domain.Sandbox", "Sandbox")
+ .WithMany("Members")
+ .HasForeignKey("SandboxId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Sandbox");
+ });
+
+ modelBuilder.Entity("CodeBeam.UltimateAuth.IdentityLab.Domain.SandboxSettings", b =>
+ {
+ b.HasOne("CodeBeam.UltimateAuth.IdentityLab.Domain.Sandbox", "Sandbox")
+ .WithOne("Settings")
+ .HasForeignKey("CodeBeam.UltimateAuth.IdentityLab.Domain.SandboxSettings", "SandboxId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Sandbox");
+ });
+
+ modelBuilder.Entity("CodeBeam.UltimateAuth.IdentityLab.Domain.Sandbox", b =>
+ {
+ b.Navigation("Members");
+
+ b.Navigation("Settings")
+ .IsRequired();
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Persistence/LabDbContext.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Persistence/LabDbContext.cs
new file mode 100644
index 00000000..64158131
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Persistence/LabDbContext.cs
@@ -0,0 +1,19 @@
+using CodeBeam.UltimateAuth.IdentityLab.Domain;
+using Microsoft.EntityFrameworkCore;
+
+namespace CodeBeam.UltimateAuth.IdentityLab.Persistence;
+
+public sealed class LabDbContext(DbContextOptions options) : DbContext(options)
+{
+ public DbSet Sandboxes => Set();
+
+ public DbSet SandboxMembers => Set();
+
+ public DbSet SandboxSettings => Set();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ base.OnModelCreating(modelBuilder);
+ modelBuilder.ApplyConfigurationsFromAssembly(typeof(LabDbContext).Assembly);
+ }
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Persistence/SandboxConfiguration.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Persistence/SandboxConfiguration.cs
new file mode 100644
index 00000000..90967dcc
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Persistence/SandboxConfiguration.cs
@@ -0,0 +1,52 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.IdentityLab.Domain;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace CodeBeam.UltimateAuth.IdentityLab.Persistence.Configurations;
+
+public sealed class SandboxConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("LabSandboxes");
+
+ builder.HasKey(x => x.Id);
+
+ builder.Property(x => x.Id)
+ .ValueGeneratedNever();
+
+ builder.Property(x => x.Name)
+ .HasMaxLength(150)
+ .IsRequired();
+
+ builder.Property(x => x.OwnerUserKey)
+ .HasConversion(
+ v => v.Value,
+ v => UserKey.FromString(v))
+ .HasMaxLength(128)
+ .IsRequired();
+
+ builder.HasIndex(x => x.OwnerUserKey)
+ .IsUnique();
+
+ builder.Property(x => x.Status)
+ .HasConversion()
+ .HasMaxLength(32)
+ .HasDefaultValue(SandboxStatus.Active)
+ .IsRequired();
+
+ builder.Property(x => x.CreatedAt)
+ .IsRequired();
+
+ builder.HasMany(x => x.Members)
+ .WithOne(x => x.Sandbox)
+ .HasForeignKey(x => x.SandboxId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ builder.HasOne(x => x.Settings)
+ .WithOne(x => x.Sandbox)
+ .HasForeignKey(x => x.SandboxId)
+ .OnDelete(DeleteBehavior.Cascade);
+ }
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Persistence/SandboxMemberConfiguration.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Persistence/SandboxMemberConfiguration.cs
new file mode 100644
index 00000000..07a8f4b3
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Persistence/SandboxMemberConfiguration.cs
@@ -0,0 +1,46 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.IdentityLab.Domain;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace CodeBeam.UltimateAuth.IdentityLab.Persistence.Configurations;
+
+public sealed class SandboxMemberConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("LabSandboxMembers");
+
+ builder.HasKey(x => x.Id);
+
+ builder.Property(x => x.Id)
+ .ValueGeneratedNever();
+
+ builder.Property(x => x.SandboxId)
+ .IsRequired();
+
+ builder.Property(x => x.UserKey)
+ .HasConversion(
+ v => v.Value,
+ v => UserKey.FromString(v))
+ .HasMaxLength(128)
+ .IsRequired();
+
+ builder.HasIndex(x => x.UserKey)
+ .IsUnique();
+
+ builder.Property(x => x.Role)
+ .HasConversion()
+ .HasMaxLength(32)
+ .IsRequired();
+
+ builder.Property(x => x.JoinedAt)
+ .IsRequired();
+
+ builder.HasIndex(x => new
+ {
+ x.SandboxId,
+ x.Role
+ });
+ }
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Persistence/SandboxSettingsConfiguration.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Persistence/SandboxSettingsConfiguration.cs
new file mode 100644
index 00000000..61ae9882
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Persistence/SandboxSettingsConfiguration.cs
@@ -0,0 +1,22 @@
+using CodeBeam.UltimateAuth.IdentityLab.Domain;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace CodeBeam.UltimateAuth.IdentityLab.Persistence.Configurations;
+
+public sealed class SandboxSettingsConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("LabSandboxSettings");
+
+ builder.HasKey(x => x.SandboxId);
+
+ builder.Property(x => x.MaxUsers)
+ .HasDefaultValue(5)
+ .IsRequired();
+
+ builder.ToTable(t =>
+ t.HasCheckConstraint("CK_LabSandboxSettings_MaxUsers", "\"MaxUsers\" >= 1"));
+ }
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs
index 6ca3bbd2..7627a873 100644
--- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs
@@ -1,8 +1,15 @@
using CodeBeam.UltimateAuth.Client.Blazor;
using CodeBeam.UltimateAuth.Client.Blazor.Extensions;
+using CodeBeam.UltimateAuth.Core.Abstractions;
using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.Infrastructure;
using CodeBeam.UltimateAuth.EntityFrameworkCore;
+using CodeBeam.UltimateAuth.IdentityLab.Authorization;
using CodeBeam.UltimateAuth.IdentityLab.Components;
+using CodeBeam.UltimateAuth.IdentityLab.Endpoints;
+using CodeBeam.UltimateAuth.IdentityLab.Persistence;
+using CodeBeam.UltimateAuth.IdentityLab.Seed;
+using CodeBeam.UltimateAuth.IdentityLab.Services;
using CodeBeam.UltimateAuth.Sample.Seed.Extensions;
using CodeBeam.UltimateAuth.Sample.Shared.Infrastructure;
using CodeBeam.UltimateAuth.Server.Extensions;
@@ -50,7 +57,27 @@
o.Reauth.Behavior = ReauthBehavior.RaiseEvent;
});
-builder.Services.AddScopedUltimateAuthSampleSeed();
+
+builder.Services.AddDbContext(options =>
+{
+ options.UseNpgsql(connectionString, npgsql =>
+ {
+ npgsql.MigrationsAssembly(typeof(LabDbContext).Assembly.GetName().Name);
+ npgsql.MigrationsHistoryTable("__IdentityLabMigrationsHistory");
+ });
+});
+
+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));
+
+builder.Services.AddScoped();
+builder.Services.AddScoped();
var app = builder.Build();
@@ -68,9 +95,13 @@
app.UseAntiforgery();
app.MapUltimateAuthEndpoints();
+app.MapLabAdminEndpoints();
+
app.MapStaticAssets();
app.MapRazorComponents()
.AddInteractiveServerRenderMode()
.AddUltimateAuthRoutes(UAuthAssemblies.BlazorClient());
+await app.SeedIdentityLabAsync();
+
app.Run();
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Seed/LabAdminSeedContributor.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Seed/LabAdminSeedContributor.cs
new file mode 100644
index 00000000..1dc7e3e6
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Seed/LabAdminSeedContributor.cs
@@ -0,0 +1,205 @@
+using CodeBeam.UltimateAuth.Authorization;
+using CodeBeam.UltimateAuth.Authorization.Contracts;
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+using CodeBeam.UltimateAuth.Credentials.Contracts;
+using CodeBeam.UltimateAuth.Credentials.Reference;
+using CodeBeam.UltimateAuth.IdentityLab.Authorization;
+using CodeBeam.UltimateAuth.Server.Infrastructure;
+using CodeBeam.UltimateAuth.Users.Contracts;
+using CodeBeam.UltimateAuth.Users.Reference;
+using Microsoft.Extensions.Options;
+
+namespace CodeBeam.UltimateAuth.IdentityLab.Seed;
+
+public sealed class LabAdminSeedContributor(
+ IUserLifecycleStoreFactory lifecycleFactory,
+ IUserProfileStoreFactory profileFactory,
+ IUserIdentifierStoreFactory identifierFactory,
+ IPasswordCredentialStoreFactory credentialFactory,
+ IRoleStoreFactory roleStoreFactory,
+ IUserRoleStoreFactory userRoleStoreFactory,
+ IIdentifierNormalizer identifierNormalizer,
+ IUAuthPasswordHasher passwordHasher,
+ IClock clock,
+ IOptions options) : ISeedContributor
+{
+ private static readonly Guid PasswordCredentialId = Guid.Parse("df5d4ed3-6f80-4a85-9bb0-53130e76d2a1");
+
+ public int Order => 0;
+
+ public async Task SeedAsync(TenantKey tenant, CancellationToken ct = default)
+ {
+ var settings = options.Value;
+
+ if (string.IsNullOrWhiteSpace(settings.Username) ||
+ string.IsNullOrWhiteSpace(settings.Email) ||
+ string.IsNullOrWhiteSpace(settings.Password))
+ {
+ throw new InvalidOperationException("LabAdmin username, email and password must be configured.");
+ }
+
+ var userKey = LabAdminIdentity.UserKey;
+
+ await EnsureUserAsync(tenant, userKey, settings, ct);
+ await EnsureCredentialAsync(tenant, userKey, settings.Password, ct);
+ await EnsureRoleAsync(tenant, userKey, ct);
+ }
+
+ private async Task EnsureUserAsync(
+ TenantKey tenant,
+ UserKey userKey,
+ LabAdminOptions settings,
+ CancellationToken ct)
+ {
+ var now = clock.UtcNow;
+
+ var lifecycleStore = lifecycleFactory.Create(tenant);
+ var profileStore = profileFactory.Create(tenant);
+ var identifierStore = identifierFactory.Create(tenant);
+
+ var lifecycleKey = new UserLifecycleKey(tenant, userKey);
+
+ if (!await lifecycleStore.ExistsAsync(lifecycleKey, ct))
+ {
+ await lifecycleStore.AddAsync(
+ UserLifecycle.Create(tenant, userKey, now),
+ ct);
+ }
+
+ var profileKey = new UserProfileKey(
+ tenant, userKey, ProfileKey.Default);
+
+ if (!await profileStore.ExistsAsync(profileKey, ct))
+ {
+ await profileStore.AddAsync(
+ UserProfile.Create(
+ Guid.NewGuid(),
+ tenant,
+ userKey,
+ ProfileKey.Default,
+ now,
+ displayName: settings.DisplayName),
+ ct);
+ }
+
+ await EnsureIdentifierAsync(
+ identifierStore,
+ tenant,
+ userKey,
+ UserIdentifierType.Username,
+ settings.Username,
+ now,
+ ct);
+
+ await EnsureIdentifierAsync(
+ identifierStore,
+ tenant,
+ userKey,
+ UserIdentifierType.Email,
+ settings.Email,
+ now,
+ ct);
+ }
+
+ private async Task EnsureIdentifierAsync(
+ IUserIdentifierStore store,
+ TenantKey tenant,
+ UserKey userKey,
+ UserIdentifierType type,
+ string value,
+ DateTimeOffset now,
+ CancellationToken ct)
+ {
+ var normalized = identifierNormalizer
+ .Normalize(type, value).Normalized;
+
+ var existing = await store.GetAsync(
+ type, normalized, ct);
+
+ if (existing is not null)
+ {
+ if (existing.UserKey != userKey)
+ throw new InvalidOperationException(
+ $"LabAdmin {type} is already assigned to another user.");
+
+ return;
+ }
+
+ await store.AddAsync(
+ UserIdentifier.Create(
+ Guid.NewGuid(),
+ tenant,
+ userKey,
+ type,
+ value,
+ normalized,
+ now,
+ true,
+ now),
+ ct);
+ }
+
+ private async Task EnsureCredentialAsync(
+ TenantKey tenant,
+ UserKey userKey,
+ string password,
+ CancellationToken ct)
+ {
+ var store = credentialFactory.Create(tenant);
+
+ var existing = await store.GetByUserAsync(userKey, ct);
+
+ // Never overwrite an existing administrator password.
+ if (existing.Any())
+ return;
+
+ await store.AddAsync(
+ PasswordCredential.Create(
+ PasswordCredentialId,
+ tenant,
+ userKey,
+ passwordHasher.Hash(password),
+ CredentialSecurityState.Active(),
+ new CredentialMetadata(),
+ clock.UtcNow),
+ ct);
+ }
+
+ private async Task EnsureRoleAsync(
+ TenantKey tenant,
+ UserKey userKey,
+ CancellationToken ct)
+ {
+ var roleStore = roleStoreFactory.Create(tenant);
+ var userRoleStore = userRoleStoreFactory.Create(tenant);
+
+ var role = await roleStore.GetByNameAsync(
+ LabRoles.Admin.ToUpperInvariant(), ct);
+
+ if (role is null)
+ {
+ role = Role.Create(
+ RoleId.From(Guid.NewGuid()),
+ tenant,
+ LabRoles.Admin,
+ new HashSet(),
+ clock.UtcNow);
+
+ await roleStore.AddAsync(role, ct);
+ }
+
+ var assignments = await userRoleStore
+ .GetAssignmentsAsync(userKey, ct);
+
+ if (assignments.Any(x => x.RoleId == role.Id))
+ return;
+
+ await userRoleStore.AssignAsync(
+ userKey,
+ role.Id,
+ clock.UtcNow,
+ ct);
+ }
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Seed/LabSeedExtensions.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Seed/LabSeedExtensions.cs
new file mode 100644
index 00000000..3bfeb88c
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Seed/LabSeedExtensions.cs
@@ -0,0 +1,14 @@
+using CodeBeam.UltimateAuth.Core.Infrastructure;
+
+namespace CodeBeam.UltimateAuth.IdentityLab.Seed;
+
+public static class LabSeedExtensions
+{
+ public static async Task SeedIdentityLabAsync(this WebApplication app, CancellationToken cancellationToken = default)
+ {
+ await using var scope = app.Services.CreateAsyncScope();
+ var runner = scope.ServiceProvider.GetRequiredService();
+
+ await runner.RunAsync(null, cancellationToken);
+ }
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxAccess.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxAccess.cs
new file mode 100644
index 00000000..535eed50
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxAccess.cs
@@ -0,0 +1,22 @@
+using CodeBeam.UltimateAuth.IdentityLab.Domain;
+
+namespace CodeBeam.UltimateAuth.IdentityLab.Services;
+
+public enum SandboxAccess
+{
+ Denied = 0,
+ ReadOnly = 1,
+ Full = 2
+}
+
+public static class SandboxAccessPolicy
+{
+ public static SandboxAccess Evaluate(SandboxStatus status)
+ => status switch
+ {
+ SandboxStatus.Active => SandboxAccess.Full,
+ SandboxStatus.Passive => SandboxAccess.ReadOnly,
+ SandboxStatus.Suspended => SandboxAccess.Denied,
+ _ => SandboxAccess.Denied
+ };
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxAccessService.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxAccessService.cs
new file mode 100644
index 00000000..4c31f671
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxAccessService.cs
@@ -0,0 +1,70 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.IdentityLab.Contracts;
+using CodeBeam.UltimateAuth.IdentityLab.Domain;
+using CodeBeam.UltimateAuth.IdentityLab.Persistence;
+using Microsoft.EntityFrameworkCore;
+
+namespace CodeBeam.UltimateAuth.IdentityLab.Services;
+
+public sealed class SandboxAccessService(LabDbContext db)
+{
+ public Task CanAccessAsync(UserKey userKey, Guid sandboxId, CancellationToken cancellationToken = default)
+ => CheckAsync(userKey, sandboxId, AccessRequirement.Access, cancellationToken);
+
+ public Task CanReadAsync(UserKey userKey, Guid sandboxId, CancellationToken cancellationToken = default)
+ => CheckAsync(userKey, sandboxId, AccessRequirement.Read, cancellationToken);
+
+ public Task CanWriteAsync(UserKey userKey, Guid sandboxId, CancellationToken cancellationToken = default)
+ => CheckAsync(userKey, sandboxId, AccessRequirement.Write, cancellationToken);
+
+ public Task CanManageAsync(UserKey userKey, Guid sandboxId, CancellationToken cancellationToken = default)
+ => CheckAsync(userKey, sandboxId, AccessRequirement.Manage, cancellationToken);
+
+ private async Task CheckAsync(UserKey userKey, Guid sandboxId, AccessRequirement requirement, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(userKey.Value))
+ return SandboxAccessResult.Denied(SandboxAccessFailure.InvalidUser);
+
+ var member = await db.SandboxMembers
+ .AsNoTracking()
+ .Where(x =>
+ x.UserKey == userKey &&
+ x.SandboxId == sandboxId)
+ .Select(x => new
+ {
+ x.Role,
+ x.SandboxId,
+ x.Sandbox.Status
+ })
+ .FirstOrDefaultAsync(cancellationToken);
+
+ if (member is null)
+ return SandboxAccessResult.Denied(SandboxAccessFailure.NotMember);
+
+ if (member.Status == SandboxStatus.Suspended)
+ return SandboxAccessResult.Denied(SandboxAccessFailure.Suspended, member.SandboxId, member.Role, member.Status);
+
+ if (requirement is AccessRequirement.Access or AccessRequirement.Read)
+ {
+ return SandboxAccessResult.Success(member.SandboxId, member.Role, member.Status);
+ }
+
+ if (member.Status != SandboxStatus.Active)
+ return SandboxAccessResult.Denied(SandboxAccessFailure.Passive, member.SandboxId, member.Role, member.Status);
+
+ if (requirement == AccessRequirement.Manage && member.Role != SandboxMemberRole.Owner)
+ {
+ return SandboxAccessResult.Denied(SandboxAccessFailure.OwnerRequired, member.SandboxId, member.Role, member.Status);
+ }
+
+ return SandboxAccessResult.Success(member.SandboxId, member.Role, member.Status);
+ }
+
+ private enum AccessRequirement
+ {
+ Access,
+ Read,
+ Write,
+ Manage
+ }
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxAdminService.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxAdminService.cs
new file mode 100644
index 00000000..f2b11d24
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxAdminService.cs
@@ -0,0 +1,204 @@
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.IdentityLab.Authorization;
+using CodeBeam.UltimateAuth.IdentityLab.Contracts;
+using CodeBeam.UltimateAuth.IdentityLab.Domain;
+using CodeBeam.UltimateAuth.IdentityLab.Persistence;
+using Microsoft.EntityFrameworkCore;
+
+namespace CodeBeam.UltimateAuth.IdentityLab.Services;
+
+public sealed class SandboxAdminService(LabDbContext db, ILabAdminGuard adminGuard)
+{
+ public async Task SetStatusAsync(AccessContext context, Guid sandboxId, SandboxStatus status, CancellationToken cancellationToken = default)
+ {
+ await adminGuard.EnsureAdminAsync(context, cancellationToken);
+
+ if (!Enum.IsDefined(status))
+ throw new ArgumentOutOfRangeException(nameof(status));
+
+ var sandbox = await db.Sandboxes
+ .FirstOrDefaultAsync(x => x.Id == sandboxId, cancellationToken);
+
+ if (sandbox is null)
+ return false;
+
+ sandbox.Status = status;
+
+ await db.SaveChangesAsync(cancellationToken);
+
+ return true;
+ }
+
+ public async Task SetMaxUsersAsync(AccessContext context, Guid sandboxId, int maxUsers, CancellationToken cancellationToken = default)
+ {
+ await adminGuard.EnsureAdminAsync(context, cancellationToken);
+
+ if (maxUsers < 1)
+ throw new ArgumentOutOfRangeException(nameof(maxUsers));
+
+ var settings = await db.SandboxSettings
+ .FirstOrDefaultAsync(x => x.SandboxId == sandboxId, cancellationToken);
+
+ if (settings is null)
+ return false;
+
+ var currentCount = await db.SandboxMembers
+ .CountAsync(x => x.SandboxId == sandboxId, cancellationToken);
+
+ if (maxUsers < currentCount)
+ throw new InvalidOperationException("Maximum users cannot be lower than the current member count.");
+
+ settings.MaxUsers = maxUsers;
+
+ await db.SaveChangesAsync(cancellationToken);
+
+ return true;
+ }
+
+ public async Task RenameAsync(AccessContext context, Guid sandboxId, string name, CancellationToken cancellationToken = default)
+ {
+ await adminGuard.EnsureAdminAsync(context, cancellationToken);
+
+ if (string.IsNullOrWhiteSpace(name))
+ throw new ArgumentException("Sandbox name is required.", nameof(name));
+
+ name = name.Trim();
+
+ if (name.Length > 150)
+ throw new ArgumentException("Sandbox name cannot exceed 150 characters.", nameof(name));
+
+ var sandbox = await db.Sandboxes
+ .FirstOrDefaultAsync(x => x.Id == sandboxId, cancellationToken);
+
+ if (sandbox is null)
+ return false;
+
+ if (sandbox.Name == name)
+ return true;
+
+ sandbox.Name = name;
+
+ await db.SaveChangesAsync(cancellationToken);
+
+ return true;
+ }
+
+ public async Task GetAsync(AccessContext context, Guid sandboxId, CancellationToken cancellationToken = default)
+ {
+ await adminGuard.EnsureAdminAsync(context, cancellationToken);
+
+ return await db.Sandboxes
+ .AsNoTracking()
+ .Where(x => x.Id == sandboxId)
+ .Select(x => new SandboxSummary(
+ x.Id,
+ x.Name,
+ x.OwnerUserKey,
+ x.Status,
+ x.Members.Count,
+ x.Settings.MaxUsers,
+ x.CreatedAt))
+ .FirstOrDefaultAsync(cancellationToken);
+ }
+
+ public async Task> QueryAsync(AccessContext context, SandboxQuery query, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(query);
+
+ await adminGuard.EnsureAdminAsync(context, cancellationToken);
+
+ var pageSize = Math.Clamp(query.PageSize, 1, 100);
+ var page = Math.Clamp(query.Page, 1, int.MaxValue / pageSize);
+
+ var sandboxes = db.Sandboxes.AsNoTracking();
+
+ if (query.Status is { } status)
+ sandboxes = sandboxes.Where(x => x.Status == status);
+
+ if (!string.IsNullOrWhiteSpace(query.Search))
+ {
+ var search = query.Search.Trim();
+
+ sandboxes = sandboxes.Where(x =>
+ EF.Functions.ILike(x.Name, $"%{EscapeLike(search)}%", "\\"));
+ }
+
+ var totalCount = await sandboxes.CountAsync(cancellationToken);
+
+ var sortBy = query.SortBy?.Trim().ToLowerInvariant() switch
+ {
+ "name" => "name",
+ "status" => "status",
+ "members" => "members",
+ "createdat" => "createdat",
+ _ => "createdat"
+ };
+
+ var ordered = (sortBy, query.Descending) switch
+ {
+ ("name", false) => sandboxes.OrderBy(x => x.Name),
+ ("name", true) => sandboxes.OrderByDescending(x => x.Name),
+
+ ("status", false) => sandboxes.OrderBy(x => x.Status),
+ ("status", true) => sandboxes.OrderByDescending(x => x.Status),
+
+ ("members", false) => sandboxes.OrderBy(x => x.Members.Count),
+ ("members", true) => sandboxes.OrderByDescending(x => x.Members.Count),
+
+ ("createdat", false) => sandboxes.OrderBy(x => x.CreatedAt),
+ _ => sandboxes.OrderByDescending(x => x.CreatedAt)
+ };
+
+ var items = await ordered
+ .ThenBy(x => x.Id)
+ .Skip((page - 1) * pageSize)
+ .Take(pageSize)
+ .Select(x => new SandboxSummary(
+ x.Id,
+ x.Name,
+ x.OwnerUserKey,
+ x.Status,
+ x.Members.Count,
+ x.Settings.MaxUsers,
+ x.CreatedAt))
+ .ToListAsync(cancellationToken);
+
+ return new PagedResult(items, totalCount, page, pageSize, sortBy, query.Descending);
+ }
+
+ public async Task GetStatisticsAsync(AccessContext context, CancellationToken cancellationToken = default)
+ {
+ await adminGuard.EnsureAdminAsync(context, cancellationToken);
+
+ var counts = await db.Sandboxes
+ .AsNoTracking()
+ .GroupBy(x => x.Status)
+ .Select(g => new
+ {
+ Status = g.Key,
+ Count = g.Count()
+ })
+ .ToListAsync(cancellationToken);
+
+ var totalMembers = await db.SandboxMembers.AsNoTracking().CountAsync(cancellationToken);
+
+ return new SandboxStatistics(
+ Total: counts.Sum(x => x.Count),
+ Active: counts
+ .Where(x => x.Status == SandboxStatus.Active)
+ .Sum(x => x.Count),
+ Passive: counts
+ .Where(x => x.Status == SandboxStatus.Passive)
+ .Sum(x => x.Count),
+ Suspended: counts
+ .Where(x => x.Status == SandboxStatus.Suspended)
+ .Sum(x => x.Count),
+ TotalMembers: totalMembers);
+ }
+
+ private static string EscapeLike(string value)
+ => value
+ .Replace("\\", "\\\\")
+ .Replace("%", "\\%")
+ .Replace("_", "\\_");
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxService.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxService.cs
new file mode 100644
index 00000000..fa1dfc17
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxService.cs
@@ -0,0 +1,134 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.IdentityLab.Domain;
+using CodeBeam.UltimateAuth.IdentityLab.Persistence;
+using Microsoft.EntityFrameworkCore;
+
+namespace CodeBeam.UltimateAuth.IdentityLab.Services;
+
+public sealed class SandboxService(LabDbContext db)
+{
+ public async Task CreateAsync(UserKey ownerUserKey, string name, CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(ownerUserKey.Value))
+ throw new ArgumentException("Owner user key is required.", nameof(ownerUserKey));
+
+ if (string.IsNullOrWhiteSpace(name))
+ throw new ArgumentException("Sandbox name is required.", nameof(name));
+
+ name = name.Trim();
+
+ if (name.Length > 150)
+ throw new ArgumentException("Sandbox name cannot exceed 150 characters.", nameof(name));
+
+ var sandbox = new Sandbox
+ {
+ Id = Guid.NewGuid(),
+ Name = name,
+ OwnerUserKey = ownerUserKey,
+ Status = SandboxStatus.Active,
+ CreatedAt = DateTimeOffset.UtcNow,
+ Settings = new SandboxSettings
+ {
+ MaxUsers = 5
+ },
+ Members =
+ [
+ new SandboxMember
+ {
+ Id = Guid.NewGuid(),
+ UserKey = ownerUserKey,
+ Role = SandboxMemberRole.Owner,
+ JoinedAt = DateTimeOffset.UtcNow
+ }
+ ]
+ };
+
+ db.Sandboxes.Add(sandbox);
+
+ // OwnerUserKey and UserKey unique indexes enforce
+ // ownership and membership uniqueness atomically.
+ await db.SaveChangesAsync(cancellationToken);
+
+ return sandbox;
+ }
+
+ public async Task RenameAsync(UserKey ownerUserKey, string name, CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(ownerUserKey.Value))
+ throw new ArgumentException("Owner user key is required.", nameof(ownerUserKey));
+
+ if (string.IsNullOrWhiteSpace(name))
+ throw new ArgumentException("Sandbox name is required.", nameof(name));
+
+ name = name.Trim();
+
+ if (name.Length > 150)
+ throw new ArgumentException("Sandbox name cannot exceed 150 characters.", nameof(name));
+
+ var sandbox = await db.Sandboxes.FirstOrDefaultAsync(x => x.OwnerUserKey == ownerUserKey, cancellationToken);
+
+ if (sandbox is null)
+ return false;
+
+ if (sandbox.Status != SandboxStatus.Active)
+ throw new InvalidOperationException("Sandbox is not active.");
+
+ sandbox.Name = name;
+
+ await db.SaveChangesAsync(cancellationToken);
+
+ return true;
+ }
+
+ public Task GetByIdAsync(Guid sandboxId, CancellationToken cancellationToken = default)
+ {
+ return db.Sandboxes
+ .AsNoTracking()
+ .Include(x => x.Settings)
+ .FirstOrDefaultAsync(x => x.Id == sandboxId, cancellationToken);
+ }
+
+ public Task GetByUserAsync(UserKey userKey, CancellationToken cancellationToken = default)
+ {
+ return db.Sandboxes
+ .AsNoTracking()
+ .Include(x => x.Settings)
+ .Where(x => x.Members.Any(m => m.UserKey == userKey))
+ .FirstOrDefaultAsync(cancellationToken);
+ }
+
+ public async Task GetAccessAsync(UserKey userKey, CancellationToken cancellationToken = default)
+ {
+ var sandbox = await GetByUserAsync(userKey, cancellationToken);
+
+ return sandbox is null
+ ? SandboxAccess.Denied
+ : SandboxAccessPolicy.Evaluate(sandbox.Status);
+ }
+
+ public Task> GetMembersAsync(Guid sandboxId, CancellationToken cancellationToken = default)
+ {
+ return db.SandboxMembers
+ .AsNoTracking()
+ .Where(x => x.SandboxId == sandboxId)
+ .OrderBy(x => x.JoinedAt)
+ .ToListAsync(cancellationToken);
+ }
+
+ public async Task GetRemainingCapacityAsync(Guid sandboxId, CancellationToken cancellationToken = default)
+ {
+ var settings = await db.SandboxSettings
+ .AsNoTracking()
+ .FirstOrDefaultAsync(
+ x => x.SandboxId == sandboxId,
+ cancellationToken);
+
+ if (settings is null)
+ throw new InvalidOperationException("Sandbox settings not found.");
+
+ var count = await db.SandboxMembers
+ .CountAsync(x => x.SandboxId == sandboxId, cancellationToken);
+
+ return Math.Max(0, settings.MaxUsers - count);
+ }
+}
From db3348f38bf2864caa6f2ad2dbbd10fb895a92b9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?=
Date: Thu, 22 Oct 2026 15:40:16 +0300
Subject: [PATCH 02/10] Login Logic and User Profile Implementation
---
.../Components/Layout/DashboardLayout.razor | 96 ++++++++-
.../Layout/DashboardLayout.razor.css | 52 +++++
.../Components/Pages/AdminTest.razor | 38 ++++
.../Pages/Dashboard/MyIdentityPage.razor | 193 +++++++++++++++--
.../Components/Pages/DashboardPage.razor | 2 -
.../Components/Pages/LoginPage.razor | 194 +++++++++++++-----
.../Program.cs | 1 +
.../Services/LabApiClient.cs | 42 ++++
.../wwwroot/js/lab-api.js | 35 ++++
.../Components/Dialogs/ProfileDialog.razor | 6 +
10 files changed, 581 insertions(+), 78 deletions(-)
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AdminTest.razor
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabApiClient.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/wwwroot/js/lab-api.js
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 2f518741..a6c6daf0 100644
--- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor
@@ -1,6 +1,7 @@
@inherits LayoutComponentBase
@implements IDisposable
@using CodeBeam.UltimateAuth.Sample.Shared.Infrastructure
+@inject IUAuthClient UAuthClient
@inject DarkModeManager DarkModeManager
@@ -33,7 +34,7 @@
Delay="300">
@@ -45,11 +46,59 @@
Color="Color.Inherit" />
-
-
-
+
+
+
+
+ @AvatarInitials
+
+
+
+
+
+
+
+
+
+ My Identity
+
+
+
+ My Sessions
+
+
+
+
+
+ Sign Out
+
+
+
@@ -131,6 +180,12 @@
Settings
}
+
+
+
+ Sign Out
+
+
@@ -150,6 +205,15 @@
@code {
+ [CascadingParameter]
+ private UAuthState? AuthState { get; set; }
+
+ private string UserName => AuthState?.Identity?.PrimaryUserName ?? "User";
+
+ private string DisplayName => UserName;
+
+ private string AvatarInitials => GetInitials(DisplayName);
+
private bool _drawerOpen = true;
// UI preview only. Replace with sandbox-scoped authorization.
@@ -165,8 +229,28 @@
_ = 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.css b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor.css
index b0fb97c9..00097497 100644
--- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor.css
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor.css
@@ -134,6 +134,58 @@
display: none;
}
+.lab-profile-avatar {
+ cursor: pointer;
+ font-weight: 700;
+ font-size: 0.85rem;
+ letter-spacing: 0.025em;
+ user-select: none;
+ transition: transform 150ms ease, opacity 150ms ease;
+}
+
+ .lab-profile-avatar:hover {
+ transform: scale(1.06);
+ opacity: 0.9;
+ }
+
+.lab-profile-menu-header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 16px 8px;
+ min-width: 230px;
+}
+
+.lab-profile-menu-avatar {
+ flex-shrink: 0;
+ font-weight: 700;
+ font-size: 0.85rem;
+}
+
+.lab-profile-menu-user {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+
+.lab-profile-menu-name {
+ font-size: 0.9rem;
+ font-weight: 600;
+ line-height: 1.4;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.lab-profile-menu-username {
+ font-size: 0.75rem;
+ opacity: 0.65;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
@media (max-width: 960px) {
.lab-mobile-menu {
display: inline-flex;
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AdminTest.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AdminTest.razor
new file mode 100644
index 00000000..b55e0671
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AdminTest.razor
@@ -0,0 +1,38 @@
+@page "/admin/test"
+@rendermode InteractiveServer
+
+@using CodeBeam.UltimateAuth.IdentityLab.Contracts
+@using CodeBeam.UltimateAuth.IdentityLab.Services
+
+@inject LabApiClient Api
+
+
+ Load Statistics
+
+
+@_message
+
+@code {
+ private string _message = "Not loaded";
+
+ private async Task LoadAsync()
+ {
+ try
+ {
+ var response = await Api.GetAsync(
+ "/api/lab/admin/statistics");
+
+ _message = response.Status switch
+ {
+ 200 => $"Total sandboxes: {response.Body?.Total}",
+ 401 => "Authentication required",
+ 403 => "LabAdmin permission required",
+ _ => $"HTTP {response.Status}"
+ };
+ }
+ catch (Exception ex)
+ {
+ _message = ex.Message;
+ }
+ }
+}
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 0d8c2f48..e1d57ad7 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,10 +1,10 @@
@page "/dashboard/identity"
+@using CodeBeam.UltimateAuth.Users.Contracts
@layout DashboardLayout
+@inject IUAuthClient UAuthClient
@inject IDialogService DialogService
-@* TODO: Enable after authentication integration.
@attribute [UAuthAuthorize]
-*@
My Identity · Identity Lab
@@ -25,6 +25,23 @@
+
+ @*
+ TODO (v0.2.x): Multi-profile management.
+
+ UltimateAuth supports multiple profiles per user.
+ Identity Lab should demonstrate:
+ - Listing available profiles
+ - Switching between profiles
+ - Creating and deleting non-default profiles
+ - Editing the selected profile
+
+ Prerequisite:
+ Add the necessary profile discovery/listing methods
+ to the UltimateAuth Users Client API.
+
+ Keep the default profile as the initial implementation.
+ *@
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+ @CurrentDisplayName
+
+
+ @if (!string.IsNullOrWhiteSpace(CurrentUserName))
+ {
+
+ @@ @CurrentUserName
+
+ }
+
+
+ Authenticated Session
+
+
+
- Sign In
+ Continue to Dashboard
-
-
-
+
+ Not you? Sign out
+
-
- New to Identity Lab?
-
- Create a sandbox
-
-
+
+ }
+ else
+ {
+
+
+ @if (!string.IsNullOrEmpty(_loginError))
+ {
+
+ @_loginError
+
+ }
+
+
+
+
+
+
+
+ Sign In
+
+
+
+
+
+
+
+ New to Identity Lab?
+ Create a sandbox
+
+ }
@@ -128,8 +191,7 @@
@@ -141,20 +203,54 @@
@code {
- private MudForm? _form;
+ [CascadingParameter]
+ private UAuthState AuthState { get; set; } = default!;
+
private string _identifier = string.Empty;
private string _password = string.Empty;
- private bool _rememberMe;
- private bool _showPassword;
+ private string? _loginError;
+ private bool _signingOut;
+
+ private string CurrentDisplayName =>
+ AuthState.Identity?.DisplayName
+ ?? AuthState.Identity?.PrimaryUserName
+ ?? "User";
+
+ private string? CurrentUserName =>
+ AuthState.Identity?.PrimaryUserName;
- private void TogglePassword()
+ private void ContinueToDashboard()
{
- _showPassword = !_showPassword;
+ NavigationManager.NavigateTo("/dashboard");
+ }
+
+ private async Task SignOutAsync()
+ {
+ if (_signingOut)
+ return;
+
+ _signingOut = true;
+
+ try
+ {
+ await UAuthClient.Flows.LogoutAsync();
+ }
+ finally
+ {
+ _signingOut = false;
+ }
}
private void HandleLoginResult(IUAuthTryResult result)
{
- // Sonraki adımda başarısız giriş mesajlarını
- // ve hesap kilidi durumunu burada işleyeceğiz.
+ if (result.IsSuccess)
+ {
+ _loginError = null;
+ return;
+ }
+
+ _loginError = result.LockoutUntilUtc is not null
+ ? "Your account is temporarily locked. Please try again later."
+ : "Sign in failed. Please check your credentials.";
}
}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs
index 7627a873..f7aca601 100644
--- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs
@@ -73,6 +73,7 @@
builder.Services.AddScoped
();
builder.Services.AddScoped();
builder.Services.AddScoped();
+builder.Services.AddScoped();
builder.Services.Configure(builder.Configuration.GetSection(LabAdminOptions.SectionName));
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabApiClient.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabApiClient.cs
new file mode 100644
index 00000000..4ccb22cd
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabApiClient.cs
@@ -0,0 +1,42 @@
+using Microsoft.JSInterop;
+
+namespace CodeBeam.UltimateAuth.IdentityLab.Services;
+
+public sealed class LabApiClient(IJSRuntime js) : IAsyncDisposable
+{
+ private IJSObjectReference? _module;
+
+ private async Task GetModuleAsync()
+ {
+ return _module ??= await js.InvokeAsync(
+ "import",
+ "/js/lab-api.js");
+ }
+
+ public async Task> GetAsync(
+ string url,
+ CancellationToken ct = default)
+ {
+ var module = await GetModuleAsync();
+
+ return await module.InvokeAsync>(
+ "request",
+ ct,
+ "GET",
+ url,
+ null);
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ if (_module is not null)
+ await _module.DisposeAsync();
+ }
+}
+
+public sealed class LabApiResponse
+{
+ public bool Ok { get; set; }
+ public int Status { get; set; }
+ public T? Body { get; set; }
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/wwwroot/js/lab-api.js b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/wwwroot/js/lab-api.js
new file mode 100644
index 00000000..4c2f124b
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/wwwroot/js/lab-api.js
@@ -0,0 +1,35 @@
+export async function request(method, url, payload = null) {
+ const deviceId = window.uauth?.deviceId;
+
+ if (!deviceId) {
+ throw new Error("UltimateAuth device is not initialized.");
+ }
+
+ const headers = {
+ "X-UDID": deviceId,
+ "X-Requested-With": "UAuth"
+ };
+
+ const options = {
+ method,
+ credentials: "same-origin",
+ headers
+ };
+
+ if (payload !== null) {
+ headers["Content-Type"] = "application/json";
+ options.body = JSON.stringify(payload);
+ }
+
+ const response = await fetch(url, options);
+
+ const body = response.status === 204
+ ? null
+ : await response.json().catch(() => null);
+
+ return {
+ ok: response.ok,
+ status: response.status,
+ body
+ };
+}
diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/ProfileDialog.razor b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/ProfileDialog.razor
index 8cbf17d3..c1ee46fa 100644
--- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/ProfileDialog.razor
+++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/ProfileDialog.razor
@@ -43,6 +43,12 @@
}
+
+ Username, email address, and phone number are login identifiers.
+ You can manage them separately under
+ User Identifiers .
+
+
Date: Fri, 23 Oct 2026 09:59:44 +0300
Subject: [PATCH 03/10] Completed MyIdentityPage
---
.../Components/Layout/DashboardLayout.razor | 17 +
.../Pages/Dashboard/MyIdentityPage.razor | 295 ++++++++++++------
.../Program.cs | 3 +
.../Dialogs/AccountStatusDialog.razor | 109 ++++---
.../Dialogs/AccountStatusDialog.razor.cs | 24 +-
.../Components/Dialogs/IdentifierDialog.razor | 26 +-
.../Dialogs/IdentifierDialog.razor.cs | 5 +-
.../Program.cs | 12 +-
.../uauth.db-shm | Bin 32768 -> 32768 bytes
.../uauth.db-wal | Bin 902312 -> 885832 bytes
10 files changed, 334 insertions(+), 157 deletions(-)
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 a6c6daf0..b965c20b 100644
--- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor
@@ -1,5 +1,6 @@
@inherits LayoutComponentBase
@implements IDisposable
+@using CodeBeam.UltimateAuth.Core.Contracts
@using CodeBeam.UltimateAuth.Sample.Shared.Infrastructure
@inject IUAuthClient UAuthClient
@inject DarkModeManager DarkModeManager
@@ -199,6 +200,22 @@
+ @if (AuthState?.Identity?.UserStatus == UserStatus.SelfSuspended)
+ {
+
+ Account Suspended
+
+
+ Your account is currently self-suspended.
+ Most account operations are restricted.
+ Reactivate your account from
+ My Identity → Account Management.
+
+
+ }
@Body
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 e1d57ad7..9ecfb863 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,4 +1,5 @@
@page "/dashboard/identity"
+@using CodeBeam.UltimateAuth.Core.Contracts
@using CodeBeam.UltimateAuth.Users.Contracts
@layout DashboardLayout
@inject IUAuthClient UAuthClient
@@ -17,10 +18,6 @@
My Identity
Explore your account, sign-in identifiers, and credential security.
-
-
- Demo Preview
-
@@ -64,7 +61,7 @@
-
+
@_profile?.Status
@@ -207,53 +204,68 @@
-
-
-
-
-
-
-
- demo@example.com
- Email address
-
-
-
- Verified
-
-
-
-
-
-
-
-
-
- demo-user
- Username
-
-
-
- Primary
-
-
-
+ @if (_identifiersLoading)
+ {
+
+ }
+ else if (_identifiersError is not null)
+ {
+
+ @_identifiersError
+
+ }
+ else if (_identifiers.Count == 0)
+ {
+
+ No identifiers found.
+
+ }
+ else
+ {
+ @foreach (var identifier in _identifiers)
+ {
+
+
+
+
+
+
+ @identifier.Value
+ @identifier.Type
+
+
+ @if (identifier.IsPrimary)
+ {
+
+ Primary
+
+ }
+
+
+ @(identifier.IsVerified ? "Verified" : "Not verified")
+
+
+ }
+ }
- Add Identifier
+ Manage Identifiers
+
+ Email and phone built in verification workflows are coming soon.
+
+
@@ -330,66 +342,55 @@
-
-
-
-
-
-
+
+
+
+
-
- Identity
- Your authenticated account
-
+
+
Account Status
+
Manage suspension, reactivation and account access.
-
-
-
-
-
-
-
-
- Authentication Chain
- Explore your authentication lifecycle
-
-
+
+ Manage
+
+
-
+ @if (_isSandboxOwner)
+ {
+
-
-
-
+
+
+
-
Sessions
-
Manage active device sessions
+
Close Sandbox
+
Close your sandbox and its associated resources.
-
-
-
-
- This is an illustrative overview.
- Live authentication data will appear after integration.
-
+
+ Coming Soon
+
+
+ }
@@ -406,12 +407,17 @@
private bool _loading = true;
private string? _loadError;
private bool _showDetails;
+ private readonly List _identifiers = [];
+ private bool _identifiersLoading = true;
+ private string? _identifiersError;
+ private bool _isSandboxOwner;
private static string FormatValue(string? value) => string.IsNullOrWhiteSpace(value) ? "Not provided" : value;
protected override async Task OnInitializedAsync()
{
await LoadProfileAsync();
+ await LoadIdentifiersAsync();
}
private async Task LoadProfileAsync()
@@ -439,6 +445,56 @@
}
}
+ private async Task LoadIdentifiersAsync()
+ {
+ _identifiersLoading = true;
+ _identifiersError = null;
+
+ try
+ {
+ var collected = new List();
+ var pageNumber = 1;
+ const int pageSize = 50;
+
+ while (true)
+ {
+ var result = await UAuthClient.Identifiers.GetMyAsync(
+ new PageRequest
+ {
+ PageNumber = pageNumber,
+ PageSize = pageSize
+ });
+
+ if (!result.IsSuccess || result.Value is null)
+ {
+ _identifiersError =
+ result.ErrorText ?? "Unable to load identifiers.";
+ return;
+ }
+
+ collected.AddRange(result.Value.Items);
+
+ if (collected.Count >= result.Value.TotalCount ||
+ result.Value.Items.Count() == 0)
+ break;
+
+ pageNumber++;
+ }
+
+ _identifiers.Clear();
+ _identifiers.AddRange(collected);
+ }
+ catch (Exception)
+ {
+ _identifiersError = "Unable to load identifiers.";
+ }
+ finally
+ {
+ _identifiersLoading = false;
+ await InvokeAsync(StateHasChanged);
+ }
+ }
+
private async Task OpenEditProfileAsync()
{
var dialog = await DialogService.ShowAsync(
@@ -452,34 +508,67 @@
var result = await dialog.Result;
if (result is { Canceled: false })
- {
await LoadProfileAsync();
- }
}
private async Task OpenIdentifiersAsync()
{
- var parameters = new DialogParameters
- {
- { x => x.PreviewMode, true }
- };
-
- await DialogService.ShowAsync(
+ var dialog = await DialogService.ShowAsync(
"Manage Identifiers",
- parameters,
+ new DialogParameters
+ {
+ { x => x.PreviewMode, false },
+ { x => x.EnableVerification, false }
+ },
UAuthDialog.GetDialogOptions(MaxWidth.Medium));
+
+ var result = await dialog.Result;
+
+ if (result is { Canceled: false })
+ {
+ await LoadIdentifiersAsync();
+ await LoadProfileAsync();
+ }
}
private async Task OpenChangePasswordAsync()
{
- var parameters = new DialogParameters
- {
- { x => x.PreviewMode, true }
- };
-
await DialogService.ShowAsync(
"Change Password",
- parameters,
+ new DialogParameters
+ {
+ { x => x.PreviewMode, false }
+ },
UAuthDialog.GetDialogOptions(MaxWidth.Small));
}
+
+ private async Task OpenAccountManagementAsync()
+ {
+ var dialog = await DialogService.ShowAsync(
+ "Account Management",
+ new DialogParameters
+ {
+ { x => x.PreviewMode, false },
+ { x => x.AuthState, AuthState },
+ { x => x.AllowAccountDeletion, !_isSandboxOwner }
+ },
+ UAuthDialog.GetDialogOptions(MaxWidth.Medium));
+
+ var result = await dialog.Result;
+
+ if (result is { Canceled: false })
+ {
+ await LoadIdentifiersAsync();
+ await LoadProfileAsync();
+ }
+ }
+
+ private static string GetIdentifierIcon(UserIdentifierType type)
+ => type switch
+ {
+ UserIdentifierType.Email => Icons.Material.Filled.Email,
+ UserIdentifierType.Phone => Icons.Material.Filled.Phone,
+ UserIdentifierType.Username => Icons.Material.Filled.Badge,
+ _ => Icons.Material.Filled.AlternateEmail
+ };
}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs
index f7aca601..a7842556 100644
--- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs
@@ -3,6 +3,7 @@
using CodeBeam.UltimateAuth.Core.Abstractions;
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;
@@ -44,6 +45,8 @@
o.Login.MaxFailedAttempts = 5;
o.Login.LockoutDuration = TimeSpan.FromMinutes(1);
o.Identifiers.Behavior.AllowMultipleUsernames = true;
+ // TODO: Remove it back after fix
+ o.CredentialSessionPolicy.OnSelfChange = CredentialSessionBehavior.PreserveAll;
})
.AddUltimateAuthEntityFrameworkCore(db =>
{
diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/AccountStatusDialog.razor b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/AccountStatusDialog.razor
index 0a370265..877ad571 100644
--- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/AccountStatusDialog.razor
+++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/AccountStatusDialog.razor
@@ -56,29 +56,40 @@
-
-
-
+ @if (IsSelfSuspended)
+ {
+
+ Reactivate Account
+
+ }
+ else
+ {
+
+
+
+
+
+
+ Suspend Account
+
+ Temporarily suspend your account.
+ Reactivation may be available according
+ to the application's account policies.
+
+
-
- Suspend Account
-
- Temporarily suspend your account.
- Reactivation may be available according
- to the application's account policies.
-
-
-
-
-
- Suspend Account
-
+
+ Suspend Account
+
+ }
@@ -88,31 +99,43 @@
Actions that may significantly affect your account.
-
-
-
-
+ @if (AllowAccountDeletion)
+ {
+
+
+
+
+
+
+
+ Delete Account
+
+ Delete your account and lose access to its
+ associated identity. Recovery, if available,
+ depends on the server's retention policy.
+
+
-
- Delete Account
-
- Delete your account and lose access to its
- associated identity. Recovery, if available,
- depends on the server's retention policy.
-
-
+
+ Delete Account
+
-
-
- Delete Account
-
-
+ }
+ else
+ {
+
+ The sandbox owner cannot delete their account.
+ Use Close Sandbox to close the entire sandbox.
+
+ }
@@ -23,6 +27,8 @@ public partial class AccountStatusDialog
? "Alex Morgan"
: AuthState?.Identity?.DisplayName ?? "Your Account";
+ private bool IsSelfSuspended => AuthState?.Identity?.UserStatus == UserStatus.SelfSuspended;
+
private async Task SuspendAccountAsync()
{
if (PreviewMode || _isSubmitting)
@@ -83,9 +89,23 @@ private async Task SuspendAccountAsync()
}
}
+ private async Task ReactivateAccountAsync()
+ {
+ var result = await UAuthClient.Users.ChangeMyStatusAsync(
+ new ChangeUserStatusSelfRequest
+ {
+ NewStatus = SelfAssignableUserStatus.Active
+ });
+
+ if (result.IsSuccess)
+ Snackbar.Add("Account activated successfully.", Severity.Success);
+ else
+ Snackbar.Add(result.ErrorText ?? "Activation failed.", Severity.Error);
+ }
+
private async Task DeleteAccountAsync()
{
- if (PreviewMode || _isSubmitting)
+ if (!AllowAccountDeletion || PreviewMode || _isSubmitting)
return;
var confirmed = await DialogService.ShowMessageBoxAsync(
@@ -159,6 +179,6 @@ private async Task DeleteAccountAsync()
private void Close()
{
if (!_isSubmitting)
- MudDialog.Close();
+ MudDialog.Close(DialogResult.Ok(true));
}
}
diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/IdentifierDialog.razor b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/IdentifierDialog.razor
index 97becc06..fdb6dd61 100644
--- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/IdentifierDialog.razor
+++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/IdentifierDialog.razor
@@ -144,13 +144,25 @@
@if (!identifier.IsVerified)
{
-
- Verify
-
+ @if (EnableVerification)
+ {
+
+ Verify
+
+ }
+ else
+ {
+
+ Verification Will be Available Soon
+
+ }
}
diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/IdentifierDialog.razor.cs b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/IdentifierDialog.razor.cs
index 49629010..93c1a1f3 100644
--- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/IdentifierDialog.razor.cs
+++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/IdentifierDialog.razor.cs
@@ -35,6 +35,9 @@ public partial class IdentifierDialog
[Parameter]
public bool PreviewMode { get; set; }
+ [Parameter]
+ public bool EnableVerification { get; set; } = true;
+
protected override async Task OnInitializedAsync()
{
if (PreviewMode)
@@ -323,6 +326,6 @@ private static string GetIdentifierIcon(UserIdentifierType type)
private void Cancel()
{
if (!_loading)
- MudDialog.Cancel();
+ MudDialog.Close(DialogResult.Ok(true));
}
}
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Program.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Program.cs
index 0be8bbef..3648af62 100644
--- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Program.cs
+++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Program.cs
@@ -1,13 +1,16 @@
using CodeBeam.UltimateAuth.Client.Blazor;
using CodeBeam.UltimateAuth.Client.Blazor.Extensions;
using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Credentials;
using CodeBeam.UltimateAuth.EntityFrameworkCore;
using CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore;
using CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Components;
+using CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Infrastructure;
using CodeBeam.UltimateAuth.Sample.Seed.Extensions;
using CodeBeam.UltimateAuth.Sample.Shared.Infrastructure;
using CodeBeam.UltimateAuth.Sample.Shared.ResourceApi;
using CodeBeam.UltimateAuth.Server.Extensions;
+using CodeBeam.UltimateAuth.Users;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.EntityFrameworkCore;
using MudBlazor.Services;
@@ -65,8 +68,15 @@
//o.UAuthStateRefreshMode = UAuthStateRefreshMode.Validate;
});
+// Customize UltimateAuth
+if (builder.Environment.IsDevelopment())
+{
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+}
+
builder.Services.AddScopedUltimateAuthSampleSeed();
-builder.Services.AddScoped();
+//builder.Services.AddScoped();
builder.Services.Configure(options =>
{
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-shm b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-shm
index 1623f1a4ec3bd7845570d4aa03c43de8ae56ec5e..08d31fd8c7e587bcba3360186265a6767e6139e3 100644
GIT binary patch
delta 177
zcmZo@U}|V!N|1P@%K!pWK+MR%AaET>`vdX1ZzoDuHGX`zt=Oo)?meeq+K036bzX$-
zBvt*!ga+oxOwP+Tv8dQ=UgdCuaq=hTn$7>58d$-MUmjQ3fsAFFO9ES%8Lv&-
aoKQWGs%4uC!o^fKe_~c++`OhXgb4u5P(?`q
delta 200
zcmZo@U}|V!N|1P@%K!pWK+MR%AaEN<`vdU?W&hKU(r!2GfAl=FamDYp{qri>&m|`7
zk*a=ULIbm)G|)glOHiJ**qsykCE{PSgasiOqKEG=7-FxjGNcghA;sDvPehA
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-wal b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-wal
index f62ad725bd6ceadc94e7c1be2fe51b421d44a30c..b13e8d5d8be4ef4e533c01847600fa081b30f037 100644
GIT binary patch
delta 13510
zcmeHOcU)A*_NQ-w0To*SyRl)xjrd})
zy;xAuRYr|Tu%pHZQ4=+GOzZ`F_sw0FWj5l=l2?E47e2xtv-_PnbIy0Zb7tnaZnmUq
z7A2MB+fA{+uew@T@GY31iX7pBPwsP^655PCg4_GwflZcZJL|iNwdcS4>fqu6qSMwP
zH!*$#;DCGJP15EpU#H=-`Nr1Dc&l`6ie-u}+3h+e5DMJ{qFzEtFQLF&DiRYCDb<4@
zdJ)8LN9ok9#l|O;?;c$Nfpm7|@aFLoAKL2R5Z3%wHV|afF#79jH=I7XShGV{u0a8r
zZ^=g!mri4h)m@K8ti3;c$=OS{Ydm$Avykt1&fW0SG0yce-SYyZwJ^KzNxKKfr|S3%
z*?gyl5tluD{(0r{4T)S`f4&vJ6$^FJ^vc<DFcFoB9{9o
zSKe-&zeAT*gN(dRbNBxDTemVkg%h?%0jaE)Jf9V@<8-BU$Pm3c-4?s&59($Zf`=le
zlLk$oNg;?gC*wyL9$&?BqinucG)RnmzskH`#6N(Vc
zo&WAJUeFp415n5aMQq?H@md=Lq$LX3N33!Y{A%_)9ufcT&g_o@umz5v@*6=D>j+#L}%;7UQI0U>6d=M;$dCl*`v>KpBp4tf6w-op52TmS-9hXa2r(`chgSpRr}tiv3-YF;
z%_sMXZ%e)?914nx(E)b*RL3uGeNSo_K!Oh7K6FJ$bglFrsN8`_d-
zLe^bVr3O|MT?G$LqY#@*m8%y`{wlT};_Qn;YSkx0qF-(Uot|1F3&pe0y4JProCZGn
zqlhstFWpX^_4O^FX>Ef9M+MT}Nu?=?hK_Q2RJPKL4*WoQFpis!vnGaT98g$dJ
zfLqZh#Kn7h>e8&NY*0QJg`|$+dL(A7BMhS`P{=&|ZOG_$qt)Q@SQNr33y61GKC*$2
zDiq?sbjR%xeTpUkz7~bt_Nk3eeKF*WVU$1=vJgBD*<3Qy5d;7f(ji;tacI%eNd`zT
z3MtxEms-l2VFj{&f)FEBcFtXuyD-E3BQWba+PwdTZ@GI^PZv=73>~0K(7tSG2~?==LxM)Gh7wxllDY#YXK2qtHnHNp+HFpL~5KAsZ}^hOJ%rJ
zE*9caH7%2?m4uog3GIjoyYAZKy;$rXXgVU3ib$!5;dX0ufawAS9nFcU0+l;$&trb(
z3;^LKvG9sYY0<9qd~Xn&XL|$-Yhq9{dksMibKgEN=~tiRVIXhTKh|qM*sC!{{X)-d
z`ej&G4^Z?O43d#IkzZEUY`*j4Em*hCb}Hs6l8FT}nM{hS6(TWCiqlCYP(>A1N)P
zl%z_el-Ij?{7&2UA8<3wI2*Ig*k|j@9@^x48{BNp01(YE(rCJC!X<}QkD^zBGgYYR
zYvc!afuRK$cyPux4f9kGN4UBu1DuV&pl*b=zCKN_FJJW*$;@}oKu+APi99N1N`_+P{&7y
zy=H>~nO)NyHPdX9Wbn=gr~cdL2dTkl!FFpMJt?6~MUjv!3MnPUNd+mzf=taE_U-G+{uC
z(SthOKdxhgsuVP)Htd#X?u4E{0WOb6o2$RwHiwl=JqDHEaLO^QhRcdDhFbCV*g?Cp
zBbI<)fykI-OrZmpXNt^KJY4MROvyS>400F!0
zp7J~>S}dl-O2{7tslZ8s6yUT{rN$*n2?_tnWt3FAcNZ(Dne_R-3ev}%$^DzS8PSCA
zKg@nv)1uS%?78Tx&wqK%g)(A|&9K0>U^9Rw*>(b!?`0isX@)CR*7`4+v1Xe`YUb?M
zu=%^NevbW%d-hC_bq?hs+g(wmI}>3AX5B)YmnH1H`zUhr5>WaQZSHeDOnAdW;{tYa
zS%?a9vNH>ce~PsM)oodq9h)Td@AFAztqs^^=iemS9M9;NLsSz1HFG(bBs+C)#x3=o
zII<&?Dp_GJ7T6rjMYmUDj}h5c+;I0$HXG)%Y+9&LDwMPYC#6!jIn!bRE`wpATBvjvv!_%oc+3CCWAm{m(i
zVtA*bza@adV%RKENab>vHQ+L(fYE%p2$vHyfy-4isix!#xk@Z&*xX&a;!{?8BwuTY
zS|Q)^H{CMiHYdSsUIu%ubl+RQr4ML5xf9JXTTgCt41umN$#m2G27P(H_xKo4v>Xba
zTZ5Qhnc1b4AFu`@w~x_36%b`Tw$64{H=rfw$od
z`Mzh2A2q#MlTW8jUj3LgomuBLShRk##?D~TFmj>w`?WdIvia9+?0#l$Ri^2xmGAJh
zH(3tg%rSIN#nj?&!VcNLXO=FjvRbjs*0UnSr}l;D1@a%Vo(UNP$E{*
z3S2IyU^XF>OK_=3BEo5SQ$j0gk%*EC>lFg;#ukliw6ej+ANGFzGWb)HepfL5$k}#y
zV1l%iCjAFWw&L*bO{R4hJql4$STJ3a0)3Q#L1{F`x}gOJ$Dhvtd_k
zV^{whVi?!x3nPR7h!`rJ>hJ%={Y`8W6m7QtUfB6_
zS7nt_#>j*nW^aMnGdDO)Qm9wpXO8-79CJ8nr&nNe^rBf(o&O)aLV`Z2zzlUN>D``c
zLwu(K;I#==-?OKHl?|&r00exEHWRnW4k~3j7epLKn>okHyZm`^6v(;&n>`z}`u+f$
zjH&l}+UbM93qrJ-7X;Os{HMx|#Fp-AmY?TsT$B`1X6242SB)CkhqzPIg77#LVQ`C_NI!H)L#5~fo;ji)DRNim<
zyls`YQV+9e%0J&pJK8%UYPV*c|Rpt=JGNnRGLIKRDHiwSa2(1&{-)23}kAVs~)
zzIwi1q^anQBBGugbBZeIHj{Y^gw;o0aD3xq+r3TH)aON?{=&Q^vH9&A7RVT@Ku=
z9AI2b%5W0feS5%T>FiygXax$H3Tg%xUG+U-fS8owY+t=#;AA!Rkh!fx_`=f0e#yCF
zoyI&9t;VzyFLDx%6x$!=d;K`J%pE+q2qDIbIH$8OYomJELUpaX$T^B>Prl{&8!;Z~
zizbZs%j*l=-f`|?Cih7ahWrEtIpYX_jU?}*n{;m5S(V{USnJ%0+emVcYvEr84r-Zg4ptHJH1?>7UwvUv1Ji0b
zYenL&-sndoC8~#80Ob1Jb$$2cpRv9v*$H!SkZlM^gm?r*7mHgU%%pa2R8`R
zwoT{Hyw1F*;#SaTYx~Y6asJ0=|G+$96=2%xRJNOTXRLj)G3b(x3nym|IT#Lz5%$|K
zPqBbh$qA(xS5hPm_eL5%dBVF|SiK+=6Ka7{A(9#jT8PnboCY2K=G_;%!BjwdL&{<{NRaXg;g=q8b*whfWf>IqQkfY5icmc8HQcrzB)qJd{1R?Nx>}`@aX!QXI(j3nAKu(b=Tqu*lyg(%*;Y}r^i$p-FB@z)K
zr)hPwX5rnaW5!v$pI8__(v}uFyisIDBn|05fE+2!OC6gscwqY2cv0H;c;WcI@fva9
z;OrDSv@eyG5i=w~l?vz69*rNdu;SFr^o)+6Jk9}n#Ej`cv`Im6*N=8z9Y5{-8}NLQ
z!+Ks=Gi))(dG+sMOyS($^^B~c#NX%{pjqiqZfO*!h-U{ptT}6MHEW(y)KfdXYUD3R
zUkYm$cTCDwdyea6bMxi)2_RrU%Km_(ZM(9IE{+8eC(-6Hb%%e9inrO=tnvI%i-i$E
z$>Mv|NW<=FQr+rimu)xy#lW4Q)S8E^_iCH+{O`_P*xM{Onbg2icR86$+kEZ>sy%qH
zcF~*|Lz@FoyVG|cjLKfVLk)`7@R+RP{oulHn^e)(maMUBv3}d!3MD$3K1GX)-8B2x
z1J^;k(^xZC2^65llgGYszjjR=zT*V5o3QvDj3<&YRZlnHt1H~VgXcLbp0x$fT7UF-
z=Fnn_qsg&OVZqUU?ZAo6&&m;htnNQZ(QmM1tTZf8otKvzpP2+tBO#KJ89r26v62u)vQi=rN*X*sF)Ap>Uo4fYp}?qdp$X2P;>+;p+fb(Xuxz(;HE#>z4w_&;w^#yFGfI8|3zme7=Wk$Z6{+}z5o6Ou(|^)
delta 13717
zcmeHNd011|woe#B0wgEOq=*8FL&XCpIVZ^p0+JC>nN$=NR73<6l|e1yz|cz3s(`gd
ztJdi{6|E@BcBpgf!8=etWIG)^Gpz
z+Cz1*bp0$DEz9E+vhZtdSS%rn`P+BxW?I4e#-iYp*K4P5O@0QO*l2q>c9nat=IP)s
z@(Ir^gKlBMWobtw>onScwnr2TDxc*et)0s{+}(40
z`4^SKGOn;yv&FQCOKk!>-&!E7z(+Q@GR``WWQNeVidvUK5$+z+O6Dx
zXoVuKNIDcfdGZ4X5dBcdfq?YZN2+o?3=k(2q7KU*a>qun9*oF>5F>MPJO|w(wdypG
zQ;0U(5U-!U&dPTNrSnk(iZ(Ty+f|kgF&J<|4Rnyi1WkRihL?Jik-d2w2
z(K*!s@kSw+_HGj#P%S9}75!025$=_{4^zY&Ae~T1nro_dXxXyfpgtRgNXOZWM~@mx
z7$BWdNOrq3uRA3U84rXDP{{o&gy@p%A~*IYX|OFS~1i_@WRGd2koUllkso#bXo_yWFw$gW6jm
z1_+Kq=!sdEJE!&C3u>ILk-Vg^5_`vwxjE4Q@kb%j(}e4tEeQv!Bq*YNQKVJiwN}Lj
zga}0pbnSEUT%Xb|fQUsQj_0`n+PY`c3=k59bZ++@Cw~5p+hD{b2r*6+^ZNC
zAZI4p?97SkI83S3fiukf8?3M*U;g;KtHOuzx!W35xDpRjT5a{1d(}sP=M!sWqDUuWqj|or
z=?1@oP{c8;U!jlR!CfHQ)doq(5%=jcI@p%743J(Zq_nEH`_-slz6SYH6w+{Kn(O1T
zjm3Y{9P(K-kc#JJPd^C*r
z(g4w*kW`yZ*Y@^ZIvWUeC?pN%Tn_D-EHpquQONDpU2}VOi@y&-018=o$Z7Jg{%$q~
zNH_|)A#t9+Hg3;WkZ~45j8u8DZ|CqwwMDN$@lCYZ2Rl;YFtb1hRy;)wl$DDf0K#gD
z!9WCRKu}VgH=#|(L!idZ7D>r(?N0WpcO36xfJ8w^RP%z_bJ-jJ2dn3lRM8OO*4%ph
z^U@uyik++UOO=pSIl-&`&+7yi)Qvgc?;Pc`X7pk}#M$aFACZPsskK@OPKebKoKg@<
zTtTWtIH49x2rWs&RzjB;$LX#+5n#vlMavPnh>*!;47XiT15w_A^W1!DQo#NlwimHL
zOAJ7`NiI5XNnbE>>dXgVM2_tdEXs^g3w!N@I`&PkXYPtEbRNhl{?~f#3wt%@XzWSi
z3Qi$%DcrWH6vi#0ecRcLUTSSHaZ
z2q~#ls++<*WrwZDJHiYr&X#KPOs;RA#t_d%hA>-V0HPU28eOlRw=b{!m&M6nj3JUnXy7C6w
zR(;JROH7ERxQM{bd@|OutPe{pez-s(K1p;sJ!K(JqR<;}0v7f+4``IXbp|
zeH?dA>AH12pm`i5Tb0LTZk4kRV<5UM+MK-7yMDu`-vt9=81E-c7bxdY#sn9fYE!>*
z@ysD0U(PYlQ48HRDF#;@DE#I8w5M{gD4erK;3J{cBDq{6!^Lt9q>WN4!j&4B0Fu(k
z2%3hm*UA{$3^y0scZXefFOK8(zmeG2>+hvHJh{OI6@xjISh6L}zx2O9SP(KB2UgLI-pB$Fr2lnsfRbaYkd%HMes;96nPG~pt
zYZ3^*LLqEHj~+)hFA4A3=G(H_aoD*nH<18Gs34ij`6Gm+tNNGXHF3uBv
zC$Uzh)u_Z;Tu#a4I3<;8aayg=;$lLop|uiHs}<`G?6eDePx_p!hV-#yaQ`N5#+ixz
zyV)-{FFL&|ylt-hzB0`o%7`^q#KMZO%^*6}HW$n5W*y76Ae0(w=7*NV<^4#_?ERV-
zfB9YiLvso??FSj>Q7)F%4bZXwS?30dZ==omv|M&JsiNQx^7k%Ur-
zNQzWwbuo9j-E~XdA)>`Msh?O*6C#l;o88izC8bHzQg3NR6>-ApKz?Xr5*RUqw++j)
zz*{}PEO7+J#ia6D+{1cWvTvP|nFLnYa28ep|1}bI
z;ltmaZ^r0Y5??Ewu-LY(xx8CA43knXKje09zqTvGn}(6V!Zu?GFz+!~OpKj3_kYxJ
z-Q_WM?aaxyg?^h9lfAz#`L^BhtscxOWG(bEIg`m9dX&x4K02o7vfw-m8z-pD^g1O7
zC=gmXK`z6i75!lW%oamxg+e3NsALLUp^;EHB_U{7IuI&cLQy2FCJ2(06HTOEKHJVC
z!VIYkglseYT2g4fUnP61anF$HkIwd7Yjyv9;l=Sy;T_Oa2YM8gho>Qw5$jd`2u>TzD$DKjYF$nMxy)iE))&
z0ryNaA;T54NR5+(T%?hUMKYR_G=;YBPCFs8wi;)|yC;jzzdP6M|I4}^-@-bHpJ6-s
zc4&;B%&*(gom`IJKD~M-GuLe}IM?C2jn(PrdqmUHZArdt{+cyD(85*Aq;lP!SRW%;
z^(h7Fj-ku*tK~e>-WWxdr@7PJ~>Xm6fqvXbJW{%ln6r)I#QIk40X_6%*v1K&Lq6zn2;`P^VyB|z>
zJ@1DQ&1sMy$VK)R4Wr*gwM__~3l{Z7n}=x<*7upF=npF5(dJc$>=Krg#1w++blBXn
zd6{lq^6?hVq0hwn!18w)#f!fS&cyY6Ms}Qa)7-gUx|-QsUPwzWjnl`XuxIn&V*ei^
zf+JgkU?lX1h~UeEE>-7OMsXPt;yznx;ER$G*&zcnAd3$W6ko((oLl`PbJ
zlQ%gFb5690aPZsAXg-^nP0So(V^~2J8-oRJ0lcG*Ww8{x#Ob`GJeg!nj(;=IL3>^g7wLtdMBMT63YKDQAkH2=f0-lEbpQ?LXYWPh?I~o)dHJ1DxlzxpuUX3qZrnIHU8XzW5Z+^!kW4(HYf6JWAAy&R{Jz5}f
zx8gEaL#r{})XThNBg5SBx$@EFDGA`=We738f{X0h^F-N}OrAaX^)l}$raS$b7i7fv
zUaDC#g{tNPuQ$AV7;+CR^`j+HDNQl2PXzFvk>i`n&)@vVgV=Zj$DKXxTR8ss!j6*G
z$9)<=w5L7dbo`;(r?p^W0K;(@8VTB60>9&f-9_|7<{JX}$;vBaQ&>YWbDi3VwO6Pk
z!XqJ!A+XVtsXUK@_^W3fK*ezT60Adu2pN5kXY*pc-Q1YtN!^c|?R|8=3hdF#ZsQMO
zuO@?xXK(`9CqouAF%Jv1EItxPfqs^x`y#Ni1t{pXq
zfyoQGwlkf_oLeJ*1~TgHr(;fL;-g!!**@VNNjGt-ru}DScgD}S2Z|q})*Xtn>MsUl
zeX)Io1CqOV>6+6{K}*A0t~UF-m^c=`y7+OzgE8kpjh6%8ykn&g>=XVakDrB}556{a
za{y={P~Wa~jc(iJl;74W1=>n?c{BFt5NbYiZIzGdYR1AH-Hs6ssm7#(+o^VwyO#0*
zk?61$^N~u`BuPjWxKyi#*POIeh095e2$yOkN);i|s1+KxN->XEh)igwS%z=--51@v
zmw$i5GXqO{fz0TDvg5+&>AfbeO4|-b6rcmx;kC$WQC8GCW^sWt%;a@paL-XTJ5~g5
z1*I!d$l)r%Y-Qufi-xxb(+)Vayfv7So$Nm`7}J9N2T;gh-Kz3IUAuEY-H#42n2%Vc
zRTFACJa2$()}1%5Av00^EJ&v(rxF_%giIHc^J-u)es|U
zTrnY6DAn+Vk4i0(!9A}WrhpW|B8JqEq*ftUt3}k?cYm?~LgYsf@Hv%~hOX*gl{a%V
zCTU2t>av#*)E7El!g^ZJKkUKemF@9ct;JwFK|1@t|<;U+gP=-Rr
zH92+dLyxIv*f!`Dq)y|$?140g=GvLVj>6zF%mx&MQciW5*|)3{SYgda=6w9nPut6dZQvwjYIaKrVe
zbL#Np@mCW1dxJIu_&;OsIZvPnwQN3qu$%jG^YwAPm=(oZ=xHJf_q}o3b;mob(r@hq
zYLJ$oe*YX`*^UQJl6FJ+770c+Hij`cLFdT{s%MTHgmS+TsiHW-Lah&%0
z=ca!)eOg3E;wKYP@EwL&uPmLcU<^zqd(n}#VNU(#Y|xLDpUdLs!m(!XlV)18Td|V#
zLsD@I6F%V=@Ogik5`?$-Yt;H?ku;+O&7WFO#!)gNQO@=>p>LA4eFfERxgB%l!p63+
zuq|+i>#)2?TklvEuO+&c<;&!?Ce$aR=|3`f_kjgr4r}WLO=i!quru%%$X^u&DJ(HF
u2KXBjsve8#RFQvS^tGmrwzIJ9(EoDGwzn-LoR0mAqpzM1%@ru>(|-e58~K+2
From b08ae581f1643b6170ee7de6d42767af07e5aac1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?=
Date: Fri, 23 Oct 2026 12:26:21 +0300
Subject: [PATCH 04/10] Register and Sandbox Create
---
.../Authorization/LabRoles.cs | 11 +-
.../Components/Layout/DashboardLayout.razor | 45 ++++--
.../Components/Pages/DashboardPage.razor | 76 +++++++---
.../Components/Pages/RegisterPage.razor | 65 ++++++---
.../Contracts/LabDashboardSnapshot.cs | 9 ++
.../Contracts/LabRegistrationRequest.cs | 6 +
.../Contracts/LabRegistrationResult.cs | 9 ++
.../Persistence/SandboxMemberConfiguration.cs | 4 +
.../Program.cs | 9 +-
.../Seed/LabAdminSeedContributor.cs | 45 +++---
.../Services/LabDashboardService.cs | 28 ++++
.../Services/LabRegistrationService.cs | 84 +++++++++++
.../Services/LabRoleService.cs | 32 +++++
.../Services/SandboxAccessService.cs | 4 +-
.../Services/SandboxAdminService.cs | 14 +-
.../Services/SandboxService.cs | 132 +++++++++++++++++-
16 files changed, 493 insertions(+), 80 deletions(-)
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/LabDashboardSnapshot.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/LabRegistrationRequest.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/LabRegistrationResult.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabDashboardService.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRegistrationService.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRoleService.cs
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabRoles.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabRoles.cs
index ed5adcc2..16c14277 100644
--- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabRoles.cs
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Authorization/LabRoles.cs
@@ -2,5 +2,14 @@
public static class LabRoles
{
- public const string Admin = "LabAdmin";
+ public const string Admin = "Ladmin";
+ public const string Owner = "Owner";
+ public const string User = "User";
+
+ public static readonly IReadOnlyList SystemRoles =
+ [
+ Admin,
+ Owner,
+ User
+ ];
}
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 b965c20b..7ba0f9ef 100644
--- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor
@@ -1,7 +1,12 @@
@inherits LayoutComponentBase
@implements IDisposable
@using CodeBeam.UltimateAuth.Core.Contracts
+@using CodeBeam.UltimateAuth.Core.Domain
+@using CodeBeam.UltimateAuth.IdentityLab.Authorization
+@using CodeBeam.UltimateAuth.IdentityLab.Contracts
+@using CodeBeam.UltimateAuth.IdentityLab.Services
@using CodeBeam.UltimateAuth.Sample.Shared.Infrastructure
+@inject LabDashboardService DashboardService
@inject IUAuthClient UAuthClient
@inject DarkModeManager DarkModeManager
@@ -117,12 +122,12 @@
- My Sandbox
- Identity environment
+ @(_sandbox?.SandboxName ?? "My Sandbox")
+
+ @(_sandbox is null ? "Identity environment" : $"{_sandbox.MemberCount} / {_sandbox.MaxUsers} users")
+
-
+
@@ -226,21 +231,41 @@
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;
- // UI preview only. Replace with sandbox-scoped authorization.
- private bool ShowManagement => 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);
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 0da12356..3ac60b03 100644
--- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/DashboardPage.razor
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/DashboardPage.razor
@@ -1,5 +1,10 @@
@page "/dashboard"
+@using CodeBeam.UltimateAuth.Core.Domain
+@using CodeBeam.UltimateAuth.IdentityLab.Authorization
+@using CodeBeam.UltimateAuth.IdentityLab.Contracts
+@using CodeBeam.UltimateAuth.IdentityLab.Services
@layout DashboardLayout
+@inject LabDashboardService DashboardService
@attribute [UAuthAuthorize]
@@ -246,28 +251,65 @@
-
-
-
Your sandbox, your experiments.
-
- Each sandbox supports up to four users,
- including its owner.
-
-
+ @if (_dashboard is not null)
+ {
+
+
+
@_dashboard.SandboxName
+
+ Each sandbox supports up to
+ @_dashboard.MaxUsers users, including its owner.
+
+
-
- 1 / 4 example users
-
-
+
+ @_dashboard.MemberCount / @_dashboard.MaxUsers users
+
+
+ }
}
@code {
- // Temporary design preview. Must be replaced by
- // server-validated, sandbox-scoped management permission.
- private bool ShowManagement => true;
+ [CascadingParameter]
+ private UAuthState? AuthState { get; set; }
+
+ private LabDashboardSnapshot? _dashboard;
+ private UserKey? _loadedUserKey;
+ private bool _loading;
+ private bool ShowManagement =>
+ AuthState?.IsInRole(LabRoles.Admin) == true ||
+ (AuthState?.IsInRole(LabRoles.Owner) == true &&
+ _dashboard?.IsOwner == true);
+
+ protected override async Task OnParametersSetAsync()
+ {
+ var userKey = AuthState?.Identity?.UserKey;
+
+ if (userKey is null)
+ {
+ _dashboard = null;
+ _loadedUserKey = null;
+ return;
+ }
+
+ if (_loadedUserKey == userKey)
+ return;
+
+ _loadedUserKey = userKey;
+ _loading = true;
+
+ try
+ {
+ _dashboard = await DashboardService.GetAsync(userKey.Value);
+ }
+ finally
+ {
+ _loading = false;
+ }
+ }
}
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 c9aa085a..8d7fa3e4 100644
--- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/RegisterPage.razor
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/RegisterPage.razor
@@ -1,5 +1,10 @@
@page "/register"
+@using CodeBeam.UltimateAuth.IdentityLab.Contracts
+@using CodeBeam.UltimateAuth.IdentityLab.Services
@layout PublicLayout
+@inject LabRegistrationService RegistrationService
+@inject ISnackbar Snackbar
+@inject NavigationManager Navigation
Create Sandbox · Identity Lab
@@ -12,16 +17,12 @@
-
+
GET STARTED
Create your sandbox.
-
- Set up your account and start exploring
- UltimateAuth in your own workspace.
-
+
Set up your account and start exploring UltimateAuth in your own workspace.
@@ -71,9 +72,10 @@
Color="Color.Primary"
FullWidth="true"
Size="Size.Large"
- Disabled="true"
+ Disabled="@_isSubmitting"
+ OnClick="SubmitAsync"
EndIcon="@Icons.Material.Filled.ArrowForward">
- Create Sandbox
+ @(_isSubmitting ? "Creating..." : "Create Sandbox")
@@ -89,9 +91,7 @@
-
+
Free, open source, and powered by UltimateAuth
@@ -105,17 +105,13 @@
YOUR IDENTITY PLAYGROUND
-
+
Build identities.
Explore freely.
-
- Create a private sandbox to experience
- authentication, sessions, and authorization
- in a real application.
+
+ Create a private sandbox to experience authentication, sessions, and authorization in a real application.
@@ -124,7 +120,7 @@
Your own users
-
Create and manage up to four sandbox members, including yourself.
+
Create and manage up to five sandbox members, including yourself.
@@ -167,6 +163,7 @@
private string _password = string.Empty;
private string _confirmPassword = string.Empty;
private bool _showPassword;
+ private bool _isSubmitting;
private MudForm? _form;
private static string? ValidateEmail(string value)
@@ -174,8 +171,7 @@
if (string.IsNullOrWhiteSpace(value))
return "Email address is required.";
- return new System.ComponentModel.DataAnnotations.EmailAddressAttribute()
- .IsValid(value)
+ return new System.ComponentModel.DataAnnotations.EmailAddressAttribute().IsValid(value)
? null
: "Enter a valid email address.";
}
@@ -204,7 +200,7 @@
private async Task SubmitAsync()
{
- if (_form is null)
+ if (_form is null || _isSubmitting)
return;
await _form.ValidateAsync();
@@ -212,6 +208,29 @@
if (!_form.IsValid)
return;
- // UltimateAuth registration + sandbox creation
+ _isSubmitting = true;
+
+ try
+ {
+ var result = await RegistrationService.RegisterAsync(new LabRegistrationRequest(_username.Trim(), _email.Trim(), _password.Trim()));
+
+ if (!result.IsSuccess)
+ {
+ Snackbar.Add(result.ErrorMessage ?? "Registration failed.", Severity.Error);
+ return;
+ }
+
+ Snackbar.Add("Your sandbox has been created successfully.", Severity.Success);
+
+ Navigation.NavigateTo("/login");
+ }
+ catch (Exception)
+ {
+ Snackbar.Add("An unexpected error occurred during registration.", Severity.Error);
+ }
+ finally
+ {
+ _isSubmitting = false;
+ }
}
}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/LabDashboardSnapshot.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/LabDashboardSnapshot.cs
new file mode 100644
index 00000000..aba6c95a
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/LabDashboardSnapshot.cs
@@ -0,0 +1,9 @@
+namespace CodeBeam.UltimateAuth.IdentityLab.Contracts;
+
+public sealed record LabDashboardSnapshot(
+ Guid SandboxId,
+ string SandboxName,
+ int MaxUsers,
+ int MemberCount,
+ int RemainingCapacity,
+ bool IsOwner);
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/LabRegistrationRequest.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/LabRegistrationRequest.cs
new file mode 100644
index 00000000..2d4593af
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/LabRegistrationRequest.cs
@@ -0,0 +1,6 @@
+namespace CodeBeam.UltimateAuth.IdentityLab.Contracts;
+
+public sealed record LabRegistrationRequest(
+ string Username,
+ string Email,
+ string Password);
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/LabRegistrationResult.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/LabRegistrationResult.cs
new file mode 100644
index 00000000..31e696c3
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/LabRegistrationResult.cs
@@ -0,0 +1,9 @@
+namespace CodeBeam.UltimateAuth.IdentityLab.Contracts;
+
+public sealed record LabRegistrationResult(bool IsSuccess, string? ErrorMessage = null, bool RequiresRepair = false)
+{
+ public static LabRegistrationResult Success() => new(true);
+
+ public static LabRegistrationResult Failed(string message, bool requiresRepair = false)
+ => new(false, message, requiresRepair);
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Persistence/SandboxMemberConfiguration.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Persistence/SandboxMemberConfiguration.cs
index 07a8f4b3..01a119a2 100644
--- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Persistence/SandboxMemberConfiguration.cs
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Persistence/SandboxMemberConfiguration.cs
@@ -42,5 +42,9 @@ public void Configure(EntityTypeBuilder builder)
x.SandboxId,
x.Role
});
+
+ builder.HasIndex(x => x.SandboxId)
+ .IsUnique()
+ .HasFilter("\"Role\" = 'Owner'");
}
}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs
index a7842556..53f58d20 100644
--- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs
@@ -20,6 +20,11 @@
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();
@@ -61,7 +66,7 @@
});
-builder.Services.AddDbContext(options =>
+builder.Services.AddDbContextFactory(options =>
{
options.UseNpgsql(connectionString, npgsql =>
{
@@ -76,6 +81,8 @@
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));
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Seed/LabAdminSeedContributor.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Seed/LabAdminSeedContributor.cs
index 1dc7e3e6..b9a61742 100644
--- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Seed/LabAdminSeedContributor.cs
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Seed/LabAdminSeedContributor.cs
@@ -44,7 +44,8 @@ public async Task SeedAsync(TenantKey tenant, CancellationToken ct = default)
await EnsureUserAsync(tenant, userKey, settings, ct);
await EnsureCredentialAsync(tenant, userKey, settings.Password, ct);
- await EnsureRoleAsync(tenant, userKey, ct);
+ await EnsureSystemRolesAsync(tenant, ct);
+ await EnsureAdminRoleAssignmentAsync(tenant, userKey, ct);
}
private async Task EnsureUserAsync(
@@ -80,6 +81,8 @@ await profileStore.AddAsync(
userKey,
ProfileKey.Default,
now,
+ firstName: "Lab",
+ lastName: "Administrator",
displayName: settings.DisplayName),
ct);
}
@@ -167,39 +170,43 @@ await store.AddAsync(
ct);
}
- private async Task EnsureRoleAsync(
- TenantKey tenant,
- UserKey userKey,
- CancellationToken ct)
+ private async Task EnsureSystemRolesAsync(TenantKey tenant, CancellationToken ct)
{
var roleStore = roleStoreFactory.Create(tenant);
- var userRoleStore = userRoleStoreFactory.Create(tenant);
-
- var role = await roleStore.GetByNameAsync(
- LabRoles.Admin.ToUpperInvariant(), ct);
- if (role is null)
+ foreach (var roleName in LabRoles.SystemRoles)
{
- role = Role.Create(
+ var existing = await roleStore.GetByNameAsync(roleName.ToUpperInvariant(), ct);
+
+ if (existing is not null)
+ continue;
+
+ var role = Role.Create(
RoleId.From(Guid.NewGuid()),
tenant,
- LabRoles.Admin,
+ roleName,
new HashSet(),
clock.UtcNow);
await roleStore.AddAsync(role, ct);
}
+ }
- var assignments = await userRoleStore
- .GetAssignmentsAsync(userKey, ct);
+ private async Task EnsureAdminRoleAssignmentAsync(TenantKey tenant, UserKey userKey, CancellationToken ct)
+ {
+ var roleStore = roleStoreFactory.Create(tenant);
+ var userRoleStore = userRoleStoreFactory.Create(tenant);
+
+ var role = await roleStore.GetByNameAsync(LabRoles.Admin.ToUpperInvariant(), ct);
+
+ if (role is null)
+ throw new InvalidOperationException("LabAdmin role was not initialized.");
+
+ var assignments = await userRoleStore.GetAssignmentsAsync(userKey, ct);
if (assignments.Any(x => x.RoleId == role.Id))
return;
- await userRoleStore.AssignAsync(
- userKey,
- role.Id,
- clock.UtcNow,
- ct);
+ await userRoleStore.AssignAsync(userKey, role.Id, clock.UtcNow, ct);
}
}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabDashboardService.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabDashboardService.cs
new file mode 100644
index 00000000..9c393b5c
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabDashboardService.cs
@@ -0,0 +1,28 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.IdentityLab.Contracts;
+
+namespace CodeBeam.UltimateAuth.IdentityLab.Services;
+
+public sealed class LabDashboardService(SandboxService sandboxService)
+{
+ public async Task GetAsync(UserKey userKey, CancellationToken ct = default)
+ {
+ var sandbox = await sandboxService.GetByUserAsync(userKey, ct);
+
+ if (sandbox is null)
+ return null;
+
+ var members = await sandboxService.GetMembersAsync(sandbox.Id, ct);
+
+ var maxUsers = sandbox.Settings.MaxUsers;
+ var memberCount = members.Count;
+
+ return new LabDashboardSnapshot(
+ SandboxId: sandbox.Id,
+ SandboxName: sandbox.Name,
+ MaxUsers: maxUsers,
+ MemberCount: memberCount,
+ RemainingCapacity: Math.Max(0, maxUsers - memberCount),
+ IsOwner: sandbox.OwnerUserKey == userKey);
+ }
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRegistrationService.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRegistrationService.cs
new file mode 100644
index 00000000..fc89b301
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRegistrationService.cs
@@ -0,0 +1,84 @@
+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.Users.Contracts;
+
+namespace CodeBeam.UltimateAuth.IdentityLab.Services;
+
+public sealed class LabRegistrationService(IUAuthClient uAuthClient, IRoleStoreFactory roleStoreFactory, IUserRoleStoreFactory userRoleStoreFactory, SandboxService sandboxService)
+{
+ public async Task RegisterAsync(LabRegistrationRequest request, CancellationToken ct = default)
+ {
+ if (string.IsNullOrWhiteSpace(request.Username) ||
+ string.IsNullOrWhiteSpace(request.Email) ||
+ string.IsNullOrWhiteSpace(request.Password))
+ {
+ return LabRegistrationResult.Failed("Username, email and password are required.");
+ }
+
+ // Step 1: Create user through UltimateAuth's public endpoint.
+ var createResult = await uAuthClient.Users.CreateAsync(
+ new CreateUserRequest
+ {
+ UserName = request.Username.Trim(),
+ Email = request.Email.Trim(),
+ Password = request.Password
+ });
+
+ if (!createResult.IsSuccess || createResult.Value is not
+ {
+ IsSuccess: true,
+ UserKey: { Length: > 0 } createdUserKey
+ })
+ {
+ return LabRegistrationResult.Failed(createResult.Value?.FailureReason ?? "Unable to create your account.");
+ }
+
+ var userKey = UserKey.FromString(createdUserKey);
+
+ // 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;
+
+ var roleStore = roleStoreFactory.Create(tenant);
+ var userRoleStore = userRoleStoreFactory.Create(tenant);
+
+ var ownerRole = await roleStore.GetByNameAsync(LabRoles.Owner.ToUpperInvariant(), ct);
+
+ if (ownerRole is null || ownerRole.IsDeleted)
+ {
+ return LabRegistrationResult.Failed("Owner role is not initialized.", requiresRepair: true);
+ }
+
+ var assignments = await userRoleStore.GetAssignmentsAsync(userKey, ct);
+
+ if (!assignments.Any(x => x.RoleId == ownerRole.Id))
+ {
+ await userRoleStore.AssignAsync(userKey, ownerRole.Id, DateTimeOffset.UtcNow, ct);
+ }
+
+ // A sandbox name is presentation data, not an identity key.
+ // The owner relationship is established using OwnerUserKey.
+ await sandboxService.CreateAsync(userKey, $"{request.Username.Trim()}'s Sandbox", ct);
+
+ return LabRegistrationResult.Success();
+ }
+ catch (Exception)
+ {
+ // The UltimateAuth account may already exist.
+ // Do not retry account creation automatically.
+
+ // TODO(Part 3): Persist provisioning state or implement
+ // authenticated repair for partially completed registration.
+
+ return LabRegistrationResult.Failed("Your account was created, but sandbox setup could not be completed. Please contact support before registering again.", requiresRepair: true);
+ }
+ }
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRoleService.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRoleService.cs
new file mode 100644
index 00000000..9a37ea80
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRoleService.cs
@@ -0,0 +1,32 @@
+using CodeBeam.UltimateAuth.Authorization;
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+using CodeBeam.UltimateAuth.IdentityLab.Authorization;
+
+namespace CodeBeam.UltimateAuth.IdentityLab.Services;
+
+public sealed class LabRoleService(IRoleStoreFactory roleStoreFactory, IUserRoleStoreFactory userRoleStoreFactory)
+{
+ public async Task AssignAsync(TenantKey tenant, UserKey userKey, string roleName, CancellationToken ct = default)
+ {
+ if (roleName != LabRoles.Owner && roleName != LabRoles.User)
+ {
+ throw new InvalidOperationException("Unsupported role assignment.");
+ }
+
+ var roleStore = roleStoreFactory.Create(tenant);
+ var userRoleStore = userRoleStoreFactory.Create(tenant);
+
+ var role = await roleStore.GetByNameAsync(roleName.ToUpperInvariant(), ct);
+
+ if (role is null)
+ throw new InvalidOperationException($"System role '{roleName}' was not found.");
+
+ var assignments = await userRoleStore.GetAssignmentsAsync(userKey, ct);
+
+ if (assignments.Any(x => x.RoleId == role.Id))
+ return;
+
+ await userRoleStore.AssignAsync(userKey, role.Id, DateTimeOffset.UtcNow, ct);
+ }
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxAccessService.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxAccessService.cs
index 4c31f671..aa0e2a23 100644
--- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxAccessService.cs
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxAccessService.cs
@@ -6,7 +6,7 @@
namespace CodeBeam.UltimateAuth.IdentityLab.Services;
-public sealed class SandboxAccessService(LabDbContext db)
+public sealed class SandboxAccessService(IDbContextFactory dbFactory)
{
public Task CanAccessAsync(UserKey userKey, Guid sandboxId, CancellationToken cancellationToken = default)
=> CheckAsync(userKey, sandboxId, AccessRequirement.Access, cancellationToken);
@@ -22,6 +22,8 @@ public Task CanManageAsync(UserKey userKey, Guid sandboxId,
private async Task CheckAsync(UserKey userKey, Guid sandboxId, AccessRequirement requirement, CancellationToken cancellationToken)
{
+ await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
+
if (string.IsNullOrWhiteSpace(userKey.Value))
return SandboxAccessResult.Denied(SandboxAccessFailure.InvalidUser);
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxAdminService.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxAdminService.cs
index f2b11d24..e65864e1 100644
--- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxAdminService.cs
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxAdminService.cs
@@ -7,10 +7,12 @@
namespace CodeBeam.UltimateAuth.IdentityLab.Services;
-public sealed class SandboxAdminService(LabDbContext db, ILabAdminGuard adminGuard)
+public sealed class SandboxAdminService(IDbContextFactory dbFactory, ILabAdminGuard adminGuard)
{
public async Task SetStatusAsync(AccessContext context, Guid sandboxId, SandboxStatus status, CancellationToken cancellationToken = default)
{
+ await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
+
await adminGuard.EnsureAdminAsync(context, cancellationToken);
if (!Enum.IsDefined(status))
@@ -31,6 +33,8 @@ public async Task SetStatusAsync(AccessContext context, Guid sandboxId, Sa
public async Task SetMaxUsersAsync(AccessContext context, Guid sandboxId, int maxUsers, CancellationToken cancellationToken = default)
{
+ await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
+
await adminGuard.EnsureAdminAsync(context, cancellationToken);
if (maxUsers < 1)
@@ -57,6 +61,8 @@ public async Task SetMaxUsersAsync(AccessContext context, Guid sandboxId,
public async Task RenameAsync(AccessContext context, Guid sandboxId, string name, CancellationToken cancellationToken = default)
{
+ await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
+
await adminGuard.EnsureAdminAsync(context, cancellationToken);
if (string.IsNullOrWhiteSpace(name))
@@ -85,6 +91,8 @@ public async Task RenameAsync(AccessContext context, Guid sandboxId, strin
public async Task GetAsync(AccessContext context, Guid sandboxId, CancellationToken cancellationToken = default)
{
+ await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
+
await adminGuard.EnsureAdminAsync(context, cancellationToken);
return await db.Sandboxes
@@ -103,6 +111,8 @@ public async Task RenameAsync(AccessContext context, Guid sandboxId, strin
public async Task> QueryAsync(AccessContext context, SandboxQuery query, CancellationToken cancellationToken = default)
{
+ await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
+
ArgumentNullException.ThrowIfNull(query);
await adminGuard.EnsureAdminAsync(context, cancellationToken);
@@ -168,6 +178,8 @@ public async Task> QueryAsync(AccessContext context,
public async Task GetStatisticsAsync(AccessContext context, CancellationToken cancellationToken = default)
{
+ await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
+
await adminGuard.EnsureAdminAsync(context, cancellationToken);
var counts = await db.Sandboxes
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxService.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxService.cs
index fa1dfc17..e6b03146 100644
--- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxService.cs
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxService.cs
@@ -5,10 +5,12 @@
namespace CodeBeam.UltimateAuth.IdentityLab.Services;
-public sealed class SandboxService(LabDbContext db)
+public sealed class SandboxService(IDbContextFactory dbFactory)
{
public async Task CreateAsync(UserKey ownerUserKey, string name, CancellationToken cancellationToken = default)
{
+ await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
+
if (string.IsNullOrWhiteSpace(ownerUserKey.Value))
throw new ArgumentException("Owner user key is required.", nameof(ownerUserKey));
@@ -52,8 +54,55 @@ public async Task CreateAsync(UserKey ownerUserKey, string name, Cancel
return sandbox;
}
+ public async Task EnsureAsync(UserKey ownerUserKey, string name, CancellationToken cancellationToken = default)
+ {
+ await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
+
+ if (string.IsNullOrWhiteSpace(ownerUserKey.Value))
+ throw new ArgumentException("Owner user key is required.", nameof(ownerUserKey));
+
+ var existing = await db.Sandboxes
+ .Include(x => x.Settings)
+ .Include(x => x.Members)
+ .FirstOrDefaultAsync(
+ x => x.OwnerUserKey == ownerUserKey,
+ cancellationToken);
+
+ if (existing is not null)
+ {
+ var ownerMember = existing.Members
+ .FirstOrDefault(x => x.UserKey == ownerUserKey);
+
+ if (ownerMember is null)
+ {
+ existing.Members.Add(new SandboxMember
+ {
+ Id = Guid.NewGuid(),
+ UserKey = ownerUserKey,
+ Role = SandboxMemberRole.Owner
+ });
+
+ await db.SaveChangesAsync(cancellationToken);
+ }
+ else if (ownerMember.Role != SandboxMemberRole.Owner)
+ {
+ ownerMember.Role = SandboxMemberRole.Owner;
+ await db.SaveChangesAsync(cancellationToken);
+ }
+
+ return existing;
+ }
+
+ return await CreateAsync(
+ ownerUserKey,
+ name,
+ cancellationToken);
+ }
+
public async Task RenameAsync(UserKey ownerUserKey, string name, CancellationToken cancellationToken = default)
{
+ await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
+
if (string.IsNullOrWhiteSpace(ownerUserKey.Value))
throw new ArgumentException("Owner user key is required.", nameof(ownerUserKey));
@@ -80,17 +129,82 @@ public async Task RenameAsync(UserKey ownerUserKey, string name, Cancellat
return true;
}
- public Task GetByIdAsync(Guid sandboxId, CancellationToken cancellationToken = default)
+ public async Task AddMemberAsync(UserKey ownerUserKey, UserKey newUserKey, CancellationToken cancellationToken = default)
+ {
+ await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
+
+ if (string.IsNullOrWhiteSpace(ownerUserKey.Value))
+ throw new ArgumentException("Owner user key is required.", nameof(ownerUserKey));
+
+ if (string.IsNullOrWhiteSpace(newUserKey.Value))
+ throw new ArgumentException("Member user key is required.", nameof(newUserKey));
+
+ if (ownerUserKey == newUserKey)
+ throw new InvalidOperationException("Sandbox owner cannot be added as a regular member.");
+
+ await using var transaction =
+ await db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, cancellationToken);
+
+ var sandbox = await db.Sandboxes
+ .Include(x => x.Settings)
+ .FirstOrDefaultAsync(x => x.OwnerUserKey == ownerUserKey, cancellationToken);
+
+ if (sandbox is null)
+ throw new InvalidOperationException("Sandbox not found.");
+
+ if (sandbox.Status != SandboxStatus.Active)
+ throw new InvalidOperationException("Sandbox is not active.");
+
+ var existing = await db.SandboxMembers.FirstOrDefaultAsync(x => x.UserKey == newUserKey, cancellationToken);
+
+ if (existing is not null)
+ {
+ if (existing.SandboxId != sandbox.Id || existing.Role != SandboxMemberRole.Member)
+ {
+ throw new InvalidOperationException("User already belongs to another sandbox or has an invalid membership.");
+ }
+
+ await transaction.CommitAsync(cancellationToken);
+ return existing;
+ }
+
+ var memberCount = await db.SandboxMembers.CountAsync(x => x.SandboxId == sandbox.Id, cancellationToken);
+
+ if (memberCount >= sandbox.Settings.MaxUsers)
+ throw new InvalidOperationException("Sandbox user limit reached.");
+
+ var member = new SandboxMember
+ {
+ Id = Guid.NewGuid(),
+ SandboxId = sandbox.Id,
+ UserKey = newUserKey,
+ Role = SandboxMemberRole.Member,
+ JoinedAt = DateTimeOffset.UtcNow
+ };
+
+ db.SandboxMembers.Add(member);
+
+ await db.SaveChangesAsync(cancellationToken);
+ await transaction.CommitAsync(cancellationToken);
+
+ return member;
+ }
+
+ public async Task GetByIdAsync(Guid sandboxId, CancellationToken cancellationToken = default)
{
- return db.Sandboxes
+ await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
+
+ return await db.Sandboxes
.AsNoTracking()
.Include(x => x.Settings)
.FirstOrDefaultAsync(x => x.Id == sandboxId, cancellationToken);
}
- public Task GetByUserAsync(UserKey userKey, CancellationToken cancellationToken = default)
+ public async Task GetByUserAsync(UserKey userKey, CancellationToken cancellationToken = default)
{
- return db.Sandboxes
+ await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
+
+ return await db.Sandboxes
.AsNoTracking()
.Include(x => x.Settings)
.Where(x => x.Members.Any(m => m.UserKey == userKey))
@@ -106,9 +220,11 @@ public async Task GetAccessAsync(UserKey userKey, CancellationTok
: SandboxAccessPolicy.Evaluate(sandbox.Status);
}
- public Task> GetMembersAsync(Guid sandboxId, CancellationToken cancellationToken = default)
+ public async Task> GetMembersAsync(Guid sandboxId, CancellationToken cancellationToken = default)
{
- return db.SandboxMembers
+ await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
+
+ return await db.SandboxMembers
.AsNoTracking()
.Where(x => x.SandboxId == sandboxId)
.OrderBy(x => x.JoinedAt)
@@ -117,6 +233,8 @@ public Task> GetMembersAsync(Guid sandboxId, CancellationTok
public async Task GetRemainingCapacityAsync(Guid sandboxId, CancellationToken cancellationToken = default)
{
+ await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
+
var settings = await db.SandboxSettings
.AsNoTracking()
.FirstOrDefaultAsync(
From 610f7bcf1cc72a7807112a4d33c198bd23f77f23 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?=
Date: Fri, 23 Oct 2026 15:45:47 +0300
Subject: [PATCH 05/10] Users Page
---
.../Components/Layout/DashboardLayout.razor | 13 ++
.../Pages/Dashboard/UsersPage.razor | 201 +++++++++++-------
.../Contracts/SandboxUserItem.cs | 19 ++
.../Contracts/SandboxUserSnapshot.cs | 8 +
... 20261023101029_InitialCreate.Designer.cs} | 8 +-
...Lab.cs => 20261023101029_InitialCreate.cs} | 9 +-
.../LabDb/LabDbContextModelSnapshot.cs | 4 +
.../Program.cs | 1 +
.../Seed/LabAdminSeedContributor.cs | 18 +-
.../Services/SandboxUserService.cs | 112 ++++++++++
10 files changed, 314 insertions(+), 79 deletions(-)
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/SandboxUserItem.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/SandboxUserSnapshot.cs
rename apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/LabDb/{20261022063638_InitIdentityLab.Designer.cs => 20261023101029_InitialCreate.Designer.cs} (95%)
rename apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/LabDb/{20261022063638_InitIdentityLab.cs => 20261023101029_InitialCreate.cs} (93%)
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxUserService.cs
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 7ba0f9ef..5ecacadd 100644
--- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor
@@ -221,6 +221,19 @@
}
+ else if (AuthState?.Identity?.UserStatus == UserStatus.Suspended || AuthState?.Identity?.UserStatus == UserStatus.Locked)
+ {
+
+
+ Your account has been suspended
+
+
+ Your account has been suspended/locked by an administrator.
+ Some actions are unavailable.
+ Please contact your system administrator to restore access.
+
+
+ }
@Body
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 79bbd06c..104528bf 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
@@ -1,10 +1,14 @@
@page "/dashboard/users"
+@using CodeBeam.UltimateAuth.Core.Contracts
+@using CodeBeam.UltimateAuth.Core.Domain
+@using CodeBeam.UltimateAuth.IdentityLab.Contracts
+@using CodeBeam.UltimateAuth.IdentityLab.Services
+@using CodeBeam.UltimateAuth.Users.Contracts
@layout DashboardLayout
+@inject SandboxUserService SandboxUserService
@inject IDialogService DialogService
-@* TODO: Enable sandbox-scoped authorization.
@attribute [UAuthAuthorize]
-*@
Users · Identity Lab
@@ -119,12 +123,20 @@
-
+ @_error
+
+ }
+
+
@@ -138,8 +150,7 @@
-
+
@context.Initials
@@ -179,29 +190,17 @@
-
-
+
+
-
+ Disabled="@context.IsOwner"
+ OnClick="@(() => OpenUserDetailsAsync(context))" />
-
-
-
- @if (!context.IsOwner)
- {
-
-
-
- }
@@ -252,22 +251,113 @@
@code {
- private const int MaxUsers = 4;
+ [CascadingParameter]
+ private UAuthState? AuthState { get; set; }
+
+ private SandboxUsersSnapshot? _snapshot;
+ private List _users = [];
+
+ private UserKey? _loadedUserKey;
+ private bool _loading;
+ private string? _error;
+
+ private int MaxUsers => _snapshot?.MaxUsers ?? 0;
+
+ private double CapacityPercentage => _snapshot?.CapacityPercentage ?? 0;
+
+ protected override async Task OnParametersSetAsync()
+ {
+ var userKey = AuthState?.Identity?.UserKey;
+
+ if (userKey is null)
+ {
+ _loadedUserKey = null;
+ _snapshot = null;
+ _users.Clear();
+ return;
+ }
+
+ if (_loadedUserKey == userKey)
+ return;
+
+ await LoadUsersAsync(userKey.Value);
+ }
+
+ private async Task LoadUsersAsync(UserKey userKey)
+ {
+ _loading = true;
+ _error = null;
+
+ try
+ {
+ var snapshot = await SandboxUserService.GetUsersAsync(
+ userKey);
+
+ _snapshot = snapshot;
+ _users = snapshot?.Users.ToList() ?? [];
+
+ _loadedUserKey = userKey;
+ }
+ catch (Exception)
+ {
+ _error = "Unable to load sandbox users.";
+ _snapshot = null;
+ _users.Clear();
+ }
+ finally
+ {
+ _loading = false;
+ }
+ }
private string _search = string.Empty;
private string _statusFilter = "all";
- private double CapacityPercentage => (double)_users.Count / MaxUsers * 100;
-
private async Task OpenCreateUserDialogAsync()
{
var parameters = new DialogParameters
{
- { x => x.PreviewMode, true }
+ { x => x.PreviewMode, false },
+ { x => x.OnCreateUserAsync, CreateSandboxUserAsync }
+ };
+
+ var dialog = await DialogService.ShowAsync(
+ "Create User",
+ parameters,
+ UAuthDialog.GetDialogOptions(MaxWidth.Small));
+
+ var result = await dialog.Result;
+
+ if (result?.Canceled == false)
+ {
+ var userKey = AuthState?.Identity?.UserKey;
+
+ if (userKey is not null)
+ await LoadUsersAsync(userKey.Value);
+ }
+ }
+
+ private async Task OpenUserDetailsAsync(SandboxUserItem user)
+ {
+ if (user.IsOwner)
+ return;
+
+ var parameters = new DialogParameters
+ {
+ { x => x.AuthState, AuthState },
+ { x => x.UserKey, user.UserKey },
+ { x => x.PreviewMode, false }
};
- await DialogService.ShowAsync("Create User", parameters, UAuthDialog.GetDialogOptions(MaxWidth.Small));
+ var dialog = await DialogService.ShowAsync("Manage User", parameters, UAuthDialog.GetDialogOptions(MaxWidth.Large));
+
+ await dialog.Result;
+
+ var actorUserKey = AuthState?.Identity?.UserKey;
+
+ if (actorUserKey is not null)
+ await LoadUsersAsync(actorUserKey.Value);
}
private async Task OpenUserProfileAsync(string displayName)
@@ -285,7 +375,7 @@
}
- private IEnumerable FilteredUsers =>
+ private IEnumerable FilteredUsers =>
_users.Where(user =>
{
var matchesSearch =
@@ -304,52 +394,13 @@
return matchesSearch && matchesStatus;
});
- private readonly List _users =
- [
- new(
- "Alex Morgan",
- "alex",
- "alex@example.com",
- true,
- UserStatus.Active,
- new DateTime(2026, 10, 18)
- ),
- new(
- "Taylor Reed",
- "taylor",
- "taylor@example.com",
- false,
- UserStatus.Active,
- new DateTime(2026, 10, 19)
- ),
- new(
- "Jordan Lee",
- "jordan",
- "jordan@example.com",
- false,
- UserStatus.Disabled,
- new DateTime(2026, 10, 20)
- )
- ];
-
- private enum UserStatus
+ private async Task CreateSandboxUserAsync(CreateUserRequest request)
{
- Active,
- Disabled
- }
+ var actorUserKey = AuthState?.Identity?.UserKey;
- private sealed record SandboxUserPreview(
- string DisplayName,
- string Username,
- string Email,
- bool IsOwner,
- UserStatus Status,
- DateTime CreatedAt)
- {
- public string Initials =>
- string.Concat(
- DisplayName.Split(' ', StringSplitOptions.RemoveEmptyEntries)
- .Take(2)
- .Select(x => char.ToUpperInvariant(x[0])));
+ if (actorUserKey is null)
+ return CreateUserDialogResult.Failure("You must be signed in.");
+
+ return await SandboxUserService.CreateAsync(actorUserKey.Value, request);
}
}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/SandboxUserItem.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/SandboxUserItem.cs
new file mode 100644
index 00000000..8eef5b1b
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/SandboxUserItem.cs
@@ -0,0 +1,19 @@
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Domain;
+
+namespace CodeBeam.UltimateAuth.IdentityLab.Contracts;
+
+public sealed record SandboxUserItem(
+ UserKey UserKey,
+ string DisplayName,
+ string Username,
+ string Email,
+ bool IsOwner,
+ UserStatus Status,
+ DateTimeOffset CreatedAt)
+{
+ public string Initials => string.Concat(DisplayName
+ .Split(' ', StringSplitOptions.RemoveEmptyEntries)
+ .Take(2)
+ .Select(x => char.ToUpperInvariant(x[0])));
+}
\ No newline at end of file
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/SandboxUserSnapshot.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/SandboxUserSnapshot.cs
new file mode 100644
index 00000000..1889436a
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/SandboxUserSnapshot.cs
@@ -0,0 +1,8 @@
+namespace CodeBeam.UltimateAuth.IdentityLab.Contracts;
+
+public sealed record SandboxUsersSnapshot(Guid SandboxId, int MaxUsers, IReadOnlyList Users)
+{
+ public int MemberCount => Users.Count;
+ public int RemainingCapacity => Math.Max(0, MaxUsers - MemberCount);
+ public double CapacityPercentage => MaxUsers <= 0 ? 0 : Math.Min(100, (double)MemberCount / MaxUsers * 100);
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/LabDb/20261022063638_InitIdentityLab.Designer.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/LabDb/20261023101029_InitialCreate.Designer.cs
similarity index 95%
rename from apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/LabDb/20261022063638_InitIdentityLab.Designer.cs
rename to apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/LabDb/20261023101029_InitialCreate.Designer.cs
index cabc7d9c..869608bd 100644
--- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/LabDb/20261022063638_InitIdentityLab.Designer.cs
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/LabDb/20261023101029_InitialCreate.Designer.cs
@@ -12,8 +12,8 @@
namespace CodeBeam.UltimateAuth.IdentityLab.Migrations.LabDb
{
[DbContext(typeof(LabDbContext))]
- [Migration("20261022063638_InitIdentityLab")]
- partial class InitIdentityLab
+ [Migration("20261023101029_InitialCreate")]
+ partial class InitialCreate
{
///
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -81,6 +81,10 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder)
b.HasKey("Id");
+ b.HasIndex("SandboxId")
+ .IsUnique()
+ .HasFilter("\"Role\" = 'Owner'");
+
b.HasIndex("UserKey")
.IsUnique();
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/LabDb/20261022063638_InitIdentityLab.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/LabDb/20261023101029_InitialCreate.cs
similarity index 93%
rename from apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/LabDb/20261022063638_InitIdentityLab.cs
rename to apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/LabDb/20261023101029_InitialCreate.cs
index 8b2a78e5..6b9f17c8 100644
--- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/LabDb/20261022063638_InitIdentityLab.cs
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/LabDb/20261023101029_InitialCreate.cs
@@ -6,7 +6,7 @@
namespace CodeBeam.UltimateAuth.IdentityLab.Migrations.LabDb
{
///
- public partial class InitIdentityLab : Migration
+ public partial class InitialCreate : Migration
{
///
protected override void Up(MigrationBuilder migrationBuilder)
@@ -72,6 +72,13 @@ protected override void Up(MigrationBuilder migrationBuilder)
column: "OwnerUserKey",
unique: true);
+ migrationBuilder.CreateIndex(
+ name: "IX_LabSandboxMembers_SandboxId",
+ table: "LabSandboxMembers",
+ column: "SandboxId",
+ unique: true,
+ filter: "\"Role\" = 'Owner'");
+
migrationBuilder.CreateIndex(
name: "IX_LabSandboxMembers_SandboxId_Role",
table: "LabSandboxMembers",
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/LabDb/LabDbContextModelSnapshot.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/LabDb/LabDbContextModelSnapshot.cs
index 467e0902..b2b15cba 100644
--- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/LabDb/LabDbContextModelSnapshot.cs
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/LabDb/LabDbContextModelSnapshot.cs
@@ -78,6 +78,10 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.HasKey("Id");
+ b.HasIndex("SandboxId")
+ .IsUnique()
+ .HasFilter("\"Role\" = 'Owner'");
+
b.HasIndex("UserKey")
.IsUnique();
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs
index 53f58d20..6285388d 100644
--- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs
@@ -83,6 +83,7 @@
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.Configure(builder.Configuration.GetSection(LabAdminOptions.SectionName));
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Seed/LabAdminSeedContributor.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Seed/LabAdminSeedContributor.cs
index b9a61742..47b354f4 100644
--- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Seed/LabAdminSeedContributor.cs
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Seed/LabAdminSeedContributor.cs
@@ -170,6 +170,18 @@ await store.AddAsync(
ct);
}
+ // TODO(Part 3 - Security):
+ // Owner wildcard permission is temporary and intended only
+ // for local Identity Lab happy-path testing.
+ //
+ // Replace wildcard access with resource-scoped authorization:
+ // - Validate sandbox membership and ownership.
+ // - Restrict user operations to the current sandbox.
+ // - Enforce sandbox scope at the UltimateAuth endpoint/request level.
+ // - Prevent cross-sandbox access through direct API calls.
+ // - Replace wildcard with explicit Owner permissions.
+ //
+ // SECURITY: Do not deploy publicly while this wildcard is active.
private async Task EnsureSystemRolesAsync(TenantKey tenant, CancellationToken ct)
{
var roleStore = roleStoreFactory.Create(tenant);
@@ -181,11 +193,15 @@ private async Task EnsureSystemRolesAsync(TenantKey tenant, CancellationToken ct
if (existing is not null)
continue;
+ var permissions = roleName is LabRoles.Admin or LabRoles.Owner
+ ? new HashSet { Permission.Wildcard }
+ : new HashSet();
+
var role = Role.Create(
RoleId.From(Guid.NewGuid()),
tenant,
roleName,
- new HashSet(),
+ permissions,
clock.UtcNow);
await roleStore.AddAsync(role, ct);
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxUserService.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxUserService.cs
new file mode 100644
index 00000000..57f24bb0
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxUserService.cs
@@ -0,0 +1,112 @@
+using CodeBeam.UltimateAuth.Authorization;
+using CodeBeam.UltimateAuth.Client;
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+using CodeBeam.UltimateAuth.IdentityLab.Contracts;
+using CodeBeam.UltimateAuth.IdentityLab.Domain;
+using CodeBeam.UltimateAuth.Sample.Shared.Components.Dialogs;
+using CodeBeam.UltimateAuth.Users.Contracts;
+
+namespace CodeBeam.UltimateAuth.IdentityLab.Services;
+
+public sealed class SandboxUserService(SandboxService sandboxService, SandboxAccessService accessService, IUAuthClient uAuthClient, IRoleStoreFactory roleStoreFactory, IUserRoleStoreFactory userRoleStoreFactory)
+{
+ public async Task GetUsersAsync(UserKey actorUserKey, CancellationToken ct = default)
+ {
+ var sandbox = await sandboxService.GetByUserAsync(actorUserKey, ct);
+
+ if (sandbox is null)
+ return null;
+
+ var access = await accessService.CanReadAsync(actorUserKey, sandbox.Id, ct);
+
+ if (!access.Allowed)
+ throw new UnauthorizedAccessException();
+
+ var members = await sandboxService.GetMembersAsync(sandbox.Id, ct);
+ var users = new List();
+
+ foreach (var member in members)
+ {
+ var result = await uAuthClient.Users.GetUserAsync(member.UserKey);
+
+ if (!result.IsSuccess || result.Value is null)
+ throw new InvalidOperationException($"Unable to load user {member.UserKey.Value}.");
+
+ var user = result.Value;
+
+ users.Add(new SandboxUserItem(
+ UserKey: member.UserKey,
+ DisplayName: user.DisplayName
+ ?? user.UserName
+ ?? "Unknown user",
+ Username: user.UserName ?? "",
+ Email: user.PrimaryEmail ?? "",
+ IsOwner: member.Role == SandboxMemberRole.Owner,
+ Status: user.Status,
+ CreatedAt: user.CreatedAt ?? member.JoinedAt
+ ));
+ }
+
+ return new SandboxUsersSnapshot(sandbox.Id, sandbox.Settings.MaxUsers, users);
+ }
+
+ public async Task CreateAsync(UserKey actorUserKey, CreateUserRequest request, CancellationToken ct = default)
+ {
+ var sandbox = await sandboxService.GetByUserAsync(actorUserKey, ct);
+
+ if (sandbox is null)
+ return CreateUserDialogResult.Failure("Sandbox not found.");
+
+ var access = await accessService.CanManageAsync(actorUserKey, sandbox.Id, ct);
+
+ if (!access.Allowed)
+ return CreateUserDialogResult.Failure("You are not allowed to manage this sandbox.");
+
+ var remaining = await sandboxService.GetRemainingCapacityAsync(sandbox.Id, ct);
+
+ if (remaining <= 0)
+ return CreateUserDialogResult.Failure("Sandbox user limit reached.");
+
+ var result = await uAuthClient.Users.CreateAsAdminAsync(request);
+
+ if (!result.IsSuccess || result.Value is null || result.Value.UserKey is null)
+ return CreateUserDialogResult.Failure(result.ErrorText ?? "User creation failed.");
+
+ var newUserKey = UserKey.FromString(result.Value.UserKey);
+
+ try
+ {
+ await AssignUserRoleAsync(newUserKey, ct);
+ await sandboxService.AddMemberAsync(actorUserKey, newUserKey, ct);
+ }
+ catch (Exception)
+ {
+ return CreateUserDialogResult.Failure(
+ "User was created, but sandbox membership failed. " +
+ "Manual repair is required.");
+ }
+
+ return CreateUserDialogResult.Success();
+ }
+
+ private async Task AssignUserRoleAsync(UserKey userKey, CancellationToken ct)
+ {
+ var tenant = TenantKeys.Single;
+
+ var roleStore = roleStoreFactory.Create(tenant);
+ var userRoleStore = userRoleStoreFactory.Create(tenant);
+
+ var role = await roleStore.GetByNameAsync("USER", ct);
+
+ if (role is null)
+ throw new InvalidOperationException("Identity Lab User role is not configured.");
+
+ var assignments = await userRoleStore.GetAssignmentsAsync(userKey, ct);
+
+ if (assignments.Any(x => x.RoleId == role.Id))
+ return;
+
+ await userRoleStore.AssignAsync(userKey, role.Id, DateTimeOffset.UtcNow, ct);
+ }
+}
From 1715543da7c5a94e681d8a7b9d7757afdd802967 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?=
Date: Fri, 23 Oct 2026 21:21:00 +0300
Subject: [PATCH 06/10] Policy Base and User Page
---
.../Components/Layout/DashboardLayout.razor | 10 +-
.../Components/Pages/AdminPage.razor | 350 ++++++++++++++++++
.../Components/Pages/AdminTest.razor | 38 --
.../Pages/Dashboard/MySessionsPage.razor | 2 -
.../Pages/Dashboard/UsersPage.razor | 49 +--
.../Pages/Dialogs/ManageSandboxDialog.razor | 258 +++++++++++++
.../Contracts/AdminDashboardSnapshot.cs | 9 +
.../Contracts/AdminSandboxItem.cs | 17 +
.../Contracts/AdminSandboxMemberItem.cs | 13 +
.../Contracts/AdminSandboxPage.cs | 7 +
.../Contracts/AdminSandboxQuery.cs | 7 +
.../Contracts/LabRoleIds.cs | 15 +
.../Contracts/UpdateSandboxRequest.cs | 5 +
.../Domain/SandboxLimits.cs | 7 +
.../Domain/SandboxSettings.cs | 2 +-
.../Policy/LabApplicationPolicy.cs | 54 +++
.../Policy/SandboxUAuthAccessPolicy.cs | 218 +++++++++++
.../Program.cs | 6 +
.../Seed/LabAdminSeedContributor.cs | 48 ++-
.../Services/ILabRootResolver.cs | 8 +
.../Services/LabAdminService.cs | 236 ++++++++++++
.../Services/LabRegistrationService.cs | 1 -
.../Services/LabRootResolver.cs | 25 ++
.../Services/SandboxService.cs | 15 +-
24 files changed, 1288 insertions(+), 112 deletions(-)
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AdminPage.razor
delete mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AdminTest.razor
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dialogs/ManageSandboxDialog.razor
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/AdminDashboardSnapshot.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/AdminSandboxItem.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/AdminSandboxMemberItem.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/AdminSandboxPage.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/AdminSandboxQuery.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/LabRoleIds.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/UpdateSandboxRequest.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Domain/SandboxLimits.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Policy/LabApplicationPolicy.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Policy/SandboxUAuthAccessPolicy.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/ILabRootResolver.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabAdminService.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRootResolver.cs
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 5ecacadd..0e97c438 100644
--- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor
@@ -133,9 +133,13 @@
WORKSPACE
-
+
+
+ Platform Administration
+
+
+
+
Overview
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AdminPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AdminPage.razor
new file mode 100644
index 00000000..cb5d3771
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AdminPage.razor
@@ -0,0 +1,350 @@
+@page "/admin"
+@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
+
+Administration · Identity Lab
+
+
+
+
+
+
+
+ PLATFORM ADMINISTRATION
+
+
+
+ Sandbox Management
+
+
+
+ Monitor and manage all Identity Lab environments.
+
+
+
+ @if (_loading)
+ {
+
+ }
+ else if (!string.IsNullOrWhiteSpace(_error))
+ {
+
+ @_error
+
+ }
+ else if (_snapshot is not null)
+ {
+
+
+
+
+
+ Total Sandboxes
+
+
+ @_snapshot.TotalSandboxes
+
+
+
+
+
+
+
+ Active Sandboxes
+
+
+ @_snapshot.ActiveSandboxes
+
+
+
+
+
+
+
+ Suspended
+
+
+ @_snapshot.SuspendedSandboxes
+
+
+
+
+
+
+
+ Total Members
+
+
+ @_snapshot.TotalMembers
+
+
+
+
+
+
+
+
+
+
+
+
+
+ All Sandboxes
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ All statuses
+
+
+
+ Active
+
+
+
+ Passive
+
+
+
+ Suspended
+
+
+
+
+
+
+
+
+
+
+ Sandbox
+ Owner
+ Members
+ Status
+ Actions
+
+
+
+
+
+ @context.Name
+
+
+
+
+ @context.OwnerName
+
+
+
+ @context.MemberCount / @context.MaxUsers
+
+
+
+
+ @context.Status
+
+
+
+
+
+
+
+
+
+
+
+
+ No sandboxes found.
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+
+
+
+
+
+@code {
+ [CascadingParameter]
+ private UAuthState? AuthState { get; set; }
+
+ private MudTable? _table;
+ private AdminDashboardSnapshot? _snapshot;
+ private UserKey? _loadedUserKey;
+ private bool _loading;
+ private string? _error;
+
+ private string _search = string.Empty;
+ private string _statusFilter = "all";
+
+ protected override async Task OnParametersSetAsync()
+ {
+ var userKey = AuthState?.Identity?.UserKey;
+
+ if (userKey is null)
+ {
+ _loadedUserKey = null;
+ _snapshot = null;
+ return;
+ }
+
+ if (_loadedUserKey == userKey)
+ return;
+
+ await LoadAsync();
+ }
+
+ private async Task LoadAsync()
+ {
+ var userKey = AuthState?.Identity?.UserKey;
+
+ if (userKey is null)
+ return;
+
+ _loading = true;
+ _error = null;
+
+ try
+ {
+ _snapshot = await AdminService.GetStatisticsAsync(userKey.Value);
+ _loadedUserKey = userKey;
+ }
+ catch (UnauthorizedAccessException)
+ {
+ _snapshot = null;
+ _error = "Platform administrator access is required.";
+ }
+ catch (Exception)
+ {
+ _snapshot = null;
+ _error = "Unable to load platform administration data.";
+ }
+ finally
+ {
+ _loading = false;
+ }
+ }
+
+ private async Task> LoadSandboxTableAsync(TableState state, CancellationToken ct)
+ {
+ var actor = AuthState?.Identity?.UserKey;
+
+ if (actor is null)
+ {
+ return new TableData
+ {
+ Items = [],
+ TotalItems = 0
+ };
+ }
+
+ var result = await AdminService.QuerySandboxesAsync(actor.Value,
+ new AdminSandboxQuery(
+ _search,
+ _statusFilter == "all" ? null : _statusFilter,
+ state.Page,
+ state.PageSize),
+ ct);
+
+ return new TableData
+ {
+ Items = result.Items,
+ TotalItems = result.TotalCount
+ };
+ }
+
+ private async Task RefreshTableAsync()
+ {
+ if (_table is not null)
+ _table.NavigateTo(0);
+ }
+
+ private async Task OpenManageAsync(AdminSandboxItem sandbox)
+ {
+ var actorUserKey = AuthState?.Identity?.UserKey;
+
+ if (actorUserKey is null)
+ return;
+
+ var parameters = new DialogParameters
+ {
+ { x => x.Sandbox, sandbox },
+ { x => x.ActorUserKey, actorUserKey.Value }
+ };
+
+ var dialog = await DialogService
+ .ShowAsync(
+ "Manage Sandbox",
+ parameters,
+ UAuthDialog.GetDialogOptions(MaxWidth.Medium));
+
+ var result = await dialog.Result;
+
+ if (result?.Canceled == false)
+ await LoadAsync();
+ }
+
+ private static Color GetStatusColor(SandboxStatus status) =>
+ status switch
+ {
+ SandboxStatus.Active => Color.Success,
+ SandboxStatus.Passive => Color.Default,
+ SandboxStatus.Suspended => Color.Warning,
+ _ => Color.Default
+ };
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AdminTest.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AdminTest.razor
deleted file mode 100644
index b55e0671..00000000
--- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AdminTest.razor
+++ /dev/null
@@ -1,38 +0,0 @@
-@page "/admin/test"
-@rendermode InteractiveServer
-
-@using CodeBeam.UltimateAuth.IdentityLab.Contracts
-@using CodeBeam.UltimateAuth.IdentityLab.Services
-
-@inject LabApiClient Api
-
-
- Load Statistics
-
-
-@_message
-
-@code {
- private string _message = "Not loaded";
-
- private async Task LoadAsync()
- {
- try
- {
- var response = await Api.GetAsync(
- "/api/lab/admin/statistics");
-
- _message = response.Status switch
- {
- 200 => $"Total sandboxes: {response.Body?.Total}",
- 401 => "Authentication required",
- 403 => "LabAdmin permission required",
- _ => $"HTTP {response.Status}"
- };
- }
- catch (Exception ex)
- {
- _message = ex.Message;
- }
- }
-}
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 fd77d9db..c044aabf 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
@@ -2,9 +2,7 @@
@layout DashboardLayout
@inject IDialogService DialogService
-@* TODO: Enable after authentication integration.
@attribute [UAuthAuthorize]
-*@
My Sessions · Identity Lab
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 104528bf..6af62350 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
@@ -8,7 +8,7 @@
@inject SandboxUserService SandboxUserService
@inject IDialogService DialogService
-@attribute [UAuthAuthorize]
+@attribute [UAuthAuthorize(Roles = "Ladmin,Owner")]
Users · Identity Lab
@@ -19,16 +19,10 @@
SANDBOX MANAGEMENT
Users
-
- Manage identities, account status, and
- membership within your sandbox.
-
+
Manage identities, account status, and membership within your sandbox.
-
+
Create User
@@ -41,25 +35,11 @@
Sandbox Capacity
@_users.Count / @MaxUsers users
-
-
- Demo Preview
-
-
+
-
- @_users.Count of @MaxUsers available user slots are
- occupied, including the sandbox owner.
-
+ @_users.Count of @MaxUsers available user slots are occupied, including the sandbox owner.
@@ -68,7 +48,7 @@
Total Users
@_users.Count
- Example sandbox members
+ Sandbox members
@@ -77,7 +57,7 @@
Active Users
@_users.Count(x => x.Status == UserStatus.Active)
- Example active accounts
+ Active accounts
@@ -360,21 +340,6 @@
await LoadUsersAsync(actorUserKey.Value);
}
- private async Task OpenUserProfileAsync(string displayName)
- {
- var parameters = new DialogParameters
- {
- { x => x.PreviewMode, true },
- { x => x.PreviewDisplayName, displayName }
- };
-
- await DialogService.ShowAsync(
- "Edit Profile",
- parameters,
- UAuthDialog.GetDialogOptions(MaxWidth.Medium));
- }
-
-
private IEnumerable FilteredUsers =>
_users.Where(user =>
{
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dialogs/ManageSandboxDialog.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dialogs/ManageSandboxDialog.razor
new file mode 100644
index 00000000..791f9236
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dialogs/ManageSandboxDialog.razor
@@ -0,0 +1,258 @@
+@using CodeBeam.UltimateAuth.Core.Contracts
+@using CodeBeam.UltimateAuth.Core.Domain
+@using CodeBeam.UltimateAuth.IdentityLab.Contracts
+@using CodeBeam.UltimateAuth.IdentityLab.Domain
+@using CodeBeam.UltimateAuth.IdentityLab.Services
+
+@inject LabAdminService AdminService
+@inject IDialogService DialogService
+@inject ISnackbar Snackbar
+
+
+
+
+
+
+
+ Manage Sandbox
+
+
+
+ Update sandbox settings and inspect members.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Active
+
+
+
+ Passive
+
+
+
+ Suspended
+
+
+
+
+ @if (_status != SandboxStatus.Active)
+ {
+
+ Changing the sandbox status may restrict
+ member access. User account statuses will
+ not be changed.
+
+ }
+
+
+
+
+
+
+
+
+
+ User
+ Role
+ Status
+ Actions
+
+
+
+
+
+ @context.DisplayName
+
+
+ @context.Email
+
+
+
+
+ @context.Role
+
+
+
+
+ @context.Status
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ @if (!string.IsNullOrWhiteSpace(_error))
+ {
+
+ @_error
+
+ }
+
+
+
+
+
+
+ Cancel
+
+
+
+ Save Changes
+
+
+
+
+@code {
+ [CascadingParameter]
+ private IMudDialogInstance MudDialog { get; set; } = default!;
+
+ [CascadingParameter]
+ private UAuthState AuthState { get; set; } = default!;
+
+ [Parameter, EditorRequired]
+ public AdminSandboxItem Sandbox { get; set; } = default!;
+
+ [Parameter, EditorRequired]
+ public UserKey ActorUserKey { get; set; }
+
+ private string _name = string.Empty;
+ private int _maxUsers;
+ private SandboxStatus _status;
+
+ private List _members = [];
+
+ private bool _saving;
+ private string? _error;
+
+ protected override async Task OnInitializedAsync()
+ {
+ _name = Sandbox.Name;
+ _maxUsers = Sandbox.MaxUsers;
+ _status = Sandbox.Status;
+
+ await LoadMembersAsync();
+ }
+
+ private async Task LoadMembersAsync()
+ {
+ try
+ {
+ _members = (await AdminService.GetMemberDetailsAsync(ActorUserKey, Sandbox.SandboxId)).ToList();
+ }
+ catch (Exception)
+ {
+ _error = "Unable to load sandbox members.";
+ }
+
+ }
+
+ private async Task ManageMemberAsync(AdminSandboxMemberItem item)
+ {
+ var parameters = new DialogParameters
+ {
+ { x => x.AuthState, AuthState },
+ { x => x.UserKey, item.UserKey },
+ { x => x.PreviewMode, false }
+ };
+
+ var dialog = await DialogService.ShowAsync("Manage User", parameters, UAuthDialog.GetDialogOptions(MaxWidth.Large));
+
+ await dialog.Result;
+
+ var actorUserKey = AuthState?.Identity?.UserKey;
+
+ if (actorUserKey is not null)
+ await LoadMembersAsync();
+ }
+
+ private async Task SaveAsync()
+ {
+ if (_saving)
+ return;
+
+ _saving = true;
+ _error = null;
+
+ try
+ {
+ await AdminService.UpdateSandboxAsync(
+ ActorUserKey,
+ Sandbox.SandboxId,
+ new UpdateSandboxRequest(
+ _name,
+ _maxUsers,
+ _status));
+
+ Snackbar.Add(
+ "Sandbox updated successfully.",
+ Severity.Success);
+
+ MudDialog.Close(DialogResult.Ok(true));
+ }
+ catch (Exception ex)
+ {
+ _error = ex.Message;
+ }
+ finally
+ {
+ _saving = false;
+ }
+ }
+
+ private void Cancel() => MudDialog.Cancel();
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/AdminDashboardSnapshot.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/AdminDashboardSnapshot.cs
new file mode 100644
index 00000000..5abbc45b
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/AdminDashboardSnapshot.cs
@@ -0,0 +1,9 @@
+using CodeBeam.UltimateAuth.IdentityLab.Domain;
+
+namespace CodeBeam.UltimateAuth.IdentityLab.Contracts;
+
+public sealed record AdminDashboardSnapshot(
+ int TotalSandboxes,
+ int ActiveSandboxes,
+ int SuspendedSandboxes,
+ int TotalMembers);
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/AdminSandboxItem.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/AdminSandboxItem.cs
new file mode 100644
index 00000000..60740be6
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/AdminSandboxItem.cs
@@ -0,0 +1,17 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.IdentityLab.Domain;
+
+namespace CodeBeam.UltimateAuth.IdentityLab.Contracts;
+
+public sealed record AdminSandboxItem(
+ Guid SandboxId,
+ string Name,
+ UserKey OwnerUserKey,
+ string OwnerName,
+ string OwnerEmail,
+ SandboxStatus Status,
+ int MemberCount,
+ int MaxUsers)
+{
+ public int RemainingCapacity => Math.Max(0, MaxUsers - MemberCount);
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/AdminSandboxMemberItem.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/AdminSandboxMemberItem.cs
new file mode 100644
index 00000000..e3bae508
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/AdminSandboxMemberItem.cs
@@ -0,0 +1,13 @@
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.IdentityLab.Domain;
+
+namespace CodeBeam.UltimateAuth.IdentityLab.Contracts;
+
+public sealed record AdminSandboxMemberItem(
+ UserKey UserKey,
+ string DisplayName,
+ string Email,
+ SandboxMemberRole Role,
+ UserStatus Status,
+ DateTimeOffset JoinedAt);
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/AdminSandboxPage.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/AdminSandboxPage.cs
new file mode 100644
index 00000000..73372403
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/AdminSandboxPage.cs
@@ -0,0 +1,7 @@
+namespace CodeBeam.UltimateAuth.IdentityLab.Contracts;
+
+public sealed record AdminSandboxPage(
+ IReadOnlyList Items,
+ int TotalCount,
+ int Page,
+ int PageSize);
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/AdminSandboxQuery.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/AdminSandboxQuery.cs
new file mode 100644
index 00000000..c85bff85
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/AdminSandboxQuery.cs
@@ -0,0 +1,7 @@
+namespace CodeBeam.UltimateAuth.IdentityLab.Contracts;
+
+public sealed record AdminSandboxQuery(
+ string? Search = null,
+ string? Status = null,
+ int Page = 0,
+ int PageSize = 10);
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/LabRoleIds.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/LabRoleIds.cs
new file mode 100644
index 00000000..724f635e
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/LabRoleIds.cs
@@ -0,0 +1,15 @@
+using CodeBeam.UltimateAuth.Authorization.Contracts;
+
+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 Owner = RoleId.From(
+ Guid.Parse("a91a7b31-1b50-4e91-8a71-98b2e6d0b002"));
+
+ public static readonly RoleId User = RoleId.From(
+ Guid.Parse("a91a7b31-1b50-4e91-8a71-98b2e6d0b003"));
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/UpdateSandboxRequest.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/UpdateSandboxRequest.cs
new file mode 100644
index 00000000..22aaf3a4
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Contracts/UpdateSandboxRequest.cs
@@ -0,0 +1,5 @@
+using CodeBeam.UltimateAuth.IdentityLab.Domain;
+
+namespace CodeBeam.UltimateAuth.IdentityLab.Contracts;
+
+public sealed record UpdateSandboxRequest(string Name, int MaxUsers, SandboxStatus Status);
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Domain/SandboxLimits.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Domain/SandboxLimits.cs
new file mode 100644
index 00000000..eeb95f1e
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Domain/SandboxLimits.cs
@@ -0,0 +1,7 @@
+namespace CodeBeam.UltimateAuth.IdentityLab.Domain;
+
+public static class SandboxLimits
+{
+ public const int DefaultMaxUsers = 5;
+ public const int AbsoluteMaxUsers = 20;
+}
\ No newline at end of file
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Domain/SandboxSettings.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Domain/SandboxSettings.cs
index 3cf3b088..af898ecf 100644
--- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Domain/SandboxSettings.cs
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Domain/SandboxSettings.cs
@@ -4,7 +4,7 @@ public sealed class SandboxSettings
{
public Guid SandboxId { get; set; }
- public int MaxUsers { get; set; } = 5;
+ public int MaxUsers { get; set; } = SandboxLimits.DefaultMaxUsers;
public Sandbox Sandbox { get; set; } = default!;
}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Policy/LabApplicationPolicy.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Policy/LabApplicationPolicy.cs
new file mode 100644
index 00000000..29b9edcc
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Policy/LabApplicationPolicy.cs
@@ -0,0 +1,54 @@
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Defaults;
+
+namespace CodeBeam.UltimateAuth.IdentityLab.Services;
+
+public sealed class LabApplicationPolicy(ILabRootResolver rootResolver) : IAccessPolicy
+{
+ public bool AppliesTo(AccessContext context)
+ => context.Action.StartsWith(
+ "authorization.roles.",
+ StringComparison.Ordinal);
+
+ public AccessDecision Decide(AccessContext context)
+ {
+ // Trusted internal operations.
+ if (context.IsSystemActor)
+ return AccessDecision.Allow();
+
+ if (!context.IsAuthenticated ||
+ context.ActorUserKey is null)
+ {
+ return AccessDecision.Deny("lab_authentication_required");
+ }
+
+ // Ladmin has unrestricted application-level access.
+ if (rootResolver.IsLadmin(context))
+ return AccessDecision.Allow();
+
+ // Role reading is available to authenticated users.
+ if (IsReadAction(context.Action))
+ return AccessDecision.Allow();
+
+ // TODO (v0.2.0):
+ // Support sandbox-scoped role assignment and removal.
+ //
+ // 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.
+
+ return AccessDecision.Deny("lab_role_management_restricted");
+ }
+
+ private static bool IsReadAction(string action)
+ => action is
+ UAuthActions.Authorization.Roles.GetSelf or
+ UAuthActions.Authorization.Roles.GetAdmin or
+ UAuthActions.Authorization.Roles.QueryAdmin;
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Policy/SandboxUAuthAccessPolicy.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Policy/SandboxUAuthAccessPolicy.cs
new file mode 100644
index 00000000..b698bea8
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Policy/SandboxUAuthAccessPolicy.cs
@@ -0,0 +1,218 @@
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Defaults;
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.IdentityLab.Domain;
+using CodeBeam.UltimateAuth.IdentityLab.Persistence;
+using CodeBeam.UltimateAuth.IdentityLab.Services;
+using Microsoft.EntityFrameworkCore;
+
+namespace CodeBeam.UltimateAuth.IdentityLab.Policy;
+
+public sealed class SandboxUAuthAccessPolicy(IDbContextFactory dbFactory, ILabRootResolver rootResolver) : IAccessPolicy
+{
+ public bool AppliesTo(AccessContext context)
+ => IsProtectedAction(context.Action);
+
+ public AccessDecision Decide(AccessContext context)
+ {
+ var action = context.Action;
+
+ // Authentication recovery and registration require
+ // their own trusted, target-aware protection.
+ if (action.EndsWith(".anonymous", StringComparison.Ordinal))
+ return AccessDecision.Allow();
+
+ // Preserve logout capability.
+ if (IsLogoutAction(action))
+ return AccessDecision.Allow();
+
+ if (context.IsSystemActor)
+ return AccessDecision.Allow();
+
+ if (!context.IsAuthenticated ||
+ context.ActorUserKey is not UserKey actorKey)
+ {
+ return AccessDecision.Deny("sandbox_actor_required");
+ }
+
+ // Ladmin is the application root administrator.
+ // Existing UltimateAuth policies still apply.
+ if (rootResolver.IsLadmin(context))
+ return AccessDecision.Allow();
+
+ using var db = dbFactory.CreateDbContext();
+
+ var actor = db.SandboxMembers
+ .AsNoTracking()
+ .Where(x => x.UserKey == actorKey)
+ .Select(x => new
+ {
+ x.SandboxId,
+ x.Role,
+ x.Sandbox.Status
+ })
+ .SingleOrDefault();
+
+ if (actor is null)
+ return AccessDecision.Deny("sandbox_membership_required");
+
+ if (actor.Status == SandboxStatus.Suspended)
+ return AccessDecision.Deny("sandbox_suspended");
+
+ var operation = ClassifyAction(action);
+
+ if (operation == SandboxOperation.Denied)
+ return AccessDecision.Deny("sandbox_operation_restricted");
+
+ if (actor.Status == SandboxStatus.Passive &&
+ operation != SandboxOperation.Read)
+ {
+ return AccessDecision.Deny("sandbox_read_only");
+ }
+
+ if (operation == SandboxOperation.Manage &&
+ actor.Role != SandboxMemberRole.Owner)
+ {
+ return AccessDecision.Deny("sandbox_owner_required");
+ }
+
+ // All target-based admin operations must remain
+ // inside the actor's sandbox.
+ if (RequiresTarget(action))
+ {
+ if (context.TargetUserKey is not UserKey targetKey)
+ return AccessDecision.Deny("sandbox_target_required");
+
+ var belongsToSandbox = db.SandboxMembers
+ .AsNoTracking()
+ .Any(x =>
+ x.UserKey == targetKey &&
+ x.SandboxId == actor.SandboxId);
+
+ if (!belongsToSandbox)
+ return AccessDecision.Deny("sandbox_target_out_of_scope");
+ }
+
+ return AccessDecision.Allow();
+ }
+
+ private static bool IsProtectedAction(string action)
+ => action.StartsWith("users.", StringComparison.Ordinal)
+ || action.StartsWith("credentials.", StringComparison.Ordinal)
+ || action.StartsWith("sessions.", StringComparison.Ordinal)
+ || action.StartsWith("authorization.", StringComparison.Ordinal)
+ || action.StartsWith("flows.", StringComparison.Ordinal);
+
+ private static bool IsLogoutAction(string action)
+ => action is
+ UAuthActions.Flows.LogoutSelf or
+ UAuthActions.Flows.LogoutDeviceSelf or
+ UAuthActions.Flows.LogoutOthersSelf or
+ UAuthActions.Flows.LogoutAllSelf;
+
+ private static bool RequiresTarget(string action)
+ {
+ if (!action.EndsWith(".admin", StringComparison.Ordinal))
+ return false;
+
+ return action.StartsWith("users.profile.", StringComparison.Ordinal)
+ || action.StartsWith("users.identifiers.", StringComparison.Ordinal)
+ || action.StartsWith("users.status.", StringComparison.Ordinal)
+ || action == UAuthActions.Users.DeleteAdmin
+ || action.StartsWith("credentials.", StringComparison.Ordinal)
+ || action.StartsWith("sessions.", StringComparison.Ordinal)
+ || action is
+ UAuthActions.Flows.LogoutDeviceAdmin or
+ UAuthActions.Flows.LogoutOthersAdmin or
+ UAuthActions.Flows.LogoutAllAdmin;
+ }
+
+ private static SandboxOperation ClassifyAction(string action)
+ {
+ // User creation requires a controlled sandbox
+ // creation workflow, including membership and capacity.
+ if (action is
+ UAuthActions.Users.CreateAdmin or
+ UAuthActions.Users.QueryAdmin)
+ {
+ return SandboxOperation.Denied;
+ }
+
+ // Global role definitions and assignments are
+ // handled separately by LabApplicationPolicy.
+ if (action.StartsWith(
+ "authorization.roles.",
+ StringComparison.Ordinal))
+ {
+ return action is
+ UAuthActions.Authorization.Roles.GetSelf or
+ UAuthActions.Authorization.Roles.GetAdmin or
+ UAuthActions.Authorization.Roles.QueryAdmin
+ ? SandboxOperation.Read
+ : SandboxOperation.Denied;
+ }
+
+ if (IsReadAction(action))
+ return SandboxOperation.Read;
+
+ if (IsAdminAction(action))
+ return SandboxOperation.Manage;
+
+ if (IsSelfWriteAction(action))
+ return SandboxOperation.Write;
+
+ return SandboxOperation.Denied;
+ }
+
+ private static bool IsReadAction(string action)
+ => action is
+ UAuthActions.UserProfiles.GetSelf or
+ UAuthActions.UserProfiles.GetAdmin or
+ UAuthActions.UserIdentifiers.GetSelf or
+ UAuthActions.UserIdentifiers.GetAdmin or
+ UAuthActions.Credentials.ListSelf or
+ UAuthActions.Credentials.ListAdmin or
+ UAuthActions.Sessions.GetChainSelf or
+ UAuthActions.Sessions.GetChainAdmin or
+ UAuthActions.Sessions.ListChainsSelf or
+ UAuthActions.Sessions.ListChainsAdmin;
+
+ private static bool IsAdminAction(string action)
+ {
+ // Only user-targeted administrative operations.
+ if (!RequiresTarget(action))
+ return false;
+
+ return action.EndsWith(".admin", StringComparison.Ordinal);
+ }
+
+ private static bool IsSelfWriteAction(string action)
+ => action is
+ UAuthActions.Users.DeleteSelf or
+ UAuthActions.Users.ChangeStatusSelf or
+ UAuthActions.UserProfiles.CreateSelf or
+ UAuthActions.UserProfiles.UpdateSelf or
+ UAuthActions.UserProfiles.DeleteSelf or
+ UAuthActions.UserIdentifiers.AddSelf or
+ UAuthActions.UserIdentifiers.UpdateSelf or
+ UAuthActions.UserIdentifiers.SetPrimarySelf or
+ UAuthActions.UserIdentifiers.UnsetPrimarySelf or
+ UAuthActions.UserIdentifiers.VerifySelf or
+ UAuthActions.UserIdentifiers.DeleteSelf or
+ UAuthActions.Credentials.AddSelf or
+ UAuthActions.Credentials.ChangeSelf or
+ UAuthActions.Credentials.RevokeSelf or
+ UAuthActions.Credentials.ActivateSelf or
+ UAuthActions.Sessions.RevokeChainSelf or
+ UAuthActions.Sessions.RevokeAllChainsSelf or
+ UAuthActions.Sessions.RevokeOtherChainsSelf;
+
+ private enum SandboxOperation
+ {
+ Denied,
+ Read,
+ Write,
+ Manage
+ }
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs
index 6285388d..fd792cba 100644
--- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs
@@ -9,6 +9,7 @@
using CodeBeam.UltimateAuth.IdentityLab.Components;
using CodeBeam.UltimateAuth.IdentityLab.Endpoints;
using CodeBeam.UltimateAuth.IdentityLab.Persistence;
+using CodeBeam.UltimateAuth.IdentityLab.Policy;
using CodeBeam.UltimateAuth.IdentityLab.Seed;
using CodeBeam.UltimateAuth.IdentityLab.Services;
using CodeBeam.UltimateAuth.Sample.Seed.Extensions;
@@ -78,6 +79,7 @@
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
@@ -85,9 +87,13 @@
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
builder.Services.Configure(builder.Configuration.GetSection(LabAdminOptions.SectionName));
+builder.Services.AddScoped();
+
builder.Services.AddScoped();
builder.Services.AddScoped();
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Seed/LabAdminSeedContributor.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Seed/LabAdminSeedContributor.cs
index 47b354f4..7b1858fc 100644
--- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Seed/LabAdminSeedContributor.cs
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Seed/LabAdminSeedContributor.cs
@@ -171,36 +171,48 @@ await store.AddAsync(
}
// TODO(Part 3 - Security):
- // Owner wildcard permission is temporary and intended only
- // for local Identity Lab happy-path testing.
- //
- // Replace wildcard access with resource-scoped authorization:
- // - Validate sandbox membership and ownership.
- // - Restrict user operations to the current sandbox.
- // - Enforce sandbox scope at the UltimateAuth endpoint/request level.
- // - Prevent cross-sandbox access through direct API calls.
- // - Replace wildcard with explicit Owner permissions.
- //
- // SECURITY: Do not deploy publicly while this wildcard is active.
+ // Owner wildcard permission is restricked with SandboxUAuthAccessPolicy, but not tested.
private async Task EnsureSystemRolesAsync(TenantKey tenant, CancellationToken ct)
{
var roleStore = roleStoreFactory.Create(tenant);
- foreach (var roleName in LabRoles.SystemRoles)
+ var roles = new[]
{
- var existing = await roleStore.GetByNameAsync(roleName.ToUpperInvariant(), ct);
+ (Name: LabRoles.Admin, Id: LabRoleIds.Admin),
+ (Name: LabRoles.Owner, Id: LabRoleIds.Owner),
+ (Name: LabRoles.User, Id: LabRoleIds.User)
+ };
+
+ foreach (var definition in roles)
+ {
+ var existing = await roleStore.GetByNameAsync(definition.Name.ToUpperInvariant(), ct);
if (existing is not null)
+ {
+ if (existing.Id != definition.Id)
+ {
+ throw new InvalidOperationException(
+ $"System role '{definition.Name}' has an unexpected ID.");
+ }
+
+ if (existing.IsDeleted)
+ {
+ throw new InvalidOperationException(
+ $"System role '{definition.Name}' is deleted.");
+ }
+
continue;
+ }
- var permissions = roleName is LabRoles.Admin or LabRoles.Owner
- ? new HashSet { Permission.Wildcard }
- : new HashSet();
+ var permissions = definition.Name is
+ LabRoles.Admin or LabRoles.Owner
+ ? new HashSet { Permission.Wildcard }
+ : new HashSet();
var role = Role.Create(
- RoleId.From(Guid.NewGuid()),
+ definition.Id,
tenant,
- roleName,
+ definition.Name,
permissions,
clock.UtcNow);
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/ILabRootResolver.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/ILabRootResolver.cs
new file mode 100644
index 00000000..557a7f3b
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/ILabRootResolver.cs
@@ -0,0 +1,8 @@
+using CodeBeam.UltimateAuth.Core.Contracts;
+
+namespace CodeBeam.UltimateAuth.IdentityLab.Services;
+
+public interface ILabRootResolver
+{
+ bool IsLadmin(AccessContext context);
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabAdminService.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabAdminService.cs
new file mode 100644
index 00000000..e20d670d
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabAdminService.cs
@@ -0,0 +1,236 @@
+using CodeBeam.UltimateAuth.Authorization;
+using CodeBeam.UltimateAuth.Client;
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+using CodeBeam.UltimateAuth.IdentityLab.Contracts;
+using CodeBeam.UltimateAuth.IdentityLab.Domain;
+using CodeBeam.UltimateAuth.IdentityLab.Persistence;
+using Microsoft.EntityFrameworkCore;
+
+namespace CodeBeam.UltimateAuth.IdentityLab.Services;
+
+public sealed class LabAdminService(IDbContextFactory dbFactory, IRoleStoreFactory roleStoreFactory,
+ IUserRoleStoreFactory userRoleStoreFactory, IUAuthClient uAuthClient)
+{
+ public async Task IsAdminAsync(UserKey actorUserKey, CancellationToken ct = default)
+ {
+ var tenant = TenantKeys.Single;
+
+ var assignments = await userRoleStoreFactory.Create(tenant).GetAssignmentsAsync(actorUserKey, ct);
+
+ if (assignments.Count == 0)
+ return false;
+
+ var roleIds = assignments
+ .Select(x => x.RoleId)
+ .Distinct()
+ .ToArray();
+
+ var roles = await roleStoreFactory
+ .Create(tenant)
+ .GetByIdsAsync(roleIds, ct);
+
+ return roles.Any(x =>
+ string.Equals(
+ x.Name,
+ "Ladmin",
+ StringComparison.OrdinalIgnoreCase));
+ }
+
+ private async Task RequireAdminAsync(UserKey actorUserKey, CancellationToken ct)
+ {
+ if (!await IsAdminAsync(actorUserKey, ct))
+ throw new UnauthorizedAccessException("Platform administrator access is required.");
+ }
+
+ public async Task GetSandboxAsync(UserKey actorUserKey, Guid sandboxId, CancellationToken ct = default)
+ {
+ await RequireAdminAsync(actorUserKey, ct);
+
+ await using var db = await dbFactory.CreateDbContextAsync(ct);
+
+ var sandbox = await db.Sandboxes
+ .AsNoTracking()
+ .Where(x => x.Id == sandboxId)
+ .Select(x => new
+ {
+ x.Id,
+ x.Name,
+ x.OwnerUserKey,
+ x.Status,
+ MaxUsers = x.Settings.MaxUsers,
+ MemberCount = x.Members.Count()
+ })
+ .FirstOrDefaultAsync(ct);
+
+ if (sandbox is null)
+ return null;
+
+ return new AdminSandboxItem(
+ sandbox.Id,
+ sandbox.Name,
+ sandbox.OwnerUserKey,
+ sandbox.OwnerUserKey.Value,
+ string.Empty,
+ sandbox.Status,
+ sandbox.MemberCount,
+ sandbox.MaxUsers);
+ }
+
+ public async Task UpdateSandboxAsync(UserKey actorUserKey, Guid sandboxId, UpdateSandboxRequest request, CancellationToken ct = default)
+ {
+ await RequireAdminAsync(actorUserKey, ct);
+
+ if (request.MaxUsers is < 1 or > SandboxLimits.AbsoluteMaxUsers)
+ {
+ throw new ArgumentOutOfRangeException(nameof(request.MaxUsers), $"Sandbox capacity must be between 1 and {SandboxLimits.AbsoluteMaxUsers}.");
+ }
+
+ var name = request.Name.Trim();
+
+ if (string.IsNullOrWhiteSpace(name))
+ throw new ArgumentException("Sandbox name is required.");
+
+ if (name.Length > 120)
+ throw new ArgumentException("Sandbox name is too long.");
+
+ if (request.MaxUsers < 1)
+ throw new ArgumentException("Maximum users must be at least 1.");
+
+ if (!Enum.IsDefined(request.Status))
+ throw new ArgumentException("Invalid sandbox status.");
+
+ await using var db =
+ await dbFactory.CreateDbContextAsync(ct);
+
+ await using var transaction =
+ await db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, ct);
+
+ var sandbox = await db.Sandboxes
+ .Include(x => x.Settings)
+ .Include(x => x.Members)
+ .FirstOrDefaultAsync(x => x.Id == sandboxId, ct);
+
+ if (sandbox is null)
+ throw new InvalidOperationException("Sandbox not found.");
+
+ if (request.MaxUsers < sandbox.Members.Count)
+ throw new InvalidOperationException("Maximum users cannot be lower than the current member count.");
+
+ sandbox.Name = name;
+ sandbox.Status = request.Status;
+ sandbox.Settings.MaxUsers = request.MaxUsers;
+
+ await db.SaveChangesAsync(ct);
+ await transaction.CommitAsync(ct);
+ }
+
+ public async Task> GetMembersAsync(UserKey actorUserKey, Guid sandboxId, CancellationToken ct = default)
+ {
+ await RequireAdminAsync(actorUserKey, ct);
+
+ await using var db = await dbFactory.CreateDbContextAsync(ct);
+
+ return await db.SandboxMembers
+ .AsNoTracking()
+ .Where(x => x.SandboxId == sandboxId)
+ .OrderBy(x => x.JoinedAt)
+ .ToListAsync(ct);
+ }
+
+ public async Task> GetMemberDetailsAsync(UserKey actorUserKey, Guid sandboxId, CancellationToken ct = default)
+ {
+ var members = await GetMembersAsync(actorUserKey, sandboxId, ct);
+
+ var result = new List();
+
+ foreach (var member in members)
+ {
+ var userResult = await uAuthClient.Users.GetUserAsync(
+ member.UserKey);
+
+ if (!userResult.IsSuccess || userResult.Value is null)
+ {
+ throw new InvalidOperationException(
+ $"Unable to load member {member.UserKey.Value}.");
+ }
+
+ var user = userResult.Value;
+
+ result.Add(new AdminSandboxMemberItem(
+ member.UserKey,
+ user.DisplayName ?? user.UserName ?? "Unknown",
+ user.PrimaryEmail ?? string.Empty,
+ member.Role,
+ user.Status,
+ member.JoinedAt));
+ }
+
+ return result;
+ }
+
+ public async Task QuerySandboxesAsync(UserKey actorUserKey, AdminSandboxQuery request, CancellationToken ct = default)
+ {
+ await RequireAdminAsync(actorUserKey, ct);
+
+ var page = Math.Max(0, request.Page);
+ var pageSize = Math.Clamp(request.PageSize, 1, 50);
+
+ await using var db = await dbFactory.CreateDbContextAsync(ct);
+
+ var query = db.Sandboxes.AsNoTracking();
+
+ if (!string.IsNullOrWhiteSpace(request.Search))
+ {
+ var search = request.Search.Trim();
+
+ query = query.Where(x => EF.Functions.ILike(x.Name, $"%{search}%"));
+ }
+
+ if (Enum.TryParse(request.Status, true, out var status))
+ {
+ query = query.Where(x => x.Status == status);
+ }
+
+ var totalCount = await query.CountAsync(ct);
+
+ var items = await query
+ .OrderBy(x => x.Name)
+ .ThenBy(x => x.Id)
+ .Skip(page * pageSize)
+ .Take(pageSize)
+ .Select(x => new AdminSandboxItem(
+ x.Id,
+ x.Name,
+ x.OwnerUserKey,
+ x.OwnerUserKey.Value,
+ string.Empty,
+ x.Status,
+ x.Members.Count(),
+ x.Settings.MaxUsers))
+ .ToListAsync(ct);
+
+ return new AdminSandboxPage(items, totalCount, page, pageSize);
+ }
+
+ public async Task GetStatisticsAsync(UserKey actorUserKey, CancellationToken ct = default)
+ {
+ await RequireAdminAsync(actorUserKey, ct);
+
+ await using var db =
+ await dbFactory.CreateDbContextAsync(ct);
+
+ var total = await db.Sandboxes.CountAsync(ct);
+
+ var active = await db.Sandboxes.CountAsync(
+ x => x.Status == SandboxStatus.Active, ct);
+
+ var suspended = await db.Sandboxes.CountAsync(
+ x => x.Status == SandboxStatus.Suspended, ct);
+
+ var members = await db.SandboxMembers.CountAsync(ct);
+
+ return new AdminDashboardSnapshot(
+ total, active, suspended, members);
+ }
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRegistrationService.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRegistrationService.cs
index fc89b301..21fb8047 100644
--- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRegistrationService.cs
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRegistrationService.cs
@@ -19,7 +19,6 @@ public async Task RegisterAsync(LabRegistrationRequest re
return LabRegistrationResult.Failed("Username, email and password are required.");
}
- // Step 1: Create user through UltimateAuth's public endpoint.
var createResult = await uAuthClient.Users.CreateAsync(
new CreateUserRequest
{
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRootResolver.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRootResolver.cs
new file mode 100644
index 00000000..0dc00545
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRootResolver.cs
@@ -0,0 +1,25 @@
+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;
+
+public sealed class LabRootResolver : ILabRootResolver
+{
+ public bool IsLadmin(AccessContext context)
+ {
+ if (!context.IsAuthenticated || context.ActorUserKey is null)
+ {
+ return false;
+ }
+
+ if (!context.Attributes.TryGetValue("roles", out var value))
+ {
+ return false;
+ }
+
+ var result = value is IEnumerable roleIds && roleIds.Contains(LabRoleIds.Admin);
+ return result;
+ }
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxService.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxService.cs
index e6b03146..c5f6c135 100644
--- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxService.cs
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxService.cs
@@ -142,8 +142,7 @@ public async Task AddMemberAsync(UserKey ownerUserKey, UserKey ne
if (ownerUserKey == newUserKey)
throw new InvalidOperationException("Sandbox owner cannot be added as a regular member.");
- await using var transaction =
- await db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, cancellationToken);
+ await using var transaction = await db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, cancellationToken);
var sandbox = await db.Sandboxes
.Include(x => x.Settings)
@@ -155,6 +154,13 @@ public async Task AddMemberAsync(UserKey ownerUserKey, UserKey ne
if (sandbox.Status != SandboxStatus.Active)
throw new InvalidOperationException("Sandbox is not active.");
+ var effectiveLimit = Math.Min(sandbox.Settings.MaxUsers, SandboxLimits.AbsoluteMaxUsers);
+
+ var memberCount = await db.SandboxMembers.CountAsync(x => x.SandboxId == sandbox.Id, cancellationToken);
+
+ if (memberCount >= effectiveLimit)
+ throw new InvalidOperationException("Sandbox user limit reached.");
+
var existing = await db.SandboxMembers.FirstOrDefaultAsync(x => x.UserKey == newUserKey, cancellationToken);
if (existing is not null)
@@ -168,11 +174,6 @@ public async Task AddMemberAsync(UserKey ownerUserKey, UserKey ne
return existing;
}
- var memberCount = await db.SandboxMembers.CountAsync(x => x.SandboxId == sandbox.Id, cancellationToken);
-
- if (memberCount >= sandbox.Settings.MaxUsers)
- throw new InvalidOperationException("Sandbox user limit reached.");
-
var member = new SandboxMember
{
Id = Guid.NewGuid(),
From 0293ed5b9a708855693f46480901e4a1f5a0f416 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?=
Date: Fri, 23 Oct 2026 22:43:26 +0300
Subject: [PATCH 07/10] Session Page
---
.../Pages/Dashboard/MySessionsPage.razor | 704 +++++++++++-------
.../Pages/Dashboard/MySessionsPage.razor.css | 25 +
.../Components/Routes.razor | 6 +-
.../Components/Dialogs/SessionDialog.razor | 17 +
.../Components/Dialogs/SessionDialog.razor.cs | 18 +-
5 files changed, 492 insertions(+), 278 deletions(-)
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 c044aabf..e474690c 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,384 +1,544 @@
@page "/dashboard/sessions"
@layout DashboardLayout
-@inject IDialogService DialogService
-
@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
My Sessions · Identity Lab
-
PERSONAL SECURITY
My Sessions
-
- Explore your devices, authentication sessions,
- and the UltimateAuth session lifecycle.
-
+
Explore your devices, authentication sessions, and the UltimateAuth session lifecycle.
-
-
- Demo Preview
-
+
Refresh
+ @if (_loading)
+ {
+
+ }
+ @if (_error is not null)
+ {
+ @_error
+ }
+
-
-
-
- Active sessions
- 2
- Example session count
+
+ Active devices
+ @(_chains.Count(x => x.State == SessionChainState.Active))
+ On this page
-
-
-
-
+
Authentication chains
- 2
- Illustrative chains
+ @_totalChains
+ Total chain records
-
-
-
-
- Security status
- Active
- Example state
+
+ Current device
+ @(_currentChain is not null ? "Identified" : "Not located")
+ Server-reported session context
-
Active Devices
-
- Sessions associated with your identity.
-
+
Devices & Chains
+
Session chains associated with your account.
-
-
- Revoke Other Sessions
-
+ Disabled="@(_busy || _totalChains < 2)"
+ OnClick="LogoutOthersAsync">Logout Other Sessions
-
-
-
-
-
-
-
-
-
- Windows · Chrome
-
-
- Current Device
-
-
+
+ @* TODO (SDK): Provide direct current-chain lookup without scanning pages. *@
+ @if (_currentChain is not null)
+ {
+
+
+
+
+
- Example desktop session
-
-
-
-
- Active now (example)
-
-
-
- Password authentication
-
-
-
+
+
+ @DeviceName(_currentChain)
-
- Details
-
-
+
+ Current Device
+
+
-
-
-
-
+ @(_currentChain.Platform ?? "Platform unavailable")
-
-
-
Mobile · Safari
+
+
+
+ Last seen:
+ @(_currentChain.LastSeenAt?.ToLocalTime().ToString("g") ?? "Unknown")
+
-
- Remote Session
-
+
+
+ Chain: @_currentChain.ChainId
+
+
-
Example mobile session
-
-
-
-
- Recently active (example)
-
-
-
- Password authentication
-
-
-
+
+ Details
+
+
+ @if (!_currentChain.IsRevoked)
+ {
+
+ Logout
+
+ }
+
+
+ }
+ else if (!_loading && _error is null)
+ {
+
+ Locate Current Device
+
+ }
-
- Revoke
-
-
+
+ @if (!_loading && _error is null && _chains.Count == 0)
+ {
+
No session chains were returned.
+ }
+ @foreach (var chain in _chains.Where(x => !x.IsCurrentDevice))
+ {
+
+
+
+
+
+
+ @DeviceName(chain)
+ @if (chain.IsCurrentDevice)
+ {
+ Current Device
+ }
+ else
+ {
+ @chain.State
+ }
+
+
@(chain.Platform ?? "Platform unavailable")
+
+
+
+ Last seen: @(chain.LastSeenAt?.ToLocalTime().ToString("g") ?? "Unknown")
+
+
+
+ Chain: @chain.ChainId
+
+
+
+ Details
+ @if (!chain.IsRevoked)
+ {
+ Logout
+ }
+
+ }
+
+
+
+
+
+ Page @_page of @TotalPages · @_totalChains chains
+
ULTIMATEAUTH ARCHITECTURE
Authentication Hierarchy
-
- Understand the relationship between authentication
- roots, chains, and sessions.
-
+
Root → Chain → Session. Open Details to inspect actual chain and session history.
-
-
-
-
Session Structure
-
+ My Chains
+
-
-
-
-
-
-
-
- Authentication Root
- Example authentication origin
-
-
-
- Active
-
+
+
Authentication Root Current account
-
-
-
-
-
-
-
-
-
Chain A
-
Example desktop chain
+ @if (_currentChain is not null)
+ {
+
+
+
@DeviceName(_currentChain) Current · @_currentChain.State
+
Sessions
-
-
-
-
-
-
-
Session A1
-
Windows · Chrome
+ }
+ @foreach (var chain in _chains.Where(x => !x.IsCurrentDevice).Take(_treeVisibleCount))
+ {
+
+
+
@DeviceName(chain) @chain.State · @chain.ChainId
+
Sessions
-
-
- Current
-
-
-
-
-
-
-
-
-
- Chain B
- Example mobile chain
-
-
-
-
-
-
-
- Session B1
- Mobile · Safari
-
-
-
+ }
+ @if (_chains.Count(x => !x.IsCurrentDevice) > _treeVisibleCount)
+ {
+
Show more chains
+ }
+ @if (_totalChains > PageSize)
+ {
+
Showing chains from page @_page of @TotalPages. Use pagination above to browse other chains.
+ }
-
-
-
How It Works
-
-
01
-
-
Root
-
- Represents the origin of an authentication
- relationship.
-
-
+
Root Account-level session authority.
-
02
-
-
Chain
-
- Groups related authentication activity
- within a lifecycle.
-
-
+
Chain Device-associated authentication continuity.
-
03
-
-
Session
-
- Represents a session that can be inspected
- and managed according to its security context.
-
-
+
Session An authentication instance within a chain.
-
-
-
- This hierarchy is illustrative. The actual structure
- depends on the authentication mode and session lifecycle.
+
+ Device information is descriptive and does not prove physical device identity.
-
-
-
-
Session Security
-
- Manage your authentication lifecycle and device access.
-
-
+
Session Security Manage your authentication lifecycle and device access.
-
-
-
-
-
+
-
Revoke other sessions
-
- End other device sessions while keeping
- your current session.
-
+
Logout other sessions
+
Logout other chains while keeping your current chain.
-
- Revoke Others
-
+
Logout Others
-
+
-
+
+
-
Sign out
-
- End your current authenticated session.
-
+
Logout all sessions
+
Logout every device chain, including your current session.
+
- Sign Out
+ Disabled="@_busy"
+ OnClick="LogoutAllAsync">
+ Logout All
+
+
+
Sign out End your current authenticated session.
+
Sign Out
+
-
@code {
- private async Task OpenSessionManagementAsync()
+ private readonly List _chains = [];
+ private bool _loading = true;
+ private bool _busy;
+ private string? _error;
+ private int _page = 1;
+ private int _totalChains;
+ private SessionChainSummary? _currentChain;
+ private int _treeVisibleCount = 4;
+ private int TotalPages => Math.Max(1, (_totalChains + PageSize - 1) / PageSize);
+ private const int PageSize = 20;
+
+ protected override async Task OnInitializedAsync() => await LoadAsync();
+
+ private async Task RefreshAsync()
+ {
+ _page = 1;
+ await LoadAsync();
+ }
+
+ private async Task OnPageChangedAsync(int page)
+ {
+ _page = page;
+ _treeVisibleCount = 4;
+ await LoadAsync();
+ }
+
+ private void ExpandTree() => _treeVisibleCount += 5;
+
+ // Fallback without a current-chain endpoint. Run only on explicit user request:
+ // a full scan on every page load would not scale for accounts with many chains.
+ // TODO (SDK): Replace with GetMyCurrentChainAsync when available.
+ private async Task FindCurrentChainAsync()
+ {
+ if (_busy) return;
+ _busy = true;
+ try
+ {
+ for (var page = 1; page <= TotalPages; page++)
+ {
+ var result = await UAuthClient.Sessions.GetMyChainsAsync(CreateChainsRequest(_page));
+
+ if (!result.IsSuccess || result.Value is null)
+ {
+ Snackbar.Add(result.ErrorText ?? "Unable to locate current device.", Severity.Error);
+ return;
+ }
+ _totalChains = result.Value.TotalCount;
+ var current = result.Value.Items.FirstOrDefault(x => x.IsCurrentDevice);
+ if (current is not null)
+ {
+ _currentChain = current;
+ return;
+ }
+ }
+ Snackbar.Add("Current device was not found in the available chains.", Severity.Info);
+ }
+ catch (Exception)
+ {
+ Snackbar.Add("Unable to locate current device.", Severity.Error);
+ }
+ finally { _busy = false; }
+ }
+
+ private async Task LoadAsync()
+ {
+ if (_busy) return;
+ _busy = true;
+ _loading = true;
+ _error = null;
+ try
+ {
+ var result = await UAuthClient.Sessions.GetMyChainsAsync(CreateChainsRequest(_page));
+
+ if (!result.IsSuccess || result.Value is null)
+ {
+ _error = result.ErrorText ?? "Unable to load session chains.";
+ return;
+ }
+ _chains.Clear();
+ _chains.AddRange(result.Value.Items);
+ _totalChains = result.Value.TotalCount;
+ _currentChain = _chains.FirstOrDefault(x => x.IsCurrentDevice) ?? _currentChain;
+ }
+ catch (Exception)
+ {
+ _error = "Session service is unavailable.";
+ }
+ finally
+ {
+ _loading = false;
+ _busy = false;
+ }
+ }
+
+ private async Task OpenSessionManagementAsync(SessionChainId chainId)
{
var parameters = new DialogParameters
{
- { x => x.PreviewMode, true }
+ { x => x.PreviewMode, false },
+ { x => x.InitialChainId, chainId }
};
- await DialogService.ShowAsync(
- "Session Management",
+ var dialog = await DialogService.ShowAsync(
+ "Session Details",
parameters,
UAuthDialog.GetDialogOptions(MaxWidth.Large));
+
+ await dialog.Result;
+ await LoadAsync();
+ }
+
+ private async Task ConfirmAsync(string title, string message, string action)
+ => await DialogService.ShowMessageBoxAsync(title, message, yesText: action, noText: "Cancel") == true;
+
+ private async Task LogoutAsync(SessionChainSummary chain)
+ {
+ if (_busy || !await ConfirmAsync("Logout device",
+ chain.IsCurrentDevice
+ ? "This is your current device. Logout it may sign you out. Continue?"
+ : "Logout this device chain?", "Logout")) return;
+ _busy = true;
+ try
+ {
+ var result = await UAuthClient.Flows.LogoutMyDeviceAsync(new LogoutDeviceRequest() { ChainId = chain.ChainId });
+ if (!result.IsSuccess)
+ {
+ Snackbar.Add(result.ErrorText ?? "Unable to logout device.", Severity.Error);
+ return;
+ }
+
+ Snackbar.Add("Logout from device chain.", Severity.Success);
+ if (result.Value?.CurrentChain == true)
+ {
+ Navigation.NavigateTo("/login");
+ return;
+ }
+ }
+ catch (Exception) { Snackbar.Add("Unable to logout device.", Severity.Error); }
+ finally { _busy = false; }
+ await LoadAsync();
+ }
+
+ private async Task LogoutOthersAsync()
+ {
+ if (_busy || !await ConfirmAsync("Logout other sessions",
+ "Logout all other device chains and keep the current chain?", "Logout Others")) return;
+ _busy = true;
+ try
+ {
+ var result = await UAuthClient.Flows.LogoutMyOtherDevicesAsync();
+ Snackbar.Add(result.IsSuccess ? "Logout from other chains." :
+ result.ErrorText ?? "Unable to logout other chains.",
+ result.IsSuccess ? Severity.Success : Severity.Error);
+ }
+ catch (Exception) { Snackbar.Add("Unable to logout other chains.", Severity.Error); }
+ finally { _busy = false; }
+
+ await LoadAsync();
}
+
+ private async Task LogoutAllAsync()
+ {
+ if (_busy || !await ConfirmAsync(
+ "Logout all sessions",
+ "This will end all your device sessions, including the current one. Continue?",
+ "Logout All"))
+ return;
+
+ _busy = true;
+
+ try
+ {
+ var result = await UAuthClient.Flows.LogoutAllMyDevicesAsync();
+
+ if (!result.IsSuccess)
+ {
+ Snackbar.Add(result.ErrorText ?? "Unable to logout all sessions.", Severity.Error);
+ return;
+ }
+
+ Navigation.NavigateTo("/login", forceLoad: true);
+ }
+ catch (Exception)
+ {
+ Snackbar.Add("Unable to logout all sessions.", Severity.Error);
+ }
+ finally
+ {
+ _busy = false;
+ }
+ }
+
+ private async Task SignOutAsync()
+ {
+ if (_busy || !await ConfirmAsync("Sign out", "End your current session?", "Sign Out")) return;
+ _busy = true;
+ try
+ {
+ await UAuthClient.Flows.LogoutAsync();
+ Navigation.NavigateTo("/login");
+ }
+ catch (Exception)
+ {
+ Snackbar.Add("Unable to sign out.", Severity.Error);
+ _busy = false;
+ }
+ }
+
+ private static string DeviceName(SessionChainSummary chain)
+ => $"{chain.OperatingSystem ?? "Unknown OS"} · {chain.Browser ?? "Unknown browser"}";
+
+ private static string DeviceIcon(string? deviceType)
+ => deviceType?.Contains("mobile", StringComparison.OrdinalIgnoreCase) == true
+ ? Icons.Material.Filled.Smartphone
+ : Icons.Material.Filled.Computer;
+
+ private PageRequest CreateChainsRequest(int page) => new()
+ {
+ PageNumber = page,
+ PageSize = PageSize,
+ SortBy = nameof(SessionChainSummary.CreatedAt),
+ Descending = true
+ };
}
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 dd58731e..440fb231 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
@@ -332,6 +332,31 @@
line-height: 1.5;
}
+
+.lab-device-main {
+ flex: 1 1 0;
+ min-width: 0;
+}
+
+.lab-device-title {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.lab-device-meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px 20px;
+}
+
+ .lab-device-meta span {
+ min-width: 0;
+ overflow-wrap: anywhere;
+ }
+
+
@media (max-width: 1100px) {
.lab-hierarchy-layout {
grid-template-columns: 1fr;
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Routes.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Routes.razor
index ec87485a..e91bc9ee 100644
--- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Routes.razor
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Routes.razor
@@ -4,6 +4,7 @@
@implements IDisposable
@inject ISnackbar Snackbar
+@inject NavigationManager Navigation
@inject DarkModeManager DarkModeManager
@@ -70,9 +71,8 @@
private Task HandleReauth()
{
- Snackbar.Add(
- "Your session has expired. Please sign in again.",
- Severity.Warning);
+ Snackbar.Add("Your session is no longer valid. Please sign in again.", Severity.Warning);
+ // Navigation.NavigateTo("/login", forceLoad: true);
return Task.CompletedTask;
}
diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/SessionDialog.razor b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/SessionDialog.razor
index 2a24534a..9edeb765 100644
--- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/SessionDialog.razor
+++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/SessionDialog.razor
@@ -111,6 +111,23 @@
+
+ @foreach (var session in _chainDetail.Sessions
+ .OrderByDescending(x => x.CreatedAt)
+ .Skip((_sessionPage - 1) * SessionPageSize)
+ .Take(SessionPageSize))
+ {
+ // Mevcut lab-session-history-item içeriği
+ }
+
+
+
+
@foreach (var session in _chainDetail.Sessions)
{
diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/SessionDialog.razor.cs b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/SessionDialog.razor.cs
index d2793e37..7981631e 100644
--- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/SessionDialog.razor.cs
+++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/SessionDialog.razor.cs
@@ -14,6 +14,8 @@ public partial class SessionDialog
private bool _loading;
private bool _reloadQueued;
+ private const int SessionPageSize = 10;
+ private int _sessionPage = 1;
private readonly List
_previewDevices =
[
@@ -49,12 +51,21 @@ public partial class SessionDialog
[Parameter]
public UserKey? UserKey { get; set; }
+ [Parameter]
+ public SessionChainId? InitialChainId { get; set; }
+
[Parameter]
public bool PreviewMode { get; set; }
- private async Task> LoadServerData(
- GridState state,
- CancellationToken ct)
+ protected override async Task OnInitializedAsync()
+ {
+ if (!PreviewMode && InitialChainId is { } chainId)
+ {
+ await ShowChainDetailsAsync(chainId);
+ }
+ }
+
+ private async Task> LoadServerData(GridState state, CancellationToken ct)
{
if (PreviewMode)
return EmptyGrid();
@@ -363,6 +374,7 @@ UserKey is null
}
_chainDetail = result.Value;
+ _sessionPage = 1;
}
catch (Exception)
{
From 809c5ed46f4ebe16e285549f3615538c5b8b7a66 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?=
Date: Sat, 24 Oct 2026 11:41:55 +0300
Subject: [PATCH 08/10] Some Part of Authorization Pages
---
.../Pages/Dashboard/AuthorizationPage.razor | 552 +++++++++++++-----
.../Pages/Dashboard/MyAccessPage.razor | 309 +++++++---
2 files changed, 630 insertions(+), 231 deletions(-)
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 a002051b..f57c2d66 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,10 +1,14 @@
@page "/dashboard/authorization"
@layout DashboardLayout
-@inject IDialogService DialogService
-@* TODO: Enable sandbox-scoped authorization.
@attribute [UAuthAuthorize]
-*@
+
+@using CodeBeam.UltimateAuth.Authorization.Contracts
+@using CodeBeam.UltimateAuth.Core.Contracts
+
+@inject IUAuthClient UAuthClient
+@inject ISnackbar Snackbar
+@inject IDialogService DialogService
Authorization · Identity Lab
@@ -15,10 +19,7 @@
SANDBOX MANAGEMENT
Authorization
-
- Explore roles, permission assignments,
- and access control within your sandbox.
-
+
Explore roles, permission assignments and access control within your sandbox.
Roles
- @_roles.Count
- Example sandbox roles
+ @_totalRoles
+ Total roles
@@ -45,17 +46,17 @@
Permissions
@_permissions.Count
- Illustrative permissions
+ Available permissions
-
+ @*
Role Assignments
3
Example assignments
-
+
*@
@@ -65,14 +66,6 @@
Select a role to inspect its assigned permissions.
-
-
- Demo Preview
-
@@ -91,88 +84,124 @@
@foreach (var role in _roles)
{
SelectRole(role)">
-
+
@role.Name
- @role.Description
- @role.MemberCount example members
+
+
+ Created @role.CreatedAt.ToLocalTime().ToString("g")
+
+
+
+ @PermissionExpander.Expand(
+ role.Permissions,
+ UAuthPermissionCatalog.GetAdminPermissions()).Count
+ permissions
+
}
+
+ @if (!_loading && _roles.Count == 0)
+ {
+
+ No roles found.
+
+ }
+
+
+
+
+
-
@SelectedRole.Name Permissions
-
Permissions assigned to this example role.
+
@(SelectedRole?.Name ?? "Role") Permissions
+
Permissions assigned to the selected role.
+ Disabled="@(_loading || SelectedRole is null)"
+ OnClick="EditPermissionsAsync">
Edit
-
+ @if (SelectedRole is not null)
+ {
+
+
+
+
+ @foreach (var permission in FilteredPermissions)
+ {
+ var granted = GrantedPermissions.Contains(permission);
-
- @foreach (var permission in FilteredPermissions)
- {
-
-
-
-
+
+
+
+
+
+
+
+ @permission
+
+
+
+ @(granted ? "Granted" : "Not granted")
+
-
- @permission.Name
- @permission.Description
+ }
-
- @(SelectedRole.PermissionIds.Contains(permission.Id)
- ? "Granted"
- : "Not granted")
-
-
- }
+ @if (!FilteredPermissions.Any())
+ {
+
+ No permissions match your search.
+
+ }
+
+ }
+ else
+ {
+
+ Select a role to inspect its permissions.
+
+ }
- @if (!FilteredPermissions.Any())
- {
-
- No permissions match your search.
-
- }
-
@@ -193,76 +222,159 @@
Access Request
-
Configure an example authorization request.
+
Evaluate authorization for your current session.
+
-
- @foreach (var role in _roles)
- {
-
- @role.Name
-
- }
-
+ @bind-Value="_playgroundAction">
+
@foreach (var permission in _permissions)
{
-
- @permission.Name
+
+ @permission
}
+
+
+
+
+
+ Disabled="@(_evaluating || string.IsNullOrWhiteSpace(_playgroundAction) || string.IsNullOrWhiteSpace(_playgroundResource))"
+ OnClick="EvaluatePolicyAsync">
+
+ @if (_evaluating)
+ {
+
+ }
+
Evaluate Policy
+
+
Evaluation Result
-
Live authorization decisions will appear here.
+
Server-side authorization decision.
+
-
-
-
-
-
Ready to evaluate
-
- Connect UltimateAuth authorization to inspect
- actual access decisions for sandbox users.
-
-
+ @if (_evaluationResult is null)
+ {
+
+
+
+
+
+
Ready to evaluate
+
+
+ Select an action and resource to evaluate
+ access using your current authentication context.
+
+
+ }
+ else
+ {
+
+
+
+ @(_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
+
+ }
+
+
+ }
- A role's permission list alone does not guarantee
- an authorization decision. Policies and context
- may also affect the result.
+ Dense="true"
+ Class="mt-4">
+ Authorization is evaluated by UltimateAuth on the server.
+ The result reflects your current authentication context,
+ not a simulated role.
+
@@ -271,81 +383,197 @@
@code {
+ private const int PageSize = 10;
+
+ private readonly List _roles = [];
+ private readonly List _permissions = [];
+
+ private RoleId? _selectedRoleId;
private string _permissionSearch = string.Empty;
- private string _playgroundRoleId = "owner";
- private string _playgroundPermissionId = "users.query";
+
+ 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()
+ {
+ _permissions.AddRange(
+ UAuthPermissionCatalog.GetAdminPermissions()
+ .OrderBy(x => x));
+
+ await LoadRolesAsync();
+ }
+
+ private async Task LoadRolesAsync()
+ {
+ 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
+ });
+
+ 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;
+ }
+ }
+
+ 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, true }
+ { x => x.PreviewMode, false }
};
- await DialogService.ShowAsync(
+ var dialog = await DialogService.ShowAsync(
"Role Management",
parameters,
UAuthDialog.GetDialogOptions(MaxWidth.Large));
- }
- private readonly List _permissions =
- [
- new("users.query", "users.query.self", "View own user information"),
- new("users.manage", "users.update.admin", "Manage sandbox users"),
- new("sessions.query", "sessions.query.self", "Inspect own sessions"),
- new("sessions.revoke", "sessions.revoke.self", "Revoke own sessions"),
- new("roles.manage", "roles.update.admin", "Manage role assignments"),
- new("credentials.update", "credentials.update.self", "Update own credentials")
- ];
-
- private readonly List _roles =
- [
- new(
- "owner",
- "Owner",
- "Sandbox administration",
- 1,
- true,
- ["users.query", "users.manage", "sessions.query",
- "sessions.revoke", "roles.manage", "credentials.update"]
- ),
- new(
- "member",
- "Member",
- "Personal identity access",
- 2,
- false,
- ["users.query", "sessions.query",
- "sessions.revoke", "credentials.update"]
- )
- ];
-
- private RolePreview SelectedRole =>
- _roles.First(x => x.Id == _selectedRoleId);
-
- private string _selectedRoleId = "owner";
-
- private IEnumerable FilteredPermissions =>
- _permissions.Where(x =>
- string.IsNullOrWhiteSpace(_permissionSearch) ||
- x.Name.Contains(_permissionSearch, StringComparison.OrdinalIgnoreCase) ||
- x.Description.Contains(_permissionSearch, StringComparison.OrdinalIgnoreCase));
+ await dialog.Result;
+
+ await LoadRolesAsync();
+ }
- private void SelectRole(RolePreview role)
+ private async Task EditPermissionsAsync()
{
- _selectedRoleId = role.Id;
+ if (SelectedRole is null)
+ return;
+
+ var parameters = new DialogParameters
+ {
+ { x => x.Role, SelectedRole },
+ { x => x.PreviewMode, false }
+ };
+
+ var dialog = await DialogService.ShowAsync(
+ "Edit Permissions",
+ parameters,
+ UAuthDialog.GetDialogOptions(MaxWidth.Large));
+
+ await dialog.Result;
+
+ await LoadRolesAsync();
}
- private sealed record PermissionPreview(
- string Id,
- string Name,
- string Description);
-
- private sealed record RolePreview(
- string Id,
- string Name,
- string Description,
- int MemberCount,
- bool IsOwner,
- string[] PermissionIds);
+ 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;
+ }
+ }
}
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 4a7af9ae..a89afc1a 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,9 +1,13 @@
@page "/dashboard/access"
@layout DashboardLayout
-@* TODO: Enable after authentication integration.
@attribute [UAuthAuthorize]
-*@
+
+@using CodeBeam.UltimateAuth.Authorization.Contracts
+@using CodeBeam.UltimateAuth.Core.Contracts
+
+@inject IUAuthClient UAuthClient
+@inject ISnackbar Snackbar
My Access · Identity Lab
@@ -35,26 +39,23 @@
Assigned Roles
- 1
- Example role assignment
+ @_totalRoles
+ Roles assigned to your identity
-
+ @*
Effective Permissions
4
Illustrative permission set
-
+
*@
-
-
-
- Access Context
- Member
- Example sandbox role
+ Available Actions
+ @_availablePermissions.Count
+ Discoverable authorization actions
@@ -71,21 +72,55 @@
-
-
-
+ @foreach (var role in _roles)
+ {
+
-
- Member
- Example sandbox membership role
-
+
+
+
+
+
+ @role.Name
+
+ Assigned @role.AssignedAt.ToLocalTime().ToString("g")
+
+
+
+
+ Assigned
+
+
+
+ }
+
+ @if (!_loading && _totalRoles == 0 && _error is null)
+ {
+
+
No roles assigned
+
You do not currently have any assigned roles.
+
+ }
+
+ @if (_error is not null)
+ {
+
+ @_error
+
+ }
-
- Assigned
-
+ @if (_totalRoles > RolePageSize)
+ {
+
+ }
-
+ @*
Effective Permissions
@@ -131,7 +166,7 @@
}
-
+ *@
@@ -156,38 +191,41 @@
-
- Users
- Sessions
- Credentials
-
- Query
- Create
- Update
- Revoke
+ @bind-Value="_selectedAction"
+ Searchable="true">
+
+ @foreach (var permission in _availablePermissions)
+ {
+
+ @permission
+
+ }
+
+ @bind-Value="_selectedResource" />
+
+
- Evaluate Access
+ Disabled="@(_evaluating || string.IsNullOrWhiteSpace(_selectedAction))"
+ OnClick="EvaluateAccessAsync">
+
+ @(_evaluating ? "Evaluating..." : "Evaluate Access")
+
@@ -203,16 +241,53 @@
-
-
-
+ @if (_accessResult is null)
+ {
+
+
+
+
-
Ready to evaluate
-
- Live access decisions will appear here once
- UltimateAuth authorization is connected.
-
+
Ready to evaluate
+
+
+ Select a permission to check your current access.
+
+
+ }
+ else
+ {
+
+
+
+ @(_accessResult.IsAllowed
+ ? "Access Granted"
+ : "Access Denied")
+
+
+ @if (_accessResult.RequiresReauthentication)
+ {
+ Reauthentication is required.
+ }
+ else if (!string.IsNullOrWhiteSpace(_accessResult.DenyReason))
+ {
+ @_accessResult.DenyReason
+ }
+ else
+ {
+
+ @(_accessResult.IsAllowed
+ ? "Your current session is authorized."
+ : "Your current session is not authorized.")
+
+ }
+
+
+ }
@code {
+ private readonly List _roles = [];
+
+ private bool _loading;
+ private string? _error;
+
+ private int _rolePage = 1;
+ private const int RolePageSize = 10;
+ private int _totalRoles;
+
+ private int TotalRolePages => Math.Max(1, (_totalRoles + RolePageSize - 1) / RolePageSize);
+
+ private string? _selectedAction;
private string _selectedResource = "users";
- private string _selectedOperation = "query";
-
- private readonly List _permissions =
- [
- new("users.query.self", "View your own user information"),
- new("sessions.query.self", "Inspect your own sessions"),
- new("credentials.update.self", "Manage your own credentials"),
- new("sessions.revoke.self", "Revoke your own sessions")
- ];
-
- private sealed record PermissionPreview(
- string Name,
- string Description);
+ private string? _selectedResourceId;
+
+ private UltimateAuth.Authorization.Contracts.AuthorizationResult? _accessResult;
+ private bool _evaluating;
+
+ protected override async Task OnInitializedAsync()
+ {
+ await LoadRolesAsync();
+ }
+
+ private async Task LoadRolesAsync()
+ {
+ _loading = true;
+ _error = null;
+
+ try
+ {
+ var result = await UAuthClient.Authorization.GetMyRolesAsync(
+ new PageRequest
+ {
+ PageNumber = _rolePage,
+ PageSize = RolePageSize
+ });
+
+ if (!result.IsSuccess || result.Value is null)
+ {
+ _error = result.ErrorText ?? "Unable to load your roles.";
+ return;
+ }
+
+ _roles.Clear();
+ _roles.AddRange(result.Value.Roles.Items);
+
+ _totalRoles = result.Value.Roles.TotalCount;
+ }
+ catch (Exception)
+ {
+ _error = "Unable to load your roles.";
+ }
+ finally
+ {
+ _loading = false;
+ }
+ }
+
+ private async Task OnRolePageChangedAsync(int page)
+ {
+ _rolePage = page;
+ await LoadRolesAsync();
+ }
+
+ private readonly List _availablePermissions =
+ UAuthPermissionCatalog.GetAdminPermissions()
+ .OrderBy(x => x)
+ .ToList();
+
+ private async Task EvaluateAccessAsync()
+ {
+ if (_evaluating ||
+ string.IsNullOrWhiteSpace(_selectedAction) ||
+ string.IsNullOrWhiteSpace(_selectedResource))
+ return;
+
+ _evaluating = true;
+ _accessResult = null;
+
+ try
+ {
+ var result = await UAuthClient.Authorization.CheckAsync(
+ new AuthorizationCheckRequest
+ {
+ Action = _selectedAction,
+ Resource = _selectedResource.Trim(),
+ ResourceId = string.IsNullOrWhiteSpace(_selectedResourceId)
+ ? null
+ : _selectedResourceId.Trim()
+ });
+
+ if (!result.IsSuccess || result.Value is null)
+ {
+ Snackbar.Add(
+ result.ErrorText ?? "Access evaluation failed.",
+ Severity.Error);
+ return;
+ }
+
+ _accessResult = result.Value;
+ }
+ catch (Exception)
+ {
+ Snackbar.Add("Unable to evaluate access.", Severity.Error);
+ }
+ finally
+ {
+ _evaluating = false;
+ }
+ }
}
From 0e46b10a3d857ef6ae583fb510fc12c541bf75c4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?=
Date: Sat, 24 Oct 2026 17:09:31 +0300
Subject: [PATCH 09/10] Activity, Settings and Overview Page
---
.../Pages/Dashboard/ActivityPage.razor | 228 +++-----
.../Pages/Dashboard/SettingsPage.razor | 234 ++++++--
.../Components/Pages/DashboardPage.razor | 540 +++++++++++-------
.../Components/Pages/LoginPage.razor | 2 +-
.../Domain/ActivityEvents.cs | 10 +
.../Domain/ActivityLog.cs | 18 +
...20261024130026_AddActivityLogs.Designer.cs | 191 +++++++
.../LabDb/20261024130026_AddActivityLogs.cs | 54 ++
.../LabDb/LabDbContextModelSnapshot.cs | 45 ++
.../Persistence/IdentityLogConfiguration.cs | 52 ++
.../Persistence/LabDbContext.cs | 2 +
.../Program.cs | 12 +
.../Services/ActivityLogService.cs | 59 ++
.../Services/LabActivityEventBridge.cs | 30 +
.../Services/LabActivityEventHandler.cs | 27 +
.../Services/LabRegistrationService.cs | 8 +-
.../Services/SandboxUserService.cs | 5 +-
17 files changed, 1121 insertions(+), 396 deletions(-)
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Domain/ActivityEvents.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Domain/ActivityLog.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/LabDb/20261024130026_AddActivityLogs.Designer.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/LabDb/20261024130026_AddActivityLogs.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Persistence/IdentityLogConfiguration.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/ActivityLogService.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabActivityEventBridge.cs
create mode 100644 apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabActivityEventHandler.cs
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/ActivityPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/ActivityPage.razor
index 136e1bd9..da663b83 100644
--- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/ActivityPage.razor
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/ActivityPage.razor
@@ -1,9 +1,14 @@
@page "/dashboard/activity"
+@using CodeBeam.UltimateAuth.Core.Domain
+@using CodeBeam.UltimateAuth.IdentityLab.Domain
+@using CodeBeam.UltimateAuth.IdentityLab.Services
@layout DashboardLayout
-@* TODO: Enable sandbox-scoped authorization.
@attribute [UAuthAuthorize]
-*@
+@inherits UAuthComponentBase
+
+@inject SandboxService SandboxService
+@inject ActivityLogService ActivityLogService
Activity · Identity Lab
@@ -16,14 +21,6 @@
Activity
Review identity, session, and authorization activity within your sandbox.
-
-
- Demo Preview
-
@@ -37,15 +34,15 @@
Actors
- @_events.Select(x => x.Actor).Distinct().Count()
+ @_events.Select(x => x.ActorUserKey).Distinct().Count()
Example users
- Security Events
- @_events.Count(x => x.Category == ActivityCategory.Security)
- Illustrative security activity
+ Login Events
+ @_events.Count(x => x.EventType == ActivityEvents.Login)
+ Security activity
@@ -76,18 +73,6 @@
Margin="Margin.Dense"
Class="lab-activity-search" />
-
- All categories
- Identity
- Session
- Authorization
- Security
-
-
-
@@ -109,61 +95,51 @@
Event
Actor
- Category
+ Target
Date
- Details
+
-
+
- @context.Title
- @context.Description
+ @GetEventTitle(context.EventType)
- @context.Actor
+
+ @context.ActorUserKey
+
-
-
- @context.Category
-
+
+ @(context.TargetUserKey ?? "—")
- @context.Timestamp.ToString("MMM dd, yyyy")
+ @context.CreatedAt.ToLocalTime().ToString("MMM dd, yyyy HH:mm")
-
-
-
-
-
-
-
No matching events
-
Try adjusting your filters.
+
+
No activity recorded
+
Activity will appear here as users interact with the sandbox.
@@ -174,120 +150,100 @@
- @if (_selectedEvent is not null)
- {
-
-
-
-
Event Details
-
Example activity record
-
-
-
-
-
-
Event @_selectedEvent.Title
-
Actor @_selectedEvent.Actor
-
Category @_selectedEvent.Category
-
Timestamp (UTC) @_selectedEvent.Timestamp.ToString("yyyy-MM-dd HH:mm")
-
-
-
- @_selectedEvent.Description
-
-
- }
-
- Activity records shown here are illustrative. Production activity
- will require explicit event recording, sandbox isolation, and
- appropriate data retention rules.
+ Activity records are temporarily stored by Identity Lab.
+ This lightweight activity history will be replaced by UltimateAuth's built-in audit system once it becomes available.
@code {
+ private readonly List _events = [];
+
private string _search = string.Empty;
- private string _category = "all";
private string _period = "all";
- private ActivityPreview? _selectedEvent;
+ private bool _loading;
+ private string? _error;
- private static readonly DateTime PreviewDate =
- new(2026, 10, 21, 12, 0, 0, DateTimeKind.Utc);
+ private async Task InitializeActivityAsync(UserKey currentUserKey)
+ {
+ var sandbox = await SandboxService.GetByUserAsync(currentUserKey);
- private readonly List _events =
- [
- new("User Created", "A new member was added to the sandbox.",
- "alex", ActivityCategory.Identity, PreviewDate.AddHours(-2)),
+ if (sandbox is null)
+ {
+ _error = "You are not a member of any sandbox.";
+ return;
+ }
- new("Session Revoked", "A remote session was revoked.",
- "taylor", ActivityCategory.Session, PreviewDate.AddHours(-5)),
+ if (sandbox.Status != SandboxStatus.Active)
+ {
+ _error = "Your sandbox is not active.";
+ return;
+ }
- new("Role Updated", "A sandbox role configuration was changed.",
- "alex", ActivityCategory.Authorization, PreviewDate.AddDays(-1)),
+ await LoadActivityAsync(currentUserKey);
+ }
- new("Password Changed", "A member updated their credential.",
- "jordan", ActivityCategory.Security, PreviewDate.AddDays(-2)),
+ private async Task LoadActivityAsync(UserKey currentUserKey)
+ {
+ _loading = true;
+ _error = null;
- new("User Disabled", "A member account was disabled.",
- "alex", ActivityCategory.Identity, PreviewDate.AddDays(-4)),
+ try
+ {
+ var events = await ActivityLogService.GetRecentAsync(
+ currentUserKey,
+ take: 100);
- new("Session Created", "A new authenticated session was established.",
- "taylor", ActivityCategory.Session, PreviewDate.AddDays(-9))
- ];
+ _events.Clear();
+ _events.AddRange(events);
+ }
+ catch (Exception)
+ {
+ _error = "Unable to load activity records.";
+ }
+ finally
+ {
+ _loading = false;
+ }
+ }
- private IEnumerable FilteredEvents =>
+ private IEnumerable FilteredEvents =>
_events
.Where(x =>
(string.IsNullOrWhiteSpace(_search) ||
- x.Title.Contains(_search, StringComparison.OrdinalIgnoreCase) ||
- x.Description.Contains(_search, StringComparison.OrdinalIgnoreCase) ||
- x.Actor.Contains(_search, StringComparison.OrdinalIgnoreCase)) &&
- (_category == "all" ||
- x.Category.ToString().Equals(_category, StringComparison.OrdinalIgnoreCase)) &&
+ x.EventType.Contains(_search, StringComparison.OrdinalIgnoreCase) ||
+ x.ActorUserKey.Contains(_search, StringComparison.OrdinalIgnoreCase) ||
+ (x.TargetUserKey?.Contains(
+ _search,
+ StringComparison.OrdinalIgnoreCase) ?? false)) &&
(_period == "all" ||
(int.TryParse(_period, out var days) &&
- x.Timestamp >= PreviewDate.AddDays(-days))))
- .OrderByDescending(x => x.Timestamp);
+ x.CreatedAt >= DateTimeOffset.UtcNow.AddDays(-days))))
+ .OrderByDescending(x => x.CreatedAt);
- private void SelectEvent(ActivityPreview item) => _selectedEvent = item;
-
- private static string GetIcon(ActivityCategory category) => category switch
+ private static string GetEventTitle(string eventType) => eventType switch
{
- ActivityCategory.Identity => Icons.Material.Filled.PersonOutline,
- ActivityCategory.Session => Icons.Material.Filled.Devices,
- ActivityCategory.Authorization => Icons.Material.Filled.Shield,
- ActivityCategory.Security => Icons.Material.Filled.Security,
- _ => Icons.Material.Outlined.Info
+ "user.login" => "User Logged In",
+ "user.logout" => "User Logged Out",
+ "user.created" => "User Created",
+ "user.updated" => "User Updated",
+ "user.deleted" => "User Deleted",
+ _ => eventType
};
- private static Color GetColor(ActivityCategory category) => category switch
+ private static string GetEventIcon(string eventType) => eventType switch
{
- ActivityCategory.Identity => Color.Primary,
- ActivityCategory.Session => Color.Info,
- ActivityCategory.Authorization => Color.Secondary,
- ActivityCategory.Security => Color.Warning,
- _ => Color.Default
+ "user.login" => Icons.Material.Filled.Login,
+ "user.logout" => Icons.Material.Filled.Logout,
+ "user.created" => Icons.Material.Filled.PersonAdd,
+ "user.updated" => Icons.Material.Filled.ManageAccounts,
+ "user.deleted" => Icons.Material.Filled.PersonRemove,
+ _ => Icons.Material.Filled.History
};
-
- private enum ActivityCategory
- {
- Identity,
- Session,
- Authorization,
- Security
- }
-
- private sealed record ActivityPreview(
- string Title,
- string Description,
- string Actor,
- ActivityCategory Category,
- DateTime Timestamp);
}
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 78af858c..860ed21d 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,9 +1,15 @@
@page "/dashboard/settings"
+@using CodeBeam.UltimateAuth.Core.Domain
+@using CodeBeam.UltimateAuth.IdentityLab.Domain
+@using CodeBeam.UltimateAuth.IdentityLab.Services
@layout DashboardLayout
-@* TODO: Enable sandbox-owner authorization.
@attribute [UAuthAuthorize]
-*@
+@inherits UAuthComponentBase
+
+@inject SandboxService SandboxService
+@inject SandboxUserService SandboxUserService
+@inject ISnackbar Snackbar
Sandbox Settings · Identity Lab
@@ -19,14 +25,6 @@
membership limits, and security boundaries.
-
-
- Demo Preview
-
@@ -44,31 +42,47 @@
+
+ ReadOnly="@(!_isOwner)"
+ MaxLength="150"
+ Immediate="true" />
+ HelperText="Unique identifier assigned to your sandbox." />
-
- Save Changes
-
+ @if (_isOwner)
+ {
+
+
+ @if (_saving)
+ {
+
+ }
+
+ Save Changes
+
+ }
+
@@ -83,31 +97,36 @@
+
-
- Your sandbox supports up to four accounts,
+ Your sandbox supports up to @_maxUsers accounts,
including the owner.
+
Maximum Users
- 4
+ @_maxUsers
Available Slots
- 1
+ @_availableSlots
@@ -146,14 +165,9 @@
Private Sandbox
-
- Users and resources are scoped to
- their own sandbox.
-
+
Sandbox membership determines which users and resources belong to this environment.
-
+
@@ -162,10 +176,7 @@
Scoped Administration
-
- Owners manage only identities and
- resources within their sandbox.
-
+
Sandbox owners can manage their sandbox through application-level access controls.
Membership Limit
-
- A maximum of four accounts prevents
- unrestricted sandbox growth.
-
+
Each sandbox supports up to @_maxUsers accounts, including the owner.
- These are planned Identity Lab security rules.
- They must be enforced server-side before launch.
+ Identity Lab applies application-level sandbox access controls.
+ Sandbox isolation must also be enforced by every operation
+ that accesses or modifies shared UltimateAuth resources.
@@ -283,3 +292,142 @@
+
+@code {
+ private Sandbox? _sandbox;
+
+ private UserKey _currentUserKey;
+
+ private string _sandboxName = string.Empty;
+ private string _ownerName = string.Empty;
+
+ private int _memberCount;
+ private int _maxUsers;
+
+ private bool _isOwner;
+ private bool _saving;
+ private bool _loading = true;
+
+ private bool _hasChanges => _sandbox is not null && !string.Equals(_sandbox.Name, _sandboxName?.Trim(), StringComparison.Ordinal);
+
+ private int _availableSlots => Math.Max(0, _maxUsers - _memberCount);
+
+ private double _capacityPercentage => _maxUsers > 0 ? Math.Min(100d, _memberCount * 100d / _maxUsers) : 0;
+
+ private string? _loadedUserKey;
+
+ protected override async Task OnParametersSetAsync()
+ {
+ await base.OnParametersSetAsync();
+
+ if (AuthState?.Identity?.UserKey is not UserKey userKey)
+ {
+ _sandbox = null;
+ _loadedUserKey = null;
+ _isOwner = false;
+ _loading = false;
+ return;
+ }
+
+ if (_loadedUserKey == userKey.Value)
+ return;
+
+ await LoadSettingsAsync(userKey);
+
+ if (_sandbox is not null)
+ _loadedUserKey = userKey.Value;
+ }
+
+ private async Task SaveChangesAsync()
+ {
+ if (!_isOwner || _sandbox is null || _saving)
+ return;
+
+ var name = _sandboxName.Trim();
+
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ Snackbar.Add("Sandbox name is required.", Severity.Warning);
+ return;
+ }
+
+ if (name.Length > 150)
+ {
+ Snackbar.Add("Sandbox name cannot exceed 150 characters.", Severity.Warning);
+ return;
+ }
+
+ _saving = true;
+
+ try
+ {
+ var updated = await SandboxService.RenameAsync(
+ _currentUserKey,
+ name);
+
+ if (!updated)
+ {
+ Snackbar.Add("Unable to update sandbox.", Severity.Error);
+ return;
+ }
+
+ _sandbox.Name = name;
+ _sandboxName = name;
+
+ Snackbar.Add("Sandbox settings updated.", Severity.Success);
+ }
+ catch (Exception)
+ {
+ Snackbar.Add("Unable to save sandbox settings.", Severity.Error);
+ }
+ finally
+ {
+ _saving = false;
+ }
+ }
+
+ private async Task LoadSettingsAsync(UserKey currentUserKey)
+ {
+ _loading = true;
+
+ try
+ {
+ _currentUserKey = currentUserKey;
+
+ _sandbox = await SandboxService.GetByUserAsync(currentUserKey);
+
+ if (_sandbox is null)
+ {
+ Snackbar.Add("Sandbox not found.", Severity.Warning);
+ return;
+ }
+
+ _sandboxName = _sandbox.Name;
+
+ _isOwner = _sandbox.OwnerUserKey == currentUserKey;
+
+ var members = await SandboxService.GetMembersAsync(_sandbox.Id);
+
+ _memberCount = members.Count;
+
+ _maxUsers = Math.Min(
+ _sandbox.Settings.MaxUsers,
+ SandboxLimits.AbsoluteMaxUsers);
+
+ var snapshot = await SandboxUserService.GetUsersAsync(currentUserKey);
+
+ var owner = snapshot?.Users.FirstOrDefault(x => x.IsOwner);
+
+ _ownerName = owner?.DisplayName
+ ?? _sandbox.OwnerUserKey.Value;
+ }
+ catch (Exception)
+ {
+ Snackbar.Add("Unable to load sandbox settings.", Severity.Error);
+ }
+ finally
+ {
+ _loading = false;
+ }
+ }
+}
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 3ac60b03..01794e5d 100644
--- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/DashboardPage.razor
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/DashboardPage.razor
@@ -1,14 +1,17 @@
@page "/dashboard"
+@layout DashboardLayout
+
@using CodeBeam.UltimateAuth.Core.Domain
@using CodeBeam.UltimateAuth.IdentityLab.Authorization
@using CodeBeam.UltimateAuth.IdentityLab.Contracts
@using CodeBeam.UltimateAuth.IdentityLab.Services
-@layout DashboardLayout
-@inject LabDashboardService DashboardService
-@attribute [UAuthAuthorize]
+@attribute [UAuthAuthorize(Roles = "Ladmin,Owner,User")]
+
+@inject LabDashboardService DashboardService
+@inject ISnackbar Snackbar
-Dashboard · Identity Lab
+Overview · Identity Lab
@@ -17,270 +20,370 @@
IDENTITY WORKSPACE
Welcome to your sandbox.
-
- Explore your identity, manage sessions, and
- experience UltimateAuth in action.
-
+
Review your identity, explore authentication and manage your sandbox with UltimateAuth.
-
-
- Demo Preview
-
-
-
-
My Identity
-
Your personal authentication and security overview.
+ @if (_loading)
+ {
+
+ }
+ else if (_dashboard is null)
+ {
+
+ Your sandbox overview is currently unavailable.
+
+ }
+ else
+ {
+
+
+
+
My Lab
+
+ A quick look at your authentication
+ and security context.
+
+
-
-
+
-
-
-
-
- Identity status
- Authenticated
- Example identity state
-
+
+
+
+
-
-
-
-
- My sessions
- 2
- Example active sessions
-
+
Identity Status
-
-
-
-
- My roles
- 1
- Example assigned role
-
+
+ @(IsAuthenticated ? "Authenticated" : "Unauthenticated")
+
-
-
-
-
- My permissions
- View
- Explore access decisions
-
+
UltimateAuth authentication state
+
-
+
+
+
+
-
+
My Sessions
-
-
-
-
Identity Details
-
Your account and authentication context.
-
-
+
—
+
+
View your active sessions
-
-
-
-
+
+
+
+
-
- Sandbox User
- Example account
-
+
My Roles
-
+
—
-
- Active
-
+
Explore your assigned roles
-
- Username
- demo-user
-
+
+
+
+
-
- Authentication
- Password
-
+
My Permissions
-
- Session model
- Root / Chain / Session
+ Explore
+
+ Evaluate your access
-
- Explore My Identity
-
-
-
-
-
-
-
Session Management
-
Discover how device sessions are managed.
+
+
+
+
+
+
+
+
+
+
+
Identity Details
+
+ Your current authenticated identity.
+
+
+
+
-
-
-
-
-
-
-
-
Windows · Chrome
-
Example current device
+
+
+
+
+
+
+
+ My Identity
+ Authenticated account
+
+
+
+
+
+ Authenticated
+
+
-
-
- Current
-
-
-
-
-
-
-
-
Mobile · Safari
-
Example remote device
+
+ Authentication Status
+
+ @(IsAuthenticated ? "Authenticated" : "Unavailable")
+
-
-
-
-
- Manage My Sessions
-
-
+
+ User Identifier
+ @CurrentUserIdentifier
+
-
+
+ Sandbox Membership
+
+ @(_dashboard.IsOwner ? "Owner" : "Member")
+
+
- @if (ShowManagement)
- {
-
-
-
SANDBOX MANAGEMENT
-
Manage your environment
-
- Create users, configure authorization, and
- experiment with identity operations.
-
-
-
+
+ Explore My Identity
+
+
+
+
+
+
+
+
+
Current Session
+
+ Your active authentication context.
+
+
+
+
+
-
+
+
+
+ Authentication
+
+ @(IsAuthenticated ? "Active" : "Unavailable")
+
+
+
+
+ Session Management
+ Available
+
+
+
+ Manage My Sessions
+
+
+
- @if (_dashboard is not null)
+
+ @if (ShowManagement)
{
-
+
-
@_dashboard.SandboxName
+
+ SANDBOX MANAGEMENT
+
+
+
Manage your environment
+
- Each sandbox supports up to
- @_dashboard.MaxUsers users, including its owner.
+ Manage identities, explore authorization,
+ and review activity within your sandbox.
+
+
+
}
- }
+
+
+
+
@_dashboard.SandboxName
+
+
+ Your isolated Identity Lab environment.
+ Manage up to @_dashboard.MaxUsers accounts, including the owner.
+
+
+
+
+ @_dashboard.MemberCount / @_dashboard.MaxUsers users
+
+
+ }
@code {
+
[CascadingParameter]
private UAuthState? AuthState { get; set; }
private LabDashboardSnapshot? _dashboard;
+
private UserKey? _loadedUserKey;
- private bool _loading;
+
+ private bool _loading = true;
+
+ private bool IsAuthenticated => AuthState?.Identity?.UserKey is not null;
+
+ private string CurrentUserIdentifier => AuthState?.Identity?.UserKey.Value ?? "Unavailable";
+
private bool ShowManagement =>
AuthState?.IsInRole(LabRoles.Admin) == true ||
(AuthState?.IsInRole(LabRoles.Owner) == true &&
@@ -294,18 +397,29 @@
{
_dashboard = null;
_loadedUserKey = null;
+ _loading = false;
return;
}
if (_loadedUserKey == userKey)
return;
- _loadedUserKey = userKey;
_loading = true;
try
{
_dashboard = await DashboardService.GetAsync(userKey.Value);
+
+ if (_dashboard is not null)
+ _loadedUserKey = userKey;
+ }
+ catch (Exception)
+ {
+ _dashboard = null;
+
+ Snackbar.Add(
+ "Unable to load your dashboard.",
+ Severity.Error);
}
finally
{
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 fac3f6d5..3c077146 100644
--- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/LoginPage.razor
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/LoginPage.razor
@@ -100,7 +100,7 @@
Placeholder="Enter your email or username"
Variant="Variant.Outlined"
Adornment="Adornment.Start"
- AdornmentIcon="@Icons.Material.Filled.AlternateEmail"
+ AdornmentIcon="@Icons.Material.Filled.PersonOutline"
Required="true"
Immediate="true" />
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Domain/ActivityEvents.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Domain/ActivityEvents.cs
new file mode 100644
index 00000000..101aa8d3
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Domain/ActivityEvents.cs
@@ -0,0 +1,10 @@
+namespace CodeBeam.UltimateAuth.IdentityLab.Domain;
+
+public static class ActivityEvents
+{
+ public const string Login = "user.login";
+ public const string Logout = "user.logout";
+ public const string UserCreated = "user.created";
+ public const string UserUpdated = "user.updated";
+ public const string UserDeleted = "user.deleted";
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Domain/ActivityLog.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Domain/ActivityLog.cs
new file mode 100644
index 00000000..15666a7d
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Domain/ActivityLog.cs
@@ -0,0 +1,18 @@
+namespace CodeBeam.UltimateAuth.IdentityLab.Domain;
+
+public sealed class ActivityLog
+{
+ public Guid Id { get; set; } = Guid.NewGuid();
+
+ public Guid SandboxId { get; set; }
+
+ public string ActorUserKey { get; set; } = default!;
+
+ public string EventType { get; set; } = default!;
+
+ public string? TargetUserKey { get; set; }
+
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
+
+ public Sandbox Sandbox { get; set; } = default!;
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/LabDb/20261024130026_AddActivityLogs.Designer.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/LabDb/20261024130026_AddActivityLogs.Designer.cs
new file mode 100644
index 00000000..876ba227
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/LabDb/20261024130026_AddActivityLogs.Designer.cs
@@ -0,0 +1,191 @@
+//
+using System;
+using CodeBeam.UltimateAuth.IdentityLab.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace CodeBeam.UltimateAuth.IdentityLab.Migrations.LabDb
+{
+ [DbContext(typeof(LabDbContext))]
+ [Migration("20261024130026_AddActivityLogs")]
+ partial class AddActivityLogs
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.12")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("CodeBeam.UltimateAuth.IdentityLab.Domain.ActivityLog", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("ActorUserKey")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EventType")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("SandboxId")
+ .HasColumnType("uuid");
+
+ b.Property("TargetUserKey")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("SandboxId", "ActorUserKey");
+
+ b.HasIndex("SandboxId", "CreatedAt");
+
+ b.ToTable("LabActivityLogs", (string)null);
+ });
+
+ modelBuilder.Entity("CodeBeam.UltimateAuth.IdentityLab.Domain.Sandbox", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(150)
+ .HasColumnType("character varying(150)");
+
+ b.Property("OwnerUserKey")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasDefaultValue("Active");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OwnerUserKey")
+ .IsUnique();
+
+ b.ToTable("LabSandboxes", (string)null);
+ });
+
+ modelBuilder.Entity("CodeBeam.UltimateAuth.IdentityLab.Domain.SandboxMember", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("JoinedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Role")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("SandboxId")
+ .HasColumnType("uuid");
+
+ b.Property("UserKey")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("SandboxId")
+ .IsUnique()
+ .HasFilter("\"Role\" = 'Owner'");
+
+ b.HasIndex("UserKey")
+ .IsUnique();
+
+ b.HasIndex("SandboxId", "Role");
+
+ b.ToTable("LabSandboxMembers", (string)null);
+ });
+
+ modelBuilder.Entity("CodeBeam.UltimateAuth.IdentityLab.Domain.SandboxSettings", b =>
+ {
+ b.Property("SandboxId")
+ .HasColumnType("uuid");
+
+ b.Property("MaxUsers")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(5);
+
+ b.HasKey("SandboxId");
+
+ b.ToTable("LabSandboxSettings", null, t =>
+ {
+ t.HasCheckConstraint("CK_LabSandboxSettings_MaxUsers", "\"MaxUsers\" >= 1");
+ });
+ });
+
+ modelBuilder.Entity("CodeBeam.UltimateAuth.IdentityLab.Domain.ActivityLog", b =>
+ {
+ b.HasOne("CodeBeam.UltimateAuth.IdentityLab.Domain.Sandbox", "Sandbox")
+ .WithMany()
+ .HasForeignKey("SandboxId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Sandbox");
+ });
+
+ modelBuilder.Entity("CodeBeam.UltimateAuth.IdentityLab.Domain.SandboxMember", b =>
+ {
+ b.HasOne("CodeBeam.UltimateAuth.IdentityLab.Domain.Sandbox", "Sandbox")
+ .WithMany("Members")
+ .HasForeignKey("SandboxId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Sandbox");
+ });
+
+ modelBuilder.Entity("CodeBeam.UltimateAuth.IdentityLab.Domain.SandboxSettings", b =>
+ {
+ b.HasOne("CodeBeam.UltimateAuth.IdentityLab.Domain.Sandbox", "Sandbox")
+ .WithOne("Settings")
+ .HasForeignKey("CodeBeam.UltimateAuth.IdentityLab.Domain.SandboxSettings", "SandboxId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Sandbox");
+ });
+
+ modelBuilder.Entity("CodeBeam.UltimateAuth.IdentityLab.Domain.Sandbox", b =>
+ {
+ b.Navigation("Members");
+
+ b.Navigation("Settings")
+ .IsRequired();
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/LabDb/20261024130026_AddActivityLogs.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/LabDb/20261024130026_AddActivityLogs.cs
new file mode 100644
index 00000000..e3b73ffd
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/LabDb/20261024130026_AddActivityLogs.cs
@@ -0,0 +1,54 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace CodeBeam.UltimateAuth.IdentityLab.Migrations.LabDb
+{
+ ///
+ public partial class AddActivityLogs : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "LabActivityLogs",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ SandboxId = table.Column(type: "uuid", nullable: false),
+ ActorUserKey = table.Column(type: "character varying(128)", maxLength: 128, nullable: false),
+ EventType = table.Column(type: "character varying(64)", maxLength: 64, nullable: false),
+ TargetUserKey = table.Column(type: "character varying(128)", maxLength: 128, nullable: true),
+ CreatedAt = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_LabActivityLogs", x => x.Id);
+ table.ForeignKey(
+ name: "FK_LabActivityLogs_LabSandboxes_SandboxId",
+ column: x => x.SandboxId,
+ principalTable: "LabSandboxes",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_LabActivityLogs_SandboxId_ActorUserKey",
+ table: "LabActivityLogs",
+ columns: new[] { "SandboxId", "ActorUserKey" });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_LabActivityLogs_SandboxId_CreatedAt",
+ table: "LabActivityLogs",
+ columns: new[] { "SandboxId", "CreatedAt" });
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "LabActivityLogs");
+ }
+ }
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/LabDb/LabDbContextModelSnapshot.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/LabDb/LabDbContextModelSnapshot.cs
index b2b15cba..2e4e1049 100644
--- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/LabDb/LabDbContextModelSnapshot.cs
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/LabDb/LabDbContextModelSnapshot.cs
@@ -22,6 +22,40 @@ protected override void BuildModel(ModelBuilder modelBuilder)
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+ modelBuilder.Entity("CodeBeam.UltimateAuth.IdentityLab.Domain.ActivityLog", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("ActorUserKey")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EventType")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("SandboxId")
+ .HasColumnType("uuid");
+
+ b.Property("TargetUserKey")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("SandboxId", "ActorUserKey");
+
+ b.HasIndex("SandboxId", "CreatedAt");
+
+ b.ToTable("LabActivityLogs", (string)null);
+ });
+
modelBuilder.Entity("CodeBeam.UltimateAuth.IdentityLab.Domain.Sandbox", b =>
{
b.Property("Id")
@@ -108,6 +142,17 @@ protected override void BuildModel(ModelBuilder modelBuilder)
});
});
+ modelBuilder.Entity("CodeBeam.UltimateAuth.IdentityLab.Domain.ActivityLog", b =>
+ {
+ b.HasOne("CodeBeam.UltimateAuth.IdentityLab.Domain.Sandbox", "Sandbox")
+ .WithMany()
+ .HasForeignKey("SandboxId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Sandbox");
+ });
+
modelBuilder.Entity("CodeBeam.UltimateAuth.IdentityLab.Domain.SandboxMember", b =>
{
b.HasOne("CodeBeam.UltimateAuth.IdentityLab.Domain.Sandbox", "Sandbox")
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Persistence/IdentityLogConfiguration.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Persistence/IdentityLogConfiguration.cs
new file mode 100644
index 00000000..805c0859
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Persistence/IdentityLogConfiguration.cs
@@ -0,0 +1,52 @@
+using CodeBeam.UltimateAuth.IdentityLab.Domain;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace CodeBeam.UltimateAuth.IdentityLab.Persistence;
+
+public sealed class ActivityLogConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("LabActivityLogs");
+
+ builder.HasKey(x => x.Id);
+
+ builder.Property(x => x.Id)
+ .ValueGeneratedNever();
+
+ builder.Property(x => x.SandboxId)
+ .IsRequired();
+
+ builder.Property(x => x.ActorUserKey)
+ .HasMaxLength(128)
+ .IsRequired();
+
+ builder.Property(x => x.EventType)
+ .HasMaxLength(64)
+ .IsRequired();
+
+ builder.Property(x => x.TargetUserKey)
+ .HasMaxLength(128);
+
+ builder.Property(x => x.CreatedAt)
+ .IsRequired();
+
+ builder.HasOne(x => x.Sandbox)
+ .WithMany()
+ .HasForeignKey(x => x.SandboxId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ builder.HasIndex(x => new
+ {
+ x.SandboxId,
+ x.CreatedAt
+ });
+
+ builder.HasIndex(x => new
+ {
+ x.SandboxId,
+ x.ActorUserKey
+ });
+ }
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Persistence/LabDbContext.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Persistence/LabDbContext.cs
index 64158131..33774b89 100644
--- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Persistence/LabDbContext.cs
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Persistence/LabDbContext.cs
@@ -11,6 +11,8 @@ public sealed class LabDbContext(DbContextOptions options) : DbCon
public DbSet SandboxSettings => Set();
+ public DbSet ActivityLogs => Set();
+
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs
index fd792cba..efd4b905 100644
--- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs
@@ -7,6 +7,7 @@
using CodeBeam.UltimateAuth.EntityFrameworkCore;
using CodeBeam.UltimateAuth.IdentityLab.Authorization;
using CodeBeam.UltimateAuth.IdentityLab.Components;
+using CodeBeam.UltimateAuth.IdentityLab.Domain;
using CodeBeam.UltimateAuth.IdentityLab.Endpoints;
using CodeBeam.UltimateAuth.IdentityLab.Persistence;
using CodeBeam.UltimateAuth.IdentityLab.Policy;
@@ -16,6 +17,7 @@
using CodeBeam.UltimateAuth.Sample.Shared.Infrastructure;
using CodeBeam.UltimateAuth.Server.Extensions;
using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Options;
using MudBlazor.Services;
using MudExtensions.Services;
@@ -39,6 +41,9 @@
var connectionString = builder.Configuration.GetConnectionString("IdentityLab")
?? throw new InvalidOperationException("Connection string 'IdentityLab' is missing.");
+var activityEventBridge = new LabActivityEventBridge();
+builder.Services.AddSingleton(activityEventBridge);
+
builder.Services.AddUltimateAuthServer(o =>
{
o.Diagnostics.EnableRefreshDetails = true;
@@ -53,6 +58,9 @@
o.Identifiers.Behavior.AllowMultipleUsernames = true;
// TODO: Remove it back after fix
o.CredentialSessionPolicy.OnSelfChange = CredentialSessionBehavior.PreserveAll;
+
+ o.Events.OnUserLoggedIn = context => activityEventBridge.RecordAsync(context.UserKey, ActivityEvents.Login);
+ o.Events.OnUserLoggedOut = context => activityEventBridge.RecordAsync(context.UserKey, ActivityEvents.Logout);
})
.AddUltimateAuthEntityFrameworkCore(db =>
{
@@ -86,9 +94,11 @@
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));
@@ -99,6 +109,8 @@
var app = builder.Build();
+activityEventBridge.Initialize(app.Services);
+
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/ActivityLogService.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/ActivityLogService.cs
new file mode 100644
index 00000000..5d810ab6
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/ActivityLogService.cs
@@ -0,0 +1,59 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.IdentityLab.Domain;
+using CodeBeam.UltimateAuth.IdentityLab.Persistence;
+using Microsoft.EntityFrameworkCore;
+
+namespace CodeBeam.UltimateAuth.IdentityLab.Services;
+
+public sealed class ActivityLogService
+{
+ private readonly IDbContextFactory _dbFactory;
+ private readonly ILogger _logger;
+
+ public ActivityLogService(IDbContextFactory dbFactory, ILogger logger)
+ {
+ _dbFactory = dbFactory;
+ _logger = logger;
+ }
+
+ public async Task RecordAsync(Guid sandboxId, string actorUserKey, string eventType, string? targetUserKey = null, CancellationToken cancellationToken = default)
+ {
+ await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
+
+ db.ActivityLogs.Add(new ActivityLog
+ {
+ SandboxId = sandboxId,
+ ActorUserKey = actorUserKey,
+ EventType = eventType,
+ TargetUserKey = targetUserKey
+ });
+
+ await db.SaveChangesAsync(cancellationToken);
+ }
+
+ public async Task TryRecordAsync(Guid sandboxId, string actorUserKey, string eventType, string? targetUserKey = null, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ await RecordAsync(sandboxId, actorUserKey, eventType, targetUserKey, cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to record activity {EventType} for sandbox {SandboxId}", eventType, sandboxId);
+ }
+ }
+
+ public async Task> GetRecentAsync(UserKey userKey, int take = 100, CancellationToken cancellationToken = default)
+ {
+ await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
+
+ return await db.ActivityLogs
+ .AsNoTracking()
+ .Where(x =>
+ x.Sandbox.Members.Any(m => m.UserKey == userKey) &&
+ x.Sandbox.Status == SandboxStatus.Active)
+ .OrderByDescending(x => x.CreatedAt)
+ .Take(Math.Clamp(take, 1, 100))
+ .ToListAsync(cancellationToken);
+ }
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabActivityEventBridge.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabActivityEventBridge.cs
new file mode 100644
index 00000000..1103d93c
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabActivityEventBridge.cs
@@ -0,0 +1,30 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.IdentityLab.Domain;
+
+namespace CodeBeam.UltimateAuth.IdentityLab.Services;
+
+public sealed class LabActivityEventBridge
+{
+ private IServiceScopeFactory? _scopeFactory;
+
+ public void Initialize(IServiceProvider services)
+ {
+ _scopeFactory = services.GetRequiredService();
+ }
+
+ public async Task RecordAsync(UserKey userKey, string eventType)
+ {
+ if (_scopeFactory is null)
+ return;
+
+ using var scope = _scopeFactory.CreateScope();
+
+ var handler = scope.ServiceProvider
+ .GetRequiredService();
+
+ if (eventType == ActivityEvents.Login)
+ await handler.RecordLoginAsync(userKey);
+ else if (eventType == ActivityEvents.Logout)
+ await handler.RecordLogoutAsync(userKey);
+ }
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabActivityEventHandler.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabActivityEventHandler.cs
new file mode 100644
index 00000000..e163f9a9
--- /dev/null
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabActivityEventHandler.cs
@@ -0,0 +1,27 @@
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.IdentityLab.Domain;
+
+namespace CodeBeam.UltimateAuth.IdentityLab.Services;
+
+public sealed class LabActivityEventHandler(SandboxService sandboxService, ActivityLogService activityLogService)
+{
+ public async Task RecordLoginAsync(UserKey userKey)
+ {
+ await RecordAsync(userKey, ActivityEvents.Login);
+ }
+
+ public async Task RecordLogoutAsync(UserKey userKey)
+ {
+ await RecordAsync(userKey, ActivityEvents.Logout);
+ }
+
+ private async Task RecordAsync(UserKey userKey, string eventType)
+ {
+ var sandbox = await sandboxService.GetByUserAsync(userKey);
+
+ if (sandbox is null)
+ return;
+
+ await activityLogService.TryRecordAsync(sandbox.Id, userKey.Value, eventType);
+ }
+}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRegistrationService.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRegistrationService.cs
index 21fb8047..96776f5d 100644
--- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRegistrationService.cs
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabRegistrationService.cs
@@ -4,11 +4,13 @@
using CodeBeam.UltimateAuth.Core.MultiTenancy;
using CodeBeam.UltimateAuth.IdentityLab.Authorization;
using CodeBeam.UltimateAuth.IdentityLab.Contracts;
+using CodeBeam.UltimateAuth.IdentityLab.Domain;
using CodeBeam.UltimateAuth.Users.Contracts;
namespace CodeBeam.UltimateAuth.IdentityLab.Services;
-public sealed class LabRegistrationService(IUAuthClient uAuthClient, IRoleStoreFactory roleStoreFactory, IUserRoleStoreFactory userRoleStoreFactory, SandboxService sandboxService)
+public sealed class LabRegistrationService(IUAuthClient uAuthClient, IRoleStoreFactory roleStoreFactory, IUserRoleStoreFactory userRoleStoreFactory,
+ SandboxService sandboxService, ActivityLogService activityLogService)
{
public async Task RegisterAsync(LabRegistrationRequest request, CancellationToken ct = default)
{
@@ -65,7 +67,9 @@ public async Task RegisterAsync(LabRegistrationRequest re
// A sandbox name is presentation data, not an identity key.
// The owner relationship is established using OwnerUserKey.
- await sandboxService.CreateAsync(userKey, $"{request.Username.Trim()}'s Sandbox", ct);
+ var sandbox = await sandboxService.CreateAsync(userKey, $"{request.Username.Trim()}'s Sandbox", ct);
+
+ await activityLogService.TryRecordAsync(sandbox.Id, userKey.Value, ActivityEvents.UserCreated, userKey.Value, ct);
return LabRegistrationResult.Success();
}
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxUserService.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxUserService.cs
index 57f24bb0..845dc9f3 100644
--- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxUserService.cs
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxUserService.cs
@@ -9,7 +9,8 @@
namespace CodeBeam.UltimateAuth.IdentityLab.Services;
-public sealed class SandboxUserService(SandboxService sandboxService, SandboxAccessService accessService, IUAuthClient uAuthClient, IRoleStoreFactory roleStoreFactory, IUserRoleStoreFactory userRoleStoreFactory)
+public sealed class SandboxUserService(SandboxService sandboxService, SandboxAccessService accessService, IUAuthClient uAuthClient,
+ IRoleStoreFactory roleStoreFactory, IUserRoleStoreFactory userRoleStoreFactory, ActivityLogService activityLogService)
{
public async Task GetUsersAsync(UserKey actorUserKey, CancellationToken ct = default)
{
@@ -87,6 +88,8 @@ public async Task CreateAsync(UserKey actorUserKey, Crea
"Manual repair is required.");
}
+ await activityLogService.TryRecordAsync(sandbox.Id, actorUserKey.Value, ActivityEvents.UserCreated, newUserKey.Value, ct);
+
return CreateUserDialogResult.Success();
}
From 2f8031b733855c939368f7213ccfcd1e3ff91b54 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?=
Date: Sat, 24 Oct 2026 17:28:59 +0300
Subject: [PATCH 10/10] Dashboard Page
---
.../Components/Layout/DashboardLayout.razor | 2 +-
.../Components/Pages/DashboardPage.razor | 52 +++++++++---
.../Contracts/LabDashboardSnapshot.cs | 10 ++-
.../Services/LabDashboardService.cs | 85 +++++++++++++++++--
4 files changed, 128 insertions(+), 21 deletions(-)
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 0e97c438..5bab31b3 100644
--- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor
@@ -26,7 +26,7 @@
-
+
Identity Lab
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 01794e5d..fff8c308 100644
--- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/DashboardPage.razor
+++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/DashboardPage.razor
@@ -68,11 +68,13 @@
- My Sessions
+ Authentication Chains
- —
+
+ @(_dashboard.ChainCount?.ToString() ?? "—")
+
- View your active sessions
+ Total authentication chains
@@ -82,9 +84,11 @@
My Roles
- —
+
+ @(_dashboard.RoleCount?.ToString() ?? "—")
+
- Explore your assigned roles
+ Assigned authorization roles
@@ -127,8 +131,15 @@
- My Identity
- Authenticated account
+
+ @(!string.IsNullOrWhiteSpace(_dashboard.DisplayName)
+ ? _dashboard.DisplayName
+ : "My Identity")
+
+
+
+ @(_dashboard.Email ?? "Authenticated account")
+
@@ -137,21 +148,19 @@
Color="Color.Success"
Variant="Variant.Text"
Size="Size.Small">
- Authenticated
+ @(_dashboard.AccountStatus ?? "Authenticated")
- Authentication Status
-
- @(IsAuthenticated ? "Authenticated" : "Unavailable")
-
+ Username
+ @(_dashboard.Username ?? "—")
- User Identifier
- @CurrentUserIdentifier
+ Email
+ @(_dashboard.Email ?? "—")
@@ -161,6 +170,21 @@
+
+ Username
+ @(_dashboard.Username ?? "—")
+
+
+
+ Email
+ @(_dashboard.Email ?? "—")
+
+
+
+ Sandbox Membership
+ @(_dashboard.IsOwner ? "Owner" : "Member")
+
+
GetAsync(UserKey userKey, CancellationToken ct = default)
{
@@ -13,16 +16,88 @@ public sealed class LabDashboardService(SandboxService sandboxService)
return null;
var members = await sandboxService.GetMembersAsync(sandbox.Id, ct);
-
- var maxUsers = sandbox.Settings.MaxUsers;
+ var maxUsers = Math.Min(sandbox.Settings.MaxUsers, SandboxLimits.AbsoluteMaxUsers);
var memberCount = members.Count;
+ string? displayName = null;
+ string? username = null;
+ string? email = null;
+ string? accountStatus = null;
+
+ int? chainCount = null;
+ int? roleCount = null;
+
+ try
+ {
+ var result = await uAuthClient.Users.GetMeAsync();
+
+ if (result.IsSuccess && result.Value is not null)
+ {
+ var profile = result.Value;
+
+ displayName = profile.DisplayName;
+ username = profile.UserName;
+ email = profile.PrimaryEmail;
+ accountStatus = profile.Status.ToString();
+ }
+ }
+ catch (Exception) when (!ct.IsCancellationRequested)
+ {
+ // Profile information is optional for the overview.
+ }
+
+ try
+ {
+ var result = await uAuthClient.Sessions.GetMyChainsAsync(
+ new PageRequest
+ {
+ PageNumber = 1,
+ PageSize = 1
+ });
+
+ if (result.IsSuccess && result.Value is not null)
+ {
+ chainCount = result.Value.TotalCount;
+ }
+ }
+ catch (Exception) when (!ct.IsCancellationRequested)
+ {
+ // Chain information is optional for the overview.
+ }
+
+ try
+ {
+ var result = await uAuthClient.Authorization.GetMyRolesAsync(
+ new PageRequest
+ {
+ PageNumber = 1,
+ PageSize = 1
+ });
+
+ if (result.IsSuccess && result.Value is not null)
+ {
+ roleCount = result.Value.Roles.TotalCount;
+ }
+ }
+ catch (Exception) when (!ct.IsCancellationRequested)
+ {
+ // Role information is optional for the overview.
+ }
+
return new LabDashboardSnapshot(
SandboxId: sandbox.Id,
SandboxName: sandbox.Name,
MaxUsers: maxUsers,
MemberCount: memberCount,
RemainingCapacity: Math.Max(0, maxUsers - memberCount),
- IsOwner: sandbox.OwnerUserKey == userKey);
+ IsOwner: sandbox.OwnerUserKey == userKey,
+
+ DisplayName: displayName,
+ Username: username,
+ Email: email,
+ AccountStatus: accountStatus,
+
+ ChainCount: chainCount,
+ RoleCount: roleCount);
}
}