Skip to content

Commit

Permalink
✨ Admin | User role management (#829)
Browse files Browse the repository at this point in the history
* Add users page

* Implement role management
  • Loading branch information
zacharykeeping authored Apr 11, 2024
1 parent a3e157b commit 03e5f68
Show file tree
Hide file tree
Showing 20 changed files with 470 additions and 2 deletions.
74 changes: 74 additions & 0 deletions src/AdminUI/Components/Dialogs/Users/EditUserRoles.razor
Original file line number Diff line number Diff line change
@@ -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

<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">Edit User Roles</MudText>
</TitleContent>
<DialogContent>
@if (_loading)
{
<MudProgressCircular Color="Color.Default" Indeterminate="true" />
}
else
{
<MudSelect T="string" @bind-SelectedValues="SelectedRoles" Label="Roles" MultiSelection="true">
@foreach (var item in _roles)
{
<MudSelectItem T="string" Value="@item.Name">@item.Name</MudSelectItem>
}
</MudSelect>
}
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton
Variant="Variant.Filled"
Color="Color.Primary"
OnClick="Submit">Save</MudButton>
</DialogActions>
</MudDialog>

@code {
private bool _loading = true;
private IEnumerable<string> SelectedRoles { get; set; } = Enumerable.Empty<string>();
IEnumerable<RoleDto> _roles = Enumerable.Empty<RoleDto>();

[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();
}
112 changes: 112 additions & 0 deletions src/AdminUI/Pages/Users.razor
Original file line number Diff line number Diff line change
@@ -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

<MudText Typo="Typo.h2">Users</MudText>
<MudText Typo="Typo.body1">All users</MudText>

<Table TItem="UserDto"
Items="@(_vm.Users.Where(s => s.Name.Contains(_searchString, StringComparison.InvariantCultureIgnoreCase) || s.Email.Contains(_searchString, StringComparison.InvariantCultureIgnoreCase)).OrderBy(i => i.Name).ToList())"
IsLoading="@_loading"
TableTitle="Users">
<ToolbarContent>
<MudTextField @bind-Value="_searchString"
Placeholder="Search for a user"
Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium"
Class="mt-0"/>
</ToolbarContent>
<HeadingTemplate>
<MudTh Style="max-width: fit-content">
<MudTableSortLabel SortBy="new Func<UserDto, object>(x => x.Id)">
Id
</MudTableSortLabel>
</MudTh>
<MudTh>
<MudTableSortLabel SortBy="new Func<UserDto, object>(x => x.Name)" InitialDirection="SortDirection.Ascending">
Name
</MudTableSortLabel>
</MudTh>
<MudTh>
<MudTableSortLabel SortBy="new Func<UserDto, object>(x => x.Email)">
Email
</MudTableSortLabel>
</MudTh>
<MudTh>Roles</MudTh>
</HeadingTemplate>
<RowTemplate>
<MudTd DataLabel="Id">@context.Id</MudTd>
<MudTd DataLabel="Name">@context.Name</MudTd>
<MudTd DataLabel="Email">@context.Email</MudTd>
<MudTd DataLabel="Roles">
<MudStack Row="true" Spacing="5" Style="align-items: center; width: 100%">
<MudText>@string.Join(", ", context.Roles.Select(x => x.Name).OrderBy(x => x))</MudText>
<MudSpacer/>
<MudIconButton
Variant="Variant.Filled"
Color="Color.Primary"
OnClick="() => { EditRoles(context); }"
Icon="@Icons.Material.Filled.Edit"/>
</MudStack>
</MudTd>
</RowTemplate>
</Table>

@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<SSW.Rewards.Admin.UI.Components.Dialogs.Users.EditUserRoles>("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);
}
}
}

}
1 change: 1 addition & 0 deletions src/AdminUI/Shared/NavMenu.razor
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
<MudNavLink Href="achievements" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Stars">Achievements</MudNavLink>
<MudNavLink Href="rewards" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Redeem">Rewards</MudNavLink>
<MudNavLink Href="quizzes" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Quiz">Quizzes</MudNavLink>
<MudNavLink Href="users" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.People">Users</MudNavLink>
<MudNavLink Href="newusers" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.SupervisedUserCircle">New Users</MudNavLink>
<MudNavLink Href="prizedraw" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Celebration">Prize Draw</MudNavLink>
<MudNavLink Href="notifications" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Chat">Notifications</MudNavLink>
Expand Down
1 change: 1 addition & 0 deletions src/ApiClient/ConfigureServices.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public static IServiceCollection AddApiClientServices<THandler>(this IServiceCol
services.AddSingleton<ISkillsAdminService, SkillsAdminService>();
services.AddSingleton<IStaffAdminService, StaffAdminService>();
services.AddSingleton<IUserAdminService, UserAdminService>();
services.AddSingleton<IRoleService, RoleService>();
}

services.AddSingleton<IAchievementService, AchievementService>();
Expand Down
39 changes: 39 additions & 0 deletions src/ApiClient/Services/IRoleService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using System.Net.Http.Json;
using SSW.Rewards.Shared.DTOs.Roles;

namespace SSW.Rewards.ApiClient.Services;

public interface IRoleService
{
Task<IEnumerable<RoleDto>> 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<IEnumerable<RoleDto>> GetRoles(CancellationToken cancellationToken)
{
var result = await _httpClient.GetAsync($"{_baseRoute}GetRoles", cancellationToken);

if (result.IsSuccessStatusCode)
{
var response = await result.Content.ReadFromJsonAsync<IEnumerable<RoleDto>>(cancellationToken: cancellationToken);

if (response is not null)
{
return response;
}
}

var responseContent = await result.Content.ReadAsStringAsync(cancellationToken);
throw new Exception($"Failed to get roles: {responseContent}");
}
}
33 changes: 33 additions & 0 deletions src/ApiClient/Services/IUserAdminService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ namespace SSW.Rewards.ApiClient.Services;

public interface IUserAdminService
{
Task<UsersViewModel> GetUsers(CancellationToken cancellationToken = default);
Task<NewUsersViewModel> GetNewUsers(LeaderboardFilter filter, bool filterStaff, CancellationToken cancellationToken = default);
Task UpdateUserRoles(UserDto dto, CancellationToken cancellationToken = default);
Task<ProfileDeletionRequestsVieWModel> GetProfileDeletionRequests(CancellationToken cancellationToken = default);
Task DeleteUserProfile(AdminDeleteProfileDto dto, CancellationToken cancellationToken = default);
}
Expand All @@ -21,6 +23,37 @@ public UserAdminService(IHttpClientFactory httpClientFactory)
_httpClient = httpClientFactory.CreateClient(Constants.AuthenticatedClient);
}

public async Task<UsersViewModel> GetUsers(CancellationToken cancellationToken = default)
{
var result = await _httpClient.GetAsync($"{_baseRoute}GetUsers", cancellationToken);

if (result.IsSuccessStatusCode)
{
var response = await result.Content.ReadFromJsonAsync<UsersViewModel>(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<NewUsersViewModel> GetNewUsers(LeaderboardFilter filter, bool filterStaff, CancellationToken cancellationToken = default)
{
var result = await _httpClient.GetAsync($"{_baseRoute}GetNewUsers?filter={filter}&filterStaff={filterStaff}", cancellationToken);
Expand Down
2 changes: 1 addition & 1 deletion src/Application/Common/Interfaces/IRolesService.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using SSW.Rewards.Domain.Entities;


namespace SSW.Rewards.Application.Common.Interfaces;

Expand Down
4 changes: 4 additions & 0 deletions src/Application/Common/Interfaces/IUserService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ public interface IUserService
UserProfileDto GetUser(int userId);
Task<UserProfileDto> GetUser(int userId, CancellationToken cancellationToken);
Task<int> GetUserId(string email, CancellationToken cancellationToken);

// Get users
UsersViewModel GetUsers();
Task<UsersViewModel> GetUsers(CancellationToken cancellationToken);

// Get user achievements
UserAchievementsViewModel GetUserAchievements(int userId);
Expand Down
23 changes: 23 additions & 0 deletions src/Application/Roles/Queries/GetRoles/GetRolesQuery.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using AutoMapper.QueryableExtensions;
using SSW.Rewards.Shared.DTOs.Roles;

namespace SSW.Rewards.Application.Roles.Queries.GetRoles;

public class GetRolesQuery : IRequest<IEnumerable<RoleDto>>;

public class GetRolesQueryHandler : IRequestHandler<GetRolesQuery, IEnumerable<RoleDto>>
{
private readonly IMapper _mapper;
private readonly IApplicationDbContext _dbContext;

public GetRolesQueryHandler(IMapper mapper, IApplicationDbContext dbContext)
{
_mapper = mapper;
_dbContext = dbContext;
}

public async Task<IEnumerable<RoleDto>> Handle(GetRolesQuery request, CancellationToken cancellationToken)
{
return await _dbContext.Roles.ProjectTo<RoleDto>(_mapper.ConfigurationProvider).ToListAsync(cancellationToken);
}
}
12 changes: 12 additions & 0 deletions src/Application/Roles/Queries/GetRoles/Mapping.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using SSW.Rewards.Shared.DTOs.Roles;

namespace SSW.Rewards.Application.Roles.Queries.GetRoles;
public class Mapping : Profile
{
public Mapping()
{
CreateMap<Role, RoleDto>()
.ForMember(dst => dst.Id, opt => opt.MapFrom(src => src.Id))
.ForMember(dst => dst.Name, opt => opt.MapFrom(src => src.Name));
}
}
20 changes: 20 additions & 0 deletions src/Application/Services/UserService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,27 @@ public async Task<UserProfileDto> GetUser(int userId, CancellationToken cancella

return vm;
}

public UsersViewModel GetUsers()
{
return GetUsers(CancellationToken.None).Result;
}

public async Task<UsersViewModel> GetUsers(CancellationToken cancellationToken)
{
var vm = await _dbContext.Users
.Include(x => x.Roles)
.ProjectTo<UserDto>(_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;
Expand Down
Loading

0 comments on commit 03e5f68

Please sign in to comment.