Ajoute une zone d'administration des utilisateurs

This commit is contained in:
2026-04-15 21:21:26 +02:00
parent 106786a638
commit 1d18a070e5
12 changed files with 1595 additions and 5 deletions

2
.gitignore vendored
View File

@@ -3,3 +3,5 @@
WhatsApp Video 2026-04-11 at 20.38.50.mp4
ChessCubing.App/bin/
ChessCubing.App/obj/
ChessCubing.Server/bin/
ChessCubing.Server/obj/

View File

@@ -27,6 +27,10 @@
<a class="@BuildNavLinkClass(ApplicationPaths)" href="application.html" aria-current="@BuildAriaCurrent(ApplicationPaths)">Application</a>
<a class="@BuildNavLinkClass(RulesPaths)" href="reglement.html" aria-current="@BuildAriaCurrent(RulesPaths)">Reglement</a>
<a class="@BuildNavLinkClass(UserPaths)" href="utilisateur.html" aria-current="@BuildAriaCurrent(UserPaths)">Utilisateur</a>
@if (IsAdmin)
{
<a class="@BuildNavLinkClass(AdminPaths)" href="administration.html" aria-current="@BuildAriaCurrent(AdminPaths)">Administration</a>
}
</nav>
<div class="site-menu-account">
@@ -70,6 +74,10 @@
<a class="@BuildNavLinkClass(ApplicationPaths)" href="application.html" aria-current="@BuildAriaCurrent(ApplicationPaths)">Application</a>
<a class="@BuildNavLinkClass(RulesPaths)" href="reglement.html" aria-current="@BuildAriaCurrent(RulesPaths)">Reglement</a>
<a class="@BuildNavLinkClass(UserPaths)" href="utilisateur.html" aria-current="@BuildAriaCurrent(UserPaths)">Utilisateur</a>
@if (IsAdmin)
{
<a class="@BuildNavLinkClass(AdminPaths)" href="administration.html" aria-current="@BuildAriaCurrent(AdminPaths)">Administration</a>
}
</nav>
<div class="site-menu-mobile-account">
@@ -202,11 +210,13 @@
private static readonly string[] ApplicationPaths = ["application", "application.html"];
private static readonly string[] RulesPaths = ["reglement", "reglement.html"];
private static readonly string[] UserPaths = ["utilisateur", "utilisateur.html"];
private static readonly string[] AdminPaths = ["administration", "administration.html"];
private readonly LoginFormModel LoginModel = new();
private readonly RegisterFormModel RegisterModel = new();
private bool IsAuthenticated;
private bool IsAdmin;
private bool ShowAuthModal;
private bool IsSubmitting;
private bool IsMobileMenuOpen;
@@ -444,6 +454,7 @@
if (user.Identity?.IsAuthenticated == true)
{
IsAuthenticated = true;
IsAdmin = user.IsInRole("admin");
DisplayName = BuildDisplayName(user);
DisplayMeta = BuildMeta(user);
}
@@ -463,6 +474,7 @@
private void ResetAuthenticationDisplay()
{
IsAuthenticated = false;
IsAdmin = false;
DisplayName = "Utilisateur connecte";
DisplayMeta = "Session active";
}

View File

@@ -0,0 +1,28 @@
namespace ChessCubing.App.Models.Users;
public sealed class AdminUpdateUserRequest
{
public string Username { get; set; } = string.Empty;
public string? Email { get; set; }
public string? FirstName { get; set; }
public string? LastName { get; set; }
public bool IsEnabled { get; set; }
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

@@ -0,0 +1,40 @@
namespace ChessCubing.App.Models.Users;
public sealed class AdminUserDetailResponse
{
public string Subject { get; set; } = string.Empty;
public string Username { get; set; } = string.Empty;
public string? Email { get; set; }
public string? FirstName { get; set; }
public string? LastName { get; set; }
public string IdentityDisplayName { get; set; } = string.Empty;
public bool IsEnabled { get; set; }
public bool IsEmailVerified { get; set; }
public DateTime? AccountCreatedUtc { get; set; }
public bool HasSiteProfile { get; set; }
public string DisplayName { get; set; } = string.Empty;
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; }
public DateTime? SiteProfileCreatedUtc { get; set; }
public DateTime? SiteProfileUpdatedUtc { get; set; }
}

View File

@@ -0,0 +1,30 @@
namespace ChessCubing.App.Models.Users;
public sealed class AdminUserSummaryResponse
{
public string Subject { get; set; } = string.Empty;
public string Username { get; set; } = string.Empty;
public string? Email { get; set; }
public string IdentityDisplayName { get; set; } = string.Empty;
public string? SiteDisplayName { get; set; }
public bool IsEnabled { get; set; }
public bool IsEmailVerified { get; set; }
public bool HasSiteProfile { get; set; }
public string? Club { get; set; }
public string? City { get; set; }
public string? PreferredFormat { get; set; }
public DateTime? AccountCreatedUtc { get; set; }
public DateTime? SiteProfileUpdatedUtc { get; set; }
}

View File

@@ -0,0 +1,747 @@
@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
<PageTitle>ChessCubing Arena | Administration</PageTitle>
<PageBody BodyClass="home-body" />
<div class="ambient ambient-left"></div>
<div class="ambient ambient-right"></div>
<div class="rules-shell">
<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>
<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.
</p>
<div class="hero-actions">
<a class="button secondary" href="utilisateur.html">Voir l'espace utilisateur</a>
<a class="button ghost" href="index.html">Retour au site</a>
</div>
</div>
<aside class="hero-preview">
<div class="preview-card">
<p class="micro-label">Vue d'ensemble</p>
<div class="home-mini-grid two-columns admin-hero-stats">
<article class="mini-panel">
<span class="micro-label">Comptes</span>
<strong>@Users.Count</strong>
</article>
<article class="mini-panel">
<span class="micro-label">Profils site</span>
<strong>@Users.Count(user => user.HasSiteProfile)</strong>
</article>
<article class="mini-panel">
<span class="micro-label">Actifs</span>
<strong>@Users.Count(user => user.IsEnabled)</strong>
</article>
<article class="mini-panel">
<span class="micro-label">Emails verifies</span>
<strong>@Users.Count(user => user.IsEmailVerified)</strong>
</article>
</div>
</div>
<div class="preview-banner">
<span class="mini-chip">Etat d'acces</span>
<strong>@HeroStatusTitle</strong>
<p>@HeroStatusDescription</p>
</div>
</aside>
</header>
<main class="rules-grid">
@if (!IsAuthenticated)
{
<section class="panel panel-wide cta-panel">
<div class="section-heading">
<div>
<p class="eyebrow">Connexion requise</p>
<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.
</p>
</div>
</section>
}
else if (!IsAdmin)
{
<section class="panel panel-wide">
<div class="section-heading">
<div>
<p class="eyebrow">Acces refuse</p>
<h2>Ce compte n'a pas les droits d'administration</h2>
</div>
<p class="section-copy">
La gestion des utilisateurs est reservee aux comptes portant le role `admin`.
</p>
</div>
</section>
}
else if (IsLoadingUsers)
{
<section class="panel panel-wide">
<p class="eyebrow">Chargement</p>
<h2>Recuperation de la liste des utilisateurs</h2>
<p class="section-copy">Le site rassemble les comptes Keycloak et les profils du site.</p>
</section>
}
else if (!string.IsNullOrWhiteSpace(LoadError))
{
<section class="panel panel-wide">
<p class="eyebrow">Serveur</p>
<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>
</div>
</section>
}
else
{
<section class="panel panel-third admin-list-panel">
<div class="section-heading">
<div>
<p class="eyebrow">Utilisateurs</p>
<h2>Parcourir les comptes</h2>
</div>
</div>
<div class="admin-toolbar">
<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>
@if (FilteredUsers.Count == 0)
{
<div class="callout">
<span class="micro-label">Recherche</span>
<p>Aucun utilisateur ne correspond au filtre en cours.</p>
</div>
}
else
{
<div class="admin-user-list">
@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>
</div>
<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>
</button>
}
</div>
}
</section>
<section class="panel admin-detail-panel">
@if (IsLoadingDetail)
{
<p class="eyebrow">Chargement</p>
<h2>Recuperation de la fiche utilisateur</h2>
<p class="section-copy">Les details du compte sont en cours de chargement.</p>
}
else if (!string.IsNullOrWhiteSpace(DetailError))
{
<p class="eyebrow">Serveur</p>
<h2>Impossible de charger cette fiche</h2>
<p class="profile-feedback error">@DetailError</p>
}
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>
}
else
{
<div class="section-heading">
<div>
<p class="eyebrow">Edition</p>
<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.
</p>
</div>
<div class="profile-meta-grid">
<article class="profile-meta-card">
<span class="micro-label">Identite Keycloak</span>
<strong>@SelectedUser.IdentityDisplayName</strong>
</article>
<article class="profile-meta-card">
<span class="micro-label">Compte cree le</span>
<strong>@FormatDate(SelectedUser.AccountCreatedUtc)</strong>
</article>
<article class="profile-meta-card">
<span class="micro-label">Profil site cree le</span>
<strong>@FormatDate(SelectedUser.SiteProfileCreatedUtc)</strong>
</article>
<article class="profile-meta-card">
<span class="micro-label">Profil site mis a jour</span>
<strong>@FormatDate(SelectedUser.SiteProfileUpdatedUtc)</strong>
</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">
<DataAnnotationsValidator />
<div class="admin-toggle-grid">
<label class="admin-toggle-card">
<InputCheckbox @bind-Value="Form.IsEnabled" />
<div>
<strong>Compte actif</strong>
<span>Autoriser ou bloquer la connexion a l'application.</span>
</div>
</label>
<label class="admin-toggle-card">
<InputCheckbox @bind-Value="Form.IsEmailVerified" />
<div>
<strong>Email verifie</strong>
<span>Indique si l'adresse email a ete validee dans Keycloak.</span>
</div>
</label>
</div>
<div class="admin-form-grid">
<label class="field">
<span>Nom d'utilisateur</span>
<InputText @bind-Value="Form.Username" />
<ValidationMessage For="@(() => Form.Username)" />
</label>
<label class="field">
<span>Email</span>
<InputText @bind-Value="Form.Email" />
<ValidationMessage For="@(() => Form.Email)" />
</label>
<label class="field">
<span>Prenom</span>
<InputText @bind-Value="Form.FirstName" />
<ValidationMessage For="@(() => Form.FirstName)" />
</label>
<label class="field">
<span>Nom</span>
<InputText @bind-Value="Form.LastName" />
<ValidationMessage For="@(() => Form.LastName)" />
</label>
<label class="field">
<span>Nom affiche sur le site</span>
<InputText @bind-Value="Form.DisplayName" />
<ValidationMessage For="@(() => Form.DisplayName)" />
</label>
<label class="field">
<span>Club</span>
<InputText @bind-Value="Form.Club" />
<ValidationMessage For="@(() => Form.Club)" />
</label>
<label class="field">
<span>Ville</span>
<InputText @bind-Value="Form.City" />
<ValidationMessage For="@(() => Form.City)" />
</label>
<label class="field">
<span>Format prefere</span>
<InputSelect @bind-Value="Form.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)" />
</label>
<label class="field span-2">
<span>Cube favori</span>
<InputText @bind-Value="Form.FavoriteCube" />
<ValidationMessage For="@(() => Form.FavoriteCube)" />
</label>
<label class="field span-2">
<span>Bio</span>
<InputTextArea @bind-Value="Form.Bio" />
<ValidationMessage For="@(() => Form.Bio)" />
</label>
</div>
<div class="profile-actions">
<button class="button secondary" type="submit" disabled="@IsSaving">
@(IsSaving ? "Enregistrement..." : "Enregistrer les modifications")
</button>
<p class="section-copy">Le profil site est cree automatiquement lors du premier enregistrement.</p>
</div>
</EditForm>
}
</section>
}
</main>
</div>
@code {
private readonly AdminUserFormModel Form = new();
private readonly List<AdminUserSummaryResponse> Users = [];
private AdminUserDetailResponse? SelectedUser;
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 string? LoadError;
private string? DetailError;
private string? SaveError;
private string? SaveMessage;
private List<AdminUserSummaryResponse> 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."
: "La fiche detaillee permet de modifier les informations de compte et le profil du site.";
protected override async Task OnInitializedAsync()
{
AuthenticationStateProvider.AuthenticationStateChanged += HandleAuthenticationStateChanged;
await LoadUsersAsync();
}
private void HandleAuthenticationStateChanged(Task<AuthenticationState> authenticationStateTask)
=> _ = InvokeAsync(LoadUsersAsync);
private async Task LoadUsersAsync()
{
LoadError = null;
DetailError = null;
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;
Form.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;
Form.Reset();
return;
}
var users = await response.Content.ReadFromJsonAsync<List<AdminUserSummaryResponse>>();
Users.Clear();
Users.AddRange(users ?? []);
if (Users.Count == 0)
{
SelectedSubject = null;
SelectedUser = null;
Form.Reset();
return;
}
var nextSubject = 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)
{
if (string.Equals(subject, SelectedSubject, StringComparison.Ordinal))
{
return;
}
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<AdminUserDetailResponse>();
if (SelectedUser is null)
{
DetailError = "Le serveur a retourne une fiche vide.";
return;
}
FillForm(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 = 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,
};
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<AdminUserDetailResponse>();
if (SelectedUser is null)
{
SaveError = "Le serveur a retourne une fiche vide apres la sauvegarde.";
return;
}
FillForm(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 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 FillForm(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;
}
private void ResetAdminState()
{
IsAuthenticated = false;
IsAdmin = false;
Users.Clear();
SelectedSubject = null;
SelectedUser = null;
Form.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 static string BuildListDisplayName(AdminUserSummaryResponse user)
=> user.SiteDisplayName
?? user.IdentityDisplayName
?? user.Username;
private static string BuildUserCardFootnote(AdminUserSummaryResponse user)
=> user.SiteProfileUpdatedUtc is not null
? $"Maj site {FormatDate(user.SiteProfileUpdatedUtc)}"
: user.AccountCreatedUtc is not null
? $"Compte cree {FormatDate(user.AccountCreatedUtc)}"
: "Aucune date disponible";
private static async Task<string> ReadErrorAsync(HttpResponseMessage response, string fallbackMessage)
{
try
{
var error = await response.Content.ReadFromJsonAsync<ApiErrorMessage>();
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"));
public void Dispose()
=> AuthenticationStateProvider.AuthenticationStateChanged -= HandleAuthenticationStateChanged;
private sealed class ApiErrorMessage
{
public string? Message { get; set; }
}
private sealed 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 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;
}
}
}

View File

@@ -0,0 +1,116 @@
namespace ChessCubing.Server.Admin;
public sealed class AdminUserSummaryResponse
{
public string Subject { get; init; } = string.Empty;
public string Username { get; init; } = string.Empty;
public string? Email { get; init; }
public string IdentityDisplayName { get; init; } = string.Empty;
public string? SiteDisplayName { get; init; }
public bool IsEnabled { get; init; }
public bool IsEmailVerified { get; init; }
public bool HasSiteProfile { get; init; }
public string? Club { get; init; }
public string? City { get; init; }
public string? PreferredFormat { get; init; }
public DateTime? AccountCreatedUtc { get; init; }
public DateTime? SiteProfileUpdatedUtc { get; init; }
}
public sealed class AdminUserDetailResponse
{
public string Subject { get; init; } = string.Empty;
public string Username { get; init; } = string.Empty;
public string? Email { get; init; }
public string? FirstName { get; init; }
public string? LastName { get; init; }
public string IdentityDisplayName { get; init; } = string.Empty;
public bool IsEnabled { get; init; }
public bool IsEmailVerified { get; init; }
public DateTime? AccountCreatedUtc { get; init; }
public bool HasSiteProfile { get; init; }
public string DisplayName { get; init; } = string.Empty;
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 DateTime? SiteProfileCreatedUtc { get; init; }
public DateTime? SiteProfileUpdatedUtc { get; init; }
}
public sealed class AdminUpdateUserRequest
{
public string Username { get; init; } = string.Empty;
public string? Email { get; init; }
public string? FirstName { get; init; }
public string? LastName { get; init; }
public bool IsEnabled { get; init; }
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,
string? Email,
string? FirstName,
string? LastName,
bool IsEnabled,
bool IsEmailVerified,
DateTime? CreatedUtc);
public sealed record AdminIdentityUserUpdateRequest(
string Username,
string? Email,
string? FirstName,
string? LastName,
bool IsEnabled,
bool IsEmailVerified);
public sealed class AdminUserValidationException(string message) : Exception(message);

View File

@@ -3,6 +3,7 @@ using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using ChessCubing.Server.Admin;
using Microsoft.Extensions.Options;
namespace ChessCubing.Server.Auth;
@@ -14,6 +15,11 @@ public sealed class KeycloakAuthService(HttpClient httpClient, IOptions<Keycloak
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
private static readonly JsonSerializerOptions UpdateJsonOptions = new(JsonOptions)
{
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
};
private readonly HttpClient _httpClient = httpClient;
private readonly KeycloakAuthOptions _options = options.Value;
@@ -32,6 +38,87 @@ public sealed class KeycloakAuthService(HttpClient httpClient, IOptions<Keycloak
return await LoginAsync(request.Username, request.Password, cancellationToken);
}
public async Task<IReadOnlyList<AdminIdentityUser>> GetAdminUsersAsync(CancellationToken cancellationToken)
{
var adminToken = await RequestAdminTokenAsync(cancellationToken);
var users = new List<AdminIdentityUser>();
const int pageSize = 100;
for (var first = 0; ; first += pageSize)
{
using var request = new HttpRequestMessage(
HttpMethod.Get,
$"{GetAdminBaseUrl()}/users?first={first}&max={pageSize}&briefRepresentation=false");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
using var response = await _httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new KeycloakAuthException("Impossible de recuperer la liste des utilisateurs Keycloak.", (int)response.StatusCode);
}
var page = await ReadJsonAsync<List<AdminUserRepresentation>>(response, cancellationToken) ?? [];
users.AddRange(page
.Where(user => !string.IsNullOrWhiteSpace(user.Id) && !string.IsNullOrWhiteSpace(user.Username))
.Select(MapAdminIdentityUser));
if (page.Count < pageSize)
{
break;
}
}
return users;
}
public async Task<AdminIdentityUser> GetAdminUserAsync(string userId, CancellationToken cancellationToken)
{
var adminToken = await RequestAdminTokenAsync(cancellationToken);
return await GetAdminUserAsync(adminToken, userId, cancellationToken);
}
public async Task<AdminIdentityUser> UpdateAdminUserAsync(
string userId,
AdminIdentityUserUpdateRequest request,
CancellationToken cancellationToken)
{
var adminToken = await RequestAdminTokenAsync(cancellationToken);
using (var httpRequest = new HttpRequestMessage(HttpMethod.Put, $"{GetAdminBaseUrl()}/users/{Uri.EscapeDataString(userId)}")
{
Content = JsonContent.Create(new
{
username = request.Username,
email = request.Email,
enabled = request.IsEnabled,
emailVerified = request.IsEmailVerified,
firstName = request.FirstName,
lastName = request.LastName,
}, options: UpdateJsonOptions)
})
{
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken);
if (response.StatusCode == HttpStatusCode.Conflict)
{
throw new KeycloakAuthException("Ce nom d'utilisateur ou cet email existe deja.", StatusCodes.Status409Conflict);
}
if (response.StatusCode == HttpStatusCode.NotFound)
{
throw new KeycloakAuthException("Utilisateur introuvable dans Keycloak.", StatusCodes.Status404NotFound);
}
if (!response.IsSuccessStatusCode)
{
throw new KeycloakAuthException("La mise a jour du compte Keycloak a echoue.", (int)response.StatusCode);
}
}
return await GetAdminUserAsync(adminToken, userId, cancellationToken);
}
private async Task<TokenSuccessResponse> RequestPasswordTokenAsync(string username, string password, CancellationToken cancellationToken)
{
var formData = new Dictionary<string, string>
@@ -235,6 +322,44 @@ public sealed class KeycloakAuthService(HttpClient httpClient, IOptions<Keycloak
return users?.FirstOrDefault()?.Id;
}
private async Task<AdminIdentityUser> GetAdminUserAsync(string adminToken, string userId, CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Get, $"{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("Impossible de recuperer le compte Keycloak.", (int)response.StatusCode);
}
var user = await ReadJsonAsync<AdminUserRepresentation>(response, cancellationToken);
if (user is null || string.IsNullOrWhiteSpace(user.Id) || string.IsNullOrWhiteSpace(user.Username))
{
throw new KeycloakAuthException("Le compte Keycloak est invalide.");
}
return MapAdminIdentityUser(user);
}
private static AdminIdentityUser MapAdminIdentityUser(AdminUserRepresentation user)
=> new(
user.Id!,
user.Username!,
user.Email,
user.FirstName,
user.LastName,
user.Enabled ?? true,
user.EmailVerified ?? false,
user.CreatedTimestamp is > 0
? DateTimeOffset.FromUnixTimeMilliseconds(user.CreatedTimestamp.Value).UtcDateTime
: null);
private string[] ExtractRealmRoles(string accessToken)
{
try
@@ -326,6 +451,33 @@ public sealed class KeycloakAuthService(HttpClient httpClient, IOptions<Keycloak
[JsonPropertyName("description")]
public string? Description { get; init; }
}
private sealed class AdminUserRepresentation
{
[JsonPropertyName("id")]
public string? Id { get; init; }
[JsonPropertyName("username")]
public string? Username { get; init; }
[JsonPropertyName("email")]
public string? Email { get; init; }
[JsonPropertyName("firstName")]
public string? FirstName { get; init; }
[JsonPropertyName("lastName")]
public string? LastName { get; init; }
[JsonPropertyName("enabled")]
public bool? Enabled { get; init; }
[JsonPropertyName("emailVerified")]
public bool? EmailVerified { get; init; }
[JsonPropertyName("createdTimestamp")]
public long? CreatedTimestamp { get; init; }
}
}
public sealed class KeycloakAuthException(string message, int statusCode = StatusCodes.Status400BadRequest) : Exception(message)

View File

@@ -1,9 +1,12 @@
using System.Security.Claims;
using System.Net.Mail;
using ChessCubing.Server.Admin;
using ChessCubing.Server.Auth;
using ChessCubing.Server.Data;
using ChessCubing.Server.Users;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
var builder = WebApplication.CreateBuilder(args);
@@ -58,7 +61,10 @@ builder.Services
};
});
builder.Services.AddAuthorization();
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("AdminOnly", policy => policy.RequireRole("admin"));
});
builder.Services.AddHttpClient<KeycloakAuthService>();
builder.Services.AddSingleton<MySqlUserProfileStore>();
@@ -116,6 +122,109 @@ app.MapPut("/api/users/me", async Task<IResult> (
}
}).RequireAuthorization();
var adminGroup = app.MapGroup("/api/admin")
.RequireAuthorization("AdminOnly");
adminGroup.MapGet("/users", async Task<IResult> (
KeycloakAuthService keycloak,
MySqlUserProfileStore profileStore,
CancellationToken cancellationToken) =>
{
var identityUsersTask = keycloak.GetAdminUsersAsync(cancellationToken);
var siteProfilesTask = profileStore.ListAsync(cancellationToken);
await Task.WhenAll(identityUsersTask, siteProfilesTask);
var siteProfilesBySubject = (await siteProfilesTask)
.ToDictionary(profile => profile.Subject, StringComparer.Ordinal);
var users = (await identityUsersTask)
.Select(identity => MapAdminSummary(identity, siteProfilesBySubject.GetValueOrDefault(identity.Subject)))
.OrderByDescending(user => user.SiteProfileUpdatedUtc ?? user.AccountCreatedUtc ?? DateTime.MinValue)
.ThenBy(user => user.Username, StringComparer.OrdinalIgnoreCase)
.ToArray();
return TypedResults.Ok(users);
});
adminGroup.MapGet("/users/{subject}", async Task<IResult> (
string subject,
KeycloakAuthService keycloak,
MySqlUserProfileStore profileStore,
CancellationToken cancellationToken) =>
{
try
{
var identityTask = keycloak.GetAdminUserAsync(subject, cancellationToken);
var profileTask = profileStore.FindBySubjectAsync(subject, cancellationToken);
await Task.WhenAll(identityTask, profileTask);
return TypedResults.Ok(MapAdminDetail(await identityTask, await profileTask));
}
catch (KeycloakAuthException exception)
{
return TypedResults.Json(new ApiErrorResponse(exception.Message), statusCode: exception.StatusCode);
}
});
adminGroup.MapPut("/users/{subject}", async Task<IResult> (
string subject,
AdminUpdateUserRequest request,
KeycloakAuthService keycloak,
MySqlUserProfileStore profileStore,
CancellationToken cancellationToken) =>
{
try
{
var normalized = NormalizeAdminUpdate(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 updatedIdentity = await keycloak.UpdateAdminUserAsync(
subject,
new AdminIdentityUserUpdateRequest(
normalized.Username,
normalized.Email,
normalized.FirstName,
normalized.LastName,
normalized.IsEnabled,
normalized.IsEmailVerified),
cancellationToken);
var updatedProfile = await profileStore.AdminUpsertAsync(
updatedIdentity.Subject,
updatedIdentity.Username,
updatedIdentity.Email,
BuildIdentityDisplayName(updatedIdentity),
siteProfileRequest,
cancellationToken);
return TypedResults.Ok(MapAdminDetail(updatedIdentity, updatedProfile));
}
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);
}
});
app.MapPost("/api/auth/login", async Task<IResult> (
LoginRequest request,
HttpContext httpContext,
@@ -184,6 +293,119 @@ app.MapPost("/api/auth/logout", async Task<IResult> (HttpContext httpContext) =>
app.Run();
static AdminUserSummaryResponse MapAdminSummary(AdminIdentityUser identity, UserProfileResponse? profile)
=> new()
{
Subject = identity.Subject,
Username = identity.Username,
Email = identity.Email,
IdentityDisplayName = BuildIdentityDisplayName(identity),
SiteDisplayName = profile?.DisplayName,
IsEnabled = identity.IsEnabled,
IsEmailVerified = identity.IsEmailVerified,
HasSiteProfile = profile is not null,
Club = profile?.Club,
City = profile?.City,
PreferredFormat = profile?.PreferredFormat,
AccountCreatedUtc = identity.CreatedUtc,
SiteProfileUpdatedUtc = profile?.UpdatedUtc,
};
static AdminUserDetailResponse MapAdminDetail(AdminIdentityUser identity, UserProfileResponse? profile)
{
var identityDisplayName = BuildIdentityDisplayName(identity);
return new AdminUserDetailResponse
{
Subject = identity.Subject,
Username = identity.Username,
Email = identity.Email,
FirstName = identity.FirstName,
LastName = identity.LastName,
IdentityDisplayName = identityDisplayName,
IsEnabled = identity.IsEnabled,
IsEmailVerified = identity.IsEmailVerified,
AccountCreatedUtc = identity.CreatedUtc,
HasSiteProfile = profile is not null,
DisplayName = profile?.DisplayName ?? identityDisplayName,
Club = profile?.Club,
City = profile?.City,
PreferredFormat = profile?.PreferredFormat,
FavoriteCube = profile?.FavoriteCube,
Bio = profile?.Bio,
SiteProfileCreatedUtc = profile?.CreatedUtc,
SiteProfileUpdatedUtc = profile?.UpdatedUtc,
};
}
static NormalizedAdminUserUpdate NormalizeAdminUpdate(AdminUpdateUserRequest request)
{
var username = NormalizeRequiredValue(request.Username, "nom d'utilisateur", 120);
var email = NormalizeEmail(request.Email);
var firstName = NormalizeOptionalValue(request.FirstName, "prenom", 120);
var lastName = NormalizeOptionalValue(request.LastName, "nom", 120);
return new NormalizedAdminUserUpdate(
username,
email,
firstName,
lastName,
request.IsEnabled,
request.IsEmailVerified);
}
static string BuildIdentityDisplayName(AdminIdentityUser identity)
=> BuildIdentityDisplayNameFromParts(identity.FirstName, identity.LastName, identity.Username);
static string BuildIdentityDisplayNameFromParts(string? firstName, string? lastName, string username)
{
var fullName = string.Join(' ', new[] { firstName, lastName }.Where(value => !string.IsNullOrWhiteSpace(value)));
return string.IsNullOrWhiteSpace(fullName)
? username
: fullName;
}
static string NormalizeRequiredValue(string? value, string fieldName, int maxLength)
{
var normalized = NormalizeOptionalValue(value, fieldName, maxLength);
return normalized ?? throw new AdminUserValidationException($"Le champ {fieldName} est obligatoire.");
}
static string? NormalizeEmail(string? value)
{
var normalized = NormalizeOptionalValue(value, "email", 255);
if (normalized is null)
{
return null;
}
try
{
_ = new MailAddress(normalized);
return normalized;
}
catch (FormatException)
{
throw new AdminUserValidationException("L'email n'est pas valide.");
}
}
static string? NormalizeOptionalValue(string? value, string fieldName, int maxLength)
{
var trimmed = value?.Trim();
if (string.IsNullOrWhiteSpace(trimmed))
{
return null;
}
if (trimmed.Length > maxLength)
{
throw new AdminUserValidationException($"Le champ {fieldName} depasse {maxLength} caracteres.");
}
return trimmed;
}
static async Task SignInAsync(HttpContext httpContext, KeycloakUserInfo userInfo)
{
var claims = new List<Claim>();
@@ -232,3 +454,11 @@ static async Task SignInAsync(HttpContext httpContext, KeycloakUserInfo userInfo
httpContext.User = principal;
}
sealed record NormalizedAdminUserUpdate(
string Username,
string? Email,
string? FirstName,
string? LastName,
bool IsEnabled,
bool IsEmailVerified);

View File

@@ -106,6 +106,23 @@ public sealed class MySqlUserProfileStore(
LIMIT 1;
""";
private const string SelectAllProfilesSql = """
SELECT
subject,
username,
email,
display_name,
club,
city,
preferred_format,
favorite_cube,
bio,
created_utc,
updated_utc
FROM site_users
ORDER BY updated_utc DESC, created_utc DESC, username ASC;
""";
private readonly SiteDataOptions _options = options.Value;
private readonly ILogger<MySqlUserProfileStore> _logger = logger;
@@ -198,9 +215,82 @@ public sealed class MySqlUserProfileStore(
return await ReadProfileAsync(connection, user.Subject, cancellationToken);
}
private static UserProfileInput NormalizeInput(AuthenticatedSiteUser user, UpdateUserProfileRequest request)
public void ValidateAdminUpdate(string fallbackDisplayName, UpdateUserProfileRequest request)
=> _ = NormalizeInput(fallbackDisplayName, request);
public async Task<IReadOnlyList<UserProfileResponse>> ListAsync(CancellationToken cancellationToken)
{
var displayName = NormalizeOptionalValue(request.DisplayName, "nom affiche", 120) ?? user.DisplayName;
await using var connection = new MySqlConnection(_options.BuildConnectionString());
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = SelectAllProfilesSql;
var profiles = new List<UserProfileResponse>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
profiles.Add(MapProfile(reader));
}
return profiles;
}
public async Task<UserProfileResponse?> FindBySubjectAsync(string subject, CancellationToken cancellationToken)
{
await using var connection = new MySqlConnection(_options.BuildConnectionString());
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = SelectProfileSql;
command.Parameters.AddWithValue("@subject", subject);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
return await reader.ReadAsync(cancellationToken)
? MapProfile(reader)
: null;
}
public async Task<UserProfileResponse> AdminUpsertAsync(
string subject,
string username,
string? email,
string fallbackDisplayName,
UpdateUserProfileRequest request,
CancellationToken cancellationToken)
{
var input = NormalizeInput(fallbackDisplayName, request);
await using var connection = new MySqlConnection(_options.BuildConnectionString());
await connection.OpenAsync(cancellationToken);
var nowUtc = DateTime.UtcNow;
await using (var command = connection.CreateCommand())
{
command.CommandText = UpsertProfileSql;
command.Parameters.AddWithValue("@subject", subject);
command.Parameters.AddWithValue("@username", username);
command.Parameters.AddWithValue("@email", (object?)email ?? DBNull.Value);
command.Parameters.AddWithValue("@displayName", input.DisplayName);
command.Parameters.AddWithValue("@club", (object?)input.Club ?? DBNull.Value);
command.Parameters.AddWithValue("@city", (object?)input.City ?? DBNull.Value);
command.Parameters.AddWithValue("@preferredFormat", (object?)input.PreferredFormat ?? DBNull.Value);
command.Parameters.AddWithValue("@favoriteCube", (object?)input.FavoriteCube ?? DBNull.Value);
command.Parameters.AddWithValue("@bio", (object?)input.Bio ?? DBNull.Value);
command.Parameters.AddWithValue("@createdUtc", nowUtc);
command.Parameters.AddWithValue("@updatedUtc", nowUtc);
await command.ExecuteNonQueryAsync(cancellationToken);
}
return await ReadProfileAsync(connection, subject, cancellationToken);
}
private static UserProfileInput NormalizeInput(AuthenticatedSiteUser user, UpdateUserProfileRequest request)
=> NormalizeInput(user.DisplayName, request);
private static UserProfileInput NormalizeInput(string fallbackDisplayName, UpdateUserProfileRequest request)
{
var displayName = NormalizeOptionalValue(request.DisplayName, "nom affiche", 120) ?? fallbackDisplayName;
var club = NormalizeOptionalValue(request.Club, "club", 120);
var city = NormalizeOptionalValue(request.City, "ville", 120);
var favoriteCube = NormalizeOptionalValue(request.FavoriteCube, "cube favori", 120);
@@ -258,6 +348,11 @@ public sealed class MySqlUserProfileStore(
throw new InvalidOperationException("Le profil utilisateur n'a pas pu etre charge.");
}
return MapProfile(reader);
}
private static UserProfileResponse MapProfile(MySqlDataReader reader)
{
var subjectOrdinal = reader.GetOrdinal("subject");
var usernameOrdinal = reader.GetOrdinal("username");
var emailOrdinal = reader.GetOrdinal("email");

View File

@@ -43,6 +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`
Le realm importe par defaut :
@@ -52,7 +53,7 @@ Le realm importe par defaut :
- inscription utilisateur : activee
- direct access grant : active
La gestion des utilisateurs se fait ensuite dans la console d'administration Keycloak.
La gestion des utilisateurs peut maintenant demarrer depuis la page d'administration du site pour les usages courants. La console d'administration Keycloak reste utile pour les reglages avances, notamment les roles.
## Demarrage local
@@ -148,6 +149,7 @@ bash -c "$(curl -fsSL https://git.jeannerot.fr/christophe/chesscubing/raw/branch
- `ChessCubing.App/Pages/Home.razor` : page d'accueil du site
- `ChessCubing.App/Pages/UserPage.razor` : page utilisateur connectee a MySQL
- `ChessCubing.App/Pages/AdminPage.razor` : premiere page d'administration pour gerer les utilisateurs
- `ChessCubing.App/Pages/ApplicationPage.razor` : configuration et reprise de match
- `ChessCubing.App/Pages/ChronoPage.razor` : phase chrono
- `ChessCubing.App/Pages/CubePage.razor` : phase cube
@@ -155,6 +157,7 @@ bash -c "$(curl -fsSL https://git.jeannerot.fr/christophe/chesscubing/raw/branch
- `ChessCubing.App/Services/MatchEngine.cs` : regles de jeu et transitions
- `ChessCubing.App/Services/AppAuthenticationStateProvider.cs` : etat de session cote client
- `ChessCubing.Server/Program.cs` : endpoints `/api/auth/*` et `/api/users/*`
- `ChessCubing.Server/Program.cs` : endpoints `/api/auth/*`, `/api/users/*` et `/api/admin/users/*`
- `ChessCubing.Server/Users/MySqlUserProfileStore.cs` : creation de table et persistance du profil utilisateur
- `keycloak/realm/chesscubing-realm.json` : realm, roles et client Keycloak importes
- `keycloak/scripts/init-config.sh` : mise en conformite du client Keycloak au demarrage

View File

@@ -1578,6 +1578,131 @@ body.site-menu-hidden .site-menu-shell {
color: #ffd8de;
}
.admin-hero-stats {
margin-top: 0.8rem;
}
.admin-list-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-search-field {
margin: 0;
}
.admin-user-list {
display: grid;
gap: 0.75rem;
max-height: 60rem;
overflow-y: auto;
}
.admin-user-card {
appearance: none;
width: 100%;
display: grid;
gap: 0.8rem;
padding: 1rem;
border: 1px solid var(--panel-border);
border-radius: 22px;
background: var(--panel-alt);
color: var(--text);
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-user-card.is-selected {
border-color: rgba(52, 141, 255, 0.52);
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-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 {
display: grid;
gap: 0.3rem;
font-size: 0.92rem;
}
.admin-chip-success {
background: rgba(69, 185, 127, 0.14);
color: #dff7ea;
}
.admin-chip-danger {
background: rgba(255, 100, 127, 0.14);
color: #ffd8de;
}
.admin-toggle-grid,
.admin-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.9rem;
}
.admin-toggle-card {
display: flex;
gap: 0.85rem;
align-items: flex-start;
padding: 1rem;
border-radius: 22px;
border: 1px solid var(--panel-border);
background: var(--panel-alt);
}
.admin-toggle-card input[type="checkbox"] {
width: 1.1rem;
height: 1.1rem;
margin-top: 0.15rem;
accent-color: var(--cool-strong);
}
.admin-toggle-card div {
display: grid;
gap: 0.25rem;
}
.admin-toggle-card span {
color: var(--muted);
}
@media (max-width: 1100px) {
.hero,
.setup-grid,
@@ -1601,10 +1726,16 @@ body.site-menu-hidden .site-menu-shell {
}
.profile-meta-grid,
.profile-form-grid {
.profile-form-grid,
.admin-toggle-grid,
.admin-form-grid {
grid-template-columns: 1fr;
}
.admin-detail-panel {
grid-column: span 12;
}
.phase-header {
grid-template-columns: 1fr;
text-align: center;
@@ -1835,6 +1966,10 @@ 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;