Fix role change silently failing via @bind

The role <select> used a manual value=/@onchange pattern that parsed
e.Value and returned silently when the parse failed, so changing a role
did nothing and showed no message. Switch to @bind + @bind:after so the
framework handles the enum conversion, and log/verify the persisted role
in UpdateRoleAsync (now returns the previous role) for diagnosis.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 10:10:00 +02:00
parent 98683bbd5e
commit cdc93d041a
3 changed files with 25 additions and 13 deletions
+6 -9
View File
@@ -86,12 +86,11 @@ else
</td> </td>
<td style="padding:8px"> <td style="padding:8px">
<select class="form-input" style="width:130px" <select class="form-input" style="width:130px"
value="@user.Role" @bind="user.Role" @bind:after="() => PersistRoleAsync(user)"
@onchange="e => OnRoleChange(user, e)"
disabled="@(user.Email == UserContext.Email)"> disabled="@(user.Email == UserContext.Email)">
@foreach (var role in Enum.GetValues<UserRole>()) @foreach (var role in Enum.GetValues<UserRole>())
{ {
<option value="@role" selected="@(user.Role == role)">@role</option> <option value="@role">@role</option>
} }
</select> </select>
</td> </td>
@@ -193,16 +192,14 @@ else
} }
} }
private async Task OnRoleChange(AppUser user, ChangeEventArgs e) // Bound via @bind:after, so user.Role already holds the newly-selected value when this runs.
private async Task PersistRoleAsync(AppUser user)
{ {
if (!Enum.TryParse<UserRole>(e.Value?.ToString(), out var newRole)) return;
try try
{ {
var oldRole = user.Role; var oldRole = await UserService.UpdateRoleAsync(user.Id, user.Role);
await UserService.UpdateRoleAsync(user.Id, newRole);
user.Role = newRole;
await Audit.LogAsync("RoleChanged", "", Array.Empty<string>(), await Audit.LogAsync("RoleChanged", "", Array.Empty<string>(),
$"Changed role for {user.Email} ({user.DisplayName}) from {oldRole} to {newRole}."); $"Changed role for {user.Email} ({user.DisplayName}) from {oldRole} to {user.Role}.");
_message = string.Format(T["usermgmt.msg.roleupdated"], user.DisplayName); _message = string.Format(T["usermgmt.msg.roleupdated"], user.DisplayName);
_isError = false; _isError = false;
} }
+3 -1
View File
@@ -11,7 +11,9 @@ public interface IUserService
Task<AppUser?> GetByEmailAsync(string email); Task<AppUser?> GetByEmailAsync(string email);
Task<IReadOnlyList<AppUser>> GetAllAsync(); Task<IReadOnlyList<AppUser>> GetAllAsync();
Task UpdateRoleAsync(string userId, UserRole role); /// <summary>Persist a new role for the user. Returns the previous role (read from the store).</summary>
/// <exception cref="KeyNotFoundException">No user matches <paramref name="userId"/>.</exception>
Task<UserRole> UpdateRoleAsync(string userId, UserRole role);
Task DeleteAsync(string userId); Task DeleteAsync(string userId);
/// <summary>Create a local password-based account. First user ever becomes Admin.</summary> /// <summary>Create a local password-based account. First user ever becomes Admin.</summary>
+16 -3
View File
@@ -1,5 +1,6 @@
using System.Security.Claims; using System.Security.Claims;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using SharepointToolbox.Web.Core.Models; using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Infrastructure.Persistence; using SharepointToolbox.Web.Infrastructure.Persistence;
@@ -9,11 +10,13 @@ public class UserService : IUserService
{ {
private readonly UserRepository _repo; private readonly UserRepository _repo;
private readonly IPasswordHasher<AppUser> _hasher; private readonly IPasswordHasher<AppUser> _hasher;
private readonly ILogger<UserService> _logger;
public UserService(UserRepository repo, IPasswordHasher<AppUser> hasher) public UserService(UserRepository repo, IPasswordHasher<AppUser> hasher, ILogger<UserService> logger)
{ {
_repo = repo; _repo = repo;
_hasher = hasher; _hasher = hasher;
_logger = logger;
} }
public async Task<AppUser> ProvisionAsync(ClaimsPrincipal principal) public async Task<AppUser> ProvisionAsync(ClaimsPrincipal principal)
@@ -56,13 +59,23 @@ public class UserService : IUserService
public Task<IReadOnlyList<AppUser>> GetAllAsync() => _repo.LoadAsync(); public Task<IReadOnlyList<AppUser>> GetAllAsync() => _repo.LoadAsync();
public async Task UpdateRoleAsync(string userId, UserRole role) public async Task<UserRole> UpdateRoleAsync(string userId, UserRole role)
{ {
var users = (await _repo.LoadAsync()).ToList(); var users = (await _repo.LoadAsync()).ToList();
var user = users.FirstOrDefault(u => u.Id == userId) var user = users.FirstOrDefault(u => u.Id == userId)
?? throw new KeyNotFoundException($"User {userId} not found."); ?? throw new KeyNotFoundException($"User '{userId}' not found among {users.Count} stored users.");
var oldRole = user.Role;
user.Role = role; user.Role = role;
await _repo.UpsertAsync(user); await _repo.UpsertAsync(user);
// Verify the write landed by re-reading the row from disk.
var persisted = (await _repo.LoadAsync()).FirstOrDefault(u => u.Id == user.Id)?.Role;
_logger.LogInformation(
"UpdateRoleAsync: {Email} (id {Id}) {OldRole} → {NewRole}; persisted value now {Persisted}.",
user.Email, user.Id, oldRole, role, persisted);
return oldRole;
} }
public Task DeleteAsync(string userId) => _repo.DeleteAsync(userId); public Task DeleteAsync(string userId) => _repo.DeleteAsync(userId);