Merge branch 'main' of https://git.azuze.fr/kawa/SharepointToolbox-Web
This commit is contained in:
@@ -64,3 +64,4 @@ data/logs/
|
||||
data/exports/
|
||||
data/templates/
|
||||
data/audit.jsonl
|
||||
data/appcerts/
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
@inject IUserContextAccessor UserContext
|
||||
@inject ISessionCredentialStore CredStore
|
||||
@inject ISessionManager SessionManager
|
||||
@inject SharepointToolbox.Web.Infrastructure.Auth.IAppOnlyContextFactory AppOnly
|
||||
@inject NavigationManager Nav
|
||||
@inject IJSRuntime JS
|
||||
@inject SharepointToolbox.Web.Services.OAuth.IOAuthFlowCache OAuthCache
|
||||
@@ -21,7 +22,7 @@
|
||||
<aside class="sidebar @(_sidebarCollapsed ? "collapsed" : "")">
|
||||
<div class="sidebar-header">
|
||||
<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>
|
||||
</div>
|
||||
<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">
|
||||
SP: @_credUsername
|
||||
@if (!CurrentProfileUsesCert)
|
||||
{
|
||||
<button class="btn btn-secondary btn-sm" style="padding:2px 6px;font-size:10px;margin-left:4px"
|
||||
@onclick="ReconnectAsync">@T["nav.reconnect"]</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -149,7 +153,9 @@
|
||||
new("/folder-structure", "📁", "tab.folderStructure", "nav.section.bulk", "profile"),
|
||||
new("/user-audit", "👤", "tab.userAccessAudit", "nav.section.audit", "profile"),
|
||||
new("/user-directory", "📖", "nav.userDirectory", "nav.section.audit", "profile"),
|
||||
new("/reports", "📑", "nav.reports", "nav.section.audit", "profile"),
|
||||
new("/templates", "📐", "tab.templates", "nav.section.config", "profile"),
|
||||
new("/scheduled-reports", "⏰", "nav.scheduledReports", "nav.section.admin", "admin"),
|
||||
new("/profiles", "⚙️", "nav.clientProfiles", "nav.section.admin", "admin"),
|
||||
new("/admin/users", "👥", "nav.userManagement", "nav.section.admin", "admin"),
|
||||
new("/admin/audit", "📋", "nav.auditLogs", "nav.section.admin", "admin"),
|
||||
@@ -219,11 +225,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
// If profile selected but no credentials → show modal
|
||||
if (Session.HasProfile && !_hasCredentials && _credModal is not null)
|
||||
// If profile selected but no credentials → show modal (cert profiles never prompt)
|
||||
if (Session.HasProfile && !_hasCredentials && !CurrentProfileUsesCert && _credModal is not null)
|
||||
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()
|
||||
{
|
||||
var uri = new Uri(Nav.Uri);
|
||||
@@ -256,6 +267,16 @@
|
||||
|
||||
private async Task RefreshCredentialState()
|
||||
{
|
||||
// Certificate-configured profiles need no session tokens — mark as connected
|
||||
// under the app identity and skip the delegated token bookkeeping entirely.
|
||||
if (CurrentProfileUsesCert)
|
||||
{
|
||||
_hasCredentials = true;
|
||||
_credUsername = $"{Session.CurrentProfile!.Name} ({T["nav.appIdentity"]})";
|
||||
await InvokeAsync(StateHasChanged);
|
||||
return;
|
||||
}
|
||||
|
||||
var tokens = await CredStore.GetAsync();
|
||||
|
||||
// Session tokens are tenant-bound (refresh token issued for the profile's TenantId/ClientId).
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
@foreach (var g in _results.Take(100))
|
||||
{
|
||||
<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>
|
||||
</div>
|
||||
@foreach (var item in g.Items)
|
||||
|
||||
@@ -3,13 +3,17 @@
|
||||
@inject IUserSessionService Session
|
||||
@inject IUserContextAccessor UserContext
|
||||
@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 NavigationManager Nav
|
||||
@inject SharepointToolbox.Web.Services.OAuth.IEntraDeviceCodeFlow DeviceFlow
|
||||
@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 TranslationSource T
|
||||
@rendermode InteractiveServer
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.WebUtilities
|
||||
@using SharepointToolbox.Web.Core.Models
|
||||
@using SharepointToolbox.Web.Services.Session
|
||||
@@ -24,7 +28,7 @@
|
||||
|
||||
@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>
|
||||
<div style="font-weight:600;font-size:15px">@p.Name</div>
|
||||
@@ -62,7 +66,7 @@
|
||||
|
||||
@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>
|
||||
<div style="font-weight:600;font-size:15px">@p.Name</div>
|
||||
@@ -86,7 +90,7 @@
|
||||
|
||||
@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>
|
||||
@if (!string.IsNullOrEmpty(_formError))
|
||||
{
|
||||
@@ -151,6 +155,64 @@
|
||||
<LogoUpload Value="_form.ClientLogo" ValueChanged="(LogoData? l) => _form.ClientLogo = l" />
|
||||
</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">
|
||||
<button class="btn btn-primary" @onclick="SaveProfile">@T["profile.save"]</button>
|
||||
<button class="btn btn-secondary" @onclick="CancelForm">@T["btn.cancel"]</button>
|
||||
@@ -171,10 +233,12 @@
|
||||
private string _regStatus = string.Empty;
|
||||
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 =
|
||||
"https://graph.microsoft.com/Application.ReadWrite.All " +
|
||||
"https://graph.microsoft.com/DelegatedPermissionGrant.ReadWrite.All " +
|
||||
"https://graph.microsoft.com/AppRoleAssignment.ReadWrite.All " +
|
||||
"https://graph.microsoft.com/Directory.Read.All " +
|
||||
"openid offline_access";
|
||||
|
||||
@@ -219,9 +283,19 @@
|
||||
private void EditProfile(TenantProfile 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;
|
||||
_formError = _pageError = string.Empty;
|
||||
_certPresent = CertStore.Exists(p.Id);
|
||||
_pfxBytes = null;
|
||||
_certPassword = string.Empty;
|
||||
_appOnlyStatus = string.Empty;
|
||||
}
|
||||
|
||||
private void CancelForm() { _showForm = false; _editing = null; }
|
||||
@@ -256,14 +330,32 @@
|
||||
_regStatus = T["profiles.reg.creating"];
|
||||
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(
|
||||
adminAccessToken: adminToken,
|
||||
tenantName: _form.Name,
|
||||
redirectUri: ConnectOpts.Value.RedirectUri,
|
||||
appOnlyCert: cert,
|
||||
ct: _regCts.Token);
|
||||
|
||||
_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)
|
||||
{
|
||||
@@ -317,6 +409,84 @@
|
||||
{
|
||||
_profiles.RemoveAll(x => x.Id == p.Id);
|
||||
await ProfileRepo.SaveAsync(_profiles);
|
||||
CertStore.Delete(p.Id);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 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.";
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
@page "/settings"
|
||||
@attribute [Authorize]
|
||||
@inject IUserSessionService Session
|
||||
@inject IUserContextAccessor UserContext
|
||||
@inject IJSRuntime JS
|
||||
@inject TranslationSource T
|
||||
@rendermode InteractiveServer
|
||||
@using Microsoft.JSInterop
|
||||
@using SharepointToolbox.Web.Core.Models
|
||||
@using SharepointToolbox.Web.Services.Session
|
||||
|
||||
<h1 class="page-title">@T["tab.settings"]</h1>
|
||||
|
||||
@@ -22,6 +25,7 @@
|
||||
<select class="form-select" style="width:160px" @bind="_theme" @bind:after="Save">
|
||||
<option value="System">@T["settings.theme.system"]</option>
|
||||
<option value="Light">@T["settings.theme.light"]</option>
|
||||
<option value="Dark">@T["settings.theme.dark"]</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -36,6 +40,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@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="form-group">
|
||||
@@ -44,6 +51,7 @@
|
||||
<LogoUpload Value="_mspLogo" ValueChanged="OnMspLogoChanged" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (_saved) { <div class="alert alert-success">@T["settings.saved"]</div> }
|
||||
|
||||
@@ -55,10 +63,10 @@
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
var s = Session.Settings;
|
||||
// Reflect the culture actually resolved for this circuit (cookie-driven), not the
|
||||
// possibly-not-yet-loaded persisted setting.
|
||||
_lang = System.Globalization.CultureInfo.CurrentUICulture.TwoLetterISOLanguageName == "fr" ? "fr" : "en";
|
||||
_theme = s.Theme is "System" or "Light" ? s.Theme : "System";
|
||||
// Read the persisted language directly — the interactive circuit doesn't reliably inherit
|
||||
// ambient CurrentUICulture (see TranslationSource), so reading it here shows the wrong value.
|
||||
_lang = s.Lang is "fr" or "en" ? s.Lang : "fr";
|
||||
_theme = s.Theme is "System" or "Light" or "Dark" ? s.Theme : "System";
|
||||
_autoTakeOwnership = s.AutoTakeOwnership;
|
||||
_mspLogo = s.MspLogo;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
@inject ISessionManager SessionMgr
|
||||
@inject IUserAccessAuditService AuditSvc
|
||||
@inject IGraphUserDirectoryService GraphSvc
|
||||
@inject ISiteDiscoveryService SiteDiscovery
|
||||
@inject UserAccessCsvExportService CsvExport
|
||||
@inject UserAccessHtmlExportService HtmlExport
|
||||
@inject WebExportService WebExport
|
||||
@@ -56,6 +57,7 @@
|
||||
<textarea class="form-textarea" @bind="_users" placeholder="alice@contoso.com bob@contoso.com" rows="2"></textarea>
|
||||
</div>
|
||||
<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-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>
|
||||
@@ -79,10 +81,72 @@
|
||||
<div class="card">
|
||||
<div class="flex-row">
|
||||
<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>
|
||||
<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>
|
||||
</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">
|
||||
<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>
|
||||
@@ -102,6 +166,7 @@
|
||||
</table>
|
||||
</div>
|
||||
@if (_results.Count > 500) { <div class="text-muted mt-8">@T["audit.msg.showFirst500Export"]</div> }
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -147,26 +212,58 @@
|
||||
private bool _running; private string _status = string.Empty, _error = string.Empty;
|
||||
private int _current, _total;
|
||||
private List<UserAccessEntry> _results = new();
|
||||
private int _sitesScanned, _sitesDenied, _sitesFailed;
|
||||
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()
|
||||
{
|
||||
_error = string.Empty; _results.Clear(); _running = true;
|
||||
_error = string.Empty; _results.Clear(); _expandedSites.Clear();
|
||||
_sitesScanned = _sitesDenied = _sitesFailed = 0; _running = true;
|
||||
_cts = new CancellationTokenSource();
|
||||
var userList = _selectedEmails
|
||||
.Concat(_users.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
if (!userList.Any()) { _error = T["audit.err.noUsersOrEmail"]; _running = false; return; }
|
||||
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); });
|
||||
try
|
||||
{
|
||||
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);
|
||||
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 (Exception ex) { _error = ex.Message; }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
@inject AuthenticationStateProvider AuthProvider
|
||||
@inject IUserService UserService
|
||||
@inject IUserContextAccessor UserContext
|
||||
@inject ILogger<AppInitializer> Logger
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using SharepointToolbox.Web.Services.Auth
|
||||
@using SharepointToolbox.Web.Services.Session
|
||||
@@ -12,13 +13,30 @@
|
||||
{
|
||||
var state = await AuthProvider.GetAuthenticationStateAsync();
|
||||
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
|
||||
?? 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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
@@ -4,4 +4,7 @@ public class AppConfiguration
|
||||
{
|
||||
public string DataFolder { get; set; } = "/data";
|
||||
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";
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>1–31. 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; }
|
||||
}
|
||||
@@ -15,4 +15,41 @@ public class TenantProfile
|
||||
public string ClientId { get; set; } = string.Empty;
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
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
|
||||
COPY ["SharepointToolbox.Web.csproj", "."]
|
||||
RUN dotnet restore
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -5,22 +5,34 @@ using SharepointToolbox.Web.Services.Session;
|
||||
|
||||
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
|
||||
{
|
||||
private readonly ISessionCredentialStore _credentialStore;
|
||||
private readonly ISessionManager _sessionManager;
|
||||
private readonly IAppOnlyContextFactory _appOnly;
|
||||
|
||||
public GraphClientFactory(ISessionCredentialStore credentialStore, ISessionManager sessionManager)
|
||||
public GraphClientFactory(
|
||||
ISessionCredentialStore credentialStore,
|
||||
ISessionManager sessionManager,
|
||||
IAppOnlyContextFactory appOnly)
|
||||
{
|
||||
_credentialStore = credentialStore;
|
||||
_sessionManager = sessionManager;
|
||||
_appOnly = appOnly;
|
||||
}
|
||||
|
||||
public async Task<GraphServiceClient> CreateClientAsync(TenantProfile profile)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(profile.TenantId);
|
||||
|
||||
if (_appOnly.IsConfigured(profile))
|
||||
return await _appOnly.CreateGraphClientAsync(profile);
|
||||
|
||||
var hasTokens = await _credentialStore.HasCredentialsAsync();
|
||||
if (!hasTokens)
|
||||
throw new InvalidOperationException(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -7,23 +7,31 @@ using SharepointToolbox.Web.Services.Session;
|
||||
namespace SharepointToolbox.Web.Infrastructure.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Delegated session manager using OAuth2 refresh tokens.
|
||||
/// Tokens come from ISessionCredentialStore (ProtectedSessionStorage — browser-side only).
|
||||
/// Caches access tokens in-memory per scope for the duration of the Blazor circuit.
|
||||
/// Session manager that resolves the auth model per profile. When a profile is configured
|
||||
/// for certificate auth (<see cref="IAppOnlyContextFactory.IsConfigured"/>), contexts are
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class SessionManager : ISessionManager
|
||||
{
|
||||
private readonly ISessionCredentialStore _credentialStore;
|
||||
private readonly ITokenRefreshService _tokenRefresh;
|
||||
private readonly IAppOnlyContextFactory _appOnly;
|
||||
private readonly Dictionary<string, ClientContext> _contexts = new();
|
||||
private readonly Dictionary<string, (string Token, DateTimeOffset ExpiresAt)> _accessTokenCache = new();
|
||||
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||
|
||||
public SessionManager(ISessionCredentialStore credentialStore, ITokenRefreshService tokenRefresh)
|
||||
public SessionManager(
|
||||
ISessionCredentialStore credentialStore,
|
||||
ITokenRefreshService tokenRefresh,
|
||||
IAppOnlyContextFactory appOnly)
|
||||
{
|
||||
_credentialStore = credentialStore;
|
||||
_tokenRefresh = tokenRefresh;
|
||||
_appOnly = appOnly;
|
||||
}
|
||||
|
||||
public bool IsAuthenticated(string tenantUrl) => _contexts.ContainsKey(NormalizeUrl(tenantUrl));
|
||||
@@ -62,6 +70,24 @@ public class SessionManager : ISessionManager
|
||||
var key = NormalizeUrl(profile.TenantUrl);
|
||||
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);
|
||||
try
|
||||
{
|
||||
@@ -90,16 +116,7 @@ public class SessionManager : ISessionManager
|
||||
|
||||
public async Task<ClientContext> GetOrCreateContextAsync(string siteUrl, TenantProfile profile, CancellationToken ct = default)
|
||||
{
|
||||
var profileForSite = new TenantProfile
|
||||
{
|
||||
Id = profile.Id,
|
||||
Name = profile.Name,
|
||||
TenantUrl = siteUrl,
|
||||
TenantId = profile.TenantId,
|
||||
ClientId = profile.ClientId,
|
||||
ClientLogo = profile.ClientLogo,
|
||||
};
|
||||
return await GetOrCreateContextAsync(profileForSite, ct);
|
||||
return await GetOrCreateContextAsync(profile.CloneForSite(siteUrl), ct);
|
||||
}
|
||||
|
||||
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(); }
|
||||
}
|
||||
@@ -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">
|
||||
<value>Élevé</value>
|
||||
</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">
|
||||
<value>Inclure les autorisations héritées</value>
|
||||
</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">
|
||||
<value>Reconnecter</value>
|
||||
</data>
|
||||
<data name="nav.appIdentity" xml:space="preserve">
|
||||
<value>identité application</value>
|
||||
</data>
|
||||
<data name="nav.searchPlaceholder" xml:space="preserve">
|
||||
<value>Rechercher…</value>
|
||||
</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">
|
||||
<value>Application inscrite. Vérifiez et enregistrez le profil.</value>
|
||||
</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">
|
||||
<value>Demande d'un code de connexion…</value>
|
||||
</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.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="nav.scheduledReports" xml:space="preserve"><value>Rapports planifiés</value></data>
|
||||
<data name="nav.reports" xml:space="preserve"><value>Rapports</value></data>
|
||||
</root>
|
||||
|
||||
@@ -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">
|
||||
<value>High</value>
|
||||
</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">
|
||||
<value>Include inherited</value>
|
||||
</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">
|
||||
<value>Reconnect</value>
|
||||
</data>
|
||||
<data name="nav.appIdentity" xml:space="preserve">
|
||||
<value>app identity</value>
|
||||
</data>
|
||||
<data name="nav.searchPlaceholder" xml:space="preserve">
|
||||
<value>Search…</value>
|
||||
</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">
|
||||
<value>App registered. Review and Save the profile.</value>
|
||||
</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">
|
||||
<value>Requesting a sign-in code…</value>
|
||||
</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.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="nav.scheduledReports" xml:space="preserve"><value>Scheduled Reports</value></data>
|
||||
<data name="nav.reports" xml:space="preserve"><value>Reports</value></data>
|
||||
</root>
|
||||
|
||||
+130
-7
@@ -3,6 +3,8 @@ using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
||||
using Microsoft.AspNetCore.Antiforgery;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
@@ -17,6 +19,7 @@ using SharepointToolbox.Web.Services.Audit;
|
||||
using SharepointToolbox.Web.Services.Auth;
|
||||
using SharepointToolbox.Web.Services.Export;
|
||||
using SharepointToolbox.Web.Services.OAuth;
|
||||
using SharepointToolbox.Web.Services.Reports;
|
||||
using SharepointToolbox.Web.Services.Session;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
@@ -41,6 +44,28 @@ builder.Services.AddRazorComponents()
|
||||
.AddInteractiveServerComponents();
|
||||
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.
|
||||
builder.Services.AddScoped<SharepointToolbox.Web.Localization.TranslationSource>();
|
||||
|
||||
@@ -64,7 +89,12 @@ else
|
||||
builder.Services.AddAuthentication(options =>
|
||||
{
|
||||
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 =>
|
||||
{
|
||||
@@ -80,11 +110,22 @@ else
|
||||
.AddOpenIdConnect(options =>
|
||||
{
|
||||
var oidc = builder.Configuration.GetSection("Oidc");
|
||||
options.Authority = $"https://login.microsoftonline.com/{oidc["TenantId"]}/v2.0";
|
||||
options.ClientId = oidc["ClientId"];
|
||||
options.ClientSecret = oidc["ClientSecret"];
|
||||
// Strip accidental surrounding quotes/whitespace. docker-compose's `environment` list form
|
||||
// (`- Oidc__TenantId="<guid>"`) embeds the literal quotes in the value, producing a malformed
|
||||
// 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.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("profile");
|
||||
options.Scope.Add("email");
|
||||
@@ -95,7 +136,29 @@ else
|
||||
options.Events.OnTokenValidated = async ctx =>
|
||||
{
|
||||
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"));
|
||||
|
||||
// ── App config ────────────────────────────────────────────────────────────────
|
||||
var certsFolder = Path.Combine(dataFolder, "appcerts");
|
||||
builder.Services.Configure<AppConfiguration>(opt =>
|
||||
{
|
||||
opt.DataFolder = dataFolder;
|
||||
opt.ExportsFolder = Path.Combine(dataFolder, "exports");
|
||||
opt.CertsFolder = certsFolder;
|
||||
Directory.CreateDirectory(opt.ExportsFolder);
|
||||
Directory.CreateDirectory(opt.CertsFolder);
|
||||
});
|
||||
|
||||
// ── 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 UserRepository(Path.Combine(dataFolder, "users.json")));
|
||||
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 ───────────────────────────────────────────────────────
|
||||
builder.Services.AddSingleton<IPasswordHasher<AppUser>, PasswordHasher<AppUser>>();
|
||||
@@ -131,6 +204,7 @@ builder.Services.AddSingleton<IOAuthFlowCache, OAuthFlowCache>();
|
||||
builder.Services.AddSingleton<IEntraDeviceCodeFlow, EntraDeviceCodeFlow>();
|
||||
builder.Services.AddHttpClient<ITokenRefreshService, TokenRefreshService>();
|
||||
builder.Services.AddHttpClient<IAppRegistrationService, AppRegistrationService>();
|
||||
builder.Services.AddSingleton<ICertProvisioningService, CertProvisioningService>();
|
||||
builder.Services.AddScoped<GraphClientFactory>();
|
||||
|
||||
// ── 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<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();
|
||||
|
||||
// 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())
|
||||
{
|
||||
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
||||
@@ -188,7 +296,7 @@ if (!app.Environment.IsDevelopment())
|
||||
// Re-execute unmatched (404) requests into the branded not-found page
|
||||
app.UseStatusCodePagesWithReExecute("/not-found");
|
||||
|
||||
app.UseStaticFiles();
|
||||
app.MapStaticAssets();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseAntiforgery();
|
||||
@@ -301,6 +409,21 @@ app.MapGet("/export/download/{fileName}", async (string fileName, IOptions<AppCo
|
||||
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 ────────────────────────────────────────────────────────
|
||||
app.MapGet("/audit/export", async (AuditRepository auditRepo, HttpContext ctx) =>
|
||||
{
|
||||
|
||||
@@ -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 |
@@ -24,6 +24,22 @@ public class AppRegistrationService : IAppRegistrationService
|
||||
"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;
|
||||
|
||||
public AppRegistrationService(HttpClient http) { _http = http; }
|
||||
@@ -32,43 +48,49 @@ public class AppRegistrationService : IAppRegistrationService
|
||||
string adminAccessToken,
|
||||
string tenantName,
|
||||
string redirectUri,
|
||||
CertProvisioningResult? appOnlyCert = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_http.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", adminAccessToken);
|
||||
|
||||
// 1. Resolve Graph + SharePoint service principals in the target tenant
|
||||
var (graphSpId, graphPermIds) = await ResolveServicePrincipalAsync(GraphAppId, GraphScopes, ct);
|
||||
var (spSpId, spPermIds) = await ResolveServicePrincipalAsync(SharePointAppId, SpScopes, ct);
|
||||
bool wantsAppOnly = appOnlyCert is not null;
|
||||
|
||||
// 2. Create app registration
|
||||
var appBody = new
|
||||
// 1. Resolve Graph + SharePoint service principals + the permission ids we need
|
||||
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}",
|
||||
signInAudience = "AzureADMyOrg",
|
||||
isFallbackPublicClient = true,
|
||||
["displayName"] = $"SP Toolbox — {tenantName}",
|
||||
["signInAudience"] = "AzureADMyOrg",
|
||||
["isFallbackPublicClient"] = true,
|
||||
// Register the redirect under the PUBLIC client platform so the connect
|
||||
// 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
|
||||
// token exchange fails with AADSTS7000218 (secret required).
|
||||
publicClient = new { redirectUris = new[] { redirectUri } },
|
||||
requiredResourceAccess = new[]
|
||||
["publicClient"] = new { redirectUris = new[] { redirectUri } },
|
||||
["requiredResourceAccess"] = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
resourceAppId = GraphAppId,
|
||||
resourceAccess = graphPermIds.Select(id => new { id, type = "Scope" }).ToArray(),
|
||||
resourceAccess = ResourceAccess(graph.ScopeIds, wantsAppOnly ? graph.AppRoleIds : []),
|
||||
},
|
||||
new
|
||||
{
|
||||
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",
|
||||
appBody, ct);
|
||||
// Attach the certificate as a sign-in credential so app-only token requests succeed.
|
||||
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()!;
|
||||
|
||||
// 3. Create service principal for the new app
|
||||
@@ -76,54 +98,90 @@ public class AppRegistrationService : IAppRegistrationService
|
||||
new { appId = clientId }, ct);
|
||||
var newSpId = spJson.GetProperty("id").GetString()!;
|
||||
|
||||
// 4. Grant org-wide admin consent for Graph
|
||||
await PostGraphAsync("https://graph.microsoft.com/v1.0/oauth2PermissionGrants",
|
||||
new
|
||||
{
|
||||
clientId = newSpId,
|
||||
consentType = "AllPrincipals",
|
||||
resourceId = graphSpId,
|
||||
scope = string.Join(" ", GraphScopes),
|
||||
}, ct);
|
||||
// 4. Grant org-wide admin consent for Graph + SharePoint delegated scopes
|
||||
await GrantDelegatedConsentAsync(newSpId, graph.SpObjectId, GraphScopes, ct);
|
||||
await GrantDelegatedConsentAsync(newSpId, sp.SpObjectId, SpScopes, ct);
|
||||
|
||||
// 5. Grant org-wide admin consent for SharePoint
|
||||
await PostGraphAsync("https://graph.microsoft.com/v1.0/oauth2PermissionGrants",
|
||||
new
|
||||
// 5. Grant admin consent for application permissions (app roles) when app-only
|
||||
if (wantsAppOnly)
|
||||
{
|
||||
clientId = newSpId,
|
||||
consentType = "AllPrincipals",
|
||||
resourceId = spSpId,
|
||||
scope = string.Join(" ", SpScopes),
|
||||
}, ct);
|
||||
await GrantAppRolesAsync(newSpId, graph.SpObjectId, graph.AppRoleIds, ct);
|
||||
await GrantAppRolesAsync(newSpId, sp.SpObjectId, sp.AppRoleIds, ct);
|
||||
}
|
||||
|
||||
return clientId;
|
||||
}
|
||||
|
||||
// Returns (servicePrincipalObjectId, [permissionIds matching requested scopes])
|
||||
private async Task<(string SpObjectId, string[] PermissionIds)> ResolveServicePrincipalAsync(
|
||||
string appId, string[] scopeNames, CancellationToken ct)
|
||||
private static object BuildKeyCredential(CertProvisioningResult cert, string tenantName) => new
|
||||
{
|
||||
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" +
|
||||
$"?$filter=appId eq '{appId}'&$select=id,oauth2PermissionScopes";
|
||||
$"?$filter=appId eq '{appId}'&$select=id,oauth2PermissionScopes,appRoles";
|
||||
var resp = await _http.GetAsync(url, ct);
|
||||
var json = await resp.Content.ReadAsStringAsync(ct);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var values = doc.RootElement.GetProperty("value");
|
||||
var sp = values.EnumerateArray().First();
|
||||
var sp = doc.RootElement.GetProperty("value").EnumerateArray().First();
|
||||
var spId = sp.GetProperty("id").GetString()!;
|
||||
var allScopes = sp.GetProperty("oauth2PermissionScopes");
|
||||
|
||||
var ids = new List<string>();
|
||||
foreach (var scope in allScopes.EnumerateArray())
|
||||
{
|
||||
var value = scope.GetProperty("value").GetString();
|
||||
if (scopeNames.Contains(value, StringComparer.OrdinalIgnoreCase))
|
||||
ids.Add(scope.GetProperty("id").GetString()!);
|
||||
var scopeIds = MatchByValue(sp.GetProperty("oauth2PermissionScopes"), scopeNames);
|
||||
var roleIds = MatchByValue(sp.GetProperty("appRoles"), roleNames);
|
||||
|
||||
return (spId, scopeIds, roleIds);
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,20 @@ public interface IAppRegistrationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an Entra ID app registration in the target tenant using a delegated admin token
|
||||
/// (requires Application.ReadWrite.All + DelegatedPermissionGrant.ReadWrite.All scope).
|
||||
/// Grants org-wide admin consent for SharePoint + Graph delegated permissions.
|
||||
/// Returns the new app's client ID (appId).
|
||||
/// (requires Application.ReadWrite.All + DelegatedPermissionGrant.ReadWrite.All +
|
||||
/// AppRoleAssignment.ReadWrite.All scope). Grants org-wide admin consent for SharePoint + Graph
|
||||
/// 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>
|
||||
Task<string> CreateAsync(
|
||||
string adminAccessToken,
|
||||
string tenantName,
|
||||
string redirectUri,
|
||||
CertProvisioningResult? appOnlyCert = null,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ namespace SharepointToolbox.Web.Services;
|
||||
|
||||
public interface IUserAccessAuditService
|
||||
{
|
||||
Task<IReadOnlyList<UserAccessEntry>> AuditUsersAsync(
|
||||
Task<UserAccessAuditResult> AuditUsersAsync(
|
||||
ISessionManager sessionManager,
|
||||
TenantProfile currentProfile,
|
||||
IReadOnlyList<string> targetUserLogins,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,11 @@ public class SharePointGroupResolver : ISharePointGroupResolver
|
||||
{
|
||||
graphClient ??= await _graphClientFactory.CreateClientAsync(profile);
|
||||
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);
|
||||
}
|
||||
else
|
||||
@@ -83,10 +87,27 @@ public class SharePointGroupResolver : ISharePointGroupResolver
|
||||
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) =>
|
||||
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)..];
|
||||
|
||||
private static async Task<IEnumerable<ResolvedMember>> ResolveAadGroupAsync(
|
||||
@@ -122,4 +143,40 @@ public class SharePointGroupResolver : ISharePointGroupResolver
|
||||
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>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,38 @@
|
||||
using Microsoft.Graph;
|
||||
using Microsoft.Graph.Models;
|
||||
using Microsoft.Kiota.Abstractions;
|
||||
using SharepointToolbox.Web.Core.Helpers;
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Delegated Graph implementation of <see cref="ISiteDiscoveryService"/>.
|
||||
/// Uses the <c>/sites?search=*</c> endpoint, paging through every result.
|
||||
/// Requires the delegated <c>Sites.Read.All</c> scope.
|
||||
/// Enumerates every site collection in a tenant via the SharePoint tenant-admin endpoint
|
||||
/// (<c>Tenant.GetSitePropertiesFromSharePointByFilters</c>), paging through all results.
|
||||
/// 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>
|
||||
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(
|
||||
@@ -25,52 +40,21 @@ public class SiteDiscoveryService : ISiteDiscoveryService
|
||||
string? query = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var graphClient = await _graphClientFactory.CreateClientAsync(profile);
|
||||
// "*" is the Graph convention for "return all sites".
|
||||
var search = string.IsNullOrWhiteSpace(query) ? "*" : query!;
|
||||
ArgumentException.ThrowIfNullOrEmpty(profile.TenantUrl);
|
||||
|
||||
// The typed Sites.GetAsync maps its Search property to OData "$search",
|
||||
// which routes "*" through KQL and fails ("'*' is not valid at position 0").
|
||||
// The all-sites wildcard only works via the bare, non-OData "search"
|
||||
// query parameter, so build the request manually.
|
||||
var requestInfo = new RequestInformation
|
||||
var adminUrl = TenantSiteEnumerator.BuildAdminUrl(profile.TenantUrl);
|
||||
|
||||
// App-only profiles: build the admin-host context through the cert factory (matches the
|
||||
// scheduler), enumerating under the SharePoint app permission the cert already grants.
|
||||
if (_appOnly.IsConfigured(profile))
|
||||
{
|
||||
HttpMethod = Method.GET,
|
||||
UrlTemplate = "{+baseurl}/sites{?search,%24top}",
|
||||
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");
|
||||
using var adminCtx = await _appOnly.CreateContextAsync(profile, adminUrl, ct);
|
||||
return await TenantSiteEnumerator.EnumerateAsync(adminCtx, ct);
|
||||
}
|
||||
|
||||
var response = await graphClient.RequestAdapter.SendAsync<SiteCollectionResponse>(
|
||||
requestInfo, SiteCollectionResponse.CreateFromDiscriminatorValue, cancellationToken: ct);
|
||||
|
||||
if (response is null) return Array.Empty<SiteInfo>();
|
||||
|
||||
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();
|
||||
// Delegated profiles: enumeration only exists on the tenant admin endpoint.
|
||||
var adminProfile = profile.CloneForSite(adminUrl);
|
||||
var ctx = await _sessionManager.GetOrCreateContextAsync(adminProfile, ct);
|
||||
return await TenantSiteEnumerator.EnumerateAsync(ctx, ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,19 +7,27 @@ public class UserAccessAuditService : IUserAccessAuditService
|
||||
{
|
||||
private readonly IPermissionsService _permissionsService;
|
||||
private readonly IElevationCoordinator _elevation;
|
||||
private readonly ISharePointGroupResolver _groupResolver;
|
||||
|
||||
private static readonly HashSet<string> HighPrivilegeLevels = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"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;
|
||||
_elevation = elevation;
|
||||
_groupResolver = groupResolver;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<UserAccessEntry>> AuditUsersAsync(
|
||||
public async Task<UserAccessAuditResult> AuditUsersAsync(
|
||||
ISessionManager sessionManager,
|
||||
TenantProfile currentProfile,
|
||||
IReadOnlyList<string> targetUserLogins,
|
||||
@@ -32,10 +40,16 @@ public class UserAccessAuditService : IUserAccessAuditService
|
||||
.Select(l => l.Trim().ToLowerInvariant())
|
||||
.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>();
|
||||
|
||||
// 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++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
@@ -43,14 +57,11 @@ public class UserAccessAuditService : IUserAccessAuditService
|
||||
progress.Report(new OperationProgress(i, sites.Count,
|
||||
$"Scanning site {i + 1}/{sites.Count}: {site.Title}..."));
|
||||
|
||||
var profile = new TenantProfile
|
||||
{
|
||||
TenantUrl = site.Url,
|
||||
TenantId = currentProfile.TenantId,
|
||||
ClientId = currentProfile.ClientId,
|
||||
Name = site.Title
|
||||
};
|
||||
var profile = currentProfile.CloneForSite(site.Url);
|
||||
profile.Name = site.Title;
|
||||
|
||||
try
|
||||
{
|
||||
// Auto-elevates site-collection admin ownership and retries when a scan is denied,
|
||||
// if the AutoTakeOwnership setting is enabled (otherwise the access-denied propagates).
|
||||
var permEntries = await _elevation.RunAsync(async c =>
|
||||
@@ -59,48 +70,144 @@ public class UserAccessAuditService : IUserAccessAuditService
|
||||
return await _permissionsService.ScanSiteAsync(ctx, options, progress, c);
|
||||
}, 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,
|
||||
$"Audit complete: {allEntries.Count} access entries found."));
|
||||
return allEntries;
|
||||
var summary = $"Audit complete: {allEntries.Count} access entries found.";
|
||||
if (deniedSites > 0) summary += $" {deniedSites} site(s) skipped (no access).";
|
||||
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(
|
||||
IReadOnlyList<PermissionEntry> permEntries, HashSet<string> targets, SiteInfo site)
|
||||
/// <summary>
|
||||
/// 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)
|
||||
{
|
||||
var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
|
||||
var names = entry.Users.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++)
|
||||
{
|
||||
var login = logins[u].Trim();
|
||||
var loginLower = login.ToLowerInvariant();
|
||||
var displayName = u < names.Length ? names[u].Trim() : login;
|
||||
|
||||
bool isTarget = targets.Any(t => loginLower.Contains(t) || t.Contains(loginLower));
|
||||
if (!isTarget) continue;
|
||||
|
||||
var accessType = !entry.HasUniquePermissions ? AccessType.Inherited
|
||||
: entry.GrantedThrough.StartsWith("SharePoint Group:", StringComparison.OrdinalIgnoreCase)
|
||||
? AccessType.Group : AccessType.Direct;
|
||||
if (!targets.Any(t => loginLower.Contains(t) || t.Contains(loginLower))) continue;
|
||||
|
||||
var accessType = entry.HasUniquePermissions ? AccessType.Direct : AccessType.Inherited;
|
||||
foreach (var level in permLevels)
|
||||
{
|
||||
var trimmed = level.Trim();
|
||||
if (string.IsNullOrEmpty(trimmed)) continue;
|
||||
yield return new UserAccessEntry(
|
||||
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);
|
||||
yield return Build(displayName, login, accessType, trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -14,7 +14,10 @@ services:
|
||||
- DataFolder=/data
|
||||
restart: unless-stopped
|
||||
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
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
+37
-22
@@ -1,35 +1,35 @@
|
||||
:root {
|
||||
--sidebar-width: 248px;
|
||||
--sidebar-collapsed-width: 78px;
|
||||
--bg: #eef0f7;
|
||||
--page-bg: #eef0f7;
|
||||
--bg: #eff6ff;
|
||||
--page-bg: #eff6ff;
|
||||
--sidebar-bg: #ffffff;
|
||||
--sidebar-text: #3f4254;
|
||||
--sidebar-muted: #8a8d9b;
|
||||
--sidebar-hover: #f2f3f9;
|
||||
--sidebar-accent: #5b5bd6;
|
||||
--sidebar-active: #5b5bd6;
|
||||
--sidebar-hover: #eff6ff;
|
||||
--sidebar-accent: #006cd2;
|
||||
--sidebar-active: #006cd2;
|
||||
--card-bg: #fff;
|
||||
--surface-hover: #f2f3f9;
|
||||
--th-bg: #f4f5fb;
|
||||
--surface-hover: #eff6ff;
|
||||
--th-bg: #eff6ff;
|
||||
--input-bg: #fff;
|
||||
--border: #e6e7f0;
|
||||
--accent: #5b5bd6;
|
||||
--accent-dark: #4a4ac0;
|
||||
--accent-soft: rgba(91,91,214,.12);
|
||||
--border: #d8e6f5;
|
||||
--accent: #006cd2;
|
||||
--accent-dark: #092c55;
|
||||
--accent-soft: rgba(0,108,210,.12);
|
||||
--danger: #d13438;
|
||||
--success: #107c10;
|
||||
--warn: #797673;
|
||||
--text: #323130;
|
||||
--text-muted: #605e5c;
|
||||
--surface-2: #2d2d4e;
|
||||
--warn: #fea20a;
|
||||
--text: #092c55;
|
||||
--text-muted: #5a6b80;
|
||||
--surface-2: #092c55;
|
||||
--font: 'Segoe UI', system-ui, sans-serif;
|
||||
/* shape + depth — match sidebar */
|
||||
--radius-lg: 20px;
|
||||
--radius-md: 12px;
|
||||
--radius-sm: 10px;
|
||||
--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; }
|
||||
@@ -69,10 +69,8 @@ body {
|
||||
}
|
||||
.logo { display: flex; align-items: center; gap: 11px; overflow: hidden; }
|
||||
.logo-mark {
|
||||
width: 38px; height: 38px; flex-shrink: 0; border-radius: 11px;
|
||||
background: linear-gradient(135deg, #6d6df0, #5b5bd6);
|
||||
color: #fff; font-weight: 700; font-size: 14px; letter-spacing: .5px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 38px; height: 38px; flex-shrink: 0;
|
||||
object-fit: contain;
|
||||
}
|
||||
.logo-text { font-weight: 700; font-size: 16px; color: var(--sidebar-text); white-space: nowrap; }
|
||||
.toggle-btn {
|
||||
@@ -135,7 +133,7 @@ body {
|
||||
.nav-item:hover { background: var(--sidebar-hover); }
|
||||
.nav-item.active {
|
||||
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-label { overflow: hidden; text-overflow: ellipsis; }
|
||||
@@ -373,9 +371,26 @@ body {
|
||||
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-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 ── */
|
||||
.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 |
@@ -19,3 +19,193 @@ window.sptb = {
|
||||
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 • 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 |
Reference in New Issue
Block a user