@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; } }