This commit is contained in:
2026-06-02 15:46:13 +02:00
25 changed files with 951 additions and 215 deletions
+122 -69
View File
@@ -18,18 +18,21 @@
<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 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="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">
<div class="identity-badge">
<div class="identity-name">
@UserContext.DisplayName
</div>
<div style="font-size:11px;color:var(--text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
<div class="identity-email">
@UserContext.Email
</div>
<div style="margin-top:4px">
@@ -55,80 +58,55 @@
</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">
<NavLink href="/" Match="NavLinkMatch.All" class="nav-item">
<span class="nav-icon">🏠</span><span class="nav-label">Home</span>
</NavLink>
@if (Session.HasProfile)
@{
string? lastSection = null;
var items = VisibleNavItems().ToList();
}
@foreach (var item in items)
{
<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>
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>
}
@* Admin-only section *@
@if (UserContext.Role == UserRole.Admin)
@if (items.Count == 0)
{
<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>
<div class="nav-empty">No match</div>
}
</nav>
<NavLink href="/settings" class="nav-item">
<span class="nav-icon">🔧</span><span class="nav-label">Settings</span>
</NavLink>
<div class="sidebar-footer">
<AuthorizeView>
<Authorized>
<a href="/account/logout" class="nav-item" style="color:var(--text-muted)">
<a href="/account/logout" class="nav-item">
<span class="nav-icon">🚪</span><span class="nav-label">Logout</span>
</a>
</Authorized>
</AuthorizeView>
</nav>
<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">
@@ -147,7 +125,49 @@
@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;
@@ -155,6 +175,7 @@
{
Session.ProfileChanged += OnProfileChanged;
UserContext.Initialized += OnUserContextInitialized;
_dark = string.Equals(Session.Settings.Theme, "Dark", StringComparison.OrdinalIgnoreCase);
}
private void OnUserContextInitialized() => InvokeAsync(StateHasChanged);
@@ -209,7 +230,20 @@
private async Task RefreshCredentialState()
{
var tokens = await CredStore.GetAsync();
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);
@@ -234,8 +268,11 @@
{
InvokeAsync(async () =>
{
StateHasChanged();
// New profile selected → prompt for credentials if none
// 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();
});
@@ -252,6 +289,22 @@
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",