Ajoute une page utilisateur et une persistance MySQL
This commit is contained in:
@@ -3,3 +3,7 @@ KEYCLOAK_DB_USER=keycloak
|
|||||||
KEYCLOAK_DB_PASSWORD=change-me
|
KEYCLOAK_DB_PASSWORD=change-me
|
||||||
KEYCLOAK_ADMIN_USER=admin
|
KEYCLOAK_ADMIN_USER=admin
|
||||||
KEYCLOAK_ADMIN_PASSWORD=change-me
|
KEYCLOAK_ADMIN_PASSWORD=change-me
|
||||||
|
SITE_DB_NAME=chesscubing_site
|
||||||
|
SITE_DB_USER=chesscubing
|
||||||
|
SITE_DB_PASSWORD=change-me
|
||||||
|
SITE_DB_ROOT_PASSWORD=change-me
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
<a class="@BuildNavLinkClass(HomePaths)" href="index.html" aria-current="@BuildAriaCurrent(HomePaths)">Accueil</a>
|
<a class="@BuildNavLinkClass(HomePaths)" href="index.html" aria-current="@BuildAriaCurrent(HomePaths)">Accueil</a>
|
||||||
<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>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="site-menu-account">
|
<div class="site-menu-account">
|
||||||
@@ -154,6 +155,7 @@
|
|||||||
private static readonly string[] HomePaths = ["", "index.html"];
|
private static readonly string[] HomePaths = ["", "index.html"];
|
||||||
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 readonly LoginFormModel LoginModel = new();
|
private readonly LoginFormModel LoginModel = new();
|
||||||
private readonly RegisterFormModel RegisterModel = new();
|
private readonly RegisterFormModel RegisterModel = new();
|
||||||
|
|||||||
16
ChessCubing.App/Models/Users/UpdateUserProfileRequest.cs
Normal file
16
ChessCubing.App/Models/Users/UpdateUserProfileRequest.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
namespace ChessCubing.App.Models.Users;
|
||||||
|
|
||||||
|
public sealed class UpdateUserProfileRequest
|
||||||
|
{
|
||||||
|
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; }
|
||||||
|
}
|
||||||
26
ChessCubing.App/Models/Users/UserProfileResponse.cs
Normal file
26
ChessCubing.App/Models/Users/UserProfileResponse.cs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
namespace ChessCubing.App.Models.Users;
|
||||||
|
|
||||||
|
public sealed class UserProfileResponse
|
||||||
|
{
|
||||||
|
public string Subject { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string Username { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string? Email { 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 CreatedUtc { get; set; }
|
||||||
|
|
||||||
|
public DateTime UpdatedUtc { get; set; }
|
||||||
|
}
|
||||||
475
ChessCubing.App/Pages/UserPage.razor
Normal file
475
ChessCubing.App/Pages/UserPage.razor
Normal file
@@ -0,0 +1,475 @@
|
|||||||
|
@page "/utilisateur"
|
||||||
|
@page "/utilisateur.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 | Utilisateur</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">Espace utilisateur</p>
|
||||||
|
<h1>Gerer les donnees du site pour chaque joueur</h1>
|
||||||
|
<p class="lead">
|
||||||
|
Cette page relie le compte connecte a une fiche utilisateur stockee
|
||||||
|
cote serveur en MySQL. L'authentification reste geree par Keycloak,
|
||||||
|
mais les informations metier du site sont maintenant pretes pour des
|
||||||
|
evolutions futures.
|
||||||
|
</p>
|
||||||
|
<div class="hero-actions">
|
||||||
|
<a class="button secondary" href="application.html">Ouvrir l'application</a>
|
||||||
|
<a class="button ghost" href="reglement.html">Voir les formats</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="hero-preview">
|
||||||
|
<div class="preview-card">
|
||||||
|
<p class="micro-label">Persistance</p>
|
||||||
|
<strong>Profil du site stocke en MySQL</strong>
|
||||||
|
<p>
|
||||||
|
Username et email restent lies au compte authentifie, pendant que
|
||||||
|
le profil ChessCubing ajoute les donnees utiles au site.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="preview-banner">
|
||||||
|
<span class="mini-chip">Etat du compte</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 pour gerer ton profil</h2>
|
||||||
|
</div>
|
||||||
|
<p class="section-copy">
|
||||||
|
Utilise les boutons Se connecter ou Creer un compte dans le menu
|
||||||
|
en haut de page, puis reviens ici pour enregistrer tes informations.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="source-grid">
|
||||||
|
<a class="source-card" href="index.html">
|
||||||
|
<span class="micro-label">Accueil</span>
|
||||||
|
<strong>Retourner au site</strong>
|
||||||
|
<p>Revenir a la presentation generale de ChessCubing Arena.</p>
|
||||||
|
</a>
|
||||||
|
<a class="source-card" href="application.html">
|
||||||
|
<span class="micro-label">Application</span>
|
||||||
|
<strong>Ouvrir l'arbitrage</strong>
|
||||||
|
<p>Configurer un match pendant que le compte reste disponible dans le menu.</p>
|
||||||
|
</a>
|
||||||
|
<a class="source-card" href="reglement.html">
|
||||||
|
<span class="micro-label">Reglement</span>
|
||||||
|
<strong>Relire les formats</strong>
|
||||||
|
<p>Retrouver rapidement les differences entre Twice et Time.</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
else if (IsLoading)
|
||||||
|
{
|
||||||
|
<section class="panel panel-wide">
|
||||||
|
<p class="eyebrow">Chargement</p>
|
||||||
|
<h2>Recuperation du profil utilisateur</h2>
|
||||||
|
<p class="section-copy">Le serveur recharge les donnees enregistrees pour ce compte.</p>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrWhiteSpace(LoadError))
|
||||||
|
{
|
||||||
|
<section class="panel panel-wide">
|
||||||
|
<p class="eyebrow">Serveur</p>
|
||||||
|
<h2>Impossible de charger le profil</h2>
|
||||||
|
<p class="profile-feedback error">@LoadError</p>
|
||||||
|
<div class="hero-actions">
|
||||||
|
<button class="button secondary" type="button" @onclick="LoadProfileAsync">Reessayer</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
else if (Profile is not null)
|
||||||
|
{
|
||||||
|
<section class="panel panel-half">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Compte relie</p>
|
||||||
|
<h2>@Profile.DisplayName</h2>
|
||||||
|
</div>
|
||||||
|
<p class="section-copy">
|
||||||
|
Le compte connecte pilote l'identite, et cette page ajoute les donnees metier du site.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="profile-meta-grid">
|
||||||
|
<article class="profile-meta-card">
|
||||||
|
<span class="micro-label">Username</span>
|
||||||
|
<strong>@Profile.Username</strong>
|
||||||
|
</article>
|
||||||
|
<article class="profile-meta-card">
|
||||||
|
<span class="micro-label">Email</span>
|
||||||
|
<strong>@(Profile.Email ?? "Non renseigne")</strong>
|
||||||
|
</article>
|
||||||
|
<article class="profile-meta-card">
|
||||||
|
<span class="micro-label">Cree le</span>
|
||||||
|
<strong>@FormatDate(Profile.CreatedUtc)</strong>
|
||||||
|
</article>
|
||||||
|
<article class="profile-meta-card">
|
||||||
|
<span class="micro-label">Mis a jour</span>
|
||||||
|
<strong>@FormatDate(Profile.UpdatedUtc)</strong>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel panel-half">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Resume du profil</p>
|
||||||
|
<h2>Informations visibles pour le site</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="home-mini-grid two-columns profile-highlights">
|
||||||
|
<article class="mini-panel">
|
||||||
|
<span class="micro-label">Club</span>
|
||||||
|
<strong>@(Profile.Club ?? "A definir")</strong>
|
||||||
|
</article>
|
||||||
|
<article class="mini-panel">
|
||||||
|
<span class="micro-label">Ville</span>
|
||||||
|
<strong>@(Profile.City ?? "A definir")</strong>
|
||||||
|
</article>
|
||||||
|
<article class="mini-panel">
|
||||||
|
<span class="micro-label">Format prefere</span>
|
||||||
|
<strong>@(Profile.PreferredFormat ?? "A definir")</strong>
|
||||||
|
</article>
|
||||||
|
<article class="mini-panel">
|
||||||
|
<span class="micro-label">Cube favori</span>
|
||||||
|
<strong>@(Profile.FavoriteCube ?? "A definir")</strong>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="callout profile-callout">
|
||||||
|
<span class="micro-label">Bio</span>
|
||||||
|
<p>@(Profile.Bio ?? "Aucune bio enregistree pour le moment.")</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel panel-wide">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Edition</p>
|
||||||
|
<h2>Mettre a jour le profil du site</h2>
|
||||||
|
</div>
|
||||||
|
<p class="section-copy">
|
||||||
|
Les champs ci-dessous sont stockes dans MySQL et pourront ensuite
|
||||||
|
servir a enrichir les pages, les inscriptions ou les statistiques.
|
||||||
|
</p>
|
||||||
|
</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="SaveProfileAsync">
|
||||||
|
<DataAnnotationsValidator />
|
||||||
|
|
||||||
|
<div class="profile-form-grid">
|
||||||
|
<label class="field">
|
||||||
|
<span>Nom affiche</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 le profil")
|
||||||
|
</button>
|
||||||
|
<p class="section-copy">Les champs vides restent facultatifs et peuvent etre completes plus tard.</p>
|
||||||
|
</div>
|
||||||
|
</EditForm>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private readonly UserProfileFormModel Form = new();
|
||||||
|
|
||||||
|
private UserProfileResponse? Profile;
|
||||||
|
private bool IsAuthenticated;
|
||||||
|
private bool IsLoading = true;
|
||||||
|
private bool IsSaving;
|
||||||
|
private string? LoadError;
|
||||||
|
private string? SaveError;
|
||||||
|
private string? SaveMessage;
|
||||||
|
|
||||||
|
private string HeroStatusTitle
|
||||||
|
=> !IsAuthenticated
|
||||||
|
? "Connexion requise"
|
||||||
|
: IsLoading
|
||||||
|
? "Chargement du profil"
|
||||||
|
: Profile?.DisplayName ?? "Profil utilisateur";
|
||||||
|
|
||||||
|
private string HeroStatusDescription
|
||||||
|
=> !IsAuthenticated
|
||||||
|
? "Le profil du site apparait des qu'un compte joueur est connecte."
|
||||||
|
: IsLoading
|
||||||
|
? "Le serveur verifie la fiche utilisateur associee a ce compte."
|
||||||
|
: $"Compte lie a {Profile?.Username ?? "l'utilisateur connecte"} et stocke en base MySQL.";
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
AuthenticationStateProvider.AuthenticationStateChanged += HandleAuthenticationStateChanged;
|
||||||
|
await LoadProfileAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleAuthenticationStateChanged(Task<AuthenticationState> authenticationStateTask)
|
||||||
|
=> _ = InvokeAsync(LoadProfileAsync);
|
||||||
|
|
||||||
|
private async Task LoadProfileAsync()
|
||||||
|
{
|
||||||
|
LoadError = null;
|
||||||
|
SaveError = null;
|
||||||
|
SaveMessage = null;
|
||||||
|
IsLoading = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
|
||||||
|
if (authState.User.Identity?.IsAuthenticated != true)
|
||||||
|
{
|
||||||
|
ResetProfileState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IsAuthenticated = true;
|
||||||
|
|
||||||
|
var response = await Http.GetAsync("api/users/me");
|
||||||
|
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||||||
|
{
|
||||||
|
ResetProfileState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
LoadError = await ReadErrorAsync(response, "Le profil utilisateur n'a pas pu etre charge.");
|
||||||
|
Profile = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Profile = await response.Content.ReadFromJsonAsync<UserProfileResponse>();
|
||||||
|
if (Profile is null)
|
||||||
|
{
|
||||||
|
LoadError = "Le serveur a retourne une reponse vide.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
FillForm(Profile);
|
||||||
|
}
|
||||||
|
catch (HttpRequestException)
|
||||||
|
{
|
||||||
|
LoadError = "Le service utilisateur est temporairement indisponible.";
|
||||||
|
Profile = null;
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
LoadError = "La reponse du service utilisateur a pris trop de temps.";
|
||||||
|
Profile = null;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsLoading = false;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveProfileAsync()
|
||||||
|
{
|
||||||
|
if (IsSaving || !IsAuthenticated)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IsSaving = true;
|
||||||
|
SaveError = null;
|
||||||
|
SaveMessage = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var payload = new UpdateUserProfileRequest
|
||||||
|
{
|
||||||
|
DisplayName = Form.DisplayName,
|
||||||
|
Club = Form.Club,
|
||||||
|
City = Form.City,
|
||||||
|
PreferredFormat = Form.PreferredFormat,
|
||||||
|
FavoriteCube = Form.FavoriteCube,
|
||||||
|
Bio = Form.Bio,
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await Http.PutAsJsonAsync("api/users/me", payload);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
SaveError = await ReadErrorAsync(response, "L'enregistrement du profil a echoue.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Profile = await response.Content.ReadFromJsonAsync<UserProfileResponse>();
|
||||||
|
if (Profile is null)
|
||||||
|
{
|
||||||
|
SaveError = "Le serveur a retourne une reponse vide apres la sauvegarde.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
FillForm(Profile);
|
||||||
|
SaveMessage = "Le profil utilisateur a bien ete enregistre.";
|
||||||
|
}
|
||||||
|
catch (HttpRequestException)
|
||||||
|
{
|
||||||
|
SaveError = "Le service utilisateur est temporairement indisponible.";
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
SaveError = "La sauvegarde a pris trop de temps.";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResetProfileState()
|
||||||
|
{
|
||||||
|
IsAuthenticated = false;
|
||||||
|
Profile = null;
|
||||||
|
Form.Reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void FillForm(UserProfileResponse profile)
|
||||||
|
{
|
||||||
|
Form.DisplayName = profile.DisplayName;
|
||||||
|
Form.Club = profile.Club;
|
||||||
|
Form.City = profile.City;
|
||||||
|
Form.PreferredFormat = profile.PreferredFormat;
|
||||||
|
Form.FavoriteCube = profile.FavoriteCube;
|
||||||
|
Form.Bio = profile.Bio;
|
||||||
|
}
|
||||||
|
|
||||||
|
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.",
|
||||||
|
_ => fallbackMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatDate(DateTime 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 UserProfileFormModel
|
||||||
|
{
|
||||||
|
[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()
|
||||||
|
{
|
||||||
|
DisplayName = string.Empty;
|
||||||
|
Club = string.Empty;
|
||||||
|
City = string.Empty;
|
||||||
|
PreferredFormat = string.Empty;
|
||||||
|
FavoriteCube = string.Empty;
|
||||||
|
Bio = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,4 +8,8 @@
|
|||||||
<AssemblyName>ChessCubing.Server</AssemblyName>
|
<AssemblyName>ChessCubing.Server</AssemblyName>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="MySqlConnector" Version="2.4.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
33
ChessCubing.Server/Data/SiteDataOptions.cs
Normal file
33
ChessCubing.Server/Data/SiteDataOptions.cs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
using MySqlConnector;
|
||||||
|
|
||||||
|
namespace ChessCubing.Server.Data;
|
||||||
|
|
||||||
|
public sealed class SiteDataOptions
|
||||||
|
{
|
||||||
|
public string Host { get; set; } = "mysql";
|
||||||
|
|
||||||
|
public int Port { get; set; } = 3306;
|
||||||
|
|
||||||
|
public string Database { get; set; } = "chesscubing_site";
|
||||||
|
|
||||||
|
public string Username { get; set; } = "chesscubing";
|
||||||
|
|
||||||
|
public string Password { get; set; } = "chesscubing";
|
||||||
|
|
||||||
|
public int InitializationRetries { get; set; } = 12;
|
||||||
|
|
||||||
|
public int InitializationDelaySeconds { get; set; } = 3;
|
||||||
|
|
||||||
|
public string BuildConnectionString()
|
||||||
|
=> new MySqlConnectionStringBuilder
|
||||||
|
{
|
||||||
|
Server = Host,
|
||||||
|
Port = checked((uint)Port),
|
||||||
|
Database = Database,
|
||||||
|
UserID = Username,
|
||||||
|
Password = Password,
|
||||||
|
CharacterSet = "utf8mb4",
|
||||||
|
Pooling = true,
|
||||||
|
ConnectionTimeout = 15,
|
||||||
|
}.ConnectionString;
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using ChessCubing.Server.Auth;
|
using ChessCubing.Server.Auth;
|
||||||
|
using ChessCubing.Server.Data;
|
||||||
|
using ChessCubing.Server.Users;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
|
|
||||||
@@ -17,6 +19,20 @@ builder.Services.AddOptions<KeycloakAuthOptions>()
|
|||||||
options.AdminPassword = configuration["KEYCLOAK_ADMIN_PASSWORD"] ?? options.AdminPassword;
|
options.AdminPassword = configuration["KEYCLOAK_ADMIN_PASSWORD"] ?? options.AdminPassword;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
builder.Services.AddOptions<SiteDataOptions>()
|
||||||
|
.Configure<IConfiguration>((options, configuration) =>
|
||||||
|
{
|
||||||
|
options.Host = configuration["SITE_DB_HOST"] ?? options.Host;
|
||||||
|
options.Database = configuration["SITE_DB_NAME"] ?? options.Database;
|
||||||
|
options.Username = configuration["SITE_DB_USER"] ?? options.Username;
|
||||||
|
options.Password = configuration["SITE_DB_PASSWORD"] ?? options.Password;
|
||||||
|
|
||||||
|
if (int.TryParse(configuration["SITE_DB_PORT"], out var port) && port > 0)
|
||||||
|
{
|
||||||
|
options.Port = port;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
builder.Services
|
builder.Services
|
||||||
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||||
.AddCookie(options =>
|
.AddCookie(options =>
|
||||||
@@ -44,9 +60,16 @@ builder.Services
|
|||||||
|
|
||||||
builder.Services.AddAuthorization();
|
builder.Services.AddAuthorization();
|
||||||
builder.Services.AddHttpClient<KeycloakAuthService>();
|
builder.Services.AddHttpClient<KeycloakAuthService>();
|
||||||
|
builder.Services.AddSingleton<MySqlUserProfileStore>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
await using (var scope = app.Services.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var profileStore = scope.ServiceProvider.GetRequiredService<MySqlUserProfileStore>();
|
||||||
|
await profileStore.InitializeAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
||||||
@@ -55,6 +78,44 @@ app.MapGet("/api/health", () => TypedResults.Ok(new { status = "ok" }));
|
|||||||
app.MapGet("/api/auth/session", (ClaimsPrincipal user) =>
|
app.MapGet("/api/auth/session", (ClaimsPrincipal user) =>
|
||||||
TypedResults.Ok(AuthSessionResponse.FromUser(user)));
|
TypedResults.Ok(AuthSessionResponse.FromUser(user)));
|
||||||
|
|
||||||
|
app.MapGet("/api/users/me", async Task<IResult> (
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
MySqlUserProfileStore profileStore,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
var siteUser = AuthenticatedSiteUserFactory.FromClaimsPrincipal(user);
|
||||||
|
if (siteUser is null)
|
||||||
|
{
|
||||||
|
return TypedResults.BadRequest(new ApiErrorResponse("La session utilisateur est incomplete."));
|
||||||
|
}
|
||||||
|
|
||||||
|
var profile = await profileStore.GetOrCreateAsync(siteUser, cancellationToken);
|
||||||
|
return TypedResults.Ok(profile);
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
|
app.MapPut("/api/users/me", async Task<IResult> (
|
||||||
|
UpdateUserProfileRequest request,
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
MySqlUserProfileStore profileStore,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
var siteUser = AuthenticatedSiteUserFactory.FromClaimsPrincipal(user);
|
||||||
|
if (siteUser is null)
|
||||||
|
{
|
||||||
|
return TypedResults.BadRequest(new ApiErrorResponse("La session utilisateur est incomplete."));
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var profile = await profileStore.UpdateAsync(siteUser, request, cancellationToken);
|
||||||
|
return TypedResults.Ok(profile);
|
||||||
|
}
|
||||||
|
catch (UserProfileValidationException exception)
|
||||||
|
{
|
||||||
|
return TypedResults.BadRequest(new ApiErrorResponse(exception.Message));
|
||||||
|
}
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
app.MapPost("/api/auth/login", async Task<IResult> (
|
app.MapPost("/api/auth/login", async Task<IResult> (
|
||||||
LoginRequest request,
|
LoginRequest request,
|
||||||
HttpContext httpContext,
|
HttpContext httpContext,
|
||||||
|
|||||||
40
ChessCubing.Server/Users/AuthenticatedSiteUser.cs
Normal file
40
ChessCubing.Server/Users/AuthenticatedSiteUser.cs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace ChessCubing.Server.Users;
|
||||||
|
|
||||||
|
public sealed record AuthenticatedSiteUser(
|
||||||
|
string Subject,
|
||||||
|
string Username,
|
||||||
|
string? Email,
|
||||||
|
string DisplayName);
|
||||||
|
|
||||||
|
public static class AuthenticatedSiteUserFactory
|
||||||
|
{
|
||||||
|
public static AuthenticatedSiteUser? FromClaimsPrincipal(ClaimsPrincipal user)
|
||||||
|
{
|
||||||
|
if (user.Identity?.IsAuthenticated != true)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var subject = user.FindFirst("sub")?.Value
|
||||||
|
?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
if (string.IsNullOrWhiteSpace(subject))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var username = user.FindFirst("preferred_username")?.Value
|
||||||
|
?? user.Identity?.Name
|
||||||
|
?? subject;
|
||||||
|
var email = user.FindFirst("email")?.Value;
|
||||||
|
var displayName = user.FindFirst("name")?.Value
|
||||||
|
?? username;
|
||||||
|
|
||||||
|
return new AuthenticatedSiteUser(
|
||||||
|
subject.Trim(),
|
||||||
|
username.Trim(),
|
||||||
|
string.IsNullOrWhiteSpace(email) ? null : email.Trim(),
|
||||||
|
displayName.Trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
296
ChessCubing.Server/Users/MySqlUserProfileStore.cs
Normal file
296
ChessCubing.Server/Users/MySqlUserProfileStore.cs
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
using ChessCubing.Server.Data;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using MySqlConnector;
|
||||||
|
|
||||||
|
namespace ChessCubing.Server.Users;
|
||||||
|
|
||||||
|
public sealed class MySqlUserProfileStore(
|
||||||
|
IOptions<SiteDataOptions> options,
|
||||||
|
ILogger<MySqlUserProfileStore> logger)
|
||||||
|
{
|
||||||
|
private const string CreateTableSql = """
|
||||||
|
CREATE TABLE IF NOT EXISTS site_users (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
|
subject VARCHAR(190) NOT NULL,
|
||||||
|
username VARCHAR(120) NOT NULL,
|
||||||
|
email VARCHAR(255) NULL,
|
||||||
|
display_name VARCHAR(120) NOT NULL,
|
||||||
|
club VARCHAR(120) NULL,
|
||||||
|
city VARCHAR(120) NULL,
|
||||||
|
preferred_format VARCHAR(40) NULL,
|
||||||
|
favorite_cube VARCHAR(120) NULL,
|
||||||
|
bio TEXT NULL,
|
||||||
|
created_utc DATETIME(6) NOT NULL,
|
||||||
|
updated_utc DATETIME(6) NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uq_site_users_subject (subject)
|
||||||
|
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string UpsertIdentitySql = """
|
||||||
|
INSERT INTO site_users (
|
||||||
|
subject,
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
display_name,
|
||||||
|
created_utc,
|
||||||
|
updated_utc
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
@subject,
|
||||||
|
@username,
|
||||||
|
@email,
|
||||||
|
@displayName,
|
||||||
|
@createdUtc,
|
||||||
|
@updatedUtc
|
||||||
|
)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
username = VALUES(username),
|
||||||
|
email = VALUES(email);
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string UpsertProfileSql = """
|
||||||
|
INSERT INTO site_users (
|
||||||
|
subject,
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
display_name,
|
||||||
|
club,
|
||||||
|
city,
|
||||||
|
preferred_format,
|
||||||
|
favorite_cube,
|
||||||
|
bio,
|
||||||
|
created_utc,
|
||||||
|
updated_utc
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
@subject,
|
||||||
|
@username,
|
||||||
|
@email,
|
||||||
|
@displayName,
|
||||||
|
@club,
|
||||||
|
@city,
|
||||||
|
@preferredFormat,
|
||||||
|
@favoriteCube,
|
||||||
|
@bio,
|
||||||
|
@createdUtc,
|
||||||
|
@updatedUtc
|
||||||
|
)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
username = VALUES(username),
|
||||||
|
email = VALUES(email),
|
||||||
|
display_name = VALUES(display_name),
|
||||||
|
club = VALUES(club),
|
||||||
|
city = VALUES(city),
|
||||||
|
preferred_format = VALUES(preferred_format),
|
||||||
|
favorite_cube = VALUES(favorite_cube),
|
||||||
|
bio = VALUES(bio),
|
||||||
|
updated_utc = VALUES(updated_utc);
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string SelectProfileSql = """
|
||||||
|
SELECT
|
||||||
|
subject,
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
display_name,
|
||||||
|
club,
|
||||||
|
city,
|
||||||
|
preferred_format,
|
||||||
|
favorite_cube,
|
||||||
|
bio,
|
||||||
|
created_utc,
|
||||||
|
updated_utc
|
||||||
|
FROM site_users
|
||||||
|
WHERE subject = @subject
|
||||||
|
LIMIT 1;
|
||||||
|
""";
|
||||||
|
|
||||||
|
private readonly SiteDataOptions _options = options.Value;
|
||||||
|
private readonly ILogger<MySqlUserProfileStore> _logger = logger;
|
||||||
|
|
||||||
|
public async Task InitializeAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
for (var attempt = 1; attempt <= _options.InitializationRetries; attempt++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var connection = new MySqlConnection(_options.BuildConnectionString());
|
||||||
|
await connection.OpenAsync(cancellationToken);
|
||||||
|
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = CreateTableSql;
|
||||||
|
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (attempt < _options.InitializationRetries)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
exception,
|
||||||
|
"Initialisation MySQL impossible pour le profil utilisateur (tentative {Attempt}/{MaxAttempts}).",
|
||||||
|
attempt,
|
||||||
|
_options.InitializationRetries);
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(_options.InitializationDelaySeconds), cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var finalConnection = new MySqlConnection(_options.BuildConnectionString());
|
||||||
|
await finalConnection.OpenAsync(cancellationToken);
|
||||||
|
|
||||||
|
await using var finalCommand = finalConnection.CreateCommand();
|
||||||
|
finalCommand.CommandText = CreateTableSql;
|
||||||
|
await finalCommand.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UserProfileResponse> GetOrCreateAsync(
|
||||||
|
AuthenticatedSiteUser user,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var connection = new MySqlConnection(_options.BuildConnectionString());
|
||||||
|
await connection.OpenAsync(cancellationToken);
|
||||||
|
|
||||||
|
var nowUtc = DateTime.UtcNow;
|
||||||
|
|
||||||
|
await using (var command = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
command.CommandText = UpsertIdentitySql;
|
||||||
|
command.Parameters.AddWithValue("@subject", user.Subject);
|
||||||
|
command.Parameters.AddWithValue("@username", user.Username);
|
||||||
|
command.Parameters.AddWithValue("@email", (object?)user.Email ?? DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("@displayName", user.DisplayName);
|
||||||
|
command.Parameters.AddWithValue("@createdUtc", nowUtc);
|
||||||
|
command.Parameters.AddWithValue("@updatedUtc", nowUtc);
|
||||||
|
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await ReadProfileAsync(connection, user.Subject, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UserProfileResponse> UpdateAsync(
|
||||||
|
AuthenticatedSiteUser user,
|
||||||
|
UpdateUserProfileRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var input = NormalizeInput(user, 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", user.Subject);
|
||||||
|
command.Parameters.AddWithValue("@username", user.Username);
|
||||||
|
command.Parameters.AddWithValue("@email", (object?)user.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, user.Subject, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UserProfileInput NormalizeInput(AuthenticatedSiteUser user, UpdateUserProfileRequest request)
|
||||||
|
{
|
||||||
|
var displayName = NormalizeOptionalValue(request.DisplayName, "nom affiche", 120) ?? user.DisplayName;
|
||||||
|
var club = NormalizeOptionalValue(request.Club, "club", 120);
|
||||||
|
var city = NormalizeOptionalValue(request.City, "ville", 120);
|
||||||
|
var favoriteCube = NormalizeOptionalValue(request.FavoriteCube, "cube favori", 120);
|
||||||
|
var bio = NormalizeOptionalValue(request.Bio, "bio", 1200);
|
||||||
|
var preferredFormat = NormalizePreferredFormat(request.PreferredFormat);
|
||||||
|
|
||||||
|
return new UserProfileInput(displayName, club, city, preferredFormat, favoriteCube, bio);
|
||||||
|
}
|
||||||
|
|
||||||
|
private 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 UserProfileValidationException($"Le champ {fieldName} depasse {maxLength} caracteres.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NormalizePreferredFormat(string? value)
|
||||||
|
{
|
||||||
|
var normalized = NormalizeOptionalValue(value, "format prefere", 40);
|
||||||
|
if (normalized is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized switch
|
||||||
|
{
|
||||||
|
"Twice" => "Twice",
|
||||||
|
"Time" => "Time",
|
||||||
|
"Les deux" => "Les deux",
|
||||||
|
_ => throw new UserProfileValidationException("Le format prefere doit etre Twice, Time ou Les deux."),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<UserProfileResponse> ReadProfileAsync(
|
||||||
|
MySqlConnection connection,
|
||||||
|
string subject,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = SelectProfileSql;
|
||||||
|
command.Parameters.AddWithValue("@subject", subject);
|
||||||
|
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||||
|
if (!await reader.ReadAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Le profil utilisateur n'a pas pu etre charge.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var subjectOrdinal = reader.GetOrdinal("subject");
|
||||||
|
var usernameOrdinal = reader.GetOrdinal("username");
|
||||||
|
var emailOrdinal = reader.GetOrdinal("email");
|
||||||
|
var displayNameOrdinal = reader.GetOrdinal("display_name");
|
||||||
|
var clubOrdinal = reader.GetOrdinal("club");
|
||||||
|
var cityOrdinal = reader.GetOrdinal("city");
|
||||||
|
var preferredFormatOrdinal = reader.GetOrdinal("preferred_format");
|
||||||
|
var favoriteCubeOrdinal = reader.GetOrdinal("favorite_cube");
|
||||||
|
var bioOrdinal = reader.GetOrdinal("bio");
|
||||||
|
var createdUtcOrdinal = reader.GetOrdinal("created_utc");
|
||||||
|
var updatedUtcOrdinal = reader.GetOrdinal("updated_utc");
|
||||||
|
|
||||||
|
return new UserProfileResponse
|
||||||
|
{
|
||||||
|
Subject = reader.GetString(subjectOrdinal),
|
||||||
|
Username = reader.GetString(usernameOrdinal),
|
||||||
|
Email = reader.IsDBNull(emailOrdinal) ? null : reader.GetString(emailOrdinal),
|
||||||
|
DisplayName = reader.GetString(displayNameOrdinal),
|
||||||
|
Club = reader.IsDBNull(clubOrdinal) ? null : reader.GetString(clubOrdinal),
|
||||||
|
City = reader.IsDBNull(cityOrdinal) ? null : reader.GetString(cityOrdinal),
|
||||||
|
PreferredFormat = reader.IsDBNull(preferredFormatOrdinal) ? null : reader.GetString(preferredFormatOrdinal),
|
||||||
|
FavoriteCube = reader.IsDBNull(favoriteCubeOrdinal) ? null : reader.GetString(favoriteCubeOrdinal),
|
||||||
|
Bio = reader.IsDBNull(bioOrdinal) ? null : reader.GetString(bioOrdinal),
|
||||||
|
CreatedUtc = DateTime.SpecifyKind(reader.GetDateTime(createdUtcOrdinal), DateTimeKind.Utc),
|
||||||
|
UpdatedUtc = DateTime.SpecifyKind(reader.GetDateTime(updatedUtcOrdinal), DateTimeKind.Utc),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record UserProfileInput(
|
||||||
|
string DisplayName,
|
||||||
|
string? Club,
|
||||||
|
string? City,
|
||||||
|
string? PreferredFormat,
|
||||||
|
string? FavoriteCube,
|
||||||
|
string? Bio);
|
||||||
|
}
|
||||||
43
ChessCubing.Server/Users/UserProfileContracts.cs
Normal file
43
ChessCubing.Server/Users/UserProfileContracts.cs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
namespace ChessCubing.Server.Users;
|
||||||
|
|
||||||
|
public sealed class UserProfileResponse
|
||||||
|
{
|
||||||
|
public string Subject { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string Username { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string? Email { 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 CreatedUtc { get; init; }
|
||||||
|
|
||||||
|
public DateTime UpdatedUtc { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UpdateUserProfileRequest
|
||||||
|
{
|
||||||
|
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 class UserProfileValidationException(string message) : Exception(message);
|
||||||
11
README.md
11
README.md
@@ -23,11 +23,11 @@ Le coeur de l'application se trouve dans `ChessCubing.App/`.
|
|||||||
- `ChessCubing.App/Services/MatchEngine.cs` : logique metier des matchs
|
- `ChessCubing.App/Services/MatchEngine.cs` : logique metier des matchs
|
||||||
- `ChessCubing.App/Services/MatchStore.cs` : persistance navigateur
|
- `ChessCubing.App/Services/MatchStore.cs` : persistance navigateur
|
||||||
- `ChessCubing.App/Services/AppAuthenticationStateProvider.cs` : session locale cote client
|
- `ChessCubing.App/Services/AppAuthenticationStateProvider.cs` : session locale cote client
|
||||||
- `ChessCubing.Server/` : backend d'authentification qui parle a Keycloak
|
- `ChessCubing.Server/` : backend d'authentification Keycloak et API utilisateur reliee a MySQL
|
||||||
- `ChessCubing.App/wwwroot/` : assets statiques, manifeste, PDFs, appli Ethan
|
- `ChessCubing.App/wwwroot/` : assets statiques, manifeste, PDFs, appli Ethan
|
||||||
- `keycloak/realm/chesscubing-realm.json` : realm importable avec client Keycloak et roles
|
- `keycloak/realm/chesscubing-realm.json` : realm importable avec client Keycloak et roles
|
||||||
- `keycloak/scripts/init-config.sh` : synchronisation automatique du client Keycloak au demarrage
|
- `keycloak/scripts/init-config.sh` : synchronisation automatique du client Keycloak au demarrage
|
||||||
- `docker-compose.yml` + `Dockerfile` + `Dockerfile.auth` : front Blazor, API d'auth et stack Keycloak/Postgres
|
- `docker-compose.yml` + `Dockerfile` + `Dockerfile.auth` : front Blazor, API d'auth, Keycloak/Postgres et MySQL pour les donnees du site
|
||||||
|
|
||||||
Le projet continue a exposer les routes historiques `index.html`, `application.html`, `chrono.html`, `cube.html` et `reglement.html`.
|
Le projet continue a exposer les routes historiques `index.html`, `application.html`, `chrono.html`, `cube.html` et `reglement.html`.
|
||||||
|
|
||||||
@@ -42,6 +42,7 @@ L'application embarque maintenant une authentification integree basee sur Keyclo
|
|||||||
- une session cookie locale est ensuite exposee au front via `/api/auth/session`
|
- une session cookie locale est ensuite exposee au front via `/api/auth/session`
|
||||||
- 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`
|
||||||
|
|
||||||
Le realm importe par defaut :
|
Le realm importe par defaut :
|
||||||
|
|
||||||
@@ -66,6 +67,7 @@ L'application est ensuite disponible sur `http://localhost:8080`.
|
|||||||
|
|
||||||
La console Keycloak est servie via le meme nginx sur `http://localhost:8080/auth/admin/`.
|
La console Keycloak est servie via le meme nginx sur `http://localhost:8080/auth/admin/`.
|
||||||
L'API d'authentification integree est servie derriere le meme point d'entree via `/api/auth/*`.
|
L'API d'authentification integree est servie derriere le meme point d'entree via `/api/auth/*`.
|
||||||
|
L'API utilisateur et sa persistance MySQL sont egalement servies via `/api/users/*`.
|
||||||
|
|
||||||
Identifiants d'administration par defaut pour le premier demarrage local :
|
Identifiants d'administration par defaut pour le premier demarrage local :
|
||||||
|
|
||||||
@@ -73,6 +75,7 @@ Identifiants d'administration par defaut pour le premier demarrage local :
|
|||||||
- mot de passe : `admin`
|
- mot de passe : `admin`
|
||||||
|
|
||||||
Ces valeurs peuvent etre surchargees via les variables d'environnement de `.env.example`.
|
Ces valeurs peuvent etre surchargees via les variables d'environnement de `.env.example`.
|
||||||
|
La base MySQL du site utilise les variables `SITE_DB_*` du meme fichier.
|
||||||
|
|
||||||
Au demarrage, le service `keycloak-init` resynchronise automatiquement le realm courant pour garder l'inscription active et autoriser le flux de connexion integre, meme si la base Keycloak existe deja.
|
Au demarrage, le service `keycloak-init` resynchronise automatiquement le realm courant pour garder l'inscription active et autoriser le flux de connexion integre, meme si la base Keycloak existe deja.
|
||||||
|
|
||||||
@@ -134,13 +137,15 @@ bash -c "$(curl -fsSL https://git.jeannerot.fr/christophe/chesscubing/raw/branch
|
|||||||
## Fichiers cles
|
## Fichiers cles
|
||||||
|
|
||||||
- `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/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
|
||||||
- `ChessCubing.App/Pages/RulesPage.razor` : synthese du reglement
|
- `ChessCubing.App/Pages/RulesPage.razor` : synthese du reglement
|
||||||
- `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/*`
|
- `ChessCubing.Server/Program.cs` : endpoints `/api/auth/*` et `/api/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/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
|
||||||
- `docker-compose.yml` + `Dockerfile` + `Dockerfile.auth` : execution locale
|
- `docker-compose.yml` + `Dockerfile` + `Dockerfile.auth` : execution locale
|
||||||
|
|||||||
@@ -25,11 +25,18 @@ services:
|
|||||||
KEYCLOAK_CLIENT_ID: chesscubing-web
|
KEYCLOAK_CLIENT_ID: chesscubing-web
|
||||||
KEYCLOAK_ADMIN_USERNAME: ${KEYCLOAK_ADMIN_USER:-admin}
|
KEYCLOAK_ADMIN_USERNAME: ${KEYCLOAK_ADMIN_USER:-admin}
|
||||||
KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin}
|
KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin}
|
||||||
|
SITE_DB_HOST: mysql
|
||||||
|
SITE_DB_PORT: 3306
|
||||||
|
SITE_DB_NAME: ${SITE_DB_NAME:-chesscubing_site}
|
||||||
|
SITE_DB_USER: ${SITE_DB_USER:-chesscubing}
|
||||||
|
SITE_DB_PASSWORD: ${SITE_DB_PASSWORD:-chesscubing}
|
||||||
depends_on:
|
depends_on:
|
||||||
keycloak-init:
|
keycloak-init:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
keycloak:
|
keycloak:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
mysql:
|
||||||
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
keycloak:
|
keycloak:
|
||||||
@@ -89,5 +96,23 @@ services:
|
|||||||
retries: 5
|
retries: 5
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.4
|
||||||
|
container_name: chesscubing-site-db
|
||||||
|
environment:
|
||||||
|
MYSQL_DATABASE: ${SITE_DB_NAME:-chesscubing_site}
|
||||||
|
MYSQL_USER: ${SITE_DB_USER:-chesscubing}
|
||||||
|
MYSQL_PASSWORD: ${SITE_DB_PASSWORD:-chesscubing}
|
||||||
|
MYSQL_ROOT_PASSWORD: ${SITE_DB_ROOT_PASSWORD:-change-me}
|
||||||
|
volumes:
|
||||||
|
- site-mysql-data:/var/lib/mysql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u$${MYSQL_USER} -p$${MYSQL_PASSWORD} --silent"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 8
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
keycloak-postgres:
|
keycloak-postgres:
|
||||||
|
site-mysql-data:
|
||||||
|
|||||||
73
styles.css
73
styles.css
@@ -422,6 +422,7 @@ fieldset {
|
|||||||
}
|
}
|
||||||
|
|
||||||
input:not([type="radio"]):not([type="checkbox"]),
|
input:not([type="radio"]):not([type="checkbox"]),
|
||||||
|
select,
|
||||||
textarea {
|
textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.95rem 1rem;
|
padding: 0.95rem 1rem;
|
||||||
@@ -439,6 +440,7 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
input:not([type="radio"]):not([type="checkbox"]):focus,
|
input:not([type="radio"]):not([type="checkbox"]):focus,
|
||||||
|
select:focus,
|
||||||
textarea:focus {
|
textarea:focus {
|
||||||
outline: 2px solid rgba(52, 141, 255, 0.45);
|
outline: 2px solid rgba(52, 141, 255, 0.45);
|
||||||
outline-offset: 1px;
|
outline-offset: 1px;
|
||||||
@@ -1476,6 +1478,72 @@ body.site-menu-hidden .site-menu-shell {
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-hero {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-meta-grid,
|
||||||
|
.profile-form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-meta-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 22px;
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
background: var(--panel-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-meta-card strong {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-highlights {
|
||||||
|
margin-bottom: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-callout {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-callout p {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-actions .section-copy {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-feedback {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-feedback.success {
|
||||||
|
border: 1px solid rgba(69, 185, 127, 0.3);
|
||||||
|
background: rgba(69, 185, 127, 0.12);
|
||||||
|
color: #dff7ea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-feedback.error {
|
||||||
|
border: 1px solid rgba(255, 100, 127, 0.28);
|
||||||
|
background: rgba(255, 100, 127, 0.12);
|
||||||
|
color: #ffd8de;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.hero,
|
.hero,
|
||||||
.setup-grid,
|
.setup-grid,
|
||||||
@@ -1498,6 +1566,11 @@ body.site-menu-hidden .site-menu-shell {
|
|||||||
grid-column: span 12;
|
grid-column: span 12;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-meta-grid,
|
||||||
|
.profile-form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.phase-header {
|
.phase-header {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user