diff --git a/Moonlight.Api/Http/Controllers/Admin/RoleMembersController.cs b/Moonlight.Api/Http/Controllers/Admin/RoleMembersController.cs new file mode 100644 index 00000000..a4910dd0 --- /dev/null +++ b/Moonlight.Api/Http/Controllers/Admin/RoleMembersController.cs @@ -0,0 +1,166 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Moonlight.Api.Database; +using Moonlight.Api.Database.Entities; +using Moonlight.Api.Mappers; +using Moonlight.Shared.Http.Responses; +using Moonlight.Shared.Http.Responses.Users; + +namespace Moonlight.Api.Http.Controllers.Admin; + +[ApiController] +[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 + .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 + .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/UsersController.cs b/Moonlight.Api/Http/Controllers/Admin/UsersController.cs similarity index 97% rename from Moonlight.Api/Http/Controllers/UsersController.cs rename to Moonlight.Api/Http/Controllers/Admin/UsersController.cs index d1f7e15c..bb5f03d4 100644 --- a/Moonlight.Api/Http/Controllers/UsersController.cs +++ b/Moonlight.Api/Http/Controllers/Admin/UsersController.cs @@ -1,19 +1,19 @@ 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.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; diff --git a/Moonlight.Frontend/UI/Admin/Modals/ManageRoleMembersDialog.razor b/Moonlight.Frontend/UI/Admin/Modals/ManageRoleMembersDialog.razor new file mode 100644 index 00000000..d71fc0fa --- /dev/null +++ b/Moonlight.Frontend/UI/Admin/Modals/ManageRoleMembersDialog.razor @@ -0,0 +1,114 @@ +@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/Views/Users/Index.razor b/Moonlight.Frontend/UI/Admin/Views/Users/Index.razor index 40d171bc..40a218ca 100644 --- a/Moonlight.Frontend/UI/Admin/Views/Users/Index.razor +++ b/Moonlight.Frontend/UI/Admin/Views/Users/Index.razor @@ -16,7 +16,7 @@ - + diff --git a/Moonlight.Frontend/UI/Admin/Views/Users/Roles.razor b/Moonlight.Frontend/UI/Admin/Views/Users/Roles.razor index adeaf542..61190202 100644 --- a/Moonlight.Frontend/UI/Admin/Views/Users/Roles.razor +++ b/Moonlight.Frontend/UI/Admin/Views/Users/Roles.razor @@ -35,7 +35,18 @@
- + + + Name + + + + + @context.Name + + + + @@ -45,19 +56,27 @@ - + + Members + + + + Edit - + Delete @@ -100,7 +119,7 @@ request, Constants.SerializerOptions ); - + await ToastService.SuccessAsync("Role creation", $"Role {request.Name} has been successfully created"); await Grid.RefreshAsync(); }; @@ -119,13 +138,18 @@ request, Constants.SerializerOptions ); - + await ToastService.SuccessAsync("Role update", $"Role {request.Name} has been successfully updated"); await Grid.RefreshAsync(); }; }); } + private async Task MembersAsync(RoleDto role) + { + 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( diff --git a/Moonlight.Frontend/UI/Admin/Views/Users/Users.razor b/Moonlight.Frontend/UI/Admin/Views/Users/Users.razor index 72bfe1c9..a106d65f 100644 --- a/Moonlight.Frontend/UI/Admin/Views/Users/Users.razor +++ b/Moonlight.Frontend/UI/Admin/Views/Users/Users.razor @@ -32,7 +32,7 @@
-
+
0 ? new FilterOptions(request.Filters) : null; var response = await HttpClient.GetFromJsonAsync>( - $"api/users{query}&filterOptions={filterOptions}", + $"api/admin/users{query}&filterOptions={filterOptions}", Constants.SerializerOptions ); @@ -97,7 +97,7 @@ parameters[nameof(CreateUserDialog.OnSubmit)] = async (CreateUserDto dto) => { await HttpClient.PostAsJsonAsync( - "/api/users", + "/api/admin/users", dto, Constants.SerializerOptions ); @@ -120,7 +120,7 @@ parameters[nameof(CreateUserDialog.OnSubmit)] = async (UpdateUserDto dto) => { await HttpClient.PatchAsJsonAsync( - $"/api/users/{user.Id}", + $"/api/admin/users/{user.Id}", dto ); @@ -141,7 +141,7 @@ "Do you really want to delete this user? This action cannot be undone", async () => { - await HttpClient.DeleteAsync($"api/users/{user.Id}"); + await HttpClient.DeleteAsync($"api/admin/users/{user.Id}"); await ToastService.SuccessAsync("User deletion", $"Successfully deleted user {user.Username}"); await Grid.RefreshAsync();