Re-read user role from store on navigation

Role lived in the scoped UserContextAccessor for the circuit's lifetime
and was never refreshed, so an admin promoting a user (e.g. N0 to N1) did
not reach the affected user's live session. AppInitializer now re-reads
the user on each LocationChanged, applying role changes on next navigation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 09:50:26 +02:00
parent e190e40b07
commit 98683bbd5e
+39 -5
View File
@@ -1,14 +1,20 @@
@inject AuthenticationStateProvider AuthProvider @inject AuthenticationStateProvider AuthProvider
@inject IUserService UserService @inject IUserService UserService
@inject IUserContextAccessor UserContext @inject IUserContextAccessor UserContext
@inject NavigationManager Nav
@inject ILogger<AppInitializer> Logger @inject ILogger<AppInitializer> Logger
@implements IDisposable
@using Microsoft.AspNetCore.Components.Authorization @using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Routing
@using SharepointToolbox.Web.Services.Auth @using SharepointToolbox.Web.Services.Auth
@using SharepointToolbox.Web.Services.Session @using SharepointToolbox.Web.Services.Session
@* Invisible component. Run once per circuit to seed IUserContextAccessor. *@ @* Invisible component. Seeds IUserContextAccessor on circuit init and re-reads it from the
store on every navigation, so an admin's role change applies on the user's next page change. *@
@code { @code {
private string? _email;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
var state = await AuthProvider.GetAuthenticationStateAsync(); var state = await AuthProvider.GetAuthenticationStateAsync();
@@ -20,23 +26,51 @@
return; return;
} }
var email = principal.FindFirst("preferred_username")?.Value _email = principal.FindFirst("preferred_username")?.Value
?? principal.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value; ?? principal.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value;
if (string.IsNullOrEmpty(email)) if (string.IsNullOrEmpty(_email))
{ {
var claims = string.Join(", ", principal.Claims.Select(c => $"{c.Type}={c.Value}")); var claims = string.Join(", ", principal.Claims.Select(c => $"{c.Type}={c.Value}"));
Logger.LogWarning("AppInitializer: authenticated but no preferred_username/email claim. Claims present: [{Claims}]", claims); Logger.LogWarning("AppInitializer: authenticated but no preferred_username/email claim. Claims present: [{Claims}]", claims);
return; return;
} }
var user = await UserService.GetByEmailAsync(email); await SeedAsync(logSeed: true);
// Re-read the user (and current role) from the store on each navigation. The role lives
// in the scoped UserContextAccessor for the circuit's lifetime, so without this a role
// change made by an admin would not reach the affected user's live session.
Nav.LocationChanged += OnLocationChanged;
}
private async Task SeedAsync(bool logSeed)
{
if (string.IsNullOrEmpty(_email)) return;
var user = await UserService.GetByEmailAsync(_email);
if (user is null) if (user is null)
{ {
Logger.LogWarning("AppInitializer: no user row for email '{Email}' — provisioning did not persist a matching record.", email); Logger.LogWarning("AppInitializer: no user row for email '{Email}' — provisioning did not persist a matching record.", _email);
return; return;
} }
if (logSeed)
Logger.LogInformation("AppInitializer: seeded UserContext for '{Email}' (role {Role}).", user.Email, user.Role); Logger.LogInformation("AppInitializer: seeded UserContext for '{Email}' (role {Role}).", user.Email, user.Role);
UserContext.Initialize(user); UserContext.Initialize(user);
} }
private async void OnLocationChanged(object? sender, LocationChangedEventArgs e)
{
try
{
await SeedAsync(logSeed: false);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "AppInitializer: failed to refresh UserContext on navigation to '{Location}'.", e.Location);
}
}
public void Dispose() => Nav.LocationChanged -= OnLocationChanged;
} }