diff --git a/Moonlight.Api/Configuration/SessionOptions.cs b/Moonlight.Api/Configuration/SessionOptions.cs new file mode 100644 index 00000000..9767009b --- /dev/null +++ b/Moonlight.Api/Configuration/SessionOptions.cs @@ -0,0 +1,6 @@ +namespace Moonlight.Api.Configuration; + +public class SessionOptions +{ + public int ValidationCacheMinutes { get; set; } = 3; +} \ No newline at end of file diff --git a/Moonlight.Api/Http/Controllers/Admin/DiagnoseController.cs b/Moonlight.Api/Http/Controllers/Admin/DiagnoseController.cs index 4cc69827..598f6368 100644 --- a/Moonlight.Api/Http/Controllers/Admin/DiagnoseController.cs +++ b/Moonlight.Api/Http/Controllers/Admin/DiagnoseController.cs @@ -1,11 +1,14 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Moonlight.Api.Mappers; using Moonlight.Api.Services; +using Moonlight.Shared; using Moonlight.Shared.Http.Responses.Admin; namespace Moonlight.Api.Http.Controllers.Admin; [ApiController] +[Authorize(Policy = Permissions.System.Diagnose)] [Route("api/admin/system/diagnose")] public class DiagnoseController : Controller { diff --git a/Moonlight.Api/Http/Controllers/Admin/RoleMembersController.cs b/Moonlight.Api/Http/Controllers/Admin/RoleMembersController.cs new file mode 100644 index 00000000..662516a4 --- /dev/null +++ b/Moonlight.Api/Http/Controllers/Admin/RoleMembersController.cs @@ -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 UsersRepository; + private readonly DatabaseRepository RolesRepository; + private readonly DatabaseRepository RoleMembersRepository; + + public RoleMembersController( + DatabaseRepository usersRepository, + DatabaseRepository rolesRepository, + DatabaseRepository roleMembersRepository + ) + { + UsersRepository = usersRepository; + RolesRepository = rolesRepository; + RoleMembersRepository = roleMembersRepository; + } + + [HttpGet] + public async Task>> 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(items, totalCount); + } + + [HttpGet("available")] + public async Task>> 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(items, totalCount); + } + + [HttpPut("{userId:int}")] + public async Task 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 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(); + } +} \ No newline at end of file diff --git a/Moonlight.Api/Http/Controllers/Admin/RolesController.cs b/Moonlight.Api/Http/Controllers/Admin/RolesController.cs index 4df2077b..9e828f9b 100644 --- a/Moonlight.Api/Http/Controllers/Admin/RolesController.cs +++ b/Moonlight.Api/Http/Controllers/Admin/RolesController.cs @@ -1,8 +1,10 @@ +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.Requests; using Moonlight.Shared.Http.Requests.Roles; using Moonlight.Shared.Http.Responses; @@ -22,6 +24,7 @@ public class RolesController : Controller } [HttpGet] + [Authorize(Policy = Permissions.Roles.View)] public async Task>> GetAsync( [FromQuery] int startIndex, [FromQuery] int length, @@ -57,6 +60,7 @@ public class RolesController : Controller // Pagination var data = await query + .OrderBy(x => x.Id) .ProjectToDto() .Skip(startIndex) .Take(length) @@ -68,6 +72,7 @@ public class RolesController : Controller } [HttpGet("{id:int}")] + [Authorize(Policy = Permissions.Roles.View)] public async Task> GetAsync([FromRoute] int id) { var role = await RoleRepository @@ -81,6 +86,7 @@ public class RolesController : Controller } [HttpPost] + [Authorize(Policy = Permissions.Roles.Create)] public async Task> CreateAsync([FromBody] CreateRoleDto request) { var role = RoleMapper.ToEntity(request); @@ -91,6 +97,7 @@ public class RolesController : Controller } [HttpPatch("{id:int}")] + [Authorize(Policy = Permissions.Roles.Edit)] public async Task> UpdateAsync([FromRoute] int id, [FromBody] UpdateRoleDto request) { var role = await RoleRepository @@ -108,6 +115,7 @@ public class RolesController : Controller } [HttpDelete("{id:int}")] + [Authorize(Policy = Permissions.Roles.Delete)] public async Task DeleteAsync([FromRoute] int id) { var role = await RoleRepository diff --git a/Moonlight.Api/Http/Controllers/Admin/SystemController.cs b/Moonlight.Api/Http/Controllers/Admin/SystemController.cs index fba690b9..50d74229 100644 --- a/Moonlight.Api/Http/Controllers/Admin/SystemController.cs +++ b/Moonlight.Api/Http/Controllers/Admin/SystemController.cs @@ -1,5 +1,7 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Moonlight.Api.Services; +using Moonlight.Shared; using Moonlight.Shared.Http.Responses.Admin; namespace Moonlight.Api.Http.Controllers.Admin; @@ -16,6 +18,7 @@ public class SystemController : Controller } [HttpGet("info")] + [Authorize(Policy = Permissions.System.Info)] public async Task> GetInfoAsync() { var cpuUsage = await ApplicationService.GetCpuUsageAsync(); diff --git a/Moonlight.Api/Http/Controllers/Admin/UserActionsController.cs b/Moonlight.Api/Http/Controllers/Admin/UserActionsController.cs new file mode 100644 index 00000000..006e201d --- /dev/null +++ b/Moonlight.Api/Http/Controllers/Admin/UserActionsController.cs @@ -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 UsersRepository; + private readonly IMemoryCache Cache; + + public UserActionsController(DatabaseRepository usersRepository, IMemoryCache cache) + { + UsersRepository = usersRepository; + Cache = cache; + } + + [HttpPost("logout")] + [Authorize(Policy = Permissions.Users.Logout)] + public async Task 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(); + } +} \ No newline at end of file diff --git a/Moonlight.Api/Http/Controllers/UsersController.cs b/Moonlight.Api/Http/Controllers/Admin/UsersController.cs similarity index 90% rename from Moonlight.Api/Http/Controllers/UsersController.cs rename to Moonlight.Api/Http/Controllers/Admin/UsersController.cs index d1f7e15c..79110804 100644 --- a/Moonlight.Api/Http/Controllers/UsersController.cs +++ b/Moonlight.Api/Http/Controllers/Admin/UsersController.cs @@ -1,19 +1,20 @@ 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.Requests; using Moonlight.Shared.Http.Requests.Users; using Moonlight.Shared.Http.Responses; 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] [ApiController] -[Route("api/users")] +[Route("api/admin/users")] public class UsersController : Controller { private readonly DatabaseRepository UserRepository; @@ -24,6 +25,7 @@ public class UsersController : Controller } [HttpGet] + [Authorize(Policy = Permissions.Users.View)] public async Task>> GetAsync( [FromQuery] int startIndex, [FromQuery] int length, @@ -62,6 +64,7 @@ public class UsersController : Controller // Pagination var data = await query + .OrderBy(x => x.Id) .ProjectToDto() .Skip(startIndex) .Take(length) @@ -73,6 +76,7 @@ public class UsersController : Controller } [HttpGet("{id:int}")] + [Authorize(Policy = Permissions.Users.View)] public async Task> GetAsync([FromRoute] int id) { var user = await UserRepository @@ -86,6 +90,7 @@ public class UsersController : Controller } [HttpPost] + [Authorize(Policy = Permissions.Users.Create)] public async Task> CreateAsync([FromBody] CreateUserDto request) { var user = UserMapper.ToEntity(request); @@ -97,6 +102,7 @@ public class UsersController : Controller } [HttpPatch("{id:int}")] + [Authorize(Policy = Permissions.Users.Edit)] public async Task> UpdateAsync([FromRoute] int id, [FromBody] UpdateUserDto request) { var user = await UserRepository @@ -113,6 +119,7 @@ public class UsersController : Controller } [HttpDelete("{id:int}")] + [Authorize(Policy = Permissions.Users.Delete)] public async Task DeleteAsync([FromRoute] int id) { var user = await UserRepository diff --git a/Moonlight.Api/Implementations/PermissionAuthorizationHandler.cs b/Moonlight.Api/Implementations/PermissionAuthorizationHandler.cs index 86194a87..dd378d57 100644 --- a/Moonlight.Api/Implementations/PermissionAuthorizationHandler.cs +++ b/Moonlight.Api/Implementations/PermissionAuthorizationHandler.cs @@ -1,11 +1,29 @@ using Microsoft.AspNetCore.Authorization; +using Moonlight.Shared; namespace Moonlight.Api.Implementations; public class PermissionAuthorizationHandler : AuthorizationHandler { - 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; } } \ No newline at end of file diff --git a/Moonlight.Api/Implementations/PermissionPolicyProvider.cs b/Moonlight.Api/Implementations/PermissionPolicyProvider.cs index 5a310778..217c868e 100644 --- a/Moonlight.Api/Implementations/PermissionPolicyProvider.cs +++ b/Moonlight.Api/Implementations/PermissionPolicyProvider.cs @@ -15,13 +15,11 @@ public class PermissionPolicyProvider : IAuthorizationPolicyProvider public async Task GetPolicyAsync(string policyName) { - if (!policyName.StartsWith("Permission:", StringComparison.OrdinalIgnoreCase)) + if (!policyName.StartsWith(Permissions.Prefix, StringComparison.OrdinalIgnoreCase)) return await FallbackProvider.GetPolicyAsync(policyName); - var identifier = policyName.Substring(Permissions.Prefix.Length); - var policy = new AuthorizationPolicyBuilder(); - policy.AddRequirements(new PermissionRequirement(identifier)); + policy.AddRequirements(new PermissionRequirement(policyName)); return policy.Build(); } diff --git a/Moonlight.Api/Services/UserAuthService.cs b/Moonlight.Api/Services/UserAuthService.cs index 2c6bb267..4a3211a9 100644 --- a/Moonlight.Api/Services/UserAuthService.cs +++ b/Moonlight.Api/Services/UserAuthService.cs @@ -1,23 +1,36 @@ using System.Security.Claims; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moonlight.Api.Configuration; using Moonlight.Api.Database; using Moonlight.Api.Database.Entities; +using Moonlight.Shared; namespace Moonlight.Api.Services; public class UserAuthService { private readonly DatabaseRepository UserRepository; + private readonly IMemoryCache Cache; private readonly ILogger Logger; + private readonly IOptions Options; private const string UserIdClaim = "UserId"; private const string IssuedAtClaim = "IssuedAt"; + + public const string ValidationCacheKeyPattern = $"Moonlight.{nameof(UserAuthService)}.{nameof(ValidateAsync)}-{{0}}"; - public UserAuthService(DatabaseRepository userRepository, ILogger logger) + public UserAuthService( + DatabaseRepository userRepository, + ILogger logger, + IMemoryCache cache, IOptions options) { UserRepository = userRepository; Logger = logger; + Cache = cache; + Options = options; } public async Task SyncAsync(ClaimsPrincipal? principal) @@ -75,13 +88,34 @@ public class UserAuthService if (!int.TryParse(userIdString, out var userId)) return false; - var user = await UserRepository - .Query() - .AsNoTracking() - .FirstOrDefaultAsync(user => user.Id == userId); + var cacheKey = $"Moonlight.{nameof(UserAuthService)}.{nameof(ValidateAsync)}-{userId}"; - if (user == null) - return false; + if (!Cache.TryGetValue(cacheKey, out var user)) + { + user = await UserRepository + .Query() + .AsNoTracking() + .Where(u => u.Id == userId) + .Select(u => new UserSession( + u.InvalidateTimestamp, + u.RoleMemberships.SelectMany(x => x.Role.Permissions).ToArray()) + ) + .FirstOrDefaultAsync(); + + if (user == null) + return false; + + Cache.Set( + cacheKey, + user, + TimeSpan.FromMinutes(Options.Value.ValidationCacheMinutes) + ); + } + else + { + if (user == null) + return false; + } var issuedAtString = principal.FindFirstValue(IssuedAtClaim); @@ -90,10 +124,21 @@ public class UserAuthService var issuedAt = DateTimeOffset.FromUnixTimeSeconds(issuedAtUnix).ToUniversalTime(); - // 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 + // 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 // 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); } \ No newline at end of file diff --git a/Moonlight.Api/Startup/Startup.Auth.cs b/Moonlight.Api/Startup/Startup.Auth.cs index f85c8a9a..35a53b10 100644 --- a/Moonlight.Api/Startup/Startup.Auth.cs +++ b/Moonlight.Api/Startup/Startup.Auth.cs @@ -1,10 +1,12 @@ using System.Security.Claims; using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Moonlight.Api.Configuration; +using Moonlight.Api.Implementations; using Moonlight.Api.Services; namespace Moonlight.Api.Startup; @@ -80,7 +82,8 @@ public partial class Startup options.GetClaimsFromUserInfoEndpoint = true; }); - builder.Services.AddAuthorization(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); } private static void UseAuth(WebApplication application) diff --git a/Moonlight.Api/Startup/Startup.Base.cs b/Moonlight.Api/Startup/Startup.Base.cs index f1456520..b97b9dad 100644 --- a/Moonlight.Api/Startup/Startup.Base.cs +++ b/Moonlight.Api/Startup/Startup.Base.cs @@ -29,6 +29,9 @@ public partial class Startup builder.Services.AddSingleton(); builder.Services.AddSingleton(); + + builder.Services.AddMemoryCache(); + builder.Services.AddOptions().BindConfiguration("Moonlight:Session"); } private static void UseBase(WebApplication application) diff --git a/Moonlight.Frontend/Implementations/PermissionAuthorizationHandler.cs b/Moonlight.Frontend/Implementations/PermissionAuthorizationHandler.cs new file mode 100644 index 00000000..dbfeb446 --- /dev/null +++ b/Moonlight.Frontend/Implementations/PermissionAuthorizationHandler.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Authorization; +using Moonlight.Shared; + +namespace Moonlight.Frontend.Implementations; + +public class PermissionAuthorizationHandler : AuthorizationHandler +{ + 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; + } +} \ No newline at end of file diff --git a/Moonlight.Frontend/Implementations/PermissionPolicyProvider.cs b/Moonlight.Frontend/Implementations/PermissionPolicyProvider.cs new file mode 100644 index 00000000..f5df4924 --- /dev/null +++ b/Moonlight.Frontend/Implementations/PermissionPolicyProvider.cs @@ -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 options) + { + FallbackProvider = new DefaultAuthorizationPolicyProvider(options); + } + + public async Task 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 GetDefaultPolicyAsync() + => FallbackProvider.GetDefaultPolicyAsync(); + + public Task GetFallbackPolicyAsync() + => FallbackProvider.GetFallbackPolicyAsync(); +} + +public class PermissionRequirement : IAuthorizationRequirement +{ + public string Identifier { get; } + + public PermissionRequirement(string identifier) + { + Identifier = identifier; + } +} \ No newline at end of file diff --git a/Moonlight.Frontend/Implementations/PermissionProvider.cs b/Moonlight.Frontend/Implementations/PermissionProvider.cs index 47d39669..22a09991 100644 --- a/Moonlight.Frontend/Implementations/PermissionProvider.cs +++ b/Moonlight.Frontend/Implementations/PermissionProvider.cs @@ -10,11 +10,23 @@ public sealed class PermissionProvider : IPermissionProvider public Task GetPermissionsAsync() { return Task.FromResult([ - new PermissionCategory("User Management", typeof(UsersRoundIcon), [ - new Permission(Permissions.Admin.Users.Create, "Create", "Create new users"), - new Permission(Permissions.Admin.Users.View, "View", "View all users"), - new Permission(Permissions.Admin.Users.Edit, "Edit", "Edit user details"), - new Permission(Permissions.Admin.Users.Delete, "Delete", "Delete user accounts"), + new PermissionCategory("Users", typeof(UserRoundIcon), [ + new Permission(Permissions.Users.Create, "Create", "Create new users"), + new Permission(Permissions.Users.View, "View", "View all users"), + new Permission(Permissions.Users.Edit, "Edit", "Edit user details"), + 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"), ]), ]); } diff --git a/Moonlight.Frontend/Implementations/SidebarProvider.cs b/Moonlight.Frontend/Implementations/SidebarProvider.cs index 5017808b..8cd52510 100644 --- a/Moonlight.Frontend/Implementations/SidebarProvider.cs +++ b/Moonlight.Frontend/Implementations/SidebarProvider.cs @@ -1,6 +1,7 @@ using LucideBlazor; using Moonlight.Frontend.Interfaces; using Moonlight.Frontend.Models; +using Moonlight.Shared; namespace Moonlight.Frontend.Implementations; @@ -24,16 +25,18 @@ public sealed class SidebarProvider : ISidebarProvider Path = "/admin", IsExactPath = true, Group = "Admin", - Order = 0 + Order = 0, + Policy = Permissions.System.Info }, new() { Name = "Users", IconType = typeof(UsersRoundIcon), - Path = "/users", + Path = "/admin/users", IsExactPath = false, Group = "Admin", - Order = 10 + Order = 10, + Policy = Permissions.Users.View }, new() { @@ -42,7 +45,8 @@ public sealed class SidebarProvider : ISidebarProvider Path = "/admin/system", IsExactPath = false, Group = "Admin", - Order = 20 + Order = 20, + Policy = Permissions.System.Info } ]); } diff --git a/Moonlight.Frontend/Models/SidebarItem.cs b/Moonlight.Frontend/Models/SidebarItem.cs index 613870ca..66c73ec7 100644 --- a/Moonlight.Frontend/Models/SidebarItem.cs +++ b/Moonlight.Frontend/Models/SidebarItem.cs @@ -14,4 +14,6 @@ public record SidebarItem // need it to work properly [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] public Type IconType { get; init; } + + public string? Policy { get; set; } } \ No newline at end of file diff --git a/Moonlight.Frontend/Startup/Startup.Auth.cs b/Moonlight.Frontend/Startup/Startup.Auth.cs index 0b2f0038..0e64c73b 100644 --- a/Moonlight.Frontend/Startup/Startup.Auth.cs +++ b/Moonlight.Frontend/Startup/Startup.Auth.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.WebAssembly.Hosting; using Microsoft.Extensions.DependencyInjection; @@ -16,5 +17,8 @@ public partial class Startup builder.Services.AddCascadingAuthenticationState(); builder.Services.AddSingleton(); + + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); } } \ No newline at end of file diff --git a/Moonlight.Frontend/UI/Admin/Modals/CreateUserDialog.razor b/Moonlight.Frontend/UI/Admin/Modals/CreateUserDialog.razor new file mode 100644 index 00000000..55597eb5 --- /dev/null +++ b/Moonlight.Frontend/UI/Admin/Modals/CreateUserDialog.razor @@ -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 + + + + Create new user + + + Create a new user by giving it a username and an email address + + + + +
+ + + +
+ + +
+ +
+ + +
+
+
+ + + Save changes + + +@code +{ + [Parameter] public Func 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(); + } +} diff --git a/Moonlight.Frontend/UI/Admin/Modals/ManageRoleMembersDialog.razor b/Moonlight.Frontend/UI/Admin/Modals/ManageRoleMembersDialog.razor new file mode 100644 index 00000000..6abb099a --- /dev/null +++ b/Moonlight.Frontend/UI/Admin/Modals/ManageRoleMembersDialog.razor @@ -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 + + + + Manage members of @Role.Name + + + Manage members of this role + + + +
+ +
+ + + + +
+
+
+ + + + + + +
+ + + +
+
+
+
+
+
+ +@code +{ + [Parameter] public RoleDto Role { get; set; } + + private UserDto? SelectedUser; + private DataGrid Grid; + + private async Task> LoadAsync(DataGridRequest request) + { + var query = $"?startIndex={request.StartIndex}&length={request.Length}"; + + if (!string.IsNullOrWhiteSpace(request.SearchTerm)) + query += $"&searchTerm={request.SearchTerm}"; + + var response = await HttpClient.GetFromJsonAsync>( + $"api/admin/roles/{Role.Id}/members{query}" + ); + + return new DataGridResponse(response!.Data, response.TotalLength); + } + + private async Task LoadUsersAsync(string? searchTerm) + { + var query = "?startIndex=0&length=100"; + + if (!string.IsNullOrWhiteSpace(searchTerm)) + query += $"&searchTerm={searchTerm}"; + + var response = await HttpClient.GetFromJsonAsync>( + $"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(); + } +} \ No newline at end of file diff --git a/Moonlight.Frontend/UI/Admin/Modals/UpdateUserDialog.razor b/Moonlight.Frontend/UI/Admin/Modals/UpdateUserDialog.razor new file mode 100644 index 00000000..fd2e77a8 --- /dev/null +++ b/Moonlight.Frontend/UI/Admin/Modals/UpdateUserDialog.razor @@ -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 + + + + Update @User.Username + + + Update the user by giving it a username and an email address + + + + +
+ + + +
+ + +
+ +
+ + +
+
+
+ + + Save changes + + +@code +{ + [Parameter] public Func 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(); + } +} diff --git a/Moonlight.Frontend/UI/Admin/Views/Sys/Diagnose.razor b/Moonlight.Frontend/UI/Admin/Views/Sys/Diagnose.razor index 4187c446..0cac7f81 100644 --- a/Moonlight.Frontend/UI/Admin/Views/Sys/Diagnose.razor +++ b/Moonlight.Frontend/UI/Admin/Views/Sys/Diagnose.razor @@ -1,4 +1,7 @@ @using LucideBlazor +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization +@using Moonlight.Shared @using Moonlight.Shared.Http.Responses.Admin @using ShadcnBlazor.Accordions @using ShadcnBlazor.Alerts @@ -9,6 +12,7 @@ @using ShadcnBlazor.Spinners @inject HttpClient HttpClient +@inject IAuthorizationService AuthorizationService
@@ -40,7 +44,7 @@ - + Start diagnostics @@ -216,10 +220,21 @@ @code { + [CascadingParameter] public Task AuthState { get; set; } + + private AuthorizationResult AccessResult; + private bool IsLoading = false; private bool HasDiagnosed = false; 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() { IsLoading = true; diff --git a/Moonlight.Frontend/UI/Admin/Views/Sys/Index.razor b/Moonlight.Frontend/UI/Admin/Views/Sys/Index.razor index ab5a4dbd..f1c42b9f 100644 --- a/Moonlight.Frontend/UI/Admin/Views/Sys/Index.razor +++ b/Moonlight.Frontend/UI/Admin/Views/Sys/Index.razor @@ -19,10 +19,6 @@ API & API Keys - - - Roles - Diagnose @@ -46,9 +42,6 @@ - - - diff --git a/Moonlight.Frontend/UI/Admin/Views/Users/Create.razor b/Moonlight.Frontend/UI/Admin/Views/Users/Create.razor deleted file mode 100644 index 35d7f0eb..00000000 --- a/Moonlight.Frontend/UI/Admin/Views/Users/Create.razor +++ /dev/null @@ -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 - -
-
-

Create user

-
- Create a new user -
-
-
- - -
-
- -
- - - -
- - - -
- - -
- -
- - -
-
-
-
-
-
- -@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"); - } -} \ No newline at end of file diff --git a/Moonlight.Frontend/UI/Admin/Views/Users/Edit.razor b/Moonlight.Frontend/UI/Admin/Views/Users/Edit.razor deleted file mode 100644 index cd285370..00000000 --- a/Moonlight.Frontend/UI/Admin/Views/Users/Edit.razor +++ /dev/null @@ -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 - -
-
-

Update user

-
- Update an existing user -
-
-
- - -
-
- -
- - - - -
- - - -
- - -
- -
- - -
-
-
-
-
-
-
- -@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($"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"); - } -} \ No newline at end of file diff --git a/Moonlight.Frontend/UI/Admin/Views/Users/Index.razor b/Moonlight.Frontend/UI/Admin/Views/Users/Index.razor index 0a628dc8..0578535a 100644 --- a/Moonlight.Frontend/UI/Admin/Views/Users/Index.razor +++ b/Moonlight.Frontend/UI/Admin/Views/Users/Index.razor @@ -1,112 +1,40 @@ -@page "/users" - +@page "/admin/users" @using LucideBlazor -@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.Responses -@using Moonlight.Shared.Http.Responses.Users +@using Microsoft.AspNetCore.Authorization +@using Moonlight.Shared +@using ShadcnBlazor.Tab -@inject HttpClient HttpClient -@inject AlertDialogService AlertDialogService -@inject ToastService ToastService @inject NavigationManager Navigation -
-
-

Users

-
- Manage users registered in your application -
-
-
- -
-
+@attribute [Authorize(Policy = Permissions.Users.View)] -
- - - - - - - -
- - - - - - - - - Edit - - - - - - Delete - - - - - - -
-
-
-
-
-
+ + + + + Users + + + + Roles + + + + + + + + + @code { - private DataGrid Grid; + [SupplyParameterFromQuery(Name = "tab")] + [Parameter] + public string? Tab { get; set; } - private async Task> LoadAsync(DataGridRequest request) + private void OnTabChanged(string name) { - var query = $"?startIndex={request.StartIndex}&length={request.Length}"; - var filterOptions = request.Filters.Count > 0 ? new FilterOptions(request.Filters) : null; - - var response = await HttpClient.GetFromJsonAsync>( - $"api/users{query}&filterOptions={filterOptions}", - Constants.SerializerOptions - ); - - return new DataGridResponse(response!.Data, response.TotalLength); + Navigation.NavigateTo($"/admin/users?tab={name}"); } - - 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(); - } - ); - } -} \ No newline at end of file +} diff --git a/Moonlight.Frontend/UI/Admin/Views/Sys/Roles.razor b/Moonlight.Frontend/UI/Admin/Views/Users/Roles.razor similarity index 60% rename from Moonlight.Frontend/UI/Admin/Views/Sys/Roles.razor rename to Moonlight.Frontend/UI/Admin/Views/Users/Roles.razor index 34dd51fd..7d7753ce 100644 --- a/Moonlight.Frontend/UI/Admin/Views/Sys/Roles.razor +++ b/Moonlight.Frontend/UI/Admin/Views/Users/Roles.razor @@ -1,5 +1,8 @@ @using LucideBlazor +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization @using Moonlight.Frontend.UI.Admin.Modals +@using Moonlight.Shared @using Moonlight.Shared.Http.Requests @using Moonlight.Shared.Http.Requests.Roles @using Moonlight.Shared.Http.Responses @@ -16,10 +19,17 @@ @inject DialogService DialogService @inject ToastService ToastService @inject AlertDialogService AlertDialogService +@inject IAuthorizationService AuthorizationService -
+
+
+

Roles

+
+ Manage roles, their members and permissions +
+
- @@ -29,7 +39,18 @@
- + + + Name + + + + + @context.Name + + + + @@ -39,19 +60,28 @@ - - + + Members + + + + + Edit - + Delete @@ -68,8 +98,25 @@ @code { + [CascadingParameter] public Task AuthState { get; set; } + private DataGrid 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> LoadAsync(DataGridRequest request) { var query = $"?startIndex={request.StartIndex}&length={request.Length}"; @@ -94,7 +141,7 @@ request, Constants.SerializerOptions ); - + await ToastService.SuccessAsync("Role creation", $"Role {request.Name} has been successfully created"); await Grid.RefreshAsync(); }; @@ -113,13 +160,24 @@ request, Constants.SerializerOptions ); - + await ToastService.SuccessAsync("Role update", $"Role {request.Name} has been successfully updated"); await Grid.RefreshAsync(); }; }); } + 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(parameters => { parameters[nameof(ManageRoleMembersDialog.Role)] = role; }, model => { model.ClassName = "sm:max-w-xl"; }); + } + private async Task DeleteAsync(RoleDto role) { 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", 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 Grid.RefreshAsync(); diff --git a/Moonlight.Frontend/UI/Admin/Views/Users/Users.razor b/Moonlight.Frontend/UI/Admin/Views/Users/Users.razor new file mode 100644 index 00000000..55f49383 --- /dev/null +++ b/Moonlight.Frontend/UI/Admin/Views/Users/Users.razor @@ -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 + +
+
+

Users

+
+ Manage users registered in your instance +
+
+
+ +
+
+ +
+ + + + + + + +
+ + + + + + + + + Logout + + + + + + Edit + + + + + + Delete + + + + + + +
+
+
+
+
+
+ +@code +{ + [CascadingParameter] public Task AuthState { get; set; } + + private DataGrid 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> LoadAsync(DataGridRequest 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>( + $"api/admin/users{query}&filterOptions={filterOptions}", + Constants.SerializerOptions + ); + + return new DataGridResponse(response!.Data, response.TotalLength); + } + + private async Task CreateAsync() + { + await DialogService.LaunchAsync(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(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(); + } + ); + } +} \ No newline at end of file diff --git a/Moonlight.Frontend/UI/App.razor b/Moonlight.Frontend/UI/App.razor index f05f6fd4..f9c663dd 100644 --- a/Moonlight.Frontend/UI/App.razor +++ b/Moonlight.Frontend/UI/App.razor @@ -1,4 +1,5 @@ -@using LucideBlazor +@using System.Net +@using LucideBlazor @using Microsoft.AspNetCore.Components.Authorization @using Moonlight.Frontend.UI.Shared @using ShadcnBlazor.Emptys @@ -35,20 +36,27 @@ -
- - - - - - - Critical Application Error - - - @context.ToString() - - - -
+ @if (context is HttpRequestException { StatusCode: HttpStatusCode.Unauthorized }) + { + + } + else + { +
+ + + + + + + Critical Application Error + + + @context.ToString() + + + +
+ }
\ No newline at end of file diff --git a/Moonlight.Frontend/UI/Shared/Partials/AppSidebar.razor b/Moonlight.Frontend/UI/Shared/Partials/AppSidebar.razor index 0fbc9d2a..2112841d 100644 --- a/Moonlight.Frontend/UI/Shared/Partials/AppSidebar.razor +++ b/Moonlight.Frontend/UI/Shared/Partials/AppSidebar.razor @@ -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 ShadcnBlazor.Sidebars @inject NavigationManager Navigation +@inject IAuthorizationService AuthorizationService @inject IEnumerable Providers @implements IDisposable @@ -68,15 +71,30 @@ @code { + [CascadingParameter] public Task AuthState { get; set; } + private readonly List Items = new(); protected override async Task OnInitializedAsync() { + var authState = await AuthState; + foreach (var provider in Providers) { - Items.AddRange( - await provider.GetItemsAsync() - ); + var items = 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; diff --git a/Moonlight.Shared/Permissions.cs b/Moonlight.Shared/Permissions.cs index fd532c7c..4c773f9a 100644 --- a/Moonlight.Shared/Permissions.cs +++ b/Moonlight.Shared/Permissions.cs @@ -5,16 +5,33 @@ public static class Permissions public const string Prefix = "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 Edit = $"{Prefix}{Section}.Edit"; - public const string Create = $"{Prefix}{Section}.Create"; - public const string Delete = $"{Prefix}{Section}.Delete"; - } + 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 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)}"; } } \ No newline at end of file