@inherits LayoutComponentBase @implements IDisposable @inject IUserSessionService Session @inject IUserContextAccessor UserContext @inject ISessionCredentialStore CredStore @inject ISessionManager SessionManager @inject SharepointToolbox.Web.Infrastructure.Auth.IAppOnlyContextFactory AppOnly @inject NavigationManager Nav @inject IJSRuntime JS @inject SharepointToolbox.Web.Services.OAuth.IOAuthFlowCache OAuthCache @inject SharepointToolbox.Web.Infrastructure.Persistence.ProfileRepository ProfileRepo @inject TranslationSource T @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 {
@T["nav.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("/", "🏠", "nav.home", "", "always"), new("/permissions", "🔐", "tab.permissions", "", "profile"), new("/storage", "💾", "tab.storage", "", "profile"), new("/duplicates", "📋", "tab.duplicates", "", "profile"), new("/versions", "🗂️", "versions.tab", "", "profile"), new("/transfer", "📦", "nav.fileTransfer", "", "profile"), new("/bulk-members", "👥", "tab.bulkMembers", "nav.section.bulk", "profile"), new("/bulk-sites", "🌐", "tab.bulkSites", "nav.section.bulk", "profile"), new("/folder-structure", "📁", "tab.folderStructure", "nav.section.bulk", "profile"), new("/user-audit", "👤", "tab.userAccessAudit", "nav.section.audit", "profile"), new("/user-directory", "📖", "nav.userDirectory", "nav.section.audit", "profile"), new("/reports", "📑", "nav.reports", "nav.section.audit", "profile"), new("/templates", "📐", "tab.templates", "nav.section.config", "profile"), new("/scheduled-reports", "⏰", "nav.scheduledReports", "nav.section.admin", "admin"), new("/profiles", "⚙️", "nav.clientProfiles", "nav.section.admin", "admin"), new("/admin/users", "👥", "nav.userManagement", "nav.section.admin", "admin"), new("/admin/audit", "📋", "nav.auditLogs", "nav.section.admin", "admin"), new("/settings", "🔧", "tab.settings", "", "always"), new("/account/change-password","🔑", "nav.changePassword", "", "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 || T[i.Label].Contains(filter, StringComparison.OrdinalIgnoreCase)); } private void ClearFilter() => _navFilter = string.Empty; private string _credUsername = string.Empty; private SessionCredentialsModal? _credModal; protected override void OnInitialized() { // Apply the user's language to this circuit's TranslationSource (used by every page in // the circuit) and to the ambient culture (used by the export services). Runs in both // the prerender and the interactive circuit, before any page renders — so the whole app // is in the right language and stays there across SPA navigation. var lang = Session.Settings.Lang; T.SetCulture(lang); var culture = TranslationSource.Resolve(lang); System.Globalization.CultureInfo.CurrentCulture = culture; System.Globalization.CultureInfo.CurrentUICulture = culture; 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 (cert profiles never prompt) if (ShouldPromptForCredentials) await _credModal!.ShowAsync(); } // True when the selected profile authenticates app-only via a stored certificate — // technicians operate under the app identity and are never prompted to sign in. private bool CurrentProfileUsesCert => Session.CurrentProfile is { } p && AppOnly.IsConfigured(p); // Whether to auto-show the delegated sign-in modal. Only admins are ever asked to // authenticate: standard technicians (TechN0/TechN1) operate under the profile's app // (certificate) identity and must never be prompted when selecting a profile. A profile // that isn't cert-configured is an admin setup concern, not a sign-in for the technician. private bool ShouldPromptForCredentials => Session.HasProfile && !_hasCredentials && !CurrentProfileUsesCert && UserContext.Role == UserRole.Admin && _credModal is not null; 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() { // Certificate-configured profiles need no session tokens — mark as connected // under the app identity and skip the delegated token bookkeeping entirely. if (CurrentProfileUsesCert) { _hasCredentials = true; _credUsername = $"{Session.CurrentProfile!.Name} ({T["nav.appIdentity"]})"; await InvokeAsync(StateHasChanged); return; } 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. // Standard technicians are never prompted (see ShouldPromptForCredentials). if (ShouldPromptForCredentials) 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; } }