Ajoute une page utilisateur et une persistance MySQL

This commit is contained in:
2026-04-14 20:03:26 +02:00
parent d0f9c76b26
commit 5cf46dce31
14 changed files with 1106 additions and 3 deletions

View File

@@ -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

View File

@@ -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();

View 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; }
}

View 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; }
}

View 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;
}
}
}

View File

@@ -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>

View 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;
}

View File

@@ -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,

View 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());
}
}

View 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);
}

View 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);

View File

@@ -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

View File

@@ -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:

View File

@@ -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;