Files
SharepointToolbox-Web/Components/Layout/MainLayout.razor
T
kawa 5a23783e07 Fix GUI polish issues across auth modal, theme, and 404
- Add missing modal CSS (.modal-overlay/.modal-dialog/.modal-header):
  the "Connect to Microsoft" auth modal was rendering unstyled inline
  at the bottom of the page. Now a centered dialog with backdrop.
- Surface OAuth connect errors in the modal instead of silently
  reopening it with no explanation.
- MainLayout: implement IDisposable so event handlers are actually
  unsubscribed (Dispose existed but was never invoked).
- Wire up the Settings theme selector (was a dead control): drop the
  unsupported Dark option, call sptb.setTheme on save and on load,
  resolve System via prefers-color-scheme.
- Add branded 404 page via UseStatusCodePagesWithReExecute + Routes
  <NotFound> (blank white page before).
- Add .progress-fill.indeterminate animation and .progress-panel.
- Home: replace inline JS hover handlers with a .feature-card CSS class.
- Define missing --surface-2 variable referenced by MainLayout.

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

268 lines
10 KiB
Plaintext

@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">
<span class="logo-text">SP Toolbox</span>
<button class="toggle-btn" @onclick="ToggleSidebar">☰</button>
</div>
@* User identity badge *@
<AuthorizeView>
<Authorized>
<div class="profile-badge" style="background:var(--surface-2);border-radius:6px;margin:8px;padding:8px">
<div style="font-size:12px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
@UserContext.DisplayName
</div>
<div style="font-size:11px;color:var(--text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
@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>
}
<nav class="nav-menu">
<NavLink href="/" Match="NavLinkMatch.All" class="nav-item">
<span class="nav-icon">🏠</span><span class="nav-label">Home</span>
</NavLink>
@if (Session.HasProfile)
{
<NavLink href="/permissions" class="nav-item">
<span class="nav-icon">🔐</span><span class="nav-label">Permissions</span>
</NavLink>
<NavLink href="/storage" class="nav-item">
<span class="nav-icon">💾</span><span class="nav-label">Storage</span>
</NavLink>
<NavLink href="/search" class="nav-item">
<span class="nav-icon">🔍</span><span class="nav-label">Search</span>
</NavLink>
<NavLink href="/duplicates" class="nav-item">
<span class="nav-icon">📋</span><span class="nav-label">Duplicates</span>
</NavLink>
<NavLink href="/versions" class="nav-item">
<span class="nav-icon">🗂️</span><span class="nav-label">Version Cleanup</span>
</NavLink>
<NavLink href="/transfer" class="nav-item">
<span class="nav-icon">📦</span><span class="nav-label">File Transfer</span>
</NavLink>
<div class="nav-divider">Bulk</div>
<NavLink href="/bulk-members" class="nav-item">
<span class="nav-icon">👥</span><span class="nav-label">Bulk Members</span>
</NavLink>
<NavLink href="/bulk-sites" class="nav-item">
<span class="nav-icon">🌐</span><span class="nav-label">Bulk Sites</span>
</NavLink>
<NavLink href="/folder-structure" class="nav-item">
<span class="nav-icon">📁</span><span class="nav-label">Folder Structure</span>
</NavLink>
<div class="nav-divider">Audit</div>
<NavLink href="/user-audit" class="nav-item">
<span class="nav-icon">👤</span><span class="nav-label">User Access Audit</span>
</NavLink>
<NavLink href="/user-directory" class="nav-item">
<span class="nav-icon">📖</span><span class="nav-label">User Directory</span>
</NavLink>
<div class="nav-divider">Config</div>
<NavLink href="/templates" class="nav-item">
<span class="nav-icon">📐</span><span class="nav-label">Templates</span>
</NavLink>
}
@* Admin-only section *@
@if (UserContext.Role == UserRole.Admin)
{
<div class="nav-divider">Admin</div>
<NavLink href="/profiles" class="nav-item">
<span class="nav-icon">⚙️</span><span class="nav-label">Client Profiles</span>
</NavLink>
<NavLink href="/admin/users" class="nav-item">
<span class="nav-icon">👥</span><span class="nav-label">User Management</span>
</NavLink>
<NavLink href="/admin/audit" class="nav-item">
<span class="nav-icon">📋</span><span class="nav-label">Audit Logs</span>
</NavLink>
}
<NavLink href="/settings" class="nav-item">
<span class="nav-icon">🔧</span><span class="nav-label">Settings</span>
</NavLink>
<AuthorizeView>
<Authorized>
<a href="/account/logout" class="nav-item" style="color:var(--text-muted)">
<span class="nav-icon">🚪</span><span class="nav-label">Logout</span>
</a>
</Authorized>
</AuthorizeView>
</nav>
</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 _hasCredentials;
private string _credUsername = string.Empty;
private SessionCredentialsModal? _credModal;
protected override void OnInitialized()
{
Session.ProfileChanged += OnProfileChanged;
UserContext.Initialized += OnUserContextInitialized;
}
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();
_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 () =>
{
StateHasChanged();
// New profile selected → prompt for credentials if none
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 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;
}
}