Files
SharepointToolbox-Web/Components/Layout/MainLayout.razor
T
kawa 0adc2d4300 Hide write-only features from TechN0 menu
Read-only TechN0 users could see nav items for pages that immediately
return a WriteGuard notice (transfer, versions, templates, bulk members/
sites, folder structure), landing them on empty screens. Add a `write`
nav scope (HasProfile && Role >= TechN1) so those items no longer appear
for N0. The Bulk and Config section headers drop out automatically since
all their children are now write-scoped. Per-page guards remain intact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:48:36 +02:00

379 lines
16 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 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
<AppInitializer />
<div class="app-layout">
<aside class="sidebar @(_sidebarCollapsed ? "collapsed" : "")">
<div class="sidebar-header">
<div class="logo">
<img class="logo-mark" src="SPToolbox-logo-ico.png" alt="SP Toolbox" />
<span class="logo-text">SP Toolbox</span>
</div>
<button class="toggle-btn @(_sidebarCollapsed ? "collapsed" : "")" @onclick="ToggleSidebar" title="@T["nav.toggleSidebar"]"></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
@if (!CurrentProfileUsesCert)
{
<button class="btn btn-secondary btn-sm" style="padding:2px 6px;font-size:10px;margin-left:4px"
@onclick="ReconnectAsync">@T["nav.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="@T["nav.searchPlaceholder"]"
@bind="_navFilter" @bind:event="oninput" />
@if (!string.IsNullOrEmpty(_navFilter))
{
<button class="nav-search-clear" @onclick="ClearFilter" title="@T["nav.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">@T[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">@T[item.Label]</span>
</NavLink>
}
@if (items.Count == 0)
{
<div class="nav-empty">@T["nav.noMatch"]</div>
}
</nav>
<div class="sidebar-footer">
<AuthorizeView>
<Authorized>
<a href="/account/logout" class="nav-item">
<span class="nav-icon">🚪</span><span class="nav-label">@T["nav.logout"]</span>
</a>
</Authorized>
</AuthorizeView>
<button class="nav-item theme-toggle" @onclick="ToggleTheme">
<span class="nav-icon">🌙</span>
<span class="nav-label">@(_dark ? T["nav.lightMode"] : T["nav.darkMode"])</span>
<span class="switch @(_dark ? "on" : "")"></span>
</button>
</div>
</aside>
<main class="content">
@if (UserContext.IsAuthenticated)
{
<header class="topbar">
<ProfileSelector />
</header>
@Body
}
else
{
<div style="padding:2rem;color:var(--text-muted)">@T["nav.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("/", "🏠", "nav.home", "", "always"),
new("/permissions", "🔐", "tab.permissions", "", "profile"),
new("/storage", "💾", "tab.storage", "", "profile"),
new("/duplicates", "📋", "tab.duplicates", "", "profile"),
new("/versions", "🗂️", "versions.tab", "", "write"),
new("/transfer", "📦", "nav.fileTransfer", "", "write"),
new("/bulk-members", "👥", "tab.bulkMembers", "nav.section.bulk", "write"),
new("/bulk-sites", "🌐", "tab.bulkSites", "nav.section.bulk", "write"),
new("/folder-structure", "📁", "tab.folderStructure", "nav.section.bulk", "write"),
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", "write"),
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<NavItem> VisibleNavItems()
{
var filter = _navFilter?.Trim() ?? string.Empty;
return AllNavItems
.Where(i => i.Scope switch
{
"profile" => Session.HasProfile,
"write" => Session.HasProfile && UserContext.Role >= UserRole.TechN1,
"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;
}
}