Ajoute une table de gestion des utilisateurs
This commit is contained in:
32
ChessCubing.App/Models/Users/AdminCreateUserRequest.cs
Normal file
32
ChessCubing.App/Models/Users/AdminCreateUserRequest.cs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
namespace ChessCubing.App.Models.Users;
|
||||||
|
|
||||||
|
public sealed class AdminCreateUserRequest
|
||||||
|
{
|
||||||
|
public string Username { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string? Email { get; set; }
|
||||||
|
|
||||||
|
public string Password { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string ConfirmPassword { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string? FirstName { get; set; }
|
||||||
|
|
||||||
|
public string? LastName { get; set; }
|
||||||
|
|
||||||
|
public bool IsEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
public bool IsEmailVerified { get; set; }
|
||||||
|
|
||||||
|
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; }
|
||||||
|
}
|
||||||
@@ -18,10 +18,10 @@
|
|||||||
<header class="hero hero-home user-hero">
|
<header class="hero hero-home user-hero">
|
||||||
<div class="hero-copy">
|
<div class="hero-copy">
|
||||||
<p class="eyebrow">Administration</p>
|
<p class="eyebrow">Administration</p>
|
||||||
<h1>Piloter les utilisateurs du site depuis l'interface</h1>
|
<h1>Gerer les utilisateurs avec une vue tableau</h1>
|
||||||
<p class="lead">
|
<p class="lead">
|
||||||
Cette premiere zone d'administration centralise les comptes du site,
|
Cette zone rassemble les comptes Keycloak et les profils du site
|
||||||
l'etat du compte Keycloak et les donnees metier stockees en MySQL.
|
dans une interface plus classique, avec ajout, edition et suppression.
|
||||||
</p>
|
</p>
|
||||||
<div class="hero-actions">
|
<div class="hero-actions">
|
||||||
<a class="button secondary" href="utilisateur.html">Voir l'espace utilisateur</a>
|
<a class="button secondary" href="utilisateur.html">Voir l'espace utilisateur</a>
|
||||||
@@ -70,8 +70,8 @@
|
|||||||
<h2>Connecte-toi avec un compte administrateur</h2>
|
<h2>Connecte-toi avec un compte administrateur</h2>
|
||||||
</div>
|
</div>
|
||||||
<p class="section-copy">
|
<p class="section-copy">
|
||||||
Utilise le menu du site pour te connecter. La zone d'administration
|
La gestion des utilisateurs devient disponible des qu'un compte
|
||||||
devient disponible des qu'un compte avec le role `admin` est actif.
|
portant le role `admin` est connecte.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -105,29 +105,44 @@
|
|||||||
<h2>Impossible de charger l'administration</h2>
|
<h2>Impossible de charger l'administration</h2>
|
||||||
<p class="profile-feedback error">@LoadError</p>
|
<p class="profile-feedback error">@LoadError</p>
|
||||||
<div class="hero-actions">
|
<div class="hero-actions">
|
||||||
<button class="button secondary" type="button" @onclick="LoadUsersAsync">Reessayer</button>
|
<button class="button secondary" type="button" @onclick="ReloadUsersAsync">Reessayer</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<section class="panel panel-third admin-list-panel">
|
<section class="panel panel-wide admin-table-panel">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Utilisateurs</p>
|
<p class="eyebrow">Utilisateurs</p>
|
||||||
<h2>Parcourir les comptes</h2>
|
<h2>Liste des comptes</h2>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="section-copy">
|
||||||
|
Le tableau centralise les comptes du site avec une colonne d'actions
|
||||||
|
pour les operations courantes.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="admin-toolbar">
|
@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">
|
<label class="field admin-search-field">
|
||||||
<span>Recherche</span>
|
<span>Recherche</span>
|
||||||
<input @bind="SearchTerm" @bind:event="oninput" placeholder="Nom, email, club, ville..." />
|
<input @bind="SearchTerm" @bind:event="oninput" placeholder="Nom, email, club, ville..." />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button class="button ghost small" type="button" @onclick="LoadUsersAsync" disabled="@IsLoadingUsers">
|
<div class="admin-toolbar-actions">
|
||||||
Actualiser
|
<button class="button secondary small" type="button" @onclick="OpenCreateModal">Ajouter un utilisateur</button>
|
||||||
</button>
|
<button class="button ghost small" type="button" @onclick="ReloadUsersAsync" disabled="@IsLoadingUsers">Actualiser</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (FilteredUsers.Count == 0)
|
@if (FilteredUsers.Count == 0)
|
||||||
@@ -139,34 +154,61 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="admin-user-list">
|
<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)
|
@foreach (var user in FilteredUsers)
|
||||||
{
|
{
|
||||||
<button class="admin-user-card @(SelectedSubject == user.Subject ? "is-selected" : string.Empty)"
|
<tr class="@BuildTableRowClass(user)">
|
||||||
type="button"
|
<td>
|
||||||
@onclick="@(() => SelectUserAsync(user.Subject))">
|
<div class="admin-cell-stack">
|
||||||
<div class="admin-user-card-head">
|
<strong>@user.Username</strong>
|
||||||
<div>
|
<span>@user.IdentityDisplayName</span>
|
||||||
<strong>@BuildListDisplayName(user)</strong>
|
|
||||||
<span>@user.Username</span>
|
|
||||||
</div>
|
</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")">
|
<span class="mini-chip @(user.IsEnabled ? "admin-chip-success" : "admin-chip-danger")">
|
||||||
@(user.IsEnabled ? "Actif" : "Suspendu")
|
@(user.IsEnabled ? "Actif" : "Suspendu")
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</td>
|
||||||
|
<td>
|
||||||
<div class="admin-user-card-meta">
|
<span class="mini-chip @(user.HasSiteProfile ? "admin-chip-neutral" : "admin-chip-outline")">
|
||||||
<span>@(user.Email ?? "Email non renseigne")</span>
|
@(user.HasSiteProfile ? "Cree" : "Absent")
|
||||||
<span>@(user.HasSiteProfile ? "Profil site actif" : "Profil site absent")</span>
|
</span>
|
||||||
<span>@BuildUserCardFootnote(user)</span>
|
</td>
|
||||||
</div>
|
<td>@BuildUserCardFootnote(user)</td>
|
||||||
|
<td>
|
||||||
|
<div class="admin-actions-cell">
|
||||||
|
<button class="button ghost small" type="button" @onclick="@(() => SelectUserAsync(user.Subject))">
|
||||||
|
Modifier
|
||||||
</button>
|
</button>
|
||||||
|
<button class="button danger small" type="button" @onclick="@(() => PromptDeleteUser(user))">
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
}
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel admin-detail-panel">
|
<section class="panel panel-wide admin-detail-panel">
|
||||||
@if (IsLoadingDetail)
|
@if (IsLoadingDetail)
|
||||||
{
|
{
|
||||||
<p class="eyebrow">Chargement</p>
|
<p class="eyebrow">Chargement</p>
|
||||||
@@ -181,9 +223,11 @@
|
|||||||
}
|
}
|
||||||
else if (SelectedUser is null)
|
else if (SelectedUser is null)
|
||||||
{
|
{
|
||||||
<p class="eyebrow">Selection</p>
|
<p class="eyebrow">Edition</p>
|
||||||
<h2>Choisis un utilisateur</h2>
|
<h2>Choisis un utilisateur dans le tableau</h2>
|
||||||
<p class="section-copy">La fiche detaillee apparait ici des qu'un compte est selectionne.</p>
|
<p class="section-copy">
|
||||||
|
Le bouton `Modifier` charge la fiche detaillee ici pour editer le compte et le profil du site.
|
||||||
|
</p>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -193,7 +237,7 @@
|
|||||||
<h2>@SelectedUser.DisplayName</h2>
|
<h2>@SelectedUser.DisplayName</h2>
|
||||||
</div>
|
</div>
|
||||||
<p class="section-copy">
|
<p class="section-copy">
|
||||||
Les roles restent geres dans Keycloak. Cette page couvre l'etat du compte et le profil du site.
|
Les roles restent geres dans Keycloak. Cette fiche couvre l'etat du compte et le profil du site.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -216,22 +260,12 @@
|
|||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (!string.IsNullOrWhiteSpace(SaveError))
|
<EditForm Model="@EditFormModel" OnValidSubmit="SaveUserAsync">
|
||||||
{
|
|
||||||
<p class="profile-feedback error">@SaveError</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (!string.IsNullOrWhiteSpace(SaveMessage))
|
|
||||||
{
|
|
||||||
<p class="profile-feedback success">@SaveMessage</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
<EditForm Model="@Form" OnValidSubmit="SaveUserAsync">
|
|
||||||
<DataAnnotationsValidator />
|
<DataAnnotationsValidator />
|
||||||
|
|
||||||
<div class="admin-toggle-grid">
|
<div class="admin-toggle-grid">
|
||||||
<label class="admin-toggle-card">
|
<label class="admin-toggle-card">
|
||||||
<InputCheckbox @bind-Value="Form.IsEnabled" />
|
<InputCheckbox @bind-Value="EditFormModel.IsEnabled" />
|
||||||
<div>
|
<div>
|
||||||
<strong>Compte actif</strong>
|
<strong>Compte actif</strong>
|
||||||
<span>Autoriser ou bloquer la connexion a l'application.</span>
|
<span>Autoriser ou bloquer la connexion a l'application.</span>
|
||||||
@@ -239,7 +273,7 @@
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="admin-toggle-card">
|
<label class="admin-toggle-card">
|
||||||
<InputCheckbox @bind-Value="Form.IsEmailVerified" />
|
<InputCheckbox @bind-Value="EditFormModel.IsEmailVerified" />
|
||||||
<div>
|
<div>
|
||||||
<strong>Email verifie</strong>
|
<strong>Email verifie</strong>
|
||||||
<span>Indique si l'adresse email a ete validee dans Keycloak.</span>
|
<span>Indique si l'adresse email a ete validee dans Keycloak.</span>
|
||||||
@@ -250,67 +284,67 @@
|
|||||||
<div class="admin-form-grid">
|
<div class="admin-form-grid">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Nom d'utilisateur</span>
|
<span>Nom d'utilisateur</span>
|
||||||
<InputText @bind-Value="Form.Username" />
|
<InputText @bind-Value="EditFormModel.Username" />
|
||||||
<ValidationMessage For="@(() => Form.Username)" />
|
<ValidationMessage For="@(() => EditFormModel.Username)" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Email</span>
|
<span>Email</span>
|
||||||
<InputText @bind-Value="Form.Email" />
|
<InputText @bind-Value="EditFormModel.Email" />
|
||||||
<ValidationMessage For="@(() => Form.Email)" />
|
<ValidationMessage For="@(() => EditFormModel.Email)" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Prenom</span>
|
<span>Prenom</span>
|
||||||
<InputText @bind-Value="Form.FirstName" />
|
<InputText @bind-Value="EditFormModel.FirstName" />
|
||||||
<ValidationMessage For="@(() => Form.FirstName)" />
|
<ValidationMessage For="@(() => EditFormModel.FirstName)" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Nom</span>
|
<span>Nom</span>
|
||||||
<InputText @bind-Value="Form.LastName" />
|
<InputText @bind-Value="EditFormModel.LastName" />
|
||||||
<ValidationMessage For="@(() => Form.LastName)" />
|
<ValidationMessage For="@(() => EditFormModel.LastName)" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Nom affiche sur le site</span>
|
<span>Nom affiche sur le site</span>
|
||||||
<InputText @bind-Value="Form.DisplayName" />
|
<InputText @bind-Value="EditFormModel.DisplayName" />
|
||||||
<ValidationMessage For="@(() => Form.DisplayName)" />
|
<ValidationMessage For="@(() => EditFormModel.DisplayName)" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Club</span>
|
<span>Club</span>
|
||||||
<InputText @bind-Value="Form.Club" />
|
<InputText @bind-Value="EditFormModel.Club" />
|
||||||
<ValidationMessage For="@(() => Form.Club)" />
|
<ValidationMessage For="@(() => EditFormModel.Club)" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Ville</span>
|
<span>Ville</span>
|
||||||
<InputText @bind-Value="Form.City" />
|
<InputText @bind-Value="EditFormModel.City" />
|
||||||
<ValidationMessage For="@(() => Form.City)" />
|
<ValidationMessage For="@(() => EditFormModel.City)" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Format prefere</span>
|
<span>Format prefere</span>
|
||||||
<InputSelect @bind-Value="Form.PreferredFormat">
|
<InputSelect @bind-Value="EditFormModel.PreferredFormat">
|
||||||
<option value="">Aucune preference</option>
|
<option value="">Aucune preference</option>
|
||||||
<option value="Twice">Twice</option>
|
<option value="Twice">Twice</option>
|
||||||
<option value="Time">Time</option>
|
<option value="Time">Time</option>
|
||||||
<option value="Les deux">Les deux</option>
|
<option value="Les deux">Les deux</option>
|
||||||
</InputSelect>
|
</InputSelect>
|
||||||
<ValidationMessage For="@(() => Form.PreferredFormat)" />
|
<ValidationMessage For="@(() => EditFormModel.PreferredFormat)" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field span-2">
|
<label class="field span-2">
|
||||||
<span>Cube favori</span>
|
<span>Cube favori</span>
|
||||||
<InputText @bind-Value="Form.FavoriteCube" />
|
<InputText @bind-Value="EditFormModel.FavoriteCube" />
|
||||||
<ValidationMessage For="@(() => Form.FavoriteCube)" />
|
<ValidationMessage For="@(() => EditFormModel.FavoriteCube)" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field span-2">
|
<label class="field span-2">
|
||||||
<span>Bio</span>
|
<span>Bio</span>
|
||||||
<InputTextArea @bind-Value="Form.Bio" />
|
<InputTextArea @bind-Value="EditFormModel.Bio" />
|
||||||
<ValidationMessage For="@(() => Form.Bio)" />
|
<ValidationMessage For="@(() => EditFormModel.Bio)" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -327,11 +361,175 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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 {
|
@code {
|
||||||
private readonly AdminUserFormModel Form = new();
|
private readonly AdminUserFormModel EditFormModel = new();
|
||||||
|
private readonly AdminCreateUserFormModel CreateFormModel = new();
|
||||||
private readonly List<AdminUserSummaryResponse> Users = [];
|
private readonly List<AdminUserSummaryResponse> Users = [];
|
||||||
|
|
||||||
private AdminUserDetailResponse? SelectedUser;
|
private AdminUserDetailResponse? SelectedUser;
|
||||||
|
private AdminUserSummaryResponse? PendingDeleteUser;
|
||||||
private string? SelectedSubject;
|
private string? SelectedSubject;
|
||||||
private string SearchTerm = string.Empty;
|
private string SearchTerm = string.Empty;
|
||||||
private bool IsAuthenticated;
|
private bool IsAuthenticated;
|
||||||
@@ -339,10 +537,16 @@
|
|||||||
private bool IsLoadingUsers = true;
|
private bool IsLoadingUsers = true;
|
||||||
private bool IsLoadingDetail;
|
private bool IsLoadingDetail;
|
||||||
private bool IsSaving;
|
private bool IsSaving;
|
||||||
|
private bool IsCreating;
|
||||||
|
private bool IsDeleting;
|
||||||
|
private bool ShowCreateModal;
|
||||||
|
private bool ShowDeleteModal;
|
||||||
private string? LoadError;
|
private string? LoadError;
|
||||||
private string? DetailError;
|
private string? DetailError;
|
||||||
private string? SaveError;
|
private string? SaveError;
|
||||||
private string? SaveMessage;
|
private string? SaveMessage;
|
||||||
|
private string? CreateError;
|
||||||
|
private string? DeleteError;
|
||||||
|
|
||||||
private List<AdminUserSummaryResponse> FilteredUsers
|
private List<AdminUserSummaryResponse> FilteredUsers
|
||||||
=> Users
|
=> Users
|
||||||
@@ -365,7 +569,7 @@
|
|||||||
? "Le compte actuel est authentifie mais ne dispose pas du role admin."
|
? "Le compte actuel est authentifie mais ne dispose pas du role admin."
|
||||||
: IsLoadingUsers
|
: IsLoadingUsers
|
||||||
? "Le site recupere les comptes et les profils deja relies a MySQL."
|
? "Le site recupere les comptes et les profils deja relies a MySQL."
|
||||||
: "La fiche detaillee permet de modifier les informations de compte et le profil du site.";
|
: "Le tableau permet maintenant les operations classiques d'ajout, modification et suppression.";
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
@@ -374,14 +578,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void HandleAuthenticationStateChanged(Task<AuthenticationState> authenticationStateTask)
|
private void HandleAuthenticationStateChanged(Task<AuthenticationState> authenticationStateTask)
|
||||||
=> _ = InvokeAsync(LoadUsersAsync);
|
=> _ = InvokeAsync(() => LoadUsersAsync(SelectedSubject));
|
||||||
|
|
||||||
private async Task LoadUsersAsync()
|
private async Task ReloadUsersAsync()
|
||||||
|
=> await LoadUsersAsync(SelectedSubject);
|
||||||
|
|
||||||
|
private async Task LoadUsersAsync(string? preferredSubject = null, bool preserveFeedback = false)
|
||||||
{
|
{
|
||||||
LoadError = null;
|
LoadError = null;
|
||||||
DetailError = null;
|
DetailError = null;
|
||||||
|
|
||||||
|
if (!preserveFeedback)
|
||||||
|
{
|
||||||
SaveError = null;
|
SaveError = null;
|
||||||
SaveMessage = null;
|
SaveMessage = null;
|
||||||
|
}
|
||||||
|
|
||||||
IsLoadingUsers = true;
|
IsLoadingUsers = true;
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -401,7 +613,7 @@
|
|||||||
Users.Clear();
|
Users.Clear();
|
||||||
SelectedSubject = null;
|
SelectedSubject = null;
|
||||||
SelectedUser = null;
|
SelectedUser = null;
|
||||||
Form.Reset();
|
EditFormModel.Reset();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,7 +624,7 @@
|
|||||||
Users.Clear();
|
Users.Clear();
|
||||||
SelectedSubject = null;
|
SelectedSubject = null;
|
||||||
SelectedUser = null;
|
SelectedUser = null;
|
||||||
Form.Reset();
|
EditFormModel.Reset();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,11 +636,13 @@
|
|||||||
{
|
{
|
||||||
SelectedSubject = null;
|
SelectedSubject = null;
|
||||||
SelectedUser = null;
|
SelectedUser = null;
|
||||||
Form.Reset();
|
EditFormModel.Reset();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var nextSubject = Users.Any(user => user.Subject == SelectedSubject)
|
var nextSubject = Users.Any(user => user.Subject == preferredSubject)
|
||||||
|
? preferredSubject
|
||||||
|
: Users.Any(user => user.Subject == SelectedSubject)
|
||||||
? SelectedSubject
|
? SelectedSubject
|
||||||
: Users[0].Subject;
|
: Users[0].Subject;
|
||||||
|
|
||||||
@@ -456,11 +670,6 @@
|
|||||||
|
|
||||||
private async Task SelectUserAsync(string subject)
|
private async Task SelectUserAsync(string subject)
|
||||||
{
|
{
|
||||||
if (string.Equals(subject, SelectedSubject, StringComparison.Ordinal))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
SaveError = null;
|
SaveError = null;
|
||||||
SaveMessage = null;
|
SaveMessage = null;
|
||||||
await LoadUserDetailAsync(subject, keepFeedback: false);
|
await LoadUserDetailAsync(subject, keepFeedback: false);
|
||||||
@@ -495,7 +704,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
FillForm(SelectedUser);
|
FillEditForm(SelectedUser);
|
||||||
}
|
}
|
||||||
catch (HttpRequestException)
|
catch (HttpRequestException)
|
||||||
{
|
{
|
||||||
@@ -529,18 +738,18 @@
|
|||||||
{
|
{
|
||||||
var payload = new AdminUpdateUserRequest
|
var payload = new AdminUpdateUserRequest
|
||||||
{
|
{
|
||||||
Username = Form.Username ?? string.Empty,
|
Username = EditFormModel.Username ?? string.Empty,
|
||||||
Email = Form.Email,
|
Email = EditFormModel.Email,
|
||||||
FirstName = Form.FirstName,
|
FirstName = EditFormModel.FirstName,
|
||||||
LastName = Form.LastName,
|
LastName = EditFormModel.LastName,
|
||||||
IsEnabled = Form.IsEnabled,
|
IsEnabled = EditFormModel.IsEnabled,
|
||||||
IsEmailVerified = Form.IsEmailVerified,
|
IsEmailVerified = EditFormModel.IsEmailVerified,
|
||||||
DisplayName = Form.DisplayName,
|
DisplayName = EditFormModel.DisplayName,
|
||||||
Club = Form.Club,
|
Club = EditFormModel.Club,
|
||||||
City = Form.City,
|
City = EditFormModel.City,
|
||||||
PreferredFormat = Form.PreferredFormat,
|
PreferredFormat = EditFormModel.PreferredFormat,
|
||||||
FavoriteCube = Form.FavoriteCube,
|
FavoriteCube = EditFormModel.FavoriteCube,
|
||||||
Bio = Form.Bio,
|
Bio = EditFormModel.Bio,
|
||||||
};
|
};
|
||||||
|
|
||||||
var response = await Http.PutAsJsonAsync($"api/admin/users/{Uri.EscapeDataString(SelectedSubject)}", payload);
|
var response = await Http.PutAsJsonAsync($"api/admin/users/{Uri.EscapeDataString(SelectedSubject)}", payload);
|
||||||
@@ -557,7 +766,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
FillForm(SelectedUser);
|
FillEditForm(SelectedUser);
|
||||||
UpdateSummary(SelectedUser);
|
UpdateSummary(SelectedUser);
|
||||||
SaveMessage = "La fiche utilisateur a bien ete mise a jour.";
|
SaveMessage = "La fiche utilisateur a bien ete mise a jour.";
|
||||||
}
|
}
|
||||||
@@ -575,6 +784,150 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
private void UpdateSummary(AdminUserDetailResponse detail)
|
||||||
{
|
{
|
||||||
var summary = Users.FirstOrDefault(user => user.Subject == detail.Subject);
|
var summary = Users.FirstOrDefault(user => user.Subject == detail.Subject);
|
||||||
@@ -597,20 +950,20 @@
|
|||||||
summary.SiteProfileUpdatedUtc = detail.SiteProfileUpdatedUtc;
|
summary.SiteProfileUpdatedUtc = detail.SiteProfileUpdatedUtc;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void FillForm(AdminUserDetailResponse user)
|
private void FillEditForm(AdminUserDetailResponse user)
|
||||||
{
|
{
|
||||||
Form.Username = user.Username;
|
EditFormModel.Username = user.Username;
|
||||||
Form.Email = user.Email;
|
EditFormModel.Email = user.Email;
|
||||||
Form.FirstName = user.FirstName;
|
EditFormModel.FirstName = user.FirstName;
|
||||||
Form.LastName = user.LastName;
|
EditFormModel.LastName = user.LastName;
|
||||||
Form.IsEnabled = user.IsEnabled;
|
EditFormModel.IsEnabled = user.IsEnabled;
|
||||||
Form.IsEmailVerified = user.IsEmailVerified;
|
EditFormModel.IsEmailVerified = user.IsEmailVerified;
|
||||||
Form.DisplayName = user.DisplayName;
|
EditFormModel.DisplayName = user.DisplayName;
|
||||||
Form.Club = user.Club;
|
EditFormModel.Club = user.Club;
|
||||||
Form.City = user.City;
|
EditFormModel.City = user.City;
|
||||||
Form.PreferredFormat = user.PreferredFormat;
|
EditFormModel.PreferredFormat = user.PreferredFormat;
|
||||||
Form.FavoriteCube = user.FavoriteCube;
|
EditFormModel.FavoriteCube = user.FavoriteCube;
|
||||||
Form.Bio = user.Bio;
|
EditFormModel.Bio = user.Bio;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ResetAdminState()
|
private void ResetAdminState()
|
||||||
@@ -620,7 +973,11 @@
|
|||||||
Users.Clear();
|
Users.Clear();
|
||||||
SelectedSubject = null;
|
SelectedSubject = null;
|
||||||
SelectedUser = null;
|
SelectedUser = null;
|
||||||
Form.Reset();
|
PendingDeleteUser = null;
|
||||||
|
ShowCreateModal = false;
|
||||||
|
ShowDeleteModal = false;
|
||||||
|
EditFormModel.Reset();
|
||||||
|
CreateFormModel.Reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool MatchesSearch(AdminUserSummaryResponse user)
|
private bool MatchesSearch(AdminUserSummaryResponse user)
|
||||||
@@ -642,17 +999,17 @@
|
|||||||
private static bool Contains(string? value, string search)
|
private static bool Contains(string? value, string search)
|
||||||
=> value?.Contains(search, StringComparison.OrdinalIgnoreCase) == true;
|
=> value?.Contains(search, StringComparison.OrdinalIgnoreCase) == true;
|
||||||
|
|
||||||
private static string BuildListDisplayName(AdminUserSummaryResponse user)
|
private string BuildTableRowClass(AdminUserSummaryResponse user)
|
||||||
=> user.SiteDisplayName
|
=> string.Equals(SelectedSubject, user.Subject, StringComparison.Ordinal)
|
||||||
?? user.IdentityDisplayName
|
? "is-selected"
|
||||||
?? user.Username;
|
: string.Empty;
|
||||||
|
|
||||||
private static string BuildUserCardFootnote(AdminUserSummaryResponse user)
|
private static string BuildUserCardFootnote(AdminUserSummaryResponse user)
|
||||||
=> user.SiteProfileUpdatedUtc is not null
|
=> user.SiteProfileUpdatedUtc is not null
|
||||||
? $"Maj site {FormatDate(user.SiteProfileUpdatedUtc)}"
|
? FormatDate(user.SiteProfileUpdatedUtc)
|
||||||
: user.AccountCreatedUtc is not null
|
: user.AccountCreatedUtc is not null
|
||||||
? $"Compte cree {FormatDate(user.AccountCreatedUtc)}"
|
? FormatDate(user.AccountCreatedUtc)
|
||||||
: "Aucune date disponible";
|
: "Non disponible";
|
||||||
|
|
||||||
private static async Task<string> ReadErrorAsync(HttpResponseMessage response, string fallbackMessage)
|
private static async Task<string> ReadErrorAsync(HttpResponseMessage response, string fallbackMessage)
|
||||||
{
|
{
|
||||||
@@ -682,6 +1039,9 @@
|
|||||||
? "Non disponible"
|
? "Non disponible"
|
||||||
: value.Value.ToLocalTime().ToString("dd MMM yyyy 'a' HH:mm", CultureInfo.GetCultureInfo("fr-FR"));
|
: 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()
|
public void Dispose()
|
||||||
=> AuthenticationStateProvider.AuthenticationStateChanged -= HandleAuthenticationStateChanged;
|
=> AuthenticationStateProvider.AuthenticationStateChanged -= HandleAuthenticationStateChanged;
|
||||||
|
|
||||||
@@ -690,7 +1050,7 @@
|
|||||||
public string? Message { get; set; }
|
public string? Message { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class AdminUserFormModel
|
private class AdminUserFormModel
|
||||||
{
|
{
|
||||||
[Required(ErrorMessage = "Le nom d'utilisateur est obligatoire.")]
|
[Required(ErrorMessage = "Le nom d'utilisateur est obligatoire.")]
|
||||||
[MaxLength(120, ErrorMessage = "Le nom d'utilisateur doit rester sous 120 caracteres.")]
|
[MaxLength(120, ErrorMessage = "Le nom d'utilisateur doit rester sous 120 caracteres.")]
|
||||||
@@ -728,7 +1088,7 @@
|
|||||||
[MaxLength(1200, ErrorMessage = "La bio doit rester sous 1200 caracteres.")]
|
[MaxLength(1200, ErrorMessage = "La bio doit rester sous 1200 caracteres.")]
|
||||||
public string? Bio { get; set; }
|
public string? Bio { get; set; }
|
||||||
|
|
||||||
public void Reset()
|
public virtual void Reset()
|
||||||
{
|
{
|
||||||
Username = string.Empty;
|
Username = string.Empty;
|
||||||
Email = string.Empty;
|
Email = string.Empty;
|
||||||
@@ -744,4 +1104,22 @@
|
|||||||
Bio = 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,37 @@ public sealed class AdminUpdateUserRequest
|
|||||||
public string? Bio { get; init; }
|
public string? Bio { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class AdminCreateUserRequest
|
||||||
|
{
|
||||||
|
public string Username { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string? Email { get; init; }
|
||||||
|
|
||||||
|
public string Password { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string ConfirmPassword { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string? FirstName { get; init; }
|
||||||
|
|
||||||
|
public string? LastName { get; init; }
|
||||||
|
|
||||||
|
public bool IsEnabled { get; init; } = true;
|
||||||
|
|
||||||
|
public bool IsEmailVerified { get; init; }
|
||||||
|
|
||||||
|
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 record AdminIdentityUser(
|
public sealed record AdminIdentityUser(
|
||||||
string Subject,
|
string Subject,
|
||||||
string Username,
|
string Username,
|
||||||
@@ -113,4 +144,13 @@ public sealed record AdminIdentityUserUpdateRequest(
|
|||||||
bool IsEnabled,
|
bool IsEnabled,
|
||||||
bool IsEmailVerified);
|
bool IsEmailVerified);
|
||||||
|
|
||||||
|
public sealed record AdminIdentityUserCreateRequest(
|
||||||
|
string Username,
|
||||||
|
string? Email,
|
||||||
|
string Password,
|
||||||
|
string? FirstName,
|
||||||
|
string? LastName,
|
||||||
|
bool IsEnabled,
|
||||||
|
bool IsEmailVerified);
|
||||||
|
|
||||||
public sealed class AdminUserValidationException(string message) : Exception(message);
|
public sealed class AdminUserValidationException(string message) : Exception(message);
|
||||||
|
|||||||
@@ -32,12 +32,41 @@ public sealed class KeycloakAuthService(HttpClient httpClient, IOptions<Keycloak
|
|||||||
public async Task<KeycloakUserInfo> RegisterAsync(RegisterRequest request, CancellationToken cancellationToken)
|
public async Task<KeycloakUserInfo> RegisterAsync(RegisterRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var adminToken = await RequestAdminTokenAsync(cancellationToken);
|
var adminToken = await RequestAdminTokenAsync(cancellationToken);
|
||||||
var userId = await CreateUserAsync(adminToken, request, cancellationToken);
|
var userId = await CreateUserAsync(
|
||||||
|
adminToken,
|
||||||
|
request.Username,
|
||||||
|
request.Email,
|
||||||
|
request.FirstName,
|
||||||
|
request.LastName,
|
||||||
|
isEnabled: true,
|
||||||
|
isEmailVerified: false,
|
||||||
|
cancellationToken);
|
||||||
await SetPasswordAsync(adminToken, userId, request.Password, cancellationToken);
|
await SetPasswordAsync(adminToken, userId, request.Password, cancellationToken);
|
||||||
await TryAssignPlayerRoleAsync(adminToken, userId, cancellationToken);
|
await TryAssignPlayerRoleAsync(adminToken, userId, cancellationToken);
|
||||||
return await LoginAsync(request.Username, request.Password, cancellationToken);
|
return await LoginAsync(request.Username, request.Password, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<AdminIdentityUser> CreateAdminUserAsync(
|
||||||
|
AdminIdentityUserCreateRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var adminToken = await RequestAdminTokenAsync(cancellationToken);
|
||||||
|
var userId = await CreateUserAsync(
|
||||||
|
adminToken,
|
||||||
|
request.Username,
|
||||||
|
request.Email,
|
||||||
|
request.FirstName,
|
||||||
|
request.LastName,
|
||||||
|
request.IsEnabled,
|
||||||
|
request.IsEmailVerified,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
await SetPasswordAsync(adminToken, userId, request.Password, cancellationToken);
|
||||||
|
await TryAssignPlayerRoleAsync(adminToken, userId, cancellationToken);
|
||||||
|
|
||||||
|
return await GetAdminUserAsync(adminToken, userId, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<AdminIdentityUser>> GetAdminUsersAsync(CancellationToken cancellationToken)
|
public async Task<IReadOnlyList<AdminIdentityUser>> GetAdminUsersAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var adminToken = await RequestAdminTokenAsync(cancellationToken);
|
var adminToken = await RequestAdminTokenAsync(cancellationToken);
|
||||||
@@ -119,6 +148,25 @@ public sealed class KeycloakAuthService(HttpClient httpClient, IOptions<Keycloak
|
|||||||
return await GetAdminUserAsync(adminToken, userId, cancellationToken);
|
return await GetAdminUserAsync(adminToken, userId, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAdminUserAsync(string userId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var adminToken = await RequestAdminTokenAsync(cancellationToken);
|
||||||
|
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Delete, $"{GetAdminBaseUrl()}/users/{Uri.EscapeDataString(userId)}");
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
|
||||||
|
|
||||||
|
using var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||||
|
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||||
|
{
|
||||||
|
throw new KeycloakAuthException("Utilisateur introuvable dans Keycloak.", StatusCodes.Status404NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
throw new KeycloakAuthException("La suppression du compte Keycloak a echoue.", (int)response.StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<TokenSuccessResponse> RequestPasswordTokenAsync(string username, string password, CancellationToken cancellationToken)
|
private async Task<TokenSuccessResponse> RequestPasswordTokenAsync(string username, string password, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var formData = new Dictionary<string, string>
|
var formData = new Dictionary<string, string>
|
||||||
@@ -208,18 +256,26 @@ public sealed class KeycloakAuthService(HttpClient httpClient, IOptions<Keycloak
|
|||||||
return userInfo;
|
return userInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string> CreateUserAsync(string adminToken, RegisterRequest request, CancellationToken cancellationToken)
|
private async Task<string> CreateUserAsync(
|
||||||
|
string adminToken,
|
||||||
|
string username,
|
||||||
|
string? email,
|
||||||
|
string? firstName,
|
||||||
|
string? lastName,
|
||||||
|
bool isEnabled,
|
||||||
|
bool isEmailVerified,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"{GetAdminBaseUrl()}/users")
|
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"{GetAdminBaseUrl()}/users")
|
||||||
{
|
{
|
||||||
Content = JsonContent.Create(new
|
Content = JsonContent.Create(new
|
||||||
{
|
{
|
||||||
username = request.Username.Trim(),
|
username = username.Trim(),
|
||||||
email = request.Email.Trim(),
|
email = string.IsNullOrWhiteSpace(email) ? null : email.Trim(),
|
||||||
enabled = true,
|
enabled = isEnabled,
|
||||||
emailVerified = false,
|
emailVerified = isEmailVerified,
|
||||||
firstName = string.IsNullOrWhiteSpace(request.FirstName) ? null : request.FirstName.Trim(),
|
firstName = string.IsNullOrWhiteSpace(firstName) ? null : firstName.Trim(),
|
||||||
lastName = string.IsNullOrWhiteSpace(request.LastName) ? null : request.LastName.Trim(),
|
lastName = string.IsNullOrWhiteSpace(lastName) ? null : lastName.Trim(),
|
||||||
}, options: JsonOptions)
|
}, options: JsonOptions)
|
||||||
};
|
};
|
||||||
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
|
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
|
||||||
@@ -241,7 +297,7 @@ public sealed class KeycloakAuthService(HttpClient httpClient, IOptions<Keycloak
|
|||||||
return userId.Trim('/');
|
return userId.Trim('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
var fallbackUserId = await FindUserIdByUsernameAsync(adminToken, request.Username, cancellationToken);
|
var fallbackUserId = await FindUserIdByUsernameAsync(adminToken, username, cancellationToken);
|
||||||
if (!string.IsNullOrWhiteSpace(fallbackUserId))
|
if (!string.IsNullOrWhiteSpace(fallbackUserId))
|
||||||
{
|
{
|
||||||
return fallbackUserId;
|
return fallbackUserId;
|
||||||
|
|||||||
@@ -147,6 +147,63 @@ adminGroup.MapGet("/users", async Task<IResult> (
|
|||||||
return TypedResults.Ok(users);
|
return TypedResults.Ok(users);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
adminGroup.MapPost("/users", async Task<IResult> (
|
||||||
|
AdminCreateUserRequest request,
|
||||||
|
KeycloakAuthService keycloak,
|
||||||
|
MySqlUserProfileStore profileStore,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var normalized = NormalizeAdminCreate(request);
|
||||||
|
var fallbackDisplayName = BuildIdentityDisplayNameFromParts(normalized.FirstName, normalized.LastName, normalized.Username);
|
||||||
|
var siteProfileRequest = new UpdateUserProfileRequest
|
||||||
|
{
|
||||||
|
DisplayName = request.DisplayName,
|
||||||
|
Club = request.Club,
|
||||||
|
City = request.City,
|
||||||
|
PreferredFormat = request.PreferredFormat,
|
||||||
|
FavoriteCube = request.FavoriteCube,
|
||||||
|
Bio = request.Bio,
|
||||||
|
};
|
||||||
|
|
||||||
|
profileStore.ValidateAdminUpdate(fallbackDisplayName, siteProfileRequest);
|
||||||
|
|
||||||
|
var createdIdentity = await keycloak.CreateAdminUserAsync(
|
||||||
|
new AdminIdentityUserCreateRequest(
|
||||||
|
normalized.Username,
|
||||||
|
normalized.Email,
|
||||||
|
normalized.Password,
|
||||||
|
normalized.FirstName,
|
||||||
|
normalized.LastName,
|
||||||
|
normalized.IsEnabled,
|
||||||
|
normalized.IsEmailVerified),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
var createdProfile = await profileStore.AdminUpsertAsync(
|
||||||
|
createdIdentity.Subject,
|
||||||
|
createdIdentity.Username,
|
||||||
|
createdIdentity.Email,
|
||||||
|
BuildIdentityDisplayName(createdIdentity),
|
||||||
|
siteProfileRequest,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return TypedResults.Created($"/api/admin/users/{Uri.EscapeDataString(createdIdentity.Subject)}", MapAdminDetail(createdIdentity, createdProfile));
|
||||||
|
}
|
||||||
|
catch (AdminUserValidationException exception)
|
||||||
|
{
|
||||||
|
return TypedResults.BadRequest(new ApiErrorResponse(exception.Message));
|
||||||
|
}
|
||||||
|
catch (UserProfileValidationException exception)
|
||||||
|
{
|
||||||
|
return TypedResults.BadRequest(new ApiErrorResponse(exception.Message));
|
||||||
|
}
|
||||||
|
catch (KeycloakAuthException exception)
|
||||||
|
{
|
||||||
|
return TypedResults.Json(new ApiErrorResponse(exception.Message), statusCode: exception.StatusCode);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
adminGroup.MapGet("/users/{subject}", async Task<IResult> (
|
adminGroup.MapGet("/users/{subject}", async Task<IResult> (
|
||||||
string subject,
|
string subject,
|
||||||
KeycloakAuthService keycloak,
|
KeycloakAuthService keycloak,
|
||||||
@@ -167,6 +224,24 @@ adminGroup.MapGet("/users/{subject}", async Task<IResult> (
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
adminGroup.MapDelete("/users/{subject}", async Task<IResult> (
|
||||||
|
string subject,
|
||||||
|
KeycloakAuthService keycloak,
|
||||||
|
MySqlUserProfileStore profileStore,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await keycloak.DeleteAdminUserAsync(subject, cancellationToken);
|
||||||
|
await profileStore.DeleteAsync(subject, cancellationToken);
|
||||||
|
return TypedResults.NoContent();
|
||||||
|
}
|
||||||
|
catch (KeycloakAuthException exception)
|
||||||
|
{
|
||||||
|
return TypedResults.Json(new ApiErrorResponse(exception.Message), statusCode: exception.StatusCode);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
adminGroup.MapPut("/users/{subject}", async Task<IResult> (
|
adminGroup.MapPut("/users/{subject}", async Task<IResult> (
|
||||||
string subject,
|
string subject,
|
||||||
AdminUpdateUserRequest request,
|
AdminUpdateUserRequest request,
|
||||||
@@ -360,6 +435,24 @@ static NormalizedAdminUserUpdate NormalizeAdminUpdate(AdminUpdateUserRequest req
|
|||||||
request.IsEmailVerified);
|
request.IsEmailVerified);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static NormalizedAdminCreateUser NormalizeAdminCreate(AdminCreateUserRequest request)
|
||||||
|
{
|
||||||
|
var username = NormalizeRequiredValue(request.Username, "nom d'utilisateur", 120);
|
||||||
|
var email = NormalizeEmail(request.Email);
|
||||||
|
var password = NormalizePassword(request.Password, request.ConfirmPassword);
|
||||||
|
var firstName = NormalizeOptionalValue(request.FirstName, "prenom", 120);
|
||||||
|
var lastName = NormalizeOptionalValue(request.LastName, "nom", 120);
|
||||||
|
|
||||||
|
return new NormalizedAdminCreateUser(
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
request.IsEnabled,
|
||||||
|
request.IsEmailVerified);
|
||||||
|
}
|
||||||
|
|
||||||
static string BuildIdentityDisplayName(AdminIdentityUser identity)
|
static string BuildIdentityDisplayName(AdminIdentityUser identity)
|
||||||
=> BuildIdentityDisplayNameFromParts(identity.FirstName, identity.LastName, identity.Username);
|
=> BuildIdentityDisplayNameFromParts(identity.FirstName, identity.LastName, identity.Username);
|
||||||
|
|
||||||
@@ -396,6 +489,26 @@ static string? NormalizeEmail(string? value)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static string NormalizePassword(string? password, string? confirmPassword)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(password))
|
||||||
|
{
|
||||||
|
throw new AdminUserValidationException("Le mot de passe est obligatoire.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(password, confirmPassword, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new AdminUserValidationException("Les mots de passe ne correspondent pas.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.Length < 8)
|
||||||
|
{
|
||||||
|
throw new AdminUserValidationException("Le mot de passe doit contenir au moins 8 caracteres.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return password;
|
||||||
|
}
|
||||||
|
|
||||||
static string? NormalizeOptionalValue(string? value, string fieldName, int maxLength)
|
static string? NormalizeOptionalValue(string? value, string fieldName, int maxLength)
|
||||||
{
|
{
|
||||||
var trimmed = value?.Trim();
|
var trimmed = value?.Trim();
|
||||||
@@ -468,3 +581,12 @@ sealed record NormalizedAdminUserUpdate(
|
|||||||
string? LastName,
|
string? LastName,
|
||||||
bool IsEnabled,
|
bool IsEnabled,
|
||||||
bool IsEmailVerified);
|
bool IsEmailVerified);
|
||||||
|
|
||||||
|
sealed record NormalizedAdminCreateUser(
|
||||||
|
string Username,
|
||||||
|
string? Email,
|
||||||
|
string Password,
|
||||||
|
string? FirstName,
|
||||||
|
string? LastName,
|
||||||
|
bool IsEnabled,
|
||||||
|
bool IsEmailVerified);
|
||||||
|
|||||||
@@ -123,6 +123,11 @@ public sealed class MySqlUserProfileStore(
|
|||||||
ORDER BY updated_utc DESC, created_utc DESC, username ASC;
|
ORDER BY updated_utc DESC, created_utc DESC, username ASC;
|
||||||
""";
|
""";
|
||||||
|
|
||||||
|
private const string DeleteProfileSql = """
|
||||||
|
DELETE FROM site_users
|
||||||
|
WHERE subject = @subject;
|
||||||
|
""";
|
||||||
|
|
||||||
private readonly SiteDataOptions _options = options.Value;
|
private readonly SiteDataOptions _options = options.Value;
|
||||||
private readonly ILogger<MySqlUserProfileStore> _logger = logger;
|
private readonly ILogger<MySqlUserProfileStore> _logger = logger;
|
||||||
|
|
||||||
@@ -285,6 +290,17 @@ public sealed class MySqlUserProfileStore(
|
|||||||
return await ReadProfileAsync(connection, subject, cancellationToken);
|
return await ReadProfileAsync(connection, subject, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(string subject, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var connection = new MySqlConnection(_options.BuildConnectionString());
|
||||||
|
await connection.OpenAsync(cancellationToken);
|
||||||
|
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = DeleteProfileSql;
|
||||||
|
command.Parameters.AddWithValue("@subject", subject);
|
||||||
|
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
private static UserProfileInput NormalizeInput(AuthenticatedSiteUser user, UpdateUserProfileRequest request)
|
private static UserProfileInput NormalizeInput(AuthenticatedSiteUser user, UpdateUserProfileRequest request)
|
||||||
=> NormalizeInput(user.DisplayName, request);
|
=> NormalizeInput(user.DisplayName, request);
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ L'application embarque maintenant une authentification integree basee sur Keyclo
|
|||||||
- les roles Keycloak du realm restent exposes dans l'application
|
- les roles Keycloak du realm restent exposes dans l'application
|
||||||
- l'etat du match est isole par utilisateur dans le navigateur grace a une cle de stockage derivee du compte connecte
|
- l'etat du match est isole par utilisateur dans le navigateur grace a une cle de stockage derivee du compte connecte
|
||||||
- une page `utilisateur` permet maintenant d'editer un profil du site persiste en base MySQL via `/api/users/me`
|
- une page `utilisateur` permet maintenant d'editer un profil du site persiste en base MySQL via `/api/users/me`
|
||||||
- une page `administration` reservee au role `admin` permet maintenant de parcourir et modifier les utilisateurs du site via `/api/admin/users`
|
- une page `administration` reservee au role `admin` propose maintenant une table utilisateurs avec actions d'ajout, modification et suppression via `/api/admin/users`
|
||||||
|
|
||||||
Le realm importe par defaut :
|
Le realm importe par defaut :
|
||||||
|
|
||||||
|
|||||||
136
styles.css
136
styles.css
@@ -1582,84 +1582,94 @@ body.site-menu-hidden .site-menu-shell {
|
|||||||
margin-top: 0.8rem;
|
margin-top: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-list-panel,
|
.admin-table-panel,
|
||||||
.admin-detail-panel {
|
.admin-detail-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-detail-panel {
|
|
||||||
grid-column: span 8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-toolbar {
|
.admin-toolbar {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.9rem;
|
gap: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-toolbar-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-search-field {
|
.admin-search-field {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-user-list {
|
.admin-toolbar-actions {
|
||||||
display: grid;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
max-height: 60rem;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-user-card {
|
.admin-table-shell {
|
||||||
appearance: none;
|
overflow-x: auto;
|
||||||
width: 100%;
|
border-radius: 24px;
|
||||||
display: grid;
|
|
||||||
gap: 0.8rem;
|
|
||||||
padding: 1rem;
|
|
||||||
border: 1px solid var(--panel-border);
|
border: 1px solid var(--panel-border);
|
||||||
border-radius: 22px;
|
|
||||||
background: var(--panel-alt);
|
background: var(--panel-alt);
|
||||||
color: var(--text);
|
}
|
||||||
|
|
||||||
|
.admin-table {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 980px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table th,
|
||||||
|
.admin-table td {
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid var(--panel-border);
|
||||||
|
vertical-align: middle;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
|
||||||
transition:
|
|
||||||
transform 160ms ease,
|
|
||||||
border-color 160ms ease,
|
|
||||||
background 160ms ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-user-card:hover {
|
.admin-table th {
|
||||||
transform: translateY(-2px);
|
color: var(--muted);
|
||||||
border-color: rgba(52, 141, 255, 0.32);
|
font-size: 0.85rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-user-card.is-selected {
|
.admin-table tbody tr {
|
||||||
border-color: rgba(52, 141, 255, 0.52);
|
transition: background 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table tbody tr:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table tbody tr.is-selected {
|
||||||
background: linear-gradient(180deg, rgba(17, 103, 255, 0.14), rgba(17, 103, 255, 0.08));
|
background: linear-gradient(180deg, rgba(17, 103, 255, 0.14), rgba(17, 103, 255, 0.08));
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-user-card-head {
|
.admin-table tbody tr:last-child td {
|
||||||
display: flex;
|
border-bottom: 0;
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.75rem;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-user-card-head strong {
|
.admin-cell-stack {
|
||||||
display: block;
|
|
||||||
margin-bottom: 0.15rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-user-card-head span,
|
|
||||||
.admin-user-card-meta {
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-user-card-meta {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.3rem;
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-cell-stack span {
|
||||||
|
color: var(--muted);
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-actions-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-chip-success {
|
.admin-chip-success {
|
||||||
background: rgba(69, 185, 127, 0.14);
|
background: rgba(69, 185, 127, 0.14);
|
||||||
color: #dff7ea;
|
color: #dff7ea;
|
||||||
@@ -1670,6 +1680,16 @@ body.site-menu-hidden .site-menu-shell {
|
|||||||
color: #ffd8de;
|
color: #ffd8de;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-chip-neutral {
|
||||||
|
background: rgba(52, 141, 255, 0.14);
|
||||||
|
color: #d6e5ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-chip-outline {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
.admin-toggle-grid,
|
.admin-toggle-grid,
|
||||||
.admin-form-grid {
|
.admin-form-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -1703,6 +1723,14 @@ body.site-menu-hidden .site-menu-shell {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-modal-card {
|
||||||
|
width: min(980px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-confirm-card {
|
||||||
|
width: min(560px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.hero,
|
.hero,
|
||||||
.setup-grid,
|
.setup-grid,
|
||||||
@@ -1732,10 +1760,6 @@ body.site-menu-hidden .site-menu-shell {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-detail-panel {
|
|
||||||
grid-column: span 12;
|
|
||||||
}
|
|
||||||
|
|
||||||
.phase-header {
|
.phase-header {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -1950,6 +1974,18 @@ body.site-menu-hidden .site-menu-shell {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-toolbar-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-toolbar-actions {
|
||||||
|
justify-content: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-toolbar-actions .button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.setup-actions,
|
.setup-actions,
|
||||||
.resume-actions,
|
.resume-actions,
|
||||||
.modal-actions {
|
.modal-actions {
|
||||||
@@ -1966,10 +2002,6 @@ body.site-menu-hidden .site-menu-shell {
|
|||||||
padding: 0.55rem 0 0.55rem;
|
padding: 0.55rem 0 0.55rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-user-list {
|
|
||||||
max-height: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.phase-header {
|
.phase-header {
|
||||||
grid-template-columns: auto 1fr auto;
|
grid-template-columns: auto 1fr auto;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user