feat/ImproveRoles #4
166
Moonlight.Api/Http/Controllers/Admin/RoleMembersController.cs
Normal file
166
Moonlight.Api/Http/Controllers/Admin/RoleMembersController.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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<User> UserRepository;
|
||||
114
Moonlight.Frontend/UI/Admin/Modals/ManageRoleMembersDialog.razor
Normal file
114
Moonlight.Frontend/UI/Admin/Modals/ManageRoleMembersDialog.razor
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent Value="users">
|
||||
<u />
|
||||
<Users />
|
||||
</TabsContent>
|
||||
<TabsContent Value="roles">
|
||||
<Roles />
|
||||
|
||||
@@ -35,7 +35,18 @@
|
||||
<div class="mt-3">
|
||||
<DataGrid @ref="Grid" TGridItem="RoleDto" Loader="LoadAsync" PageSize="10" ClassName="bg-card">
|
||||
<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="Members" Field="r => r.MemberCount"/>
|
||||
<TemplateColumn>
|
||||
@@ -45,19 +56,27 @@
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Slot Context="dropdownSlot">
|
||||
<Button Size="ButtonSize.IconSm" Variant="ButtonVariant.Ghost" @attributes="dropdownSlot">
|
||||
<Button Size="ButtonSize.IconSm" Variant="ButtonVariant.Ghost"
|
||||
@attributes="dropdownSlot">
|
||||
<EllipsisIcon/>
|
||||
</Button>
|
||||
</Slot>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent SideOffset="2">
|
||||
<DropdownMenuItem OnClick="() => MembersAsync(context)">
|
||||
Members
|
||||
<DropdownMenuShortcut>
|
||||
<UsersRoundIcon/>
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem OnClick="() => EditAsync(context)">
|
||||
Edit
|
||||
<DropdownMenuShortcut>
|
||||
<PenIcon/>
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem OnClick="() => DeleteAsync(context)" Variant="DropdownMenuItemVariant.Destructive">
|
||||
<DropdownMenuItem OnClick="() => DeleteAsync(context)"
|
||||
Variant="DropdownMenuItemVariant.Destructive">
|
||||
Delete
|
||||
<DropdownMenuShortcut>
|
||||
<TrashIcon/>
|
||||
@@ -126,6 +145,11 @@
|
||||
});
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
await AlertDialogService.ConfirmDangerAsync(
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<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"
|
||||
@@ -83,7 +83,7 @@
|
||||
var filterOptions = request.Filters.Count > 0 ? new FilterOptions(request.Filters) : null;
|
||||
|
||||
var response = await HttpClient.GetFromJsonAsync<PagedData<UserDto>>(
|
||||
$"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();
|
||||
|
||||
Reference in New Issue
Block a user