Add scheduled reports + app-only cert auth; fix tenant-wide user-access audit

Feature work:
- Certificate (app-only) auth per profile: cert store, context/Graph client
  factories, automated app-registration provisioning (delegated + application
  permissions, admin consent), and a SessionManager seam that resolves the auth
  model per profile.
- Scheduled reports: repositories, hosted service/runner/coordinator, report
  pages, and email delivery (app-only Mail.Send).
- Tenant-wide user-access audit when no site is selected.

Audit fixes:
- Site enumeration: app-only discovery used Graph getAllSites (needs Graph
  Sites.Read.All the cert app lacks) and silently returned empty. Switched to
  the admin-host CSOM TenantSiteEnumerator, matching the scheduler; both auth
  models now share one enumeration path.
- Group expansion: the scan records a SharePoint group as a single principal, so
  user-centric audits found nothing for group-granted access. Resolve group
  membership (shared by audit + scheduler) and attribute it to the target user.
- M365 group claims: the resolver only recognized AAD security groups
  (c:0t.c|). Group-connected/Teams sites grant via the M365 group claim
  (c:0o.c|…|<guid>[_o]); now expanded too, resolving owners for the "_o" claim.
- Provision Directory.Read.All as an application permission so M365/AAD group
  expansion works under the cert identity.

Also: ignore data/appcerts/ (encrypted certificate key material).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 17:55:28 +02:00
parent 1b0f4ce588
commit 6d9c79ad5a
40 changed files with 3020 additions and 269 deletions
+25 -4
View File
@@ -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
@@ -44,8 +45,11 @@
{
<div style="font-size:10px;color:var(--text-muted);margin-top:4px">
SP: @_credUsername
<button class="btn btn-secondary btn-sm" style="padding:2px 6px;font-size:10px;margin-left:4px"
@onclick="ReconnectAsync">@T["nav.reconnect"]</button>
@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).
+1 -1
View File
@@ -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)
+178 -8
View File
@@ -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 };
_showForm = true;
_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.ClientId = clientId;
_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);
}
}
+101
View File
@@ -0,0 +1,101 @@
@page "/reports"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@inject IUserSessionService Session
@inject SharepointToolbox.Web.Infrastructure.Persistence.GeneratedReportRepository ReportIndex
@inject Microsoft.Extensions.Options.IOptions<SharepointToolbox.Web.Core.Models.AppConfiguration> Cfg
@inject TranslationSource T
@rendermode InteractiveServer
@using SharepointToolbox.Web.Core.Models
@using SharepointToolbox.Web.Services.Session
<h1 class="page-title">Reports</h1>
<p class="page-subtitle">Generated reports for the selected client.</p>
@if (!Session.HasProfile) { <NoProfilePrompt /> return; }
<div class="card">
<div class="flex-row">
<div class="card-title">@Session.CurrentProfile!.Name <span class="count-badge">@_reports.Count</span></div>
<div class="spacer"></div>
<button class="btn btn-secondary btn-sm" @onclick="Reload">Refresh</button>
</div>
@if (_reports.Count == 0)
{
<div class="alert alert-info">No reports generated yet for this client. Schedules run automatically; an admin can create them under Scheduled Reports.</div>
}
else
{
<div class="data-table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Name</th><th>Type</th><th>Generated (UTC)</th><th>Size</th><th>Status</th><th></th>
</tr>
</thead>
<tbody>
@foreach (var r in _reports)
{
<tr>
<td>@(string.IsNullOrEmpty(r.Name) ? "—" : r.Name)</td>
<td>@r.Type</td>
<td>@r.GeneratedUtc.ToString("yyyy-MM-dd HH:mm")</td>
<td>@(r.Status == ReportRunStatus.Success ? $"{r.SizeBytes / 1024.0:F1} KB" : "—")</td>
<td>
@if (r.Status == ReportRunStatus.Success)
{
<span class="chip chip-green">Success</span>
@if (r.Emailed)
{
<span class="chip chip-blue" style="margin-left:4px">Emailed</span>
}
else if (!string.IsNullOrEmpty(r.EmailError))
{
<span class="chip chip-red" style="margin-left:4px" title="@r.EmailError">Email failed</span>
}
}
else
{
<span class="chip chip-red" title="@r.Error">Failed</span>
}
</td>
<td>
<div class="flex-row" style="gap:6px;justify-content:flex-end">
@if (r.Status == ReportRunStatus.Success)
{
<a class="btn btn-secondary btn-sm" href="/reports/download/@r.Id" target="_blank" rel="noopener">Download</a>
}
<button class="btn btn-danger btn-sm" @onclick="() => DeleteAsync(r)">Delete</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
@code {
private List<GeneratedReport> _reports = new();
protected override async Task OnInitializedAsync() => await Reload();
private async Task Reload()
{
if (!Session.HasProfile) { _reports = new(); return; }
_reports = (await ReportIndex.LoadForProfileAsync(Session.CurrentProfile!.Id)).ToList();
}
private async Task DeleteAsync(GeneratedReport r)
{
// Remove the file (best-effort) then the index entry.
if (r.Status == ReportRunStatus.Success && !string.IsNullOrEmpty(r.FileName))
{
var path = System.IO.Path.Combine(Cfg.Value.ExportsFolder, r.ProfileId, System.IO.Path.GetFileName(r.FileName));
try { if (System.IO.File.Exists(path)) System.IO.File.Delete(path); } catch { /* ignore */ }
}
await ReportIndex.DeleteAsync(r.Id);
await Reload();
}
}
+499
View File
@@ -0,0 +1,499 @@
@page "/scheduled-reports"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@inject IUserContextAccessor UserContext
@inject SharepointToolbox.Web.Infrastructure.Persistence.ScheduledReportRepository ScheduleRepo
@inject SharepointToolbox.Web.Infrastructure.Persistence.ProfileRepository ProfileRepo
@inject SharepointToolbox.Web.Services.Reports.IScheduledReportRunner Runner
@inject SharepointToolbox.Web.Services.Reports.ScheduledRunCoordinator Coordinator
@inject TranslationSource T
@rendermode InteractiveServer
@using SharepointToolbox.Web.Core.Models
@using SharepointToolbox.Web.Services.Export
<h1 class="page-title">Scheduled Reports</h1>
<p class="page-subtitle">Automatic report generation per client. Generated files appear under Reports and are downloadable there.</p>
@if (UserContext.Role != UserRole.Admin)
{
<div class="alert alert-info">Only administrators can manage scheduled reports.</div>
return;
}
@if (!string.IsNullOrEmpty(_pageMsg)) { <div class="alert alert-info" style="margin-bottom:12px">@_pageMsg</div> }
@if (_appOnlyProfiles.Count == 0)
{
<div class="alert alert-error">
No client has app-only access enabled. Open a client under <a href="/profiles">Client Profiles</a>,
enable scheduled reports, and upload its certificate first.
</div>
}
<div class="flex-row" style="margin-bottom:16px">
<button class="btn btn-primary" @onclick="AddNew" disabled="@(_appOnlyProfiles.Count == 0)">New schedule</button>
<div class="spacer"></div>
@if (Coordinator.IsPaused)
{
<span class="chip chip-blue" style="align-self:center">Scheduler paused</span>
<button class="btn btn-secondary btn-sm" @onclick="ResumeScheduler">Resume scheduler</button>
}
else
{
<button class="btn btn-secondary btn-sm" @onclick="PauseScheduler">Pause scheduler</button>
}
<button class="btn btn-danger btn-sm" @onclick="StopAll">Stop all running</button>
</div>
@if (_schedules.Count == 0 && !_showForm)
{
<div class="alert alert-info">No schedules defined.</div>
}
@foreach (var s in _schedules)
{
<div class="card">
<div class="flex-row">
<div>
<div style="font-weight:600;font-size:15px">
@(string.IsNullOrEmpty(s.Name) ? "(unnamed)" : s.Name)
@if (!s.Enabled) { <span class="chip chip-blue" style="margin-left:6px">Disabled</span> }
</div>
<div class="text-muted">@ClientName(s.ProfileId) · @s.Type · @s.Format · @RecurrenceSummary(s.Recurrence)</div>
<div class="text-muted">
@(s.AllSites ? "All sites" : $"{s.SiteUrls.Count} site(s)") ·
Next: @(s.NextRunUtc?.ToString("yyyy-MM-dd HH:mm 'UTC'") ?? "—") ·
Last: @(s.LastRunUtc?.ToString("yyyy-MM-dd HH:mm 'UTC'") ?? "never")
</div>
</div>
<div class="spacer"></div>
<button class="btn btn-secondary btn-sm" @onclick="() => RunNowAsync(s)" disabled="@Coordinator.IsRunning(s.Id)">
@(Coordinator.IsRunning(s.Id) ? "Running…" : "Run now")
</button>
@if (Coordinator.IsRunning(s.Id))
{
<button class="btn btn-danger btn-sm" @onclick="() => Stop(s)">Stop</button>
}
<button class="btn btn-secondary btn-sm" @onclick="() => ToggleEnabledAsync(s)">@(s.Enabled ? "Disable" : "Enable")</button>
<button class="btn btn-secondary btn-sm" @onclick="() => Edit(s)">Edit</button>
<button class="btn btn-danger btn-sm" @onclick="() => DeleteAsync(s)">Delete</button>
</div>
</div>
}
@if (_showForm)
{
<div class="card" style="border-color:var(--accent)">
<div class="card-title">@(_editing is null ? "New schedule" : "Edit schedule")</div>
@if (!string.IsNullOrEmpty(_formError)) { <div class="alert alert-error">@_formError</div> }
<div class="form-group">
<label class="form-label">Name</label>
<input class="form-input" @bind="_form.Name" placeholder="e.g. Weekly permissions audit" />
</div>
<div class="form-row">
<div class="form-group" style="flex:1">
<label class="form-label">Client</label>
<select class="form-input" @bind="_form.ProfileId">
@foreach (var p in _appOnlyProfiles)
{
<option value="@p.Id">@p.Name</option>
}
</select>
</div>
<div class="form-group" style="flex:1">
<label class="form-label">Report type</label>
<select class="form-input" @bind="_form.Type">
@foreach (var t in Enum.GetValues<ReportType>())
{
<option value="@t">@t</option>
}
</select>
</div>
</div>
@if (_form.Type == ReportType.VersionCleanup)
{
<div class="alert alert-error">
<strong>Destructive action.</strong> Version Cleanup permanently <strong>deletes</strong> old file
versions across the selected sites every time it runs — unattended, with no confirmation.
The report is only a summary of what was deleted. Output is HTML (no CSV).
</div>
}
<div class="form-row">
<div class="form-group" style="flex:1">
<label class="form-label">Format</label>
<select class="form-input" @bind="_form.Format" disabled="@(_form.Type == ReportType.VersionCleanup)">
@foreach (var f in Enum.GetValues<ReportFormat>())
{
<option value="@f">@f</option>
}
</select>
</div>
<div class="form-group" style="flex:1">
<label class="form-label">Multi-site bundling</label>
<select class="form-input" @bind="_form.MergeMode">
@foreach (var m in Enum.GetValues<ReportMergeMode>())
{
<option value="@m">@m</option>
}
</select>
</div>
</div>
@* ── Site scope ── *@
<div class="form-group">
<label><input type="checkbox" @bind="_form.AllSites" /> All sites in the tenant (auto-discovered)</label>
@if (!_form.AllSites)
{
<label class="form-label mt-8">Site URLs (one per line)</label>
<textarea class="form-textarea" rows="3" @bind="_siteUrlsText"
placeholder="https://contoso.sharepoint.com/sites/Marketing"></textarea>
}
</div>
@* ── Recurrence ── *@
<div class="form-row">
<div class="form-group" style="flex:1">
<label class="form-label">Frequency</label>
<select class="form-input" @bind="_form.Recurrence.Frequency">
@foreach (var f in Enum.GetValues<ReportFrequency>())
{
<option value="@f">@f</option>
}
</select>
</div>
<div class="form-group" style="flex:1">
<label class="form-label">Time (UTC, HH:mm)</label>
<input class="form-input" @bind="_form.Recurrence.TimeOfDayUtc" placeholder="06:00" />
</div>
@if (_form.Recurrence.Frequency == ReportFrequency.Weekly)
{
<div class="form-group" style="flex:1">
<label class="form-label">Day of week</label>
<select class="form-input" @bind="_form.Recurrence.DayOfWeek">
@foreach (var d in Enum.GetValues<DayOfWeek>())
{
<option value="@d">@d</option>
}
</select>
</div>
}
else if (_form.Recurrence.Frequency == ReportFrequency.Monthly)
{
<div class="form-group" style="flex:1">
<label class="form-label">Day of month</label>
<input class="form-input" type="number" min="1" max="31" @bind="_form.Recurrence.DayOfMonth" />
</div>
}
</div>
@* ── Type-specific options ── *@
<div class="form-group" style="border-top:1px solid var(--border);padding-top:12px">
<label class="form-label" style="font-weight:600">Options</label>
@switch (_form.Type)
{
case ReportType.Permissions:
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:center">
<label><input type="checkbox" @bind="_form.Options.IncludeInherited" /> Include inherited</label>
<label><input type="checkbox" @bind="_form.Options.ScanFolders" /> Scan folders</label>
<label><input type="checkbox" @bind="_form.Options.IncludeSubsites" /> Include subsites</label>
<label>Folder depth <input class="form-input" type="number" min="0" max="999" style="width:80px" @bind="_form.Options.FolderDepth" /></label>
</div>
break;
case ReportType.Storage:
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:center">
<label><input type="checkbox" @bind="_form.Options.IncludeSubsites" /> Include subsites</label>
<label><input type="checkbox" @bind="_form.Options.IncludeHiddenLibraries" /> Include hidden libraries</label>
<label><input type="checkbox" @bind="_form.Options.IncludeRecycleBin" /> Include recycle bin</label>
<label>Folder depth <input class="form-input" type="number" min="0" max="20" style="width:80px" @bind="_form.Options.FolderDepth" /></label>
</div>
break;
case ReportType.Duplicates:
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:center">
<label>Mode
<select class="form-input" style="width:120px" @bind="_form.Options.DuplicateMode">
<option value="Files">Files</option>
<option value="Folders">Folders</option>
</select>
</label>
<label><input type="checkbox" @bind="_form.Options.MatchSize" /> Match size</label>
<label><input type="checkbox" @bind="_form.Options.IncludeSubsites" /> Include subsites</label>
<label>Library <input class="form-input" style="width:160px" @bind="_form.Options.Library" placeholder="(all)" /></label>
</div>
break;
case ReportType.Search:
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:center">
<label>Extensions <input class="form-input" style="width:180px" @bind="_extensionsText" placeholder="pdf, docx" /></label>
<label>Regex <input class="form-input" style="width:180px" @bind="_form.Options.Regex" placeholder="(optional)" /></label>
<label>Max results <input class="form-input" type="number" min="1" style="width:100px" @bind="_form.Options.MaxResults" /></label>
<label>Library <input class="form-input" style="width:160px" @bind="_form.Options.Library" placeholder="(all)" /></label>
</div>
break;
case ReportType.UserAccess:
<label class="form-label">Target users (login/email, one per line)</label>
<textarea class="form-textarea" rows="2" @bind="_targetUsersText" placeholder="alice@contoso.com&#10;bob@contoso.com"></textarea>
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:center;margin-top:8px">
<label><input type="checkbox" @bind="_form.Options.IncludeInherited" /> Include inherited</label>
<label><input type="checkbox" @bind="_form.Options.IncludeSubsites" /> Include subsites</label>
</div>
break;
case ReportType.VersionCleanup:
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:center">
<label>Libraries (comma separated, blank = all) <input class="form-input" style="width:220px" @bind="_libraryTitlesText" /></label>
<label>Keep last <input class="form-input" type="number" min="0" style="width:80px" @bind="_form.Options.KeepLast" /></label>
<label><input type="checkbox" @bind="_form.Options.KeepFirst" /> Keep first version</label>
</div>
break;
}
</div>
@* ── Email delivery ── *@
<div class="form-group" style="border-top:1px solid var(--border);padding-top:12px">
<label style="font-weight:600"><input type="checkbox" @bind="_form.Email.Enabled" /> Email the report via Graph</label>
@if (_form.Email.Enabled)
{
<div class="alert alert-info" style="margin-top:8px">
Sent through the client's app-only certificate (requires the <strong>Mail.Send</strong> application
permission — re-run onboarding if the app was registered before this was added). The report file is attached.
</div>
<div class="form-group">
<label class="form-label">From (sender mailbox)</label>
<input class="form-input" @bind="_form.Email.From" placeholder="reports@contoso.com" />
</div>
<div class="form-row">
<div class="form-group" style="flex:1">
<label class="form-label">To (one per line)</label>
<textarea class="form-textarea" rows="2" @bind="_emailToText" placeholder="alice@contoso.com"></textarea>
</div>
<div class="form-group" style="flex:1">
<label class="form-label">Cc (one per line)</label>
<textarea class="form-textarea" rows="2" @bind="_emailCcText" placeholder="(optional)"></textarea>
</div>
</div>
<div class="form-group">
<label class="form-label">Subject</label>
<input class="form-input" @bind="_form.Email.Subject" />
</div>
<div class="form-group">
<label class="form-label">Body (HTML)</label>
<textarea class="form-textarea" rows="5" @bind="_form.Email.Body"></textarea>
<div class="text-muted" style="margin-top:4px">
Placeholders: {ReportName} {ClientName} {ReportType} {FileName} {DateUtc}
</div>
</div>
}
</div>
<div class="form-group">
<label><input type="checkbox" @bind="_form.Enabled" /> Enabled</label>
</div>
<div class="flex-row mt-8">
<button class="btn btn-primary" @onclick="SaveAsync">Save</button>
<button class="btn btn-secondary" @onclick="() => _showForm = false">Cancel</button>
</div>
</div>
}
@code {
private List<ScheduledReport> _schedules = new();
private List<TenantProfile> _appOnlyProfiles = new();
private bool _showForm;
private ScheduledReport? _editing;
private ScheduledReport _form = new();
private string _formError = string.Empty;
private string _pageMsg = string.Empty;
// Textarea/CSV scratch buffers mapped to/from the option lists on save.
private string _siteUrlsText = string.Empty;
private string _extensionsText = string.Empty;
private string _targetUsersText = string.Empty;
private string _libraryTitlesText = string.Empty;
private string _emailToText = string.Empty;
private string _emailCcText = string.Empty;
protected override async Task OnInitializedAsync()
{
if (UserContext.Role != UserRole.Admin) return;
await Reload();
}
private async Task Reload()
{
_schedules = (await ScheduleRepo.LoadAsync()).OrderBy(s => s.Name).ToList();
_appOnlyProfiles = (await ProfileRepo.LoadAsync()).Where(p => p.AppOnlyEnabled).OrderBy(p => p.Name).ToList();
}
private string ClientName(string profileId) =>
_appOnlyProfiles.FirstOrDefault(p => p.Id == profileId)?.Name ?? "(client removed)";
private static string RecurrenceSummary(RecurrenceRule r) => r.Frequency switch
{
ReportFrequency.Daily => $"Daily at {r.TimeOfDayUtc} UTC",
ReportFrequency.Weekly => $"Weekly {r.DayOfWeek} at {r.TimeOfDayUtc} UTC",
ReportFrequency.Monthly => $"Monthly day {r.DayOfMonth} at {r.TimeOfDayUtc} UTC",
_ => r.TimeOfDayUtc
};
private void AddNew()
{
_editing = null;
_form = new ScheduledReport
{
ProfileId = _appOnlyProfiles.FirstOrDefault()?.Id ?? string.Empty,
CreatedBy = UserContext.Email
};
_siteUrlsText = _extensionsText = _targetUsersText = _libraryTitlesText = string.Empty;
_emailToText = _emailCcText = string.Empty;
_formError = string.Empty;
_showForm = true;
}
private void Edit(ScheduledReport s)
{
_editing = s;
// Deep-ish copy so cancel discards edits.
_form = new ScheduledReport
{
Id = s.Id, ProfileId = s.ProfileId, Name = s.Name, Type = s.Type,
AllSites = s.AllSites, SiteUrls = new List<string>(s.SiteUrls),
MergeMode = s.MergeMode, Format = s.Format, Enabled = s.Enabled,
CreatedBy = s.CreatedBy, CreatedUtc = s.CreatedUtc,
LastRunUtc = s.LastRunUtc, NextRunUtc = s.NextRunUtc,
Recurrence = new RecurrenceRule
{
Frequency = s.Recurrence.Frequency, TimeOfDayUtc = s.Recurrence.TimeOfDayUtc,
DayOfWeek = s.Recurrence.DayOfWeek, DayOfMonth = s.Recurrence.DayOfMonth
},
Options = Clone(s.Options),
Email = new ReportEmailSettings
{
Enabled = s.Email.Enabled, From = s.Email.From,
To = new List<string>(s.Email.To), Cc = new List<string>(s.Email.Cc),
Subject = s.Email.Subject, Body = s.Email.Body
}
};
_siteUrlsText = string.Join("\n", s.SiteUrls);
_extensionsText = string.Join(", ", s.Options.Extensions);
_targetUsersText = string.Join("\n", s.Options.TargetUserLogins);
_libraryTitlesText = string.Join(", ", s.Options.LibraryTitles);
_emailToText = string.Join("\n", s.Email.To);
_emailCcText = string.Join("\n", s.Email.Cc);
_formError = string.Empty;
_showForm = true;
}
private static ScheduledReportOptions Clone(ScheduledReportOptions o) => new()
{
IncludeInherited = o.IncludeInherited, ScanFolders = o.ScanFolders, FolderDepth = o.FolderDepth,
IncludeSubsites = o.IncludeSubsites, PerLibrary = o.PerLibrary,
IncludeHiddenLibraries = o.IncludeHiddenLibraries, IncludePreservationHold = o.IncludePreservationHold,
IncludeListAttachments = o.IncludeListAttachments, IncludeRecycleBin = o.IncludeRecycleBin,
DuplicateMode = o.DuplicateMode, MatchSize = o.MatchSize, MatchCreated = o.MatchCreated,
MatchModified = o.MatchModified, MatchSubfolderCount = o.MatchSubfolderCount, MatchFileCount = o.MatchFileCount,
Library = o.Library, LibraryTitles = new List<string>(o.LibraryTitles), KeepLast = o.KeepLast, KeepFirst = o.KeepFirst,
Extensions = new List<string>(o.Extensions), Regex = o.Regex, MaxResults = o.MaxResults,
TargetUserLogins = new List<string>(o.TargetUserLogins)
};
private async Task SaveAsync()
{
_formError = string.Empty;
if (string.IsNullOrWhiteSpace(_form.Name)) { _formError = "Name is required."; return; }
if (string.IsNullOrWhiteSpace(_form.ProfileId)) { _formError = "Select a client."; return; }
// VersionCleanup has no CSV exporter.
if (_form.Type == ReportType.VersionCleanup) _form.Format = ReportFormat.Html;
// Map scratch buffers back to option lists.
_form.SiteUrls = _siteUrlsText.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
_form.Options.Extensions = _extensionsText.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
_form.Options.TargetUserLogins = _targetUsersText.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
_form.Options.LibraryTitles = _libraryTitlesText.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
if (!_form.AllSites && _form.SiteUrls.Count == 0) { _formError = "Add at least one site URL, or choose All sites."; return; }
if (_form.Type == ReportType.UserAccess && _form.Options.TargetUserLogins.Count == 0) { _formError = "User Access reports need at least one target user."; return; }
// Map email scratch buffers back to lists and validate when delivery is on.
_form.Email.To = _emailToText.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
_form.Email.Cc = _emailCcText.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
if (_form.Email.Enabled)
{
if (string.IsNullOrWhiteSpace(_form.Email.From)) { _formError = "Email delivery needs a sender mailbox (From)."; return; }
if (_form.Email.To.Count == 0 && _form.Email.Cc.Count == 0) { _formError = "Email delivery needs at least one To or Cc recipient."; return; }
}
// Arm the next run from now so the scheduler picks it up on the right cadence.
_form.NextRunUtc = _form.Recurrence.ComputeNextRunUtc(DateTime.UtcNow);
await ScheduleRepo.UpsertAsync(_form);
_showForm = false;
_editing = null;
await Reload();
}
private async Task ToggleEnabledAsync(ScheduledReport s)
{
s.Enabled = !s.Enabled;
if (s.Enabled && s.NextRunUtc is null) s.NextRunUtc = s.Recurrence.ComputeNextRunUtc(DateTime.UtcNow);
await ScheduleRepo.UpsertAsync(s);
await Reload();
}
private async Task DeleteAsync(ScheduledReport s)
{
await ScheduleRepo.DeleteAsync(s.Id);
await Reload();
}
private async Task RunNowAsync(ScheduledReport s)
{
// Register through the coordinator so this manual run is stoppable and can't
// overlap a scheduler-triggered run of the same schedule.
var token = Coordinator.TryBegin(s.Id, CancellationToken.None);
if (token is null) { _pageMsg = $"'{s.Name}' is already running."; return; }
_pageMsg = string.Empty;
await InvokeAsync(StateHasChanged);
try
{
var report = await Runner.RunAsync(s, token.Value);
_pageMsg = report.Status == ReportRunStatus.Success
? $"'{s.Name}' generated {report.FileName}. See Reports."
: $"'{s.Name}' failed: {report.Error}";
}
catch (OperationCanceledException) { _pageMsg = $"'{s.Name}' was stopped."; }
catch (Exception ex) { _pageMsg = $"'{s.Name}' failed: {ex.Message}"; }
finally { Coordinator.Complete(s.Id); await Reload(); }
}
private void Stop(ScheduledReport s) =>
_pageMsg = Coordinator.Cancel(s.Id)
? $"Stop signal sent to '{s.Name}'. It ends after the current site finishes."
: $"'{s.Name}' is not running.";
private void StopAll()
{
var n = Coordinator.CancelAll();
_pageMsg = n == 0 ? "No runs in progress." : $"Stop signal sent to {n} running report(s).";
}
private void PauseScheduler()
{
Coordinator.Pause();
_pageMsg = "Scheduler paused — no schedules will fire until resumed. Runs in progress keep going (Stop them individually).";
}
private void ResumeScheduler()
{
Coordinator.Resume();
_pageMsg = "Scheduler resumed.";
}
}
+118 -21
View File
@@ -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&#10;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,29 +81,92 @@
<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>
<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>
<tbody>
@foreach (var r in _results.Take(500))
@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)
{
<tr>
<td>@r.UserDisplayName</td>
<td>@r.SiteTitle</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>
<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>
}
</tbody>
</table>
</div>
@if (_results.Count > 500) { <div class="text-muted mt-8">@T["audit.msg.showFirst500Export"]</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>
<tbody>
@foreach (var r in _results.Take(500))
{
<tr>
<td>@r.UserDisplayName</td>
<td>@r.SiteTitle</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>
@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; }