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 IJSRuntime JS
@inject SharepointToolbox.Web.Services.OAuth.IOAuthFlowCache OAuthCache @inject SharepointToolbox.Web.Services.OAuth.IOAuthFlowCache OAuthCache
@inject SharepointToolbox.Web.Infrastructure.Persistence.ProfileRepository ProfileRepo @inject SharepointToolbox.Web.Infrastructure.Persistence.ProfileRepository ProfileRepo
@inject TranslationSource T
@using Microsoft.AspNetCore.Components.Authorization @using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.WebUtilities @using Microsoft.AspNetCore.WebUtilities
@using Microsoft.JSInterop @using Microsoft.JSInterop
@@ -23,7 +24,7 @@
<span class="logo-mark">SP</span> <span class="logo-mark">SP</span>
<span class="logo-text">SP Toolbox</span> <span class="logo-text">SP Toolbox</span>
</div> </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> </div>
@* User identity badge *@ @* User identity badge *@
@@ -44,7 +45,7 @@
<div style="font-size:10px;color:var(--text-muted);margin-top:4px"> <div style="font-size:10px;color:var(--text-muted);margin-top:4px">
SP: @_credUsername SP: @_credUsername
<button class="btn btn-secondary btn-sm" style="padding:2px 6px;font-size:10px;margin-left:4px" <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>
} }
</div> </div>
@@ -61,11 +62,11 @@
<div class="nav-search"> <div class="nav-search">
<span class="nav-icon">🔍</span> <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" /> @bind="_navFilter" @bind:event="oninput" />
@if (!string.IsNullOrEmpty(_navFilter)) @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> </div>
@@ -81,16 +82,16 @@
lastSection = item.Section; lastSection = item.Section;
if (!string.IsNullOrEmpty(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"> <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> </NavLink>
} }
@if (items.Count == 0) @if (items.Count == 0)
{ {
<div class="nav-empty">No match</div> <div class="nav-empty">@T["nav.noMatch"]</div>
} }
</nav> </nav>
@@ -98,13 +99,13 @@
<AuthorizeView> <AuthorizeView>
<Authorized> <Authorized>
<a href="/account/logout" class="nav-item"> <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> </a>
</Authorized> </Authorized>
</AuthorizeView> </AuthorizeView>
<button class="nav-item theme-toggle" @onclick="ToggleTheme"> <button class="nav-item theme-toggle" @onclick="ToggleTheme">
<span class="nav-icon">🌙</span> <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> <span class="switch @(_dark ? "on" : "")"></span>
</button> </button>
</div> </div>
@@ -120,7 +121,7 @@
} }
else else
{ {
<div style="padding:2rem;color:var(--text-muted)">Loading</div> <div style="padding:2rem;color:var(--text-muted)">@T["nav.loading"]</div>
} }
</main> </main>
</div> </div>
@@ -137,23 +138,23 @@
private static readonly NavItem[] AllNavItems = private static readonly NavItem[] AllNavItems =
{ {
new("/", "🏠", "Home", "", "always"), new("/", "🏠", "nav.home", "", "always"),
new("/permissions", "🔐", "Permissions", "", "profile"), new("/permissions", "🔐", "tab.permissions", "", "profile"),
new("/storage", "💾", "Storage", "", "profile"), new("/storage", "💾", "tab.storage", "", "profile"),
new("/duplicates", "📋", "Duplicates", "", "profile"), new("/duplicates", "📋", "tab.duplicates", "", "profile"),
new("/versions", "🗂️", "Version Cleanup", "", "profile"), new("/versions", "🗂️", "versions.tab", "", "profile"),
new("/transfer", "📦", "File Transfer", "", "profile"), new("/transfer", "📦", "nav.fileTransfer", "", "profile"),
new("/bulk-members", "👥", "Bulk Members", "Bulk", "profile"), new("/bulk-members", "👥", "tab.bulkMembers", "nav.section.bulk", "profile"),
new("/bulk-sites", "🌐", "Bulk Sites", "Bulk", "profile"), new("/bulk-sites", "🌐", "tab.bulkSites", "nav.section.bulk", "profile"),
new("/folder-structure", "📁", "Folder Structure", "Bulk", "profile"), new("/folder-structure", "📁", "tab.folderStructure", "nav.section.bulk", "profile"),
new("/user-audit", "👤", "User Access Audit","Audit", "profile"), new("/user-audit", "👤", "tab.userAccessAudit", "nav.section.audit", "profile"),
new("/user-directory", "📖", "User Directory", "Audit", "profile"), new("/user-directory", "📖", "nav.userDirectory", "nav.section.audit", "profile"),
new("/templates", "📐", "Templates", "Config", "profile"), new("/templates", "📐", "tab.templates", "nav.section.config", "profile"),
new("/profiles", "⚙️", "Client Profiles", "Admin", "admin"), new("/profiles", "⚙️", "nav.clientProfiles", "nav.section.admin", "admin"),
new("/admin/users", "👥", "User Management", "Admin", "admin"), new("/admin/users", "👥", "nav.userManagement", "nav.section.admin", "admin"),
new("/admin/audit", "📋", "Audit Logs", "Admin", "admin"), new("/admin/audit", "📋", "nav.auditLogs", "nav.section.admin", "admin"),
new("/settings", "🔧", "Settings", "", "always"), new("/settings", "🔧", "tab.settings", "", "always"),
new("/account/change-password","🔑", "Change Password", "", "auth"), new("/account/change-password","🔑", "nav.changePassword", "", "auth"),
}; };
private IEnumerable<NavItem> VisibleNavItems() private IEnumerable<NavItem> VisibleNavItems()
@@ -168,7 +169,7 @@
_ => true _ => true
}) })
.Where(i => filter.Length == 0 .Where(i => filter.Length == 0
|| i.Label.Contains(filter, StringComparison.OrdinalIgnoreCase)); || T[i.Label].Contains(filter, StringComparison.OrdinalIgnoreCase));
} }
private void ClearFilter() => _navFilter = string.Empty; private void ClearFilter() => _navFilter = string.Empty;
@@ -177,6 +178,16 @@
protected override void OnInitialized() 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; Session.ProfileChanged += OnProfileChanged;
UserContext.Initialized += OnUserContextInitialized; UserContext.Initialized += OnUserContextInitialized;
_dark = string.Equals(Session.Settings.Theme, "Dark", StringComparison.OrdinalIgnoreCase); _dark = string.Equals(Session.Settings.Theme, "Dark", StringComparison.OrdinalIgnoreCase);
+12 -11
View File
@@ -3,28 +3,29 @@
@inject IUserService UserService @inject IUserService UserService
@inject IUserContextAccessor UserContext @inject IUserContextAccessor UserContext
@inject IAuditService Audit @inject IAuditService Audit
@inject TranslationSource T
@rendermode InteractiveServer @rendermode InteractiveServer
@using SharepointToolbox.Web.Core.Models @using SharepointToolbox.Web.Core.Models
@using SharepointToolbox.Web.Services.Audit @using SharepointToolbox.Web.Services.Audit
@using SharepointToolbox.Web.Services.Auth @using SharepointToolbox.Web.Services.Auth
@using SharepointToolbox.Web.Services.Session @using SharepointToolbox.Web.Services.Session
<h1 class="page-title">Change Password</h1> <h1 class="page-title">@T["changepw.title"]</h1>
@if (!UserContext.IsAuthenticated) @if (!UserContext.IsAuthenticated)
{ {
<div class="alert alert-error">You must be signed in.</div> <div class="alert alert-error">@T["changepw.mustsignin"]</div>
return; return;
} }
@if (_user is null) @if (_user is null)
{ {
<p class="page-subtitle">Loading</p> <p class="page-subtitle">@T["changepw.loading"]</p>
} }
else if (_user.Provider != AuthProvider.Local) else if (_user.Provider != AuthProvider.Local)
{ {
<div class="alert alert-info"> <div class="alert alert-info">
Your account signs in with Microsoft (Entra). Manage its password in your Microsoft account. @T["changepw.entra"]
</div> </div>
} }
else else
@@ -34,17 +35,17 @@ else
<div class="alert @(_isError ? "alert-error" : "alert-success")">@_message</div> <div class="alert @(_isError ? "alert-error" : "alert-success")">@_message</div>
} }
<div class="card" style="max-width:420px"> <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" /> <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" /> <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" /> <input id="confirm" class="form-input" type="password" @bind="_confirm" autocomplete="new-password" />
<div style="margin-top:14px"> <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>
</div> </div>
} }
@@ -68,7 +69,7 @@ else
if (_user is null) return; if (_user is null) return;
if (string.IsNullOrWhiteSpace(_new) || _new != _confirm) if (string.IsNullOrWhiteSpace(_new) || _new != _confirm)
{ {
_message = "New passwords do not match."; _message = T["changepw.err.mismatch"];
_isError = true; _isError = true;
return; return;
} }
@@ -78,13 +79,13 @@ else
{ {
await Audit.LogAsync("PasswordChanged", "", Array.Empty<string>(), await Audit.LogAsync("PasswordChanged", "", Array.Empty<string>(),
$"Changed own password ({_user.Email})."); $"Changed own password ({_user.Email}).");
_message = "Password changed."; _message = T["changepw.success"];
_isError = false; _isError = false;
_current = _new = _confirm = string.Empty; _current = _new = _confirm = string.Empty;
} }
else else
{ {
_message = "Current password is incorrect."; _message = T["changepw.err.incorrect"];
_isError = true; _isError = true;
} }
} }
+18 -17
View File
@@ -3,34 +3,35 @@
@inject IAuditService AuditService @inject IAuditService AuditService
@inject IUserContextAccessor UserContext @inject IUserContextAccessor UserContext
@inject NavigationManager Nav @inject NavigationManager Nav
@inject TranslationSource T
@rendermode InteractiveServer @rendermode InteractiveServer
@using SharepointToolbox.Web.Core.Models @using SharepointToolbox.Web.Core.Models
@using SharepointToolbox.Web.Services.Audit @using SharepointToolbox.Web.Services.Audit
@using SharepointToolbox.Web.Services.Session @using SharepointToolbox.Web.Services.Session
<h1 class="page-title">Audit Logs</h1> <h1 class="page-title">@T["adminaudit.title"]</h1>
<p class="page-subtitle">All technician and admin actions within the application.</p> <p class="page-subtitle">@T["adminaudit.subtitle"]</p>
@if (!UserContext.IsAuthenticated || UserContext.Role != UserRole.Admin) @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; return;
} }
<div class="flex-row" style="margin-bottom:16px;flex-wrap:wrap;gap:8px"> <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="@T["adminaudit.filter.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="@T["adminaudit.filter.client"]" @bind="_filterClient" @bind:event="oninput" />
<input class="form-input" style="width:200px" placeholder="Filter by action..." @bind="_filterAction" @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">Export CSV</a> <a href="/audit/export" class="btn btn-secondary" target="_blank">@T["audit.btn.exportCsv"]</a>
</div> </div>
@if (_loading) @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) 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 else
{ {
@@ -38,13 +39,13 @@ else
<table style="width:100%;border-collapse:collapse;font-size:13px"> <table style="width:100%;border-collapse:collapse;font-size:13px">
<thead> <thead>
<tr style="border-bottom:2px solid var(--border)"> <tr style="border-bottom:2px solid var(--border)">
<th style="text-align:left;padding:6px">Timestamp</th> <th style="text-align:left;padding:6px">@T["report.col.timestamp"]</th>
<th style="text-align:left;padding:6px">User</th> <th style="text-align:left;padding:6px">@T["report.col.user"]</th>
<th style="text-align:left;padding:6px">Role</th> <th style="text-align:left;padding:6px">@T["adminaudit.col.role"]</th>
<th style="text-align:left;padding:6px">Action</th> <th style="text-align:left;padding:6px">@T["adminaudit.col.action"]</th>
<th style="text-align:left;padding:6px">Client</th> <th style="text-align:left;padding:6px">@T["adminaudit.col.client"]</th>
<th style="text-align:left;padding:6px">Sites</th> <th style="text-align:left;padding:6px">@T["report.col.sites"]</th>
<th style="text-align:left;padding:6px">Details</th> <th style="text-align:left;padding:6px">@T["adminaudit.col.details"]</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -63,7 +64,7 @@ else
</tbody> </tbody>
</table> </table>
</div> </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 { @code {
+33 -32
View File
@@ -4,18 +4,19 @@
@inject IUserContextAccessor UserContext @inject IUserContextAccessor UserContext
@inject IAuditService Audit @inject IAuditService Audit
@inject NavigationManager Nav @inject NavigationManager Nav
@inject TranslationSource T
@rendermode InteractiveServer @rendermode InteractiveServer
@using SharepointToolbox.Web.Core.Models @using SharepointToolbox.Web.Core.Models
@using SharepointToolbox.Web.Services.Audit @using SharepointToolbox.Web.Services.Audit
@using SharepointToolbox.Web.Services.Auth @using SharepointToolbox.Web.Services.Auth
@using SharepointToolbox.Web.Services.Session @using SharepointToolbox.Web.Services.Session
<h1 class="page-title">User Management</h1> <h1 class="page-title">@T["usermgmt.title"]</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> <p class="page-subtitle">@T["usermgmt.subtitle"]</p>
@if (!UserContext.IsAuthenticated || UserContext.Role != UserRole.Admin) @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; return;
} }
@@ -25,18 +26,18 @@
} }
<div class="card"> <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 style="display:grid;grid-template-columns:1fr 1fr;gap:12px;max-width:640px">
<div> <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" /> <input id="new-email" class="form-input" type="email" @bind="_newEmail" placeholder="user@example.com" />
</div> </div>
<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" /> <input id="new-name" class="form-input" type="text" @bind="_newName" placeholder="Jane Doe" />
</div> </div>
<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"> <select id="new-role" class="form-input" @bind="_newRole">
@foreach (var role in Enum.GetValues<UserRole>()) @foreach (var role in Enum.GetValues<UserRole>())
{ {
@@ -45,18 +46,18 @@
</select> </select>
</div> </div>
<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" /> <input id="new-pw" class="form-input" type="password" @bind="_newPassword" autocomplete="new-password" />
</div> </div>
</div> </div>
<div style="margin-top:12px"> <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>
</div> </div>
@if (_users.Count == 0) @if (_users.Count == 0)
{ {
<div class="alert alert-info">No users provisioned yet.</div> <div class="alert alert-info">@T["usermgmt.empty"]</div>
} }
else else
{ {
@@ -64,12 +65,12 @@ else
<table style="width:100%;border-collapse:collapse"> <table style="width:100%;border-collapse:collapse">
<thead> <thead>
<tr style="border-bottom:2px solid var(--border)"> <tr style="border-bottom:2px solid var(--border)">
<th style="text-align:left;padding:8px">User</th> <th style="text-align:left;padding:8px">@T["usermgmt.col.user"]</th>
<th style="text-align:left;padding:8px">Email</th> <th style="text-align:left;padding:8px">@T["usermgmt.col.email"]</th>
<th style="text-align:left;padding:8px">Source</th> <th style="text-align:left;padding:8px">@T["usermgmt.col.source"]</th>
<th style="text-align:left;padding:8px">Role</th> <th style="text-align:left;padding:8px">@T["usermgmt.col.role"]</th>
<th style="text-align:left;padding:8px">Last Login</th> <th style="text-align:left;padding:8px">@T["usermgmt.col.lastlogin"]</th>
<th style="text-align:left;padding:8px">Actions</th> <th style="text-align:left;padding:8px">@T["usermgmt.col.actions"]</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -80,7 +81,7 @@ else
<td style="padding:8px">@user.Email</td> <td style="padding:8px">@user.Email</td>
<td style="padding:8px"> <td style="padding:8px">
<span class="chip @(user.Provider == AuthProvider.Local ? "chip-blue" : "chip-green")"> <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> </span>
</td> </td>
<td style="padding:8px"> <td style="padding:8px">
@@ -94,19 +95,19 @@ else
} }
</select> </select>
</td> </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"> <td style="padding:8px;white-space:nowrap">
@if (user.Provider == AuthProvider.Local) @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) @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 else
{ {
<span class="chip chip-green">You</span> <span class="chip chip-green">@T["usermgmt.badge.you"]</span>
} }
</td> </td>
</tr> </tr>
@@ -119,12 +120,12 @@ else
@if (_resetUser is not null) @if (_resetUser is not null)
{ {
<div class="card" style="max-width:420px"> <div class="card" style="max-width:420px">
<h2 class="card-title">Reset password — @_resetUser.DisplayName</h2> <h2 class="card-title">@string.Format(T["usermgmt.reset.title"], _resetUser.DisplayName)</h2>
<label class="form-label" for="reset-pw">New password</label> <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" /> <input id="reset-pw" class="form-input" type="password" @bind="_resetPassword" autocomplete="new-password" />
<div style="margin-top:12px;display:flex;gap:8px"> <div style="margin-top:12px;display:flex;gap:8px">
<button class="btn btn-primary" @onclick="ResetPasswordAsync">Set password</button> <button class="btn btn-primary" @onclick="ResetPasswordAsync">@T["usermgmt.btn.setpw"]</button>
<button class="btn btn-secondary" @onclick="() => _resetUser = null">Cancel</button> <button class="btn btn-secondary" @onclick="() => _resetUser = null">@T["btn.cancel"]</button>
</div> </div>
</div> </div>
} }
@@ -155,14 +156,14 @@ else
_users.Add(user); _users.Add(user);
await Audit.LogAsync("UserCreated", "", Array.Empty<string>(), await Audit.LogAsync("UserCreated", "", Array.Empty<string>(),
$"Created local user {user.Email} ({user.DisplayName}) with role {user.Role}."); $"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; _isError = false;
_newEmail = _newName = _newPassword = string.Empty; _newEmail = _newName = _newPassword = string.Empty;
_newRole = UserRole.TechN0; _newRole = UserRole.TechN0;
} }
catch (Exception ex) catch (Exception ex)
{ {
_message = $"Error: {ex.Message}"; _message = string.Format(T["usermgmt.msg.error"], ex.Message);
_isError = true; _isError = true;
} }
} }
@@ -181,13 +182,13 @@ else
await UserService.SetPasswordAsync(_resetUser.Id, _resetPassword); await UserService.SetPasswordAsync(_resetUser.Id, _resetPassword);
await Audit.LogAsync("PasswordReset", "", Array.Empty<string>(), await Audit.LogAsync("PasswordReset", "", Array.Empty<string>(),
$"Reset password for local user {_resetUser.Email} ({_resetUser.DisplayName})."); $"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; _isError = false;
_resetUser = null; _resetUser = null;
} }
catch (Exception ex) catch (Exception ex)
{ {
_message = $"Error: {ex.Message}"; _message = string.Format(T["usermgmt.msg.error"], ex.Message);
_isError = true; _isError = true;
} }
} }
@@ -202,12 +203,12 @@ else
user.Role = newRole; user.Role = newRole;
await Audit.LogAsync("RoleChanged", "", Array.Empty<string>(), await Audit.LogAsync("RoleChanged", "", Array.Empty<string>(),
$"Changed role for {user.Email} ({user.DisplayName}) from {oldRole} to {newRole}."); $"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; _isError = false;
} }
catch (Exception ex) catch (Exception ex)
{ {
_message = $"Error: {ex.Message}"; _message = string.Format(T["usermgmt.msg.error"], ex.Message);
_isError = true; _isError = true;
} }
} }
@@ -218,7 +219,7 @@ else
_users.Remove(user); _users.Remove(user);
await Audit.LogAsync("UserDeleted", "", Array.Empty<string>(), await Audit.LogAsync("UserDeleted", "", Array.Empty<string>(),
$"Removed {user.Provider} user {user.Email} ({user.DisplayName}), role {user.Role}."); $"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; _isError = false;
} }
} }
+13 -12
View File
@@ -8,10 +8,11 @@
@inject ICsvValidationService CsvValidation @inject ICsvValidationService CsvValidation
@inject BulkResultCsvExportService ExportSvc @inject BulkResultCsvExportService ExportSvc
@inject WebExportService WebExport @inject WebExportService WebExport
@inject TranslationSource T
@rendermode InteractiveServer @rendermode InteractiveServer
<h1 class="page-title">Bulk Members</h1> <h1 class="page-title">@T["tab.bulkMembers"]</h1>
<p class="page-subtitle">Add users to SharePoint groups from a CSV file.</p> <p class="page-subtitle">@T["bulkmembers.subtitle"]</p>
@if (!Session.HasProfile) { <NoProfilePrompt /> return; } @if (!Session.HasProfile) { <NoProfilePrompt /> return; }
@if (UserContext.Role < UserRole.TechN1) { <WriteGuard /> return; } @if (UserContext.Role < UserRole.TechN1) { <WriteGuard /> return; }
@@ -19,18 +20,18 @@
<div class="card"> <div class="card">
<SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_sites" Single="true" /> <SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_sites" Single="true" />
<div class="form-group"> <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" /> <InputFile OnChange="LoadFile" accept=".csv" class="form-input" />
</div> </div>
@if (_rows.Count > 0) @if (_rows.Count > 0)
{ {
<div class="alert alert-info mt-8"> <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>
<div class="data-table-wrap" style="max-height:200px;overflow-y:auto"> <div class="data-table-wrap" style="max-height:200px;overflow-y:auto">
<table class="data-table"> <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> <tbody>
@foreach (var row in _rows.Take(50)) @foreach (var row in _rows.Take(50))
{ {
@@ -48,9 +49,9 @@
<div class="flex-row mt-8"> <div class="flex-row mt-8">
<button class="btn btn-primary" @onclick="RunBulk" disabled="@(_running || _rows.Count == 0 || _rows.All(r => !r.IsValid))"> <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> </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> </div>
<ProgressPanel IsRunning="_running" StatusMessage="@_status" Current="_current" Total="_total" /> <ProgressPanel IsRunning="_running" StatusMessage="@_status" Current="_current" Total="_total" />
</div> </div>
@@ -61,11 +62,11 @@
{ {
<div class="card"> <div class="card">
<div class="alert @(_summary.HasFailures ? "alert-warn" : "alert-success")"> <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> </div>
@if (_summary.HasFailures) @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> </div>
} }
@@ -92,7 +93,7 @@
_cts = new CancellationTokenSource(); _cts = new CancellationTokenSource();
var validRows = _rows.Where(r => r.IsValid && r.Record != null).Select(r => r.Record!).ToList(); var validRows = _rows.Where(r => r.IsValid && r.Record != null).Select(r => r.Record!).ToList();
var siteUrl = _sites.FirstOrDefault()?.Url; 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); }); var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
try try
{ {
@@ -101,9 +102,9 @@
var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, c); var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, c);
return await BulkSvc.AddMembersAsync(ctx, Session.CurrentProfile!, validRows, progress, c); return await BulkSvc.AddMembersAsync(ctx, Session.CurrentProfile!, validRows, progress, c);
}, _cts.Token); }, _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; } catch (Exception ex) { _error = ex.Message; }
finally { _running = false; await InvokeAsync(StateHasChanged); } finally { _running = false; await InvokeAsync(StateHasChanged); }
} }
+13 -12
View File
@@ -7,30 +7,31 @@
@inject ICsvValidationService CsvValidation @inject ICsvValidationService CsvValidation
@inject BulkResultCsvExportService ExportSvc @inject BulkResultCsvExportService ExportSvc
@inject WebExportService WebExport @inject WebExportService WebExport
@inject TranslationSource T
@rendermode InteractiveServer @rendermode InteractiveServer
<h1 class="page-title">Bulk Site Creation</h1> <h1 class="page-title">@T["bulksites.page.title"]</h1>
<p class="page-subtitle">Create multiple SharePoint sites from a CSV file.</p> <p class="page-subtitle">@T["bulksites.page.subtitle"]</p>
@if (!Session.HasProfile) { <NoProfilePrompt /> return; } @if (!Session.HasProfile) { <NoProfilePrompt /> return; }
@if (UserContext.Role < UserRole.TechN1) { <WriteGuard /> return; } @if (UserContext.Role < UserRole.TechN1) { <WriteGuard /> return; }
<div class="card"> <div class="card">
<div class="form-group"> <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" /> <input class="form-input" @bind="_adminUrl" placeholder="https://contoso-admin.sharepoint.com" />
</div> </div>
<div class="form-group"> <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" /> <InputFile OnChange="LoadFile" accept=".csv" class="form-input" />
</div> </div>
@if (_rows.Count > 0) @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"> <div class="data-table-wrap" style="max-height:200px;overflow-y:auto">
<table class="data-table"> <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> <tbody>
@foreach (var row in _rows.Take(50)) @foreach (var row in _rows.Take(50))
{ {
@@ -48,9 +49,9 @@
<div class="flex-row mt-8"> <div class="flex-row mt-8">
<button class="btn btn-primary" @onclick="RunBulk" disabled="@(_running || _rows.Count == 0 || _rows.All(r => !r.IsValid))"> <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> </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> </div>
<ProgressPanel IsRunning="_running" StatusMessage="@_status" Current="_current" Total="_total" /> <ProgressPanel IsRunning="_running" StatusMessage="@_status" Current="_current" Total="_total" />
</div> </div>
@@ -61,11 +62,11 @@
{ {
<div class="card"> <div class="card">
<div class="alert @(_summary.HasFailures ? "alert-warn" : "alert-success")"> <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> </div>
@if (_summary.HasFailures) @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> </div>
} }
@@ -98,9 +99,9 @@
{ {
var ctx = await SessionMgr.GetOrCreateContextAsync(adminUrl, Session.CurrentProfile!, _cts.Token); var ctx = await SessionMgr.GetOrCreateContextAsync(adminUrl, Session.CurrentProfile!, _cts.Token);
_summary = await BulkSvc.CreateSitesAsync(ctx, validRows, progress, _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; } catch (Exception ex) { _error = ex.Message; }
finally { _running = false; await InvokeAsync(StateHasChanged); } finally { _running = false; await InvokeAsync(StateHasChanged); }
} }
+18 -17
View File
@@ -8,9 +8,10 @@
@inject DuplicatesHtmlExportService HtmlExport @inject DuplicatesHtmlExportService HtmlExport
@inject WebExportService WebExport @inject WebExportService WebExport
@inject IAuditService Audit @inject IAuditService Audit
@inject TranslationSource T
@rendermode InteractiveServer @rendermode InteractiveServer
<h1 class="page-title">Duplicate Detection</h1> <h1 class="page-title">@T["duplicates.page.title"]</h1>
@if (!Session.HasProfile) { <NoProfilePrompt /> return; } @if (!Session.HasProfile) { <NoProfilePrompt /> return; }
@@ -18,29 +19,29 @@
<SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_sites" /> <SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_sites" />
<div class="form-row mt-8"> <div class="form-row mt-8">
<div class="form-group"> <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"> <select class="form-select" @bind="_mode" style="width:120px">
<option value="Files">Files</option> <option value="Files">@T["duplicates.mode.files"]</option>
<option value="Folders">Folders</option> <option value="Folders">@T["duplicates.mode.folders"]</option>
</select> </select>
</div> </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>
<div class="flex-row"> <div class="flex-row">
<label><input type="checkbox" @bind="_matchSize" /> Match size</label> <label><input type="checkbox" @bind="_matchSize" /> @T["duplicates.chk.match_size"]</label>
<label><input type="checkbox" @bind="_matchCreated" /> Match created</label> <label><input type="checkbox" @bind="_matchCreated" /> @T["duplicates.chk.match_created"]</label>
<label><input type="checkbox" @bind="_matchModified" /> Match modified</label> <label><input type="checkbox" @bind="_matchModified" /> @T["duplicates.chk.match_modified"]</label>
@if (_mode == "Folders") @if (_mode == "Folders")
{ {
<label><input type="checkbox" @bind="_matchFolderCount" /> Match subfolder count</label> <label><input type="checkbox" @bind="_matchFolderCount" /> @T["duplicates.chk.match_folder_count"]</label>
<label><input type="checkbox" @bind="_matchFileCount" /> Match file count</label> <label><input type="checkbox" @bind="_matchFileCount" /> @T["duplicates.chk.match_file_count"]</label>
} }
</div> </div>
<div class="flex-row mt-8"> <div class="flex-row mt-8">
<button class="btn btn-primary" @onclick="RunScan" disabled="@_running"> <button class="btn btn-primary" @onclick="RunScan" disabled="@_running">
@(_running ? "Scanning" : "Find Duplicates") @(_running ? T["duplicates.btn.scanning"] : T["duplicates.btn.find"])
</button> </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> </div>
<ProgressPanel IsRunning="_running" StatusMessage="@_status" Current="_current" Total="_total" /> <ProgressPanel IsRunning="_running" StatusMessage="@_status" Current="_current" Total="_total" />
</div> </div>
@@ -51,17 +52,17 @@
{ {
<div class="card"> <div class="card">
<div class="flex-row"> <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> <div class="spacer"></div>
<MergeModeSelect Value="_mergeMode" ValueChanged="v => _mergeMode = v" Visible="_bySite.Count > 1" /> <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="ExportCsv">@T["audit.btn.exportCsv"]</button>
<button class="btn btn-secondary btn-sm" @onclick="ExportHtml">Export HTML</button> <button class="btn btn-secondary btn-sm" @onclick="ExportHtml">@T["audit.btn.exportHtml"]</button>
</div> </div>
@foreach (var g in _results.Take(100)) @foreach (var g in _results.Take(100))
{ {
<div style="margin-bottom:8px;border:1px solid var(--border);border-radius:4px;overflow:hidden"> <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"> <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> </div>
@foreach (var item in g.Items) @foreach (var item in g.Items)
{ {
@@ -72,7 +73,7 @@
} }
</div> </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> </div>
} }
+26 -25
View File
@@ -5,60 +5,61 @@
@inject ISessionManager SessionMgr @inject ISessionManager SessionMgr
@inject IElevationCoordinator Elevation @inject IElevationCoordinator Elevation
@inject IFileTransferService TransferSvc @inject IFileTransferService TransferSvc
@inject TranslationSource T
@rendermode InteractiveServer @rendermode InteractiveServer
<h1 class="page-title">File Transfer</h1> <h1 class="page-title">@T["transfer.page.title"]</h1>
@if (!Session.HasProfile) { <NoProfilePrompt /> return; } @if (!Session.HasProfile) { <NoProfilePrompt /> return; }
@if (UserContext.Role < UserRole.TechN1) { <WriteGuard /> return; } @if (UserContext.Role < UserRole.TechN1) { <WriteGuard /> return; }
<div class="card"> <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" /> <SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_srcSites" Single="true" />
<div class="form-row mt-8"> <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"> <div class="form-group">
<label class="form-label">Source Folder (optional)</label> <label class="form-label">@T["transfer.sourcefolder.optional"]</label>
<input class="form-input" @bind="_srcFolder" placeholder="SubFolder/Path" /> <input class="form-input" @bind="_srcFolder" placeholder="@T["transfer.sourcefolder.placeholder"]" />
</div> </div>
</div> </div>
</div> </div>
<div class="card"> <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" /> <SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_dstSites" Single="true" />
<div class="form-row mt-8"> <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"> <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" /> <input class="form-input" @bind="_dstFolder" />
</div> </div>
</div> </div>
<div class="form-row"> <div class="form-row">
<div class="form-group"> <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"> <select class="form-select" @bind="_mode" style="width:100px">
<option value="Copy">Copy</option> <option value="Copy">@T["transfer.mode.copy"]</option>
<option value="Move">Move</option> <option value="Move">@T["transfer.mode.move"]</option>
</select> </select>
</div> </div>
<div class="form-group"> <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"> <select class="form-select" @bind="_conflict" style="width:120px">
<option value="Skip">Skip</option> <option value="Skip">@T["transfer.conflict.skip"]</option>
<option value="Overwrite">Overwrite</option> <option value="Overwrite">@T["transfer.conflict.overwrite"]</option>
<option value="Rename">Rename</option> <option value="Rename">@T["transfer.conflict.rename.short"]</option>
</select> </select>
</div> </div>
<div class="form-group" style="display:flex;align-items:center;padding-top:20px"> <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> </div>
<div class="flex-row mt-8"> <div class="flex-row mt-8">
<button class="btn btn-primary" @onclick="RunTransfer" disabled="@_running"> <button class="btn btn-primary" @onclick="RunTransfer" disabled="@_running">
@(_running ? "Transferring" : "Start Transfer") @(_running ? T["transfer.transferring"] : T["transfer.start"])
</button> </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> </div>
<ProgressPanel IsRunning="_running" StatusMessage="@_status" Current="_current" Total="_total" /> <ProgressPanel IsRunning="_running" StatusMessage="@_status" Current="_current" Total="_total" />
</div> </div>
@@ -69,14 +70,14 @@
{ {
<div class="card"> <div class="card">
<div class="alert @(_summary.HasFailures ? "alert-warn" : "alert-success")"> <div class="alert @(_summary.HasFailures ? "alert-warn" : "alert-success")">
Transferred: @_summary.SuccessCount / @_summary.TotalCount files. @string.Format(T["transfer.result.transferred"], _summary.SuccessCount, _summary.TotalCount)
@if (_summary.HasFailures) { <span>Failures: @_summary.FailedCount</span> } @if (_summary.HasFailures) { <span>@string.Format(T["transfer.result.failures"], _summary.FailedCount)</span> }
</div> </div>
@if (_summary.HasFailures) @if (_summary.HasFailures)
{ {
<div class="data-table-wrap mt-8"> <div class="data-table-wrap mt-8">
<table class="data-table"> <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> <tbody>
@foreach (var f in _summary.FailedItems) @foreach (var f in _summary.FailedItems)
{ {
@@ -109,8 +110,8 @@
{ {
var srcUrl = _srcSites.FirstOrDefault()?.Url; var srcUrl = _srcSites.FirstOrDefault()?.Url;
var dstUrl = _dstSites.FirstOrDefault()?.Url; var dstUrl = _dstSites.FirstOrDefault()?.Url;
if (string.IsNullOrWhiteSpace(srcUrl)) { _error = "Please select a source site."; return; } if (string.IsNullOrWhiteSpace(srcUrl)) { _error = T["transfer.err.no_source_site"]; return; }
if (string.IsNullOrWhiteSpace(dstUrl)) { _error = "Please select a destination site."; return; } if (string.IsNullOrWhiteSpace(dstUrl)) { _error = T["transfer.err.no_dest_site"]; return; }
var job = new TransferJob var job = new TransferJob
{ {
SourceSiteUrl = srcUrl, SourceLibrary = _srcLibrary, SourceFolderPath = _srcFolder, SourceSiteUrl = srcUrl, SourceLibrary = _srcLibrary, SourceFolderPath = _srcFolder,
@@ -126,9 +127,9 @@
var dstCtx = await SessionMgr.GetOrCreateContextAsync(dstUrl, Session.CurrentProfile!, c); var dstCtx = await SessionMgr.GetOrCreateContextAsync(dstUrl, Session.CurrentProfile!, c);
return await TransferSvc.TransferAsync(srcCtx, dstCtx, job, progress, c); return await TransferSvc.TransferAsync(srcCtx, dstCtx, job, progress, c);
}, _cts.Token); }, _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; } catch (Exception ex) { _error = ex.Message; }
finally { _running = false; await InvokeAsync(StateHasChanged); } finally { _running = false; await InvokeAsync(StateHasChanged); }
} }
+18 -17
View File
@@ -6,10 +6,11 @@
@inject IElevationCoordinator Elevation @inject IElevationCoordinator Elevation
@inject IFolderStructureService FolderSvc @inject IFolderStructureService FolderSvc
@inject ICsvValidationService CsvValidation @inject ICsvValidationService CsvValidation
@inject TranslationSource T
@rendermode InteractiveServer @rendermode InteractiveServer
<h1 class="page-title">Folder Structure</h1> <h1 class="page-title">@T["tab.folderStructure"]</h1>
<p class="page-subtitle">Create folder hierarchies in a document library from a CSV template.</p> <p class="page-subtitle">@T["folderstruct.subtitle"]</p>
@if (!Session.HasProfile) { <NoProfilePrompt /> return; } @if (!Session.HasProfile) { <NoProfilePrompt /> return; }
@if (UserContext.Role < UserRole.TechN1) { <WriteGuard /> return; } @if (UserContext.Role < UserRole.TechN1) { <WriteGuard /> return; }
@@ -17,30 +18,30 @@
<div class="card"> <div class="card">
<SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_sites" Single="true" /> <SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_sites" Single="true" />
<div class="form-row mt-8"> <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>
<div class="form-group"> <div class="form-group">
<label class="form-label">Source</label> <label class="form-label">@T["folderstruct.lbl.source"]</label>
<div class="flex-row"> <div class="flex-row">
<button class="btn btn-sm @(_mode == InputMode.Csv ? "btn-primary" : "btn-secondary")" <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")" <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>
</div> </div>
@if (_mode == InputMode.Csv) @if (_mode == InputMode.Csv)
{ {
<div class="form-group"> <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" /> <InputFile OnChange="LoadFile" accept=".csv" class="form-input" />
</div> </div>
} }
else else
{ {
<div class="form-group"> <div class="form-group">
<label class="form-label">Folder Structure</label> <label class="form-label">@T["tab.folderStructure"]</label>
<div class="folder-builder"> <div class="folder-builder">
@foreach (var root in _tree) @foreach (var root in _tree)
{ {
@@ -48,23 +49,23 @@
} }
@if (_tree.Count == 0) @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> </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> </div>
} }
@if (_rows.Count > 0) @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"> <div class="flex-row mt-8">
<button class="btn btn-primary" @onclick="RunCreate" disabled="@(_running || _rows.Count == 0 || string.IsNullOrWhiteSpace(_libraryTitle))"> <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> </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> </div>
<ProgressPanel IsRunning="_running" StatusMessage="@_status" Current="_current" Total="_total" /> <ProgressPanel IsRunning="_running" StatusMessage="@_status" Current="_current" Total="_total" />
</div> </div>
@@ -75,7 +76,7 @@
{ {
<div class="card"> <div class="card">
<div class="alert @(_summary.HasFailures ? "alert-warn" : "alert-success")"> <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>
</div> </div>
} }
@@ -134,7 +135,7 @@
_cts = new CancellationTokenSource(); _cts = new CancellationTokenSource();
var validRows = _rows.Where(r => r.IsValid && r.Record != null).Select(r => r.Record!).ToList(); var validRows = _rows.Where(r => r.IsValid && r.Record != null).Select(r => r.Record!).ToList();
var siteUrl = _sites.FirstOrDefault()?.Url; 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); }); var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
try try
{ {
@@ -143,9 +144,9 @@
var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, c); var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, c);
return await FolderSvc.CreateFoldersAsync(ctx, _libraryTitle, validRows, progress, c); return await FolderSvc.CreateFoldersAsync(ctx, _libraryTitle, validRows, progress, c);
}, _cts.Token); }, _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; } catch (Exception ex) { _error = ex.Message; }
finally { _running = false; await InvokeAsync(StateHasChanged); } finally { _running = false; await InvokeAsync(StateHasChanged); }
} }
+26 -25
View File
@@ -1,28 +1,29 @@
@page "/" @page "/"
@attribute [Authorize] @attribute [Authorize]
@inject IUserSessionService Session @inject IUserSessionService Session
@inject TranslationSource T
@rendermode InteractiveServer @rendermode InteractiveServer
<h1 class="page-title">SharePoint Toolbox</h1> <h1 class="page-title">@T["app.title"]</h1>
@if (!Session.HasProfile) @if (!Session.HasProfile)
{ {
<div class="card"> <div class="card">
<div class="card-title">Welcome</div> <div class="card-title">@T["home.welcome"]</div>
<p>Select a tenant profile to start using SharePoint Toolbox.</p> <p>@T["home.welcome.body"]</p>
<a href="/profiles" class="btn btn-primary">Manage Profiles</a> <a href="/profiles" class="btn btn-primary">@T["profmgmt.title"]</a>
</div> </div>
} }
else else
{ {
<div class="card"> <div class="card">
<div class="card-title">Connected: @Session.CurrentProfile!.Name</div> <div class="card-title">@(string.Format(T["home.connected"], Session.CurrentProfile!.Name))</div>
<p>Tenant: <strong>@Session.CurrentProfile.TenantUrl</strong></p> <p>@T["home.tenant"] <strong>@Session.CurrentProfile.TenantUrl</strong></p>
<div class="flex-row mt-16"> <div class="flex-row mt-16">
<a href="/permissions" class="btn btn-secondary">Permissions Audit</a> <a href="/permissions" class="btn btn-secondary">@T["home.link.permissions"]</a>
<a href="/storage" class="btn btn-secondary">Storage Metrics</a> <a href="/storage" class="btn btn-secondary">@T["home.link.storage"]</a>
<a href="/search" class="btn btn-secondary">File Search</a> <a href="/search" class="btn btn-secondary">@T["tab.search"]</a>
<a href="/user-audit" class="btn btn-secondary">User Access Audit</a> <a href="/user-audit" class="btn btn-secondary">@T["tab.userAccessAudit"]</a>
</div> </div>
</div> </div>
@@ -32,8 +33,8 @@ else
<a href="@feature.Href" style="text-decoration:none;color:inherit"> <a href="@feature.Href" style="text-decoration:none;color:inherit">
<div class="card feature-card"> <div class="card feature-card">
<div style="font-size:28px;margin-bottom:8px">@feature.Icon</div> <div style="font-size:28px;margin-bottom:8px">@feature.Icon</div>
<div style="font-weight:600;margin-bottom:4px">@feature.Title</div> <div style="font-weight:600;margin-bottom:4px">@T[feature.TitleKey]</div>
<div class="text-muted">@feature.Description</div> <div class="text-muted">@T[feature.DescriptionKey]</div>
</div> </div>
</a> </a>
} }
@@ -41,19 +42,19 @@ else
} }
@code { @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"), ("/permissions", "🔐", "home.link.permissions", "home.feat.permissions.desc"),
("/storage", "💾", "Storage Metrics", "Analyze library storage usage"), ("/storage", "💾", "home.link.storage", "home.feat.storage.desc"),
("/search", "🔍", "File Search", "KQL-based file search"), ("/search", "🔍", "tab.search", "home.feat.search.desc"),
("/duplicates", "📋", "Duplicates", "Find duplicate files/folders"), ("/duplicates", "📋", "tab.duplicates", "home.feat.duplicates.desc"),
("/versions", "🗂️", "Version Cleanup", "Delete old file versions"), ("/versions", "🗂️", "home.feat.versions.title", "home.feat.versions.desc"),
("/transfer", "📦", "File Transfer", "Copy/move files between libraries"), ("/transfer", "📦", "home.feat.transfer.title", "home.feat.transfer.desc"),
("/bulk-members", "👥", "Bulk Members", "Add users to groups via CSV"), ("/bulk-members", "👥", "tab.bulkMembers", "home.feat.bulkmembers.desc"),
("/bulk-sites", "🌐", "Bulk Sites", "Create sites from CSV"), ("/bulk-sites", "🌐", "tab.bulkSites", "home.feat.bulksites.desc"),
("/folder-structure", "📁", "Folder Structure", "Create folders from CSV template"), ("/folder-structure", "📁", "tab.folderStructure", "home.feat.folderstruct.desc"),
("/user-audit", "👤", "User Access Audit", "Audit user permissions cross-site"), ("/user-audit", "👤", "tab.userAccessAudit", "home.feat.useraudit.desc"),
("/user-directory", "📖", "User Directory", "Browse tenant users via Graph"), ("/user-directory", "📖", "directory.grp.browse", "home.feat.userdir.desc"),
("/templates", "📐", "Templates", "Capture and apply site templates"), ("/templates", "📐", "tab.templates", "home.feat.templates.desc"),
}; };
} }
+5 -4
View File
@@ -1,10 +1,11 @@
@page "/not-found" @page "/not-found"
@attribute [Microsoft.AspNetCore.Authorization.AllowAnonymous] @attribute [Microsoft.AspNetCore.Authorization.AllowAnonymous]
@inject TranslationSource T
<PageTitle>Page not found — SharePoint Toolbox</PageTitle> <PageTitle>@T["notfound.pagetitle"]</PageTitle>
<div class="no-profile"> <div class="no-profile">
<h2>Page not found</h2> <h2>@T["notfound.heading"]</h2>
<p>The page you requested doesn't exist or has moved.</p> <p>@T["notfound.body"]</p>
<a href="/" class="btn btn-primary">Back to Home</a> <a href="/" class="btn btn-primary">@T["notfound.back"]</a>
</div> </div>
+22 -21
View File
@@ -8,33 +8,34 @@
@inject HtmlExportService HtmlExport @inject HtmlExportService HtmlExport
@inject WebExportService WebExport @inject WebExportService WebExport
@inject IAuditService Audit @inject IAuditService Audit
@inject TranslationSource T
@rendermode InteractiveServer @rendermode InteractiveServer
<h1 class="page-title">Permissions Audit</h1> <h1 class="page-title">@T["perm.title"]</h1>
@if (!Session.HasProfile) { <NoProfilePrompt /> return; } @if (!Session.HasProfile) { <NoProfilePrompt /> return; }
<div class="card"> <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" /> <SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_sites" />
<div class="form-row mt-8"> <div class="form-row mt-8">
<div class="form-group" style="flex:0 0 auto"> <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" /> <input class="form-input" type="number" @bind="_folderDepth" min="0" max="999" style="width:80px" />
</div> </div>
<div class="form-group" style="display:flex;align-items:center;gap:16px;padding-top:20px"> <div class="form-group" style="display:flex;align-items:center;gap:16px;padding-top:20px">
<label><input type="checkbox" @bind="_includeInherited" /> Include inherited</label> <label><input type="checkbox" @bind="_includeInherited" /> @T["chk.inherited.perms"]</label>
<label><input type="checkbox" @bind="_scanFolders" /> Scan folders</label> <label><input type="checkbox" @bind="_scanFolders" /> @T["chk.scan.folders"]</label>
<label><input type="checkbox" @bind="_includeSubsites" /> Include subsites</label> <label><input type="checkbox" @bind="_includeSubsites" /> @T["chk.include.subsites"]</label>
</div> </div>
</div> </div>
<div class="flex-row mt-8"> <div class="flex-row mt-8">
<button class="btn btn-primary" @onclick="RunScan" disabled="@_running"> <button class="btn btn-primary" @onclick="RunScan" disabled="@_running">
@(_running ? "Scanning" : "Scan Sites") @(_running ? T["perm.btn.scanning"] : T["perm.btn.scan"])
</button> </button>
@if (_running) @if (_running)
{ {
<button class="btn btn-secondary" @onclick="Cancel">Cancel</button> <button class="btn btn-secondary" @onclick="Cancel">@T["btn.cancel"]</button>
} }
</div> </div>
<ProgressPanel IsRunning="_running" StatusMessage="@_status" Current="_current" Total="_total" /> <ProgressPanel IsRunning="_running" StatusMessage="@_status" Current="_current" Total="_total" />
@@ -49,21 +50,21 @@
{ {
<div class="card"> <div class="card">
<div class="flex-row"> <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> <div class="spacer"></div>
<MergeModeSelect Value="_mergeMode" ValueChanged="v => _mergeMode = v" Visible="_bySite.Count > 1" /> <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="ExportCsv">@T["audit.btn.exportCsv"]</button>
<button class="btn btn-secondary btn-sm" @onclick="ExportHtml">Export HTML</button> <button class="btn btn-secondary btn-sm" @onclick="ExportHtml">@T["audit.btn.exportHtml"]</button>
</div> </div>
<div class="data-table-wrap"> <div class="data-table-wrap">
<table class="data-table"> <table class="data-table">
<thead> <thead>
<tr> <tr>
<th>Type</th> <th>@T["directory.col.type"]</th>
<th>Title</th> <th>@T["report.col.title"]</th>
<th>Users</th> <th>@T["perm.col.users"]</th>
<th>Permission</th> <th>@T["perm.col.permission"]</th>
<th>Granted Through</th> <th>@T["report.col.granted_through"]</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -82,7 +83,7 @@
</div> </div>
@if (_results.Count > 500) @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> </div>
} }
@@ -105,7 +106,7 @@
{ {
_error = string.Empty; _results = new(); _bySite = new(); _running = true; _error = string.Empty; _results = new(); _bySite = new(); _running = true;
_cts = new CancellationTokenSource(); _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); }); var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
try try
{ {
@@ -116,7 +117,7 @@
foreach (var site in _sites) foreach (var site in _sites)
{ {
_cts.Token.ThrowIfCancellationRequested(); _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); await InvokeAsync(StateHasChanged);
var entries = await Elevation.RunAsync(async c => var entries = await Elevation.RunAsync(async c =>
{ {
@@ -127,11 +128,11 @@
flat.AddRange(entries); flat.AddRange(entries);
} }
_bySite = bySite; _results = flat; _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), await Audit.LogAsync("PermissionsScan", Session.CurrentProfile?.Name ?? "", _sites.Select(s => s.Url),
$"{_results.Count} entries; inherited={_includeInherited} folders={_scanFolders} depth={_folderDepth} subsites={_includeSubsites}"); $"{_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; } catch (Exception ex) { _error = ex.Message; }
finally { _running = false; await InvokeAsync(StateHasChanged); } 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.OAuth.IEntraDeviceCodeFlow DeviceFlow
@inject SharepointToolbox.Web.Services.Auth.IAppRegistrationService AppRegService @inject SharepointToolbox.Web.Services.Auth.IAppRegistrationService AppRegService
@inject Microsoft.Extensions.Options.IOptions<SharepointToolbox.Web.Core.Config.ClientConnectOptions> ConnectOpts @inject Microsoft.Extensions.Options.IOptions<SharepointToolbox.Web.Core.Config.ClientConnectOptions> ConnectOpts
@inject TranslationSource T
@rendermode InteractiveServer @rendermode InteractiveServer
@using Microsoft.AspNetCore.WebUtilities @using Microsoft.AspNetCore.WebUtilities
@using SharepointToolbox.Web.Core.Models @using SharepointToolbox.Web.Core.Models
@using SharepointToolbox.Web.Services.Session @using SharepointToolbox.Web.Services.Session
<h1 class="page-title">Client Profiles</h1> <h1 class="page-title">@T["profiles.title"]</h1>
<p class="page-subtitle">Manage SharePoint tenant connections. Credentials are entered per session — no secrets stored on disk.</p> <p class="page-subtitle">@T["profiles.subtitle"]</p>
@if (UserContext.Role != UserRole.Admin) @if (UserContext.Role != UserRole.Admin)
{ {
@* Non-admins can only select a profile, not create/edit/delete *@ @* 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) @foreach (var p in _profiles)
{ {
@@ -32,10 +33,10 @@
<div class="spacer"></div> <div class="spacer"></div>
@if (Session.CurrentProfile?.Id == p.Id) @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)"> <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>
</div> </div>
</div> </div>
@@ -51,12 +52,12 @@
} }
<div class="flex-row" style="margin-bottom:16px"> <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> </div>
@if (_profiles.Count == 0 && !_showForm) @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) @foreach (var p in _profiles)
@@ -66,19 +67,19 @@
<div> <div>
<div style="font-weight:600;font-size:15px">@p.Name</div> <div style="font-weight:600;font-size:15px">@p.Name</div>
<div class="text-muted">@p.TenantUrl</div> <div class="text-muted">@p.TenantUrl</div>
<div class="text-muted">Tenant ID: @p.TenantId</div> <div class="text-muted">@T["profiles.tenantid.label"] @p.TenantId</div>
<div class="text-muted">Client ID: @p.ClientId</div> <div class="text-muted">@T["profiles.clientid.label"] @p.ClientId</div>
</div> </div>
<div class="spacer"></div> <div class="spacer"></div>
@if (Session.CurrentProfile?.Id == p.Id) @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)"> <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>
<button class="btn btn-secondary btn-sm" @onclick="() => EditProfile(p)">Edit</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)">Delete</button> <button class="btn btn-danger btn-sm" @onclick="() => DeleteProfile(p)">@T["profile.delete"]</button>
</div> </div>
</div> </div>
} }
@@ -86,57 +87,55 @@
@if (_showForm) @if (_showForm)
{ {
<div class="card" style="border-color:#0078d4"> <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)) @if (!string.IsNullOrEmpty(_formError))
{ {
<div class="alert alert-error">@_formError</div> <div class="alert alert-error">@_formError</div>
} }
<div class="form-group"> <div class="form-group">
<label class="form-label">Profile Name *</label> <label class="form-label">@T["profiles.form.name"]</label>
<input class="form-input" @bind="_form.Name" placeholder="e.g. Contoso Production" /> <input class="form-input" @bind="_form.Name" placeholder="@T["profiles.form.name.ph"]" />
</div> </div>
<div class="form-group"> <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" /> <input class="form-input" @bind="_form.TenantUrl" placeholder="https://contoso.sharepoint.com" />
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Tenant ID (GUID or domain) *</label> <label class="form-label">@T["profiles.form.tenantid"]</label>
<input class="form-input" @bind="_form.TenantId" placeholder="contoso.onmicrosoft.com or GUID" /> <input class="form-input" @bind="_form.TenantId" placeholder="@T["profiles.form.tenantid.ph"]" />
</div> </div>
@* App registration section *@ @* App registration section *@
<div class="form-group"> <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"> <div class="flex-row" style="gap:8px;align-items:center">
<input class="form-input" @bind="_form.ClientId" <input class="form-input" @bind="_form.ClientId"
placeholder="Auto-filled after registration, or enter manually" placeholder="@T["profiles.form.clientid.ph"]"
style="flex:1" /> style="flex:1" />
<button class="btn btn-secondary" @onclick="RegisterAppAsync" <button class="btn btn-secondary" @onclick="RegisterAppAsync"
disabled="@(!CanRegister || _registering)" 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")"> title="@(CanRegister ? T["profiles.register.tooltip.ready"] : T["profiles.register.tooltip.disabled"])">
@(_registering ? "Waiting" : "Register in Entra") @(_registering ? T["profiles.register.waiting"] : T["profiles.register.btn"])
</button> </button>
</div> </div>
<small class="text-muted"> <small class="text-muted">
Click "Register in Entra" to auto-create the app registration in the client tenant. @T["profiles.register.hint"]
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.
</small> </small>
@if (_deviceCode is not null) @if (_deviceCode is not null)
{ {
<div class="alert alert-info" style="margin-top:10px"> <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"> <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>@T["profiles.devicecode.step.open"] <a href="@_deviceCode.VerificationUri" target="_blank" rel="noopener">@_deviceCode.VerificationUri</a></li>
<li>Enter code: <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> <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>
<li>Approve the requested permissions with an admin account.</li> <li>@T["profiles.devicecode.step.approve"]</li>
</ol> </ol>
<div class="flex-row" style="gap:8px"> <div class="flex-row" style="gap:8px">
<span class="text-muted">@_regStatus</span> <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>
</div> </div>
} }
@@ -147,14 +146,14 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Client logo (optional)</label> <label class="form-label">@T["profiles.form.logo"]</label>
<small class="text-muted d-block" style="margin-bottom:6px">Shown top-right on exported reports for this client.</small> <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" /> <LogoUpload Value="_form.ClientLogo" ValueChanged="(LogoData? l) => _form.ClientLogo = l" />
</div> </div>
<div class="flex-row mt-8"> <div class="flex-row mt-8">
<button class="btn btn-primary" @onclick="SaveProfile">Save</button> <button class="btn btn-primary" @onclick="SaveProfile">@T["profile.save"]</button>
<button class="btn btn-secondary" @onclick="CancelForm">Cancel</button> <button class="btn btn-secondary" @onclick="CancelForm">@T["btn.cancel"]</button>
</div> </div>
</div> </div>
} }
@@ -239,7 +238,7 @@
_registering = true; _registering = true;
_formError = string.Empty; _formError = string.Empty;
_regStatus = "Requesting a sign-in code…"; _regStatus = T["profiles.reg.requesting"];
_deviceCode = null; _deviceCode = null;
_regCts = new CancellationTokenSource(TimeSpan.FromMinutes(15)); _regCts = new CancellationTokenSource(TimeSpan.FromMinutes(15));
StateHasChanged(); StateHasChanged();
@@ -248,13 +247,13 @@
{ {
// Secretless bootstrap: device code flow against the client tenant. // Secretless bootstrap: device code flow against the client tenant.
_deviceCode = await DeviceFlow.BeginAsync(_form.TenantId.Trim(), RegistrationScope, _regCts.Token); _deviceCode = await DeviceFlow.BeginAsync(_form.TenantId.Trim(), RegistrationScope, _regCts.Token);
_regStatus = "Waiting for sign-in to complete…"; _regStatus = T["profiles.reg.waitingsignin"];
StateHasChanged(); StateHasChanged();
var adminToken = await DeviceFlow.PollForAccessTokenAsync(_form.TenantId.Trim(), _deviceCode, _regCts.Token); var adminToken = await DeviceFlow.PollForAccessTokenAsync(_form.TenantId.Trim(), _deviceCode, _regCts.Token);
_deviceCode = null; _deviceCode = null;
_regStatus = "Creating the app registration…"; _regStatus = T["profiles.reg.creating"];
StateHasChanged(); StateHasChanged();
var clientId = await AppRegService.CreateAsync( var clientId = await AppRegService.CreateAsync(
@@ -264,15 +263,15 @@
ct: _regCts.Token); ct: _regCts.Token);
_form.ClientId = clientId; _form.ClientId = clientId;
_regStatus = "App registered. Review and Save the profile."; _regStatus = T["profiles.reg.registered"];
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
_regStatus = "Registration cancelled."; _regStatus = T["profiles.reg.cancelled"];
} }
catch (Exception ex) catch (Exception ex)
{ {
_formError = $"Registration failed: {ex.Message}"; _formError = string.Format(T["profiles.reg.failed"], ex.Message);
_regStatus = string.Empty; _regStatus = string.Empty;
} }
finally finally
@@ -289,16 +288,16 @@
{ {
_regCts?.Cancel(); _regCts?.Cancel();
_deviceCode = null; _deviceCode = null;
_regStatus = "Registration cancelled."; _regStatus = T["profiles.reg.cancelled"];
} }
private async Task SaveProfile() private async Task SaveProfile()
{ {
_formError = string.Empty; _formError = string.Empty;
if (string.IsNullOrWhiteSpace(_form.Name)) { _formError = "Name is required."; return; } if (string.IsNullOrWhiteSpace(_form.Name)) { _formError = T["profiles.err.name_required"]; return; }
if (string.IsNullOrWhiteSpace(_form.TenantUrl)) { _formError = "Tenant URL is required."; return; } if (string.IsNullOrWhiteSpace(_form.TenantUrl)) { _formError = T["profiles.err.url_required"]; return; }
if (string.IsNullOrWhiteSpace(_form.ClientId)) { _formError = "Client ID is required."; return; } if (string.IsNullOrWhiteSpace(_form.ClientId)) { _formError = T["profiles.err.clientid_required"]; return; }
if (string.IsNullOrWhiteSpace(_form.TenantId)) { _formError = "Tenant ID is required."; return; } if (string.IsNullOrWhiteSpace(_form.TenantId)) { _formError = T["profiles.err.tenantid_required"]; return; }
if (_editing == null) if (_editing == null)
{ {
+22 -21
View File
@@ -8,47 +8,48 @@
@inject SearchHtmlExportService HtmlExport @inject SearchHtmlExportService HtmlExport
@inject WebExportService WebExport @inject WebExportService WebExport
@inject IAuditService Audit @inject IAuditService Audit
@inject TranslationSource T
@rendermode InteractiveServer @rendermode InteractiveServer
<h1 class="page-title">File Search</h1> <h1 class="page-title">@T["tab.search"]</h1>
@if (!Session.HasProfile) { <NoProfilePrompt /> return; } @if (!Session.HasProfile) { <NoProfilePrompt /> return; }
<div class="card"> <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" /> <SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_sites" />
<div class="form-row mt-8"> <div class="form-row mt-8">
<div class="form-group"> <div class="form-group">
<label class="form-label">File Extensions (comma-separated)</label> <label class="form-label">@T["srch.lbl.extensions"]</label>
<input class="form-input" @bind="_extensions" placeholder="docx, xlsx, pdf" /> <input class="form-input" @bind="_extensions" placeholder="@T["ph.extensions"]" />
</div> </div>
</div> </div>
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label class="form-label">Regex filter (filename)</label> <label class="form-label">@T["lbl.regex"]</label>
<input class="form-input" @bind="_regex" placeholder="Optional regex pattern" /> <input class="form-input" @bind="_regex" placeholder="@T["ph.regex"]" />
</div> </div>
<div class="form-group"> <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" /> <input class="form-input" type="number" @bind="_maxResults" min="1" max="50000" style="width:120px" />
</div> </div>
</div> </div>
<div class="form-row"> <div class="form-row">
<div class="form-group"> <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" /> <input class="form-input" @bind="_createdBy" />
</div> </div>
<div class="form-group"> <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" /> <input class="form-input" @bind="_modifiedBy" />
</div> </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>
<div class="flex-row mt-8"> <div class="flex-row mt-8">
<button class="btn btn-primary" @onclick="RunSearch" disabled="@_running"> <button class="btn btn-primary" @onclick="RunSearch" disabled="@_running">
@(_running ? "Searching" : "Search") @(_running ? T["audit.searching"] : T["audit.mode.search"])
</button> </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> </div>
<ProgressPanel IsRunning="_running" StatusMessage="@_status" Current="_current" Total="_total" /> <ProgressPanel IsRunning="_running" StatusMessage="@_status" Current="_current" Total="_total" />
</div> </div>
@@ -59,15 +60,15 @@
{ {
<div class="card"> <div class="card">
<div class="flex-row"> <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> <div class="spacer"></div>
<MergeModeSelect Value="_mergeMode" ValueChanged="v => _mergeMode = v" Visible="_bySite.Count > 1" /> <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="ExportCsv">@T["audit.btn.exportCsv"]</button>
<button class="btn btn-secondary btn-sm" @onclick="ExportHtml">Export HTML</button> <button class="btn btn-secondary btn-sm" @onclick="ExportHtml">@T["audit.btn.exportHtml"]</button>
</div> </div>
<div class="data-table-wrap"> <div class="data-table-wrap">
<table class="data-table"> <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> <tbody>
@foreach (var r in _results.Take(500)) @foreach (var r in _results.Take(500))
{ {
@@ -83,7 +84,7 @@
</tbody> </tbody>
</table> </table>
</div> </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> </div>
} }
@@ -103,7 +104,7 @@
{ {
_error = string.Empty; _results = new(); _bySite = new(); _running = true; _error = string.Empty; _results = new(); _bySite = new(); _running = true;
_cts = new CancellationTokenSource(); _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); }); var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
try try
{ {
@@ -114,7 +115,7 @@
foreach (var site in _sites) foreach (var site in _sites)
{ {
_cts.Token.ThrowIfCancellationRequested(); _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); await InvokeAsync(StateHasChanged);
var opts = new SearchOptions(exts, _regex, null, null, null, null, _createdBy.TrimOrNull(), _modifiedBy.TrimOrNull(), _library.TrimOrNull(), _maxResults, site.Url); var opts = new SearchOptions(exts, _regex, null, null, null, null, _createdBy.TrimOrNull(), _modifiedBy.TrimOrNull(), _library.TrimOrNull(), _maxResults, site.Url);
var found = await Elevation.RunAsync(async c => var found = await Elevation.RunAsync(async c =>
@@ -126,11 +127,11 @@
flat.AddRange(found); flat.AddRange(found);
} }
_bySite = bySite; _results = flat; _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), await Audit.LogAsync("FileSearch", Session.CurrentProfile?.Name ?? "", _sites.Select(s => s.Url),
$"{_results.Count} files; ext=[{_extensions}] regex=[{_regex}] lib=[{_library}]"); $"{_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; } catch (Exception ex) { _error = ex.Message; }
finally { _running = false; await InvokeAsync(StateHasChanged); } finally { _running = false; await InvokeAsync(StateHasChanged); }
} }
+29 -16
View File
@@ -2,49 +2,50 @@
@attribute [Authorize] @attribute [Authorize]
@inject IUserSessionService Session @inject IUserSessionService Session
@inject IJSRuntime JS @inject IJSRuntime JS
@inject TranslationSource T
@rendermode InteractiveServer @rendermode InteractiveServer
@using Microsoft.JSInterop @using Microsoft.JSInterop
<h1 class="page-title">Settings</h1> <h1 class="page-title">@T["tab.settings"]</h1>
<div class="card"> <div class="card">
<div class="card-title">Display</div> <div class="card-title">@T["settings.section.display"]</div>
<div class="form-group"> <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"> <select class="form-select" style="width:160px" @bind="_lang" @bind:after="Save">
<option value="en">English</option> <option value="en">@T["settings.lang.en"]</option>
<option value="fr">Français</option> <option value="fr">@T["settings.lang.fr"]</option>
</select> </select>
</div> </div>
<div class="form-group"> <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"> <select class="form-select" style="width:160px" @bind="_theme" @bind:after="Save">
<option value="System">System</option> <option value="System">@T["settings.theme.system"]</option>
<option value="Light">Light</option> <option value="Light">@T["settings.theme.light"]</option>
</select> </select>
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-title">Behavior</div> <div class="card-title">@T["settings.section.behavior"]</div>
<div class="form-group"> <div class="form-group">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer"> <label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" @bind="_autoTakeOwnership" @bind:after="Save" /> <input type="checkbox" @bind="_autoTakeOwnership" @bind:after="Save" />
Auto-elevate ownership when permission scan is denied @T["settings.behavior.autoElevate"]
</label> </label>
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-title">Report Branding</div> <div class="card-title">@T["settings.section.branding"]</div>
<div class="form-group"> <div class="form-group">
<label class="form-label">MSP logo</label> <label class="form-label">@T["settings.logo.title"]</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> <p class="text-muted" style="margin-top:0">@T["settings.logo.description"]</p>
<LogoUpload Value="_mspLogo" ValueChanged="OnMspLogoChanged" /> <LogoUpload Value="_mspLogo" ValueChanged="OnMspLogoChanged" />
</div> </div>
</div> </div>
@if (_saved) { <div class="alert alert-success">Settings saved.</div> } @if (_saved) { <div class="alert alert-success">@T["settings.saved"]</div> }
@code { @code {
private string _lang = "en", _theme = "System"; private string _lang = "en", _theme = "System";
@@ -54,7 +55,9 @@
protected override void OnInitialized() protected override void OnInitialized()
{ {
var s = Session.Settings; 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"; _theme = s.Theme is "System" or "Light" ? s.Theme : "System";
_autoTakeOwnership = s.AutoTakeOwnership; _autoTakeOwnership = s.AutoTakeOwnership;
_mspLogo = s.MspLogo; _mspLogo = s.MspLogo;
@@ -68,9 +71,19 @@
private async Task Save() 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 }); 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); 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; _saved = true;
StateHasChanged(); StateHasChanged();
_ = Task.Delay(2000).ContinueWith(_ => { _saved = false; InvokeAsync(StateHasChanged); }); _ = Task.Delay(2000).ContinueWith(_ => { _saved = false; InvokeAsync(StateHasChanged); });
+24 -23
View File
@@ -8,35 +8,36 @@
@inject StorageHtmlExportService HtmlExport @inject StorageHtmlExportService HtmlExport
@inject WebExportService WebExport @inject WebExportService WebExport
@inject IAuditService Audit @inject IAuditService Audit
@inject TranslationSource T
@rendermode InteractiveServer @rendermode InteractiveServer
<h1 class="page-title">Storage Metrics</h1> <h1 class="page-title">@T["stor.page.title"]</h1>
@if (!Session.HasProfile) { <NoProfilePrompt /> return; } @if (!Session.HasProfile) { <NoProfilePrompt /> return; }
<div class="card"> <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" /> <SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_sites" />
<div class="form-row mt-8"> <div class="form-row mt-8">
<div class="form-group" style="max-width:220px"> <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" /> <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> </div>
<div class="form-row"> <div class="form-row">
<div class="form-group" style="display:flex;align-items:center;gap:16px;padding-top:20px"> <div class="form-group" style="display:flex;align-items:center;gap:16px;padding-top:20px">
<label><input type="checkbox" @bind="_includeSubsites" /> Include subsites</label> <label><input type="checkbox" @bind="_includeSubsites" /> @T["chk.include.subsites"]</label>
<label><input type="checkbox" @bind="_includeHidden" /> Include hidden libs</label> <label><input type="checkbox" @bind="_includeHidden" /> @T["stor.chk.include_hidden_libs"]</label>
<label><input type="checkbox" @bind="_includeRecycleBin" /> Include recycle bin</label> <label><input type="checkbox" @bind="_includeRecycleBin" /> @T["stor.chk.include_recycle_bin"]</label>
</div> </div>
</div> </div>
<div class="flex-row mt-8"> <div class="flex-row mt-8">
<button class="btn btn-primary" @onclick="RunScan" disabled="@_running"> <button class="btn btn-primary" @onclick="RunScan" disabled="@_running">
@(_running ? "Scanning" : "Scan Storage") @(_running ? T["stor.btn.scanning"] : T["stor.btn.scan_storage"])
</button> </button>
@if (_sites.Count > 0) { <span class="text-muted" style="align-self:center">@_sites.Count site(s) selected</span> } @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">Cancel</button> } @if (_running) { <button class="btn btn-secondary" @onclick="Cancel">@T["btn.cancel"]</button> }
</div> </div>
<ProgressPanel IsRunning="_running" StatusMessage="@_status" Current="_current" Total="_total" /> <ProgressPanel IsRunning="_running" StatusMessage="@_status" Current="_current" Total="_total" />
</div> </div>
@@ -47,22 +48,22 @@
{ {
<div class="card"> <div class="card">
<div class="flex-row"> <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> <div class="spacer"></div>
<MergeModeSelect Value="_mergeMode" ValueChanged="v => _mergeMode = v" Visible="_bySite.Count > 1" /> <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="ExportCsv">@T["audit.btn.exportCsv"]</button>
<button class="btn btn-secondary btn-sm" @onclick="ExportHtml">Export HTML</button> <button class="btn btn-secondary btn-sm" @onclick="ExportHtml">@T["audit.btn.exportHtml"]</button>
</div> </div>
<div class="data-table-wrap"> <div class="data-table-wrap">
<table class="data-table"> <table class="data-table">
<thead> <thead>
<tr> <tr>
<th>Library</th> <th>@T["stor.col.library"]</th>
<th>Site</th> <th>@T["stor.col.site"]</th>
<th class="num">Files</th> <th class="num">@T["stor.col.files"]</th>
<th class="num">Total (MB)</th> <th class="num">@T["stor.col.total_mb"]</th>
<th class="num">Versions (MB)</th> <th class="num">@T["stor.col.versions_mb"]</th>
<th>Last Modified</th> <th>@T["stor.col.lastmod"]</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -98,7 +99,7 @@
{ {
_error = string.Empty; _results = new(); _bySite = new(); _running = true; _error = string.Empty; _results = new(); _bySite = new(); _running = true;
_cts = new CancellationTokenSource(); _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); }); var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
try try
{ {
@@ -109,7 +110,7 @@
foreach (var site in _sites) foreach (var site in _sites)
{ {
_cts.Token.ThrowIfCancellationRequested(); _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); await InvokeAsync(StateHasChanged);
var nodes = await Elevation.RunAsync(async c => var nodes = await Elevation.RunAsync(async c =>
{ {
@@ -120,11 +121,11 @@
flat.AddRange(nodes); flat.AddRange(nodes);
} }
_bySite = bySite; _results = flat; _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), await Audit.LogAsync("StorageScan", Session.CurrentProfile?.Name ?? "", _sites.Select(s => s.Url),
$"{_results.Count} nodes; depth={_folderDepth} subsites={_includeSubsites} hidden={_includeHidden} recycle={_includeRecycleBin}"); $"{_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; } catch (Exception ex) { _error = ex.Message; }
finally { _running = false; await InvokeAsync(StateHasChanged); } finally { _running = false; await InvokeAsync(StateHasChanged); }
} }
+26 -25
View File
@@ -6,56 +6,57 @@
@inject IElevationCoordinator Elevation @inject IElevationCoordinator Elevation
@inject ITemplateService TemplateSvc @inject ITemplateService TemplateSvc
@inject SharepointToolbox.Web.Infrastructure.Persistence.TemplateRepository TemplateRepo @inject SharepointToolbox.Web.Infrastructure.Persistence.TemplateRepository TemplateRepo
@inject TranslationSource T
@rendermode InteractiveServer @rendermode InteractiveServer
<h1 class="page-title">Site Templates</h1> <h1 class="page-title">@T["templates.page.title"]</h1>
<p class="page-subtitle">Capture site structure and apply to new sites.</p> <p class="page-subtitle">@T["templates.page.subtitle"]</p>
@if (!Session.HasProfile) { <NoProfilePrompt /> return; } @if (!Session.HasProfile) { <NoProfilePrompt /> return; }
@if (UserContext.Role < UserRole.TechN1) { <WriteGuard /> return; } @if (UserContext.Role < UserRole.TechN1) { <WriteGuard /> return; }
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
<div class="card"> <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" /> <SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_captureSites" Single="true" />
<div class="form-group mt-8"> <div class="form-group mt-8">
<label class="form-label">Template Name</label> <label class="form-label">@T["templates.name"]</label>
<input class="form-input" @bind="_captureName" placeholder="My Template" /> <input class="form-input" @bind="_captureName" placeholder="@T["templates.name.placeholder"]" />
</div> </div>
<div class="flex-row" style="flex-wrap:wrap"> <div class="flex-row" style="flex-wrap:wrap">
<label><input type="checkbox" @bind="_capLibraries" /> Libraries</label> <label><input type="checkbox" @bind="_capLibraries" /> @T["templates.opt.libraries"]</label>
<label><input type="checkbox" @bind="_capFolders" /> Folders</label> <label><input type="checkbox" @bind="_capFolders" /> @T["templates.opt.folders"]</label>
<label><input type="checkbox" @bind="_capGroups" /> Permission groups</label> <label><input type="checkbox" @bind="_capGroups" /> @T["templates.opt.permissions"]</label>
</div> </div>
<button class="btn btn-primary mt-8" @onclick="CaptureTemplate" disabled="@_running"> <button class="btn btn-primary mt-8" @onclick="CaptureTemplate" disabled="@_running">
@(_running ? "Capturing" : "Capture") @(_running ? T["templates.btn.capturing"] : T["templates.btn.capture"])
</button> </button>
<ProgressPanel IsRunning="_running" StatusMessage="@_status" /> <ProgressPanel IsRunning="_running" StatusMessage="@_status" />
</div> </div>
<div class="card"> <div class="card">
<div class="card-title">Apply Template</div> <div class="card-title">@T["templates.apply"]</div>
@if (_selectedTemplate == null) @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 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"> <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" /> <input class="form-input" @bind="_newTitle" />
</div> </div>
<div class="form-group"> <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" /> <input class="form-input" @bind="_newAlias" />
</div> </div>
<div class="form-group"> <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" /> <input class="form-input" @bind="_adminUrl" placeholder="https://contoso-admin.sharepoint.com" />
</div> </div>
<button class="btn btn-primary" @onclick="ApplyTemplate" disabled="@_running"> <button class="btn btn-primary" @onclick="ApplyTemplate" disabled="@_running">
@(_running ? "Applying" : "Apply Template") @(_running ? T["templates.btn.applying"] : T["templates.apply"])
</button> </button>
} }
</div> </div>
@@ -65,21 +66,21 @@
@if (!string.IsNullOrEmpty(_successMsg)) { <div class="alert alert-success mt-8">@_successMsg</div> } @if (!string.IsNullOrEmpty(_successMsg)) { <div class="alert alert-success mt-8">@_successMsg</div> }
<div class="card" style="margin-top:16px"> <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) @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) @foreach (var t in _templates)
{ {
<div class="flex-row" style="padding:8px 0;border-bottom:1px solid var(--border)"> <div class="flex-row" style="padding:8px 0;border-bottom:1px solid var(--border)">
<div> <div>
<div style="font-weight:600">@t.Name</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>
<div class="spacer"></div> <div class="spacer"></div>
<button class="btn btn-secondary btn-sm" @onclick="() => _selectedTemplate = t">Use</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)">Delete</button> <button class="btn btn-danger btn-sm" @onclick="() => DeleteTemplate(t)">@T["templates.delete"]</button>
</div> </div>
} }
</div> </div>
@@ -116,9 +117,9 @@
template.Name = string.IsNullOrWhiteSpace(_captureName) ? $"Template-{DateTime.Now:yyyyMMdd}" : _captureName; template.Name = string.IsNullOrWhiteSpace(_captureName) ? $"Template-{DateTime.Now:yyyyMMdd}" : _captureName;
await TemplateRepo.SaveAsync(template); await TemplateRepo.SaveAsync(template);
_templates = (await TemplateRepo.GetAllAsync()).ToList(); _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; } catch (Exception ex) { _error = ex.Message; }
finally { _running = false; await InvokeAsync(StateHasChanged); } finally { _running = false; await InvokeAsync(StateHasChanged); }
} }
@@ -136,9 +137,9 @@
{ {
var ctx = await SessionMgr.GetOrCreateContextAsync(adminUrl, Session.CurrentProfile!, _cts.Token); var ctx = await SessionMgr.GetOrCreateContextAsync(adminUrl, Session.CurrentProfile!, _cts.Token);
var url = await TemplateSvc.ApplyTemplateAsync(ctx, _selectedTemplate, _newTitle, _newAlias, progress, _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; } catch (Exception ex) { _error = ex.Message; }
finally { _running = false; await InvokeAsync(StateHasChanged); } finally { _running = false; await InvokeAsync(StateHasChanged); }
} }
+27 -26
View File
@@ -8,32 +8,33 @@
@inject UserAccessHtmlExportService HtmlExport @inject UserAccessHtmlExportService HtmlExport
@inject WebExportService WebExport @inject WebExportService WebExport
@inject IAuditService Audit @inject IAuditService Audit
@inject TranslationSource T
@rendermode InteractiveServer @rendermode InteractiveServer
<h1 class="page-title">User Access Audit</h1> <h1 class="page-title">@T["tab.userAccessAudit"]</h1>
<p class="page-subtitle">Find all permissions for one or more users across multiple sites.</p> <p class="page-subtitle">@T["audit.subtitle"]</p>
@if (!Session.HasProfile) { <NoProfilePrompt /> return; } @if (!Session.HasProfile) { <NoProfilePrompt /> return; }
<div class="card"> <div class="card">
<div class="form-group"> <div class="form-group">
<div class="flex-row"> <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> <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"> <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> </button>
</div> </div>
@if (_directoryUsers.Count > 0) @if (_directoryUsers.Count > 0)
{ {
<div class="flex-row mt-8"> <div class="flex-row mt-8">
<input class="form-input" style="width:260px" @bind="_userFilter" @bind:event="oninput" placeholder="Filter by name or email…" /> <input class="form-input" style="width:260px" @bind="_userFilter" @bind:event="oninput" placeholder="@T["audit.ph.filterUsers"]" />
<span class="text-muted">@_selectedEmails.Count selected</span> <span class="text-muted">@string.Format(T["audit.lbl.selectedCount"], _selectedEmails.Count)</span>
<div class="spacer"></div> <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="SelectAllFiltered">@string.Format(T["audit.btn.selectAll"], FilteredUsers.Count())</button>
<button class="btn btn-link btn-sm" @onclick="ClearSelection">Clear</button> <button class="btn btn-link btn-sm" @onclick="ClearSelection">@T["settings.logo.clear"]</button>
</div> </div>
<div class="user-select-list"> <div class="user-select-list">
@foreach (var u in FilteredUsers.Take(500)) @foreach (var u in FilteredUsers.Take(500))
@@ -44,29 +45,29 @@
@onchange="e => ToggleUser(email, (bool)e.Value!)" /> @onchange="e => ToggleUser(email, (bool)e.Value!)" />
<span class="user-select-name">@u.DisplayName</span> <span class="user-select-name">@u.DisplayName</span>
<span class="text-muted">@email</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> </label>
} }
</div> </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> <textarea class="form-textarea" @bind="_users" placeholder="alice@contoso.com&#10;bob@contoso.com" rows="2"></textarea>
</div> </div>
<SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_sites" /> <SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_sites" />
<div class="form-row"> <div class="form-row">
<div class="form-group" style="display:flex;align-items:center;gap:16px;padding-top:20px"> <div class="form-group" style="display:flex;align-items:center;gap:16px;padding-top:20px">
<label><input type="checkbox" @bind="_includeInherited" /> Include inherited</label> <label><input type="checkbox" @bind="_includeInherited" /> @T["audit.chk.includeInherited"]</label>
<label><input type="checkbox" @bind="_scanFolders" /> Scan folders</label> <label><input type="checkbox" @bind="_scanFolders" /> @T["chk.scan.folders"]</label>
<label><input type="checkbox" @bind="_includeSubsites" /> Include subsites</label> <label><input type="checkbox" @bind="_includeSubsites" /> @T["chk.include.subsites"]</label>
</div> </div>
</div> </div>
<div class="flex-row mt-8"> <div class="flex-row mt-8">
<button class="btn btn-primary" @onclick="RunAudit" disabled="@_running"> <button class="btn btn-primary" @onclick="RunAudit" disabled="@_running">
@(_running ? "Auditing" : "Audit Users") @(_running ? T["audit.btn.auditing"] : T["audit.btn.auditUsers"])
</button> </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> </div>
<ProgressPanel IsRunning="_running" StatusMessage="@_status" Current="_current" Total="_total" /> <ProgressPanel IsRunning="_running" StatusMessage="@_status" Current="_current" Total="_total" />
</div> </div>
@@ -77,14 +78,14 @@
{ {
<div class="card"> <div class="card">
<div class="flex-row"> <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> <div class="spacer"></div>
<button class="btn btn-secondary btn-sm" @onclick="ExportCsv">Export CSV</button> <button class="btn btn-secondary btn-sm" @onclick="ExportCsv">@T["audit.btn.exportCsv"]</button>
<button class="btn btn-secondary btn-sm" @onclick="ExportHtml">Export HTML</button> <button class="btn btn-secondary btn-sm" @onclick="ExportHtml">@T["audit.btn.exportHtml"]</button>
</div> </div>
<div class="data-table-wrap"> <div class="data-table-wrap">
<table class="data-table"> <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> <tbody>
@foreach (var r in _results.Take(500)) @foreach (var r in _results.Take(500))
{ {
@@ -92,7 +93,7 @@
<td>@r.UserDisplayName</td> <td>@r.UserDisplayName</td>
<td>@r.SiteTitle</td> <td>@r.SiteTitle</td>
<td>@r.ObjectTitle <span class="text-muted">(@r.ObjectType)</span></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.AccessType</td>
<td>@r.GrantedThrough</td> <td>@r.GrantedThrough</td>
</tr> </tr>
@@ -100,7 +101,7 @@
</tbody> </tbody>
</table> </table>
</div> </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> </div>
} }
@@ -155,7 +156,7 @@
var userList = _selectedEmails var userList = _selectedEmails
.Concat(_users.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) .Concat(_users.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); .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(); var siteList = _sites.ToList();
if (!siteList.Any()) siteList.Add(new SiteInfo(Session.CurrentProfile!.TenantUrl, Session.CurrentProfile.Name)); 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); }); 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); var opts = new ScanOptions(_includeInherited, _scanFolders, 1, _includeSubsites);
_results = (await AuditSvc.AuditUsersAsync(SessionMgr, Session.CurrentProfile!, userList, siteList, opts, progress, _cts.Token)).ToList(); _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), await Audit.LogAsync("UserAccessAudit", Session.CurrentProfile?.Name ?? "", siteList.Select(s => s.Url),
$"{_results.Count} entries for {userList.Count} user(s)"); $"{_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; } catch (Exception ex) { _error = ex.Message; }
finally { _running = false; await InvokeAsync(StateHasChanged); } finally { _running = false; await InvokeAsync(StateHasChanged); }
} }
+11 -10
View File
@@ -3,18 +3,19 @@
@inject IUserSessionService Session @inject IUserSessionService Session
@inject IGraphUserDirectoryService GraphSvc @inject IGraphUserDirectoryService GraphSvc
@inject IAuditService Audit @inject IAuditService Audit
@inject TranslationSource T
@rendermode InteractiveServer @rendermode InteractiveServer
<h1 class="page-title">User Directory</h1> <h1 class="page-title">@T["directory.grp.browse"]</h1>
<p class="page-subtitle">Browse all tenant users via Microsoft Graph.</p> <p class="page-subtitle">@T["directory.subtitle"]</p>
@if (!Session.HasProfile) { <NoProfilePrompt /> return; } @if (!Session.HasProfile) { <NoProfilePrompt /> return; }
<div class="card"> <div class="card">
<div class="flex-row"> <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"> <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> </button>
</div> </div>
<ProgressPanel IsRunning="_running" StatusMessage="@_status" /> <ProgressPanel IsRunning="_running" StatusMessage="@_status" />
@@ -26,12 +27,12 @@
{ {
<div class="card"> <div class="card">
<div class="flex-row"> <div class="flex-row">
<div class="card-title">Users <span class="count-badge">@_users.Count</span></div> <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="Filter by name or email" /> <input class="form-input" style="width:260px" @bind="_filter" @bind:event="oninput" placeholder="@T["directory.filter.byNameEmail"]" />
</div> </div>
<div class="data-table-wrap"> <div class="data-table-wrap">
<table class="data-table"> <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> <tbody>
@foreach (var u in FilteredUsers.Take(500)) @foreach (var u in FilteredUsers.Take(500))
{ {
@@ -40,13 +41,13 @@
<td>@u.UserPrincipalName</td> <td>@u.UserPrincipalName</td>
<td>@u.Department</td> <td>@u.Department</td>
<td>@u.JobTitle</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> </tr>
} }
</tbody> </tbody>
</table> </table>
</div> </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> </div>
} }
@@ -67,7 +68,7 @@
try try
{ {
_users = (await GraphSvc.GetUsersAsync(Session.CurrentProfile!, _includeGuests, progress)).ToList(); _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>(), await Audit.LogAsync("UserDirectoryLoad", Session.CurrentProfile?.Name ?? "", Array.Empty<string>(),
$"{_users.Count} users; guests={_includeGuests}"); $"{_users.Count} users; guests={_includeGuests}");
} }
+8 -7
View File
@@ -7,9 +7,10 @@
@inject IVersionCleanupService VersionSvc @inject IVersionCleanupService VersionSvc
@inject VersionCleanupHtmlExportService HtmlExport @inject VersionCleanupHtmlExportService HtmlExport
@inject WebExportService WebExport @inject WebExportService WebExport
@inject TranslationSource T
@rendermode InteractiveServer @rendermode InteractiveServer
<h1 class="page-title">Version Cleanup</h1> <h1 class="page-title">@T["versions.page.title"]</h1>
@if (!Session.HasProfile) { <NoProfilePrompt /> return; } @if (!Session.HasProfile) { <NoProfilePrompt /> return; }
@if (UserContext.Role < UserRole.TechN1) { <WriteGuard /> return; } @if (UserContext.Role < UserRole.TechN1) { <WriteGuard /> return; }
@@ -18,13 +19,13 @@
<SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_sites" Single="true" /> <SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_sites" Single="true" />
<div class="flex-row mt-8"> <div class="flex-row mt-8">
<button class="btn btn-secondary" @onclick="LoadLibraries" disabled="@_loading"> <button class="btn btn-secondary" @onclick="LoadLibraries" disabled="@_loading">
@(_loading ? "Loading" : "Load Libraries") @(_loading ? T["versions.btn.loading"] : T["versions.btn.loadLibs"])
</button> </button>
</div> </div>
@if (_libraries.Count > 0) @if (_libraries.Count > 0)
{ {
<div class="form-group mt-8"> <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"> <div style="max-height:200px;overflow-y:auto;border:1px solid var(--border);border-radius:4px;padding:4px">
@foreach (var lib in _libraries) @foreach (var lib in _libraries)
{ {
@@ -38,18 +39,18 @@
} }
<div class="form-row mt-8"> <div class="form-row mt-8">
<div class="form-group" style="flex:0 0 auto"> <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" /> <input class="form-input" type="number" @bind="_keepLast" min="0" max="999" style="width:80px" />
</div> </div>
<div class="form-group" style="display:flex;align-items:center;padding-top:20px"> <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> </div>
<div class="flex-row mt-8"> <div class="flex-row mt-8">
<button class="btn btn-danger" @onclick="RunCleanup" disabled="@_running"> <button class="btn btn-danger" @onclick="RunCleanup" disabled="@_running">
@(_running ? "Cleaning" : "Delete Old Versions") @(_running ? T["versions.btn.cleaning"] : T["versions.btn.run"])
</button> </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> </div>
<ProgressPanel IsRunning="_running" StatusMessage="@_status" Current="_current" Total="_total" /> <ProgressPanel IsRunning="_running" StatusMessage="@_status" Current="_current" Total="_total" />
</div> </div>
+4 -3
View File
@@ -1,17 +1,18 @@
@* Recursive editor row for one folder in the visual builder. *@ @* Recursive editor row for one folder in the visual builder. *@
@using SharepointToolbox.Web.Core.Models @using SharepointToolbox.Web.Core.Models
@inject TranslationSource T
<div class="folder-node" style="margin-left:@(Depth > 1 ? "18px" : "0")"> <div class="folder-node" style="margin-left:@(Depth > 1 ? "18px" : "0")">
<div class="flex-row" style="gap:6px"> <div class="flex-row" style="gap:6px">
<span class="text-muted" style="font-family:monospace">📁</span> <span class="text-muted" style="font-family:monospace">📁</span>
<input class="form-input" style="width:auto;flex:1;min-width:160px" <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" /> @oninput="OnNameInput" />
@if (Depth < FolderNode.MaxDepth) @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> </div>
@foreach (var child in Node.Children) @foreach (var child in Node.Children)
+4 -3
View File
@@ -1,4 +1,5 @@
@inject ILibraryDiscoveryService LibraryDiscovery @inject ILibraryDiscoveryService LibraryDiscovery
@inject TranslationSource T
@* Library name field with a picker: type a title, or click Browse to load and @* Library name field with a picker: type a title, or click Browse to load and
choose from the libraries on the selected site. *@ choose from the libraries on the selected site. *@
@@ -13,7 +14,7 @@
<button type="button" class="btn btn-secondary" <button type="button" class="btn btn-secondary"
@onclick="Browse" @onclick="Browse"
disabled="@(Disabled || _loading)"> disabled="@(Disabled || _loading)">
@(_loading ? "Loading…" : "Browse") @(_loading ? T["librarypicker.loadingShort"] : T["librarypicker.browse"])
</button> </button>
</div> </div>
@@ -36,7 +37,7 @@
} }
@if (_libraries.Count == 0) @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> </div>
} }
@@ -65,7 +66,7 @@
private async Task Browse() private async Task Browse()
{ {
_error = string.Empty; _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. // Toggle closed if already showing the list for this site.
if (_open && _loadedForSite == SiteUrl) { _open = false; return; } if (_open && _loadedForSite == SiteUrl) { _open = false; return; }
+5 -4
View File
@@ -1,5 +1,6 @@
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using SharepointToolbox.Web.Core.Models @using SharepointToolbox.Web.Core.Models
@inject TranslationSource T
@* Reusable logo picker. Reads an image into a base64 LogoData (no disk/blob storage). *@ @* Reusable logo picker. Reads an image into a base64 LogoData (no disk/blob storage). *@
<div class="logo-upload"> <div class="logo-upload">
@@ -8,13 +9,13 @@
<div class="flex-row" style="gap:12px;align-items:center"> <div class="flex-row" style="gap:12px;align-items:center">
<img src="data:@Value.MimeType;base64,@Value.Base64" alt="" <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" /> 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> </div>
} }
else else
{ {
<InputFile OnChange="OnChange" accept="image/png,image/jpeg,image/svg+xml,image/gif" /> <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> } @if (!string.IsNullOrEmpty(_error)) { <div class="alert alert-error mt-8">@_error</div> }
</div> </div>
@@ -36,7 +37,7 @@
if (file.Size > MaxBytes) 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; return;
} }
@@ -51,7 +52,7 @@
} }
catch (Exception ex) 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. *@ @* Dropdown for choosing how multi-site reports are bundled on export. *@
@inject TranslationSource T
@if (Visible) @if (Visible)
{ {
<select class="form-select" style="width:auto;font-size:13px" value="@Value" @onchange="OnChange" <select class="form-select" style="width:auto;font-size:13px" value="@Value" @onchange="OnChange"
title="How to bundle reports when multiple sites are scanned"> title="@T["mergemode.tooltip"]">
<option value="@ReportMergeMode.SingleMerged">One document, no tabs</option> <option value="@ReportMergeMode.SingleMerged">@T["mergemode.opt.singleMerged"]</option>
<option value="@ReportMergeMode.SingleTabbed">One document, tabs (HTML)</option> <option value="@ReportMergeMode.SingleTabbed">@T["mergemode.opt.singleTabbed"]</option>
<option value="@ReportMergeMode.MultipleFiles">Multiple documents (ZIP)</option> <option value="@ReportMergeMode.MultipleFiles">@T["mergemode.opt.multipleFiles"]</option>
</select> </select>
} }
+5 -3
View File
@@ -1,5 +1,7 @@
@inject TranslationSource T
<div class="no-profile"> <div class="no-profile">
<h2>No profile selected</h2> <h2>@T["noprofile.heading"]</h2>
<p>Select or create a tenant profile to get started.</p> <p>@T["noprofile.body"]</p>
<a href="/profiles" class="btn btn-primary">Go to Profiles</a> <a href="/profiles" class="btn btn-primary">@T["noprofile.goto"]</a>
</div> </div>
+4 -3
View File
@@ -1,5 +1,6 @@
@inject IUserSessionService Session @inject IUserSessionService Session
@inject SharepointToolbox.Web.Infrastructure.Persistence.ProfileRepository ProfileRepo @inject SharepointToolbox.Web.Infrastructure.Persistence.ProfileRepository ProfileRepo
@inject TranslationSource T
@implements IDisposable @implements IDisposable
@using SharepointToolbox.Web.Core.Models @using SharepointToolbox.Web.Core.Models
@using SharepointToolbox.Web.Services.Session @using SharepointToolbox.Web.Services.Session
@@ -7,7 +8,7 @@
<div class="profile-selector"> <div class="profile-selector">
<button class="profile-selector-trigger @(Session.HasProfile ? "" : "unset")" @onclick="ToggleAsync"> <button class="profile-selector-trigger @(Session.HasProfile ? "" : "unset")" @onclick="ToggleAsync">
<span class="profile-selector-icon">🏢</span> <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> <span class="profile-selector-caret @(_open ? "open" : "")">▾</span>
</button> </button>
@@ -17,7 +18,7 @@
<div class="profile-selector-menu"> <div class="profile-selector-menu">
@if (_profiles.Count == 0) @if (_profiles.Count == 0)
{ {
<div class="profile-selector-empty">No profiles configured.</div> <div class="profile-selector-empty">@T["profile.selector.empty"]</div>
} }
else else
{ {
@@ -36,7 +37,7 @@
</button> </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>
} }
</div> </div>
@@ -2,6 +2,7 @@
@inject IUserSessionService Session @inject IUserSessionService Session
@inject ISessionManager SessionManager @inject ISessionManager SessionManager
@inject NavigationManager Nav @inject NavigationManager Nav
@inject TranslationSource T
@using SharepointToolbox.Web.Core.Models @using SharepointToolbox.Web.Core.Models
@using SharepointToolbox.Web.Services.Session @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-overlay" role="dialog" aria-modal="true" aria-labelledby="connect-modal-title">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-header"> <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"> <p class="text-muted">
Authenticate to access <strong>@Session.CurrentProfile?.Name</strong>. @T["connect.subtitle.prefix"] <strong>@Session.CurrentProfile?.Name</strong>.
Your session token is stored in your browser only — never saved to disk. @T["connect.token.note"]
</p> </p>
</div> </div>
@@ -23,14 +24,14 @@
} }
<div class="modal-footer"> <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"> <button class="btn btn-primary" @onclick="ConnectAsync" disabled="@_connecting">
@(_connecting ? "Redirecting" : "Connect via Microsoft") @(_connecting ? T["connect.redirecting"] : T["connect.button"])
</button> </button>
</div> </div>
<p class="text-muted" style="font-size:11px;margin-top:8px;text-align:right"> <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> </p>
</div> </div>
</div> </div>
@@ -54,7 +55,7 @@
private async Task ConnectAsync() private async Task ConnectAsync()
{ {
var profile = Session.CurrentProfile; 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; _connecting = true;
_error = string.Empty; _error = string.Empty;
+10 -9
View File
@@ -1,13 +1,14 @@
@inject ISiteDiscoveryService SiteDiscovery @inject ISiteDiscoveryService SiteDiscovery
@inject TranslationSource T
<div class="site-picker"> <div class="site-picker">
<div class="flex-row" style="gap:8px;align-items:flex-end"> <div class="flex-row" style="gap:8px;align-items:flex-end">
<div class="form-group" style="flex:1"> <div class="form-group" style="flex:1">
<label class="form-label">@(Single ? "Site" : "Sites")</label> <label class="form-label">@(Single ? T["sitepicker.label.site"] : T["sitepicker.label.sites"])</label>
<input class="form-input" @bind="_filter" @bind:event="oninput" placeholder="Filter loaded sites by name or URL…" /> <input class="form-input" @bind="_filter" @bind:event="oninput" placeholder="@T["sitepicker.ph.filter"]" />
</div> </div>
<button class="btn btn-secondary" @onclick="LoadSites" disabled="@_loading"> <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> </button>
</div> </div>
@@ -21,11 +22,11 @@
<div class="flex-row mt-8" style="gap:12px;align-items:center"> <div class="flex-row mt-8" style="gap:12px;align-items:center">
@if (!Single) @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="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>
<div class="site-picker-list" style="max-height:240px;overflow:auto;border:1px solid var(--border);border-radius:4px;padding:4px;margin-top:6px"> <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) @foreach (var s in Filtered)
@@ -45,13 +46,13 @@
} }
@if (!Filtered.Any()) @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> </div>
} }
else if (!_loading) 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> </div>
@@ -84,7 +85,7 @@
try try
{ {
_all = (await SiteDiscovery.SearchSitesAsync(Profile)).ToList(); _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; } catch (Exception ex) { _error = ex.Message; }
finally { _loading = false; } finally { _loading = false; }
+2 -1
View File
@@ -1,4 +1,5 @@
@inject IUserContextAccessor UserContext @inject IUserContextAccessor UserContext
@inject TranslationSource T
@using SharepointToolbox.Web.Core.Models @using SharepointToolbox.Web.Core.Models
@using SharepointToolbox.Web.Services.Session @using SharepointToolbox.Web.Services.Session
@@ -17,7 +18,7 @@ else
else else
{ {
<div class="alert alert-info"> <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> </div>
} }
} }
+1
View File
@@ -19,3 +19,4 @@
@using SharepointToolbox.Web.Components @using SharepointToolbox.Web.Components
@using SharepointToolbox.Web.Components.Shared @using SharepointToolbox.Web.Components.Shared
@using SharepointToolbox.Web.Core.Helpers @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 class AppSettings
{ {
public string DataFolder { get; set; } = string.Empty; public string DataFolder { get; set; } = string.Empty;
public string Lang { get; set; } = "en"; public string Lang { get; set; } = "fr";
public bool AutoTakeOwnership { get; set; } = false; public bool AutoTakeOwnership { get; set; } = true;
public string Theme { get; set; } = "System"; public string Theme { get; set; } = "System";
/// <summary>MSP logo shown top-left on exported reports. Null = none.</summary> /// <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
+27 -19
View File
@@ -4,8 +4,17 @@ using System.Resources;
namespace SharepointToolbox.Web.Localization; namespace SharepointToolbox.Web.Localization;
/// <summary> /// <summary>
/// Singleton string lookup backed by Strings.resx / Strings.fr.resx. /// String lookup backed by Strings.resx / Strings.fr.resx.
/// Web version: no INotifyPropertyChanged — culture switching is per-request. ///
/// 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> /// </summary>
public class TranslationSource public class TranslationSource
{ {
@@ -16,31 +25,30 @@ public class TranslationSource
// name ("SharepointToolbox.Strings") from before the project was renamed to // name ("SharepointToolbox.Strings") from before the project was renamed to
// *.Web, so its lookups throw MissingManifestResourceException. The embedded // *.Web, so its lookups throw MissingManifestResourceException. The embedded
// resource is "SharepointToolbox.Web.Localization.Strings". // resource is "SharepointToolbox.Web.Localization.Strings".
private ResourceManager _resourceManager = private readonly ResourceManager _resourceManager =
new ResourceManager("SharepointToolbox.Web.Localization.Strings", typeof(TranslationSource).Assembly); 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] => public string this[string key] =>
_resourceManager.GetString(key, _currentCulture) ?? $"[{key}]"; _resourceManager.GetString(key, Culture) ?? $"[{key}]";
public CultureInfo CurrentCulture /// <summary>Sets this instance's culture from a language code ("fr" → French, else English/invariant).</summary>
{ public void SetCulture(string lang) => Culture = Resolve(lang);
get => _currentCulture;
set
{
if (Equals(_currentCulture, value)) return;
_currentCulture = value;
}
}
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"), "fr" => new CultureInfo("fr"),
_ => CultureInfo.InvariantCulture _ => CultureInfo.InvariantCulture
}; };
} }
}
+3
View File
@@ -41,6 +41,9 @@ builder.Services.AddRazorComponents()
.AddInteractiveServerComponents(); .AddInteractiveServerComponents();
builder.Services.AddHttpContextAccessor(); builder.Services.AddHttpContextAccessor();
// Localization string source — Scoped: one per circuit, with its own explicit culture.
builder.Services.AddScoped<SharepointToolbox.Web.Localization.TranslationSource>();
// ── Authentication ──────────────────────────────────────────────────────────── // ── Authentication ────────────────────────────────────────────────────────────
if (builder.Environment.IsDevelopment()) if (builder.Environment.IsDevelopment())
{ {
+9 -7
View File
@@ -23,7 +23,15 @@ public class UserSessionService : IUserSessionService
{ {
_sessionManager = sessionManager; _sessionManager = sessionManager;
_settingsRepo = settingsRepo; _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) public void SetProfile(TenantProfile profile)
@@ -45,10 +53,4 @@ public class UserSessionService : IUserSessionService
_settings = settings; _settings = settings;
_ = _settingsRepo.SaveAsync(settings); _ = _settingsRepo.SaveAsync(settings);
} }
private async Task LoadSettingsAsync()
{
try { _settings = await _settingsRepo.LoadAsync(); }
catch { /* use defaults */ }
}
} }