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_ADMIN_USER=admin
|
||||
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(ApplicationPaths)" href="application.html" aria-current="@BuildAriaCurrent(ApplicationPaths)">Application</a>
|
||||
<a class="@BuildNavLinkClass(RulesPaths)" href="reglement.html" aria-current="@BuildAriaCurrent(RulesPaths)">Reglement</a>
|
||||
<a class="@BuildNavLinkClass(UserPaths)" href="utilisateur.html" aria-current="@BuildAriaCurrent(UserPaths)">Utilisateur</a>
|
||||
</nav>
|
||||
|
||||
<div class="site-menu-account">
|
||||
@@ -154,6 +155,7 @@
|
||||
private static readonly string[] HomePaths = ["", "index.html"];
|
||||
private static readonly string[] ApplicationPaths = ["application", "application.html"];
|
||||
private static readonly string[] RulesPaths = ["reglement", "reglement.html"];
|
||||
private static readonly string[] UserPaths = ["utilisateur", "utilisateur.html"];
|
||||
|
||||
private readonly LoginFormModel LoginModel = 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>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MySqlConnector" Version="2.4.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</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 ChessCubing.Server.Auth;
|
||||
using ChessCubing.Server.Data;
|
||||
using ChessCubing.Server.Users;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
|
||||
@@ -17,6 +19,20 @@ builder.Services.AddOptions<KeycloakAuthOptions>()
|
||||
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
|
||||
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.AddCookie(options =>
|
||||
@@ -44,9 +60,16 @@ builder.Services
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddHttpClient<KeycloakAuthService>();
|
||||
builder.Services.AddSingleton<MySqlUserProfileStore>();
|
||||
|
||||
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.UseAuthorization();
|
||||
|
||||
@@ -55,6 +78,44 @@ app.MapGet("/api/health", () => TypedResults.Ok(new { status = "ok" }));
|
||||
app.MapGet("/api/auth/session", (ClaimsPrincipal 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> (
|
||||
LoginRequest request,
|
||||
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/MatchStore.cs` : persistance navigateur
|
||||
- `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
|
||||
- `keycloak/realm/chesscubing-realm.json` : realm importable avec client Keycloak et roles
|
||||
- `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`.
|
||||
|
||||
@@ -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`
|
||||
- les roles Keycloak du realm restent exposes dans l'application
|
||||
- l'etat du match est isole par utilisateur dans le navigateur grace a une cle de stockage derivee du compte connecte
|
||||
- une page `utilisateur` permet maintenant d'editer un profil du site persiste en base MySQL via `/api/users/me`
|
||||
|
||||
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/`.
|
||||
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 :
|
||||
|
||||
@@ -73,6 +75,7 @@ Identifiants d'administration par defaut pour le premier demarrage local :
|
||||
- mot de passe : `admin`
|
||||
|
||||
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.
|
||||
|
||||
@@ -134,13 +137,15 @@ bash -c "$(curl -fsSL https://git.jeannerot.fr/christophe/chesscubing/raw/branch
|
||||
## Fichiers cles
|
||||
|
||||
- `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/ChronoPage.razor` : phase chrono
|
||||
- `ChessCubing.App/Pages/CubePage.razor` : phase cube
|
||||
- `ChessCubing.App/Pages/RulesPage.razor` : synthese du reglement
|
||||
- `ChessCubing.App/Services/MatchEngine.cs` : regles de jeu et transitions
|
||||
- `ChessCubing.App/Services/AppAuthenticationStateProvider.cs` : etat de session cote client
|
||||
- `ChessCubing.Server/Program.cs` : endpoints `/api/auth/*`
|
||||
- `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/scripts/init-config.sh` : mise en conformite du client Keycloak au demarrage
|
||||
- `docker-compose.yml` + `Dockerfile` + `Dockerfile.auth` : execution locale
|
||||
|
||||
@@ -25,11 +25,18 @@ services:
|
||||
KEYCLOAK_CLIENT_ID: chesscubing-web
|
||||
KEYCLOAK_ADMIN_USERNAME: ${KEYCLOAK_ADMIN_USER:-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:
|
||||
keycloak-init:
|
||||
condition: service_completed_successfully
|
||||
keycloak:
|
||||
condition: service_started
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
keycloak:
|
||||
@@ -89,5 +96,23 @@ services:
|
||||
retries: 5
|
||||
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:
|
||||
keycloak-postgres:
|
||||
site-mysql-data:
|
||||
|
||||
73
styles.css
73
styles.css
@@ -422,6 +422,7 @@ fieldset {
|
||||
}
|
||||
|
||||
input:not([type="radio"]):not([type="checkbox"]),
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 0.95rem 1rem;
|
||||
@@ -439,6 +440,7 @@ textarea {
|
||||
}
|
||||
|
||||
input:not([type="radio"]):not([type="checkbox"]):focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: 2px solid rgba(52, 141, 255, 0.45);
|
||||
outline-offset: 1px;
|
||||
@@ -1476,6 +1478,72 @@ body.site-menu-hidden .site-menu-shell {
|
||||
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) {
|
||||
.hero,
|
||||
.setup-grid,
|
||||
@@ -1498,6 +1566,11 @@ body.site-menu-hidden .site-menu-shell {
|
||||
grid-column: span 12;
|
||||
}
|
||||
|
||||
.profile-meta-grid,
|
||||
.profile-form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.phase-header {
|
||||
grid-template-columns: 1fr;
|
||||
text-align: center;
|
||||
|
||||
Reference in New Issue
Block a user