feat/ImproveRoles #4

Merged
ChiaraBm merged 4 commits from feat/ImproveRoles into v2.1 2026-01-16 12:08:41 +00:00
6 changed files with 320 additions and 16 deletions
Showing only changes of commit 10cd0f0b09 - Show all commits

View File

@@ -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<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
.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
.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,19 +1,19 @@
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.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;

View File

@@ -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
<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>
<WButtom OnClick="_ => RemoveAsync(context)" Variant="ButtonVariant.Destructive" Size="ButtonSize.Icon">
<TrashIcon/>
</WButtom>
</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

@@ -16,7 +16,7 @@
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<TabsContent Value="users"> <TabsContent Value="users">
<u /> <Users />
</TabsContent> </TabsContent>
<TabsContent Value="roles"> <TabsContent Value="roles">
<Roles /> <Roles />

View File

@@ -35,7 +35,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>
@@ -45,19 +56,27 @@
<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="() => MembersAsync(context)">
Members
<DropdownMenuShortcut>
<UsersRoundIcon/>
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem OnClick="() => EditAsync(context)"> <DropdownMenuItem OnClick="() => EditAsync(context)">
Edit Edit
<DropdownMenuShortcut> <DropdownMenuShortcut>
<PenIcon/> <PenIcon/>
</DropdownMenuShortcut> </DropdownMenuShortcut>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem OnClick="() => DeleteAsync(context)" Variant="DropdownMenuItemVariant.Destructive"> <DropdownMenuItem OnClick="() => DeleteAsync(context)"
Variant="DropdownMenuItemVariant.Destructive">
Delete Delete
<DropdownMenuShortcut> <DropdownMenuShortcut>
<TrashIcon/> <TrashIcon/>
@@ -100,7 +119,7 @@
request, request,
Constants.SerializerOptions Constants.SerializerOptions
); );
await ToastService.SuccessAsync("Role creation", $"Role {request.Name} has been successfully created"); await ToastService.SuccessAsync("Role creation", $"Role {request.Name} has been successfully created");
await Grid.RefreshAsync(); await Grid.RefreshAsync();
}; };
@@ -119,13 +138,18 @@
request, request,
Constants.SerializerOptions Constants.SerializerOptions
); );
await ToastService.SuccessAsync("Role update", $"Role {request.Name} has been successfully updated"); await ToastService.SuccessAsync("Role update", $"Role {request.Name} has been successfully updated");
await Grid.RefreshAsync(); await Grid.RefreshAsync();
}; };
}); });
} }
private async Task MembersAsync(RoleDto role)
{
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(

View File

@@ -32,7 +32,7 @@
</div> </div>
</div> </div>
<div class="mt-8"> <div class="mt-3">
<DataGrid @ref="Grid" TGridItem="UserDto" Loader="LoadAsync" PageSize="10" ClassName="bg-card"> <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" Field="u => u.Id"/>
<PropertyColumn HeadClassName="text-left" CellClassName="text-left" IsFilterable="true" <PropertyColumn HeadClassName="text-left" CellClassName="text-left" IsFilterable="true"
@@ -83,7 +83,7 @@
var filterOptions = request.Filters.Count > 0 ? new FilterOptions(request.Filters) : null; var filterOptions = request.Filters.Count > 0 ? new FilterOptions(request.Filters) : null;
var response = await HttpClient.GetFromJsonAsync<PagedData<UserDto>>( var response = await HttpClient.GetFromJsonAsync<PagedData<UserDto>>(
$"api/users{query}&filterOptions={filterOptions}", $"api/admin/users{query}&filterOptions={filterOptions}",
Constants.SerializerOptions Constants.SerializerOptions
); );
@@ -97,7 +97,7 @@
parameters[nameof(CreateUserDialog.OnSubmit)] = async (CreateUserDto dto) => parameters[nameof(CreateUserDialog.OnSubmit)] = async (CreateUserDto dto) =>
{ {
await HttpClient.PostAsJsonAsync( await HttpClient.PostAsJsonAsync(
"/api/users", "/api/admin/users",
dto, dto,
Constants.SerializerOptions Constants.SerializerOptions
); );
@@ -120,7 +120,7 @@
parameters[nameof(CreateUserDialog.OnSubmit)] = async (UpdateUserDto dto) => parameters[nameof(CreateUserDialog.OnSubmit)] = async (UpdateUserDto dto) =>
{ {
await HttpClient.PatchAsJsonAsync( await HttpClient.PatchAsJsonAsync(
$"/api/users/{user.Id}", $"/api/admin/users/{user.Id}",
dto dto
); );
@@ -141,7 +141,7 @@
"Do you really want to delete this user? This action cannot be undone", "Do you really want to delete this user? This action cannot be undone",
async () => async () =>
{ {
await HttpClient.DeleteAsync($"api/users/{user.Id}"); await HttpClient.DeleteAsync($"api/admin/users/{user.Id}");
await ToastService.SuccessAsync("User deletion", $"Successfully deleted user {user.Username}"); await ToastService.SuccessAsync("User deletion", $"Successfully deleted user {user.Username}");
await Grid.RefreshAsync(); await Grid.RefreshAsync();