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 + + + + +
+ + @AvatarInitials + + +
+
+ @DisplayName +
+ +
+ @@ @UserName +
+
+
+ + + + + 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. + *@
@@ -41,42 +58,132 @@
- Sandbox User - @@demo-user + @_profile?.FirstName @_profile?.LastName + @@ @_profile?.UserName
- - Active + + @_profile?.Status
Display name - Sandbox User + @_profile?.DisplayName
Username - demo-user + @_profile?.UserName
Email - demo@example.com + @_profile?.PrimaryEmail +
+ +
+ Primary phone + @(_profile?.PrimaryPhone ?? "Not provided")
- Account status - Active + User Key + @_profile?.UserKey.Value
+ + + + Additional Details + + + +
+
+ Profile key + @_profile?.ProfileKey +
+ +
+ First name + @FormatValue(_profile?.FirstName) +
+ +
+ Last name + @FormatValue(_profile?.LastName) +
+ +
+ Birth date + @(_profile?.BirthDate?.ToString("d") ?? "Not provided") +
+ +
+ Gender + @FormatValue(_profile?.Gender) +
+ +
+ Bio + @FormatValue(_profile?.Bio) +
+ +
+ Language + @FormatValue(_profile?.Language) +
+ +
+ Culture + @FormatValue(_profile?.Culture) +
+ +
+ Time zone + @FormatValue(_profile?.TimeZone) +
+ +
+ Created at + + @(_profile?.CreatedAt?.ToLocalTime().ToString("g") ?? "—") + +
+ +
+ Metadata + + @if (_profile?.Metadata is { Count: > 0 } metadata) + { + + @foreach (var entry in metadata) + { + + @entry.Key: @entry.Value + + } + + } + else + { + No metadata + } +
+
+
+ @code { - private async Task OpenEditProfileAsync() + [CascadingParameter] + private UAuthState AuthState { get; set; } = default!; + + private UserView? _profile; + private bool _loading = true; + private string? _loadError; + private bool _showDetails; + + private static string FormatValue(string? value) => string.IsNullOrWhiteSpace(value) ? "Not provided" : value; + + protected override async Task OnInitializedAsync() + { + await LoadProfileAsync(); + } + + private async Task LoadProfileAsync() { - var parameters = new DialogParameters + _loading = true; + _loadError = null; + + try { - { x => x.PreviewMode, true }, - { x => x.PreviewDisplayName, "Alex Morgan" } - }; + var result = await UAuthClient.Users.GetMeAsync(); + + if (result.IsSuccess) + _profile = result.Value; + else + _loadError = "Unable to load your profile."; + } + catch (Exception) + { + _loadError = "An error occurred while loading your profile."; + } + finally + { + _loading = false; + await InvokeAsync(StateHasChanged); + } + } - await DialogService.ShowAsync( + private async Task OpenEditProfileAsync() + { + var dialog = await DialogService.ShowAsync( "Edit Profile", - parameters, + new DialogParameters + { + { x => x.PreviewMode, false } + }, UAuthDialog.GetDialogOptions(MaxWidth.Medium)); + + var result = await dialog.Result; + + if (result is { Canceled: false }) + { + await LoadProfileAsync(); + } } private async Task OpenIdentifiersAsync() 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 804a657d..0da12356 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/DashboardPage.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/DashboardPage.razor @@ -1,9 +1,7 @@ @page "/dashboard" @layout DashboardLayout -@* TODO: Enable after authentication integration. @attribute [UAuthAuthorize] -*@ Dashboard · Identity Lab 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 dcc66f72..fac3f6d5 100644 --- a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/LoginPage.razor +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/LoginPage.razor @@ -1,6 +1,8 @@ @page "/login" @using CodeBeam.UltimateAuth.Core.Contracts @layout PublicLayout +@inject IUAuthClient UAuthClient +@inject NavigationManager NavigationManager Sign In · Identity Lab @@ -17,55 +19,116 @@ YOUR IDENTITY WORKSPACE -

Welcome back.

-

Sign in to continue to your Identity Lab sandbox.

+

+ @(AuthState.IsAuthenticated ? "You're signed in." : "Welcome back.") +

+ +

+ @(AuthState.IsAuthenticated + ? "Continue with your current account or sign out to switch accounts." + : "Sign in to continue to your Identity Lab sandbox.") +

@@ -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 @@
-
-

Authentication Context

-

- Understand your UltimateAuth identity. -

+

Account Management

+

Manage your account status and lifecycle.

-
-
-
- - - +
+ + + -
- 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 @@ - @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 @@ @@ -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 @@ @@ -105,17 +105,13 @@ YOUR IDENTITY PLAYGROUND -