Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using CodeBeam.UltimateAuth.Core.Contracts;

namespace CodeBeam.UltimateAuth.IdentityLab.Authorization;

public interface ILabAdminGuard
{
Task<bool> IsAdminAsync(AccessContext context, CancellationToken ct = default);
Task EnsureAdminAsync(AccessContext context, CancellationToken ct = default);
}
Original file line number Diff line number Diff line change
@@ -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<AccessContext> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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<object?> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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<bool> 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.");
}
}
}
Original file line number Diff line number Diff line change
@@ -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"));
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace CodeBeam.UltimateAuth.IdentityLab.Authorization;

public static class LabRoles
{
public const string Admin = "Ladmin";
public const string Owner = "Owner";
public const string User = "User";

public static readonly IReadOnlyList<string> SystemRoles =
[
Admin,
Owner,
User
];
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
@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

<HeadContent>
Expand All @@ -19,7 +26,7 @@

<a href="/dashboard" class="lab-dashboard-brand">
<span class="lab-dashboard-brand-icon">
<MudIcon Icon="@Icons.Material.Filled.Science" />
<MudImage Src="identity-lab.png" Height="36" Width="36" />
</span>
<span>
<strong>Identity Lab</strong>
Expand All @@ -33,7 +40,7 @@
Delay="300">
<MudIconButton Icon="@(DarkModeManager.IsDarkMode
? Icons.Material.Filled.LightMode
: Icons.Material.Filled.DarkMode)"
: Icons.Material.Filled.DarkMode)"
Color="Color.Inherit"
OnClick="DarkModeManager.ToggleAsync" />
</MudTooltip>
Expand All @@ -45,11 +52,59 @@
Color="Color.Inherit" />
</MudTooltip>

<MudAvatar Color="Color.Primary"
Size="Size.Medium"
Class="ml-3">
<MudIcon Icon="@Icons.Material.Filled.Person" />
</MudAvatar>
<MudMenu AnchorOrigin="Origin.BottomRight"
TransformOrigin="Origin.TopRight"
Dense="true"
PopoverClass="lab-profile-menu">

<ActivatorContent>
<MudAvatar Color="Color.Primary"
Size="Size.Medium"
Class="ml-3 lab-profile-avatar"
@onclick="@context.ToggleAsync">
@AvatarInitials
</MudAvatar>
</ActivatorContent>

<ChildContent>
<div class="lab-profile-menu-header">
<MudAvatar Color="Color.Primary"
Size="Size.Medium"
Class="lab-profile-menu-avatar">
@AvatarInitials
</MudAvatar>

<div class="lab-profile-menu-user">
<div class="lab-profile-menu-name">
@DisplayName
</div>

<div class="lab-profile-menu-username">
@@ @UserName
</div>
</div>
</div>

<MudDivider Class="my-2" />

<MudMenuItem Href="/dashboard/identity"
Icon="@Icons.Material.Outlined.Badge">
My Identity
</MudMenuItem>

<MudMenuItem Href="/dashboard/sessions"
Icon="@Icons.Material.Outlined.Devices">
My Sessions
</MudMenuItem>

<MudDivider Class="my-2" />

<MudMenuItem Icon="@Icons.Material.Outlined.Logout"
OnClick="LogoutAsync">
Sign Out
</MudMenuItem>
</ChildContent>
</MudMenu>
</MudAppBar>
</div>

Expand All @@ -67,20 +122,24 @@
<MudIcon Icon="@Icons.Material.Filled.Science" />
</span>
<div>
<strong>My Sandbox</strong>
<small>Identity environment</small>
<strong>@(_sandbox?.SandboxName ?? "My Sandbox")</strong>
<small>
@(_sandbox is null ? "Identity environment" : $"{_sandbox.MemberCount} / {_sandbox.MaxUsers} users")
</small>
</div>
<MudIcon Icon="@Icons.Material.Filled.CheckCircle"
Color="Color.Success"
Size="Size.Small" />
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
</div>

<MudNavMenu>
<div class="lab-nav-label">WORKSPACE</div>

<MudNavLink Href="/dashboard"
Match="NavLinkMatch.All"
Icon="@Icons.Material.Filled.Dashboard">
<UAuthStateView Roles="Ladmin">
<MudNavLink Href="/admin" Icon="@Icons.Material.Filled.AdminPanelSettings">
Platform Administration
</MudNavLink>
</UAuthStateView>

<MudNavLink Href="/dashboard" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">
Overview
</MudNavLink>

Expand Down Expand Up @@ -131,6 +190,12 @@
Settings
</MudNavLink>
}

<MudNavMenu>
<MudNavLink Icon="@Icons.Material.Filled.Logout" OnClick="LogoutAsync">
Sign Out
</MudNavLink>
</MudNavMenu>
</MudNavMenu>

<MudSpacer />
Expand All @@ -144,29 +209,107 @@
</MudDrawer>

<MudMainContent Class="lab-dashboard-main">
@if (AuthState?.Identity?.UserStatus == UserStatus.SelfSuspended)
{
<MudAlert Severity="Severity.Warning"
Variant="Variant.Outlined"
Class="mx-4 mt-4"
Icon="@Icons.Material.Filled.PauseCircleOutline">
<strong>Account Suspended</strong>

<MudText Typo="Typo.body2">
Your account is currently self-suspended.
Most account operations are restricted.
Reactivate your account from
My Identity → Account Management.
</MudText>
</MudAlert>
}
else if (AuthState?.Identity?.UserStatus == UserStatus.Suspended || AuthState?.Identity?.UserStatus == UserStatus.Locked)
{
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Class="mx-4 mt-4">
<MudText Typo="Typo.subtitle2">
Your account has been suspended
</MudText>
<MudText Typo="Typo.body2">
Your account has been suspended/locked by an administrator.
Some actions are unavailable.
Please contact your system administrator to restore access.
</MudText>
</MudAlert>
}
@Body
</MudMainContent>

</MudLayout>

@code {
[CascadingParameter]
private UAuthState? AuthState { get; set; }

private string UserName => AuthState?.Identity?.PrimaryUserName ?? "User";
private string DisplayName => UserName;
private string AvatarInitials => GetInitials(DisplayName);
private LabDashboardSnapshot? _sandbox;
private UserKey? _loadedUserKey;
private bool _drawerOpen = true;

// 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);
}

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();
}
}
Loading
Loading