Add report logos and configurable folder scan depth #2

Merged
kawa merged 2 commits from feat/report-logos-and-scan-depth into main 2026-06-02 15:02:52 +02:00
12 changed files with 112 additions and 9 deletions
Showing only changes of commit 5df7b72800 - Show all commits
+1 -1
View File
@@ -111,5 +111,5 @@
private void Cancel() => _cts?.Cancel(); private void Cancel() => _cts?.Cancel();
private async Task ExportCsv() { await WebExport.DownloadCsvAsync(CsvExport.BuildCsv(_results), $"duplicates_{DateTime.Now:yyyyMMdd_HHmmss}.csv"); } 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"); } private async Task ExportHtml() { await WebExport.DownloadHtmlAsync(HtmlExport.BuildHtml(_results, Session.CurrentBranding), $"duplicates_{DateTime.Now:yyyyMMdd_HHmmss}.html"); }
} }
+1 -1
View File
@@ -133,7 +133,7 @@
private async Task ExportHtml() private async Task ExportHtml()
{ {
var html = HtmlExport.BuildHtml(_results); var html = HtmlExport.BuildHtml(_results, Session.CurrentBranding);
await WebExport.DownloadHtmlAsync(html, $"permissions_{DateTime.Now:yyyyMMdd_HHmmss}.html"); await WebExport.DownloadHtmlAsync(html, $"permissions_{DateTime.Now:yyyyMMdd_HHmmss}.html");
} }
} }
+7 -1
View File
@@ -146,6 +146,12 @@
} }
</div> </div>
<div class="form-group">
<label class="form-label">Client logo (optional)</label>
<small class="text-muted d-block" style="margin-bottom:6px">Shown top-right on exported reports for this client.</small>
<LogoUpload Value="_form.ClientLogo" ValueChanged="(LogoData? l) => _form.ClientLogo = l" />
</div>
<div class="flex-row mt-8"> <div class="flex-row mt-8">
<button class="btn btn-primary" @onclick="SaveProfile">Save</button> <button class="btn btn-primary" @onclick="SaveProfile">Save</button>
<button class="btn btn-secondary" @onclick="CancelForm">Cancel</button> <button class="btn btn-secondary" @onclick="CancelForm">Cancel</button>
@@ -214,7 +220,7 @@
private void EditProfile(TenantProfile p) private void EditProfile(TenantProfile p)
{ {
_editing = 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; _showForm = true;
_formError = _pageError = string.Empty; _formError = _pageError = string.Empty;
} }
+1 -1
View File
@@ -124,5 +124,5 @@
private void Cancel() => _cts?.Cancel(); private void Cancel() => _cts?.Cancel();
private async Task ExportCsv() { await WebExport.DownloadCsvAsync(CsvExport.BuildCsv(_results), $"search_{DateTime.Now:yyyyMMdd_HHmmss}.csv"); } 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 ExportHtml() { await WebExport.DownloadHtmlAsync(HtmlExport.BuildHtml(_results, Session.CurrentBranding), $"search_{DateTime.Now:yyyyMMdd_HHmmss}.html"); }
} }
+18 -1
View File
@@ -35,11 +35,21 @@
</div> </div>
</div> </div>
<div class="card">
<div class="card-title">Report Branding</div>
<div class="form-group">
<label class="form-label">MSP logo</label>
<p class="text-muted" style="margin-top:0">Shown top-left on exported HTML reports. The client's logo (top-right) is set per profile.</p>
<LogoUpload Value="_mspLogo" ValueChanged="OnMspLogoChanged" />
</div>
</div>
@if (_saved) { <div class="alert alert-success">Settings saved.</div> } @if (_saved) { <div class="alert alert-success">Settings saved.</div> }
@code { @code {
private string _lang = "en", _theme = "System"; private string _lang = "en", _theme = "System";
private bool _autoTakeOwnership, _saved; private bool _autoTakeOwnership, _saved;
private LogoData? _mspLogo;
protected override void OnInitialized() protected override void OnInitialized()
{ {
@@ -47,11 +57,18 @@
_lang = s.Lang; _lang = s.Lang;
_theme = s.Theme is "System" or "Light" ? s.Theme : "System"; _theme = s.Theme is "System" or "Light" ? s.Theme : "System";
_autoTakeOwnership = s.AutoTakeOwnership; _autoTakeOwnership = s.AutoTakeOwnership;
_mspLogo = s.MspLogo;
}
private async Task OnMspLogoChanged(LogoData? logo)
{
_mspLogo = logo;
await Save();
} }
private async Task 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); SharepointToolbox.Web.Localization.TranslationSource.Instance.SetCulture(_lang);
await JS.InvokeVoidAsync("sptb.setTheme", _theme); await JS.InvokeVoidAsync("sptb.setTheme", _theme);
_saved = true; _saved = true;
+10 -2
View File
@@ -21,6 +21,13 @@
<input class="form-input" @bind="_siteUrl" placeholder="@Session.CurrentProfile!.TenantUrl" /> <input class="form-input" @bind="_siteUrl" placeholder="@Session.CurrentProfile!.TenantUrl" />
</div> </div>
</div> </div>
<div class="form-row">
<div class="form-group" style="max-width:220px">
<label class="form-label">Folder scan depth</label>
<input class="form-input" type="number" min="0" max="20" @bind="_folderDepth" />
<small class="text-muted">0 = libraries only. 1+ = drill into subfolders that many levels deep.</small>
</div>
</div>
<div class="form-row"> <div class="form-row">
<div class="form-group" style="display:flex;align-items:center;gap:16px;padding-top:20px"> <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="_includeSubsites" /> Include subsites</label>
@@ -81,6 +88,7 @@
@code { @code {
private string _siteUrl = string.Empty; private string _siteUrl = string.Empty;
private bool _includeSubsites, _includeHidden = true, _includeRecycleBin = true; private bool _includeSubsites, _includeHidden = true, _includeRecycleBin = true;
private int _folderDepth;
private bool _running; private string _status = string.Empty, _error = string.Empty; private bool _running; private string _status = string.Empty, _error = string.Empty;
private int _current, _total; private int _current, _total;
private List<StorageNode> _results = new(); private List<StorageNode> _results = new();
@@ -94,7 +102,7 @@
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); }); var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
try try
{ {
var opts = new StorageScanOptions(IncludeSubsites: _includeSubsites, IncludeHiddenLibraries: _includeHidden, IncludeRecycleBin: _includeRecycleBin); var opts = new StorageScanOptions(IncludeSubsites: _includeSubsites, FolderDepth: Math.Clamp(_folderDepth, 0, 20), IncludeHiddenLibraries: _includeHidden, IncludeRecycleBin: _includeRecycleBin);
_results = (await Elevation.RunAsync(async c => _results = (await Elevation.RunAsync(async c =>
{ {
var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, c); var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, c);
@@ -116,7 +124,7 @@
} }
private async Task ExportHtml() private async Task ExportHtml()
{ {
var html = HtmlExport.BuildHtml(_results); var html = HtmlExport.BuildHtml(_results, Session.CurrentBranding);
await WebExport.DownloadHtmlAsync(html, $"storage_{DateTime.Now:yyyyMMdd_HHmmss}.html"); await WebExport.DownloadHtmlAsync(html, $"storage_{DateTime.Now:yyyyMMdd_HHmmss}.html");
} }
} }
+1 -1
View File
@@ -103,5 +103,5 @@
private void Cancel() => _cts?.Cancel(); 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 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"); }
} }
+1 -1
View File
@@ -144,5 +144,5 @@
} }
private void Cancel() => _cts?.Cancel(); 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"); }
} }
+64
View File
@@ -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). *@
<div class="logo-upload">
@if (Value is not null)
{
<div class="flex-row" style="gap:12px;align-items:center">
<img src="data:@Value.MimeType;base64,@Value.Base64" alt=""
style="max-height:60px;max-width:200px;object-fit:contain;border:1px solid var(--border);border-radius:4px;padding:4px;background:#fff" />
<button type="button" class="btn btn-secondary btn-sm" @onclick="Remove">Remove</button>
</div>
}
else
{
<InputFile OnChange="OnChange" accept="image/png,image/jpeg,image/svg+xml,image/gif" />
<small class="text-muted d-block">PNG, JPEG, SVG or GIF — max @(MaxBytes / 1024) KB.</small>
}
@if (!string.IsNullOrEmpty(_error)) { <div class="alert alert-error mt-8">@_error</div> }
</div>
@code {
[Parameter] public LogoData? Value { get; set; }
[Parameter] public EventCallback<LogoData?> ValueChanged { get; set; }
/// <summary>Upload size cap. Logos are stored inline as base64 in JSON, so keep small.</summary>
[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);
}
}
+3
View File
@@ -6,4 +6,7 @@ public class AppSettings
public string Lang { get; set; } = "en"; public string Lang { get; set; } = "en";
public bool AutoTakeOwnership { get; set; } = false; public bool AutoTakeOwnership { get; set; } = false;
public string Theme { get; set; } = "System"; public string Theme { get; set; } = "System";
/// <summary>MSP logo shown top-left on exported reports. Null = none.</summary>
public LogoData? MspLogo { get; set; }
} }
+3
View File
@@ -12,6 +12,9 @@ public interface IUserSessionService
bool HasProfile { get; } bool HasProfile { get; }
AppSettings Settings { get; } AppSettings Settings { get; }
/// <summary>Branding for exported reports: MSP logo (settings) + active profile's client logo.</summary>
ReportBranding CurrentBranding { get; }
void SetProfile(TenantProfile profile); void SetProfile(TenantProfile profile);
Task ClearSessionAsync(); Task ClearSessionAsync();
void UpdateSettings(AppSettings settings); void UpdateSettings(AppSettings settings);
+2
View File
@@ -15,6 +15,8 @@ public class UserSessionService : IUserSessionService
public bool HasProfile => _currentProfile is not null; public bool HasProfile => _currentProfile is not null;
public AppSettings Settings => _settings; public AppSettings Settings => _settings;
public ReportBranding CurrentBranding => new(_settings.MspLogo, _currentProfile?.ClientLogo);
public event Action? ProfileChanged; public event Action? ProfileChanged;
public UserSessionService(ISessionManager sessionManager, SettingsRepository settingsRepo) public UserSessionService(ISessionManager sessionManager, SettingsRepository settingsRepo)