fe0fcdb7da
@bind:after did not persist reliably. Move back to an explicit @onchange handler and surface every outcome in the page alert, including the role re-read from the store after the write. This makes a failed save visible (unrecognized value, exception, or saved != selected) instead of silent, so we can pinpoint where the role update breaks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
234 lines
9.8 KiB
Plaintext
234 lines
9.8 KiB
Plaintext
@page "/admin/users"
|
|
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
|
@inject IUserService UserService
|
|
@inject IUserContextAccessor UserContext
|
|
@inject IAuditService Audit
|
|
@inject NavigationManager Nav
|
|
@inject TranslationSource T
|
|
@rendermode InteractiveServer
|
|
@using SharepointToolbox.Web.Core.Models
|
|
@using SharepointToolbox.Web.Services.Audit
|
|
@using SharepointToolbox.Web.Services.Auth
|
|
@using SharepointToolbox.Web.Services.Session
|
|
|
|
<h1 class="page-title">@T["usermgmt.title"]</h1>
|
|
<p class="page-subtitle">@T["usermgmt.subtitle"]</p>
|
|
|
|
@if (!UserContext.IsAuthenticated || UserContext.Role != UserRole.Admin)
|
|
{
|
|
<div class="alert alert-error">@T["usermgmt.accessdenied"]</div>
|
|
return;
|
|
}
|
|
|
|
@if (!string.IsNullOrEmpty(_message))
|
|
{
|
|
<div class="alert @(_isError ? "alert-error" : "alert-success")">@_message</div>
|
|
}
|
|
|
|
<div class="card">
|
|
<h2 class="card-title">@T["usermgmt.create.title"]</h2>
|
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;max-width:640px">
|
|
<div>
|
|
<label class="form-label" for="new-email">@T["usermgmt.col.email"]</label>
|
|
<input id="new-email" class="form-input" type="email" @bind="_newEmail" placeholder="user@example.com" />
|
|
</div>
|
|
<div>
|
|
<label class="form-label" for="new-name">@T["usermgmt.lbl.displayname"]</label>
|
|
<input id="new-name" class="form-input" type="text" @bind="_newName" placeholder="Jane Doe" />
|
|
</div>
|
|
<div>
|
|
<label class="form-label" for="new-role">@T["usermgmt.col.role"]</label>
|
|
<select id="new-role" class="form-input" @bind="_newRole">
|
|
@foreach (var role in Enum.GetValues<UserRole>())
|
|
{
|
|
<option value="@role">@role</option>
|
|
}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="form-label" for="new-pw">@T["usermgmt.lbl.password"]</label>
|
|
<input id="new-pw" class="form-input" type="password" @bind="_newPassword" autocomplete="new-password" />
|
|
</div>
|
|
</div>
|
|
<div style="margin-top:12px">
|
|
<button class="btn btn-primary" @onclick="CreateLocalUserAsync">@T["usermgmt.btn.create"]</button>
|
|
</div>
|
|
</div>
|
|
|
|
@if (_users.Count == 0)
|
|
{
|
|
<div class="alert alert-info">@T["usermgmt.empty"]</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="card" style="overflow-x:auto">
|
|
<table style="width:100%;border-collapse:collapse">
|
|
<thead>
|
|
<tr style="border-bottom:2px solid var(--border)">
|
|
<th style="text-align:left;padding:8px">@T["usermgmt.col.user"]</th>
|
|
<th style="text-align:left;padding:8px">@T["usermgmt.col.email"]</th>
|
|
<th style="text-align:left;padding:8px">@T["usermgmt.col.source"]</th>
|
|
<th style="text-align:left;padding:8px">@T["usermgmt.col.role"]</th>
|
|
<th style="text-align:left;padding:8px">@T["usermgmt.col.lastlogin"]</th>
|
|
<th style="text-align:left;padding:8px">@T["usermgmt.col.actions"]</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var user in _users)
|
|
{
|
|
<tr style="border-bottom:1px solid var(--border)">
|
|
<td style="padding:8px">@user.DisplayName</td>
|
|
<td style="padding:8px">@user.Email</td>
|
|
<td style="padding:8px">
|
|
<span class="chip @(user.Provider == AuthProvider.Local ? "chip-blue" : "chip-green")">
|
|
@(user.Provider == AuthProvider.Local ? T["usermgmt.source.local"] : T["usermgmt.source.entra"])
|
|
</span>
|
|
</td>
|
|
<td style="padding:8px">
|
|
<select class="form-input" style="width:130px"
|
|
value="@user.Role"
|
|
@onchange="@(e => OnRoleChange(user, e))"
|
|
disabled="@(user.Email == UserContext.Email)">
|
|
@foreach (var role in Enum.GetValues<UserRole>())
|
|
{
|
|
<option value="@role" selected="@(user.Role == role)">@role</option>
|
|
}
|
|
</select>
|
|
</td>
|
|
<td style="padding:8px">@(user.LastLogin?.ToString("yyyy-MM-dd HH:mm") ?? T["usermgmt.lastlogin.never"])</td>
|
|
<td style="padding:8px;white-space:nowrap">
|
|
@if (user.Provider == AuthProvider.Local)
|
|
{
|
|
<button class="btn btn-secondary btn-sm" @onclick="() => OpenReset(user)">@T["usermgmt.btn.resetpw"]</button>
|
|
}
|
|
@if (user.Email != UserContext.Email)
|
|
{
|
|
<button class="btn btn-danger btn-sm" @onclick="() => DeleteUserAsync(user)">@T["usermgmt.btn.remove"]</button>
|
|
}
|
|
else
|
|
{
|
|
<span class="chip chip-green">@T["usermgmt.badge.you"]</span>
|
|
}
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
}
|
|
|
|
@if (_resetUser is not null)
|
|
{
|
|
<div class="card" style="max-width:420px">
|
|
<h2 class="card-title">@string.Format(T["usermgmt.reset.title"], _resetUser.DisplayName)</h2>
|
|
<label class="form-label" for="reset-pw">@T["usermgmt.lbl.newpassword"]</label>
|
|
<input id="reset-pw" class="form-input" type="password" @bind="_resetPassword" autocomplete="new-password" />
|
|
<div style="margin-top:12px;display:flex;gap:8px">
|
|
<button class="btn btn-primary" @onclick="ResetPasswordAsync">@T["usermgmt.btn.setpw"]</button>
|
|
<button class="btn btn-secondary" @onclick="() => _resetUser = null">@T["btn.cancel"]</button>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
@code {
|
|
private List<AppUser> _users = new();
|
|
private string _message = string.Empty;
|
|
private bool _isError;
|
|
|
|
private string _newEmail = string.Empty;
|
|
private string _newName = string.Empty;
|
|
private UserRole _newRole = UserRole.TechN0;
|
|
private string _newPassword = string.Empty;
|
|
|
|
private AppUser? _resetUser;
|
|
private string _resetPassword = string.Empty;
|
|
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
_users = (await UserService.GetAllAsync()).ToList();
|
|
}
|
|
|
|
private async Task CreateLocalUserAsync()
|
|
{
|
|
try
|
|
{
|
|
var user = await UserService.CreateLocalUserAsync(_newEmail, _newName, _newRole, _newPassword);
|
|
_users.Add(user);
|
|
await Audit.LogAsync("UserCreated", "", Array.Empty<string>(),
|
|
$"Created local user {user.Email} ({user.DisplayName}) with role {user.Role}.");
|
|
_message = string.Format(T["usermgmt.msg.created"], user.DisplayName);
|
|
_isError = false;
|
|
_newEmail = _newName = _newPassword = string.Empty;
|
|
_newRole = UserRole.TechN0;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_message = string.Format(T["usermgmt.msg.error"], ex.Message);
|
|
_isError = true;
|
|
}
|
|
}
|
|
|
|
private void OpenReset(AppUser user)
|
|
{
|
|
_resetUser = user;
|
|
_resetPassword = string.Empty;
|
|
}
|
|
|
|
private async Task ResetPasswordAsync()
|
|
{
|
|
if (_resetUser is null) return;
|
|
try
|
|
{
|
|
await UserService.SetPasswordAsync(_resetUser.Id, _resetPassword);
|
|
await Audit.LogAsync("PasswordReset", "", Array.Empty<string>(),
|
|
$"Reset password for local user {_resetUser.Email} ({_resetUser.DisplayName}).");
|
|
_message = string.Format(T["usermgmt.msg.pwreset"], _resetUser.DisplayName);
|
|
_isError = false;
|
|
_resetUser = null;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_message = string.Format(T["usermgmt.msg.error"], ex.Message);
|
|
_isError = true;
|
|
}
|
|
}
|
|
|
|
private async Task OnRoleChange(AppUser user, ChangeEventArgs e)
|
|
{
|
|
// Surface every branch on-screen so a failed save is never silent. The "saved" value is
|
|
// re-read from the store, so it proves whether the write actually landed on disk.
|
|
var raw = e.Value?.ToString();
|
|
if (!Enum.TryParse<UserRole>(raw, out var newRole))
|
|
{
|
|
_message = string.Format(T["usermgmt.msg.error"], $"unrecognized role value '{raw}'");
|
|
_isError = true;
|
|
return;
|
|
}
|
|
try
|
|
{
|
|
var oldRole = await UserService.UpdateRoleAsync(user.Id, newRole);
|
|
user.Role = newRole;
|
|
var saved = (await UserService.GetByEmailAsync(user.Email))?.Role;
|
|
await Audit.LogAsync("RoleChanged", "", Array.Empty<string>(),
|
|
$"Changed role for {user.Email} ({user.DisplayName}) from {oldRole} to {newRole}.");
|
|
_message = $"{string.Format(T["usermgmt.msg.roleupdated"], user.DisplayName)} ({oldRole} → {newRole}, saved: {saved})";
|
|
_isError = saved != newRole;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_message = string.Format(T["usermgmt.msg.error"], ex.Message);
|
|
_isError = true;
|
|
}
|
|
}
|
|
|
|
private async Task DeleteUserAsync(AppUser user)
|
|
{
|
|
await UserService.DeleteAsync(user.Id);
|
|
_users.Remove(user);
|
|
await Audit.LogAsync("UserDeleted", "", Array.Empty<string>(),
|
|
$"Removed {user.Provider} user {user.Email} ({user.DisplayName}), role {user.Role}.");
|
|
_message = string.Format(T["usermgmt.msg.removed"], user.DisplayName);
|
|
_isError = false;
|
|
}
|
|
}
|