Ajoute une zone d'administration des utilisateurs
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -3,3 +3,5 @@
|
|||||||
WhatsApp Video 2026-04-11 at 20.38.50.mp4
|
WhatsApp Video 2026-04-11 at 20.38.50.mp4
|
||||||
ChessCubing.App/bin/
|
ChessCubing.App/bin/
|
||||||
ChessCubing.App/obj/
|
ChessCubing.App/obj/
|
||||||
|
ChessCubing.Server/bin/
|
||||||
|
ChessCubing.Server/obj/
|
||||||
|
|||||||
@@ -27,6 +27,10 @@
|
|||||||
<a class="@BuildNavLinkClass(ApplicationPaths)" href="application.html" aria-current="@BuildAriaCurrent(ApplicationPaths)">Application</a>
|
<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(RulesPaths)" href="reglement.html" aria-current="@BuildAriaCurrent(RulesPaths)">Reglement</a>
|
||||||
<a class="@BuildNavLinkClass(UserPaths)" href="utilisateur.html" aria-current="@BuildAriaCurrent(UserPaths)">Utilisateur</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>
|
</nav>
|
||||||
|
|
||||||
<div class="site-menu-account">
|
<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(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(RulesPaths)" href="reglement.html" aria-current="@BuildAriaCurrent(RulesPaths)">Reglement</a>
|
||||||
<a class="@BuildNavLinkClass(UserPaths)" href="utilisateur.html" aria-current="@BuildAriaCurrent(UserPaths)">Utilisateur</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>
|
</nav>
|
||||||
|
|
||||||
<div class="site-menu-mobile-account">
|
<div class="site-menu-mobile-account">
|
||||||
@@ -202,11 +210,13 @@
|
|||||||
private static readonly string[] ApplicationPaths = ["application", "application.html"];
|
private static readonly string[] ApplicationPaths = ["application", "application.html"];
|
||||||
private static readonly string[] RulesPaths = ["reglement", "reglement.html"];
|
private static readonly string[] RulesPaths = ["reglement", "reglement.html"];
|
||||||
private static readonly string[] UserPaths = ["utilisateur", "utilisateur.html"];
|
private static readonly string[] UserPaths = ["utilisateur", "utilisateur.html"];
|
||||||
|
private static readonly string[] AdminPaths = ["administration", "administration.html"];
|
||||||
|
|
||||||
private readonly LoginFormModel LoginModel = new();
|
private readonly LoginFormModel LoginModel = new();
|
||||||
private readonly RegisterFormModel RegisterModel = new();
|
private readonly RegisterFormModel RegisterModel = new();
|
||||||
|
|
||||||
private bool IsAuthenticated;
|
private bool IsAuthenticated;
|
||||||
|
private bool IsAdmin;
|
||||||
private bool ShowAuthModal;
|
private bool ShowAuthModal;
|
||||||
private bool IsSubmitting;
|
private bool IsSubmitting;
|
||||||
private bool IsMobileMenuOpen;
|
private bool IsMobileMenuOpen;
|
||||||
@@ -444,6 +454,7 @@
|
|||||||
if (user.Identity?.IsAuthenticated == true)
|
if (user.Identity?.IsAuthenticated == true)
|
||||||
{
|
{
|
||||||
IsAuthenticated = true;
|
IsAuthenticated = true;
|
||||||
|
IsAdmin = user.IsInRole("admin");
|
||||||
DisplayName = BuildDisplayName(user);
|
DisplayName = BuildDisplayName(user);
|
||||||
DisplayMeta = BuildMeta(user);
|
DisplayMeta = BuildMeta(user);
|
||||||
}
|
}
|
||||||
@@ -463,6 +474,7 @@
|
|||||||
private void ResetAuthenticationDisplay()
|
private void ResetAuthenticationDisplay()
|
||||||
{
|
{
|
||||||
IsAuthenticated = false;
|
IsAuthenticated = false;
|
||||||
|
IsAdmin = false;
|
||||||
DisplayName = "Utilisateur connecte";
|
DisplayName = "Utilisateur connecte";
|
||||||
DisplayMeta = "Session active";
|
DisplayMeta = "Session active";
|
||||||
}
|
}
|
||||||
|
|||||||
28
ChessCubing.App/Models/Users/AdminUpdateUserRequest.cs
Normal file
28
ChessCubing.App/Models/Users/AdminUpdateUserRequest.cs
Normal 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; }
|
||||||
|
}
|
||||||
40
ChessCubing.App/Models/Users/AdminUserDetailResponse.cs
Normal file
40
ChessCubing.App/Models/Users/AdminUserDetailResponse.cs
Normal 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; }
|
||||||
|
}
|
||||||
30
ChessCubing.App/Models/Users/AdminUserSummaryResponse.cs
Normal file
30
ChessCubing.App/Models/Users/AdminUserSummaryResponse.cs
Normal 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; }
|
||||||
|
}
|
||||||
747
ChessCubing.App/Pages/AdminPage.razor
Normal file
747
ChessCubing.App/Pages/AdminPage.razor
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
116
ChessCubing.Server/Admin/AdminUserContracts.cs
Normal file
116
ChessCubing.Server/Admin/AdminUserContracts.cs
Normal 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);
|
||||||
@@ -3,6 +3,7 @@ using System.Net.Http.Headers;
|
|||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
using ChessCubing.Server.Admin;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace ChessCubing.Server.Auth;
|
namespace ChessCubing.Server.Auth;
|
||||||
@@ -14,6 +15,11 @@ public sealed class KeycloakAuthService(HttpClient httpClient, IOptions<Keycloak
|
|||||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions UpdateJsonOptions = new(JsonOptions)
|
||||||
|
{
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
||||||
|
};
|
||||||
|
|
||||||
private readonly HttpClient _httpClient = httpClient;
|
private readonly HttpClient _httpClient = httpClient;
|
||||||
private readonly KeycloakAuthOptions _options = options.Value;
|
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);
|
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)
|
private async Task<TokenSuccessResponse> RequestPasswordTokenAsync(string username, string password, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var formData = new Dictionary<string, string>
|
var formData = new Dictionary<string, string>
|
||||||
@@ -235,6 +322,44 @@ public sealed class KeycloakAuthService(HttpClient httpClient, IOptions<Keycloak
|
|||||||
return users?.FirstOrDefault()?.Id;
|
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)
|
private string[] ExtractRealmRoles(string accessToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -326,6 +451,33 @@ public sealed class KeycloakAuthService(HttpClient httpClient, IOptions<Keycloak
|
|||||||
[JsonPropertyName("description")]
|
[JsonPropertyName("description")]
|
||||||
public string? Description { get; init; }
|
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)
|
public sealed class KeycloakAuthException(string message, int statusCode = StatusCodes.Status400BadRequest) : Exception(message)
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using System.Net.Mail;
|
||||||
|
using ChessCubing.Server.Admin;
|
||||||
using ChessCubing.Server.Auth;
|
using ChessCubing.Server.Auth;
|
||||||
using ChessCubing.Server.Data;
|
using ChessCubing.Server.Data;
|
||||||
using ChessCubing.Server.Users;
|
using ChessCubing.Server.Users;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
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.AddHttpClient<KeycloakAuthService>();
|
||||||
builder.Services.AddSingleton<MySqlUserProfileStore>();
|
builder.Services.AddSingleton<MySqlUserProfileStore>();
|
||||||
|
|
||||||
@@ -116,6 +122,109 @@ app.MapPut("/api/users/me", async Task<IResult> (
|
|||||||
}
|
}
|
||||||
}).RequireAuthorization();
|
}).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> (
|
app.MapPost("/api/auth/login", async Task<IResult> (
|
||||||
LoginRequest request,
|
LoginRequest request,
|
||||||
HttpContext httpContext,
|
HttpContext httpContext,
|
||||||
@@ -184,6 +293,119 @@ app.MapPost("/api/auth/logout", async Task<IResult> (HttpContext httpContext) =>
|
|||||||
|
|
||||||
app.Run();
|
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)
|
static async Task SignInAsync(HttpContext httpContext, KeycloakUserInfo userInfo)
|
||||||
{
|
{
|
||||||
var claims = new List<Claim>();
|
var claims = new List<Claim>();
|
||||||
@@ -232,3 +454,11 @@ static async Task SignInAsync(HttpContext httpContext, KeycloakUserInfo userInfo
|
|||||||
|
|
||||||
httpContext.User = principal;
|
httpContext.User = principal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sealed record NormalizedAdminUserUpdate(
|
||||||
|
string Username,
|
||||||
|
string? Email,
|
||||||
|
string? FirstName,
|
||||||
|
string? LastName,
|
||||||
|
bool IsEnabled,
|
||||||
|
bool IsEmailVerified);
|
||||||
|
|||||||
@@ -106,6 +106,23 @@ public sealed class MySqlUserProfileStore(
|
|||||||
LIMIT 1;
|
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 SiteDataOptions _options = options.Value;
|
||||||
private readonly ILogger<MySqlUserProfileStore> _logger = logger;
|
private readonly ILogger<MySqlUserProfileStore> _logger = logger;
|
||||||
|
|
||||||
@@ -198,9 +215,82 @@ public sealed class MySqlUserProfileStore(
|
|||||||
return await ReadProfileAsync(connection, user.Subject, cancellationToken);
|
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 club = NormalizeOptionalValue(request.Club, "club", 120);
|
||||||
var city = NormalizeOptionalValue(request.City, "ville", 120);
|
var city = NormalizeOptionalValue(request.City, "ville", 120);
|
||||||
var favoriteCube = NormalizeOptionalValue(request.FavoriteCube, "cube favori", 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.");
|
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 subjectOrdinal = reader.GetOrdinal("subject");
|
||||||
var usernameOrdinal = reader.GetOrdinal("username");
|
var usernameOrdinal = reader.GetOrdinal("username");
|
||||||
var emailOrdinal = reader.GetOrdinal("email");
|
var emailOrdinal = reader.GetOrdinal("email");
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ L'application embarque maintenant une authentification integree basee sur Keyclo
|
|||||||
- les roles Keycloak du realm restent exposes dans l'application
|
- 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
|
- 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 `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 :
|
Le realm importe par defaut :
|
||||||
|
|
||||||
@@ -52,7 +53,7 @@ Le realm importe par defaut :
|
|||||||
- inscription utilisateur : activee
|
- inscription utilisateur : activee
|
||||||
- direct access grant : active
|
- 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
|
## 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/Home.razor` : page d'accueil du site
|
||||||
- `ChessCubing.App/Pages/UserPage.razor` : page utilisateur connectee a MySQL
|
- `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/ApplicationPage.razor` : configuration et reprise de match
|
||||||
- `ChessCubing.App/Pages/ChronoPage.razor` : phase chrono
|
- `ChessCubing.App/Pages/ChronoPage.razor` : phase chrono
|
||||||
- `ChessCubing.App/Pages/CubePage.razor` : phase cube
|
- `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/MatchEngine.cs` : regles de jeu et transitions
|
||||||
- `ChessCubing.App/Services/AppAuthenticationStateProvider.cs` : etat de session cote client
|
- `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/*` 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
|
- `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/realm/chesscubing-realm.json` : realm, roles et client Keycloak importes
|
||||||
- `keycloak/scripts/init-config.sh` : mise en conformite du client Keycloak au demarrage
|
- `keycloak/scripts/init-config.sh` : mise en conformite du client Keycloak au demarrage
|
||||||
|
|||||||
137
styles.css
137
styles.css
@@ -1578,6 +1578,131 @@ body.site-menu-hidden .site-menu-shell {
|
|||||||
color: #ffd8de;
|
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) {
|
@media (max-width: 1100px) {
|
||||||
.hero,
|
.hero,
|
||||||
.setup-grid,
|
.setup-grid,
|
||||||
@@ -1601,10 +1726,16 @@ body.site-menu-hidden .site-menu-shell {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.profile-meta-grid,
|
.profile-meta-grid,
|
||||||
.profile-form-grid {
|
.profile-form-grid,
|
||||||
|
.admin-toggle-grid,
|
||||||
|
.admin-form-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-detail-panel {
|
||||||
|
grid-column: span 12;
|
||||||
|
}
|
||||||
|
|
||||||
.phase-header {
|
.phase-header {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -1835,6 +1966,10 @@ body.site-menu-hidden .site-menu-shell {
|
|||||||
padding: 0.55rem 0 0.55rem;
|
padding: 0.55rem 0 0.55rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-user-list {
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
|
||||||
.phase-header {
|
.phase-header {
|
||||||
grid-template-columns: auto 1fr auto;
|
grid-template-columns: auto 1fr auto;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user