1214 lines
46 KiB
Plaintext
1214 lines
46 KiB
Plaintext
@page "/administration"
|
|
@page "/administration.html"
|
|
@using System.ComponentModel.DataAnnotations
|
|
@using System.Net
|
|
@using System.Net.Http.Json
|
|
@using ChessCubing.App.Models.Users
|
|
@implements IDisposable
|
|
@inject AuthenticationStateProvider AuthenticationStateProvider
|
|
@inject HttpClient Http
|
|
|
|
<PageTitle>ChessCubing Arena | Administration</PageTitle>
|
|
<PageBody BodyClass="home-body" />
|
|
|
|
<div class="ambient ambient-left"></div>
|
|
<div class="ambient ambient-right"></div>
|
|
|
|
<div class="rules-shell">
|
|
<header class="hero hero-home user-hero">
|
|
<div class="hero-copy">
|
|
<p class="eyebrow">Administration</p>
|
|
<h1>Gerer les utilisateurs avec une vue tableau</h1>
|
|
<p class="lead">
|
|
Cette zone rassemble les comptes Keycloak et les profils du site
|
|
dans une interface plus classique, avec ajout, edition et suppression.
|
|
</p>
|
|
<div class="hero-actions">
|
|
<a class="button secondary" href="utilisateur.html">Voir l'espace utilisateur</a>
|
|
<a class="button ghost" href="index.html">Retour au site</a>
|
|
</div>
|
|
</div>
|
|
|
|
<aside class="hero-preview">
|
|
<div class="preview-card">
|
|
<p class="micro-label">Vue d'ensemble</p>
|
|
<div class="home-mini-grid two-columns admin-hero-stats">
|
|
<article class="mini-panel">
|
|
<span class="micro-label">Comptes</span>
|
|
<strong>@Users.Count</strong>
|
|
</article>
|
|
<article class="mini-panel">
|
|
<span class="micro-label">Profils site</span>
|
|
<strong>@Users.Count(user => user.HasSiteProfile)</strong>
|
|
</article>
|
|
<article class="mini-panel">
|
|
<span class="micro-label">Actifs</span>
|
|
<strong>@Users.Count(user => user.IsEnabled)</strong>
|
|
</article>
|
|
<article class="mini-panel">
|
|
<span class="micro-label">Emails verifies</span>
|
|
<strong>@Users.Count(user => user.IsEmailVerified)</strong>
|
|
</article>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="preview-banner">
|
|
<span class="mini-chip">Etat d'acces</span>
|
|
<strong>@HeroStatusTitle</strong>
|
|
<p>@HeroStatusDescription</p>
|
|
</div>
|
|
</aside>
|
|
</header>
|
|
|
|
<main class="rules-grid">
|
|
@if (!IsAuthenticated)
|
|
{
|
|
<section class="panel panel-wide cta-panel">
|
|
<div class="section-heading">
|
|
<div>
|
|
<p class="eyebrow">Connexion requise</p>
|
|
<h2>Connecte-toi avec un compte administrateur</h2>
|
|
</div>
|
|
<p class="section-copy">
|
|
La gestion des utilisateurs devient disponible des qu'un compte
|
|
portant le role `admin` est connecte.
|
|
</p>
|
|
</div>
|
|
</section>
|
|
}
|
|
else if (!IsAdmin)
|
|
{
|
|
<section class="panel panel-wide">
|
|
<div class="section-heading">
|
|
<div>
|
|
<p class="eyebrow">Acces refuse</p>
|
|
<h2>Ce compte n'a pas les droits d'administration</h2>
|
|
</div>
|
|
<p class="section-copy">
|
|
La gestion des utilisateurs est reservee aux comptes portant le role `admin`.
|
|
</p>
|
|
</div>
|
|
</section>
|
|
}
|
|
else if (IsLoadingUsers)
|
|
{
|
|
<section class="panel panel-wide">
|
|
<p class="eyebrow">Chargement</p>
|
|
<h2>Recuperation de la liste des utilisateurs</h2>
|
|
<p class="section-copy">Le site rassemble les comptes Keycloak et les profils du site.</p>
|
|
</section>
|
|
}
|
|
else if (!string.IsNullOrWhiteSpace(LoadError))
|
|
{
|
|
<section class="panel panel-wide">
|
|
<p class="eyebrow">Serveur</p>
|
|
<h2>Impossible de charger l'administration</h2>
|
|
<p class="profile-feedback error">@LoadError</p>
|
|
<div class="hero-actions">
|
|
<button class="button secondary" type="button" @onclick="ReloadUsersAsync">Reessayer</button>
|
|
</div>
|
|
</section>
|
|
}
|
|
else
|
|
{
|
|
<section class="panel panel-wide admin-table-panel">
|
|
<div class="section-heading">
|
|
<div>
|
|
<p class="eyebrow">Utilisateurs</p>
|
|
<h2>Liste des comptes</h2>
|
|
</div>
|
|
<p class="section-copy">
|
|
Le tableau centralise les comptes du site avec une colonne d'actions
|
|
pour les operations courantes.
|
|
</p>
|
|
</div>
|
|
|
|
@if (!string.IsNullOrWhiteSpace(SaveError))
|
|
{
|
|
<p class="profile-feedback error">@SaveError</p>
|
|
}
|
|
|
|
@if (!string.IsNullOrWhiteSpace(SaveMessage))
|
|
{
|
|
<p class="profile-feedback success">@SaveMessage</p>
|
|
}
|
|
|
|
<div class="admin-toolbar admin-toolbar-grid">
|
|
<label class="field admin-search-field">
|
|
<span>Recherche</span>
|
|
<input @bind="SearchTerm" @bind:event="oninput" placeholder="Nom, email, club, ville..." />
|
|
</label>
|
|
|
|
<div class="admin-toolbar-actions">
|
|
<button class="button secondary small with-icon" type="button" @onclick="OpenCreateModal">
|
|
<span class="material-icons action-icon" aria-hidden="true">person_add</span>
|
|
<span>Ajouter un utilisateur</span>
|
|
</button>
|
|
<button class="button ghost small with-icon" type="button" @onclick="ReloadUsersAsync" disabled="@IsLoadingUsers">
|
|
<span class="material-icons action-icon" aria-hidden="true">refresh</span>
|
|
<span>Actualiser</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
@if (FilteredUsers.Count == 0)
|
|
{
|
|
<div class="callout">
|
|
<span class="micro-label">Recherche</span>
|
|
<p>Aucun utilisateur ne correspond au filtre en cours.</p>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="admin-table-shell">
|
|
<table class="admin-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Utilisateur</th>
|
|
<th>Email</th>
|
|
<th>Nom affiche</th>
|
|
<th>Etat</th>
|
|
<th>Profil site</th>
|
|
<th>Derniere mise a jour</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var user in FilteredUsers)
|
|
{
|
|
<tr class="@BuildTableRowClass(user)">
|
|
<td>
|
|
<div class="admin-cell-stack">
|
|
<strong>@user.Username</strong>
|
|
<span>@user.IdentityDisplayName</span>
|
|
</div>
|
|
</td>
|
|
<td>@(user.Email ?? "Non renseigne")</td>
|
|
<td>@(user.SiteDisplayName ?? "A definir")</td>
|
|
<td>
|
|
<span class="mini-chip @(user.IsEnabled ? "admin-chip-success" : "admin-chip-danger")">
|
|
@(user.IsEnabled ? "Actif" : "Suspendu")
|
|
</span>
|
|
</td>
|
|
<td>
|
|
<span class="mini-chip @(user.HasSiteProfile ? "admin-chip-neutral" : "admin-chip-outline")">
|
|
@(user.HasSiteProfile ? "Cree" : "Absent")
|
|
</span>
|
|
</td>
|
|
<td>@BuildUserCardFootnote(user)</td>
|
|
<td>
|
|
<div class="admin-actions-cell">
|
|
<button class="button ghost small icon-button"
|
|
type="button"
|
|
title="Modifier"
|
|
aria-label="Modifier"
|
|
@onclick="@(() => SelectUserAsync(user.Subject))">
|
|
<span class="material-icons action-icon" aria-hidden="true">edit</span>
|
|
</button>
|
|
<button class="button ghost danger small icon-button"
|
|
type="button"
|
|
title="Supprimer"
|
|
aria-label="Supprimer"
|
|
@onclick="@(() => PromptDeleteUser(user))">
|
|
<span class="material-icons action-icon" aria-hidden="true">delete</span>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
}
|
|
</section>
|
|
|
|
}
|
|
</main>
|
|
</div>
|
|
|
|
<section class="modal @(ShowEditModal ? string.Empty : "hidden")" aria-hidden="@BoolString(!ShowEditModal)">
|
|
<div class="modal-backdrop" @onclick="CloseEditModal"></div>
|
|
<div class="modal-card admin-modal-card admin-edit-modal-card">
|
|
<div class="modal-head admin-edit-modal-head">
|
|
<div class="admin-edit-heading">
|
|
<p class="eyebrow">Edition</p>
|
|
<h2>@BuildEditModalTitle()</h2>
|
|
|
|
@if (SelectedUser is not null)
|
|
{
|
|
<div class="admin-edit-chips">
|
|
<span class="mini-chip admin-chip-outline">@SelectedUser.Username</span>
|
|
|
|
@if (!string.IsNullOrWhiteSpace(SelectedUser.Email))
|
|
{
|
|
<span class="mini-chip admin-chip-neutral">@SelectedUser.Email</span>
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
<div class="admin-modal-head-actions">
|
|
<button class="button ghost small" type="button" @onclick="CloseEditModal" disabled="@IsSaving">Fermer</button>
|
|
</div>
|
|
</div>
|
|
|
|
@if (IsLoadingDetail)
|
|
{
|
|
<p class="admin-modal-note">La fiche utilisateur est en cours de chargement.</p>
|
|
}
|
|
else if (!string.IsNullOrWhiteSpace(DetailError))
|
|
{
|
|
<p class="profile-feedback error">@DetailError</p>
|
|
<div class="modal-actions">
|
|
<button class="button secondary" type="button" @onclick="ReloadSelectedUserAsync">Recharger</button>
|
|
<button class="button ghost" type="button" @onclick="CloseEditModal">Fermer</button>
|
|
</div>
|
|
}
|
|
else if (SelectedUser is null)
|
|
{
|
|
<p class="admin-modal-note">Cette fiche n'est plus disponible pour le moment.</p>
|
|
}
|
|
else
|
|
{
|
|
@if (!string.IsNullOrWhiteSpace(SaveError))
|
|
{
|
|
<p class="profile-feedback error">@SaveError</p>
|
|
}
|
|
|
|
@if (!string.IsNullOrWhiteSpace(SaveMessage))
|
|
{
|
|
<p class="profile-feedback success">@SaveMessage</p>
|
|
}
|
|
|
|
<EditForm Model="@EditFormModel" OnValidSubmit="SaveUserAsync">
|
|
<DataAnnotationsValidator />
|
|
|
|
<div class="admin-edit-summary-grid">
|
|
<article class="admin-edit-meta-card">
|
|
<span class="micro-label">Compte cree</span>
|
|
<strong>@FormatDate(SelectedUser.AccountCreatedUtc)</strong>
|
|
</article>
|
|
<article class="admin-edit-meta-card">
|
|
<span class="micro-label">Profil cree</span>
|
|
<strong>@FormatDate(SelectedUser.SiteProfileCreatedUtc)</strong>
|
|
</article>
|
|
<article class="admin-edit-meta-card">
|
|
<span class="micro-label">Maj profil</span>
|
|
<strong>@FormatDate(SelectedUser.SiteProfileUpdatedUtc)</strong>
|
|
</article>
|
|
<label class="admin-edit-toggle-pill">
|
|
<InputCheckbox @bind-Value="EditFormModel.IsEnabled" />
|
|
<span>Compte actif</span>
|
|
</label>
|
|
<label class="admin-edit-toggle-pill">
|
|
<InputCheckbox @bind-Value="EditFormModel.IsEmailVerified" />
|
|
<span>Email verifie</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="admin-form-grid admin-edit-form-grid">
|
|
<label class="field">
|
|
<span>Nom d'utilisateur</span>
|
|
<InputText @bind-Value="EditFormModel.Username" />
|
|
<ValidationMessage For="@(() => EditFormModel.Username)" />
|
|
</label>
|
|
|
|
<label class="field admin-edit-span-3">
|
|
<span>Email</span>
|
|
<InputText @bind-Value="EditFormModel.Email" />
|
|
<ValidationMessage For="@(() => EditFormModel.Email)" />
|
|
</label>
|
|
|
|
<label class="field">
|
|
<span>Prenom</span>
|
|
<InputText @bind-Value="EditFormModel.FirstName" />
|
|
<ValidationMessage For="@(() => EditFormModel.FirstName)" />
|
|
</label>
|
|
|
|
<label class="field">
|
|
<span>Nom</span>
|
|
<InputText @bind-Value="EditFormModel.LastName" />
|
|
<ValidationMessage For="@(() => EditFormModel.LastName)" />
|
|
</label>
|
|
|
|
<label class="field admin-edit-span-2">
|
|
<span>Nom affiche sur le site</span>
|
|
<InputText @bind-Value="EditFormModel.DisplayName" />
|
|
<ValidationMessage For="@(() => EditFormModel.DisplayName)" />
|
|
</label>
|
|
|
|
<label class="field">
|
|
<span>Club</span>
|
|
<InputText @bind-Value="EditFormModel.Club" />
|
|
<ValidationMessage For="@(() => EditFormModel.Club)" />
|
|
</label>
|
|
|
|
<label class="field">
|
|
<span>Ville</span>
|
|
<InputText @bind-Value="EditFormModel.City" />
|
|
<ValidationMessage For="@(() => EditFormModel.City)" />
|
|
</label>
|
|
|
|
<label class="field">
|
|
<span>Format prefere</span>
|
|
<InputSelect @bind-Value="EditFormModel.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="@(() => EditFormModel.PreferredFormat)" />
|
|
</label>
|
|
|
|
<label class="field admin-edit-span-2">
|
|
<span>Cube favori</span>
|
|
<InputText @bind-Value="EditFormModel.FavoriteCube" />
|
|
<ValidationMessage For="@(() => EditFormModel.FavoriteCube)" />
|
|
</label>
|
|
|
|
<label class="field admin-edit-span-full">
|
|
<span>Bio</span>
|
|
<InputTextArea @bind-Value="EditFormModel.Bio" rows="2" />
|
|
<ValidationMessage For="@(() => EditFormModel.Bio)" />
|
|
</label>
|
|
</div>
|
|
|
|
<div class="profile-actions">
|
|
<button class="button secondary small" type="submit" disabled="@IsSaving">
|
|
@(IsSaving ? "Enregistrement..." : "Enregistrer")
|
|
</button>
|
|
<button class="button ghost small" type="button" @onclick="CloseEditModal" disabled="@IsSaving">Fermer</button>
|
|
</div>
|
|
</EditForm>
|
|
}
|
|
</div>
|
|
</section>
|
|
|
|
<section class="modal @(ShowCreateModal ? string.Empty : "hidden")" aria-hidden="@BoolString(!ShowCreateModal)">
|
|
<div class="modal-backdrop" @onclick="CloseCreateModal"></div>
|
|
<div class="modal-card admin-modal-card">
|
|
<div class="modal-head">
|
|
<div>
|
|
<p class="eyebrow">Ajout</p>
|
|
<h2>Ajouter un utilisateur</h2>
|
|
</div>
|
|
<button class="button ghost small" type="button" @onclick="CloseCreateModal" disabled="@IsCreating">Fermer</button>
|
|
</div>
|
|
|
|
<p class="section-copy">
|
|
La creation ajoute un compte dans Keycloak puis initialise le profil du site.
|
|
</p>
|
|
|
|
@if (!string.IsNullOrWhiteSpace(CreateError))
|
|
{
|
|
<p class="profile-feedback error">@CreateError</p>
|
|
}
|
|
|
|
<EditForm Model="@CreateFormModel" OnValidSubmit="CreateUserAsync">
|
|
<DataAnnotationsValidator />
|
|
|
|
<div class="admin-form-grid">
|
|
<label class="field">
|
|
<span>Nom d'utilisateur</span>
|
|
<InputText @bind-Value="CreateFormModel.Username" />
|
|
<ValidationMessage For="@(() => CreateFormModel.Username)" />
|
|
</label>
|
|
|
|
<label class="field">
|
|
<span>Email</span>
|
|
<InputText @bind-Value="CreateFormModel.Email" />
|
|
<ValidationMessage For="@(() => CreateFormModel.Email)" />
|
|
</label>
|
|
|
|
<label class="field">
|
|
<span>Prenom</span>
|
|
<InputText @bind-Value="CreateFormModel.FirstName" />
|
|
<ValidationMessage For="@(() => CreateFormModel.FirstName)" />
|
|
</label>
|
|
|
|
<label class="field">
|
|
<span>Nom</span>
|
|
<InputText @bind-Value="CreateFormModel.LastName" />
|
|
<ValidationMessage For="@(() => CreateFormModel.LastName)" />
|
|
</label>
|
|
|
|
<label class="field">
|
|
<span>Mot de passe</span>
|
|
<InputText @bind-Value="CreateFormModel.Password" type="password" />
|
|
<ValidationMessage For="@(() => CreateFormModel.Password)" />
|
|
</label>
|
|
|
|
<label class="field">
|
|
<span>Confirmation du mot de passe</span>
|
|
<InputText @bind-Value="CreateFormModel.ConfirmPassword" type="password" />
|
|
<ValidationMessage For="@(() => CreateFormModel.ConfirmPassword)" />
|
|
</label>
|
|
|
|
<label class="field">
|
|
<span>Nom affiche sur le site</span>
|
|
<InputText @bind-Value="CreateFormModel.DisplayName" />
|
|
<ValidationMessage For="@(() => CreateFormModel.DisplayName)" />
|
|
</label>
|
|
|
|
<label class="field">
|
|
<span>Club</span>
|
|
<InputText @bind-Value="CreateFormModel.Club" />
|
|
<ValidationMessage For="@(() => CreateFormModel.Club)" />
|
|
</label>
|
|
|
|
<label class="field">
|
|
<span>Ville</span>
|
|
<InputText @bind-Value="CreateFormModel.City" />
|
|
<ValidationMessage For="@(() => CreateFormModel.City)" />
|
|
</label>
|
|
|
|
<label class="field">
|
|
<span>Format prefere</span>
|
|
<InputSelect @bind-Value="CreateFormModel.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="@(() => CreateFormModel.PreferredFormat)" />
|
|
</label>
|
|
|
|
<label class="field span-2">
|
|
<span>Cube favori</span>
|
|
<InputText @bind-Value="CreateFormModel.FavoriteCube" />
|
|
<ValidationMessage For="@(() => CreateFormModel.FavoriteCube)" />
|
|
</label>
|
|
|
|
<label class="field span-2">
|
|
<span>Bio</span>
|
|
<InputTextArea @bind-Value="CreateFormModel.Bio" />
|
|
<ValidationMessage For="@(() => CreateFormModel.Bio)" />
|
|
</label>
|
|
</div>
|
|
|
|
<div class="admin-toggle-grid">
|
|
<label class="admin-toggle-card">
|
|
<InputCheckbox @bind-Value="CreateFormModel.IsEnabled" />
|
|
<div>
|
|
<strong>Compte actif</strong>
|
|
<span>Permettre au nouvel utilisateur de se connecter immediatement.</span>
|
|
</div>
|
|
</label>
|
|
|
|
<label class="admin-toggle-card">
|
|
<InputCheckbox @bind-Value="CreateFormModel.IsEmailVerified" />
|
|
<div>
|
|
<strong>Email verifie</strong>
|
|
<span>Marquer l'adresse comme deja verifiee dans Keycloak.</span>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="modal-actions">
|
|
<button class="button secondary" type="submit" disabled="@IsCreating">
|
|
@(IsCreating ? "Creation..." : "Creer l'utilisateur")
|
|
</button>
|
|
</div>
|
|
</EditForm>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="modal @(ShowDeleteModal ? string.Empty : "hidden")" aria-hidden="@BoolString(!ShowDeleteModal)">
|
|
<div class="modal-backdrop" @onclick="CloseDeleteModal"></div>
|
|
<div class="modal-card admin-confirm-card">
|
|
<div class="modal-head">
|
|
<div>
|
|
<p class="eyebrow">Suppression</p>
|
|
<h2>Supprimer cet utilisateur</h2>
|
|
</div>
|
|
<button class="button ghost small" type="button" @onclick="CloseDeleteModal" disabled="@IsDeleting">Fermer</button>
|
|
</div>
|
|
|
|
@if (PendingDeleteUser is not null)
|
|
{
|
|
<p>
|
|
Le compte <strong>@PendingDeleteUser.Username</strong> sera supprime de Keycloak
|
|
ainsi que son profil du site. Cette action est irreversible.
|
|
</p>
|
|
}
|
|
|
|
@if (!string.IsNullOrWhiteSpace(DeleteError))
|
|
{
|
|
<p class="profile-feedback error">@DeleteError</p>
|
|
}
|
|
|
|
<div class="modal-actions">
|
|
<button class="button danger" type="button" @onclick="DeleteUserAsync" disabled="@IsDeleting">
|
|
@(IsDeleting ? "Suppression..." : "Supprimer definitivement")
|
|
</button>
|
|
<button class="button ghost" type="button" @onclick="CloseDeleteModal" disabled="@IsDeleting">Annuler</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
@code {
|
|
private readonly AdminUserFormModel EditFormModel = new();
|
|
private readonly AdminCreateUserFormModel CreateFormModel = new();
|
|
private readonly List<AdminUserSummaryResponse> Users = [];
|
|
|
|
private AdminUserDetailResponse? SelectedUser;
|
|
private AdminUserSummaryResponse? PendingDeleteUser;
|
|
private string? SelectedSubject;
|
|
private string SearchTerm = string.Empty;
|
|
private bool IsAuthenticated;
|
|
private bool IsAdmin;
|
|
private bool IsLoadingUsers = true;
|
|
private bool IsLoadingDetail;
|
|
private bool IsSaving;
|
|
private bool IsCreating;
|
|
private bool IsDeleting;
|
|
private bool ShowEditModal;
|
|
private bool ShowCreateModal;
|
|
private bool ShowDeleteModal;
|
|
private string? LoadError;
|
|
private string? DetailError;
|
|
private string? SaveError;
|
|
private string? SaveMessage;
|
|
private string? CreateError;
|
|
private string? DeleteError;
|
|
|
|
private List<AdminUserSummaryResponse> FilteredUsers
|
|
=> Users
|
|
.Where(MatchesSearch)
|
|
.ToList();
|
|
|
|
private string HeroStatusTitle
|
|
=> !IsAuthenticated
|
|
? "Connexion requise"
|
|
: !IsAdmin
|
|
? "Compte non admin"
|
|
: IsLoadingUsers
|
|
? "Chargement des utilisateurs"
|
|
: $"{Users.Count} comptes disponibles";
|
|
|
|
private string HeroStatusDescription
|
|
=> !IsAuthenticated
|
|
? "Connecte-toi avec un compte admin pour ouvrir la zone d'administration."
|
|
: !IsAdmin
|
|
? "Le compte actuel est authentifie mais ne dispose pas du role admin."
|
|
: IsLoadingUsers
|
|
? "Le site recupere les comptes et les profils deja relies a MySQL."
|
|
: "Le tableau permet maintenant les operations classiques d'ajout, modification et suppression.";
|
|
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
AuthenticationStateProvider.AuthenticationStateChanged += HandleAuthenticationStateChanged;
|
|
await LoadUsersAsync();
|
|
}
|
|
|
|
private void HandleAuthenticationStateChanged(Task<AuthenticationState> authenticationStateTask)
|
|
=> _ = InvokeAsync(() => LoadUsersAsync(SelectedSubject));
|
|
|
|
private async Task ReloadUsersAsync()
|
|
=> await LoadUsersAsync(SelectedSubject);
|
|
|
|
private async Task LoadUsersAsync(string? preferredSubject = null, bool preserveFeedback = false)
|
|
{
|
|
LoadError = null;
|
|
DetailError = null;
|
|
|
|
if (!preserveFeedback)
|
|
{
|
|
SaveError = null;
|
|
SaveMessage = null;
|
|
}
|
|
|
|
IsLoadingUsers = true;
|
|
|
|
try
|
|
{
|
|
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
|
|
if (authState.User.Identity?.IsAuthenticated != true)
|
|
{
|
|
ResetAdminState();
|
|
return;
|
|
}
|
|
|
|
IsAuthenticated = true;
|
|
IsAdmin = authState.User.IsInRole("admin");
|
|
|
|
if (!IsAdmin)
|
|
{
|
|
Users.Clear();
|
|
SelectedSubject = null;
|
|
SelectedUser = null;
|
|
ShowEditModal = false;
|
|
EditFormModel.Reset();
|
|
return;
|
|
}
|
|
|
|
var response = await Http.GetAsync("api/admin/users");
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
LoadError = await ReadErrorAsync(response, "La liste des utilisateurs n'a pas pu etre chargee.");
|
|
Users.Clear();
|
|
SelectedSubject = null;
|
|
SelectedUser = null;
|
|
ShowEditModal = false;
|
|
EditFormModel.Reset();
|
|
return;
|
|
}
|
|
|
|
var users = await response.Content.ReadFromJsonAsync<List<AdminUserSummaryResponse>>();
|
|
Users.Clear();
|
|
Users.AddRange(users ?? []);
|
|
|
|
if (Users.Count == 0)
|
|
{
|
|
SelectedSubject = null;
|
|
SelectedUser = null;
|
|
ShowEditModal = false;
|
|
EditFormModel.Reset();
|
|
return;
|
|
}
|
|
|
|
var nextSubject = Users.Any(user => user.Subject == preferredSubject)
|
|
? preferredSubject
|
|
: Users.Any(user => user.Subject == SelectedSubject)
|
|
? SelectedSubject
|
|
: null;
|
|
|
|
if (!string.IsNullOrWhiteSpace(nextSubject))
|
|
{
|
|
SelectedSubject = nextSubject;
|
|
|
|
if (ShowEditModal)
|
|
{
|
|
await LoadUserDetailAsync(nextSubject, keepFeedback: true);
|
|
}
|
|
else
|
|
{
|
|
SelectedUser = null;
|
|
EditFormModel.Reset();
|
|
}
|
|
}
|
|
else
|
|
{
|
|
SelectedSubject = null;
|
|
SelectedUser = null;
|
|
EditFormModel.Reset();
|
|
}
|
|
}
|
|
catch (HttpRequestException)
|
|
{
|
|
LoadError = "Le service d'administration est temporairement indisponible.";
|
|
Users.Clear();
|
|
SelectedSubject = null;
|
|
SelectedUser = null;
|
|
ShowEditModal = false;
|
|
EditFormModel.Reset();
|
|
}
|
|
catch (TaskCanceledException)
|
|
{
|
|
LoadError = "Le chargement de l'administration a pris trop de temps.";
|
|
Users.Clear();
|
|
SelectedSubject = null;
|
|
SelectedUser = null;
|
|
ShowEditModal = false;
|
|
EditFormModel.Reset();
|
|
}
|
|
finally
|
|
{
|
|
IsLoadingUsers = false;
|
|
StateHasChanged();
|
|
}
|
|
}
|
|
|
|
private async Task SelectUserAsync(string subject)
|
|
{
|
|
SaveError = null;
|
|
SaveMessage = null;
|
|
ShowEditModal = true;
|
|
await LoadUserDetailAsync(subject, keepFeedback: false);
|
|
}
|
|
|
|
private async Task LoadUserDetailAsync(string subject, bool keepFeedback)
|
|
{
|
|
if (!keepFeedback)
|
|
{
|
|
SaveError = null;
|
|
SaveMessage = null;
|
|
}
|
|
|
|
IsLoadingDetail = true;
|
|
DetailError = null;
|
|
SelectedSubject = subject;
|
|
|
|
try
|
|
{
|
|
var response = await Http.GetAsync($"api/admin/users/{Uri.EscapeDataString(subject)}");
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
DetailError = await ReadErrorAsync(response, "La fiche utilisateur n'a pas pu etre chargee.");
|
|
SelectedUser = null;
|
|
return;
|
|
}
|
|
|
|
SelectedUser = await response.Content.ReadFromJsonAsync<AdminUserDetailResponse>();
|
|
if (SelectedUser is null)
|
|
{
|
|
DetailError = "Le serveur a retourne une fiche vide.";
|
|
return;
|
|
}
|
|
|
|
FillEditForm(SelectedUser);
|
|
}
|
|
catch (HttpRequestException)
|
|
{
|
|
DetailError = "Le detail utilisateur est temporairement indisponible.";
|
|
SelectedUser = null;
|
|
}
|
|
catch (TaskCanceledException)
|
|
{
|
|
DetailError = "Le detail utilisateur a pris trop de temps a se charger.";
|
|
SelectedUser = null;
|
|
}
|
|
finally
|
|
{
|
|
IsLoadingDetail = false;
|
|
StateHasChanged();
|
|
}
|
|
}
|
|
|
|
private async Task SaveUserAsync()
|
|
{
|
|
if (IsSaving || SelectedSubject is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
IsSaving = true;
|
|
SaveError = null;
|
|
SaveMessage = null;
|
|
|
|
try
|
|
{
|
|
var payload = new AdminUpdateUserRequest
|
|
{
|
|
Username = EditFormModel.Username ?? string.Empty,
|
|
Email = EditFormModel.Email,
|
|
FirstName = EditFormModel.FirstName,
|
|
LastName = EditFormModel.LastName,
|
|
IsEnabled = EditFormModel.IsEnabled,
|
|
IsEmailVerified = EditFormModel.IsEmailVerified,
|
|
DisplayName = EditFormModel.DisplayName,
|
|
Club = EditFormModel.Club,
|
|
City = EditFormModel.City,
|
|
PreferredFormat = EditFormModel.PreferredFormat,
|
|
FavoriteCube = EditFormModel.FavoriteCube,
|
|
Bio = EditFormModel.Bio,
|
|
};
|
|
|
|
var response = await Http.PutAsJsonAsync($"api/admin/users/{Uri.EscapeDataString(SelectedSubject)}", payload);
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
SaveError = await ReadErrorAsync(response, "La mise a jour de l'utilisateur a echoue.");
|
|
return;
|
|
}
|
|
|
|
SelectedUser = await response.Content.ReadFromJsonAsync<AdminUserDetailResponse>();
|
|
if (SelectedUser is null)
|
|
{
|
|
SaveError = "Le serveur a retourne une fiche vide apres la sauvegarde.";
|
|
return;
|
|
}
|
|
|
|
FillEditForm(SelectedUser);
|
|
UpdateSummary(SelectedUser);
|
|
SaveMessage = "La fiche utilisateur a bien ete mise a jour.";
|
|
}
|
|
catch (HttpRequestException)
|
|
{
|
|
SaveError = "Le service d'administration est temporairement indisponible.";
|
|
}
|
|
catch (TaskCanceledException)
|
|
{
|
|
SaveError = "La sauvegarde a pris trop de temps.";
|
|
}
|
|
finally
|
|
{
|
|
IsSaving = false;
|
|
}
|
|
}
|
|
|
|
private void CloseEditModal()
|
|
{
|
|
if (IsSaving)
|
|
{
|
|
return;
|
|
}
|
|
|
|
ShowEditModal = false;
|
|
DetailError = null;
|
|
}
|
|
|
|
private async Task ReloadSelectedUserAsync()
|
|
{
|
|
if (SelectedSubject is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
await LoadUserDetailAsync(SelectedSubject, keepFeedback: true);
|
|
}
|
|
|
|
private void OpenCreateModal()
|
|
{
|
|
CreateFormModel.Reset();
|
|
CreateError = null;
|
|
ShowCreateModal = true;
|
|
}
|
|
|
|
private void CloseCreateModal()
|
|
{
|
|
if (IsCreating)
|
|
{
|
|
return;
|
|
}
|
|
|
|
ShowCreateModal = false;
|
|
CreateError = null;
|
|
}
|
|
|
|
private async Task CreateUserAsync()
|
|
{
|
|
if (IsCreating)
|
|
{
|
|
return;
|
|
}
|
|
|
|
IsCreating = true;
|
|
CreateError = null;
|
|
|
|
try
|
|
{
|
|
var payload = new AdminCreateUserRequest
|
|
{
|
|
Username = CreateFormModel.Username ?? string.Empty,
|
|
Email = CreateFormModel.Email,
|
|
Password = CreateFormModel.Password ?? string.Empty,
|
|
ConfirmPassword = CreateFormModel.ConfirmPassword ?? string.Empty,
|
|
FirstName = CreateFormModel.FirstName,
|
|
LastName = CreateFormModel.LastName,
|
|
IsEnabled = CreateFormModel.IsEnabled,
|
|
IsEmailVerified = CreateFormModel.IsEmailVerified,
|
|
DisplayName = CreateFormModel.DisplayName,
|
|
Club = CreateFormModel.Club,
|
|
City = CreateFormModel.City,
|
|
PreferredFormat = CreateFormModel.PreferredFormat,
|
|
FavoriteCube = CreateFormModel.FavoriteCube,
|
|
Bio = CreateFormModel.Bio,
|
|
};
|
|
|
|
var response = await Http.PostAsJsonAsync("api/admin/users", payload);
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
CreateError = await ReadErrorAsync(response, "La creation de l'utilisateur a echoue.");
|
|
return;
|
|
}
|
|
|
|
var createdUser = await response.Content.ReadFromJsonAsync<AdminUserDetailResponse>();
|
|
if (createdUser is null)
|
|
{
|
|
CreateError = "Le serveur a retourne une fiche vide apres la creation.";
|
|
return;
|
|
}
|
|
|
|
ShowCreateModal = false;
|
|
SaveMessage = $"L'utilisateur {createdUser.Username} a bien ete cree.";
|
|
SaveError = null;
|
|
await LoadUsersAsync(createdUser.Subject, preserveFeedback: true);
|
|
}
|
|
catch (HttpRequestException)
|
|
{
|
|
CreateError = "Le service d'administration est temporairement indisponible.";
|
|
}
|
|
catch (TaskCanceledException)
|
|
{
|
|
CreateError = "La creation a pris trop de temps.";
|
|
}
|
|
finally
|
|
{
|
|
IsCreating = false;
|
|
}
|
|
}
|
|
|
|
private void PromptDeleteUser(AdminUserSummaryResponse user)
|
|
{
|
|
PendingDeleteUser = user;
|
|
DeleteError = null;
|
|
ShowDeleteModal = true;
|
|
}
|
|
|
|
private void CloseDeleteModal()
|
|
{
|
|
if (IsDeleting)
|
|
{
|
|
return;
|
|
}
|
|
|
|
ShowDeleteModal = false;
|
|
DeleteError = null;
|
|
PendingDeleteUser = null;
|
|
}
|
|
|
|
private async Task DeleteUserAsync()
|
|
{
|
|
if (IsDeleting || PendingDeleteUser is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
IsDeleting = true;
|
|
DeleteError = null;
|
|
|
|
try
|
|
{
|
|
var deletedUsername = PendingDeleteUser.Username;
|
|
var deletedSubject = PendingDeleteUser.Subject;
|
|
|
|
var response = await Http.DeleteAsync($"api/admin/users/{Uri.EscapeDataString(deletedSubject)}");
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
DeleteError = await ReadErrorAsync(response, "La suppression de l'utilisateur a echoue.");
|
|
return;
|
|
}
|
|
|
|
ShowDeleteModal = false;
|
|
PendingDeleteUser = null;
|
|
SaveError = null;
|
|
SaveMessage = $"L'utilisateur {deletedUsername} a bien ete supprime.";
|
|
|
|
var nextSubject = SelectedSubject == deletedSubject ? null : SelectedSubject;
|
|
await LoadUsersAsync(nextSubject, preserveFeedback: true);
|
|
}
|
|
catch (HttpRequestException)
|
|
{
|
|
DeleteError = "Le service d'administration est temporairement indisponible.";
|
|
}
|
|
catch (TaskCanceledException)
|
|
{
|
|
DeleteError = "La suppression a pris trop de temps.";
|
|
}
|
|
finally
|
|
{
|
|
IsDeleting = false;
|
|
}
|
|
}
|
|
|
|
private void UpdateSummary(AdminUserDetailResponse detail)
|
|
{
|
|
var summary = Users.FirstOrDefault(user => user.Subject == detail.Subject);
|
|
if (summary is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
summary.Username = detail.Username;
|
|
summary.Email = detail.Email;
|
|
summary.IdentityDisplayName = detail.IdentityDisplayName;
|
|
summary.SiteDisplayName = detail.DisplayName;
|
|
summary.IsEnabled = detail.IsEnabled;
|
|
summary.IsEmailVerified = detail.IsEmailVerified;
|
|
summary.HasSiteProfile = detail.HasSiteProfile;
|
|
summary.Club = detail.Club;
|
|
summary.City = detail.City;
|
|
summary.PreferredFormat = detail.PreferredFormat;
|
|
summary.AccountCreatedUtc = detail.AccountCreatedUtc;
|
|
summary.SiteProfileUpdatedUtc = detail.SiteProfileUpdatedUtc;
|
|
}
|
|
|
|
private void FillEditForm(AdminUserDetailResponse user)
|
|
{
|
|
EditFormModel.Username = user.Username;
|
|
EditFormModel.Email = user.Email;
|
|
EditFormModel.FirstName = user.FirstName;
|
|
EditFormModel.LastName = user.LastName;
|
|
EditFormModel.IsEnabled = user.IsEnabled;
|
|
EditFormModel.IsEmailVerified = user.IsEmailVerified;
|
|
EditFormModel.DisplayName = user.DisplayName;
|
|
EditFormModel.Club = user.Club;
|
|
EditFormModel.City = user.City;
|
|
EditFormModel.PreferredFormat = user.PreferredFormat;
|
|
EditFormModel.FavoriteCube = user.FavoriteCube;
|
|
EditFormModel.Bio = user.Bio;
|
|
}
|
|
|
|
private void ResetAdminState()
|
|
{
|
|
IsAuthenticated = false;
|
|
IsAdmin = false;
|
|
Users.Clear();
|
|
SelectedSubject = null;
|
|
SelectedUser = null;
|
|
PendingDeleteUser = null;
|
|
ShowEditModal = false;
|
|
ShowCreateModal = false;
|
|
ShowDeleteModal = false;
|
|
EditFormModel.Reset();
|
|
CreateFormModel.Reset();
|
|
}
|
|
|
|
private bool MatchesSearch(AdminUserSummaryResponse user)
|
|
{
|
|
var search = SearchTerm.Trim();
|
|
if (string.IsNullOrWhiteSpace(search))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
return Contains(user.Username, search)
|
|
|| Contains(user.Email, search)
|
|
|| Contains(user.IdentityDisplayName, search)
|
|
|| Contains(user.SiteDisplayName, search)
|
|
|| Contains(user.Club, search)
|
|
|| Contains(user.City, search);
|
|
}
|
|
|
|
private static bool Contains(string? value, string search)
|
|
=> value?.Contains(search, StringComparison.OrdinalIgnoreCase) == true;
|
|
|
|
private string BuildTableRowClass(AdminUserSummaryResponse user)
|
|
=> string.Equals(SelectedSubject, user.Subject, StringComparison.Ordinal)
|
|
? "is-selected"
|
|
: string.Empty;
|
|
|
|
private string BuildEditModalTitle()
|
|
{
|
|
if (SelectedUser is not null)
|
|
{
|
|
return !string.IsNullOrWhiteSpace(SelectedUser.DisplayName)
|
|
? SelectedUser.DisplayName
|
|
: SelectedUser.Username;
|
|
}
|
|
|
|
var summary = Users.FirstOrDefault(user => user.Subject == SelectedSubject);
|
|
return summary?.IdentityDisplayName
|
|
?? summary?.Username
|
|
?? "Modifier un utilisateur";
|
|
}
|
|
|
|
private static string BuildUserCardFootnote(AdminUserSummaryResponse user)
|
|
=> user.SiteProfileUpdatedUtc is not null
|
|
? FormatDate(user.SiteProfileUpdatedUtc)
|
|
: user.AccountCreatedUtc is not null
|
|
? FormatDate(user.AccountCreatedUtc)
|
|
: "Non disponible";
|
|
|
|
private static async Task<string> ReadErrorAsync(HttpResponseMessage response, string fallbackMessage)
|
|
{
|
|
try
|
|
{
|
|
var error = await response.Content.ReadFromJsonAsync<ApiErrorMessage>();
|
|
if (!string.IsNullOrWhiteSpace(error?.Message))
|
|
{
|
|
return error.Message;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
|
|
return response.StatusCode switch
|
|
{
|
|
HttpStatusCode.Unauthorized => "La session a expire. Reconnecte-toi puis recharge la page.",
|
|
HttpStatusCode.Forbidden => "Ce compte n'a pas le role admin requis pour cette action.",
|
|
HttpStatusCode.NotFound => "Cet utilisateur n'existe plus ou n'est plus disponible.",
|
|
_ => fallbackMessage,
|
|
};
|
|
}
|
|
|
|
private static string FormatDate(DateTime? value)
|
|
=> value is null
|
|
? "Non disponible"
|
|
: value.Value.ToLocalTime().ToString("dd MMM yyyy 'a' HH:mm", CultureInfo.GetCultureInfo("fr-FR"));
|
|
|
|
private static string BoolString(bool value)
|
|
=> value ? "true" : "false";
|
|
|
|
public void Dispose()
|
|
=> AuthenticationStateProvider.AuthenticationStateChanged -= HandleAuthenticationStateChanged;
|
|
|
|
private sealed class ApiErrorMessage
|
|
{
|
|
public string? Message { get; set; }
|
|
}
|
|
|
|
private class AdminUserFormModel
|
|
{
|
|
[Required(ErrorMessage = "Le nom d'utilisateur est obligatoire.")]
|
|
[MaxLength(120, ErrorMessage = "Le nom d'utilisateur doit rester sous 120 caracteres.")]
|
|
public string? Username { get; set; }
|
|
|
|
[MaxLength(255, ErrorMessage = "L'email doit rester sous 255 caracteres.")]
|
|
[EmailAddress(ErrorMessage = "L'email n'est pas valide.")]
|
|
public string? Email { get; set; }
|
|
|
|
[MaxLength(120, ErrorMessage = "Le prenom doit rester sous 120 caracteres.")]
|
|
public string? FirstName { get; set; }
|
|
|
|
[MaxLength(120, ErrorMessage = "Le nom doit rester sous 120 caracteres.")]
|
|
public string? LastName { get; set; }
|
|
|
|
public bool IsEnabled { get; set; } = true;
|
|
|
|
public bool IsEmailVerified { get; set; }
|
|
|
|
[MaxLength(120, ErrorMessage = "Le nom affiche doit rester sous 120 caracteres.")]
|
|
public string? DisplayName { get; set; }
|
|
|
|
[MaxLength(120, ErrorMessage = "Le club doit rester sous 120 caracteres.")]
|
|
public string? Club { get; set; }
|
|
|
|
[MaxLength(120, ErrorMessage = "La ville doit rester sous 120 caracteres.")]
|
|
public string? City { get; set; }
|
|
|
|
[MaxLength(40, ErrorMessage = "Le format prefere doit rester sous 40 caracteres.")]
|
|
public string? PreferredFormat { get; set; }
|
|
|
|
[MaxLength(120, ErrorMessage = "Le cube favori doit rester sous 120 caracteres.")]
|
|
public string? FavoriteCube { get; set; }
|
|
|
|
[MaxLength(1200, ErrorMessage = "La bio doit rester sous 1200 caracteres.")]
|
|
public string? Bio { get; set; }
|
|
|
|
public virtual void Reset()
|
|
{
|
|
Username = string.Empty;
|
|
Email = string.Empty;
|
|
FirstName = string.Empty;
|
|
LastName = string.Empty;
|
|
IsEnabled = true;
|
|
IsEmailVerified = false;
|
|
DisplayName = string.Empty;
|
|
Club = string.Empty;
|
|
City = string.Empty;
|
|
PreferredFormat = string.Empty;
|
|
FavoriteCube = string.Empty;
|
|
Bio = string.Empty;
|
|
}
|
|
}
|
|
|
|
private sealed class AdminCreateUserFormModel : AdminUserFormModel
|
|
{
|
|
[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; }
|
|
|
|
[Required(ErrorMessage = "La confirmation est obligatoire.")]
|
|
[Compare(nameof(Password), ErrorMessage = "Les mots de passe ne correspondent pas.")]
|
|
public string? ConfirmPassword { get; set; }
|
|
|
|
public override void Reset()
|
|
{
|
|
base.Reset();
|
|
Password = string.Empty;
|
|
ConfirmPassword = string.Empty;
|
|
}
|
|
}
|
|
}
|