@page "/administration" @page "/administration.html" @using System.ComponentModel.DataAnnotations @using System.Net @using System.Net.Http.Json @using ChessCubing.App.Models.Users @implements IDisposable @inject AuthenticationStateProvider AuthenticationStateProvider @inject HttpClient Http ChessCubing Arena | Administration

Administration

Gerer les utilisateurs avec une vue tableau

Cette zone rassemble les comptes Keycloak et les profils du site dans une interface plus classique, avec ajout, edition et suppression.

@if (!IsAuthenticated) {

Connexion requise

Connecte-toi avec un compte administrateur

La gestion des utilisateurs devient disponible des qu'un compte portant le role `admin` est connecte.

} else if (!IsAdmin) {

Acces refuse

Ce compte n'a pas les droits d'administration

La gestion des utilisateurs est reservee aux comptes portant le role `admin`.

} else if (IsLoadingUsers) {

Chargement

Recuperation de la liste des utilisateurs

Le site rassemble les comptes Keycloak et les profils du site.

} else if (!string.IsNullOrWhiteSpace(LoadError)) {

Serveur

Impossible de charger l'administration

@LoadError

} else {

Utilisateurs

Liste des comptes

Le tableau centralise les comptes du site avec une colonne d'actions pour les operations courantes.

@if (!string.IsNullOrWhiteSpace(SaveError)) {

@SaveError

} @if (!string.IsNullOrWhiteSpace(SaveMessage)) {

@SaveMessage

}
@if (FilteredUsers.Count == 0) {
Recherche

Aucun utilisateur ne correspond au filtre en cours.

} else {
@foreach (var user in FilteredUsers) { }
Utilisateur Email Nom affiche Etat Profil site Derniere mise a jour Actions
@user.Username @user.IdentityDisplayName
@(user.Email ?? "Non renseigne") @(user.SiteDisplayName ?? "A definir") @(user.IsEnabled ? "Actif" : "Suspendu") @(user.HasSiteProfile ? "Cree" : "Absent") @BuildUserCardFootnote(user)
}
@if (IsLoadingDetail) {

Chargement

Recuperation de la fiche utilisateur

Les details du compte sont en cours de chargement.

} else if (!string.IsNullOrWhiteSpace(DetailError)) {

Serveur

Impossible de charger cette fiche

@DetailError

} else if (SelectedUser is null) {

Edition

Choisis un utilisateur dans le tableau

Le bouton `Modifier` charge la fiche detaillee ici pour editer le compte et le profil du site.

} else {

Edition

@SelectedUser.DisplayName

Les roles restent geres dans Keycloak. Cette fiche couvre l'etat du compte et le profil du site.

Identite Keycloak @SelectedUser.IdentityDisplayName
Compte cree le @FormatDate(SelectedUser.AccountCreatedUtc)
Profil site cree le @FormatDate(SelectedUser.SiteProfileCreatedUtc)
Profil site mis a jour @FormatDate(SelectedUser.SiteProfileUpdatedUtc)

Le profil site est cree automatiquement lors du premier enregistrement.

}
}
@code { private readonly AdminUserFormModel EditFormModel = new(); private readonly AdminCreateUserFormModel CreateFormModel = new(); private readonly List Users = []; private AdminUserDetailResponse? SelectedUser; private AdminUserSummaryResponse? PendingDeleteUser; private string? SelectedSubject; private string SearchTerm = string.Empty; private bool IsAuthenticated; private bool IsAdmin; private bool IsLoadingUsers = true; private bool IsLoadingDetail; private bool IsSaving; private bool IsCreating; private bool IsDeleting; private bool ShowCreateModal; private bool ShowDeleteModal; private string? LoadError; private string? DetailError; private string? SaveError; private string? SaveMessage; private string? CreateError; private string? DeleteError; private List FilteredUsers => Users .Where(MatchesSearch) .ToList(); private string HeroStatusTitle => !IsAuthenticated ? "Connexion requise" : !IsAdmin ? "Compte non admin" : IsLoadingUsers ? "Chargement des utilisateurs" : $"{Users.Count} comptes disponibles"; private string HeroStatusDescription => !IsAuthenticated ? "Connecte-toi avec un compte admin pour ouvrir la zone d'administration." : !IsAdmin ? "Le compte actuel est authentifie mais ne dispose pas du role admin." : IsLoadingUsers ? "Le site recupere les comptes et les profils deja relies a MySQL." : "Le tableau permet maintenant les operations classiques d'ajout, modification et suppression."; protected override async Task OnInitializedAsync() { AuthenticationStateProvider.AuthenticationStateChanged += HandleAuthenticationStateChanged; await LoadUsersAsync(); } private void HandleAuthenticationStateChanged(Task authenticationStateTask) => _ = InvokeAsync(() => LoadUsersAsync(SelectedSubject)); private async Task ReloadUsersAsync() => await LoadUsersAsync(SelectedSubject); private async Task LoadUsersAsync(string? preferredSubject = null, bool preserveFeedback = false) { LoadError = null; DetailError = null; if (!preserveFeedback) { SaveError = null; SaveMessage = null; } IsLoadingUsers = true; try { var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync(); if (authState.User.Identity?.IsAuthenticated != true) { ResetAdminState(); return; } IsAuthenticated = true; IsAdmin = authState.User.IsInRole("admin"); if (!IsAdmin) { Users.Clear(); SelectedSubject = null; SelectedUser = null; EditFormModel.Reset(); return; } var response = await Http.GetAsync("api/admin/users"); if (!response.IsSuccessStatusCode) { LoadError = await ReadErrorAsync(response, "La liste des utilisateurs n'a pas pu etre chargee."); Users.Clear(); SelectedSubject = null; SelectedUser = null; EditFormModel.Reset(); return; } var users = await response.Content.ReadFromJsonAsync>(); Users.Clear(); Users.AddRange(users ?? []); if (Users.Count == 0) { SelectedSubject = null; SelectedUser = null; EditFormModel.Reset(); return; } var nextSubject = Users.Any(user => user.Subject == preferredSubject) ? preferredSubject : Users.Any(user => user.Subject == SelectedSubject) ? SelectedSubject : Users[0].Subject; if (!string.IsNullOrWhiteSpace(nextSubject)) { await LoadUserDetailAsync(nextSubject, keepFeedback: true); } } catch (HttpRequestException) { LoadError = "Le service d'administration est temporairement indisponible."; Users.Clear(); } catch (TaskCanceledException) { LoadError = "Le chargement de l'administration a pris trop de temps."; Users.Clear(); } finally { IsLoadingUsers = false; StateHasChanged(); } } private async Task SelectUserAsync(string subject) { SaveError = null; SaveMessage = null; await LoadUserDetailAsync(subject, keepFeedback: false); } private async Task LoadUserDetailAsync(string subject, bool keepFeedback) { if (!keepFeedback) { SaveError = null; SaveMessage = null; } IsLoadingDetail = true; DetailError = null; SelectedSubject = subject; try { var response = await Http.GetAsync($"api/admin/users/{Uri.EscapeDataString(subject)}"); if (!response.IsSuccessStatusCode) { DetailError = await ReadErrorAsync(response, "La fiche utilisateur n'a pas pu etre chargee."); SelectedUser = null; return; } SelectedUser = await response.Content.ReadFromJsonAsync(); if (SelectedUser is null) { DetailError = "Le serveur a retourne une fiche vide."; return; } FillEditForm(SelectedUser); } catch (HttpRequestException) { DetailError = "Le detail utilisateur est temporairement indisponible."; SelectedUser = null; } catch (TaskCanceledException) { DetailError = "Le detail utilisateur a pris trop de temps a se charger."; SelectedUser = null; } finally { IsLoadingDetail = false; StateHasChanged(); } } private async Task SaveUserAsync() { if (IsSaving || SelectedSubject is null) { return; } IsSaving = true; SaveError = null; SaveMessage = null; try { var payload = new AdminUpdateUserRequest { Username = EditFormModel.Username ?? string.Empty, Email = EditFormModel.Email, FirstName = EditFormModel.FirstName, LastName = EditFormModel.LastName, IsEnabled = EditFormModel.IsEnabled, IsEmailVerified = EditFormModel.IsEmailVerified, DisplayName = EditFormModel.DisplayName, Club = EditFormModel.Club, City = EditFormModel.City, PreferredFormat = EditFormModel.PreferredFormat, FavoriteCube = EditFormModel.FavoriteCube, Bio = EditFormModel.Bio, }; var response = await Http.PutAsJsonAsync($"api/admin/users/{Uri.EscapeDataString(SelectedSubject)}", payload); if (!response.IsSuccessStatusCode) { SaveError = await ReadErrorAsync(response, "La mise a jour de l'utilisateur a echoue."); return; } SelectedUser = await response.Content.ReadFromJsonAsync(); if (SelectedUser is null) { SaveError = "Le serveur a retourne une fiche vide apres la sauvegarde."; return; } FillEditForm(SelectedUser); UpdateSummary(SelectedUser); SaveMessage = "La fiche utilisateur a bien ete mise a jour."; } catch (HttpRequestException) { SaveError = "Le service d'administration est temporairement indisponible."; } catch (TaskCanceledException) { SaveError = "La sauvegarde a pris trop de temps."; } finally { IsSaving = false; } } private void OpenCreateModal() { CreateFormModel.Reset(); CreateError = null; ShowCreateModal = true; } private void CloseCreateModal() { if (IsCreating) { return; } ShowCreateModal = false; CreateError = null; } private async Task CreateUserAsync() { if (IsCreating) { return; } IsCreating = true; CreateError = null; try { var payload = new AdminCreateUserRequest { Username = CreateFormModel.Username ?? string.Empty, Email = CreateFormModel.Email, Password = CreateFormModel.Password ?? string.Empty, ConfirmPassword = CreateFormModel.ConfirmPassword ?? string.Empty, FirstName = CreateFormModel.FirstName, LastName = CreateFormModel.LastName, IsEnabled = CreateFormModel.IsEnabled, IsEmailVerified = CreateFormModel.IsEmailVerified, DisplayName = CreateFormModel.DisplayName, Club = CreateFormModel.Club, City = CreateFormModel.City, PreferredFormat = CreateFormModel.PreferredFormat, FavoriteCube = CreateFormModel.FavoriteCube, Bio = CreateFormModel.Bio, }; var response = await Http.PostAsJsonAsync("api/admin/users", payload); if (!response.IsSuccessStatusCode) { CreateError = await ReadErrorAsync(response, "La creation de l'utilisateur a echoue."); return; } var createdUser = await response.Content.ReadFromJsonAsync(); if (createdUser is null) { CreateError = "Le serveur a retourne une fiche vide apres la creation."; return; } ShowCreateModal = false; SaveMessage = $"L'utilisateur {createdUser.Username} a bien ete cree."; SaveError = null; await LoadUsersAsync(createdUser.Subject, preserveFeedback: true); } catch (HttpRequestException) { CreateError = "Le service d'administration est temporairement indisponible."; } catch (TaskCanceledException) { CreateError = "La creation a pris trop de temps."; } finally { IsCreating = false; } } private void PromptDeleteUser(AdminUserSummaryResponse user) { PendingDeleteUser = user; DeleteError = null; ShowDeleteModal = true; } private void CloseDeleteModal() { if (IsDeleting) { return; } ShowDeleteModal = false; DeleteError = null; PendingDeleteUser = null; } private async Task DeleteUserAsync() { if (IsDeleting || PendingDeleteUser is null) { return; } IsDeleting = true; DeleteError = null; try { var deletedUsername = PendingDeleteUser.Username; var deletedSubject = PendingDeleteUser.Subject; var response = await Http.DeleteAsync($"api/admin/users/{Uri.EscapeDataString(deletedSubject)}"); if (!response.IsSuccessStatusCode) { DeleteError = await ReadErrorAsync(response, "La suppression de l'utilisateur a echoue."); return; } ShowDeleteModal = false; PendingDeleteUser = null; SaveError = null; SaveMessage = $"L'utilisateur {deletedUsername} a bien ete supprime."; var nextSubject = SelectedSubject == deletedSubject ? null : SelectedSubject; await LoadUsersAsync(nextSubject, preserveFeedback: true); } catch (HttpRequestException) { DeleteError = "Le service d'administration est temporairement indisponible."; } catch (TaskCanceledException) { DeleteError = "La suppression a pris trop de temps."; } finally { IsDeleting = false; } } private void UpdateSummary(AdminUserDetailResponse detail) { var summary = Users.FirstOrDefault(user => user.Subject == detail.Subject); if (summary is null) { return; } summary.Username = detail.Username; summary.Email = detail.Email; summary.IdentityDisplayName = detail.IdentityDisplayName; summary.SiteDisplayName = detail.DisplayName; summary.IsEnabled = detail.IsEnabled; summary.IsEmailVerified = detail.IsEmailVerified; summary.HasSiteProfile = detail.HasSiteProfile; summary.Club = detail.Club; summary.City = detail.City; summary.PreferredFormat = detail.PreferredFormat; summary.AccountCreatedUtc = detail.AccountCreatedUtc; summary.SiteProfileUpdatedUtc = detail.SiteProfileUpdatedUtc; } private void FillEditForm(AdminUserDetailResponse user) { EditFormModel.Username = user.Username; EditFormModel.Email = user.Email; EditFormModel.FirstName = user.FirstName; EditFormModel.LastName = user.LastName; EditFormModel.IsEnabled = user.IsEnabled; EditFormModel.IsEmailVerified = user.IsEmailVerified; EditFormModel.DisplayName = user.DisplayName; EditFormModel.Club = user.Club; EditFormModel.City = user.City; EditFormModel.PreferredFormat = user.PreferredFormat; EditFormModel.FavoriteCube = user.FavoriteCube; EditFormModel.Bio = user.Bio; } private void ResetAdminState() { IsAuthenticated = false; IsAdmin = false; Users.Clear(); SelectedSubject = null; SelectedUser = null; PendingDeleteUser = null; ShowCreateModal = false; ShowDeleteModal = false; EditFormModel.Reset(); CreateFormModel.Reset(); } private bool MatchesSearch(AdminUserSummaryResponse user) { var search = SearchTerm.Trim(); if (string.IsNullOrWhiteSpace(search)) { return true; } return Contains(user.Username, search) || Contains(user.Email, search) || Contains(user.IdentityDisplayName, search) || Contains(user.SiteDisplayName, search) || Contains(user.Club, search) || Contains(user.City, search); } private static bool Contains(string? value, string search) => value?.Contains(search, StringComparison.OrdinalIgnoreCase) == true; private string BuildTableRowClass(AdminUserSummaryResponse user) => string.Equals(SelectedSubject, user.Subject, StringComparison.Ordinal) ? "is-selected" : string.Empty; private static string BuildUserCardFootnote(AdminUserSummaryResponse user) => user.SiteProfileUpdatedUtc is not null ? FormatDate(user.SiteProfileUpdatedUtc) : user.AccountCreatedUtc is not null ? FormatDate(user.AccountCreatedUtc) : "Non disponible"; private static async Task ReadErrorAsync(HttpResponseMessage response, string fallbackMessage) { try { var error = await response.Content.ReadFromJsonAsync(); if (!string.IsNullOrWhiteSpace(error?.Message)) { return error.Message; } } catch { } return response.StatusCode switch { HttpStatusCode.Unauthorized => "La session a expire. Reconnecte-toi puis recharge la page.", HttpStatusCode.Forbidden => "Ce compte n'a pas le role admin requis pour cette action.", HttpStatusCode.NotFound => "Cet utilisateur n'existe plus ou n'est plus disponible.", _ => fallbackMessage, }; } private static string FormatDate(DateTime? value) => value is null ? "Non disponible" : value.Value.ToLocalTime().ToString("dd MMM yyyy 'a' HH:mm", CultureInfo.GetCultureInfo("fr-FR")); private static string BoolString(bool value) => value ? "true" : "false"; public void Dispose() => AuthenticationStateProvider.AuthenticationStateChanged -= HandleAuthenticationStateChanged; private sealed class ApiErrorMessage { public string? Message { get; set; } } private class AdminUserFormModel { [Required(ErrorMessage = "Le nom d'utilisateur est obligatoire.")] [MaxLength(120, ErrorMessage = "Le nom d'utilisateur doit rester sous 120 caracteres.")] public string? Username { get; set; } [MaxLength(255, ErrorMessage = "L'email doit rester sous 255 caracteres.")] [EmailAddress(ErrorMessage = "L'email n'est pas valide.")] public string? Email { get; set; } [MaxLength(120, ErrorMessage = "Le prenom doit rester sous 120 caracteres.")] public string? FirstName { get; set; } [MaxLength(120, ErrorMessage = "Le nom doit rester sous 120 caracteres.")] public string? LastName { get; set; } public bool IsEnabled { get; set; } = true; public bool IsEmailVerified { get; set; } [MaxLength(120, ErrorMessage = "Le nom affiche doit rester sous 120 caracteres.")] public string? DisplayName { get; set; } [MaxLength(120, ErrorMessage = "Le club doit rester sous 120 caracteres.")] public string? Club { get; set; } [MaxLength(120, ErrorMessage = "La ville doit rester sous 120 caracteres.")] public string? City { get; set; } [MaxLength(40, ErrorMessage = "Le format prefere doit rester sous 40 caracteres.")] public string? PreferredFormat { get; set; } [MaxLength(120, ErrorMessage = "Le cube favori doit rester sous 120 caracteres.")] public string? FavoriteCube { get; set; } [MaxLength(1200, ErrorMessage = "La bio doit rester sous 1200 caracteres.")] public string? Bio { get; set; } public virtual void Reset() { Username = string.Empty; Email = string.Empty; FirstName = string.Empty; LastName = string.Empty; IsEnabled = true; IsEmailVerified = false; DisplayName = string.Empty; Club = string.Empty; City = string.Empty; PreferredFormat = string.Empty; FavoriteCube = string.Empty; Bio = string.Empty; } } private sealed class AdminCreateUserFormModel : AdminUserFormModel { [Required(ErrorMessage = "Le mot de passe est obligatoire.")] [MinLength(8, ErrorMessage = "Le mot de passe doit contenir au moins 8 caracteres.")] public string? Password { get; set; } [Required(ErrorMessage = "La confirmation est obligatoire.")] [Compare(nameof(Password), ErrorMessage = "Les mots de passe ne correspondent pas.")] public string? ConfirmPassword { get; set; } public override void Reset() { base.Reset(); Password = string.Empty; ConfirmPassword = string.Empty; } } }