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:
@@ -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 };
|
||||
_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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.";
|
||||
}
|
||||
}
|
||||
@@ -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,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; }
|
||||
|
||||
Reference in New Issue
Block a user