This commit is contained in:
2026-06-02 17:39:58 +02:00
36 changed files with 2520 additions and 463 deletions
+39 -28
View File
@@ -8,6 +8,7 @@
@inject IJSRuntime JS
@inject SharepointToolbox.Web.Services.OAuth.IOAuthFlowCache OAuthCache
@inject SharepointToolbox.Web.Infrastructure.Persistence.ProfileRepository ProfileRepo
@inject TranslationSource T
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.WebUtilities
@using Microsoft.JSInterop
@@ -23,7 +24,7 @@
<span class="logo-mark">SP</span>
<span class="logo-text">SP Toolbox</span>
</div>
<button class="toggle-btn @(_sidebarCollapsed ? "collapsed" : "")" @onclick="ToggleSidebar" title="Toggle sidebar"></button>
<button class="toggle-btn @(_sidebarCollapsed ? "collapsed" : "")" @onclick="ToggleSidebar" title="@T["nav.toggleSidebar"]"></button>
</div>
@* User identity badge *@
@@ -44,7 +45,7 @@
<div style="font-size:10px;color:var(--text-muted);margin-top:4px">
SP: @_credUsername
<button class="btn btn-secondary btn-sm" style="padding:2px 6px;font-size:10px;margin-left:4px"
@onclick="ReconnectAsync">Reconnect</button>
@onclick="ReconnectAsync">@T["nav.reconnect"]</button>
</div>
}
</div>
@@ -61,11 +62,11 @@
<div class="nav-search">
<span class="nav-icon">🔍</span>
<input type="text" class="nav-search-input" placeholder="Search…"
<input type="text" class="nav-search-input" placeholder="@T["nav.searchPlaceholder"]"
@bind="_navFilter" @bind:event="oninput" />
@if (!string.IsNullOrEmpty(_navFilter))
{
<button class="nav-search-clear" @onclick="ClearFilter" title="Clear">✕</button>
<button class="nav-search-clear" @onclick="ClearFilter" title="@T["nav.clear"]">✕</button>
}
</div>
@@ -81,16 +82,16 @@
lastSection = item.Section;
if (!string.IsNullOrEmpty(item.Section))
{
<div class="nav-divider">@item.Section</div>
<div class="nav-divider">@T[item.Section]</div>
}
}
<NavLink href="@item.Href" Match="@(item.Href == "/" ? NavLinkMatch.All : NavLinkMatch.Prefix)" class="nav-item">
<span class="nav-icon">@item.Icon</span><span class="nav-label">@item.Label</span>
<span class="nav-icon">@item.Icon</span><span class="nav-label">@T[item.Label]</span>
</NavLink>
}
@if (items.Count == 0)
{
<div class="nav-empty">No match</div>
<div class="nav-empty">@T["nav.noMatch"]</div>
}
</nav>
@@ -98,13 +99,13 @@
<AuthorizeView>
<Authorized>
<a href="/account/logout" class="nav-item">
<span class="nav-icon">🚪</span><span class="nav-label">Logout</span>
<span class="nav-icon">🚪</span><span class="nav-label">@T["nav.logout"]</span>
</a>
</Authorized>
</AuthorizeView>
<button class="nav-item theme-toggle" @onclick="ToggleTheme">
<span class="nav-icon">🌙</span>
<span class="nav-label">@(_dark ? "Light Mode" : "Dark Mode")</span>
<span class="nav-label">@(_dark ? T["nav.lightMode"] : T["nav.darkMode"])</span>
<span class="switch @(_dark ? "on" : "")"></span>
</button>
</div>
@@ -120,7 +121,7 @@
}
else
{
<div style="padding:2rem;color:var(--text-muted)">Loading</div>
<div style="padding:2rem;color:var(--text-muted)">@T["nav.loading"]</div>
}
</main>
</div>
@@ -137,23 +138,23 @@
private static readonly NavItem[] AllNavItems =
{
new("/", "🏠", "Home", "", "always"),
new("/permissions", "🔐", "Permissions", "", "profile"),
new("/storage", "💾", "Storage", "", "profile"),
new("/duplicates", "📋", "Duplicates", "", "profile"),
new("/versions", "🗂️", "Version Cleanup", "", "profile"),
new("/transfer", "📦", "File Transfer", "", "profile"),
new("/bulk-members", "👥", "Bulk Members", "Bulk", "profile"),
new("/bulk-sites", "🌐", "Bulk Sites", "Bulk", "profile"),
new("/folder-structure", "📁", "Folder Structure", "Bulk", "profile"),
new("/user-audit", "👤", "User Access Audit","Audit", "profile"),
new("/user-directory", "📖", "User Directory", "Audit", "profile"),
new("/templates", "📐", "Templates", "Config", "profile"),
new("/profiles", "⚙️", "Client Profiles", "Admin", "admin"),
new("/admin/users", "👥", "User Management", "Admin", "admin"),
new("/admin/audit", "📋", "Audit Logs", "Admin", "admin"),
new("/settings", "🔧", "Settings", "", "always"),
new("/account/change-password","🔑", "Change Password", "", "auth"),
new("/", "🏠", "nav.home", "", "always"),
new("/permissions", "🔐", "tab.permissions", "", "profile"),
new("/storage", "💾", "tab.storage", "", "profile"),
new("/duplicates", "📋", "tab.duplicates", "", "profile"),
new("/versions", "🗂️", "versions.tab", "", "profile"),
new("/transfer", "📦", "nav.fileTransfer", "", "profile"),
new("/bulk-members", "👥", "tab.bulkMembers", "nav.section.bulk", "profile"),
new("/bulk-sites", "🌐", "tab.bulkSites", "nav.section.bulk", "profile"),
new("/folder-structure", "📁", "tab.folderStructure", "nav.section.bulk", "profile"),
new("/user-audit", "👤", "tab.userAccessAudit", "nav.section.audit", "profile"),
new("/user-directory", "📖", "nav.userDirectory", "nav.section.audit", "profile"),
new("/templates", "📐", "tab.templates", "nav.section.config", "profile"),
new("/profiles", "⚙️", "nav.clientProfiles", "nav.section.admin", "admin"),
new("/admin/users", "👥", "nav.userManagement", "nav.section.admin", "admin"),
new("/admin/audit", "📋", "nav.auditLogs", "nav.section.admin", "admin"),
new("/settings", "🔧", "tab.settings", "", "always"),
new("/account/change-password","🔑", "nav.changePassword", "", "auth"),
};
private IEnumerable<NavItem> VisibleNavItems()
@@ -168,7 +169,7 @@
_ => true
})
.Where(i => filter.Length == 0
|| i.Label.Contains(filter, StringComparison.OrdinalIgnoreCase));
|| T[i.Label].Contains(filter, StringComparison.OrdinalIgnoreCase));
}
private void ClearFilter() => _navFilter = string.Empty;
@@ -177,6 +178,16 @@
protected override void OnInitialized()
{
// Apply the user's language to this circuit's TranslationSource (used by every page in
// the circuit) and to the ambient culture (used by the export services). Runs in both
// the prerender and the interactive circuit, before any page renders — so the whole app
// is in the right language and stays there across SPA navigation.
var lang = Session.Settings.Lang;
T.SetCulture(lang);
var culture = TranslationSource.Resolve(lang);
System.Globalization.CultureInfo.CurrentCulture = culture;
System.Globalization.CultureInfo.CurrentUICulture = culture;
Session.ProfileChanged += OnProfileChanged;
UserContext.Initialized += OnUserContextInitialized;
_dark = string.Equals(Session.Settings.Theme, "Dark", StringComparison.OrdinalIgnoreCase);
+12 -11
View File
@@ -3,28 +3,29 @@
@inject IUserService UserService
@inject IUserContextAccessor UserContext
@inject IAuditService Audit
@inject TranslationSource T
@rendermode InteractiveServer
@using SharepointToolbox.Web.Core.Models
@using SharepointToolbox.Web.Services.Audit
@using SharepointToolbox.Web.Services.Auth
@using SharepointToolbox.Web.Services.Session
<h1 class="page-title">Change Password</h1>
<h1 class="page-title">@T["changepw.title"]</h1>
@if (!UserContext.IsAuthenticated)
{
<div class="alert alert-error">You must be signed in.</div>
<div class="alert alert-error">@T["changepw.mustsignin"]</div>
return;
}
@if (_user is null)
{
<p class="page-subtitle">Loading</p>
<p class="page-subtitle">@T["changepw.loading"]</p>
}
else if (_user.Provider != AuthProvider.Local)
{
<div class="alert alert-info">
Your account signs in with Microsoft (Entra). Manage its password in your Microsoft account.
@T["changepw.entra"]
</div>
}
else
@@ -34,17 +35,17 @@ else
<div class="alert @(_isError ? "alert-error" : "alert-success")">@_message</div>
}
<div class="card" style="max-width:420px">
<label class="form-label" for="cur">Current password</label>
<label class="form-label" for="cur">@T["changepw.current"]</label>
<input id="cur" class="form-input" type="password" @bind="_current" autocomplete="current-password" />
<label class="form-label" for="new" style="margin-top:12px">New password</label>
<label class="form-label" for="new" style="margin-top:12px">@T["changepw.new"]</label>
<input id="new" class="form-input" type="password" @bind="_new" autocomplete="new-password" />
<label class="form-label" for="confirm" style="margin-top:12px">Confirm new password</label>
<label class="form-label" for="confirm" style="margin-top:12px">@T["changepw.confirm"]</label>
<input id="confirm" class="form-input" type="password" @bind="_confirm" autocomplete="new-password" />
<div style="margin-top:14px">
<button class="btn btn-primary" @onclick="SubmitAsync">Change password</button>
<button class="btn btn-primary" @onclick="SubmitAsync">@T["changepw.submit"]</button>
</div>
</div>
}
@@ -68,7 +69,7 @@ else
if (_user is null) return;
if (string.IsNullOrWhiteSpace(_new) || _new != _confirm)
{
_message = "New passwords do not match.";
_message = T["changepw.err.mismatch"];
_isError = true;
return;
}
@@ -78,13 +79,13 @@ else
{
await Audit.LogAsync("PasswordChanged", "", Array.Empty<string>(),
$"Changed own password ({_user.Email}).");
_message = "Password changed.";
_message = T["changepw.success"];
_isError = false;
_current = _new = _confirm = string.Empty;
}
else
{
_message = "Current password is incorrect.";
_message = T["changepw.err.incorrect"];
_isError = true;
}
}
+18 -17
View File
@@ -3,34 +3,35 @@
@inject IAuditService AuditService
@inject IUserContextAccessor UserContext
@inject NavigationManager Nav
@inject TranslationSource T
@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>
<h1 class="page-title">@T["adminaudit.title"]</h1>
<p class="page-subtitle">@T["adminaudit.subtitle"]</p>
@if (!UserContext.IsAuthenticated || UserContext.Role != UserRole.Admin)
{
<div class="alert alert-error">Access denied. Admin role required.</div>
<div class="alert alert-error">@T["adminaudit.accessdenied"]</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>
<input class="form-input" style="width:200px" placeholder="@T["adminaudit.filter.user"]" @bind="_filterUser" @bind:event="oninput" />
<input class="form-input" style="width:200px" placeholder="@T["adminaudit.filter.client"]" @bind="_filterClient" @bind:event="oninput" />
<input class="form-input" style="width:200px" placeholder="@T["adminaudit.filter.action"]" @bind="_filterAction" @bind:event="oninput" />
<a href="/audit/export" class="btn btn-secondary" target="_blank">@T["audit.btn.exportCsv"]</a>
</div>
@if (_loading)
{
<div class="alert alert-info">Loading audit log...</div>
<div class="alert alert-info">@T["adminaudit.loading"]</div>
}
else if (_filtered.Count == 0)
{
<div class="alert alert-info">No audit entries found.</div>
<div class="alert alert-info">@T["adminaudit.noentries"]</div>
}
else
{
@@ -38,13 +39,13 @@ else
<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>
<th style="text-align:left;padding:6px">@T["report.col.timestamp"]</th>
<th style="text-align:left;padding:6px">@T["report.col.user"]</th>
<th style="text-align:left;padding:6px">@T["adminaudit.col.role"]</th>
<th style="text-align:left;padding:6px">@T["adminaudit.col.action"]</th>
<th style="text-align:left;padding:6px">@T["adminaudit.col.client"]</th>
<th style="text-align:left;padding:6px">@T["report.col.sites"]</th>
<th style="text-align:left;padding:6px">@T["adminaudit.col.details"]</th>
</tr>
</thead>
<tbody>
@@ -63,7 +64,7 @@ else
</tbody>
</table>
</div>
<p class="text-muted" style="margin-top:8px;font-size:12px">Showing @_filtered.Count of @_entries.Count entries</p>
<p class="text-muted" style="margin-top:8px;font-size:12px">@string.Format(T["adminaudit.showing"], _filtered.Count, _entries.Count)</p>
}
@code {
+33 -32
View File
@@ -4,18 +4,19 @@
@inject IUserContextAccessor UserContext
@inject IAuditService Audit
@inject NavigationManager Nav
@inject TranslationSource T
@rendermode InteractiveServer
@using SharepointToolbox.Web.Core.Models
@using SharepointToolbox.Web.Services.Audit
@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. Entra users are auto-provisioned on first OIDC login; local users are created here.</p>
<h1 class="page-title">@T["usermgmt.title"]</h1>
<p class="page-subtitle">@T["usermgmt.subtitle"]</p>
@if (!UserContext.IsAuthenticated || UserContext.Role != UserRole.Admin)
{
<div class="alert alert-error">Access denied. Admin role required.</div>
<div class="alert alert-error">@T["usermgmt.accessdenied"]</div>
return;
}
@@ -25,18 +26,18 @@
}
<div class="card">
<h2 class="card-title">Create local user</h2>
<h2 class="card-title">@T["usermgmt.create.title"]</h2>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;max-width:640px">
<div>
<label class="form-label" for="new-email">Email</label>
<label class="form-label" for="new-email">@T["usermgmt.col.email"]</label>
<input id="new-email" class="form-input" type="email" @bind="_newEmail" placeholder="user@example.com" />
</div>
<div>
<label class="form-label" for="new-name">Display name</label>
<label class="form-label" for="new-name">@T["usermgmt.lbl.displayname"]</label>
<input id="new-name" class="form-input" type="text" @bind="_newName" placeholder="Jane Doe" />
</div>
<div>
<label class="form-label" for="new-role">Role</label>
<label class="form-label" for="new-role">@T["usermgmt.col.role"]</label>
<select id="new-role" class="form-input" @bind="_newRole">
@foreach (var role in Enum.GetValues<UserRole>())
{
@@ -45,18 +46,18 @@
</select>
</div>
<div>
<label class="form-label" for="new-pw">Password</label>
<label class="form-label" for="new-pw">@T["usermgmt.lbl.password"]</label>
<input id="new-pw" class="form-input" type="password" @bind="_newPassword" autocomplete="new-password" />
</div>
</div>
<div style="margin-top:12px">
<button class="btn btn-primary" @onclick="CreateLocalUserAsync">Create user</button>
<button class="btn btn-primary" @onclick="CreateLocalUserAsync">@T["usermgmt.btn.create"]</button>
</div>
</div>
@if (_users.Count == 0)
{
<div class="alert alert-info">No users provisioned yet.</div>
<div class="alert alert-info">@T["usermgmt.empty"]</div>
}
else
{
@@ -64,12 +65,12 @@ else
<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">Source</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>
<th style="text-align:left;padding:8px">@T["usermgmt.col.user"]</th>
<th style="text-align:left;padding:8px">@T["usermgmt.col.email"]</th>
<th style="text-align:left;padding:8px">@T["usermgmt.col.source"]</th>
<th style="text-align:left;padding:8px">@T["usermgmt.col.role"]</th>
<th style="text-align:left;padding:8px">@T["usermgmt.col.lastlogin"]</th>
<th style="text-align:left;padding:8px">@T["usermgmt.col.actions"]</th>
</tr>
</thead>
<tbody>
@@ -80,7 +81,7 @@ else
<td style="padding:8px">@user.Email</td>
<td style="padding:8px">
<span class="chip @(user.Provider == AuthProvider.Local ? "chip-blue" : "chip-green")">
@(user.Provider == AuthProvider.Local ? "Local" : "Entra")
@(user.Provider == AuthProvider.Local ? T["usermgmt.source.local"] : T["usermgmt.source.entra"])
</span>
</td>
<td style="padding:8px">
@@ -94,19 +95,19 @@ else
}
</select>
</td>
<td style="padding:8px">@(user.LastLogin?.ToString("yyyy-MM-dd HH:mm") ?? "Never")</td>
<td style="padding:8px">@(user.LastLogin?.ToString("yyyy-MM-dd HH:mm") ?? T["usermgmt.lastlogin.never"])</td>
<td style="padding:8px;white-space:nowrap">
@if (user.Provider == AuthProvider.Local)
{
<button class="btn btn-secondary btn-sm" @onclick="() => OpenReset(user)">Reset password</button>
<button class="btn btn-secondary btn-sm" @onclick="() => OpenReset(user)">@T["usermgmt.btn.resetpw"]</button>
}
@if (user.Email != UserContext.Email)
{
<button class="btn btn-danger btn-sm" @onclick="() => DeleteUserAsync(user)">Remove</button>
<button class="btn btn-danger btn-sm" @onclick="() => DeleteUserAsync(user)">@T["usermgmt.btn.remove"]</button>
}
else
{
<span class="chip chip-green">You</span>
<span class="chip chip-green">@T["usermgmt.badge.you"]</span>
}
</td>
</tr>
@@ -119,12 +120,12 @@ else
@if (_resetUser is not null)
{
<div class="card" style="max-width:420px">
<h2 class="card-title">Reset password — @_resetUser.DisplayName</h2>
<label class="form-label" for="reset-pw">New password</label>
<h2 class="card-title">@string.Format(T["usermgmt.reset.title"], _resetUser.DisplayName)</h2>
<label class="form-label" for="reset-pw">@T["usermgmt.lbl.newpassword"]</label>
<input id="reset-pw" class="form-input" type="password" @bind="_resetPassword" autocomplete="new-password" />
<div style="margin-top:12px;display:flex;gap:8px">
<button class="btn btn-primary" @onclick="ResetPasswordAsync">Set password</button>
<button class="btn btn-secondary" @onclick="() => _resetUser = null">Cancel</button>
<button class="btn btn-primary" @onclick="ResetPasswordAsync">@T["usermgmt.btn.setpw"]</button>
<button class="btn btn-secondary" @onclick="() => _resetUser = null">@T["btn.cancel"]</button>
</div>
</div>
}
@@ -155,14 +156,14 @@ else
_users.Add(user);
await Audit.LogAsync("UserCreated", "", Array.Empty<string>(),
$"Created local user {user.Email} ({user.DisplayName}) with role {user.Role}.");
_message = $"Local user {user.DisplayName} created.";
_message = string.Format(T["usermgmt.msg.created"], user.DisplayName);
_isError = false;
_newEmail = _newName = _newPassword = string.Empty;
_newRole = UserRole.TechN0;
}
catch (Exception ex)
{
_message = $"Error: {ex.Message}";
_message = string.Format(T["usermgmt.msg.error"], ex.Message);
_isError = true;
}
}
@@ -181,13 +182,13 @@ else
await UserService.SetPasswordAsync(_resetUser.Id, _resetPassword);
await Audit.LogAsync("PasswordReset", "", Array.Empty<string>(),
$"Reset password for local user {_resetUser.Email} ({_resetUser.DisplayName}).");
_message = $"Password reset for {_resetUser.DisplayName}.";
_message = string.Format(T["usermgmt.msg.pwreset"], _resetUser.DisplayName);
_isError = false;
_resetUser = null;
}
catch (Exception ex)
{
_message = $"Error: {ex.Message}";
_message = string.Format(T["usermgmt.msg.error"], ex.Message);
_isError = true;
}
}
@@ -202,12 +203,12 @@ else
user.Role = newRole;
await Audit.LogAsync("RoleChanged", "", Array.Empty<string>(),
$"Changed role for {user.Email} ({user.DisplayName}) from {oldRole} to {newRole}.");
_message = $"Role updated for {user.DisplayName}.";
_message = string.Format(T["usermgmt.msg.roleupdated"], user.DisplayName);
_isError = false;
}
catch (Exception ex)
{
_message = $"Error: {ex.Message}";
_message = string.Format(T["usermgmt.msg.error"], ex.Message);
_isError = true;
}
}
@@ -218,7 +219,7 @@ else
_users.Remove(user);
await Audit.LogAsync("UserDeleted", "", Array.Empty<string>(),
$"Removed {user.Provider} user {user.Email} ({user.DisplayName}), role {user.Role}.");
_message = $"User {user.DisplayName} removed.";
_message = string.Format(T["usermgmt.msg.removed"], user.DisplayName);
_isError = false;
}
}
+13 -12
View File
@@ -8,10 +8,11 @@
@inject ICsvValidationService CsvValidation
@inject BulkResultCsvExportService ExportSvc
@inject WebExportService WebExport
@inject TranslationSource T
@rendermode InteractiveServer
<h1 class="page-title">Bulk Members</h1>
<p class="page-subtitle">Add users to SharePoint groups from a CSV file.</p>
<h1 class="page-title">@T["tab.bulkMembers"]</h1>
<p class="page-subtitle">@T["bulkmembers.subtitle"]</p>
@if (!Session.HasProfile) { <NoProfilePrompt /> return; }
@if (UserContext.Role < UserRole.TechN1) { <WriteGuard /> return; }
@@ -19,18 +20,18 @@
<div class="card">
<SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_sites" Single="true" />
<div class="form-group">
<label class="form-label">CSV File (GroupName, GroupUrl, Email, Role)</label>
<label class="form-label">@T["bulkmembers.csvlabel"]</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.
@string.Format(T["bulkmembers.validsummary"], _rows.Count(r => r.IsValid), _rows.Count(r => !r.IsValid))
</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>
<thead><tr><th>@T["report.col.group"]</th><th>@T["bulkmembers.email"]</th><th>@T["bulkmembers.role"]</th><th>@T["bulkmembers.status"]</th></tr></thead>
<tbody>
@foreach (var row in _rows.Take(50))
{
@@ -48,9 +49,9 @@
<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")
@(_running ? T["bulkmembers.processing"] : T["bulkmembers.execute"])
</button>
@if (_running) { <button class="btn btn-secondary" @onclick="Cancel">Cancel</button> }
@if (_running) { <button class="btn btn-secondary" @onclick="Cancel">@T["btn.cancel"]</button> }
</div>
<ProgressPanel IsRunning="_running" StatusMessage="@_status" Current="_current" Total="_total" />
</div>
@@ -61,11 +62,11 @@
{
<div class="card">
<div class="alert @(_summary.HasFailures ? "alert-warn" : "alert-success")">
Processed: @_summary.SuccessCount / @_summary.TotalCount. Failures: @_summary.FailedCount
@string.Format(T["bulkmembers.processed"], _summary.SuccessCount, _summary.TotalCount, _summary.FailedCount)
</div>
@if (_summary.HasFailures)
{
<button class="btn btn-secondary btn-sm mt-8" @onclick="ExportErrors">Export Errors CSV</button>
<button class="btn btn-secondary btn-sm mt-8" @onclick="ExportErrors">@T["bulkmembers.exporterrors"]</button>
}
</div>
}
@@ -92,7 +93,7 @@
_cts = new CancellationTokenSource();
var validRows = _rows.Where(r => r.IsValid && r.Record != null).Select(r => r.Record!).ToList();
var siteUrl = _sites.FirstOrDefault()?.Url;
if (string.IsNullOrWhiteSpace(siteUrl)) { _error = "Please select a site."; _running = false; return; }
if (string.IsNullOrWhiteSpace(siteUrl)) { _error = T["bulkmembers.err.nosite"]; _running = false; return; }
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
try
{
@@ -101,9 +102,9 @@
var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, c);
return await BulkSvc.AddMembersAsync(ctx, Session.CurrentProfile!, validRows, progress, c);
}, _cts.Token);
_status = $"Complete: {_summary.SuccessCount} added, {_summary.FailedCount} failed.";
_status = string.Format(T["bulkmembers.complete"], _summary.SuccessCount, _summary.FailedCount);
}
catch (OperationCanceledException) { _status = "Cancelled."; }
catch (OperationCanceledException) { _status = T["status.cancelled"]; }
catch (Exception ex) { _error = ex.Message; }
finally { _running = false; await InvokeAsync(StateHasChanged); }
}
+13 -12
View File
@@ -7,30 +7,31 @@
@inject ICsvValidationService CsvValidation
@inject BulkResultCsvExportService ExportSvc
@inject WebExportService WebExport
@inject TranslationSource T
@rendermode InteractiveServer
<h1 class="page-title">Bulk Site Creation</h1>
<p class="page-subtitle">Create multiple SharePoint sites from a CSV file.</p>
<h1 class="page-title">@T["bulksites.page.title"]</h1>
<p class="page-subtitle">@T["bulksites.page.subtitle"]</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>
<label class="form-label">@T["bulksites.adminurl"]</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>
<label class="form-label">@T["bulksites.csvfile.label"]</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="alert alert-info mt-8">@string.Format(T["bulksites.validcount"], _rows.Count(r => r.IsValid), _rows.Count(r => !r.IsValid))</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>
<thead><tr><th>@T["bulksites.name"]</th><th>@T["bulksites.type"]</th><th>@T["bulksites.alias"]</th><th>@T["bulksites.col.status"]</th></tr></thead>
<tbody>
@foreach (var row in _rows.Take(50))
{
@@ -48,9 +49,9 @@
<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")
@(_running ? T["bulksites.creating"] : T["bulksites.execute"])
</button>
@if (_running) { <button class="btn btn-secondary" @onclick="Cancel">Cancel</button> }
@if (_running) { <button class="btn btn-secondary" @onclick="Cancel">@T["btn.cancel"]</button> }
</div>
<ProgressPanel IsRunning="_running" StatusMessage="@_status" Current="_current" Total="_total" />
</div>
@@ -61,11 +62,11 @@
{
<div class="card">
<div class="alert @(_summary.HasFailures ? "alert-warn" : "alert-success")">
Created: @_summary.SuccessCount / @_summary.TotalCount. Failures: @_summary.FailedCount
@string.Format(T["bulksites.summary.created"], _summary.SuccessCount, _summary.TotalCount, _summary.FailedCount)
</div>
@if (_summary.HasFailures)
{
<button class="btn btn-secondary btn-sm mt-8" @onclick="ExportErrors">Export Errors CSV</button>
<button class="btn btn-secondary btn-sm mt-8" @onclick="ExportErrors">@T["bulksites.export.errors"]</button>
}
</div>
}
@@ -98,9 +99,9 @@
{
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.";
_status = string.Format(T["bulksites.status.complete"], _summary.SuccessCount, _summary.FailedCount);
}
catch (OperationCanceledException) { _status = "Cancelled."; }
catch (OperationCanceledException) { _status = T["bulksites.status.cancelled"]; }
catch (Exception ex) { _error = ex.Message; }
finally { _running = false; await InvokeAsync(StateHasChanged); }
}
+18 -17
View File
@@ -8,9 +8,10 @@
@inject DuplicatesHtmlExportService HtmlExport
@inject WebExportService WebExport
@inject IAuditService Audit
@inject TranslationSource T
@rendermode InteractiveServer
<h1 class="page-title">Duplicate Detection</h1>
<h1 class="page-title">@T["duplicates.page.title"]</h1>
@if (!Session.HasProfile) { <NoProfilePrompt /> return; }
@@ -18,29 +19,29 @@
<SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_sites" />
<div class="form-row mt-8">
<div class="form-group">
<label class="form-label">Mode</label>
<label class="form-label">@T["duplicates.lbl.mode"]</label>
<select class="form-select" @bind="_mode" style="width:120px">
<option value="Files">Files</option>
<option value="Folders">Folders</option>
<option value="Files">@T["duplicates.mode.files"]</option>
<option value="Folders">@T["duplicates.mode.folders"]</option>
</select>
</div>
<LibraryPicker Profile="Session.CurrentProfile!" SiteUrl="@(_sites.FirstOrDefault()?.Url)" @bind-Library="_library" Label="Library (optional)" Placeholder="" />
<LibraryPicker Profile="Session.CurrentProfile!" SiteUrl="@(_sites.FirstOrDefault()?.Url)" @bind-Library="_library" Label="@T["duplicates.lbl.library_optional"]" Placeholder="" />
</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>
<label><input type="checkbox" @bind="_matchSize" /> @T["duplicates.chk.match_size"]</label>
<label><input type="checkbox" @bind="_matchCreated" /> @T["duplicates.chk.match_created"]</label>
<label><input type="checkbox" @bind="_matchModified" /> @T["duplicates.chk.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>
<label><input type="checkbox" @bind="_matchFolderCount" /> @T["duplicates.chk.match_folder_count"]</label>
<label><input type="checkbox" @bind="_matchFileCount" /> @T["duplicates.chk.match_file_count"]</label>
}
</div>
<div class="flex-row mt-8">
<button class="btn btn-primary" @onclick="RunScan" disabled="@_running">
@(_running ? "Scanning" : "Find Duplicates")
@(_running ? T["duplicates.btn.scanning"] : T["duplicates.btn.find"])
</button>
@if (_running) { <button class="btn btn-secondary" @onclick="Cancel">Cancel</button> }
@if (_running) { <button class="btn btn-secondary" @onclick="Cancel">@T["btn.cancel"]</button> }
</div>
<ProgressPanel IsRunning="_running" StatusMessage="@_status" Current="_current" Total="_total" />
</div>
@@ -51,17 +52,17 @@
{
<div class="card">
<div class="flex-row">
<div class="card-title">Duplicate Groups <span class="count-badge">@_results.Count</span></div>
<div class="card-title">@T["duplicates.results.title"] <span class="count-badge">@_results.Count</span></div>
<div class="spacer"></div>
<MergeModeSelect Value="_mergeMode" ValueChanged="v => _mergeMode = v" Visible="_bySite.Count > 1" />
<button class="btn btn-secondary btn-sm" @onclick="ExportCsv">Export CSV</button>
<button class="btn btn-secondary btn-sm" @onclick="ExportHtml">Export HTML</button>
<button class="btn btn-secondary btn-sm" @onclick="ExportCsv">@T["audit.btn.exportCsv"]</button>
<button class="btn btn-secondary btn-sm" @onclick="ExportHtml">@T["audit.btn.exportHtml"]</button>
</div>
@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>
@g.Name <span class="chip chip-blue">@g.Items.Count @T["report.text.copies"]</span>
</div>
@foreach (var item in g.Items)
{
@@ -72,7 +73,7 @@
}
</div>
}
@if (_results.Count > 100) { <div class="text-muted mt-8">Showing first 100 groups. Export for all.</div> }
@if (_results.Count > 100) { <div class="text-muted mt-8">@T["duplicates.results.truncated"]</div> }
</div>
}
+26 -25
View File
@@ -5,60 +5,61 @@
@inject ISessionManager SessionMgr
@inject IElevationCoordinator Elevation
@inject IFileTransferService TransferSvc
@inject TranslationSource T
@rendermode InteractiveServer
<h1 class="page-title">File Transfer</h1>
<h1 class="page-title">@T["transfer.page.title"]</h1>
@if (!Session.HasProfile) { <NoProfilePrompt /> return; }
@if (UserContext.Role < UserRole.TechN1) { <WriteGuard /> return; }
<div class="card">
<div class="card-title">Source</div>
<div class="card-title">@T["transfer.source"]</div>
<SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_srcSites" Single="true" />
<div class="form-row mt-8">
<LibraryPicker Profile="Session.CurrentProfile!" SiteUrl="@(_srcSites.FirstOrDefault()?.Url)" @bind-Library="_srcLibrary" Label="Source Library" />
<LibraryPicker Profile="Session.CurrentProfile!" SiteUrl="@(_srcSites.FirstOrDefault()?.Url)" @bind-Library="_srcLibrary" Label="@T["transfer.sourcelibrary"]" />
<div class="form-group">
<label class="form-label">Source Folder (optional)</label>
<input class="form-input" @bind="_srcFolder" placeholder="SubFolder/Path" />
<label class="form-label">@T["transfer.sourcefolder.optional"]</label>
<input class="form-input" @bind="_srcFolder" placeholder="@T["transfer.sourcefolder.placeholder"]" />
</div>
</div>
</div>
<div class="card">
<div class="card-title">Destination</div>
<div class="card-title">@T["transfer.destination"]</div>
<SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_dstSites" Single="true" />
<div class="form-row mt-8">
<LibraryPicker Profile="Session.CurrentProfile!" SiteUrl="@(_dstSites.FirstOrDefault()?.Url)" @bind-Library="_dstLibrary" Label="Destination Library" />
<LibraryPicker Profile="Session.CurrentProfile!" SiteUrl="@(_dstSites.FirstOrDefault()?.Url)" @bind-Library="_dstLibrary" Label="@T["transfer.destlibrary"]" />
<div class="form-group">
<label class="form-label">Destination Folder (optional)</label>
<label class="form-label">@T["transfer.destfolder.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>
<label class="form-label">@T["transfer.mode"]</label>
<select class="form-select" @bind="_mode" style="width:100px">
<option value="Copy">Copy</option>
<option value="Move">Move</option>
<option value="Copy">@T["transfer.mode.copy"]</option>
<option value="Move">@T["transfer.mode.move"]</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Conflict Policy</label>
<label class="form-label">@T["transfer.conflict"]</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>
<option value="Skip">@T["transfer.conflict.skip"]</option>
<option value="Overwrite">@T["transfer.conflict.overwrite"]</option>
<option value="Rename">@T["transfer.conflict.rename.short"]</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>
<label><input type="checkbox" @bind="_includeSourceFolder" /> @T["transfer.chk.include_source_short"]</label>
</div>
</div>
<div class="flex-row mt-8">
<button class="btn btn-primary" @onclick="RunTransfer" disabled="@_running">
@(_running ? "Transferring" : "Start Transfer")
@(_running ? T["transfer.transferring"] : T["transfer.start"])
</button>
@if (_running) { <button class="btn btn-secondary" @onclick="Cancel">Cancel</button> }
@if (_running) { <button class="btn btn-secondary" @onclick="Cancel">@T["btn.cancel"]</button> }
</div>
<ProgressPanel IsRunning="_running" StatusMessage="@_status" Current="_current" Total="_total" />
</div>
@@ -69,14 +70,14 @@
{
<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> }
@string.Format(T["transfer.result.transferred"], _summary.SuccessCount, _summary.TotalCount)
@if (_summary.HasFailures) { <span>@string.Format(T["transfer.result.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>
<thead><tr><th>@T["versions.col.file"]</th><th>@T["report.col.error"]</th></tr></thead>
<tbody>
@foreach (var f in _summary.FailedItems)
{
@@ -109,8 +110,8 @@
{
var srcUrl = _srcSites.FirstOrDefault()?.Url;
var dstUrl = _dstSites.FirstOrDefault()?.Url;
if (string.IsNullOrWhiteSpace(srcUrl)) { _error = "Please select a source site."; return; }
if (string.IsNullOrWhiteSpace(dstUrl)) { _error = "Please select a destination site."; return; }
if (string.IsNullOrWhiteSpace(srcUrl)) { _error = T["transfer.err.no_source_site"]; return; }
if (string.IsNullOrWhiteSpace(dstUrl)) { _error = T["transfer.err.no_dest_site"]; return; }
var job = new TransferJob
{
SourceSiteUrl = srcUrl, SourceLibrary = _srcLibrary, SourceFolderPath = _srcFolder,
@@ -126,9 +127,9 @@
var dstCtx = await SessionMgr.GetOrCreateContextAsync(dstUrl, Session.CurrentProfile!, c);
return await TransferSvc.TransferAsync(srcCtx, dstCtx, job, progress, c);
}, _cts.Token);
_status = $"Complete: {_summary.SuccessCount} transferred.";
_status = string.Format(T["transfer.status.complete"], _summary.SuccessCount);
}
catch (OperationCanceledException) { _status = "Cancelled."; }
catch (OperationCanceledException) { _status = T["transfer.status.cancelled"]; }
catch (Exception ex) { _error = ex.Message; }
finally { _running = false; await InvokeAsync(StateHasChanged); }
}
+18 -17
View File
@@ -6,10 +6,11 @@
@inject IElevationCoordinator Elevation
@inject IFolderStructureService FolderSvc
@inject ICsvValidationService CsvValidation
@inject TranslationSource T
@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>
<h1 class="page-title">@T["tab.folderStructure"]</h1>
<p class="page-subtitle">@T["folderstruct.subtitle"]</p>
@if (!Session.HasProfile) { <NoProfilePrompt /> return; }
@if (UserContext.Role < UserRole.TechN1) { <WriteGuard /> return; }
@@ -17,30 +18,30 @@
<div class="card">
<SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_sites" Single="true" />
<div class="form-row mt-8">
<LibraryPicker Profile="Session.CurrentProfile!" SiteUrl="@(_sites.FirstOrDefault()?.Url)" @bind-Library="_libraryTitle" Label="Library Title" />
<LibraryPicker Profile="Session.CurrentProfile!" SiteUrl="@(_sites.FirstOrDefault()?.Url)" @bind-Library="_libraryTitle" Label="@T["folderstruct.lbl.libraryTitle"]" />
</div>
<div class="form-group">
<label class="form-label">Source</label>
<label class="form-label">@T["folderstruct.lbl.source"]</label>
<div class="flex-row">
<button class="btn btn-sm @(_mode == InputMode.Csv ? "btn-primary" : "btn-secondary")"
type="button" @onclick="() => SetMode(InputMode.Csv)">Upload CSV</button>
type="button" @onclick="() => SetMode(InputMode.Csv)">@T["folderstruct.btn.uploadCsv"]</button>
<button class="btn btn-sm @(_mode == InputMode.Builder ? "btn-primary" : "btn-secondary")"
type="button" @onclick="() => SetMode(InputMode.Builder)">Build visually</button>
type="button" @onclick="() => SetMode(InputMode.Builder)">@T["folderstruct.btn.buildVisually"]</button>
</div>
</div>
@if (_mode == InputMode.Csv)
{
<div class="form-group">
<label class="form-label">CSV File (Level1, Level2, Level3, Level4)</label>
<label class="form-label">@T["folderstruct.lbl.csvFile"]</label>
<InputFile OnChange="LoadFile" accept=".csv" class="form-input" />
</div>
}
else
{
<div class="form-group">
<label class="form-label">Folder Structure</label>
<label class="form-label">@T["tab.folderStructure"]</label>
<div class="folder-builder">
@foreach (var root in _tree)
{
@@ -48,23 +49,23 @@
}
@if (_tree.Count == 0)
{
<p class="text-muted">No folders yet. Add a top-level folder to start.</p>
<p class="text-muted">@T["folderstruct.builder.empty"]</p>
}
</div>
<button class="btn btn-secondary btn-sm mt-8" type="button" @onclick="AddRoot">+ Add top-level folder</button>
<button class="btn btn-secondary btn-sm mt-8" type="button" @onclick="AddRoot">@T["folderstruct.btn.addRoot"]</button>
</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="alert alert-info mt-8">@string.Format(T["folderstruct.rowsSummary"], _rows.Count(r => r.IsValid), _rows.Count(r => !r.IsValid))</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")
@(_running ? T["folderstruct.btn.creating"] : T["folderstruct.execute"])
</button>
@if (_running) { <button class="btn btn-secondary" @onclick="Cancel">Cancel</button> }
@if (_running) { <button class="btn btn-secondary" @onclick="Cancel">@T["btn.cancel"]</button> }
</div>
<ProgressPanel IsRunning="_running" StatusMessage="@_status" Current="_current" Total="_total" />
</div>
@@ -75,7 +76,7 @@
{
<div class="card">
<div class="alert @(_summary.HasFailures ? "alert-warn" : "alert-success")">
Created: @_summary.SuccessCount folders. Failures: @_summary.FailedCount
@string.Format(T["folderstruct.result"], _summary.SuccessCount, _summary.FailedCount)
</div>
</div>
}
@@ -134,7 +135,7 @@
_cts = new CancellationTokenSource();
var validRows = _rows.Where(r => r.IsValid && r.Record != null).Select(r => r.Record!).ToList();
var siteUrl = _sites.FirstOrDefault()?.Url;
if (string.IsNullOrWhiteSpace(siteUrl)) { _error = "Please select a site."; _running = false; return; }
if (string.IsNullOrWhiteSpace(siteUrl)) { _error = T["folderstruct.err.selectSite"]; _running = false; return; }
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
try
{
@@ -143,9 +144,9 @@
var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, c);
return await FolderSvc.CreateFoldersAsync(ctx, _libraryTitle, validRows, progress, c);
}, _cts.Token);
_status = $"Complete: {_summary.SuccessCount} folders created.";
_status = string.Format(T["folderstruct.status.complete"], _summary.SuccessCount);
}
catch (OperationCanceledException) { _status = "Cancelled."; }
catch (OperationCanceledException) { _status = T["status.cancelled"]; }
catch (Exception ex) { _error = ex.Message; }
finally { _running = false; await InvokeAsync(StateHasChanged); }
}
+26 -25
View File
@@ -1,28 +1,29 @@
@page "/"
@attribute [Authorize]
@inject IUserSessionService Session
@inject TranslationSource T
@rendermode InteractiveServer
<h1 class="page-title">SharePoint Toolbox</h1>
<h1 class="page-title">@T["app.title"]</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 class="card-title">@T["home.welcome"]</div>
<p>@T["home.welcome.body"]</p>
<a href="/profiles" class="btn btn-primary">@T["profmgmt.title"]</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="card-title">@(string.Format(T["home.connected"], Session.CurrentProfile!.Name))</div>
<p>@T["home.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>
<a href="/permissions" class="btn btn-secondary">@T["home.link.permissions"]</a>
<a href="/storage" class="btn btn-secondary">@T["home.link.storage"]</a>
<a href="/search" class="btn btn-secondary">@T["tab.search"]</a>
<a href="/user-audit" class="btn btn-secondary">@T["tab.userAccessAudit"]</a>
</div>
</div>
@@ -32,8 +33,8 @@ else
<a href="@feature.Href" style="text-decoration:none;color:inherit">
<div class="card feature-card">
<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 style="font-weight:600;margin-bottom:4px">@T[feature.TitleKey]</div>
<div class="text-muted">@T[feature.DescriptionKey]</div>
</div>
</a>
}
@@ -41,19 +42,19 @@ else
}
@code {
private readonly (string Href, string Icon, string Title, string Description)[] _features = new[]
private readonly (string Href, string Icon, string TitleKey, string DescriptionKey)[] _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"),
("/permissions", "🔐", "home.link.permissions", "home.feat.permissions.desc"),
("/storage", "💾", "home.link.storage", "home.feat.storage.desc"),
("/search", "🔍", "tab.search", "home.feat.search.desc"),
("/duplicates", "📋", "tab.duplicates", "home.feat.duplicates.desc"),
("/versions", "🗂️", "home.feat.versions.title", "home.feat.versions.desc"),
("/transfer", "📦", "home.feat.transfer.title", "home.feat.transfer.desc"),
("/bulk-members", "👥", "tab.bulkMembers", "home.feat.bulkmembers.desc"),
("/bulk-sites", "🌐", "tab.bulkSites", "home.feat.bulksites.desc"),
("/folder-structure", "📁", "tab.folderStructure", "home.feat.folderstruct.desc"),
("/user-audit", "👤", "tab.userAccessAudit", "home.feat.useraudit.desc"),
("/user-directory", "📖", "directory.grp.browse", "home.feat.userdir.desc"),
("/templates", "📐", "tab.templates", "home.feat.templates.desc"),
};
}
+5 -4
View File
@@ -1,10 +1,11 @@
@page "/not-found"
@attribute [Microsoft.AspNetCore.Authorization.AllowAnonymous]
@inject TranslationSource T
<PageTitle>Page not found — SharePoint Toolbox</PageTitle>
<PageTitle>@T["notfound.pagetitle"]</PageTitle>
<div class="no-profile">
<h2>Page not found</h2>
<p>The page you requested doesn't exist or has moved.</p>
<a href="/" class="btn btn-primary">Back to Home</a>
<h2>@T["notfound.heading"]</h2>
<p>@T["notfound.body"]</p>
<a href="/" class="btn btn-primary">@T["notfound.back"]</a>
</div>
+22 -21
View File
@@ -8,33 +8,34 @@
@inject HtmlExportService HtmlExport
@inject WebExportService WebExport
@inject IAuditService Audit
@inject TranslationSource T
@rendermode InteractiveServer
<h1 class="page-title">Permissions Audit</h1>
<h1 class="page-title">@T["perm.title"]</h1>
@if (!Session.HasProfile) { <NoProfilePrompt /> return; }
<div class="card">
<div class="card-title">Scan Options</div>
<div class="card-title">@T["grp.scan.opts"]</div>
<SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_sites" />
<div class="form-row mt-8">
<div class="form-group" style="flex:0 0 auto">
<label class="form-label">Folder Depth</label>
<label class="form-label">@T["lbl.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>
<label><input type="checkbox" @bind="_includeInherited" /> @T["chk.inherited.perms"]</label>
<label><input type="checkbox" @bind="_scanFolders" /> @T["chk.scan.folders"]</label>
<label><input type="checkbox" @bind="_includeSubsites" /> @T["chk.include.subsites"]</label>
</div>
</div>
<div class="flex-row mt-8">
<button class="btn btn-primary" @onclick="RunScan" disabled="@_running">
@(_running ? "Scanning" : "Scan Sites")
@(_running ? T["perm.btn.scanning"] : T["perm.btn.scan"])
</button>
@if (_running)
{
<button class="btn btn-secondary" @onclick="Cancel">Cancel</button>
<button class="btn btn-secondary" @onclick="Cancel">@T["btn.cancel"]</button>
}
</div>
<ProgressPanel IsRunning="_running" StatusMessage="@_status" Current="_current" Total="_total" />
@@ -49,21 +50,21 @@
{
<div class="card">
<div class="flex-row">
<div class="card-title">Results <span class="count-badge">@_results.Count</span></div>
<div class="card-title">@T["perm.results"] <span class="count-badge">@_results.Count</span></div>
<div class="spacer"></div>
<MergeModeSelect Value="_mergeMode" ValueChanged="v => _mergeMode = v" Visible="_bySite.Count > 1" />
<button class="btn btn-secondary btn-sm" @onclick="ExportCsv">Export CSV</button>
<button class="btn btn-secondary btn-sm" @onclick="ExportHtml">Export HTML</button>
<button class="btn btn-secondary btn-sm" @onclick="ExportCsv">@T["audit.btn.exportCsv"]</button>
<button class="btn btn-secondary btn-sm" @onclick="ExportHtml">@T["audit.btn.exportHtml"]</button>
</div>
<div class="data-table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Type</th>
<th>Title</th>
<th>Users</th>
<th>Permission</th>
<th>Granted Through</th>
<th>@T["directory.col.type"]</th>
<th>@T["report.col.title"]</th>
<th>@T["perm.col.users"]</th>
<th>@T["perm.col.permission"]</th>
<th>@T["report.col.granted_through"]</th>
</tr>
</thead>
<tbody>
@@ -82,7 +83,7 @@
</div>
@if (_results.Count > 500)
{
<div class="text-muted mt-8">Showing first 500 of @_results.Count rows. Export for full results.</div>
<div class="text-muted mt-8">@string.Format(T["perm.status.showing_first"], _results.Count)</div>
}
</div>
}
@@ -105,7 +106,7 @@
{
_error = string.Empty; _results = new(); _bySite = new(); _running = true;
_cts = new CancellationTokenSource();
if (_sites.Count == 0) { _error = "Please select at least one site."; _running = false; return; }
if (_sites.Count == 0) { _error = T["err.no_sites_selected"]; _running = false; return; }
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
try
{
@@ -116,7 +117,7 @@
foreach (var site in _sites)
{
_cts.Token.ThrowIfCancellationRequested();
_status = $"Scanning {site.Title} ({++i}/{_sites.Count})…";
_status = string.Format(T["perm.status.scanning_site"], site.Title, ++i, _sites.Count);
await InvokeAsync(StateHasChanged);
var entries = await Elevation.RunAsync(async c =>
{
@@ -127,11 +128,11 @@
flat.AddRange(entries);
}
_bySite = bySite; _results = flat;
_status = $"Scan complete: {_results.Count} entries across {_sites.Count} site(s).";
_status = string.Format(T["perm.status.scan_complete"], _results.Count, _sites.Count);
await Audit.LogAsync("PermissionsScan", Session.CurrentProfile?.Name ?? "", _sites.Select(s => s.Url),
$"{_results.Count} entries; inherited={_includeInherited} folders={_scanFolders} depth={_folderDepth} subsites={_includeSubsites}");
}
catch (OperationCanceledException) { _status = "Cancelled."; }
catch (OperationCanceledException) { _status = T["status.cancelled"]; }
catch (Exception ex) { _error = ex.Message; }
finally { _running = false; await InvokeAsync(StateHasChanged); }
}
+45 -46
View File
@@ -8,18 +8,19 @@
@inject SharepointToolbox.Web.Services.OAuth.IEntraDeviceCodeFlow DeviceFlow
@inject SharepointToolbox.Web.Services.Auth.IAppRegistrationService AppRegService
@inject Microsoft.Extensions.Options.IOptions<SharepointToolbox.Web.Core.Config.ClientConnectOptions> ConnectOpts
@inject TranslationSource T
@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>
<h1 class="page-title">@T["profiles.title"]</h1>
<p class="page-subtitle">@T["profiles.subtitle"]</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>
<div class="alert alert-info">@T["profiles.restricted"]</div>
@foreach (var p in _profiles)
{
@@ -32,10 +33,10 @@
<div class="spacer"></div>
@if (Session.CurrentProfile?.Id == p.Id)
{
<span class="chip chip-green">Active</span>
<span class="chip chip-green">@T["profiles.active"]</span>
}
<button class="btn btn-secondary btn-sm" @onclick="() => SelectProfile(p)">
@(Session.CurrentProfile?.Id == p.Id ? "Selected" : "Select")
@(Session.CurrentProfile?.Id == p.Id ? T["profiles.selected"] : T["profiles.select"])
</button>
</div>
</div>
@@ -51,12 +52,12 @@
}
<div class="flex-row" style="margin-bottom:16px">
<button class="btn btn-primary" @onclick="AddNew">+ New Profile</button>
<button class="btn btn-primary" @onclick="AddNew">@T["profiles.new"]</button>
</div>
@if (_profiles.Count == 0 && !_showForm)
{
<div class="alert alert-info">No profiles configured. Create one to get started.</div>
<div class="alert alert-info">@T["profiles.empty"]</div>
}
@foreach (var p in _profiles)
@@ -66,19 +67,19 @@
<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 class="text-muted">@T["profiles.tenantid.label"] @p.TenantId</div>
<div class="text-muted">@T["profiles.clientid.label"] @p.ClientId</div>
</div>
<div class="spacer"></div>
@if (Session.CurrentProfile?.Id == p.Id)
{
<span class="chip chip-green">Active</span>
<span class="chip chip-green">@T["profiles.active"]</span>
}
<button class="btn btn-secondary btn-sm" @onclick="() => SelectProfile(p)">
@(Session.CurrentProfile?.Id == p.Id ? "Selected" : "Select")
@(Session.CurrentProfile?.Id == p.Id ? T["profiles.selected"] : T["profiles.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>
<button class="btn btn-secondary btn-sm" @onclick="() => EditProfile(p)">@T["profiles.edit"]</button>
<button class="btn btn-danger btn-sm" @onclick="() => DeleteProfile(p)">@T["profile.delete"]</button>
</div>
</div>
}
@@ -86,57 +87,55 @@
@if (_showForm)
{
<div class="card" style="border-color:#0078d4">
<div class="card-title">@(_editing?.Id == null ? "New Profile" : "Edit Profile")</div>
<div class="card-title">@(_editing?.Id == null ? T["profiles.form.new"] : T["profiles.form.edit"])</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" />
<label class="form-label">@T["profiles.form.name"]</label>
<input class="form-input" @bind="_form.Name" placeholder="@T["profiles.form.name.ph"]" />
</div>
<div class="form-group">
<label class="form-label">Tenant URL *</label>
<label class="form-label">@T["profiles.form.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" />
<label class="form-label">@T["profiles.form.tenantid"]</label>
<input class="form-input" @bind="_form.TenantId" placeholder="@T["profiles.form.tenantid.ph"]" />
</div>
@* App registration section *@
<div class="form-group">
<label class="form-label">Client ID (App Registration)</label>
<label class="form-label">@T["profiles.form.clientid"]</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"
placeholder="@T["profiles.form.clientid.ph"]"
style="flex:1" />
<button class="btn btn-secondary" @onclick="RegisterAppAsync"
disabled="@(!CanRegister || _registering)"
title="@(CanRegister ? "Register app in client Entra ID (requires an admin who can create app registrations)" : "Fill Tenant URL, Tenant ID and Profile Name first")">
@(_registering ? "Waiting" : "Register in Entra")
title="@(CanRegister ? T["profiles.register.tooltip.ready"] : T["profiles.register.tooltip.disabled"])">
@(_registering ? T["profiles.register.waiting"] : T["profiles.register.btn"])
</button>
</div>
<small class="text-muted">
Click "Register in Entra" to auto-create the app registration in the client tenant.
You'll sign in with a client admin account — no secrets, no pre-existing app needed.
Or enter an existing public client App Registration ID manually.
@T["profiles.register.hint"]
</small>
@if (_deviceCode is not null)
{
<div class="alert alert-info" style="margin-top:10px">
<div style="margin-bottom:6px">Sign in to the <strong>client tenant</strong> to authorize app creation:</div>
<div style="margin-bottom:6px">@T["profiles.devicecode.intro.pre"] <strong>@T["profiles.devicecode.intro.tenant"]</strong> @T["profiles.devicecode.intro.post"]</div>
<ol style="margin:0 0 8px 18px;padding:0;line-height:1.7">
<li>Open <a href="@_deviceCode.VerificationUri" target="_blank" rel="noopener">@_deviceCode.VerificationUri</a></li>
<li>Enter code:
<li>@T["profiles.devicecode.step.open"] <a href="@_deviceCode.VerificationUri" target="_blank" rel="noopener">@_deviceCode.VerificationUri</a></li>
<li>@T["profiles.devicecode.step.code"]
<code style="font-size:16px;font-weight:700;letter-spacing:1px;background:#fff;padding:2px 8px;border-radius:4px;border:1px solid var(--border)">@_deviceCode.UserCode</code>
</li>
<li>Approve the requested permissions with an admin account.</li>
<li>@T["profiles.devicecode.step.approve"]</li>
</ol>
<div class="flex-row" style="gap:8px">
<span class="text-muted">@_regStatus</span>
<button class="btn btn-secondary btn-sm" @onclick="CancelRegistration">Cancel</button>
<button class="btn btn-secondary btn-sm" @onclick="CancelRegistration">@T["btn.cancel"]</button>
</div>
</div>
}
@@ -147,14 +146,14 @@
</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>
<label class="form-label">@T["profiles.form.logo"]</label>
<small class="text-muted d-block" style="margin-bottom:6px">@T["profiles.form.logo.hint"]</small>
<LogoUpload Value="_form.ClientLogo" ValueChanged="(LogoData? l) => _form.ClientLogo = l" />
</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>
<button class="btn btn-primary" @onclick="SaveProfile">@T["profile.save"]</button>
<button class="btn btn-secondary" @onclick="CancelForm">@T["btn.cancel"]</button>
</div>
</div>
}
@@ -239,7 +238,7 @@
_registering = true;
_formError = string.Empty;
_regStatus = "Requesting a sign-in code…";
_regStatus = T["profiles.reg.requesting"];
_deviceCode = null;
_regCts = new CancellationTokenSource(TimeSpan.FromMinutes(15));
StateHasChanged();
@@ -248,13 +247,13 @@
{
// Secretless bootstrap: device code flow against the client tenant.
_deviceCode = await DeviceFlow.BeginAsync(_form.TenantId.Trim(), RegistrationScope, _regCts.Token);
_regStatus = "Waiting for sign-in to complete…";
_regStatus = T["profiles.reg.waitingsignin"];
StateHasChanged();
var adminToken = await DeviceFlow.PollForAccessTokenAsync(_form.TenantId.Trim(), _deviceCode, _regCts.Token);
_deviceCode = null;
_regStatus = "Creating the app registration…";
_regStatus = T["profiles.reg.creating"];
StateHasChanged();
var clientId = await AppRegService.CreateAsync(
@@ -264,15 +263,15 @@
ct: _regCts.Token);
_form.ClientId = clientId;
_regStatus = "App registered. Review and Save the profile.";
_regStatus = T["profiles.reg.registered"];
}
catch (OperationCanceledException)
{
_regStatus = "Registration cancelled.";
_regStatus = T["profiles.reg.cancelled"];
}
catch (Exception ex)
{
_formError = $"Registration failed: {ex.Message}";
_formError = string.Format(T["profiles.reg.failed"], ex.Message);
_regStatus = string.Empty;
}
finally
@@ -289,16 +288,16 @@
{
_regCts?.Cancel();
_deviceCode = null;
_regStatus = "Registration cancelled.";
_regStatus = T["profiles.reg.cancelled"];
}
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 (string.IsNullOrWhiteSpace(_form.Name)) { _formError = T["profiles.err.name_required"]; return; }
if (string.IsNullOrWhiteSpace(_form.TenantUrl)) { _formError = T["profiles.err.url_required"]; return; }
if (string.IsNullOrWhiteSpace(_form.ClientId)) { _formError = T["profiles.err.clientid_required"]; return; }
if (string.IsNullOrWhiteSpace(_form.TenantId)) { _formError = T["profiles.err.tenantid_required"]; return; }
if (_editing == null)
{
+22 -21
View File
@@ -8,47 +8,48 @@
@inject SearchHtmlExportService HtmlExport
@inject WebExportService WebExport
@inject IAuditService Audit
@inject TranslationSource T
@rendermode InteractiveServer
<h1 class="page-title">File Search</h1>
<h1 class="page-title">@T["tab.search"]</h1>
@if (!Session.HasProfile) { <NoProfilePrompt /> return; }
<div class="card">
<div class="card-title">Search Options</div>
<div class="card-title">@T["srch.options"]</div>
<SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_sites" />
<div class="form-row mt-8">
<div class="form-group">
<label class="form-label">File Extensions (comma-separated)</label>
<input class="form-input" @bind="_extensions" placeholder="docx, xlsx, pdf" />
<label class="form-label">@T["srch.lbl.extensions"]</label>
<input class="form-input" @bind="_extensions" placeholder="@T["ph.extensions"]" />
</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" />
<label class="form-label">@T["lbl.regex"]</label>
<input class="form-input" @bind="_regex" placeholder="@T["ph.regex"]" />
</div>
<div class="form-group">
<label class="form-label">Max results</label>
<label class="form-label">@T["lbl.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>
<label class="form-label">@T["lbl.created.by"]</label>
<input class="form-input" @bind="_createdBy" />
</div>
<div class="form-group">
<label class="form-label">Modified by</label>
<label class="form-label">@T["lbl.modified.by"]</label>
<input class="form-input" @bind="_modifiedBy" />
</div>
<LibraryPicker Profile="Session.CurrentProfile!" SiteUrl="@(_sites.FirstOrDefault()?.Url)" @bind-Library="_library" Label="Library (optional)" Placeholder="" />
<LibraryPicker Profile="Session.CurrentProfile!" SiteUrl="@(_sites.FirstOrDefault()?.Url)" @bind-Library="_library" Label="@T["srch.lbl.library"]" Placeholder="" />
</div>
<div class="flex-row mt-8">
<button class="btn btn-primary" @onclick="RunSearch" disabled="@_running">
@(_running ? "Searching" : "Search")
@(_running ? T["audit.searching"] : T["audit.mode.search"])
</button>
@if (_running) { <button class="btn btn-secondary" @onclick="Cancel">Cancel</button> }
@if (_running) { <button class="btn btn-secondary" @onclick="Cancel">@T["btn.cancel"]</button> }
</div>
<ProgressPanel IsRunning="_running" StatusMessage="@_status" Current="_current" Total="_total" />
</div>
@@ -59,15 +60,15 @@
{
<div class="card">
<div class="flex-row">
<div class="card-title">Results <span class="count-badge">@_results.Count</span></div>
<div class="card-title">@T["srch.results"] <span class="count-badge">@_results.Count</span></div>
<div class="spacer"></div>
<MergeModeSelect Value="_mergeMode" ValueChanged="v => _mergeMode = v" Visible="_bySite.Count > 1" />
<button class="btn btn-secondary btn-sm" @onclick="ExportCsv">Export CSV</button>
<button class="btn btn-secondary btn-sm" @onclick="ExportHtml">Export HTML</button>
<button class="btn btn-secondary btn-sm" @onclick="ExportCsv">@T["audit.btn.exportCsv"]</button>
<button class="btn btn-secondary btn-sm" @onclick="ExportHtml">@T["audit.btn.exportHtml"]</button>
</div>
<div class="data-table-wrap">
<table class="data-table">
<thead><tr><th>Name</th><th>Ext</th><th>Path</th><th>Created</th><th>Modified</th><th class="num">Size (KB)</th></tr></thead>
<thead><tr><th>@T["srch.col.name"]</th><th>@T["srch.col.ext"]</th><th>@T["srch.col.path"]</th><th>@T["srch.col.created"]</th><th>@T["srch.col.modified"]</th><th class="num">@T["srch.col.size.kb"]</th></tr></thead>
<tbody>
@foreach (var r in _results.Take(500))
{
@@ -83,7 +84,7 @@
</tbody>
</table>
</div>
@if (_results.Count > 500) { <div class="text-muted mt-8">Showing first 500 of @_results.Count. Export for full results.</div> }
@if (_results.Count > 500) { <div class="text-muted mt-8">@(string.Format(T["srch.truncated"], _results.Count))</div> }
</div>
}
@@ -103,7 +104,7 @@
{
_error = string.Empty; _results = new(); _bySite = new(); _running = true;
_cts = new CancellationTokenSource();
if (_sites.Count == 0) { _error = "Please select at least one site."; _running = false; return; }
if (_sites.Count == 0) { _error = T["srch.err.noSites"]; _running = false; return; }
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
try
{
@@ -114,7 +115,7 @@
foreach (var site in _sites)
{
_cts.Token.ThrowIfCancellationRequested();
_status = $"Searching {site.Title} ({++i}/{_sites.Count})…";
_status = string.Format(T["srch.status.searchingSite"], 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 =>
@@ -126,11 +127,11 @@
flat.AddRange(found);
}
_bySite = bySite; _results = flat;
_status = $"Found {_results.Count} files across {_sites.Count} site(s).";
_status = string.Format(T["srch.status.found"], _results.Count, _sites.Count);
await Audit.LogAsync("FileSearch", Session.CurrentProfile?.Name ?? "", _sites.Select(s => s.Url),
$"{_results.Count} files; ext=[{_extensions}] regex=[{_regex}] lib=[{_library}]");
}
catch (OperationCanceledException) { _status = "Cancelled."; }
catch (OperationCanceledException) { _status = T["status.cancelled"]; }
catch (Exception ex) { _error = ex.Message; }
finally { _running = false; await InvokeAsync(StateHasChanged); }
}
+29 -16
View File
@@ -2,49 +2,50 @@
@attribute [Authorize]
@inject IUserSessionService Session
@inject IJSRuntime JS
@inject TranslationSource T
@rendermode InteractiveServer
@using Microsoft.JSInterop
<h1 class="page-title">Settings</h1>
<h1 class="page-title">@T["tab.settings"]</h1>
<div class="card">
<div class="card-title">Display</div>
<div class="card-title">@T["settings.section.display"]</div>
<div class="form-group">
<label class="form-label">Language</label>
<label class="form-label">@T["settings.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>
<option value="en">@T["settings.lang.en"]</option>
<option value="fr">@T["settings.lang.fr"]</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Theme</label>
<label class="form-label">@T["settings.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="System">@T["settings.theme.system"]</option>
<option value="Light">@T["settings.theme.light"]</option>
</select>
</div>
</div>
<div class="card">
<div class="card-title">Behavior</div>
<div class="card-title">@T["settings.section.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
@T["settings.behavior.autoElevate"]
</label>
</div>
</div>
<div class="card">
<div class="card-title">Report Branding</div>
<div class="card-title">@T["settings.section.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>
<label class="form-label">@T["settings.logo.title"]</label>
<p class="text-muted" style="margin-top:0">@T["settings.logo.description"]</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">@T["settings.saved"]</div> }
@code {
private string _lang = "en", _theme = "System";
@@ -54,7 +55,9 @@
protected override void OnInitialized()
{
var s = Session.Settings;
_lang = s.Lang;
// Reflect the culture actually resolved for this circuit (cookie-driven), not the
// possibly-not-yet-loaded persisted setting.
_lang = System.Globalization.CultureInfo.CurrentUICulture.TwoLetterISOLanguageName == "fr" ? "fr" : "en";
_theme = s.Theme is "System" or "Light" ? s.Theme : "System";
_autoTakeOwnership = s.AutoTakeOwnership;
_mspLogo = s.MspLogo;
@@ -68,9 +71,19 @@
private async Task Save()
{
var langChanged = !string.Equals(Session.Settings.Lang, _lang, StringComparison.Ordinal);
Session.UpdateSettings(new AppSettings { Lang = _lang, Theme = _theme, AutoTakeOwnership = _autoTakeOwnership, MspLogo = _mspLogo });
SharepointToolbox.Web.Localization.TranslationSource.Instance.SetCulture(_lang);
T.SetCulture(_lang);
await JS.InvokeVoidAsync("sptb.setTheme", _theme);
// Persisted above. A full reload restarts the circuit; MainLayout then applies the new
// language (from the just-saved settings) before anything renders.
if (langChanged)
{
await JS.InvokeVoidAsync("location.reload");
return;
}
_saved = true;
StateHasChanged();
_ = Task.Delay(2000).ContinueWith(_ => { _saved = false; InvokeAsync(StateHasChanged); });
+24 -23
View File
@@ -8,35 +8,36 @@
@inject StorageHtmlExportService HtmlExport
@inject WebExportService WebExport
@inject IAuditService Audit
@inject TranslationSource T
@rendermode InteractiveServer
<h1 class="page-title">Storage Metrics</h1>
<h1 class="page-title">@T["stor.page.title"]</h1>
@if (!Session.HasProfile) { <NoProfilePrompt /> return; }
<div class="card">
<div class="card-title">Scan Options</div>
<div class="card-title">@T["grp.scan.opts"]</div>
<SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_sites" />
<div class="form-row mt-8">
<div class="form-group" style="max-width:220px">
<label class="form-label">Folder scan depth</label>
<label class="form-label">@T["stor.lbl.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>
<small class="text-muted">@T["stor.hint.folder_depth"]</small>
</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>
<label><input type="checkbox" @bind="_includeSubsites" /> @T["chk.include.subsites"]</label>
<label><input type="checkbox" @bind="_includeHidden" /> @T["stor.chk.include_hidden_libs"]</label>
<label><input type="checkbox" @bind="_includeRecycleBin" /> @T["stor.chk.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")
@(_running ? T["stor.btn.scanning"] : T["stor.btn.scan_storage"])
</button>
@if (_sites.Count > 0) { <span class="text-muted" style="align-self:center">@_sites.Count site(s) selected</span> }
@if (_running) { <button class="btn btn-secondary" @onclick="Cancel">Cancel</button> }
@if (_sites.Count > 0) { <span class="text-muted" style="align-self:center">@string.Format(T["perm.sites.selected"], _sites.Count)</span> }
@if (_running) { <button class="btn btn-secondary" @onclick="Cancel">@T["btn.cancel"]</button> }
</div>
<ProgressPanel IsRunning="_running" StatusMessage="@_status" Current="_current" Total="_total" />
</div>
@@ -47,22 +48,22 @@
{
<div class="card">
<div class="flex-row">
<div class="card-title">Storage Report <span class="count-badge">@_results.Count libraries</span></div>
<div class="card-title">@T["stor.report.title"] <span class="count-badge">@string.Format(T["stor.badge.libraries_count"], _results.Count)</span></div>
<div class="spacer"></div>
<MergeModeSelect Value="_mergeMode" ValueChanged="v => _mergeMode = v" Visible="_bySite.Count > 1" />
<button class="btn btn-secondary btn-sm" @onclick="ExportCsv">Export CSV</button>
<button class="btn btn-secondary btn-sm" @onclick="ExportHtml">Export HTML</button>
<button class="btn btn-secondary btn-sm" @onclick="ExportCsv">@T["audit.btn.exportCsv"]</button>
<button class="btn btn-secondary btn-sm" @onclick="ExportHtml">@T["audit.btn.exportHtml"]</button>
</div>
<div class="data-table-wrap">
<table class="data-table">
<thead>
<tr>
<th>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>
<th>@T["stor.col.library"]</th>
<th>@T["stor.col.site"]</th>
<th class="num">@T["stor.col.files"]</th>
<th class="num">@T["stor.col.total_mb"]</th>
<th class="num">@T["stor.col.versions_mb"]</th>
<th>@T["stor.col.lastmod"]</th>
</tr>
</thead>
<tbody>
@@ -98,7 +99,7 @@
{
_error = string.Empty; _results = new(); _bySite = new(); _running = true;
_cts = new CancellationTokenSource();
if (_sites.Count == 0) { _error = "Please select at least one site."; _running = false; return; }
if (_sites.Count == 0) { _error = T["stor.err.select_site"]; _running = false; return; }
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
try
{
@@ -109,7 +110,7 @@
foreach (var site in _sites)
{
_cts.Token.ThrowIfCancellationRequested();
_status = $"Scanning {site.Title} ({++i}/{_sites.Count})…";
_status = string.Format(T["stor.status.scanning_site"], site.Title, ++i, _sites.Count);
await InvokeAsync(StateHasChanged);
var nodes = await Elevation.RunAsync(async c =>
{
@@ -120,11 +121,11 @@
flat.AddRange(nodes);
}
_bySite = bySite; _results = flat;
_status = $"Complete: {_results.Count} nodes across {_sites.Count} site(s).";
_status = string.Format(T["stor.status.complete_nodes"], _results.Count, _sites.Count);
await Audit.LogAsync("StorageScan", Session.CurrentProfile?.Name ?? "", _sites.Select(s => s.Url),
$"{_results.Count} nodes; depth={_folderDepth} subsites={_includeSubsites} hidden={_includeHidden} recycle={_includeRecycleBin}");
}
catch (OperationCanceledException) { _status = "Cancelled."; }
catch (OperationCanceledException) { _status = T["stor.status.cancelled"]; }
catch (Exception ex) { _error = ex.Message; }
finally { _running = false; await InvokeAsync(StateHasChanged); }
}
+26 -25
View File
@@ -6,56 +6,57 @@
@inject IElevationCoordinator Elevation
@inject ITemplateService TemplateSvc
@inject SharepointToolbox.Web.Infrastructure.Persistence.TemplateRepository TemplateRepo
@inject TranslationSource T
@rendermode InteractiveServer
<h1 class="page-title">Site Templates</h1>
<p class="page-subtitle">Capture site structure and apply to new sites.</p>
<h1 class="page-title">@T["templates.page.title"]</h1>
<p class="page-subtitle">@T["templates.page.subtitle"]</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="card-title">@T["templates.capture"]</div>
<SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_captureSites" Single="true" />
<div class="form-group mt-8">
<label class="form-label">Template Name</label>
<input class="form-input" @bind="_captureName" placeholder="My Template" />
<label class="form-label">@T["templates.name"]</label>
<input class="form-input" @bind="_captureName" placeholder="@T["templates.name.placeholder"]" />
</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>
<label><input type="checkbox" @bind="_capLibraries" /> @T["templates.opt.libraries"]</label>
<label><input type="checkbox" @bind="_capFolders" /> @T["templates.opt.folders"]</label>
<label><input type="checkbox" @bind="_capGroups" /> @T["templates.opt.permissions"]</label>
</div>
<button class="btn btn-primary mt-8" @onclick="CaptureTemplate" disabled="@_running">
@(_running ? "Capturing" : "Capture")
@(_running ? T["templates.btn.capturing"] : T["templates.btn.capture"])
</button>
<ProgressPanel IsRunning="_running" StatusMessage="@_status" />
</div>
<div class="card">
<div class="card-title">Apply Template</div>
<div class="card-title">@T["templates.apply"]</div>
@if (_selectedTemplate == null)
{
<div class="alert alert-info">Select a template from the list below.</div>
<div class="alert alert-info">@T["templates.apply.selectprompt"]</div>
}
else
{
<div class="alert alert-info">Template: <strong>@_selectedTemplate.Name</strong></div>
<div class="alert alert-info">@T["templates.apply.selectedlabel"] <strong>@_selectedTemplate.Name</strong></div>
<div class="form-group">
<label class="form-label">New Site Title</label>
<label class="form-label">@T["templates.newtitle"]</label>
<input class="form-input" @bind="_newTitle" />
</div>
<div class="form-group">
<label class="form-label">New Site Alias</label>
<label class="form-label">@T["templates.newalias"]</label>
<input class="form-input" @bind="_newAlias" />
</div>
<div class="form-group">
<label class="form-label">Admin Center URL</label>
<label class="form-label">@T["templates.adminurl"]</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")
@(_running ? T["templates.btn.applying"] : T["templates.apply"])
</button>
}
</div>
@@ -65,21 +66,21 @@
@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>
<div class="card-title">@T["templates.list"]</div>
@if (_templates.Count == 0)
{
<div class="text-muted">No templates saved.</div>
<div class="text-muted">@T["templates.empty"]</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 class="text-muted">@t.SiteType · @t.CapturedAt.ToString("yyyy-MM-dd") · @string.Format(T["templates.libraries.suffix"], t.Libraries.Count)</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>
<button class="btn btn-secondary btn-sm" @onclick="() => _selectedTemplate = t">@T["templates.btn.use"]</button>
<button class="btn btn-danger btn-sm" @onclick="() => DeleteTemplate(t)">@T["templates.delete"]</button>
</div>
}
</div>
@@ -116,9 +117,9 @@
template.Name = string.IsNullOrWhiteSpace(_captureName) ? $"Template-{DateTime.Now:yyyyMMdd}" : _captureName;
await TemplateRepo.SaveAsync(template);
_templates = (await TemplateRepo.GetAllAsync()).ToList();
_successMsg = $"Template '{template.Name}' saved.";
_successMsg = string.Format(T["templates.status.saved"], template.Name);
}
catch (OperationCanceledException) { _status = "Cancelled."; }
catch (OperationCanceledException) { _status = T["templates.status.cancelled"]; }
catch (Exception ex) { _error = ex.Message; }
finally { _running = false; await InvokeAsync(StateHasChanged); }
}
@@ -136,9 +137,9 @@
{
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}";
_successMsg = string.Format(T["templates.status.sitecreated"], url);
}
catch (OperationCanceledException) { _status = "Cancelled."; }
catch (OperationCanceledException) { _status = T["templates.status.cancelled"]; }
catch (Exception ex) { _error = ex.Message; }
finally { _running = false; await InvokeAsync(StateHasChanged); }
}
+27 -26
View File
@@ -8,32 +8,33 @@
@inject UserAccessHtmlExportService HtmlExport
@inject WebExportService WebExport
@inject IAuditService Audit
@inject TranslationSource T
@rendermode InteractiveServer
<h1 class="page-title">User Access Audit</h1>
<p class="page-subtitle">Find all permissions for one or more users across multiple sites.</p>
<h1 class="page-title">@T["tab.userAccessAudit"]</h1>
<p class="page-subtitle">@T["audit.subtitle"]</p>
@if (!Session.HasProfile) { <NoProfilePrompt /> return; }
<div class="card">
<div class="form-group">
<div class="flex-row">
<label class="form-label" style="margin:0">Users</label>
<label class="form-label" style="margin:0">@T["audit.lbl.users"]</label>
<div class="spacer"></div>
<label style="font-weight:normal"><input type="checkbox" @bind="_includeGuests" /> Include guests</label>
<label style="font-weight:normal"><input type="checkbox" @bind="_includeGuests" /> @T["directory.chk.guests"]</label>
<button class="btn btn-secondary btn-sm" @onclick="LoadUsers" disabled="@_loadingUsers">
@(_loadingUsers ? $"Loading… ({_loadCount})" : "Load Users")
@(_loadingUsers ? string.Format(T["audit.btn.loading"], _loadCount) : T["audit.btn.loadUsers"])
</button>
</div>
@if (_directoryUsers.Count > 0)
{
<div class="flex-row mt-8">
<input class="form-input" style="width:260px" @bind="_userFilter" @bind:event="oninput" placeholder="Filter by name or email…" />
<span class="text-muted">@_selectedEmails.Count selected</span>
<input class="form-input" style="width:260px" @bind="_userFilter" @bind:event="oninput" placeholder="@T["audit.ph.filterUsers"]" />
<span class="text-muted">@string.Format(T["audit.lbl.selectedCount"], _selectedEmails.Count)</span>
<div class="spacer"></div>
<button class="btn btn-link btn-sm" @onclick="SelectAllFiltered">Select all (@FilteredUsers.Count())</button>
<button class="btn btn-link btn-sm" @onclick="ClearSelection">Clear</button>
<button class="btn btn-link btn-sm" @onclick="SelectAllFiltered">@string.Format(T["audit.btn.selectAll"], FilteredUsers.Count())</button>
<button class="btn btn-link btn-sm" @onclick="ClearSelection">@T["settings.logo.clear"]</button>
</div>
<div class="user-select-list">
@foreach (var u in FilteredUsers.Take(500))
@@ -44,29 +45,29 @@
@onchange="e => ToggleUser(email, (bool)e.Value!)" />
<span class="user-select-name">@u.DisplayName</span>
<span class="text-muted">@email</span>
@if (u.UserType == "Guest") { <span class="chip chip-yellow">Guest</span> }
@if (u.UserType == "Guest") { <span class="chip chip-yellow">@T["common.guest"]</span> }
</label>
}
</div>
@if (FilteredUsers.Count() > 500) { <div class="text-muted mt-8">Showing first 500. Refine filter to narrow.</div> }
@if (FilteredUsers.Count() > 500) { <div class="text-muted mt-8">@T["audit.msg.showFirst500Filter"]</div> }
}
<label class="form-label mt-8">Additional emails (one per line)</label>
<label class="form-label mt-8">@T["audit.lbl.additionalEmails"]</label>
<textarea class="form-textarea" @bind="_users" placeholder="alice@contoso.com&#10;bob@contoso.com" rows="2"></textarea>
</div>
<SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_sites" />
<div class="form-row">
<div class="form-group" style="display:flex;align-items:center;gap:16px;padding-top:20px">
<label><input type="checkbox" @bind="_includeInherited" /> Include inherited</label>
<label><input type="checkbox" @bind="_scanFolders" /> Scan folders</label>
<label><input type="checkbox" @bind="_includeSubsites" /> Include subsites</label>
<label><input type="checkbox" @bind="_includeInherited" /> @T["audit.chk.includeInherited"]</label>
<label><input type="checkbox" @bind="_scanFolders" /> @T["chk.scan.folders"]</label>
<label><input type="checkbox" @bind="_includeSubsites" /> @T["chk.include.subsites"]</label>
</div>
</div>
<div class="flex-row mt-8">
<button class="btn btn-primary" @onclick="RunAudit" disabled="@_running">
@(_running ? "Auditing" : "Audit Users")
@(_running ? T["audit.btn.auditing"] : T["audit.btn.auditUsers"])
</button>
@if (_running) { <button class="btn btn-secondary" @onclick="Cancel">Cancel</button> }
@if (_running) { <button class="btn btn-secondary" @onclick="Cancel">@T["btn.cancel"]</button> }
</div>
<ProgressPanel IsRunning="_running" StatusMessage="@_status" Current="_current" Total="_total" />
</div>
@@ -77,14 +78,14 @@
{
<div class="card">
<div class="flex-row">
<div class="card-title">Audit Results <span class="count-badge">@_results.Count</span></div>
<div class="card-title">@T["audit.results.title"] <span class="count-badge">@_results.Count</span></div>
<div class="spacer"></div>
<button class="btn btn-secondary btn-sm" @onclick="ExportCsv">Export CSV</button>
<button class="btn btn-secondary btn-sm" @onclick="ExportHtml">Export HTML</button>
<button class="btn btn-secondary btn-sm" @onclick="ExportCsv">@T["audit.btn.exportCsv"]</button>
<button class="btn btn-secondary btn-sm" @onclick="ExportHtml">@T["audit.btn.exportHtml"]</button>
</div>
<div class="data-table-wrap">
<table class="data-table">
<thead><tr><th>User</th><th>Site</th><th>Object</th><th>Permission</th><th>Access Type</th><th>Granted Through</th></tr></thead>
<thead><tr><th>@T["report.col.user"]</th><th>@T["report.col.site"]</th><th>@T["report.col.object"]</th><th>@T["audit.col.permission"]</th><th>@T["report.col.access_type"]</th><th>@T["report.col.granted_through"]</th></tr></thead>
<tbody>
@foreach (var r in _results.Take(500))
{
@@ -92,7 +93,7 @@
<td>@r.UserDisplayName</td>
<td>@r.SiteTitle</td>
<td>@r.ObjectTitle <span class="text-muted">(@r.ObjectType)</span></td>
<td>@r.PermissionLevel @if (r.IsHighPrivilege) { <span class="chip chip-red">High</span> }</td>
<td>@r.PermissionLevel @if (r.IsHighPrivilege) { <span class="chip chip-red">@T["audit.chip.high"]</span> }</td>
<td>@r.AccessType</td>
<td>@r.GrantedThrough</td>
</tr>
@@ -100,7 +101,7 @@
</tbody>
</table>
</div>
@if (_results.Count > 500) { <div class="text-muted mt-8">Showing first 500. Export for full results.</div> }
@if (_results.Count > 500) { <div class="text-muted mt-8">@T["audit.msg.showFirst500Export"]</div> }
</div>
}
@@ -155,7 +156,7 @@
var userList = _selectedEmails
.Concat(_users.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
if (!userList.Any()) { _error = "Select at least one user or enter an email."; _running = false; return; }
if (!userList.Any()) { _error = T["audit.err.noUsersOrEmail"]; _running = false; return; }
var siteList = _sites.ToList();
if (!siteList.Any()) siteList.Add(new SiteInfo(Session.CurrentProfile!.TenantUrl, Session.CurrentProfile.Name));
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
@@ -163,11 +164,11 @@
{
var opts = new ScanOptions(_includeInherited, _scanFolders, 1, _includeSubsites);
_results = (await AuditSvc.AuditUsersAsync(SessionMgr, Session.CurrentProfile!, userList, siteList, opts, progress, _cts.Token)).ToList();
_status = $"Found {_results.Count} access entries.";
_status = string.Format(T["audit.status.found"], _results.Count);
await Audit.LogAsync("UserAccessAudit", Session.CurrentProfile?.Name ?? "", siteList.Select(s => s.Url),
$"{_results.Count} entries for {userList.Count} user(s)");
}
catch (OperationCanceledException) { _status = "Cancelled."; }
catch (OperationCanceledException) { _status = T["audit.status.cancelled"]; }
catch (Exception ex) { _error = ex.Message; }
finally { _running = false; await InvokeAsync(StateHasChanged); }
}
+11 -10
View File
@@ -3,18 +3,19 @@
@inject IUserSessionService Session
@inject IGraphUserDirectoryService GraphSvc
@inject IAuditService Audit
@inject TranslationSource T
@rendermode InteractiveServer
<h1 class="page-title">User Directory</h1>
<p class="page-subtitle">Browse all tenant users via Microsoft Graph.</p>
<h1 class="page-title">@T["directory.grp.browse"]</h1>
<p class="page-subtitle">@T["directory.subtitle"]</p>
@if (!Session.HasProfile) { <NoProfilePrompt /> return; }
<div class="card">
<div class="flex-row">
<label><input type="checkbox" @bind="_includeGuests" /> Include guests</label>
<label><input type="checkbox" @bind="_includeGuests" /> @T["directory.chk.guests"]</label>
<button class="btn btn-primary" @onclick="LoadUsers" disabled="@_running">
@(_running ? $"Loading… ({_loadCount} users)" : "Load Users")
@(_running ? string.Format(T["directory.btn.loading"], _loadCount) : T["directory.btn.loadUsers"])
</button>
</div>
<ProgressPanel IsRunning="_running" StatusMessage="@_status" />
@@ -26,12 +27,12 @@
{
<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 class="card-title">@T["directory.title.users"] <span class="count-badge">@_users.Count</span></div>
<input class="form-input" style="width:260px" @bind="_filter" @bind:event="oninput" placeholder="@T["directory.filter.byNameEmail"]" />
</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>
<thead><tr><th>@T["directory.col.name"]</th><th>@T["directory.col.upn"]</th><th>@T["directory.col.department"]</th><th>@T["directory.col.jobtitle"]</th><th>@T["directory.col.type"]</th></tr></thead>
<tbody>
@foreach (var u in FilteredUsers.Take(500))
{
@@ -40,13 +41,13 @@
<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>
<td><span class="chip @(u.UserType == "Guest" ? "chip-yellow" : "chip-blue")">@(u.UserType ?? T["directory.type.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> }
@if (FilteredUsers.Count() > 500) { <div class="text-muted mt-8">@string.Format(T["directory.showing500"], FilteredUsers.Count())</div> }
</div>
}
@@ -67,7 +68,7 @@
try
{
_users = (await GraphSvc.GetUsersAsync(Session.CurrentProfile!, _includeGuests, progress)).ToList();
_status = $"Loaded {_users.Count} users.";
_status = string.Format(T["directory.status.loaded"], _users.Count);
await Audit.LogAsync("UserDirectoryLoad", Session.CurrentProfile?.Name ?? "", Array.Empty<string>(),
$"{_users.Count} users; guests={_includeGuests}");
}
+8 -7
View File
@@ -7,9 +7,10 @@
@inject IVersionCleanupService VersionSvc
@inject VersionCleanupHtmlExportService HtmlExport
@inject WebExportService WebExport
@inject TranslationSource T
@rendermode InteractiveServer
<h1 class="page-title">Version Cleanup</h1>
<h1 class="page-title">@T["versions.page.title"]</h1>
@if (!Session.HasProfile) { <NoProfilePrompt /> return; }
@if (UserContext.Role < UserRole.TechN1) { <WriteGuard /> return; }
@@ -18,13 +19,13 @@
<SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_sites" Single="true" />
<div class="flex-row mt-8">
<button class="btn btn-secondary" @onclick="LoadLibraries" disabled="@_loading">
@(_loading ? "Loading" : "Load Libraries")
@(_loading ? T["versions.btn.loading"] : T["versions.btn.loadLibs"])
</button>
</div>
@if (_libraries.Count > 0)
{
<div class="form-group mt-8">
<label class="form-label">Libraries (none = all)</label>
<label class="form-label">@T["versions.lbl.libsNoneAll"]</label>
<div style="max-height:200px;overflow-y:auto;border:1px solid var(--border);border-radius:4px;padding:4px">
@foreach (var lib in _libraries)
{
@@ -38,18 +39,18 @@
}
<div class="form-row mt-8">
<div class="form-group" style="flex:0 0 auto">
<label class="form-label">Keep last N versions</label>
<label class="form-label">@T["versions.lbl.keepLastN"]</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>
<label><input type="checkbox" @bind="_keepFirst" /> @T["versions.chk.keepFirstShort"]</label>
</div>
</div>
<div class="flex-row mt-8">
<button class="btn btn-danger" @onclick="RunCleanup" disabled="@_running">
@(_running ? "Cleaning" : "Delete Old Versions")
@(_running ? T["versions.btn.cleaning"] : T["versions.btn.run"])
</button>
@if (_running) { <button class="btn btn-secondary" @onclick="Cancel">Cancel</button> }
@if (_running) { <button class="btn btn-secondary" @onclick="Cancel">@T["btn.cancel"]</button> }
</div>
<ProgressPanel IsRunning="_running" StatusMessage="@_status" Current="_current" Total="_total" />
</div>
+4 -3
View File
@@ -1,17 +1,18 @@
@* Recursive editor row for one folder in the visual builder. *@
@using SharepointToolbox.Web.Core.Models
@inject TranslationSource T
<div class="folder-node" style="margin-left:@(Depth > 1 ? "18px" : "0")">
<div class="flex-row" style="gap:6px">
<span class="text-muted" style="font-family:monospace">📁</span>
<input class="form-input" style="width:auto;flex:1;min-width:160px"
placeholder="Folder name" value="@Node.Name"
placeholder="@T["foldertree.ph.name"]" value="@Node.Name"
@oninput="OnNameInput" />
@if (Depth < FolderNode.MaxDepth)
{
<button class="btn btn-secondary btn-sm" type="button" @onclick="AddChild" title="Add subfolder">+ Sub</button>
<button class="btn btn-secondary btn-sm" type="button" @onclick="AddChild" title="@T["foldertree.btn.addsub.tooltip"]">@T["foldertree.btn.addsub"]</button>
}
<button class="btn btn-danger btn-sm" type="button" @onclick="() => OnRemove.InvokeAsync(Node)" title="Remove">✕</button>
<button class="btn btn-danger btn-sm" type="button" @onclick="() => OnRemove.InvokeAsync(Node)" title="@T["foldertree.btn.remove.tooltip"]">✕</button>
</div>
@foreach (var child in Node.Children)
+4 -3
View File
@@ -1,4 +1,5 @@
@inject ILibraryDiscoveryService LibraryDiscovery
@inject TranslationSource T
@* Library name field with a picker: type a title, or click Browse to load and
choose from the libraries on the selected site. *@
@@ -13,7 +14,7 @@
<button type="button" class="btn btn-secondary"
@onclick="Browse"
disabled="@(Disabled || _loading)">
@(_loading ? "Loading…" : "Browse")
@(_loading ? T["librarypicker.loadingShort"] : T["librarypicker.browse"])
</button>
</div>
@@ -36,7 +37,7 @@
}
@if (_libraries.Count == 0)
{
<div class="text-muted" style="padding:6px">No document libraries found on this site.</div>
<div class="text-muted" style="padding:6px">@T["librarypicker.noLibraries"]</div>
}
</div>
}
@@ -65,7 +66,7 @@
private async Task Browse()
{
_error = string.Empty;
if (string.IsNullOrWhiteSpace(SiteUrl)) { _error = "Select a site first."; return; }
if (string.IsNullOrWhiteSpace(SiteUrl)) { _error = T["librarypicker.selectSiteFirst"]; return; }
// Toggle closed if already showing the list for this site.
if (_open && _loadedForSite == SiteUrl) { _open = false; return; }
+5 -4
View File
@@ -1,5 +1,6 @@
@using Microsoft.AspNetCore.Components.Forms
@using SharepointToolbox.Web.Core.Models
@inject TranslationSource T
@* Reusable logo picker. Reads an image into a base64 LogoData (no disk/blob storage). *@
<div class="logo-upload">
@@ -8,13 +9,13 @@
<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>
<button type="button" class="btn btn-secondary btn-sm" @onclick="Remove">@T["logoupload.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>
<small class="text-muted d-block">@string.Format(T["logoupload.hint"], MaxBytes / 1024)</small>
}
@if (!string.IsNullOrEmpty(_error)) { <div class="alert alert-error mt-8">@_error</div> }
</div>
@@ -36,7 +37,7 @@
if (file.Size > MaxBytes)
{
_error = $"File too large ({file.Size / 1024} KB). Max {MaxBytes / 1024} KB.";
_error = string.Format(T["logoupload.err.toolarge"], file.Size / 1024, MaxBytes / 1024);
return;
}
@@ -51,7 +52,7 @@
}
catch (Exception ex)
{
_error = $"Could not read image: {ex.Message}";
_error = string.Format(T["logoupload.err.read"], ex.Message);
}
}
+6 -4
View File
@@ -1,11 +1,13 @@
@* Dropdown for choosing how multi-site reports are bundled on export. *@
@inject TranslationSource T
@if (Visible)
{
<select class="form-select" style="width:auto;font-size:13px" value="@Value" @onchange="OnChange"
title="How to bundle reports when multiple sites are scanned">
<option value="@ReportMergeMode.SingleMerged">One document, no tabs</option>
<option value="@ReportMergeMode.SingleTabbed">One document, tabs (HTML)</option>
<option value="@ReportMergeMode.MultipleFiles">Multiple documents (ZIP)</option>
title="@T["mergemode.tooltip"]">
<option value="@ReportMergeMode.SingleMerged">@T["mergemode.opt.singleMerged"]</option>
<option value="@ReportMergeMode.SingleTabbed">@T["mergemode.opt.singleTabbed"]</option>
<option value="@ReportMergeMode.MultipleFiles">@T["mergemode.opt.multipleFiles"]</option>
</select>
}
+5 -3
View File
@@ -1,5 +1,7 @@
@inject TranslationSource T
<div class="no-profile">
<h2>No profile selected</h2>
<p>Select or create a tenant profile to get started.</p>
<a href="/profiles" class="btn btn-primary">Go to Profiles</a>
<h2>@T["noprofile.heading"]</h2>
<p>@T["noprofile.body"]</p>
<a href="/profiles" class="btn btn-primary">@T["noprofile.goto"]</a>
</div>
+4 -3
View File
@@ -1,5 +1,6 @@
@inject IUserSessionService Session
@inject SharepointToolbox.Web.Infrastructure.Persistence.ProfileRepository ProfileRepo
@inject TranslationSource T
@implements IDisposable
@using SharepointToolbox.Web.Core.Models
@using SharepointToolbox.Web.Services.Session
@@ -7,7 +8,7 @@
<div class="profile-selector">
<button class="profile-selector-trigger @(Session.HasProfile ? "" : "unset")" @onclick="ToggleAsync">
<span class="profile-selector-icon">🏢</span>
<span class="profile-selector-name">@(Session.CurrentProfile?.Name ?? "Select a profile")</span>
<span class="profile-selector-name">@(Session.CurrentProfile?.Name ?? T["profile.selector.placeholder"])</span>
<span class="profile-selector-caret @(_open ? "open" : "")">▾</span>
</button>
@@ -17,7 +18,7 @@
<div class="profile-selector-menu">
@if (_profiles.Count == 0)
{
<div class="profile-selector-empty">No profiles configured.</div>
<div class="profile-selector-empty">@T["profile.selector.empty"]</div>
}
else
{
@@ -36,7 +37,7 @@
</button>
}
}
<a class="profile-selector-manage" href="/profiles" @onclick="Close">⚙️ Manage profiles</a>
<a class="profile-selector-manage" href="/profiles" @onclick="Close">⚙️ @T["profile.selector.manage"]</a>
</div>
}
</div>
@@ -2,6 +2,7 @@
@inject IUserSessionService Session
@inject ISessionManager SessionManager
@inject NavigationManager Nav
@inject TranslationSource T
@using SharepointToolbox.Web.Core.Models
@using SharepointToolbox.Web.Services.Session
@@ -10,10 +11,10 @@
<div class="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="connect-modal-title">
<div class="modal-dialog">
<div class="modal-header">
<h3 id="connect-modal-title">Connect to Microsoft</h3>
<h3 id="connect-modal-title">@T["connect.title"]</h3>
<p class="text-muted">
Authenticate to access <strong>@Session.CurrentProfile?.Name</strong>.
Your session token is stored in your browser only — never saved to disk.
@T["connect.subtitle.prefix"] <strong>@Session.CurrentProfile?.Name</strong>.
@T["connect.token.note"]
</p>
</div>
@@ -23,14 +24,14 @@
}
<div class="modal-footer">
<button class="btn btn-secondary" @onclick="Cancel" disabled="@_connecting">Cancel</button>
<button class="btn btn-secondary" @onclick="Cancel" disabled="@_connecting">@T["btn.cancel"]</button>
<button class="btn btn-primary" @onclick="ConnectAsync" disabled="@_connecting">
@(_connecting ? "Redirecting" : "Connect via Microsoft")
@(_connecting ? T["connect.redirecting"] : T["connect.button"])
</button>
</div>
<p class="text-muted" style="font-size:11px;margin-top:8px;text-align:right">
You will be redirected to Microsoft login. MFA is supported.
@T["connect.redirect.note"]
</p>
</div>
</div>
@@ -54,7 +55,7 @@
private async Task ConnectAsync()
{
var profile = Session.CurrentProfile;
if (profile is null) { _error = "No client profile selected."; return; }
if (profile is null) { _error = T["connect.err.noprofile"]; return; }
_connecting = true;
_error = string.Empty;
+10 -9
View File
@@ -1,13 +1,14 @@
@inject ISiteDiscoveryService SiteDiscovery
@inject TranslationSource T
<div class="site-picker">
<div class="flex-row" style="gap:8px;align-items:flex-end">
<div class="form-group" style="flex:1">
<label class="form-label">@(Single ? "Site" : "Sites")</label>
<input class="form-input" @bind="_filter" @bind:event="oninput" placeholder="Filter loaded sites by name or URL…" />
<label class="form-label">@(Single ? T["sitepicker.label.site"] : T["sitepicker.label.sites"])</label>
<input class="form-input" @bind="_filter" @bind:event="oninput" placeholder="@T["sitepicker.ph.filter"]" />
</div>
<button class="btn btn-secondary" @onclick="LoadSites" disabled="@_loading">
@(_loading ? "Loading…" : (_all.Count > 0 ? "Reload sites" : "Load sites"))
@(_loading ? T["sitepicker.status.loadingShort"] : (_all.Count > 0 ? T["sitepicker.btn.reload"] : T["sitepicker.btn.load"]))
</button>
</div>
@@ -21,11 +22,11 @@
<div class="flex-row mt-8" style="gap:12px;align-items:center">
@if (!Single)
{
<button class="btn btn-link btn-sm" @onclick="SelectAllFiltered">Select all (@Filtered.Count())</button>
<button class="btn btn-link btn-sm" @onclick="SelectAllFiltered">@string.Format(T["sitepicker.btn.selectAllCount"], Filtered.Count())</button>
}
<button class="btn btn-link btn-sm" @onclick="ClearSelection">Clear</button>
<button class="btn btn-link btn-sm" @onclick="ClearSelection">@T["sitepicker.btn.clear"]</button>
<span class="spacer"></span>
<span class="count-badge">@SelectedSites.Count selected</span>
<span class="count-badge">@string.Format(T["sitepicker.status.selectedCount"], SelectedSites.Count)</span>
</div>
<div class="site-picker-list" style="max-height:240px;overflow:auto;border:1px solid var(--border);border-radius:4px;padding:4px;margin-top:6px">
@foreach (var s in Filtered)
@@ -45,13 +46,13 @@
}
@if (!Filtered.Any())
{
<div class="text-muted" style="padding:6px">No sites match the filter.</div>
<div class="text-muted" style="padding:6px">@T["sitepicker.empty.noMatch"]</div>
}
</div>
}
else if (!_loading)
{
<div class="text-muted mt-8" style="font-size:12px">Click “Load sites” to list the tenants SharePoint sites, then @(Single ? "pick one." : "tick the ones to scan.")</div>
<div class="text-muted mt-8" style="font-size:12px">@(Single ? T["sitepicker.hint.loadSingle"] : T["sitepicker.hint.loadMulti"])</div>
}
</div>
@@ -84,7 +85,7 @@
try
{
_all = (await SiteDiscovery.SearchSitesAsync(Profile)).ToList();
if (_all.Count == 0) _error = "No sites returned. The account may lack Sites.Read.All.";
if (_all.Count == 0) _error = T["sitepicker.err.noSites"];
}
catch (Exception ex) { _error = ex.Message; }
finally { _loading = false; }
+2 -1
View File
@@ -1,4 +1,5 @@
@inject IUserContextAccessor UserContext
@inject TranslationSource T
@using SharepointToolbox.Web.Core.Models
@using SharepointToolbox.Web.Services.Session
@@ -17,7 +18,7 @@ else
else
{
<div class="alert alert-info">
You have <strong>read-only</strong> access (Tech-N0). Contact an Admin to request write access.
@T["writeguard.readonly.before"] <strong>@T["writeguard.readonly.emphasis"]</strong> @T["writeguard.readonly.after"]
</div>
}
}
+1
View File
@@ -19,3 +19,4 @@
@using SharepointToolbox.Web.Components
@using SharepointToolbox.Web.Components.Shared
@using SharepointToolbox.Web.Core.Helpers
@using SharepointToolbox.Web.Localization
+2 -2
View File
@@ -3,8 +3,8 @@ namespace SharepointToolbox.Web.Core.Models;
public class AppSettings
{
public string DataFolder { get; set; } = string.Empty;
public string Lang { get; set; } = "en";
public bool AutoTakeOwnership { get; set; } = false;
public string Lang { get; set; } = "fr";
public bool AutoTakeOwnership { get; set; } = true;
public string Theme { get; set; } = "System";
/// <summary>MSP logo shown top-left on exported reports. Null = none.</summary>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+30 -22
View File
@@ -4,8 +4,17 @@ using System.Resources;
namespace SharepointToolbox.Web.Localization;
/// <summary>
/// Singleton string lookup backed by Strings.resx / Strings.fr.resx.
/// Web version: no INotifyPropertyChanged — culture switching is per-request.
/// String lookup backed by Strings.resx / Strings.fr.resx.
///
/// Registered as Scoped: in Blazor Server each circuit gets its own instance with its own
/// explicit <see cref="Culture"/>. The culture is stored as a field (not read from the
/// ambient <see cref="CultureInfo.CurrentUICulture"/>) because the interactive circuit does
/// NOT inherit the request/middleware culture, and ambient culture does not reliably flow
/// across render batches and SPA navigations. An explicit per-circuit field is deterministic:
/// set it once at circuit start and every page in that circuit renders in the same language.
///
/// The static <see cref="Instance"/> (used by the export services) has no explicit culture and
/// falls back to the ambient culture.
/// </summary>
public class TranslationSource
{
@@ -16,31 +25,30 @@ public class TranslationSource
// 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 =
private readonly ResourceManager _resourceManager =
new ResourceManager("SharepointToolbox.Web.Localization.Strings", typeof(TranslationSource).Assembly);
private CultureInfo _currentCulture = CultureInfo.CurrentUICulture;
private TranslationSource() { }
private CultureInfo? _culture;
public TranslationSource() { }
/// <summary>Explicit lookup culture. When unset, falls back to the ambient UI culture.</summary>
public CultureInfo Culture
{
get => _culture ?? CultureInfo.CurrentUICulture;
set => _culture = value;
}
public string this[string key] =>
_resourceManager.GetString(key, _currentCulture) ?? $"[{key}]";
_resourceManager.GetString(key, Culture) ?? $"[{key}]";
public CultureInfo CurrentCulture
{
get => _currentCulture;
set
{
if (Equals(_currentCulture, value)) return;
_currentCulture = value;
}
}
/// <summary>Sets this instance's culture from a language code ("fr" → French, else English/invariant).</summary>
public void SetCulture(string lang) => Culture = Resolve(lang);
public void SetCulture(string lang)
/// <summary>"fr" → French; anything else → invariant (the base Strings.resx, i.e. English).</summary>
public static CultureInfo Resolve(string lang) => lang switch
{
CurrentCulture = lang switch
{
"fr" => new CultureInfo("fr"),
_ => CultureInfo.InvariantCulture
};
}
"fr" => new CultureInfo("fr"),
_ => CultureInfo.InvariantCulture
};
}
+3
View File
@@ -41,6 +41,9 @@ builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Services.AddHttpContextAccessor();
// Localization string source — Scoped: one per circuit, with its own explicit culture.
builder.Services.AddScoped<SharepointToolbox.Web.Localization.TranslationSource>();
// ── Authentication ────────────────────────────────────────────────────────────
if (builder.Environment.IsDevelopment())
{
+9 -7
View File
@@ -23,7 +23,15 @@ public class UserSessionService : IUserSessionService
{
_sessionManager = sessionManager;
_settingsRepo = settingsRepo;
_ = LoadSettingsAsync();
// Load synchronously so Settings (esp. Lang) are available the moment the circuit
// starts — culture is applied in MainLayout.OnInitialized before any page renders,
// so a fire-and-forget load here would race and lose.
//
// Run on the thread pool (Task.Run) so LoadAsync's await continuation does NOT post
// back to the circuit's SynchronizationContext. Blocking that context here with a plain
// GetResult() deadlocks: the continuation can never resume on the thread we're blocking.
try { _settings = Task.Run(() => _settingsRepo.LoadAsync()).GetAwaiter().GetResult(); }
catch { /* use defaults */ }
}
public void SetProfile(TenantProfile profile)
@@ -45,10 +53,4 @@ public class UserSessionService : IUserSessionService
_settings = settings;
_ = _settingsRepo.SaveAsync(settings);
}
private async Task LoadSettingsAsync()
{
try { _settings = await _settingsRepo.LoadAsync(); }
catch { /* use defaults */ }
}
}