Files

569 lines
21 KiB
Plaintext

@using System.ComponentModel.DataAnnotations
@using System.Net
@using System.Net.Http.Json
@using System.Security.Claims
@using ChessCubing.App.Models.Auth
@using Microsoft.AspNetCore.Components.Authorization
@implements IDisposable
@inject AppAuthenticationStateProvider AuthenticationStateProvider
@inject BrowserBridge Browser
@inject HttpClient Http
@inject NavigationManager Navigation
<div class="site-menu-shell">
<header class="site-menu-bar">
<div class="site-menu-main @(IsMobileMenuOpen ? "is-mobile-open" : string.Empty)">
<a class="site-menu-brand" href="index.html" aria-label="Accueil ChessCubing">
<img class="site-menu-brand-icon" src="logo.png" alt="Icone ChessCubing" />
<span class="site-menu-brand-copy">
<span class="micro-label">ChessCubing Arena</span>
<strong>ChessCubing</strong>
</span>
</a>
<div class="site-menu-desktop">
<nav class="site-menu-links" aria-label="Navigation principale">
<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>
@if (IsAdmin)
{
<a class="@BuildNavLinkClass(AdminPaths)" href="administration.html" aria-current="@BuildAriaCurrent(AdminPaths)">Administration</a>
}
</nav>
<div class="site-menu-account">
<span class="micro-label">Compte joueur</span>
@if (IsAuthenticated)
{
<div class="site-menu-account-panel">
<div class="site-menu-user">
<strong>@DisplayName</strong>
<span>@DisplayMeta</span>
</div>
<a class="button ghost small" href="api/auth/logout/browser">Se deconnecter</a>
</div>
}
else
{
<div class="site-menu-account-actions">
<button class="button secondary small" type="button" @onclick="OpenLoginModal">Se connecter</button>
<button class="button ghost small" type="button" @onclick="OpenRegisterModal">Creer un compte</button>
</div>
}
</div>
</div>
<button class="site-menu-mobile-toggle"
type="button"
aria-label="@BuildMobileMenuToggleLabel()"
aria-controls="site-mobile-menu-panel"
aria-expanded="@BoolString(IsMobileMenuOpen)"
@onclick="ToggleMobileMenu">
<span class="site-menu-mobile-toggle-icon" aria-hidden="true">
<span></span>
<span></span>
<span></span>
</span>
</button>
<div class="site-menu-mobile-panel" id="site-mobile-menu-panel">
<nav class="site-menu-mobile-links" aria-label="Navigation principale">
<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>
@if (IsAdmin)
{
<a class="@BuildNavLinkClass(AdminPaths)" href="administration.html" aria-current="@BuildAriaCurrent(AdminPaths)">Administration</a>
}
</nav>
<div class="site-menu-mobile-account">
<span class="micro-label">Compte joueur</span>
@if (IsAuthenticated)
{
<div class="site-menu-mobile-account-panel">
<div class="site-menu-mobile-user">
<strong>@DisplayName</strong>
<span>@DisplayMeta</span>
</div>
<a class="button ghost small" href="api/auth/logout/browser">Se deconnecter</a>
</div>
}
else
{
<div class="site-menu-mobile-account-actions">
<button class="button secondary small" type="button" @onclick="OpenLoginModal">Se connecter</button>
<button class="button ghost small" type="button" @onclick="OpenRegisterModal">Creer un compte</button>
</div>
}
</div>
</div>
</div>
</header>
</div>
<section class="modal @(ShowAuthModal ? string.Empty : "hidden")" aria-hidden="@BoolString(!ShowAuthModal)">
<div class="modal-backdrop" @onclick="CloseAuthModal"></div>
<div class="modal-card auth-modal-card">
<div class="modal-head">
<div>
<p class="eyebrow">Compte joueur</p>
<h2>@AuthModalTitle</h2>
</div>
<button class="button ghost small" type="button" @onclick="CloseAuthModal" disabled="@IsSubmitting">Fermer</button>
</div>
<p class="auth-modal-copy">
L'authentification se fait maintenant directement dans l'application, sans redirection vers une page externe.
</p>
<p class="auth-modal-copy">
La connexion reste memorisee sur ce navigateur pendant 30 jours.
</p>
<div class="auth-modal-switch">
<button class="@BuildModeButtonClass(AuthMode.Login)" type="button" @onclick="SwitchToLogin" disabled="@IsSubmitting">Se connecter</button>
<button class="@BuildModeButtonClass(AuthMode.Register)" type="button" @onclick="SwitchToRegister" disabled="@IsSubmitting">Creer un compte</button>
</div>
@if (!string.IsNullOrWhiteSpace(FormError))
{
<p class="auth-form-error">@FormError</p>
}
@if (Mode == AuthMode.Login)
{
<EditForm Model="@LoginModel" OnValidSubmit="SubmitLoginAsync">
<DataAnnotationsValidator />
<div class="auth-form-grid">
<label class="field">
<span>Identifiant ou email</span>
<InputText @bind-Value="LoginModel.Username" autocomplete="username email" />
<ValidationMessage For="@(() => LoginModel.Username)" />
</label>
<label class="field">
<span>Mot de passe</span>
<InputText @bind-Value="LoginModel.Password" type="password" autocomplete="current-password" />
<ValidationMessage For="@(() => LoginModel.Password)" />
</label>
</div>
<div class="modal-actions">
<button class="button secondary" type="submit" disabled="@IsSubmitting">
@(IsSubmitting ? "Connexion..." : "Se connecter")
</button>
</div>
</EditForm>
}
else
{
<EditForm Model="@RegisterModel" OnValidSubmit="SubmitRegisterAsync">
<DataAnnotationsValidator />
<div class="auth-form-grid two-columns">
<label class="field">
<span>Prenom</span>
<InputText @bind-Value="RegisterModel.FirstName" autocomplete="given-name" />
</label>
<label class="field">
<span>Nom</span>
<InputText @bind-Value="RegisterModel.LastName" autocomplete="family-name" />
</label>
<label class="field">
<span>Nom d'utilisateur</span>
<InputText @bind-Value="RegisterModel.Username" autocomplete="username" />
<ValidationMessage For="@(() => RegisterModel.Username)" />
</label>
<label class="field">
<span>Email</span>
<InputText @bind-Value="RegisterModel.Email" autocomplete="email" />
<ValidationMessage For="@(() => RegisterModel.Email)" />
</label>
<label class="field">
<span>Mot de passe</span>
<InputText @bind-Value="RegisterModel.Password" type="password" autocomplete="new-password" />
<ValidationMessage For="@(() => RegisterModel.Password)" />
</label>
<label class="field">
<span>Confirmation du mot de passe</span>
<InputText @bind-Value="RegisterModel.ConfirmPassword" type="password" autocomplete="new-password" />
<ValidationMessage For="@(() => RegisterModel.ConfirmPassword)" />
</label>
</div>
<div class="modal-actions">
<button class="button secondary" type="submit" disabled="@IsSubmitting">
@(IsSubmitting ? "Creation..." : "Creer mon compte")
</button>
</div>
</EditForm>
}
</div>
</section>
@code {
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 static readonly string[] AdminPaths = ["administration", "administration.html"];
private readonly LoginFormModel LoginModel = new();
private readonly RegisterFormModel RegisterModel = new();
private bool IsAuthenticated;
private bool IsAdmin;
private bool ShowAuthModal;
private bool IsSubmitting;
private bool IsMobileMenuOpen;
private string? FormError;
private string AuthModalTitle = "Se connecter";
private AuthMode Mode = AuthMode.Login;
private string DisplayName = "Utilisateur connecte";
private string DisplayMeta = "Session active";
private bool _syncMenuAfterRender;
private string CurrentPath
{
get
{
var absolutePath = new Uri(Navigation.Uri).AbsolutePath;
return absolutePath.Trim('/');
}
}
protected override async Task OnInitializedAsync()
{
AuthenticationStateProvider.AuthenticationStateChanged += HandleAuthenticationStateChanged;
await RefreshAuthenticationStateAsync();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!_syncMenuAfterRender)
{
return;
}
_syncMenuAfterRender = false;
await Browser.SyncMenuAsync();
}
private string BuildNavLinkClass(string[] paths)
=> IsCurrentPage(paths) ? "site-menu-link is-active" : "site-menu-link";
private string? BuildAriaCurrent(string[] paths)
=> IsCurrentPage(paths) ? "page" : null;
private bool IsCurrentPage(string[] paths)
=> paths.Any(path => string.Equals(CurrentPath, path, StringComparison.OrdinalIgnoreCase));
private void OpenLoginModal()
{
CloseMobileMenu();
ShowAuthModal = true;
SwitchToLogin();
RequestMenuSync();
}
private void OpenRegisterModal()
{
CloseMobileMenu();
ShowAuthModal = true;
SwitchToRegister();
RequestMenuSync();
}
private void CloseAuthModal()
{
if (IsSubmitting)
{
return;
}
ShowAuthModal = false;
FormError = null;
RequestMenuSync();
}
private void SwitchToLogin()
{
Mode = AuthMode.Login;
AuthModalTitle = "Se connecter";
FormError = null;
}
private void SwitchToRegister()
{
Mode = AuthMode.Register;
AuthModalTitle = "Creer un compte";
FormError = null;
}
private string BuildModeButtonClass(AuthMode mode)
=> mode == Mode ? "button secondary small" : "button ghost small";
private async Task SubmitLoginAsync()
{
await SubmitAsync(
"api/auth/login",
new LoginRequest
{
Username = LoginModel.Username.Trim(),
Password = LoginModel.Password,
},
"Connexion impossible.");
}
private async Task SubmitRegisterAsync()
{
await SubmitAsync(
"api/auth/register",
new RegisterRequest
{
Username = RegisterModel.Username.Trim(),
Email = RegisterModel.Email.Trim(),
Password = RegisterModel.Password,
ConfirmPassword = RegisterModel.ConfirmPassword,
FirstName = RegisterModel.FirstName?.Trim(),
LastName = RegisterModel.LastName?.Trim(),
},
"Creation du compte impossible.");
}
private async Task SubmitAsync<TRequest>(string endpoint, TRequest payload, string fallbackMessage)
{
if (IsSubmitting)
{
return;
}
IsSubmitting = true;
FormError = null;
try
{
if (!await EnsureAuthServiceReadyAsync())
{
FormError = "Le service d'authentification demarre encore. Reessaie dans quelques secondes.";
return;
}
var response = await Http.PostAsJsonAsync(endpoint, payload);
if (!response.IsSuccessStatusCode)
{
FormError = await ReadErrorAsync(response, fallbackMessage);
return;
}
var session = await response.Content.ReadFromJsonAsync<AuthSessionResponse>();
if (session is not null && session.IsAuthenticated)
{
AuthenticationStateProvider.SetAuthenticated(session);
}
else
{
await AuthenticationStateProvider.RefreshAsync();
}
ShowAuthModal = false;
FormError = null;
ResetForms();
CloseMobileMenu();
await RefreshAuthenticationStateAsync();
RequestMenuSync();
}
catch (HttpRequestException)
{
FormError = "Le service d'authentification est temporairement indisponible. Reessaie dans quelques secondes.";
}
catch (TaskCanceledException)
{
FormError = "La reponse du service d'authentification a pris trop de temps. Reessaie dans quelques secondes.";
}
catch
{
FormError = fallbackMessage;
}
finally
{
IsSubmitting = false;
}
}
private void HandleAuthenticationStateChanged(Task<AuthenticationState> authenticationStateTask)
=> _ = InvokeAsync(RefreshAuthenticationStateAsync);
private void ToggleMobileMenu()
{
IsMobileMenuOpen = !IsMobileMenuOpen;
RequestMenuSync();
}
private void CloseMobileMenu()
{
if (!IsMobileMenuOpen)
{
return;
}
IsMobileMenuOpen = false;
RequestMenuSync();
}
private string BuildMobileMenuToggleLabel()
=> IsMobileMenuOpen ? "Fermer le menu mobile" : "Ouvrir le menu mobile";
private void RequestMenuSync()
=> _syncMenuAfterRender = true;
private async Task RefreshAuthenticationStateAsync()
{
try
{
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
var user = authState.User;
if (user.Identity?.IsAuthenticated == true)
{
IsAuthenticated = true;
IsAdmin = user.IsInRole("admin");
DisplayName = BuildDisplayName(user);
DisplayMeta = BuildMeta(user);
}
else
{
ResetAuthenticationDisplay();
}
}
catch
{
ResetAuthenticationDisplay();
}
StateHasChanged();
}
private void ResetAuthenticationDisplay()
{
IsAuthenticated = false;
IsAdmin = false;
DisplayName = "Utilisateur connecte";
DisplayMeta = "Session active";
}
private void ResetForms()
{
LoginModel.Username = string.Empty;
LoginModel.Password = string.Empty;
RegisterModel.FirstName = string.Empty;
RegisterModel.LastName = string.Empty;
RegisterModel.Username = string.Empty;
RegisterModel.Email = string.Empty;
RegisterModel.Password = string.Empty;
RegisterModel.ConfirmPassword = string.Empty;
}
private static string BuildDisplayName(ClaimsPrincipal user)
=> user.FindFirst("name")?.Value
?? user.FindFirst("preferred_username")?.Value
?? user.Identity?.Name
?? "Utilisateur connecte";
private static string BuildMeta(ClaimsPrincipal user)
=> user.FindFirst("email")?.Value
?? "Session active";
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.BadGateway or HttpStatusCode.ServiceUnavailable or HttpStatusCode.GatewayTimeout
=> "Le service d'authentification demarre encore. Reessaie dans quelques secondes.",
HttpStatusCode.Conflict
=> "Ce nom d'utilisateur ou cet email existe deja.",
_ => fallbackMessage,
};
}
private async Task<bool> EnsureAuthServiceReadyAsync()
{
try
{
using var response = await Http.GetAsync("api/health");
return response.IsSuccessStatusCode;
}
catch
{
return false;
}
}
private static string BoolString(bool value)
=> value ? "true" : "false";
public void Dispose()
=> AuthenticationStateProvider.AuthenticationStateChanged -= HandleAuthenticationStateChanged;
private enum AuthMode
{
Login,
Register,
}
private sealed class ApiErrorMessage
{
public string? Message { get; set; }
}
private sealed class LoginFormModel
{
[Required(ErrorMessage = "L'identifiant est obligatoire.")]
public string Username { get; set; } = string.Empty;
[Required(ErrorMessage = "Le mot de passe est obligatoire.")]
public string Password { get; set; } = string.Empty;
}
private sealed class RegisterFormModel
{
public string? FirstName { get; set; }
public string? LastName { get; set; }
[Required(ErrorMessage = "Le nom d'utilisateur est obligatoire.")]
public string Username { get; set; } = string.Empty;
[Required(ErrorMessage = "L'email est obligatoire.")]
[EmailAddress(ErrorMessage = "L'email n'est pas valide.")]
public string Email { get; set; } = string.Empty;
[Required(ErrorMessage = "Le mot de passe est obligatoire.")]
[MinLength(8, ErrorMessage = "Le mot de passe doit contenir au moins 8 caracteres.")]
public string Password { get; set; } = string.Empty;
[Required(ErrorMessage = "La confirmation est obligatoire.")]
[Compare(nameof(Password), ErrorMessage = "Les mots de passe ne correspondent pas.")]
public string ConfirmPassword { get; set; } = string.Empty;
}
}