This commit is contained in:
2026-06-10 14:24:32 +02:00
51 changed files with 3487 additions and 247 deletions
+1
View File
@@ -64,3 +64,4 @@ data/logs/
data/exports/ data/exports/
data/templates/ data/templates/
data/audit.jsonl data/audit.jsonl
data/appcerts/
+24 -3
View File
@@ -4,6 +4,7 @@
@inject IUserContextAccessor UserContext @inject IUserContextAccessor UserContext
@inject ISessionCredentialStore CredStore @inject ISessionCredentialStore CredStore
@inject ISessionManager SessionManager @inject ISessionManager SessionManager
@inject SharepointToolbox.Web.Infrastructure.Auth.IAppOnlyContextFactory AppOnly
@inject NavigationManager Nav @inject NavigationManager Nav
@inject IJSRuntime JS @inject IJSRuntime JS
@inject SharepointToolbox.Web.Services.OAuth.IOAuthFlowCache OAuthCache @inject SharepointToolbox.Web.Services.OAuth.IOAuthFlowCache OAuthCache
@@ -21,7 +22,7 @@
<aside class="sidebar @(_sidebarCollapsed ? "collapsed" : "")"> <aside class="sidebar @(_sidebarCollapsed ? "collapsed" : "")">
<div class="sidebar-header"> <div class="sidebar-header">
<div class="logo"> <div class="logo">
<span class="logo-mark">SP</span> <img class="logo-mark" src="SPToolbox-logo-ico.png" alt="SP Toolbox" />
<span class="logo-text">SP Toolbox</span> <span class="logo-text">SP Toolbox</span>
</div> </div>
<button class="toggle-btn @(_sidebarCollapsed ? "collapsed" : "")" @onclick="ToggleSidebar" title="@T["nav.toggleSidebar"]"></button> <button class="toggle-btn @(_sidebarCollapsed ? "collapsed" : "")" @onclick="ToggleSidebar" title="@T["nav.toggleSidebar"]"></button>
@@ -44,8 +45,11 @@
{ {
<div style="font-size:10px;color:var(--text-muted);margin-top:4px"> <div style="font-size:10px;color:var(--text-muted);margin-top:4px">
SP: @_credUsername SP: @_credUsername
@if (!CurrentProfileUsesCert)
{
<button class="btn btn-secondary btn-sm" style="padding:2px 6px;font-size:10px;margin-left:4px" <button class="btn btn-secondary btn-sm" style="padding:2px 6px;font-size:10px;margin-left:4px"
@onclick="ReconnectAsync">@T["nav.reconnect"]</button> @onclick="ReconnectAsync">@T["nav.reconnect"]</button>
}
</div> </div>
} }
</div> </div>
@@ -149,7 +153,9 @@
new("/folder-structure", "📁", "tab.folderStructure", "nav.section.bulk", "profile"), new("/folder-structure", "📁", "tab.folderStructure", "nav.section.bulk", "profile"),
new("/user-audit", "👤", "tab.userAccessAudit", "nav.section.audit", "profile"), new("/user-audit", "👤", "tab.userAccessAudit", "nav.section.audit", "profile"),
new("/user-directory", "📖", "nav.userDirectory", "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("/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("/profiles", "⚙️", "nav.clientProfiles", "nav.section.admin", "admin"),
new("/admin/users", "👥", "nav.userManagement", "nav.section.admin", "admin"), new("/admin/users", "👥", "nav.userManagement", "nav.section.admin", "admin"),
new("/admin/audit", "📋", "nav.auditLogs", "nav.section.admin", "admin"), new("/admin/audit", "📋", "nav.auditLogs", "nav.section.admin", "admin"),
@@ -219,11 +225,16 @@
} }
} }
// If profile selected but no credentials → show modal // If profile selected but no credentials → show modal (cert profiles never prompt)
if (Session.HasProfile && !_hasCredentials && _credModal is not null) if (Session.HasProfile && !_hasCredentials && !CurrentProfileUsesCert && _credModal is not null)
await _credModal.ShowAsync(); 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);
private async Task HandleOAuthCallbackAsync() private async Task HandleOAuthCallbackAsync()
{ {
var uri = new Uri(Nav.Uri); var uri = new Uri(Nav.Uri);
@@ -256,6 +267,16 @@
private async Task RefreshCredentialState() 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(); var tokens = await CredStore.GetAsync();
// Session tokens are tenant-bound (refresh token issued for the profile's TenantId/ClientId). // Session tokens are tenant-bound (refresh token issued for the profile's TenantId/ClientId).
+1 -1
View File
@@ -61,7 +61,7 @@
@foreach (var g in _results.Take(100)) @foreach (var g in _results.Take(100))
{ {
<div style="margin-bottom:8px;border:1px solid var(--border);border-radius:4px;overflow:hidden"> <div style="margin-bottom:8px;border:1px solid var(--border);border-radius:4px;overflow:hidden">
<div style="background:#f0f0f0;padding:6px 12px;font-weight:600;font-size:13px"> <div style="background:var(--th-bg);padding:6px 12px;font-weight:600;font-size:13px">
@g.Name <span class="chip chip-blue">@g.Items.Count @T["report.text.copies"]</span> @g.Name <span class="chip chip-blue">@g.Items.Count @T["report.text.copies"]</span>
</div> </div>
@foreach (var item in g.Items) @foreach (var item in g.Items)
+176 -6
View File
@@ -3,13 +3,17 @@
@inject IUserSessionService Session @inject IUserSessionService Session
@inject IUserContextAccessor UserContext @inject IUserContextAccessor UserContext
@inject SharepointToolbox.Web.Infrastructure.Persistence.ProfileRepository ProfileRepo @inject SharepointToolbox.Web.Infrastructure.Persistence.ProfileRepository ProfileRepo
@inject SharepointToolbox.Web.Infrastructure.Auth.IAppOnlyCertStore CertStore
@inject SharepointToolbox.Web.Infrastructure.Auth.IAppOnlyContextFactory AppOnlyFactory
@inject ISessionCredentialStore CredStore @inject ISessionCredentialStore CredStore
@inject NavigationManager Nav @inject NavigationManager Nav
@inject SharepointToolbox.Web.Services.OAuth.IEntraDeviceCodeFlow DeviceFlow @inject SharepointToolbox.Web.Services.OAuth.IEntraDeviceCodeFlow DeviceFlow
@inject SharepointToolbox.Web.Services.Auth.IAppRegistrationService AppRegService @inject SharepointToolbox.Web.Services.Auth.IAppRegistrationService AppRegService
@inject SharepointToolbox.Web.Services.Auth.ICertProvisioningService CertProvisioning
@inject Microsoft.Extensions.Options.IOptions<SharepointToolbox.Web.Core.Config.ClientConnectOptions> ConnectOpts @inject Microsoft.Extensions.Options.IOptions<SharepointToolbox.Web.Core.Config.ClientConnectOptions> ConnectOpts
@inject TranslationSource T @inject TranslationSource T
@rendermode InteractiveServer @rendermode InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.WebUtilities @using Microsoft.AspNetCore.WebUtilities
@using SharepointToolbox.Web.Core.Models @using SharepointToolbox.Web.Core.Models
@using SharepointToolbox.Web.Services.Session @using SharepointToolbox.Web.Services.Session
@@ -24,7 +28,7 @@
@foreach (var p in _profiles) @foreach (var p in _profiles)
{ {
<div class="card" style="@(Session.CurrentProfile?.Id == p.Id ? "border-color:#0078d4;border-width:2px" : "")"> <div class="card" style="@(Session.CurrentProfile?.Id == p.Id ? "border-color:var(--accent);border-width:2px" : "")">
<div class="flex-row"> <div class="flex-row">
<div> <div>
<div style="font-weight:600;font-size:15px">@p.Name</div> <div style="font-weight:600;font-size:15px">@p.Name</div>
@@ -62,7 +66,7 @@
@foreach (var p in _profiles) @foreach (var p in _profiles)
{ {
<div class="card" style="@(Session.CurrentProfile?.Id == p.Id ? "border-color:#0078d4;border-width:2px" : "")"> <div class="card" style="@(Session.CurrentProfile?.Id == p.Id ? "border-color:var(--accent);border-width:2px" : "")">
<div class="flex-row"> <div class="flex-row">
<div> <div>
<div style="font-weight:600;font-size:15px">@p.Name</div> <div style="font-weight:600;font-size:15px">@p.Name</div>
@@ -86,7 +90,7 @@
@if (_showForm) @if (_showForm)
{ {
<div class="card" style="border-color:#0078d4"> <div class="card" style="border-color:var(--accent)">
<div class="card-title">@(_editing?.Id == null ? T["profiles.form.new"] : T["profiles.form.edit"])</div> <div class="card-title">@(_editing?.Id == null ? T["profiles.form.new"] : T["profiles.form.edit"])</div>
@if (!string.IsNullOrEmpty(_formError)) @if (!string.IsNullOrEmpty(_formError))
{ {
@@ -151,6 +155,64 @@
<LogoUpload Value="_form.ClientLogo" ValueChanged="(LogoData? l) => _form.ClientLogo = l" /> <LogoUpload Value="_form.ClientLogo" ValueChanged="(LogoData? l) => _form.ClientLogo = l" />
</div> </div>
@* ── App-only credentials for scheduled (unattended) reports ── *@
<div class="form-group" style="border-top:1px solid var(--border);padding-top:14px;margin-top:10px">
<label class="form-label" style="font-size:14px;font-weight:600">Certificate auth (app identity)</label>
@if (_editing is null)
{
<div class="alert alert-info">Save this client first, then re-open it to configure certificate credentials.</div>
}
else
{
<small class="text-muted d-block" style="margin-bottom:8px">
When enabled, this client uses a certificate-based app registration with
<strong>application</strong> permissions (Sites.FullControl.All, admin-consented) for
<strong>both</strong> interactive work and scheduled reports. Technicians never sign in
to SharePoint per profile. The <em>Register app</em> button provisions the certificate
and consent automatically; the fields below are for manual setup.
</small>
<label style="display:block;margin-bottom:8px">
<input type="checkbox" @bind="_form.AppOnlyEnabled" /> Use certificate auth for this client (no per-profile sign-in)
</label>
<label class="form-label">App-only client (application) ID</label>
<input class="form-input" @bind="_form.AppOnlyClientId"
placeholder="GUID of the app registration used for app-only auth" />
<label class="form-label mt-8">Certificate (.pfx)</label>
@if (_certPresent)
{
<div class="flex-row" style="gap:8px;align-items:center">
<span class="chip chip-green">Certificate stored</span>
@if (!string.IsNullOrEmpty(_form.AppOnlyCertThumbprint))
{
<span class="text-muted" style="font-size:12px">@_form.AppOnlyCertThumbprint</span>
}
<button class="btn btn-secondary btn-sm" @onclick="RemoveCertAsync">Remove</button>
</div>
}
else
{
<div class="flex-row" style="gap:8px;align-items:center;flex-wrap:wrap">
<InputFile OnChange="OnCertSelected" accept=".pfx,.p12" />
<input class="form-input" type="password" @bind="_certPassword"
placeholder="PFX password (if any)" style="max-width:220px" />
<button class="btn btn-secondary btn-sm" @onclick="UploadCertAsync" disabled="@(_pfxBytes is null)">
Upload certificate
</button>
</div>
}
<div class="flex-row mt-8" style="gap:8px;align-items:center">
<button class="btn btn-secondary btn-sm" @onclick="TestAppOnlyAsync" disabled="@_appOnlyTesting">
@(_appOnlyTesting ? "Testing…" : "Test connection")
</button>
@if (!string.IsNullOrEmpty(_appOnlyStatus)) { <span class="text-muted" style="font-size:12px">@_appOnlyStatus</span> }
</div>
}
</div>
<div class="flex-row mt-8"> <div class="flex-row mt-8">
<button class="btn btn-primary" @onclick="SaveProfile">@T["profile.save"]</button> <button class="btn btn-primary" @onclick="SaveProfile">@T["profile.save"]</button>
<button class="btn btn-secondary" @onclick="CancelForm">@T["btn.cancel"]</button> <button class="btn btn-secondary" @onclick="CancelForm">@T["btn.cancel"]</button>
@@ -171,10 +233,12 @@
private string _regStatus = string.Empty; private string _regStatus = string.Empty;
private CancellationTokenSource? _regCts; private CancellationTokenSource? _regCts;
// Graph delegated scopes the admin must consent to so we can create the app registration. // Graph delegated scopes the admin must consent to so we can create the app registration,
// attach the certificate, and grant application-permission (app-role) admin consent.
private const string RegistrationScope = private const string RegistrationScope =
"https://graph.microsoft.com/Application.ReadWrite.All " + "https://graph.microsoft.com/Application.ReadWrite.All " +
"https://graph.microsoft.com/DelegatedPermissionGrant.ReadWrite.All " + "https://graph.microsoft.com/DelegatedPermissionGrant.ReadWrite.All " +
"https://graph.microsoft.com/AppRoleAssignment.ReadWrite.All " +
"https://graph.microsoft.com/Directory.Read.All " + "https://graph.microsoft.com/Directory.Read.All " +
"openid offline_access"; "openid offline_access";
@@ -219,9 +283,19 @@
private void EditProfile(TenantProfile p) private void EditProfile(TenantProfile p)
{ {
_editing = p; _editing = p;
_form = new TenantProfile { Id = p.Id, Name = p.Name, TenantUrl = p.TenantUrl, TenantId = p.TenantId, ClientId = p.ClientId, ClientLogo = p.ClientLogo }; _form = new TenantProfile
{
Id = p.Id, Name = p.Name, TenantUrl = p.TenantUrl, TenantId = p.TenantId,
ClientId = p.ClientId, ClientLogo = p.ClientLogo,
AppOnlyEnabled = p.AppOnlyEnabled, AppOnlyClientId = p.AppOnlyClientId,
AppOnlyCertThumbprint = p.AppOnlyCertThumbprint
};
_showForm = true; _showForm = true;
_formError = _pageError = string.Empty; _formError = _pageError = string.Empty;
_certPresent = CertStore.Exists(p.Id);
_pfxBytes = null;
_certPassword = string.Empty;
_appOnlyStatus = string.Empty;
} }
private void CancelForm() { _showForm = false; _editing = null; } private void CancelForm() { _showForm = false; _editing = null; }
@@ -256,14 +330,32 @@
_regStatus = T["profiles.reg.creating"]; _regStatus = T["profiles.reg.creating"];
StateHasChanged(); StateHasChanged();
// Generate + store the app-only certificate before creating the registration so its
// public key can be attached as a sign-in credential. Technicians then operate under
// the app identity and never sign in to SharePoint per profile.
var cert = await CertProvisioning.GenerateAndStoreAsync(_form.Id, $"SP Toolbox — {_form.Name}", _regCts.Token);
var clientId = await AppRegService.CreateAsync( var clientId = await AppRegService.CreateAsync(
adminAccessToken: adminToken, adminAccessToken: adminToken,
tenantName: _form.Name, tenantName: _form.Name,
redirectUri: ConnectOpts.Value.RedirectUri, redirectUri: ConnectOpts.Value.RedirectUri,
appOnlyCert: cert,
ct: _regCts.Token); ct: _regCts.Token);
_form.ClientId = clientId; _form.ClientId = clientId;
_regStatus = T["profiles.reg.registered"]; _form.AppOnlyClientId = clientId;
_form.AppOnlyEnabled = true;
_form.AppOnlyCertThumbprint = cert.Thumbprint;
_certPresent = true;
// Cert key credential + app-role consent take time to propagate through Entra;
// wait it out so the profile is usable immediately instead of 401ing on first use.
_regStatus = T["profiles.reg.propagating"];
StateHasChanged();
var notReady = await AppOnlyFactory.WaitUntilReadyAsync(_form, TimeSpan.FromSeconds(90), _regCts.Token);
_regStatus = notReady is null
? T["profiles.reg.registered"]
: string.Format(T["profiles.reg.notready"], notReady);
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
@@ -317,6 +409,84 @@
{ {
_profiles.RemoveAll(x => x.Id == p.Id); _profiles.RemoveAll(x => x.Id == p.Id);
await ProfileRepo.SaveAsync(_profiles); await ProfileRepo.SaveAsync(_profiles);
CertStore.Delete(p.Id);
if (Session.CurrentProfile?.Id == p.Id) await Session.ClearSessionAsync(); if (Session.CurrentProfile?.Id == p.Id) await Session.ClearSessionAsync();
} }
// ── App-only credential handlers ───────────────────────────────────────────
private const long MaxCertBytes = 256 * 1024;
private byte[]? _pfxBytes;
private string _certPassword = string.Empty;
private bool _certPresent;
private bool _appOnlyTesting;
private string _appOnlyStatus = string.Empty;
private async Task OnCertSelected(InputFileChangeEventArgs e)
{
_appOnlyStatus = string.Empty;
var file = e.File;
if (file is null) return;
if (file.Size > MaxCertBytes) { _appOnlyStatus = $"Certificate too large (max {MaxCertBytes / 1024} KB)."; _pfxBytes = null; return; }
try
{
using var ms = new MemoryStream();
await file.OpenReadStream(MaxCertBytes).CopyToAsync(ms);
_pfxBytes = ms.ToArray();
}
catch (Exception ex) { _appOnlyStatus = $"Failed to read certificate: {ex.Message}"; _pfxBytes = null; }
}
private async Task UploadCertAsync()
{
if (_pfxBytes is null || _editing is null) return;
try
{
var thumbprint = await CertStore.SaveAsync(_form.Id, _pfxBytes, string.IsNullOrEmpty(_certPassword) ? null : _certPassword);
_form.AppOnlyCertThumbprint = thumbprint;
_certPresent = true;
_pfxBytes = null;
_certPassword = string.Empty;
await PersistFormAsync();
_appOnlyStatus = "Certificate stored.";
}
catch (Exception ex) { _appOnlyStatus = $"Certificate rejected: {ex.Message}"; }
}
private async Task RemoveCertAsync()
{
if (_editing is null) return;
CertStore.Delete(_form.Id);
_certPresent = false;
_form.AppOnlyCertThumbprint = string.Empty;
await PersistFormAsync();
_appOnlyStatus = "Certificate removed.";
}
private async Task TestAppOnlyAsync()
{
if (_editing is null) return;
_appOnlyTesting = true; _appOnlyStatus = string.Empty;
try
{
// Persist current field edits first so the test uses what the admin sees.
await PersistFormAsync();
var probe = new TenantProfile
{
Id = _form.Id, Name = _form.Name, TenantUrl = _form.TenantUrl, TenantId = _form.TenantId,
AppOnlyEnabled = true, AppOnlyClientId = _form.AppOnlyClientId
};
var error = await AppOnlyFactory.TestConnectionAsync(probe);
_appOnlyStatus = error is null ? "✓ Connected successfully." : $"✗ {error}";
}
catch (Exception ex) { _appOnlyStatus = $"✗ {ex.Message}"; }
finally { _appOnlyTesting = false; }
}
// Upserts the in-progress form into the profile list and saves, without closing the form.
private async Task PersistFormAsync()
{
var idx = _profiles.FindIndex(p => p.Id == _form.Id);
if (idx >= 0) _profiles[idx] = _form; else _profiles.Add(_form);
await ProfileRepo.SaveAsync(_profiles);
}
} }
+101
View File
@@ -0,0 +1,101 @@
@page "/reports"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@inject IUserSessionService Session
@inject SharepointToolbox.Web.Infrastructure.Persistence.GeneratedReportRepository ReportIndex
@inject Microsoft.Extensions.Options.IOptions<SharepointToolbox.Web.Core.Models.AppConfiguration> Cfg
@inject TranslationSource T
@rendermode InteractiveServer
@using SharepointToolbox.Web.Core.Models
@using SharepointToolbox.Web.Services.Session
<h1 class="page-title">Reports</h1>
<p class="page-subtitle">Generated reports for the selected client.</p>
@if (!Session.HasProfile) { <NoProfilePrompt /> return; }
<div class="card">
<div class="flex-row">
<div class="card-title">@Session.CurrentProfile!.Name <span class="count-badge">@_reports.Count</span></div>
<div class="spacer"></div>
<button class="btn btn-secondary btn-sm" @onclick="Reload">Refresh</button>
</div>
@if (_reports.Count == 0)
{
<div class="alert alert-info">No reports generated yet for this client. Schedules run automatically; an admin can create them under Scheduled Reports.</div>
}
else
{
<div class="data-table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Name</th><th>Type</th><th>Generated (UTC)</th><th>Size</th><th>Status</th><th></th>
</tr>
</thead>
<tbody>
@foreach (var r in _reports)
{
<tr>
<td>@(string.IsNullOrEmpty(r.Name) ? "—" : r.Name)</td>
<td>@r.Type</td>
<td>@r.GeneratedUtc.ToString("yyyy-MM-dd HH:mm")</td>
<td>@(r.Status == ReportRunStatus.Success ? $"{r.SizeBytes / 1024.0:F1} KB" : "—")</td>
<td>
@if (r.Status == ReportRunStatus.Success)
{
<span class="chip chip-green">Success</span>
@if (r.Emailed)
{
<span class="chip chip-blue" style="margin-left:4px">Emailed</span>
}
else if (!string.IsNullOrEmpty(r.EmailError))
{
<span class="chip chip-red" style="margin-left:4px" title="@r.EmailError">Email failed</span>
}
}
else
{
<span class="chip chip-red" title="@r.Error">Failed</span>
}
</td>
<td>
<div class="flex-row" style="gap:6px;justify-content:flex-end">
@if (r.Status == ReportRunStatus.Success)
{
<a class="btn btn-secondary btn-sm" href="/reports/download/@r.Id" target="_blank" rel="noopener">Download</a>
}
<button class="btn btn-danger btn-sm" @onclick="() => DeleteAsync(r)">Delete</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
@code {
private List<GeneratedReport> _reports = new();
protected override async Task OnInitializedAsync() => await Reload();
private async Task Reload()
{
if (!Session.HasProfile) { _reports = new(); return; }
_reports = (await ReportIndex.LoadForProfileAsync(Session.CurrentProfile!.Id)).ToList();
}
private async Task DeleteAsync(GeneratedReport r)
{
// Remove the file (best-effort) then the index entry.
if (r.Status == ReportRunStatus.Success && !string.IsNullOrEmpty(r.FileName))
{
var path = System.IO.Path.Combine(Cfg.Value.ExportsFolder, r.ProfileId, System.IO.Path.GetFileName(r.FileName));
try { if (System.IO.File.Exists(path)) System.IO.File.Delete(path); } catch { /* ignore */ }
}
await ReportIndex.DeleteAsync(r.Id);
await Reload();
}
}
+499
View File
@@ -0,0 +1,499 @@
@page "/scheduled-reports"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@inject IUserContextAccessor UserContext
@inject SharepointToolbox.Web.Infrastructure.Persistence.ScheduledReportRepository ScheduleRepo
@inject SharepointToolbox.Web.Infrastructure.Persistence.ProfileRepository ProfileRepo
@inject SharepointToolbox.Web.Services.Reports.IScheduledReportRunner Runner
@inject SharepointToolbox.Web.Services.Reports.ScheduledRunCoordinator Coordinator
@inject TranslationSource T
@rendermode InteractiveServer
@using SharepointToolbox.Web.Core.Models
@using SharepointToolbox.Web.Services.Export
<h1 class="page-title">Scheduled Reports</h1>
<p class="page-subtitle">Automatic report generation per client. Generated files appear under Reports and are downloadable there.</p>
@if (UserContext.Role != UserRole.Admin)
{
<div class="alert alert-info">Only administrators can manage scheduled reports.</div>
return;
}
@if (!string.IsNullOrEmpty(_pageMsg)) { <div class="alert alert-info" style="margin-bottom:12px">@_pageMsg</div> }
@if (_appOnlyProfiles.Count == 0)
{
<div class="alert alert-error">
No client has app-only access enabled. Open a client under <a href="/profiles">Client Profiles</a>,
enable scheduled reports, and upload its certificate first.
</div>
}
<div class="flex-row" style="margin-bottom:16px">
<button class="btn btn-primary" @onclick="AddNew" disabled="@(_appOnlyProfiles.Count == 0)">New schedule</button>
<div class="spacer"></div>
@if (Coordinator.IsPaused)
{
<span class="chip chip-blue" style="align-self:center">Scheduler paused</span>
<button class="btn btn-secondary btn-sm" @onclick="ResumeScheduler">Resume scheduler</button>
}
else
{
<button class="btn btn-secondary btn-sm" @onclick="PauseScheduler">Pause scheduler</button>
}
<button class="btn btn-danger btn-sm" @onclick="StopAll">Stop all running</button>
</div>
@if (_schedules.Count == 0 && !_showForm)
{
<div class="alert alert-info">No schedules defined.</div>
}
@foreach (var s in _schedules)
{
<div class="card">
<div class="flex-row">
<div>
<div style="font-weight:600;font-size:15px">
@(string.IsNullOrEmpty(s.Name) ? "(unnamed)" : s.Name)
@if (!s.Enabled) { <span class="chip chip-blue" style="margin-left:6px">Disabled</span> }
</div>
<div class="text-muted">@ClientName(s.ProfileId) · @s.Type · @s.Format · @RecurrenceSummary(s.Recurrence)</div>
<div class="text-muted">
@(s.AllSites ? "All sites" : $"{s.SiteUrls.Count} site(s)") ·
Next: @(s.NextRunUtc?.ToString("yyyy-MM-dd HH:mm 'UTC'") ?? "—") ·
Last: @(s.LastRunUtc?.ToString("yyyy-MM-dd HH:mm 'UTC'") ?? "never")
</div>
</div>
<div class="spacer"></div>
<button class="btn btn-secondary btn-sm" @onclick="() => RunNowAsync(s)" disabled="@Coordinator.IsRunning(s.Id)">
@(Coordinator.IsRunning(s.Id) ? "Running…" : "Run now")
</button>
@if (Coordinator.IsRunning(s.Id))
{
<button class="btn btn-danger btn-sm" @onclick="() => Stop(s)">Stop</button>
}
<button class="btn btn-secondary btn-sm" @onclick="() => ToggleEnabledAsync(s)">@(s.Enabled ? "Disable" : "Enable")</button>
<button class="btn btn-secondary btn-sm" @onclick="() => Edit(s)">Edit</button>
<button class="btn btn-danger btn-sm" @onclick="() => DeleteAsync(s)">Delete</button>
</div>
</div>
}
@if (_showForm)
{
<div class="card" style="border-color:var(--accent)">
<div class="card-title">@(_editing is null ? "New schedule" : "Edit schedule")</div>
@if (!string.IsNullOrEmpty(_formError)) { <div class="alert alert-error">@_formError</div> }
<div class="form-group">
<label class="form-label">Name</label>
<input class="form-input" @bind="_form.Name" placeholder="e.g. Weekly permissions audit" />
</div>
<div class="form-row">
<div class="form-group" style="flex:1">
<label class="form-label">Client</label>
<select class="form-input" @bind="_form.ProfileId">
@foreach (var p in _appOnlyProfiles)
{
<option value="@p.Id">@p.Name</option>
}
</select>
</div>
<div class="form-group" style="flex:1">
<label class="form-label">Report type</label>
<select class="form-input" @bind="_form.Type">
@foreach (var t in Enum.GetValues<ReportType>())
{
<option value="@t">@t</option>
}
</select>
</div>
</div>
@if (_form.Type == ReportType.VersionCleanup)
{
<div class="alert alert-error">
<strong>Destructive action.</strong> Version Cleanup permanently <strong>deletes</strong> old file
versions across the selected sites every time it runs — unattended, with no confirmation.
The report is only a summary of what was deleted. Output is HTML (no CSV).
</div>
}
<div class="form-row">
<div class="form-group" style="flex:1">
<label class="form-label">Format</label>
<select class="form-input" @bind="_form.Format" disabled="@(_form.Type == ReportType.VersionCleanup)">
@foreach (var f in Enum.GetValues<ReportFormat>())
{
<option value="@f">@f</option>
}
</select>
</div>
<div class="form-group" style="flex:1">
<label class="form-label">Multi-site bundling</label>
<select class="form-input" @bind="_form.MergeMode">
@foreach (var m in Enum.GetValues<ReportMergeMode>())
{
<option value="@m">@m</option>
}
</select>
</div>
</div>
@* ── Site scope ── *@
<div class="form-group">
<label><input type="checkbox" @bind="_form.AllSites" /> All sites in the tenant (auto-discovered)</label>
@if (!_form.AllSites)
{
<label class="form-label mt-8">Site URLs (one per line)</label>
<textarea class="form-textarea" rows="3" @bind="_siteUrlsText"
placeholder="https://contoso.sharepoint.com/sites/Marketing"></textarea>
}
</div>
@* ── Recurrence ── *@
<div class="form-row">
<div class="form-group" style="flex:1">
<label class="form-label">Frequency</label>
<select class="form-input" @bind="_form.Recurrence.Frequency">
@foreach (var f in Enum.GetValues<ReportFrequency>())
{
<option value="@f">@f</option>
}
</select>
</div>
<div class="form-group" style="flex:1">
<label class="form-label">Time (UTC, HH:mm)</label>
<input class="form-input" @bind="_form.Recurrence.TimeOfDayUtc" placeholder="06:00" />
</div>
@if (_form.Recurrence.Frequency == ReportFrequency.Weekly)
{
<div class="form-group" style="flex:1">
<label class="form-label">Day of week</label>
<select class="form-input" @bind="_form.Recurrence.DayOfWeek">
@foreach (var d in Enum.GetValues<DayOfWeek>())
{
<option value="@d">@d</option>
}
</select>
</div>
}
else if (_form.Recurrence.Frequency == ReportFrequency.Monthly)
{
<div class="form-group" style="flex:1">
<label class="form-label">Day of month</label>
<input class="form-input" type="number" min="1" max="31" @bind="_form.Recurrence.DayOfMonth" />
</div>
}
</div>
@* ── Type-specific options ── *@
<div class="form-group" style="border-top:1px solid var(--border);padding-top:12px">
<label class="form-label" style="font-weight:600">Options</label>
@switch (_form.Type)
{
case ReportType.Permissions:
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:center">
<label><input type="checkbox" @bind="_form.Options.IncludeInherited" /> Include inherited</label>
<label><input type="checkbox" @bind="_form.Options.ScanFolders" /> Scan folders</label>
<label><input type="checkbox" @bind="_form.Options.IncludeSubsites" /> Include subsites</label>
<label>Folder depth <input class="form-input" type="number" min="0" max="999" style="width:80px" @bind="_form.Options.FolderDepth" /></label>
</div>
break;
case ReportType.Storage:
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:center">
<label><input type="checkbox" @bind="_form.Options.IncludeSubsites" /> Include subsites</label>
<label><input type="checkbox" @bind="_form.Options.IncludeHiddenLibraries" /> Include hidden libraries</label>
<label><input type="checkbox" @bind="_form.Options.IncludeRecycleBin" /> Include recycle bin</label>
<label>Folder depth <input class="form-input" type="number" min="0" max="20" style="width:80px" @bind="_form.Options.FolderDepth" /></label>
</div>
break;
case ReportType.Duplicates:
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:center">
<label>Mode
<select class="form-input" style="width:120px" @bind="_form.Options.DuplicateMode">
<option value="Files">Files</option>
<option value="Folders">Folders</option>
</select>
</label>
<label><input type="checkbox" @bind="_form.Options.MatchSize" /> Match size</label>
<label><input type="checkbox" @bind="_form.Options.IncludeSubsites" /> Include subsites</label>
<label>Library <input class="form-input" style="width:160px" @bind="_form.Options.Library" placeholder="(all)" /></label>
</div>
break;
case ReportType.Search:
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:center">
<label>Extensions <input class="form-input" style="width:180px" @bind="_extensionsText" placeholder="pdf, docx" /></label>
<label>Regex <input class="form-input" style="width:180px" @bind="_form.Options.Regex" placeholder="(optional)" /></label>
<label>Max results <input class="form-input" type="number" min="1" style="width:100px" @bind="_form.Options.MaxResults" /></label>
<label>Library <input class="form-input" style="width:160px" @bind="_form.Options.Library" placeholder="(all)" /></label>
</div>
break;
case ReportType.UserAccess:
<label class="form-label">Target users (login/email, one per line)</label>
<textarea class="form-textarea" rows="2" @bind="_targetUsersText" placeholder="alice@contoso.com&#10;bob@contoso.com"></textarea>
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:center;margin-top:8px">
<label><input type="checkbox" @bind="_form.Options.IncludeInherited" /> Include inherited</label>
<label><input type="checkbox" @bind="_form.Options.IncludeSubsites" /> Include subsites</label>
</div>
break;
case ReportType.VersionCleanup:
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:center">
<label>Libraries (comma separated, blank = all) <input class="form-input" style="width:220px" @bind="_libraryTitlesText" /></label>
<label>Keep last <input class="form-input" type="number" min="0" style="width:80px" @bind="_form.Options.KeepLast" /></label>
<label><input type="checkbox" @bind="_form.Options.KeepFirst" /> Keep first version</label>
</div>
break;
}
</div>
@* ── Email delivery ── *@
<div class="form-group" style="border-top:1px solid var(--border);padding-top:12px">
<label style="font-weight:600"><input type="checkbox" @bind="_form.Email.Enabled" /> Email the report via Graph</label>
@if (_form.Email.Enabled)
{
<div class="alert alert-info" style="margin-top:8px">
Sent through the client's app-only certificate (requires the <strong>Mail.Send</strong> application
permission — re-run onboarding if the app was registered before this was added). The report file is attached.
</div>
<div class="form-group">
<label class="form-label">From (sender mailbox)</label>
<input class="form-input" @bind="_form.Email.From" placeholder="reports@contoso.com" />
</div>
<div class="form-row">
<div class="form-group" style="flex:1">
<label class="form-label">To (one per line)</label>
<textarea class="form-textarea" rows="2" @bind="_emailToText" placeholder="alice@contoso.com"></textarea>
</div>
<div class="form-group" style="flex:1">
<label class="form-label">Cc (one per line)</label>
<textarea class="form-textarea" rows="2" @bind="_emailCcText" placeholder="(optional)"></textarea>
</div>
</div>
<div class="form-group">
<label class="form-label">Subject</label>
<input class="form-input" @bind="_form.Email.Subject" />
</div>
<div class="form-group">
<label class="form-label">Body (HTML)</label>
<textarea class="form-textarea" rows="5" @bind="_form.Email.Body"></textarea>
<div class="text-muted" style="margin-top:4px">
Placeholders: {ReportName} {ClientName} {ReportType} {FileName} {DateUtc}
</div>
</div>
}
</div>
<div class="form-group">
<label><input type="checkbox" @bind="_form.Enabled" /> Enabled</label>
</div>
<div class="flex-row mt-8">
<button class="btn btn-primary" @onclick="SaveAsync">Save</button>
<button class="btn btn-secondary" @onclick="() => _showForm = false">Cancel</button>
</div>
</div>
}
@code {
private List<ScheduledReport> _schedules = new();
private List<TenantProfile> _appOnlyProfiles = new();
private bool _showForm;
private ScheduledReport? _editing;
private ScheduledReport _form = new();
private string _formError = string.Empty;
private string _pageMsg = string.Empty;
// Textarea/CSV scratch buffers mapped to/from the option lists on save.
private string _siteUrlsText = string.Empty;
private string _extensionsText = string.Empty;
private string _targetUsersText = string.Empty;
private string _libraryTitlesText = string.Empty;
private string _emailToText = string.Empty;
private string _emailCcText = string.Empty;
protected override async Task OnInitializedAsync()
{
if (UserContext.Role != UserRole.Admin) return;
await Reload();
}
private async Task Reload()
{
_schedules = (await ScheduleRepo.LoadAsync()).OrderBy(s => s.Name).ToList();
_appOnlyProfiles = (await ProfileRepo.LoadAsync()).Where(p => p.AppOnlyEnabled).OrderBy(p => p.Name).ToList();
}
private string ClientName(string profileId) =>
_appOnlyProfiles.FirstOrDefault(p => p.Id == profileId)?.Name ?? "(client removed)";
private static string RecurrenceSummary(RecurrenceRule r) => r.Frequency switch
{
ReportFrequency.Daily => $"Daily at {r.TimeOfDayUtc} UTC",
ReportFrequency.Weekly => $"Weekly {r.DayOfWeek} at {r.TimeOfDayUtc} UTC",
ReportFrequency.Monthly => $"Monthly day {r.DayOfMonth} at {r.TimeOfDayUtc} UTC",
_ => r.TimeOfDayUtc
};
private void AddNew()
{
_editing = null;
_form = new ScheduledReport
{
ProfileId = _appOnlyProfiles.FirstOrDefault()?.Id ?? string.Empty,
CreatedBy = UserContext.Email
};
_siteUrlsText = _extensionsText = _targetUsersText = _libraryTitlesText = string.Empty;
_emailToText = _emailCcText = string.Empty;
_formError = string.Empty;
_showForm = true;
}
private void Edit(ScheduledReport s)
{
_editing = s;
// Deep-ish copy so cancel discards edits.
_form = new ScheduledReport
{
Id = s.Id, ProfileId = s.ProfileId, Name = s.Name, Type = s.Type,
AllSites = s.AllSites, SiteUrls = new List<string>(s.SiteUrls),
MergeMode = s.MergeMode, Format = s.Format, Enabled = s.Enabled,
CreatedBy = s.CreatedBy, CreatedUtc = s.CreatedUtc,
LastRunUtc = s.LastRunUtc, NextRunUtc = s.NextRunUtc,
Recurrence = new RecurrenceRule
{
Frequency = s.Recurrence.Frequency, TimeOfDayUtc = s.Recurrence.TimeOfDayUtc,
DayOfWeek = s.Recurrence.DayOfWeek, DayOfMonth = s.Recurrence.DayOfMonth
},
Options = Clone(s.Options),
Email = new ReportEmailSettings
{
Enabled = s.Email.Enabled, From = s.Email.From,
To = new List<string>(s.Email.To), Cc = new List<string>(s.Email.Cc),
Subject = s.Email.Subject, Body = s.Email.Body
}
};
_siteUrlsText = string.Join("\n", s.SiteUrls);
_extensionsText = string.Join(", ", s.Options.Extensions);
_targetUsersText = string.Join("\n", s.Options.TargetUserLogins);
_libraryTitlesText = string.Join(", ", s.Options.LibraryTitles);
_emailToText = string.Join("\n", s.Email.To);
_emailCcText = string.Join("\n", s.Email.Cc);
_formError = string.Empty;
_showForm = true;
}
private static ScheduledReportOptions Clone(ScheduledReportOptions o) => new()
{
IncludeInherited = o.IncludeInherited, ScanFolders = o.ScanFolders, FolderDepth = o.FolderDepth,
IncludeSubsites = o.IncludeSubsites, PerLibrary = o.PerLibrary,
IncludeHiddenLibraries = o.IncludeHiddenLibraries, IncludePreservationHold = o.IncludePreservationHold,
IncludeListAttachments = o.IncludeListAttachments, IncludeRecycleBin = o.IncludeRecycleBin,
DuplicateMode = o.DuplicateMode, MatchSize = o.MatchSize, MatchCreated = o.MatchCreated,
MatchModified = o.MatchModified, MatchSubfolderCount = o.MatchSubfolderCount, MatchFileCount = o.MatchFileCount,
Library = o.Library, LibraryTitles = new List<string>(o.LibraryTitles), KeepLast = o.KeepLast, KeepFirst = o.KeepFirst,
Extensions = new List<string>(o.Extensions), Regex = o.Regex, MaxResults = o.MaxResults,
TargetUserLogins = new List<string>(o.TargetUserLogins)
};
private async Task SaveAsync()
{
_formError = string.Empty;
if (string.IsNullOrWhiteSpace(_form.Name)) { _formError = "Name is required."; return; }
if (string.IsNullOrWhiteSpace(_form.ProfileId)) { _formError = "Select a client."; return; }
// VersionCleanup has no CSV exporter.
if (_form.Type == ReportType.VersionCleanup) _form.Format = ReportFormat.Html;
// Map scratch buffers back to option lists.
_form.SiteUrls = _siteUrlsText.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
_form.Options.Extensions = _extensionsText.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
_form.Options.TargetUserLogins = _targetUsersText.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
_form.Options.LibraryTitles = _libraryTitlesText.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
if (!_form.AllSites && _form.SiteUrls.Count == 0) { _formError = "Add at least one site URL, or choose All sites."; return; }
if (_form.Type == ReportType.UserAccess && _form.Options.TargetUserLogins.Count == 0) { _formError = "User Access reports need at least one target user."; return; }
// Map email scratch buffers back to lists and validate when delivery is on.
_form.Email.To = _emailToText.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
_form.Email.Cc = _emailCcText.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
if (_form.Email.Enabled)
{
if (string.IsNullOrWhiteSpace(_form.Email.From)) { _formError = "Email delivery needs a sender mailbox (From)."; return; }
if (_form.Email.To.Count == 0 && _form.Email.Cc.Count == 0) { _formError = "Email delivery needs at least one To or Cc recipient."; return; }
}
// Arm the next run from now so the scheduler picks it up on the right cadence.
_form.NextRunUtc = _form.Recurrence.ComputeNextRunUtc(DateTime.UtcNow);
await ScheduleRepo.UpsertAsync(_form);
_showForm = false;
_editing = null;
await Reload();
}
private async Task ToggleEnabledAsync(ScheduledReport s)
{
s.Enabled = !s.Enabled;
if (s.Enabled && s.NextRunUtc is null) s.NextRunUtc = s.Recurrence.ComputeNextRunUtc(DateTime.UtcNow);
await ScheduleRepo.UpsertAsync(s);
await Reload();
}
private async Task DeleteAsync(ScheduledReport s)
{
await ScheduleRepo.DeleteAsync(s.Id);
await Reload();
}
private async Task RunNowAsync(ScheduledReport s)
{
// Register through the coordinator so this manual run is stoppable and can't
// overlap a scheduler-triggered run of the same schedule.
var token = Coordinator.TryBegin(s.Id, CancellationToken.None);
if (token is null) { _pageMsg = $"'{s.Name}' is already running."; return; }
_pageMsg = string.Empty;
await InvokeAsync(StateHasChanged);
try
{
var report = await Runner.RunAsync(s, token.Value);
_pageMsg = report.Status == ReportRunStatus.Success
? $"'{s.Name}' generated {report.FileName}. See Reports."
: $"'{s.Name}' failed: {report.Error}";
}
catch (OperationCanceledException) { _pageMsg = $"'{s.Name}' was stopped."; }
catch (Exception ex) { _pageMsg = $"'{s.Name}' failed: {ex.Message}"; }
finally { Coordinator.Complete(s.Id); await Reload(); }
}
private void Stop(ScheduledReport s) =>
_pageMsg = Coordinator.Cancel(s.Id)
? $"Stop signal sent to '{s.Name}'. It ends after the current site finishes."
: $"'{s.Name}' is not running.";
private void StopAll()
{
var n = Coordinator.CancelAll();
_pageMsg = n == 0 ? "No runs in progress." : $"Stop signal sent to {n} running report(s).";
}
private void PauseScheduler()
{
Coordinator.Pause();
_pageMsg = "Scheduler paused — no schedules will fire until resumed. Runs in progress keep going (Stop them individually).";
}
private void ResumeScheduler()
{
Coordinator.Resume();
_pageMsg = "Scheduler resumed.";
}
}
+14 -6
View File
@@ -1,10 +1,13 @@
@page "/settings" @page "/settings"
@attribute [Authorize] @attribute [Authorize]
@inject IUserSessionService Session @inject IUserSessionService Session
@inject IUserContextAccessor UserContext
@inject IJSRuntime JS @inject IJSRuntime JS
@inject TranslationSource T @inject TranslationSource T
@rendermode InteractiveServer @rendermode InteractiveServer
@using Microsoft.JSInterop @using Microsoft.JSInterop
@using SharepointToolbox.Web.Core.Models
@using SharepointToolbox.Web.Services.Session
<h1 class="page-title">@T["tab.settings"]</h1> <h1 class="page-title">@T["tab.settings"]</h1>
@@ -22,6 +25,7 @@
<select class="form-select" style="width:160px" @bind="_theme" @bind:after="Save"> <select class="form-select" style="width:160px" @bind="_theme" @bind:after="Save">
<option value="System">@T["settings.theme.system"]</option> <option value="System">@T["settings.theme.system"]</option>
<option value="Light">@T["settings.theme.light"]</option> <option value="Light">@T["settings.theme.light"]</option>
<option value="Dark">@T["settings.theme.dark"]</option>
</select> </select>
</div> </div>
</div> </div>
@@ -36,14 +40,18 @@
</div> </div>
</div> </div>
<div class="card"> @if (UserContext.Role == UserRole.Admin)
{
@* MSP branding is shared (global settings file) — only Admins set it for everyone. *@
<div class="card">
<div class="card-title">@T["settings.section.branding"]</div> <div class="card-title">@T["settings.section.branding"]</div>
<div class="form-group"> <div class="form-group">
<label class="form-label">@T["settings.logo.title"]</label> <label class="form-label">@T["settings.logo.title"]</label>
<p class="text-muted" style="margin-top:0">@T["settings.logo.description"]</p> <p class="text-muted" style="margin-top:0">@T["settings.logo.description"]</p>
<LogoUpload Value="_mspLogo" ValueChanged="OnMspLogoChanged" /> <LogoUpload Value="_mspLogo" ValueChanged="OnMspLogoChanged" />
</div> </div>
</div> </div>
}
@if (_saved) { <div class="alert alert-success">@T["settings.saved"]</div> } @if (_saved) { <div class="alert alert-success">@T["settings.saved"]</div> }
@@ -55,10 +63,10 @@
protected override void OnInitialized() protected override void OnInitialized()
{ {
var s = Session.Settings; var s = Session.Settings;
// Reflect the culture actually resolved for this circuit (cookie-driven), not the // Read the persisted language directly — the interactive circuit doesn't reliably inherit
// possibly-not-yet-loaded persisted setting. // ambient CurrentUICulture (see TranslationSource), so reading it here shows the wrong value.
_lang = System.Globalization.CultureInfo.CurrentUICulture.TwoLetterISOLanguageName == "fr" ? "fr" : "en"; _lang = s.Lang is "fr" or "en" ? s.Lang : "fr";
_theme = s.Theme is "System" or "Light" ? s.Theme : "System"; _theme = s.Theme is "System" or "Light" or "Dark" ? s.Theme : "System";
_autoTakeOwnership = s.AutoTakeOwnership; _autoTakeOwnership = s.AutoTakeOwnership;
_mspLogo = s.MspLogo; _mspLogo = s.MspLogo;
} }
+101 -4
View File
@@ -4,6 +4,7 @@
@inject ISessionManager SessionMgr @inject ISessionManager SessionMgr
@inject IUserAccessAuditService AuditSvc @inject IUserAccessAuditService AuditSvc
@inject IGraphUserDirectoryService GraphSvc @inject IGraphUserDirectoryService GraphSvc
@inject ISiteDiscoveryService SiteDiscovery
@inject UserAccessCsvExportService CsvExport @inject UserAccessCsvExportService CsvExport
@inject UserAccessHtmlExportService HtmlExport @inject UserAccessHtmlExportService HtmlExport
@inject WebExportService WebExport @inject WebExportService WebExport
@@ -56,6 +57,7 @@
<textarea class="form-textarea" @bind="_users" placeholder="alice@contoso.com&#10;bob@contoso.com" rows="2"></textarea> <textarea class="form-textarea" @bind="_users" placeholder="alice@contoso.com&#10;bob@contoso.com" rows="2"></textarea>
</div> </div>
<SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_sites" /> <SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_sites" />
<div class="text-muted" style="margin-top:4px">@T["audit.hint.allSites"]</div>
<div class="form-row"> <div class="form-row">
<div class="form-group" style="display:flex;align-items:center;gap:16px;padding-top:20px"> <div class="form-group" style="display:flex;align-items:center;gap:16px;padding-top:20px">
<label><input type="checkbox" @bind="_includeInherited" /> @T["audit.chk.includeInherited"]<HelpTip Text="@T["help.inheritedPerms"]" /></label> <label><input type="checkbox" @bind="_includeInherited" /> @T["audit.chk.includeInherited"]<HelpTip Text="@T["help.inheritedPerms"]" /></label>
@@ -79,10 +81,72 @@
<div class="card"> <div class="card">
<div class="flex-row"> <div class="flex-row">
<div class="card-title">@T["audit.results.title"] <span class="count-badge">@_results.Count</span></div> <div class="card-title">@T["audit.results.title"] <span class="count-badge">@_results.Count</span></div>
<button class="btn btn-sm @(_viewMode == "site" ? "btn-primary" : "btn-secondary")" @onclick='() => _viewMode = "site"'>@T["audit.view.bySite"]</button>
<button class="btn btn-sm @(_viewMode == "flat" ? "btn-primary" : "btn-secondary")" @onclick='() => _viewMode = "flat"'>@T["audit.view.table"]</button>
<div class="spacer"></div> <div class="spacer"></div>
<button class="btn btn-secondary btn-sm" @onclick="ExportCsv">@T["audit.btn.exportCsv"]</button> <button class="btn btn-secondary btn-sm" @onclick="ExportCsv">@T["audit.btn.exportCsv"]</button>
<button class="btn btn-secondary btn-sm" @onclick="ExportHtml">@T["audit.btn.exportHtml"]</button> <button class="btn btn-secondary btn-sm" @onclick="ExportHtml">@T["audit.btn.exportHtml"]</button>
</div> </div>
@if (_sitesScanned > 0)
{
<div class="flex-row mt-8" style="gap:8px">
<span class="chip chip-blue">@string.Format(T["audit.scan.sitesScanned"], _sitesScanned)</span>
@if (_sitesDenied > 0) { <span class="chip chip-yellow">@string.Format(T["audit.scan.sitesDenied"], _sitesDenied)</span> }
@if (_sitesFailed > 0) { <span class="chip chip-red">@string.Format(T["audit.scan.sitesFailed"], _sitesFailed)</span> }
</div>
}
@if (_viewMode == "site")
{
<div class="text-muted mt-8" style="margin-bottom:8px">@T["audit.bysite.hint"]</div>
@foreach (var g in _results.GroupBy(r => (r.SiteUrl, r.SiteTitle)).OrderBy(g => g.Key.SiteTitle, StringComparer.OrdinalIgnoreCase))
{
var siteUrl = g.Key.SiteUrl;
var expanded = _expandedSites.Contains(siteUrl);
var hasHigh = g.Any(e => e.IsHighPrivilege);
<div class="site-drill @(expanded ? "open" : "")">
<button class="site-drill-header" @onclick="() => ToggleSite(siteUrl)">
<span class="drill-caret @(expanded ? "open" : "")">▸</span>
<span class="drill-title">@g.Key.SiteTitle</span>
<span class="text-muted drill-url">@g.Key.SiteUrl</span>
<span class="spacer"></span>
@if (hasHigh) { <span class="chip chip-red">@T["audit.chip.high"]</span> }
<span class="count-badge">@g.Count() @T["report.text.permissions_parens"]</span>
</button>
@if (expanded)
{
<div class="site-drill-body">
<div class="data-table-wrap">
<table class="data-table">
<thead><tr>
@if (_multiUser) { <th>@T["report.col.user"]</th> }
<th>@T["report.col.object"]</th>
<th>@T["audit.col.permission"]<HelpTip Text="@T["help.permissionLevel"]" /></th>
<th>@T["report.col.access_type"]<HelpTip Text="@T["help.accessType"]" Wide="true" /></th>
<th>@T["report.col.granted_through"]<HelpTip Text="@T["help.grantedThrough"]" /></th>
</tr></thead>
<tbody>
@foreach (var r in g)
{
<tr>
@if (_multiUser) { <td>@r.UserDisplayName</td> }
<td>@r.ObjectTitle <span class="text-muted">(@r.ObjectType)</span></td>
<td>@r.PermissionLevel @if (r.IsHighPrivilege) { <span class="chip chip-red">@T["audit.chip.high"]</span> }</td>
<td>@r.AccessType</td>
<td>@r.GrantedThrough</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
</div>
}
}
else
{
<div class="data-table-wrap"> <div class="data-table-wrap">
<table class="data-table"> <table class="data-table">
<thead><tr><th>@T["report.col.user"]</th><th>@T["report.col.site"]</th><th>@T["report.col.object"]</th><th>@T["audit.col.permission"]<HelpTip Text="@T["help.permissionLevel"]" /></th><th>@T["report.col.access_type"]<HelpTip Text="@T["help.accessType"]" Wide="true" /></th><th>@T["report.col.granted_through"]<HelpTip Text="@T["help.grantedThrough"]" /></th></tr></thead> <thead><tr><th>@T["report.col.user"]</th><th>@T["report.col.site"]</th><th>@T["report.col.object"]</th><th>@T["audit.col.permission"]<HelpTip Text="@T["help.permissionLevel"]" /></th><th>@T["report.col.access_type"]<HelpTip Text="@T["help.accessType"]" Wide="true" /></th><th>@T["report.col.granted_through"]<HelpTip Text="@T["help.grantedThrough"]" /></th></tr></thead>
@@ -102,6 +166,7 @@
</table> </table>
</div> </div>
@if (_results.Count > 500) { <div class="text-muted mt-8">@T["audit.msg.showFirst500Export"]</div> } @if (_results.Count > 500) { <div class="text-muted mt-8">@T["audit.msg.showFirst500Export"]</div> }
}
</div> </div>
} }
@@ -147,26 +212,58 @@
private bool _running; private string _status = string.Empty, _error = string.Empty; private bool _running; private string _status = string.Empty, _error = string.Empty;
private int _current, _total; private int _current, _total;
private List<UserAccessEntry> _results = new(); private List<UserAccessEntry> _results = new();
private int _sitesScanned, _sitesDenied, _sitesFailed;
private CancellationTokenSource? _cts; private CancellationTokenSource? _cts;
// Results presentation: "site" = drill-down grouped by site (pick user → sites → click → detail);
// "flat" = the original per-entry table. Site view is default and matches the single-user flow.
private string _viewMode = "site";
private readonly HashSet<string> _expandedSites = new(StringComparer.OrdinalIgnoreCase);
// Show the User column inside the per-site detail only when the audit spans multiple users.
private bool _multiUser => _results.Select(r => r.UserLogin).Distinct(StringComparer.OrdinalIgnoreCase).Count() > 1;
private void ToggleSite(string siteUrl)
{
if (!_expandedSites.Remove(siteUrl)) _expandedSites.Add(siteUrl);
}
private async Task RunAudit() private async Task RunAudit()
{ {
_error = string.Empty; _results.Clear(); _running = true; _error = string.Empty; _results.Clear(); _expandedSites.Clear();
_sitesScanned = _sitesDenied = _sitesFailed = 0; _running = true;
_cts = new CancellationTokenSource(); _cts = new CancellationTokenSource();
var userList = _selectedEmails var userList = _selectedEmails
.Concat(_users.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) .Concat(_users.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); .Distinct(StringComparer.OrdinalIgnoreCase).ToList();
if (!userList.Any()) { _error = T["audit.err.noUsersOrEmail"]; _running = false; return; } if (!userList.Any()) { _error = T["audit.err.noUsersOrEmail"]; _running = false; return; }
var siteList = _sites.ToList(); var siteList = _sites.ToList();
if (!siteList.Any()) siteList.Add(new SiteInfo(Session.CurrentProfile!.TenantUrl, Session.CurrentProfile.Name)); // No explicit selection → audit every site in the tenant. The scan itself is resilient
// (per-site errors are skipped) so a tenant-wide run completes despite denied/failed sites.
if (siteList.Count == 0)
{
_status = T["audit.status.discoveringSites"];
await InvokeAsync(StateHasChanged);
try
{
siteList = (await SiteDiscovery.SearchSitesAsync(Session.CurrentProfile!, null, _cts.Token)).ToList();
}
catch (OperationCanceledException) { _status = T["audit.status.cancelled"]; _running = false; return; }
catch (Exception ex) { _error = string.Format(T["audit.err.discoverFailed"], ex.Message); _running = false; return; }
// Enumeration came back empty → fall back to the profile root site.
if (siteList.Count == 0)
siteList.Add(new SiteInfo(Session.CurrentProfile!.TenantUrl, Session.CurrentProfile.Name));
}
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); }); var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
try try
{ {
var opts = new ScanOptions(_includeInherited, _scanFolders, 1, _includeSubsites); var opts = new ScanOptions(_includeInherited, _scanFolders, 1, _includeSubsites);
_results = (await AuditSvc.AuditUsersAsync(SessionMgr, Session.CurrentProfile!, userList, siteList, opts, progress, _cts.Token)).ToList(); var res = await AuditSvc.AuditUsersAsync(SessionMgr, Session.CurrentProfile!, userList, siteList, opts, progress, _cts.Token);
_results = res.Entries.ToList();
_sitesScanned = res.SitesScanned; _sitesDenied = res.SitesDenied; _sitesFailed = res.SitesFailed;
_status = string.Format(T["audit.status.found"], _results.Count); _status = string.Format(T["audit.status.found"], _results.Count);
await Audit.LogAsync("UserAccessAudit", Session.CurrentProfile?.Name ?? "", siteList.Select(s => s.Url), await Audit.LogAsync("UserAccessAudit", Session.CurrentProfile?.Name ?? "", siteList.Select(s => s.Url),
$"{_results.Count} entries for {userList.Count} user(s)"); $"{_results.Count} entries for {userList.Count} user(s); {res.SitesScanned} sites, {res.SitesDenied} denied, {res.SitesFailed} failed");
} }
catch (OperationCanceledException) { _status = T["audit.status.cancelled"]; } catch (OperationCanceledException) { _status = T["audit.status.cancelled"]; }
catch (Exception ex) { _error = ex.Message; } catch (Exception ex) { _error = ex.Message; }
+21 -3
View File
@@ -1,6 +1,7 @@
@inject AuthenticationStateProvider AuthProvider @inject AuthenticationStateProvider AuthProvider
@inject IUserService UserService @inject IUserService UserService
@inject IUserContextAccessor UserContext @inject IUserContextAccessor UserContext
@inject ILogger<AppInitializer> Logger
@using Microsoft.AspNetCore.Components.Authorization @using Microsoft.AspNetCore.Components.Authorization
@using SharepointToolbox.Web.Services.Auth @using SharepointToolbox.Web.Services.Auth
@using SharepointToolbox.Web.Services.Session @using SharepointToolbox.Web.Services.Session
@@ -12,13 +13,30 @@
{ {
var state = await AuthProvider.GetAuthenticationStateAsync(); var state = await AuthProvider.GetAuthenticationStateAsync();
var principal = state.User; var principal = state.User;
if (principal.Identity?.IsAuthenticated != true) return;
if (principal.Identity?.IsAuthenticated != true)
{
Logger.LogWarning("AppInitializer: circuit principal NOT authenticated; UserContext left unseeded → page stays on loading.");
return;
}
var email = principal.FindFirst("preferred_username")?.Value var email = principal.FindFirst("preferred_username")?.Value
?? principal.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value; ?? principal.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value;
if (string.IsNullOrEmpty(email)) return; if (string.IsNullOrEmpty(email))
{
var claims = string.Join(", ", principal.Claims.Select(c => $"{c.Type}={c.Value}"));
Logger.LogWarning("AppInitializer: authenticated but no preferred_username/email claim. Claims present: [{Claims}]", claims);
return;
}
var user = await UserService.GetByEmailAsync(email); var user = await UserService.GetByEmailAsync(email);
if (user is not null) UserContext.Initialize(user); if (user is null)
{
Logger.LogWarning("AppInitializer: no user row for email '{Email}' — provisioning did not persist a matching record.", email);
return;
}
Logger.LogInformation("AppInitializer: seeded UserContext for '{Email}' (role {Role}).", user.Email, user.Role);
UserContext.Initialize(user);
} }
} }
+92
View File
@@ -0,0 +1,92 @@
using Microsoft.Online.SharePoint.TenantAdministration;
using Microsoft.SharePoint.Client;
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Core.Helpers;
/// <summary>
/// Enumerates every site collection in a tenant via the SharePoint tenant-admin
/// endpoint (<c>Tenant.GetSitePropertiesFromSharePointByFilters</c>), paging through
/// all results. Shared by the delegated <c>SiteDiscoveryService</c> and the app-only
/// background report scheduler so both produce the identical, complete site set.
///
/// The caller supplies a <see cref="ClientContext"/> already pointed at the tenant
/// admin host (see <see cref="BuildAdminUrl"/>) and authenticated by whichever model
/// applies. The cold-token 403 retry handles the transient denial a freshly minted
/// delegated token hits against the admin host; it is harmless under app-only auth.
/// </summary>
public static class TenantSiteEnumerator
{
private const int MaxColdTokenAttempts = 4;
private const int BackoffBaseSeconds = 3;
public static async Task<IReadOnlyList<SiteInfo>> EnumerateAsync(ClientContext adminCtx, CancellationToken ct)
{
var tenant = new Tenant(adminCtx);
var filter = new SPOSitePropertiesEnumerableFilter
{
IncludeDetail = false,
IncludePersonalSite = PersonalSiteFilter.Exclude,
StartIndex = null,
Template = null,
};
var results = new List<SiteInfo>();
SPOSitePropertiesEnumerable page;
do
{
ct.ThrowIfCancellationRequested();
page = await FetchPageWithColdTokenRetryAsync(adminCtx, tenant, filter, ct);
foreach (var sp in page)
{
var url = sp.Url ?? string.Empty;
if (string.IsNullOrEmpty(url)) continue;
if (url.Contains("/personal/", StringComparison.OrdinalIgnoreCase)) continue;
var title = string.IsNullOrEmpty(sp.Title) ? url : sp.Title;
results.Add(new SiteInfo(url, title));
}
filter.StartIndex = page.NextStartIndexFromSharePoint;
}
while (!string.IsNullOrEmpty(filter.StartIndex));
return results
.GroupBy(s => s.Url, StringComparer.OrdinalIgnoreCase)
.Select(g => g.First())
.OrderBy(s => s.Title, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static async Task<SPOSitePropertiesEnumerable> FetchPageWithColdTokenRetryAsync(
ClientContext ctx, Tenant tenant, SPOSitePropertiesEnumerableFilter filter, CancellationToken ct)
{
for (int attempt = 1; ; attempt++)
{
try
{
var page = tenant.GetSitePropertiesFromSharePointByFilters(filter);
ctx.Load(page);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct);
return page;
}
catch (SharePointAccessDeniedException ex) when (attempt < MaxColdTokenAttempts)
{
var delay = TimeSpan.FromSeconds(BackoffBaseSeconds * attempt);
Serilog.Log.Warning(
"Tenant admin endpoint denied during site enumeration (attempt {N}/{Max}); " +
"retrying in {Delay}s. {Err}",
attempt, MaxColdTokenAttempts, delay.TotalSeconds, ex.Message);
await Task.Delay(delay, ct);
}
}
}
/// <summary>https://contoso.sharepoint.com[/sites/Foo] → https://contoso-admin.sharepoint.com</summary>
public static string BuildAdminUrl(string tenantUrl)
{
if (!Uri.TryCreate(tenantUrl, UriKind.Absolute, out var uri))
return tenantUrl;
var adminHost = uri.Host.Replace(".sharepoint.com", "-admin.sharepoint.com",
StringComparison.OrdinalIgnoreCase);
return $"{uri.Scheme}://{adminHost}";
}
}
+3
View File
@@ -4,4 +4,7 @@ public class AppConfiguration
{ {
public string DataFolder { get; set; } = "/data"; public string DataFolder { get; set; } = "/data";
public string ExportsFolder { get; set; } = "/data/exports"; public string ExportsFolder { get; set; } = "/data/exports";
/// <summary>DataProtection-encrypted app-only certificates, one file per client profile.</summary>
public string CertsFolder { get; set; } = "/data/appcerts";
} }
+51
View File
@@ -0,0 +1,51 @@
namespace SharepointToolbox.Web.Core.Models;
public enum ReportRunStatus
{
Success,
Failed
}
/// <summary>
/// One produced report file, listed per client. The file itself lives under
/// {ExportsFolder}/{ProfileId}/{FileName}; this record is the index entry that the
/// "Reports" list and the id-based download endpoint resolve against.
/// Persisted to reports-index.json.
/// </summary>
public class GeneratedReport
{
public string Id { get; set; } = Guid.NewGuid().ToString();
public string ProfileId { get; set; } = string.Empty;
/// <summary>The schedule that produced this; null for an ad-hoc/manual run.</summary>
public string? ScheduledReportId { get; set; }
public ReportType Type { get; set; }
/// <summary>Human label (usually copied from the schedule name).</summary>
public string Name { get; set; } = string.Empty;
/// <summary>File name on disk, relative to the profile's exports subfolder.</summary>
public string FileName { get; set; } = string.Empty;
public string Mime { get; set; } = string.Empty;
public long SizeBytes { get; set; }
public DateTime GeneratedUtc { get; set; } = DateTime.UtcNow;
public ReportRunStatus Status { get; set; } = ReportRunStatus.Success;
/// <summary>Populated when <see cref="Status"/> is Failed.</summary>
public string? Error { get; set; }
/// <summary>True when the report was successfully emailed via Graph.</summary>
public bool Emailed { get; set; }
/// <summary>
/// Populated when email delivery was requested but failed. The report itself still
/// succeeded (file is on disk) — only delivery failed.
/// </summary>
public string? EmailError { get; set; }
}
+12
View File
@@ -0,0 +1,12 @@
namespace SharepointToolbox.Web.Core.Models;
/// <summary>The kinds of report that can be generated, scheduled, and exported.</summary>
public enum ReportType
{
Permissions,
Storage,
Duplicates,
UserAccess,
VersionCleanup,
Search
}
+196
View File
@@ -0,0 +1,196 @@
using SharepointToolbox.Web.Services.Export;
namespace SharepointToolbox.Web.Core.Models;
/// <summary>How often a scheduled report recurs.</summary>
public enum ReportFrequency
{
Daily,
Weekly,
Monthly
}
/// <summary>
/// A recurrence rule. The report fires at <see cref="TimeOfDayUtc"/> on the cadence
/// described by <see cref="Frequency"/>. <see cref="DayOfWeek"/> applies to Weekly;
/// <see cref="DayOfMonth"/> applies to Monthly (clamped to the last day of short months).
/// All times are UTC to keep scheduling unambiguous across DST.
/// </summary>
public class RecurrenceRule
{
public ReportFrequency Frequency { get; set; } = ReportFrequency.Weekly;
/// <summary>Time of day to run, UTC, "HH:mm".</summary>
public string TimeOfDayUtc { get; set; } = "06:00";
/// <summary>0 = Sunday … 6 = Saturday. Used when <see cref="Frequency"/> is Weekly.</summary>
public DayOfWeek DayOfWeek { get; set; } = DayOfWeek.Monday;
/// <summary>131. Used when <see cref="Frequency"/> is Monthly.</summary>
public int DayOfMonth { get; set; } = 1;
/// <summary>
/// Computes the next fire time strictly after <paramref name="afterUtc"/>.
/// </summary>
public DateTime ComputeNextRunUtc(DateTime afterUtc)
{
var (hh, mm) = ParseTime(TimeOfDayUtc);
switch (Frequency)
{
case ReportFrequency.Daily:
{
var candidate = new DateTime(afterUtc.Year, afterUtc.Month, afterUtc.Day, hh, mm, 0, DateTimeKind.Utc);
if (candidate <= afterUtc) candidate = candidate.AddDays(1);
return candidate;
}
case ReportFrequency.Weekly:
{
var candidate = new DateTime(afterUtc.Year, afterUtc.Month, afterUtc.Day, hh, mm, 0, DateTimeKind.Utc);
int delta = ((int)DayOfWeek - (int)candidate.DayOfWeek + 7) % 7;
candidate = candidate.AddDays(delta);
if (candidate <= afterUtc) candidate = candidate.AddDays(7);
return candidate;
}
case ReportFrequency.Monthly:
default:
{
var candidate = BuildMonthly(afterUtc.Year, afterUtc.Month, hh, mm);
if (candidate <= afterUtc)
{
var next = afterUtc.AddMonths(1);
candidate = BuildMonthly(next.Year, next.Month, hh, mm);
}
return candidate;
}
}
}
private DateTime BuildMonthly(int year, int month, int hh, int mm)
{
int day = Math.Min(DayOfMonth, DateTime.DaysInMonth(year, month));
return new DateTime(year, month, day, hh, mm, 0, DateTimeKind.Utc);
}
private static (int Hour, int Minute) ParseTime(string s)
{
var parts = (s ?? "06:00").Split(':');
int hh = parts.Length > 0 && int.TryParse(parts[0], out var h) ? Math.Clamp(h, 0, 23) : 6;
int mm = parts.Length > 1 && int.TryParse(parts[1], out var m) ? Math.Clamp(m, 0, 59) : 0;
return (hh, mm);
}
}
/// <summary>
/// Flat, serializable bag of the report-generation options used across all report
/// types. Only the fields relevant to the chosen <see cref="ScheduledReport.Type"/>
/// are honoured; the runner maps them to the concrete option records
/// (<see cref="ScanOptions"/>, <see cref="StorageScanOptions"/>, …).
/// </summary>
public class ScheduledReportOptions
{
// Permissions
public bool IncludeInherited { get; set; }
public bool ScanFolders { get; set; } = true;
// Permissions + Storage
public int FolderDepth { get; set; } = 1;
public bool IncludeSubsites { get; set; }
// Storage
public bool PerLibrary { get; set; } = true;
public bool IncludeHiddenLibraries { get; set; } = true;
public bool IncludePreservationHold { get; set; } = true;
public bool IncludeListAttachments { get; set; } = true;
public bool IncludeRecycleBin { get; set; } = true;
// Duplicates
public string DuplicateMode { get; set; } = "Files";
public bool MatchSize { get; set; } = true;
public bool MatchCreated { get; set; }
public bool MatchModified { get; set; }
public bool MatchSubfolderCount { get; set; }
public bool MatchFileCount { get; set; }
public string? Library { get; set; }
// Version cleanup
public List<string> LibraryTitles { get; set; } = new();
public int KeepLast { get; set; } = 5;
public bool KeepFirst { get; set; }
// Search
public List<string> Extensions { get; set; } = new();
public string? Regex { get; set; }
public int MaxResults { get; set; } = 1000;
// User access audit — the logins/emails to report access for (substring matched).
public List<string> TargetUserLogins { get; set; } = new();
}
/// <summary>
/// Optional email delivery for a generated report, sent through Graph (app-only,
/// <c>Mail.Send</c>). The report file is attached. Body/subject support the
/// placeholders {ReportName}, {ClientName}, {ReportType}, {DateUtc} and {FileName}.
/// </summary>
public class ReportEmailSettings
{
public bool Enabled { get; set; }
/// <summary>
/// Mailbox to send AS (UPN or address). App-only Graph has no signed-in user, so a
/// concrete sender mailbox is required — Graph posts to /users/{From}/sendMail.
/// </summary>
public string From { get; set; } = string.Empty;
public List<string> To { get; set; } = new();
public List<string> Cc { get; set; } = new();
public string Subject { get; set; } = "{ClientName} — {ReportName}";
/// <summary>HTML body. Placeholders are substituted before sending.</summary>
public string Body { get; set; } =
"<p>Hello,</p><p>Please find attached the {ReportType} report \"{ReportName}\" for {ClientName}, generated on {DateUtc} UTC.</p>";
}
/// <summary>
/// A user-defined schedule that generates a report for a single client (profile)
/// on a recurrence. Persisted to schedules.json.
/// </summary>
public class ScheduledReport
{
public string Id { get; set; } = Guid.NewGuid().ToString();
/// <summary><see cref="TenantProfile.Id"/> this schedule belongs to.</summary>
public string ProfileId { get; set; } = string.Empty;
/// <summary>Human label shown in the UI.</summary>
public string Name { get; set; } = string.Empty;
public ReportType Type { get; set; }
public ScheduledReportOptions Options { get; set; } = new();
/// <summary>When true, run against every site in the tenant (site discovery); otherwise use <see cref="SiteUrls"/>.</summary>
public bool AllSites { get; set; } = true;
public List<string> SiteUrls { get; set; } = new();
public ReportMergeMode MergeMode { get; set; } = ReportMergeMode.SingleMerged;
public ReportFormat Format { get; set; } = ReportFormat.Html;
public RecurrenceRule Recurrence { get; set; } = new();
/// <summary>Optional Graph email delivery of the generated report.</summary>
public ReportEmailSettings Email { get; set; } = new();
public bool Enabled { get; set; } = true;
public string CreatedBy { get; set; } = string.Empty;
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
public DateTime? LastRunUtc { get; set; }
public DateTime? NextRunUtc { get; set; }
}
+37
View File
@@ -15,4 +15,41 @@ public class TenantProfile
public string ClientId { get; set; } = string.Empty; public string ClientId { get; set; } = string.Empty;
public LogoData? ClientLogo { get; set; } public LogoData? ClientLogo { get; set; }
// ── Certificate (app-only) credentials ──────────────────────────────────────
// Opt-in per client by an admin. When enabled, certificate auth drives BOTH the
// interactive session (technicians never sign in to SharePoint per profile) AND
// the background report scheduler — all operations run under the app identity.
// When disabled, the app falls back to the delegated refresh-token sign-in flow.
// SharePoint CSOM app-only requires a certificate (Sites.FullControl.All
// application permission, admin-consented). The certificate itself is NOT stored
// here — it lives DataProtection-encrypted on disk (see AppOnlyCertStore); this
// class only carries the metadata needed to load and display it.
/// <summary>When true, this client uses certificate (app-only) auth for interactive and scheduled work.</summary>
public bool AppOnlyEnabled { get; set; }
/// <summary>Client (application) ID of the app-registration used for certificate auth. May differ from <see cref="ClientId"/>.</summary>
public string AppOnlyClientId { get; set; } = string.Empty;
/// <summary>Thumbprint of the stored certificate — display/verification only; the key material is stored separately.</summary>
public string AppOnlyCertThumbprint { get; set; } = string.Empty;
/// <summary>
/// Clones this profile pointed at a different site/admin URL, preserving every other
/// field (notably the certificate metadata) so the auth model is resolved identically
/// for the derived URL. Use instead of hand-building partial copies.
/// </summary>
public TenantProfile CloneForSite(string siteUrl) => new()
{
Id = Id,
Name = Name,
TenantUrl = siteUrl,
TenantId = TenantId,
ClientId = ClientId,
ClientLogo = ClientLogo,
AppOnlyEnabled = AppOnlyEnabled,
AppOnlyClientId = AppOnlyClientId,
AppOnlyCertThumbprint = AppOnlyCertThumbprint,
};
} }
+12
View File
@@ -0,0 +1,12 @@
namespace SharepointToolbox.Web.Core.Models;
/// <summary>
/// Outcome of a user-access audit run. Carries the matched access entries plus per-site
/// scan tallies so the UI can report how many sites were skipped for no access or failed
/// on a non-access error (a tenant-wide scan continues past both).
/// </summary>
public record UserAccessAuditResult(
IReadOnlyList<UserAccessEntry> Entries,
int SitesScanned,
int SitesDenied,
int SitesFailed);
+9 -2
View File
@@ -1,8 +1,15 @@
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base # Base images pinned to exact patch for reproducible builds. Floating `:10.0` tags
# drift; a stale/pre-GA SDK base silently drops the Blazor framework static assets
# (blazor.web.js) from the publish manifest → 404 in production. Bump deliberately.
FROM mcr.microsoft.com/dotnet/aspnet:10.0.8 AS base
WORKDIR /app WORKDIR /app
EXPOSE 8080 EXPOSE 8080
# curl for the compose healthcheck (aspnet image ships no wget/curl).
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build FROM mcr.microsoft.com/dotnet/sdk:10.0.300 AS build
WORKDIR /src WORKDIR /src
COPY ["SharepointToolbox.Web.csproj", "."] COPY ["SharepointToolbox.Web.csproj", "."]
RUN dotnet restore RUN dotnet restore
+70
View File
@@ -0,0 +1,70 @@
using System.Security.Cryptography.X509Certificates;
using Microsoft.AspNetCore.DataProtection;
namespace SharepointToolbox.Web.Infrastructure.Auth;
/// <summary>
/// File-backed <see cref="IAppOnlyCertStore"/>. Each profile's certificate is
/// re-exported password-less, encrypted with ASP.NET Core Data Protection, and
/// written to {certsFolder}/{profileId}.bin. The uploaded PFX password is consumed
/// at save time and never persisted.
/// </summary>
public class AppOnlyCertStore : IAppOnlyCertStore
{
private const string Purpose = "SharepointToolbox.AppOnlyCert.v1";
private readonly string _certsFolder;
private readonly IDataProtector _protector;
public AppOnlyCertStore(string certsFolder, IDataProtectionProvider dataProtection)
{
_certsFolder = certsFolder;
_protector = dataProtection.CreateProtector(Purpose);
Directory.CreateDirectory(_certsFolder);
}
private string PathFor(string profileId) => Path.Combine(_certsFolder, $"{profileId}.bin");
public async Task<string> SaveAsync(string profileId, byte[] pfxBytes, string? password, CancellationToken ct = default)
{
// Open the uploaded PFX (Exportable so we can re-emit a password-less copy that
// the loader can open later without prompting). EphemeralKeySet keeps the key
// out of the Windows certificate store during this transient operation.
using var cert = X509CertificateLoader.LoadPkcs12(
pfxBytes, password,
X509KeyStorageFlags.Exportable | X509KeyStorageFlags.EphemeralKeySet);
if (!cert.HasPrivateKey)
throw new InvalidOperationException("The uploaded certificate has no private key. Export the PFX with its key.");
var passwordless = cert.Export(X509ContentType.Pkcs12);
var protectedBytes = _protector.Protect(passwordless);
Directory.CreateDirectory(_certsFolder);
var tmp = PathFor(profileId) + ".tmp";
await File.WriteAllBytesAsync(tmp, protectedBytes, ct);
File.Move(tmp, PathFor(profileId), overwrite: true);
return cert.Thumbprint;
}
public async Task<X509Certificate2?> LoadAsync(string profileId, CancellationToken ct = default)
{
var path = PathFor(profileId);
if (!File.Exists(path)) return null;
var protectedBytes = await File.ReadAllBytesAsync(path, ct);
var pfx = _protector.Unprotect(protectedBytes);
return X509CertificateLoader.LoadPkcs12(
pfx, password: null,
X509KeyStorageFlags.Exportable | X509KeyStorageFlags.EphemeralKeySet);
}
public bool Exists(string profileId) => File.Exists(PathFor(profileId));
public void Delete(string profileId)
{
var path = PathFor(profileId);
if (File.Exists(path)) File.Delete(path);
}
}
@@ -0,0 +1,120 @@
using Azure.Core;
using Azure.Identity;
using Microsoft.Graph;
using Microsoft.SharePoint.Client;
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Infrastructure.Auth;
/// <summary>
/// Certificate-based app-only client factory. Acquires tokens with
/// <see cref="ClientCertificateCredential"/> and injects the SharePoint bearer token
/// through CSOM's <c>ExecutingWebRequest</c> hook — the same mechanism the delegated
/// <c>SessionManager</c> uses, so report services see an ordinary authenticated
/// <see cref="ClientContext"/> regardless of which auth model produced it.
/// </summary>
public class AppOnlyContextFactory : IAppOnlyContextFactory
{
private static readonly string[] GraphScopes = ["https://graph.microsoft.com/.default"];
private readonly IAppOnlyCertStore _certStore;
public AppOnlyContextFactory(IAppOnlyCertStore certStore) { _certStore = certStore; }
public bool IsConfigured(TenantProfile profile) =>
profile.AppOnlyEnabled
&& !string.IsNullOrWhiteSpace(profile.AppOnlyClientId)
&& !string.IsNullOrWhiteSpace(profile.TenantId)
&& _certStore.Exists(profile.Id);
public async Task<AccessToken> GetTokenAsync(TenantProfile profile, string scope, CancellationToken ct = default)
{
var credential = await BuildCredentialAsync(profile, ct);
return await credential.GetTokenAsync(new TokenRequestContext([scope]), ct);
}
public async Task<ClientContext> CreateContextAsync(TenantProfile profile, string siteUrl, CancellationToken ct = default)
{
var credential = await BuildCredentialAsync(profile, ct);
var spScope = SharePointScope(siteUrl);
var ctx = new ClientContext(siteUrl);
ctx.ExecutingWebRequest += (_, e) =>
{
// CSOM raises this synchronously immediately before sending; acquire the
// token synchronously so the header is guaranteed set. ClientCertificateCredential
// caches access tokens internally, so this is cheap after the first call.
var token = credential.GetToken(new TokenRequestContext([spScope]), CancellationToken.None);
e.WebRequestExecutor.RequestHeaders["Authorization"] = "Bearer " + token.Token;
};
return ctx;
}
public async Task<GraphServiceClient> CreateGraphClientAsync(TenantProfile profile, CancellationToken ct = default)
{
var credential = await BuildCredentialAsync(profile, ct);
return new GraphServiceClient(credential, GraphScopes);
}
public async Task<string?> TestConnectionAsync(TenantProfile profile, CancellationToken ct = default)
{
try
{
using var ctx = await CreateContextAsync(profile, profile.TenantUrl, ct);
ctx.Load(ctx.Web, w => w.Title);
await ctx.ExecuteQueryAsync();
return null;
}
catch (Exception ex)
{
return ex.Message;
}
}
public async Task<string?> WaitUntilReadyAsync(TenantProfile profile, TimeSpan timeout, CancellationToken ct = default)
{
var deadline = DateTimeOffset.UtcNow + timeout;
var delay = TimeSpan.FromSeconds(5);
string? lastError;
do
{
// Each attempt builds a fresh credential, so a cached 401 never sticks across retries.
lastError = await TestConnectionAsync(profile, ct);
if (lastError is null) return null;
if (DateTimeOffset.UtcNow + delay >= deadline) break;
await Task.Delay(delay, ct);
}
while (DateTimeOffset.UtcNow < deadline);
return lastError;
}
private async Task<ClientCertificateCredential> BuildCredentialAsync(TenantProfile profile, CancellationToken ct)
{
if (!profile.AppOnlyEnabled)
throw new InvalidOperationException($"App-only reports are not enabled for client '{profile.Name}'.");
if (string.IsNullOrWhiteSpace(profile.AppOnlyClientId))
throw new InvalidOperationException($"No app-only client ID configured for client '{profile.Name}'.");
if (string.IsNullOrWhiteSpace(profile.TenantId))
throw new InvalidOperationException($"No tenant ID configured for client '{profile.Name}'.");
var cert = await _certStore.LoadAsync(profile.Id, ct)
?? throw new InvalidOperationException($"No app-only certificate stored for client '{profile.Name}'.");
var options = new ClientCertificateCredentialOptions
{
// SharePoint app-only requires the v1 resource audience; SendCertificateChain
// improves compatibility with subject-name/issuer-configured app registrations.
SendCertificateChain = true
};
return new ClientCertificateCredential(profile.TenantId, profile.AppOnlyClientId, cert, options);
}
// https://contoso.sharepoint.com/sites/Foo → https://contoso.sharepoint.com/.default
private static string SharePointScope(string siteUrl)
{
if (Uri.TryCreate(siteUrl, UriKind.Absolute, out var uri))
return $"{uri.Scheme}://{uri.Host}/.default";
return siteUrl.TrimEnd('/') + "/.default";
}
}
+14 -2
View File
@@ -5,22 +5,34 @@ using SharepointToolbox.Web.Services.Session;
namespace SharepointToolbox.Web.Infrastructure.Auth; namespace SharepointToolbox.Web.Infrastructure.Auth;
/// <summary>Delegated Graph client using OAuth2 refresh-token flow via ISessionManager.</summary> /// <summary>
/// Builds a Graph client for a profile. Certificate-configured profiles get an app-only
/// client (no interactive sign-in); all others use the delegated OAuth2 refresh-token flow
/// via ISessionManager.
/// </summary>
public class GraphClientFactory public class GraphClientFactory
{ {
private readonly ISessionCredentialStore _credentialStore; private readonly ISessionCredentialStore _credentialStore;
private readonly ISessionManager _sessionManager; private readonly ISessionManager _sessionManager;
private readonly IAppOnlyContextFactory _appOnly;
public GraphClientFactory(ISessionCredentialStore credentialStore, ISessionManager sessionManager) public GraphClientFactory(
ISessionCredentialStore credentialStore,
ISessionManager sessionManager,
IAppOnlyContextFactory appOnly)
{ {
_credentialStore = credentialStore; _credentialStore = credentialStore;
_sessionManager = sessionManager; _sessionManager = sessionManager;
_appOnly = appOnly;
} }
public async Task<GraphServiceClient> CreateClientAsync(TenantProfile profile) public async Task<GraphServiceClient> CreateClientAsync(TenantProfile profile)
{ {
ArgumentException.ThrowIfNullOrEmpty(profile.TenantId); ArgumentException.ThrowIfNullOrEmpty(profile.TenantId);
if (_appOnly.IsConfigured(profile))
return await _appOnly.CreateGraphClientAsync(profile);
var hasTokens = await _credentialStore.HasCredentialsAsync(); var hasTokens = await _credentialStore.HasCredentialsAsync();
if (!hasTokens) if (!hasTokens)
throw new InvalidOperationException( throw new InvalidOperationException(
+24
View File
@@ -0,0 +1,24 @@
using System.Security.Cryptography.X509Certificates;
namespace SharepointToolbox.Web.Infrastructure.Auth;
/// <summary>
/// Stores the per-client app-only certificate (private key included) encrypted at
/// rest, keyed by profile id. Used only by the background scheduler — never exposed
/// to the browser.
/// </summary>
public interface IAppOnlyCertStore
{
/// <summary>
/// Persists an uploaded PFX for a profile. Returns the certificate thumbprint.
/// The uploaded password is used only to open the PFX; it is not retained.
/// </summary>
Task<string> SaveAsync(string profileId, byte[] pfxBytes, string? password, CancellationToken ct = default);
/// <summary>Loads the stored certificate (with private key) for app-only auth, or null if none.</summary>
Task<X509Certificate2?> LoadAsync(string profileId, CancellationToken ct = default);
bool Exists(string profileId);
void Delete(string profileId);
}
@@ -0,0 +1,47 @@
using Azure.Core;
using Microsoft.Graph;
using Microsoft.SharePoint.Client;
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Infrastructure.Auth;
/// <summary>
/// Builds app-only (certificate-based) clients for a client profile. Drives BOTH the
/// background report scheduler AND the interactive session: when a profile is configured
/// for certificate auth (see <see cref="IsConfigured"/>), technicians operate through the
/// app identity and never sign in to SharePoint per profile. Requires
/// <see cref="TenantProfile.AppOnlyEnabled"/>, an app-only client id, and a stored certificate.
/// </summary>
public interface IAppOnlyContextFactory
{
/// <summary>
/// True when this profile can authenticate app-only without an interactive sign-in:
/// <see cref="TenantProfile.AppOnlyEnabled"/> is set, an app-only client id is present,
/// and a certificate is stored for the profile. When false, callers fall back to the
/// delegated refresh-token flow.
/// </summary>
bool IsConfigured(TenantProfile profile);
/// <summary>CSOM context for a specific site, authenticated app-only.</summary>
Task<ClientContext> CreateContextAsync(TenantProfile profile, string siteUrl, CancellationToken ct = default);
/// <summary>Microsoft Graph client, authenticated app-only.</summary>
Task<GraphServiceClient> CreateGraphClientAsync(TenantProfile profile, CancellationToken ct = default);
/// <summary>Acquires an app-only access token for an arbitrary scope (e.g. a SharePoint host or Graph).</summary>
Task<AccessToken> GetTokenAsync(TenantProfile profile, string scope, CancellationToken ct = default);
/// <summary>
/// Verifies the stored credentials can authenticate against the tenant root web.
/// Returns null on success, or an error message describing the failure.
/// </summary>
Task<string?> TestConnectionAsync(TenantProfile profile, CancellationToken ct = default);
/// <summary>
/// Polls <see cref="TestConnectionAsync"/> until it succeeds or <paramref name="timeout"/>
/// elapses. After a fresh app registration, the certificate key credential and app-role
/// admin consent take time to propagate through Entra (token requests 401 until then);
/// this waits that window out. Returns null once ready, or the last error on timeout.
/// </summary>
Task<string?> WaitUntilReadyAsync(TenantProfile profile, TimeSpan timeout, CancellationToken ct = default);
}
+31 -14
View File
@@ -7,23 +7,31 @@ using SharepointToolbox.Web.Services.Session;
namespace SharepointToolbox.Web.Infrastructure.Auth; namespace SharepointToolbox.Web.Infrastructure.Auth;
/// <summary> /// <summary>
/// Delegated session manager using OAuth2 refresh tokens. /// Session manager that resolves the auth model per profile. When a profile is configured
/// Tokens come from ISessionCredentialStore (ProtectedSessionStorage — browser-side only). /// for certificate auth (<see cref="IAppOnlyContextFactory.IsConfigured"/>), contexts are
/// Caches access tokens in-memory per scope for the duration of the Blazor circuit. /// built app-only via the stored certificate and no interactive sign-in is required.
/// Otherwise it falls back to the delegated OAuth2 refresh-token flow, whose access tokens
/// come from ISessionCredentialStore (ProtectedSessionStorage — browser-side only) and are
/// cached in-memory per scope for the duration of the Blazor circuit.
/// Scoped per Blazor circuit. /// Scoped per Blazor circuit.
/// </summary> /// </summary>
public class SessionManager : ISessionManager public class SessionManager : ISessionManager
{ {
private readonly ISessionCredentialStore _credentialStore; private readonly ISessionCredentialStore _credentialStore;
private readonly ITokenRefreshService _tokenRefresh; private readonly ITokenRefreshService _tokenRefresh;
private readonly IAppOnlyContextFactory _appOnly;
private readonly Dictionary<string, ClientContext> _contexts = new(); private readonly Dictionary<string, ClientContext> _contexts = new();
private readonly Dictionary<string, (string Token, DateTimeOffset ExpiresAt)> _accessTokenCache = new(); private readonly Dictionary<string, (string Token, DateTimeOffset ExpiresAt)> _accessTokenCache = new();
private readonly SemaphoreSlim _lock = new(1, 1); private readonly SemaphoreSlim _lock = new(1, 1);
public SessionManager(ISessionCredentialStore credentialStore, ITokenRefreshService tokenRefresh) public SessionManager(
ISessionCredentialStore credentialStore,
ITokenRefreshService tokenRefresh,
IAppOnlyContextFactory appOnly)
{ {
_credentialStore = credentialStore; _credentialStore = credentialStore;
_tokenRefresh = tokenRefresh; _tokenRefresh = tokenRefresh;
_appOnly = appOnly;
} }
public bool IsAuthenticated(string tenantUrl) => _contexts.ContainsKey(NormalizeUrl(tenantUrl)); public bool IsAuthenticated(string tenantUrl) => _contexts.ContainsKey(NormalizeUrl(tenantUrl));
@@ -62,6 +70,24 @@ public class SessionManager : ISessionManager
var key = NormalizeUrl(profile.TenantUrl); var key = NormalizeUrl(profile.TenantUrl);
var spScope = NormalizeScopeUrl(profile.TenantUrl) + "/.default"; var spScope = NormalizeScopeUrl(profile.TenantUrl) + "/.default";
// Certificate-configured profiles authenticate app-only: no interactive sign-in,
// no session tokens. Build the context through the cert factory and cache it under
// the same key so report services see an ordinary authenticated ClientContext.
if (_appOnly.IsConfigured(profile))
{
await _lock.WaitAsync(ct);
try
{
if (_contexts.TryGetValue(key, out var existingCert))
return existingCert;
var certCtx = await _appOnly.CreateContextAsync(profile, profile.TenantUrl, ct);
_contexts[key] = certCtx;
return certCtx;
}
finally { _lock.Release(); }
}
await _lock.WaitAsync(ct); await _lock.WaitAsync(ct);
try try
{ {
@@ -90,16 +116,7 @@ public class SessionManager : ISessionManager
public async Task<ClientContext> GetOrCreateContextAsync(string siteUrl, TenantProfile profile, CancellationToken ct = default) public async Task<ClientContext> GetOrCreateContextAsync(string siteUrl, TenantProfile profile, CancellationToken ct = default)
{ {
var profileForSite = new TenantProfile return await GetOrCreateContextAsync(profile.CloneForSite(siteUrl), ct);
{
Id = profile.Id,
Name = profile.Name,
TenantUrl = siteUrl,
TenantId = profile.TenantId,
ClientId = profile.ClientId,
ClientLogo = profile.ClientLogo,
};
return await GetOrCreateContextAsync(profileForSite, ct);
} }
public async Task ClearSessionAsync(string tenantUrl) public async Task ClearSessionAsync(string tenantUrl)
@@ -0,0 +1,87 @@
using System.Text;
using System.Text.Json;
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Infrastructure.Persistence;
/// <summary>
/// JSON-file index of produced report files (reports-index.json). The files
/// themselves live under {ExportsFolder}/{ProfileId}/; this is the catalogue the
/// per-client "Reports" list and the id-based download endpoint read.
/// </summary>
public class GeneratedReportRepository
{
private readonly string _filePath;
private readonly SemaphoreSlim _writeLock = new(1, 1);
private static readonly JsonSerializerOptions ReadOpts = new() { PropertyNameCaseInsensitive = true };
private static readonly JsonSerializerOptions WriteOpts = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public GeneratedReportRepository(string filePath) { _filePath = filePath; }
public async Task<IReadOnlyList<GeneratedReport>> LoadAsync()
{
if (!File.Exists(_filePath)) return Array.Empty<GeneratedReport>();
string json;
try { json = await File.ReadAllTextAsync(_filePath, Encoding.UTF8); }
catch (IOException ex) { throw new InvalidDataException($"Failed to read report index: {_filePath}", ex); }
Root? root;
try { root = JsonSerializer.Deserialize<Root>(json, ReadOpts); }
catch (JsonException ex) { throw new InvalidDataException($"Invalid JSON in report index: {_filePath}", ex); }
return (IReadOnlyList<GeneratedReport>?)root?.Reports ?? Array.Empty<GeneratedReport>();
}
public async Task<GeneratedReport?> GetAsync(string id)
=> (await LoadAsync()).FirstOrDefault(r => r.Id == id);
public async Task<IReadOnlyList<GeneratedReport>> LoadForProfileAsync(string profileId)
{
var all = await LoadAsync();
return all.Where(r => r.ProfileId == profileId)
.OrderByDescending(r => r.GeneratedUtc)
.ToList();
}
public async Task AddAsync(GeneratedReport report)
{
await _writeLock.WaitAsync();
try
{
var list = (await LoadAsync()).ToList();
list.Add(report);
await WriteUnlockedAsync(list);
}
finally { _writeLock.Release(); }
}
public async Task DeleteAsync(string id)
{
await _writeLock.WaitAsync();
try
{
var list = (await LoadAsync()).ToList();
list.RemoveAll(r => r.Id == id);
await WriteUnlockedAsync(list);
}
finally { _writeLock.Release(); }
}
private async Task WriteUnlockedAsync(IReadOnlyList<GeneratedReport> reports)
{
var json = JsonSerializer.Serialize(new Root { Reports = reports.ToList() }, WriteOpts);
var tmpPath = _filePath + ".tmp";
var dir = Path.GetDirectoryName(_filePath);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
await File.WriteAllTextAsync(tmpPath, json, Encoding.UTF8);
JsonDocument.Parse(await File.ReadAllTextAsync(tmpPath, Encoding.UTF8)).Dispose();
File.Move(tmpPath, _filePath, overwrite: true);
}
private sealed class Root { public List<GeneratedReport> Reports { get; set; } = new(); }
}
@@ -0,0 +1,91 @@
using System.Text;
using System.Text.Json;
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Infrastructure.Persistence;
/// <summary>
/// JSON-file store for <see cref="ScheduledReport"/> definitions (schedules.json).
/// Mirrors <see cref="ProfileRepository"/>'s atomic temp-file-then-move write.
/// Mutating helpers (<see cref="UpsertAsync"/>/<see cref="DeleteAsync"/>) perform the
/// read-modify-write under the same lock so concurrent callers don't clobber each other.
/// </summary>
public class ScheduledReportRepository
{
private readonly string _filePath;
private readonly SemaphoreSlim _writeLock = new(1, 1);
private static readonly JsonSerializerOptions ReadOpts = new() { PropertyNameCaseInsensitive = true };
private static readonly JsonSerializerOptions WriteOpts = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public ScheduledReportRepository(string filePath) { _filePath = filePath; }
public async Task<IReadOnlyList<ScheduledReport>> LoadAsync()
{
if (!File.Exists(_filePath)) return Array.Empty<ScheduledReport>();
string json;
try { json = await File.ReadAllTextAsync(_filePath, Encoding.UTF8); }
catch (IOException ex) { throw new InvalidDataException($"Failed to read schedules: {_filePath}", ex); }
Root? root;
try { root = JsonSerializer.Deserialize<Root>(json, ReadOpts); }
catch (JsonException ex) { throw new InvalidDataException($"Invalid JSON in schedules: {_filePath}", ex); }
return (IReadOnlyList<ScheduledReport>?)root?.Schedules ?? Array.Empty<ScheduledReport>();
}
public async Task<IReadOnlyList<ScheduledReport>> LoadForProfileAsync(string profileId)
{
var all = await LoadAsync();
return all.Where(s => s.ProfileId == profileId).ToList();
}
public async Task SaveAllAsync(IReadOnlyList<ScheduledReport> schedules)
{
await _writeLock.WaitAsync();
try { await WriteUnlockedAsync(schedules); }
finally { _writeLock.Release(); }
}
public async Task UpsertAsync(ScheduledReport schedule)
{
await _writeLock.WaitAsync();
try
{
var list = (await LoadAsync()).ToList();
var idx = list.FindIndex(s => s.Id == schedule.Id);
if (idx >= 0) list[idx] = schedule; else list.Add(schedule);
await WriteUnlockedAsync(list);
}
finally { _writeLock.Release(); }
}
public async Task DeleteAsync(string id)
{
await _writeLock.WaitAsync();
try
{
var list = (await LoadAsync()).ToList();
list.RemoveAll(s => s.Id == id);
await WriteUnlockedAsync(list);
}
finally { _writeLock.Release(); }
}
private async Task WriteUnlockedAsync(IReadOnlyList<ScheduledReport> schedules)
{
var json = JsonSerializer.Serialize(new Root { Schedules = schedules.ToList() }, WriteOpts);
var tmpPath = _filePath + ".tmp";
var dir = Path.GetDirectoryName(_filePath);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
await File.WriteAllTextAsync(tmpPath, json, Encoding.UTF8);
JsonDocument.Parse(await File.ReadAllTextAsync(tmpPath, Encoding.UTF8)).Dispose();
File.Move(tmpPath, _filePath, overwrite: true);
}
private sealed class Root { public List<ScheduledReport> Schedules { get; set; } = new(); }
}
+38
View File
@@ -924,6 +924,33 @@ Cet onglet fait l'inverse : vous sélectionnez un ou plusieurs utilisateurs et i
<data name="audit.chip.high" xml:space="preserve"> <data name="audit.chip.high" xml:space="preserve">
<value>Élevé</value> <value>Élevé</value>
</data> </data>
<data name="audit.view.bySite" xml:space="preserve">
<value>Par site</value>
</data>
<data name="audit.view.table" xml:space="preserve">
<value>Tableau</value>
</data>
<data name="audit.bysite.hint" xml:space="preserve">
<value>Sites auxquels le(s) utilisateur(s) sélectionné(s) ont accès. Cliquez sur un site pour afficher le détail des permissions.</value>
</data>
<data name="audit.hint.allSites" xml:space="preserve">
<value>Facultatif — laissez vide pour analyser tous les sites du locataire.</value>
</data>
<data name="audit.status.discoveringSites" xml:space="preserve">
<value>Découverte de tous les sites du locataire…</value>
</data>
<data name="audit.err.discoverFailed" xml:space="preserve">
<value>Impossible de lister les sites du locataire : {0}</value>
</data>
<data name="audit.scan.sitesScanned" xml:space="preserve">
<value>{0} site(s) analysé(s)</value>
</data>
<data name="audit.scan.sitesDenied" xml:space="preserve">
<value>{0} ignoré(s) (aucun accès)</value>
</data>
<data name="audit.scan.sitesFailed" xml:space="preserve">
<value>{0} en échec</value>
</data>
<data name="audit.chk.includeInherited" xml:space="preserve"> <data name="audit.chk.includeInherited" xml:space="preserve">
<value>Inclure les autorisations héritées</value> <value>Inclure les autorisations héritées</value>
</data> </data>
@@ -1326,6 +1353,9 @@ Cet onglet fait l'inverse : vous sélectionnez un ou plusieurs utilisateurs et i
<data name="nav.reconnect" xml:space="preserve"> <data name="nav.reconnect" xml:space="preserve">
<value>Reconnecter</value> <value>Reconnecter</value>
</data> </data>
<data name="nav.appIdentity" xml:space="preserve">
<value>identité application</value>
</data>
<data name="nav.searchPlaceholder" xml:space="preserve"> <data name="nav.searchPlaceholder" xml:space="preserve">
<value>Rechercher…</value> <value>Rechercher…</value>
</data> </data>
@@ -1497,6 +1527,12 @@ Cet onglet fait l'inverse : vous sélectionnez un ou plusieurs utilisateurs et i
<data name="profiles.reg.registered" xml:space="preserve"> <data name="profiles.reg.registered" xml:space="preserve">
<value>Application inscrite. Vérifiez et enregistrez le profil.</value> <value>Application inscrite. Vérifiez et enregistrez le profil.</value>
</data> </data>
<data name="profiles.reg.propagating" xml:space="preserve">
<value>Application inscrite. Propagation du certificat et du consentement en cours…</value>
</data>
<data name="profiles.reg.notready" xml:space="preserve">
<value>Application inscrite, mais l'authentification app-only n'est pas encore prête ({0}). Cela peut prendre quelques minutes ; enregistrez puis utilisez « Tester la connexion » sous peu.</value>
</data>
<data name="profiles.reg.requesting" xml:space="preserve"> <data name="profiles.reg.requesting" xml:space="preserve">
<value>Demande d'un code de connexion…</value> <value>Demande d'un code de connexion…</value>
</data> </data>
@@ -1887,4 +1923,6 @@ Cet onglet fait l'inverse : vous sélectionnez un ou plusieurs utilisateurs et i
<data name="help.userType" xml:space="preserve"><value>Membre = un compte interne à votre organisation ; Invité = un utilisateur externe invité d'une autre organisation.</value></data> <data name="help.userType" xml:space="preserve"><value>Membre = un compte interne à votre organisation ; Invité = un utilisateur externe invité d'une autre organisation.</value></data>
<data name="help.templateCapture" xml:space="preserve"><value>La capture enregistre la structure d'un site (bibliothèques, dossiers, groupes de permissions) comme modèle réutilisable pour créer de nouveaux sites.</value></data> <data name="help.templateCapture" xml:space="preserve"><value>La capture enregistre la structure d'un site (bibliothèques, dossiers, groupes de permissions) comme modèle réutilisable pour créer de nouveaux sites.</value></data>
<data name="help.permissionGroups" xml:space="preserve"><value>Les groupes de permissions du site (Propriétaires, Membres, Visiteurs) et leurs membres, afin de recréer la même configuration d'accès.</value></data> <data name="help.permissionGroups" xml:space="preserve"><value>Les groupes de permissions du site (Propriétaires, Membres, Visiteurs) et leurs membres, afin de recréer la même configuration d'accès.</value></data>
<data name="nav.scheduledReports" xml:space="preserve"><value>Rapports planifiés</value></data>
<data name="nav.reports" xml:space="preserve"><value>Rapports</value></data>
</root> </root>
+38
View File
@@ -924,6 +924,33 @@ This tab does the reverse: you select one or more users and it finds every objec
<data name="audit.chip.high" xml:space="preserve"> <data name="audit.chip.high" xml:space="preserve">
<value>High</value> <value>High</value>
</data> </data>
<data name="audit.view.bySite" xml:space="preserve">
<value>By site</value>
</data>
<data name="audit.view.table" xml:space="preserve">
<value>Table</value>
</data>
<data name="audit.bysite.hint" xml:space="preserve">
<value>Sites the selected user(s) can access. Click a site to reveal the permission detail.</value>
</data>
<data name="audit.hint.allSites" xml:space="preserve">
<value>Optional — leave empty to scan every site in the tenant.</value>
</data>
<data name="audit.status.discoveringSites" xml:space="preserve">
<value>Discovering all sites in the tenant…</value>
</data>
<data name="audit.err.discoverFailed" xml:space="preserve">
<value>Could not list tenant sites: {0}</value>
</data>
<data name="audit.scan.sitesScanned" xml:space="preserve">
<value>{0} site(s) scanned</value>
</data>
<data name="audit.scan.sitesDenied" xml:space="preserve">
<value>{0} skipped (no access)</value>
</data>
<data name="audit.scan.sitesFailed" xml:space="preserve">
<value>{0} failed</value>
</data>
<data name="audit.chk.includeInherited" xml:space="preserve"> <data name="audit.chk.includeInherited" xml:space="preserve">
<value>Include inherited</value> <value>Include inherited</value>
</data> </data>
@@ -1326,6 +1353,9 @@ This tab does the reverse: you select one or more users and it finds every objec
<data name="nav.reconnect" xml:space="preserve"> <data name="nav.reconnect" xml:space="preserve">
<value>Reconnect</value> <value>Reconnect</value>
</data> </data>
<data name="nav.appIdentity" xml:space="preserve">
<value>app identity</value>
</data>
<data name="nav.searchPlaceholder" xml:space="preserve"> <data name="nav.searchPlaceholder" xml:space="preserve">
<value>Search…</value> <value>Search…</value>
</data> </data>
@@ -1497,6 +1527,12 @@ This tab does the reverse: you select one or more users and it finds every objec
<data name="profiles.reg.registered" xml:space="preserve"> <data name="profiles.reg.registered" xml:space="preserve">
<value>App registered. Review and Save the profile.</value> <value>App registered. Review and Save the profile.</value>
</data> </data>
<data name="profiles.reg.propagating" xml:space="preserve">
<value>App registered. Waiting for certificate and consent to propagate…</value>
</data>
<data name="profiles.reg.notready" xml:space="preserve">
<value>App registered, but app-only auth is not ready yet ({0}). It may take a few minutes; Save and use Test connection shortly.</value>
</data>
<data name="profiles.reg.requesting" xml:space="preserve"> <data name="profiles.reg.requesting" xml:space="preserve">
<value>Requesting a sign-in code…</value> <value>Requesting a sign-in code…</value>
</data> </data>
@@ -1887,4 +1923,6 @@ This tab does the reverse: you select one or more users and it finds every objec
<data name="help.userType" xml:space="preserve"><value>Member = an account inside your organization; Guest = an external user invited from another organization.</value></data> <data name="help.userType" xml:space="preserve"><value>Member = an account inside your organization; Guest = an external user invited from another organization.</value></data>
<data name="help.templateCapture" xml:space="preserve"><value>Capturing saves a site's structure (libraries, folders, permission groups) as a reusable template you can later apply to create new sites.</value></data> <data name="help.templateCapture" xml:space="preserve"><value>Capturing saves a site's structure (libraries, folders, permission groups) as a reusable template you can later apply to create new sites.</value></data>
<data name="help.permissionGroups" xml:space="preserve"><value>The site's permission groups (Owners, Members, Visitors) and their members, so the same access setup can be recreated.</value></data> <data name="help.permissionGroups" xml:space="preserve"><value>The site's permission groups (Owners, Members, Visitors) and their members, so the same access setup can be recreated.</value></data>
<data name="nav.scheduledReports" xml:space="preserve"><value>Scheduled Reports</value></data>
<data name="nav.reports" xml:space="preserve"><value>Reports</value></data>
</root> </root>
+130 -7
View File
@@ -3,6 +3,8 @@ using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Antiforgery; using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Protocols.OpenIdConnect;
@@ -17,6 +19,7 @@ using SharepointToolbox.Web.Services.Audit;
using SharepointToolbox.Web.Services.Auth; using SharepointToolbox.Web.Services.Auth;
using SharepointToolbox.Web.Services.Export; using SharepointToolbox.Web.Services.Export;
using SharepointToolbox.Web.Services.OAuth; using SharepointToolbox.Web.Services.OAuth;
using SharepointToolbox.Web.Services.Reports;
using SharepointToolbox.Web.Services.Session; using SharepointToolbox.Web.Services.Session;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -41,6 +44,28 @@ builder.Services.AddRazorComponents()
.AddInteractiveServerComponents(); .AddInteractiveServerComponents();
builder.Services.AddHttpContextAccessor(); builder.Services.AddHttpContextAccessor();
// ── Forwarded headers ─────────────────────────────────────────────────────────
// Behind a TLS-terminating reverse proxy the app receives plain HTTP; without this
// it would see scheme=http and build an http:// OIDC redirect_uri (which Entra
// rejects for non-localhost hosts) and set the auth cookie non-Secure. Honour
// X-Forwarded-Proto/For so the app sees the real https scheme + client IP. Proxy IP
// is unknown inside the container network, so don't restrict to known proxies.
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownIPNetworks.Clear();
options.KnownProxies.Clear();
});
// ── Data Protection ───────────────────────────────────────────────────────────
// Keys MUST persist across container recreates: they encrypt the auth cookie AND
// the app-only certs on disk (/data/appcerts). Default storage is the container's
// ephemeral home dir, so a redeploy would log everyone out and make stored certs
// undecryptable. Pin keys + app name to the data volume.
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(dataFolder, "dpkeys")))
.SetApplicationName("SharepointToolbox.Web");
// Localization string source — Scoped: one per circuit, with its own explicit culture. // Localization string source — Scoped: one per circuit, with its own explicit culture.
builder.Services.AddScoped<SharepointToolbox.Web.Localization.TranslationSource>(); builder.Services.AddScoped<SharepointToolbox.Web.Localization.TranslationSource>();
@@ -64,7 +89,12 @@ else
builder.Services.AddAuthentication(options => builder.Services.AddAuthentication(options =>
{ {
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; // Challenge the cookie scheme (→ redirect to /account/login, the combined
// local + Microsoft page). OIDC is triggered explicitly from the "Sign in
// with Microsoft" button (/account/login/entra), never as the implicit
// challenge — otherwise logged-out hits on protected pages force OIDC and
// 404 when it is unconfigured/unreachable.
options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
}) })
.AddCookie(options => .AddCookie(options =>
{ {
@@ -80,11 +110,22 @@ else
.AddOpenIdConnect(options => .AddOpenIdConnect(options =>
{ {
var oidc = builder.Configuration.GetSection("Oidc"); var oidc = builder.Configuration.GetSection("Oidc");
options.Authority = $"https://login.microsoftonline.com/{oidc["TenantId"]}/v2.0"; // Strip accidental surrounding quotes/whitespace. docker-compose's `environment` list form
options.ClientId = oidc["ClientId"]; // (`- Oidc__TenantId="<guid>"`) embeds the literal quotes in the value, producing a malformed
options.ClientSecret = oidc["ClientSecret"]; // Authority (…/"<tenant>"/v2.0) that fails metadata discovery with IDX20803. Same trap on the
// secret would silently break the token exchange. Trim defensively.
static string Clean(string? v) => v?.Trim().Trim('"', '\'') ?? string.Empty;
options.Authority = $"https://login.microsoftonline.com/{Clean(oidc["TenantId"])}/v2.0";
options.ClientId = Clean(oidc["ClientId"]);
options.ClientSecret = Clean(oidc["ClientSecret"]);
options.ResponseType = OpenIdConnectResponseType.Code; options.ResponseType = OpenIdConnectResponseType.Code;
options.SaveTokens = true; // Do NOT persist the OIDC access/id/refresh tokens in the auth cookie. They are
// never read (SharePoint/Graph auth runs through the separate connect flow +
// app-only cert paths), and storing them bloats the cookie past ~4 KB so it gets
// chunked. The chunked cookie survives the prerender GET but is dropped on the
// WebSocket upgrade that establishes the interactive circuit → the circuit comes
// up anonymous and the app sticks on "Chargement…". Keeping the cookie small fixes it.
options.SaveTokens = false;
options.Scope.Add("openid"); options.Scope.Add("openid");
options.Scope.Add("profile"); options.Scope.Add("profile");
options.Scope.Add("email"); options.Scope.Add("email");
@@ -95,7 +136,29 @@ else
options.Events.OnTokenValidated = async ctx => options.Events.OnTokenValidated = async ctx =>
{ {
var userService = ctx.HttpContext.RequestServices.GetRequiredService<IUserService>(); var userService = ctx.HttpContext.RequestServices.GetRequiredService<IUserService>();
await userService.ProvisionAsync(ctx.Principal!); var user = await userService.ProvisionAsync(ctx.Principal!);
// The whole principal is serialized into the auth cookie. The raw OIDC principal carries
// dozens of id_token + userinfo claims (oid, tid, given/family_name, a long picture URL …);
// encrypted + base64 it exceeds ~4 KB, so ChunkingCookieManager splits it into …CookiesC1/C2.
// The chunked cookie survives the prerender GET but is dropped on the Blazor WebSocket upgrade
// → the interactive circuit comes up anonymous → page sticks on "Chargement…". Replace it with
// a slim principal holding only the claims the app reads — identical to the local-login path —
// so the cookie stays small (single, unchunked) and the circuit authenticates. This also adds
// the app_role claim (role-based authz) and auth_provider (logout's OIDC sign-out branch),
// which the fat OIDC principal never had.
var identity = new ClaimsIdentity(
new Claim[]
{
new("preferred_username", user.Email),
new("name", user.DisplayName),
new("app_role", user.Role.ToString()),
new("auth_provider", nameof(AuthProvider.Entra)),
},
ctx.Principal!.Identity!.AuthenticationType,
"preferred_username",
"app_role");
ctx.Principal = new ClaimsPrincipal(identity);
}; };
}); });
} }
@@ -110,11 +173,14 @@ builder.Services.AddHttpClient("oauth");
builder.Services.Configure<ClientConnectOptions>(builder.Configuration.GetSection("ClientConnect")); builder.Services.Configure<ClientConnectOptions>(builder.Configuration.GetSection("ClientConnect"));
// ── App config ──────────────────────────────────────────────────────────────── // ── App config ────────────────────────────────────────────────────────────────
var certsFolder = Path.Combine(dataFolder, "appcerts");
builder.Services.Configure<AppConfiguration>(opt => builder.Services.Configure<AppConfiguration>(opt =>
{ {
opt.DataFolder = dataFolder; opt.DataFolder = dataFolder;
opt.ExportsFolder = Path.Combine(dataFolder, "exports"); opt.ExportsFolder = Path.Combine(dataFolder, "exports");
opt.CertsFolder = certsFolder;
Directory.CreateDirectory(opt.ExportsFolder); Directory.CreateDirectory(opt.ExportsFolder);
Directory.CreateDirectory(opt.CertsFolder);
}); });
// ── Persistence (Singleton — files on disk) ─────────────────────────────────── // ── Persistence (Singleton — files on disk) ───────────────────────────────────
@@ -123,6 +189,13 @@ builder.Services.AddSingleton(new SettingsRepository(Path.Combine(dataFolder, "s
builder.Services.AddSingleton(new TemplateRepository(Path.Combine(dataFolder, "templates"))); builder.Services.AddSingleton(new TemplateRepository(Path.Combine(dataFolder, "templates")));
builder.Services.AddSingleton(new UserRepository(Path.Combine(dataFolder, "users.json"))); builder.Services.AddSingleton(new UserRepository(Path.Combine(dataFolder, "users.json")));
builder.Services.AddSingleton(new AuditRepository(Path.Combine(dataFolder, "audit.jsonl"))); builder.Services.AddSingleton(new AuditRepository(Path.Combine(dataFolder, "audit.jsonl")));
builder.Services.AddSingleton(new ScheduledReportRepository(Path.Combine(dataFolder, "schedules.json")));
builder.Services.AddSingleton(new GeneratedReportRepository(Path.Combine(dataFolder, "reports-index.json")));
// ── App-only (unattended) auth for scheduled reports ──────────────────────────
builder.Services.AddSingleton<IAppOnlyCertStore>(sp =>
new AppOnlyCertStore(certsFolder, sp.GetRequiredService<IDataProtectionProvider>()));
builder.Services.AddSingleton<IAppOnlyContextFactory, AppOnlyContextFactory>();
// ── Auth infrastructure ─────────────────────────────────────────────────────── // ── Auth infrastructure ───────────────────────────────────────────────────────
builder.Services.AddSingleton<IPasswordHasher<AppUser>, PasswordHasher<AppUser>>(); builder.Services.AddSingleton<IPasswordHasher<AppUser>, PasswordHasher<AppUser>>();
@@ -131,6 +204,7 @@ builder.Services.AddSingleton<IOAuthFlowCache, OAuthFlowCache>();
builder.Services.AddSingleton<IEntraDeviceCodeFlow, EntraDeviceCodeFlow>(); builder.Services.AddSingleton<IEntraDeviceCodeFlow, EntraDeviceCodeFlow>();
builder.Services.AddHttpClient<ITokenRefreshService, TokenRefreshService>(); builder.Services.AddHttpClient<ITokenRefreshService, TokenRefreshService>();
builder.Services.AddHttpClient<IAppRegistrationService, AppRegistrationService>(); builder.Services.AddHttpClient<IAppRegistrationService, AppRegistrationService>();
builder.Services.AddSingleton<ICertProvisioningService, CertProvisioningService>();
builder.Services.AddScoped<GraphClientFactory>(); builder.Services.AddScoped<GraphClientFactory>();
// ── User session (Scoped = one per Blazor circuit = one per browser tab) ───── // ── User session (Scoped = one per Blazor circuit = one per browser tab) ─────
@@ -177,8 +251,42 @@ builder.Services.AddScoped<VersionCleanupHtmlExportService>();
builder.Services.AddScoped<BulkResultCsvExportService>(); builder.Services.AddScoped<BulkResultCsvExportService>();
builder.Services.AddScoped<WebExportService>(); builder.Services.AddScoped<WebExportService>();
// ── Scheduled reports (background generation) ─────────────────────────────────
builder.Services.AddSingleton<ScheduledRunCoordinator>();
builder.Services.AddScoped<IReportMailService, ReportMailService>();
builder.Services.AddScoped<IScheduledReportRunner, ScheduledReportRunner>();
builder.Services.AddHostedService<ScheduledReportHostedService>();
var app = builder.Build(); var app = builder.Build();
// Must run before anything that inspects the request scheme/IP (auth, OIDC, cookies).
app.UseForwardedHeaders();
// ── First-run bootstrap ───────────────────────────────────────────────────────
// Seed a local admin when no users exist yet, so a plain-HTTP / LAN deployment that
// can't use Microsoft OIDC (which requires HTTPS + a matching Entra redirect URI) can
// still sign in via the local email/password form. Only fires while the user store is
// empty; set Bootstrap__AdminEmail and Bootstrap__AdminPassword to enable.
{
var bootEmail = app.Configuration["Bootstrap:AdminEmail"];
var bootPass = app.Configuration["Bootstrap:AdminPassword"];
if (!string.IsNullOrWhiteSpace(bootEmail) && !string.IsNullOrWhiteSpace(bootPass))
{
var users = app.Services.GetRequiredService<IUserService>();
// Seed if this email has no account yet — covers both an empty store and a store
// that already holds an Entra-provisioned user from a failed sign-in attempt.
if (await users.GetByEmailAsync(bootEmail) is null)
{
await users.CreateLocalUserAsync(bootEmail, "Administrator", UserRole.Admin, bootPass);
Log.Information("Bootstrap: created local admin {Email}.", bootEmail);
}
else
{
Log.Information("Bootstrap: local admin {Email} already present; skipping seed.", bootEmail);
}
}
}
if (!app.Environment.IsDevelopment()) if (!app.Environment.IsDevelopment())
{ {
app.UseExceptionHandler("/Error", createScopeForErrors: true); app.UseExceptionHandler("/Error", createScopeForErrors: true);
@@ -188,7 +296,7 @@ if (!app.Environment.IsDevelopment())
// Re-execute unmatched (404) requests into the branded not-found page // Re-execute unmatched (404) requests into the branded not-found page
app.UseStatusCodePagesWithReExecute("/not-found"); app.UseStatusCodePagesWithReExecute("/not-found");
app.UseStaticFiles(); app.MapStaticAssets();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.UseAntiforgery(); app.UseAntiforgery();
@@ -301,6 +409,21 @@ app.MapGet("/export/download/{fileName}", async (string fileName, IOptions<AppCo
return Results.File(bytes, ct, fileName); return Results.File(bytes, ct, fileName);
}); });
// ── Scheduled report download (id-based, scoped to the client's exports subfolder) ──
app.MapGet("/reports/download/{id}", async (string id, GeneratedReportRepository index, IOptions<AppConfiguration> opts, HttpContext ctx) =>
{
if (!ctx.User.Identity?.IsAuthenticated ?? true) return Results.Unauthorized();
var report = await index.GetAsync(id);
if (report is null || report.Status != ReportRunStatus.Success || string.IsNullOrEmpty(report.FileName))
return Results.NotFound();
// ProfileId and FileName are app-generated; GetFileName strips any traversal just in case.
var path = Path.Combine(opts.Value.ExportsFolder, report.ProfileId, Path.GetFileName(report.FileName));
if (!File.Exists(path)) return Results.NotFound();
var bytes = await File.ReadAllBytesAsync(path);
var mime = string.IsNullOrEmpty(report.Mime) ? "application/octet-stream" : report.Mime;
return Results.File(bytes, mime, report.FileName);
});
// ── Audit CSV download ──────────────────────────────────────────────────────── // ── Audit CSV download ────────────────────────────────────────────────────────
app.MapGet("/audit/export", async (AuditRepository auditRepo, HttpContext ctx) => app.MapGet("/audit/export", async (AuditRepository auditRepo, HttpContext ctx) =>
{ {
+116 -1
View File
@@ -1,2 +1,117 @@
# SharepointToolbox-Web # SharePoint Toolbox
A web admin toolbox for Microsoft 365 / SharePoint Online, built with Blazor Server (.NET 10) and Microsoft Graph.
## Features
- **Site management** — bulk site creation, folder-structure provisioning, templates
- **Members & permissions** — bulk member add, permission inspection
- **Content tools** — search, duplicate finder, file transfer, storage usage, version cleanup
- **Reporting** — on-demand reports, scheduled reports (unattended via app-only cert auth)
- **Auditing** — tenant-wide user-access audit (SP + M365/AAD group expansion)
- **Directory** — user directory browsing
- Multi-tenant via connection profiles. EN / FR localization.
## Requirements
- An Entra ID (Azure AD) app registration — see [Configuration](#configuration)
- Docker, **or** the .NET 10 SDK for bare-metal
## Configuration
Authentication uses Microsoft OIDC (interactive sign-in) and, for scheduled reports, app-only certificate auth.
Set these as environment variables (or in `appsettings.json` under the `Oidc` section). .NET maps `Section__Key` to `Section:Key`.
| Variable | Description |
|----------|-------------|
| `Oidc__TenantId` | Entra tenant GUID |
| `Oidc__ClientId` | App registration client ID |
| `Oidc__ClientSecret` | App registration client secret |
| `DataFolder` | Persistent data path (default `/data`) |
| `ASPNETCORE_ENVIRONMENT` | Must be `Production` to enable OIDC |
> In `Development`, OIDC is disabled — the app uses a cookie-only auto-login (hardcoded Admin) for local work.
### Two distinct OAuth flows — two redirect URIs
These are separate and registered on **different** Entra apps. Don't conflate them.
1. **App sign-in (OIDC).** Logging into the toolbox itself via "Sign in with Microsoft". Uses the `Oidc__*` app above. Callback path is the framework default `/signin-oidc` (not configurable here).
→ On **this** app registration, add redirect URI `https://your-host/signin-oidc` under the **Web** platform. This app also needs the Graph permissions the audit/reporting features require: `GroupMember.Read.All`, `Group.Read.All`, `User.Read.All`.
2. **SharePoint connect (per-profile).** Getting a delegated SharePoint/Graph token for a client tenant. A PKCE public-client flow that uses **each connection profile's own `ClientId`/`TenantId`** — not the `Oidc__*` app. `ClientConnect__RedirectUri` is the callback for this flow.
→ On **each client-tenant profile's** app registration, add the `ClientConnect__RedirectUri` value (e.g. `https://your-host/connect/callback`) under the **Mobile and desktop / public client** platform.
> **HTTPS note.** The sign-in app is a confidential (Web) client, so Entra requires its `/signin-oidc` redirect URI to be **HTTPS** — plain HTTP is allowed only for `http://localhost`, not a LAN host/IP. To run OIDC on a plain-HTTP LAN deployment, put the app behind an HTTPS-terminating reverse proxy: register `https://your-host/signin-oidc`, and the app honours `X-Forwarded-Proto` (see `UseForwardedHeaders`) to build the correct `https` redirect. Without a proxy, OIDC sign-in won't work over a non-localhost HTTP host — use the local email/password login instead.
Persistent state (profiles, settings, templates, logs, exports, certs) lives in `DataFolder`.
## Installation — Docker
```bash
docker compose up -d --build
```
App listens on **http://localhost:8080**. Data persists in the `sptb-data` volume.
Set your OIDC values in `docker-compose.yml` under `environment:`, or pass an env file:
```yaml
environment:
- ASPNETCORE_ENVIRONMENT=Production
- DataFolder=/data
- Oidc__TenantId=...
- Oidc__ClientId=...
- Oidc__ClientSecret=...
- ClientConnect__RedirectUri=https://your-host/connect/callback
```
Plain Docker (no compose):
```bash
docker build -t sptb-web .
docker run -d -p 8080:8080 \
-v sptb-data:/data \
-e ASPNETCORE_ENVIRONMENT=Production \
-e Oidc__TenantId=... \
-e Oidc__ClientId=... \
-e Oidc__ClientSecret=... \
-e ClientConnect__RedirectUri=https://your-host/connect/callback \
sptb-web
```
## Installation — Bare metal
Requires the [.NET 10 SDK](https://dotnet.microsoft.com/download).
```bash
# Restore + build
dotnet restore
dotnet publish -c Release -o ./publish
# Configure (PowerShell example)
$env:ASPNETCORE_ENVIRONMENT = "Production"
$env:DataFolder = "C:\sptb-data"
$env:Oidc__TenantId = "..."
$env:Oidc__ClientId = "..."
$env:Oidc__ClientSecret = "..."
$env:ClientConnect__RedirectUri = "https://your-host/connect/callback"
# Run
dotnet ./publish/SharepointToolbox.Web.dll
```
By default it listens on the Kestrel port (`http://localhost:5000`). Override with `ASPNETCORE_URLS`, e.g. `http://+:8080`.
### Local development
```bash
dotnet run
```
Runs in `Development` mode — OIDC off, auto-login as Admin. No Entra config needed.
## Tech stack
.NET 10 · Blazor Server · Microsoft Graph SDK · PnP.Framework · Serilog · CsvHelper
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

+103 -45
View File
@@ -24,6 +24,22 @@ public class AppRegistrationService : IAppRegistrationService
"AllSites.FullControl", // CSOM — site permissions, content, admin operations "AllSites.FullControl", // CSOM — site permissions, content, admin operations
]; ];
// Graph APPLICATION permissions (app roles) for certificate (app-only) auth.
private static readonly string[] GraphAppRoles =
[
"User.Read.All",
"Group.ReadWrite.All",
"Directory.Read.All", // expand M365/AAD group membership in the user-access audit (SharePointGroupResolver)
"Sites.FullControl.All",
"Mail.Send", // app-only sendMail for emailed scheduled reports
];
// SharePoint APPLICATION permission (app role) for certificate (app-only) CSOM.
private static readonly string[] SpAppRoles =
[
"Sites.FullControl.All",
];
private readonly HttpClient _http; private readonly HttpClient _http;
public AppRegistrationService(HttpClient http) { _http = http; } public AppRegistrationService(HttpClient http) { _http = http; }
@@ -32,43 +48,49 @@ public class AppRegistrationService : IAppRegistrationService
string adminAccessToken, string adminAccessToken,
string tenantName, string tenantName,
string redirectUri, string redirectUri,
CertProvisioningResult? appOnlyCert = null,
CancellationToken ct = default) CancellationToken ct = default)
{ {
_http.DefaultRequestHeaders.Authorization = _http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", adminAccessToken); new AuthenticationHeaderValue("Bearer", adminAccessToken);
// 1. Resolve Graph + SharePoint service principals in the target tenant bool wantsAppOnly = appOnlyCert is not null;
var (graphSpId, graphPermIds) = await ResolveServicePrincipalAsync(GraphAppId, GraphScopes, ct);
var (spSpId, spPermIds) = await ResolveServicePrincipalAsync(SharePointAppId, SpScopes, ct);
// 2. Create app registration // 1. Resolve Graph + SharePoint service principals + the permission ids we need
var appBody = new var graph = await ResolveServicePrincipalAsync(GraphAppId, GraphScopes, GraphAppRoles, ct);
var sp = await ResolveServicePrincipalAsync(SharePointAppId, SpScopes, SpAppRoles, ct);
// 2. Create app registration (delegated scopes always; application roles when app-only)
var appBody = new Dictionary<string, object?>
{ {
displayName = $"SP Toolbox — {tenantName}", ["displayName"] = $"SP Toolbox — {tenantName}",
signInAudience = "AzureADMyOrg", ["signInAudience"] = "AzureADMyOrg",
isFallbackPublicClient = true, ["isFallbackPublicClient"] = true,
// Register the redirect under the PUBLIC client platform so the connect // Register the redirect under the PUBLIC client platform so the connect
// flow can redeem the auth code with PKCE only (no client secret). A // flow can redeem the auth code with PKCE only (no client secret). A
// redirect under `web` makes Entra treat the app as confidential and the // redirect under `web` makes Entra treat the app as confidential and the
// token exchange fails with AADSTS7000218 (secret required). // token exchange fails with AADSTS7000218 (secret required).
publicClient = new { redirectUris = new[] { redirectUri } }, ["publicClient"] = new { redirectUris = new[] { redirectUri } },
requiredResourceAccess = new[] ["requiredResourceAccess"] = new[]
{ {
new new
{ {
resourceAppId = GraphAppId, resourceAppId = GraphAppId,
resourceAccess = graphPermIds.Select(id => new { id, type = "Scope" }).ToArray(), resourceAccess = ResourceAccess(graph.ScopeIds, wantsAppOnly ? graph.AppRoleIds : []),
}, },
new new
{ {
resourceAppId = SharePointAppId, resourceAppId = SharePointAppId,
resourceAccess = spPermIds.Select(id => new { id, type = "Scope" }).ToArray(), resourceAccess = ResourceAccess(sp.ScopeIds, wantsAppOnly ? sp.AppRoleIds : []),
}, },
}, },
}; };
var appJson = await PostGraphAsync("https://graph.microsoft.com/v1.0/applications", // Attach the certificate as a sign-in credential so app-only token requests succeed.
appBody, ct); if (wantsAppOnly)
appBody["keyCredentials"] = new[] { BuildKeyCredential(appOnlyCert!, tenantName) };
var appJson = await PostGraphAsync("https://graph.microsoft.com/v1.0/applications", appBody, ct);
var clientId = appJson.GetProperty("appId").GetString()!; var clientId = appJson.GetProperty("appId").GetString()!;
// 3. Create service principal for the new app // 3. Create service principal for the new app
@@ -76,54 +98,90 @@ public class AppRegistrationService : IAppRegistrationService
new { appId = clientId }, ct); new { appId = clientId }, ct);
var newSpId = spJson.GetProperty("id").GetString()!; var newSpId = spJson.GetProperty("id").GetString()!;
// 4. Grant org-wide admin consent for Graph // 4. Grant org-wide admin consent for Graph + SharePoint delegated scopes
await PostGraphAsync("https://graph.microsoft.com/v1.0/oauth2PermissionGrants", await GrantDelegatedConsentAsync(newSpId, graph.SpObjectId, GraphScopes, ct);
new await GrantDelegatedConsentAsync(newSpId, sp.SpObjectId, SpScopes, ct);
{
clientId = newSpId,
consentType = "AllPrincipals",
resourceId = graphSpId,
scope = string.Join(" ", GraphScopes),
}, ct);
// 5. Grant org-wide admin consent for SharePoint // 5. Grant admin consent for application permissions (app roles) when app-only
await PostGraphAsync("https://graph.microsoft.com/v1.0/oauth2PermissionGrants", if (wantsAppOnly)
new
{ {
clientId = newSpId, await GrantAppRolesAsync(newSpId, graph.SpObjectId, graph.AppRoleIds, ct);
consentType = "AllPrincipals", await GrantAppRolesAsync(newSpId, sp.SpObjectId, sp.AppRoleIds, ct);
resourceId = spSpId, }
scope = string.Join(" ", SpScopes),
}, ct);
return clientId; return clientId;
} }
// Returns (servicePrincipalObjectId, [permissionIds matching requested scopes]) private static object BuildKeyCredential(CertProvisioningResult cert, string tenantName) => new
private async Task<(string SpObjectId, string[] PermissionIds)> ResolveServicePrincipalAsync( {
string appId, string[] scopeNames, CancellationToken ct) type = "AsymmetricX509Cert",
usage = "Verify",
key = cert.PublicCertBase64,
displayName = $"CN=SP Toolbox — {tenantName}",
startDateTime = cert.NotBefore.UtcDateTime.ToString("o"),
endDateTime = cert.NotAfter.UtcDateTime.ToString("o"),
};
private static object[] ResourceAccess(string[] scopeIds, string[] appRoleIds)
{
var list = new List<object>(scopeIds.Length + appRoleIds.Length);
list.AddRange(scopeIds.Select(id => new { id, type = "Scope" }));
list.AddRange(appRoleIds.Select(id => new { id, type = "Role" }));
return list.ToArray();
}
private async Task GrantDelegatedConsentAsync(string clientSpId, string resourceSpId, string[] scopes, CancellationToken ct)
{
await PostGraphAsync("https://graph.microsoft.com/v1.0/oauth2PermissionGrants",
new
{
clientId = clientSpId,
consentType = "AllPrincipals",
resourceId = resourceSpId,
scope = string.Join(" ", scopes),
}, ct);
}
private async Task GrantAppRolesAsync(string clientSpId, string resourceSpId, string[] appRoleIds, CancellationToken ct)
{
foreach (var appRoleId in appRoleIds)
{
await PostGraphAsync(
$"https://graph.microsoft.com/v1.0/servicePrincipals/{clientSpId}/appRoleAssignments",
new { principalId = clientSpId, resourceId = resourceSpId, appRoleId }, ct);
}
}
// Returns the SP object id plus the ids of the requested delegated scopes and application roles.
private async Task<(string SpObjectId, string[] ScopeIds, string[] AppRoleIds)> ResolveServicePrincipalAsync(
string appId, string[] scopeNames, string[] roleNames, CancellationToken ct)
{ {
var url = $"https://graph.microsoft.com/v1.0/servicePrincipals" + var url = $"https://graph.microsoft.com/v1.0/servicePrincipals" +
$"?$filter=appId eq '{appId}'&$select=id,oauth2PermissionScopes"; $"?$filter=appId eq '{appId}'&$select=id,oauth2PermissionScopes,appRoles";
var resp = await _http.GetAsync(url, ct); var resp = await _http.GetAsync(url, ct);
var json = await resp.Content.ReadAsStringAsync(ct); var json = await resp.Content.ReadAsStringAsync(ct);
resp.EnsureSuccessStatusCode(); resp.EnsureSuccessStatusCode();
using var doc = JsonDocument.Parse(json); using var doc = JsonDocument.Parse(json);
var values = doc.RootElement.GetProperty("value"); var sp = doc.RootElement.GetProperty("value").EnumerateArray().First();
var sp = values.EnumerateArray().First();
var spId = sp.GetProperty("id").GetString()!; var spId = sp.GetProperty("id").GetString()!;
var allScopes = sp.GetProperty("oauth2PermissionScopes");
var ids = new List<string>(); var scopeIds = MatchByValue(sp.GetProperty("oauth2PermissionScopes"), scopeNames);
foreach (var scope in allScopes.EnumerateArray()) var roleIds = MatchByValue(sp.GetProperty("appRoles"), roleNames);
{
var value = scope.GetProperty("value").GetString(); return (spId, scopeIds, roleIds);
if (scopeNames.Contains(value, StringComparer.OrdinalIgnoreCase))
ids.Add(scope.GetProperty("id").GetString()!);
} }
return (spId, ids.ToArray()); private static string[] MatchByValue(JsonElement entries, string[] wantedValues)
{
var ids = new List<string>();
foreach (var entry in entries.EnumerateArray())
{
var value = entry.GetProperty("value").GetString();
if (wantedValues.Contains(value, StringComparer.OrdinalIgnoreCase))
ids.Add(entry.GetProperty("id").GetString()!);
}
return ids.ToArray();
} }
private async Task<JsonElement> PostGraphAsync(string url, object body, CancellationToken ct) private async Task<JsonElement> PostGraphAsync(string url, object body, CancellationToken ct)
+57
View File
@@ -0,0 +1,57 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using SharepointToolbox.Web.Infrastructure.Auth;
namespace SharepointToolbox.Web.Services.Auth;
/// <summary>
/// Creates a 2048-bit RSA self-signed certificate valid for two years, persists its private
/// key (PFX) through <see cref="IAppOnlyCertStore"/>, and returns the public certificate so
/// the caller can attach it to the Entra app registration as a sign-in credential.
/// </summary>
public class CertProvisioningService : ICertProvisioningService
{
private readonly IAppOnlyCertStore _certStore;
public CertProvisioningService(IAppOnlyCertStore certStore) { _certStore = certStore; }
public async Task<CertProvisioningResult> GenerateAndStoreAsync(
string profileId, string subjectName, CancellationToken ct = default)
{
// X.509 validity is stored at whole-second precision (ASN.1 has no sub-second field).
// Truncate here so the keyCredential start/endDateTime we send to Graph match the
// certificate's embedded validity exactly — otherwise the JSON endDateTime carries
// a fractional second that lands *after* the cert's NotAfter and Graph rejects it
// with KeyCredentialsInvalidEndDate.
var notBefore = TruncateToSecond(DateTimeOffset.UtcNow.AddMinutes(-5));
var notAfter = notBefore.AddYears(2);
using var rsa = RSA.Create(2048);
var req = new CertificateRequest(
$"CN={Sanitize(subjectName)}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
req.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false));
req.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, false));
using var cert = req.CreateSelfSigned(notBefore, notAfter);
// Transient password only protects the in-memory PFX handoff to the store, which
// re-exports it password-less and encrypts at rest with Data Protection.
var transientPwd = Convert.ToBase64String(RandomNumberGenerator.GetBytes(24));
var pfxBytes = cert.Export(X509ContentType.Pkcs12, transientPwd);
var thumbprint = await _certStore.SaveAsync(profileId, pfxBytes, transientPwd, ct);
var publicBase64 = Convert.ToBase64String(cert.Export(X509ContentType.Cert));
return new CertProvisioningResult(thumbprint, publicBase64, notBefore, notAfter);
}
private static DateTimeOffset TruncateToSecond(DateTimeOffset value) =>
new(value.Ticks - (value.Ticks % TimeSpan.TicksPerSecond), value.Offset);
// CN cannot contain characters that break the X.500 distinguished name.
private static string Sanitize(string name)
{
var cleaned = name.Replace(",", " ").Replace("=", " ").Replace("\"", " ").Trim();
return string.IsNullOrEmpty(cleaned) ? "SP Toolbox" : cleaned;
}
}
+10 -3
View File
@@ -4,13 +4,20 @@ public interface IAppRegistrationService
{ {
/// <summary> /// <summary>
/// Creates an Entra ID app registration in the target tenant using a delegated admin token /// Creates an Entra ID app registration in the target tenant using a delegated admin token
/// (requires Application.ReadWrite.All + DelegatedPermissionGrant.ReadWrite.All scope). /// (requires Application.ReadWrite.All + DelegatedPermissionGrant.ReadWrite.All +
/// Grants org-wide admin consent for SharePoint + Graph delegated permissions. /// AppRoleAssignment.ReadWrite.All scope). Grants org-wide admin consent for SharePoint + Graph
/// Returns the new app's client ID (appId). /// delegated permissions (fallback sign-in flow).
///
/// When <paramref name="appOnlyCert"/> is supplied, the registration is also provisioned for
/// certificate (app-only) auth: the public certificate is attached as a sign-in credential,
/// SharePoint + Graph <em>application</em> permissions are requested, and admin consent for
/// those app roles is granted. This lets technicians operate under the app identity without an
/// interactive sign-in. Returns the new app's client ID (appId).
/// </summary> /// </summary>
Task<string> CreateAsync( Task<string> CreateAsync(
string adminAccessToken, string adminAccessToken,
string tenantName, string tenantName,
string redirectUri, string redirectUri,
CertProvisioningResult? appOnlyCert = null,
CancellationToken ct = default); CancellationToken ct = default);
} }
+25
View File
@@ -0,0 +1,25 @@
namespace SharepointToolbox.Web.Services.Auth;
/// <summary>
/// Public material of a freshly generated app-only certificate. The private key is already
/// stored (encrypted) in the cert store; these fields are what the app registration needs
/// to trust the certificate as a sign-in credential.
/// </summary>
/// <param name="Thumbprint">SHA-1 thumbprint of the generated certificate.</param>
/// <param name="PublicCertBase64">Base64 of the DER-encoded public certificate (Graph keyCredential.key).</param>
/// <param name="NotBefore">Validity start (UTC).</param>
/// <param name="NotAfter">Validity end (UTC).</param>
public record CertProvisioningResult(
string Thumbprint,
string PublicCertBase64,
DateTimeOffset NotBefore,
DateTimeOffset NotAfter);
/// <summary>
/// Generates a self-signed certificate for a client profile, stores the private key in the
/// app-only cert store, and returns the public material to register against the Entra app.
/// </summary>
public interface ICertProvisioningService
{
Task<CertProvisioningResult> GenerateAndStoreAsync(string profileId, string subjectName, CancellationToken ct = default);
}
+1 -1
View File
@@ -4,7 +4,7 @@ namespace SharepointToolbox.Web.Services;
public interface IUserAccessAuditService public interface IUserAccessAuditService
{ {
Task<IReadOnlyList<UserAccessEntry>> AuditUsersAsync( Task<UserAccessAuditResult> AuditUsersAsync(
ISessionManager sessionManager, ISessionManager sessionManager,
TenantProfile currentProfile, TenantProfile currentProfile,
IReadOnlyList<string> targetUserLogins, IReadOnlyList<string> targetUserLogins,
+21
View File
@@ -0,0 +1,21 @@
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Services.Reports;
/// <summary>Sends a generated report as a Graph email (app-only, Mail.Send).</summary>
public interface IReportMailService
{
/// <summary>
/// Sends <paramref name="bytes"/> as an attachment to the recipients in
/// <paramref name="settings"/>, sending AS <see cref="ReportEmailSettings.From"/>.
/// Subject/body placeholders are resolved from the schedule and client.
/// </summary>
Task SendAsync(
TenantProfile profile,
ScheduledReport schedule,
ReportEmailSettings settings,
string fileName,
string mime,
byte[] bytes,
CancellationToken ct = default);
}
@@ -0,0 +1,14 @@
using SharepointToolbox.Web.Core.Models;
namespace SharepointToolbox.Web.Services.Reports;
public interface IScheduledReportRunner
{
/// <summary>
/// Generates one report for the given schedule using app-only auth, writes the
/// file under the client's exports subfolder, records it in the report index, and
/// audit-logs the run. Never throws for report-level failures — a failed run is
/// captured as a <see cref="GeneratedReport"/> with <see cref="ReportRunStatus.Failed"/>.
/// </summary>
Task<GeneratedReport> RunAsync(ScheduledReport schedule, CancellationToken ct = default);
}
+90
View File
@@ -0,0 +1,90 @@
using Microsoft.Graph.Models;
using Microsoft.Graph.Users.Item.SendMail;
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Infrastructure.Auth;
namespace SharepointToolbox.Web.Services.Reports;
/// <summary>
/// Delivers a generated report by email through Graph. Uses the client's app-only
/// (certificate) Graph client, which has no signed-in user, so it posts to
/// <c>/users/{From}/sendMail</c> — the configured sender mailbox must exist in the
/// tenant and the app registration must hold the <c>Mail.Send</c> application role.
/// </summary>
public class ReportMailService : IReportMailService
{
// Graph caps a single sendMail request at ~4 MB total; larger files need an upload
// session we don't implement. Reject early with a clear message instead of a 413.
private const long MaxAttachmentBytes = 3 * 1024 * 1024;
private readonly IAppOnlyContextFactory _appOnly;
public ReportMailService(IAppOnlyContextFactory appOnly) { _appOnly = appOnly; }
public async Task SendAsync(
TenantProfile profile,
ScheduledReport schedule,
ReportEmailSettings settings,
string fileName,
string mime,
byte[] bytes,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(settings.From))
throw new InvalidOperationException("No sender mailbox (From) configured for report email.");
var to = CleanAddresses(settings.To);
var cc = CleanAddresses(settings.Cc);
if (to.Count == 0 && cc.Count == 0)
throw new InvalidOperationException("Report email has no To or Cc recipients.");
var message = new Message
{
Subject = Substitute(settings.Subject, profile, schedule, fileName),
Body = new ItemBody
{
ContentType = BodyType.Html,
Content = Substitute(settings.Body, profile, schedule, fileName)
},
ToRecipients = to.Select(Recipient).ToList(),
CcRecipients = cc.Select(Recipient).ToList(),
};
if (bytes.LongLength > MaxAttachmentBytes)
throw new InvalidOperationException(
$"Report '{fileName}' is {bytes.LongLength / 1024.0 / 1024.0:F1} MB — too large to email " +
$"(Graph sendMail limit ~{MaxAttachmentBytes / 1024 / 1024} MB).");
message.Attachments = new List<Attachment>
{
new FileAttachment
{
OdataType = "#microsoft.graph.fileAttachment",
Name = fileName,
ContentType = mime,
ContentBytes = bytes,
}
};
var graph = await _appOnly.CreateGraphClientAsync(profile, ct);
await graph.Users[settings.From].SendMail.PostAsync(
new SendMailPostRequestBody { Message = message, SaveToSentItems = false }, cancellationToken: ct);
}
private static List<string> CleanAddresses(IEnumerable<string> raw) =>
raw.Select(a => a?.Trim() ?? string.Empty)
.Where(a => a.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
private static Recipient Recipient(string address) =>
new() { EmailAddress = new EmailAddress { Address = address } };
private static string Substitute(string template, TenantProfile profile, ScheduledReport schedule, string fileName) =>
(template ?? string.Empty)
.Replace("{ReportName}", schedule.Name)
.Replace("{ClientName}", profile.Name)
.Replace("{ReportType}", schedule.Type.ToString())
.Replace("{FileName}", fileName)
.Replace("{DateUtc}", DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm"));
}
@@ -0,0 +1,130 @@
using Microsoft.Extensions.Logging;
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Infrastructure.Persistence;
namespace SharepointToolbox.Web.Services.Reports;
/// <summary>
/// Background scheduler. Every <see cref="TickInterval"/> it loads the schedule
/// definitions, runs any whose <see cref="ScheduledReport.NextRunUtc"/> is due, and
/// advances their next-run stamp. Each run executes in its own DI scope (report
/// services are scoped). Due schedules run sequentially within a tick to bound the
/// load a single tenant sees; a long run simply delays the next due check.
/// </summary>
public class ScheduledReportHostedService : BackgroundService
{
private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(1);
private readonly IServiceScopeFactory _scopeFactory;
private readonly ScheduledReportRepository _repo;
private readonly ScheduledRunCoordinator _coordinator;
private readonly ILogger<ScheduledReportHostedService> _log;
public ScheduledReportHostedService(
IServiceScopeFactory scopeFactory,
ScheduledReportRepository repo,
ScheduledRunCoordinator coordinator,
ILogger<ScheduledReportHostedService> log)
{
_scopeFactory = scopeFactory;
_repo = repo;
_coordinator = coordinator;
_log = log;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_log.LogInformation("Scheduled report service started (tick {Interval}).", TickInterval);
using var timer = new PeriodicTimer(TickInterval);
// Tick once at startup, then on every interval.
do
{
try { await TickAsync(stoppingToken); }
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; }
catch (Exception ex) { _log.LogError(ex, "Scheduled report tick failed."); }
}
while (await WaitAsync(timer, stoppingToken));
_log.LogInformation("Scheduled report service stopping.");
}
private static async Task<bool> WaitAsync(PeriodicTimer timer, CancellationToken ct)
{
try { return await timer.WaitForNextTickAsync(ct); }
catch (OperationCanceledException) { return false; }
}
private async Task TickAsync(CancellationToken ct)
{
// Global pause: an admin has suspended all cadence-triggered runs. In-flight
// runs are unaffected (those are stopped individually); due schedules simply
// wait — NextRun is not advanced, so they fire once resumed.
if (_coordinator.IsPaused) return;
var now = DateTime.UtcNow;
var schedules = await _repo.LoadAsync();
foreach (var schedule in schedules)
{
ct.ThrowIfCancellationRequested();
if (!schedule.Enabled) continue;
// First time we see an enabled schedule with no next-run: arm it, don't run.
if (schedule.NextRunUtc is null)
{
schedule.NextRunUtc = schedule.Recurrence.ComputeNextRunUtc(now);
await _repo.UpsertAsync(schedule);
continue;
}
if (schedule.NextRunUtc > now) continue;
await RunOneAsync(schedule, now, ct);
}
}
private async Task RunOneAsync(ScheduledReport schedule, DateTime now, CancellationToken ct)
{
// Register the run so the UI can stop it mid-flight. The returned token trips on
// either app shutdown (ct) or an admin Stop. Null = a run is already in progress
// (e.g. a long previous run or a "Run now"); skip without advancing so it retries.
var token = _coordinator.TryBegin(schedule.Id, ct);
if (token is null)
{
_log.LogWarning("Schedule '{Name}' ({Id}) still running; skipping this tick.", schedule.Name, schedule.Id);
return;
}
try
{
using var scope = _scopeFactory.CreateScope();
var runner = scope.ServiceProvider.GetRequiredService<IScheduledReportRunner>();
await runner.RunAsync(schedule, token.Value);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw; // app shutdown — bubble up to stop the service loop
}
catch (OperationCanceledException)
{
// Stopped via the coordinator (admin Stop / Stop all), not shutdown. Not a failure.
_log.LogInformation("Schedule '{Name}' ({Id}) was stopped.", schedule.Name, schedule.Id);
}
catch (Exception ex)
{
// RunAsync already captures report-level failures; this guards anything
// thrown outside it so one bad schedule can't stop the others advancing.
_log.LogError(ex, "Schedule '{Name}' ({Id}) failed to run.", schedule.Name, schedule.Id);
}
finally
{
_coordinator.Complete(schedule.Id);
// Advance regardless of outcome so a persistently failing (or stopped)
// schedule doesn't hot-loop every tick.
schedule.LastRunUtc = now;
schedule.NextRunUtc = schedule.Recurrence.ComputeNextRunUtc(now);
await _repo.UpsertAsync(schedule);
}
}
}
+297
View File
@@ -0,0 +1,297 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.SharePoint.Client;
using SharepointToolbox.Web.Core.Helpers;
using SharepointToolbox.Web.Core.Models;
using SharepointToolbox.Web.Infrastructure.Auth;
using SharepointToolbox.Web.Infrastructure.Persistence;
using SharepointToolbox.Web.Services.Export;
using AppConfiguration = SharepointToolbox.Web.Core.Models.AppConfiguration;
namespace SharepointToolbox.Web.Services.Reports;
/// <summary>
/// Drives one scheduled report end-to-end under app-only auth: resolve the client
/// profile, discover/select sites, scan each site with the matching report service,
/// merge the per-site results into a single artifact, write it to the client's
/// exports subfolder, and index + audit the run.
///
/// Report services take a plain <see cref="ClientContext"/>, so they are reused
/// verbatim; the only difference from the interactive pages is that the context is
/// produced by <see cref="IAppOnlyContextFactory"/> instead of the delegated session.
/// </summary>
public class ScheduledReportRunner : IScheduledReportRunner
{
private readonly ProfileRepository _profiles;
private readonly SettingsRepository _settings;
private readonly GeneratedReportRepository _index;
private readonly AuditRepository _audit;
private readonly IAppOnlyContextFactory _appOnly;
private readonly IReportMailService _mail;
private readonly AppConfiguration _cfg;
private readonly ILogger<ScheduledReportRunner> _log;
private readonly IPermissionsService _perm;
private readonly ISharePointGroupResolver _groupResolver;
private readonly IStorageService _storage;
private readonly IDuplicatesService _dup;
private readonly ISearchService _search;
private readonly IVersionCleanupService _version;
private readonly CsvExportService _permCsv;
private readonly HtmlExportService _permHtml;
private readonly StorageCsvExportService _storageCsv;
private readonly StorageHtmlExportService _storageHtml;
private readonly DuplicatesCsvExportService _dupCsv;
private readonly DuplicatesHtmlExportService _dupHtml;
private readonly SearchCsvExportService _searchCsv;
private readonly SearchHtmlExportService _searchHtml;
private readonly UserAccessCsvExportService _uaCsv;
private readonly UserAccessHtmlExportService _uaHtml;
private readonly VersionCleanupHtmlExportService _versionHtml;
public ScheduledReportRunner(
ProfileRepository profiles, SettingsRepository settings,
GeneratedReportRepository index, AuditRepository audit,
IAppOnlyContextFactory appOnly, IReportMailService mail, IOptions<AppConfiguration> cfg,
ILogger<ScheduledReportRunner> log,
IPermissionsService perm, ISharePointGroupResolver groupResolver,
IStorageService storage, IDuplicatesService dup,
ISearchService search, IVersionCleanupService version,
CsvExportService permCsv, HtmlExportService permHtml,
StorageCsvExportService storageCsv, StorageHtmlExportService storageHtml,
DuplicatesCsvExportService dupCsv, DuplicatesHtmlExportService dupHtml,
SearchCsvExportService searchCsv, SearchHtmlExportService searchHtml,
UserAccessCsvExportService uaCsv, UserAccessHtmlExportService uaHtml,
VersionCleanupHtmlExportService versionHtml)
{
_profiles = profiles; _settings = settings; _index = index; _audit = audit;
_appOnly = appOnly; _mail = mail; _cfg = cfg.Value; _log = log;
_perm = perm; _groupResolver = groupResolver; _storage = storage; _dup = dup; _search = search; _version = version;
_permCsv = permCsv; _permHtml = permHtml;
_storageCsv = storageCsv; _storageHtml = storageHtml;
_dupCsv = dupCsv; _dupHtml = dupHtml;
_searchCsv = searchCsv; _searchHtml = searchHtml;
_uaCsv = uaCsv; _uaHtml = uaHtml; _versionHtml = versionHtml;
}
public async Task<GeneratedReport> RunAsync(ScheduledReport schedule, CancellationToken ct = default)
{
var profile = (await _profiles.LoadAsync()).FirstOrDefault(p => p.Id == schedule.ProfileId);
if (profile is null)
return await FailAsync(schedule, profileName: "(unknown)", $"Client profile '{schedule.ProfileId}' not found.");
if (!profile.AppOnlyEnabled)
return await FailAsync(schedule, profile.Name, $"App-only reports are not enabled for client '{profile.Name}'.");
try
{
var sites = await ResolveSitesAsync(profile, schedule, ct);
if (sites.Count == 0)
return await FailAsync(schedule, profile.Name, "No sites resolved for this schedule.");
var settings = await _settings.LoadAsync();
var branding = new ReportBranding(settings.MspLogo, profile.ClientLogo);
var ts = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss");
var output = await GenerateAsync(profile, schedule, sites, branding, ts, ct);
var dir = Path.Combine(_cfg.ExportsFolder, profile.Id);
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, output.FileName);
await System.IO.File.WriteAllBytesAsync(path, output.Bytes, ct);
var report = new GeneratedReport
{
ProfileId = profile.Id,
ScheduledReportId = schedule.Id,
Type = schedule.Type,
Name = schedule.Name,
FileName = output.FileName,
Mime = output.Mime,
SizeBytes = output.Bytes.LongLength,
Status = ReportRunStatus.Success
};
// Optional email delivery. A delivery failure does NOT fail the report —
// the file is already on disk and indexed; we record the error on the entry.
string mailNote = "";
if (schedule.Email.Enabled)
{
try
{
await _mail.SendAsync(profile, schedule, schedule.Email,
output.FileName, output.Mime, output.Bytes, ct);
report.Emailed = true;
mailNote = $", emailed to {schedule.Email.To.Count + schedule.Email.Cc.Count} recipient(s)";
}
catch (OperationCanceledException) { throw; }
catch (Exception mex)
{
report.EmailError = mex.Message;
mailNote = $", email FAILED: {mex.Message}";
_log.LogError(mex, "Emailing report '{Name}' for '{Client}' failed", schedule.Name, profile.Name);
}
}
await _index.AddAsync(report);
await AuditAsync(profile.Name, schedule, ReportRunStatus.Success,
$"{schedule.Type} report '{output.FileName}' ({sites.Count} site(s), {output.Bytes.LongLength / 1024.0:F1} KB){mailNote}");
_log.LogInformation("Scheduled report '{Name}' for '{Client}' produced {File}",
schedule.Name, profile.Name, output.FileName);
return report;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_log.LogError(ex, "Scheduled report '{Name}' for '{Client}' failed", schedule.Name, profile.Name);
return await FailAsync(schedule, profile.Name, ex.Message);
}
}
private async Task<IReadOnlyList<SiteInfo>> ResolveSitesAsync(
TenantProfile profile, ScheduledReport schedule, CancellationToken ct)
{
if (!schedule.AllSites)
return schedule.SiteUrls
.Where(u => !string.IsNullOrWhiteSpace(u))
.Select(u => new SiteInfo(u, ReportSplitHelper.DeriveSiteLabel(u)))
.ToList();
using var adminCtx = await _appOnly.CreateContextAsync(
profile, TenantSiteEnumerator.BuildAdminUrl(profile.TenantUrl), ct);
return await TenantSiteEnumerator.EnumerateAsync(adminCtx, ct);
}
private async Task<MergeOutput> GenerateAsync(
TenantProfile profile, ScheduledReport schedule, IReadOnlyList<SiteInfo> sites,
ReportBranding branding, string ts, CancellationToken ct)
{
var o = schedule.Options;
var progress = new Progress<OperationProgress>();
var mode = schedule.MergeMode;
var fmt = schedule.Format;
// Runs the same per-site scan loop for every report type. ClientContext is
// app-only and disposed per site.
async Task<List<(string Label, IReadOnlyList<T> Results)>> ScanSites<T>(
Func<ClientContext, SiteInfo, Task<IReadOnlyList<T>>> scan)
{
var bySite = new List<(string, IReadOnlyList<T>)>();
foreach (var site in sites)
{
ct.ThrowIfCancellationRequested();
using var ctx = await _appOnly.CreateContextAsync(profile, site.Url, ct);
bySite.Add((site.Title, await scan(ctx, site)));
}
return bySite;
}
switch (schedule.Type)
{
case ReportType.Permissions:
{
var opts = new ScanOptions(o.IncludeInherited, o.ScanFolders, o.FolderDepth, o.IncludeSubsites);
var bySite = await ScanSites<PermissionEntry>((ctx, _) => _perm.ScanSiteAsync(ctx, opts, progress, ct));
return ReportMergeHelper.Build(bySite, mode, "permissions", ts, fmt,
fmt == ReportFormat.Csv ? rs => _permCsv.BuildCsv(rs) : rs => _permHtml.BuildHtml(rs, branding));
}
case ReportType.Storage:
{
var opts = new StorageScanOptions(o.PerLibrary, o.IncludeSubsites, o.FolderDepth,
o.IncludeHiddenLibraries, o.IncludePreservationHold, o.IncludeListAttachments, o.IncludeRecycleBin);
var bySite = await ScanSites<StorageNode>((ctx, _) => _storage.CollectStorageAsync(ctx, opts, progress, ct));
return ReportMergeHelper.Build(bySite, mode, "storage", ts, fmt,
fmt == ReportFormat.Csv ? rs => _storageCsv.BuildCsv(rs) : rs => _storageHtml.BuildHtml(rs, branding));
}
case ReportType.Duplicates:
{
var opts = new DuplicateScanOptions(o.DuplicateMode, o.MatchSize, o.MatchCreated, o.MatchModified,
o.MatchSubfolderCount, o.MatchFileCount, o.IncludeSubsites, o.Library);
var bySite = await ScanSites<DuplicateGroup>((ctx, _) => _dup.ScanDuplicatesAsync(ctx, opts, progress, ct));
return ReportMergeHelper.Build(bySite, mode, "duplicates", ts, fmt,
fmt == ReportFormat.Csv ? rs => _dupCsv.BuildCsv(rs) : rs => _dupHtml.BuildHtml(rs, branding));
}
case ReportType.Search:
{
var bySite = await ScanSites<SearchResult>((ctx, site) =>
{
var opts = new SearchOptions(o.Extensions.ToArray(), o.Regex,
null, null, null, null, null, null, o.Library, o.MaxResults, site.Url);
return _search.SearchFilesAsync(ctx, opts, progress, ct);
});
return ReportMergeHelper.Build(bySite, mode, "search", ts, fmt,
fmt == ReportFormat.Csv ? rs => _searchCsv.BuildCsv(rs) : rs => _searchHtml.BuildHtml(rs, branding));
}
case ReportType.UserAccess:
{
var opts = new ScanOptions(o.IncludeInherited, o.ScanFolders, o.FolderDepth, o.IncludeSubsites);
var targets = o.TargetUserLogins
.Select(l => l.Trim().ToLowerInvariant())
.Where(l => l.Length > 0).ToHashSet();
var bySite = await ScanSites<UserAccessEntry>(async (ctx, site) =>
{
var permEntries = await _perm.ScanSiteAsync(ctx, opts, progress, ct);
// Expand SharePoint group membership so group-granted access is attributed to
// the target user (otherwise the scan only sees the group principal, not the user).
var groupMembers = await UserAccessAuditService.ResolveGroupMembersAsync(
_groupResolver, ctx, profile, permEntries, ct);
return UserAccessAuditService.TransformEntries(permEntries, targets, site, groupMembers).ToList();
});
return ReportMergeHelper.Build(bySite, mode, "user_audit", ts, fmt,
fmt == ReportFormat.Csv
? rs => _uaCsv.BuildCsv(rs.FirstOrDefault()?.UserDisplayName ?? "Users", rs.FirstOrDefault()?.UserLogin ?? "", rs)
: rs => _uaHtml.BuildHtml(rs, mergePermissions: false, branding: branding));
}
case ReportType.VersionCleanup:
{
// Destructive: this DELETES old file versions. No CSV exporter exists, so the
// output is always the HTML summary of what was removed.
var opts = new VersionCleanupOptions(o.LibraryTitles, o.KeepLast, o.KeepFirst);
var bySite = await ScanSites<VersionCleanupResult>((ctx, _) => _version.DeleteOldVersionsAsync(ctx, opts, progress, ct));
return ReportMergeHelper.Build(bySite, mode, "versions", ts, ReportFormat.Html,
rs => _versionHtml.BuildHtml(rs, branding));
}
default:
throw new NotSupportedException($"Report type {schedule.Type} is not supported.");
}
}
private async Task<GeneratedReport> FailAsync(ScheduledReport schedule, string profileName, string error)
{
var report = new GeneratedReport
{
ProfileId = schedule.ProfileId,
ScheduledReportId = schedule.Id,
Type = schedule.Type,
Name = schedule.Name,
Status = ReportRunStatus.Failed,
Error = error
};
await _index.AddAsync(report);
await AuditAsync(profileName, schedule, ReportRunStatus.Failed, error);
return report;
}
private Task AuditAsync(string profileName, ScheduledReport schedule, ReportRunStatus status, string details)
=> _audit.AppendAsync(new AuditEntry
{
Action = "ScheduledReport",
ClientName = profileName,
Sites = new List<string>(),
Details = $"{status}: {schedule.Name} — {details}",
UserEmail = "system",
UserDisplay = "Scheduler",
UserRole = UserRole.Admin
});
}
@@ -0,0 +1,78 @@
using System.Collections.Concurrent;
namespace SharepointToolbox.Web.Services.Reports;
/// <summary>
/// Process-wide coordinator for scheduled-report execution. Covers two operator needs:
/// • <b>Cancel an in-flight run</b> — every active run registers a linked
/// <see cref="CancellationTokenSource"/> keyed by schedule id, so the UI (or a
/// global stop) can abort a report that is currently executing, whether it was
/// started by the scheduler or by "Run now".
/// • <b>Pause future runs</b> — a global flag the background scheduler honours,
/// letting an admin suspend all cadence-triggered runs at once without toggling
/// each schedule's <c>Enabled</c> flag.
///
/// In-memory and singleton. The pause flag does NOT survive a process restart (a
/// restart resumes the scheduler); per-schedule <c>Enabled</c> flags persist and are
/// the durable way to keep a schedule off.
/// </summary>
public sealed class ScheduledRunCoordinator
{
private readonly ConcurrentDictionary<string, CancellationTokenSource> _active = new();
private volatile bool _paused;
/// <summary>True while the scheduler is globally paused (no schedules fire).</summary>
public bool IsPaused => _paused;
public void Pause() => _paused = true;
public void Resume() => _paused = false;
/// <summary>True while a run is registered for this schedule id.</summary>
public bool IsRunning(string scheduleId) => _active.ContainsKey(scheduleId);
/// <summary>Snapshot of schedule ids with a run in progress.</summary>
public IReadOnlyCollection<string> RunningIds => _active.Keys.ToArray();
/// <summary>
/// Registers a run for <paramref name="scheduleId"/> and returns a token that trips
/// when either the caller's <paramref name="linked"/> token (e.g. app shutdown) or a
/// <see cref="Cancel"/>/<see cref="CancelAll"/> call fires. Returns <c>null</c> if a
/// run is already registered for this schedule — callers must skip to avoid overlap.
/// Always pair a non-null return with <see cref="Complete"/> in a <c>finally</c>.
/// </summary>
public CancellationToken? TryBegin(string scheduleId, CancellationToken linked)
{
var cts = CancellationTokenSource.CreateLinkedTokenSource(linked);
if (!_active.TryAdd(scheduleId, cts)) { cts.Dispose(); return null; }
return cts.Token;
}
/// <summary>Deregisters and disposes the run for this schedule id.</summary>
public void Complete(string scheduleId)
{
if (_active.TryRemove(scheduleId, out var cts)) cts.Dispose();
}
/// <summary>Signals cancellation to the run for this schedule id. Returns false if none.</summary>
public bool Cancel(string scheduleId)
{
if (_active.TryGetValue(scheduleId, out var cts))
{
try { cts.Cancel(); return true; }
catch (ObjectDisposedException) { return false; } // completed between lookup and cancel
}
return false;
}
/// <summary>Signals cancellation to every run in progress. Returns the count signalled.</summary>
public int CancelAll()
{
int n = 0;
foreach (var cts in _active.Values)
{
try { cts.Cancel(); n++; }
catch (ObjectDisposedException) { /* completed concurrently */ }
}
return n;
}
}
+60 -3
View File
@@ -63,7 +63,11 @@ public class SharePointGroupResolver : ISharePointGroupResolver
{ {
graphClient ??= await _graphClientFactory.CreateClientAsync(profile); graphClient ??= await _graphClientFactory.CreateClientAsync(profile);
var aadId = ExtractAadGroupId(user.LoginName); var aadId = ExtractAadGroupId(user.LoginName);
var leafUsers = await ResolveAadGroupAsync(graphClient, aadId, ct); // M365 (group-connected) sites add the group's OWNERS claim ("…_o") to the
// site Owners SP group; resolve owners for those, transitive members otherwise.
var leafUsers = IsM365GroupOwnersClaim(user.LoginName)
? await ResolveAadGroupOwnersAsync(graphClient, aadId, ct)
: await ResolveAadGroupAsync(graphClient, aadId, ct);
members.AddRange(leafUsers); members.AddRange(leafUsers);
} }
else else
@@ -83,10 +87,27 @@ public class SharePointGroupResolver : ISharePointGroupResolver
return result; return result;
} }
// Group principals that must be expanded via Graph:
// c:0t.c|tenant|<guid> → AAD security group
// c:0o.c|federateddirectoryclaimprovider|<guid> → M365 group members (group-connected/Teams sites)
// c:0o.c|federateddirectoryclaimprovider|<guid>_o → M365 group owners
// The M365 cases are how modern group-connected sites grant access; without expanding them a
// user who is "just a member of the site" never appears in a user-centric audit.
internal static bool IsAadGroup(string login) => internal static bool IsAadGroup(string login) =>
login.StartsWith("c:0t.c|", StringComparison.OrdinalIgnoreCase); login.StartsWith("c:0t.c|", StringComparison.OrdinalIgnoreCase) ||
login.StartsWith("c:0o.c|", StringComparison.OrdinalIgnoreCase);
internal static bool IsM365GroupOwnersClaim(string login) =>
login.StartsWith("c:0o.c|", StringComparison.OrdinalIgnoreCase) &&
login.EndsWith("_o", StringComparison.OrdinalIgnoreCase);
// Last claim segment is the group GUID; M365 owners claims append "_o" — strip it.
internal static string ExtractAadGroupId(string login)
{
var id = login[(login.LastIndexOf('|') + 1)..];
return id.EndsWith("_o", StringComparison.OrdinalIgnoreCase) ? id[..^2] : id;
}
internal static string ExtractAadGroupId(string login) => login[(login.LastIndexOf('|') + 1)..];
internal static string StripClaims(string login) => login[(login.LastIndexOf('|') + 1)..]; internal static string StripClaims(string login) => login[(login.LastIndexOf('|') + 1)..];
private static async Task<IEnumerable<ResolvedMember>> ResolveAadGroupAsync( private static async Task<IEnumerable<ResolvedMember>> ResolveAadGroupAsync(
@@ -122,4 +143,40 @@ public class SharePointGroupResolver : ISharePointGroupResolver
return Enumerable.Empty<ResolvedMember>(); return Enumerable.Empty<ResolvedMember>();
} }
} }
// M365 group owners (the "…_o" claim). Owners are a direct, non-nested collection, so no
// transitive expansion is needed — owners cannot themselves be groups.
private static async Task<IEnumerable<ResolvedMember>> ResolveAadGroupOwnersAsync(
GraphServiceClient graphClient, string aadGroupId, CancellationToken ct)
{
try
{
var response = await graphClient.Groups[aadGroupId].Owners.GraphUser.GetAsync(config =>
{
config.QueryParameters.Select = new[] { "displayName", "userPrincipalName" };
config.QueryParameters.Top = 999;
}, ct);
if (response?.Value is null) return Enumerable.Empty<ResolvedMember>();
var owners = new List<ResolvedMember>();
var iter = PageIterator<GraphUser, GraphUserCollectionResponse>.CreatePageIterator(
graphClient, response,
user =>
{
if (ct.IsCancellationRequested) return false;
owners.Add(new ResolvedMember(
user.DisplayName ?? user.UserPrincipalName ?? "Unknown",
user.UserPrincipalName ?? string.Empty));
return true;
});
await iter.IterateAsync(ct);
return owners;
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
Log.Warning("Could not resolve AAD group '{Id}' owners: {Error}", aadGroupId, ex.Message);
return Enumerable.Empty<ResolvedMember>();
}
}
} }
+38 -54
View File
@@ -1,23 +1,38 @@
using Microsoft.Graph; using SharepointToolbox.Web.Core.Helpers;
using Microsoft.Graph.Models;
using Microsoft.Kiota.Abstractions;
using SharepointToolbox.Web.Core.Models; using SharepointToolbox.Web.Core.Models;
using AppGraphClientFactory = SharepointToolbox.Web.Infrastructure.Auth.GraphClientFactory; using SharepointToolbox.Web.Infrastructure.Auth;
using SharepointToolbox.Web.Services.Session;
namespace SharepointToolbox.Web.Services; namespace SharepointToolbox.Web.Services;
/// <summary> /// <summary>
/// Delegated Graph implementation of <see cref="ISiteDiscoveryService"/>. /// Enumerates every site collection in a tenant via the SharePoint tenant-admin endpoint
/// Uses the <c>/sites?search=*</c> endpoint, paging through every result. /// (<c>Tenant.GetSitePropertiesFromSharePointByFilters</c>), paging through all results.
/// Requires the delegated <c>Sites.Read.All</c> scope. /// The auth model only changes how the admin-host context is built:
///
/// • Certificate (app-only) profiles build the admin context through the cert factory — the
/// same path the background report scheduler uses (<see cref="Services.Reports"/>), which
/// relies only on the SharePoint <c>Sites.FullControl.All</c> application permission the cert
/// app already holds. (The earlier Graph <c>/sites/getAllSites</c> path was dropped: it needs
/// a separate Graph <c>Sites.Read.All</c> grant the cert app is not provisioned with, so it
/// returned empty/403 and tenant-wide audits silently fell back to the root site alone.)
/// • Delegated profiles build the admin context through the session manager; this requires the
/// signed-in user to be a SharePoint administrator.
///
/// The Graph <c>/sites?search=*</c> endpoint was deliberately abandoned for both: it ranks by
/// relevance and is capped server-side, silently dropping sites and returning varying counts.
/// </summary> /// </summary>
public class SiteDiscoveryService : ISiteDiscoveryService public class SiteDiscoveryService : ISiteDiscoveryService
{ {
private readonly AppGraphClientFactory _graphClientFactory; private readonly ISessionManager _sessionManager;
private readonly IAppOnlyContextFactory _appOnly;
public SiteDiscoveryService(AppGraphClientFactory graphClientFactory) public SiteDiscoveryService(
ISessionManager sessionManager,
IAppOnlyContextFactory appOnly)
{ {
_graphClientFactory = graphClientFactory; _sessionManager = sessionManager;
_appOnly = appOnly;
} }
public async Task<IReadOnlyList<SiteInfo>> SearchSitesAsync( public async Task<IReadOnlyList<SiteInfo>> SearchSitesAsync(
@@ -25,52 +40,21 @@ public class SiteDiscoveryService : ISiteDiscoveryService
string? query = null, string? query = null,
CancellationToken ct = default) CancellationToken ct = default)
{ {
var graphClient = await _graphClientFactory.CreateClientAsync(profile); ArgumentException.ThrowIfNullOrEmpty(profile.TenantUrl);
// "*" is the Graph convention for "return all sites".
var search = string.IsNullOrWhiteSpace(query) ? "*" : query!;
// The typed Sites.GetAsync maps its Search property to OData "$search", var adminUrl = TenantSiteEnumerator.BuildAdminUrl(profile.TenantUrl);
// which routes "*" through KQL and fails ("'*' is not valid at position 0").
// The all-sites wildcard only works via the bare, non-OData "search" // App-only profiles: build the admin-host context through the cert factory (matches the
// query parameter, so build the request manually. // scheduler), enumerating under the SharePoint app permission the cert already grants.
var requestInfo = new RequestInformation if (_appOnly.IsConfigured(profile))
{ {
HttpMethod = Method.GET, using var adminCtx = await _appOnly.CreateContextAsync(profile, adminUrl, ct);
UrlTemplate = "{+baseurl}/sites{?search,%24top}", return await TenantSiteEnumerator.EnumerateAsync(adminCtx, ct);
PathParameters = new Dictionary<string, object> }
{
{ "baseurl", graphClient.RequestAdapter.BaseUrl ?? "https://graph.microsoft.com/v1.0" }
},
};
requestInfo.QueryParameters.Add("search", search);
requestInfo.QueryParameters.Add("%24top", 999);
requestInfo.Headers.Add("Accept", "application/json");
var response = await graphClient.RequestAdapter.SendAsync<SiteCollectionResponse>( // Delegated profiles: enumeration only exists on the tenant admin endpoint.
requestInfo, SiteCollectionResponse.CreateFromDiscriminatorValue, cancellationToken: ct); var adminProfile = profile.CloneForSite(adminUrl);
var ctx = await _sessionManager.GetOrCreateContextAsync(adminProfile, ct);
if (response is null) return Array.Empty<SiteInfo>(); return await TenantSiteEnumerator.EnumerateAsync(ctx, ct);
var results = new List<SiteInfo>();
var iter = PageIterator<Site, SiteCollectionResponse>.CreatePageIterator(
graphClient, response,
site =>
{
if (ct.IsCancellationRequested) return false;
var url = site.WebUrl ?? string.Empty;
if (string.IsNullOrEmpty(url)) return true;
// Skip OneDrive personal sites — not useful for these scans.
if (url.Contains("/personal/", StringComparison.OrdinalIgnoreCase)) return true;
var title = site.DisplayName ?? site.Name ?? url;
results.Add(new SiteInfo(url, title));
return true;
});
await iter.IterateAsync(ct);
return results
.GroupBy(s => s.Url, StringComparer.OrdinalIgnoreCase)
.Select(g => g.First())
.OrderBy(s => s.Title, StringComparer.OrdinalIgnoreCase)
.ToList();
} }
} }
+139 -32
View File
@@ -7,19 +7,27 @@ public class UserAccessAuditService : IUserAccessAuditService
{ {
private readonly IPermissionsService _permissionsService; private readonly IPermissionsService _permissionsService;
private readonly IElevationCoordinator _elevation; private readonly IElevationCoordinator _elevation;
private readonly ISharePointGroupResolver _groupResolver;
private static readonly HashSet<string> HighPrivilegeLevels = new(StringComparer.OrdinalIgnoreCase) private static readonly HashSet<string> HighPrivilegeLevels = new(StringComparer.OrdinalIgnoreCase)
{ {
"Full Control", "Site Collection Administrator" "Full Control", "Site Collection Administrator"
}; };
public UserAccessAuditService(IPermissionsService permissionsService, IElevationCoordinator elevation) private static readonly IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>> NoGroupMembers =
new Dictionary<string, IReadOnlyList<ResolvedMember>>();
public UserAccessAuditService(
IPermissionsService permissionsService,
IElevationCoordinator elevation,
ISharePointGroupResolver groupResolver)
{ {
_permissionsService = permissionsService; _permissionsService = permissionsService;
_elevation = elevation; _elevation = elevation;
_groupResolver = groupResolver;
} }
public async Task<IReadOnlyList<UserAccessEntry>> AuditUsersAsync( public async Task<UserAccessAuditResult> AuditUsersAsync(
ISessionManager sessionManager, ISessionManager sessionManager,
TenantProfile currentProfile, TenantProfile currentProfile,
IReadOnlyList<string> targetUserLogins, IReadOnlyList<string> targetUserLogins,
@@ -32,10 +40,16 @@ public class UserAccessAuditService : IUserAccessAuditService
.Select(l => l.Trim().ToLowerInvariant()) .Select(l => l.Trim().ToLowerInvariant())
.Where(l => l.Length > 0).ToHashSet(); .Where(l => l.Length > 0).ToHashSet();
if (targets.Count == 0) return Array.Empty<UserAccessEntry>(); if (targets.Count == 0) return new UserAccessAuditResult(Array.Empty<UserAccessEntry>(), 0, 0, 0);
var allEntries = new List<UserAccessEntry>(); var allEntries = new List<UserAccessEntry>();
// Per-site resilience: when auditing many sites (e.g. the whole tenant), one bad site
// must not abort the run. Access-denied is the expected "tech has no access here" case
// and is skipped quietly; any other error is skipped but counted as a failure so it can
// be surfaced. Cancellation always propagates.
int deniedSites = 0, failedSites = 0;
for (int i = 0; i < sites.Count; i++) for (int i = 0; i < sites.Count; i++)
{ {
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
@@ -43,14 +57,11 @@ public class UserAccessAuditService : IUserAccessAuditService
progress.Report(new OperationProgress(i, sites.Count, progress.Report(new OperationProgress(i, sites.Count,
$"Scanning site {i + 1}/{sites.Count}: {site.Title}...")); $"Scanning site {i + 1}/{sites.Count}: {site.Title}..."));
var profile = new TenantProfile var profile = currentProfile.CloneForSite(site.Url);
{ profile.Name = site.Title;
TenantUrl = site.Url,
TenantId = currentProfile.TenantId,
ClientId = currentProfile.ClientId,
Name = site.Title
};
try
{
// Auto-elevates site-collection admin ownership and retries when a scan is denied, // Auto-elevates site-collection admin ownership and retries when a scan is denied,
// if the AutoTakeOwnership setting is enabled (otherwise the access-denied propagates). // if the AutoTakeOwnership setting is enabled (otherwise the access-denied propagates).
var permEntries = await _elevation.RunAsync(async c => var permEntries = await _elevation.RunAsync(async c =>
@@ -59,48 +70,144 @@ public class UserAccessAuditService : IUserAccessAuditService
return await _permissionsService.ScanSiteAsync(ctx, options, progress, c); return await _permissionsService.ScanSiteAsync(ctx, options, progress, c);
}, ct); }, ct);
allEntries.AddRange(TransformEntries(permEntries, targets, site)); // Most users get access through SharePoint group membership, not direct grants.
// The scan records the group as a single principal, so the group's members must be
// expanded (including nested AAD groups, via the resolver) for a user-centric audit
// to attribute that access to the target — without it the audit finds nothing.
var siteCtx = await sessionManager.GetOrCreateContextAsync(profile, ct);
var groupMembers = await ResolveGroupMembersAsync(_groupResolver, siteCtx, profile, permEntries, ct);
allEntries.AddRange(TransformEntries(permEntries, targets, site, groupMembers));
}
catch (OperationCanceledException)
{
throw;
}
catch (SharePointAccessDeniedException)
{
// No access to this site (and elevation could not / was not allowed to fix it).
// Expected when scanning the whole tenant under a delegated identity — skip.
deniedSites++;
}
catch (Exception)
{
// Transient/throttling/malformed-site error — skip and keep going.
failedSites++;
}
} }
progress.Report(new OperationProgress(sites.Count, sites.Count, var summary = $"Audit complete: {allEntries.Count} access entries found.";
$"Audit complete: {allEntries.Count} access entries found.")); if (deniedSites > 0) summary += $" {deniedSites} site(s) skipped (no access).";
return allEntries; if (failedSites > 0) summary += $" {failedSites} site(s) failed.";
progress.Report(new OperationProgress(sites.Count, sites.Count, summary));
return new UserAccessAuditResult(allEntries, sites.Count, deniedSites, failedSites);
} }
private static IEnumerable<UserAccessEntry> TransformEntries( /// <summary>
IReadOnlyList<PermissionEntry> permEntries, HashSet<string> targets, SiteInfo site) /// Resolves every SharePoint group referenced by <paramref name="permEntries"/> to its
/// member set (expanding nested AAD groups via Graph), so group-granted access can be
/// attributed to the target user. Returns an empty map if there are no group principals
/// or resolution fails (the audit then falls back to direct grants only rather than abort).
/// Shared by the interactive audit and the background report scheduler.
/// </summary>
public static async Task<IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>> ResolveGroupMembersAsync(
ISharePointGroupResolver resolver,
Microsoft.SharePoint.Client.ClientContext ctx,
TenantProfile profile,
IReadOnlyList<PermissionEntry> permEntries,
CancellationToken ct)
{
var groupNames = permEntries
.Where(e => string.Equals(e.PrincipalType, "SharePointGroup", StringComparison.OrdinalIgnoreCase))
.Select(e => e.Users) // group title; matches GrantedThrough "SharePoint Group: {title}"
.Where(n => !string.IsNullOrWhiteSpace(n))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (groupNames.Count == 0) return NoGroupMembers;
try
{
return await resolver.ResolveGroupsAsync(ctx, profile, groupNames, ct);
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
Serilog.Log.Warning("User-access audit: SP group expansion failed on {Url}: {Error}", ctx.Url, ex.Message);
return NoGroupMembers;
}
}
/// <summary>
/// Projects raw permission entries for one site into per-user access entries, keeping only
/// rows touching one of <paramref name="targets"/> (substring match on login). Direct user
/// grants match the principal's own login; SharePoint-group grants match against the group's
/// expanded membership in <paramref name="groupMembers"/> (see <see cref="ResolveGroupMembersAsync"/>),
/// so access held through a group is attributed to its members. Exposed for the background
/// report scheduler, which reuses this projection under app-only auth.
/// </summary>
internal static IEnumerable<UserAccessEntry> TransformEntries(
IReadOnlyList<PermissionEntry> permEntries, HashSet<string> targets, SiteInfo site,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
{ {
foreach (var entry in permEntries) foreach (var entry in permEntries)
{ {
var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
var names = entry.Users.Split(';', StringSplitOptions.RemoveEmptyEntries);
var permLevels = entry.PermissionLevels.Split(';', StringSplitOptions.RemoveEmptyEntries); var permLevels = entry.PermissionLevels.Split(';', StringSplitOptions.RemoveEmptyEntries);
UserAccessEntry Build(string displayName, string login, AccessType accessType, string level) =>
new(displayName, StripClaimsPrefix(login),
site.Url, site.Title,
entry.ObjectType, entry.Title, entry.Url,
level, accessType, entry.GrantedThrough,
HighPrivilegeLevels.Contains(level),
PermissionEntryHelper.IsExternalUser(login),
entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType);
bool isGroup = string.Equals(entry.PrincipalType, "SharePointGroup", StringComparison.OrdinalIgnoreCase);
if (isGroup)
{
// Group principal: match the target against the group's expanded members. Without a
// resolved map (or an unknown group) there is no user to attribute access to — skip.
if (groupMembers is null
|| string.IsNullOrEmpty(entry.Users)
|| !groupMembers.TryGetValue(entry.Users, out var members))
continue;
var accessType = entry.HasUniquePermissions ? AccessType.Group : AccessType.Inherited;
foreach (var m in members)
{
var loginLower = m.Login.ToLowerInvariant();
if (string.IsNullOrEmpty(loginLower)) continue;
if (!targets.Any(t => loginLower.Contains(t) || t.Contains(loginLower))) continue;
foreach (var level in permLevels)
{
var trimmed = level.Trim();
if (string.IsNullOrEmpty(trimmed)) continue;
yield return Build(m.DisplayName, m.Login, accessType, trimmed);
}
}
continue;
}
// Direct / external user grant (also the joined site-collection-admins entry).
var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
var names = entry.Users.Split(';', StringSplitOptions.RemoveEmptyEntries);
for (int u = 0; u < logins.Length; u++) for (int u = 0; u < logins.Length; u++)
{ {
var login = logins[u].Trim(); var login = logins[u].Trim();
var loginLower = login.ToLowerInvariant(); var loginLower = login.ToLowerInvariant();
var displayName = u < names.Length ? names[u].Trim() : login; var displayName = u < names.Length ? names[u].Trim() : login;
bool isTarget = targets.Any(t => loginLower.Contains(t) || t.Contains(loginLower)); if (!targets.Any(t => loginLower.Contains(t) || t.Contains(loginLower))) continue;
if (!isTarget) continue;
var accessType = !entry.HasUniquePermissions ? AccessType.Inherited
: entry.GrantedThrough.StartsWith("SharePoint Group:", StringComparison.OrdinalIgnoreCase)
? AccessType.Group : AccessType.Direct;
var accessType = entry.HasUniquePermissions ? AccessType.Direct : AccessType.Inherited;
foreach (var level in permLevels) foreach (var level in permLevels)
{ {
var trimmed = level.Trim(); var trimmed = level.Trim();
if (string.IsNullOrEmpty(trimmed)) continue; if (string.IsNullOrEmpty(trimmed)) continue;
yield return new UserAccessEntry( yield return Build(displayName, login, accessType, trimmed);
displayName, StripClaimsPrefix(login),
site.Url, site.Title,
entry.ObjectType, entry.Title, entry.Url,
trimmed, accessType, entry.GrantedThrough,
HighPrivilegeLevels.Contains(trimmed),
PermissionEntryHelper.IsExternalUser(login),
entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType);
} }
} }
} }
+4 -1
View File
@@ -14,7 +14,10 @@ services:
- DataFolder=/data - DataFolder=/data
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/"] # /account/login is anonymous and returns 200 (the app root now 302-redirects
# unauthenticated users, which would read as unhealthy). curl is installed in
# the image; -f fails on >=400.
test: ["CMD", "curl", "-fsS", "http://localhost:8080/account/login"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

+37 -22
View File
@@ -1,35 +1,35 @@
:root { :root {
--sidebar-width: 248px; --sidebar-width: 248px;
--sidebar-collapsed-width: 78px; --sidebar-collapsed-width: 78px;
--bg: #eef0f7; --bg: #eff6ff;
--page-bg: #eef0f7; --page-bg: #eff6ff;
--sidebar-bg: #ffffff; --sidebar-bg: #ffffff;
--sidebar-text: #3f4254; --sidebar-text: #3f4254;
--sidebar-muted: #8a8d9b; --sidebar-muted: #8a8d9b;
--sidebar-hover: #f2f3f9; --sidebar-hover: #eff6ff;
--sidebar-accent: #5b5bd6; --sidebar-accent: #006cd2;
--sidebar-active: #5b5bd6; --sidebar-active: #006cd2;
--card-bg: #fff; --card-bg: #fff;
--surface-hover: #f2f3f9; --surface-hover: #eff6ff;
--th-bg: #f4f5fb; --th-bg: #eff6ff;
--input-bg: #fff; --input-bg: #fff;
--border: #e6e7f0; --border: #d8e6f5;
--accent: #5b5bd6; --accent: #006cd2;
--accent-dark: #4a4ac0; --accent-dark: #092c55;
--accent-soft: rgba(91,91,214,.12); --accent-soft: rgba(0,108,210,.12);
--danger: #d13438; --danger: #d13438;
--success: #107c10; --success: #107c10;
--warn: #797673; --warn: #fea20a;
--text: #323130; --text: #092c55;
--text-muted: #605e5c; --text-muted: #5a6b80;
--surface-2: #2d2d4e; --surface-2: #092c55;
--font: 'Segoe UI', system-ui, sans-serif; --font: 'Segoe UI', system-ui, sans-serif;
/* shape + depth — match sidebar */ /* shape + depth — match sidebar */
--radius-lg: 20px; --radius-lg: 20px;
--radius-md: 12px; --radius-md: 12px;
--radius-sm: 10px; --radius-sm: 10px;
--shadow-card: 0 10px 34px rgba(30,30,70,.10); --shadow-card: 0 10px 34px rgba(30,30,70,.10);
--shadow-soft: 0 6px 16px rgba(91,91,214,.22); --shadow-soft: 0 6px 16px rgba(0,108,210,.22);
} }
*, *::before, *::after { box-sizing: border-box; } *, *::before, *::after { box-sizing: border-box; }
@@ -69,10 +69,8 @@ body {
} }
.logo { display: flex; align-items: center; gap: 11px; overflow: hidden; } .logo { display: flex; align-items: center; gap: 11px; overflow: hidden; }
.logo-mark { .logo-mark {
width: 38px; height: 38px; flex-shrink: 0; border-radius: 11px; width: 38px; height: 38px; flex-shrink: 0;
background: linear-gradient(135deg, #6d6df0, #5b5bd6); object-fit: contain;
color: #fff; font-weight: 700; font-size: 14px; letter-spacing: .5px;
display: flex; align-items: center; justify-content: center;
} }
.logo-text { font-weight: 700; font-size: 16px; color: var(--sidebar-text); white-space: nowrap; } .logo-text { font-weight: 700; font-size: 16px; color: var(--sidebar-text); white-space: nowrap; }
.toggle-btn { .toggle-btn {
@@ -135,7 +133,7 @@ body {
.nav-item:hover { background: var(--sidebar-hover); } .nav-item:hover { background: var(--sidebar-hover); }
.nav-item.active { .nav-item.active {
background: var(--sidebar-accent); color: #fff; background: var(--sidebar-accent); color: #fff;
box-shadow: 0 6px 16px rgba(91,91,214,.35); box-shadow: 0 6px 16px rgba(0,108,210,.35);
} }
.nav-icon { font-size: 16px; min-width: 22px; text-align: center; } .nav-icon { font-size: 16px; min-width: 22px; text-align: center; }
.nav-label { overflow: hidden; text-overflow: ellipsis; } .nav-label { overflow: hidden; text-overflow: ellipsis; }
@@ -373,9 +371,26 @@ body {
100% { margin-left: 100%; } 100% { margin-left: 100%; }
} }
/* ── User→Sites access drill-down ── */
.site-drill { border: 1px solid var(--border); border-radius: var(--radius-md); overflow: hidden; margin-bottom: 8px; background: var(--card-bg); }
.site-drill-header {
display: flex; align-items: center; gap: 10px; width: 100%;
padding: 11px 14px; border: none; background: none; cursor: pointer;
font-family: inherit; font-size: 13.5px; text-align: left; color: var(--text);
transition: background .12s;
}
.site-drill-header:hover { background: var(--surface-hover); }
.site-drill.open .site-drill-header { background: var(--surface-hover); }
.drill-caret { color: var(--text-muted); font-size: 11px; transition: transform .15s; flex-shrink: 0; }
.drill-caret.open { transform: rotate(90deg); }
.drill-title { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.drill-url { font-size: 11px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.site-drill-body { border-top: 1px solid var(--border); }
.site-drill-body .data-table-wrap { border: none; border-radius: 0; }
/* ── Feature cards (Home) ── */ /* ── Feature cards (Home) ── */
.feature-card { cursor: pointer; transition: box-shadow .15s, transform .15s; } .feature-card { cursor: pointer; transition: box-shadow .15s, transform .15s; }
.feature-card:hover { box-shadow: 0 14px 36px rgba(91, 91, 214, .22); transform: translateY(-2px); } .feature-card:hover { box-shadow: 0 14px 36px rgba(0, 108, 210, .22); transform: translateY(-2px); }
/* ── Visual folder-structure builder ── */ /* ── Visual folder-structure builder ── */
.folder-builder { display: flex; flex-direction: column; gap: 6px; padding: 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); } .folder-builder { display: flex; flex-direction: column; gap: 6px; padding: 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); }
Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

+190
View File
@@ -19,3 +19,193 @@ window.sptb = {
if (el) el.scrollTop = el.scrollHeight; if (el) el.scrollTop = el.scrollHeight;
} }
}; };
// ── Easter eggs: N spaces in a row → slow full-screen fade-in ──
(function () {
// threshold (consecutive spaces) → image file in wwwroot
var EGGS = [
{ at: 5, src: 'seb-egg.jpg' },
{ at: 10, src: 'easter-egg.jpg' }
];
var count = 0;
function typingInField(t) {
if (!t) return false;
var tag = t.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || t.isContentEditable;
}
function reveal(src) {
var id = 'sptb-egg-' + src.replace(/[^a-z0-9]/gi, '-');
if (document.getElementById(id)) return; // this egg already showing
var ov = document.createElement('div');
ov.id = id;
ov.style.cssText =
'position:fixed;inset:0;z-index:99999;background-color:#000;' +
'background-image:url("' + src + '");background-size:100% 100%;' +
'background-repeat:no-repeat;background-position:center;' +
'opacity:0;transition:opacity 10s ease;cursor:pointer';
ov.addEventListener('click', function () { ov.remove(); });
document.body.appendChild(ov);
void ov.offsetWidth; // force reflow so transition runs
ov.style.opacity = '1';
}
window.addEventListener('keydown', function (e) {
var isSpace = e.code === 'Space' || e.key === ' ' || e.keyCode === 32;
if (isSpace && !typingInField(e.target)) {
count++;
for (var i = 0; i < EGGS.length; i++) {
if (count === EGGS[i].at) { reveal(EGGS[i].src); break; }
}
} else if (!isSpace) {
count = 0;
}
}, true);
})();
// ── Easter egg: type "maze" → fake 3-level maze → screamer at the end ──
(function () {
var TRIGGER = 'maze';
var buf = '';
function typingInField(t) {
if (!t) return false;
var g = t.tagName;
return g === 'INPUT' || g === 'TEXTAREA' || t.isContentEditable;
}
// base maze: '#' wall, '.' path, 'P' start, 'G' goal. Solvable snake corridor.
var BASE = [
'#########',
'#P......#',
'#######.#',
'#.......#',
'#.#######',
'#.......#',
'#######.#',
'#......G#',
'#########'
];
function mirrorH(g) { return g.map(function (r) { return r.split('').reverse().join(''); }); }
function mirrorV(g) { return g.slice().reverse(); }
var LEVELS = [BASE, mirrorH(BASE), mirrorV(BASE)];
var active = false, lvl = 0, grid = null, px = 0, py = 0, rootEl = null, keyHandler = null;
function parse(g) {
for (var y = 0; y < g.length; y++)
for (var x = 0; x < g[y].length; x++)
if (g[y][x] === 'P') { px = x; py = y; }
}
function cellAt(x, y) { return grid[y] ? grid[y][x] : undefined; }
function render() {
var cols = grid[0].length, html = '';
for (var y = 0; y < grid.length; y++) {
for (var x = 0; x < grid[y].length; x++) {
var c = cellAt(x, y);
var bg = c === '#' ? '#222' : (c === 'G' ? '#1a7f37' : '#0b0b0b');
if (x === px && y === py) bg = '#e63946';
html += '<div style="background:' + bg + '"></div>';
}
}
var board = rootEl.querySelector('.mz-board');
board.style.gridTemplateColumns = 'repeat(' + cols + ',1fr)';
board.innerHTML = html;
rootEl.querySelector('.mz-lvl').textContent = 'Level ' + (lvl + 1) + ' / 3';
}
function move(dx, dy) {
var nx = px + dx, ny = py + dy, c = cellAt(nx, ny);
if (c === undefined || c === '#') return;
px = nx; py = ny;
if (c === 'G') { nextLevel(); return; }
render();
}
function nextLevel() {
lvl++;
if (lvl >= LEVELS.length) { end(); screamer(); return; }
grid = LEVELS[lvl].slice(); parse(grid); render();
}
function start() {
if (active) return;
active = true; lvl = 0; grid = LEVELS[0].slice(); parse(grid);
rootEl = document.createElement('div');
rootEl.id = 'sptb-maze';
rootEl.style.cssText = 'position:fixed;inset:0;z-index:99998;background:#000;display:flex;' +
'flex-direction:column;align-items:center;justify-content:center;font-family:monospace;color:#ddd;gap:12px';
rootEl.innerHTML =
'<div class="mz-lvl" style="font-size:18px;letter-spacing:1px"></div>' +
'<div class="mz-board" style="display:grid;width:min(80vmin,560px);aspect-ratio:1;gap:2px"></div>' +
'<div style="font-size:12px;color:#888">Arrows / WASD to move &bull; Esc to quit</div>';
document.body.appendChild(rootEl);
render();
keyHandler = function (e) {
var k = e.key;
if (k === 'Escape') { end(); return; }
var dx = 0, dy = 0;
if (k === 'ArrowUp' || k === 'w' || k === 'W') dy = -1;
else if (k === 'ArrowDown' || k === 's' || k === 'S') dy = 1;
else if (k === 'ArrowLeft' || k === 'a' || k === 'A') dx = -1;
else if (k === 'ArrowRight' || k === 'd' || k === 'D') dx = 1;
else return;
e.preventDefault(); move(dx, dy);
};
window.addEventListener('keydown', keyHandler, true);
}
function end() {
active = false;
if (keyHandler) { window.removeEventListener('keydown', keyHandler, true); keyHandler = null; }
if (rootEl) { rootEl.remove(); rootEl = null; }
}
function screamer() {
if (!document.getElementById('sptb-shake-css')) {
var st = document.createElement('style'); st.id = 'sptb-shake-css';
st.textContent = '@keyframes sptbShake{' +
'0%{transform:translate(4px,-4px) scale(1.05)}' +
'25%{transform:translate(-5px,3px) scale(1.08)}' +
'50%{transform:translate(3px,5px) scale(1.04)}' +
'75%{transform:translate(-4px,-3px) scale(1.07)}' +
'100%{transform:translate(4px,4px) scale(1.05)}}';
document.head.appendChild(st);
}
var ov = document.createElement('div');
ov.style.cssText = 'position:fixed;inset:0;z-index:100000;background:#000 center/cover no-repeat ' +
'url("screamer.jpg");animation:sptbShake .05s linear infinite;cursor:pointer';
document.body.appendChild(ov);
scream();
ov.addEventListener('click', function () { ov.remove(); });
setTimeout(function () { if (ov.parentNode) ov.remove(); }, 2500);
}
function scream() {
try {
var AC = window.AudioContext || window.webkitAudioContext; if (!AC) return;
var ac = new AC(), dur = 2.0, n = Math.floor(ac.sampleRate * dur);
var b = ac.createBuffer(1, n, ac.sampleRate), d = b.getChannelData(0);
for (var i = 0; i < n; i++) d[i] = Math.random() * 2 - 1; // white noise
var src = ac.createBufferSource(); src.buffer = b;
var ng = ac.createGain(); ng.gain.value = 0.9;
var osc = ac.createOscillator(); osc.type = 'sawtooth'; osc.frequency.value = 180;
var og = ac.createGain(); og.gain.value = 0.4;
src.connect(ng).connect(ac.destination);
osc.connect(og).connect(ac.destination);
src.start(); osc.start();
src.stop(ac.currentTime + dur); osc.stop(ac.currentTime + dur);
} catch (e) { /* audio blocked — image jump still fires */ }
}
window.addEventListener('keydown', function (e) {
if (active) return;
if (typingInField(e.target)) { buf = ''; return; }
var k = e.key;
if (!k || k.length !== 1) return;
buf = (buf + k.toLowerCase()).slice(-TRIGGER.length);
if (buf === TRIGGER) { buf = ''; start(); }
});
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB