336 lines
13 KiB
Plaintext
336 lines
13 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
|
||
@inject SharepointToolbox.Web.Infrastructure.Persistence.ProfileRepository ProfileRepo
|
||
@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)
|
||
{
|
||
<header class="topbar">
|
||
<ProfileSelector />
|
||
</header>
|
||
@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();
|
||
|
||
// 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()
|
||
{
|
||
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;
|
||
}
|
||
}
|