This commit is contained in:
2026-06-02 17:39:58 +02:00
36 changed files with 2520 additions and 463 deletions
+27 -26
View File
@@ -8,32 +8,33 @@
@inject UserAccessHtmlExportService HtmlExport
@inject WebExportService WebExport
@inject IAuditService Audit
@inject TranslationSource T
@rendermode InteractiveServer
<h1 class="page-title">User Access Audit</h1>
<p class="page-subtitle">Find all permissions for one or more users across multiple sites.</p>
<h1 class="page-title">@T["tab.userAccessAudit"]</h1>
<p class="page-subtitle">@T["audit.subtitle"]</p>
@if (!Session.HasProfile) { <NoProfilePrompt /> return; }
<div class="card">
<div class="form-group">
<div class="flex-row">
<label class="form-label" style="margin:0">Users</label>
<label class="form-label" style="margin:0">@T["audit.lbl.users"]</label>
<div class="spacer"></div>
<label style="font-weight:normal"><input type="checkbox" @bind="_includeGuests" /> Include guests</label>
<label style="font-weight:normal"><input type="checkbox" @bind="_includeGuests" /> @T["directory.chk.guests"]</label>
<button class="btn btn-secondary btn-sm" @onclick="LoadUsers" disabled="@_loadingUsers">
@(_loadingUsers ? $"Loading… ({_loadCount})" : "Load Users")
@(_loadingUsers ? string.Format(T["audit.btn.loading"], _loadCount) : T["audit.btn.loadUsers"])
</button>
</div>
@if (_directoryUsers.Count > 0)
{
<div class="flex-row mt-8">
<input class="form-input" style="width:260px" @bind="_userFilter" @bind:event="oninput" placeholder="Filter by name or email…" />
<span class="text-muted">@_selectedEmails.Count selected</span>
<input class="form-input" style="width:260px" @bind="_userFilter" @bind:event="oninput" placeholder="@T["audit.ph.filterUsers"]" />
<span class="text-muted">@string.Format(T["audit.lbl.selectedCount"], _selectedEmails.Count)</span>
<div class="spacer"></div>
<button class="btn btn-link btn-sm" @onclick="SelectAllFiltered">Select all (@FilteredUsers.Count())</button>
<button class="btn btn-link btn-sm" @onclick="ClearSelection">Clear</button>
<button class="btn btn-link btn-sm" @onclick="SelectAllFiltered">@string.Format(T["audit.btn.selectAll"], FilteredUsers.Count())</button>
<button class="btn btn-link btn-sm" @onclick="ClearSelection">@T["settings.logo.clear"]</button>
</div>
<div class="user-select-list">
@foreach (var u in FilteredUsers.Take(500))
@@ -44,29 +45,29 @@
@onchange="e => ToggleUser(email, (bool)e.Value!)" />
<span class="user-select-name">@u.DisplayName</span>
<span class="text-muted">@email</span>
@if (u.UserType == "Guest") { <span class="chip chip-yellow">Guest</span> }
@if (u.UserType == "Guest") { <span class="chip chip-yellow">@T["common.guest"]</span> }
</label>
}
</div>
@if (FilteredUsers.Count() > 500) { <div class="text-muted mt-8">Showing first 500. Refine filter to narrow.</div> }
@if (FilteredUsers.Count() > 500) { <div class="text-muted mt-8">@T["audit.msg.showFirst500Filter"]</div> }
}
<label class="form-label mt-8">Additional emails (one per line)</label>
<label class="form-label mt-8">@T["audit.lbl.additionalEmails"]</label>
<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="form-row">
<div class="form-group" style="display:flex;align-items:center;gap:16px;padding-top:20px">
<label><input type="checkbox" @bind="_includeInherited" /> Include inherited</label>
<label><input type="checkbox" @bind="_scanFolders" /> Scan folders</label>
<label><input type="checkbox" @bind="_includeSubsites" /> Include subsites</label>
<label><input type="checkbox" @bind="_includeInherited" /> @T["audit.chk.includeInherited"]</label>
<label><input type="checkbox" @bind="_scanFolders" /> @T["chk.scan.folders"]</label>
<label><input type="checkbox" @bind="_includeSubsites" /> @T["chk.include.subsites"]</label>
</div>
</div>
<div class="flex-row mt-8">
<button class="btn btn-primary" @onclick="RunAudit" disabled="@_running">
@(_running ? "Auditing" : "Audit Users")
@(_running ? T["audit.btn.auditing"] : T["audit.btn.auditUsers"])
</button>
@if (_running) { <button class="btn btn-secondary" @onclick="Cancel">Cancel</button> }
@if (_running) { <button class="btn btn-secondary" @onclick="Cancel">@T["btn.cancel"]</button> }
</div>
<ProgressPanel IsRunning="_running" StatusMessage="@_status" Current="_current" Total="_total" />
</div>
@@ -77,14 +78,14 @@
{
<div class="card">
<div class="flex-row">
<div class="card-title">Audit Results <span class="count-badge">@_results.Count</span></div>
<div class="card-title">@T["audit.results.title"] <span class="count-badge">@_results.Count</span></div>
<div class="spacer"></div>
<button class="btn btn-secondary btn-sm" @onclick="ExportCsv">Export CSV</button>
<button class="btn btn-secondary btn-sm" @onclick="ExportHtml">Export HTML</button>
<button class="btn btn-secondary btn-sm" @onclick="ExportCsv">@T["audit.btn.exportCsv"]</button>
<button class="btn btn-secondary btn-sm" @onclick="ExportHtml">@T["audit.btn.exportHtml"]</button>
</div>
<div class="data-table-wrap">
<table class="data-table">
<thead><tr><th>User</th><th>Site</th><th>Object</th><th>Permission</th><th>Access Type</th><th>Granted Through</th></tr></thead>
<thead><tr><th>@T["report.col.user"]</th><th>@T["report.col.site"]</th><th>@T["report.col.object"]</th><th>@T["audit.col.permission"]</th><th>@T["report.col.access_type"]</th><th>@T["report.col.granted_through"]</th></tr></thead>
<tbody>
@foreach (var r in _results.Take(500))
{
@@ -92,7 +93,7 @@
<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">High</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>
@@ -100,7 +101,7 @@
</tbody>
</table>
</div>
@if (_results.Count > 500) { <div class="text-muted mt-8">Showing first 500. Export for full results.</div> }
@if (_results.Count > 500) { <div class="text-muted mt-8">@T["audit.msg.showFirst500Export"]</div> }
</div>
}
@@ -155,7 +156,7 @@
var userList = _selectedEmails
.Concat(_users.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
if (!userList.Any()) { _error = "Select at least one user or enter an email."; _running = false; return; }
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));
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
@@ -163,11 +164,11 @@
{
var opts = new ScanOptions(_includeInherited, _scanFolders, 1, _includeSubsites);
_results = (await AuditSvc.AuditUsersAsync(SessionMgr, Session.CurrentProfile!, userList, siteList, opts, progress, _cts.Token)).ToList();
_status = $"Found {_results.Count} access entries.";
_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)");
}
catch (OperationCanceledException) { _status = "Cancelled."; }
catch (OperationCanceledException) { _status = T["audit.status.cancelled"]; }
catch (Exception ex) { _error = ex.Message; }
finally { _running = false; await InvokeAsync(StateHasChanged); }
}