@inherits LayoutComponentBase
@implements IDisposable
@inject IUserSessionService Session
@inject IUserContextAccessor UserContext
@inject ISessionCredentialStore CredStore
@inject ISessionManager SessionManager
@inject NavigationManager Nav
@inject IJSRuntime JS
@inject SharepointToolbox.Web.Services.OAuth.IOAuthFlowCache OAuthCache
@inject SharepointToolbox.Web.Infrastructure.Persistence.ProfileRepository ProfileRepo
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.WebUtilities
@using Microsoft.JSInterop
@using SharepointToolbox.Web.Core.Models
@using SharepointToolbox.Web.Services.Session
@if (UserContext.IsAuthenticated)
{
@Body
}
else
{
Loadingβ¦
}
@code {
private bool _sidebarCollapsed;
private bool _dark;
private string _navFilter = string.Empty;
private bool _hasCredentials;
private sealed record NavItem(string Href, string Icon, string Label, string Section, string Scope);
private static readonly NavItem[] AllNavItems =
{
new("/", "π ", "Home", "", "always"),
new("/permissions", "π", "Permissions", "", "profile"),
new("/storage", "πΎ", "Storage", "", "profile"),
new("/duplicates", "π", "Duplicates", "", "profile"),
new("/versions", "ποΈ", "Version Cleanup", "", "profile"),
new("/transfer", "π¦", "File Transfer", "", "profile"),
new("/bulk-members", "π₯", "Bulk Members", "Bulk", "profile"),
new("/bulk-sites", "π", "Bulk Sites", "Bulk", "profile"),
new("/folder-structure", "π", "Folder Structure", "Bulk", "profile"),
new("/user-audit", "π€", "User Access Audit","Audit", "profile"),
new("/user-directory", "π", "User Directory", "Audit", "profile"),
new("/templates", "π", "Templates", "Config", "profile"),
new("/profiles", "βοΈ", "Client Profiles", "Admin", "admin"),
new("/admin/users", "π₯", "User Management", "Admin", "admin"),
new("/admin/audit", "π", "Audit Logs", "Admin", "admin"),
new("/settings", "π§", "Settings", "", "always"),
new("/account/change-password","π", "Change Password", "", "auth"),
};
private IEnumerable VisibleNavItems()
{
var filter = _navFilter?.Trim() ?? string.Empty;
return AllNavItems
.Where(i => i.Scope switch
{
"profile" => Session.HasProfile,
"admin" => UserContext.Role == UserRole.Admin,
"auth" => UserContext.IsAuthenticated,
_ => true
})
.Where(i => filter.Length == 0
|| i.Label.Contains(filter, StringComparison.OrdinalIgnoreCase));
}
private void ClearFilter() => _navFilter = string.Empty;
private string _credUsername = string.Empty;
private SessionCredentialsModal? _credModal;
protected override void OnInitialized()
{
Session.ProfileChanged += OnProfileChanged;
UserContext.Initialized += OnUserContextInitialized;
_dark = string.Equals(Session.Settings.Theme, "Dark", StringComparison.OrdinalIgnoreCase);
}
private void OnUserContextInitialized() => InvokeAsync(StateHasChanged);
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
// Apply persisted theme preference
await ApplyThemeAsync();
// Pick up token_key from OAuth callback redirect before checking credential state
await HandleOAuthCallbackAsync();
await RefreshCredentialState();
// Check for connect_error query param
var uri = new Uri(Nav.Uri);
var query = QueryHelpers.ParseQuery(uri.Query);
if (query.TryGetValue("connect_error", out var err) && !string.IsNullOrEmpty(err))
{
Nav.NavigateTo(uri.GetLeftPart(UriPartial.Path), replace: true);
if (_credModal is not null)
{
// Surface the failure reason instead of silently reopening the modal
await _credModal.ShowAsync(err!);
}
}
// If profile selected but no credentials β show modal
if (Session.HasProfile && !_hasCredentials && _credModal is not null)
await _credModal.ShowAsync();
}
private async Task HandleOAuthCallbackAsync()
{
var uri = new Uri(Nav.Uri);
var query = QueryHelpers.ParseQuery(uri.Query);
if (!query.TryGetValue("token_key", out var tokenKey) || string.IsNullOrEmpty(tokenKey))
return;
var tokens = OAuthCache.GetAndRemoveTokens(tokenKey!);
if (tokens is not null)
{
await CredStore.SetAsync(tokens);
await SessionManager.ClearAllAsync();
// Re-select the profile that started this connect flow. The forceLoad redirect tore
// down the previous circuit, so the in-memory selection in UserSessionService (scoped)
// was lost. Restoring it here means a successful auth lands the user on a fully
// selected profile instead of forcing a second "Select" click.
if (query.TryGetValue("profile_id", out var profileId) && !string.IsNullOrEmpty(profileId))
{
var profile = (await ProfileRepo.LoadAsync()).FirstOrDefault(p => p.Id == profileId);
if (profile is not null)
Session.SetProfile(profile);
}
}
// Strip token_key / profile_id from URL bar
Nav.NavigateTo(uri.GetLeftPart(UriPartial.Path), replace: true);
}
private async Task RefreshCredentialState()
{
var tokens = await CredStore.GetAsync();
// Session tokens are tenant-bound (refresh token issued for the profile's TenantId/ClientId).
// After switching profiles the stored tokens may belong to the *previous* tenant β treat those
// as stale: drop them and the cached CSOM contexts so we don't keep operating against the old
// profile, and so the connect prompt below fires for the new one.
if (tokens is not null && Session.CurrentProfile is { } profile &&
!string.Equals(tokens.TenantId, profile.TenantId, StringComparison.OrdinalIgnoreCase))
{
await CredStore.ClearAsync();
await SessionManager.ClearAllAsync();
tokens = null;
}
_hasCredentials = tokens is not null && !string.IsNullOrEmpty(tokens.RefreshToken);
_credUsername = tokens?.UserPrincipalName ?? string.Empty;
await InvokeAsync(StateHasChanged);
}
private async Task ReconnectAsync()
{
await CredStore.ClearAsync();
await SessionManager.ClearAllAsync();
_hasCredentials = false;
_credUsername = string.Empty;
if (Session.HasProfile && _credModal is not null)
await _credModal.ShowAsync();
}
private async Task OnCredentialsConnected()
{
await RefreshCredentialState();
}
private void OnProfileChanged()
{
InvokeAsync(async () =>
{
// Re-evaluate credentials against the newly selected profile. This drops tokens
// left over from the previous profile (different tenant), so a switch never keeps
// operating on the old connection.
await RefreshCredentialState();
// New profile selected and no valid credentials for it β prompt to connect.
if (Session.HasProfile && !_hasCredentials && _credModal is not null)
await _credModal.ShowAsync();
});
}
private async Task ApplyThemeAsync()
{
try
{
await JS.InvokeVoidAsync("sptb.setTheme", Session.Settings.Theme);
}
catch (JSException) { /* best-effort; JS not yet available */ }
}
private void ToggleSidebar() => _sidebarCollapsed = !_sidebarCollapsed;
private async Task ToggleTheme()
{
_dark = !_dark;
var theme = _dark ? "Dark" : "Light";
var s = Session.Settings;
Session.UpdateSettings(new AppSettings
{
DataFolder = s.DataFolder,
Lang = s.Lang,
AutoTakeOwnership = s.AutoTakeOwnership,
Theme = theme,
MspLogo = s.MspLogo
});
await JS.InvokeVoidAsync("sptb.setTheme", theme);
}
private static string RoleChipClass(UserRole role) => role switch
{
UserRole.Admin => "chip-red",
UserRole.TechN1 => "chip-green",
_ => "chip-blue"
};
public void Dispose()
{
Session.ProfileChanged -= OnProfileChanged;
UserContext.Initialized -= OnUserContextInitialized;
}
}