diff --git a/Components/Pages/BulkMembers.razor b/Components/Pages/BulkMembers.razor index 74d9c51..d003e4b 100644 --- a/Components/Pages/BulkMembers.razor +++ b/Components/Pages/BulkMembers.razor @@ -55,7 +55,7 @@ @if (_running) { } - + @if (!string.IsNullOrEmpty(_error)) {
@_error
} @@ -94,7 +94,8 @@ _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(); + if (string.IsNullOrWhiteSpace(_siteUrl)) { _error = "Please enter a site URL."; _running = false; return; } + var siteUrl = _siteUrl.Trim(); var progress = new Progress(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); }); try { diff --git a/Components/Pages/BulkSites.razor b/Components/Pages/BulkSites.razor index 5dfda53..33eeb14 100644 --- a/Components/Pages/BulkSites.razor +++ b/Components/Pages/BulkSites.razor @@ -52,7 +52,7 @@ @if (_running) { } - + @if (!string.IsNullOrEmpty(_error)) {
@_error
} diff --git a/Components/Pages/Duplicates.razor b/Components/Pages/Duplicates.razor index 7eff966..d5a4d21 100644 --- a/Components/Pages/Duplicates.razor +++ b/Components/Pages/Duplicates.razor @@ -14,11 +14,8 @@ @if (!Session.HasProfile) { return; }
-
-
- - -
+ +
-
-
-
+ +
@@ -34,14 +29,14 @@
@if (_running) { }
- +
@if (!string.IsNullOrEmpty(_error)) @@ -55,6 +50,7 @@
Results @_results.Count
+
@@ -91,7 +87,7 @@ } @code { - private string _siteUrl = string.Empty; + private List _sites = new(); private bool _includeInherited, _includeSubsites; private bool _scanFolders = true; private int _folderDepth = 1; @@ -100,23 +96,37 @@ private string _error = string.Empty; private int _current, _total; private List _results = new(); + private List<(string Label, IReadOnlyList Results)> _bySite = new(); + private ReportMergeMode _mergeMode = ReportMergeMode.SingleMerged; private CancellationTokenSource? _cts; private async Task RunScan() { - _error = string.Empty; _results.Clear(); _running = true; + _error = string.Empty; _results = new(); _bySite = new(); _running = true; _cts = new CancellationTokenSource(); - var siteUrl = string.IsNullOrWhiteSpace(_siteUrl) ? Session.CurrentProfile!.TenantUrl : _siteUrl.Trim(); + if (_sites.Count == 0) { _error = "Please select at least one site."; _running = false; return; } var progress = new Progress(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); }); try { var opts = new ScanOptions(_includeInherited, _scanFolders, _folderDepth, _includeSubsites); - _results = (await Elevation.RunAsync(async c => + var bySite = new List<(string, IReadOnlyList)>(); + var flat = new List(); + int i = 0; + foreach (var site in _sites) { - var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, c); - return await PermSvc.ScanSiteAsync(ctx, opts, progress, c); - }, _cts.Token)).ToList(); - _status = $"Scan complete: {_results.Count} entries found."; + _cts.Token.ThrowIfCancellationRequested(); + _status = $"Scanning {site.Title} ({++i}/{_sites.Count})…"; + await InvokeAsync(StateHasChanged); + var entries = await Elevation.RunAsync(async c => + { + var ctx = await SessionMgr.GetOrCreateContextAsync(site.Url, Session.CurrentProfile!, c); + return await PermSvc.ScanSiteAsync(ctx, opts, progress, c); + }, _cts.Token); + bySite.Add((site.Title, entries)); + flat.AddRange(entries); + } + _bySite = bySite; _results = flat; + _status = $"Scan complete: {_results.Count} entries across {_sites.Count} site(s)."; } catch (OperationCanceledException) { _status = "Cancelled."; } catch (Exception ex) { _error = ex.Message; } @@ -127,13 +137,17 @@ private async Task ExportCsv() { - var csv = CsvExport.BuildCsv(_results); - await WebExport.DownloadCsvAsync(csv, $"permissions_{DateTime.Now:yyyyMMdd_HHmmss}.csv"); + var ts = DateTime.Now.ToString("yyyyMMdd_HHmmss"); + var output = ReportMergeHelper.Build(_bySite, _mergeMode, "permissions", ts, ReportFormat.Csv, + rs => CsvExport.BuildCsv(rs)); + await WebExport.DownloadBytesAsync(output.Bytes, output.FileName, output.Mime); } private async Task ExportHtml() { - var html = HtmlExport.BuildHtml(_results); - await WebExport.DownloadHtmlAsync(html, $"permissions_{DateTime.Now:yyyyMMdd_HHmmss}.html"); + var ts = DateTime.Now.ToString("yyyyMMdd_HHmmss"); + var output = ReportMergeHelper.Build(_bySite, _mergeMode, "permissions", ts, ReportFormat.Html, + rs => HtmlExport.BuildHtml(rs, Session.CurrentBranding)); + await WebExport.DownloadBytesAsync(output.Bytes, output.FileName, output.Mime); } } diff --git a/Components/Pages/Profiles.razor b/Components/Pages/Profiles.razor index 2b5070f..3cc4351 100644 --- a/Components/Pages/Profiles.razor +++ b/Components/Pages/Profiles.razor @@ -146,6 +146,12 @@ }
+
+ + Shown top-right on exported reports for this client. + +
+
@@ -214,7 +220,7 @@ 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 }; + _form = new TenantProfile { Id = p.Id, Name = p.Name, TenantUrl = p.TenantUrl, TenantId = p.TenantId, ClientId = p.ClientId, ClientLogo = p.ClientLogo }; _showForm = true; _formError = _pageError = string.Empty; } diff --git a/Components/Pages/Search.razor b/Components/Pages/Search.razor index e856345..8d9ea20 100644 --- a/Components/Pages/Search.razor +++ b/Components/Pages/Search.razor @@ -15,11 +15,8 @@
Search Options
-
-
- - -
+ +
@@ -55,7 +52,7 @@ @if (_running) { }
- +
@if (!string.IsNullOrEmpty(_error)) {
@_error
} @@ -66,6 +63,7 @@
Results @_results.Count
+
@@ -92,30 +90,45 @@ } @code { - private string _siteUrl = string.Empty, _extensions = string.Empty, _regex = string.Empty; + private List _sites = new(); + private string _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 _results = new(); + private List<(string Label, IReadOnlyList Results)> _bySite = new(); + private ReportMergeMode _mergeMode = ReportMergeMode.SingleMerged; private CancellationTokenSource? _cts; private async Task RunSearch() { - _error = string.Empty; _results.Clear(); _running = true; + _error = string.Empty; _results = new(); _bySite = new(); _running = true; _cts = new CancellationTokenSource(); - var siteUrl = string.IsNullOrWhiteSpace(_siteUrl) ? Session.CurrentProfile!.TenantUrl : _siteUrl.Trim(); + if (_sites.Count == 0) { _error = "Please select at least one site."; _running = false; return; } var progress = new Progress(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); }); try { 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 Elevation.RunAsync(async c => + var bySite = new List<(string, IReadOnlyList)>(); + var flat = new List(); + int i = 0; + foreach (var site in _sites) { - var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, c); - return await SearchSvc.SearchFilesAsync(ctx, opts, progress, c); - }, _cts.Token)).ToList(); - _status = $"Found {_results.Count} files."; + _cts.Token.ThrowIfCancellationRequested(); + _status = $"Searching {site.Title} ({++i}/{_sites.Count})…"; + await InvokeAsync(StateHasChanged); + var opts = new SearchOptions(exts, _regex, null, null, null, null, _createdBy.TrimOrNull(), _modifiedBy.TrimOrNull(), _library.TrimOrNull(), _maxResults, site.Url); + var found = await Elevation.RunAsync(async c => + { + var ctx = await SessionMgr.GetOrCreateContextAsync(site.Url, Session.CurrentProfile!, c); + return await SearchSvc.SearchFilesAsync(ctx, opts, progress, c); + }, _cts.Token); + bySite.Add((site.Title, found)); + flat.AddRange(found); + } + _bySite = bySite; _results = flat; + _status = $"Found {_results.Count} files across {_sites.Count} site(s)."; } catch (OperationCanceledException) { _status = "Cancelled."; } catch (Exception ex) { _error = ex.Message; } @@ -123,6 +136,16 @@ } 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"); } + private async Task ExportCsv() + { + var ts = DateTime.Now.ToString("yyyyMMdd_HHmmss"); + var output = ReportMergeHelper.Build(_bySite, _mergeMode, "search", ts, ReportFormat.Csv, rs => CsvExport.BuildCsv(rs)); + await WebExport.DownloadBytesAsync(output.Bytes, output.FileName, output.Mime); + } + private async Task ExportHtml() + { + var ts = DateTime.Now.ToString("yyyyMMdd_HHmmss"); + var output = ReportMergeHelper.Build(_bySite, _mergeMode, "search", ts, ReportFormat.Html, rs => HtmlExport.BuildHtml(rs, Session.CurrentBranding)); + await WebExport.DownloadBytesAsync(output.Bytes, output.FileName, output.Mime); + } } diff --git a/Components/Pages/Settings.razor b/Components/Pages/Settings.razor index 26a48b5..ecf4ce6 100644 --- a/Components/Pages/Settings.razor +++ b/Components/Pages/Settings.razor @@ -35,11 +35,21 @@
+
+
Report Branding
+
+ +

Shown top-left on exported HTML reports. The client's logo (top-right) is set per profile.

+ +
+
+ @if (_saved) {
Settings saved.
} @code { private string _lang = "en", _theme = "System"; private bool _autoTakeOwnership, _saved; + private LogoData? _mspLogo; protected override void OnInitialized() { @@ -47,11 +57,18 @@ _lang = s.Lang; _theme = s.Theme is "System" or "Light" ? s.Theme : "System"; _autoTakeOwnership = s.AutoTakeOwnership; + _mspLogo = s.MspLogo; + } + + private async Task OnMspLogoChanged(LogoData? logo) + { + _mspLogo = logo; + await Save(); } private async Task Save() { - Session.UpdateSettings(new AppSettings { Lang = _lang, Theme = _theme, AutoTakeOwnership = _autoTakeOwnership }); + Session.UpdateSettings(new AppSettings { Lang = _lang, Theme = _theme, AutoTakeOwnership = _autoTakeOwnership, MspLogo = _mspLogo }); SharepointToolbox.Web.Localization.TranslationSource.Instance.SetCulture(_lang); await JS.InvokeVoidAsync("sptb.setTheme", _theme); _saved = true; diff --git a/Components/Pages/Storage.razor b/Components/Pages/Storage.razor index 61674b1..f70edb4 100644 --- a/Components/Pages/Storage.razor +++ b/Components/Pages/Storage.razor @@ -15,10 +15,12 @@
Scan Options
-
-
- - + +
+
+ + + 0 = libraries only. 1+ = drill into subfolders that many levels deep.
@@ -32,9 +34,10 @@ + @if (_sites.Count > 0) { @_sites.Count site(s) selected } @if (_running) { }
- +
@if (!string.IsNullOrEmpty(_error)) {
@_error
} @@ -45,6 +48,7 @@
Storage Report @_results.Count libraries
+
@@ -79,28 +83,43 @@ } @code { - private string _siteUrl = string.Empty; + private List _sites = new(); private bool _includeSubsites, _includeHidden = true, _includeRecycleBin = true; + private int _folderDepth; private bool _running; private string _status = string.Empty, _error = string.Empty; private int _current, _total; private List _results = new(); + private List<(string Label, IReadOnlyList Results)> _bySite = new(); + private ReportMergeMode _mergeMode = ReportMergeMode.SingleMerged; private CancellationTokenSource? _cts; private async Task RunScan() { - _error = string.Empty; _results.Clear(); _running = true; + _error = string.Empty; _results = new(); _bySite = new(); _running = true; _cts = new CancellationTokenSource(); - var siteUrl = string.IsNullOrWhiteSpace(_siteUrl) ? Session.CurrentProfile!.TenantUrl : _siteUrl.Trim(); + if (_sites.Count == 0) { _error = "Please select at least one site."; _running = false; return; } var progress = new Progress(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); }); try { - var opts = new StorageScanOptions(IncludeSubsites: _includeSubsites, IncludeHiddenLibraries: _includeHidden, IncludeRecycleBin: _includeRecycleBin); - _results = (await Elevation.RunAsync(async c => + var opts = new StorageScanOptions(IncludeSubsites: _includeSubsites, FolderDepth: Math.Clamp(_folderDepth, 0, 20), IncludeHiddenLibraries: _includeHidden, IncludeRecycleBin: _includeRecycleBin); + var bySite = new List<(string, IReadOnlyList)>(); + var flat = new List(); + int i = 0; + foreach (var site in _sites) { - var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, c); - return await StorageSvc.CollectStorageAsync(ctx, opts, progress, c); - }, _cts.Token)).ToList(); - _status = $"Complete: {_results.Count} nodes."; + _cts.Token.ThrowIfCancellationRequested(); + _status = $"Scanning {site.Title} ({++i}/{_sites.Count})…"; + await InvokeAsync(StateHasChanged); + var nodes = await Elevation.RunAsync(async c => + { + var ctx = await SessionMgr.GetOrCreateContextAsync(site.Url, Session.CurrentProfile!, c); + return await StorageSvc.CollectStorageAsync(ctx, opts, progress, c); + }, _cts.Token); + bySite.Add((site.Title, nodes)); + flat.AddRange(nodes); + } + _bySite = bySite; _results = flat; + _status = $"Complete: {_results.Count} nodes across {_sites.Count} site(s)."; } catch (OperationCanceledException) { _status = "Cancelled."; } catch (Exception ex) { _error = ex.Message; } @@ -111,12 +130,16 @@ private async Task ExportCsv() { - var csv = CsvExport.BuildCsv(_results); - await WebExport.DownloadCsvAsync(csv, $"storage_{DateTime.Now:yyyyMMdd_HHmmss}.csv"); + var ts = DateTime.Now.ToString("yyyyMMdd_HHmmss"); + var output = ReportMergeHelper.Build(_bySite, _mergeMode, "storage", ts, ReportFormat.Csv, + rs => CsvExport.BuildCsv(rs)); + await WebExport.DownloadBytesAsync(output.Bytes, output.FileName, output.Mime); } private async Task ExportHtml() { - var html = HtmlExport.BuildHtml(_results); - await WebExport.DownloadHtmlAsync(html, $"storage_{DateTime.Now:yyyyMMdd_HHmmss}.html"); + var ts = DateTime.Now.ToString("yyyyMMdd_HHmmss"); + var output = ReportMergeHelper.Build(_bySite, _mergeMode, "storage", ts, ReportFormat.Html, + rs => HtmlExport.BuildHtml(rs, Session.CurrentBranding)); + await WebExport.DownloadBytesAsync(output.Bytes, output.FileName, output.Mime); } } diff --git a/Components/Pages/Templates.razor b/Components/Pages/Templates.razor index 06ee1b3..1d920b7 100644 --- a/Components/Pages/Templates.razor +++ b/Components/Pages/Templates.razor @@ -33,7 +33,7 @@ - +
diff --git a/Components/Pages/UserAccessAudit.razor b/Components/Pages/UserAccessAudit.razor index da55a3e..f2f3fb7 100644 --- a/Components/Pages/UserAccessAudit.razor +++ b/Components/Pages/UserAccessAudit.razor @@ -37,7 +37,7 @@ @if (_running) { }
- +
@if (!string.IsNullOrEmpty(_error)) {
@_error
} @@ -103,5 +103,5 @@ 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"); } + private async Task ExportHtml() { await WebExport.DownloadHtmlAsync(HtmlExport.BuildHtml(_results, branding: Session.CurrentBranding), $"user_audit_{DateTime.Now:yyyyMMdd_HHmmss}.html"); } } diff --git a/Components/Pages/UserDirectory.razor b/Components/Pages/UserDirectory.razor index f1a2eb5..c7ff813 100644 --- a/Components/Pages/UserDirectory.razor +++ b/Components/Pages/UserDirectory.razor @@ -16,7 +16,7 @@ @(_running ? $"Loading… ({_loadCount} users)" : "Load Users")
- +
@if (!string.IsNullOrEmpty(_error)) {
@_error
} diff --git a/Components/Pages/VersionCleanup.razor b/Components/Pages/VersionCleanup.razor index fe4efab..9db5b46 100644 --- a/Components/Pages/VersionCleanup.razor +++ b/Components/Pages/VersionCleanup.razor @@ -56,7 +56,7 @@ @if (_running) { }
- + @if (!string.IsNullOrEmpty(_error)) {
@_error
} @@ -109,7 +109,8 @@ _loading = true; _error = string.Empty; try { - var siteUrl = string.IsNullOrWhiteSpace(_siteUrl) ? Session.CurrentProfile!.TenantUrl : _siteUrl.Trim(); + if (string.IsNullOrWhiteSpace(_siteUrl)) { _error = "Please enter a site URL."; return; } + var siteUrl = _siteUrl.Trim(); _libraries = (await Elevation.RunAsync(async c => { var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, c); @@ -126,7 +127,8 @@ { _error = string.Empty; _results.Clear(); _running = true; _cts = new CancellationTokenSource(); - var siteUrl = string.IsNullOrWhiteSpace(_siteUrl) ? Session.CurrentProfile!.TenantUrl : _siteUrl.Trim(); + if (string.IsNullOrWhiteSpace(_siteUrl)) { _error = "Please enter a site URL."; _running = false; return; } + var siteUrl = _siteUrl.Trim(); var progress = new Progress(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); }); try { @@ -144,5 +146,5 @@ } private void Cancel() => _cts?.Cancel(); - private async Task ExportHtml() { await WebExport.DownloadHtmlAsync(HtmlExport.BuildHtml(_results), $"versions_{DateTime.Now:yyyyMMdd_HHmmss}.html"); } + private async Task ExportHtml() { await WebExport.DownloadHtmlAsync(HtmlExport.BuildHtml(_results, Session.CurrentBranding), $"versions_{DateTime.Now:yyyyMMdd_HHmmss}.html"); } } diff --git a/Components/Shared/LogoUpload.razor b/Components/Shared/LogoUpload.razor new file mode 100644 index 0000000..f0cccda --- /dev/null +++ b/Components/Shared/LogoUpload.razor @@ -0,0 +1,64 @@ +@using Microsoft.AspNetCore.Components.Forms +@using SharepointToolbox.Web.Core.Models + +@* Reusable logo picker. Reads an image into a base64 LogoData (no disk/blob storage). *@ +
+ @if (Value is not null) + { +
+ + +
+ } + else + { + + PNG, JPEG, SVG or GIF — max @(MaxBytes / 1024) KB. + } + @if (!string.IsNullOrEmpty(_error)) {
@_error
} +
+ +@code { + [Parameter] public LogoData? Value { get; set; } + [Parameter] public EventCallback ValueChanged { get; set; } + + /// Upload size cap. Logos are stored inline as base64 in JSON, so keep small. + [Parameter] public long MaxBytes { get; set; } = 512 * 1024; + + private string _error = string.Empty; + + private async Task OnChange(InputFileChangeEventArgs e) + { + _error = string.Empty; + var file = e.File; + if (file is null) return; + + if (file.Size > MaxBytes) + { + _error = $"File too large ({file.Size / 1024} KB). Max {MaxBytes / 1024} KB."; + return; + } + + try + { + using var ms = new MemoryStream(); + await file.OpenReadStream(MaxBytes).CopyToAsync(ms); + var mime = string.IsNullOrWhiteSpace(file.ContentType) ? "image/png" : file.ContentType; + var logo = new LogoData { Base64 = Convert.ToBase64String(ms.ToArray()), MimeType = mime }; + Value = logo; + await ValueChanged.InvokeAsync(logo); + } + catch (Exception ex) + { + _error = $"Could not read image: {ex.Message}"; + } + } + + private async Task Remove() + { + Value = null; + _error = string.Empty; + await ValueChanged.InvokeAsync(null); + } +} diff --git a/Components/Shared/MergeModeSelect.razor b/Components/Shared/MergeModeSelect.razor new file mode 100644 index 0000000..38eee77 --- /dev/null +++ b/Components/Shared/MergeModeSelect.razor @@ -0,0 +1,22 @@ +@* Dropdown for choosing how multi-site reports are bundled on export. *@ +@if (Visible) +{ + +} + +@code { + [Parameter] public ReportMergeMode Value { get; set; } + [Parameter] public EventCallback ValueChanged { get; set; } + [Parameter] public bool Visible { get; set; } = true; + + private async Task OnChange(ChangeEventArgs e) + { + if (Enum.TryParse(e.Value?.ToString(), out var mode)) + await ValueChanged.InvokeAsync(mode); + } +} diff --git a/Components/Shared/SitePicker.razor b/Components/Shared/SitePicker.razor new file mode 100644 index 0000000..c933b3c --- /dev/null +++ b/Components/Shared/SitePicker.razor @@ -0,0 +1,106 @@ +@inject ISiteDiscoveryService SiteDiscovery + +
+
+
+ + +
+ +
+ + @if (!string.IsNullOrEmpty(_error)) + { +
@_error
+ } + + @if (_all.Count > 0) + { +
+ + + + @SelectedSites.Count selected +
+
+ @foreach (var s in Filtered) + { + + } + @if (!Filtered.Any()) + { +
No sites match the filter.
+ } +
+ } + else if (!_loading) + { +
Click “Load sites” to list the tenant’s SharePoint sites, then tick the ones to scan.
+ } +
+ +@code { + [Parameter] public TenantProfile Profile { get; set; } = default!; + [Parameter] public List SelectedSites { get; set; } = new(); + [Parameter] public EventCallback> SelectedSitesChanged { get; set; } + [Parameter] public bool Disabled { get; set; } + + private List _all = new(); + private string _filter = string.Empty; + private bool _loading; + private string _error = string.Empty; + + private IEnumerable Filtered => + string.IsNullOrWhiteSpace(_filter) + ? _all + : _all.Where(s => + s.Title.Contains(_filter, StringComparison.OrdinalIgnoreCase) || + s.Url.Contains(_filter, StringComparison.OrdinalIgnoreCase)); + + private bool IsSelected(SiteInfo s) => + SelectedSites.Any(x => string.Equals(x.Url, s.Url, StringComparison.OrdinalIgnoreCase)); + + private async Task LoadSites() + { + _loading = true; _error = string.Empty; + try + { + _all = (await SiteDiscovery.SearchSitesAsync(Profile)).ToList(); + if (_all.Count == 0) _error = "No sites returned. The account may lack Sites.Read.All."; + } + catch (Exception ex) { _error = ex.Message; } + finally { _loading = false; } + } + + private async Task Toggle(SiteInfo s, bool on) + { + if (on) + { + if (!IsSelected(s)) SelectedSites.Add(s); + } + else + { + SelectedSites.RemoveAll(x => string.Equals(x.Url, s.Url, StringComparison.OrdinalIgnoreCase)); + } + await SelectedSitesChanged.InvokeAsync(SelectedSites); + } + + private async Task SelectAllFiltered() + { + foreach (var s in Filtered) + if (!IsSelected(s)) SelectedSites.Add(s); + await SelectedSitesChanged.InvokeAsync(SelectedSites); + } + + private async Task ClearSelection() + { + SelectedSites.Clear(); + await SelectedSitesChanged.InvokeAsync(SelectedSites); + } +} diff --git a/Core/Models/AppSettings.cs b/Core/Models/AppSettings.cs index a6d141e..1401701 100644 --- a/Core/Models/AppSettings.cs +++ b/Core/Models/AppSettings.cs @@ -6,4 +6,7 @@ public class AppSettings public string Lang { get; set; } = "en"; public bool AutoTakeOwnership { get; set; } = false; public string Theme { get; set; } = "System"; + + /// MSP logo shown top-left on exported reports. Null = none. + public LogoData? MspLogo { get; set; } } diff --git a/Localization/TranslationSource.cs b/Localization/TranslationSource.cs index 692063d..a149bd3 100644 --- a/Localization/TranslationSource.cs +++ b/Localization/TranslationSource.cs @@ -11,7 +11,13 @@ public class TranslationSource { public static readonly TranslationSource Instance = new(); - private ResourceManager _resourceManager = Strings.ResourceManager; + // Construct the ResourceManager with the explicit manifest base name rather + // than Strings.ResourceManager: the generated designer carries a stale base + // name ("SharepointToolbox.Strings") from before the project was renamed to + // *.Web, so its lookups throw MissingManifestResourceException. The embedded + // resource is "SharepointToolbox.Web.Localization.Strings". + private ResourceManager _resourceManager = + new ResourceManager("SharepointToolbox.Web.Localization.Strings", typeof(TranslationSource).Assembly); private CultureInfo _currentCulture = CultureInfo.CurrentUICulture; private TranslationSource() { } diff --git a/Program.cs b/Program.cs index 06ddc85..e557626 100644 --- a/Program.cs +++ b/Program.cs @@ -147,6 +147,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/Services/Export/ReportMergeHelper.cs b/Services/Export/ReportMergeHelper.cs new file mode 100644 index 0000000..3ac89db --- /dev/null +++ b/Services/Export/ReportMergeHelper.cs @@ -0,0 +1,116 @@ +using System.IO; +using System.IO.Compression; +using System.Text; + +namespace SharepointToolbox.Web.Services.Export; + +/// How a multi-site report is bundled for download. +public enum ReportMergeMode +{ + /// One document containing all sites, no per-site separation. + SingleMerged, + /// One HTML document with one tab per site (CSV falls back to merged). + SingleTabbed, + /// One file per site, delivered as a single ZIP. + MultipleFiles +} + +/// Output format of a report. +public enum ReportFormat +{ + Csv, + Html +} + +/// A ready-to-download artifact: bytes plus filename and MIME type. +public sealed record MergeOutput(string FileName, byte[] Bytes, string Mime); + +/// +/// Bundles per-site report content into a single downloadable artifact +/// according to a . Format-agnostic: the caller +/// supplies a buildDoc delegate that renders one site's results to a +/// document string; this helper handles flattening, tabbing, and zipping. +/// +public static class ReportMergeHelper +{ + /// + /// Builds the download artifact. + /// + /// Per-site results (label + result list). + /// How to bundle the output. + /// File stem, e.g. "permissions". + /// Stamp appended to single-file names, e.g. "20260602_101500". + /// CSV or HTML. + /// Renders one result list to a complete document. + public static MergeOutput Build( + IReadOnlyList<(string Label, IReadOnlyList Results)> sites, + ReportMergeMode mode, + string baseName, + string timestamp, + ReportFormat format, + Func, string> buildDoc) + { + var ext = format == ReportFormat.Csv ? "csv" : "html"; + var mime = format == ReportFormat.Csv ? "text/csv;charset=utf-8" : "text/html;charset=utf-8"; + // CSV is BOM-prefixed for Excel; HTML is not. + var enc = new UTF8Encoding(encoderShouldEmitUTF8Identifier: format == ReportFormat.Csv); + + // Tabs are an HTML-only concept — degrade to a single merged CSV. + if (mode == ReportMergeMode.SingleTabbed && format == ReportFormat.Csv) + mode = ReportMergeMode.SingleMerged; + + switch (mode) + { + case ReportMergeMode.SingleTabbed: + { + var parts = sites + .Select(s => (s.Label, buildDoc(s.Results))) + .ToList(); + var html = ReportSplitHelper.BuildTabbedHtml(parts, baseName); + return new MergeOutput($"{baseName}_{timestamp}.html", enc.GetBytes(html), mime); + } + + case ReportMergeMode.MultipleFiles: + { + using var ms = new MemoryStream(); + using (var zip = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) + { + var used = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var s in sites) + { + var name = UniqueName(used, $"{baseName}_{ReportSplitHelper.SanitizeFileName(s.Label)}.{ext}"); + var entry = zip.CreateEntry(name, CompressionLevel.Optimal); + using var es = entry.Open(); + var bytes = enc.GetBytes(buildDoc(s.Results)); + es.Write(bytes, 0, bytes.Length); + } + } + return new MergeOutput($"{baseName}_{timestamp}.zip", ms.ToArray(), "application/zip"); + } + + case ReportMergeMode.SingleMerged: + default: + { + var flat = sites.SelectMany(s => s.Results).ToList(); + var bytes = enc.GetBytes(buildDoc(flat)); + return new MergeOutput($"{baseName}_{timestamp}.{ext}", bytes, mime); + } + } + } + + /// + /// Returns or, if already taken, a suffixed + /// variant ("name_2.ext", "name_3.ext", …) so ZIP entries never collide. + /// + private static string UniqueName(HashSet used, string candidate) + { + if (used.Add(candidate)) return candidate; + var stem = Path.GetFileNameWithoutExtension(candidate); + var ext = Path.GetExtension(candidate); + for (int i = 2; ; i++) + { + var next = $"{stem}_{i}{ext}"; + if (used.Add(next)) return next; + } + } +} diff --git a/Services/Export/WebExportService.cs b/Services/Export/WebExportService.cs index 3996161..b877847 100644 --- a/Services/Export/WebExportService.cs +++ b/Services/Export/WebExportService.cs @@ -25,4 +25,13 @@ public class WebExportService var bytes = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(content); await _js.InvokeVoidAsync("sptb.downloadFile", fileName, "text/html;charset=utf-8", Convert.ToBase64String(bytes)); } + + /// + /// Downloads pre-encoded bytes (e.g. a ZIP or a merged report produced by + /// ) with an explicit MIME type. + /// + public async Task DownloadBytesAsync(byte[] content, string fileName, string mime) + { + await _js.InvokeVoidAsync("sptb.downloadFile", fileName, mime, Convert.ToBase64String(content)); + } } diff --git a/Services/ISiteDiscoveryService.cs b/Services/ISiteDiscoveryService.cs new file mode 100644 index 0000000..9a20f6e --- /dev/null +++ b/Services/ISiteDiscoveryService.cs @@ -0,0 +1,20 @@ +using SharepointToolbox.Web.Core.Models; + +namespace SharepointToolbox.Web.Services; + +/// +/// Discovers SharePoint sites in a tenant via Microsoft Graph so users can +/// pick multiple sites to scan instead of typing URLs one at a time. +/// +public interface ISiteDiscoveryService +{ + /// + /// Returns sites matching (defaults to all sites). + /// OneDrive personal sites are excluded; results are de-duplicated by URL + /// and ordered by title. + /// + Task> SearchSitesAsync( + TenantProfile profile, + string? query = null, + CancellationToken ct = default); +} diff --git a/Services/Session/IUserSessionService.cs b/Services/Session/IUserSessionService.cs index 31b2dcb..ee1c4ca 100644 --- a/Services/Session/IUserSessionService.cs +++ b/Services/Session/IUserSessionService.cs @@ -12,6 +12,9 @@ public interface IUserSessionService bool HasProfile { get; } AppSettings Settings { get; } + /// Branding for exported reports: MSP logo (settings) + active profile's client logo. + ReportBranding CurrentBranding { get; } + void SetProfile(TenantProfile profile); Task ClearSessionAsync(); void UpdateSettings(AppSettings settings); diff --git a/Services/Session/UserSessionService.cs b/Services/Session/UserSessionService.cs index 8587a74..d7ec810 100644 --- a/Services/Session/UserSessionService.cs +++ b/Services/Session/UserSessionService.cs @@ -15,6 +15,8 @@ public class UserSessionService : IUserSessionService public bool HasProfile => _currentProfile is not null; public AppSettings Settings => _settings; + public ReportBranding CurrentBranding => new(_settings.MspLogo, _currentProfile?.ClientLogo); + public event Action? ProfileChanged; public UserSessionService(ISessionManager sessionManager, SettingsRepository settingsRepo) diff --git a/Services/SiteDiscoveryService.cs b/Services/SiteDiscoveryService.cs new file mode 100644 index 0000000..d63e9b5 --- /dev/null +++ b/Services/SiteDiscoveryService.cs @@ -0,0 +1,76 @@ +using Microsoft.Graph; +using Microsoft.Graph.Models; +using Microsoft.Kiota.Abstractions; +using SharepointToolbox.Web.Core.Models; +using AppGraphClientFactory = SharepointToolbox.Web.Infrastructure.Auth.GraphClientFactory; + +namespace SharepointToolbox.Web.Services; + +/// +/// Delegated Graph implementation of . +/// Uses the /sites?search=* endpoint, paging through every result. +/// Requires the delegated Sites.Read.All scope. +/// +public class SiteDiscoveryService : ISiteDiscoveryService +{ + private readonly AppGraphClientFactory _graphClientFactory; + + public SiteDiscoveryService(AppGraphClientFactory graphClientFactory) + { + _graphClientFactory = graphClientFactory; + } + + public async Task> SearchSitesAsync( + TenantProfile profile, + string? query = null, + CancellationToken ct = default) + { + var graphClient = await _graphClientFactory.CreateClientAsync(profile); + // "*" is the Graph convention for "return all sites". + var search = string.IsNullOrWhiteSpace(query) ? "*" : query!; + + // The typed Sites.GetAsync maps its Search property to OData "$search", + // which routes "*" through KQL and fails ("'*' is not valid at position 0"). + // The all-sites wildcard only works via the bare, non-OData "search" + // query parameter, so build the request manually. + var requestInfo = new RequestInformation + { + HttpMethod = Method.GET, + UrlTemplate = "{+baseurl}/sites{?search,%24top}", + PathParameters = new Dictionary + { + { "baseurl", graphClient.RequestAdapter.BaseUrl ?? "https://graph.microsoft.com/v1.0" } + }, + }; + requestInfo.QueryParameters.Add("search", search); + requestInfo.QueryParameters.Add("%24top", 999); + requestInfo.Headers.Add("Accept", "application/json"); + + var response = await graphClient.RequestAdapter.SendAsync( + requestInfo, SiteCollectionResponse.CreateFromDiscriminatorValue, cancellationToken: ct); + + if (response is null) return Array.Empty(); + + var results = new List(); + var iter = PageIterator.CreatePageIterator( + graphClient, response, + site => + { + if (ct.IsCancellationRequested) return false; + var url = site.WebUrl ?? string.Empty; + if (string.IsNullOrEmpty(url)) return true; + // Skip OneDrive personal sites — not useful for these scans. + if (url.Contains("/personal/", StringComparison.OrdinalIgnoreCase)) return true; + var title = site.DisplayName ?? site.Name ?? url; + results.Add(new SiteInfo(url, title)); + return true; + }); + await iter.IterateAsync(ct); + + return results + .GroupBy(s => s.Url, StringComparer.OrdinalIgnoreCase) + .Select(g => g.First()) + .OrderBy(s => s.Title, StringComparer.OrdinalIgnoreCase) + .ToList(); + } +}