Add Users And account administration panel #1

Merged
maxime.batista merged 5 commits from users into master 1 year ago

@ -20,6 +20,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="7.0.13" /> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="7.0.13" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="7.0.13" PrivateAssets="all" /> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="7.0.13" PrivateAssets="all" />
<PackageReference Include="MudBlazor" Version="6.12.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

@ -1,4 +1,8 @@
<Router AppAssembly="@typeof(App).Assembly"> <link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" />
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData"> <Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" /> <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" /> <FocusOnNavigate RouteData="@routeData" Selector="h1" />

@ -2,11 +2,11 @@
@if (@User.IsAdmin) @if (@User.IsAdmin)
{ {
<h3>Administrator @User.UserName</h3> <h3>Administrator @User.Name</h3>
} }
else else
{ {
<h3>@User.UserName</h3> <h3>@User.Name</h3>
} }
<p>email: @User.Email</p> <p>email: @User.Email</p>
<p>id: @User.Id</p> <p>id: @User.Id</p>

@ -1,5 +1,14 @@
namespace AdminPanel.Models; namespace AdminPanel.Models;
public record User(string UserName, string Email, int Id, bool IsAdmin) { public class User
{
public required string Name { get; set; }
public required string Email { get; set; }
public uint Id { get; set; }
public bool IsAdmin { get; set; }
public override string ToString()
{
return $"{nameof(Name)}: {Name}, {nameof(Email)}: {Email}, {nameof(Id)}: {Id}, {nameof(IsAdmin)}: {IsAdmin}";
}
} }

@ -1,32 +1,95 @@
@page "/users" @page "/users"
@using AdminPanel.Models
@using System.ComponentModel.DataAnnotations
@using AdminPanel.Components;
@using AdminPanel.Models;
<PageTitle>User Panel</PageTitle> <PageTitle>User Panel</PageTitle>
<h1>User Panel</h1> <h1>User Panel</h1>
<div class="users-div"> <div id="users-div">
@if (Users == null)
{ <MudOverlay Visible="IsAddingUser" DarkBackground>
<p>Fetching Data...</p> <MudForm id="add-account-form">
} <MudTextField T="string"
else Label="Name"
foreach (User user in Users) @bind-Value="FormAccountName"
{ Required
<div class="user-cell"> Validation="@(VerifyLength(6, 256))"/>
<UserComponent User=@user/> <MudTextField T="string"
<Leaflet> Label="Email"
<Header> @bind-Value="FormAccountEmail"
see more Required
</Header> Validation="@(new EmailAddressAttribute() { ErrorMessage = "The email address is invalid" })"/>
<MudTextField T="string"
<Body> InputType="InputType.Password"
<p>Ratio</p> Label="Password"
</Body> @bind-Value="FormAccountPassword"
</Leaflet> Validation="@(VerifyLength(6, 256))"
Required/>
<MudCheckBox T="bool" Label="Is Administrator" @bind-Value="FormAccountIsAdmin"/>
<MudButton Variant="Variant.Filled" Color="Color.Secondary" OnClick="@(() => IsAddingUser = false)">Cancel</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="ConfirmAddAccount">Add Account</MudButton>
</MudForm>
</MudOverlay>
<MudDataGrid
T="User"
@ref="Grid"
ServerData="LoadUsersFromServer"
MultiSelection="true"
ColumnResizeMode="ResizeMode.Column"
RowsPerPage="10"
ReadOnly="false"
EditMode="DataGridEditMode.Form"
@bind-SelectedItems="SelectedItems"
CommittedItemChanges="OnAccountUpdated">
<ToolBarContent>
<div style="display: flex; align-items: center">
<MudIconButton Icon="@Icons.Material.Filled.Add" OnClick="@(() => IsAddingUser = true)" Color="@Color.Success" Class="add-item-btn"/>
<MudText Align="Align.Center">Add Account</MudText>
</div> </div>
} <div style="display: flex; align-items: center">
<MudIconButton Icon="@Icons.Material.Filled.Remove" OnClick="RemoveSelection" Color="@Color.Error" Class="remove-item-btn"/>
<MudText Align="Align.Center">Remove Selection</MudText>
</div>
<MudSpacer/>
<MudText>Accounts</MudText>
<MudSpacer/>
<MudTextField
T="string"
@bind-Value="SearchString"
OnKeyDown="@ValidateSearch"
Placeholder="Search an email or a name"
AdornmentIcon="@Icons.Material.Filled.Search"
Immediate
IconSize="Size.Medium"/>
</ToolBarContent>
<Columns>
<SelectColumn T="User"/>
<PropertyColumn Property="x => x.Name" Required/>
<PropertyColumn Property="x => x.Email" Title="Email Address" Required/>
<TemplateColumn Title="Is Administrator">
<CellTemplate>
<MudCheckBox Value="@context.Item!.IsAdmin" ReadOnly/>
</CellTemplate>
<EditTemplate>
<MudText>Is Administrator :</MudText>
<MudCheckBox @bind-Value="context.Item.IsAdmin"/>
</EditTemplate>
</TemplateColumn>
<PropertyColumn Property="x => x.Id" IsEditable="false"/>
<TemplateColumn>
<CellTemplate>
<MudIconButton Size="Size.Small" Icon="@Icons.Material.Outlined.Edit" OnClick="@context.Actions.StartEditingItemAsync"/>
</CellTemplate>
</TemplateColumn>
</Columns>
<PagerContent>
<MudDataGridPager T="User" PageSizeOptions="new[] { 1, 2, 4, 10, 25, 50, 100 }"/>
</PagerContent>
</MudDataGrid>
</div> </div>

@ -1,34 +1,121 @@
using System.Net.Http.Json; using System.Text;
using AdminPanel.Models; using AdminPanel.Models;
using AdminPanel.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.Extensions.Primitives;
using MudBlazor;
namespace AdminPanel.Pages namespace AdminPanel.Pages
{ {
public partial class UserListPanel public partial class UserListPanel
{ {
public List<User>? Users { get; private set; } [Inject] private ISnackbar Snackbar { get; init; }
[Inject] private IUsersService Service { get; init; }
[Inject] private ILogger<UserListPanel> Logger { get; init; }
public UserListPanel() private MudDataGrid<User> Grid { get; set; }
private string? SearchString { get; set; }
private HashSet<User> SelectedItems { get; set; } = new();
private string? FormAccountName { get; set; }
private string? FormAccountEmail { get; set; }
private string? FormAccountPassword { get; set; }
private bool FormAccountIsAdmin { get; set; }
public bool IsAddingUser { get; set; }
private async Task<GridData<User>> LoadUsersFromServer(GridState<User> state)
{ {
HttpClient client = new() Logger.LogDebug($"Loading users from server, state = {state} searchString = {SearchString}");
var (count, users) =
await Service.ListUsers((uint)(state.Page * state.PageSize), (uint)state.PageSize, SearchString);
return new GridData<User>
{ {
BaseAddress = new Uri("http://localhost:8080") TotalItems = (int)count,
Items = users
}; };
RetrieveUsers(client);
} }
public void OnAccessToUserSpaceClicked(User user)
private async void OnAccountUpdated(User user)
{
Logger.LogDebug($"Account updated : {user}");
try
{ {
await Service.UpdateUser(user);
}
catch (ServiceException err)
{
ShowErrors(err);
}
} }
private async void RetrieveUsers(HttpClient client) private async void ConfirmAddAccount(MouseEventArgs e)
{ {
using HttpResponseMessage response = await client.GetAsync("/api/admin/list-users?start=0&n=100"); // We no longer add an account if it is confirmed
IsAddingUser = false;
response.EnsureSuccessStatusCode(); try
{
var user = await Service.AddUser(FormAccountName!, FormAccountEmail!, FormAccountPassword!,
FormAccountIsAdmin);
Logger.LogDebug($"Added user : {user}");
await Grid.ReloadServerData();
}
catch (ServiceException err)
{
ShowErrors(err);
}
}
Users = (await response.Content.ReadFromJsonAsync<List<User>>())!; private void ShowErrors(ServiceException e)
StateHasChanged(); {
Console.WriteLine(Users); foreach (var erronedArgument in e.ArgumentMessages)
{
var sb = new StringBuilder(erronedArgument.Key);
sb.Append(" :");
foreach (var message in erronedArgument.Value)
{
sb.Append("\n\t-");
sb.Append(message);
}
Snackbar.Add(sb.ToString());
}
}
private async void RemoveSelection(MouseEventArgs e)
{
var users = SelectedItems.ToList().ConvertAll(u => u.Id);
Logger.LogDebug($"Removing users : {users}");
try
{
await Service.RemoveUsers(users);
await Grid.ReloadServerData();
}
catch (ServiceException err)
{
ShowErrors(err);
}
}
private Func<string, string?> VerifyLength(uint min, uint max)
{
return s => s.Length >= min && s.Length <= max
? null
: $"length is incorrect (must be between {min} and {max})";
}
private void ValidateSearch(KeyboardEventArgs e)
{
Grid.ReloadServerData();
Logger.LogDebug($"Searching for {SearchString}");
} }
} }
} }

@ -0,0 +1,8 @@
#add-account-form {
background-color: white;
visibility: hidden;
color: #ffba00;
}

@ -1,11 +1,28 @@
using AdminPanel; using AdminPanel;
using AdminPanel.Pages;
using AdminPanel.Services;
using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting; using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using MudBlazor.Services;
var builder = WebAssemblyHostBuilder.CreateDefault(args); var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app"); builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after"); builder.RootComponents.Add<HeadOutlet>("head::after");
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); builder.Services
.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
// builder.Logging.SetMinimumLevel(LogLevel.Debug);
var client = new HttpClient
{
BaseAddress = new Uri("http://localhost:8080")
};
builder.Services.AddScoped<IUsersService>(sp => new HttpUsersService(client));
//builder.Services.AddScoped<IUsersService, UsersServiceStub>();
builder.Services.AddMudServices();
await builder.Build().RunAsync(); await builder.Build().RunAsync();

@ -0,0 +1,93 @@
using System.Diagnostics;
using System.Net;
using System.Net.Http.Json;
using AdminPanel.Models;
namespace AdminPanel.Services;
public class HttpUsersService : IUsersService
{
private HttpClient Client { get; }
public HttpUsersService(HttpClient client)
{
this.Client = client;
}
private record ServerErrorMessageDto(string? Field, string? Error, string Message);
private async Task EnsureResponseIsOk(HttpResponseMessage response)
{
if (response.StatusCode == HttpStatusCode.OK)
{
return;
}
var content = await response.Content.ReadFromJsonAsync<ServerErrorMessageDto[]>();
var messages = content!
.GroupBy(e => e.Field ?? e.Error!)
.ToDictionary(
g => g.Key,
g => g.ToList().ConvertAll(e => e.Message)
);
throw new ServiceException("Server refused request", messages);
}
private record ListUsersResponseDto(List<User> Users, uint TotalCount);
public async Task<(uint, List<User>)> ListUsers(uint from, uint len, string? searchString = null)
{
var httpResponse =
await Client.GetAsync($"/api/admin/list-users?start={from}&n={len}&search={searchString ?? ""}");
await EnsureResponseIsOk(httpResponse);
var response = await httpResponse.Content.ReadFromJsonAsync<ListUsersResponseDto>()
?? throw new Exception("Received non-parseable json from server");
return (response.TotalCount, response.Users);
}
private record AddUserRequestDto(string Username, string Email, string Password, bool IsAdmin);
private record AddUserResponseDto(uint Id);
public async Task<User> AddUser(string username, string email, string password, bool isAdmin)
{
var httpResponse = await Client.PostAsJsonAsync("/api/admin/user/add",
new AddUserRequestDto(username, email, password, isAdmin));
await EnsureResponseIsOk(httpResponse);
var response = await httpResponse.Content.ReadFromJsonAsync<AddUserResponseDto>()
?? throw new Exception("Received non-parseable json from server");
return new User
{
Name = username,
Email = email,
IsAdmin = isAdmin,
Id = response.Id
};
}
private record RemoveUsersRequestDto(uint[] Identifiers);
public async Task RemoveUsers(IEnumerable<uint> userIds)
{
var httpResponse =
await Client.PostAsJsonAsync("/api/admin/user/remove-all", new RemoveUsersRequestDto(userIds.ToArray()));
await EnsureResponseIsOk(httpResponse);
}
private record UpdateUserRequestDto(string Email, string Username, bool IsAdmin);
public async Task UpdateUser(User user)
{
var httpResponse = await Client.PostAsJsonAsync($"/api/admin/user/{user.Id}/update",
new UpdateUserRequestDto(user.Email, user.Name, user.IsAdmin));
await EnsureResponseIsOk(httpResponse);
}
}

@ -0,0 +1,15 @@
using AdminPanel.Models;
namespace AdminPanel.Services
{
public interface IUsersService
{
public Task<(uint, List<User>)> ListUsers(uint from, uint len, string? searchString = null);
public Task<User> AddUser(string username, string email, string password, bool isAdmin);
public Task RemoveUsers(IEnumerable<uint> userId);
public Task UpdateUser(User user);
}
}

@ -0,0 +1,18 @@
namespace AdminPanel.Services;
class ServiceException : Exception
{
public Dictionary<string, List<string>> ArgumentMessages { get; init; }
public ServiceException(string? message, Dictionary<string, List<string>> arguments) : base(message)
{
ArgumentMessages = arguments;
}
public ServiceException(string? message, Dictionary<string, List<string>> arguments, Exception? innerException) : base(message, innerException)
{
ArgumentMessages = arguments;
}
}

@ -0,0 +1,65 @@
using AdminPanel.Models;
namespace AdminPanel.Services
{
public class UsersServiceStub : IUsersService
{
private Dictionary<uint, User> Users { get; } = new[]
{
new User
{
Name = "Mathis",
Email = "mathis@gmail.com",
Id = 0,
IsAdmin = true
},
new User
{
Name = "Maeva",
Email = "maeva@gmail.com",
Id = 1,
IsAdmin = false
},
}.ToDictionary(u => u.Id);
public Task<User> AddUser(string username, string email, string password, bool isAdmin)
{
User user = new User
{
Email = email,
Name = username,
IsAdmin = isAdmin,
Id = (uint) Users.Count
};
Users[user.Id] = user;
return Task.FromResult(user);
}
public async Task<(uint, List<User>)> ListUsers(uint from, uint len, string? searchString = null)
{
//simulate a 1sec long request
await Task.Delay(1000);
var slice = Users.Values
.ToList()
.FindAll(a => searchString == null || a.Name.Contains(searchString) || a.Email.Contains(searchString))
.GetRange((int)from, (int)(from + len > Users.Count ? Users.Count - from : len));
return ((uint)Users.Count, slice);
}
public Task RemoveUsers(IEnumerable<uint> userIds)
{
foreach (var id in userIds)
{
Users.Remove(id);
}
return Task.CompletedTask;
}
public Task UpdateUser(User user)
{
Users[user.Id] = user;
return Task.CompletedTask;
}
}
}

@ -1,4 +1,8 @@
@inherits LayoutComponentBase @inherits LayoutComponentBase
@using MudBlazor
<MudThemeProvider/>
<MudDialogProvider/>
<MudSnackbarProvider/>
<div class="page"> <div class="page">
<div class="sidebar"> <div class="sidebar">

@ -8,3 +8,4 @@
@using Microsoft.JSInterop @using Microsoft.JSInterop
@using AdminPanel @using AdminPanel
@using AdminPanel.Shared @using AdminPanel.Shared
@using MudBlazor

@ -13,9 +13,14 @@
<link href="manifest.json" rel="manifest" /> <link href="manifest.json" rel="manifest" />
<link rel="apple-touch-icon" sizes="512x512" href="icon-512.png" /> <link rel="apple-touch-icon" sizes="512x512" href="icon-512.png" />
<link rel="apple-touch-icon" sizes="192x192" href="icon-192.png" /> <link rel="apple-touch-icon" sizes="192x192" href="icon-192.png" />
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" />
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
</head> </head>
<body> <body>
<div id="app"> <div id="app">
<svg class="loading-progress"> <svg class="loading-progress">
<circle r="40%" cx="50%" cy="50%" /> <circle r="40%" cx="50%" cy="50%" />
@ -29,6 +34,7 @@
<a href="" class="reload">Reload</a> <a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a> <a class="dismiss">🗙</a>
</div> </div>
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
<script src="_framework/blazor.webassembly.js"></script> <script src="_framework/blazor.webassembly.js"></script>
<script>navigator.serviceWorker.register('service-worker.js');</script> <script>navigator.serviceWorker.register('service-worker.js');</script>
</body> </body>

Loading…
Cancel
Save