diff --git a/src/AdminUI/Components/Dialogs/Users/EditUserRoles.razor b/src/AdminUI/Components/Dialogs/Users/EditUserRoles.razor new file mode 100644 index 000000000..4e100a8a2 --- /dev/null +++ b/src/AdminUI/Components/Dialogs/Users/EditUserRoles.razor @@ -0,0 +1,74 @@ +@using Microsoft.AspNetCore.Components.WebAssembly.Authentication +@using SSW.Rewards.ApiClient.Services +@using SSW.Rewards.Shared.DTOs.Roles +@using SSW.Rewards.Shared.DTOs.Users + +@inject IRoleService RoleService + + + + Edit User Roles + + + @if (_loading) + { + + } + else + { + + @foreach (var item in _roles) + { + @item.Name + } + + } + + + Cancel + Save + + + +@code { + private bool _loading = true; + private IEnumerable SelectedRoles { get; set; } = Enumerable.Empty(); + IEnumerable _roles = Enumerable.Empty(); + + [CascadingParameter] + MudDialogInstance MudDialog { get; set; } + + [Parameter] + public UserDto User { get; set; } + + protected override async Task OnInitializedAsync() + { + try + { + await FetchRoles(); + } + catch (AccessTokenNotAvailableException exception) + { + exception.Redirect(); + } + } + + private async Task FetchRoles() + { + _loading = true; + _roles = await RoleService.GetRoles(); + SelectedRoles = User.Roles.Select(r => r.Name); + _loading = false; + } + + private void Submit() + { + User.Roles = _roles.Where(r => SelectedRoles.Contains(r.Name)).ToList(); + MudDialog.Close(DialogResult.Ok(User)); + } + + private void Cancel() => MudDialog?.Cancel(); +} \ No newline at end of file diff --git a/src/AdminUI/Pages/Users.razor b/src/AdminUI/Pages/Users.razor new file mode 100644 index 000000000..9cf73edef --- /dev/null +++ b/src/AdminUI/Pages/Users.razor @@ -0,0 +1,112 @@ +@page "/Users" +@using SSW.Rewards.ApiClient.Services +@using SSW.Rewards.Shared.DTOs.Users +@using Microsoft.AspNetCore.Components.WebAssembly.Authentication +@using Icons = MudBlazor.Icons +@using Microsoft.AspNetCore.Authorization +@using SSW.Rewards.Admin.UI.Components + +@attribute [Authorize(Roles = "Admin")] + +@inject IUserAdminService UserAdminService +@inject IDialogService DialogService +@inject ISnackbar Snackbar + +Users +All users + + + + + + + + + Id + + + + + Name + + + + + Email + + + Roles + + + @context.Id + @context.Name + @context.Email + + + @string.Join(", ", context.Roles.Select(x => x.Name).OrderBy(x => x)) + + + + + +
+ +@code { + UsersViewModel _vm = new(); + + private bool _loading = true; + private string _searchString = string.Empty; + + protected override async Task OnInitializedAsync() + { + try + { + await FetchData(); + } + catch (AccessTokenNotAvailableException exception) + { + exception.Redirect(); + } + } + + private async Task FetchData() + { + _loading = true; + _vm = await UserAdminService.GetUsers(); + _loading = false; + } + + private async Task EditRoles(UserDto user) + { + var dialog = DialogService.Show("Edit User Roles", new DialogParameters { { "User", user } }); + var result = await dialog.Result; + if (!result.Canceled) + { + try + { + if (result.Data is UserDto userResult) + { + await UserAdminService.UpdateUserRoles(userResult); + StateHasChanged(); + } + + Snackbar.Add("Roles updated", Severity.Success); + } + catch (Exception) + { + Snackbar.Add("Failed to update roles", Severity.Error); + } + } + } + +} \ No newline at end of file diff --git a/src/AdminUI/Shared/NavMenu.razor b/src/AdminUI/Shared/NavMenu.razor index 7f44510a3..8079d4036 100644 --- a/src/AdminUI/Shared/NavMenu.razor +++ b/src/AdminUI/Shared/NavMenu.razor @@ -10,6 +10,7 @@ Achievements Rewards Quizzes + Users New Users Prize Draw Notifications diff --git a/src/ApiClient/ConfigureServices.cs b/src/ApiClient/ConfigureServices.cs index 65c7afbbd..31f0b5f29 100644 --- a/src/ApiClient/ConfigureServices.cs +++ b/src/ApiClient/ConfigureServices.cs @@ -29,6 +29,7 @@ public static IServiceCollection AddApiClientServices(this IServiceCol services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); } services.AddSingleton(); diff --git a/src/ApiClient/Services/IRoleService.cs b/src/ApiClient/Services/IRoleService.cs new file mode 100644 index 000000000..eabe0d480 --- /dev/null +++ b/src/ApiClient/Services/IRoleService.cs @@ -0,0 +1,39 @@ +using System.Net.Http.Json; +using SSW.Rewards.Shared.DTOs.Roles; + +namespace SSW.Rewards.ApiClient.Services; + +public interface IRoleService +{ + Task> GetRoles(CancellationToken cancellationToken = default); +} + +public class RoleService : IRoleService +{ + private readonly HttpClient _httpClient; + + private const string _baseRoute = "api/Role/"; + + public RoleService(IHttpClientFactory httpClientFactory) + { + _httpClient = httpClientFactory.CreateClient(Constants.AuthenticatedClient); + } + + public async Task> GetRoles(CancellationToken cancellationToken) + { + var result = await _httpClient.GetAsync($"{_baseRoute}GetRoles", cancellationToken); + + if (result.IsSuccessStatusCode) + { + var response = await result.Content.ReadFromJsonAsync>(cancellationToken: cancellationToken); + + if (response is not null) + { + return response; + } + } + + var responseContent = await result.Content.ReadAsStringAsync(cancellationToken); + throw new Exception($"Failed to get roles: {responseContent}"); + } +} \ No newline at end of file diff --git a/src/ApiClient/Services/IUserAdminService.cs b/src/ApiClient/Services/IUserAdminService.cs index f0b07ff19..0db39c4cd 100644 --- a/src/ApiClient/Services/IUserAdminService.cs +++ b/src/ApiClient/Services/IUserAdminService.cs @@ -5,7 +5,9 @@ namespace SSW.Rewards.ApiClient.Services; public interface IUserAdminService { + Task GetUsers(CancellationToken cancellationToken = default); Task GetNewUsers(LeaderboardFilter filter, bool filterStaff, CancellationToken cancellationToken = default); + Task UpdateUserRoles(UserDto dto, CancellationToken cancellationToken = default); Task GetProfileDeletionRequests(CancellationToken cancellationToken = default); Task DeleteUserProfile(AdminDeleteProfileDto dto, CancellationToken cancellationToken = default); } @@ -21,6 +23,37 @@ public UserAdminService(IHttpClientFactory httpClientFactory) _httpClient = httpClientFactory.CreateClient(Constants.AuthenticatedClient); } + public async Task GetUsers(CancellationToken cancellationToken = default) + { + var result = await _httpClient.GetAsync($"{_baseRoute}GetUsers", cancellationToken); + + if (result.IsSuccessStatusCode) + { + var response = await result.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + + if (response is not null) + { + return response; + } + } + + var responseContent = await result.Content.ReadAsStringAsync(cancellationToken); + + throw new Exception($"Failed to get users: {responseContent}"); + } + + public async Task UpdateUserRoles(UserDto dto, CancellationToken cancellationToken = default) + { + var result = await _httpClient.PostAsJsonAsync($"{_baseRoute}UpdateUserRoles", dto, cancellationToken); + + if (!result.IsSuccessStatusCode) + { + var responseContent = await result.Content.ReadAsStringAsync(cancellationToken); + + throw new Exception($"Failed to update user roles: {responseContent}"); + } + } + public async Task GetNewUsers(LeaderboardFilter filter, bool filterStaff, CancellationToken cancellationToken = default) { var result = await _httpClient.GetAsync($"{_baseRoute}GetNewUsers?filter={filter}&filterStaff={filterStaff}", cancellationToken); diff --git a/src/Application/Common/Interfaces/IRolesService.cs b/src/Application/Common/Interfaces/IRolesService.cs index 80c69788e..55423d146 100644 --- a/src/Application/Common/Interfaces/IRolesService.cs +++ b/src/Application/Common/Interfaces/IRolesService.cs @@ -1,4 +1,4 @@ -using SSW.Rewards.Domain.Entities; + namespace SSW.Rewards.Application.Common.Interfaces; diff --git a/src/Application/Common/Interfaces/IUserService.cs b/src/Application/Common/Interfaces/IUserService.cs index a481cd367..d903a1ed0 100644 --- a/src/Application/Common/Interfaces/IUserService.cs +++ b/src/Application/Common/Interfaces/IUserService.cs @@ -34,6 +34,10 @@ public interface IUserService UserProfileDto GetUser(int userId); Task GetUser(int userId, CancellationToken cancellationToken); Task GetUserId(string email, CancellationToken cancellationToken); + + // Get users + UsersViewModel GetUsers(); + Task GetUsers(CancellationToken cancellationToken); // Get user achievements UserAchievementsViewModel GetUserAchievements(int userId); diff --git a/src/Application/Roles/Queries/GetRoles/GetRolesQuery.cs b/src/Application/Roles/Queries/GetRoles/GetRolesQuery.cs new file mode 100644 index 000000000..99a4b5282 --- /dev/null +++ b/src/Application/Roles/Queries/GetRoles/GetRolesQuery.cs @@ -0,0 +1,23 @@ +using AutoMapper.QueryableExtensions; +using SSW.Rewards.Shared.DTOs.Roles; + +namespace SSW.Rewards.Application.Roles.Queries.GetRoles; + +public class GetRolesQuery : IRequest>; + +public class GetRolesQueryHandler : IRequestHandler> +{ + private readonly IMapper _mapper; + private readonly IApplicationDbContext _dbContext; + + public GetRolesQueryHandler(IMapper mapper, IApplicationDbContext dbContext) + { + _mapper = mapper; + _dbContext = dbContext; + } + + public async Task> Handle(GetRolesQuery request, CancellationToken cancellationToken) + { + return await _dbContext.Roles.ProjectTo(_mapper.ConfigurationProvider).ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/src/Application/Roles/Queries/GetRoles/Mapping.cs b/src/Application/Roles/Queries/GetRoles/Mapping.cs new file mode 100644 index 000000000..71bf5dd56 --- /dev/null +++ b/src/Application/Roles/Queries/GetRoles/Mapping.cs @@ -0,0 +1,12 @@ +using SSW.Rewards.Shared.DTOs.Roles; + +namespace SSW.Rewards.Application.Roles.Queries.GetRoles; +public class Mapping : Profile +{ + public Mapping() + { + CreateMap() + .ForMember(dst => dst.Id, opt => opt.MapFrom(src => src.Id)) + .ForMember(dst => dst.Name, opt => opt.MapFrom(src => src.Name)); + } +} \ No newline at end of file diff --git a/src/Application/Services/UserService.cs b/src/Application/Services/UserService.cs index 1eb279257..63797f8f6 100644 --- a/src/Application/Services/UserService.cs +++ b/src/Application/Services/UserService.cs @@ -228,7 +228,27 @@ public async Task GetUser(int userId, CancellationToken cancella return vm; } + + public UsersViewModel GetUsers() + { + return GetUsers(CancellationToken.None).Result; + } + + public async Task GetUsers(CancellationToken cancellationToken) + { + var vm = await _dbContext.Users + .Include(x => x.Roles) + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(cancellationToken); + if (vm == null) + { + throw new NotFoundException(nameof(User)); + } + + return new UsersViewModel { Users = vm }; + } + public UserAchievementsViewModel GetUserAchievements(int userId) { return GetUserAchievements(userId, CancellationToken.None).Result; diff --git a/src/Application/Users/Commands/AdminUpdateUserRoles/AdminUpdateUserRolesCommand.cs b/src/Application/Users/Commands/AdminUpdateUserRoles/AdminUpdateUserRolesCommand.cs new file mode 100644 index 000000000..498fc7754 --- /dev/null +++ b/src/Application/Users/Commands/AdminUpdateUserRoles/AdminUpdateUserRolesCommand.cs @@ -0,0 +1,49 @@ +using SSW.Rewards.Shared.DTOs.Users; + +namespace SSW.Rewards.Application.Users.Commands.RegisterUser; + +public class AdminUpdateUserRolesCommand : IRequest +{ + public UserDto User { get; set; } = new(); +} + +public class AdminUpdateUserRolesCommandHandler : IRequestHandler +{ + private readonly IApplicationDbContext _dbContext; + + public AdminUpdateUserRolesCommandHandler(IApplicationDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task Handle(AdminUpdateUserRolesCommand request, CancellationToken cancellationToken) + { + var user = _dbContext.Users + .Include(u => u.Roles) + .ThenInclude(ur => ur.Role) + .FirstOrDefault(x => x.Id == request.User.Id); + + if (user != null) + { + var rolesToRemove = user.Roles + .Where(ur => !request.User.Roles.Any(r => r.Name == ur.Role.Name)) + .ToList(); + + var rolesToAdd = request.User.Roles + .Where(r => !user.Roles.Any(ur => ur.Role.Name == r.Name)) + .ToList(); + + foreach (var roleToRemove in rolesToRemove) + { + user.Roles.Remove(roleToRemove); + } + + foreach (var roleToAdd in rolesToAdd) + { + user.Roles.Add(new UserRole { RoleId = roleToAdd.Id, UserId = user.Id }); + } + + await _dbContext.SaveChangesAsync(cancellationToken); + } + } +} diff --git a/src/Application/Users/Common/Mapping.cs b/src/Application/Users/Common/Mapping.cs index a7d2f53d1..8dd9c983b 100644 --- a/src/Application/Users/Common/Mapping.cs +++ b/src/Application/Users/Common/Mapping.cs @@ -1,4 +1,5 @@ -using SSW.Rewards.Shared.DTOs.Users; +using SSW.Rewards.Shared.DTOs.Roles; +using SSW.Rewards.Shared.DTOs.Users; namespace SSW.Rewards.Application.Users.Common; public class Mapping : Profile @@ -8,5 +9,9 @@ public Mapping() CreateMap() .ForMember(dst => dst.AwardedAt, opt => opt.MapFrom(src => src != null ? src.AwardedAt : (DateTime?)null)) .ForMember(dst => dst.Complete, opt => opt.MapFrom(src => src != null)); + + CreateMap() + .ForMember(dst => dst.Id, opt => opt.MapFrom(src => src.Role.Id)) + .ForMember(dst => dst.Name, opt => opt.MapFrom(src => src.Role.Name)); } } diff --git a/src/Application/Users/Queries/GetUsers/GetUsersQuery.cs b/src/Application/Users/Queries/GetUsers/GetUsersQuery.cs new file mode 100644 index 000000000..8228f3aba --- /dev/null +++ b/src/Application/Users/Queries/GetUsers/GetUsersQuery.cs @@ -0,0 +1,20 @@ +using SSW.Rewards.Shared.DTOs.Users; + +namespace SSW.Rewards.Application.Users.Queries.GetUsers; + +public class GetUsersQuery : IRequest; + +public class GetUsersQueryHandler : IRequestHandler +{ + private readonly IUserService _userService; + + public GetUsersQueryHandler(IUserService userService) + { + _userService = userService; + } + + public async Task Handle(GetUsersQuery request, CancellationToken cancellationToken) + { + return await _userService.GetUsers(cancellationToken); + } +} \ No newline at end of file diff --git a/src/Application/Users/Queries/GetUsers/Mapping.cs b/src/Application/Users/Queries/GetUsers/Mapping.cs new file mode 100644 index 000000000..0ebbf4428 --- /dev/null +++ b/src/Application/Users/Queries/GetUsers/Mapping.cs @@ -0,0 +1,14 @@ +using SSW.Rewards.Shared.DTOs.Users; + +namespace SSW.Rewards.Application.Users.Queries.GetUsers; +public class Mapping : Profile +{ + public Mapping() + { + CreateMap() + .ForMember(dst => dst.Id, opt => opt.MapFrom(src => src.Id)) + .ForMember(dst => dst.Name, opt => opt.MapFrom(src => src.FullName)) + .ForMember(dst => dst.Email, opt => opt.MapFrom(src => src.Email)) + .ForMember(dst => dst.Roles, opt => opt.MapFrom(src => src.Roles)); + } +} diff --git a/src/Common/DTOs/Roles/RoleDto.cs b/src/Common/DTOs/Roles/RoleDto.cs new file mode 100644 index 000000000..7f35da6af --- /dev/null +++ b/src/Common/DTOs/Roles/RoleDto.cs @@ -0,0 +1,8 @@ +namespace SSW.Rewards.Shared.DTOs.Roles; + +public class RoleDto +{ + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; +} diff --git a/src/Common/DTOs/Users/UserDto.cs b/src/Common/DTOs/Users/UserDto.cs new file mode 100644 index 000000000..1692681f6 --- /dev/null +++ b/src/Common/DTOs/Users/UserDto.cs @@ -0,0 +1,14 @@ +using SSW.Rewards.Shared.DTOs.Roles; + +namespace SSW.Rewards.Shared.DTOs.Users; + +public class UserDto +{ + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public string Email { get; set; } = string.Empty; + + public IEnumerable Roles { get; set; } = Enumerable.Empty(); +} diff --git a/src/Common/DTOs/Users/UsersViewModel.cs b/src/Common/DTOs/Users/UsersViewModel.cs new file mode 100644 index 000000000..52ed7f494 --- /dev/null +++ b/src/Common/DTOs/Users/UsersViewModel.cs @@ -0,0 +1,6 @@ +namespace SSW.Rewards.Shared.DTOs.Users; + +public class UsersViewModel +{ + public IEnumerable Users { get; set; } = Enumerable.Empty(); +} \ No newline at end of file diff --git a/src/WebAPI/Controllers/RoleController.cs b/src/WebAPI/Controllers/RoleController.cs new file mode 100644 index 000000000..23f550344 --- /dev/null +++ b/src/WebAPI/Controllers/RoleController.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SSW.Rewards.Application.Roles.Queries.GetRoles; +using SSW.Rewards.Shared.DTOs.Roles; +using SSW.Rewards.WebAPI.Authorisation; + +namespace SSW.Rewards.WebAPI.Controllers; + +public class RoleController : ApiControllerBase +{ + [HttpGet] + [Authorize(Roles = AuthorizationRoles.Admin)] + public async Task>> GetRoles() + { + return Ok(await Mediator.Send(new GetRolesQuery())); + } +} \ No newline at end of file diff --git a/src/WebAPI/Controllers/UserController.cs b/src/WebAPI/Controllers/UserController.cs index 52c75ca5d..3802d7f84 100644 --- a/src/WebAPI/Controllers/UserController.cs +++ b/src/WebAPI/Controllers/UserController.cs @@ -17,6 +17,7 @@ using SSW.Rewards.WebAPI.Authorisation; using SSW.Rewards.Application.Users.Commands.AdminDeleteProfile; using SSW.Rewards.Application.Users.Queries.AdminGetProfileDeletionRequests; +using SSW.Rewards.Application.Users.Queries.GetUsers; namespace SSW.Rewards.WebAPI.Controllers; @@ -99,6 +100,21 @@ public async Task DeleteMyProfile() return Ok(); } + + [HttpGet] + [Authorize(Roles = AuthorizationRoles.Admin)] + public async Task> GetUsers() + { + return await Mediator.Send(new GetUsersQuery()); + } + + [HttpPost] + [Authorize(Roles = AuthorizationRoles.Admin)] + public async Task UpdateUserRoles(UserDto dto) + { + await Mediator.Send(new AdminUpdateUserRolesCommand { User = dto }); + return Accepted(); + } [HttpGet] [Authorize(Roles = AuthorizationRoles.Admin)]