Files
SharepointToolbox-Web/Components/Layout/MainLayout.razor
T

321 lines
12 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@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
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.WebUtilities
@using Microsoft.JSInterop
@using SharepointToolbox.Web.Core.Models
@using SharepointToolbox.Web.Services.Session
<AppInitializer />
<div class="app-layout">
<aside class="sidebar @(_sidebarCollapsed ? "collapsed" : "")">
<div class="sidebar-header">
<div class="logo">
<span class="logo-mark">SP</span>
<span class="logo-text">SP Toolbox</span>
</div>
<button class="toggle-btn @(_sidebarCollapsed ? "collapsed" : "")" @onclick="ToggleSidebar" title="Toggle sidebar"></button>
</div>
@* User identity badge *@
<AuthorizeView>
<Authorized>
<div class="identity-badge">
<div class="identity-name">
@UserContext.DisplayName
</div>
<div class="identity-email">
@UserContext.Email
</div>
<div style="margin-top:4px">
<span class="chip @RoleChipClass(UserContext.Role)" style="font-size:10px">@UserContext.Role</span>
</div>
@if (_hasCredentials)
{
<div style="font-size:10px;color:var(--text-muted);margin-top:4px">
SP: @_credUsername
<button class="btn btn-secondary btn-sm" style="padding:2px 6px;font-size:10px;margin-left:4px"
@onclick="ReconnectAsync">Reconnect</button>
</div>
}
</div>
</Authorized>
</AuthorizeView>
@if (Session.HasProfile)
{
<div class="profile-badge">
<span class="profile-icon">🏢</span>
<span class="profile-name">@Session.CurrentProfile!.Name</span>
</div>
}
<div class="nav-search">
<span class="nav-icon">🔍</span>
<input type="text" class="nav-search-input" placeholder="Search…"
@bind="_navFilter" @bind:event="oninput" />
@if (!string.IsNullOrEmpty(_navFilter))
{
<button class="nav-search-clear" @onclick="ClearFilter" title="Clear">✕</button>
}
</div>
<nav class="nav-menu">
@{
string? lastSection = null;
var items = VisibleNavItems().ToList();
}
@foreach (var item in items)
{
if (item.Section != lastSection)
{
lastSection = item.Section;
if (!string.IsNullOrEmpty(item.Section))
{
<div class="nav-divider">@item.Section</div>
}
}
<NavLink href="@item.Href" Match="@(item.Href == "/" ? NavLinkMatch.All : NavLinkMatch.Prefix)" class="nav-item">
<span class="nav-icon">@item.Icon</span><span class="nav-label">@item.Label</span>
</NavLink>
}
@if (items.Count == 0)
{
<div class="nav-empty">No match</div>
}
</nav>
<div class="sidebar-footer">
<AuthorizeView>
<Authorized>
<a href="/account/logout" class="nav-item">
<span class="nav-icon">🚪</span><span class="nav-label">Logout</span>
</a>
</Authorized>
</AuthorizeView>
<button class="nav-item theme-toggle" @onclick="ToggleTheme">
<span class="nav-icon">🌙</span>
<span class="nav-label">@(_dark ? "Light Mode" : "Dark Mode")</span>
<span class="switch @(_dark ? "on" : "")"></span>
</button>
</div>
</aside>
<main class="content">
@if (UserContext.IsAuthenticated)
{
@Body
}
else
{
<div style="padding:2rem;color:var(--text-muted)">Loading…</div>
}
</main>
</div>
<SessionCredentialsModal @ref="_credModal" OnConnected="OnCredentialsConnected" />
@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<NavItem> 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();
}
// Strip token_key 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;
}
}