Ajoute une table de gestion des utilisateurs

This commit is contained in:
2026-04-15 21:53:24 +02:00
parent 655637072c
commit 0e6115e423
8 changed files with 865 additions and 189 deletions

View File

@@ -0,0 +1,32 @@
namespace ChessCubing.App.Models.Users;
public sealed class AdminCreateUserRequest
{
public string Username { get; set; } = string.Empty;
public string? Email { get; set; }
public string Password { get; set; } = string.Empty;
public string ConfirmPassword { get; set; } = string.Empty;
public string? FirstName { get; set; }
public string? LastName { get; set; }
public bool IsEnabled { get; set; } = true;
public bool IsEmailVerified { get; set; }
public string? DisplayName { get; set; }
public string? Club { get; set; }
public string? City { get; set; }
public string? PreferredFormat { get; set; }
public string? FavoriteCube { get; set; }
public string? Bio { get; set; }
}

View File

@@ -18,10 +18,10 @@
<header class="hero hero-home user-hero">
<div class="hero-copy">
<p class="eyebrow">Administration</p>
<h1>Piloter les utilisateurs du site depuis l'interface</h1>
<h1>Gerer les utilisateurs avec une vue tableau</h1>
<p class="lead">
Cette premiere zone d'administration centralise les comptes du site,
l'etat du compte Keycloak et les donnees metier stockees en MySQL.
Cette zone rassemble les comptes Keycloak et les profils du site
dans une interface plus classique, avec ajout, edition et suppression.
</p>
<div class="hero-actions">
<a class="button secondary" href="utilisateur.html">Voir l'espace utilisateur</a>
@@ -70,8 +70,8 @@
<h2>Connecte-toi avec un compte administrateur</h2>
</div>
<p class="section-copy">
Utilise le menu du site pour te connecter. La zone d'administration
devient disponible des qu'un compte avec le role `admin` est actif.
La gestion des utilisateurs devient disponible des qu'un compte
portant le role `admin` est connecte.
</p>
</div>
</section>
@@ -105,29 +105,44 @@
<h2>Impossible de charger l'administration</h2>
<p class="profile-feedback error">@LoadError</p>
<div class="hero-actions">
<button class="button secondary" type="button" @onclick="LoadUsersAsync">Reessayer</button>
<button class="button secondary" type="button" @onclick="ReloadUsersAsync">Reessayer</button>
</div>
</section>
}
else
{
<section class="panel panel-third admin-list-panel">
<section class="panel panel-wide admin-table-panel">
<div class="section-heading">
<div>
<p class="eyebrow">Utilisateurs</p>
<h2>Parcourir les comptes</h2>
<h2>Liste des comptes</h2>
</div>
<p class="section-copy">
Le tableau centralise les comptes du site avec une colonne d'actions
pour les operations courantes.
</p>
</div>
<div class="admin-toolbar">
@if (!string.IsNullOrWhiteSpace(SaveError))
{
<p class="profile-feedback error">@SaveError</p>
}
@if (!string.IsNullOrWhiteSpace(SaveMessage))
{
<p class="profile-feedback success">@SaveMessage</p>
}
<div class="admin-toolbar admin-toolbar-grid">
<label class="field admin-search-field">
<span>Recherche</span>
<input @bind="SearchTerm" @bind:event="oninput" placeholder="Nom, email, club, ville..." />
</label>
<button class="button ghost small" type="button" @onclick="LoadUsersAsync" disabled="@IsLoadingUsers">
Actualiser
</button>
<div class="admin-toolbar-actions">
<button class="button secondary small" type="button" @onclick="OpenCreateModal">Ajouter un utilisateur</button>
<button class="button ghost small" type="button" @onclick="ReloadUsersAsync" disabled="@IsLoadingUsers">Actualiser</button>
</div>
</div>
@if (FilteredUsers.Count == 0)
@@ -139,34 +154,61 @@
}
else
{
<div class="admin-user-list">
<div class="admin-table-shell">
<table class="admin-table">
<thead>
<tr>
<th>Utilisateur</th>
<th>Email</th>
<th>Nom affiche</th>
<th>Etat</th>
<th>Profil site</th>
<th>Derniere mise a jour</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var user in FilteredUsers)
{
<button class="admin-user-card @(SelectedSubject == user.Subject ? "is-selected" : string.Empty)"
type="button"
@onclick="@(() => SelectUserAsync(user.Subject))">
<div class="admin-user-card-head">
<div>
<strong>@BuildListDisplayName(user)</strong>
<span>@user.Username</span>
<tr class="@BuildTableRowClass(user)">
<td>
<div class="admin-cell-stack">
<strong>@user.Username</strong>
<span>@user.IdentityDisplayName</span>
</div>
</td>
<td>@(user.Email ?? "Non renseigne")</td>
<td>@(user.SiteDisplayName ?? "A definir")</td>
<td>
<span class="mini-chip @(user.IsEnabled ? "admin-chip-success" : "admin-chip-danger")">
@(user.IsEnabled ? "Actif" : "Suspendu")
</span>
</div>
<div class="admin-user-card-meta">
<span>@(user.Email ?? "Email non renseigne")</span>
<span>@(user.HasSiteProfile ? "Profil site actif" : "Profil site absent")</span>
<span>@BuildUserCardFootnote(user)</span>
</div>
</td>
<td>
<span class="mini-chip @(user.HasSiteProfile ? "admin-chip-neutral" : "admin-chip-outline")">
@(user.HasSiteProfile ? "Cree" : "Absent")
</span>
</td>
<td>@BuildUserCardFootnote(user)</td>
<td>
<div class="admin-actions-cell">
<button class="button ghost small" type="button" @onclick="@(() => SelectUserAsync(user.Subject))">
Modifier
</button>
<button class="button danger small" type="button" @onclick="@(() => PromptDeleteUser(user))">
Supprimer
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
<section class="panel admin-detail-panel">
<section class="panel panel-wide admin-detail-panel">
@if (IsLoadingDetail)
{
<p class="eyebrow">Chargement</p>
@@ -181,9 +223,11 @@
}
else if (SelectedUser is null)
{
<p class="eyebrow">Selection</p>
<h2>Choisis un utilisateur</h2>
<p class="section-copy">La fiche detaillee apparait ici des qu'un compte est selectionne.</p>
<p class="eyebrow">Edition</p>
<h2>Choisis un utilisateur dans le tableau</h2>
<p class="section-copy">
Le bouton `Modifier` charge la fiche detaillee ici pour editer le compte et le profil du site.
</p>
}
else
{
@@ -193,7 +237,7 @@
<h2>@SelectedUser.DisplayName</h2>
</div>
<p class="section-copy">
Les roles restent geres dans Keycloak. Cette page couvre l'etat du compte et le profil du site.
Les roles restent geres dans Keycloak. Cette fiche couvre l'etat du compte et le profil du site.
</p>
</div>
@@ -216,22 +260,12 @@
</article>
</div>
@if (!string.IsNullOrWhiteSpace(SaveError))
{
<p class="profile-feedback error">@SaveError</p>
}
@if (!string.IsNullOrWhiteSpace(SaveMessage))
{
<p class="profile-feedback success">@SaveMessage</p>
}
<EditForm Model="@Form" OnValidSubmit="SaveUserAsync">
<EditForm Model="@EditFormModel" OnValidSubmit="SaveUserAsync">
<DataAnnotationsValidator />
<div class="admin-toggle-grid">
<label class="admin-toggle-card">
<InputCheckbox @bind-Value="Form.IsEnabled" />
<InputCheckbox @bind-Value="EditFormModel.IsEnabled" />
<div>
<strong>Compte actif</strong>
<span>Autoriser ou bloquer la connexion a l'application.</span>
@@ -239,7 +273,7 @@
</label>
<label class="admin-toggle-card">
<InputCheckbox @bind-Value="Form.IsEmailVerified" />
<InputCheckbox @bind-Value="EditFormModel.IsEmailVerified" />
<div>
<strong>Email verifie</strong>
<span>Indique si l'adresse email a ete validee dans Keycloak.</span>
@@ -250,67 +284,67 @@
<div class="admin-form-grid">
<label class="field">
<span>Nom d'utilisateur</span>
<InputText @bind-Value="Form.Username" />
<ValidationMessage For="@(() => Form.Username)" />
<InputText @bind-Value="EditFormModel.Username" />
<ValidationMessage For="@(() => EditFormModel.Username)" />
</label>
<label class="field">
<span>Email</span>
<InputText @bind-Value="Form.Email" />
<ValidationMessage For="@(() => Form.Email)" />
<InputText @bind-Value="EditFormModel.Email" />
<ValidationMessage For="@(() => EditFormModel.Email)" />
</label>
<label class="field">
<span>Prenom</span>
<InputText @bind-Value="Form.FirstName" />
<ValidationMessage For="@(() => Form.FirstName)" />
<InputText @bind-Value="EditFormModel.FirstName" />
<ValidationMessage For="@(() => EditFormModel.FirstName)" />
</label>
<label class="field">
<span>Nom</span>
<InputText @bind-Value="Form.LastName" />
<ValidationMessage For="@(() => Form.LastName)" />
<InputText @bind-Value="EditFormModel.LastName" />
<ValidationMessage For="@(() => EditFormModel.LastName)" />
</label>
<label class="field">
<span>Nom affiche sur le site</span>
<InputText @bind-Value="Form.DisplayName" />
<ValidationMessage For="@(() => Form.DisplayName)" />
<InputText @bind-Value="EditFormModel.DisplayName" />
<ValidationMessage For="@(() => EditFormModel.DisplayName)" />
</label>
<label class="field">
<span>Club</span>
<InputText @bind-Value="Form.Club" />
<ValidationMessage For="@(() => Form.Club)" />
<InputText @bind-Value="EditFormModel.Club" />
<ValidationMessage For="@(() => EditFormModel.Club)" />
</label>
<label class="field">
<span>Ville</span>
<InputText @bind-Value="Form.City" />
<ValidationMessage For="@(() => Form.City)" />
<InputText @bind-Value="EditFormModel.City" />
<ValidationMessage For="@(() => EditFormModel.City)" />
</label>
<label class="field">
<span>Format prefere</span>
<InputSelect @bind-Value="Form.PreferredFormat">
<InputSelect @bind-Value="EditFormModel.PreferredFormat">
<option value="">Aucune preference</option>
<option value="Twice">Twice</option>
<option value="Time">Time</option>
<option value="Les deux">Les deux</option>
</InputSelect>
<ValidationMessage For="@(() => Form.PreferredFormat)" />
<ValidationMessage For="@(() => EditFormModel.PreferredFormat)" />
</label>
<label class="field span-2">
<span>Cube favori</span>
<InputText @bind-Value="Form.FavoriteCube" />
<ValidationMessage For="@(() => Form.FavoriteCube)" />
<InputText @bind-Value="EditFormModel.FavoriteCube" />
<ValidationMessage For="@(() => EditFormModel.FavoriteCube)" />
</label>
<label class="field span-2">
<span>Bio</span>
<InputTextArea @bind-Value="Form.Bio" />
<ValidationMessage For="@(() => Form.Bio)" />
<InputTextArea @bind-Value="EditFormModel.Bio" />
<ValidationMessage For="@(() => EditFormModel.Bio)" />
</label>
</div>
@@ -327,11 +361,175 @@
</main>
</div>
<section class="modal @(ShowCreateModal ? string.Empty : "hidden")" aria-hidden="@BoolString(!ShowCreateModal)">
<div class="modal-backdrop" @onclick="CloseCreateModal"></div>
<div class="modal-card admin-modal-card">
<div class="modal-head">
<div>
<p class="eyebrow">Ajout</p>
<h2>Ajouter un utilisateur</h2>
</div>
<button class="button ghost small" type="button" @onclick="CloseCreateModal" disabled="@IsCreating">Fermer</button>
</div>
<p class="section-copy">
La creation ajoute un compte dans Keycloak puis initialise le profil du site.
</p>
@if (!string.IsNullOrWhiteSpace(CreateError))
{
<p class="profile-feedback error">@CreateError</p>
}
<EditForm Model="@CreateFormModel" OnValidSubmit="CreateUserAsync">
<DataAnnotationsValidator />
<div class="admin-form-grid">
<label class="field">
<span>Nom d'utilisateur</span>
<InputText @bind-Value="CreateFormModel.Username" />
<ValidationMessage For="@(() => CreateFormModel.Username)" />
</label>
<label class="field">
<span>Email</span>
<InputText @bind-Value="CreateFormModel.Email" />
<ValidationMessage For="@(() => CreateFormModel.Email)" />
</label>
<label class="field">
<span>Prenom</span>
<InputText @bind-Value="CreateFormModel.FirstName" />
<ValidationMessage For="@(() => CreateFormModel.FirstName)" />
</label>
<label class="field">
<span>Nom</span>
<InputText @bind-Value="CreateFormModel.LastName" />
<ValidationMessage For="@(() => CreateFormModel.LastName)" />
</label>
<label class="field">
<span>Mot de passe</span>
<InputText @bind-Value="CreateFormModel.Password" type="password" />
<ValidationMessage For="@(() => CreateFormModel.Password)" />
</label>
<label class="field">
<span>Confirmation du mot de passe</span>
<InputText @bind-Value="CreateFormModel.ConfirmPassword" type="password" />
<ValidationMessage For="@(() => CreateFormModel.ConfirmPassword)" />
</label>
<label class="field">
<span>Nom affiche sur le site</span>
<InputText @bind-Value="CreateFormModel.DisplayName" />
<ValidationMessage For="@(() => CreateFormModel.DisplayName)" />
</label>
<label class="field">
<span>Club</span>
<InputText @bind-Value="CreateFormModel.Club" />
<ValidationMessage For="@(() => CreateFormModel.Club)" />
</label>
<label class="field">
<span>Ville</span>
<InputText @bind-Value="CreateFormModel.City" />
<ValidationMessage For="@(() => CreateFormModel.City)" />
</label>
<label class="field">
<span>Format prefere</span>
<InputSelect @bind-Value="CreateFormModel.PreferredFormat">
<option value="">Aucune preference</option>
<option value="Twice">Twice</option>
<option value="Time">Time</option>
<option value="Les deux">Les deux</option>
</InputSelect>
<ValidationMessage For="@(() => CreateFormModel.PreferredFormat)" />
</label>
<label class="field span-2">
<span>Cube favori</span>
<InputText @bind-Value="CreateFormModel.FavoriteCube" />
<ValidationMessage For="@(() => CreateFormModel.FavoriteCube)" />
</label>
<label class="field span-2">
<span>Bio</span>
<InputTextArea @bind-Value="CreateFormModel.Bio" />
<ValidationMessage For="@(() => CreateFormModel.Bio)" />
</label>
</div>
<div class="admin-toggle-grid">
<label class="admin-toggle-card">
<InputCheckbox @bind-Value="CreateFormModel.IsEnabled" />
<div>
<strong>Compte actif</strong>
<span>Permettre au nouvel utilisateur de se connecter immediatement.</span>
</div>
</label>
<label class="admin-toggle-card">
<InputCheckbox @bind-Value="CreateFormModel.IsEmailVerified" />
<div>
<strong>Email verifie</strong>
<span>Marquer l'adresse comme deja verifiee dans Keycloak.</span>
</div>
</label>
</div>
<div class="modal-actions">
<button class="button secondary" type="submit" disabled="@IsCreating">
@(IsCreating ? "Creation..." : "Creer l'utilisateur")
</button>
</div>
</EditForm>
</div>
</section>
<section class="modal @(ShowDeleteModal ? string.Empty : "hidden")" aria-hidden="@BoolString(!ShowDeleteModal)">
<div class="modal-backdrop" @onclick="CloseDeleteModal"></div>
<div class="modal-card admin-confirm-card">
<div class="modal-head">
<div>
<p class="eyebrow">Suppression</p>
<h2>Supprimer cet utilisateur</h2>
</div>
<button class="button ghost small" type="button" @onclick="CloseDeleteModal" disabled="@IsDeleting">Fermer</button>
</div>
@if (PendingDeleteUser is not null)
{
<p>
Le compte <strong>@PendingDeleteUser.Username</strong> sera supprime de Keycloak
ainsi que son profil du site. Cette action est irreversible.
</p>
}
@if (!string.IsNullOrWhiteSpace(DeleteError))
{
<p class="profile-feedback error">@DeleteError</p>
}
<div class="modal-actions">
<button class="button danger" type="button" @onclick="DeleteUserAsync" disabled="@IsDeleting">
@(IsDeleting ? "Suppression..." : "Supprimer definitivement")
</button>
<button class="button ghost" type="button" @onclick="CloseDeleteModal" disabled="@IsDeleting">Annuler</button>
</div>
</div>
</section>
@code {
private readonly AdminUserFormModel Form = new();
private readonly AdminUserFormModel EditFormModel = new();
private readonly AdminCreateUserFormModel CreateFormModel = new();
private readonly List<AdminUserSummaryResponse> Users = [];
private AdminUserDetailResponse? SelectedUser;
private AdminUserSummaryResponse? PendingDeleteUser;
private string? SelectedSubject;
private string SearchTerm = string.Empty;
private bool IsAuthenticated;
@@ -339,10 +537,16 @@
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<AdminUserSummaryResponse> FilteredUsers
=> Users
@@ -365,7 +569,7 @@
? "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."
: "La fiche detaillee permet de modifier les informations de compte et le profil du site.";
: "Le tableau permet maintenant les operations classiques d'ajout, modification et suppression.";
protected override async Task OnInitializedAsync()
{
@@ -374,14 +578,22 @@
}
private void HandleAuthenticationStateChanged(Task<AuthenticationState> authenticationStateTask)
=> _ = InvokeAsync(LoadUsersAsync);
=> _ = InvokeAsync(() => LoadUsersAsync(SelectedSubject));
private async Task LoadUsersAsync()
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
@@ -401,7 +613,7 @@
Users.Clear();
SelectedSubject = null;
SelectedUser = null;
Form.Reset();
EditFormModel.Reset();
return;
}
@@ -412,7 +624,7 @@
Users.Clear();
SelectedSubject = null;
SelectedUser = null;
Form.Reset();
EditFormModel.Reset();
return;
}
@@ -424,11 +636,13 @@
{
SelectedSubject = null;
SelectedUser = null;
Form.Reset();
EditFormModel.Reset();
return;
}
var nextSubject = Users.Any(user => user.Subject == SelectedSubject)
var nextSubject = Users.Any(user => user.Subject == preferredSubject)
? preferredSubject
: Users.Any(user => user.Subject == SelectedSubject)
? SelectedSubject
: Users[0].Subject;
@@ -456,11 +670,6 @@
private async Task SelectUserAsync(string subject)
{
if (string.Equals(subject, SelectedSubject, StringComparison.Ordinal))
{
return;
}
SaveError = null;
SaveMessage = null;
await LoadUserDetailAsync(subject, keepFeedback: false);
@@ -495,7 +704,7 @@
return;
}
FillForm(SelectedUser);
FillEditForm(SelectedUser);
}
catch (HttpRequestException)
{
@@ -529,18 +738,18 @@
{
var payload = new AdminUpdateUserRequest
{
Username = Form.Username ?? string.Empty,
Email = Form.Email,
FirstName = Form.FirstName,
LastName = Form.LastName,
IsEnabled = Form.IsEnabled,
IsEmailVerified = Form.IsEmailVerified,
DisplayName = Form.DisplayName,
Club = Form.Club,
City = Form.City,
PreferredFormat = Form.PreferredFormat,
FavoriteCube = Form.FavoriteCube,
Bio = Form.Bio,
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);
@@ -557,7 +766,7 @@
return;
}
FillForm(SelectedUser);
FillEditForm(SelectedUser);
UpdateSummary(SelectedUser);
SaveMessage = "La fiche utilisateur a bien ete mise a jour.";
}
@@ -575,6 +784,150 @@
}
}
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<AdminUserDetailResponse>();
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);
@@ -597,20 +950,20 @@
summary.SiteProfileUpdatedUtc = detail.SiteProfileUpdatedUtc;
}
private void FillForm(AdminUserDetailResponse user)
private void FillEditForm(AdminUserDetailResponse user)
{
Form.Username = user.Username;
Form.Email = user.Email;
Form.FirstName = user.FirstName;
Form.LastName = user.LastName;
Form.IsEnabled = user.IsEnabled;
Form.IsEmailVerified = user.IsEmailVerified;
Form.DisplayName = user.DisplayName;
Form.Club = user.Club;
Form.City = user.City;
Form.PreferredFormat = user.PreferredFormat;
Form.FavoriteCube = user.FavoriteCube;
Form.Bio = user.Bio;
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()
@@ -620,7 +973,11 @@
Users.Clear();
SelectedSubject = null;
SelectedUser = null;
Form.Reset();
PendingDeleteUser = null;
ShowCreateModal = false;
ShowDeleteModal = false;
EditFormModel.Reset();
CreateFormModel.Reset();
}
private bool MatchesSearch(AdminUserSummaryResponse user)
@@ -642,17 +999,17 @@
private static bool Contains(string? value, string search)
=> value?.Contains(search, StringComparison.OrdinalIgnoreCase) == true;
private static string BuildListDisplayName(AdminUserSummaryResponse user)
=> user.SiteDisplayName
?? user.IdentityDisplayName
?? user.Username;
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
? $"Maj site {FormatDate(user.SiteProfileUpdatedUtc)}"
? FormatDate(user.SiteProfileUpdatedUtc)
: user.AccountCreatedUtc is not null
? $"Compte cree {FormatDate(user.AccountCreatedUtc)}"
: "Aucune date disponible";
? FormatDate(user.AccountCreatedUtc)
: "Non disponible";
private static async Task<string> ReadErrorAsync(HttpResponseMessage response, string fallbackMessage)
{
@@ -682,6 +1039,9 @@
? "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;
@@ -690,7 +1050,7 @@
public string? Message { get; set; }
}
private sealed class AdminUserFormModel
private class AdminUserFormModel
{
[Required(ErrorMessage = "Le nom d'utilisateur est obligatoire.")]
[MaxLength(120, ErrorMessage = "Le nom d'utilisateur doit rester sous 120 caracteres.")]
@@ -728,7 +1088,7 @@
[MaxLength(1200, ErrorMessage = "La bio doit rester sous 1200 caracteres.")]
public string? Bio { get; set; }
public void Reset()
public virtual void Reset()
{
Username = string.Empty;
Email = string.Empty;
@@ -744,4 +1104,22 @@
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;
}
}
}

View File

@@ -95,6 +95,37 @@ public sealed class AdminUpdateUserRequest
public string? Bio { get; init; }
}
public sealed class AdminCreateUserRequest
{
public string Username { get; init; } = string.Empty;
public string? Email { get; init; }
public string Password { get; init; } = string.Empty;
public string ConfirmPassword { get; init; } = string.Empty;
public string? FirstName { get; init; }
public string? LastName { get; init; }
public bool IsEnabled { get; init; } = true;
public bool IsEmailVerified { get; init; }
public string? DisplayName { get; init; }
public string? Club { get; init; }
public string? City { get; init; }
public string? PreferredFormat { get; init; }
public string? FavoriteCube { get; init; }
public string? Bio { get; init; }
}
public sealed record AdminIdentityUser(
string Subject,
string Username,
@@ -113,4 +144,13 @@ public sealed record AdminIdentityUserUpdateRequest(
bool IsEnabled,
bool IsEmailVerified);
public sealed record AdminIdentityUserCreateRequest(
string Username,
string? Email,
string Password,
string? FirstName,
string? LastName,
bool IsEnabled,
bool IsEmailVerified);
public sealed class AdminUserValidationException(string message) : Exception(message);

View File

@@ -32,12 +32,41 @@ public sealed class KeycloakAuthService(HttpClient httpClient, IOptions<Keycloak
public async Task<KeycloakUserInfo> RegisterAsync(RegisterRequest request, CancellationToken cancellationToken)
{
var adminToken = await RequestAdminTokenAsync(cancellationToken);
var userId = await CreateUserAsync(adminToken, request, cancellationToken);
var userId = await CreateUserAsync(
adminToken,
request.Username,
request.Email,
request.FirstName,
request.LastName,
isEnabled: true,
isEmailVerified: false,
cancellationToken);
await SetPasswordAsync(adminToken, userId, request.Password, cancellationToken);
await TryAssignPlayerRoleAsync(adminToken, userId, cancellationToken);
return await LoginAsync(request.Username, request.Password, cancellationToken);
}
public async Task<AdminIdentityUser> CreateAdminUserAsync(
AdminIdentityUserCreateRequest request,
CancellationToken cancellationToken)
{
var adminToken = await RequestAdminTokenAsync(cancellationToken);
var userId = await CreateUserAsync(
adminToken,
request.Username,
request.Email,
request.FirstName,
request.LastName,
request.IsEnabled,
request.IsEmailVerified,
cancellationToken);
await SetPasswordAsync(adminToken, userId, request.Password, cancellationToken);
await TryAssignPlayerRoleAsync(adminToken, userId, cancellationToken);
return await GetAdminUserAsync(adminToken, userId, cancellationToken);
}
public async Task<IReadOnlyList<AdminIdentityUser>> GetAdminUsersAsync(CancellationToken cancellationToken)
{
var adminToken = await RequestAdminTokenAsync(cancellationToken);
@@ -119,6 +148,25 @@ public sealed class KeycloakAuthService(HttpClient httpClient, IOptions<Keycloak
return await GetAdminUserAsync(adminToken, userId, cancellationToken);
}
public async Task DeleteAdminUserAsync(string userId, CancellationToken cancellationToken)
{
var adminToken = await RequestAdminTokenAsync(cancellationToken);
using var request = new HttpRequestMessage(HttpMethod.Delete, $"{GetAdminBaseUrl()}/users/{Uri.EscapeDataString(userId)}");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
using var response = await _httpClient.SendAsync(request, cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
{
throw new KeycloakAuthException("Utilisateur introuvable dans Keycloak.", StatusCodes.Status404NotFound);
}
if (!response.IsSuccessStatusCode)
{
throw new KeycloakAuthException("La suppression du compte Keycloak a echoue.", (int)response.StatusCode);
}
}
private async Task<TokenSuccessResponse> RequestPasswordTokenAsync(string username, string password, CancellationToken cancellationToken)
{
var formData = new Dictionary<string, string>
@@ -208,18 +256,26 @@ public sealed class KeycloakAuthService(HttpClient httpClient, IOptions<Keycloak
return userInfo;
}
private async Task<string> CreateUserAsync(string adminToken, RegisterRequest request, CancellationToken cancellationToken)
private async Task<string> CreateUserAsync(
string adminToken,
string username,
string? email,
string? firstName,
string? lastName,
bool isEnabled,
bool isEmailVerified,
CancellationToken cancellationToken)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"{GetAdminBaseUrl()}/users")
{
Content = JsonContent.Create(new
{
username = request.Username.Trim(),
email = request.Email.Trim(),
enabled = true,
emailVerified = false,
firstName = string.IsNullOrWhiteSpace(request.FirstName) ? null : request.FirstName.Trim(),
lastName = string.IsNullOrWhiteSpace(request.LastName) ? null : request.LastName.Trim(),
username = username.Trim(),
email = string.IsNullOrWhiteSpace(email) ? null : email.Trim(),
enabled = isEnabled,
emailVerified = isEmailVerified,
firstName = string.IsNullOrWhiteSpace(firstName) ? null : firstName.Trim(),
lastName = string.IsNullOrWhiteSpace(lastName) ? null : lastName.Trim(),
}, options: JsonOptions)
};
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
@@ -241,7 +297,7 @@ public sealed class KeycloakAuthService(HttpClient httpClient, IOptions<Keycloak
return userId.Trim('/');
}
var fallbackUserId = await FindUserIdByUsernameAsync(adminToken, request.Username, cancellationToken);
var fallbackUserId = await FindUserIdByUsernameAsync(adminToken, username, cancellationToken);
if (!string.IsNullOrWhiteSpace(fallbackUserId))
{
return fallbackUserId;

View File

@@ -147,6 +147,63 @@ adminGroup.MapGet("/users", async Task<IResult> (
return TypedResults.Ok(users);
});
adminGroup.MapPost("/users", async Task<IResult> (
AdminCreateUserRequest request,
KeycloakAuthService keycloak,
MySqlUserProfileStore profileStore,
CancellationToken cancellationToken) =>
{
try
{
var normalized = NormalizeAdminCreate(request);
var fallbackDisplayName = BuildIdentityDisplayNameFromParts(normalized.FirstName, normalized.LastName, normalized.Username);
var siteProfileRequest = new UpdateUserProfileRequest
{
DisplayName = request.DisplayName,
Club = request.Club,
City = request.City,
PreferredFormat = request.PreferredFormat,
FavoriteCube = request.FavoriteCube,
Bio = request.Bio,
};
profileStore.ValidateAdminUpdate(fallbackDisplayName, siteProfileRequest);
var createdIdentity = await keycloak.CreateAdminUserAsync(
new AdminIdentityUserCreateRequest(
normalized.Username,
normalized.Email,
normalized.Password,
normalized.FirstName,
normalized.LastName,
normalized.IsEnabled,
normalized.IsEmailVerified),
cancellationToken);
var createdProfile = await profileStore.AdminUpsertAsync(
createdIdentity.Subject,
createdIdentity.Username,
createdIdentity.Email,
BuildIdentityDisplayName(createdIdentity),
siteProfileRequest,
cancellationToken);
return TypedResults.Created($"/api/admin/users/{Uri.EscapeDataString(createdIdentity.Subject)}", MapAdminDetail(createdIdentity, createdProfile));
}
catch (AdminUserValidationException exception)
{
return TypedResults.BadRequest(new ApiErrorResponse(exception.Message));
}
catch (UserProfileValidationException exception)
{
return TypedResults.BadRequest(new ApiErrorResponse(exception.Message));
}
catch (KeycloakAuthException exception)
{
return TypedResults.Json(new ApiErrorResponse(exception.Message), statusCode: exception.StatusCode);
}
});
adminGroup.MapGet("/users/{subject}", async Task<IResult> (
string subject,
KeycloakAuthService keycloak,
@@ -167,6 +224,24 @@ adminGroup.MapGet("/users/{subject}", async Task<IResult> (
}
});
adminGroup.MapDelete("/users/{subject}", async Task<IResult> (
string subject,
KeycloakAuthService keycloak,
MySqlUserProfileStore profileStore,
CancellationToken cancellationToken) =>
{
try
{
await keycloak.DeleteAdminUserAsync(subject, cancellationToken);
await profileStore.DeleteAsync(subject, cancellationToken);
return TypedResults.NoContent();
}
catch (KeycloakAuthException exception)
{
return TypedResults.Json(new ApiErrorResponse(exception.Message), statusCode: exception.StatusCode);
}
});
adminGroup.MapPut("/users/{subject}", async Task<IResult> (
string subject,
AdminUpdateUserRequest request,
@@ -360,6 +435,24 @@ static NormalizedAdminUserUpdate NormalizeAdminUpdate(AdminUpdateUserRequest req
request.IsEmailVerified);
}
static NormalizedAdminCreateUser NormalizeAdminCreate(AdminCreateUserRequest request)
{
var username = NormalizeRequiredValue(request.Username, "nom d'utilisateur", 120);
var email = NormalizeEmail(request.Email);
var password = NormalizePassword(request.Password, request.ConfirmPassword);
var firstName = NormalizeOptionalValue(request.FirstName, "prenom", 120);
var lastName = NormalizeOptionalValue(request.LastName, "nom", 120);
return new NormalizedAdminCreateUser(
username,
email,
password,
firstName,
lastName,
request.IsEnabled,
request.IsEmailVerified);
}
static string BuildIdentityDisplayName(AdminIdentityUser identity)
=> BuildIdentityDisplayNameFromParts(identity.FirstName, identity.LastName, identity.Username);
@@ -396,6 +489,26 @@ static string? NormalizeEmail(string? value)
}
}
static string NormalizePassword(string? password, string? confirmPassword)
{
if (string.IsNullOrWhiteSpace(password))
{
throw new AdminUserValidationException("Le mot de passe est obligatoire.");
}
if (!string.Equals(password, confirmPassword, StringComparison.Ordinal))
{
throw new AdminUserValidationException("Les mots de passe ne correspondent pas.");
}
if (password.Length < 8)
{
throw new AdminUserValidationException("Le mot de passe doit contenir au moins 8 caracteres.");
}
return password;
}
static string? NormalizeOptionalValue(string? value, string fieldName, int maxLength)
{
var trimmed = value?.Trim();
@@ -468,3 +581,12 @@ sealed record NormalizedAdminUserUpdate(
string? LastName,
bool IsEnabled,
bool IsEmailVerified);
sealed record NormalizedAdminCreateUser(
string Username,
string? Email,
string Password,
string? FirstName,
string? LastName,
bool IsEnabled,
bool IsEmailVerified);

View File

@@ -123,6 +123,11 @@ public sealed class MySqlUserProfileStore(
ORDER BY updated_utc DESC, created_utc DESC, username ASC;
""";
private const string DeleteProfileSql = """
DELETE FROM site_users
WHERE subject = @subject;
""";
private readonly SiteDataOptions _options = options.Value;
private readonly ILogger<MySqlUserProfileStore> _logger = logger;
@@ -285,6 +290,17 @@ public sealed class MySqlUserProfileStore(
return await ReadProfileAsync(connection, subject, cancellationToken);
}
public async Task DeleteAsync(string subject, CancellationToken cancellationToken)
{
await using var connection = new MySqlConnection(_options.BuildConnectionString());
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = DeleteProfileSql;
command.Parameters.AddWithValue("@subject", subject);
await command.ExecuteNonQueryAsync(cancellationToken);
}
private static UserProfileInput NormalizeInput(AuthenticatedSiteUser user, UpdateUserProfileRequest request)
=> NormalizeInput(user.DisplayName, request);

View File

@@ -43,7 +43,7 @@ L'application embarque maintenant une authentification integree basee sur Keyclo
- les roles Keycloak du realm restent exposes dans l'application
- l'etat du match est isole par utilisateur dans le navigateur grace a une cle de stockage derivee du compte connecte
- une page `utilisateur` permet maintenant d'editer un profil du site persiste en base MySQL via `/api/users/me`
- une page `administration` reservee au role `admin` permet maintenant de parcourir et modifier les utilisateurs du site via `/api/admin/users`
- une page `administration` reservee au role `admin` propose maintenant une table utilisateurs avec actions d'ajout, modification et suppression via `/api/admin/users`
Le realm importe par defaut :

View File

@@ -1582,84 +1582,94 @@ body.site-menu-hidden .site-menu-shell {
margin-top: 0.8rem;
}
.admin-list-panel,
.admin-table-panel,
.admin-detail-panel {
display: grid;
align-content: start;
gap: 1rem;
}
.admin-detail-panel {
grid-column: span 8;
}
.admin-toolbar {
display: grid;
gap: 0.9rem;
}
.admin-toolbar-grid {
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
}
.admin-search-field {
margin: 0;
}
.admin-user-list {
display: grid;
.admin-toolbar-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.75rem;
max-height: 60rem;
overflow-y: auto;
}
.admin-user-card {
appearance: none;
width: 100%;
display: grid;
gap: 0.8rem;
padding: 1rem;
.admin-table-shell {
overflow-x: auto;
border-radius: 24px;
border: 1px solid var(--panel-border);
border-radius: 22px;
background: var(--panel-alt);
color: var(--text);
}
.admin-table {
width: 100%;
min-width: 980px;
border-collapse: collapse;
}
.admin-table th,
.admin-table td {
padding: 1rem;
border-bottom: 1px solid var(--panel-border);
vertical-align: middle;
text-align: left;
cursor: pointer;
transition:
transform 160ms ease,
border-color 160ms ease,
background 160ms ease;
}
.admin-user-card:hover {
transform: translateY(-2px);
border-color: rgba(52, 141, 255, 0.32);
.admin-table th {
color: var(--muted);
font-size: 0.85rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.admin-user-card.is-selected {
border-color: rgba(52, 141, 255, 0.52);
.admin-table tbody tr {
transition: background 160ms ease;
}
.admin-table tbody tr:hover {
background: rgba(255, 255, 255, 0.03);
}
.admin-table tbody tr.is-selected {
background: linear-gradient(180deg, rgba(17, 103, 255, 0.14), rgba(17, 103, 255, 0.08));
}
.admin-user-card-head {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: flex-start;
.admin-table tbody tr:last-child td {
border-bottom: 0;
}
.admin-user-card-head strong {
display: block;
margin-bottom: 0.15rem;
}
.admin-user-card-head span,
.admin-user-card-meta {
color: var(--muted);
}
.admin-user-card-meta {
.admin-cell-stack {
display: grid;
gap: 0.3rem;
gap: 0.2rem;
}
.admin-cell-stack span {
color: var(--muted);
font-size: 0.92rem;
}
.admin-actions-cell {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
}
.admin-chip-success {
background: rgba(69, 185, 127, 0.14);
color: #dff7ea;
@@ -1670,6 +1680,16 @@ body.site-menu-hidden .site-menu-shell {
color: #ffd8de;
}
.admin-chip-neutral {
background: rgba(52, 141, 255, 0.14);
color: #d6e5ff;
}
.admin-chip-outline {
background: transparent;
color: var(--muted);
}
.admin-toggle-grid,
.admin-form-grid {
display: grid;
@@ -1703,6 +1723,14 @@ body.site-menu-hidden .site-menu-shell {
color: var(--muted);
}
.admin-modal-card {
width: min(980px, 100%);
}
.admin-confirm-card {
width: min(560px, 100%);
}
@media (max-width: 1100px) {
.hero,
.setup-grid,
@@ -1732,10 +1760,6 @@ body.site-menu-hidden .site-menu-shell {
grid-template-columns: 1fr;
}
.admin-detail-panel {
grid-column: span 12;
}
.phase-header {
grid-template-columns: 1fr;
text-align: center;
@@ -1950,6 +1974,18 @@ body.site-menu-hidden .site-menu-shell {
grid-template-columns: 1fr;
}
.admin-toolbar-grid {
grid-template-columns: 1fr;
}
.admin-toolbar-actions {
justify-content: stretch;
}
.admin-toolbar-actions .button {
width: 100%;
}
.setup-actions,
.resume-actions,
.modal-actions {
@@ -1966,10 +2002,6 @@ body.site-menu-hidden .site-menu-shell {
padding: 0.55rem 0 0.55rem;
}
.admin-user-list {
max-height: none;
}
.phase-header {
grid-template-columns: auto 1fr auto;
gap: 0.45rem;