fe33960c0e
Standard technicians (TechN0/TechN1) are no longer auto-prompted for a delegated SharePoint sign-in when selecting a profile — only admins are. Techs operate under the profile's app (certificate) identity, so a profile selection never forces them to authenticate. To keep that usable, the admin profile list now shows a "No shared access" badge on any profile that isn't certificate-configured, since standard techs can't operate against those until an admin registers a cert. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
378 lines
16 KiB
Plaintext
378 lines
16 KiB
Plaintext
@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", "", "profile"),
|
||
new("/transfer", "📦", "nav.fileTransfer", "", "profile"),
|
||
new("/bulk-members", "👥", "tab.bulkMembers", "nav.section.bulk", "profile"),
|
||
new("/bulk-sites", "🌐", "tab.bulkSites", "nav.section.bulk", "profile"),
|
||
new("/folder-structure", "📁", "tab.folderStructure", "nav.section.bulk", "profile"),
|
||
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", "profile"),
|
||
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,
|
||
"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;
|
||
}
|
||
}
|