@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 (Session.HasProfile && !_hasCredentials && !CurrentProfileUsesCert && _credModal is not null)
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);
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.
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;
}
}