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