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)]