feat/ImproveRoles #4

Merged
ChiaraBm merged 4 commits from feat/ImproveRoles into v2.1 2026-01-16 12:08:41 +00:00
31 changed files with 1069 additions and 374 deletions

View File

@@ -0,0 +1,6 @@
namespace Moonlight.Api.Configuration;
public class SessionOptions
{
public int ValidationCacheMinutes { get; set; } = 3;
}

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

@@ -0,0 +1,171 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Moonlight.Api.Database;
using Moonlight.Api.Database.Entities;
using Moonlight.Api.Mappers;
using Moonlight.Shared;
using Moonlight.Shared.Http.Responses;
using Moonlight.Shared.Http.Responses.Users;
namespace Moonlight.Api.Http.Controllers.Admin;
[ApiController]
[Authorize(Policy = Permissions.Roles.Members)]
[Route("api/admin/roles/{roleId:int}/members")]
public class RoleMembersController : Controller
{
private readonly DatabaseRepository<User> UsersRepository;
private readonly DatabaseRepository<Role> RolesRepository;
private readonly DatabaseRepository<RoleMember> RoleMembersRepository;
public RoleMembersController(
DatabaseRepository<User> usersRepository,
DatabaseRepository<Role> rolesRepository,
DatabaseRepository<RoleMember> roleMembersRepository
)
{
UsersRepository = usersRepository;
RolesRepository = rolesRepository;
RoleMembersRepository = roleMembersRepository;
}
[HttpGet]
public async Task<ActionResult<PagedData<UserDto>>> GetAsync(
[FromRoute] int roleId,
[FromQuery] int startIndex, [FromQuery] int length,
[FromQuery] string? searchTerm
)
{
// Validation
if (startIndex < 0)
return Problem("Invalid start index specified", statusCode: 400);
if (length is < 1 or > 100)
return Problem("Invalid length specified");
// Query building
var query = RoleMembersRepository
.Query()
.Where(x => x.Role.Id == roleId)
.Select(x => x.User);
// Filtering
if (!string.IsNullOrWhiteSpace(searchTerm))
{
query = query.Where(x =>
EF.Functions.ILike(x.Username, $"%{searchTerm}%") ||
EF.Functions.ILike(x.Email, $"%{searchTerm}%")
);
}
// Pagination
var items = query
.OrderBy(x => x.Id)
.Skip(startIndex)
.Take(length)
.ProjectToDto()
.ToArray();
var totalCount = await query.CountAsync();
return new PagedData<UserDto>(items, totalCount);
}
[HttpGet("available")]
public async Task<ActionResult<PagedData<UserDto>>> GetAvailableAsync(
[FromRoute] int roleId,
[FromQuery] int startIndex, [FromQuery] int length,
[FromQuery] string? searchTerm
)
{
// Validation
if (startIndex < 0)
return Problem("Invalid start index specified", statusCode: 400);
if (length is < 1 or > 100)
return Problem("Invalid length specified");
// Query building
var query = UsersRepository
.Query()
.Where(x => x.RoleMemberships.All(y => y.Role.Id != roleId));
// Filtering
if (!string.IsNullOrWhiteSpace(searchTerm))
{
query = query.Where(x =>
EF.Functions.ILike(x.Username, $"%{searchTerm}%") ||
EF.Functions.ILike(x.Email, $"%{searchTerm}%")
);
}
// Pagination
var items = query
.OrderBy(x => x.Id)
.Skip(startIndex)
.Take(length)
.ProjectToDto()
.ToArray();
var totalCount = await query.CountAsync();
return new PagedData<UserDto>(items, totalCount);
}
[HttpPut("{userId:int}")]
public async Task<ActionResult> AddMemberAsync([FromRoute] int roleId, [FromRoute] int userId)
{
// Check and load role
var role = await RolesRepository
.Query()
.FirstOrDefaultAsync(x => x.Id == roleId);
if (role == null)
return Problem("Role not found", statusCode: 404);
// Check and load user
var user = await UsersRepository
.Query()
.FirstOrDefaultAsync(x => x.Id == userId);
if (user == null)
return Problem("User not found", statusCode: 404);
// Check if a role member already exists with these details
var isUserInRole = await RoleMembersRepository
.Query()
.AnyAsync(x => x.Role.Id == roleId && x.User.Id == userId);
if (isUserInRole)
return Problem("User is already a member of this role", statusCode: 400);
var roleMember = new RoleMember
{
Role = role,
User = user
};
await RoleMembersRepository.AddAsync(roleMember);
return NoContent();
}
[HttpDelete("{userId:int}")]
public async Task<ActionResult> RemoveMemberAsync([FromRoute] int roleId, [FromRoute] int userId)
{
var roleMember = await RoleMembersRepository
.Query()
.FirstOrDefaultAsync(x => x.User.Id == userId && x.Role.Id == roleId);
if (roleMember == null)
return Problem("User is not a member of this role, the role does not exist or the user does not exist",
statusCode: 404);
await RoleMembersRepository.RemoveAsync(roleMember);
return NoContent();
}
}

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

@@ -1,19 +1,20 @@
using Microsoft.AspNetCore.Authorization; 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.Entities;
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;
using Moonlight.Shared.Http.Responses.Users; using Moonlight.Shared.Http.Responses.Users;
using Moonlight.Api.Database;
using Moonlight.Api.Database.Entities;
using Moonlight.Api.Mappers;
namespace Moonlight.Api.Http.Controllers; namespace Moonlight.Api.Http.Controllers.Admin;
[Authorize] [Authorize]
[ApiController] [ApiController]
[Route("api/users")] [Route("api/admin/users")]
public class UsersController : Controller public class UsersController : Controller
{ {
private readonly DatabaseRepository<User> UserRepository; private readonly DatabaseRepository<User> UserRepository;
@@ -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

@@ -1,23 +1,36 @@
using System.Security.Claims; using System.Security.Claims;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
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;
public class UserAuthService public class UserAuthService
{ {
private readonly DatabaseRepository<User> UserRepository; private readonly DatabaseRepository<User> UserRepository;
private readonly IMemoryCache Cache;
private readonly ILogger<UserAuthService> Logger; private readonly ILogger<UserAuthService> Logger;
private readonly IOptions<SessionOptions> Options;
private const string UserIdClaim = "UserId"; private const string UserIdClaim = "UserId";
private const string IssuedAtClaim = "IssuedAt"; private const string IssuedAtClaim = "IssuedAt";
public UserAuthService(DatabaseRepository<User> userRepository, ILogger<UserAuthService> logger) public const string ValidationCacheKeyPattern = $"Moonlight.{nameof(UserAuthService)}.{nameof(ValidateAsync)}-{{0}}";
public UserAuthService(
DatabaseRepository<User> userRepository,
ILogger<UserAuthService> logger,
IMemoryCache cache, IOptions<SessionOptions> options)
{ {
UserRepository = userRepository; UserRepository = userRepository;
Logger = logger; Logger = logger;
Cache = cache;
Options = options;
} }
public async Task<bool> SyncAsync(ClaimsPrincipal? principal) public async Task<bool> SyncAsync(ClaimsPrincipal? principal)
@@ -75,14 +88,35 @@ public class UserAuthService
if (!int.TryParse(userIdString, out var userId)) if (!int.TryParse(userIdString, out var userId))
return false; return false;
var user = await UserRepository var cacheKey = $"Moonlight.{nameof(UserAuthService)}.{nameof(ValidateAsync)}-{userId}";
if (!Cache.TryGetValue<UserSession>(cacheKey, out var user))
{
user = await UserRepository
.Query() .Query()
.AsNoTracking() .AsNoTracking()
.FirstOrDefaultAsync(user => user.Id == userId); .Where(u => u.Id == userId)
.Select(u => new UserSession(
u.InvalidateTimestamp,
u.RoleMemberships.SelectMany(x => x.Role.Permissions).ToArray())
)
.FirstOrDefaultAsync();
if (user == null) if (user == null)
return false; return false;
Cache.Set(
cacheKey,
user,
TimeSpan.FromMinutes(Options.Value.ValidationCacheMinutes)
);
}
else
{
if (user == null)
return false;
}
var issuedAtString = principal.FindFirstValue(IssuedAtClaim); var issuedAtString = principal.FindFirstValue(IssuedAtClaim);
if (!long.TryParse(issuedAtString, out var issuedAtUnix)) if (!long.TryParse(issuedAtString, out var issuedAtUnix))
@@ -90,10 +124,21 @@ public class UserAuthService
var issuedAt = DateTimeOffset.FromUnixTimeSeconds(issuedAtUnix).ToUniversalTime(); var issuedAt = DateTimeOffset.FromUnixTimeSeconds(issuedAtUnix).ToUniversalTime();
// If the issued at timestamp is greater than the token validation timestamp // If the issued at timestamp is greater than the token validation timestamp,
// 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.
// Used for projection
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

@@ -29,6 +29,9 @@ public partial class Startup
builder.Services.AddSingleton<DiagnoseService>(); builder.Services.AddSingleton<DiagnoseService>();
builder.Services.AddSingleton<IDiagnoseProvider, UpdateDiagnoseProvider>(); builder.Services.AddSingleton<IDiagnoseProvider, UpdateDiagnoseProvider>();
builder.Services.AddMemoryCache();
builder.Services.AddOptions<SessionOptions>().BindConfiguration("Moonlight:Session");
} }
private static void UseBase(WebApplication application) private static void UseBase(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,16 +25,18 @@ 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()
{ {
Name = "Users", Name = "Users",
IconType = typeof(UsersRoundIcon), IconType = typeof(UsersRoundIcon),
Path = "/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

@@ -0,0 +1,65 @@
@using Moonlight.Shared.Http.Requests.Users
@using ShadcnBlazor.Dialogs
@using ShadcnBlazor.Extras.Common
@using ShadcnBlazor.Extras.FormHandlers
@using ShadcnBlazor.Inputs
@using ShadcnBlazor.Labels
@inherits ShadcnBlazor.Extras.Dialogs.DialogBase
<DialogHeader>
<DialogTitle>
Create new user
</DialogTitle>
<DialogDescription>
Create a new user by giving it a username and an email address
</DialogDescription>
</DialogHeader>
<FormHandler @ref="FormHandler" Model="Request" OnValidSubmit="SubmitAsync">
<div class="flex flex-col gap-6">
<FormValidationSummary/>
<div class="grid gap-2">
<Label for="username">Username</Label>
<InputField
@bind-Value="Request.Username"
id="username"
placeholder="Name of the user"/>
</div>
<div class="grid gap-2">
<Label for="emailAddress">Email Address</Label>
<InputField
@bind-Value="Request.Email"
id="emailAddress"
Type="email"
placeholder="email@of.user"/>
</div>
</div>
</FormHandler>
<DialogFooter ClassName="justify-end gap-x-1">
<WButtom OnClick="_ => FormHandler.SubmitAsync()">Save changes</WButtom>
</DialogFooter>
@code
{
[Parameter] public Func<CreateUserDto, Task> OnSubmit { get; set; }
private CreateUserDto Request;
private FormHandler FormHandler;
protected override void OnInitialized()
{
Request = new();
}
private async Task SubmitAsync()
{
await OnSubmit.Invoke(Request);
await CloseAsync();
}
}

View File

@@ -0,0 +1,116 @@
@using LucideBlazor
@using Moonlight.Shared.Http.Responses
@using Moonlight.Shared.Http.Responses.Admin
@using Moonlight.Shared.Http.Responses.Users
@using ShadcnBlazor.Buttons
@using ShadcnBlazor.DataGrids
@using ShadcnBlazor.Dialogs
@using ShadcnBlazor.Extras.Comboboxes
@using ShadcnBlazor.Extras.Common
@using ShadcnBlazor.Labels
@using ShadcnBlazor.Tabels
@inherits ShadcnBlazor.Extras.Dialogs.DialogBase
@inject HttpClient HttpClient
<DialogHeader>
<DialogTitle>
Manage members of @Role.Name
</DialogTitle>
<DialogDescription>
Manage members of this role
</DialogDescription>
</DialogHeader>
<div class="grid gap-2 mt-5">
<Label>Add new member</Label>
<div class="flex justify-start gap-1">
<Combobox @bind-Value="SelectedUser"
ClassName="w-[200px]"
FieldPlaceholder="Select user"
SearchPlaceholder="Search user"
ValueSelector="dto => dto.Username"
Source="LoadUsersAsync"/>
<WButtom OnClick="AddAsync" Variant="ButtonVariant.Outline" Size="ButtonSize.Icon">
<PlusIcon/>
</WButtom>
</div>
</div>
<div class="mt-3">
<DataGrid @ref="Grid"
TGridItem="UserDto"
ColumnVisibility="false"
EnableSearch="true"
EnableLiveSearch="true"
Loader="LoadAsync">
<PropertyColumn Field="dto => dto.Username"/>
<PropertyColumn Field="dto => dto.Email"/>
<TemplateColumn>
<CellTemplate>
<TableCell>
<div class="flex justify-end me-1.5">
<WButtom OnClick="_ => RemoveAsync(context)" Variant="ButtonVariant.Destructive" Size="ButtonSize.Icon">
<TrashIcon/>
</WButtom>
</div>
</TableCell>
</CellTemplate>
</TemplateColumn>
</DataGrid>
</div>
@code
{
[Parameter] public RoleDto Role { get; set; }
private UserDto? SelectedUser;
private DataGrid<UserDto> Grid;
private async Task<DataGridResponse<UserDto>> LoadAsync(DataGridRequest<UserDto> request)
{
var query = $"?startIndex={request.StartIndex}&length={request.Length}";
if (!string.IsNullOrWhiteSpace(request.SearchTerm))
query += $"&searchTerm={request.SearchTerm}";
var response = await HttpClient.GetFromJsonAsync<PagedData<UserDto>>(
$"api/admin/roles/{Role.Id}/members{query}"
);
return new DataGridResponse<UserDto>(response!.Data, response.TotalLength);
}
private async Task<UserDto[]> LoadUsersAsync(string? searchTerm)
{
var query = "?startIndex=0&length=100";
if (!string.IsNullOrWhiteSpace(searchTerm))
query += $"&searchTerm={searchTerm}";
var response = await HttpClient.GetFromJsonAsync<PagedData<UserDto>>(
$"api/admin/roles/{Role.Id}/members/available{query}"
);
return response!.Data;
}
private async Task AddAsync()
{
if (SelectedUser == null)
return;
await HttpClient.PutAsync($"api/admin/roles/{Role.Id}/members/{SelectedUser.Id}", null);
SelectedUser = null;
await InvokeAsync(StateHasChanged);
await Grid.RefreshAsync();
}
private async Task RemoveAsync(UserDto user)
{
await HttpClient.DeleteAsync($"api/admin/roles/{Role.Id}/members/{user.Id}");
await Grid.RefreshAsync();
}
}

View File

@@ -0,0 +1,68 @@
@using Moonlight.Frontend.Mappers
@using Moonlight.Shared.Http.Requests.Users
@using Moonlight.Shared.Http.Responses.Users
@using ShadcnBlazor.Dialogs
@using ShadcnBlazor.Extras.Common
@using ShadcnBlazor.Extras.FormHandlers
@using ShadcnBlazor.Inputs
@using ShadcnBlazor.Labels
@inherits ShadcnBlazor.Extras.Dialogs.DialogBase
<DialogHeader>
<DialogTitle>
Update @User.Username
</DialogTitle>
<DialogDescription>
Update the user by giving it a username and an email address
</DialogDescription>
</DialogHeader>
<FormHandler @ref="FormHandler" Model="Request" OnValidSubmit="SubmitAsync">
<div class="flex flex-col gap-6">
<FormValidationSummary/>
<div class="grid gap-2">
<Label for="username">Username</Label>
<InputField
@bind-Value="Request.Username"
id="username"
placeholder="Name of the user"/>
</div>
<div class="grid gap-2">
<Label for="emailAddress">Email Address</Label>
<InputField
@bind-Value="Request.Email"
id="emailAddress"
Type="email"
placeholder="email@of.user"/>
</div>
</div>
</FormHandler>
<DialogFooter ClassName="justify-end gap-x-1">
<WButtom OnClick="_ => FormHandler.SubmitAsync()">Save changes</WButtom>
</DialogFooter>
@code
{
[Parameter] public Func<UpdateUserDto, Task> OnSubmit { get; set; }
[Parameter] public UserDto User { get; set; }
private UpdateUserDto Request;
private FormHandler FormHandler;
protected override void OnInitialized()
{
Request = UserMapper.ToUpdate(User);
}
private async Task SubmitAsync()
{
await OnSubmit.Invoke(Request);
await CloseAsync();
}
}

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

@@ -19,10 +19,6 @@
<KeyIcon /> <KeyIcon />
API & API Keys API & API Keys
</TabsTrigger> </TabsTrigger>
<TabsTrigger Value="roles">
<UsersRoundIcon />
Roles
</TabsTrigger>
<TabsTrigger Value="diagnose"> <TabsTrigger Value="diagnose">
<HeartPulseIcon /> <HeartPulseIcon />
Diagnose Diagnose
@@ -46,9 +42,6 @@
</CardFooter> </CardFooter>
</Card> </Card>
</TabsContent> </TabsContent>
<TabsContent Value="roles">
<Roles />
</TabsContent>
<TabsContent Value="diagnose"> <TabsContent Value="diagnose">
<Diagnose /> <Diagnose />
</TabsContent> </TabsContent>

View File

@@ -1,91 +0,0 @@
@page "/users/create"
@using LucideBlazor
@using ShadcnBlazor.Buttons
@using ShadcnBlazor.Labels
@using ShadcnBlazor.Cards
@using ShadcnBlazor.Extras.FormHandlers
@using ShadcnBlazor.Extras.Toasts
@using ShadcnBlazor.Inputs
@using Moonlight.Shared.Http.Requests.Users
@inject HttpClient HttpClient
@inject NavigationManager Navigation
@inject ToastService ToastService
<div class="flex flex-row justify-between">
<div class="flex flex-col">
<h1 class="text-xl font-semibold">Create user</h1>
<div class="text-muted-foreground">
Create a new user
</div>
</div>
<div class="flex flex-row gap-x-1.5">
<Button Variant="ButtonVariant.Secondary">
<Slot>
<a href="/users" @attributes="context">
<ChevronLeftIcon/>
Back
</a>
</Slot>
</Button>
<Button @onclick="() => Form.SubmitAsync()">
<CheckIcon/>
Continue
</Button>
</div>
</div>
<div class="mt-8">
<Card>
<CardContent>
<FormHandler @ref="Form" OnValidSubmit="OnSubmitAsync" Model="Request">
<div class="flex flex-col gap-6">
<FormValidationSummary/>
<div class="grid gap-2">
<Label for="email">Email</Label>
<InputField
@bind-Value="Request.Email"
id="email"
Type="email"
placeholder="m@example.com"/>
</div>
<div class="grid gap-2">
<Label for="email">Username</Label>
<InputField
@bind-Value="Request.Username"
id="username"
Type="text"
placeholder="example_user"/>
</div>
</div>
</FormHandler>
</CardContent>
</Card>
</div>
@code
{
private CreateUserDto Request = new();
private FormHandler Form;
private async Task OnSubmitAsync()
{
await HttpClient.PostAsJsonAsync(
"/api/users",
Request,
Constants.SerializerOptions
);
await ToastService.SuccessAsync(
"User creation",
$"Successfully created user {Request.Username}"
);
Navigation.NavigateTo("/users");
}
}

View File

@@ -1,105 +0,0 @@
@page "/users/{Id:int}"
@using LucideBlazor
@using ShadcnBlazor.Buttons
@using ShadcnBlazor.Labels
@using ShadcnBlazor.Cards
@using ShadcnBlazor.Extras.Common
@using ShadcnBlazor.Extras.FormHandlers
@using ShadcnBlazor.Extras.Toasts
@using ShadcnBlazor.Inputs
@using Moonlight.Frontend.Mappers
@using Moonlight.Shared.Http.Requests.Users
@using Moonlight.Shared.Http.Responses.Users
@inject HttpClient HttpClient
@inject NavigationManager Navigation
@inject ToastService ToastService
<div class="flex flex-row justify-between">
<div class="flex flex-col">
<h1 class="text-xl font-semibold">Update user</h1>
<div class="text-muted-foreground">
Update an existing user
</div>
</div>
<div class="flex flex-row gap-x-1.5">
<Button Variant="ButtonVariant.Secondary">
<Slot>
<a href="/users" @attributes="context">
<ChevronLeftIcon/>
Back
</a>
</Slot>
</Button>
<Button @onclick="() => Form.SubmitAsync()">
<CheckIcon/>
Continue
</Button>
</div>
</div>
<div class="mt-8">
<Card>
<CardContent>
<LazyLoader Load="LoadAsync">
<FormHandler @ref="Form" OnValidSubmit="OnSubmitAsync" Model="Request">
<div class="flex flex-col gap-6">
<FormValidationSummary/>
<div class="grid gap-2">
<Label for="email">Email</Label>
<InputField
@bind-Value="Request.Email"
id="email"
Type="email"
placeholder="m@example.com"/>
</div>
<div class="grid gap-2">
<Label for="email">Username</Label>
<InputField
@bind-Value="Request.Username"
id="username"
Type="text"
placeholder="example_user"/>
</div>
</div>
</FormHandler>
</LazyLoader>
</CardContent>
</Card>
</div>
@code
{
[Parameter] public int Id { get; set; }
private FormHandler Form;
private UpdateUserDto Request;
private UserDto User;
private async Task LoadAsync(LazyLoader _)
{
var user = await HttpClient.GetFromJsonAsync<UserDto>($"api/users/{Id}", Constants.SerializerOptions);
User = user!;
Request = UserMapper.ToUpdate(User);
}
private async Task OnSubmitAsync()
{
await HttpClient.PatchAsJsonAsync(
$"/api/users/{User.Id}",
Request
);
await ToastService.SuccessAsync(
"User creation",
$"Successfully updated user {Request.Username}"
);
Navigation.NavigateTo("/users");
}
}

View File

@@ -1,112 +1,40 @@
@page "/users" @page "/admin/users"
@using LucideBlazor @using LucideBlazor
@using ShadcnBlazor.Buttons @using Microsoft.AspNetCore.Authorization
@using ShadcnBlazor.DataGrids @using Moonlight.Shared
@using ShadcnBlazor.Dropdowns @using ShadcnBlazor.Tab
@using ShadcnBlazor.Extras.AlertDialogs
@using ShadcnBlazor.Extras.Toasts
@using ShadcnBlazor.Tabels
@using Moonlight.Shared.Http.Requests
@using Moonlight.Shared.Http.Responses
@using Moonlight.Shared.Http.Responses.Users
@inject HttpClient HttpClient
@inject AlertDialogService AlertDialogService
@inject ToastService ToastService
@inject NavigationManager Navigation @inject NavigationManager Navigation
<div class="flex flex-row justify-between"> @attribute [Authorize(Policy = Permissions.Users.View)]
<div class="flex flex-col">
<h1 class="text-xl font-semibold">Users</h1>
<div class="text-muted-foreground">
Manage users registered in your application
</div>
</div>
<div class="flex flex-row gap-x-1.5">
<Button>
<Slot>
<a href="/users/create" @attributes="context">
<PlusIcon/>
Create
</a>
</Slot>
</Button>
</div>
</div>
<div class="mt-8"> <Tabs DefaultValue="@(Tab ?? "users")" OnValueChanged="OnTabChanged">
<DataGrid @ref="Grid" TGridItem="UserDto" Loader="LoadAsync" PageSize="10" ClassName="bg-card"> <TabsList ClassName="inline-flex w-full lg:w-fit justify-start overflow-x-auto overflow-y-hidden">
<PropertyColumn HeadClassName="text-left" CellClassName="text-left" Field="u => u.Id"/> <TabsTrigger Value="users">
<PropertyColumn HeadClassName="text-left" CellClassName="text-left" IsFilterable="true" <UserRoundIcon />
Identifier="@nameof(UserDto.Username)" Field="u => u.Username"/> Users
<PropertyColumn HeadClassName="text-left" CellClassName="text-left" IsFilterable="true" </TabsTrigger>
Identifier="@nameof(UserDto.Email)" Field="u => u.Email"/> <TabsTrigger Value="roles">
<TemplateColumn> <UsersRoundIcon />
<CellTemplate> Roles
<TableCell> </TabsTrigger>
<div class="flex flex-row items-center justify-end me-3"> </TabsList>
<DropdownMenu> <TabsContent Value="users">
<DropdownMenuTrigger> <Users />
<Slot Context="dropdownSlot"> </TabsContent>
<Button Size="ButtonSize.IconSm" Variant="ButtonVariant.Ghost" @attributes="dropdownSlot"> <TabsContent Value="roles">
<EllipsisIcon/> <Roles />
</Button> </TabsContent>
</Slot> </Tabs>
</DropdownMenuTrigger>
<DropdownMenuContent SideOffset="2">
<DropdownMenuItem OnClick="() => Edit(context)">
Edit
<DropdownMenuShortcut>
<PenIcon/>
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem OnClick="() => DeleteAsync(context)" Variant="DropdownMenuItemVariant.Destructive">
Delete
<DropdownMenuShortcut>
<TrashIcon/>
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TableCell>
</CellTemplate>
</TemplateColumn>
</DataGrid>
</div>
@code @code
{ {
private DataGrid<UserDto> Grid; [SupplyParameterFromQuery(Name = "tab")]
[Parameter]
public string? Tab { get; set; }
private async Task<DataGridResponse<UserDto>> LoadAsync(DataGridRequest<UserDto> request) private void OnTabChanged(string name)
{ {
var query = $"?startIndex={request.StartIndex}&length={request.Length}"; Navigation.NavigateTo($"/admin/users?tab={name}");
var filterOptions = request.Filters.Count > 0 ? new FilterOptions(request.Filters) : null;
var response = await HttpClient.GetFromJsonAsync<PagedData<UserDto>>(
$"api/users{query}&filterOptions={filterOptions}",
Constants.SerializerOptions
);
return new DataGridResponse<UserDto>(response!.Data, response.TotalLength);
}
private void Edit(UserDto dto) => Navigation.NavigateTo($"/users/{dto.Id}");
private async Task DeleteAsync(UserDto user)
{
await AlertDialogService.ConfirmDangerAsync(
$"Deletion of user {user.Username}",
"Do you really want to delete this user? This action cannot be undone",
async () =>
{
await HttpClient.DeleteAsync($"api/users/{user.Id}");
await ToastService.SuccessAsync("User deletion", $"Successfully deleted user {user.Username}");
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 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,10 +19,17 @@
@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-end mt-5"> <div class="flex flex-row justify-between mt-5">
<div class="flex flex-col">
<h1 class="text-xl font-semibold">Roles</h1>
<div class="text-muted-foreground">
Manage roles, their members and permissions
</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>
@@ -29,7 +39,18 @@
<div class="mt-3"> <div class="mt-3">
<DataGrid @ref="Grid" TGridItem="RoleDto" Loader="LoadAsync" PageSize="10" ClassName="bg-card"> <DataGrid @ref="Grid" TGridItem="RoleDto" Loader="LoadAsync" PageSize="10" ClassName="bg-card">
<PropertyColumn Field="u => u.Id"/> <PropertyColumn Field="u => u.Id"/>
<PropertyColumn IsFilterable="true" Identifier="@nameof(RoleDto.Name)" Field="r => r.Name"/> <TemplateColumn Title="Name" IsFilterable="true" Identifier="@nameof(RoleDto.Name)">
<HeadTemplate>
<TableHead>Name</TableHead>
</HeadTemplate>
<CellTemplate>
<TableCell>
<a class="text-primary" href="#" @onclick="_ => MembersAsync(context)" @onclick:preventDefault>
@context.Name
</a>
</TableCell>
</CellTemplate>
</TemplateColumn>
<PropertyColumn Title="Description" Field="r => r.Description"/> <PropertyColumn Title="Description" Field="r => r.Description"/>
<PropertyColumn Title="Members" Field="r => r.MemberCount"/> <PropertyColumn Title="Members" Field="r => r.MemberCount"/>
<TemplateColumn> <TemplateColumn>
@@ -39,19 +60,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="() => MembersAsync(context)" Disabled="@(!MembersAccess.Succeeded)">
Members
<DropdownMenuShortcut>
<UsersRoundIcon/>
</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/>
@@ -68,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}";
@@ -120,6 +167,17 @@
}); });
} }
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"; });
}
private async Task DeleteAsync(RoleDto role) private async Task DeleteAsync(RoleDto role)
{ {
await AlertDialogService.ConfirmDangerAsync( await AlertDialogService.ConfirmDangerAsync(
@@ -127,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

@@ -0,0 +1,200 @@
@using LucideBlazor
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Moonlight.Frontend.UI.Admin.Modals
@using Moonlight.Shared
@using ShadcnBlazor.Buttons
@using ShadcnBlazor.DataGrids
@using ShadcnBlazor.Dropdowns
@using ShadcnBlazor.Extras.AlertDialogs
@using ShadcnBlazor.Extras.Toasts
@using ShadcnBlazor.Tabels
@using Moonlight.Shared.Http.Requests
@using Moonlight.Shared.Http.Requests.Users
@using Moonlight.Shared.Http.Responses
@using Moonlight.Shared.Http.Responses.Users
@using ShadcnBlazor.Extras.Dialogs
@inject HttpClient HttpClient
@inject AlertDialogService AlertDialogService
@inject DialogService DialogService
@inject ToastService ToastService
@inject IAuthorizationService AuthorizationService
<div class="flex flex-row justify-between mt-5">
<div class="flex flex-col">
<h1 class="text-xl font-semibold">Users</h1>
<div class="text-muted-foreground">
Manage users registered in your instance
</div>
</div>
<div class="flex flex-row gap-x-1.5">
<Button @onclick="CreateAsync" disabled="@(!CreateAccess.Succeeded)">
<PlusIcon/>
Create
</Button>
</div>
</div>
<div class="mt-3">
<DataGrid @ref="Grid" TGridItem="UserDto" Loader="LoadAsync" PageSize="10" ClassName="bg-card">
<PropertyColumn HeadClassName="text-left" CellClassName="text-left" Field="u => u.Id"/>
<PropertyColumn HeadClassName="text-left" CellClassName="text-left" IsFilterable="true"
Identifier="@nameof(UserDto.Username)" Field="u => u.Username"/>
<PropertyColumn HeadClassName="text-left" CellClassName="text-left" IsFilterable="true"
Identifier="@nameof(UserDto.Email)" Field="u => u.Email"/>
<TemplateColumn>
<CellTemplate>
<TableCell>
<div class="flex flex-row items-center justify-end me-3">
<DropdownMenu>
<DropdownMenuTrigger>
<Slot Context="dropdownSlot">
<Button Size="ButtonSize.IconSm" Variant="ButtonVariant.Ghost"
@attributes="dropdownSlot">
<EllipsisIcon/>
</Button>
</Slot>
</DropdownMenuTrigger>
<DropdownMenuContent SideOffset="2">
<DropdownMenuItem OnClick="() => LogoutAsync(context)" Disabled="@(!LogoutAccess.Succeeded)">
Logout
<DropdownMenuShortcut>
<LogOutIcon/>
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem OnClick="() => EditAsync(context)" Disabled="@(!EditAccess.Succeeded)">
Edit
<DropdownMenuShortcut>
<PenIcon/>
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem OnClick="() => DeleteAsync(context)"
Variant="DropdownMenuItemVariant.Destructive"
Disabled="@(!DeleteAccess.Succeeded)">
Delete
<DropdownMenuShortcut>
<TrashIcon/>
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TableCell>
</CellTemplate>
</TemplateColumn>
</DataGrid>
</div>
@code
{
[CascadingParameter] public Task<AuthenticationState> AuthState { get; set; }
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)
{
var query = $"?startIndex={request.StartIndex}&length={request.Length}";
var filterOptions = request.Filters.Count > 0 ? new FilterOptions(request.Filters) : null;
var response = await HttpClient.GetFromJsonAsync<PagedData<UserDto>>(
$"api/admin/users{query}&filterOptions={filterOptions}",
Constants.SerializerOptions
);
return new DataGridResponse<UserDto>(response!.Data, response.TotalLength);
}
private async Task CreateAsync()
{
await DialogService.LaunchAsync<CreateUserDialog>(parameters =>
{
parameters[nameof(CreateUserDialog.OnSubmit)] = async (CreateUserDto dto) =>
{
await HttpClient.PostAsJsonAsync(
"/api/admin/users",
dto,
Constants.SerializerOptions
);
await ToastService.SuccessAsync(
"User creation",
$"Successfully created user {dto.Username}"
);
await Grid.RefreshAsync();
};
});
}
private async Task EditAsync(UserDto user)
{
await DialogService.LaunchAsync<UpdateUserDialog>(parameters =>
{
parameters[nameof(UpdateUserDialog.User)] = user;
parameters[nameof(CreateUserDialog.OnSubmit)] = async (UpdateUserDto dto) =>
{
await HttpClient.PatchAsJsonAsync(
$"/api/admin/users/{user.Id}",
dto
);
await ToastService.SuccessAsync(
"User update",
$"Successfully updated user {dto.Username}"
);
await Grid.RefreshAsync();
};
});
}
private async Task DeleteAsync(UserDto user)
{
await AlertDialogService.ConfirmDangerAsync(
$"Deletion of user {user.Username}",
"Do you really want to delete this user? This action cannot be undone",
async () =>
{
var response = await HttpClient.DeleteAsync($"api/admin/users/{user.Id}");
response.EnsureSuccessStatusCode();
await ToastService.SuccessAsync("User deletion", $"Successfully deleted user {user.Username}");
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,6 +36,12 @@
</AuthorizeView> </AuthorizeView>
</ChildContent> </ChildContent>
<ErrorContent> <ErrorContent>
@if (context is HttpRequestException { StatusCode: HttpStatusCode.Unauthorized })
{
<Authentication/>
}
else
{
<div class="m-10"> <div class="m-10">
<Empty> <Empty>
<EmptyHeader> <EmptyHeader>
@@ -50,5 +57,6 @@
</EmptyHeader> </EmptyHeader>
</Empty> </Empty>
</div> </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)}";
} }
} }