Initial commit

This commit is contained in:
2026-06-02 10:51:14 +02:00
committed by kawa
commit d19092c84e
182 changed files with 13757 additions and 0 deletions
+101
View File
@@ -0,0 +1,101 @@
@page "/admin/audit"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@inject IAuditService AuditService
@inject IUserContextAccessor UserContext
@inject NavigationManager Nav
@rendermode InteractiveServer
@using SharepointToolbox.Web.Core.Models
@using SharepointToolbox.Web.Services.Audit
@using SharepointToolbox.Web.Services.Session
<h1 class="page-title">Audit Logs</h1>
<p class="page-subtitle">All technician and admin actions within the application.</p>
@if (!UserContext.IsAuthenticated || UserContext.Role != UserRole.Admin)
{
<div class="alert alert-error">Access denied. Admin role required.</div>
return;
}
<div class="flex-row" style="margin-bottom:16px;flex-wrap:wrap;gap:8px">
<input class="form-input" style="width:200px" placeholder="Filter by user..." @bind="_filterUser" @bind:event="oninput" />
<input class="form-input" style="width:200px" placeholder="Filter by client..." @bind="_filterClient" @bind:event="oninput" />
<input class="form-input" style="width:200px" placeholder="Filter by action..." @bind="_filterAction" @bind:event="oninput" />
<a href="/audit/export" class="btn btn-secondary" target="_blank">Export CSV</a>
</div>
@if (_loading)
{
<div class="alert alert-info">Loading audit log...</div>
}
else if (_filtered.Count == 0)
{
<div class="alert alert-info">No audit entries found.</div>
}
else
{
<div class="card" style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse;font-size:13px">
<thead>
<tr style="border-bottom:2px solid var(--border)">
<th style="text-align:left;padding:6px">Timestamp</th>
<th style="text-align:left;padding:6px">User</th>
<th style="text-align:left;padding:6px">Role</th>
<th style="text-align:left;padding:6px">Action</th>
<th style="text-align:left;padding:6px">Client</th>
<th style="text-align:left;padding:6px">Sites</th>
<th style="text-align:left;padding:6px">Details</th>
</tr>
</thead>
<tbody>
@foreach (var e in _filtered)
{
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:6px;white-space:nowrap">@e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")</td>
<td style="padding:6px">@e.UserDisplay<br /><span class="text-muted" style="font-size:11px">@e.UserEmail</span></td>
<td style="padding:6px"><span class="chip @RoleChipClass(e.UserRole)">@e.UserRole</span></td>
<td style="padding:6px;font-weight:600">@e.Action</td>
<td style="padding:6px">@e.ClientName</td>
<td style="padding:6px">@string.Join(", ", e.Sites)</td>
<td style="padding:6px;color:var(--text-muted)">@e.Details</td>
</tr>
}
</tbody>
</table>
</div>
<p class="text-muted" style="margin-top:8px;font-size:12px">Showing @_filtered.Count of @_entries.Count entries</p>
}
@code {
private List<AuditEntry> _entries = new();
private List<AuditEntry> _filtered = new();
private bool _loading = true;
private string _filterUser = string.Empty;
private string _filterClient = string.Empty;
private string _filterAction = string.Empty;
protected override async Task OnInitializedAsync()
{
_entries = (await AuditService.GetAllAsync())
.OrderByDescending(e => e.Timestamp)
.ToList();
_loading = false;
ApplyFilters();
}
private void ApplyFilters()
{
_filtered = _entries.Where(e =>
(string.IsNullOrEmpty(_filterUser) || e.UserEmail.Contains(_filterUser, StringComparison.OrdinalIgnoreCase) || e.UserDisplay.Contains(_filterUser, StringComparison.OrdinalIgnoreCase)) &&
(string.IsNullOrEmpty(_filterClient) || e.ClientName.Contains(_filterClient, StringComparison.OrdinalIgnoreCase)) &&
(string.IsNullOrEmpty(_filterAction) || e.Action.Contains(_filterAction, StringComparison.OrdinalIgnoreCase))
).ToList();
}
private static string RoleChipClass(UserRole role) => role switch
{
UserRole.Admin => "chip-red",
UserRole.TechN1 => "chip-green",
_ => "chip-blue"
};
}
+111
View File
@@ -0,0 +1,111 @@
@page "/admin/users"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@inject IUserService UserService
@inject IUserContextAccessor UserContext
@inject NavigationManager Nav
@rendermode InteractiveServer
@using SharepointToolbox.Web.Core.Models
@using SharepointToolbox.Web.Services.Auth
@using SharepointToolbox.Web.Services.Session
<h1 class="page-title">User Management</h1>
<p class="page-subtitle">Manage technician accounts and roles. Auto-provisioned on first OIDC login.</p>
@if (!UserContext.IsAuthenticated || UserContext.Role != UserRole.Admin)
{
<div class="alert alert-error">Access denied. Admin role required.</div>
return;
}
@if (!string.IsNullOrEmpty(_message))
{
<div class="alert @(_isError ? "alert-error" : "alert-info")">@_message</div>
}
@if (_users.Count == 0)
{
<div class="alert alert-info">No users provisioned yet.</div>
}
else
{
<div class="card" style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse">
<thead>
<tr style="border-bottom:2px solid var(--border)">
<th style="text-align:left;padding:8px">User</th>
<th style="text-align:left;padding:8px">Email</th>
<th style="text-align:left;padding:8px">Role</th>
<th style="text-align:left;padding:8px">Last Login</th>
<th style="text-align:left;padding:8px">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var user in _users)
{
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:8px">@user.DisplayName</td>
<td style="padding:8px">@user.Email</td>
<td style="padding:8px">
<select class="form-input" style="width:130px"
value="@user.Role"
@onchange="e => OnRoleChange(user, e)"
disabled="@(user.Email == UserContext.Email)">
@foreach (var role in Enum.GetValues<UserRole>())
{
<option value="@role" selected="@(user.Role == role)">@role</option>
}
</select>
</td>
<td style="padding:8px">@(user.LastLogin?.ToString("yyyy-MM-dd HH:mm") ?? "Never")</td>
<td style="padding:8px">
@if (user.Email != UserContext.Email)
{
<button class="btn btn-danger btn-sm" @onclick="() => DeleteUserAsync(user)">Remove</button>
}
else
{
<span class="chip chip-green">You</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
@code {
private List<AppUser> _users = new();
private string _message = string.Empty;
private bool _isError;
protected override async Task OnInitializedAsync()
{
_users = (await UserService.GetAllAsync()).ToList();
}
private async Task OnRoleChange(AppUser user, ChangeEventArgs e)
{
if (!Enum.TryParse<UserRole>(e.Value?.ToString(), out var newRole)) return;
try
{
await UserService.UpdateRoleAsync(user.Id, newRole);
user.Role = newRole;
_message = $"Role updated for {user.DisplayName}.";
_isError = false;
}
catch (Exception ex)
{
_message = $"Error: {ex.Message}";
_isError = true;
}
}
private async Task DeleteUserAsync(AppUser user)
{
await UserService.DeleteAsync(user.Id);
_users.Remove(user);
_message = $"User {user.DisplayName} removed.";
_isError = false;
}
}
+116
View File
@@ -0,0 +1,116 @@
@page "/bulk-members"
@attribute [Authorize]
@inject IUserSessionService Session
@inject IUserContextAccessor UserContext
@inject ISessionManager SessionMgr
@inject IBulkMemberService BulkSvc
@inject ICsvValidationService CsvValidation
@inject BulkResultCsvExportService ExportSvc
@inject WebExportService WebExport
@rendermode InteractiveServer
<h1 class="page-title">Bulk Members</h1>
<p class="page-subtitle">Add users to SharePoint groups from a CSV file.</p>
@if (!Session.HasProfile) { <NoProfilePrompt /> return; }
@if (UserContext.Role < UserRole.TechN1) { <WriteGuard /> return; }
<div class="card">
<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">CSV File (GroupName, GroupUrl, Email, Role)</label>
<InputFile OnChange="LoadFile" accept=".csv" class="form-input" />
</div>
@if (_rows.Count > 0)
{
<div class="alert alert-info mt-8">
@_rows.Count(r => r.IsValid) valid rows, @_rows.Count(r => !r.IsValid) errors.
</div>
<div class="data-table-wrap" style="max-height:200px;overflow-y:auto">
<table class="data-table">
<thead><tr><th>Group</th><th>Email</th><th>Role</th><th>Status</th></tr></thead>
<tbody>
@foreach (var row in _rows.Take(50))
{
<tr class="@(row.IsValid ? "val-valid" : "val-error")">
<td>@(row.Record?.GroupName ?? "—")</td>
<td>@(row.Record?.Email ?? "—")</td>
<td>@(row.Record?.Role ?? "—")</td>
<td>@(row.IsValid ? "✓" : string.Join("; ", row.Errors))</td>
</tr>
}
</tbody>
</table>
</div>
}
<div class="flex-row mt-8">
<button class="btn btn-primary" @onclick="RunBulk" disabled="@(_running || _rows.Count == 0 || _rows.All(r => !r.IsValid))">
@(_running ? "Processing…" : "Add Members")
</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 (_summary != null)
{
<div class="card">
<div class="alert @(_summary.HasFailures ? "alert-warn" : "alert-success")">
Processed: @_summary.SuccessCount / @_summary.TotalCount. Failures: @_summary.FailedCount
</div>
@if (_summary.HasFailures)
{
<button class="btn btn-secondary btn-sm mt-8" @onclick="ExportErrors">Export Errors CSV</button>
}
</div>
}
@code {
private string _siteUrl = string.Empty;
private List<CsvValidationRow<BulkMemberRow>> _rows = new();
private bool _running; private string _status = string.Empty, _error = string.Empty;
private int _current, _total;
private BulkOperationSummary<BulkMemberRow>? _summary;
private CancellationTokenSource? _cts;
private async Task LoadFile(InputFileChangeEventArgs e)
{
_rows.Clear();
var file = e.File;
using var stream = file.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024);
_rows = CsvValidation.ParseAndValidateMembers(stream);
}
private async Task RunBulk()
{
_error = string.Empty; _summary = null; _running = true;
_cts = new CancellationTokenSource();
var validRows = _rows.Where(r => r.IsValid && r.Record != null).Select(r => r.Record!).ToList();
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 ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, _cts.Token);
_summary = await BulkSvc.AddMembersAsync(ctx, Session.CurrentProfile!, validRows, progress, _cts.Token);
_status = $"Complete: {_summary.SuccessCount} added, {_summary.FailedCount} failed.";
}
catch (OperationCanceledException) { _status = "Cancelled."; }
catch (Exception ex) { _error = ex.Message; }
finally { _running = false; await InvokeAsync(StateHasChanged); }
}
private void Cancel() => _cts?.Cancel();
private async Task ExportErrors()
{
if (_summary == null) return;
var csv = ExportSvc.BuildFailedItemsCsv(_summary.Results.ToList());
await WebExport.DownloadCsvAsync(csv, $"bulk_members_errors_{DateTime.Now:yyyyMMdd_HHmmss}.csv");
}
}
+115
View File
@@ -0,0 +1,115 @@
@page "/bulk-sites"
@attribute [Authorize]
@inject IUserSessionService Session
@inject IUserContextAccessor UserContext
@inject ISessionManager SessionMgr
@inject IBulkSiteService BulkSvc
@inject ICsvValidationService CsvValidation
@inject BulkResultCsvExportService ExportSvc
@inject WebExportService WebExport
@rendermode InteractiveServer
<h1 class="page-title">Bulk Site Creation</h1>
<p class="page-subtitle">Create multiple SharePoint sites from a CSV file.</p>
@if (!Session.HasProfile) { <NoProfilePrompt /> return; }
@if (UserContext.Role < UserRole.TechN1) { <WriteGuard /> return; }
<div class="card">
<div class="form-group">
<label class="form-label">Admin Center URL</label>
<input class="form-input" @bind="_adminUrl" placeholder="https://contoso-admin.sharepoint.com" />
</div>
<div class="form-group">
<label class="form-label">CSV File (Name, Alias, Type, Template, Owners, Members)</label>
<InputFile OnChange="LoadFile" accept=".csv" class="form-input" />
</div>
@if (_rows.Count > 0)
{
<div class="alert alert-info mt-8">@_rows.Count(r => r.IsValid) valid, @_rows.Count(r => !r.IsValid) errors.</div>
<div class="data-table-wrap" style="max-height:200px;overflow-y:auto">
<table class="data-table">
<thead><tr><th>Name</th><th>Type</th><th>Alias</th><th>Status</th></tr></thead>
<tbody>
@foreach (var row in _rows.Take(50))
{
<tr class="@(row.IsValid ? "val-valid" : "val-error")">
<td>@(row.Record?.Name ?? "—")</td>
<td>@(row.Record?.Type ?? "—")</td>
<td>@(row.Record?.Alias ?? "—")</td>
<td>@(row.IsValid ? "✓" : string.Join("; ", row.Errors))</td>
</tr>
}
</tbody>
</table>
</div>
}
<div class="flex-row mt-8">
<button class="btn btn-primary" @onclick="RunBulk" disabled="@(_running || _rows.Count == 0 || _rows.All(r => !r.IsValid))">
@(_running ? "Creating…" : "Create Sites")
</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 (_summary != null)
{
<div class="card">
<div class="alert @(_summary.HasFailures ? "alert-warn" : "alert-success")">
Created: @_summary.SuccessCount / @_summary.TotalCount. Failures: @_summary.FailedCount
</div>
@if (_summary.HasFailures)
{
<button class="btn btn-secondary btn-sm mt-8" @onclick="ExportErrors">Export Errors CSV</button>
}
</div>
}
@code {
private string _adminUrl = string.Empty;
private List<CsvValidationRow<BulkSiteRow>> _rows = new();
private bool _running; private string _status = string.Empty, _error = string.Empty;
private int _current, _total;
private BulkOperationSummary<BulkSiteRow>? _summary;
private CancellationTokenSource? _cts;
private async Task LoadFile(InputFileChangeEventArgs e)
{
_rows.Clear();
using var stream = e.File.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024);
_rows = CsvValidation.ParseAndValidateSites(stream);
}
private async Task RunBulk()
{
_error = string.Empty; _summary = null; _running = true;
_cts = new CancellationTokenSource();
var validRows = _rows.Where(r => r.IsValid && r.Record != null).Select(r => r.Record!).ToList();
var adminUrl = string.IsNullOrWhiteSpace(_adminUrl)
? Session.CurrentProfile!.TenantUrl.Replace(".sharepoint.com", "-admin.sharepoint.com")
: _adminUrl.Trim();
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
try
{
var ctx = await SessionMgr.GetOrCreateContextAsync(adminUrl, Session.CurrentProfile!, _cts.Token);
_summary = await BulkSvc.CreateSitesAsync(ctx, validRows, progress, _cts.Token);
_status = $"Complete: {_summary.SuccessCount} created, {_summary.FailedCount} failed.";
}
catch (OperationCanceledException) { _status = "Cancelled."; }
catch (Exception ex) { _error = ex.Message; }
finally { _running = false; await InvokeAsync(StateHasChanged); }
}
private void Cancel() => _cts?.Cancel();
private async Task ExportErrors()
{
if (_summary == null) return;
var csv = ExportSvc.BuildFailedItemsCsv(_summary.Results.ToList());
await WebExport.DownloadCsvAsync(csv, $"bulk_sites_errors_{DateTime.Now:yyyyMMdd_HHmmss}.csv");
}
}
+111
View File
@@ -0,0 +1,111 @@
@page "/duplicates"
@attribute [Authorize]
@inject IUserSessionService Session
@inject ISessionManager SessionMgr
@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 ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, _cts.Token);
var opts = new DuplicateScanOptions(_mode, _matchSize, _matchCreated, _matchModified, _matchFolderCount, _matchFileCount, Library: _library.TrimOrNull());
_results = (await DupSvc.ScanDuplicatesAsync(ctx, opts, progress, _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"); }
}
+141
View File
@@ -0,0 +1,141 @@
@page "/transfer"
@attribute [Authorize]
@inject IUserSessionService Session
@inject IUserContextAccessor UserContext
@inject ISessionManager SessionMgr
@inject IFileTransferService TransferSvc
@rendermode InteractiveServer
<h1 class="page-title">File Transfer</h1>
@if (!Session.HasProfile) { <NoProfilePrompt /> return; }
@if (UserContext.Role < UserRole.TechN1) { <WriteGuard /> return; }
<div class="card">
<div class="card-title">Source</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Source Site URL</label>
<input class="form-input" @bind="_srcSiteUrl" placeholder="@Session.CurrentProfile!.TenantUrl" />
</div>
<div class="form-group">
<label class="form-label">Source Library</label>
<input class="form-input" @bind="_srcLibrary" placeholder="Shared Documents" />
</div>
<div class="form-group">
<label class="form-label">Source Folder (optional)</label>
<input class="form-input" @bind="_srcFolder" placeholder="SubFolder/Path" />
</div>
</div>
</div>
<div class="card">
<div class="card-title">Destination</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Destination Site URL</label>
<input class="form-input" @bind="_dstSiteUrl" placeholder="@Session.CurrentProfile!.TenantUrl" />
</div>
<div class="form-group">
<label class="form-label">Destination Library</label>
<input class="form-input" @bind="_dstLibrary" placeholder="Shared Documents" />
</div>
<div class="form-group">
<label class="form-label">Destination Folder (optional)</label>
<input class="form-input" @bind="_dstFolder" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Transfer Mode</label>
<select class="form-select" @bind="_mode" style="width:100px">
<option value="Copy">Copy</option>
<option value="Move">Move</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Conflict Policy</label>
<select class="form-select" @bind="_conflict" style="width:120px">
<option value="Skip">Skip</option>
<option value="Overwrite">Overwrite</option>
<option value="Rename">Rename</option>
</select>
</div>
<div class="form-group" style="display:flex;align-items:center;padding-top:20px">
<label><input type="checkbox" @bind="_includeSourceFolder" /> Include source folder</label>
</div>
</div>
<div class="flex-row mt-8">
<button class="btn btn-primary" @onclick="RunTransfer" disabled="@_running">
@(_running ? "Transferring…" : "Start Transfer")
</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 (_summary != null)
{
<div class="card">
<div class="alert @(_summary.HasFailures ? "alert-warn" : "alert-success")">
Transferred: @_summary.SuccessCount / @_summary.TotalCount files.
@if (_summary.HasFailures) { <span>Failures: @_summary.FailedCount</span> }
</div>
@if (_summary.HasFailures)
{
<div class="data-table-wrap mt-8">
<table class="data-table">
<thead><tr><th>File</th><th>Error</th></tr></thead>
<tbody>
@foreach (var f in _summary.FailedItems)
{
<tr><td>@f.Item</td><td style="color:var(--danger)">@f.ErrorMessage</td></tr>
}
</tbody>
</table>
</div>
}
</div>
}
@code {
private string _srcSiteUrl = string.Empty, _srcLibrary = string.Empty, _srcFolder = string.Empty;
private string _dstSiteUrl = string.Empty, _dstLibrary = string.Empty, _dstFolder = string.Empty;
private string _mode = "Copy", _conflict = "Skip";
private bool _includeSourceFolder;
private bool _running; private string _status = string.Empty, _error = string.Empty;
private int _current, _total;
private BulkOperationSummary<string>? _summary;
private CancellationTokenSource? _cts;
private async Task RunTransfer()
{
_error = string.Empty; _summary = null; _running = true;
_cts = new CancellationTokenSource();
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
try
{
var srcUrl = string.IsNullOrWhiteSpace(_srcSiteUrl) ? Session.CurrentProfile!.TenantUrl : _srcSiteUrl.Trim();
var dstUrl = string.IsNullOrWhiteSpace(_dstSiteUrl) ? Session.CurrentProfile!.TenantUrl : _dstSiteUrl.Trim();
var srcCtx = await SessionMgr.GetOrCreateContextAsync(srcUrl, Session.CurrentProfile!, _cts.Token);
var dstCtx = await SessionMgr.GetOrCreateContextAsync(dstUrl, Session.CurrentProfile!, _cts.Token);
var job = new TransferJob
{
SourceSiteUrl = srcUrl, SourceLibrary = _srcLibrary, SourceFolderPath = _srcFolder,
DestinationSiteUrl = dstUrl, DestinationLibrary = _dstLibrary, DestinationFolderPath = _dstFolder,
Mode = _mode == "Move" ? TransferMode.Move : TransferMode.Copy,
ConflictPolicy = _conflict == "Overwrite" ? ConflictPolicy.Overwrite : _conflict == "Rename" ? ConflictPolicy.Rename : ConflictPolicy.Skip,
IncludeSourceFolder = _includeSourceFolder
};
_summary = await TransferSvc.TransferAsync(srcCtx, dstCtx, job, progress, _cts.Token);
_status = $"Complete: {_summary.SuccessCount} transferred.";
}
catch (OperationCanceledException) { _status = "Cancelled."; }
catch (Exception ex) { _error = ex.Message; }
finally { _running = false; await InvokeAsync(StateHasChanged); }
}
private void Cancel() => _cts?.Cancel();
}
+91
View File
@@ -0,0 +1,91 @@
@page "/folder-structure"
@attribute [Authorize]
@inject IUserSessionService Session
@inject IUserContextAccessor UserContext
@inject ISessionManager SessionMgr
@inject IFolderStructureService FolderSvc
@inject ICsvValidationService CsvValidation
@rendermode InteractiveServer
<h1 class="page-title">Folder Structure</h1>
<p class="page-subtitle">Create folder hierarchies in a document library from a CSV template.</p>
@if (!Session.HasProfile) { <NoProfilePrompt /> return; }
@if (UserContext.Role < UserRole.TechN1) { <WriteGuard /> 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">Library Title</label>
<input class="form-input" @bind="_libraryTitle" placeholder="Shared Documents" />
</div>
</div>
<div class="form-group">
<label class="form-label">CSV File (Level1, Level2, Level3, Level4)</label>
<InputFile OnChange="LoadFile" accept=".csv" class="form-input" />
</div>
@if (_rows.Count > 0)
{
<div class="alert alert-info mt-8">@_rows.Count(r => r.IsValid) valid rows, @_rows.Count(r => !r.IsValid) errors.</div>
}
<div class="flex-row mt-8">
<button class="btn btn-primary" @onclick="RunCreate" disabled="@(_running || _rows.Count == 0 || string.IsNullOrWhiteSpace(_libraryTitle))">
@(_running ? "Creating…" : "Create Folders")
</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 (_summary != null)
{
<div class="card">
<div class="alert @(_summary.HasFailures ? "alert-warn" : "alert-success")">
Created: @_summary.SuccessCount folders. Failures: @_summary.FailedCount
</div>
</div>
}
@code {
private string _siteUrl = string.Empty, _libraryTitle = string.Empty;
private List<CsvValidationRow<FolderStructureRow>> _rows = new();
private bool _running; private string _status = string.Empty, _error = string.Empty;
private int _current, _total;
private BulkOperationSummary<string>? _summary;
private CancellationTokenSource? _cts;
private async Task LoadFile(InputFileChangeEventArgs e)
{
_rows.Clear();
using var stream = e.File.OpenReadStream(maxAllowedSize: 5 * 1024 * 1024);
_rows = CsvValidation.ParseAndValidateFolders(stream);
}
private async Task RunCreate()
{
_error = string.Empty; _summary = null; _running = true;
_cts = new CancellationTokenSource();
var validRows = _rows.Where(r => r.IsValid && r.Record != null).Select(r => r.Record!).ToList();
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 ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, _cts.Token);
_summary = await FolderSvc.CreateFoldersAsync(ctx, _libraryTitle, validRows, progress, _cts.Token);
_status = $"Complete: {_summary.SuccessCount} folders created.";
}
catch (OperationCanceledException) { _status = "Cancelled."; }
catch (Exception ex) { _error = ex.Message; }
finally { _running = false; await InvokeAsync(StateHasChanged); }
}
private void Cancel() => _cts?.Cancel();
}
+59
View File
@@ -0,0 +1,59 @@
@page "/"
@attribute [Authorize]
@inject IUserSessionService Session
@rendermode InteractiveServer
<h1 class="page-title">SharePoint Toolbox</h1>
@if (!Session.HasProfile)
{
<div class="card">
<div class="card-title">Welcome</div>
<p>Select a tenant profile to start using SharePoint Toolbox.</p>
<a href="/profiles" class="btn btn-primary">Manage Profiles</a>
</div>
}
else
{
<div class="card">
<div class="card-title">Connected: @Session.CurrentProfile!.Name</div>
<p>Tenant: <strong>@Session.CurrentProfile.TenantUrl</strong></p>
<div class="flex-row mt-16">
<a href="/permissions" class="btn btn-secondary">Permissions Audit</a>
<a href="/storage" class="btn btn-secondary">Storage Metrics</a>
<a href="/search" class="btn btn-secondary">File Search</a>
<a href="/user-audit" class="btn btn-secondary">User Access Audit</a>
</div>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:16px;margin-top:16px">
@foreach (var feature in _features)
{
<a href="@feature.Href" style="text-decoration:none">
<div class="card" style="cursor:pointer;transition:box-shadow .15s" onmouseover="this.style.boxShadow='0 2px 8px rgba(0,120,212,.2)'" onmouseout="this.style.boxShadow=''">
<div style="font-size:28px;margin-bottom:8px">@feature.Icon</div>
<div style="font-weight:600;margin-bottom:4px">@feature.Title</div>
<div class="text-muted">@feature.Description</div>
</div>
</a>
}
</div>
}
@code {
private readonly (string Href, string Icon, string Title, string Description)[] _features = new[]
{
("/permissions", "🔐", "Permissions Audit", "Scan site permission assignments"),
("/storage", "💾", "Storage Metrics", "Analyze library storage usage"),
("/search", "🔍", "File Search", "KQL-based file search"),
("/duplicates", "📋", "Duplicates", "Find duplicate files/folders"),
("/versions", "🗂️", "Version Cleanup", "Delete old file versions"),
("/transfer", "📦", "File Transfer", "Copy/move files between libraries"),
("/bulk-members", "👥", "Bulk Members", "Add users to groups via CSV"),
("/bulk-sites", "🌐", "Bulk Sites", "Create sites from CSV"),
("/folder-structure", "📁", "Folder Structure", "Create folders from CSV template"),
("/user-audit", "👤", "User Access Audit", "Audit user permissions cross-site"),
("/user-directory", "📖", "User Directory", "Browse tenant users via Graph"),
("/templates", "📐", "Templates", "Capture and apply site templates"),
};
}
+135
View File
@@ -0,0 +1,135 @@
@page "/permissions"
@attribute [Authorize]
@inject IUserSessionService Session
@inject ISessionManager SessionMgr
@inject IPermissionsService PermSvc
@inject CsvExportService CsvExport
@inject HtmlExportService HtmlExport
@inject WebExportService WebExport
@rendermode InteractiveServer
<h1 class="page-title">Permissions Audit</h1>
@if (!Session.HasProfile) { <NoProfilePrompt /> return; }
<div class="card">
<div class="card-title">Scan Options</div>
<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>
<div class="form-row">
<div class="form-group" style="flex:0 0 auto">
<label class="form-label">Folder Depth</label>
<input class="form-input" type="number" @bind="_folderDepth" min="0" max="999" style="width:80px" />
</div>
<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>
</div>
</div>
<div class="flex-row mt-8">
<button class="btn btn-primary" @onclick="RunScan" disabled="@_running">
@(_running ? "Scanning…" : "Scan Site")
</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">Results <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>
<div class="data-table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Type</th>
<th>Title</th>
<th>Users</th>
<th>Permission</th>
<th>Granted Through</th>
</tr>
</thead>
<tbody>
@foreach (var r in _results.Take(500))
{
<tr>
<td>@r.ObjectType</td>
<td title="@r.Url">@r.Title</td>
<td>@r.Users</td>
<td>@r.PermissionLevels</td>
<td>@r.GrantedThrough</td>
</tr>
}
</tbody>
</table>
</div>
@if (_results.Count > 500)
{
<div class="text-muted mt-8">Showing first 500 of @_results.Count rows. Export for full results.</div>
}
</div>
}
@code {
private string _siteUrl = string.Empty;
private bool _includeInherited, _includeSubsites;
private bool _scanFolders = true;
private int _folderDepth = 1;
private bool _running;
private string _status = string.Empty;
private string _error = string.Empty;
private int _current, _total;
private List<PermissionEntry> _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 ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, _cts.Token);
var opts = new ScanOptions(_includeInherited, _scanFolders, _folderDepth, _includeSubsites);
_results = (await PermSvc.ScanSiteAsync(ctx, opts, progress, _cts.Token)).ToList();
_status = $"Scan complete: {_results.Count} entries found.";
}
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()
{
var csv = CsvExport.BuildCsv(_results);
await WebExport.DownloadCsvAsync(csv, $"permissions_{DateTime.Now:yyyyMMdd_HHmmss}.csv");
}
private async Task ExportHtml()
{
var html = HtmlExport.BuildHtml(_results);
await WebExport.DownloadHtmlAsync(html, $"permissions_{DateTime.Now:yyyyMMdd_HHmmss}.html");
}
}
+268
View File
@@ -0,0 +1,268 @@
@page "/profiles"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@inject IUserSessionService Session
@inject IUserContextAccessor UserContext
@inject SharepointToolbox.Web.Infrastructure.Persistence.ProfileRepository ProfileRepo
@inject ISessionCredentialStore CredStore
@inject NavigationManager Nav
@inject SharepointToolbox.Web.Services.OAuth.IOAuthFlowCache OAuthCache
@rendermode InteractiveServer
@using Microsoft.AspNetCore.WebUtilities
@using SharepointToolbox.Web.Core.Models
@using SharepointToolbox.Web.Services.Session
<h1 class="page-title">Client Profiles</h1>
<p class="page-subtitle">Manage SharePoint tenant connections. Credentials are entered per session — no secrets stored on disk.</p>
@if (UserContext.Role != UserRole.Admin)
{
@* Non-admins can only select a profile, not create/edit/delete *@
<div class="alert alert-info">Profile management is restricted to Admins. Select a profile below to work on a client.</div>
@foreach (var p in _profiles)
{
<div class="card" style="@(Session.CurrentProfile?.Id == p.Id ? "border-color:#0078d4;border-width:2px" : "")">
<div class="flex-row">
<div>
<div style="font-weight:600;font-size:15px">@p.Name</div>
<div class="text-muted">@p.TenantUrl</div>
</div>
<div class="spacer"></div>
@if (Session.CurrentProfile?.Id == p.Id)
{
<span class="chip chip-green">Active</span>
}
<button class="btn btn-secondary btn-sm" @onclick="() => SelectProfile(p)">
@(Session.CurrentProfile?.Id == p.Id ? "Selected" : "Select")
</button>
</div>
</div>
}
return;
}
@* Admin view — full CRUD *@
@if (!string.IsNullOrEmpty(_pageError))
{
<div class="alert alert-error" style="margin-bottom:12px">@_pageError</div>
}
<div class="flex-row" style="margin-bottom:16px">
<button class="btn btn-primary" @onclick="AddNew">+ New Profile</button>
</div>
@if (_profiles.Count == 0 && !_showForm)
{
<div class="alert alert-info">No profiles configured. Create one to get started.</div>
}
@foreach (var p in _profiles)
{
<div class="card" style="@(Session.CurrentProfile?.Id == p.Id ? "border-color:#0078d4;border-width:2px" : "")">
<div class="flex-row">
<div>
<div style="font-weight:600;font-size:15px">@p.Name</div>
<div class="text-muted">@p.TenantUrl</div>
<div class="text-muted">Tenant ID: @p.TenantId</div>
<div class="text-muted">Client ID: @p.ClientId</div>
</div>
<div class="spacer"></div>
@if (Session.CurrentProfile?.Id == p.Id)
{
<span class="chip chip-green">Active</span>
}
<button class="btn btn-secondary btn-sm" @onclick="() => SelectProfile(p)">
@(Session.CurrentProfile?.Id == p.Id ? "Selected" : "Select")
</button>
<button class="btn btn-secondary btn-sm" @onclick="() => EditProfile(p)">Edit</button>
<button class="btn btn-danger btn-sm" @onclick="() => DeleteProfile(p)">Delete</button>
</div>
</div>
}
@if (_showForm)
{
<div class="card" style="border-color:#0078d4">
<div class="card-title">@(_editing?.Id == null ? "New Profile" : "Edit Profile")</div>
@if (!string.IsNullOrEmpty(_formError))
{
<div class="alert alert-error">@_formError</div>
}
<div class="form-group">
<label class="form-label">Profile Name *</label>
<input class="form-input" @bind="_form.Name" placeholder="e.g. Contoso Production" />
</div>
<div class="form-group">
<label class="form-label">Tenant URL *</label>
<input class="form-input" @bind="_form.TenantUrl" placeholder="https://contoso.sharepoint.com" />
</div>
<div class="form-group">
<label class="form-label">Tenant ID (GUID or domain) *</label>
<input class="form-input" @bind="_form.TenantId" placeholder="contoso.onmicrosoft.com or GUID" />
</div>
@* App registration section *@
<div class="form-group">
<label class="form-label">Client ID (App Registration)</label>
<div class="flex-row" style="gap:8px;align-items:center">
<input class="form-input" @bind="_form.ClientId"
placeholder="Auto-filled after registration, or enter manually"
style="flex:1" />
<button class="btn btn-secondary" @onclick="RegisterAppAsync"
disabled="@(!CanRegister || _registering)"
title="@(CanRegister ? "Register app in client Entra ID (requires Global Admin)" : "Fill Tenant URL, Tenant ID and Profile Name first")">
@(_registering ? "Redirecting…" : "Register in Entra")
</button>
</div>
<small class="text-muted">
Click "Register in Entra" to auto-create the app registration in the client tenant — requires Global Admin credentials.
Or enter an existing public client App Registration ID manually.
</small>
</div>
<div class="flex-row mt-8">
<button class="btn btn-primary" @onclick="SaveProfile">Save</button>
<button class="btn btn-secondary" @onclick="CancelForm">Cancel</button>
</div>
</div>
}
@code {
private List<TenantProfile> _profiles = new();
private bool _showForm;
private bool _registering;
private TenantProfile? _editing;
private TenantProfile _form = new();
private string _formError = string.Empty;
private string _pageError = string.Empty;
private bool CanRegister =>
!string.IsNullOrWhiteSpace(_form.Name) &&
!string.IsNullOrWhiteSpace(_form.TenantUrl) &&
!string.IsNullOrWhiteSpace(_form.TenantId);
protected override async Task OnInitializedAsync()
{
_profiles = (await ProfileRepo.LoadAsync()).ToList();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
await HandleRegResultAsync();
await HandleConnectErrorAsync();
}
private async Task HandleRegResultAsync()
{
var uri = new Uri(Nav.Uri);
var query = QueryHelpers.ParseQuery(uri.Query);
if (!query.TryGetValue("reg_result_key", out var key) || string.IsNullOrEmpty(key))
return;
var result = OAuthCache.GetAndRemoveRegistrationResult(key!);
if (result is not null)
{
_form = new TenantProfile
{
Name = result.TenantName,
TenantUrl = result.TenantUrl,
TenantId = result.TenantId,
ClientId = result.ClientId,
};
_showForm = true;
_formError = string.Empty;
await InvokeAsync(StateHasChanged);
}
Nav.NavigateTo(uri.GetLeftPart(UriPartial.Path), replace: true);
}
private async Task HandleConnectErrorAsync()
{
var uri = new Uri(Nav.Uri);
var query = QueryHelpers.ParseQuery(uri.Query);
if (!query.TryGetValue("connect_error", out var err) || string.IsNullOrEmpty(err))
return;
_pageError = err!;
await InvokeAsync(StateHasChanged);
Nav.NavigateTo(uri.GetLeftPart(UriPartial.Path), replace: true);
}
private void AddNew()
{
_editing = null;
_form = new TenantProfile();
_showForm = true;
_formError = string.Empty;
_pageError = string.Empty;
}
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 };
_showForm = true;
_formError = _pageError = string.Empty;
}
private void CancelForm() { _showForm = false; _editing = null; }
private void SelectProfile(TenantProfile p)
{
Session.SetProfile(p);
StateHasChanged();
}
private async Task RegisterAppAsync()
{
if (!CanRegister) return;
_registering = true;
StateHasChanged();
var returnUrl = Nav.Uri.Contains('?')
? Nav.Uri.Substring(0, Nav.Uri.IndexOf('?'))
: Nav.Uri;
var url = $"/connect/register-initiate" +
$"?tenantId={Uri.EscapeDataString(_form.TenantId)}" +
$"&tenantName={Uri.EscapeDataString(_form.Name)}" +
$"&tenantUrl={Uri.EscapeDataString(_form.TenantUrl)}" +
$"&returnUrl={Uri.EscapeDataString(returnUrl)}";
Nav.NavigateTo(url, forceLoad: true);
}
private async Task SaveProfile()
{
_formError = string.Empty;
if (string.IsNullOrWhiteSpace(_form.Name)) { _formError = "Name is required."; return; }
if (string.IsNullOrWhiteSpace(_form.TenantUrl)) { _formError = "Tenant URL is required."; return; }
if (string.IsNullOrWhiteSpace(_form.ClientId)) { _formError = "Client ID is required."; return; }
if (string.IsNullOrWhiteSpace(_form.TenantId)) { _formError = "Tenant ID is required."; return; }
if (_editing == null)
{
_form.Id = Guid.NewGuid().ToString();
_profiles.Add(_form);
}
else
{
var idx = _profiles.FindIndex(p => p.Id == _editing.Id);
if (idx >= 0) _profiles[idx] = _form;
}
await ProfileRepo.SaveAsync(_profiles);
_showForm = false; _editing = null;
}
private async Task DeleteProfile(TenantProfile p)
{
_profiles.RemoveAll(x => x.Id == p.Id);
await ProfileRepo.SaveAsync(_profiles);
if (Session.CurrentProfile?.Id == p.Id) await Session.ClearSessionAsync();
}
}
+124
View File
@@ -0,0 +1,124 @@
@page "/search"
@attribute [Authorize]
@inject IUserSessionService Session
@inject ISessionManager SessionMgr
@inject ISearchService SearchSvc
@inject SearchCsvExportService CsvExport
@inject SearchHtmlExportService HtmlExport
@inject WebExportService WebExport
@rendermode InteractiveServer
<h1 class="page-title">File Search</h1>
@if (!Session.HasProfile) { <NoProfilePrompt /> return; }
<div class="card">
<div class="card-title">Search Options</div>
<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">File Extensions (comma-separated)</label>
<input class="form-input" @bind="_extensions" placeholder="docx, xlsx, pdf" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Regex filter (filename)</label>
<input class="form-input" @bind="_regex" placeholder="Optional regex pattern" />
</div>
<div class="form-group">
<label class="form-label">Max results</label>
<input class="form-input" type="number" @bind="_maxResults" min="1" max="50000" style="width:120px" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Created by</label>
<input class="form-input" @bind="_createdBy" />
</div>
<div class="form-group">
<label class="form-label">Modified by</label>
<input class="form-input" @bind="_modifiedBy" />
</div>
<div class="form-group">
<label class="form-label">Library (optional)</label>
<input class="form-input" @bind="_library" />
</div>
</div>
<div class="flex-row mt-8">
<button class="btn btn-primary" @onclick="RunSearch" disabled="@_running">
@(_running ? "Searching…" : "Search")
</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">Results <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>
<div class="data-table-wrap">
<table class="data-table">
<thead><tr><th>Name</th><th>Ext</th><th>Path</th><th>Created</th><th>Modified</th><th class="num">Size (KB)</th></tr></thead>
<tbody>
@foreach (var r in _results.Take(500))
{
<tr>
<td>@System.IO.Path.GetFileName(r.Path)</td>
<td>@r.FileExtension</td>
<td title="@r.Path" style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">@r.Path</td>
<td>@(r.Created?.ToString("yyyy-MM-dd") ?? "")</td>
<td>@(r.LastModified?.ToString("yyyy-MM-dd") ?? "")</td>
<td class="num">@((r.SizeBytes / 1024.0).ToString("F1"))</td>
</tr>
}
</tbody>
</table>
</div>
@if (_results.Count > 500) { <div class="text-muted mt-8">Showing first 500 of @_results.Count. Export for full results.</div> }
</div>
}
@code {
private string _siteUrl = string.Empty, _extensions = string.Empty, _regex = string.Empty;
private string _createdBy = string.Empty, _modifiedBy = string.Empty, _library = string.Empty;
private int _maxResults = 5000;
private bool _running; private string _status = string.Empty, _error = string.Empty;
private int _current, _total;
private List<SearchResult> _results = new();
private CancellationTokenSource? _cts;
private async Task RunSearch()
{
_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 ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, _cts.Token);
var exts = _extensions.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var opts = new SearchOptions(exts, _regex, null, null, null, null, _createdBy.TrimOrNull(), _modifiedBy.TrimOrNull(), _library.TrimOrNull(), _maxResults, siteUrl);
_results = (await SearchSvc.SearchFilesAsync(ctx, opts, progress, _cts.Token)).ToList();
_status = $"Found {_results.Count} files.";
}
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), $"search_{DateTime.Now:yyyyMMdd_HHmmss}.csv"); }
private async Task ExportHtml() { await WebExport.DownloadHtmlAsync(HtmlExport.BuildHtml(_results), $"search_{DateTime.Now:yyyyMMdd_HHmmss}.html"); }
}
+59
View File
@@ -0,0 +1,59 @@
@page "/settings"
@attribute [Authorize]
@inject IUserSessionService Session
@rendermode InteractiveServer
<h1 class="page-title">Settings</h1>
<div class="card">
<div class="card-title">Display</div>
<div class="form-group">
<label class="form-label">Language</label>
<select class="form-select" style="width:160px" @bind="_lang" @bind:after="Save">
<option value="en">English</option>
<option value="fr">Français</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Theme</label>
<select class="form-select" style="width:160px" @bind="_theme" @bind:after="Save">
<option value="System">System</option>
<option value="Light">Light</option>
<option value="Dark">Dark</option>
</select>
</div>
</div>
<div class="card">
<div class="card-title">Behavior</div>
<div class="form-group">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" @bind="_autoTakeOwnership" @bind:after="Save" />
Auto-elevate ownership when permission scan is denied
</label>
</div>
</div>
@if (_saved) { <div class="alert alert-success">Settings saved.</div> }
@code {
private string _lang = "en", _theme = "System";
private bool _autoTakeOwnership, _saved;
protected override void OnInitialized()
{
var s = Session.Settings;
_lang = s.Lang;
_theme = s.Theme;
_autoTakeOwnership = s.AutoTakeOwnership;
}
private void Save()
{
Session.UpdateSettings(new AppSettings { Lang = _lang, Theme = _theme, AutoTakeOwnership = _autoTakeOwnership });
SharepointToolbox.Web.Localization.TranslationSource.Instance.SetCulture(_lang);
_saved = true;
StateHasChanged();
_ = Task.Delay(2000).ContinueWith(_ => { _saved = false; InvokeAsync(StateHasChanged); });
}
}
+118
View File
@@ -0,0 +1,118 @@
@page "/storage"
@attribute [Authorize]
@inject IUserSessionService Session
@inject ISessionManager SessionMgr
@inject IStorageService StorageSvc
@inject StorageCsvExportService CsvExport
@inject StorageHtmlExportService HtmlExport
@inject WebExportService WebExport
@rendermode InteractiveServer
<h1 class="page-title">Storage Metrics</h1>
@if (!Session.HasProfile) { <NoProfilePrompt /> return; }
<div class="card">
<div class="card-title">Scan Options</div>
<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>
<div class="form-row">
<div class="form-group" style="display:flex;align-items:center;gap:16px;padding-top:20px">
<label><input type="checkbox" @bind="_includeSubsites" /> Include subsites</label>
<label><input type="checkbox" @bind="_includeHidden" /> Include hidden libs</label>
<label><input type="checkbox" @bind="_includeRecycleBin" /> Include recycle bin</label>
</div>
</div>
<div class="flex-row mt-8">
<button class="btn btn-primary" @onclick="RunScan" disabled="@_running">
@(_running ? "Scanning…" : "Scan Storage")
</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">Storage Report <span class="count-badge">@_results.Count libraries</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>
<div class="data-table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Library</th>
<th>Site</th>
<th class="num">Files</th>
<th class="num">Total (MB)</th>
<th class="num">Versions (MB)</th>
<th>Last Modified</th>
</tr>
</thead>
<tbody>
@foreach (var n in _results)
{
<tr>
<td style="padding-left:@(n.IndentLevel * 20 + 12)px">@n.Name</td>
<td>@n.SiteTitle</td>
<td class="num">@n.TotalFileCount.ToString("N0")</td>
<td class="num">@((n.TotalSizeBytes / 1048576.0).ToString("F2"))</td>
<td class="num">@((n.VersionSizeBytes / 1048576.0).ToString("F2"))</td>
<td>@(n.LastModified?.ToString("yyyy-MM-dd") ?? "")</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
@code {
private string _siteUrl = string.Empty;
private bool _includeSubsites, _includeHidden = true, _includeRecycleBin = true;
private bool _running; private string _status = string.Empty, _error = string.Empty;
private int _current, _total;
private List<StorageNode> _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 ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, _cts.Token);
var opts = new StorageScanOptions(IncludeSubsites: _includeSubsites, IncludeHiddenLibraries: _includeHidden, IncludeRecycleBin: _includeRecycleBin);
_results = (await StorageSvc.CollectStorageAsync(ctx, opts, progress, _cts.Token)).ToList();
_status = $"Complete: {_results.Count} nodes.";
}
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()
{
var csv = CsvExport.BuildCsv(_results);
await WebExport.DownloadCsvAsync(csv, $"storage_{DateTime.Now:yyyyMMdd_HHmmss}.csv");
}
private async Task ExportHtml()
{
var html = HtmlExport.BuildHtml(_results);
await WebExport.DownloadHtmlAsync(html, $"storage_{DateTime.Now:yyyyMMdd_HHmmss}.html");
}
}
+150
View File
@@ -0,0 +1,150 @@
@page "/templates"
@attribute [Authorize]
@inject IUserSessionService Session
@inject IUserContextAccessor UserContext
@inject ISessionManager SessionMgr
@inject ITemplateService TemplateSvc
@inject SharepointToolbox.Web.Infrastructure.Persistence.TemplateRepository TemplateRepo
@rendermode InteractiveServer
<h1 class="page-title">Site Templates</h1>
<p class="page-subtitle">Capture site structure and apply to new sites.</p>
@if (!Session.HasProfile) { <NoProfilePrompt /> return; }
@if (UserContext.Role < UserRole.TechN1) { <WriteGuard /> return; }
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
<div class="card">
<div class="card-title">Capture Template</div>
<div class="form-group">
<label class="form-label">Source Site URL</label>
<input class="form-input" @bind="_captureUrl" placeholder="@Session.CurrentProfile!.TenantUrl" />
</div>
<div class="form-group">
<label class="form-label">Template Name</label>
<input class="form-input" @bind="_captureName" placeholder="My Template" />
</div>
<div class="flex-row" style="flex-wrap:wrap">
<label><input type="checkbox" @bind="_capLibraries" /> Libraries</label>
<label><input type="checkbox" @bind="_capFolders" /> Folders</label>
<label><input type="checkbox" @bind="_capGroups" /> Permission groups</label>
</div>
<button class="btn btn-primary mt-8" @onclick="CaptureTemplate" disabled="@_running">
@(_running ? "Capturing…" : "Capture")
</button>
<ProgressPanel IsRunning="_running" StatusMessage="_status" />
</div>
<div class="card">
<div class="card-title">Apply Template</div>
@if (_selectedTemplate == null)
{
<div class="alert alert-info">Select a template from the list below.</div>
}
else
{
<div class="alert alert-info">Template: <strong>@_selectedTemplate.Name</strong></div>
<div class="form-group">
<label class="form-label">New Site Title</label>
<input class="form-input" @bind="_newTitle" />
</div>
<div class="form-group">
<label class="form-label">New Site Alias</label>
<input class="form-input" @bind="_newAlias" />
</div>
<div class="form-group">
<label class="form-label">Admin Center URL</label>
<input class="form-input" @bind="_adminUrl" placeholder="https://contoso-admin.sharepoint.com" />
</div>
<button class="btn btn-primary" @onclick="ApplyTemplate" disabled="@_running">
@(_running ? "Applying…" : "Apply Template")
</button>
}
</div>
</div>
@if (!string.IsNullOrEmpty(_error)) { <div class="alert alert-error mt-8">@_error</div> }
@if (!string.IsNullOrEmpty(_successMsg)) { <div class="alert alert-success mt-8">@_successMsg</div> }
<div class="card" style="margin-top:16px">
<div class="card-title">Saved Templates</div>
@if (_templates.Count == 0)
{
<div class="text-muted">No templates saved.</div>
}
@foreach (var t in _templates)
{
<div class="flex-row" style="padding:8px 0;border-bottom:1px solid var(--border)">
<div>
<div style="font-weight:600">@t.Name</div>
<div class="text-muted">@t.SiteType · @t.CapturedAt.ToString("yyyy-MM-dd") · @t.Libraries.Count libraries</div>
</div>
<div class="spacer"></div>
<button class="btn btn-secondary btn-sm" @onclick="() => _selectedTemplate = t">Use</button>
<button class="btn btn-danger btn-sm" @onclick="() => DeleteTemplate(t)">Delete</button>
</div>
}
</div>
@code {
private string _captureUrl = string.Empty, _captureName = string.Empty;
private bool _capLibraries = true, _capFolders = true, _capGroups = true;
private SiteTemplate? _selectedTemplate;
private string _newTitle = string.Empty, _newAlias = string.Empty, _adminUrl = string.Empty;
private bool _running; private string _status = string.Empty, _error = string.Empty, _successMsg = string.Empty;
private List<SiteTemplate> _templates = new();
private CancellationTokenSource? _cts;
protected override async Task OnInitializedAsync()
{
_templates = (await TemplateRepo.GetAllAsync()).ToList();
}
private async Task CaptureTemplate()
{
_error = string.Empty; _successMsg = string.Empty; _running = true;
_cts = new CancellationTokenSource();
var siteUrl = string.IsNullOrWhiteSpace(_captureUrl) ? Session.CurrentProfile!.TenantUrl : _captureUrl.Trim();
var progress = new Progress<OperationProgress>(p => { _status = p.Message; InvokeAsync(StateHasChanged); });
try
{
var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, _cts.Token);
var opts = new SiteTemplateOptions { CaptureLibraries = _capLibraries, CaptureFolders = _capFolders, CapturePermissionGroups = _capGroups };
var template = await TemplateSvc.CaptureTemplateAsync(ctx, opts, progress, _cts.Token);
template.Name = string.IsNullOrWhiteSpace(_captureName) ? $"Template-{DateTime.Now:yyyyMMdd}" : _captureName;
await TemplateRepo.SaveAsync(template);
_templates = (await TemplateRepo.GetAllAsync()).ToList();
_successMsg = $"Template '{template.Name}' saved.";
}
catch (OperationCanceledException) { _status = "Cancelled."; }
catch (Exception ex) { _error = ex.Message; }
finally { _running = false; await InvokeAsync(StateHasChanged); }
}
private async Task ApplyTemplate()
{
if (_selectedTemplate == null) return;
_error = string.Empty; _successMsg = string.Empty; _running = true;
_cts = new CancellationTokenSource();
var adminUrl = string.IsNullOrWhiteSpace(_adminUrl)
? Session.CurrentProfile!.TenantUrl.Replace(".sharepoint.com", "-admin.sharepoint.com")
: _adminUrl.Trim();
var progress = new Progress<OperationProgress>(p => { _status = p.Message; InvokeAsync(StateHasChanged); });
try
{
var ctx = await SessionMgr.GetOrCreateContextAsync(adminUrl, Session.CurrentProfile!, _cts.Token);
var url = await TemplateSvc.ApplyTemplateAsync(ctx, _selectedTemplate, _newTitle, _newAlias, progress, _cts.Token);
_successMsg = $"Site created: {url}";
}
catch (OperationCanceledException) { _status = "Cancelled."; }
catch (Exception ex) { _error = ex.Message; }
finally { _running = false; await InvokeAsync(StateHasChanged); }
}
private async Task DeleteTemplate(SiteTemplate t)
{
await TemplateRepo.DeleteAsync(t.Id);
_templates.RemoveAll(x => x.Id == t.Id);
if (_selectedTemplate?.Id == t.Id) _selectedTemplate = null;
}
}
+107
View File
@@ -0,0 +1,107 @@
@page "/user-audit"
@attribute [Authorize]
@inject IUserSessionService Session
@inject ISessionManager SessionMgr
@inject IUserAccessAuditService AuditSvc
@inject UserAccessCsvExportService CsvExport
@inject UserAccessHtmlExportService HtmlExport
@inject WebExportService WebExport
@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>
@if (!Session.HasProfile) { <NoProfilePrompt /> return; }
<div class="card">
<div class="form-row">
<div class="form-group" style="flex:2">
<label class="form-label">Users (emails, one per line)</label>
<textarea class="form-textarea" @bind="_users" placeholder="alice@contoso.com&#10;bob@contoso.com" rows="3"></textarea>
</div>
<div class="form-group" style="flex:2">
<label class="form-label">Site URLs (one per line)</label>
<textarea class="form-textarea" @bind="_sites" placeholder="@Session.CurrentProfile!.TenantUrl" rows="3"></textarea>
</div>
</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" /> Include inherited</label>
<label><input type="checkbox" @bind="_scanFolders" /> Scan folders</label>
<label><input type="checkbox" @bind="_includeSubsites" /> Include subsites</label>
</div>
</div>
<div class="flex-row mt-8">
<button class="btn btn-primary" @onclick="RunAudit" disabled="@_running">
@(_running ? "Auditing…" : "Audit Users")
</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">Audit Results <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>
<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>
<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">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">Showing first 500. Export for full results.</div> }
</div>
}
@code {
private string _users = string.Empty, _sites = string.Empty;
private bool _includeInherited, _includeSubsites, _scanFolders = true;
private bool _running; private string _status = string.Empty, _error = string.Empty;
private int _current, _total;
private List<UserAccessEntry> _results = new();
private CancellationTokenSource? _cts;
private async Task RunAudit()
{
_error = string.Empty; _results.Clear(); _running = true;
_cts = new CancellationTokenSource();
var userList = _users.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
var siteList = _sites.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(u => new SiteInfo(u, u.TrimEnd('/').Split('/').Last())).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); });
try
{
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.";
}
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.FirstOrDefault()?.UserDisplayName ?? "Users", _results.FirstOrDefault()?.UserLogin ?? "", _results), $"user_audit_{DateTime.Now:yyyyMMdd_HHmmss}.csv"); }
private async Task ExportHtml() { await WebExport.DownloadHtmlAsync(HtmlExport.BuildHtml(_results), $"user_audit_{DateTime.Now:yyyyMMdd_HHmmss}.html"); }
}
+74
View File
@@ -0,0 +1,74 @@
@page "/user-directory"
@attribute [Authorize]
@inject IUserSessionService Session
@inject IGraphUserDirectoryService GraphSvc
@rendermode InteractiveServer
<h1 class="page-title">User Directory</h1>
<p class="page-subtitle">Browse all tenant users via Microsoft Graph.</p>
@if (!Session.HasProfile) { <NoProfilePrompt /> return; }
<div class="card">
<div class="flex-row">
<label><input type="checkbox" @bind="_includeGuests" /> Include guests</label>
<button class="btn btn-primary" @onclick="LoadUsers" disabled="@_running">
@(_running ? $"Loading… ({_loadCount} users)" : "Load Users")
</button>
</div>
<ProgressPanel IsRunning="_running" StatusMessage="_status" />
</div>
@if (!string.IsNullOrEmpty(_error)) { <div class="alert alert-error">@_error</div> }
@if (_users.Count > 0)
{
<div class="card">
<div class="flex-row">
<div class="card-title">Users <span class="count-badge">@_users.Count</span></div>
<input class="form-input" style="width:260px" @bind="_filter" @bind:event="oninput" placeholder="Filter by name or email…" />
</div>
<div class="data-table-wrap">
<table class="data-table">
<thead><tr><th>Name</th><th>UPN</th><th>Department</th><th>Job Title</th><th>Type</th></tr></thead>
<tbody>
@foreach (var u in FilteredUsers.Take(500))
{
<tr>
<td>@u.DisplayName</td>
<td>@u.UserPrincipalName</td>
<td>@u.Department</td>
<td>@u.JobTitle</td>
<td><span class="chip @(u.UserType == "Guest" ? "chip-yellow" : "chip-blue")">@(u.UserType ?? "Member")</span></td>
</tr>
}
</tbody>
</table>
</div>
@if (FilteredUsers.Count() > 500) { <div class="text-muted mt-8">Showing first 500 of @FilteredUsers.Count() filtered.</div> }
</div>
}
@code {
private bool _includeGuests, _running;
private string _status = string.Empty, _error = string.Empty, _filter = string.Empty;
private int _loadCount;
private List<GraphDirectoryUser> _users = new();
private IEnumerable<GraphDirectoryUser> FilteredUsers => string.IsNullOrWhiteSpace(_filter)
? _users
: _users.Where(u => u.DisplayName.Contains(_filter, StringComparison.OrdinalIgnoreCase) || u.UserPrincipalName.Contains(_filter, StringComparison.OrdinalIgnoreCase));
private async Task LoadUsers()
{
_error = string.Empty; _users.Clear(); _running = true; _loadCount = 0;
var progress = new Progress<int>(count => { _loadCount = count; InvokeAsync(StateHasChanged); });
try
{
_users = (await GraphSvc.GetUsersAsync(Session.CurrentProfile!, _includeGuests, progress)).ToList();
_status = $"Loaded {_users.Count} users.";
}
catch (Exception ex) { _error = ex.Message; }
finally { _running = false; await InvokeAsync(StateHasChanged); }
}
}
+141
View File
@@ -0,0 +1,141 @@
@page "/versions"
@attribute [Authorize]
@inject IUserSessionService Session
@inject IUserContextAccessor UserContext
@inject ISessionManager SessionMgr
@inject IVersionCleanupService VersionSvc
@inject VersionCleanupHtmlExportService HtmlExport
@inject WebExportService WebExport
@rendermode InteractiveServer
<h1 class="page-title">Version Cleanup</h1>
@if (!Session.HasProfile) { <NoProfilePrompt /> return; }
@if (UserContext.Role < UserRole.TechN1) { <WriteGuard /> 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>
<div class="flex-row">
<button class="btn btn-secondary" @onclick="LoadLibraries" disabled="@_loading">
@(_loading ? "Loading…" : "Load Libraries")
</button>
</div>
@if (_libraries.Count > 0)
{
<div class="form-group mt-8">
<label class="form-label">Libraries (none = all)</label>
<div style="max-height:200px;overflow-y:auto;border:1px solid var(--border);border-radius:4px;padding:4px">
@foreach (var lib in _libraries)
{
<label style="display:flex;align-items:center;gap:6px;padding:4px 8px;cursor:pointer">
<input type="checkbox" checked="@_selectedLibs.Contains(lib)" @onchange="e => ToggleLib(lib, (bool)e.Value!)" />
@lib
</label>
}
</div>
</div>
}
<div class="form-row mt-8">
<div class="form-group" style="flex:0 0 auto">
<label class="form-label">Keep last N versions</label>
<input class="form-input" type="number" @bind="_keepLast" min="0" max="999" style="width:80px" />
</div>
<div class="form-group" style="display:flex;align-items:center;padding-top:20px">
<label><input type="checkbox" @bind="_keepFirst" /> Keep first version</label>
</div>
</div>
<div class="flex-row mt-8">
<button class="btn btn-danger" @onclick="RunCleanup" disabled="@_running">
@(_running ? "Cleaning…" : "Delete Old Versions")
</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">Results <span class="count-badge">@_results.Count files</span></div>
<div class="spacer"></div>
<button class="btn btn-secondary btn-sm" @onclick="ExportHtml">Export HTML</button>
</div>
<div class="alert alert-info">
Versions deleted: <strong>@_results.Sum(r => r.VersionsDeleted)</strong> |
Freed: <strong>@((_results.Sum(r => r.BytesFreed) / 1048576.0).ToString("F2")) MB</strong> |
Errors: <strong>@_results.Count(r => r.Error != null)</strong>
</div>
<div class="data-table-wrap">
<table class="data-table">
<thead><tr><th>Library</th><th>File</th><th class="num">Before</th><th class="num">Deleted</th><th class="num">Freed (KB)</th><th>Error</th></tr></thead>
<tbody>
@foreach (var r in _results.Take(500))
{
<tr>
<td>@r.Library</td>
<td title="@r.FileServerRelativeUrl">@r.FileName</td>
<td class="num">@r.VersionsBefore</td>
<td class="num">@r.VersionsDeleted</td>
<td class="num">@((r.BytesFreed / 1024.0).ToString("F1"))</td>
<td style="color:var(--danger)">@r.Error</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
@code {
private string _siteUrl = string.Empty;
private int _keepLast = 5; private bool _keepFirst;
private List<string> _libraries = new(), _selectedLibs = new();
private bool _running, _loading; private string _status = string.Empty, _error = string.Empty;
private int _current, _total;
private List<VersionCleanupResult> _results = new();
private CancellationTokenSource? _cts;
private async Task LoadLibraries()
{
_loading = true; _error = string.Empty;
try
{
var siteUrl = string.IsNullOrWhiteSpace(_siteUrl) ? Session.CurrentProfile!.TenantUrl : _siteUrl.Trim();
var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, CancellationToken.None);
_libraries = (await VersionSvc.ListLibraryTitlesAsync(ctx, CancellationToken.None)).ToList();
}
catch (Exception ex) { _error = ex.Message; }
finally { _loading = false; }
}
private void ToggleLib(string lib, bool selected) { if (selected) _selectedLibs.Add(lib); else _selectedLibs.Remove(lib); }
private async Task RunCleanup()
{
_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 ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, _cts.Token);
var opts = new VersionCleanupOptions(_selectedLibs, _keepLast, _keepFirst);
_results = (await VersionSvc.DeleteOldVersionsAsync(ctx, opts, progress, _cts.Token)).ToList();
_status = $"Complete: {_results.Sum(r => r.VersionsDeleted)} versions deleted.";
}
catch (OperationCanceledException) { _status = "Cancelled."; }
catch (Exception ex) { _error = ex.Message; }
finally { _running = false; await InvokeAsync(StateHasChanged); }
}
private void Cancel() => _cts?.Cancel();
private async Task ExportHtml() { await WebExport.DownloadHtmlAsync(HtmlExport.BuildHtml(_results), $"versions_{DateTime.Now:yyyyMMdd_HHmmss}.html"); }
}