Files
SharepointToolbox-Web/Components/Pages/Duplicates.razor
T
kawa 57f5239cfc Wire auto-elevate ownership across all SharePoint operations
The "Auto-elevate ownership when permission scan is denied" setting was
dead code: the toggle was persisted but never read, the audit flow never
passed its onAccessDenied callback, and EnrichException wrapped every CSOM
error (including ServerUnauthorizedAccessException) into a generic
InvalidOperationException so the access-denied catch could never match.

Centralize elevation instead of per-call-site callbacks:

- Throw typed SharePointAccessDeniedException from EnrichException on
  access-denied, preserving the failing site URL and enriched diagnostic.
- Add scoped IElevationCoordinator that catches it, and when AutoTakeOwnership
  is enabled takes site-collection admin via the tenant admin endpoint and
  retries the operation once. Per-site dedupe prevents loops; admin-host
  denials are not treated as ownership issues. Retry is safe because each
  wrapped operation closure re-issues its own CSOM loads.
- Wrap all site-scoped operations (Storage, Permissions, Duplicates, Search,
  VersionCleanup, FolderStructure, BulkMembers, FileTransfer, Templates) and
  the UserAccessAudit per-site scan in the coordinator.
- Drop the unused onAccessDenied parameter from IUserAccessAuditService.

Elevation still requires SharePoint tenant admin rights on the signed-in
account; the coordinator surfaces a clear message when that is missing.

Also keeps the prior StorageService change that avoids admin-gated
folder.StorageMetrics (403 for delegated non-admin tokens).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:16:12 +02:00

116 lines
5.5 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@page "/duplicates"
@attribute [Authorize]
@inject IUserSessionService Session
@inject ISessionManager SessionMgr
@inject IElevationCoordinator Elevation
@inject IDuplicatesService DupSvc
@inject DuplicatesCsvExportService CsvExport
@inject DuplicatesHtmlExportService HtmlExport
@inject WebExportService WebExport
@rendermode InteractiveServer
<h1 class="page-title">Duplicate Detection</h1>
@if (!Session.HasProfile) { <NoProfilePrompt /> return; }
<div class="card">
<div class="form-row">
<div class="form-group">
<label class="form-label">Site URL</label>
<input class="form-input" @bind="_siteUrl" placeholder="@Session.CurrentProfile!.TenantUrl" />
</div>
<div class="form-group">
<label class="form-label">Mode</label>
<select class="form-select" @bind="_mode" style="width:120px">
<option value="Files">Files</option>
<option value="Folders">Folders</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Library (optional)</label>
<input class="form-input" @bind="_library" />
</div>
</div>
<div class="flex-row">
<label><input type="checkbox" @bind="_matchSize" /> Match size</label>
<label><input type="checkbox" @bind="_matchCreated" /> Match created</label>
<label><input type="checkbox" @bind="_matchModified" /> Match modified</label>
@if (_mode == "Folders")
{
<label><input type="checkbox" @bind="_matchFolderCount" /> Match subfolder count</label>
<label><input type="checkbox" @bind="_matchFileCount" /> Match file count</label>
}
</div>
<div class="flex-row mt-8">
<button class="btn btn-primary" @onclick="RunScan" disabled="@_running">
@(_running ? "Scanning…" : "Find Duplicates")
</button>
@if (_running) { <button class="btn btn-secondary" @onclick="Cancel">Cancel</button> }
</div>
<ProgressPanel IsRunning="_running" StatusMessage="_status" Current="_current" Total="_total" />
</div>
@if (!string.IsNullOrEmpty(_error)) { <div class="alert alert-error">@_error</div> }
@if (_results.Count > 0)
{
<div class="card">
<div class="flex-row">
<div class="card-title">Duplicate Groups <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>
</div>
@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">
@g.Name <span class="chip chip-blue">@g.Items.Count copies</span>
</div>
@foreach (var item in g.Items)
{
<div style="padding:4px 12px;font-size:12px;border-top:1px solid var(--border)">
<span style="color:var(--text-muted)">@item.Library</span> @item.Path
@if (item.SizeBytes.HasValue) { <span class="text-muted"> (@((item.SizeBytes.Value/1024.0).ToString("F1")) KB)</span> }
</div>
}
</div>
}
@if (_results.Count > 100) { <div class="text-muted mt-8">Showing first 100 groups. Export for all.</div> }
</div>
}
@code {
private string _siteUrl = string.Empty, _library = string.Empty, _mode = "Files";
private bool _matchSize = true, _matchCreated, _matchModified, _matchFolderCount, _matchFileCount;
private bool _running; private string _status = string.Empty, _error = string.Empty;
private int _current, _total;
private List<DuplicateGroup> _results = new();
private CancellationTokenSource? _cts;
private async Task RunScan()
{
_error = string.Empty; _results.Clear(); _running = true;
_cts = new CancellationTokenSource();
var siteUrl = string.IsNullOrWhiteSpace(_siteUrl) ? Session.CurrentProfile!.TenantUrl : _siteUrl.Trim();
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
try
{
var opts = new DuplicateScanOptions(_mode, _matchSize, _matchCreated, _matchModified, _matchFolderCount, _matchFileCount, Library: _library.TrimOrNull());
_results = (await Elevation.RunAsync(async c =>
{
var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, c);
return await DupSvc.ScanDuplicatesAsync(ctx, opts, progress, c);
}, _cts.Token)).ToList();
_status = $"Found {_results.Count} duplicate groups.";
}
catch (OperationCanceledException) { _status = "Cancelled."; }
catch (Exception ex) { _error = ex.Message; }
finally { _running = false; await InvokeAsync(StateHasChanged); }
}
private void Cancel() => _cts?.Cancel();
private async Task ExportCsv() { await WebExport.DownloadCsvAsync(CsvExport.BuildCsv(_results), $"duplicates_{DateTime.Now:yyyyMMdd_HHmmss}.csv"); }
private async Task ExportHtml() { await WebExport.DownloadHtmlAsync(HtmlExport.BuildHtml(_results), $"duplicates_{DateTime.Now:yyyyMMdd_HHmmss}.html"); }
}