Ajoute une zone d'administration des utilisateurs
This commit is contained in:
116
ChessCubing.Server/Admin/AdminUserContracts.cs
Normal file
116
ChessCubing.Server/Admin/AdminUserContracts.cs
Normal file
@@ -0,0 +1,116 @@
|
||||
namespace ChessCubing.Server.Admin;
|
||||
|
||||
public sealed class AdminUserSummaryResponse
|
||||
{
|
||||
public string Subject { get; init; } = string.Empty;
|
||||
|
||||
public string Username { get; init; } = string.Empty;
|
||||
|
||||
public string? Email { get; init; }
|
||||
|
||||
public string IdentityDisplayName { get; init; } = string.Empty;
|
||||
|
||||
public string? SiteDisplayName { get; init; }
|
||||
|
||||
public bool IsEnabled { get; init; }
|
||||
|
||||
public bool IsEmailVerified { get; init; }
|
||||
|
||||
public bool HasSiteProfile { get; init; }
|
||||
|
||||
public string? Club { get; init; }
|
||||
|
||||
public string? City { get; init; }
|
||||
|
||||
public string? PreferredFormat { get; init; }
|
||||
|
||||
public DateTime? AccountCreatedUtc { get; init; }
|
||||
|
||||
public DateTime? SiteProfileUpdatedUtc { get; init; }
|
||||
}
|
||||
|
||||
public sealed class AdminUserDetailResponse
|
||||
{
|
||||
public string Subject { get; init; } = string.Empty;
|
||||
|
||||
public string Username { get; init; } = string.Empty;
|
||||
|
||||
public string? Email { get; init; }
|
||||
|
||||
public string? FirstName { get; init; }
|
||||
|
||||
public string? LastName { get; init; }
|
||||
|
||||
public string IdentityDisplayName { get; init; } = string.Empty;
|
||||
|
||||
public bool IsEnabled { get; init; }
|
||||
|
||||
public bool IsEmailVerified { get; init; }
|
||||
|
||||
public DateTime? AccountCreatedUtc { get; init; }
|
||||
|
||||
public bool HasSiteProfile { get; init; }
|
||||
|
||||
public string DisplayName { get; init; } = string.Empty;
|
||||
|
||||
public string? Club { get; init; }
|
||||
|
||||
public string? City { get; init; }
|
||||
|
||||
public string? PreferredFormat { get; init; }
|
||||
|
||||
public string? FavoriteCube { get; init; }
|
||||
|
||||
public string? Bio { get; init; }
|
||||
|
||||
public DateTime? SiteProfileCreatedUtc { get; init; }
|
||||
|
||||
public DateTime? SiteProfileUpdatedUtc { get; init; }
|
||||
}
|
||||
|
||||
public sealed class AdminUpdateUserRequest
|
||||
{
|
||||
public string Username { get; init; } = string.Empty;
|
||||
|
||||
public string? Email { get; init; }
|
||||
|
||||
public string? FirstName { get; init; }
|
||||
|
||||
public string? LastName { get; init; }
|
||||
|
||||
public bool IsEnabled { get; init; }
|
||||
|
||||
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(
|
||||
string Subject,
|
||||
string Username,
|
||||
string? Email,
|
||||
string? FirstName,
|
||||
string? LastName,
|
||||
bool IsEnabled,
|
||||
bool IsEmailVerified,
|
||||
DateTime? CreatedUtc);
|
||||
|
||||
public sealed record AdminIdentityUserUpdateRequest(
|
||||
string Username,
|
||||
string? Email,
|
||||
string? FirstName,
|
||||
string? LastName,
|
||||
bool IsEnabled,
|
||||
bool IsEmailVerified);
|
||||
|
||||
public sealed class AdminUserValidationException(string message) : Exception(message);
|
||||
@@ -3,6 +3,7 @@ using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ChessCubing.Server.Admin;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ChessCubing.Server.Auth;
|
||||
@@ -14,6 +15,11 @@ public sealed class KeycloakAuthService(HttpClient httpClient, IOptions<Keycloak
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions UpdateJsonOptions = new(JsonOptions)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
||||
};
|
||||
|
||||
private readonly HttpClient _httpClient = httpClient;
|
||||
private readonly KeycloakAuthOptions _options = options.Value;
|
||||
|
||||
@@ -32,6 +38,87 @@ public sealed class KeycloakAuthService(HttpClient httpClient, IOptions<Keycloak
|
||||
return await LoginAsync(request.Username, request.Password, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AdminIdentityUser>> GetAdminUsersAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var adminToken = await RequestAdminTokenAsync(cancellationToken);
|
||||
var users = new List<AdminIdentityUser>();
|
||||
const int pageSize = 100;
|
||||
|
||||
for (var first = 0; ; first += pageSize)
|
||||
{
|
||||
using var request = new HttpRequestMessage(
|
||||
HttpMethod.Get,
|
||||
$"{GetAdminBaseUrl()}/users?first={first}&max={pageSize}&briefRepresentation=false");
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new KeycloakAuthException("Impossible de recuperer la liste des utilisateurs Keycloak.", (int)response.StatusCode);
|
||||
}
|
||||
|
||||
var page = await ReadJsonAsync<List<AdminUserRepresentation>>(response, cancellationToken) ?? [];
|
||||
users.AddRange(page
|
||||
.Where(user => !string.IsNullOrWhiteSpace(user.Id) && !string.IsNullOrWhiteSpace(user.Username))
|
||||
.Select(MapAdminIdentityUser));
|
||||
|
||||
if (page.Count < pageSize)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
public async Task<AdminIdentityUser> GetAdminUserAsync(string userId, CancellationToken cancellationToken)
|
||||
{
|
||||
var adminToken = await RequestAdminTokenAsync(cancellationToken);
|
||||
return await GetAdminUserAsync(adminToken, userId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<AdminIdentityUser> UpdateAdminUserAsync(
|
||||
string userId,
|
||||
AdminIdentityUserUpdateRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var adminToken = await RequestAdminTokenAsync(cancellationToken);
|
||||
|
||||
using (var httpRequest = new HttpRequestMessage(HttpMethod.Put, $"{GetAdminBaseUrl()}/users/{Uri.EscapeDataString(userId)}")
|
||||
{
|
||||
Content = JsonContent.Create(new
|
||||
{
|
||||
username = request.Username,
|
||||
email = request.Email,
|
||||
enabled = request.IsEnabled,
|
||||
emailVerified = request.IsEmailVerified,
|
||||
firstName = request.FirstName,
|
||||
lastName = request.LastName,
|
||||
}, options: UpdateJsonOptions)
|
||||
})
|
||||
{
|
||||
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken);
|
||||
if (response.StatusCode == HttpStatusCode.Conflict)
|
||||
{
|
||||
throw new KeycloakAuthException("Ce nom d'utilisateur ou cet email existe deja.", StatusCodes.Status409Conflict);
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
throw new KeycloakAuthException("Utilisateur introuvable dans Keycloak.", StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new KeycloakAuthException("La mise a jour du compte Keycloak a echoue.", (int)response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
return await GetAdminUserAsync(adminToken, userId, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<TokenSuccessResponse> RequestPasswordTokenAsync(string username, string password, CancellationToken cancellationToken)
|
||||
{
|
||||
var formData = new Dictionary<string, string>
|
||||
@@ -235,6 +322,44 @@ public sealed class KeycloakAuthService(HttpClient httpClient, IOptions<Keycloak
|
||||
return users?.FirstOrDefault()?.Id;
|
||||
}
|
||||
|
||||
private async Task<AdminIdentityUser> GetAdminUserAsync(string adminToken, string userId, CancellationToken cancellationToken)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, $"{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("Impossible de recuperer le compte Keycloak.", (int)response.StatusCode);
|
||||
}
|
||||
|
||||
var user = await ReadJsonAsync<AdminUserRepresentation>(response, cancellationToken);
|
||||
if (user is null || string.IsNullOrWhiteSpace(user.Id) || string.IsNullOrWhiteSpace(user.Username))
|
||||
{
|
||||
throw new KeycloakAuthException("Le compte Keycloak est invalide.");
|
||||
}
|
||||
|
||||
return MapAdminIdentityUser(user);
|
||||
}
|
||||
|
||||
private static AdminIdentityUser MapAdminIdentityUser(AdminUserRepresentation user)
|
||||
=> new(
|
||||
user.Id!,
|
||||
user.Username!,
|
||||
user.Email,
|
||||
user.FirstName,
|
||||
user.LastName,
|
||||
user.Enabled ?? true,
|
||||
user.EmailVerified ?? false,
|
||||
user.CreatedTimestamp is > 0
|
||||
? DateTimeOffset.FromUnixTimeMilliseconds(user.CreatedTimestamp.Value).UtcDateTime
|
||||
: null);
|
||||
|
||||
private string[] ExtractRealmRoles(string accessToken)
|
||||
{
|
||||
try
|
||||
@@ -326,6 +451,33 @@ public sealed class KeycloakAuthService(HttpClient httpClient, IOptions<Keycloak
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
private sealed class AdminUserRepresentation
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string? Id { get; init; }
|
||||
|
||||
[JsonPropertyName("username")]
|
||||
public string? Username { get; init; }
|
||||
|
||||
[JsonPropertyName("email")]
|
||||
public string? Email { get; init; }
|
||||
|
||||
[JsonPropertyName("firstName")]
|
||||
public string? FirstName { get; init; }
|
||||
|
||||
[JsonPropertyName("lastName")]
|
||||
public string? LastName { get; init; }
|
||||
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool? Enabled { get; init; }
|
||||
|
||||
[JsonPropertyName("emailVerified")]
|
||||
public bool? EmailVerified { get; init; }
|
||||
|
||||
[JsonPropertyName("createdTimestamp")]
|
||||
public long? CreatedTimestamp { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class KeycloakAuthException(string message, int statusCode = StatusCodes.Status400BadRequest) : Exception(message)
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
using System.Security.Claims;
|
||||
using System.Net.Mail;
|
||||
using ChessCubing.Server.Admin;
|
||||
using ChessCubing.Server.Auth;
|
||||
using ChessCubing.Server.Data;
|
||||
using ChessCubing.Server.Users;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -58,7 +61,10 @@ builder.Services
|
||||
};
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddPolicy("AdminOnly", policy => policy.RequireRole("admin"));
|
||||
});
|
||||
builder.Services.AddHttpClient<KeycloakAuthService>();
|
||||
builder.Services.AddSingleton<MySqlUserProfileStore>();
|
||||
|
||||
@@ -116,6 +122,109 @@ app.MapPut("/api/users/me", async Task<IResult> (
|
||||
}
|
||||
}).RequireAuthorization();
|
||||
|
||||
var adminGroup = app.MapGroup("/api/admin")
|
||||
.RequireAuthorization("AdminOnly");
|
||||
|
||||
adminGroup.MapGet("/users", async Task<IResult> (
|
||||
KeycloakAuthService keycloak,
|
||||
MySqlUserProfileStore profileStore,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var identityUsersTask = keycloak.GetAdminUsersAsync(cancellationToken);
|
||||
var siteProfilesTask = profileStore.ListAsync(cancellationToken);
|
||||
|
||||
await Task.WhenAll(identityUsersTask, siteProfilesTask);
|
||||
|
||||
var siteProfilesBySubject = (await siteProfilesTask)
|
||||
.ToDictionary(profile => profile.Subject, StringComparer.Ordinal);
|
||||
|
||||
var users = (await identityUsersTask)
|
||||
.Select(identity => MapAdminSummary(identity, siteProfilesBySubject.GetValueOrDefault(identity.Subject)))
|
||||
.OrderByDescending(user => user.SiteProfileUpdatedUtc ?? user.AccountCreatedUtc ?? DateTime.MinValue)
|
||||
.ThenBy(user => user.Username, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
return TypedResults.Ok(users);
|
||||
});
|
||||
|
||||
adminGroup.MapGet("/users/{subject}", async Task<IResult> (
|
||||
string subject,
|
||||
KeycloakAuthService keycloak,
|
||||
MySqlUserProfileStore profileStore,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var identityTask = keycloak.GetAdminUserAsync(subject, cancellationToken);
|
||||
var profileTask = profileStore.FindBySubjectAsync(subject, cancellationToken);
|
||||
|
||||
await Task.WhenAll(identityTask, profileTask);
|
||||
return TypedResults.Ok(MapAdminDetail(await identityTask, await profileTask));
|
||||
}
|
||||
catch (KeycloakAuthException exception)
|
||||
{
|
||||
return TypedResults.Json(new ApiErrorResponse(exception.Message), statusCode: exception.StatusCode);
|
||||
}
|
||||
});
|
||||
|
||||
adminGroup.MapPut("/users/{subject}", async Task<IResult> (
|
||||
string subject,
|
||||
AdminUpdateUserRequest request,
|
||||
KeycloakAuthService keycloak,
|
||||
MySqlUserProfileStore profileStore,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var normalized = NormalizeAdminUpdate(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 updatedIdentity = await keycloak.UpdateAdminUserAsync(
|
||||
subject,
|
||||
new AdminIdentityUserUpdateRequest(
|
||||
normalized.Username,
|
||||
normalized.Email,
|
||||
normalized.FirstName,
|
||||
normalized.LastName,
|
||||
normalized.IsEnabled,
|
||||
normalized.IsEmailVerified),
|
||||
cancellationToken);
|
||||
|
||||
var updatedProfile = await profileStore.AdminUpsertAsync(
|
||||
updatedIdentity.Subject,
|
||||
updatedIdentity.Username,
|
||||
updatedIdentity.Email,
|
||||
BuildIdentityDisplayName(updatedIdentity),
|
||||
siteProfileRequest,
|
||||
cancellationToken);
|
||||
|
||||
return TypedResults.Ok(MapAdminDetail(updatedIdentity, updatedProfile));
|
||||
}
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
app.MapPost("/api/auth/login", async Task<IResult> (
|
||||
LoginRequest request,
|
||||
HttpContext httpContext,
|
||||
@@ -184,6 +293,119 @@ app.MapPost("/api/auth/logout", async Task<IResult> (HttpContext httpContext) =>
|
||||
|
||||
app.Run();
|
||||
|
||||
static AdminUserSummaryResponse MapAdminSummary(AdminIdentityUser identity, UserProfileResponse? profile)
|
||||
=> new()
|
||||
{
|
||||
Subject = identity.Subject,
|
||||
Username = identity.Username,
|
||||
Email = identity.Email,
|
||||
IdentityDisplayName = BuildIdentityDisplayName(identity),
|
||||
SiteDisplayName = profile?.DisplayName,
|
||||
IsEnabled = identity.IsEnabled,
|
||||
IsEmailVerified = identity.IsEmailVerified,
|
||||
HasSiteProfile = profile is not null,
|
||||
Club = profile?.Club,
|
||||
City = profile?.City,
|
||||
PreferredFormat = profile?.PreferredFormat,
|
||||
AccountCreatedUtc = identity.CreatedUtc,
|
||||
SiteProfileUpdatedUtc = profile?.UpdatedUtc,
|
||||
};
|
||||
|
||||
static AdminUserDetailResponse MapAdminDetail(AdminIdentityUser identity, UserProfileResponse? profile)
|
||||
{
|
||||
var identityDisplayName = BuildIdentityDisplayName(identity);
|
||||
|
||||
return new AdminUserDetailResponse
|
||||
{
|
||||
Subject = identity.Subject,
|
||||
Username = identity.Username,
|
||||
Email = identity.Email,
|
||||
FirstName = identity.FirstName,
|
||||
LastName = identity.LastName,
|
||||
IdentityDisplayName = identityDisplayName,
|
||||
IsEnabled = identity.IsEnabled,
|
||||
IsEmailVerified = identity.IsEmailVerified,
|
||||
AccountCreatedUtc = identity.CreatedUtc,
|
||||
HasSiteProfile = profile is not null,
|
||||
DisplayName = profile?.DisplayName ?? identityDisplayName,
|
||||
Club = profile?.Club,
|
||||
City = profile?.City,
|
||||
PreferredFormat = profile?.PreferredFormat,
|
||||
FavoriteCube = profile?.FavoriteCube,
|
||||
Bio = profile?.Bio,
|
||||
SiteProfileCreatedUtc = profile?.CreatedUtc,
|
||||
SiteProfileUpdatedUtc = profile?.UpdatedUtc,
|
||||
};
|
||||
}
|
||||
|
||||
static NormalizedAdminUserUpdate NormalizeAdminUpdate(AdminUpdateUserRequest request)
|
||||
{
|
||||
var username = NormalizeRequiredValue(request.Username, "nom d'utilisateur", 120);
|
||||
var email = NormalizeEmail(request.Email);
|
||||
var firstName = NormalizeOptionalValue(request.FirstName, "prenom", 120);
|
||||
var lastName = NormalizeOptionalValue(request.LastName, "nom", 120);
|
||||
|
||||
return new NormalizedAdminUserUpdate(
|
||||
username,
|
||||
email,
|
||||
firstName,
|
||||
lastName,
|
||||
request.IsEnabled,
|
||||
request.IsEmailVerified);
|
||||
}
|
||||
|
||||
static string BuildIdentityDisplayName(AdminIdentityUser identity)
|
||||
=> BuildIdentityDisplayNameFromParts(identity.FirstName, identity.LastName, identity.Username);
|
||||
|
||||
static string BuildIdentityDisplayNameFromParts(string? firstName, string? lastName, string username)
|
||||
{
|
||||
var fullName = string.Join(' ', new[] { firstName, lastName }.Where(value => !string.IsNullOrWhiteSpace(value)));
|
||||
return string.IsNullOrWhiteSpace(fullName)
|
||||
? username
|
||||
: fullName;
|
||||
}
|
||||
|
||||
static string NormalizeRequiredValue(string? value, string fieldName, int maxLength)
|
||||
{
|
||||
var normalized = NormalizeOptionalValue(value, fieldName, maxLength);
|
||||
return normalized ?? throw new AdminUserValidationException($"Le champ {fieldName} est obligatoire.");
|
||||
}
|
||||
|
||||
static string? NormalizeEmail(string? value)
|
||||
{
|
||||
var normalized = NormalizeOptionalValue(value, "email", 255);
|
||||
if (normalized is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_ = new MailAddress(normalized);
|
||||
return normalized;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
throw new AdminUserValidationException("L'email n'est pas valide.");
|
||||
}
|
||||
}
|
||||
|
||||
static string? NormalizeOptionalValue(string? value, string fieldName, int maxLength)
|
||||
{
|
||||
var trimmed = value?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(trimmed))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (trimmed.Length > maxLength)
|
||||
{
|
||||
throw new AdminUserValidationException($"Le champ {fieldName} depasse {maxLength} caracteres.");
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
static async Task SignInAsync(HttpContext httpContext, KeycloakUserInfo userInfo)
|
||||
{
|
||||
var claims = new List<Claim>();
|
||||
@@ -232,3 +454,11 @@ static async Task SignInAsync(HttpContext httpContext, KeycloakUserInfo userInfo
|
||||
|
||||
httpContext.User = principal;
|
||||
}
|
||||
|
||||
sealed record NormalizedAdminUserUpdate(
|
||||
string Username,
|
||||
string? Email,
|
||||
string? FirstName,
|
||||
string? LastName,
|
||||
bool IsEnabled,
|
||||
bool IsEmailVerified);
|
||||
|
||||
@@ -106,6 +106,23 @@ public sealed class MySqlUserProfileStore(
|
||||
LIMIT 1;
|
||||
""";
|
||||
|
||||
private const string SelectAllProfilesSql = """
|
||||
SELECT
|
||||
subject,
|
||||
username,
|
||||
email,
|
||||
display_name,
|
||||
club,
|
||||
city,
|
||||
preferred_format,
|
||||
favorite_cube,
|
||||
bio,
|
||||
created_utc,
|
||||
updated_utc
|
||||
FROM site_users
|
||||
ORDER BY updated_utc DESC, created_utc DESC, username ASC;
|
||||
""";
|
||||
|
||||
private readonly SiteDataOptions _options = options.Value;
|
||||
private readonly ILogger<MySqlUserProfileStore> _logger = logger;
|
||||
|
||||
@@ -198,9 +215,82 @@ public sealed class MySqlUserProfileStore(
|
||||
return await ReadProfileAsync(connection, user.Subject, cancellationToken);
|
||||
}
|
||||
|
||||
private static UserProfileInput NormalizeInput(AuthenticatedSiteUser user, UpdateUserProfileRequest request)
|
||||
public void ValidateAdminUpdate(string fallbackDisplayName, UpdateUserProfileRequest request)
|
||||
=> _ = NormalizeInput(fallbackDisplayName, request);
|
||||
|
||||
public async Task<IReadOnlyList<UserProfileResponse>> ListAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var displayName = NormalizeOptionalValue(request.DisplayName, "nom affiche", 120) ?? user.DisplayName;
|
||||
await using var connection = new MySqlConnection(_options.BuildConnectionString());
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = SelectAllProfilesSql;
|
||||
|
||||
var profiles = new List<UserProfileResponse>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
profiles.Add(MapProfile(reader));
|
||||
}
|
||||
|
||||
return profiles;
|
||||
}
|
||||
|
||||
public async Task<UserProfileResponse?> FindBySubjectAsync(string subject, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = new MySqlConnection(_options.BuildConnectionString());
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = SelectProfileSql;
|
||||
command.Parameters.AddWithValue("@subject", subject);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
return await reader.ReadAsync(cancellationToken)
|
||||
? MapProfile(reader)
|
||||
: null;
|
||||
}
|
||||
|
||||
public async Task<UserProfileResponse> AdminUpsertAsync(
|
||||
string subject,
|
||||
string username,
|
||||
string? email,
|
||||
string fallbackDisplayName,
|
||||
UpdateUserProfileRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var input = NormalizeInput(fallbackDisplayName, request);
|
||||
await using var connection = new MySqlConnection(_options.BuildConnectionString());
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
|
||||
await using (var command = connection.CreateCommand())
|
||||
{
|
||||
command.CommandText = UpsertProfileSql;
|
||||
command.Parameters.AddWithValue("@subject", subject);
|
||||
command.Parameters.AddWithValue("@username", username);
|
||||
command.Parameters.AddWithValue("@email", (object?)email ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("@displayName", input.DisplayName);
|
||||
command.Parameters.AddWithValue("@club", (object?)input.Club ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("@city", (object?)input.City ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("@preferredFormat", (object?)input.PreferredFormat ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("@favoriteCube", (object?)input.FavoriteCube ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("@bio", (object?)input.Bio ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("@createdUtc", nowUtc);
|
||||
command.Parameters.AddWithValue("@updatedUtc", nowUtc);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return await ReadProfileAsync(connection, subject, cancellationToken);
|
||||
}
|
||||
|
||||
private static UserProfileInput NormalizeInput(AuthenticatedSiteUser user, UpdateUserProfileRequest request)
|
||||
=> NormalizeInput(user.DisplayName, request);
|
||||
|
||||
private static UserProfileInput NormalizeInput(string fallbackDisplayName, UpdateUserProfileRequest request)
|
||||
{
|
||||
var displayName = NormalizeOptionalValue(request.DisplayName, "nom affiche", 120) ?? fallbackDisplayName;
|
||||
var club = NormalizeOptionalValue(request.Club, "club", 120);
|
||||
var city = NormalizeOptionalValue(request.City, "ville", 120);
|
||||
var favoriteCube = NormalizeOptionalValue(request.FavoriteCube, "cube favori", 120);
|
||||
@@ -258,6 +348,11 @@ public sealed class MySqlUserProfileStore(
|
||||
throw new InvalidOperationException("Le profil utilisateur n'a pas pu etre charge.");
|
||||
}
|
||||
|
||||
return MapProfile(reader);
|
||||
}
|
||||
|
||||
private static UserProfileResponse MapProfile(MySqlDataReader reader)
|
||||
{
|
||||
var subjectOrdinal = reader.GetOrdinal("subject");
|
||||
var usernameOrdinal = reader.GetOrdinal("username");
|
||||
var emailOrdinal = reader.GetOrdinal("email");
|
||||
|
||||
Reference in New Issue
Block a user