Added permission checks to all controllers. Added role permission loading. Added frontend permission checks. Implemented user logout in admin panel.

This commit was merged in pull request #4.
This commit is contained in:
2026-01-16 13:07:19 +01:00
parent bee381702b
commit a28b8aca7a
24 changed files with 401 additions and 62 deletions

View File

@@ -1,11 +1,14 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Moonlight.Api.Mappers; using Moonlight.Api.Mappers;
using Moonlight.Api.Services; using Moonlight.Api.Services;
using Moonlight.Shared;
using Moonlight.Shared.Http.Responses.Admin; using Moonlight.Shared.Http.Responses.Admin;
namespace Moonlight.Api.Http.Controllers.Admin; namespace Moonlight.Api.Http.Controllers.Admin;
[ApiController] [ApiController]
[Authorize(Policy = Permissions.System.Diagnose)]
[Route("api/admin/system/diagnose")] [Route("api/admin/system/diagnose")]
public class DiagnoseController : Controller public class DiagnoseController : Controller
{ {

View File

@@ -1,14 +1,17 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Moonlight.Api.Database; using Moonlight.Api.Database;
using Moonlight.Api.Database.Entities; using Moonlight.Api.Database.Entities;
using Moonlight.Api.Mappers; using Moonlight.Api.Mappers;
using Moonlight.Shared;
using Moonlight.Shared.Http.Responses; using Moonlight.Shared.Http.Responses;
using Moonlight.Shared.Http.Responses.Users; using Moonlight.Shared.Http.Responses.Users;
namespace Moonlight.Api.Http.Controllers.Admin; namespace Moonlight.Api.Http.Controllers.Admin;
[ApiController] [ApiController]
[Authorize(Policy = Permissions.Roles.Members)]
[Route("api/admin/roles/{roleId:int}/members")] [Route("api/admin/roles/{roleId:int}/members")]
public class RoleMembersController : Controller public class RoleMembersController : Controller
{ {
@@ -59,6 +62,7 @@ public class RoleMembersController : Controller
// Pagination // Pagination
var items = query var items = query
.OrderBy(x => x.Id)
.Skip(startIndex) .Skip(startIndex)
.Take(length) .Take(length)
.ProjectToDto() .ProjectToDto()
@@ -100,6 +104,7 @@ public class RoleMembersController : Controller
// Pagination // Pagination
var items = query var items = query
.OrderBy(x => x.Id)
.Skip(startIndex) .Skip(startIndex)
.Take(length) .Take(length)
.ProjectToDto() .ProjectToDto()

View File

@@ -1,8 +1,10 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Moonlight.Api.Database; using Moonlight.Api.Database;
using Moonlight.Api.Database.Entities; using Moonlight.Api.Database.Entities;
using Moonlight.Api.Mappers; using Moonlight.Api.Mappers;
using Moonlight.Shared;
using Moonlight.Shared.Http.Requests; using Moonlight.Shared.Http.Requests;
using Moonlight.Shared.Http.Requests.Roles; using Moonlight.Shared.Http.Requests.Roles;
using Moonlight.Shared.Http.Responses; using Moonlight.Shared.Http.Responses;
@@ -22,6 +24,7 @@ public class RolesController : Controller
} }
[HttpGet] [HttpGet]
[Authorize(Policy = Permissions.Roles.View)]
public async Task<ActionResult<PagedData<RoleDto>>> GetAsync( public async Task<ActionResult<PagedData<RoleDto>>> GetAsync(
[FromQuery] int startIndex, [FromQuery] int startIndex,
[FromQuery] int length, [FromQuery] int length,
@@ -57,6 +60,7 @@ public class RolesController : Controller
// Pagination // Pagination
var data = await query var data = await query
.OrderBy(x => x.Id)
.ProjectToDto() .ProjectToDto()
.Skip(startIndex) .Skip(startIndex)
.Take(length) .Take(length)
@@ -68,6 +72,7 @@ public class RolesController : Controller
} }
[HttpGet("{id:int}")] [HttpGet("{id:int}")]
[Authorize(Policy = Permissions.Roles.View)]
public async Task<ActionResult<RoleDto>> GetAsync([FromRoute] int id) public async Task<ActionResult<RoleDto>> GetAsync([FromRoute] int id)
{ {
var role = await RoleRepository var role = await RoleRepository
@@ -81,6 +86,7 @@ public class RolesController : Controller
} }
[HttpPost] [HttpPost]
[Authorize(Policy = Permissions.Roles.Create)]
public async Task<ActionResult<RoleDto>> CreateAsync([FromBody] CreateRoleDto request) public async Task<ActionResult<RoleDto>> CreateAsync([FromBody] CreateRoleDto request)
{ {
var role = RoleMapper.ToEntity(request); var role = RoleMapper.ToEntity(request);
@@ -91,6 +97,7 @@ public class RolesController : Controller
} }
[HttpPatch("{id:int}")] [HttpPatch("{id:int}")]
[Authorize(Policy = Permissions.Roles.Edit)]
public async Task<ActionResult<RoleDto>> UpdateAsync([FromRoute] int id, [FromBody] UpdateRoleDto request) public async Task<ActionResult<RoleDto>> UpdateAsync([FromRoute] int id, [FromBody] UpdateRoleDto request)
{ {
var role = await RoleRepository var role = await RoleRepository
@@ -108,6 +115,7 @@ public class RolesController : Controller
} }
[HttpDelete("{id:int}")] [HttpDelete("{id:int}")]
[Authorize(Policy = Permissions.Roles.Delete)]
public async Task<ActionResult> DeleteAsync([FromRoute] int id) public async Task<ActionResult> DeleteAsync([FromRoute] int id)
{ {
var role = await RoleRepository var role = await RoleRepository

View File

@@ -1,5 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Moonlight.Api.Services; using Moonlight.Api.Services;
using Moonlight.Shared;
using Moonlight.Shared.Http.Responses.Admin; using Moonlight.Shared.Http.Responses.Admin;
namespace Moonlight.Api.Http.Controllers.Admin; namespace Moonlight.Api.Http.Controllers.Admin;
@@ -16,6 +18,7 @@ public class SystemController : Controller
} }
[HttpGet("info")] [HttpGet("info")]
[Authorize(Policy = Permissions.System.Info)]
public async Task<ActionResult<SystemInfoDto>> GetInfoAsync() public async Task<ActionResult<SystemInfoDto>> GetInfoAsync()
{ {
var cpuUsage = await ApplicationService.GetCpuUsageAsync(); var cpuUsage = await ApplicationService.GetCpuUsageAsync();

View File

@@ -0,0 +1,45 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Moonlight.Api.Database;
using Moonlight.Api.Database.Entities;
using Moonlight.Api.Services;
using Moonlight.Shared;
namespace Moonlight.Api.Http.Controllers.Admin;
[ApiController]
[Route("api/admin/users/{id:int}")]
public class UserActionsController : Controller
{
// Consider building a service for deletion and logout or actions in general
private readonly DatabaseRepository<User> UsersRepository;
private readonly IMemoryCache Cache;
public UserActionsController(DatabaseRepository<User> usersRepository, IMemoryCache cache)
{
UsersRepository = usersRepository;
Cache = cache;
}
[HttpPost("logout")]
[Authorize(Policy = Permissions.Users.Logout)]
public async Task<ActionResult> LogoutAsync([FromRoute] int id)
{
var user = await UsersRepository
.Query()
.FirstOrDefaultAsync(u => u.Id == id);
if(user == null)
return Problem("User not found", statusCode: 404);
user.InvalidateTimestamp = DateTimeOffset.UtcNow;
await UsersRepository.UpdateAsync(user);
Cache.Remove(string.Format(UserAuthService.ValidationCacheKeyPattern, id));
return NoContent();
}
}

View File

@@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
using Moonlight.Api.Database; using Moonlight.Api.Database;
using Moonlight.Api.Database.Entities; using Moonlight.Api.Database.Entities;
using Moonlight.Api.Mappers; using Moonlight.Api.Mappers;
using Moonlight.Shared;
using Moonlight.Shared.Http.Requests; using Moonlight.Shared.Http.Requests;
using Moonlight.Shared.Http.Requests.Users; using Moonlight.Shared.Http.Requests.Users;
using Moonlight.Shared.Http.Responses; using Moonlight.Shared.Http.Responses;
@@ -24,6 +25,7 @@ public class UsersController : Controller
} }
[HttpGet] [HttpGet]
[Authorize(Policy = Permissions.Users.View)]
public async Task<ActionResult<PagedData<UserDto>>> GetAsync( public async Task<ActionResult<PagedData<UserDto>>> GetAsync(
[FromQuery] int startIndex, [FromQuery] int startIndex,
[FromQuery] int length, [FromQuery] int length,
@@ -62,6 +64,7 @@ public class UsersController : Controller
// Pagination // Pagination
var data = await query var data = await query
.OrderBy(x => x.Id)
.ProjectToDto() .ProjectToDto()
.Skip(startIndex) .Skip(startIndex)
.Take(length) .Take(length)
@@ -73,6 +76,7 @@ public class UsersController : Controller
} }
[HttpGet("{id:int}")] [HttpGet("{id:int}")]
[Authorize(Policy = Permissions.Users.View)]
public async Task<ActionResult<UserDto>> GetAsync([FromRoute] int id) public async Task<ActionResult<UserDto>> GetAsync([FromRoute] int id)
{ {
var user = await UserRepository var user = await UserRepository
@@ -86,6 +90,7 @@ public class UsersController : Controller
} }
[HttpPost] [HttpPost]
[Authorize(Policy = Permissions.Users.Create)]
public async Task<ActionResult<UserDto>> CreateAsync([FromBody] CreateUserDto request) public async Task<ActionResult<UserDto>> CreateAsync([FromBody] CreateUserDto request)
{ {
var user = UserMapper.ToEntity(request); var user = UserMapper.ToEntity(request);
@@ -97,6 +102,7 @@ public class UsersController : Controller
} }
[HttpPatch("{id:int}")] [HttpPatch("{id:int}")]
[Authorize(Policy = Permissions.Users.Edit)]
public async Task<ActionResult<UserDto>> UpdateAsync([FromRoute] int id, [FromBody] UpdateUserDto request) public async Task<ActionResult<UserDto>> UpdateAsync([FromRoute] int id, [FromBody] UpdateUserDto request)
{ {
var user = await UserRepository var user = await UserRepository
@@ -113,6 +119,7 @@ public class UsersController : Controller
} }
[HttpDelete("{id:int}")] [HttpDelete("{id:int}")]
[Authorize(Policy = Permissions.Users.Delete)]
public async Task<ActionResult> DeleteAsync([FromRoute] int id) public async Task<ActionResult> DeleteAsync([FromRoute] int id)
{ {
var user = await UserRepository var user = await UserRepository

View File

@@ -1,11 +1,29 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Moonlight.Shared;
namespace Moonlight.Api.Implementations; namespace Moonlight.Api.Implementations;
public class PermissionAuthorizationHandler : AuthorizationHandler<PermissionRequirement> public class PermissionAuthorizationHandler : AuthorizationHandler<PermissionRequirement>
{ {
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, PermissionRequirement requirement) protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
PermissionRequirement requirement)
{ {
var permissionClaim = context.User.FindFirst(x =>
x.Type.Equals(Permissions.ClaimType, StringComparison.OrdinalIgnoreCase) &&
x.Value.Equals(requirement.Identifier, StringComparison.OrdinalIgnoreCase)
);
if (permissionClaim == null)
{
context.Fail(new AuthorizationFailureReason(
this,
$"User does not have the requested permission '{requirement.Identifier}'"
));
return Task.CompletedTask;
}
context.Succeed(requirement);
return Task.CompletedTask;
} }
} }

View File

@@ -15,13 +15,11 @@ public class PermissionPolicyProvider : IAuthorizationPolicyProvider
public async Task<AuthorizationPolicy?> GetPolicyAsync(string policyName) public async Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
{ {
if (!policyName.StartsWith("Permission:", StringComparison.OrdinalIgnoreCase)) if (!policyName.StartsWith(Permissions.Prefix, StringComparison.OrdinalIgnoreCase))
return await FallbackProvider.GetPolicyAsync(policyName); return await FallbackProvider.GetPolicyAsync(policyName);
var identifier = policyName.Substring(Permissions.Prefix.Length);
var policy = new AuthorizationPolicyBuilder(); var policy = new AuthorizationPolicyBuilder();
policy.AddRequirements(new PermissionRequirement(identifier)); policy.AddRequirements(new PermissionRequirement(policyName));
return policy.Build(); return policy.Build();
} }

View File

@@ -6,6 +6,7 @@ using Microsoft.Extensions.Options;
using Moonlight.Api.Configuration; using Moonlight.Api.Configuration;
using Moonlight.Api.Database; using Moonlight.Api.Database;
using Moonlight.Api.Database.Entities; using Moonlight.Api.Database.Entities;
using Moonlight.Shared;
namespace Moonlight.Api.Services; namespace Moonlight.Api.Services;
@@ -18,6 +19,8 @@ public class UserAuthService
private const string UserIdClaim = "UserId"; private const string UserIdClaim = "UserId";
private const string IssuedAtClaim = "IssuedAt"; private const string IssuedAtClaim = "IssuedAt";
public const string ValidationCacheKeyPattern = $"Moonlight.{nameof(UserAuthService)}.{nameof(ValidateAsync)}-{{0}}";
public UserAuthService( public UserAuthService(
DatabaseRepository<User> userRepository, DatabaseRepository<User> userRepository,
@@ -93,7 +96,10 @@ public class UserAuthService
.Query() .Query()
.AsNoTracking() .AsNoTracking()
.Where(u => u.Id == userId) .Where(u => u.Id == userId)
.Select(u => new UserSession(u.InvalidateTimestamp)) .Select(u => new UserSession(
u.InvalidateTimestamp,
u.RoleMemberships.SelectMany(x => x.Role.Permissions).ToArray())
)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (user == null) if (user == null)
@@ -122,10 +128,17 @@ public class UserAuthService
// everything is fine. If not, it means that the token should be invalidated // everything is fine. If not, it means that the token should be invalidated
// as it is too old // as it is too old
return issuedAt > user.InvalidateTimestamp; if (issuedAt < user.InvalidateTimestamp)
return false;
principal.Identities.First().AddClaims(
user.Permissions.Select(x => new Claim(Permissions.ClaimType, x))
);
return true;
} }
// A small model which contains data queried per session validation after the defined cache time. // A small model which contains data queried per session validation after the defined cache time.
// Used for projection // Used for projection
private record UserSession(DateTimeOffset InvalidateTimestamp); private record UserSession(DateTimeOffset InvalidateTimestamp, string[] Permissions);
} }

View File

@@ -1,10 +1,12 @@
using System.Security.Claims; using System.Security.Claims;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Moonlight.Api.Configuration; using Moonlight.Api.Configuration;
using Moonlight.Api.Implementations;
using Moonlight.Api.Services; using Moonlight.Api.Services;
namespace Moonlight.Api.Startup; namespace Moonlight.Api.Startup;
@@ -80,7 +82,8 @@ public partial class Startup
options.GetClaimsFromUserInfoEndpoint = true; options.GetClaimsFromUserInfoEndpoint = true;
}); });
builder.Services.AddAuthorization(); builder.Services.AddSingleton<IAuthorizationHandler, PermissionAuthorizationHandler>();
builder.Services.AddSingleton<IAuthorizationPolicyProvider, PermissionPolicyProvider>();
} }
private static void UseAuth(WebApplication application) private static void UseAuth(WebApplication application)

View File

@@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Authorization;
using Moonlight.Shared;
namespace Moonlight.Frontend.Implementations;
public class PermissionAuthorizationHandler : AuthorizationHandler<PermissionRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
PermissionRequirement requirement)
{
var permissionClaim = context.User.FindFirst(x =>
x.Type.Equals(Permissions.ClaimType, StringComparison.OrdinalIgnoreCase) &&
x.Value.Equals(requirement.Identifier, StringComparison.OrdinalIgnoreCase)
);
if (permissionClaim == null)
{
context.Fail(new AuthorizationFailureReason(
this,
$"User does not have the requested permission '{requirement.Identifier}'"
));
return Task.CompletedTask;
}
context.Succeed(requirement);
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,42 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
using Moonlight.Shared;
namespace Moonlight.Frontend.Implementations;
public class PermissionPolicyProvider : IAuthorizationPolicyProvider
{
private readonly DefaultAuthorizationPolicyProvider FallbackProvider;
public PermissionPolicyProvider(IOptions<AuthorizationOptions> options)
{
FallbackProvider = new DefaultAuthorizationPolicyProvider(options);
}
public async Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
{
if (!policyName.StartsWith(Permissions.Prefix, StringComparison.OrdinalIgnoreCase))
return await FallbackProvider.GetPolicyAsync(policyName);
var policy = new AuthorizationPolicyBuilder();
policy.AddRequirements(new PermissionRequirement(policyName));
return policy.Build();
}
public Task<AuthorizationPolicy> GetDefaultPolicyAsync()
=> FallbackProvider.GetDefaultPolicyAsync();
public Task<AuthorizationPolicy?> GetFallbackPolicyAsync()
=> FallbackProvider.GetFallbackPolicyAsync();
}
public class PermissionRequirement : IAuthorizationRequirement
{
public string Identifier { get; }
public PermissionRequirement(string identifier)
{
Identifier = identifier;
}
}

View File

@@ -10,11 +10,23 @@ public sealed class PermissionProvider : IPermissionProvider
public Task<PermissionCategory[]> GetPermissionsAsync() public Task<PermissionCategory[]> GetPermissionsAsync()
{ {
return Task.FromResult<PermissionCategory[]>([ return Task.FromResult<PermissionCategory[]>([
new PermissionCategory("User Management", typeof(UsersRoundIcon), [ new PermissionCategory("Users", typeof(UserRoundIcon), [
new Permission(Permissions.Admin.Users.Create, "Create", "Create new users"), new Permission(Permissions.Users.Create, "Create", "Create new users"),
new Permission(Permissions.Admin.Users.View, "View", "View all users"), new Permission(Permissions.Users.View, "View", "View all users"),
new Permission(Permissions.Admin.Users.Edit, "Edit", "Edit user details"), new Permission(Permissions.Users.Edit, "Edit", "Edit user details"),
new Permission(Permissions.Admin.Users.Delete, "Delete", "Delete user accounts"), new Permission(Permissions.Users.Delete, "Delete", "Delete user accounts"),
new Permission(Permissions.Users.Logout, "Logout", "Logout user accounts"),
]),
new PermissionCategory("Roles", typeof(UsersRoundIcon), [
new Permission(Permissions.Roles.Create, "Create", "Create new roles"),
new Permission(Permissions.Roles.View, "View", "View all roles"),
new Permission(Permissions.Roles.Edit, "Edit", "Edit role details"),
new Permission(Permissions.Roles.Delete, "Delete", "Delete role accounts"),
new Permission(Permissions.Roles.Members, "Members", "Manage role members"),
]),
new PermissionCategory("System", typeof(CogIcon), [
new Permission(Permissions.System.Info, "Info", "View system info"),
new Permission(Permissions.System.Diagnose, "Diagnose", "Run diagnostics"),
]), ]),
]); ]);
} }

View File

@@ -1,6 +1,7 @@
using LucideBlazor; using LucideBlazor;
using Moonlight.Frontend.Interfaces; using Moonlight.Frontend.Interfaces;
using Moonlight.Frontend.Models; using Moonlight.Frontend.Models;
using Moonlight.Shared;
namespace Moonlight.Frontend.Implementations; namespace Moonlight.Frontend.Implementations;
@@ -24,7 +25,8 @@ public sealed class SidebarProvider : ISidebarProvider
Path = "/admin", Path = "/admin",
IsExactPath = true, IsExactPath = true,
Group = "Admin", Group = "Admin",
Order = 0 Order = 0,
Policy = Permissions.System.Info
}, },
new() new()
{ {
@@ -33,7 +35,8 @@ public sealed class SidebarProvider : ISidebarProvider
Path = "/admin/users", Path = "/admin/users",
IsExactPath = false, IsExactPath = false,
Group = "Admin", Group = "Admin",
Order = 10 Order = 10,
Policy = Permissions.Users.View
}, },
new() new()
{ {
@@ -42,7 +45,8 @@ public sealed class SidebarProvider : ISidebarProvider
Path = "/admin/system", Path = "/admin/system",
IsExactPath = false, IsExactPath = false,
Group = "Admin", Group = "Admin",
Order = 20 Order = 20,
Policy = Permissions.System.Info
} }
]); ]);
} }

View File

@@ -14,4 +14,6 @@ public record SidebarItem
// need it to work properly // need it to work properly
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)]
public Type IconType { get; init; } public Type IconType { get; init; }
public string? Policy { get; set; }
} }

View File

@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting; using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -16,5 +17,8 @@ public partial class Startup
builder.Services.AddCascadingAuthenticationState(); builder.Services.AddCascadingAuthenticationState();
builder.Services.AddSingleton<IPermissionProvider, PermissionProvider>(); builder.Services.AddSingleton<IPermissionProvider, PermissionProvider>();
builder.Services.AddSingleton<IAuthorizationHandler, PermissionAuthorizationHandler>();
builder.Services.AddSingleton<IAuthorizationPolicyProvider, PermissionPolicyProvider>();
} }
} }

View File

@@ -49,9 +49,11 @@
<TemplateColumn> <TemplateColumn>
<CellTemplate> <CellTemplate>
<TableCell> <TableCell>
<WButtom OnClick="_ => RemoveAsync(context)" Variant="ButtonVariant.Destructive" Size="ButtonSize.Icon"> <div class="flex justify-end me-1.5">
<TrashIcon/> <WButtom OnClick="_ => RemoveAsync(context)" Variant="ButtonVariant.Destructive" Size="ButtonSize.Icon">
</WButtom> <TrashIcon/>
</WButtom>
</div>
</TableCell> </TableCell>
</CellTemplate> </CellTemplate>
</TemplateColumn> </TemplateColumn>

View File

@@ -1,4 +1,7 @@
@using LucideBlazor @using LucideBlazor
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Moonlight.Shared
@using Moonlight.Shared.Http.Responses.Admin @using Moonlight.Shared.Http.Responses.Admin
@using ShadcnBlazor.Accordions @using ShadcnBlazor.Accordions
@using ShadcnBlazor.Alerts @using ShadcnBlazor.Alerts
@@ -9,6 +12,7 @@
@using ShadcnBlazor.Spinners @using ShadcnBlazor.Spinners
@inject HttpClient HttpClient @inject HttpClient HttpClient
@inject IAuthorizationService AuthorizationService
<div class="grid grid-cols-1 xl:grid-cols-2 gap-5 mt-5"> <div class="grid grid-cols-1 xl:grid-cols-2 gap-5 mt-5">
<div class="col-span-1"> <div class="col-span-1">
@@ -40,7 +44,7 @@
</Alert> </Alert>
</CardContent> </CardContent>
<CardFooter ClassName="justify-end"> <CardFooter ClassName="justify-end">
<WButtom OnClick="DiagnoseAsync"> <WButtom OnClick="DiagnoseAsync" disabled="@(!AccessResult.Succeeded)">
<StethoscopeIcon/> <StethoscopeIcon/>
Start diagnostics Start diagnostics
</WButtom> </WButtom>
@@ -216,10 +220,21 @@
@code @code
{ {
[CascadingParameter] public Task<AuthenticationState> AuthState { get; set; }
private AuthorizationResult AccessResult;
private bool IsLoading = false; private bool IsLoading = false;
private bool HasDiagnosed = false; private bool HasDiagnosed = false;
private DiagnoseResultDto[] Entries; private DiagnoseResultDto[] Entries;
protected override async Task OnInitializedAsync()
{
var authState = await AuthState;
AccessResult = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.System.Diagnose);
}
private async Task DiagnoseAsync() private async Task DiagnoseAsync()
{ {
IsLoading = true; IsLoading = true;

View File

@@ -1,9 +1,13 @@
@page "/admin/users" @page "/admin/users"
@using LucideBlazor @using LucideBlazor
@using Microsoft.AspNetCore.Authorization
@using Moonlight.Shared
@using ShadcnBlazor.Tab @using ShadcnBlazor.Tab
@inject NavigationManager Navigation @inject NavigationManager Navigation
@attribute [Authorize(Policy = Permissions.Users.View)]
<Tabs DefaultValue="@(Tab ?? "users")" OnValueChanged="OnTabChanged"> <Tabs DefaultValue="@(Tab ?? "users")" OnValueChanged="OnTabChanged">
<TabsList ClassName="inline-flex w-full lg:w-fit justify-start overflow-x-auto overflow-y-hidden"> <TabsList ClassName="inline-flex w-full lg:w-fit justify-start overflow-x-auto overflow-y-hidden">
<TabsTrigger Value="users"> <TabsTrigger Value="users">

View File

@@ -1,5 +1,8 @@
@using LucideBlazor @using LucideBlazor
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Moonlight.Frontend.UI.Admin.Modals @using Moonlight.Frontend.UI.Admin.Modals
@using Moonlight.Shared
@using Moonlight.Shared.Http.Requests @using Moonlight.Shared.Http.Requests
@using Moonlight.Shared.Http.Requests.Roles @using Moonlight.Shared.Http.Requests.Roles
@using Moonlight.Shared.Http.Responses @using Moonlight.Shared.Http.Responses
@@ -16,6 +19,7 @@
@inject DialogService DialogService @inject DialogService DialogService
@inject ToastService ToastService @inject ToastService ToastService
@inject AlertDialogService AlertDialogService @inject AlertDialogService AlertDialogService
@inject IAuthorizationService AuthorizationService
<div class="flex flex-row justify-between mt-5"> <div class="flex flex-row justify-between mt-5">
<div class="flex flex-col"> <div class="flex flex-col">
@@ -25,7 +29,7 @@
</div> </div>
</div> </div>
<div class="flex flex-row gap-x-1.5"> <div class="flex flex-row gap-x-1.5">
<Button @onclick="CreateAsync"> <Button @onclick="CreateAsync" disabled="@(!CreateAccess.Succeeded)">
<PlusIcon/> <PlusIcon/>
Create Create
</Button> </Button>
@@ -63,20 +67,21 @@
</Slot> </Slot>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent SideOffset="2"> <DropdownMenuContent SideOffset="2">
<DropdownMenuItem OnClick="() => MembersAsync(context)"> <DropdownMenuItem OnClick="() => MembersAsync(context)" Disabled="@(!MembersAccess.Succeeded)">
Members Members
<DropdownMenuShortcut> <DropdownMenuShortcut>
<UsersRoundIcon/> <UsersRoundIcon/>
</DropdownMenuShortcut> </DropdownMenuShortcut>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem OnClick="() => EditAsync(context)"> <DropdownMenuItem OnClick="() => EditAsync(context)" Disabled="@(!EditAccess.Succeeded)">
Edit Edit
<DropdownMenuShortcut> <DropdownMenuShortcut>
<PenIcon/> <PenIcon/>
</DropdownMenuShortcut> </DropdownMenuShortcut>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem OnClick="() => DeleteAsync(context)" <DropdownMenuItem OnClick="() => DeleteAsync(context)"
Variant="DropdownMenuItemVariant.Destructive"> Variant="DropdownMenuItemVariant.Destructive"
Disabled="@(!DeleteAccess.Succeeded)">
Delete Delete
<DropdownMenuShortcut> <DropdownMenuShortcut>
<TrashIcon/> <TrashIcon/>
@@ -93,8 +98,25 @@
@code @code
{ {
[CascadingParameter] public Task<AuthenticationState> AuthState { get; set; }
private DataGrid<RoleDto> Grid; private DataGrid<RoleDto> Grid;
private AuthorizationResult MembersAccess;
private AuthorizationResult EditAccess;
private AuthorizationResult DeleteAccess;
private AuthorizationResult CreateAccess;
protected override async Task OnInitializedAsync()
{
var authState = await AuthState;
MembersAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.Roles.Members);
EditAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.Roles.Edit);
DeleteAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.Roles.Delete);
CreateAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.Roles.Create);
}
private async Task<DataGridResponse<RoleDto>> LoadAsync(DataGridRequest<RoleDto> request) private async Task<DataGridResponse<RoleDto>> LoadAsync(DataGridRequest<RoleDto> request)
{ {
var query = $"?startIndex={request.StartIndex}&length={request.Length}"; var query = $"?startIndex={request.StartIndex}&length={request.Length}";
@@ -147,6 +169,12 @@
private async Task MembersAsync(RoleDto role) private async Task MembersAsync(RoleDto role)
{ {
if (!MembersAccess.Succeeded)
{
await ToastService.ErrorAsync("Permission denied", "You dont have the required permission to manage members");
return;
}
await DialogService.LaunchAsync<ManageRoleMembersDialog>(parameters => { parameters[nameof(ManageRoleMembersDialog.Role)] = role; }, model => { model.ClassName = "sm:max-w-xl"; }); await DialogService.LaunchAsync<ManageRoleMembersDialog>(parameters => { parameters[nameof(ManageRoleMembersDialog.Role)] = role; }, model => { model.ClassName = "sm:max-w-xl"; });
} }
@@ -157,7 +185,9 @@
$"Do you really want to delete the role {role.Name} with {role.MemberCount} members? This action cannot be undone", $"Do you really want to delete the role {role.Name} with {role.MemberCount} members? This action cannot be undone",
async () => async () =>
{ {
await HttpClient.DeleteAsync($"api/admin/roles/{role.Id}"); var response = await HttpClient.DeleteAsync($"api/admin/roles/{role.Id}");
response.EnsureSuccessStatusCode();
await ToastService.SuccessAsync("User deletion", $"Successfully deleted role {role.Name}"); await ToastService.SuccessAsync("User deletion", $"Successfully deleted role {role.Name}");
await Grid.RefreshAsync(); await Grid.RefreshAsync();

View File

@@ -1,5 +1,8 @@
@using LucideBlazor @using LucideBlazor
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Moonlight.Frontend.UI.Admin.Modals @using Moonlight.Frontend.UI.Admin.Modals
@using Moonlight.Shared
@using ShadcnBlazor.Buttons @using ShadcnBlazor.Buttons
@using ShadcnBlazor.DataGrids @using ShadcnBlazor.DataGrids
@using ShadcnBlazor.Dropdowns @using ShadcnBlazor.Dropdowns
@@ -16,6 +19,7 @@
@inject AlertDialogService AlertDialogService @inject AlertDialogService AlertDialogService
@inject DialogService DialogService @inject DialogService DialogService
@inject ToastService ToastService @inject ToastService ToastService
@inject IAuthorizationService AuthorizationService
<div class="flex flex-row justify-between mt-5"> <div class="flex flex-row justify-between mt-5">
<div class="flex flex-col"> <div class="flex flex-col">
@@ -25,7 +29,7 @@
</div> </div>
</div> </div>
<div class="flex flex-row gap-x-1.5"> <div class="flex flex-row gap-x-1.5">
<Button @onclick="CreateAsync"> <Button @onclick="CreateAsync" disabled="@(!CreateAccess.Succeeded)">
<PlusIcon/> <PlusIcon/>
Create Create
</Button> </Button>
@@ -46,19 +50,28 @@
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger> <DropdownMenuTrigger>
<Slot Context="dropdownSlot"> <Slot Context="dropdownSlot">
<Button Size="ButtonSize.IconSm" Variant="ButtonVariant.Ghost" @attributes="dropdownSlot"> <Button Size="ButtonSize.IconSm" Variant="ButtonVariant.Ghost"
@attributes="dropdownSlot">
<EllipsisIcon/> <EllipsisIcon/>
</Button> </Button>
</Slot> </Slot>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent SideOffset="2"> <DropdownMenuContent SideOffset="2">
<DropdownMenuItem OnClick="() => EditAsync(context)"> <DropdownMenuItem OnClick="() => LogoutAsync(context)" Disabled="@(!LogoutAccess.Succeeded)">
Logout
<DropdownMenuShortcut>
<LogOutIcon/>
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem OnClick="() => EditAsync(context)" Disabled="@(!EditAccess.Succeeded)">
Edit Edit
<DropdownMenuShortcut> <DropdownMenuShortcut>
<PenIcon/> <PenIcon/>
</DropdownMenuShortcut> </DropdownMenuShortcut>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem OnClick="() => DeleteAsync(context)" Variant="DropdownMenuItemVariant.Destructive"> <DropdownMenuItem OnClick="() => DeleteAsync(context)"
Variant="DropdownMenuItemVariant.Destructive"
Disabled="@(!DeleteAccess.Succeeded)">
Delete Delete
<DropdownMenuShortcut> <DropdownMenuShortcut>
<TrashIcon/> <TrashIcon/>
@@ -75,7 +88,24 @@
@code @code
{ {
[CascadingParameter] public Task<AuthenticationState> AuthState { get; set; }
private DataGrid<UserDto> Grid; private DataGrid<UserDto> Grid;
private AuthorizationResult LogoutAccess;
private AuthorizationResult EditAccess;
private AuthorizationResult DeleteAccess;
private AuthorizationResult CreateAccess;
protected override async Task OnInitializedAsync()
{
var authState = await AuthState;
LogoutAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.Users.Logout);
EditAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.Users.Edit);
DeleteAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.Users.Delete);
CreateAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.Users.Create);
}
private async Task<DataGridResponse<UserDto>> LoadAsync(DataGridRequest<UserDto> request) private async Task<DataGridResponse<UserDto>> LoadAsync(DataGridRequest<UserDto> request)
{ {
@@ -141,11 +171,30 @@
"Do you really want to delete this user? This action cannot be undone", "Do you really want to delete this user? This action cannot be undone",
async () => async () =>
{ {
await HttpClient.DeleteAsync($"api/admin/users/{user.Id}"); var response = await HttpClient.DeleteAsync($"api/admin/users/{user.Id}");
response.EnsureSuccessStatusCode();
await ToastService.SuccessAsync("User deletion", $"Successfully deleted user {user.Username}"); await ToastService.SuccessAsync("User deletion", $"Successfully deleted user {user.Username}");
await Grid.RefreshAsync(); await Grid.RefreshAsync();
} }
); );
} }
private async Task LogoutAsync(UserDto user)
{
await AlertDialogService.ConfirmDangerAsync(
$"Logout all session of user {user.Username}",
"Do you really want to logout all session of this user? This action cannot be undone",
async () =>
{
var response = await HttpClient.PostAsync($"api/admin/users/{user.Id}/logout", null);
response.EnsureSuccessStatusCode();
await ToastService.SuccessAsync("User logout", $"Successfully logged out all session of user {user.Username}");
await Grid.RefreshAsync();
}
);
}
} }

View File

@@ -1,4 +1,5 @@
@using LucideBlazor @using System.Net
@using LucideBlazor
@using Microsoft.AspNetCore.Components.Authorization @using Microsoft.AspNetCore.Components.Authorization
@using Moonlight.Frontend.UI.Shared @using Moonlight.Frontend.UI.Shared
@using ShadcnBlazor.Emptys @using ShadcnBlazor.Emptys
@@ -35,20 +36,27 @@
</AuthorizeView> </AuthorizeView>
</ChildContent> </ChildContent>
<ErrorContent> <ErrorContent>
<div class="m-10"> @if (context is HttpRequestException { StatusCode: HttpStatusCode.Unauthorized })
<Empty> {
<EmptyHeader> <Authentication/>
<EmptyMedia Variant="EmptyMediaVariant.Icon"> }
<OctagonAlertIcon ClassName="text-red-500/80"/> else
</EmptyMedia> {
<EmptyTitle> <div class="m-10">
Critical Application Error <Empty>
</EmptyTitle> <EmptyHeader>
<EmptyDescription> <EmptyMedia Variant="EmptyMediaVariant.Icon">
@context.ToString() <OctagonAlertIcon ClassName="text-red-500/80"/>
</EmptyDescription> </EmptyMedia>
</EmptyHeader> <EmptyTitle>
</Empty> Critical Application Error
</div> </EmptyTitle>
<EmptyDescription>
@context.ToString()
</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
}
</ErrorContent> </ErrorContent>
</ErrorBoundary> </ErrorBoundary>

View File

@@ -1,8 +1,11 @@
@using Moonlight.Frontend.Interfaces @using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Moonlight.Frontend.Interfaces
@using Moonlight.Frontend.Models @using Moonlight.Frontend.Models
@using ShadcnBlazor.Sidebars @using ShadcnBlazor.Sidebars
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject IAuthorizationService AuthorizationService
@inject IEnumerable<ISidebarProvider> Providers @inject IEnumerable<ISidebarProvider> Providers
@implements IDisposable @implements IDisposable
@@ -68,15 +71,30 @@
@code @code
{ {
[CascadingParameter] public Task<AuthenticationState> AuthState { get; set; }
private readonly List<SidebarItem> Items = new(); private readonly List<SidebarItem> Items = new();
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
var authState = await AuthState;
foreach (var provider in Providers) foreach (var provider in Providers)
{ {
Items.AddRange( var items = await provider.GetItemsAsync();
await provider.GetItemsAsync()
); foreach (var item in items)
{
if (!string.IsNullOrWhiteSpace(item.Policy))
{
var result = await AuthorizationService.AuthorizeAsync(authState.User, item.Policy);
if(!result.Succeeded)
continue;
}
Items.Add(item);
}
} }
Navigation.LocationChanged += OnLocationChanged; Navigation.LocationChanged += OnLocationChanged;

View File

@@ -5,16 +5,33 @@ public static class Permissions
public const string Prefix = "Permissions:"; public const string Prefix = "Permissions:";
public const string ClaimType = "Permissions"; public const string ClaimType = "Permissions";
public static class Admin public static class Users
{ {
public static class Users private const string Section = "Users";
{
private const string Section = "Users";
public const string View = $"{Prefix}{Section}.View"; public const string View = $"{Prefix}{Section}.{nameof(View)}";
public const string Edit = $"{Prefix}{Section}.Edit"; public const string Edit = $"{Prefix}{Section}.{nameof(Edit)}";
public const string Create = $"{Prefix}{Section}.Create"; public const string Create = $"{Prefix}{Section}.{nameof(Create)}";
public const string Delete = $"{Prefix}{Section}.Delete"; public const string Delete = $"{Prefix}{Section}.{nameof(Delete)}";
} public const string Logout = $"{Prefix}{Section}.{nameof(Logout)}";
}
public static class Roles
{
private const string Section = "Roles";
public const string View = $"{Prefix}{Section}.{nameof(View)}";
public const string Edit = $"{Prefix}{Section}.{nameof(Edit)}";
public const string Create = $"{Prefix}{Section}.{nameof(Create)}";
public const string Delete = $"{Prefix}{Section}.{nameof(Delete)}";
public const string Members = $"{Prefix}{Section}.{nameof(Members)}";
}
public static class System
{
private const string Section = "System";
public const string Info = $"{Prefix}{Section}.{nameof(Info)}";
public const string Diagnose = $"{Prefix}{Section}.{nameof(Diagnose)}";
} }
} }