Initial commit
This commit is contained in:
@@ -0,0 +1,28 @@
|
|||||||
|
**/.classpath
|
||||||
|
**/.dockerignore
|
||||||
|
**/.env
|
||||||
|
**/.git
|
||||||
|
**/.gitignore
|
||||||
|
**/.project
|
||||||
|
**/.settings
|
||||||
|
**/.toolstarget
|
||||||
|
**/.vs
|
||||||
|
**/.vscode
|
||||||
|
**/*.*proj.user
|
||||||
|
**/*.dbmdl
|
||||||
|
**/*.jfm
|
||||||
|
**/azds.yaml
|
||||||
|
**/bin
|
||||||
|
**/charts
|
||||||
|
**/docker-compose*
|
||||||
|
**/Dockerfile*
|
||||||
|
**/node_modules
|
||||||
|
**/npm-debug.log
|
||||||
|
**/obj
|
||||||
|
**/secrets.dev.yaml
|
||||||
|
**/values.dev.yaml
|
||||||
|
LICENSE
|
||||||
|
README
|
||||||
|
!**/.gitignore
|
||||||
|
!.git/HEAD
|
||||||
|
data/
|
||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
## .NET / C#
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
*.user
|
||||||
|
*.suo
|
||||||
|
*.userosscache
|
||||||
|
*.sln.docstates
|
||||||
|
.vs/
|
||||||
|
*.rsuser
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
## Build outputs
|
||||||
|
[Dd]ebug/
|
||||||
|
[Rr]elease/
|
||||||
|
[Pp]ublish/
|
||||||
|
[Oo]ut/
|
||||||
|
artifacts/
|
||||||
|
*.nupkg
|
||||||
|
*.snupkg
|
||||||
|
*.zip
|
||||||
|
|
||||||
|
## NuGet
|
||||||
|
*.nuget.props
|
||||||
|
*.nuget.targets
|
||||||
|
packages/
|
||||||
|
!**/packages/build/
|
||||||
|
project.lock.json
|
||||||
|
project.fragment.lock.json
|
||||||
|
|
||||||
|
## ASP.NET Core
|
||||||
|
appsettings.Development.json
|
||||||
|
appsettings.*.json
|
||||||
|
!appsettings.json
|
||||||
|
|
||||||
|
## Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
## User secrets / sensitive config
|
||||||
|
secrets.json
|
||||||
|
*.pfx
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
|
||||||
|
## OS
|
||||||
|
Thumbs.db
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
## Node (if any frontend assets)
|
||||||
|
node_modules/
|
||||||
|
wwwroot/dist/
|
||||||
|
wwwroot/lib/
|
||||||
|
|
||||||
|
## Test results
|
||||||
|
TestResults/
|
||||||
|
*.trx
|
||||||
|
*.coveragexml
|
||||||
|
coverage/
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<base href="/" />
|
||||||
|
<title>SharePoint Toolbox</title>
|
||||||
|
<link rel="stylesheet" href="app.css" />
|
||||||
|
<HeadOutlet />
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<Routes @rendermode="@RenderMode.InteractiveServer" />
|
||||||
|
<script src="_framework/blazor.web.js"></script>
|
||||||
|
<script src="js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
@inherits LayoutComponentBase
|
||||||
|
@inject IUserSessionService Session
|
||||||
|
@inject IUserContextAccessor UserContext
|
||||||
|
@inject ISessionCredentialStore CredStore
|
||||||
|
@inject ISessionManager SessionManager
|
||||||
|
@inject NavigationManager Nav
|
||||||
|
@inject SharepointToolbox.Web.Services.OAuth.IOAuthFlowCache OAuthCache
|
||||||
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
|
@using Microsoft.AspNetCore.WebUtilities
|
||||||
|
@using SharepointToolbox.Web.Core.Models
|
||||||
|
@using SharepointToolbox.Web.Services.Session
|
||||||
|
|
||||||
|
<AppInitializer />
|
||||||
|
|
||||||
|
<div class="app-layout">
|
||||||
|
<aside class="sidebar @(_sidebarCollapsed ? "collapsed" : "")">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<span class="logo-text">SP Toolbox</span>
|
||||||
|
<button class="toggle-btn" @onclick="ToggleSidebar">☰</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* User identity badge *@
|
||||||
|
<AuthorizeView>
|
||||||
|
<Authorized>
|
||||||
|
<div class="profile-badge" style="background:var(--surface-2);border-radius:6px;margin:8px;padding:8px">
|
||||||
|
<div style="font-size:12px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
|
||||||
|
@UserContext.DisplayName
|
||||||
|
</div>
|
||||||
|
<div style="font-size:11px;color:var(--text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
|
||||||
|
@UserContext.Email
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:4px">
|
||||||
|
<span class="chip @RoleChipClass(UserContext.Role)" style="font-size:10px">@UserContext.Role</span>
|
||||||
|
</div>
|
||||||
|
@if (_hasCredentials)
|
||||||
|
{
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</Authorized>
|
||||||
|
</AuthorizeView>
|
||||||
|
|
||||||
|
@if (Session.HasProfile)
|
||||||
|
{
|
||||||
|
<div class="profile-badge">
|
||||||
|
<span class="profile-icon">🏢</span>
|
||||||
|
<span class="profile-name">@Session.CurrentProfile!.Name</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<nav class="nav-menu">
|
||||||
|
<NavLink href="/" Match="NavLinkMatch.All" class="nav-item">
|
||||||
|
<span class="nav-icon">🏠</span><span class="nav-label">Home</span>
|
||||||
|
</NavLink>
|
||||||
|
@if (Session.HasProfile)
|
||||||
|
{
|
||||||
|
<NavLink href="/permissions" class="nav-item">
|
||||||
|
<span class="nav-icon">🔐</span><span class="nav-label">Permissions</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink href="/storage" class="nav-item">
|
||||||
|
<span class="nav-icon">💾</span><span class="nav-label">Storage</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink href="/search" class="nav-item">
|
||||||
|
<span class="nav-icon">🔍</span><span class="nav-label">Search</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink href="/duplicates" class="nav-item">
|
||||||
|
<span class="nav-icon">📋</span><span class="nav-label">Duplicates</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink href="/versions" class="nav-item">
|
||||||
|
<span class="nav-icon">🗂️</span><span class="nav-label">Version Cleanup</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink href="/transfer" class="nav-item">
|
||||||
|
<span class="nav-icon">📦</span><span class="nav-label">File Transfer</span>
|
||||||
|
</NavLink>
|
||||||
|
<div class="nav-divider">Bulk</div>
|
||||||
|
<NavLink href="/bulk-members" class="nav-item">
|
||||||
|
<span class="nav-icon">👥</span><span class="nav-label">Bulk Members</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink href="/bulk-sites" class="nav-item">
|
||||||
|
<span class="nav-icon">🌐</span><span class="nav-label">Bulk Sites</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink href="/folder-structure" class="nav-item">
|
||||||
|
<span class="nav-icon">📁</span><span class="nav-label">Folder Structure</span>
|
||||||
|
</NavLink>
|
||||||
|
<div class="nav-divider">Audit</div>
|
||||||
|
<NavLink href="/user-audit" class="nav-item">
|
||||||
|
<span class="nav-icon">👤</span><span class="nav-label">User Access Audit</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink href="/user-directory" class="nav-item">
|
||||||
|
<span class="nav-icon">📖</span><span class="nav-label">User Directory</span>
|
||||||
|
</NavLink>
|
||||||
|
<div class="nav-divider">Config</div>
|
||||||
|
<NavLink href="/templates" class="nav-item">
|
||||||
|
<span class="nav-icon">📐</span><span class="nav-label">Templates</span>
|
||||||
|
</NavLink>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Admin-only section *@
|
||||||
|
@if (UserContext.Role == UserRole.Admin)
|
||||||
|
{
|
||||||
|
<div class="nav-divider">Admin</div>
|
||||||
|
<NavLink href="/profiles" class="nav-item">
|
||||||
|
<span class="nav-icon">⚙️</span><span class="nav-label">Client Profiles</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink href="/admin/users" class="nav-item">
|
||||||
|
<span class="nav-icon">👥</span><span class="nav-label">User Management</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink href="/admin/audit" class="nav-item">
|
||||||
|
<span class="nav-icon">📋</span><span class="nav-label">Audit Logs</span>
|
||||||
|
</NavLink>
|
||||||
|
}
|
||||||
|
|
||||||
|
<NavLink href="/settings" class="nav-item">
|
||||||
|
<span class="nav-icon">🔧</span><span class="nav-label">Settings</span>
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
<AuthorizeView>
|
||||||
|
<Authorized>
|
||||||
|
<a href="/account/logout" class="nav-item" style="color:var(--text-muted)">
|
||||||
|
<span class="nav-icon">🚪</span><span class="nav-label">Logout</span>
|
||||||
|
</a>
|
||||||
|
</Authorized>
|
||||||
|
</AuthorizeView>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="content">
|
||||||
|
@if (UserContext.IsAuthenticated)
|
||||||
|
{
|
||||||
|
@Body
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div style="padding:2rem;color:var(--text-muted)">Loading…</div>
|
||||||
|
}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SessionCredentialsModal @ref="_credModal" OnConnected="OnCredentialsConnected" />
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private bool _sidebarCollapsed;
|
||||||
|
private bool _hasCredentials;
|
||||||
|
private string _credUsername = string.Empty;
|
||||||
|
private SessionCredentialsModal? _credModal;
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
Session.ProfileChanged += OnProfileChanged;
|
||||||
|
UserContext.Initialized += OnUserContextInitialized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnUserContextInitialized() => InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (!firstRender) return;
|
||||||
|
|
||||||
|
// Pick up token_key from OAuth callback redirect before checking credential state
|
||||||
|
await HandleOAuthCallbackAsync();
|
||||||
|
await RefreshCredentialState();
|
||||||
|
|
||||||
|
// Check for connect_error query param
|
||||||
|
var uri = new Uri(Nav.Uri);
|
||||||
|
var query = QueryHelpers.ParseQuery(uri.Query);
|
||||||
|
if (query.TryGetValue("connect_error", out var err) && !string.IsNullOrEmpty(err))
|
||||||
|
{
|
||||||
|
Nav.NavigateTo(uri.GetLeftPart(UriPartial.Path), replace: true);
|
||||||
|
if (_credModal is not null)
|
||||||
|
{
|
||||||
|
await _credModal.ShowAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If profile selected but no credentials → show modal
|
||||||
|
if (Session.HasProfile && !_hasCredentials && _credModal is not null)
|
||||||
|
await _credModal.ShowAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleOAuthCallbackAsync()
|
||||||
|
{
|
||||||
|
var uri = new Uri(Nav.Uri);
|
||||||
|
var query = QueryHelpers.ParseQuery(uri.Query);
|
||||||
|
|
||||||
|
if (!query.TryGetValue("token_key", out var tokenKey) || string.IsNullOrEmpty(tokenKey))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var tokens = OAuthCache.GetAndRemoveTokens(tokenKey!);
|
||||||
|
if (tokens is not null)
|
||||||
|
{
|
||||||
|
await CredStore.SetAsync(tokens);
|
||||||
|
await SessionManager.ClearAllAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip token_key from URL bar
|
||||||
|
Nav.NavigateTo(uri.GetLeftPart(UriPartial.Path), replace: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RefreshCredentialState()
|
||||||
|
{
|
||||||
|
var tokens = await CredStore.GetAsync();
|
||||||
|
_hasCredentials = tokens is not null && !string.IsNullOrEmpty(tokens.RefreshToken);
|
||||||
|
_credUsername = tokens?.UserPrincipalName ?? string.Empty;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReconnectAsync()
|
||||||
|
{
|
||||||
|
await CredStore.ClearAsync();
|
||||||
|
await SessionManager.ClearAllAsync();
|
||||||
|
_hasCredentials = false;
|
||||||
|
_credUsername = string.Empty;
|
||||||
|
if (Session.HasProfile && _credModal is not null)
|
||||||
|
await _credModal.ShowAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnCredentialsConnected()
|
||||||
|
{
|
||||||
|
await RefreshCredentialState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnProfileChanged()
|
||||||
|
{
|
||||||
|
InvokeAsync(async () =>
|
||||||
|
{
|
||||||
|
StateHasChanged();
|
||||||
|
// New profile selected → prompt for credentials if none
|
||||||
|
if (Session.HasProfile && !_hasCredentials && _credModal is not null)
|
||||||
|
await _credModal.ShowAsync();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ToggleSidebar() => _sidebarCollapsed = !_sidebarCollapsed;
|
||||||
|
|
||||||
|
private static string RoleChipClass(UserRole role) => role switch
|
||||||
|
{
|
||||||
|
UserRole.Admin => "chip-red",
|
||||||
|
UserRole.TechN1 => "chip-green",
|
||||||
|
_ => "chip-blue"
|
||||||
|
};
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Session.ProfileChanged -= OnProfileChanged;
|
||||||
|
UserContext.Initialized -= OnUserContextInitialized;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
@page "/admin/audit"
|
||||||
|
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||||
|
@inject IAuditService AuditService
|
||||||
|
@inject IUserContextAccessor UserContext
|
||||||
|
@inject NavigationManager Nav
|
||||||
|
@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>
|
||||||
|
|
||||||
|
@if (!UserContext.IsAuthenticated || UserContext.Role != UserRole.Admin)
|
||||||
|
{
|
||||||
|
<div class="alert alert-error">Access denied. Admin role required.</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_loading)
|
||||||
|
{
|
||||||
|
<div class="alert alert-info">Loading audit log...</div>
|
||||||
|
}
|
||||||
|
else if (_filtered.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="alert alert-info">No audit entries found.</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="card" style="overflow-x:auto">
|
||||||
|
<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>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var e in _filtered)
|
||||||
|
{
|
||||||
|
<tr style="border-bottom:1px solid var(--border)">
|
||||||
|
<td style="padding:6px;white-space:nowrap">@e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")</td>
|
||||||
|
<td style="padding:6px">@e.UserDisplay<br /><span class="text-muted" style="font-size:11px">@e.UserEmail</span></td>
|
||||||
|
<td style="padding:6px"><span class="chip @RoleChipClass(e.UserRole)">@e.UserRole</span></td>
|
||||||
|
<td style="padding:6px;font-weight:600">@e.Action</td>
|
||||||
|
<td style="padding:6px">@e.ClientName</td>
|
||||||
|
<td style="padding:6px">@string.Join(", ", e.Sites)</td>
|
||||||
|
<td style="padding:6px;color:var(--text-muted)">@e.Details</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted" style="margin-top:8px;font-size:12px">Showing @_filtered.Count of @_entries.Count entries</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<AuditEntry> _entries = new();
|
||||||
|
private List<AuditEntry> _filtered = new();
|
||||||
|
private bool _loading = true;
|
||||||
|
private string _filterUser = string.Empty;
|
||||||
|
private string _filterClient = string.Empty;
|
||||||
|
private string _filterAction = string.Empty;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
_entries = (await AuditService.GetAllAsync())
|
||||||
|
.OrderByDescending(e => e.Timestamp)
|
||||||
|
.ToList();
|
||||||
|
_loading = false;
|
||||||
|
ApplyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyFilters()
|
||||||
|
{
|
||||||
|
_filtered = _entries.Where(e =>
|
||||||
|
(string.IsNullOrEmpty(_filterUser) || e.UserEmail.Contains(_filterUser, StringComparison.OrdinalIgnoreCase) || e.UserDisplay.Contains(_filterUser, StringComparison.OrdinalIgnoreCase)) &&
|
||||||
|
(string.IsNullOrEmpty(_filterClient) || e.ClientName.Contains(_filterClient, StringComparison.OrdinalIgnoreCase)) &&
|
||||||
|
(string.IsNullOrEmpty(_filterAction) || e.Action.Contains(_filterAction, StringComparison.OrdinalIgnoreCase))
|
||||||
|
).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RoleChipClass(UserRole role) => role switch
|
||||||
|
{
|
||||||
|
UserRole.Admin => "chip-red",
|
||||||
|
UserRole.TechN1 => "chip-green",
|
||||||
|
_ => "chip-blue"
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
@page "/admin/users"
|
||||||
|
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||||
|
@inject IUserService UserService
|
||||||
|
@inject IUserContextAccessor UserContext
|
||||||
|
@inject NavigationManager Nav
|
||||||
|
@rendermode InteractiveServer
|
||||||
|
@using SharepointToolbox.Web.Core.Models
|
||||||
|
@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. Auto-provisioned on first OIDC login.</p>
|
||||||
|
|
||||||
|
@if (!UserContext.IsAuthenticated || UserContext.Role != UserRole.Admin)
|
||||||
|
{
|
||||||
|
<div class="alert alert-error">Access denied. Admin role required.</div>
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(_message))
|
||||||
|
{
|
||||||
|
<div class="alert @(_isError ? "alert-error" : "alert-info")">@_message</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (_users.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="alert alert-info">No users provisioned yet.</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="card" style="overflow-x:auto">
|
||||||
|
<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">Role</th>
|
||||||
|
<th style="text-align:left;padding:8px">Last Login</th>
|
||||||
|
<th style="text-align:left;padding:8px">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var user in _users)
|
||||||
|
{
|
||||||
|
<tr style="border-bottom:1px solid var(--border)">
|
||||||
|
<td style="padding:8px">@user.DisplayName</td>
|
||||||
|
<td style="padding:8px">@user.Email</td>
|
||||||
|
<td style="padding:8px">
|
||||||
|
<select class="form-input" style="width:130px"
|
||||||
|
value="@user.Role"
|
||||||
|
@onchange="e => OnRoleChange(user, e)"
|
||||||
|
disabled="@(user.Email == UserContext.Email)">
|
||||||
|
@foreach (var role in Enum.GetValues<UserRole>())
|
||||||
|
{
|
||||||
|
<option value="@role" selected="@(user.Role == role)">@role</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td style="padding:8px">@(user.LastLogin?.ToString("yyyy-MM-dd HH:mm") ?? "Never")</td>
|
||||||
|
<td style="padding:8px">
|
||||||
|
@if (user.Email != UserContext.Email)
|
||||||
|
{
|
||||||
|
<button class="btn btn-danger btn-sm" @onclick="() => DeleteUserAsync(user)">Remove</button>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="chip chip-green">You</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<AppUser> _users = new();
|
||||||
|
private string _message = string.Empty;
|
||||||
|
private bool _isError;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
_users = (await UserService.GetAllAsync()).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnRoleChange(AppUser user, ChangeEventArgs e)
|
||||||
|
{
|
||||||
|
if (!Enum.TryParse<UserRole>(e.Value?.ToString(), out var newRole)) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await UserService.UpdateRoleAsync(user.Id, newRole);
|
||||||
|
user.Role = newRole;
|
||||||
|
_message = $"Role updated for {user.DisplayName}.";
|
||||||
|
_isError = false;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_message = $"Error: {ex.Message}";
|
||||||
|
_isError = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteUserAsync(AppUser user)
|
||||||
|
{
|
||||||
|
await UserService.DeleteAsync(user.Id);
|
||||||
|
_users.Remove(user);
|
||||||
|
_message = $"User {user.DisplayName} removed.";
|
||||||
|
_isError = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
@page "/bulk-members"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@inject IUserSessionService Session
|
||||||
|
@inject IUserContextAccessor UserContext
|
||||||
|
@inject ISessionManager SessionMgr
|
||||||
|
@inject IBulkMemberService BulkSvc
|
||||||
|
@inject ICsvValidationService CsvValidation
|
||||||
|
@inject BulkResultCsvExportService ExportSvc
|
||||||
|
@inject WebExportService WebExport
|
||||||
|
@rendermode InteractiveServer
|
||||||
|
|
||||||
|
<h1 class="page-title">Bulk Members</h1>
|
||||||
|
<p class="page-subtitle">Add users to SharePoint groups from a CSV file.</p>
|
||||||
|
|
||||||
|
@if (!Session.HasProfile) { <NoProfilePrompt /> return; }
|
||||||
|
@if (UserContext.Role < UserRole.TechN1) { <WriteGuard /> return; }
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Site URL</label>
|
||||||
|
<input class="form-input" @bind="_siteUrl" placeholder="@Session.CurrentProfile!.TenantUrl" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">CSV File (GroupName, GroupUrl, Email, Role)</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.
|
||||||
|
</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>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var row in _rows.Take(50))
|
||||||
|
{
|
||||||
|
<tr class="@(row.IsValid ? "val-valid" : "val-error")">
|
||||||
|
<td>@(row.Record?.GroupName ?? "—")</td>
|
||||||
|
<td>@(row.Record?.Email ?? "—")</td>
|
||||||
|
<td>@(row.Record?.Role ?? "—")</td>
|
||||||
|
<td>@(row.IsValid ? "✓" : string.Join("; ", row.Errors))</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<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")
|
||||||
|
</button>
|
||||||
|
@if (_running) { <button class="btn btn-secondary" @onclick="Cancel">Cancel</button> }
|
||||||
|
</div>
|
||||||
|
<ProgressPanel IsRunning="_running" StatusMessage="_status" Current="_current" Total="_total" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(_error)) { <div class="alert alert-error">@_error</div> }
|
||||||
|
|
||||||
|
@if (_summary != null)
|
||||||
|
{
|
||||||
|
<div class="card">
|
||||||
|
<div class="alert @(_summary.HasFailures ? "alert-warn" : "alert-success")">
|
||||||
|
Processed: @_summary.SuccessCount / @_summary.TotalCount. Failures: @_summary.FailedCount
|
||||||
|
</div>
|
||||||
|
@if (_summary.HasFailures)
|
||||||
|
{
|
||||||
|
<button class="btn btn-secondary btn-sm mt-8" @onclick="ExportErrors">Export Errors CSV</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private string _siteUrl = string.Empty;
|
||||||
|
private List<CsvValidationRow<BulkMemberRow>> _rows = new();
|
||||||
|
private bool _running; private string _status = string.Empty, _error = string.Empty;
|
||||||
|
private int _current, _total;
|
||||||
|
private BulkOperationSummary<BulkMemberRow>? _summary;
|
||||||
|
private CancellationTokenSource? _cts;
|
||||||
|
|
||||||
|
private async Task LoadFile(InputFileChangeEventArgs e)
|
||||||
|
{
|
||||||
|
_rows.Clear();
|
||||||
|
var file = e.File;
|
||||||
|
using var stream = file.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024);
|
||||||
|
_rows = CsvValidation.ParseAndValidateMembers(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunBulk()
|
||||||
|
{
|
||||||
|
_error = string.Empty; _summary = null; _running = true;
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
var validRows = _rows.Where(r => r.IsValid && r.Record != null).Select(r => r.Record!).ToList();
|
||||||
|
var siteUrl = string.IsNullOrWhiteSpace(_siteUrl) ? Session.CurrentProfile!.TenantUrl : _siteUrl.Trim();
|
||||||
|
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, _cts.Token);
|
||||||
|
_summary = await BulkSvc.AddMembersAsync(ctx, Session.CurrentProfile!, validRows, progress, _cts.Token);
|
||||||
|
_status = $"Complete: {_summary.SuccessCount} added, {_summary.FailedCount} failed.";
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { _status = "Cancelled."; }
|
||||||
|
catch (Exception ex) { _error = ex.Message; }
|
||||||
|
finally { _running = false; await InvokeAsync(StateHasChanged); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Cancel() => _cts?.Cancel();
|
||||||
|
private async Task ExportErrors()
|
||||||
|
{
|
||||||
|
if (_summary == null) return;
|
||||||
|
var csv = ExportSvc.BuildFailedItemsCsv(_summary.Results.ToList());
|
||||||
|
await WebExport.DownloadCsvAsync(csv, $"bulk_members_errors_{DateTime.Now:yyyyMMdd_HHmmss}.csv");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
@page "/bulk-sites"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@inject IUserSessionService Session
|
||||||
|
@inject IUserContextAccessor UserContext
|
||||||
|
@inject ISessionManager SessionMgr
|
||||||
|
@inject IBulkSiteService BulkSvc
|
||||||
|
@inject ICsvValidationService CsvValidation
|
||||||
|
@inject BulkResultCsvExportService ExportSvc
|
||||||
|
@inject WebExportService WebExport
|
||||||
|
@rendermode InteractiveServer
|
||||||
|
|
||||||
|
<h1 class="page-title">Bulk Site Creation</h1>
|
||||||
|
<p class="page-subtitle">Create multiple SharePoint sites from a CSV file.</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>
|
||||||
|
<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>
|
||||||
|
<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="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>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var row in _rows.Take(50))
|
||||||
|
{
|
||||||
|
<tr class="@(row.IsValid ? "val-valid" : "val-error")">
|
||||||
|
<td>@(row.Record?.Name ?? "—")</td>
|
||||||
|
<td>@(row.Record?.Type ?? "—")</td>
|
||||||
|
<td>@(row.Record?.Alias ?? "—")</td>
|
||||||
|
<td>@(row.IsValid ? "✓" : string.Join("; ", row.Errors))</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<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")
|
||||||
|
</button>
|
||||||
|
@if (_running) { <button class="btn btn-secondary" @onclick="Cancel">Cancel</button> }
|
||||||
|
</div>
|
||||||
|
<ProgressPanel IsRunning="_running" StatusMessage="_status" Current="_current" Total="_total" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(_error)) { <div class="alert alert-error">@_error</div> }
|
||||||
|
|
||||||
|
@if (_summary != null)
|
||||||
|
{
|
||||||
|
<div class="card">
|
||||||
|
<div class="alert @(_summary.HasFailures ? "alert-warn" : "alert-success")">
|
||||||
|
Created: @_summary.SuccessCount / @_summary.TotalCount. Failures: @_summary.FailedCount
|
||||||
|
</div>
|
||||||
|
@if (_summary.HasFailures)
|
||||||
|
{
|
||||||
|
<button class="btn btn-secondary btn-sm mt-8" @onclick="ExportErrors">Export Errors CSV</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private string _adminUrl = string.Empty;
|
||||||
|
private List<CsvValidationRow<BulkSiteRow>> _rows = new();
|
||||||
|
private bool _running; private string _status = string.Empty, _error = string.Empty;
|
||||||
|
private int _current, _total;
|
||||||
|
private BulkOperationSummary<BulkSiteRow>? _summary;
|
||||||
|
private CancellationTokenSource? _cts;
|
||||||
|
|
||||||
|
private async Task LoadFile(InputFileChangeEventArgs e)
|
||||||
|
{
|
||||||
|
_rows.Clear();
|
||||||
|
using var stream = e.File.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024);
|
||||||
|
_rows = CsvValidation.ParseAndValidateSites(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunBulk()
|
||||||
|
{
|
||||||
|
_error = string.Empty; _summary = null; _running = true;
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
var validRows = _rows.Where(r => r.IsValid && r.Record != null).Select(r => r.Record!).ToList();
|
||||||
|
var adminUrl = string.IsNullOrWhiteSpace(_adminUrl)
|
||||||
|
? Session.CurrentProfile!.TenantUrl.Replace(".sharepoint.com", "-admin.sharepoint.com")
|
||||||
|
: _adminUrl.Trim();
|
||||||
|
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
|
||||||
|
try
|
||||||
|
{
|
||||||
|
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.";
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { _status = "Cancelled."; }
|
||||||
|
catch (Exception ex) { _error = ex.Message; }
|
||||||
|
finally { _running = false; await InvokeAsync(StateHasChanged); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Cancel() => _cts?.Cancel();
|
||||||
|
private async Task ExportErrors()
|
||||||
|
{
|
||||||
|
if (_summary == null) return;
|
||||||
|
var csv = ExportSvc.BuildFailedItemsCsv(_summary.Results.ToList());
|
||||||
|
await WebExport.DownloadCsvAsync(csv, $"bulk_sites_errors_{DateTime.Now:yyyyMMdd_HHmmss}.csv");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
@page "/duplicates"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@inject IUserSessionService Session
|
||||||
|
@inject ISessionManager SessionMgr
|
||||||
|
@inject IDuplicatesService DupSvc
|
||||||
|
@inject DuplicatesCsvExportService CsvExport
|
||||||
|
@inject DuplicatesHtmlExportService HtmlExport
|
||||||
|
@inject WebExportService WebExport
|
||||||
|
@rendermode InteractiveServer
|
||||||
|
|
||||||
|
<h1 class="page-title">Duplicate Detection</h1>
|
||||||
|
|
||||||
|
@if (!Session.HasProfile) { <NoProfilePrompt /> return; }
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Site URL</label>
|
||||||
|
<input class="form-input" @bind="_siteUrl" placeholder="@Session.CurrentProfile!.TenantUrl" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Mode</label>
|
||||||
|
<select class="form-select" @bind="_mode" style="width:120px">
|
||||||
|
<option value="Files">Files</option>
|
||||||
|
<option value="Folders">Folders</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Library (optional)</label>
|
||||||
|
<input class="form-input" @bind="_library" />
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
@if (_mode == "Folders")
|
||||||
|
{
|
||||||
|
<label><input type="checkbox" @bind="_matchFolderCount" /> Match subfolder count</label>
|
||||||
|
<label><input type="checkbox" @bind="_matchFileCount" /> Match file count</label>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="flex-row mt-8">
|
||||||
|
<button class="btn btn-primary" @onclick="RunScan" disabled="@_running">
|
||||||
|
@(_running ? "Scanning…" : "Find Duplicates")
|
||||||
|
</button>
|
||||||
|
@if (_running) { <button class="btn btn-secondary" @onclick="Cancel">Cancel</button> }
|
||||||
|
</div>
|
||||||
|
<ProgressPanel IsRunning="_running" StatusMessage="_status" Current="_current" Total="_total" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(_error)) { <div class="alert alert-error">@_error</div> }
|
||||||
|
|
||||||
|
@if (_results.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="card">
|
||||||
|
<div class="flex-row">
|
||||||
|
<div class="card-title">Duplicate Groups <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>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
@foreach (var item in g.Items)
|
||||||
|
{
|
||||||
|
<div style="padding:4px 12px;font-size:12px;border-top:1px solid var(--border)">
|
||||||
|
<span style="color:var(--text-muted)">@item.Library</span> › @item.Path
|
||||||
|
@if (item.SizeBytes.HasValue) { <span class="text-muted"> (@((item.SizeBytes.Value/1024.0).ToString("F1")) KB)</span> }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (_results.Count > 100) { <div class="text-muted mt-8">Showing first 100 groups. Export for all.</div> }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private string _siteUrl = string.Empty, _library = string.Empty, _mode = "Files";
|
||||||
|
private bool _matchSize = true, _matchCreated, _matchModified, _matchFolderCount, _matchFileCount;
|
||||||
|
private bool _running; private string _status = string.Empty, _error = string.Empty;
|
||||||
|
private int _current, _total;
|
||||||
|
private List<DuplicateGroup> _results = new();
|
||||||
|
private CancellationTokenSource? _cts;
|
||||||
|
|
||||||
|
private async Task RunScan()
|
||||||
|
{
|
||||||
|
_error = string.Empty; _results.Clear(); _running = true;
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
var siteUrl = string.IsNullOrWhiteSpace(_siteUrl) ? Session.CurrentProfile!.TenantUrl : _siteUrl.Trim();
|
||||||
|
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, _cts.Token);
|
||||||
|
var opts = new DuplicateScanOptions(_mode, _matchSize, _matchCreated, _matchModified, _matchFolderCount, _matchFileCount, Library: _library.TrimOrNull());
|
||||||
|
_results = (await DupSvc.ScanDuplicatesAsync(ctx, opts, progress, _cts.Token)).ToList();
|
||||||
|
_status = $"Found {_results.Count} duplicate groups.";
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { _status = "Cancelled."; }
|
||||||
|
catch (Exception ex) { _error = ex.Message; }
|
||||||
|
finally { _running = false; await InvokeAsync(StateHasChanged); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Cancel() => _cts?.Cancel();
|
||||||
|
private async Task ExportCsv() { await WebExport.DownloadCsvAsync(CsvExport.BuildCsv(_results), $"duplicates_{DateTime.Now:yyyyMMdd_HHmmss}.csv"); }
|
||||||
|
private async Task ExportHtml() { await WebExport.DownloadHtmlAsync(HtmlExport.BuildHtml(_results), $"duplicates_{DateTime.Now:yyyyMMdd_HHmmss}.html"); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
@page "/transfer"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@inject IUserSessionService Session
|
||||||
|
@inject IUserContextAccessor UserContext
|
||||||
|
@inject ISessionManager SessionMgr
|
||||||
|
@inject IFileTransferService TransferSvc
|
||||||
|
@rendermode InteractiveServer
|
||||||
|
|
||||||
|
<h1 class="page-title">File Transfer</h1>
|
||||||
|
|
||||||
|
@if (!Session.HasProfile) { <NoProfilePrompt /> return; }
|
||||||
|
@if (UserContext.Role < UserRole.TechN1) { <WriteGuard /> return; }
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Source</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Source Site URL</label>
|
||||||
|
<input class="form-input" @bind="_srcSiteUrl" placeholder="@Session.CurrentProfile!.TenantUrl" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Source Library</label>
|
||||||
|
<input class="form-input" @bind="_srcLibrary" placeholder="Shared Documents" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Source Folder (optional)</label>
|
||||||
|
<input class="form-input" @bind="_srcFolder" placeholder="SubFolder/Path" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Destination</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Destination Site URL</label>
|
||||||
|
<input class="form-input" @bind="_dstSiteUrl" placeholder="@Session.CurrentProfile!.TenantUrl" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Destination Library</label>
|
||||||
|
<input class="form-input" @bind="_dstLibrary" placeholder="Shared Documents" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Destination Folder (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>
|
||||||
|
<select class="form-select" @bind="_mode" style="width:100px">
|
||||||
|
<option value="Copy">Copy</option>
|
||||||
|
<option value="Move">Move</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Conflict Policy</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>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-row mt-8">
|
||||||
|
<button class="btn btn-primary" @onclick="RunTransfer" disabled="@_running">
|
||||||
|
@(_running ? "Transferring…" : "Start Transfer")
|
||||||
|
</button>
|
||||||
|
@if (_running) { <button class="btn btn-secondary" @onclick="Cancel">Cancel</button> }
|
||||||
|
</div>
|
||||||
|
<ProgressPanel IsRunning="_running" StatusMessage="_status" Current="_current" Total="_total" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(_error)) { <div class="alert alert-error">@_error</div> }
|
||||||
|
|
||||||
|
@if (_summary != null)
|
||||||
|
{
|
||||||
|
<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> }
|
||||||
|
</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>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var f in _summary.FailedItems)
|
||||||
|
{
|
||||||
|
<tr><td>@f.Item</td><td style="color:var(--danger)">@f.ErrorMessage</td></tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private string _srcSiteUrl = string.Empty, _srcLibrary = string.Empty, _srcFolder = string.Empty;
|
||||||
|
private string _dstSiteUrl = string.Empty, _dstLibrary = string.Empty, _dstFolder = string.Empty;
|
||||||
|
private string _mode = "Copy", _conflict = "Skip";
|
||||||
|
private bool _includeSourceFolder;
|
||||||
|
private bool _running; private string _status = string.Empty, _error = string.Empty;
|
||||||
|
private int _current, _total;
|
||||||
|
private BulkOperationSummary<string>? _summary;
|
||||||
|
private CancellationTokenSource? _cts;
|
||||||
|
|
||||||
|
private async Task RunTransfer()
|
||||||
|
{
|
||||||
|
_error = string.Empty; _summary = null; _running = true;
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var srcUrl = string.IsNullOrWhiteSpace(_srcSiteUrl) ? Session.CurrentProfile!.TenantUrl : _srcSiteUrl.Trim();
|
||||||
|
var dstUrl = string.IsNullOrWhiteSpace(_dstSiteUrl) ? Session.CurrentProfile!.TenantUrl : _dstSiteUrl.Trim();
|
||||||
|
var srcCtx = await SessionMgr.GetOrCreateContextAsync(srcUrl, Session.CurrentProfile!, _cts.Token);
|
||||||
|
var dstCtx = await SessionMgr.GetOrCreateContextAsync(dstUrl, Session.CurrentProfile!, _cts.Token);
|
||||||
|
var job = new TransferJob
|
||||||
|
{
|
||||||
|
SourceSiteUrl = srcUrl, SourceLibrary = _srcLibrary, SourceFolderPath = _srcFolder,
|
||||||
|
DestinationSiteUrl = dstUrl, DestinationLibrary = _dstLibrary, DestinationFolderPath = _dstFolder,
|
||||||
|
Mode = _mode == "Move" ? TransferMode.Move : TransferMode.Copy,
|
||||||
|
ConflictPolicy = _conflict == "Overwrite" ? ConflictPolicy.Overwrite : _conflict == "Rename" ? ConflictPolicy.Rename : ConflictPolicy.Skip,
|
||||||
|
IncludeSourceFolder = _includeSourceFolder
|
||||||
|
};
|
||||||
|
_summary = await TransferSvc.TransferAsync(srcCtx, dstCtx, job, progress, _cts.Token);
|
||||||
|
_status = $"Complete: {_summary.SuccessCount} transferred.";
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { _status = "Cancelled."; }
|
||||||
|
catch (Exception ex) { _error = ex.Message; }
|
||||||
|
finally { _running = false; await InvokeAsync(StateHasChanged); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Cancel() => _cts?.Cancel();
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
@page "/folder-structure"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@inject IUserSessionService Session
|
||||||
|
@inject IUserContextAccessor UserContext
|
||||||
|
@inject ISessionManager SessionMgr
|
||||||
|
@inject IFolderStructureService FolderSvc
|
||||||
|
@inject ICsvValidationService CsvValidation
|
||||||
|
@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>
|
||||||
|
|
||||||
|
@if (!Session.HasProfile) { <NoProfilePrompt /> return; }
|
||||||
|
@if (UserContext.Role < UserRole.TechN1) { <WriteGuard /> return; }
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Site URL</label>
|
||||||
|
<input class="form-input" @bind="_siteUrl" placeholder="@Session.CurrentProfile!.TenantUrl" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Library Title</label>
|
||||||
|
<input class="form-input" @bind="_libraryTitle" placeholder="Shared Documents" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">CSV File (Level1, Level2, Level3, Level4)</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.</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")
|
||||||
|
</button>
|
||||||
|
@if (_running) { <button class="btn btn-secondary" @onclick="Cancel">Cancel</button> }
|
||||||
|
</div>
|
||||||
|
<ProgressPanel IsRunning="_running" StatusMessage="_status" Current="_current" Total="_total" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(_error)) { <div class="alert alert-error">@_error</div> }
|
||||||
|
|
||||||
|
@if (_summary != null)
|
||||||
|
{
|
||||||
|
<div class="card">
|
||||||
|
<div class="alert @(_summary.HasFailures ? "alert-warn" : "alert-success")">
|
||||||
|
Created: @_summary.SuccessCount folders. Failures: @_summary.FailedCount
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private string _siteUrl = string.Empty, _libraryTitle = string.Empty;
|
||||||
|
private List<CsvValidationRow<FolderStructureRow>> _rows = new();
|
||||||
|
private bool _running; private string _status = string.Empty, _error = string.Empty;
|
||||||
|
private int _current, _total;
|
||||||
|
private BulkOperationSummary<string>? _summary;
|
||||||
|
private CancellationTokenSource? _cts;
|
||||||
|
|
||||||
|
private async Task LoadFile(InputFileChangeEventArgs e)
|
||||||
|
{
|
||||||
|
_rows.Clear();
|
||||||
|
using var stream = e.File.OpenReadStream(maxAllowedSize: 5 * 1024 * 1024);
|
||||||
|
_rows = CsvValidation.ParseAndValidateFolders(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunCreate()
|
||||||
|
{
|
||||||
|
_error = string.Empty; _summary = null; _running = true;
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
var validRows = _rows.Where(r => r.IsValid && r.Record != null).Select(r => r.Record!).ToList();
|
||||||
|
var siteUrl = string.IsNullOrWhiteSpace(_siteUrl) ? Session.CurrentProfile!.TenantUrl : _siteUrl.Trim();
|
||||||
|
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, _cts.Token);
|
||||||
|
_summary = await FolderSvc.CreateFoldersAsync(ctx, _libraryTitle, validRows, progress, _cts.Token);
|
||||||
|
_status = $"Complete: {_summary.SuccessCount} folders created.";
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { _status = "Cancelled."; }
|
||||||
|
catch (Exception ex) { _error = ex.Message; }
|
||||||
|
finally { _running = false; await InvokeAsync(StateHasChanged); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Cancel() => _cts?.Cancel();
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
@page "/"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@inject IUserSessionService Session
|
||||||
|
@rendermode InteractiveServer
|
||||||
|
|
||||||
|
<h1 class="page-title">SharePoint Toolbox</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>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Connected: @Session.CurrentProfile!.Name</div>
|
||||||
|
<p>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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:16px;margin-top:16px">
|
||||||
|
@foreach (var feature in _features)
|
||||||
|
{
|
||||||
|
<a href="@feature.Href" style="text-decoration:none">
|
||||||
|
<div class="card" style="cursor:pointer;transition:box-shadow .15s" onmouseover="this.style.boxShadow='0 2px 8px rgba(0,120,212,.2)'" onmouseout="this.style.boxShadow=''">
|
||||||
|
<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>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private readonly (string Href, string Icon, string Title, string Description)[] _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"),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
@page "/permissions"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@inject IUserSessionService Session
|
||||||
|
@inject ISessionManager SessionMgr
|
||||||
|
@inject IPermissionsService PermSvc
|
||||||
|
@inject CsvExportService CsvExport
|
||||||
|
@inject HtmlExportService HtmlExport
|
||||||
|
@inject WebExportService WebExport
|
||||||
|
@rendermode InteractiveServer
|
||||||
|
|
||||||
|
<h1 class="page-title">Permissions Audit</h1>
|
||||||
|
|
||||||
|
@if (!Session.HasProfile) { <NoProfilePrompt /> return; }
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Scan Options</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Site URL</label>
|
||||||
|
<input class="form-input" @bind="_siteUrl" placeholder="@Session.CurrentProfile!.TenantUrl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group" style="flex:0 0 auto">
|
||||||
|
<label class="form-label">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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-row mt-8">
|
||||||
|
<button class="btn btn-primary" @onclick="RunScan" disabled="@_running">
|
||||||
|
@(_running ? "Scanning…" : "Scan Site")
|
||||||
|
</button>
|
||||||
|
@if (_running)
|
||||||
|
{
|
||||||
|
<button class="btn btn-secondary" @onclick="Cancel">Cancel</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<ProgressPanel IsRunning="_running" StatusMessage="_status" Current="_current" Total="_total" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(_error))
|
||||||
|
{
|
||||||
|
<div class="alert alert-error">@_error</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (_results.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="card">
|
||||||
|
<div class="flex-row">
|
||||||
|
<div class="card-title">Results <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>
|
||||||
|
</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>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var r in _results.Take(500))
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>@r.ObjectType</td>
|
||||||
|
<td title="@r.Url">@r.Title</td>
|
||||||
|
<td>@r.Users</td>
|
||||||
|
<td>@r.PermissionLevels</td>
|
||||||
|
<td>@r.GrantedThrough</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@if (_results.Count > 500)
|
||||||
|
{
|
||||||
|
<div class="text-muted mt-8">Showing first 500 of @_results.Count rows. Export for full results.</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private string _siteUrl = string.Empty;
|
||||||
|
private bool _includeInherited, _includeSubsites;
|
||||||
|
private bool _scanFolders = true;
|
||||||
|
private int _folderDepth = 1;
|
||||||
|
private bool _running;
|
||||||
|
private string _status = string.Empty;
|
||||||
|
private string _error = string.Empty;
|
||||||
|
private int _current, _total;
|
||||||
|
private List<PermissionEntry> _results = new();
|
||||||
|
private CancellationTokenSource? _cts;
|
||||||
|
|
||||||
|
private async Task RunScan()
|
||||||
|
{
|
||||||
|
_error = string.Empty; _results.Clear(); _running = true;
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
var siteUrl = string.IsNullOrWhiteSpace(_siteUrl) ? Session.CurrentProfile!.TenantUrl : _siteUrl.Trim();
|
||||||
|
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, _cts.Token);
|
||||||
|
var opts = new ScanOptions(_includeInherited, _scanFolders, _folderDepth, _includeSubsites);
|
||||||
|
_results = (await PermSvc.ScanSiteAsync(ctx, opts, progress, _cts.Token)).ToList();
|
||||||
|
_status = $"Scan complete: {_results.Count} entries found.";
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { _status = "Cancelled."; }
|
||||||
|
catch (Exception ex) { _error = ex.Message; }
|
||||||
|
finally { _running = false; await InvokeAsync(StateHasChanged); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Cancel() => _cts?.Cancel();
|
||||||
|
|
||||||
|
private async Task ExportCsv()
|
||||||
|
{
|
||||||
|
var csv = CsvExport.BuildCsv(_results);
|
||||||
|
await WebExport.DownloadCsvAsync(csv, $"permissions_{DateTime.Now:yyyyMMdd_HHmmss}.csv");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ExportHtml()
|
||||||
|
{
|
||||||
|
var html = HtmlExport.BuildHtml(_results);
|
||||||
|
await WebExport.DownloadHtmlAsync(html, $"permissions_{DateTime.Now:yyyyMMdd_HHmmss}.html");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
@page "/profiles"
|
||||||
|
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||||
|
@inject IUserSessionService Session
|
||||||
|
@inject IUserContextAccessor UserContext
|
||||||
|
@inject SharepointToolbox.Web.Infrastructure.Persistence.ProfileRepository ProfileRepo
|
||||||
|
@inject ISessionCredentialStore CredStore
|
||||||
|
@inject NavigationManager Nav
|
||||||
|
@inject SharepointToolbox.Web.Services.OAuth.IOAuthFlowCache OAuthCache
|
||||||
|
@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>
|
||||||
|
|
||||||
|
@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>
|
||||||
|
|
||||||
|
@foreach (var p in _profiles)
|
||||||
|
{
|
||||||
|
<div class="card" style="@(Session.CurrentProfile?.Id == p.Id ? "border-color:#0078d4;border-width:2px" : "")">
|
||||||
|
<div class="flex-row">
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:600;font-size:15px">@p.Name</div>
|
||||||
|
<div class="text-muted">@p.TenantUrl</div>
|
||||||
|
</div>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
@if (Session.CurrentProfile?.Id == p.Id)
|
||||||
|
{
|
||||||
|
<span class="chip chip-green">Active</span>
|
||||||
|
}
|
||||||
|
<button class="btn btn-secondary btn-sm" @onclick="() => SelectProfile(p)">
|
||||||
|
@(Session.CurrentProfile?.Id == p.Id ? "Selected" : "Select")
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Admin view — full CRUD *@
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(_pageError))
|
||||||
|
{
|
||||||
|
<div class="alert alert-error" style="margin-bottom:12px">@_pageError</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="flex-row" style="margin-bottom:16px">
|
||||||
|
<button class="btn btn-primary" @onclick="AddNew">+ New Profile</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_profiles.Count == 0 && !_showForm)
|
||||||
|
{
|
||||||
|
<div class="alert alert-info">No profiles configured. Create one to get started.</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@foreach (var p in _profiles)
|
||||||
|
{
|
||||||
|
<div class="card" style="@(Session.CurrentProfile?.Id == p.Id ? "border-color:#0078d4;border-width:2px" : "")">
|
||||||
|
<div class="flex-row">
|
||||||
|
<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>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
@if (Session.CurrentProfile?.Id == p.Id)
|
||||||
|
{
|
||||||
|
<span class="chip chip-green">Active</span>
|
||||||
|
}
|
||||||
|
<button class="btn btn-secondary btn-sm" @onclick="() => SelectProfile(p)">
|
||||||
|
@(Session.CurrentProfile?.Id == p.Id ? "Selected" : "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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (_showForm)
|
||||||
|
{
|
||||||
|
<div class="card" style="border-color:#0078d4">
|
||||||
|
<div class="card-title">@(_editing?.Id == null ? "New Profile" : "Edit Profile")</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" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Tenant 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" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* App registration section *@
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Client ID (App Registration)</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"
|
||||||
|
style="flex:1" />
|
||||||
|
<button class="btn btn-secondary" @onclick="RegisterAppAsync"
|
||||||
|
disabled="@(!CanRegister || _registering)"
|
||||||
|
title="@(CanRegister ? "Register app in client Entra ID (requires Global Admin)" : "Fill Tenant URL, Tenant ID and Profile Name first")">
|
||||||
|
@(_registering ? "Redirecting…" : "Register in Entra")
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">
|
||||||
|
Click "Register in Entra" to auto-create the app registration in the client tenant — requires Global Admin credentials.
|
||||||
|
Or enter an existing public client App Registration ID manually.
|
||||||
|
</small>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<TenantProfile> _profiles = new();
|
||||||
|
private bool _showForm;
|
||||||
|
private bool _registering;
|
||||||
|
private TenantProfile? _editing;
|
||||||
|
private TenantProfile _form = new();
|
||||||
|
private string _formError = string.Empty;
|
||||||
|
private string _pageError = string.Empty;
|
||||||
|
|
||||||
|
private bool CanRegister =>
|
||||||
|
!string.IsNullOrWhiteSpace(_form.Name) &&
|
||||||
|
!string.IsNullOrWhiteSpace(_form.TenantUrl) &&
|
||||||
|
!string.IsNullOrWhiteSpace(_form.TenantId);
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
_profiles = (await ProfileRepo.LoadAsync()).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (!firstRender) return;
|
||||||
|
await HandleRegResultAsync();
|
||||||
|
await HandleConnectErrorAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleRegResultAsync()
|
||||||
|
{
|
||||||
|
var uri = new Uri(Nav.Uri);
|
||||||
|
var query = QueryHelpers.ParseQuery(uri.Query);
|
||||||
|
|
||||||
|
if (!query.TryGetValue("reg_result_key", out var key) || string.IsNullOrEmpty(key))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var result = OAuthCache.GetAndRemoveRegistrationResult(key!);
|
||||||
|
if (result is not null)
|
||||||
|
{
|
||||||
|
_form = new TenantProfile
|
||||||
|
{
|
||||||
|
Name = result.TenantName,
|
||||||
|
TenantUrl = result.TenantUrl,
|
||||||
|
TenantId = result.TenantId,
|
||||||
|
ClientId = result.ClientId,
|
||||||
|
};
|
||||||
|
_showForm = true;
|
||||||
|
_formError = string.Empty;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
Nav.NavigateTo(uri.GetLeftPart(UriPartial.Path), replace: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleConnectErrorAsync()
|
||||||
|
{
|
||||||
|
var uri = new Uri(Nav.Uri);
|
||||||
|
var query = QueryHelpers.ParseQuery(uri.Query);
|
||||||
|
|
||||||
|
if (!query.TryGetValue("connect_error", out var err) || string.IsNullOrEmpty(err))
|
||||||
|
return;
|
||||||
|
|
||||||
|
_pageError = err!;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
Nav.NavigateTo(uri.GetLeftPart(UriPartial.Path), replace: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddNew()
|
||||||
|
{
|
||||||
|
_editing = null;
|
||||||
|
_form = new TenantProfile();
|
||||||
|
_showForm = true;
|
||||||
|
_formError = string.Empty;
|
||||||
|
_pageError = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EditProfile(TenantProfile p)
|
||||||
|
{
|
||||||
|
_editing = p;
|
||||||
|
_form = new TenantProfile { Id = p.Id, Name = p.Name, TenantUrl = p.TenantUrl, TenantId = p.TenantId, ClientId = p.ClientId };
|
||||||
|
_showForm = true;
|
||||||
|
_formError = _pageError = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelForm() { _showForm = false; _editing = null; }
|
||||||
|
|
||||||
|
private void SelectProfile(TenantProfile p)
|
||||||
|
{
|
||||||
|
Session.SetProfile(p);
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RegisterAppAsync()
|
||||||
|
{
|
||||||
|
if (!CanRegister) return;
|
||||||
|
_registering = true;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
var returnUrl = Nav.Uri.Contains('?')
|
||||||
|
? Nav.Uri.Substring(0, Nav.Uri.IndexOf('?'))
|
||||||
|
: Nav.Uri;
|
||||||
|
|
||||||
|
var url = $"/connect/register-initiate" +
|
||||||
|
$"?tenantId={Uri.EscapeDataString(_form.TenantId)}" +
|
||||||
|
$"&tenantName={Uri.EscapeDataString(_form.Name)}" +
|
||||||
|
$"&tenantUrl={Uri.EscapeDataString(_form.TenantUrl)}" +
|
||||||
|
$"&returnUrl={Uri.EscapeDataString(returnUrl)}";
|
||||||
|
|
||||||
|
Nav.NavigateTo(url, forceLoad: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (_editing == null)
|
||||||
|
{
|
||||||
|
_form.Id = Guid.NewGuid().ToString();
|
||||||
|
_profiles.Add(_form);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var idx = _profiles.FindIndex(p => p.Id == _editing.Id);
|
||||||
|
if (idx >= 0) _profiles[idx] = _form;
|
||||||
|
}
|
||||||
|
await ProfileRepo.SaveAsync(_profiles);
|
||||||
|
_showForm = false; _editing = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteProfile(TenantProfile p)
|
||||||
|
{
|
||||||
|
_profiles.RemoveAll(x => x.Id == p.Id);
|
||||||
|
await ProfileRepo.SaveAsync(_profiles);
|
||||||
|
if (Session.CurrentProfile?.Id == p.Id) await Session.ClearSessionAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
@page "/search"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@inject IUserSessionService Session
|
||||||
|
@inject ISessionManager SessionMgr
|
||||||
|
@inject ISearchService SearchSvc
|
||||||
|
@inject SearchCsvExportService CsvExport
|
||||||
|
@inject SearchHtmlExportService HtmlExport
|
||||||
|
@inject WebExportService WebExport
|
||||||
|
@rendermode InteractiveServer
|
||||||
|
|
||||||
|
<h1 class="page-title">File Search</h1>
|
||||||
|
|
||||||
|
@if (!Session.HasProfile) { <NoProfilePrompt /> return; }
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Search Options</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Site URL</label>
|
||||||
|
<input class="form-input" @bind="_siteUrl" placeholder="@Session.CurrentProfile!.TenantUrl" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">File Extensions (comma-separated)</label>
|
||||||
|
<input class="form-input" @bind="_extensions" placeholder="docx, xlsx, pdf" />
|
||||||
|
</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" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">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>
|
||||||
|
<input class="form-input" @bind="_createdBy" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Modified by</label>
|
||||||
|
<input class="form-input" @bind="_modifiedBy" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Library (optional)</label>
|
||||||
|
<input class="form-input" @bind="_library" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-row mt-8">
|
||||||
|
<button class="btn btn-primary" @onclick="RunSearch" disabled="@_running">
|
||||||
|
@(_running ? "Searching…" : "Search")
|
||||||
|
</button>
|
||||||
|
@if (_running) { <button class="btn btn-secondary" @onclick="Cancel">Cancel</button> }
|
||||||
|
</div>
|
||||||
|
<ProgressPanel IsRunning="_running" StatusMessage="_status" Current="_current" Total="_total" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(_error)) { <div class="alert alert-error">@_error</div> }
|
||||||
|
|
||||||
|
@if (_results.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="card">
|
||||||
|
<div class="flex-row">
|
||||||
|
<div class="card-title">Results <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>
|
||||||
|
</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>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var r in _results.Take(500))
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>@System.IO.Path.GetFileName(r.Path)</td>
|
||||||
|
<td>@r.FileExtension</td>
|
||||||
|
<td title="@r.Path" style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">@r.Path</td>
|
||||||
|
<td>@(r.Created?.ToString("yyyy-MM-dd") ?? "")</td>
|
||||||
|
<td>@(r.LastModified?.ToString("yyyy-MM-dd") ?? "")</td>
|
||||||
|
<td class="num">@((r.SizeBytes / 1024.0).ToString("F1"))</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@if (_results.Count > 500) { <div class="text-muted mt-8">Showing first 500 of @_results.Count. Export for full results.</div> }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private string _siteUrl = string.Empty, _extensions = string.Empty, _regex = string.Empty;
|
||||||
|
private string _createdBy = string.Empty, _modifiedBy = string.Empty, _library = string.Empty;
|
||||||
|
private int _maxResults = 5000;
|
||||||
|
private bool _running; private string _status = string.Empty, _error = string.Empty;
|
||||||
|
private int _current, _total;
|
||||||
|
private List<SearchResult> _results = new();
|
||||||
|
private CancellationTokenSource? _cts;
|
||||||
|
|
||||||
|
private async Task RunSearch()
|
||||||
|
{
|
||||||
|
_error = string.Empty; _results.Clear(); _running = true;
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
var siteUrl = string.IsNullOrWhiteSpace(_siteUrl) ? Session.CurrentProfile!.TenantUrl : _siteUrl.Trim();
|
||||||
|
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, _cts.Token);
|
||||||
|
var exts = _extensions.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
var opts = new SearchOptions(exts, _regex, null, null, null, null, _createdBy.TrimOrNull(), _modifiedBy.TrimOrNull(), _library.TrimOrNull(), _maxResults, siteUrl);
|
||||||
|
_results = (await SearchSvc.SearchFilesAsync(ctx, opts, progress, _cts.Token)).ToList();
|
||||||
|
_status = $"Found {_results.Count} files.";
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { _status = "Cancelled."; }
|
||||||
|
catch (Exception ex) { _error = ex.Message; }
|
||||||
|
finally { _running = false; await InvokeAsync(StateHasChanged); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Cancel() => _cts?.Cancel();
|
||||||
|
private async Task ExportCsv() { await WebExport.DownloadCsvAsync(CsvExport.BuildCsv(_results), $"search_{DateTime.Now:yyyyMMdd_HHmmss}.csv"); }
|
||||||
|
private async Task ExportHtml() { await WebExport.DownloadHtmlAsync(HtmlExport.BuildHtml(_results), $"search_{DateTime.Now:yyyyMMdd_HHmmss}.html"); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
@page "/settings"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@inject IUserSessionService Session
|
||||||
|
@rendermode InteractiveServer
|
||||||
|
|
||||||
|
<h1 class="page-title">Settings</h1>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Display</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">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>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">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="Dark">Dark</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">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
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_saved) { <div class="alert alert-success">Settings saved.</div> }
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private string _lang = "en", _theme = "System";
|
||||||
|
private bool _autoTakeOwnership, _saved;
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
var s = Session.Settings;
|
||||||
|
_lang = s.Lang;
|
||||||
|
_theme = s.Theme;
|
||||||
|
_autoTakeOwnership = s.AutoTakeOwnership;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Save()
|
||||||
|
{
|
||||||
|
Session.UpdateSettings(new AppSettings { Lang = _lang, Theme = _theme, AutoTakeOwnership = _autoTakeOwnership });
|
||||||
|
SharepointToolbox.Web.Localization.TranslationSource.Instance.SetCulture(_lang);
|
||||||
|
_saved = true;
|
||||||
|
StateHasChanged();
|
||||||
|
_ = Task.Delay(2000).ContinueWith(_ => { _saved = false; InvokeAsync(StateHasChanged); });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
@page "/storage"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@inject IUserSessionService Session
|
||||||
|
@inject ISessionManager SessionMgr
|
||||||
|
@inject IStorageService StorageSvc
|
||||||
|
@inject StorageCsvExportService CsvExport
|
||||||
|
@inject StorageHtmlExportService HtmlExport
|
||||||
|
@inject WebExportService WebExport
|
||||||
|
@rendermode InteractiveServer
|
||||||
|
|
||||||
|
<h1 class="page-title">Storage Metrics</h1>
|
||||||
|
|
||||||
|
@if (!Session.HasProfile) { <NoProfilePrompt /> return; }
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Scan Options</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Site URL</label>
|
||||||
|
<input class="form-input" @bind="_siteUrl" placeholder="@Session.CurrentProfile!.TenantUrl" />
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-row mt-8">
|
||||||
|
<button class="btn btn-primary" @onclick="RunScan" disabled="@_running">
|
||||||
|
@(_running ? "Scanning…" : "Scan Storage")
|
||||||
|
</button>
|
||||||
|
@if (_running) { <button class="btn btn-secondary" @onclick="Cancel">Cancel</button> }
|
||||||
|
</div>
|
||||||
|
<ProgressPanel IsRunning="_running" StatusMessage="_status" Current="_current" Total="_total" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(_error)) { <div class="alert alert-error">@_error</div> }
|
||||||
|
|
||||||
|
@if (_results.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="card">
|
||||||
|
<div class="flex-row">
|
||||||
|
<div class="card-title">Storage Report <span class="count-badge">@_results.Count libraries</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>
|
||||||
|
</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>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var n in _results)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td style="padding-left:@(n.IndentLevel * 20 + 12)px">@n.Name</td>
|
||||||
|
<td>@n.SiteTitle</td>
|
||||||
|
<td class="num">@n.TotalFileCount.ToString("N0")</td>
|
||||||
|
<td class="num">@((n.TotalSizeBytes / 1048576.0).ToString("F2"))</td>
|
||||||
|
<td class="num">@((n.VersionSizeBytes / 1048576.0).ToString("F2"))</td>
|
||||||
|
<td>@(n.LastModified?.ToString("yyyy-MM-dd") ?? "")</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private string _siteUrl = string.Empty;
|
||||||
|
private bool _includeSubsites, _includeHidden = true, _includeRecycleBin = true;
|
||||||
|
private bool _running; private string _status = string.Empty, _error = string.Empty;
|
||||||
|
private int _current, _total;
|
||||||
|
private List<StorageNode> _results = new();
|
||||||
|
private CancellationTokenSource? _cts;
|
||||||
|
|
||||||
|
private async Task RunScan()
|
||||||
|
{
|
||||||
|
_error = string.Empty; _results.Clear(); _running = true;
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
var siteUrl = string.IsNullOrWhiteSpace(_siteUrl) ? Session.CurrentProfile!.TenantUrl : _siteUrl.Trim();
|
||||||
|
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, _cts.Token);
|
||||||
|
var opts = new StorageScanOptions(IncludeSubsites: _includeSubsites, IncludeHiddenLibraries: _includeHidden, IncludeRecycleBin: _includeRecycleBin);
|
||||||
|
_results = (await StorageSvc.CollectStorageAsync(ctx, opts, progress, _cts.Token)).ToList();
|
||||||
|
_status = $"Complete: {_results.Count} nodes.";
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { _status = "Cancelled."; }
|
||||||
|
catch (Exception ex) { _error = ex.Message; }
|
||||||
|
finally { _running = false; await InvokeAsync(StateHasChanged); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Cancel() => _cts?.Cancel();
|
||||||
|
|
||||||
|
private async Task ExportCsv()
|
||||||
|
{
|
||||||
|
var csv = CsvExport.BuildCsv(_results);
|
||||||
|
await WebExport.DownloadCsvAsync(csv, $"storage_{DateTime.Now:yyyyMMdd_HHmmss}.csv");
|
||||||
|
}
|
||||||
|
private async Task ExportHtml()
|
||||||
|
{
|
||||||
|
var html = HtmlExport.BuildHtml(_results);
|
||||||
|
await WebExport.DownloadHtmlAsync(html, $"storage_{DateTime.Now:yyyyMMdd_HHmmss}.html");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
@page "/templates"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@inject IUserSessionService Session
|
||||||
|
@inject IUserContextAccessor UserContext
|
||||||
|
@inject ISessionManager SessionMgr
|
||||||
|
@inject ITemplateService TemplateSvc
|
||||||
|
@inject SharepointToolbox.Web.Infrastructure.Persistence.TemplateRepository TemplateRepo
|
||||||
|
@rendermode InteractiveServer
|
||||||
|
|
||||||
|
<h1 class="page-title">Site Templates</h1>
|
||||||
|
<p class="page-subtitle">Capture site structure and apply to new sites.</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="form-group">
|
||||||
|
<label class="form-label">Source Site URL</label>
|
||||||
|
<input class="form-input" @bind="_captureUrl" placeholder="@Session.CurrentProfile!.TenantUrl" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Template Name</label>
|
||||||
|
<input class="form-input" @bind="_captureName" placeholder="My Template" />
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary mt-8" @onclick="CaptureTemplate" disabled="@_running">
|
||||||
|
@(_running ? "Capturing…" : "Capture")
|
||||||
|
</button>
|
||||||
|
<ProgressPanel IsRunning="_running" StatusMessage="_status" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Apply Template</div>
|
||||||
|
@if (_selectedTemplate == null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-info">Select a template from the list below.</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="alert alert-info">Template: <strong>@_selectedTemplate.Name</strong></div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">New Site Title</label>
|
||||||
|
<input class="form-input" @bind="_newTitle" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">New Site Alias</label>
|
||||||
|
<input class="form-input" @bind="_newAlias" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Admin Center URL</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")
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(_error)) { <div class="alert alert-error mt-8">@_error</div> }
|
||||||
|
@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>
|
||||||
|
@if (_templates.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="text-muted">No templates saved.</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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private string _captureUrl = string.Empty, _captureName = string.Empty;
|
||||||
|
private bool _capLibraries = true, _capFolders = true, _capGroups = true;
|
||||||
|
private SiteTemplate? _selectedTemplate;
|
||||||
|
private string _newTitle = string.Empty, _newAlias = string.Empty, _adminUrl = string.Empty;
|
||||||
|
private bool _running; private string _status = string.Empty, _error = string.Empty, _successMsg = string.Empty;
|
||||||
|
private List<SiteTemplate> _templates = new();
|
||||||
|
private CancellationTokenSource? _cts;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
_templates = (await TemplateRepo.GetAllAsync()).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CaptureTemplate()
|
||||||
|
{
|
||||||
|
_error = string.Empty; _successMsg = string.Empty; _running = true;
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
var siteUrl = string.IsNullOrWhiteSpace(_captureUrl) ? Session.CurrentProfile!.TenantUrl : _captureUrl.Trim();
|
||||||
|
var progress = new Progress<OperationProgress>(p => { _status = p.Message; InvokeAsync(StateHasChanged); });
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, _cts.Token);
|
||||||
|
var opts = new SiteTemplateOptions { CaptureLibraries = _capLibraries, CaptureFolders = _capFolders, CapturePermissionGroups = _capGroups };
|
||||||
|
var template = await TemplateSvc.CaptureTemplateAsync(ctx, opts, progress, _cts.Token);
|
||||||
|
template.Name = string.IsNullOrWhiteSpace(_captureName) ? $"Template-{DateTime.Now:yyyyMMdd}" : _captureName;
|
||||||
|
await TemplateRepo.SaveAsync(template);
|
||||||
|
_templates = (await TemplateRepo.GetAllAsync()).ToList();
|
||||||
|
_successMsg = $"Template '{template.Name}' saved.";
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { _status = "Cancelled."; }
|
||||||
|
catch (Exception ex) { _error = ex.Message; }
|
||||||
|
finally { _running = false; await InvokeAsync(StateHasChanged); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ApplyTemplate()
|
||||||
|
{
|
||||||
|
if (_selectedTemplate == null) return;
|
||||||
|
_error = string.Empty; _successMsg = string.Empty; _running = true;
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
var adminUrl = string.IsNullOrWhiteSpace(_adminUrl)
|
||||||
|
? Session.CurrentProfile!.TenantUrl.Replace(".sharepoint.com", "-admin.sharepoint.com")
|
||||||
|
: _adminUrl.Trim();
|
||||||
|
var progress = new Progress<OperationProgress>(p => { _status = p.Message; InvokeAsync(StateHasChanged); });
|
||||||
|
try
|
||||||
|
{
|
||||||
|
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}";
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { _status = "Cancelled."; }
|
||||||
|
catch (Exception ex) { _error = ex.Message; }
|
||||||
|
finally { _running = false; await InvokeAsync(StateHasChanged); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteTemplate(SiteTemplate t)
|
||||||
|
{
|
||||||
|
await TemplateRepo.DeleteAsync(t.Id);
|
||||||
|
_templates.RemoveAll(x => x.Id == t.Id);
|
||||||
|
if (_selectedTemplate?.Id == t.Id) _selectedTemplate = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
@page "/user-audit"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@inject IUserSessionService Session
|
||||||
|
@inject ISessionManager SessionMgr
|
||||||
|
@inject IUserAccessAuditService AuditSvc
|
||||||
|
@inject UserAccessCsvExportService CsvExport
|
||||||
|
@inject UserAccessHtmlExportService HtmlExport
|
||||||
|
@inject WebExportService WebExport
|
||||||
|
@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>
|
||||||
|
|
||||||
|
@if (!Session.HasProfile) { <NoProfilePrompt /> return; }
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group" style="flex:2">
|
||||||
|
<label class="form-label">Users (emails, one per line)</label>
|
||||||
|
<textarea class="form-textarea" @bind="_users" placeholder="alice@contoso.com bob@contoso.com" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="flex:2">
|
||||||
|
<label class="form-label">Site URLs (one per line)</label>
|
||||||
|
<textarea class="form-textarea" @bind="_sites" placeholder="@Session.CurrentProfile!.TenantUrl" rows="3"></textarea>
|
||||||
|
</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="_includeInherited" /> Include inherited</label>
|
||||||
|
<label><input type="checkbox" @bind="_scanFolders" /> Scan folders</label>
|
||||||
|
<label><input type="checkbox" @bind="_includeSubsites" /> Include subsites</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-row mt-8">
|
||||||
|
<button class="btn btn-primary" @onclick="RunAudit" disabled="@_running">
|
||||||
|
@(_running ? "Auditing…" : "Audit Users")
|
||||||
|
</button>
|
||||||
|
@if (_running) { <button class="btn btn-secondary" @onclick="Cancel">Cancel</button> }
|
||||||
|
</div>
|
||||||
|
<ProgressPanel IsRunning="_running" StatusMessage="_status" Current="_current" Total="_total" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(_error)) { <div class="alert alert-error">@_error</div> }
|
||||||
|
|
||||||
|
@if (_results.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="card">
|
||||||
|
<div class="flex-row">
|
||||||
|
<div class="card-title">Audit Results <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>
|
||||||
|
</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>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var r in _results.Take(500))
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<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.AccessType</td>
|
||||||
|
<td>@r.GrantedThrough</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@if (_results.Count > 500) { <div class="text-muted mt-8">Showing first 500. Export for full results.</div> }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private string _users = string.Empty, _sites = string.Empty;
|
||||||
|
private bool _includeInherited, _includeSubsites, _scanFolders = true;
|
||||||
|
private bool _running; private string _status = string.Empty, _error = string.Empty;
|
||||||
|
private int _current, _total;
|
||||||
|
private List<UserAccessEntry> _results = new();
|
||||||
|
private CancellationTokenSource? _cts;
|
||||||
|
|
||||||
|
private async Task RunAudit()
|
||||||
|
{
|
||||||
|
_error = string.Empty; _results.Clear(); _running = true;
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
var userList = _users.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
|
||||||
|
var siteList = _sites.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.Select(u => new SiteInfo(u, u.TrimEnd('/').Split('/').Last())).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); });
|
||||||
|
try
|
||||||
|
{
|
||||||
|
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.";
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { _status = "Cancelled."; }
|
||||||
|
catch (Exception ex) { _error = ex.Message; }
|
||||||
|
finally { _running = false; await InvokeAsync(StateHasChanged); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Cancel() => _cts?.Cancel();
|
||||||
|
private async Task ExportCsv() { await WebExport.DownloadCsvAsync(CsvExport.BuildCsv(_results.FirstOrDefault()?.UserDisplayName ?? "Users", _results.FirstOrDefault()?.UserLogin ?? "", _results), $"user_audit_{DateTime.Now:yyyyMMdd_HHmmss}.csv"); }
|
||||||
|
private async Task ExportHtml() { await WebExport.DownloadHtmlAsync(HtmlExport.BuildHtml(_results), $"user_audit_{DateTime.Now:yyyyMMdd_HHmmss}.html"); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
@page "/user-directory"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@inject IUserSessionService Session
|
||||||
|
@inject IGraphUserDirectoryService GraphSvc
|
||||||
|
@rendermode InteractiveServer
|
||||||
|
|
||||||
|
<h1 class="page-title">User Directory</h1>
|
||||||
|
<p class="page-subtitle">Browse all tenant users via Microsoft Graph.</p>
|
||||||
|
|
||||||
|
@if (!Session.HasProfile) { <NoProfilePrompt /> return; }
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="flex-row">
|
||||||
|
<label><input type="checkbox" @bind="_includeGuests" /> Include guests</label>
|
||||||
|
<button class="btn btn-primary" @onclick="LoadUsers" disabled="@_running">
|
||||||
|
@(_running ? $"Loading… ({_loadCount} users)" : "Load Users")
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ProgressPanel IsRunning="_running" StatusMessage="_status" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(_error)) { <div class="alert alert-error">@_error</div> }
|
||||||
|
|
||||||
|
@if (_users.Count > 0)
|
||||||
|
{
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var u in FilteredUsers.Take(500))
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>@u.DisplayName</td>
|
||||||
|
<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>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@if (FilteredUsers.Count() > 500) { <div class="text-muted mt-8">Showing first 500 of @FilteredUsers.Count() filtered.</div> }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private bool _includeGuests, _running;
|
||||||
|
private string _status = string.Empty, _error = string.Empty, _filter = string.Empty;
|
||||||
|
private int _loadCount;
|
||||||
|
private List<GraphDirectoryUser> _users = new();
|
||||||
|
|
||||||
|
private IEnumerable<GraphDirectoryUser> FilteredUsers => string.IsNullOrWhiteSpace(_filter)
|
||||||
|
? _users
|
||||||
|
: _users.Where(u => u.DisplayName.Contains(_filter, StringComparison.OrdinalIgnoreCase) || u.UserPrincipalName.Contains(_filter, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
private async Task LoadUsers()
|
||||||
|
{
|
||||||
|
_error = string.Empty; _users.Clear(); _running = true; _loadCount = 0;
|
||||||
|
var progress = new Progress<int>(count => { _loadCount = count; InvokeAsync(StateHasChanged); });
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_users = (await GraphSvc.GetUsersAsync(Session.CurrentProfile!, _includeGuests, progress)).ToList();
|
||||||
|
_status = $"Loaded {_users.Count} users.";
|
||||||
|
}
|
||||||
|
catch (Exception ex) { _error = ex.Message; }
|
||||||
|
finally { _running = false; await InvokeAsync(StateHasChanged); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
@page "/versions"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@inject IUserSessionService Session
|
||||||
|
@inject IUserContextAccessor UserContext
|
||||||
|
@inject ISessionManager SessionMgr
|
||||||
|
@inject IVersionCleanupService VersionSvc
|
||||||
|
@inject VersionCleanupHtmlExportService HtmlExport
|
||||||
|
@inject WebExportService WebExport
|
||||||
|
@rendermode InteractiveServer
|
||||||
|
|
||||||
|
<h1 class="page-title">Version Cleanup</h1>
|
||||||
|
|
||||||
|
@if (!Session.HasProfile) { <NoProfilePrompt /> return; }
|
||||||
|
@if (UserContext.Role < UserRole.TechN1) { <WriteGuard /> return; }
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Site URL</label>
|
||||||
|
<input class="form-input" @bind="_siteUrl" placeholder="@Session.CurrentProfile!.TenantUrl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-row">
|
||||||
|
<button class="btn btn-secondary" @onclick="LoadLibraries" disabled="@_loading">
|
||||||
|
@(_loading ? "Loading…" : "Load Libraries")
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@if (_libraries.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="form-group mt-8">
|
||||||
|
<label class="form-label">Libraries (none = all)</label>
|
||||||
|
<div style="max-height:200px;overflow-y:auto;border:1px solid var(--border);border-radius:4px;padding:4px">
|
||||||
|
@foreach (var lib in _libraries)
|
||||||
|
{
|
||||||
|
<label style="display:flex;align-items:center;gap:6px;padding:4px 8px;cursor:pointer">
|
||||||
|
<input type="checkbox" checked="@_selectedLibs.Contains(lib)" @onchange="e => ToggleLib(lib, (bool)e.Value!)" />
|
||||||
|
@lib
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="form-row mt-8">
|
||||||
|
<div class="form-group" style="flex:0 0 auto">
|
||||||
|
<label class="form-label">Keep last N versions</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-row mt-8">
|
||||||
|
<button class="btn btn-danger" @onclick="RunCleanup" disabled="@_running">
|
||||||
|
@(_running ? "Cleaning…" : "Delete Old Versions")
|
||||||
|
</button>
|
||||||
|
@if (_running) { <button class="btn btn-secondary" @onclick="Cancel">Cancel</button> }
|
||||||
|
</div>
|
||||||
|
<ProgressPanel IsRunning="_running" StatusMessage="_status" Current="_current" Total="_total" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(_error)) { <div class="alert alert-error">@_error</div> }
|
||||||
|
|
||||||
|
@if (_results.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="card">
|
||||||
|
<div class="flex-row">
|
||||||
|
<div class="card-title">Results <span class="count-badge">@_results.Count files</span></div>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<button class="btn btn-secondary btn-sm" @onclick="ExportHtml">Export HTML</button>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-info">
|
||||||
|
Versions deleted: <strong>@_results.Sum(r => r.VersionsDeleted)</strong> |
|
||||||
|
Freed: <strong>@((_results.Sum(r => r.BytesFreed) / 1048576.0).ToString("F2")) MB</strong> |
|
||||||
|
Errors: <strong>@_results.Count(r => r.Error != null)</strong>
|
||||||
|
</div>
|
||||||
|
<div class="data-table-wrap">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead><tr><th>Library</th><th>File</th><th class="num">Before</th><th class="num">Deleted</th><th class="num">Freed (KB)</th><th>Error</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var r in _results.Take(500))
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>@r.Library</td>
|
||||||
|
<td title="@r.FileServerRelativeUrl">@r.FileName</td>
|
||||||
|
<td class="num">@r.VersionsBefore</td>
|
||||||
|
<td class="num">@r.VersionsDeleted</td>
|
||||||
|
<td class="num">@((r.BytesFreed / 1024.0).ToString("F1"))</td>
|
||||||
|
<td style="color:var(--danger)">@r.Error</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private string _siteUrl = string.Empty;
|
||||||
|
private int _keepLast = 5; private bool _keepFirst;
|
||||||
|
private List<string> _libraries = new(), _selectedLibs = new();
|
||||||
|
private bool _running, _loading; private string _status = string.Empty, _error = string.Empty;
|
||||||
|
private int _current, _total;
|
||||||
|
private List<VersionCleanupResult> _results = new();
|
||||||
|
private CancellationTokenSource? _cts;
|
||||||
|
|
||||||
|
private async Task LoadLibraries()
|
||||||
|
{
|
||||||
|
_loading = true; _error = string.Empty;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var siteUrl = string.IsNullOrWhiteSpace(_siteUrl) ? Session.CurrentProfile!.TenantUrl : _siteUrl.Trim();
|
||||||
|
var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, CancellationToken.None);
|
||||||
|
_libraries = (await VersionSvc.ListLibraryTitlesAsync(ctx, CancellationToken.None)).ToList();
|
||||||
|
}
|
||||||
|
catch (Exception ex) { _error = ex.Message; }
|
||||||
|
finally { _loading = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ToggleLib(string lib, bool selected) { if (selected) _selectedLibs.Add(lib); else _selectedLibs.Remove(lib); }
|
||||||
|
|
||||||
|
private async Task RunCleanup()
|
||||||
|
{
|
||||||
|
_error = string.Empty; _results.Clear(); _running = true;
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
var siteUrl = string.IsNullOrWhiteSpace(_siteUrl) ? Session.CurrentProfile!.TenantUrl : _siteUrl.Trim();
|
||||||
|
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, _cts.Token);
|
||||||
|
var opts = new VersionCleanupOptions(_selectedLibs, _keepLast, _keepFirst);
|
||||||
|
_results = (await VersionSvc.DeleteOldVersionsAsync(ctx, opts, progress, _cts.Token)).ToList();
|
||||||
|
_status = $"Complete: {_results.Sum(r => r.VersionsDeleted)} versions deleted.";
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { _status = "Cancelled."; }
|
||||||
|
catch (Exception ex) { _error = ex.Message; }
|
||||||
|
finally { _running = false; await InvokeAsync(StateHasChanged); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Cancel() => _cts?.Cancel();
|
||||||
|
private async Task ExportHtml() { await WebExport.DownloadHtmlAsync(HtmlExport.BuildHtml(_results), $"versions_{DateTime.Now:yyyyMMdd_HHmmss}.html"); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<CascadingAuthenticationState>
|
||||||
|
<Router AppAssembly="typeof(App).Assembly">
|
||||||
|
<Found Context="routeData">
|
||||||
|
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
|
||||||
|
<NotAuthorized>
|
||||||
|
@if (!context.User.Identity?.IsAuthenticated ?? true)
|
||||||
|
{
|
||||||
|
<meta http-equiv="refresh" content="0;url=/account/login" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div style="padding:2rem">
|
||||||
|
<h2>Access denied</h2>
|
||||||
|
<p>You do not have permission to view this page.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</NotAuthorized>
|
||||||
|
</AuthorizeRouteView>
|
||||||
|
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
||||||
|
</Found>
|
||||||
|
</Router>
|
||||||
|
</CascadingAuthenticationState>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
@inject AuthenticationStateProvider AuthProvider
|
||||||
|
@inject IUserService UserService
|
||||||
|
@inject IUserContextAccessor UserContext
|
||||||
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
|
@using SharepointToolbox.Web.Services.Auth
|
||||||
|
@using SharepointToolbox.Web.Services.Session
|
||||||
|
|
||||||
|
@* Invisible component. Run once per circuit to seed IUserContextAccessor. *@
|
||||||
|
|
||||||
|
@code {
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
var state = await AuthProvider.GetAuthenticationStateAsync();
|
||||||
|
var principal = state.User;
|
||||||
|
if (principal.Identity?.IsAuthenticated != true) return;
|
||||||
|
|
||||||
|
var email = principal.FindFirst("preferred_username")?.Value
|
||||||
|
?? principal.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value;
|
||||||
|
if (string.IsNullOrEmpty(email)) return;
|
||||||
|
|
||||||
|
var user = await UserService.GetByEmailAsync(email);
|
||||||
|
if (user is not null) UserContext.Initialize(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<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>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
@if (IsRunning || !string.IsNullOrEmpty(StatusMessage))
|
||||||
|
{
|
||||||
|
<div class="progress-panel mt-8">
|
||||||
|
@if (!string.IsNullOrEmpty(StatusMessage))
|
||||||
|
{
|
||||||
|
<div class="progress-msg">@StatusMessage</div>
|
||||||
|
}
|
||||||
|
@if (IsRunning)
|
||||||
|
{
|
||||||
|
<div class="progress-bar">
|
||||||
|
@if (Total > 0)
|
||||||
|
{
|
||||||
|
<div class="progress-fill" style="width:@(Math.Round((double)Current/Total*100))%"></div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="progress-fill indeterminate"></div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public bool IsRunning { get; set; }
|
||||||
|
[Parameter] public string StatusMessage { get; set; } = string.Empty;
|
||||||
|
[Parameter] public int Current { get; set; }
|
||||||
|
[Parameter] public int Total { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
@inject ISessionCredentialStore CredStore
|
||||||
|
@inject IUserSessionService Session
|
||||||
|
@inject ISessionManager SessionManager
|
||||||
|
@inject NavigationManager Nav
|
||||||
|
@using SharepointToolbox.Web.Core.Models
|
||||||
|
@using SharepointToolbox.Web.Services.Session
|
||||||
|
|
||||||
|
@if (_visible)
|
||||||
|
{
|
||||||
|
<div class="modal-overlay">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Connect to Microsoft</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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(_error))
|
||||||
|
{
|
||||||
|
<div class="alert alert-error">@_error</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="flex-row mt-8">
|
||||||
|
<button class="btn btn-primary" @onclick="ConnectAsync" disabled="@_connecting">
|
||||||
|
@(_connecting ? "Redirecting…" : "Connect via Microsoft")
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary" @onclick="Cancel" disabled="@_connecting">Cancel</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-muted" style="font-size:11px;margin-top:8px">
|
||||||
|
You will be redirected to Microsoft login. MFA is supported.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public EventCallback OnConnected { get; set; }
|
||||||
|
|
||||||
|
private bool _visible;
|
||||||
|
private bool _connecting;
|
||||||
|
private string _error = string.Empty;
|
||||||
|
|
||||||
|
public async Task ShowAsync()
|
||||||
|
{
|
||||||
|
_error = string.Empty;
|
||||||
|
_connecting = false;
|
||||||
|
_visible = true;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ConnectAsync()
|
||||||
|
{
|
||||||
|
var profile = Session.CurrentProfile;
|
||||||
|
if (profile is null) { _error = "No client profile selected."; return; }
|
||||||
|
|
||||||
|
_connecting = true;
|
||||||
|
_error = string.Empty;
|
||||||
|
|
||||||
|
// Clear any stale CSOM contexts
|
||||||
|
await SessionManager.ClearAllAsync();
|
||||||
|
|
||||||
|
var currentUrl = Nav.Uri;
|
||||||
|
var connectUrl = $"/connect/initiate?profileId={Uri.EscapeDataString(profile.Id)}" +
|
||||||
|
$"&returnUrl={Uri.EscapeDataString(currentUrl)}";
|
||||||
|
|
||||||
|
// Force full HTTP navigation to break out of the Blazor SignalR circuit
|
||||||
|
Nav.NavigateTo(connectUrl, forceLoad: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Cancel()
|
||||||
|
{
|
||||||
|
_visible = false;
|
||||||
|
_connecting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
@inject IUserContextAccessor UserContext
|
||||||
|
@using SharepointToolbox.Web.Core.Models
|
||||||
|
@using SharepointToolbox.Web.Services.Session
|
||||||
|
|
||||||
|
@* Wrap write-only UI. TechN0 sees the ReadOnlyContent fallback. *@
|
||||||
|
|
||||||
|
@if (UserContext.Role >= UserRole.TechN1)
|
||||||
|
{
|
||||||
|
@ChildContent
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@if (ReadOnlyContent is not null)
|
||||||
|
{
|
||||||
|
@ReadOnlyContent
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="alert alert-info">
|
||||||
|
You have <strong>read-only</strong> access (Tech-N0). Contact an Admin to request write access.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||||
|
[Parameter] public RenderFragment? ReadOnlyContent { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
@using System.Net.Http
|
||||||
|
@using System.Net.Http.Json
|
||||||
|
@using Microsoft.AspNetCore.Authorization
|
||||||
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
|
@using SharepointToolbox.Web.Services.Audit
|
||||||
|
@using SharepointToolbox.Web.Services.Auth
|
||||||
|
@using Microsoft.AspNetCore.Components.Forms
|
||||||
|
@using Microsoft.AspNetCore.Components.Routing
|
||||||
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
|
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||||
|
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||||
|
@using Microsoft.JSInterop
|
||||||
|
@using Microsoft.Extensions.Options
|
||||||
|
@using SharepointToolbox.Web
|
||||||
|
@using SharepointToolbox.Web.Core.Models
|
||||||
|
@using SharepointToolbox.Web.Services
|
||||||
|
@using SharepointToolbox.Web.Services.Session
|
||||||
|
@using SharepointToolbox.Web.Services.Export
|
||||||
|
@using SharepointToolbox.Web.Components
|
||||||
|
@using SharepointToolbox.Web.Components.Shared
|
||||||
|
@using SharepointToolbox.Web.Core.Helpers
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Config;
|
||||||
|
|
||||||
|
public class ClientConnectOptions
|
||||||
|
{
|
||||||
|
/// <summary>Must match the redirect URI registered in each client's app registration.</summary>
|
||||||
|
public string RedirectUri { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using Microsoft.SharePoint.Client;
|
||||||
|
using SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Web.Core.Helpers;
|
||||||
|
|
||||||
|
public static class ExecuteQueryRetryHelper
|
||||||
|
{
|
||||||
|
private const int MaxRetries = 5;
|
||||||
|
|
||||||
|
public static async Task ExecuteQueryRetryAsync(
|
||||||
|
ClientContext ctx,
|
||||||
|
IProgress<OperationProgress>? progress = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
int attempt = 0;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ctx.ExecuteQueryAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (IsThrottleException(ex) && attempt < MaxRetries)
|
||||||
|
{
|
||||||
|
attempt++;
|
||||||
|
int delaySeconds = (int)Math.Pow(2, attempt) * 5;
|
||||||
|
progress?.Report(OperationProgress.Indeterminate(
|
||||||
|
$"Throttled by SharePoint — retrying in {delaySeconds}s (attempt {attempt}/{MaxRetries})…"));
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(delaySeconds), ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool IsThrottleException(Exception ex)
|
||||||
|
{
|
||||||
|
var msg = ex.Message;
|
||||||
|
return msg.Contains("429") || msg.Contains("503") ||
|
||||||
|
msg.Contains("throttl", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Web.Core.Helpers;
|
||||||
|
|
||||||
|
public static class PermissionConsolidator
|
||||||
|
{
|
||||||
|
internal static string MakeKey(UserAccessEntry entry)
|
||||||
|
=> string.Join("|",
|
||||||
|
entry.UserLogin.ToLowerInvariant(),
|
||||||
|
entry.PermissionLevel.ToLowerInvariant(),
|
||||||
|
entry.AccessType.ToString(),
|
||||||
|
entry.GrantedThrough.ToLowerInvariant());
|
||||||
|
|
||||||
|
public static IReadOnlyList<ConsolidatedPermissionEntry> Consolidate(IReadOnlyList<UserAccessEntry> entries)
|
||||||
|
{
|
||||||
|
if (entries.Count == 0) return Array.Empty<ConsolidatedPermissionEntry>();
|
||||||
|
return entries
|
||||||
|
.GroupBy(e => MakeKey(e))
|
||||||
|
.Select(g =>
|
||||||
|
{
|
||||||
|
var first = g.First();
|
||||||
|
var locations = g.Select(e => new LocationInfo(
|
||||||
|
e.SiteUrl, e.SiteTitle, e.ObjectTitle, e.ObjectUrl, e.ObjectType)).ToList();
|
||||||
|
return new ConsolidatedPermissionEntry(
|
||||||
|
first.UserDisplayName, first.UserLogin, first.PermissionLevel,
|
||||||
|
first.AccessType, first.GrantedThrough, first.IsHighPrivilege,
|
||||||
|
first.IsExternalUser, locations,
|
||||||
|
first.TargetUrl, first.TargetLabel, first.SharingLinkType);
|
||||||
|
})
|
||||||
|
.OrderBy(c => c.UserLogin).ThenBy(c => c.PermissionLevel).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Web.Core.Helpers;
|
||||||
|
|
||||||
|
public static class PermissionEntryHelper
|
||||||
|
{
|
||||||
|
private static readonly Regex LimitedAccessWebRegex = new(
|
||||||
|
@"^Limited Access System Group For Web\s+(?<id>[0-9a-fA-F-]{36})\s*$",
|
||||||
|
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
private static readonly Regex LimitedAccessListRegex = new(
|
||||||
|
@"^Limited Access System Group For List\s+(?<id>[0-9a-fA-F-]{36})\s*$",
|
||||||
|
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
private static readonly Regex SharingLinkRegex = new(
|
||||||
|
@"^SharingLinks\.(?<item>[0-9a-fA-F-]{36})\.(?<type>[^.]+)\.(?<share>[0-9a-fA-F-]{36})\s*$",
|
||||||
|
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
public static bool IsExternalUser(string loginName) =>
|
||||||
|
loginName.Contains("#EXT#", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public static IReadOnlyList<string> FilterPermissionLevels(IEnumerable<string> levels) =>
|
||||||
|
levels.Where(l => !string.Equals(l, "Limited Access", StringComparison.OrdinalIgnoreCase)).ToList();
|
||||||
|
|
||||||
|
public static bool IsBareLimitedAccessSystemGroup(string name) =>
|
||||||
|
name.Equals("Limited Access System Group", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public static SystemGroupClassification Classify(string groupTitle)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(groupTitle))
|
||||||
|
return new SystemGroupClassification(SystemGroupKind.None, null, null, null, null, null);
|
||||||
|
|
||||||
|
var trimmed = groupTitle.Trim();
|
||||||
|
|
||||||
|
if (IsBareLimitedAccessSystemGroup(trimmed))
|
||||||
|
return new SystemGroupClassification(SystemGroupKind.LimitedAccessBare, null, null, null, null, null);
|
||||||
|
|
||||||
|
var mWeb = LimitedAccessWebRegex.Match(trimmed);
|
||||||
|
if (mWeb.Success && Guid.TryParse(mWeb.Groups["id"].Value, out var webId))
|
||||||
|
return new SystemGroupClassification(SystemGroupKind.LimitedAccessWeb, webId, null, null, null, null);
|
||||||
|
|
||||||
|
var mList = LimitedAccessListRegex.Match(trimmed);
|
||||||
|
if (mList.Success && Guid.TryParse(mList.Groups["id"].Value, out var listId))
|
||||||
|
return new SystemGroupClassification(SystemGroupKind.LimitedAccessList, null, listId, null, null, null);
|
||||||
|
|
||||||
|
var mShare = SharingLinkRegex.Match(trimmed);
|
||||||
|
if (mShare.Success
|
||||||
|
&& Guid.TryParse(mShare.Groups["item"].Value, out var itemId)
|
||||||
|
&& Guid.TryParse(mShare.Groups["share"].Value, out var shareId))
|
||||||
|
{
|
||||||
|
return new SystemGroupClassification(
|
||||||
|
SystemGroupKind.SharingLink, null, null, itemId, mShare.Groups["type"].Value, shareId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SystemGroupClassification(SystemGroupKind.None, null, null, null, null, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum SystemGroupKind
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
LimitedAccessBare,
|
||||||
|
LimitedAccessWeb,
|
||||||
|
LimitedAccessList,
|
||||||
|
SharingLink
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly record struct SystemGroupClassification(
|
||||||
|
SystemGroupKind Kind,
|
||||||
|
Guid? WebId,
|
||||||
|
Guid? ListId,
|
||||||
|
Guid? ItemUniqueId,
|
||||||
|
string? LinkType,
|
||||||
|
Guid? ShareId);
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Web.Core.Helpers;
|
||||||
|
|
||||||
|
public static class PermissionLevelMapping
|
||||||
|
{
|
||||||
|
public record MappingResult(string Label, RiskLevel RiskLevel);
|
||||||
|
|
||||||
|
private static readonly Dictionary<string, MappingResult> Mappings = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["Full Control"] = new("Full control (can manage everything)", RiskLevel.High),
|
||||||
|
["Site Collection Administrator"] = new("Site collection admin (full control)", RiskLevel.High),
|
||||||
|
["Contribute"] = new("Can edit files and list items", RiskLevel.Medium),
|
||||||
|
["Edit"] = new("Can edit files, lists, and pages", RiskLevel.Medium),
|
||||||
|
["Design"] = new("Can edit pages and use design tools", RiskLevel.Medium),
|
||||||
|
["Approve"] = new("Can approve content and list items", RiskLevel.Medium),
|
||||||
|
["Manage Hierarchy"] = new("Can create sites and manage pages", RiskLevel.Medium),
|
||||||
|
["Read"] = new("Can view files and pages", RiskLevel.Low),
|
||||||
|
["Restricted Read"] = new("Can view pages only (no download)", RiskLevel.Low),
|
||||||
|
["View Only"] = new("Can view files in browser only", RiskLevel.ReadOnly),
|
||||||
|
["Restricted View"] = new("Restricted view access", RiskLevel.ReadOnly),
|
||||||
|
};
|
||||||
|
|
||||||
|
public static MappingResult GetMapping(string roleName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(roleName)) return new(roleName, RiskLevel.Low);
|
||||||
|
return Mappings.TryGetValue(roleName.Trim(), out var result) ? result : new(roleName.Trim(), RiskLevel.Medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IReadOnlyList<MappingResult> GetMappings(string permissionLevels)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(permissionLevels)) return Array.Empty<MappingResult>();
|
||||||
|
return permissionLevels.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.Select(GetMapping).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static RiskLevel GetHighestRisk(string permissionLevels)
|
||||||
|
{
|
||||||
|
var mappings = GetMappings(permissionLevels);
|
||||||
|
if (mappings.Count == 0) return RiskLevel.Low;
|
||||||
|
return mappings.Min(m => m.RiskLevel);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetSimplifiedLabels(string permissionLevels)
|
||||||
|
=> string.Join("; ", GetMappings(permissionLevels).Select(m => m.Label));
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using Microsoft.SharePoint.Client;
|
||||||
|
using SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Web.Core.Helpers;
|
||||||
|
|
||||||
|
public static class SharePointPaginationHelper
|
||||||
|
{
|
||||||
|
private const int DefaultRowLimit = 5000;
|
||||||
|
|
||||||
|
public static async IAsyncEnumerable<ListItem> GetAllItemsAsync(
|
||||||
|
ClientContext ctx,
|
||||||
|
List list,
|
||||||
|
CamlQuery? baseQuery = null,
|
||||||
|
[EnumeratorCancellation] CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var query = baseQuery ?? CamlQuery.CreateAllItemsQuery();
|
||||||
|
query.ViewXml = BuildPagedViewXml(query.ViewXml, DefaultRowLimit);
|
||||||
|
query.ListItemCollectionPosition = null;
|
||||||
|
|
||||||
|
do
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
var items = list.GetItems(query);
|
||||||
|
ctx.Load(items);
|
||||||
|
await ctx.ExecuteQueryAsync();
|
||||||
|
foreach (var item in items) yield return item;
|
||||||
|
query.ListItemCollectionPosition = items.ListItemCollectionPosition;
|
||||||
|
}
|
||||||
|
while (query.ListItemCollectionPosition != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async IAsyncEnumerable<ListItem> GetItemsInFolderAsync(
|
||||||
|
ClientContext ctx,
|
||||||
|
List list,
|
||||||
|
string folderServerRelativeUrl,
|
||||||
|
bool recursive,
|
||||||
|
string[]? viewFields = null,
|
||||||
|
[EnumeratorCancellation] CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var fields = viewFields ?? new[]
|
||||||
|
{
|
||||||
|
"FSObjType", "FileRef", "FileLeafRef", "FileDirRef", "File_x0020_Size"
|
||||||
|
};
|
||||||
|
var viewFieldsXml = string.Join(string.Empty, fields.Select(f => $"<FieldRef Name='{f}' />"));
|
||||||
|
var scope = recursive ? " Scope='RecursiveAll'" : string.Empty;
|
||||||
|
var viewXml =
|
||||||
|
$"<View{scope}><Query></Query>" +
|
||||||
|
$"<ViewFields>{viewFieldsXml}</ViewFields>" +
|
||||||
|
$"<RowLimit Paged='TRUE'>{DefaultRowLimit}</RowLimit></View>";
|
||||||
|
|
||||||
|
var query = new CamlQuery
|
||||||
|
{
|
||||||
|
ViewXml = viewXml,
|
||||||
|
FolderServerRelativeUrl = folderServerRelativeUrl,
|
||||||
|
ListItemCollectionPosition = null
|
||||||
|
};
|
||||||
|
|
||||||
|
do
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
var items = list.GetItems(query);
|
||||||
|
ctx.Load(items);
|
||||||
|
await ctx.ExecuteQueryAsync();
|
||||||
|
foreach (var item in items) yield return item;
|
||||||
|
query.ListItemCollectionPosition = items.ListItemCollectionPosition;
|
||||||
|
}
|
||||||
|
while (query.ListItemCollectionPosition != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string BuildPagedViewXml(string? existingXml, int rowLimit)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(existingXml))
|
||||||
|
return $"<View><RowLimit Paged='TRUE'>{rowLimit}</RowLimit></View>";
|
||||||
|
if (System.Text.RegularExpressions.Regex.IsMatch(
|
||||||
|
existingXml, @"<RowLimit[^>]*>\d+</RowLimit>",
|
||||||
|
System.Text.RegularExpressions.RegexOptions.IgnoreCase))
|
||||||
|
{
|
||||||
|
return System.Text.RegularExpressions.Regex.Replace(
|
||||||
|
existingXml, @"<RowLimit[^>]*>\d+</RowLimit>",
|
||||||
|
$"<RowLimit Paged='TRUE'>{rowLimit}</RowLimit>",
|
||||||
|
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
}
|
||||||
|
return existingXml.Replace("</View>",
|
||||||
|
$"<RowLimit Paged='TRUE'>{rowLimit}</RowLimit></View>",
|
||||||
|
StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Helpers;
|
||||||
|
|
||||||
|
public enum SharingLinkRisk { Low, Medium, High, Unknown }
|
||||||
|
|
||||||
|
public static class SharingLinkLabels
|
||||||
|
{
|
||||||
|
public static (string Label, SharingLinkRisk Risk) Describe(string? rawLinkType)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(rawLinkType)) return (string.Empty, SharingLinkRisk.Unknown);
|
||||||
|
return rawLinkType.Trim() switch
|
||||||
|
{
|
||||||
|
"OrganizationView" => ("Org link · View", SharingLinkRisk.Low),
|
||||||
|
"OrganizationEdit" => ("Org link · Edit", SharingLinkRisk.Medium),
|
||||||
|
"AnonymousView" => ("Anyone · View", SharingLinkRisk.High),
|
||||||
|
"AnonymousEdit" => ("Anyone · Edit", SharingLinkRisk.High),
|
||||||
|
"Flexible" => ("Custom link", SharingLinkRisk.Medium),
|
||||||
|
"Direct" => ("Specific people", SharingLinkRisk.Low),
|
||||||
|
"Existing" => ("Existing access", SharingLinkRisk.Low),
|
||||||
|
"Review" => ("Review only", SharingLinkRisk.Low),
|
||||||
|
"Embed" => ("Embedded link", SharingLinkRisk.Medium),
|
||||||
|
_ => (rawLinkType, SharingLinkRisk.Unknown)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static (string Background, string Foreground) Colors(SharingLinkRisk risk) => risk switch
|
||||||
|
{
|
||||||
|
SharingLinkRisk.Low => ("#D1FAE5", "#065F46"),
|
||||||
|
SharingLinkRisk.Medium => ("#FEF3C7", "#92400E"),
|
||||||
|
SharingLinkRisk.High => ("#FEE2E2", "#991B1B"),
|
||||||
|
_ => ("#F3F4F6", "#374151"),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Helpers;
|
||||||
|
|
||||||
|
public static class StringExtensions
|
||||||
|
{
|
||||||
|
public static string? TrimOrNull(this string? s)
|
||||||
|
=> string.IsNullOrWhiteSpace(s) ? null : s.Trim();
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// SystemGroupKind, SystemGroupClassification, and PermissionEntryHelper are defined in PermissionEntryHelper.cs
|
||||||
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
public class AppConfiguration
|
||||||
|
{
|
||||||
|
public string DataFolder { get; set; } = "/data";
|
||||||
|
public string ExportsFolder { get; set; } = "/data/exports";
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
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 Theme { get; set; } = "System";
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
public class AppUser
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = Guid.NewGuid().ToString();
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
public string DisplayName { get; set; } = string.Empty;
|
||||||
|
public UserRole Role { get; set; } = UserRole.TechN0;
|
||||||
|
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
public DateTimeOffset? LastLogin { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
public class AuditEntry
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = Guid.NewGuid().ToString();
|
||||||
|
public DateTimeOffset Timestamp { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
public string UserEmail { get; set; } = string.Empty;
|
||||||
|
public string UserDisplay { get; set; } = string.Empty;
|
||||||
|
public UserRole UserRole { get; set; }
|
||||||
|
public string Action { get; set; } = string.Empty;
|
||||||
|
public string ClientName { get; set; } = string.Empty;
|
||||||
|
public List<string> Sites { get; set; } = new();
|
||||||
|
public string Details { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
public class BrandingSettings
|
||||||
|
{
|
||||||
|
public LogoData? MspLogo { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using CsvHelper.Configuration.Attributes;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
public class BulkMemberRow
|
||||||
|
{
|
||||||
|
[Name("GroupName")] public string GroupName { get; set; } = string.Empty;
|
||||||
|
[Name("GroupUrl")] public string GroupUrl { get; set; } = string.Empty;
|
||||||
|
[Name("Email")] public string Email { get; set; } = string.Empty;
|
||||||
|
[Name("Role")] public string Role { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
public class BulkItemResult<T>
|
||||||
|
{
|
||||||
|
public T Item { get; }
|
||||||
|
public bool IsSuccess { get; }
|
||||||
|
public string? ErrorMessage { get; }
|
||||||
|
public DateTime Timestamp { get; }
|
||||||
|
|
||||||
|
private BulkItemResult(T item, bool success, string? error)
|
||||||
|
{
|
||||||
|
Item = item; IsSuccess = success; ErrorMessage = error; Timestamp = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static BulkItemResult<T> Success(T item) => new(item, true, null);
|
||||||
|
public static BulkItemResult<T> Failed(T item, string error) => new(item, false, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class BulkOperationSummary<T>
|
||||||
|
{
|
||||||
|
public IReadOnlyList<BulkItemResult<T>> Results { get; }
|
||||||
|
public int TotalCount => Results.Count;
|
||||||
|
public int SuccessCount => Results.Count(r => r.IsSuccess);
|
||||||
|
public int FailedCount => Results.Count(r => !r.IsSuccess);
|
||||||
|
public bool HasFailures => FailedCount > 0;
|
||||||
|
public IReadOnlyList<BulkItemResult<T>> FailedItems => Results.Where(r => !r.IsSuccess).ToList();
|
||||||
|
|
||||||
|
public BulkOperationSummary(IReadOnlyList<BulkItemResult<T>> results) { Results = results; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using CsvHelper.Configuration.Attributes;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
public class BulkSiteRow
|
||||||
|
{
|
||||||
|
[Name("Name")] public string Name { get; set; } = string.Empty;
|
||||||
|
[Name("Alias")] public string Alias { get; set; } = string.Empty;
|
||||||
|
[Name("Type")] public string Type { get; set; } = string.Empty;
|
||||||
|
[Name("Template")] public string Template { get; set; } = string.Empty;
|
||||||
|
[Name("Owners")] public string Owners { get; set; } = string.Empty;
|
||||||
|
[Name("Members")] public string Members { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
public enum ConflictPolicy { Skip, Overwrite, Rename }
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
public record ConsolidatedPermissionEntry(
|
||||||
|
string UserDisplayName,
|
||||||
|
string UserLogin,
|
||||||
|
string PermissionLevel,
|
||||||
|
AccessType AccessType,
|
||||||
|
string GrantedThrough,
|
||||||
|
bool IsHighPrivilege,
|
||||||
|
bool IsExternalUser,
|
||||||
|
IReadOnlyList<LocationInfo> Locations,
|
||||||
|
string? TargetUrl = null,
|
||||||
|
string? TargetLabel = null,
|
||||||
|
string? SharingLinkType = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
public int LocationCount => Locations.Count;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
public class CsvValidationRow<T>
|
||||||
|
{
|
||||||
|
public T? Record { get; }
|
||||||
|
public bool IsValid => Errors.Count == 0;
|
||||||
|
public List<string> Errors { get; }
|
||||||
|
public string? RawRecord { get; }
|
||||||
|
|
||||||
|
public CsvValidationRow(T record, List<string> errors)
|
||||||
|
{
|
||||||
|
Record = record; Errors = errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
private CsvValidationRow(string rawRecord, string parseError)
|
||||||
|
{
|
||||||
|
Record = default; RawRecord = rawRecord; Errors = new List<string> { parseError };
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CsvValidationRow<T> ParseError(string? rawRecord, string error)
|
||||||
|
=> new(rawRecord ?? string.Empty, error);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
public class DuplicateGroup
|
||||||
|
{
|
||||||
|
public string GroupKey { get; set; } = string.Empty;
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public List<DuplicateItem> Items { get; set; } = new();
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
public class DuplicateItem
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string Path { get; set; } = string.Empty;
|
||||||
|
public string Library { get; set; } = string.Empty;
|
||||||
|
public long? SizeBytes { get; set; }
|
||||||
|
public DateTime? Created { get; set; }
|
||||||
|
public DateTime? Modified { get; set; }
|
||||||
|
public int? FolderCount { get; set; }
|
||||||
|
public int? FileCount { get; set; }
|
||||||
|
public string SiteUrl { get; set; } = string.Empty;
|
||||||
|
public string SiteTitle { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
public record DuplicateScanOptions(
|
||||||
|
string Mode = "Files",
|
||||||
|
bool MatchSize = true,
|
||||||
|
bool MatchCreated = false,
|
||||||
|
bool MatchModified = false,
|
||||||
|
bool MatchSubfolderCount = false,
|
||||||
|
bool MatchFileCount = false,
|
||||||
|
bool IncludeSubsites = false,
|
||||||
|
string? Library = null
|
||||||
|
);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
public record FileTypeMetric(
|
||||||
|
string Extension,
|
||||||
|
long TotalSizeBytes,
|
||||||
|
int FileCount)
|
||||||
|
{
|
||||||
|
public string DisplayLabel => string.IsNullOrEmpty(Extension)
|
||||||
|
? "No Extension"
|
||||||
|
: Extension.TrimStart('.').ToUpperInvariant();
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using CsvHelper.Configuration.Attributes;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
public class FolderStructureRow
|
||||||
|
{
|
||||||
|
[Name("Level1")] public string Level1 { get; set; } = string.Empty;
|
||||||
|
[Name("Level2")] public string Level2 { get; set; } = string.Empty;
|
||||||
|
[Name("Level3")] public string Level3 { get; set; } = string.Empty;
|
||||||
|
[Name("Level4")] public string Level4 { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string BuildPath()
|
||||||
|
{
|
||||||
|
var parts = new[] { Level1, Level2, Level3, Level4 }.Where(s => !string.IsNullOrWhiteSpace(s));
|
||||||
|
return string.Join("/", parts);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
public record GraphDirectoryUser(
|
||||||
|
string DisplayName,
|
||||||
|
string UserPrincipalName,
|
||||||
|
string? Mail,
|
||||||
|
string? Department,
|
||||||
|
string? JobTitle,
|
||||||
|
string? UserType);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
public record LocationInfo(
|
||||||
|
string SiteUrl,
|
||||||
|
string SiteTitle,
|
||||||
|
string ObjectTitle,
|
||||||
|
string ObjectUrl,
|
||||||
|
string ObjectType
|
||||||
|
);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
public record LogoData
|
||||||
|
{
|
||||||
|
public string Base64 { get; init; } = string.Empty;
|
||||||
|
public string MimeType { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
public record OperationProgress(int Current, int Total, string Message, bool IsIndeterminate = false)
|
||||||
|
{
|
||||||
|
public static OperationProgress Indeterminate(string message) =>
|
||||||
|
new(0, 0, message, IsIndeterminate: true);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
public record PermissionEntry(
|
||||||
|
string ObjectType,
|
||||||
|
string Title,
|
||||||
|
string Url,
|
||||||
|
bool HasUniquePermissions,
|
||||||
|
string Users,
|
||||||
|
string UserLogins,
|
||||||
|
string PermissionLevels,
|
||||||
|
string GrantedThrough,
|
||||||
|
string PrincipalType,
|
||||||
|
bool WasAutoElevated = false,
|
||||||
|
string? TargetUrl = null,
|
||||||
|
string? TargetLabel = null,
|
||||||
|
string? SharingLinkType = null
|
||||||
|
);
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
public record PermissionSummary(
|
||||||
|
string Label,
|
||||||
|
RiskLevel RiskLevel,
|
||||||
|
int Count,
|
||||||
|
int DistinctUsers
|
||||||
|
);
|
||||||
|
|
||||||
|
public static class PermissionSummaryBuilder
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<RiskLevel, string> Labels = new()
|
||||||
|
{
|
||||||
|
[RiskLevel.High] = "High Risk",
|
||||||
|
[RiskLevel.Medium] = "Medium Risk",
|
||||||
|
[RiskLevel.Low] = "Low Risk",
|
||||||
|
[RiskLevel.ReadOnly] = "Read Only",
|
||||||
|
};
|
||||||
|
|
||||||
|
public static IReadOnlyList<PermissionSummary> Build(IEnumerable<SimplifiedPermissionEntry> entries)
|
||||||
|
{
|
||||||
|
var grouped = entries.GroupBy(e => e.RiskLevel).ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
return Enum.GetValues<RiskLevel>().Select(level =>
|
||||||
|
{
|
||||||
|
var items = grouped.GetValueOrDefault(level, new List<SimplifiedPermissionEntry>());
|
||||||
|
var distinctUsers = items
|
||||||
|
.SelectMany(e => e.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
.Select(u => u.Trim()).Where(u => u.Length > 0)
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase).Count();
|
||||||
|
return new PermissionSummary(Labels[level], level, items.Count, distinctUsers);
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
public record ReportBranding(LogoData? MspLogo, LogoData? ClientLogo);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
public record ResolvedMember(string DisplayName, string Login);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
public enum RiskLevel
|
||||||
|
{
|
||||||
|
High,
|
||||||
|
Medium,
|
||||||
|
Low,
|
||||||
|
ReadOnly
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
public record ScanOptions(
|
||||||
|
bool IncludeInherited = false,
|
||||||
|
bool ScanFolders = true,
|
||||||
|
int FolderDepth = 1,
|
||||||
|
bool IncludeSubsites = false
|
||||||
|
);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
public record SearchOptions(
|
||||||
|
string[] Extensions,
|
||||||
|
string? Regex,
|
||||||
|
DateTime? CreatedAfter,
|
||||||
|
DateTime? CreatedBefore,
|
||||||
|
DateTime? ModifiedAfter,
|
||||||
|
DateTime? ModifiedBefore,
|
||||||
|
string? CreatedBy,
|
||||||
|
string? ModifiedBy,
|
||||||
|
string? Library,
|
||||||
|
int MaxResults,
|
||||||
|
string SiteUrl
|
||||||
|
);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
public class SearchResult
|
||||||
|
{
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
public string Path { get; set; } = string.Empty;
|
||||||
|
public string FileExtension { get; set; } = string.Empty;
|
||||||
|
public DateTime? Created { get; set; }
|
||||||
|
public DateTime? LastModified { get; set; }
|
||||||
|
public string Author { get; set; } = string.Empty;
|
||||||
|
public string ModifiedBy { get; set; } = string.Empty;
|
||||||
|
public long SizeBytes { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
/// <summary>Held in ProtectedSessionStorage — never persisted to disk.</summary>
|
||||||
|
public class SessionTokens
|
||||||
|
{
|
||||||
|
public string RefreshToken { get; set; } = string.Empty;
|
||||||
|
public string TenantId { get; set; } = string.Empty;
|
||||||
|
public string ClientId { get; set; } = string.Empty;
|
||||||
|
public string SpHost { get; set; } = string.Empty;
|
||||||
|
public string UserPrincipalName { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using SharepointToolbox.Web.Core.Helpers;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
public class SimplifiedPermissionEntry
|
||||||
|
{
|
||||||
|
public PermissionEntry Inner { get; }
|
||||||
|
public string SimplifiedLabels { get; }
|
||||||
|
public RiskLevel RiskLevel { get; }
|
||||||
|
public IReadOnlyList<PermissionLevelMapping.MappingResult> Mappings { get; }
|
||||||
|
|
||||||
|
public string ObjectType => Inner.ObjectType;
|
||||||
|
public string Title => Inner.Title;
|
||||||
|
public string Url => Inner.Url;
|
||||||
|
public bool HasUniquePermissions => Inner.HasUniquePermissions;
|
||||||
|
public string Users => Inner.Users;
|
||||||
|
public string UserLogins => Inner.UserLogins;
|
||||||
|
public string PermissionLevels => Inner.PermissionLevels;
|
||||||
|
public string GrantedThrough => Inner.GrantedThrough;
|
||||||
|
public string PrincipalType => Inner.PrincipalType;
|
||||||
|
public string? TargetUrl => Inner.TargetUrl;
|
||||||
|
public string? TargetLabel => Inner.TargetLabel;
|
||||||
|
public string? SharingLinkType => Inner.SharingLinkType;
|
||||||
|
|
||||||
|
public SimplifiedPermissionEntry(PermissionEntry entry)
|
||||||
|
{
|
||||||
|
Inner = entry;
|
||||||
|
Mappings = PermissionLevelMapping.GetMappings(entry.PermissionLevels);
|
||||||
|
SimplifiedLabels = PermissionLevelMapping.GetSimplifiedLabels(entry.PermissionLevels);
|
||||||
|
RiskLevel = PermissionLevelMapping.GetHighestRisk(entry.PermissionLevels);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IReadOnlyList<SimplifiedPermissionEntry> WrapAll(IEnumerable<PermissionEntry> entries)
|
||||||
|
=> entries.Select(e => new SimplifiedPermissionEntry(e)).ToList();
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
public record SiteInfo(string Url, string Title)
|
||||||
|
{
|
||||||
|
public long StorageUsedMb { get; init; }
|
||||||
|
public long StorageQuotaMb { get; init; }
|
||||||
|
public string Template { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public SiteKind Kind => SiteKindHelper.FromTemplate(Template);
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum SiteKind
|
||||||
|
{
|
||||||
|
Unknown,
|
||||||
|
TeamSite,
|
||||||
|
CommunicationSite,
|
||||||
|
Classic
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class SiteKindHelper
|
||||||
|
{
|
||||||
|
public static SiteKind FromTemplate(string template)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(template)) return SiteKind.Unknown;
|
||||||
|
if (template.StartsWith("GROUP#", StringComparison.OrdinalIgnoreCase)) return SiteKind.TeamSite;
|
||||||
|
if (template.StartsWith("SITEPAGEPUBLISHING#", StringComparison.OrdinalIgnoreCase)) return SiteKind.CommunicationSite;
|
||||||
|
if (template.StartsWith("STS#", StringComparison.OrdinalIgnoreCase)) return SiteKind.Classic;
|
||||||
|
return SiteKind.Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string DisplayName(SiteKind kind) => kind switch
|
||||||
|
{
|
||||||
|
SiteKind.TeamSite => "Team site",
|
||||||
|
SiteKind.CommunicationSite => "Communication site",
|
||||||
|
SiteKind.Classic => "Classic site",
|
||||||
|
_ => "Other"
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
public class SiteTemplate
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = Guid.NewGuid().ToString();
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string SourceUrl { get; set; } = string.Empty;
|
||||||
|
public DateTime CapturedAt { get; set; }
|
||||||
|
public string SiteType { get; set; } = string.Empty;
|
||||||
|
public SiteTemplateOptions Options { get; set; } = new();
|
||||||
|
public TemplateSettings? Settings { get; set; }
|
||||||
|
public TemplateLogo? Logo { get; set; }
|
||||||
|
public List<TemplateLibraryInfo> Libraries { get; set; } = new();
|
||||||
|
public List<TemplatePermissionGroup> PermissionGroups { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TemplateSettings
|
||||||
|
{
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public int Language { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TemplateLogo
|
||||||
|
{
|
||||||
|
public string LogoUrl { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
public class SiteTemplateOptions
|
||||||
|
{
|
||||||
|
public bool CaptureLibraries { get; set; } = true;
|
||||||
|
public bool CaptureFolders { get; set; } = true;
|
||||||
|
public bool CapturePermissionGroups { get; set; } = true;
|
||||||
|
public bool CaptureLogo { get; set; } = true;
|
||||||
|
public bool CaptureSettings { get; set; } = true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
public class StorageNode
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string Url { get; set; } = string.Empty;
|
||||||
|
public string SiteTitle { get; set; } = string.Empty;
|
||||||
|
public string Library { get; set; } = string.Empty;
|
||||||
|
public long TotalSizeBytes { get; set; }
|
||||||
|
public long FileStreamSizeBytes { get; set; }
|
||||||
|
public long VersionSizeBytes => Math.Max(0L, TotalSizeBytes - FileStreamSizeBytes);
|
||||||
|
public long TotalFileCount { get; set; }
|
||||||
|
public DateTime? LastModified { get; set; }
|
||||||
|
public int IndentLevel { get; set; }
|
||||||
|
public StorageNodeKind Kind { get; set; } = StorageNodeKind.Library;
|
||||||
|
public List<StorageNode> Children { get; set; } = new();
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
public enum StorageNodeKind
|
||||||
|
{
|
||||||
|
Library,
|
||||||
|
HiddenLibrary,
|
||||||
|
PreservationHold,
|
||||||
|
ListAttachments,
|
||||||
|
RecycleBin,
|
||||||
|
Subsite
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
public record StorageScanOptions(
|
||||||
|
bool PerLibrary = true,
|
||||||
|
bool IncludeSubsites = false,
|
||||||
|
int FolderDepth = 0,
|
||||||
|
bool IncludeHiddenLibraries = true,
|
||||||
|
bool IncludePreservationHold = true,
|
||||||
|
bool IncludeListAttachments = true,
|
||||||
|
bool IncludeRecycleBin = true
|
||||||
|
);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using SharepointToolbox.Web.Core.Helpers;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
public record SystemGroupTarget(
|
||||||
|
SystemGroupKind Kind,
|
||||||
|
string Label,
|
||||||
|
string Url,
|
||||||
|
string? LinkType = null
|
||||||
|
);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
public class TemplateFolderInfo
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string RelativePath { get; set; } = string.Empty;
|
||||||
|
public List<TemplateFolderInfo> Children { get; set; } = new();
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
public class TemplateLibraryInfo
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string BaseType { get; set; } = string.Empty;
|
||||||
|
public int BaseTemplate { get; set; }
|
||||||
|
public List<TemplateFolderInfo> Folders { get; set; } = new();
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
public class TemplatePermissionGroup
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public List<string> RoleDefinitions { get; set; } = new();
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
public class TenantProfile
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = Guid.NewGuid().ToString();
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>https://contoso.sharepoint.com</summary>
|
||||||
|
public string TenantUrl { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Azure AD tenant GUID or domain (e.g. contoso.onmicrosoft.com). Required for app-only Graph calls.</summary>
|
||||||
|
public string TenantId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Azure AD app registration client (application) ID.</summary>
|
||||||
|
public string ClientId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public LogoData? ClientLogo { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
public class TransferJob
|
||||||
|
{
|
||||||
|
public string SourceSiteUrl { get; set; } = string.Empty;
|
||||||
|
public string SourceLibrary { get; set; } = string.Empty;
|
||||||
|
public string SourceFolderPath { get; set; } = string.Empty;
|
||||||
|
public string DestinationSiteUrl { get; set; } = string.Empty;
|
||||||
|
public string DestinationLibrary { get; set; } = string.Empty;
|
||||||
|
public string DestinationFolderPath { get; set; } = string.Empty;
|
||||||
|
public TransferMode Mode { get; set; } = TransferMode.Copy;
|
||||||
|
public ConflictPolicy ConflictPolicy { get; set; } = ConflictPolicy.Skip;
|
||||||
|
public IReadOnlyList<string> SelectedFilePaths { get; set; } = Array.Empty<string>();
|
||||||
|
public bool IncludeSourceFolder { get; set; }
|
||||||
|
public bool CopyFolderContents { get; set; } = true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
public enum TransferMode { Copy, Move }
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
public enum AccessType
|
||||||
|
{
|
||||||
|
Direct,
|
||||||
|
Group,
|
||||||
|
Inherited
|
||||||
|
}
|
||||||
|
|
||||||
|
public record UserAccessEntry(
|
||||||
|
string UserDisplayName,
|
||||||
|
string UserLogin,
|
||||||
|
string SiteUrl,
|
||||||
|
string SiteTitle,
|
||||||
|
string ObjectType,
|
||||||
|
string ObjectTitle,
|
||||||
|
string ObjectUrl,
|
||||||
|
string PermissionLevel,
|
||||||
|
AccessType AccessType,
|
||||||
|
string GrantedThrough,
|
||||||
|
bool IsHighPrivilege,
|
||||||
|
bool IsExternalUser,
|
||||||
|
string? TargetUrl = null,
|
||||||
|
string? TargetLabel = null,
|
||||||
|
string? SharingLinkType = null
|
||||||
|
);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
public enum UserRole
|
||||||
|
{
|
||||||
|
TechN0 = 0, // Read-only
|
||||||
|
TechN1 = 1, // Read/Write
|
||||||
|
Admin = 2 // Read/Write + account management + client profiles
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
public record VersionCleanupOptions(
|
||||||
|
IReadOnlyList<string> LibraryTitles,
|
||||||
|
int KeepLast,
|
||||||
|
bool KeepFirst)
|
||||||
|
{
|
||||||
|
public static VersionCleanupOptions Default => new(Array.Empty<string>(), 5, false);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
public class VersionCleanupResult
|
||||||
|
{
|
||||||
|
public string SiteUrl { get; init; } = string.Empty;
|
||||||
|
public string Library { get; init; } = string.Empty;
|
||||||
|
public string FileServerRelativeUrl { get; init; } = string.Empty;
|
||||||
|
public string FileName { get; init; } = string.Empty;
|
||||||
|
public int VersionsBefore { get; init; }
|
||||||
|
public int VersionsDeleted { get; init; }
|
||||||
|
public int VersionsRemaining { get; init; }
|
||||||
|
public long BytesFreed { get; init; }
|
||||||
|
public string? Error { get; init; }
|
||||||
|
}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||||
|
WORKDIR /app
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY ["SharepointToolbox.Web.csproj", "."]
|
||||||
|
RUN dotnet restore
|
||||||
|
COPY . .
|
||||||
|
RUN dotnet publish -c Release -o /app/publish --no-restore
|
||||||
|
|
||||||
|
FROM base AS final
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /app/publish .
|
||||||
|
|
||||||
|
# Volume for persistent data (profiles, settings, templates, logs, exports)
|
||||||
|
VOLUME ["/data"]
|
||||||
|
|
||||||
|
ENV ASPNETCORE_URLS=http://+:8080
|
||||||
|
ENV DataFolder=/data
|
||||||
|
|
||||||
|
ENTRYPOINT ["dotnet", "SharepointToolbox.Web.dll"]
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using Microsoft.Graph;
|
||||||
|
using SharepointToolbox.Web.Core.Models;
|
||||||
|
using SharepointToolbox.Web.Services;
|
||||||
|
using SharepointToolbox.Web.Services.Session;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Web.Infrastructure.Auth;
|
||||||
|
|
||||||
|
/// <summary>Delegated Graph client using OAuth2 refresh-token flow via ISessionManager.</summary>
|
||||||
|
public class GraphClientFactory
|
||||||
|
{
|
||||||
|
private readonly ISessionCredentialStore _credentialStore;
|
||||||
|
private readonly ISessionManager _sessionManager;
|
||||||
|
|
||||||
|
public GraphClientFactory(ISessionCredentialStore credentialStore, ISessionManager sessionManager)
|
||||||
|
{
|
||||||
|
_credentialStore = credentialStore;
|
||||||
|
_sessionManager = sessionManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<GraphServiceClient> CreateClientAsync(TenantProfile profile)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(profile.TenantId);
|
||||||
|
|
||||||
|
var hasTokens = await _credentialStore.HasCredentialsAsync();
|
||||||
|
if (!hasTokens)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"No session tokens found. Please authenticate via Microsoft first.");
|
||||||
|
|
||||||
|
var credential = new SessionTokenCredential(_sessionManager);
|
||||||
|
return new GraphServiceClient(credential, ["https://graph.microsoft.com/.default"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
using Microsoft.SharePoint.Client;
|
||||||
|
using SharepointToolbox.Web.Core.Models;
|
||||||
|
using SharepointToolbox.Web.Services;
|
||||||
|
using SharepointToolbox.Web.Services.Auth;
|
||||||
|
using SharepointToolbox.Web.Services.Session;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Web.Infrastructure.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Delegated session manager using OAuth2 refresh tokens.
|
||||||
|
/// Tokens come from ISessionCredentialStore (ProtectedSessionStorage — browser-side only).
|
||||||
|
/// Caches access tokens in-memory per scope for the duration of the Blazor circuit.
|
||||||
|
/// Scoped per Blazor circuit.
|
||||||
|
/// </summary>
|
||||||
|
public class SessionManager : ISessionManager
|
||||||
|
{
|
||||||
|
private readonly ISessionCredentialStore _credentialStore;
|
||||||
|
private readonly ITokenRefreshService _tokenRefresh;
|
||||||
|
private readonly Dictionary<string, ClientContext> _contexts = new();
|
||||||
|
private readonly Dictionary<string, (string Token, DateTimeOffset ExpiresAt)> _accessTokenCache = new();
|
||||||
|
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||||
|
|
||||||
|
public SessionManager(ISessionCredentialStore credentialStore, ITokenRefreshService tokenRefresh)
|
||||||
|
{
|
||||||
|
_credentialStore = credentialStore;
|
||||||
|
_tokenRefresh = tokenRefresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsAuthenticated(string tenantUrl) => _contexts.ContainsKey(NormalizeUrl(tenantUrl));
|
||||||
|
|
||||||
|
public async Task<(string Token, DateTimeOffset ExpiresAt)> GetAccessTokenWithExpiryAsync(
|
||||||
|
string scope, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await _lock.WaitAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_accessTokenCache.TryGetValue(scope, out var cached) && cached.ExpiresAt > DateTimeOffset.UtcNow)
|
||||||
|
return cached;
|
||||||
|
|
||||||
|
var tokens = await _credentialStore.GetAsync()
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"No session tokens found. Please authenticate via Microsoft first.");
|
||||||
|
|
||||||
|
var result = await _tokenRefresh.RefreshAsync(tokens.RefreshToken, tokens.TenantId, tokens.ClientId, scope);
|
||||||
|
|
||||||
|
// Persist rotated refresh token back to browser storage
|
||||||
|
if (result.RefreshToken != tokens.RefreshToken)
|
||||||
|
await _credentialStore.UpdateRefreshTokenAsync(result.RefreshToken);
|
||||||
|
|
||||||
|
var entry = (result.AccessToken, result.ExpiresAt);
|
||||||
|
_accessTokenCache[scope] = entry;
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
finally { _lock.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ClientContext> GetOrCreateContextAsync(TenantProfile profile, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(profile.TenantUrl);
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(profile.TenantId);
|
||||||
|
|
||||||
|
var key = NormalizeUrl(profile.TenantUrl);
|
||||||
|
var spScope = NormalizeScopeUrl(profile.TenantUrl) + "/.default";
|
||||||
|
|
||||||
|
await _lock.WaitAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_contexts.TryGetValue(key, out var existing))
|
||||||
|
return existing;
|
||||||
|
|
||||||
|
// Validate tokens are present before creating context
|
||||||
|
var tokens = await _credentialStore.GetAsync()
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"No session tokens found. Please authenticate via Microsoft first.");
|
||||||
|
|
||||||
|
_ = tokens; // validated; actual token acquired per-request below
|
||||||
|
|
||||||
|
var ctx = new ClientContext(profile.TenantUrl);
|
||||||
|
ctx.ExecutingWebRequest += async (_, e) =>
|
||||||
|
{
|
||||||
|
var (token, _) = await GetAccessTokenWithExpiryAsyncInternal(spScope, tokens.TenantId);
|
||||||
|
e.WebRequestExecutor.RequestHeaders["Authorization"] = "Bearer " + token;
|
||||||
|
};
|
||||||
|
|
||||||
|
_contexts[key] = ctx;
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
finally { _lock.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ClientContext> GetOrCreateContextAsync(string siteUrl, TenantProfile profile, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var profileForSite = new TenantProfile
|
||||||
|
{
|
||||||
|
Id = profile.Id,
|
||||||
|
Name = profile.Name,
|
||||||
|
TenantUrl = siteUrl,
|
||||||
|
TenantId = profile.TenantId,
|
||||||
|
ClientId = profile.ClientId,
|
||||||
|
ClientLogo = profile.ClientLogo,
|
||||||
|
};
|
||||||
|
return await GetOrCreateContextAsync(profileForSite, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ClearSessionAsync(string tenantUrl)
|
||||||
|
{
|
||||||
|
var key = NormalizeUrl(tenantUrl);
|
||||||
|
await _lock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_contexts.TryGetValue(key, out var ctx))
|
||||||
|
{
|
||||||
|
ctx.Dispose();
|
||||||
|
_contexts.Remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally { _lock.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ClearAllAsync()
|
||||||
|
{
|
||||||
|
await _lock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var ctx in _contexts.Values) ctx.Dispose();
|
||||||
|
_contexts.Clear();
|
||||||
|
_accessTokenCache.Clear();
|
||||||
|
}
|
||||||
|
finally { _lock.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal version that bypasses the outer lock (called from ExecutingWebRequest which may run concurrently)
|
||||||
|
private async Task<(string Token, DateTimeOffset ExpiresAt)> GetAccessTokenWithExpiryAsyncInternal(
|
||||||
|
string scope, string tenantId)
|
||||||
|
{
|
||||||
|
if (_accessTokenCache.TryGetValue(scope, out var cached) && cached.ExpiresAt > DateTimeOffset.UtcNow)
|
||||||
|
return cached;
|
||||||
|
|
||||||
|
var tokens = await _credentialStore.GetAsync()
|
||||||
|
?? throw new InvalidOperationException("No session tokens in store.");
|
||||||
|
|
||||||
|
var result = await _tokenRefresh.RefreshAsync(tokens.RefreshToken, tenantId, tokens.ClientId, scope);
|
||||||
|
|
||||||
|
if (result.RefreshToken != tokens.RefreshToken)
|
||||||
|
await _credentialStore.UpdateRefreshTokenAsync(result.RefreshToken);
|
||||||
|
|
||||||
|
var entry = (result.AccessToken, result.ExpiresAt);
|
||||||
|
_accessTokenCache[scope] = entry;
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeUrl(string url) => url.TrimEnd('/').ToLowerInvariant();
|
||||||
|
|
||||||
|
private static string NormalizeScopeUrl(string siteUrl)
|
||||||
|
{
|
||||||
|
if (Uri.TryCreate(siteUrl, UriKind.Absolute, out var uri))
|
||||||
|
return $"{uri.Scheme}://{uri.Host}";
|
||||||
|
return siteUrl.TrimEnd('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using Azure.Core;
|
||||||
|
using SharepointToolbox.Web.Services;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Web.Infrastructure.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// TokenCredential backed by the circuit's ISessionManager.
|
||||||
|
/// Lets GraphServiceClient call GetTokenAsync transparently using our refresh-token flow.
|
||||||
|
/// </summary>
|
||||||
|
public class SessionTokenCredential : TokenCredential
|
||||||
|
{
|
||||||
|
private readonly ISessionManager _sessionManager;
|
||||||
|
|
||||||
|
public SessionTokenCredential(ISessionManager sessionManager) { _sessionManager = sessionManager; }
|
||||||
|
|
||||||
|
public override async ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var scope = requestContext.Scopes.FirstOrDefault() ?? "https://graph.microsoft.com/.default";
|
||||||
|
var (token, expiresAt) = await _sessionManager.GetAccessTokenWithExpiryAsync(scope, ct);
|
||||||
|
return new AccessToken(token, expiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken ct)
|
||||||
|
=> GetTokenAsync(requestContext, ct).AsTask().GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.AspNetCore.WebUtilities;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using SharepointToolbox.Web.Core.Config;
|
||||||
|
using SharepointToolbox.Web.Core.Models;
|
||||||
|
using SharepointToolbox.Web.Infrastructure.Persistence;
|
||||||
|
using SharepointToolbox.Web.Services.Auth;
|
||||||
|
using SharepointToolbox.Web.Services.OAuth;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Web.Infrastructure.OAuth;
|
||||||
|
|
||||||
|
public static class OAuthEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapOAuthEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
// ── Connect: initiate PKCE flow for client tenant access ──────────────────
|
||||||
|
app.MapGet("/connect/initiate", async (
|
||||||
|
HttpContext ctx,
|
||||||
|
string profileId,
|
||||||
|
string? returnUrl,
|
||||||
|
ProfileRepository profiles,
|
||||||
|
IOAuthFlowCache flowCache,
|
||||||
|
IOptions<ClientConnectOptions> opts) =>
|
||||||
|
{
|
||||||
|
if (!ctx.User.Identity?.IsAuthenticated ?? true)
|
||||||
|
return Results.Unauthorized();
|
||||||
|
|
||||||
|
var allProfiles = await profiles.LoadAsync();
|
||||||
|
var profile = allProfiles.FirstOrDefault(p => p.Id == profileId);
|
||||||
|
if (profile is null)
|
||||||
|
return Results.NotFound($"Profile '{profileId}' not found.");
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(profile.ClientId))
|
||||||
|
return Results.Problem($"Profile '{profile.Name}' has no ClientId configured.");
|
||||||
|
|
||||||
|
var o = opts.Value;
|
||||||
|
if (string.IsNullOrEmpty(o.RedirectUri))
|
||||||
|
return Results.Problem("ClientConnect:RedirectUri is not configured on this server.");
|
||||||
|
|
||||||
|
var (state, authUrl) = BuildAuthUrl(
|
||||||
|
tenantId: profile.TenantId,
|
||||||
|
clientId: profile.ClientId,
|
||||||
|
redirectUri: o.RedirectUri,
|
||||||
|
scope: "openid offline_access",
|
||||||
|
flowCache: flowCache,
|
||||||
|
flowState: new OAuthFlowState
|
||||||
|
{
|
||||||
|
ProfileId = profileId,
|
||||||
|
TenantId = profile.TenantId,
|
||||||
|
ClientId = profile.ClientId,
|
||||||
|
SpHost = ExtractHost(profile.TenantUrl),
|
||||||
|
ReturnUrl = string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl,
|
||||||
|
IsRegistration = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return Results.Redirect(authUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Register: initiate admin auth to create app registration in client tenant
|
||||||
|
app.MapGet("/connect/register-initiate", (
|
||||||
|
HttpContext ctx,
|
||||||
|
string tenantId,
|
||||||
|
string tenantName,
|
||||||
|
string tenantUrl,
|
||||||
|
string? returnUrl,
|
||||||
|
IOAuthFlowCache flowCache,
|
||||||
|
IOptions<ClientConnectOptions> opts,
|
||||||
|
IConfiguration config) =>
|
||||||
|
{
|
||||||
|
if (!ctx.User.Identity?.IsAuthenticated ?? true)
|
||||||
|
return Results.Unauthorized();
|
||||||
|
|
||||||
|
var o = opts.Value;
|
||||||
|
if (string.IsNullOrEmpty(o.RedirectUri))
|
||||||
|
return Results.Problem("ClientConnect:RedirectUri is not configured on this server.");
|
||||||
|
|
||||||
|
// Use our OIDC app (confidential client) to authenticate against the client tenant
|
||||||
|
var oidcClientId = config["Oidc:ClientId"];
|
||||||
|
if (string.IsNullOrEmpty(oidcClientId))
|
||||||
|
return Results.Problem("Oidc:ClientId is not configured.");
|
||||||
|
|
||||||
|
// Need admin consent for Application.ReadWrite.All + DelegatedPermissionGrant.ReadWrite.All
|
||||||
|
var (_, authUrl) = BuildAuthUrl(
|
||||||
|
tenantId: tenantId,
|
||||||
|
clientId: oidcClientId,
|
||||||
|
redirectUri: o.RedirectUri,
|
||||||
|
scope: "https://graph.microsoft.com/Application.ReadWrite.All " +
|
||||||
|
"https://graph.microsoft.com/DelegatedPermissionGrant.ReadWrite.All " +
|
||||||
|
"openid offline_access",
|
||||||
|
flowCache: flowCache,
|
||||||
|
flowState: new OAuthFlowState
|
||||||
|
{
|
||||||
|
TenantId = tenantId,
|
||||||
|
TenantName = tenantName,
|
||||||
|
TenantUrl = tenantUrl,
|
||||||
|
ReturnUrl = string.IsNullOrEmpty(returnUrl) ? "/profiles" : returnUrl,
|
||||||
|
IsRegistration = true,
|
||||||
|
},
|
||||||
|
promptConsent: true);
|
||||||
|
|
||||||
|
return Results.Redirect(authUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Shared callback for both connect and register flows ────────────────────
|
||||||
|
app.MapGet("/connect/callback", async (
|
||||||
|
string? code,
|
||||||
|
string? state,
|
||||||
|
string? error,
|
||||||
|
string? error_description,
|
||||||
|
IOAuthFlowCache flowCache,
|
||||||
|
IOptions<ClientConnectOptions> opts,
|
||||||
|
IConfiguration config,
|
||||||
|
IAppRegistrationService appRegService,
|
||||||
|
IHttpClientFactory httpClientFactory) =>
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(error))
|
||||||
|
{
|
||||||
|
var errMsg = Uri.EscapeDataString(error_description ?? error);
|
||||||
|
return Results.Redirect($"/?connect_error={errMsg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state))
|
||||||
|
return Results.BadRequest("Missing code or state.");
|
||||||
|
|
||||||
|
var flowState = flowCache.GetAndRemoveFlowState(state);
|
||||||
|
if (flowState is null)
|
||||||
|
return Results.BadRequest("Invalid or expired state. Please try connecting again.");
|
||||||
|
|
||||||
|
var o = opts.Value;
|
||||||
|
var http = httpClientFactory.CreateClient("oauth");
|
||||||
|
|
||||||
|
if (flowState.IsRegistration)
|
||||||
|
{
|
||||||
|
// ── Registration flow: confidential client exchange (OIDC app + secret) ──
|
||||||
|
var oidcClientId = config["Oidc:ClientId"]!;
|
||||||
|
var oidcClientSecret = config["Oidc:ClientSecret"]!;
|
||||||
|
|
||||||
|
var body = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["grant_type"] = "authorization_code",
|
||||||
|
["client_id"] = oidcClientId,
|
||||||
|
["client_secret"] = oidcClientSecret,
|
||||||
|
["code"] = code,
|
||||||
|
["redirect_uri"] = o.RedirectUri,
|
||||||
|
["code_verifier"] = flowState.CodeVerifier,
|
||||||
|
["scope"] = "https://graph.microsoft.com/Application.ReadWrite.All " +
|
||||||
|
"https://graph.microsoft.com/DelegatedPermissionGrant.ReadWrite.All " +
|
||||||
|
"openid offline_access",
|
||||||
|
};
|
||||||
|
|
||||||
|
var tokenUrl = $"https://login.microsoftonline.com/{flowState.TenantId}/oauth2/v2.0/token";
|
||||||
|
var resp = await http.PostAsync(tokenUrl, new FormUrlEncodedContent(body));
|
||||||
|
var json = await resp.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
|
if (!resp.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var msg = Uri.EscapeDataString($"Admin token exchange failed: {json}");
|
||||||
|
return Results.Redirect($"/profiles?connect_error={msg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
var accessToken = doc.RootElement.GetProperty("access_token").GetString()!;
|
||||||
|
|
||||||
|
string clientId;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
clientId = await appRegService.CreateAsync(
|
||||||
|
adminAccessToken: accessToken,
|
||||||
|
tenantName: flowState.TenantName,
|
||||||
|
redirectUri: o.RedirectUri);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
var msg = Uri.EscapeDataString($"App registration failed: {ex.Message}");
|
||||||
|
return Results.Redirect($"/profiles?connect_error={msg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var regKey = Guid.NewGuid().ToString("N");
|
||||||
|
flowCache.StoreRegistrationResult(regKey, new AppRegistrationResult
|
||||||
|
{
|
||||||
|
ClientId = clientId,
|
||||||
|
TenantId = flowState.TenantId,
|
||||||
|
TenantUrl = flowState.TenantUrl,
|
||||||
|
TenantName = flowState.TenantName,
|
||||||
|
DisplayName = $"SP Toolbox — {flowState.TenantName}",
|
||||||
|
});
|
||||||
|
|
||||||
|
var returnTo = QueryHelpers.AddQueryString(flowState.ReturnUrl, "reg_result_key", regKey);
|
||||||
|
return Results.Redirect(returnTo);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// ── Connect flow: public client exchange (profile ClientId, no secret) ──
|
||||||
|
var body = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["grant_type"] = "authorization_code",
|
||||||
|
["client_id"] = flowState.ClientId,
|
||||||
|
["code"] = code,
|
||||||
|
["redirect_uri"] = o.RedirectUri,
|
||||||
|
["code_verifier"] = flowState.CodeVerifier,
|
||||||
|
["scope"] = "openid offline_access",
|
||||||
|
};
|
||||||
|
|
||||||
|
var tokenUrl = $"https://login.microsoftonline.com/{flowState.TenantId}/oauth2/v2.0/token";
|
||||||
|
var resp = await http.PostAsync(tokenUrl, new FormUrlEncodedContent(body));
|
||||||
|
var json = await resp.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
|
if (!resp.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var msg = Uri.EscapeDataString($"Token exchange failed: {json}");
|
||||||
|
return Results.Redirect($"/?connect_error={msg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
var upn = ExtractUpnFromIdToken(root);
|
||||||
|
var refreshToken = root.GetProperty("refresh_token").GetString()!;
|
||||||
|
|
||||||
|
var tokens = new SessionTokens
|
||||||
|
{
|
||||||
|
RefreshToken = refreshToken,
|
||||||
|
TenantId = flowState.TenantId,
|
||||||
|
ClientId = flowState.ClientId,
|
||||||
|
SpHost = flowState.SpHost,
|
||||||
|
UserPrincipalName = upn,
|
||||||
|
};
|
||||||
|
|
||||||
|
var tokenKey = Guid.NewGuid().ToString("N");
|
||||||
|
flowCache.StoreTokens(tokenKey, tokens);
|
||||||
|
|
||||||
|
var returnTo = QueryHelpers.AddQueryString(flowState.ReturnUrl, "token_key", tokenKey);
|
||||||
|
return Results.Redirect(returnTo);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static (string State, string AuthUrl) BuildAuthUrl(
|
||||||
|
string tenantId,
|
||||||
|
string clientId,
|
||||||
|
string redirectUri,
|
||||||
|
string scope,
|
||||||
|
IOAuthFlowCache flowCache,
|
||||||
|
OAuthFlowState flowState,
|
||||||
|
bool promptConsent = false)
|
||||||
|
{
|
||||||
|
var codeVerifier = GenerateCodeVerifier();
|
||||||
|
var codeChallenge = GenerateCodeChallenge(codeVerifier);
|
||||||
|
var state = Guid.NewGuid().ToString("N");
|
||||||
|
|
||||||
|
flowState.CodeVerifier = codeVerifier;
|
||||||
|
flowCache.StoreFlowState(state, flowState);
|
||||||
|
|
||||||
|
var @params = new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["client_id"] = clientId,
|
||||||
|
["response_type"] = "code",
|
||||||
|
["redirect_uri"] = redirectUri,
|
||||||
|
["scope"] = scope,
|
||||||
|
["state"] = state,
|
||||||
|
["code_challenge"] = codeChallenge,
|
||||||
|
["code_challenge_method"] = "S256",
|
||||||
|
["prompt"] = promptConsent ? "consent" : "select_account",
|
||||||
|
};
|
||||||
|
|
||||||
|
var authUrl = QueryHelpers.AddQueryString(
|
||||||
|
$"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/authorize",
|
||||||
|
@params);
|
||||||
|
|
||||||
|
return (state, authUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GenerateCodeVerifier()
|
||||||
|
{
|
||||||
|
var bytes = new byte[32];
|
||||||
|
RandomNumberGenerator.Fill(bytes);
|
||||||
|
return Base64UrlEncode(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GenerateCodeChallenge(string codeVerifier)
|
||||||
|
{
|
||||||
|
var bytes = SHA256.HashData(Encoding.ASCII.GetBytes(codeVerifier));
|
||||||
|
return Base64UrlEncode(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Base64UrlEncode(byte[] bytes) =>
|
||||||
|
Convert.ToBase64String(bytes)
|
||||||
|
.TrimEnd('=')
|
||||||
|
.Replace('+', '-')
|
||||||
|
.Replace('/', '_');
|
||||||
|
|
||||||
|
private static string ExtractHost(string url)
|
||||||
|
{
|
||||||
|
if (Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||||
|
return uri.Host;
|
||||||
|
return url.TrimEnd('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExtractUpnFromIdToken(JsonElement tokenResponse)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!tokenResponse.TryGetProperty("id_token", out var idTokenEl))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
var parts = idTokenEl.GetString()!.Split('.');
|
||||||
|
if (parts.Length < 2) return string.Empty;
|
||||||
|
|
||||||
|
var payload = parts[1];
|
||||||
|
var padded = payload.PadRight(payload.Length + (4 - payload.Length % 4) % 4, '=')
|
||||||
|
.Replace('-', '+').Replace('_', '/');
|
||||||
|
var bytes = Convert.FromBase64String(padded);
|
||||||
|
using var doc = JsonDocument.Parse(bytes);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
foreach (var claim in new[] { "preferred_username", "upn", "email", "unique_name" })
|
||||||
|
if (root.TryGetProperty(claim, out var val) && val.ValueKind == JsonValueKind.String)
|
||||||
|
return val.GetString()!;
|
||||||
|
}
|
||||||
|
catch { /* best-effort */ }
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Web.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
/// <summary>Append-only JSONL audit log. Each line is one AuditEntry JSON object.</summary>
|
||||||
|
public class AuditRepository
|
||||||
|
{
|
||||||
|
private readonly string _filePath;
|
||||||
|
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||||
|
private static readonly JsonSerializerOptions _opts = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
};
|
||||||
|
|
||||||
|
public AuditRepository(string filePath) { _filePath = filePath; }
|
||||||
|
|
||||||
|
public async Task AppendAsync(AuditEntry entry)
|
||||||
|
{
|
||||||
|
await _writeLock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dir = Path.GetDirectoryName(_filePath);
|
||||||
|
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||||
|
var line = JsonSerializer.Serialize(entry, _opts) + "\n";
|
||||||
|
await File.AppendAllTextAsync(_filePath, line, Encoding.UTF8);
|
||||||
|
}
|
||||||
|
finally { _writeLock.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<AuditEntry>> LoadAllAsync()
|
||||||
|
{
|
||||||
|
if (!File.Exists(_filePath)) return Array.Empty<AuditEntry>();
|
||||||
|
var lines = await File.ReadAllLinesAsync(_filePath, Encoding.UTF8);
|
||||||
|
var result = new List<AuditEntry>(lines.Length);
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var entry = JsonSerializer.Deserialize<AuditEntry>(line, _opts);
|
||||||
|
if (entry != null) result.Add(entry);
|
||||||
|
}
|
||||||
|
catch { /* skip corrupt lines */ }
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Web.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
public class ProfileRepository
|
||||||
|
{
|
||||||
|
private readonly string _filePath;
|
||||||
|
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||||
|
|
||||||
|
public ProfileRepository(string filePath) { _filePath = filePath; }
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<TenantProfile>> LoadAsync()
|
||||||
|
{
|
||||||
|
if (!File.Exists(_filePath)) return Array.Empty<TenantProfile>();
|
||||||
|
string json;
|
||||||
|
try { json = await File.ReadAllTextAsync(_filePath, Encoding.UTF8); }
|
||||||
|
catch (IOException ex) { throw new InvalidDataException($"Failed to read profiles: {_filePath}", ex); }
|
||||||
|
|
||||||
|
ProfilesRoot? root;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
root = JsonSerializer.Deserialize<ProfilesRoot>(json,
|
||||||
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||||
|
}
|
||||||
|
catch (JsonException ex) { throw new InvalidDataException($"Invalid JSON in profiles: {_filePath}", ex); }
|
||||||
|
|
||||||
|
return (IReadOnlyList<TenantProfile>?)root?.Profiles ?? Array.Empty<TenantProfile>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SaveAsync(IReadOnlyList<TenantProfile> profiles)
|
||||||
|
{
|
||||||
|
await _writeLock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var root = new ProfilesRoot { Profiles = profiles.ToList() };
|
||||||
|
var json = JsonSerializer.Serialize(root, new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||||
|
});
|
||||||
|
var tmpPath = _filePath + ".tmp";
|
||||||
|
var dir = Path.GetDirectoryName(_filePath);
|
||||||
|
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||||
|
await File.WriteAllTextAsync(tmpPath, json, Encoding.UTF8);
|
||||||
|
JsonDocument.Parse(await File.ReadAllTextAsync(tmpPath, Encoding.UTF8)).Dispose();
|
||||||
|
File.Move(tmpPath, _filePath, overwrite: true);
|
||||||
|
}
|
||||||
|
finally { _writeLock.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ProfilesRoot { public List<TenantProfile> Profiles { get; set; } = new(); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Web.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
public class SettingsRepository
|
||||||
|
{
|
||||||
|
private readonly string _filePath;
|
||||||
|
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||||
|
|
||||||
|
public SettingsRepository(string filePath) { _filePath = filePath; }
|
||||||
|
|
||||||
|
public async Task<AppSettings> LoadAsync()
|
||||||
|
{
|
||||||
|
if (!File.Exists(_filePath)) return new AppSettings();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = await File.ReadAllTextAsync(_filePath, Encoding.UTF8);
|
||||||
|
return JsonSerializer.Deserialize<AppSettings>(json,
|
||||||
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new AppSettings();
|
||||||
|
}
|
||||||
|
catch { return new AppSettings(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SaveAsync(AppSettings settings)
|
||||||
|
{
|
||||||
|
await _writeLock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(settings, new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||||
|
});
|
||||||
|
var dir = Path.GetDirectoryName(_filePath);
|
||||||
|
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||||
|
var tmp = _filePath + ".tmp";
|
||||||
|
await File.WriteAllTextAsync(tmp, json, Encoding.UTF8);
|
||||||
|
File.Move(tmp, _filePath, overwrite: true);
|
||||||
|
}
|
||||||
|
finally { _writeLock.Release(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Web.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
public class TemplateRepository
|
||||||
|
{
|
||||||
|
private readonly string _directory;
|
||||||
|
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||||
|
|
||||||
|
public TemplateRepository(string directory) { _directory = directory; }
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<SiteTemplate>> GetAllAsync()
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(_directory)) return Array.Empty<SiteTemplate>();
|
||||||
|
var files = Directory.GetFiles(_directory, "*.json");
|
||||||
|
var templates = new List<SiteTemplate>();
|
||||||
|
foreach (var file in files)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = await File.ReadAllTextAsync(file, Encoding.UTF8);
|
||||||
|
var t = JsonSerializer.Deserialize<SiteTemplate>(json,
|
||||||
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||||
|
if (t is not null) templates.Add(t);
|
||||||
|
}
|
||||||
|
catch { /* skip corrupt files */ }
|
||||||
|
}
|
||||||
|
return templates.OrderByDescending(t => t.CapturedAt).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SiteTemplate?> GetByIdAsync(string id)
|
||||||
|
{
|
||||||
|
var file = Path.Combine(_directory, $"{id}.json");
|
||||||
|
if (!File.Exists(file)) return null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = await File.ReadAllTextAsync(file, Encoding.UTF8);
|
||||||
|
return JsonSerializer.Deserialize<SiteTemplate>(json,
|
||||||
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||||
|
}
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SaveAsync(SiteTemplate template)
|
||||||
|
{
|
||||||
|
await _lock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_directory);
|
||||||
|
var json = JsonSerializer.Serialize(template, new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||||
|
});
|
||||||
|
var path = Path.Combine(_directory, $"{template.Id}.json");
|
||||||
|
var tmp = path + ".tmp";
|
||||||
|
await File.WriteAllTextAsync(tmp, json, Encoding.UTF8);
|
||||||
|
File.Move(tmp, path, overwrite: true);
|
||||||
|
}
|
||||||
|
finally { _lock.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task DeleteAsync(string id)
|
||||||
|
{
|
||||||
|
var file = Path.Combine(_directory, $"{id}.json");
|
||||||
|
if (File.Exists(file)) File.Delete(file);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RenameAsync(string id, string newName)
|
||||||
|
{
|
||||||
|
var t = await GetByIdAsync(id);
|
||||||
|
if (t is null) return;
|
||||||
|
t.Name = newName;
|
||||||
|
await SaveAsync(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Web.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
public class UserRepository
|
||||||
|
{
|
||||||
|
private readonly string _filePath;
|
||||||
|
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||||
|
private static readonly JsonSerializerOptions _opts = new()
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
};
|
||||||
|
|
||||||
|
public UserRepository(string filePath) { _filePath = filePath; }
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<AppUser>> LoadAsync()
|
||||||
|
{
|
||||||
|
if (!File.Exists(_filePath)) return Array.Empty<AppUser>();
|
||||||
|
var json = await File.ReadAllTextAsync(_filePath, Encoding.UTF8);
|
||||||
|
var root = JsonSerializer.Deserialize<UsersRoot>(json, _opts);
|
||||||
|
return (IReadOnlyList<AppUser>?)root?.Users ?? Array.Empty<AppUser>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AppUser?> FindByEmailAsync(string email)
|
||||||
|
{
|
||||||
|
var users = await LoadAsync();
|
||||||
|
return users.FirstOrDefault(u => u.Email.Equals(email, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SaveAsync(IReadOnlyList<AppUser> users)
|
||||||
|
{
|
||||||
|
await _writeLock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var root = new UsersRoot { Users = users.ToList() };
|
||||||
|
var json = JsonSerializer.Serialize(root, _opts);
|
||||||
|
var tmpPath = _filePath + ".tmp";
|
||||||
|
var dir = Path.GetDirectoryName(_filePath);
|
||||||
|
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||||
|
await File.WriteAllTextAsync(tmpPath, json, Encoding.UTF8);
|
||||||
|
File.Move(tmpPath, _filePath, overwrite: true);
|
||||||
|
}
|
||||||
|
finally { _writeLock.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpsertAsync(AppUser user)
|
||||||
|
{
|
||||||
|
await _writeLock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var users = (await LoadInternal()).ToList();
|
||||||
|
var idx = users.FindIndex(u => u.Id == user.Id);
|
||||||
|
if (idx >= 0) users[idx] = user;
|
||||||
|
else users.Add(user);
|
||||||
|
await SaveInternal(users);
|
||||||
|
}
|
||||||
|
finally { _writeLock.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(string userId)
|
||||||
|
{
|
||||||
|
await _writeLock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var users = (await LoadInternal()).ToList();
|
||||||
|
users.RemoveAll(u => u.Id == userId);
|
||||||
|
await SaveInternal(users);
|
||||||
|
}
|
||||||
|
finally { _writeLock.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<AppUser>> LoadInternal()
|
||||||
|
{
|
||||||
|
if (!File.Exists(_filePath)) return Array.Empty<AppUser>();
|
||||||
|
var json = await File.ReadAllTextAsync(_filePath, Encoding.UTF8);
|
||||||
|
var root = JsonSerializer.Deserialize<UsersRoot>(json, _opts);
|
||||||
|
return (IReadOnlyList<AppUser>?)root?.Users ?? Array.Empty<AppUser>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveInternal(List<AppUser> users)
|
||||||
|
{
|
||||||
|
var root = new UsersRoot { Users = users };
|
||||||
|
var json = JsonSerializer.Serialize(root, _opts);
|
||||||
|
var tmpPath = _filePath + ".tmp";
|
||||||
|
var dir = Path.GetDirectoryName(_filePath);
|
||||||
|
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||||
|
await File.WriteAllTextAsync(tmpPath, json, Encoding.UTF8);
|
||||||
|
File.Move(tmpPath, _filePath, overwrite: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class UsersRoot { public List<AppUser> Users { get; set; } = new(); }
|
||||||
|
}
|
||||||
Generated
+231
@@ -0,0 +1,231 @@
|
|||||||
|
//------------------------------------------------------------------------------
|
||||||
|
// <auto-generated>
|
||||||
|
// This code was generated by a tool.
|
||||||
|
// Runtime Version:4.0.30319.42000
|
||||||
|
//
|
||||||
|
// Changes to this file may cause incorrect behavior and will be lost if
|
||||||
|
// the code is regenerated.
|
||||||
|
// </auto-generated>
|
||||||
|
//------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Web.Localization {
|
||||||
|
using System;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A strongly-typed resource class, for looking up localized strings, etc.
|
||||||
|
/// Auto-generated designer file for Strings.resx — do not edit manually.
|
||||||
|
/// </summary>
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")]
|
||||||
|
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||||
|
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
|
||||||
|
internal class Strings {
|
||||||
|
|
||||||
|
private static global::System.Resources.ResourceManager resourceMan;
|
||||||
|
|
||||||
|
private static global::System.Globalization.CultureInfo resourceCulture;
|
||||||
|
|
||||||
|
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
|
||||||
|
internal Strings() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the cached ResourceManager instance used by this class.
|
||||||
|
/// </summary>
|
||||||
|
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||||
|
internal static global::System.Resources.ResourceManager ResourceManager {
|
||||||
|
get {
|
||||||
|
if (object.ReferenceEquals(resourceMan, null)) {
|
||||||
|
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("SharepointToolbox.Localization.Strings", typeof(Strings).Assembly);
|
||||||
|
resourceMan = temp;
|
||||||
|
}
|
||||||
|
return resourceMan;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Overrides the current thread's CurrentUICulture property for all
|
||||||
|
/// resource lookups using this strongly typed resource class.
|
||||||
|
/// </summary>
|
||||||
|
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||||
|
internal static global::System.Globalization.CultureInfo Culture {
|
||||||
|
get {
|
||||||
|
return resourceCulture;
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
resourceCulture = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string grp_scan_opts => ResourceManager.GetString("grp.scan.opts", resourceCulture) ?? string.Empty;
|
||||||
|
public static string chk_scan_folders => ResourceManager.GetString("chk.scan.folders", resourceCulture) ?? string.Empty;
|
||||||
|
public static string chk_recursive => ResourceManager.GetString("chk.recursive", resourceCulture) ?? string.Empty;
|
||||||
|
public static string lbl_folder_depth => ResourceManager.GetString("lbl.folder.depth", resourceCulture) ?? string.Empty;
|
||||||
|
public static string chk_max_depth => ResourceManager.GetString("chk.max.depth", resourceCulture) ?? string.Empty;
|
||||||
|
public static string chk_inherited_perms => ResourceManager.GetString("chk.inherited.perms", resourceCulture) ?? string.Empty;
|
||||||
|
public static string grp_export_fmt => ResourceManager.GetString("grp.export.fmt", resourceCulture) ?? string.Empty;
|
||||||
|
public static string rad_csv_perms => ResourceManager.GetString("rad.csv.perms", resourceCulture) ?? string.Empty;
|
||||||
|
public static string rad_html_perms => ResourceManager.GetString("rad.html.perms", resourceCulture) ?? string.Empty;
|
||||||
|
public static string btn_gen_perms => ResourceManager.GetString("btn.gen.perms", resourceCulture) ?? string.Empty;
|
||||||
|
public static string btn_open_perms => ResourceManager.GetString("btn.open.perms", resourceCulture) ?? string.Empty;
|
||||||
|
public static string btn_view_sites => ResourceManager.GetString("btn.view.sites", resourceCulture) ?? string.Empty;
|
||||||
|
public static string perm_site_url => ResourceManager.GetString("perm.site.url", resourceCulture) ?? string.Empty;
|
||||||
|
public static string perm_or_select => ResourceManager.GetString("perm.or.select", resourceCulture) ?? string.Empty;
|
||||||
|
public static string perm_sites_selected => ResourceManager.GetString("perm.sites.selected", resourceCulture) ?? string.Empty;
|
||||||
|
|
||||||
|
// Phase 3: Storage Tab
|
||||||
|
public static string chk_per_lib => ResourceManager.GetString("chk.per.lib", resourceCulture) ?? string.Empty;
|
||||||
|
public static string chk_subsites => ResourceManager.GetString("chk.subsites", resourceCulture) ?? string.Empty;
|
||||||
|
public static string stor_note => ResourceManager.GetString("stor.note", resourceCulture) ?? string.Empty;
|
||||||
|
public static string btn_gen_storage => ResourceManager.GetString("btn.gen.storage", resourceCulture) ?? string.Empty;
|
||||||
|
public static string btn_open_storage => ResourceManager.GetString("btn.open.storage", resourceCulture) ?? string.Empty;
|
||||||
|
public static string stor_col_library => ResourceManager.GetString("stor.col.library", resourceCulture) ?? string.Empty;
|
||||||
|
public static string stor_col_site => ResourceManager.GetString("stor.col.site", resourceCulture) ?? string.Empty;
|
||||||
|
public static string stor_col_files => ResourceManager.GetString("stor.col.files", resourceCulture) ?? string.Empty;
|
||||||
|
public static string stor_col_size => ResourceManager.GetString("stor.col.size", resourceCulture) ?? string.Empty;
|
||||||
|
public static string stor_col_versions => ResourceManager.GetString("stor.col.versions", resourceCulture) ?? string.Empty;
|
||||||
|
public static string stor_col_lastmod => ResourceManager.GetString("stor.col.lastmod", resourceCulture) ?? string.Empty;
|
||||||
|
public static string stor_col_share => ResourceManager.GetString("stor.col.share", resourceCulture) ?? string.Empty;
|
||||||
|
public static string stor_rad_csv => ResourceManager.GetString("stor.rad.csv", resourceCulture) ?? string.Empty;
|
||||||
|
public static string stor_rad_html => ResourceManager.GetString("stor.rad.html", resourceCulture) ?? string.Empty;
|
||||||
|
|
||||||
|
// Phase 3: File Search Tab
|
||||||
|
public static string grp_search_filters => ResourceManager.GetString("grp.search.filters", resourceCulture) ?? string.Empty;
|
||||||
|
public static string lbl_extensions => ResourceManager.GetString("lbl.extensions", resourceCulture) ?? string.Empty;
|
||||||
|
public static string ph_extensions => ResourceManager.GetString("ph.extensions", resourceCulture) ?? string.Empty;
|
||||||
|
public static string lbl_regex => ResourceManager.GetString("lbl.regex", resourceCulture) ?? string.Empty;
|
||||||
|
public static string ph_regex => ResourceManager.GetString("ph.regex", resourceCulture) ?? string.Empty;
|
||||||
|
public static string chk_created_after => ResourceManager.GetString("chk.created.after", resourceCulture) ?? string.Empty;
|
||||||
|
public static string chk_created_before => ResourceManager.GetString("chk.created.before", resourceCulture) ?? string.Empty;
|
||||||
|
public static string chk_modified_after => ResourceManager.GetString("chk.modified.after", resourceCulture) ?? string.Empty;
|
||||||
|
public static string chk_modified_before => ResourceManager.GetString("chk.modified.before", resourceCulture) ?? string.Empty;
|
||||||
|
public static string lbl_created_by => ResourceManager.GetString("lbl.created.by", resourceCulture) ?? string.Empty;
|
||||||
|
public static string ph_created_by => ResourceManager.GetString("ph.created.by", resourceCulture) ?? string.Empty;
|
||||||
|
public static string lbl_modified_by => ResourceManager.GetString("lbl.modified.by", resourceCulture) ?? string.Empty;
|
||||||
|
public static string ph_modified_by => ResourceManager.GetString("ph.modified.by", resourceCulture) ?? string.Empty;
|
||||||
|
public static string lbl_library => ResourceManager.GetString("lbl.library", resourceCulture) ?? string.Empty;
|
||||||
|
public static string ph_library => ResourceManager.GetString("ph.library", resourceCulture) ?? string.Empty;
|
||||||
|
public static string lbl_max_results => ResourceManager.GetString("lbl.max.results", resourceCulture) ?? string.Empty;
|
||||||
|
public static string lbl_site_url => ResourceManager.GetString("lbl.site.url", resourceCulture) ?? string.Empty;
|
||||||
|
public static string ph_site_url => ResourceManager.GetString("ph.site.url", resourceCulture) ?? string.Empty;
|
||||||
|
public static string btn_run_search => ResourceManager.GetString("btn.run.search", resourceCulture) ?? string.Empty;
|
||||||
|
public static string btn_open_search => ResourceManager.GetString("btn.open.search", resourceCulture) ?? string.Empty;
|
||||||
|
public static string srch_col_name => ResourceManager.GetString("srch.col.name", resourceCulture) ?? string.Empty;
|
||||||
|
public static string srch_col_ext => ResourceManager.GetString("srch.col.ext", resourceCulture) ?? string.Empty;
|
||||||
|
public static string srch_col_created => ResourceManager.GetString("srch.col.created", resourceCulture) ?? string.Empty;
|
||||||
|
public static string srch_col_modified => ResourceManager.GetString("srch.col.modified", resourceCulture) ?? string.Empty;
|
||||||
|
public static string srch_col_author => ResourceManager.GetString("srch.col.author", resourceCulture) ?? string.Empty;
|
||||||
|
public static string srch_col_modby => ResourceManager.GetString("srch.col.modby", resourceCulture) ?? string.Empty;
|
||||||
|
public static string srch_col_size => ResourceManager.GetString("srch.col.size", resourceCulture) ?? string.Empty;
|
||||||
|
public static string srch_col_path => ResourceManager.GetString("srch.col.path", resourceCulture) ?? string.Empty;
|
||||||
|
public static string srch_rad_csv => ResourceManager.GetString("srch.rad.csv", resourceCulture) ?? string.Empty;
|
||||||
|
public static string srch_rad_html => ResourceManager.GetString("srch.rad.html", resourceCulture) ?? string.Empty;
|
||||||
|
|
||||||
|
// Phase 3: Duplicates Tab
|
||||||
|
public static string grp_dup_type => ResourceManager.GetString("grp.dup.type", resourceCulture) ?? string.Empty;
|
||||||
|
public static string rad_dup_files => ResourceManager.GetString("rad.dup.files", resourceCulture) ?? string.Empty;
|
||||||
|
public static string rad_dup_folders => ResourceManager.GetString("rad.dup.folders", resourceCulture) ?? string.Empty;
|
||||||
|
public static string grp_dup_criteria => ResourceManager.GetString("grp.dup.criteria", resourceCulture) ?? string.Empty;
|
||||||
|
public static string lbl_dup_note => ResourceManager.GetString("lbl.dup.note", resourceCulture) ?? string.Empty;
|
||||||
|
public static string chk_dup_size => ResourceManager.GetString("chk.dup.size", resourceCulture) ?? string.Empty;
|
||||||
|
public static string chk_dup_created => ResourceManager.GetString("chk.dup.created", resourceCulture) ?? string.Empty;
|
||||||
|
public static string chk_dup_modified => ResourceManager.GetString("chk.dup.modified", resourceCulture) ?? string.Empty;
|
||||||
|
public static string chk_dup_subfolders => ResourceManager.GetString("chk.dup.subfolders", resourceCulture) ?? string.Empty;
|
||||||
|
public static string chk_dup_filecount => ResourceManager.GetString("chk.dup.filecount", resourceCulture) ?? string.Empty;
|
||||||
|
public static string chk_include_subsites => ResourceManager.GetString("chk.include.subsites", resourceCulture) ?? string.Empty;
|
||||||
|
public static string ph_dup_lib => ResourceManager.GetString("ph.dup.lib", resourceCulture) ?? string.Empty;
|
||||||
|
public static string btn_run_scan => ResourceManager.GetString("btn.run.scan", resourceCulture) ?? string.Empty;
|
||||||
|
public static string btn_open_results => ResourceManager.GetString("btn.open.results", resourceCulture) ?? string.Empty;
|
||||||
|
|
||||||
|
// Phase 4: Tab headers
|
||||||
|
public static string tab_transfer => ResourceManager.GetString("tab.transfer", resourceCulture) ?? string.Empty;
|
||||||
|
public static string tab_bulkMembers => ResourceManager.GetString("tab.bulkMembers", resourceCulture) ?? string.Empty;
|
||||||
|
public static string tab_bulkSites => ResourceManager.GetString("tab.bulkSites", resourceCulture) ?? string.Empty;
|
||||||
|
public static string tab_folderStructure => ResourceManager.GetString("tab.folderStructure", resourceCulture) ?? string.Empty;
|
||||||
|
|
||||||
|
// Phase 4: Transfer tab
|
||||||
|
public static string transfer_sourcesite => ResourceManager.GetString("transfer.sourcesite", resourceCulture) ?? string.Empty;
|
||||||
|
public static string transfer_destsite => ResourceManager.GetString("transfer.destsite", resourceCulture) ?? string.Empty;
|
||||||
|
public static string transfer_sourcelibrary => ResourceManager.GetString("transfer.sourcelibrary", resourceCulture) ?? string.Empty;
|
||||||
|
public static string transfer_destlibrary => ResourceManager.GetString("transfer.destlibrary", resourceCulture) ?? string.Empty;
|
||||||
|
public static string transfer_sourcefolder => ResourceManager.GetString("transfer.sourcefolder", resourceCulture) ?? string.Empty;
|
||||||
|
public static string transfer_destfolder => ResourceManager.GetString("transfer.destfolder", resourceCulture) ?? string.Empty;
|
||||||
|
public static string transfer_mode => ResourceManager.GetString("transfer.mode", resourceCulture) ?? string.Empty;
|
||||||
|
public static string transfer_mode_copy => ResourceManager.GetString("transfer.mode.copy", resourceCulture) ?? string.Empty;
|
||||||
|
public static string transfer_mode_move => ResourceManager.GetString("transfer.mode.move", resourceCulture) ?? string.Empty;
|
||||||
|
public static string transfer_conflict => ResourceManager.GetString("transfer.conflict", resourceCulture) ?? string.Empty;
|
||||||
|
public static string transfer_conflict_skip => ResourceManager.GetString("transfer.conflict.skip", resourceCulture) ?? string.Empty;
|
||||||
|
public static string transfer_conflict_overwrite => ResourceManager.GetString("transfer.conflict.overwrite", resourceCulture) ?? string.Empty;
|
||||||
|
public static string transfer_conflict_rename => ResourceManager.GetString("transfer.conflict.rename", resourceCulture) ?? string.Empty;
|
||||||
|
public static string transfer_browse => ResourceManager.GetString("transfer.browse", resourceCulture) ?? string.Empty;
|
||||||
|
public static string transfer_start => ResourceManager.GetString("transfer.start", resourceCulture) ?? string.Empty;
|
||||||
|
public static string transfer_nofiles => ResourceManager.GetString("transfer.nofiles", resourceCulture) ?? string.Empty;
|
||||||
|
|
||||||
|
// Phase 4: Bulk Members tab
|
||||||
|
public static string bulkmembers_import => ResourceManager.GetString("bulkmembers.import", resourceCulture) ?? string.Empty;
|
||||||
|
public static string bulkmembers_example => ResourceManager.GetString("bulkmembers.example", resourceCulture) ?? string.Empty;
|
||||||
|
public static string bulkmembers_execute => ResourceManager.GetString("bulkmembers.execute", resourceCulture) ?? string.Empty;
|
||||||
|
public static string bulkmembers_preview => ResourceManager.GetString("bulkmembers.preview", resourceCulture) ?? string.Empty;
|
||||||
|
public static string bulkmembers_groupname => ResourceManager.GetString("bulkmembers.groupname", resourceCulture) ?? string.Empty;
|
||||||
|
public static string bulkmembers_groupurl => ResourceManager.GetString("bulkmembers.groupurl", resourceCulture) ?? string.Empty;
|
||||||
|
public static string bulkmembers_email => ResourceManager.GetString("bulkmembers.email", resourceCulture) ?? string.Empty;
|
||||||
|
public static string bulkmembers_role => ResourceManager.GetString("bulkmembers.role", resourceCulture) ?? string.Empty;
|
||||||
|
|
||||||
|
// Phase 4: Bulk Sites tab
|
||||||
|
public static string bulksites_import => ResourceManager.GetString("bulksites.import", resourceCulture) ?? string.Empty;
|
||||||
|
public static string bulksites_example => ResourceManager.GetString("bulksites.example", resourceCulture) ?? string.Empty;
|
||||||
|
public static string bulksites_execute => ResourceManager.GetString("bulksites.execute", resourceCulture) ?? string.Empty;
|
||||||
|
public static string bulksites_preview => ResourceManager.GetString("bulksites.preview", resourceCulture) ?? string.Empty;
|
||||||
|
public static string bulksites_name => ResourceManager.GetString("bulksites.name", resourceCulture) ?? string.Empty;
|
||||||
|
public static string bulksites_alias => ResourceManager.GetString("bulksites.alias", resourceCulture) ?? string.Empty;
|
||||||
|
public static string bulksites_type => ResourceManager.GetString("bulksites.type", resourceCulture) ?? string.Empty;
|
||||||
|
public static string bulksites_owners => ResourceManager.GetString("bulksites.owners", resourceCulture) ?? string.Empty;
|
||||||
|
public static string bulksites_members => ResourceManager.GetString("bulksites.members", resourceCulture) ?? string.Empty;
|
||||||
|
|
||||||
|
// Phase 4: Folder Structure tab
|
||||||
|
public static string folderstruct_import => ResourceManager.GetString("folderstruct.import", resourceCulture) ?? string.Empty;
|
||||||
|
public static string folderstruct_example => ResourceManager.GetString("folderstruct.example", resourceCulture) ?? string.Empty;
|
||||||
|
public static string folderstruct_execute => ResourceManager.GetString("folderstruct.execute", resourceCulture) ?? string.Empty;
|
||||||
|
public static string folderstruct_preview => ResourceManager.GetString("folderstruct.preview", resourceCulture) ?? string.Empty;
|
||||||
|
public static string folderstruct_library => ResourceManager.GetString("folderstruct.library", resourceCulture) ?? string.Empty;
|
||||||
|
public static string folderstruct_siteurl => ResourceManager.GetString("folderstruct.siteurl", resourceCulture) ?? string.Empty;
|
||||||
|
|
||||||
|
// Phase 4: Templates tab
|
||||||
|
public static string templates_list => ResourceManager.GetString("templates.list", resourceCulture) ?? string.Empty;
|
||||||
|
public static string templates_capture => ResourceManager.GetString("templates.capture", resourceCulture) ?? string.Empty;
|
||||||
|
public static string templates_apply => ResourceManager.GetString("templates.apply", resourceCulture) ?? string.Empty;
|
||||||
|
public static string templates_rename => ResourceManager.GetString("templates.rename", resourceCulture) ?? string.Empty;
|
||||||
|
public static string templates_delete => ResourceManager.GetString("templates.delete", resourceCulture) ?? string.Empty;
|
||||||
|
public static string templates_siteurl => ResourceManager.GetString("templates.siteurl", resourceCulture) ?? string.Empty;
|
||||||
|
public static string templates_name => ResourceManager.GetString("templates.name", resourceCulture) ?? string.Empty;
|
||||||
|
public static string templates_newtitle => ResourceManager.GetString("templates.newtitle", resourceCulture) ?? string.Empty;
|
||||||
|
public static string templates_newalias => ResourceManager.GetString("templates.newalias", resourceCulture) ?? string.Empty;
|
||||||
|
public static string templates_options => ResourceManager.GetString("templates.options", resourceCulture) ?? string.Empty;
|
||||||
|
public static string templates_opt_libraries => ResourceManager.GetString("templates.opt.libraries", resourceCulture) ?? string.Empty;
|
||||||
|
public static string templates_opt_folders => ResourceManager.GetString("templates.opt.folders", resourceCulture) ?? string.Empty;
|
||||||
|
public static string templates_opt_permissions => ResourceManager.GetString("templates.opt.permissions", resourceCulture) ?? string.Empty;
|
||||||
|
public static string templates_opt_logo => ResourceManager.GetString("templates.opt.logo", resourceCulture) ?? string.Empty;
|
||||||
|
public static string templates_opt_settings => ResourceManager.GetString("templates.opt.settings", resourceCulture) ?? string.Empty;
|
||||||
|
public static string templates_empty => ResourceManager.GetString("templates.empty", resourceCulture) ?? string.Empty;
|
||||||
|
|
||||||
|
// Phase 4: Shared bulk operation strings
|
||||||
|
public static string bulk_confirm_title => ResourceManager.GetString("bulk.confirm.title", resourceCulture) ?? string.Empty;
|
||||||
|
public static string bulk_confirm_proceed => ResourceManager.GetString("bulk.confirm.proceed", resourceCulture) ?? string.Empty;
|
||||||
|
public static string bulk_confirm_cancel => ResourceManager.GetString("bulk.confirm.cancel", resourceCulture) ?? string.Empty;
|
||||||
|
public static string bulk_confirm_message => ResourceManager.GetString("bulk.confirm.message", resourceCulture) ?? string.Empty;
|
||||||
|
public static string bulk_result_success => ResourceManager.GetString("bulk.result.success", resourceCulture) ?? string.Empty;
|
||||||
|
public static string bulk_result_allfailed => ResourceManager.GetString("bulk.result.allfailed", resourceCulture) ?? string.Empty;
|
||||||
|
public static string bulk_result_allsuccess => ResourceManager.GetString("bulk.result.allsuccess", resourceCulture) ?? string.Empty;
|
||||||
|
public static string bulk_exportfailed => ResourceManager.GetString("bulk.exportfailed", resourceCulture) ?? string.Empty;
|
||||||
|
public static string bulk_retryfailed => ResourceManager.GetString("bulk.retryfailed", resourceCulture) ?? string.Empty;
|
||||||
|
public static string bulk_validation_invalid => ResourceManager.GetString("bulk.validation.invalid", resourceCulture) ?? string.Empty;
|
||||||
|
public static string bulk_csvimport_title => ResourceManager.GetString("bulk.csvimport.title", resourceCulture) ?? string.Empty;
|
||||||
|
public static string bulk_csvimport_filter => ResourceManager.GetString("bulk.csvimport.filter", resourceCulture) ?? string.Empty;
|
||||||
|
|
||||||
|
// Phase 4: Folder browser dialog
|
||||||
|
public static string folderbrowser_title => ResourceManager.GetString("folderbrowser.title", resourceCulture) ?? string.Empty;
|
||||||
|
public static string folderbrowser_loading => ResourceManager.GetString("folderbrowser.loading", resourceCulture) ?? string.Empty;
|
||||||
|
public static string folderbrowser_select => ResourceManager.GetString("folderbrowser.select", resourceCulture) ?? string.Empty;
|
||||||
|
public static string folderbrowser_cancel => ResourceManager.GetString("folderbrowser.cancel", resourceCulture) ?? string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,870 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<root>
|
||||||
|
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||||
|
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
|
||||||
|
<xsd:element name="root" msdata:IsDataSet="true">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:choice maxOccurs="unbounded">
|
||||||
|
<xsd:element name="metadata">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:sequence>
|
||||||
|
<xsd:element name="value" type="xsd:string" minOccurs="0" />
|
||||||
|
</xsd:sequence>
|
||||||
|
<xsd:attribute name="name" use="required" type="xsd:string" />
|
||||||
|
<xsd:attribute name="type" type="xsd:string" />
|
||||||
|
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||||
|
<xsd:attribute ref="xml:space" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
<xsd:element name="assembly">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:attribute name="alias" type="xsd:string" />
|
||||||
|
<xsd:attribute name="name" type="xsd:string" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
<xsd:element name="data">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:sequence>
|
||||||
|
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||||
|
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
|
||||||
|
</xsd:sequence>
|
||||||
|
<xsd:attribute name="name" use="required" type="xsd:string" />
|
||||||
|
<xsd:attribute name="type" type="xsd:string" />
|
||||||
|
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||||
|
<xsd:attribute ref="xml:space" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
<xsd:element name="resheader">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:sequence>
|
||||||
|
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||||
|
</xsd:sequence>
|
||||||
|
<xsd:attribute name="name" type="xsd:string" use="required" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
</xsd:choice>
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
</xsd:schema>
|
||||||
|
<resheader name="resmimetype">
|
||||||
|
<value>text/microsoft-resx</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="version">
|
||||||
|
<value>2.0</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="reader">
|
||||||
|
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="writer">
|
||||||
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
|
</resheader>
|
||||||
|
<data name="app.title" xml:space="preserve">
|
||||||
|
<value>SharePoint Toolbox</value>
|
||||||
|
</data>
|
||||||
|
<data name="toolbar.connect" xml:space="preserve">
|
||||||
|
<value>Connexion</value>
|
||||||
|
</data>
|
||||||
|
<data name="toolbar.manage" xml:space="preserve">
|
||||||
|
<value>Gérer les profils...</value>
|
||||||
|
</data>
|
||||||
|
<data name="toolbar.clear" xml:space="preserve">
|
||||||
|
<value>Déconnecter</value>
|
||||||
|
</data>
|
||||||
|
<data name="tab.permissions" xml:space="preserve">
|
||||||
|
<value>Permissions</value>
|
||||||
|
</data>
|
||||||
|
<data name="tab.storage" xml:space="preserve">
|
||||||
|
<value>Stockage</value>
|
||||||
|
</data>
|
||||||
|
<data name="tab.search" xml:space="preserve">
|
||||||
|
<value>Recherche de fichiers</value>
|
||||||
|
</data>
|
||||||
|
<data name="tab.duplicates" xml:space="preserve">
|
||||||
|
<value>Doublons</value>
|
||||||
|
</data>
|
||||||
|
<data name="tab.versions" xml:space="preserve">
|
||||||
|
<value>Versions</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.tab" xml:space="preserve">
|
||||||
|
<value>Nettoyage des versions</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.grp.libs" xml:space="preserve">
|
||||||
|
<value>Bibliothèques</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.grp.policy" xml:space="preserve">
|
||||||
|
<value>Politique de conservation</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.btn.pickLibs" xml:space="preserve">
|
||||||
|
<value>Choisir des bibliothèques…</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.btn.clearLibs" xml:space="preserve">
|
||||||
|
<value>Réinitialiser (toutes les bibliothèques)</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.btn.run" xml:space="preserve">
|
||||||
|
<value>Supprimer les anciennes versions</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.lbl.keepLast" xml:space="preserve">
|
||||||
|
<value>Conserver les dernières :</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.chk.keepFirst" xml:space="preserve">
|
||||||
|
<value>Conserver aussi la toute première version</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.chk.confirm" xml:space="preserve">
|
||||||
|
<value>Demander confirmation avant l'exécution</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.note" xml:space="preserve">
|
||||||
|
<value>Seules les versions historiques sont supprimées. La version courante publiée est toujours conservée. L'action est irréversible.</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.libs.all" xml:space="preserve">
|
||||||
|
<value>Toutes les bibliothèques (aucun filtre)</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.libs.count" xml:space="preserve">
|
||||||
|
<value>{0} bibliothèque(s) sélectionnée(s)</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.confirm" xml:space="preserve">
|
||||||
|
<value>Supprimer les versions historiques en gardant les {0} dernières {1} ?
|
||||||
|
Cette action est irréversible.</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.confirm.keepFirst" xml:space="preserve">
|
||||||
|
<value>(plus la première version)</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.err.keepLast" xml:space="preserve">
|
||||||
|
<value>« Conserver les dernières » doit être supérieur ou égal à 0.</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.summary.files" xml:space="preserve">
|
||||||
|
<value>Fichiers nettoyés :</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.summary.deleted" xml:space="preserve">
|
||||||
|
<value>Versions supprimées :</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.summary.freed" xml:space="preserve">
|
||||||
|
<value>Octets libérés :</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.col.library" xml:space="preserve">
|
||||||
|
<value>Bibliothèque</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.col.file" xml:space="preserve">
|
||||||
|
<value>Fichier</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.col.before" xml:space="preserve">
|
||||||
|
<value>Avant</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.col.deleted" xml:space="preserve">
|
||||||
|
<value>Supprimées</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.col.remaining" xml:space="preserve">
|
||||||
|
<value>Restantes</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.col.freed" xml:space="preserve">
|
||||||
|
<value>Libérés</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.col.path" xml:space="preserve">
|
||||||
|
<value>Chemin</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.col.error" xml:space="preserve">
|
||||||
|
<value>Erreur</value>
|
||||||
|
</data>
|
||||||
|
<data name="librarypicker.title" xml:space="preserve">
|
||||||
|
<value>Sélectionner les bibliothèques</value>
|
||||||
|
</data>
|
||||||
|
<data name="librarypicker.loading" xml:space="preserve">
|
||||||
|
<value>Chargement des bibliothèques…</value>
|
||||||
|
</data>
|
||||||
|
<data name="librarypicker.loaded" xml:space="preserve">
|
||||||
|
<value>{0} bibliothèques chargées.</value>
|
||||||
|
</data>
|
||||||
|
<data name="librarypicker.selectAll" xml:space="preserve">
|
||||||
|
<value>Tout sélectionner</value>
|
||||||
|
</data>
|
||||||
|
<data name="librarypicker.selectNone" xml:space="preserve">
|
||||||
|
<value>Tout désélectionner</value>
|
||||||
|
</data>
|
||||||
|
<data name="tab.templates" xml:space="preserve">
|
||||||
|
<value>Modèles</value>
|
||||||
|
</data>
|
||||||
|
<data name="tab.bulk" xml:space="preserve">
|
||||||
|
<value>Opérations en masse</value>
|
||||||
|
</data>
|
||||||
|
<data name="tab.structure" xml:space="preserve">
|
||||||
|
<value>Structure de dossiers</value>
|
||||||
|
</data>
|
||||||
|
<data name="tab.settings" xml:space="preserve">
|
||||||
|
<value>Paramètres</value>
|
||||||
|
</data>
|
||||||
|
<data name="tab.comingsoon" xml:space="preserve">
|
||||||
|
<value>Bientôt disponible</value>
|
||||||
|
</data>
|
||||||
|
<data name="btn.cancel" xml:space="preserve">
|
||||||
|
<value>Annuler</value>
|
||||||
|
</data>
|
||||||
|
<data name="settings.language" xml:space="preserve">
|
||||||
|
<value>Langue</value>
|
||||||
|
</data>
|
||||||
|
<data name="settings.lang.en" xml:space="preserve">
|
||||||
|
<value>Anglais</value>
|
||||||
|
</data>
|
||||||
|
<data name="settings.lang.fr" xml:space="preserve">
|
||||||
|
<value>Français</value>
|
||||||
|
</data>
|
||||||
|
<data name="settings.theme" xml:space="preserve">
|
||||||
|
<value>Thème</value>
|
||||||
|
</data>
|
||||||
|
<data name="settings.theme.system" xml:space="preserve">
|
||||||
|
<value>Utiliser le paramètre système</value>
|
||||||
|
</data>
|
||||||
|
<data name="settings.theme.light" xml:space="preserve">
|
||||||
|
<value>Clair</value>
|
||||||
|
</data>
|
||||||
|
<data name="settings.theme.dark" xml:space="preserve">
|
||||||
|
<value>Sombre</value>
|
||||||
|
</data>
|
||||||
|
<data name="settings.folder" xml:space="preserve">
|
||||||
|
<value>Dossier de sortie des données</value>
|
||||||
|
</data>
|
||||||
|
<data name="settings.browse" xml:space="preserve">
|
||||||
|
<value>Parcourir...</value>
|
||||||
|
</data>
|
||||||
|
<data name="profile.name" xml:space="preserve">
|
||||||
|
<value>Nom du profil</value>
|
||||||
|
</data>
|
||||||
|
<data name="profile.url" xml:space="preserve">
|
||||||
|
<value>URL du tenant</value>
|
||||||
|
</data>
|
||||||
|
<data name="profile.clientid" xml:space="preserve">
|
||||||
|
<value>ID client</value>
|
||||||
|
</data>
|
||||||
|
<data name="profile.clientid.hint" xml:space="preserve">
|
||||||
|
<value>Optionnel — laissez vide pour enregistrer l'application automatiquement</value>
|
||||||
|
</data>
|
||||||
|
<data name="profile.add" xml:space="preserve">
|
||||||
|
<value>Ajouter</value>
|
||||||
|
</data>
|
||||||
|
<data name="profile.save" xml:space="preserve">
|
||||||
|
<value>Enregistrer</value>
|
||||||
|
</data>
|
||||||
|
<data name="profile.delete" xml:space="preserve">
|
||||||
|
<value>Supprimer</value>
|
||||||
|
</data>
|
||||||
|
<data name="profile.add.tooltip" xml:space="preserve">
|
||||||
|
<value>Créer un nouveau profil à partir des valeurs ci-dessus.</value>
|
||||||
|
</data>
|
||||||
|
<data name="profile.save.tooltip" xml:space="preserve">
|
||||||
|
<value>Enregistrer les modifications du profil sélectionné.</value>
|
||||||
|
</data>
|
||||||
|
<data name="profile.delete.tooltip" xml:space="preserve">
|
||||||
|
<value>Supprimer le profil sélectionné.</value>
|
||||||
|
</data>
|
||||||
|
<data name="profile.register.warning" xml:space="preserve">
|
||||||
|
<value>L'enregistrement de l'application peut nécessiter jusqu'à {0} connexions. Continuer ?</value>
|
||||||
|
</data>
|
||||||
|
<data name="status.ready" xml:space="preserve">
|
||||||
|
<value>Prêt</value>
|
||||||
|
</data>
|
||||||
|
<data name="status.complete" xml:space="preserve">
|
||||||
|
<value>Terminé</value>
|
||||||
|
</data>
|
||||||
|
<data name="status.cancelled" xml:space="preserve">
|
||||||
|
<value>Opération annulée</value>
|
||||||
|
</data>
|
||||||
|
<data name="err.auth.failed" xml:space="preserve">
|
||||||
|
<value>Échec de l'authentification. Vérifiez l'URL du tenant et l'ID client.</value>
|
||||||
|
</data>
|
||||||
|
<data name="err.generic" xml:space="preserve">
|
||||||
|
<value>Une erreur s'est produite. Consultez le journal pour plus de détails.</value>
|
||||||
|
</data>
|
||||||
|
<data name="grp.scan.opts" xml:space="preserve"><value>Options d'analyse</value></data>
|
||||||
|
<data name="chk.scan.folders" xml:space="preserve"><value>Analyser les dossiers</value></data>
|
||||||
|
<data name="chk.recursive" xml:space="preserve"><value>Récursif (sous-sites)</value></data>
|
||||||
|
<data name="lbl.folder.depth" xml:space="preserve"><value>Profondeur des dossiers :</value></data>
|
||||||
|
<data name="chk.max.depth" xml:space="preserve"><value>Maximum (tous les niveaux)</value></data>
|
||||||
|
<data name="chk.inherited.perms" xml:space="preserve"><value>Inclure les permissions héritées</value></data>
|
||||||
|
<data name="chk.simplified.mode" xml:space="preserve"><value>Mode simplifié</value></data>
|
||||||
|
<data name="grp.display.opts" xml:space="preserve"><value>Options d'affichage</value></data>
|
||||||
|
<data name="grp.export.fmt" xml:space="preserve"><value>Format d'export</value></data>
|
||||||
|
<data name="rad.csv.perms" xml:space="preserve"><value>CSV</value></data>
|
||||||
|
<data name="rad.detail.detailed" xml:space="preserve"><value>Détaillé (toutes les lignes)</value></data>
|
||||||
|
<data name="rad.detail.simple" xml:space="preserve"><value>Simple (résumé uniquement)</value></data>
|
||||||
|
<data name="rad.html.perms" xml:space="preserve"><value>HTML</value></data>
|
||||||
|
<data name="btn.gen.perms" xml:space="preserve"><value>Générer le rapport</value></data>
|
||||||
|
<data name="btn.open.perms" xml:space="preserve"><value>Ouvrir le rapport</value></data>
|
||||||
|
<data name="btn.view.sites" xml:space="preserve"><value>Voir les sites</value></data>
|
||||||
|
<data name="perm.site.url" xml:space="preserve"><value>URL du site :</value></data>
|
||||||
|
<data name="perm.or.select" xml:space="preserve"><value>ou sélectionnez plusieurs sites :</value></data>
|
||||||
|
<data name="perm.sites.selected" xml:space="preserve"><value>{0} site(s) sélectionné(s)</value></data>
|
||||||
|
<!-- Phase 3: Storage Tab -->
|
||||||
|
<data name="chk.per.lib" xml:space="preserve"><value>Détail par bibliothèque</value></data>
|
||||||
|
<data name="chk.subsites" xml:space="preserve"><value>Inclure les sous-sites</value></data>
|
||||||
|
<data name="stor.note" xml:space="preserve"><value>Remarque : les analyses de dossiers profondes sur les grands sites peuvent prendre plusieurs minutes.</value></data>
|
||||||
|
<data name="btn.gen.storage" xml:space="preserve"><value>Générer les métriques</value></data>
|
||||||
|
<data name="btn.open.storage" xml:space="preserve"><value>Ouvrir le rapport</value></data>
|
||||||
|
<data name="stor.col.library" xml:space="preserve"><value>Bibliothèque</value></data>
|
||||||
|
<data name="stor.col.site" xml:space="preserve"><value>Site</value></data>
|
||||||
|
<data name="stor.col.files" xml:space="preserve"><value>Fichiers</value></data>
|
||||||
|
<data name="stor.col.size" xml:space="preserve"><value>Taille totale</value></data>
|
||||||
|
<data name="stor.col.versions" xml:space="preserve"><value>Taille des versions</value></data>
|
||||||
|
<data name="stor.col.lastmod" xml:space="preserve"><value>Dernière modification</value></data>
|
||||||
|
<data name="stor.col.share" xml:space="preserve"><value>Part du total</value></data>
|
||||||
|
<data name="stor.rad.csv" xml:space="preserve"><value>CSV</value></data>
|
||||||
|
<data name="stor.rad.html" xml:space="preserve"><value>HTML</value></data>
|
||||||
|
<data name="stor.col.kind" xml:space="preserve"><value>Type</value></data>
|
||||||
|
<data name="stor.kind.library" xml:space="preserve"><value>Bibliothèque</value></data>
|
||||||
|
<data name="stor.kind.hidden" xml:space="preserve"><value>Bibliothèque masquée</value></data>
|
||||||
|
<data name="stor.kind.preservation" xml:space="preserve"><value>Conservation</value></data>
|
||||||
|
<data name="stor.kind.attachments" xml:space="preserve"><value>Pièces jointes</value></data>
|
||||||
|
<data name="stor.kind.recyclebin" xml:space="preserve"><value>Corbeille</value></data>
|
||||||
|
<data name="stor.kind.subsite" xml:space="preserve"><value>Sous-site</value></data>
|
||||||
|
<data name="grp.scan.sources" xml:space="preserve"><value>Sources analysées</value></data>
|
||||||
|
<data name="grp.report.filter" xml:space="preserve"><value>Afficher dans le rapport</value></data>
|
||||||
|
<data name="chk.scan.hidden" xml:space="preserve"><value>Bibliothèques masquées</value></data>
|
||||||
|
<data name="chk.scan.preservation" xml:space="preserve"><value>Conservation</value></data>
|
||||||
|
<data name="chk.scan.attachments" xml:space="preserve"><value>Pièces jointes</value></data>
|
||||||
|
<data name="chk.scan.recyclebin" xml:space="preserve"><value>Corbeille</value></data>
|
||||||
|
<data name="chk.show.libraries" xml:space="preserve"><value>Bibliothèques</value></data>
|
||||||
|
<data name="chk.show.hidden" xml:space="preserve"><value>Bibliothèques masquées</value></data>
|
||||||
|
<data name="chk.show.preservation" xml:space="preserve"><value>Conservation</value></data>
|
||||||
|
<data name="chk.show.attachments" xml:space="preserve"><value>Pièces jointes</value></data>
|
||||||
|
<data name="chk.show.recyclebin" xml:space="preserve"><value>Corbeille</value></data>
|
||||||
|
<data name="chk.show.subsites" xml:space="preserve"><value>Sous-sites</value></data>
|
||||||
|
<data name="chk.combine.recyclebin" xml:space="preserve"><value>Combiner les corbeilles (afficher le total)</value></data>
|
||||||
|
<data name="storage.lbl.spo_reported_colon" xml:space="preserve"><value>Total rapporté par SPO : </value></data>
|
||||||
|
<data name="storage.lbl.recyclebin_colon" xml:space="preserve"><value>Corbeille : </value></data>
|
||||||
|
<!-- Phase 3: File Search Tab -->
|
||||||
|
<data name="grp.search.filters" xml:space="preserve"><value>Filtres de recherche</value></data>
|
||||||
|
<data name="lbl.detail.level" xml:space="preserve"><value>Niveau de détail :</value></data>
|
||||||
|
<data name="lbl.extensions" xml:space="preserve"><value>Extension(s) :</value></data>
|
||||||
|
<data name="ph.extensions" xml:space="preserve"><value>docx pdf xlsx</value></data>
|
||||||
|
<data name="lbl.regex" xml:space="preserve"><value>Nom / Regex :</value></data>
|
||||||
|
<data name="ph.regex" xml:space="preserve"><value>Ex : rapport.* ou \.bak$</value></data>
|
||||||
|
<data name="chk.created.after" xml:space="preserve"><value>Créé après :</value></data>
|
||||||
|
<data name="chk.created.before" xml:space="preserve"><value>Créé avant :</value></data>
|
||||||
|
<data name="chk.modified.after" xml:space="preserve"><value>Modifié après :</value></data>
|
||||||
|
<data name="chk.modified.before" xml:space="preserve"><value>Modifié avant :</value></data>
|
||||||
|
<data name="lbl.created.by" xml:space="preserve"><value>Créé par :</value></data>
|
||||||
|
<data name="ph.created.by" xml:space="preserve"><value>Prénom Nom ou courriel</value></data>
|
||||||
|
<data name="lbl.modified.by" xml:space="preserve"><value>Modifié par :</value></data>
|
||||||
|
<data name="ph.modified.by" xml:space="preserve"><value>Prénom Nom ou courriel</value></data>
|
||||||
|
<data name="lbl.library" xml:space="preserve"><value>Bibliothèque :</value></data>
|
||||||
|
<data name="ph.library" xml:space="preserve"><value>Chemin relatif optionnel, ex. Documents partagés</value></data>
|
||||||
|
<data name="lbl.max.results" xml:space="preserve"><value>Max résultats :</value></data>
|
||||||
|
<data name="lbl.site.url" xml:space="preserve"><value>URL du site :</value></data>
|
||||||
|
<data name="lbl.summary.users" xml:space="preserve"><value>utilisateur(s)</value></data>
|
||||||
|
<data name="ph.site.url" xml:space="preserve"><value>https://tenant.sharepoint.com/sites/MonSite</value></data>
|
||||||
|
<data name="btn.run.search" xml:space="preserve"><value>Lancer la recherche</value></data>
|
||||||
|
<data name="btn.open.search" xml:space="preserve"><value>Ouvrir les résultats</value></data>
|
||||||
|
<data name="srch.col.name" xml:space="preserve"><value>Nom du fichier</value></data>
|
||||||
|
<data name="srch.col.ext" xml:space="preserve"><value>Extension</value></data>
|
||||||
|
<data name="srch.col.created" xml:space="preserve"><value>Créé</value></data>
|
||||||
|
<data name="srch.col.modified" xml:space="preserve"><value>Modifié</value></data>
|
||||||
|
<data name="srch.col.author" xml:space="preserve"><value>Créé par</value></data>
|
||||||
|
<data name="srch.col.modby" xml:space="preserve"><value>Modifié par</value></data>
|
||||||
|
<data name="srch.col.size" xml:space="preserve"><value>Taille</value></data>
|
||||||
|
<data name="srch.col.path" xml:space="preserve"><value>Chemin</value></data>
|
||||||
|
<data name="srch.rad.csv" xml:space="preserve"><value>CSV</value></data>
|
||||||
|
<data name="srch.rad.html" xml:space="preserve"><value>HTML</value></data>
|
||||||
|
<!-- Phase 3: Duplicates Tab -->
|
||||||
|
<data name="grp.dup.type" xml:space="preserve"><value>Type de doublon</value></data>
|
||||||
|
<data name="rad.dup.files" xml:space="preserve"><value>Fichiers en doublon</value></data>
|
||||||
|
<data name="rad.dup.folders" xml:space="preserve"><value>Dossiers en doublon</value></data>
|
||||||
|
<data name="grp.dup.criteria" xml:space="preserve"><value>Critères de comparaison</value></data>
|
||||||
|
<data name="lbl.dup.note" xml:space="preserve"><value>Le nom est toujours le critère principal. Cochez des critères supplémentaires :</value></data>
|
||||||
|
<data name="chk.dup.size" xml:space="preserve"><value>Même taille</value></data>
|
||||||
|
<data name="chk.dup.created" xml:space="preserve"><value>Même date de création</value></data>
|
||||||
|
<data name="chk.dup.modified" xml:space="preserve"><value>Même date de modification</value></data>
|
||||||
|
<data name="chk.dup.subfolders" xml:space="preserve"><value>Même nombre de sous-dossiers</value></data>
|
||||||
|
<data name="chk.dup.filecount" xml:space="preserve"><value>Même nombre de fichiers</value></data>
|
||||||
|
<data name="chk.include.subsites" xml:space="preserve"><value>Inclure les sous-sites</value></data>
|
||||||
|
<data name="ph.dup.lib" xml:space="preserve"><value>Tous (laisser vide)</value></data>
|
||||||
|
<data name="btn.run.scan" xml:space="preserve"><value>Lancer l'analyse</value></data>
|
||||||
|
<data name="btn.open.results" xml:space="preserve"><value>Ouvrir les résultats</value></data>
|
||||||
|
<!-- Phase 4: Tab headers -->
|
||||||
|
<data name="tab.transfer" xml:space="preserve"><value>Transfert</value></data>
|
||||||
|
<data name="tab.bulkMembers" xml:space="preserve"><value>Ajout en masse</value></data>
|
||||||
|
<data name="tab.bulkSites" xml:space="preserve"><value>Sites en masse</value></data>
|
||||||
|
<data name="tab.folderStructure" xml:space="preserve"><value>Structure de dossiers</value></data>
|
||||||
|
<!-- Phase 4: Transfer tab -->
|
||||||
|
<data name="transfer.sourcesite" xml:space="preserve"><value>Site source</value></data>
|
||||||
|
<data name="transfer.destsite" xml:space="preserve"><value>Site destination</value></data>
|
||||||
|
<data name="transfer.sourcelibrary" xml:space="preserve"><value>Bibliothèque source</value></data>
|
||||||
|
<data name="transfer.destlibrary" xml:space="preserve"><value>Bibliothèque destination</value></data>
|
||||||
|
<data name="transfer.sourcefolder" xml:space="preserve"><value>Dossier source</value></data>
|
||||||
|
<data name="transfer.destfolder" xml:space="preserve"><value>Dossier destination</value></data>
|
||||||
|
<data name="transfer.mode" xml:space="preserve"><value>Mode de transfert</value></data>
|
||||||
|
<data name="transfer.mode.copy" xml:space="preserve"><value>Copier</value></data>
|
||||||
|
<data name="transfer.mode.move" xml:space="preserve"><value>Déplacer</value></data>
|
||||||
|
<data name="transfer.conflict" xml:space="preserve"><value>Politique de conflit</value></data>
|
||||||
|
<data name="transfer.conflict.skip" xml:space="preserve"><value>Ignorer</value></data>
|
||||||
|
<data name="transfer.conflict.overwrite" xml:space="preserve"><value>Écraser</value></data>
|
||||||
|
<data name="transfer.conflict.rename" xml:space="preserve"><value>Renommer (ajouter suffixe)</value></data>
|
||||||
|
<data name="transfer.browse" xml:space="preserve"><value>Parcourir...</value></data>
|
||||||
|
<data name="transfer.start" xml:space="preserve"><value>Démarrer le transfert</value></data>
|
||||||
|
<data name="transfer.nofiles" xml:space="preserve"><value>Aucun fichier à transférer.</value></data>
|
||||||
|
<!-- Phase 4: Bulk Members tab -->
|
||||||
|
<data name="bulkmembers.import" xml:space="preserve"><value>Importer CSV</value></data>
|
||||||
|
<data name="bulkmembers.example" xml:space="preserve"><value>Charger l'exemple</value></data>
|
||||||
|
<data name="bulkmembers.execute" xml:space="preserve"><value>Ajouter les membres</value></data>
|
||||||
|
<data name="bulkmembers.preview" xml:space="preserve"><value>Aperçu ({0} lignes, {1} valides, {2} invalides)</value></data>
|
||||||
|
<data name="bulkmembers.groupname" xml:space="preserve"><value>Nom du groupe</value></data>
|
||||||
|
<data name="bulkmembers.groupurl" xml:space="preserve"><value>URL du groupe</value></data>
|
||||||
|
<data name="bulkmembers.email" xml:space="preserve"><value>Courriel</value></data>
|
||||||
|
<data name="bulkmembers.role" xml:space="preserve"><value>Role</value></data>
|
||||||
|
<!-- Phase 4: Bulk Sites tab -->
|
||||||
|
<data name="bulksites.import" xml:space="preserve"><value>Importer CSV</value></data>
|
||||||
|
<data name="bulksites.example" xml:space="preserve"><value>Charger l'exemple</value></data>
|
||||||
|
<data name="bulksites.execute" xml:space="preserve"><value>Créer les sites</value></data>
|
||||||
|
<data name="bulksites.preview" xml:space="preserve"><value>Aperçu ({0} lignes, {1} valides, {2} invalides)</value></data>
|
||||||
|
<data name="bulksites.name" xml:space="preserve"><value>Nom</value></data>
|
||||||
|
<data name="bulksites.alias" xml:space="preserve"><value>Alias</value></data>
|
||||||
|
<data name="bulksites.type" xml:space="preserve"><value>Type</value></data>
|
||||||
|
<data name="bulksites.owners" xml:space="preserve"><value>Propriétaires</value></data>
|
||||||
|
<data name="bulksites.members" xml:space="preserve"><value>Membres</value></data>
|
||||||
|
<!-- Phase 4: Folder Structure tab -->
|
||||||
|
<data name="folderstruct.import" xml:space="preserve"><value>Importer CSV</value></data>
|
||||||
|
<data name="folderstruct.example" xml:space="preserve"><value>Charger l'exemple</value></data>
|
||||||
|
<data name="folderstruct.execute" xml:space="preserve"><value>Créer les dossiers</value></data>
|
||||||
|
<data name="folderstruct.preview" xml:space="preserve"><value>Aperçu ({0} dossiers à créer)</value></data>
|
||||||
|
<data name="folderstruct.library" xml:space="preserve"><value>Bibliothèque cible</value></data>
|
||||||
|
<data name="folderstruct.siteurl" xml:space="preserve"><value>URL du site</value></data>
|
||||||
|
<!-- Phase 4: Templates tab -->
|
||||||
|
<data name="templates.list" xml:space="preserve"><value>Modèles enregistrés</value></data>
|
||||||
|
<data name="templates.capture" xml:space="preserve"><value>Capturer un modèle</value></data>
|
||||||
|
<data name="templates.apply" xml:space="preserve"><value>Appliquer le modèle</value></data>
|
||||||
|
<data name="templates.rename" xml:space="preserve"><value>Renommer</value></data>
|
||||||
|
<data name="templates.delete" xml:space="preserve"><value>Supprimer</value></data>
|
||||||
|
<data name="templates.siteurl" xml:space="preserve"><value>URL du site source</value></data>
|
||||||
|
<data name="templates.name" xml:space="preserve"><value>Nom du modèle</value></data>
|
||||||
|
<data name="templates.newtitle" xml:space="preserve"><value>Titre du nouveau site</value></data>
|
||||||
|
<data name="templates.newalias" xml:space="preserve"><value>Alias du nouveau site</value></data>
|
||||||
|
<data name="templates.options" xml:space="preserve"><value>Options de capture</value></data>
|
||||||
|
<data name="templates.opt.libraries" xml:space="preserve"><value>Bibliothèques</value></data>
|
||||||
|
<data name="templates.opt.folders" xml:space="preserve"><value>Dossiers</value></data>
|
||||||
|
<data name="templates.opt.permissions" xml:space="preserve"><value>Groupes de permissions</value></data>
|
||||||
|
<data name="templates.opt.logo" xml:space="preserve"><value>Logo du site</value></data>
|
||||||
|
<data name="templates.opt.settings" xml:space="preserve"><value>Paramètres du site</value></data>
|
||||||
|
<data name="templates.empty" xml:space="preserve"><value>Aucun modèle enregistré.</value></data>
|
||||||
|
<!-- Phase 4: Shared bulk operation strings -->
|
||||||
|
<data name="bulk.confirm.title" xml:space="preserve"><value>Confirmer l'opération</value></data>
|
||||||
|
<data name="bulk.confirm.proceed" xml:space="preserve"><value>Continuer</value></data>
|
||||||
|
<data name="bulk.confirm.cancel" xml:space="preserve"><value>Annuler</value></data>
|
||||||
|
<data name="bulk.confirm.message" xml:space="preserve"><value>{0} — Continuer ?</value></data>
|
||||||
|
<data name="bulk.result.success" xml:space="preserve"><value>Terminé : {0} réussis, {1} échoués</value></data>
|
||||||
|
<data name="bulk.result.allfailed" xml:space="preserve"><value>Les {0} éléments ont échoué.</value></data>
|
||||||
|
<data name="bulk.result.allsuccess" xml:space="preserve"><value>Les {0} éléments ont été traités avec succès.</value></data>
|
||||||
|
<data name="bulk.exportfailed" xml:space="preserve"><value>Exporter les éléments échoués</value></data>
|
||||||
|
<data name="bulk.retryfailed" xml:space="preserve"><value>Réessayer les échecs</value></data>
|
||||||
|
<data name="bulk.validation.invalid" xml:space="preserve"><value>{0} lignes contiennent des erreurs. Corrigez et réimportez.</value></data>
|
||||||
|
<data name="bulk.csvimport.title" xml:space="preserve"><value>Sélectionner un fichier CSV</value></data>
|
||||||
|
<data name="bulk.csvimport.filter" xml:space="preserve"><value>Fichiers CSV (*.csv)|*.csv</value></data>
|
||||||
|
<!-- Phase 4: Folder browser dialog -->
|
||||||
|
<data name="folderbrowser.title" xml:space="preserve"><value>Sélectionner un dossier</value></data>
|
||||||
|
<data name="folderbrowser.loading" xml:space="preserve"><value>Chargement de l'arborescence...</value></data>
|
||||||
|
<data name="folderbrowser.select" xml:space="preserve"><value>Sélectionner</value></data>
|
||||||
|
<data name="folderbrowser.cancel" xml:space="preserve"><value>Annuler</value></data>
|
||||||
|
<!-- Phase 6: Global Site Selection toolbar -->
|
||||||
|
<data name="toolbar.selectSites" xml:space="preserve">
|
||||||
|
<value>Choisir les sites</value>
|
||||||
|
</data>
|
||||||
|
<data name="toolbar.selectSites.tooltip" xml:space="preserve">
|
||||||
|
<value>Choisir les sites cibles pour tous les onglets</value>
|
||||||
|
</data>
|
||||||
|
<data name="toolbar.selectSites.tooltipDisabled" xml:space="preserve">
|
||||||
|
<value>Connectez-vous d'abord</value>
|
||||||
|
</data>
|
||||||
|
<data name="toolbar.globalSites.count" xml:space="preserve">
|
||||||
|
<value>{0} site(s) selectionne(s)</value>
|
||||||
|
</data>
|
||||||
|
<data name="toolbar.globalSites.none" xml:space="preserve">
|
||||||
|
<value>Aucun site selectionne</value>
|
||||||
|
</data>
|
||||||
|
<!-- Phase 7: User Access Audit -->
|
||||||
|
<data name="tab.userAccessAudit" xml:space="preserve">
|
||||||
|
<value>Audit des accès utilisateur</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.grp.users" xml:space="preserve">
|
||||||
|
<value>Sélectionner les utilisateurs</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.grp.sites" xml:space="preserve">
|
||||||
|
<value>Sites cibles</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.grp.options" xml:space="preserve">
|
||||||
|
<value>Options d'analyse</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.search.placeholder" xml:space="preserve">
|
||||||
|
<value>Rechercher par nom ou email...</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.users.selected" xml:space="preserve">
|
||||||
|
<value>{0} utilisateur(s) sélectionné(s)</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.btn.run" xml:space="preserve">
|
||||||
|
<value>Lancer l'audit</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.btn.exportCsv" xml:space="preserve">
|
||||||
|
<value>Exporter CSV</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.btn.exportHtml" xml:space="preserve">
|
||||||
|
<value>Exporter HTML</value>
|
||||||
|
</data>
|
||||||
|
<data name="export.split.label" xml:space="preserve">
|
||||||
|
<value>Découper</value>
|
||||||
|
</data>
|
||||||
|
<data name="export.split.single" xml:space="preserve">
|
||||||
|
<value>Fichier unique</value>
|
||||||
|
</data>
|
||||||
|
<data name="export.split.bySite" xml:space="preserve">
|
||||||
|
<value>Par site</value>
|
||||||
|
</data>
|
||||||
|
<data name="export.split.byUser" xml:space="preserve">
|
||||||
|
<value>Par utilisateur</value>
|
||||||
|
</data>
|
||||||
|
<data name="export.html.layout.label" xml:space="preserve">
|
||||||
|
<value>Mise en page HTML</value>
|
||||||
|
</data>
|
||||||
|
<data name="export.html.layout.separate" xml:space="preserve">
|
||||||
|
<value>Fichiers séparés</value>
|
||||||
|
</data>
|
||||||
|
<data name="export.html.layout.tabbed" xml:space="preserve">
|
||||||
|
<value>Fichier unique à onglets</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.summary.total" xml:space="preserve">
|
||||||
|
<value>Total des accès</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.summary.sites" xml:space="preserve">
|
||||||
|
<value>Sites</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.summary.highPriv" xml:space="preserve">
|
||||||
|
<value>Privilèges élevés</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.toggle.byUser" xml:space="preserve">
|
||||||
|
<value>Par utilisateur</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.toggle.bySite" xml:space="preserve">
|
||||||
|
<value>Par site</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.filter.placeholder" xml:space="preserve">
|
||||||
|
<value>Filtrer les résultats...</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.noUsers" xml:space="preserve">
|
||||||
|
<value>Sélectionnez au moins un utilisateur.</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.noSites" xml:space="preserve">
|
||||||
|
<value>Sélectionnez au moins un site.</value>
|
||||||
|
</data>
|
||||||
|
<!-- Phase 9: Storage Visualization Charts -->
|
||||||
|
<data name="stor.chart.title" xml:space="preserve"><value>Stockage par type de fichier</value></data>
|
||||||
|
<data name="stor.chart.donut" xml:space="preserve"><value>Graphique en anneau</value></data>
|
||||||
|
<data name="stor.chart.bar" xml:space="preserve"><value>Graphique en barres</value></data>
|
||||||
|
<data name="stor.chart.toggle" xml:space="preserve"><value>Type de graphique :</value></data>
|
||||||
|
<data name="stor.chart.nodata" xml:space="preserve"><value>Exécutez une analyse pour voir la répartition par type de fichier.</value></data>
|
||||||
|
<!-- Phase 12: Logo UI -->
|
||||||
|
<data name="settings.logo.title" xml:space="preserve"><value>Logo MSP</value></data>
|
||||||
|
<data name="settings.logo.browse" xml:space="preserve"><value>Importer</value></data>
|
||||||
|
<data name="settings.logo.clear" xml:space="preserve"><value>Effacer</value></data>
|
||||||
|
<data name="settings.logo.nopreview" xml:space="preserve"><value>Aucun logo configuré</value></data>
|
||||||
|
<data name="profile.logo.title" xml:space="preserve"><value>Logo client</value></data>
|
||||||
|
<data name="profile.logo.browse" xml:space="preserve"><value>Importer</value></data>
|
||||||
|
<data name="profile.logo.clear" xml:space="preserve"><value>Effacer</value></data>
|
||||||
|
<data name="profile.logo.autopull" xml:space="preserve"><value>Importer depuis Entra</value></data>
|
||||||
|
<data name="profile.logo.nopreview" xml:space="preserve"><value>Aucun logo configuré</value></data>
|
||||||
|
<!-- Phase 14: Directory Browse UI -->
|
||||||
|
<data name="audit.mode.search" xml:space="preserve"><value>Recherche</value></data>
|
||||||
|
<data name="audit.mode.browse" xml:space="preserve"><value>Parcourir l'annuaire</value></data>
|
||||||
|
<data name="directory.grp.browse" xml:space="preserve"><value>Annuaire utilisateurs</value></data>
|
||||||
|
<data name="directory.btn.load" xml:space="preserve"><value>Charger l'annuaire</value></data>
|
||||||
|
<data name="directory.btn.cancel" xml:space="preserve"><value>Annuler</value></data>
|
||||||
|
<data name="directory.filter.placeholder" xml:space="preserve"><value>Filtrer les utilisateurs...</value></data>
|
||||||
|
<data name="directory.chk.guests" xml:space="preserve"><value>Inclure les invités</value></data>
|
||||||
|
<data name="directory.status.count" xml:space="preserve"><value>utilisateurs</value></data>
|
||||||
|
<data name="directory.hint.doubleclick" xml:space="preserve"><value>Double-cliquez sur un utilisateur pour l'ajouter à l'audit</value></data>
|
||||||
|
<data name="directory.col.name" xml:space="preserve"><value>Nom</value></data>
|
||||||
|
<data name="directory.col.upn" xml:space="preserve"><value>Courriel</value></data>
|
||||||
|
<data name="directory.col.department" xml:space="preserve"><value>Département</value></data>
|
||||||
|
<data name="directory.col.jobtitle" xml:space="preserve"><value>Poste</value></data>
|
||||||
|
<data name="directory.col.type" xml:space="preserve"><value>Type</value></data>
|
||||||
|
<!-- Phase 16: Report Consolidation Toggle -->
|
||||||
|
<data name="audit.grp.export" xml:space="preserve"><value>Options d'exportation</value></data>
|
||||||
|
<data name="chk.merge.permissions" xml:space="preserve"><value>Fusionner les permissions en double</value></data>
|
||||||
|
<data name="chk.hide.system.group.raw" xml:space="preserve"><value>Masquer les noms bruts (SharingLinks, Limited Access)</value></data>
|
||||||
|
<data name="chk.exclude.sharing.links" xml:space="preserve"><value>Exclure les liens de partage</value></data>
|
||||||
|
<data name="chk.exclude.system.groups" xml:space="preserve"><value>Exclure les groupes système (Limited Access)</value></data>
|
||||||
|
<!-- Phase 19: App Registration & Removal -->
|
||||||
|
<data name="profile.register" xml:space="preserve"><value>Enregistrer l'app</value></data>
|
||||||
|
<data name="profile.remove" xml:space="preserve"><value>Supprimer l'app</value></data>
|
||||||
|
<data name="profile.register.checking" xml:space="preserve"><value>Vérification des permissions...</value></data>
|
||||||
|
<data name="profile.register.registering" xml:space="preserve"><value>Enregistrement de l'application...</value></data>
|
||||||
|
<data name="profile.register.success" xml:space="preserve"><value>Application enregistrée avec succès</value></data>
|
||||||
|
<data name="profile.register.failed" xml:space="preserve"><value>L'enregistrement a échoué</value></data>
|
||||||
|
<data name="profile.register.noperm" xml:space="preserve"><value>Permissions insuffisantes pour l'enregistrement automatique</value></data>
|
||||||
|
<data name="profile.remove.removing" xml:space="preserve"><value>Suppression de l'application...</value></data>
|
||||||
|
<data name="profile.remove.success" xml:space="preserve"><value>Application supprimée avec succès</value></data>
|
||||||
|
<data name="profile.fallback.title" xml:space="preserve"><value>Enregistrement manuel requis</value></data>
|
||||||
|
<data name="profile.fallback.step1" xml:space="preserve"><value>1. Allez dans le portail Azure > Inscriptions d'applications > Nouvelle inscription</value></data>
|
||||||
|
<data name="profile.fallback.step2" xml:space="preserve"><value>2. Nom : 'SharePoint Toolbox - {0}', Types de comptes : Locataire unique</value></data>
|
||||||
|
<data name="profile.fallback.step3" xml:space="preserve"><value>3. URI de redirection : Client public, https://login.microsoftonline.com/common/oauth2/nativeclient</value></data>
|
||||||
|
<data name="profile.fallback.step4" xml:space="preserve"><value>4. Sous Permissions API, ajouter : Microsoft Graph (User.Read, User.Read.All, Group.Read.All, Directory.Read.All) et SharePoint (AllSites.FullControl)</value></data>
|
||||||
|
<data name="profile.fallback.step5" xml:space="preserve"><value>5. Accorder le consentement administrateur pour toutes les permissions</value></data>
|
||||||
|
<data name="profile.fallback.step6" xml:space="preserve"><value>6. Copier l'ID d'application (client) et le coller dans le champ ID Client ci-dessus</value></data>
|
||||||
|
<!-- Phase 18: Auto-Take Ownership -->
|
||||||
|
<data name="settings.ownership.title" xml:space="preserve"><value>Propriété du site</value></data>
|
||||||
|
<data name="settings.ownership.auto" xml:space="preserve"><value>Prendre automatiquement la propriété d'administrateur de collection de sites en cas de refus d'accès</value></data>
|
||||||
|
<data name="settings.ownership.description" xml:space="preserve"><value>Lorsqu'activé, l'application prendra automatiquement les droits d'administrateur de collection de sites lorsqu'un scan rencontre une erreur de refus d'accès. Nécessite les permissions d'administrateur de tenant.</value></data>
|
||||||
|
<data name="permissions.elevated.tooltip" xml:space="preserve"><value>Ce site a été élevé automatiquement — la propriété a été prise pour compléter le scan</value></data>
|
||||||
|
<!-- Report export localization -->
|
||||||
|
<data name="report.title.user_access" xml:space="preserve"><value>Rapport d'audit des accès utilisateurs</value></data>
|
||||||
|
<data name="report.title.user_access_consolidated" xml:space="preserve"><value>Rapport d'audit des accès utilisateurs (consolidé)</value></data>
|
||||||
|
<data name="report.title.permissions" xml:space="preserve"><value>Rapport des permissions SharePoint</value></data>
|
||||||
|
<data name="report.title.permissions_simplified" xml:space="preserve"><value>Rapport des permissions SharePoint (simplifié)</value></data>
|
||||||
|
<data name="report.title.storage" xml:space="preserve"><value>Métriques de stockage SharePoint</value></data>
|
||||||
|
<data name="report.title.duplicates" xml:space="preserve"><value>Rapport de détection de doublons SharePoint</value></data>
|
||||||
|
<data name="report.title.duplicates_short" xml:space="preserve"><value>Rapport de détection de doublons</value></data>
|
||||||
|
<data name="report.title.search" xml:space="preserve"><value>Résultats de recherche de fichiers SharePoint</value></data>
|
||||||
|
<data name="report.title.search_short" xml:space="preserve"><value>Résultats de recherche de fichiers</value></data>
|
||||||
|
<data name="report.title.versions" xml:space="preserve"><value>Rapport de nettoyage des versions SharePoint</value></data>
|
||||||
|
<data name="report.title.versions_short" xml:space="preserve"><value>Rapport de nettoyage des versions</value></data>
|
||||||
|
<data name="report.stat.total_accesses" xml:space="preserve"><value>Accès totaux</value></data>
|
||||||
|
<data name="report.stat.users_audited" xml:space="preserve"><value>Utilisateurs audités</value></data>
|
||||||
|
<data name="report.stat.sites_scanned" xml:space="preserve"><value>Sites analysés</value></data>
|
||||||
|
<data name="report.stat.high_privilege" xml:space="preserve"><value>Privilège élevé</value></data>
|
||||||
|
<data name="report.stat.external_users" xml:space="preserve"><value>Utilisateurs externes</value></data>
|
||||||
|
<data name="report.stat.total_entries" xml:space="preserve"><value>Entrées totales</value></data>
|
||||||
|
<data name="report.stat.unique_permission_sets" xml:space="preserve"><value>Ensembles de permissions uniques</value></data>
|
||||||
|
<data name="report.stat.distinct_users_groups" xml:space="preserve"><value>Utilisateurs/Groupes distincts</value></data>
|
||||||
|
<data name="report.stat.libraries" xml:space="preserve"><value>Bibliothèques</value></data>
|
||||||
|
<data name="report.stat.files" xml:space="preserve"><value>Fichiers</value></data>
|
||||||
|
<data name="report.stat.total_size" xml:space="preserve"><value>Taille totale</value></data>
|
||||||
|
<data name="report.stat.version_size" xml:space="preserve"><value>Taille des versions</value></data>
|
||||||
|
<data name="report.badge.guest" xml:space="preserve"><value>Invité</value></data>
|
||||||
|
<data name="report.badge.direct" xml:space="preserve"><value>Direct</value></data>
|
||||||
|
<data name="report.badge.group" xml:space="preserve"><value>Groupe</value></data>
|
||||||
|
<data name="report.badge.inherited" xml:space="preserve"><value>Hérité</value></data>
|
||||||
|
<data name="report.badge.unique" xml:space="preserve"><value>Unique</value></data>
|
||||||
|
<data name="report.view.by_user" xml:space="preserve"><value>Par utilisateur</value></data>
|
||||||
|
<data name="report.view.by_site" xml:space="preserve"><value>Par site</value></data>
|
||||||
|
<data name="report.filter.placeholder_results" xml:space="preserve"><value>Filtrer les résultats...</value></data>
|
||||||
|
<data name="report.filter.placeholder_permissions" xml:space="preserve"><value>Filtrer les permissions...</value></data>
|
||||||
|
<data name="report.filter.placeholder_rows" xml:space="preserve"><value>Filtrer les lignes…</value></data>
|
||||||
|
<data name="report.filter.label" xml:space="preserve"><value>Filtre :</value></data>
|
||||||
|
<data name="report.col.site" xml:space="preserve"><value>Site</value></data>
|
||||||
|
<data name="report.col.sites" xml:space="preserve"><value>Sites</value></data>
|
||||||
|
<data name="report.col.object_type" xml:space="preserve"><value>Type d'objet</value></data>
|
||||||
|
<data name="report.col.object" xml:space="preserve"><value>Objet</value></data>
|
||||||
|
<data name="report.col.permission_level" xml:space="preserve"><value>Niveau de permission</value></data>
|
||||||
|
<data name="report.col.access_type" xml:space="preserve"><value>Type d'accès</value></data>
|
||||||
|
<data name="report.col.granted_through" xml:space="preserve"><value>Accordé via</value></data>
|
||||||
|
<data name="report.col.user" xml:space="preserve"><value>Utilisateur</value></data>
|
||||||
|
<data name="report.col.title" xml:space="preserve"><value>Titre</value></data>
|
||||||
|
<data name="report.col.url" xml:space="preserve"><value>URL</value></data>
|
||||||
|
<data name="report.col.users_groups" xml:space="preserve"><value>Utilisateurs/Groupes</value></data>
|
||||||
|
<data name="report.col.simplified" xml:space="preserve"><value>Simplifié</value></data>
|
||||||
|
<data name="report.col.risk" xml:space="preserve"><value>Risque</value></data>
|
||||||
|
<data name="report.col.library_folder" xml:space="preserve"><value>Bibliothèque / Dossier</value></data>
|
||||||
|
<data name="report.col.last_modified" xml:space="preserve"><value>Dernière modification</value></data>
|
||||||
|
<data name="report.col.name" xml:space="preserve"><value>Nom</value></data>
|
||||||
|
<data name="report.col.library" xml:space="preserve"><value>Bibliothèque</value></data>
|
||||||
|
<data name="report.col.path" xml:space="preserve"><value>Chemin</value></data>
|
||||||
|
<data name="report.col.size" xml:space="preserve"><value>Taille</value></data>
|
||||||
|
<data name="report.col.created" xml:space="preserve"><value>Créé</value></data>
|
||||||
|
<data name="report.col.modified" xml:space="preserve"><value>Modifié</value></data>
|
||||||
|
<data name="report.col.created_by" xml:space="preserve"><value>Créé par</value></data>
|
||||||
|
<data name="report.col.modified_by" xml:space="preserve"><value>Modifié par</value></data>
|
||||||
|
<data name="report.col.file_name" xml:space="preserve"><value>Nom de fichier</value></data>
|
||||||
|
<data name="report.col.extension" xml:space="preserve"><value>Extension</value></data>
|
||||||
|
<data name="report.col.file_type" xml:space="preserve"><value>Type de fichier</value></data>
|
||||||
|
<data name="report.col.file_count" xml:space="preserve"><value>Nombre de fichiers</value></data>
|
||||||
|
<data name="report.col.error" xml:space="preserve"><value>Erreur</value></data>
|
||||||
|
<data name="report.col.timestamp" xml:space="preserve"><value>Horodatage</value></data>
|
||||||
|
<data name="report.col.number" xml:space="preserve"><value>#</value></data>
|
||||||
|
<data name="report.col.group" xml:space="preserve"><value>Groupe</value></data>
|
||||||
|
<data name="report.col.total_size_mb" xml:space="preserve"><value>Taille totale (Mo)</value></data>
|
||||||
|
<data name="report.col.version_size_mb" xml:space="preserve"><value>Taille des versions (Mo)</value></data>
|
||||||
|
<data name="report.col.size_mb" xml:space="preserve"><value>Taille (Mo)</value></data>
|
||||||
|
<data name="report.col.size_bytes" xml:space="preserve"><value>Taille (octets)</value></data>
|
||||||
|
<data name="report.text.accesses" xml:space="preserve"><value>accès</value></data>
|
||||||
|
<data name="report.text.access_es" xml:space="preserve"><value>accès</value></data>
|
||||||
|
<data name="report.text.sites_parens" xml:space="preserve"><value>site(s)</value></data>
|
||||||
|
<data name="report.text.permissions_parens" xml:space="preserve"><value>permission(s)</value></data>
|
||||||
|
<data name="report.text.copies" xml:space="preserve"><value>copies</value></data>
|
||||||
|
<data name="report.text.duplicate_groups_found" xml:space="preserve"><value>groupe(s) de doublons trouvé(s).</value></data>
|
||||||
|
<data name="report.text.results_parens" xml:space="preserve"><value>résultat(s)</value></data>
|
||||||
|
<data name="report.text.of" xml:space="preserve"><value>sur</value></data>
|
||||||
|
<data name="report.text.shown" xml:space="preserve"><value>affiché(s)</value></data>
|
||||||
|
<data name="report.text.generated" xml:space="preserve"><value>Généré</value></data>
|
||||||
|
<data name="report.text.generated_colon" xml:space="preserve"><value>Généré :</value></data>
|
||||||
|
<data name="report.text.members_unavailable" xml:space="preserve"><value>membres indisponibles</value></data>
|
||||||
|
<data name="report.text.empty_group" xml:space="preserve"><value>Groupe vide</value></data>
|
||||||
|
<data name="report.text.link" xml:space="preserve"><value>Lien</value></data>
|
||||||
|
<data name="report.text.no_ext" xml:space="preserve"><value>(sans ext.)</value></data>
|
||||||
|
<data name="report.text.no_extension" xml:space="preserve"><value>(sans extension)</value></data>
|
||||||
|
<data name="report.text.high_priv" xml:space="preserve"><value>priv. élevé</value></data>
|
||||||
|
<data name="report.section.storage_by_file_type" xml:space="preserve"><value>Stockage par type de fichier</value></data>
|
||||||
|
<data name="report.section.library_details" xml:space="preserve"><value>Détails des bibliothèques</value></data>
|
||||||
|
<!-- Site picker dialog -->
|
||||||
|
<data name="sitepicker.title" xml:space="preserve"><value>Sélectionner les sites</value></data>
|
||||||
|
<data name="sitepicker.filter" xml:space="preserve"><value>Filtre :</value></data>
|
||||||
|
<data name="sitepicker.type" xml:space="preserve"><value>Type :</value></data>
|
||||||
|
<data name="sitepicker.type.all" xml:space="preserve"><value>Tous</value></data>
|
||||||
|
<data name="sitepicker.type.team" xml:space="preserve"><value>Sites d'équipe (MS Teams)</value></data>
|
||||||
|
<data name="sitepicker.type.communication" xml:space="preserve"><value>Communication</value></data>
|
||||||
|
<data name="sitepicker.type.classic" xml:space="preserve"><value>Classique</value></data>
|
||||||
|
<data name="sitepicker.type.other" xml:space="preserve"><value>Autre</value></data>
|
||||||
|
<data name="sitepicker.size" xml:space="preserve"><value>Taille (Mo) :</value></data>
|
||||||
|
<data name="sitepicker.size.min" xml:space="preserve"><value>min</value></data>
|
||||||
|
<data name="sitepicker.size.max" xml:space="preserve"><value>max</value></data>
|
||||||
|
<data name="sitepicker.col.title" xml:space="preserve"><value>Titre</value></data>
|
||||||
|
<data name="sitepicker.col.url" xml:space="preserve"><value>URL</value></data>
|
||||||
|
<data name="sitepicker.col.type" xml:space="preserve"><value>Type</value></data>
|
||||||
|
<data name="sitepicker.col.size" xml:space="preserve"><value>Taille</value></data>
|
||||||
|
<data name="sitepicker.btn.load" xml:space="preserve"><value>Charger les sites</value></data>
|
||||||
|
<data name="sitepicker.btn.selectAll" xml:space="preserve"><value>Tout sélectionner</value></data>
|
||||||
|
<data name="sitepicker.btn.deselectAll" xml:space="preserve"><value>Tout désélectionner</value></data>
|
||||||
|
<data name="sitepicker.btn.ok" xml:space="preserve"><value>OK</value></data>
|
||||||
|
<data name="sitepicker.btn.cancel" xml:space="preserve"><value>Annuler</value></data>
|
||||||
|
<data name="sitepicker.status.loading" xml:space="preserve"><value>Chargement des sites...</value></data>
|
||||||
|
<data name="sitepicker.status.loaded" xml:space="preserve"><value>{0} sites chargés.</value></data>
|
||||||
|
<data name="sitepicker.status.shown" xml:space="preserve"><value>{0} / {1} sites affichés.</value></data>
|
||||||
|
<data name="sitepicker.status.error" xml:space="preserve"><value>Erreur : {0}</value></data>
|
||||||
|
<data name="sitepicker.kind.teamsite" xml:space="preserve"><value>Site d'équipe</value></data>
|
||||||
|
<data name="sitepicker.kind.communication" xml:space="preserve"><value>Communication</value></data>
|
||||||
|
<data name="sitepicker.kind.classic" xml:space="preserve"><value>Classique</value></data>
|
||||||
|
<data name="sitepicker.kind.other" xml:space="preserve"><value>Autre</value></data>
|
||||||
|
<!-- Common UI -->
|
||||||
|
<data name="common.valid" xml:space="preserve"><value>Valide</value></data>
|
||||||
|
<data name="common.errors" xml:space="preserve"><value>Erreurs</value></data>
|
||||||
|
<data name="common.close" xml:space="preserve"><value>Fermer</value></data>
|
||||||
|
<data name="common.new_folder" xml:space="preserve"><value>+ Nouveau dossier</value></data>
|
||||||
|
<data name="common.guest" xml:space="preserve"><value>Invité</value></data>
|
||||||
|
<!-- InputDialog -->
|
||||||
|
<data name="input.title" xml:space="preserve"><value>Saisie</value></data>
|
||||||
|
<!-- ProfileManagementDialog -->
|
||||||
|
<data name="profmgmt.title" xml:space="preserve"><value>Gérer les profils</value></data>
|
||||||
|
<data name="profmgmt.group" xml:space="preserve"><value>Profils</value></data>
|
||||||
|
<!-- Duplicates columns -->
|
||||||
|
<data name="duplicates.col.group" xml:space="preserve"><value>Groupe</value></data>
|
||||||
|
<data name="duplicates.col.copies" xml:space="preserve"><value>Copies</value></data>
|
||||||
|
<!-- Folder structure levels -->
|
||||||
|
<data name="folderstruct.col.level1" xml:space="preserve"><value>Niveau 1</value></data>
|
||||||
|
<data name="folderstruct.col.level2" xml:space="preserve"><value>Niveau 2</value></data>
|
||||||
|
<data name="folderstruct.col.level3" xml:space="preserve"><value>Niveau 3</value></data>
|
||||||
|
<data name="folderstruct.col.level4" xml:space="preserve"><value>Niveau 4</value></data>
|
||||||
|
<!-- Permissions extra columns -->
|
||||||
|
<data name="perm.col.unique_perms" xml:space="preserve"><value>Perm. uniques</value></data>
|
||||||
|
<data name="perm.col.permission_levels" xml:space="preserve"><value>Niveaux d'autorisation</value></data>
|
||||||
|
<data name="perm.col.principal_type" xml:space="preserve"><value>Type de principal</value></data>
|
||||||
|
<!-- Storage summary labels -->
|
||||||
|
<data name="storage.lbl.total_size_colon" xml:space="preserve"><value>Taille totale : </value></data>
|
||||||
|
<data name="storage.lbl.version_size_colon" xml:space="preserve"><value>Taille des versions : </value></data>
|
||||||
|
<data name="storage.lbl.files_colon" xml:space="preserve"><value>Fichiers : </value></data>
|
||||||
|
<!-- Templates columns -->
|
||||||
|
<data name="templates.col.source" xml:space="preserve"><value>Source</value></data>
|
||||||
|
<data name="templates.col.captured" xml:space="preserve"><value>Capturé</value></data>
|
||||||
|
<!-- Transfer view -->
|
||||||
|
<data name="transfer.text.files_selected" xml:space="preserve"><value> fichier(s) sélectionné(s)</value></data>
|
||||||
|
<data name="transfer.chk.include_source" xml:space="preserve"><value>Inclure le dossier source dans la destination</value></data>
|
||||||
|
<data name="transfer.chk.include_source.tooltip" xml:space="preserve"><value>Si activé, recrée le dossier source sous la destination. Sinon, dépose le contenu directement dans le dossier de destination.</value></data>
|
||||||
|
<data name="transfer.chk.copy_contents" xml:space="preserve"><value>Copier le contenu du dossier</value></data>
|
||||||
|
<data name="transfer.chk.copy_contents.tooltip" xml:space="preserve"><value>Si activé (par défaut), transfère les fichiers du dossier. Sinon, seul le dossier est créé à la destination.</value></data>
|
||||||
|
<!-- Shared ViewModel errors and statuses -->
|
||||||
|
<data name="err.no_tenant" xml:space="preserve"><value>Aucun tenant connecté.</value></data>
|
||||||
|
<data name="err.no_tenant_connected" xml:space="preserve"><value>Aucun tenant sélectionné. Connectez-vous à un tenant d'abord.</value></data>
|
||||||
|
<data name="err.no_profile_selected" xml:space="preserve"><value>Aucun profil de tenant sélectionné. Connectez-vous d'abord.</value></data>
|
||||||
|
<data name="err.no_sites_selected" xml:space="preserve"><value>Sélectionnez au moins un site dans la barre d'outils.</value></data>
|
||||||
|
<data name="err.no_users_selected" xml:space="preserve"><value>Ajoutez au moins un utilisateur à auditer.</value></data>
|
||||||
|
<data name="err.no_valid_rows" xml:space="preserve"><value>Aucune ligne valide à traiter. Importez un CSV d'abord.</value></data>
|
||||||
|
<data name="err.template_name_required" xml:space="preserve"><value>Le nom du modèle est requis.</value></data>
|
||||||
|
<data name="err.site_title_required" xml:space="preserve"><value>Le titre du nouveau site est requis.</value></data>
|
||||||
|
<data name="err.site_alias_required" xml:space="preserve"><value>L'alias du nouveau site est requis.</value></data>
|
||||||
|
<data name="err.transfer_source_required" xml:space="preserve"><value>Le site source et la bibliothèque doivent être sélectionnés.</value></data>
|
||||||
|
<data name="err.transfer_dest_required" xml:space="preserve"><value>Le site de destination et la bibliothèque doivent être sélectionnés.</value></data>
|
||||||
|
<data name="err.library_title_required" xml:space="preserve"><value>Le titre de la bibliothèque est requis.</value></data>
|
||||||
|
<!-- Templates status -->
|
||||||
|
<data name="templates.status.capturing" xml:space="preserve"><value>Capture du modèle...</value></data>
|
||||||
|
<data name="templates.status.success" xml:space="preserve"><value>Modèle capturé avec succès.</value></data>
|
||||||
|
<data name="templates.status.capture_failed" xml:space="preserve"><value>Échec de la capture : {0}</value></data>
|
||||||
|
<data name="templates.status.applying" xml:space="preserve"><value>Application du modèle...</value></data>
|
||||||
|
<data name="templates.status.applied" xml:space="preserve"><value>Modèle appliqué. Site créé à : {0}</value></data>
|
||||||
|
<data name="templates.status.apply_failed" xml:space="preserve"><value>Échec de l'application : {0}</value></data>
|
||||||
|
<!-- UI text -->
|
||||||
|
<data name="audit.searching" xml:space="preserve"><value>Recherche en cours...</value></data>
|
||||||
|
<!-- Report text -->
|
||||||
|
<data name="report.text.users_parens" xml:space="preserve"><value>utilisateur(s)</value></data>
|
||||||
|
<data name="report.text.files_unit" xml:space="preserve"><value>fichiers</value></data>
|
||||||
|
<data name="report.text.sites_unit" xml:space="preserve"><value>sites</value></data>
|
||||||
|
<data name="report.text.entries_unit" xml:space="preserve"><value>entrées</value></data>
|
||||||
|
<!-- Textes d'aide / boutons info -->
|
||||||
|
<data name="help.perm.simplified.title" xml:space="preserve"><value>Mode simplifié</value></data>
|
||||||
|
<data name="help.perm.simplified.body" xml:space="preserve"><value>Regroupe les permissions brutes SharePoint en libellés lisibles (Propriétaire, Éditeur, Contributeur, Lecteur, Lecture seule) et colore les lignes par niveau de risque. Utile pour un aperçu rapide de la sécurité sans jargon technique.</value></data>
|
||||||
|
<data name="help.perm.merge.title" xml:space="preserve"><value>Fusionner les permissions</value></data>
|
||||||
|
<data name="help.perm.merge.body" xml:space="preserve"><value>Lorsqu'activé, les entrées de permission multiples pour le même utilisateur ou groupe sont regroupées en une seule ligne dans l'export, réduisant la taille du rapport. Désactivez pour voir chaque permission individuellement.</value></data>
|
||||||
|
<data name="help.perm.hidesys.title" xml:space="preserve"><value>Masquer les groupes système</value></data>
|
||||||
|
<data name="help.perm.hidesys.body" xml:space="preserve"><value>Supprime les groupes système créés automatiquement par SharePoint (ex. « Excel Services Viewers », groupes « SharingLinks.* »). Ces groupes sont gérés en interne par SharePoint et ne sont généralement pas pertinents pour les audits d'accès.</value></data>
|
||||||
|
<data name="help.perm.excl.sharing.title" xml:space="preserve"><value>Exclure les liens de partage</value></data>
|
||||||
|
<data name="help.perm.excl.sharing.body" xml:space="preserve"><value>Supprime les entrées de lien de partage des résultats et des exports (ex. « Tout le monde avec le lien », liens à l'échelle de l'organisation). Utile pour ne conserver que les permissions directes des utilisateurs et groupes.</value></data>
|
||||||
|
<data name="help.perm.excl.system.title" xml:space="preserve"><value>Exclure les groupes système (Limited Access)</value></data>
|
||||||
|
<data name="help.perm.excl.system.body" xml:space="preserve"><value>Supprime les entrées « Limited Access System Group For Web/List » des résultats et des exports. SharePoint crée ces groupes automatiquement lorsqu'un utilisateur a accès à un élément spécifique ; ils sont rarement pertinents pour les audits d'accès.</value></data>
|
||||||
|
<data name="help.perm.inherited.title" xml:space="preserve"><value>Inclure les permissions héritées</value></data>
|
||||||
|
<data name="help.perm.inherited.body" xml:space="preserve"><value>Par défaut, seuls les objets avec des permissions uniques (rompues) sont affichés. Activez pour inclure les objets qui héritent les permissions d'un parent et obtenir une vue complète des accès.</value></data>
|
||||||
|
<data name="help.perm.splitmode.title" xml:space="preserve"><value>Mode de fractionnement de l'export</value></data>
|
||||||
|
<data name="help.perm.splitmode.body" xml:space="preserve"><value>Fichier unique : tous les résultats dans un seul fichier CSV ou HTML.
|
||||||
|
|
||||||
|
Fractionner par site : crée un fichier séparé pour chaque collection de sites. Utile pour les grandes tenances multi-sites.</value></data>
|
||||||
|
<data name="help.search.title" xml:space="preserve"><value>Recherche de fichiers KQL</value></data>
|
||||||
|
<data name="help.search.body" xml:space="preserve"><value>Recherche des fichiers dans vos sites SharePoint via KQL (Keyword Query Language). Le champ mot-clé est optionnel — laissez-le vide pour retourner tous les fichiers correspondant aux filtres actifs. Combinez les filtres de date, auteur et bibliothèque pour affiner les résultats.</value></data>
|
||||||
|
<data name="help.search.regex.title" xml:space="preserve"><value>Filtre regex sur le nom de fichier</value></data>
|
||||||
|
<data name="help.search.regex.body" xml:space="preserve"><value>Filtre les résultats côté client avec une expression régulière .NET appliquée aux noms de fichiers. Exemple : \.pdf$ correspond uniquement aux PDF. Laissez vide pour ignorer ce filtre. L'expression est insensible à la casse.</value></data>
|
||||||
|
<data name="help.versions.policy.title" xml:space="preserve"><value>Politique de nettoyage des versions</value></data>
|
||||||
|
<data name="help.versions.policy.body" xml:space="preserve"><value>Supprime définitivement les anciennes versions de documents des bibliothèques SharePoint. Seules les N versions les plus récentes sont conservées — les versions plus anciennes sont supprimées de façon permanente et ne peuvent pas être récupérées. Effectuez d'abord une analyse pour prévisualiser les suppressions.</value></data>
|
||||||
|
<data name="help.versions.keepfirst.title" xml:space="preserve"><value>Conserver la première version</value></data>
|
||||||
|
<data name="help.versions.keepfirst.body" xml:space="preserve"><value>Conserve toujours la version 1.0 (originale) de chaque document, indépendamment du paramètre « Conserver les N dernières ». Utile pour maintenir une trace de l'état initial du document.</value></data>
|
||||||
|
<data name="help.versions.confirm.title" xml:space="preserve"><value>Confirmer avant suppression</value></data>
|
||||||
|
<data name="help.versions.confirm.body" xml:space="preserve"><value>Lorsqu'activé, une boîte de dialogue de confirmation apparaît pour chaque fichier avant la suppression des versions. Décochez pour un traitement en lot sans intervention.</value></data>
|
||||||
|
<data name="help.dup.criteria.title" xml:space="preserve"><value>Critères de détection des doublons</value></data>
|
||||||
|
<data name="help.dup.criteria.body" xml:space="preserve"><value>Deux éléments sont identifiés comme doublons quand leurs noms correspondent ET que tous les critères supplémentaires cochés correspondent également. Plus de critères cochés = moins de groupes, mais plus précis. Nom uniquement : trouve les fichiers avec le même nom, quel que soit leur contenu.</value></data>
|
||||||
|
<data name="help.transfer.incsource.title" xml:space="preserve"><value>Inclure le dossier source</value></data>
|
||||||
|
<data name="help.transfer.incsource.body" xml:space="preserve"><value>Lorsqu'activé, le dossier source lui-même est recréé à la destination (ex. transférer « Rapports » crée un dossier « Rapports/ » à la cible). Lorsque désactivé, seul le contenu du dossier est transféré — utile pour fusionner du contenu dans un dossier existant.</value></data>
|
||||||
|
<data name="help.transfer.copycontent.title" xml:space="preserve"><value>Copier uniquement le contenu</value></data>
|
||||||
|
<data name="help.transfer.copycontent.body" xml:space="preserve"><value>Lorsqu'activé, seuls les fichiers et sous-dossiers à l'intérieur du dossier sélectionné sont transférés — le dossier lui-même n'est pas recréé à la destination.</value></data>
|
||||||
|
<data name="help.transfer.conflict.title" xml:space="preserve"><value>Politique de conflit de fichiers</value></data>
|
||||||
|
<data name="help.transfer.conflict.body" xml:space="preserve"><value>Définit ce qui se passe quand un fichier du même nom existe déjà à la destination :
|
||||||
|
|
||||||
|
• Ignorer — laisser le fichier destination inchangé.
|
||||||
|
• Écraser — remplacer le fichier destination par le fichier source.
|
||||||
|
• Renommer — conserver les deux en ajoutant un suffixe numérique au fichier transféré.</value></data>
|
||||||
|
<data name="help.bulkmembers.title" xml:space="preserve"><value>Ajout de membres en masse — Format CSV</value></data>
|
||||||
|
<data name="help.bulkmembers.body" xml:space="preserve"><value>Le fichier CSV doit contenir ces colonnes (en-têtes obligatoires, ordre libre) :
|
||||||
|
• GroupName — le nom exact du groupe SharePoint
|
||||||
|
• Email — l'adresse e-mail de l'utilisateur
|
||||||
|
• Role — Member, Owner ou Visitor
|
||||||
|
|
||||||
|
Cliquez sur « Charger l'exemple » pour ouvrir un fichier d'exemple pré-rempli.</value></data>
|
||||||
|
<data name="help.bulksites.title" xml:space="preserve"><value>Création de sites en masse — Format CSV</value></data>
|
||||||
|
<data name="help.bulksites.body" xml:space="preserve"><value>Le fichier CSV doit contenir ces colonnes :
|
||||||
|
• Name — le nom d'affichage du nouveau site
|
||||||
|
• Alias — alias d'URL (sans espaces ; fait partie de l'URL du site)
|
||||||
|
• Type — TeamSite ou CommunicationSite
|
||||||
|
• Owners — liste d'adresses e-mail des propriétaires séparées par des virgules
|
||||||
|
|
||||||
|
Cliquez sur « Charger l'exemple » pour ouvrir un fichier d'exemple pré-rempli.</value></data>
|
||||||
|
<data name="help.folderstruct.title" xml:space="preserve"><value>Créer une structure de dossiers — Format CSV</value></data>
|
||||||
|
<data name="help.folderstruct.body" xml:space="preserve"><value>Crée une hiérarchie de dossiers dans une bibliothèque SharePoint à partir d'un fichier CSV. Chaque ligne définit un chemin avec jusqu'à 4 niveaux (Level1–Level4). Laissez les colonnes des niveaux inférieurs vides pour des chemins plus courts.
|
||||||
|
|
||||||
|
Exemple : Contrats | 2024 | T1 | (vide)
|
||||||
|
Crée : Bibliothèque / Contrats / 2024 / T1</value></data>
|
||||||
|
<data name="help.templates.capture.title" xml:space="preserve"><value>Capturer un modèle de site</value></data>
|
||||||
|
<data name="help.templates.capture.body" xml:space="preserve"><value>Enregistre la structure du site sélectionné (bibliothèques, dossiers, permissions, paramètres et logo) comme modèle réutilisable stocké localement. Le site source n'est pas modifié.
|
||||||
|
|
||||||
|
Sélectionnez les éléments à capturer avec les cases à cocher ci-dessus.</value></data>
|
||||||
|
<data name="help.templates.apply.title" xml:space="preserve"><value>Appliquer le modèle à un nouveau site</value></data>
|
||||||
|
<data name="help.templates.apply.body" xml:space="preserve"><value>Crée un nouveau site SharePoint et reproduit la structure du modèle sélectionné — bibliothèques, dossiers, permissions, paramètres et logo. Le modèle source et le site d'origine ne sont pas affectés.
|
||||||
|
|
||||||
|
Fournissez un nom d'affichage et un alias d'URL avant de cliquer sur Appliquer.</value></data>
|
||||||
|
<data name="help.audit.mode.title" xml:space="preserve"><value>Mode Recherche vs Mode Navigation</value></data>
|
||||||
|
<data name="help.audit.mode.body" xml:space="preserve"><value>Mode Recherche : tapez un nom ou e-mail pour trouver un utilisateur via Azure AD. Les résultats apparaissent dans une liste — cliquez pour sélectionner.
|
||||||
|
|
||||||
|
Mode Navigation : charge tous les utilisateurs du répertoire de la tenant. Utilisez le filtre pour trouver un utilisateur, puis double-cliquez pour l'ajouter à l'audit.</value></data>
|
||||||
|
<data name="help.audit.vs.perms.title" xml:space="preserve"><value>Audit d'accès vs Audit des permissions</value></data>
|
||||||
|
<data name="help.audit.vs.perms.body" xml:space="preserve"><value>L'onglet Permissions analyse les objets (bibliothèques, dossiers, éléments) pour montrer qui y a accès.
|
||||||
|
|
||||||
|
Cet onglet fait l'inverse : vous sélectionnez un ou plusieurs utilisateurs et il trouve chaque objet auquel ils peuvent accéder — y compris via des groupes SharePoint ou Active Directory.</value></data>
|
||||||
|
<data name="help.storage.hidden.title" xml:space="preserve"><value>Bibliothèques masquées</value></data>
|
||||||
|
<data name="help.storage.hidden.body" xml:space="preserve"><value>Analyse les bibliothèques SharePoint cachées dans la navigation normale du site (ex. Site Assets, Style Library, Form Templates). Elles peuvent consommer beaucoup d'espace et sont souvent oubliées dans les audits de routine.</value></data>
|
||||||
|
<data name="help.storage.preservation.title" xml:space="preserve"><value>Bibliothèque de conservation</value></data>
|
||||||
|
<data name="help.storage.preservation.body" xml:space="preserve"><value>Bibliothèque SharePoint cachée qui stocke les versions de documents modifiés ou supprimés pendant qu'une politique de rétention Microsoft Purview / Microsoft 365 Compliance est active. Elle peut croître considérablement sans être visible pour les utilisateurs du site.</value></data>
|
||||||
|
</root>
|
||||||
@@ -0,0 +1,870 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<root>
|
||||||
|
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||||
|
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
|
||||||
|
<xsd:element name="root" msdata:IsDataSet="true">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:choice maxOccurs="unbounded">
|
||||||
|
<xsd:element name="metadata">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:sequence>
|
||||||
|
<xsd:element name="value" type="xsd:string" minOccurs="0" />
|
||||||
|
</xsd:sequence>
|
||||||
|
<xsd:attribute name="name" use="required" type="xsd:string" />
|
||||||
|
<xsd:attribute name="type" type="xsd:string" />
|
||||||
|
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||||
|
<xsd:attribute ref="xml:space" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
<xsd:element name="assembly">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:attribute name="alias" type="xsd:string" />
|
||||||
|
<xsd:attribute name="name" type="xsd:string" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
<xsd:element name="data">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:sequence>
|
||||||
|
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||||
|
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
|
||||||
|
</xsd:sequence>
|
||||||
|
<xsd:attribute name="name" use="required" type="xsd:string" />
|
||||||
|
<xsd:attribute name="type" type="xsd:string" />
|
||||||
|
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||||
|
<xsd:attribute ref="xml:space" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
<xsd:element name="resheader">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:sequence>
|
||||||
|
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||||
|
</xsd:sequence>
|
||||||
|
<xsd:attribute name="name" type="xsd:string" use="required" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
</xsd:choice>
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
</xsd:schema>
|
||||||
|
<resheader name="resmimetype">
|
||||||
|
<value>text/microsoft-resx</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="version">
|
||||||
|
<value>2.0</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="reader">
|
||||||
|
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="writer">
|
||||||
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
|
</resheader>
|
||||||
|
<data name="app.title" xml:space="preserve">
|
||||||
|
<value>SharePoint Toolbox</value>
|
||||||
|
</data>
|
||||||
|
<data name="toolbar.connect" xml:space="preserve">
|
||||||
|
<value>Connect</value>
|
||||||
|
</data>
|
||||||
|
<data name="toolbar.manage" xml:space="preserve">
|
||||||
|
<value>Manage Profiles...</value>
|
||||||
|
</data>
|
||||||
|
<data name="toolbar.clear" xml:space="preserve">
|
||||||
|
<value>Clear Session</value>
|
||||||
|
</data>
|
||||||
|
<data name="tab.permissions" xml:space="preserve">
|
||||||
|
<value>Permissions</value>
|
||||||
|
</data>
|
||||||
|
<data name="tab.storage" xml:space="preserve">
|
||||||
|
<value>Storage</value>
|
||||||
|
</data>
|
||||||
|
<data name="tab.search" xml:space="preserve">
|
||||||
|
<value>File Search</value>
|
||||||
|
</data>
|
||||||
|
<data name="tab.duplicates" xml:space="preserve">
|
||||||
|
<value>Duplicates</value>
|
||||||
|
</data>
|
||||||
|
<data name="tab.versions" xml:space="preserve">
|
||||||
|
<value>Versions</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.tab" xml:space="preserve">
|
||||||
|
<value>Version cleanup</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.grp.libs" xml:space="preserve">
|
||||||
|
<value>Libraries</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.grp.policy" xml:space="preserve">
|
||||||
|
<value>Retention policy</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.btn.pickLibs" xml:space="preserve">
|
||||||
|
<value>Select libraries...</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.btn.clearLibs" xml:space="preserve">
|
||||||
|
<value>Reset (all libraries)</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.btn.run" xml:space="preserve">
|
||||||
|
<value>Delete old versions</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.lbl.keepLast" xml:space="preserve">
|
||||||
|
<value>Keep last:</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.chk.keepFirst" xml:space="preserve">
|
||||||
|
<value>Also keep the very first version</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.chk.confirm" xml:space="preserve">
|
||||||
|
<value>Ask for confirmation before running</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.note" xml:space="preserve">
|
||||||
|
<value>Only historical versions are removed. The current published version is always kept. The action cannot be undone.</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.libs.all" xml:space="preserve">
|
||||||
|
<value>All libraries (no filter)</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.libs.count" xml:space="preserve">
|
||||||
|
<value>{0} library/libraries selected</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.confirm" xml:space="preserve">
|
||||||
|
<value>Delete historical file versions, keeping the last {0} {1}?
|
||||||
|
This cannot be undone.</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.confirm.keepFirst" xml:space="preserve">
|
||||||
|
<value>(plus the first version)</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.err.keepLast" xml:space="preserve">
|
||||||
|
<value>"Keep last" must be 0 or greater.</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.summary.files" xml:space="preserve">
|
||||||
|
<value>Files trimmed:</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.summary.deleted" xml:space="preserve">
|
||||||
|
<value>Versions deleted:</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.summary.freed" xml:space="preserve">
|
||||||
|
<value>Bytes freed:</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.col.library" xml:space="preserve">
|
||||||
|
<value>Library</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.col.file" xml:space="preserve">
|
||||||
|
<value>File</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.col.before" xml:space="preserve">
|
||||||
|
<value>Before</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.col.deleted" xml:space="preserve">
|
||||||
|
<value>Deleted</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.col.remaining" xml:space="preserve">
|
||||||
|
<value>Remaining</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.col.freed" xml:space="preserve">
|
||||||
|
<value>Freed</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.col.path" xml:space="preserve">
|
||||||
|
<value>Path</value>
|
||||||
|
</data>
|
||||||
|
<data name="versions.col.error" xml:space="preserve">
|
||||||
|
<value>Error</value>
|
||||||
|
</data>
|
||||||
|
<data name="librarypicker.title" xml:space="preserve">
|
||||||
|
<value>Select libraries</value>
|
||||||
|
</data>
|
||||||
|
<data name="librarypicker.loading" xml:space="preserve">
|
||||||
|
<value>Loading libraries...</value>
|
||||||
|
</data>
|
||||||
|
<data name="librarypicker.loaded" xml:space="preserve">
|
||||||
|
<value>{0} libraries loaded.</value>
|
||||||
|
</data>
|
||||||
|
<data name="librarypicker.selectAll" xml:space="preserve">
|
||||||
|
<value>Select all</value>
|
||||||
|
</data>
|
||||||
|
<data name="librarypicker.selectNone" xml:space="preserve">
|
||||||
|
<value>Select none</value>
|
||||||
|
</data>
|
||||||
|
<data name="tab.templates" xml:space="preserve">
|
||||||
|
<value>Templates</value>
|
||||||
|
</data>
|
||||||
|
<data name="tab.bulk" xml:space="preserve">
|
||||||
|
<value>Bulk Operations</value>
|
||||||
|
</data>
|
||||||
|
<data name="tab.structure" xml:space="preserve">
|
||||||
|
<value>Folder Structure</value>
|
||||||
|
</data>
|
||||||
|
<data name="tab.settings" xml:space="preserve">
|
||||||
|
<value>Settings</value>
|
||||||
|
</data>
|
||||||
|
<data name="tab.comingsoon" xml:space="preserve">
|
||||||
|
<value>Coming soon</value>
|
||||||
|
</data>
|
||||||
|
<data name="btn.cancel" xml:space="preserve">
|
||||||
|
<value>Cancel</value>
|
||||||
|
</data>
|
||||||
|
<data name="settings.language" xml:space="preserve">
|
||||||
|
<value>Language</value>
|
||||||
|
</data>
|
||||||
|
<data name="settings.lang.en" xml:space="preserve">
|
||||||
|
<value>English</value>
|
||||||
|
</data>
|
||||||
|
<data name="settings.lang.fr" xml:space="preserve">
|
||||||
|
<value>French</value>
|
||||||
|
</data>
|
||||||
|
<data name="settings.theme" xml:space="preserve">
|
||||||
|
<value>Theme</value>
|
||||||
|
</data>
|
||||||
|
<data name="settings.theme.system" xml:space="preserve">
|
||||||
|
<value>Use system setting</value>
|
||||||
|
</data>
|
||||||
|
<data name="settings.theme.light" xml:space="preserve">
|
||||||
|
<value>Light</value>
|
||||||
|
</data>
|
||||||
|
<data name="settings.theme.dark" xml:space="preserve">
|
||||||
|
<value>Dark</value>
|
||||||
|
</data>
|
||||||
|
<data name="settings.folder" xml:space="preserve">
|
||||||
|
<value>Data output folder</value>
|
||||||
|
</data>
|
||||||
|
<data name="settings.browse" xml:space="preserve">
|
||||||
|
<value>Browse...</value>
|
||||||
|
</data>
|
||||||
|
<data name="profile.name" xml:space="preserve">
|
||||||
|
<value>Profile name</value>
|
||||||
|
</data>
|
||||||
|
<data name="profile.url" xml:space="preserve">
|
||||||
|
<value>Tenant URL</value>
|
||||||
|
</data>
|
||||||
|
<data name="profile.clientid" xml:space="preserve">
|
||||||
|
<value>Client ID</value>
|
||||||
|
</data>
|
||||||
|
<data name="profile.clientid.hint" xml:space="preserve">
|
||||||
|
<value>Optional — leave blank to register the app automatically</value>
|
||||||
|
</data>
|
||||||
|
<data name="profile.add" xml:space="preserve">
|
||||||
|
<value>Add</value>
|
||||||
|
</data>
|
||||||
|
<data name="profile.save" xml:space="preserve">
|
||||||
|
<value>Save</value>
|
||||||
|
</data>
|
||||||
|
<data name="profile.delete" xml:space="preserve">
|
||||||
|
<value>Delete</value>
|
||||||
|
</data>
|
||||||
|
<data name="profile.add.tooltip" xml:space="preserve">
|
||||||
|
<value>Create a new profile from the values entered above.</value>
|
||||||
|
</data>
|
||||||
|
<data name="profile.save.tooltip" xml:space="preserve">
|
||||||
|
<value>Save changes to the selected profile.</value>
|
||||||
|
</data>
|
||||||
|
<data name="profile.delete.tooltip" xml:space="preserve">
|
||||||
|
<value>Delete the selected profile.</value>
|
||||||
|
</data>
|
||||||
|
<data name="profile.register.warning" xml:space="preserve">
|
||||||
|
<value>Registering an app may prompt you to sign in up to {0} times. Continue?</value>
|
||||||
|
</data>
|
||||||
|
<data name="status.ready" xml:space="preserve">
|
||||||
|
<value>Ready</value>
|
||||||
|
</data>
|
||||||
|
<data name="status.complete" xml:space="preserve">
|
||||||
|
<value>Complete</value>
|
||||||
|
</data>
|
||||||
|
<data name="status.cancelled" xml:space="preserve">
|
||||||
|
<value>Operation cancelled</value>
|
||||||
|
</data>
|
||||||
|
<data name="err.auth.failed" xml:space="preserve">
|
||||||
|
<value>Authentication failed. Check tenant URL and Client ID.</value>
|
||||||
|
</data>
|
||||||
|
<data name="err.generic" xml:space="preserve">
|
||||||
|
<value>An error occurred. See log for details.</value>
|
||||||
|
</data>
|
||||||
|
<data name="grp.scan.opts" xml:space="preserve"><value>Scan Options</value></data>
|
||||||
|
<data name="chk.scan.folders" xml:space="preserve"><value>Scan Folders</value></data>
|
||||||
|
<data name="chk.recursive" xml:space="preserve"><value>Recursive (subsites)</value></data>
|
||||||
|
<data name="lbl.folder.depth" xml:space="preserve"><value>Folder depth:</value></data>
|
||||||
|
<data name="chk.max.depth" xml:space="preserve"><value>Maximum (all levels)</value></data>
|
||||||
|
<data name="chk.inherited.perms" xml:space="preserve"><value>Include Inherited Permissions</value></data>
|
||||||
|
<data name="chk.simplified.mode" xml:space="preserve"><value>Simplified mode</value></data>
|
||||||
|
<data name="grp.display.opts" xml:space="preserve"><value>Display Options</value></data>
|
||||||
|
<data name="grp.export.fmt" xml:space="preserve"><value>Export Format</value></data>
|
||||||
|
<data name="rad.csv.perms" xml:space="preserve"><value>CSV</value></data>
|
||||||
|
<data name="rad.detail.detailed" xml:space="preserve"><value>Detailed (all rows)</value></data>
|
||||||
|
<data name="rad.detail.simple" xml:space="preserve"><value>Simple (summary only)</value></data>
|
||||||
|
<data name="rad.html.perms" xml:space="preserve"><value>HTML</value></data>
|
||||||
|
<data name="btn.gen.perms" xml:space="preserve"><value>Generate Report</value></data>
|
||||||
|
<data name="btn.open.perms" xml:space="preserve"><value>Open Report</value></data>
|
||||||
|
<data name="btn.view.sites" xml:space="preserve"><value>View Sites</value></data>
|
||||||
|
<data name="perm.site.url" xml:space="preserve"><value>Site URL:</value></data>
|
||||||
|
<data name="perm.or.select" xml:space="preserve"><value>or select multiple sites:</value></data>
|
||||||
|
<data name="perm.sites.selected" xml:space="preserve"><value>{0} site(s) selected</value></data>
|
||||||
|
<!-- Phase 3: Storage Tab -->
|
||||||
|
<data name="chk.per.lib" xml:space="preserve"><value>Per-Library Breakdown</value></data>
|
||||||
|
<data name="chk.subsites" xml:space="preserve"><value>Include Subsites</value></data>
|
||||||
|
<data name="stor.note" xml:space="preserve"><value>Note: deeper folder scans on large sites may take several minutes.</value></data>
|
||||||
|
<data name="btn.gen.storage" xml:space="preserve"><value>Generate Metrics</value></data>
|
||||||
|
<data name="btn.open.storage" xml:space="preserve"><value>Open Report</value></data>
|
||||||
|
<data name="stor.col.library" xml:space="preserve"><value>Library</value></data>
|
||||||
|
<data name="stor.col.site" xml:space="preserve"><value>Site</value></data>
|
||||||
|
<data name="stor.col.files" xml:space="preserve"><value>Files</value></data>
|
||||||
|
<data name="stor.col.size" xml:space="preserve"><value>Total Size</value></data>
|
||||||
|
<data name="stor.col.versions" xml:space="preserve"><value>Version Size</value></data>
|
||||||
|
<data name="stor.col.lastmod" xml:space="preserve"><value>Last Modified</value></data>
|
||||||
|
<data name="stor.col.share" xml:space="preserve"><value>Share of Total</value></data>
|
||||||
|
<data name="stor.rad.csv" xml:space="preserve"><value>CSV</value></data>
|
||||||
|
<data name="stor.rad.html" xml:space="preserve"><value>HTML</value></data>
|
||||||
|
<data name="stor.col.kind" xml:space="preserve"><value>Kind</value></data>
|
||||||
|
<data name="stor.kind.library" xml:space="preserve"><value>Library</value></data>
|
||||||
|
<data name="stor.kind.hidden" xml:space="preserve"><value>Hidden Library</value></data>
|
||||||
|
<data name="stor.kind.preservation" xml:space="preserve"><value>Preservation Hold</value></data>
|
||||||
|
<data name="stor.kind.attachments" xml:space="preserve"><value>List Attachments</value></data>
|
||||||
|
<data name="stor.kind.recyclebin" xml:space="preserve"><value>Recycle Bin</value></data>
|
||||||
|
<data name="stor.kind.subsite" xml:space="preserve"><value>Subsite</value></data>
|
||||||
|
<data name="grp.scan.sources" xml:space="preserve"><value>Scan Sources</value></data>
|
||||||
|
<data name="grp.report.filter" xml:space="preserve"><value>Show in Report</value></data>
|
||||||
|
<data name="chk.scan.hidden" xml:space="preserve"><value>Hidden Libraries</value></data>
|
||||||
|
<data name="chk.scan.preservation" xml:space="preserve"><value>Preservation Hold</value></data>
|
||||||
|
<data name="chk.scan.attachments" xml:space="preserve"><value>List Attachments</value></data>
|
||||||
|
<data name="chk.scan.recyclebin" xml:space="preserve"><value>Recycle Bin</value></data>
|
||||||
|
<data name="chk.show.libraries" xml:space="preserve"><value>Libraries</value></data>
|
||||||
|
<data name="chk.show.hidden" xml:space="preserve"><value>Hidden Libraries</value></data>
|
||||||
|
<data name="chk.show.preservation" xml:space="preserve"><value>Preservation Hold</value></data>
|
||||||
|
<data name="chk.show.attachments" xml:space="preserve"><value>List Attachments</value></data>
|
||||||
|
<data name="chk.show.recyclebin" xml:space="preserve"><value>Recycle Bin</value></data>
|
||||||
|
<data name="chk.show.subsites" xml:space="preserve"><value>Subsites</value></data>
|
||||||
|
<data name="chk.combine.recyclebin" xml:space="preserve"><value>Combine Recycle Bin Stages (show total)</value></data>
|
||||||
|
<data name="storage.lbl.spo_reported_colon" xml:space="preserve"><value>SPO reported total: </value></data>
|
||||||
|
<data name="storage.lbl.recyclebin_colon" xml:space="preserve"><value>Recycle Bin: </value></data>
|
||||||
|
<!-- Phase 3: File Search Tab -->
|
||||||
|
<data name="grp.search.filters" xml:space="preserve"><value>Search Filters</value></data>
|
||||||
|
<data name="lbl.detail.level" xml:space="preserve"><value>Detail level:</value></data>
|
||||||
|
<data name="lbl.extensions" xml:space="preserve"><value>Extension(s):</value></data>
|
||||||
|
<data name="ph.extensions" xml:space="preserve"><value>docx pdf xlsx</value></data>
|
||||||
|
<data name="lbl.regex" xml:space="preserve"><value>Name / Regex:</value></data>
|
||||||
|
<data name="ph.regex" xml:space="preserve"><value>Ex: report.* or \.bak$</value></data>
|
||||||
|
<data name="chk.created.after" xml:space="preserve"><value>Created after:</value></data>
|
||||||
|
<data name="chk.created.before" xml:space="preserve"><value>Created before:</value></data>
|
||||||
|
<data name="chk.modified.after" xml:space="preserve"><value>Modified after:</value></data>
|
||||||
|
<data name="chk.modified.before" xml:space="preserve"><value>Modified before:</value></data>
|
||||||
|
<data name="lbl.created.by" xml:space="preserve"><value>Created by:</value></data>
|
||||||
|
<data name="ph.created.by" xml:space="preserve"><value>First Last or email</value></data>
|
||||||
|
<data name="lbl.modified.by" xml:space="preserve"><value>Modified by:</value></data>
|
||||||
|
<data name="ph.modified.by" xml:space="preserve"><value>First Last or email</value></data>
|
||||||
|
<data name="lbl.library" xml:space="preserve"><value>Library:</value></data>
|
||||||
|
<data name="ph.library" xml:space="preserve"><value>Optional relative path e.g. Shared Documents</value></data>
|
||||||
|
<data name="lbl.max.results" xml:space="preserve"><value>Max results:</value></data>
|
||||||
|
<data name="lbl.site.url" xml:space="preserve"><value>Site URL:</value></data>
|
||||||
|
<data name="lbl.summary.users" xml:space="preserve"><value>user(s)</value></data>
|
||||||
|
<data name="ph.site.url" xml:space="preserve"><value>https://tenant.sharepoint.com/sites/MySite</value></data>
|
||||||
|
<data name="btn.run.search" xml:space="preserve"><value>Run Search</value></data>
|
||||||
|
<data name="btn.open.search" xml:space="preserve"><value>Open Results</value></data>
|
||||||
|
<data name="srch.col.name" xml:space="preserve"><value>File Name</value></data>
|
||||||
|
<data name="srch.col.ext" xml:space="preserve"><value>Extension</value></data>
|
||||||
|
<data name="srch.col.created" xml:space="preserve"><value>Created</value></data>
|
||||||
|
<data name="srch.col.modified" xml:space="preserve"><value>Modified</value></data>
|
||||||
|
<data name="srch.col.author" xml:space="preserve"><value>Created By</value></data>
|
||||||
|
<data name="srch.col.modby" xml:space="preserve"><value>Modified By</value></data>
|
||||||
|
<data name="srch.col.size" xml:space="preserve"><value>Size</value></data>
|
||||||
|
<data name="srch.col.path" xml:space="preserve"><value>Path</value></data>
|
||||||
|
<data name="srch.rad.csv" xml:space="preserve"><value>CSV</value></data>
|
||||||
|
<data name="srch.rad.html" xml:space="preserve"><value>HTML</value></data>
|
||||||
|
<!-- Phase 3: Duplicates Tab -->
|
||||||
|
<data name="grp.dup.type" xml:space="preserve"><value>Duplicate Type</value></data>
|
||||||
|
<data name="rad.dup.files" xml:space="preserve"><value>Duplicate files</value></data>
|
||||||
|
<data name="rad.dup.folders" xml:space="preserve"><value>Duplicate folders</value></data>
|
||||||
|
<data name="grp.dup.criteria" xml:space="preserve"><value>Comparison Criteria</value></data>
|
||||||
|
<data name="lbl.dup.note" xml:space="preserve"><value>Name is always the primary criterion. Check additional criteria:</value></data>
|
||||||
|
<data name="chk.dup.size" xml:space="preserve"><value>Same size</value></data>
|
||||||
|
<data name="chk.dup.created" xml:space="preserve"><value>Same creation date</value></data>
|
||||||
|
<data name="chk.dup.modified" xml:space="preserve"><value>Same modification date</value></data>
|
||||||
|
<data name="chk.dup.subfolders" xml:space="preserve"><value>Same subfolder count</value></data>
|
||||||
|
<data name="chk.dup.filecount" xml:space="preserve"><value>Same file count</value></data>
|
||||||
|
<data name="chk.include.subsites" xml:space="preserve"><value>Include subsites</value></data>
|
||||||
|
<data name="ph.dup.lib" xml:space="preserve"><value>All (leave empty)</value></data>
|
||||||
|
<data name="btn.run.scan" xml:space="preserve"><value>Run Scan</value></data>
|
||||||
|
<data name="btn.open.results" xml:space="preserve"><value>Open Results</value></data>
|
||||||
|
<!-- Phase 4: Tab headers -->
|
||||||
|
<data name="tab.transfer" xml:space="preserve"><value>Transfer</value></data>
|
||||||
|
<data name="tab.bulkMembers" xml:space="preserve"><value>Bulk Members</value></data>
|
||||||
|
<data name="tab.bulkSites" xml:space="preserve"><value>Bulk Sites</value></data>
|
||||||
|
<data name="tab.folderStructure" xml:space="preserve"><value>Folder Structure</value></data>
|
||||||
|
<!-- Phase 4: Transfer tab -->
|
||||||
|
<data name="transfer.sourcesite" xml:space="preserve"><value>Source Site</value></data>
|
||||||
|
<data name="transfer.destsite" xml:space="preserve"><value>Destination Site</value></data>
|
||||||
|
<data name="transfer.sourcelibrary" xml:space="preserve"><value>Source Library</value></data>
|
||||||
|
<data name="transfer.destlibrary" xml:space="preserve"><value>Destination Library</value></data>
|
||||||
|
<data name="transfer.sourcefolder" xml:space="preserve"><value>Source Folder</value></data>
|
||||||
|
<data name="transfer.destfolder" xml:space="preserve"><value>Destination Folder</value></data>
|
||||||
|
<data name="transfer.mode" xml:space="preserve"><value>Transfer Mode</value></data>
|
||||||
|
<data name="transfer.mode.copy" xml:space="preserve"><value>Copy</value></data>
|
||||||
|
<data name="transfer.mode.move" xml:space="preserve"><value>Move</value></data>
|
||||||
|
<data name="transfer.conflict" xml:space="preserve"><value>Conflict Policy</value></data>
|
||||||
|
<data name="transfer.conflict.skip" xml:space="preserve"><value>Skip</value></data>
|
||||||
|
<data name="transfer.conflict.overwrite" xml:space="preserve"><value>Overwrite</value></data>
|
||||||
|
<data name="transfer.conflict.rename" xml:space="preserve"><value>Rename (append suffix)</value></data>
|
||||||
|
<data name="transfer.browse" xml:space="preserve"><value>Browse...</value></data>
|
||||||
|
<data name="transfer.start" xml:space="preserve"><value>Start Transfer</value></data>
|
||||||
|
<data name="transfer.nofiles" xml:space="preserve"><value>No files found to transfer.</value></data>
|
||||||
|
<!-- Phase 4: Bulk Members tab -->
|
||||||
|
<data name="bulkmembers.import" xml:space="preserve"><value>Import CSV</value></data>
|
||||||
|
<data name="bulkmembers.example" xml:space="preserve"><value>Load Example</value></data>
|
||||||
|
<data name="bulkmembers.execute" xml:space="preserve"><value>Add Members</value></data>
|
||||||
|
<data name="bulkmembers.preview" xml:space="preserve"><value>Preview ({0} rows, {1} valid, {2} invalid)</value></data>
|
||||||
|
<data name="bulkmembers.groupname" xml:space="preserve"><value>Group Name</value></data>
|
||||||
|
<data name="bulkmembers.groupurl" xml:space="preserve"><value>Group URL</value></data>
|
||||||
|
<data name="bulkmembers.email" xml:space="preserve"><value>Email</value></data>
|
||||||
|
<data name="bulkmembers.role" xml:space="preserve"><value>Role</value></data>
|
||||||
|
<!-- Phase 4: Bulk Sites tab -->
|
||||||
|
<data name="bulksites.import" xml:space="preserve"><value>Import CSV</value></data>
|
||||||
|
<data name="bulksites.example" xml:space="preserve"><value>Load Example</value></data>
|
||||||
|
<data name="bulksites.execute" xml:space="preserve"><value>Create Sites</value></data>
|
||||||
|
<data name="bulksites.preview" xml:space="preserve"><value>Preview ({0} rows, {1} valid, {2} invalid)</value></data>
|
||||||
|
<data name="bulksites.name" xml:space="preserve"><value>Name</value></data>
|
||||||
|
<data name="bulksites.alias" xml:space="preserve"><value>Alias</value></data>
|
||||||
|
<data name="bulksites.type" xml:space="preserve"><value>Type</value></data>
|
||||||
|
<data name="bulksites.owners" xml:space="preserve"><value>Owners</value></data>
|
||||||
|
<data name="bulksites.members" xml:space="preserve"><value>Members</value></data>
|
||||||
|
<!-- Phase 4: Folder Structure tab -->
|
||||||
|
<data name="folderstruct.import" xml:space="preserve"><value>Import CSV</value></data>
|
||||||
|
<data name="folderstruct.example" xml:space="preserve"><value>Load Example</value></data>
|
||||||
|
<data name="folderstruct.execute" xml:space="preserve"><value>Create Folders</value></data>
|
||||||
|
<data name="folderstruct.preview" xml:space="preserve"><value>Preview ({0} folders to create)</value></data>
|
||||||
|
<data name="folderstruct.library" xml:space="preserve"><value>Target Library</value></data>
|
||||||
|
<data name="folderstruct.siteurl" xml:space="preserve"><value>Site URL</value></data>
|
||||||
|
<!-- Phase 4: Templates tab -->
|
||||||
|
<data name="templates.list" xml:space="preserve"><value>Saved Templates</value></data>
|
||||||
|
<data name="templates.capture" xml:space="preserve"><value>Capture Template</value></data>
|
||||||
|
<data name="templates.apply" xml:space="preserve"><value>Apply Template</value></data>
|
||||||
|
<data name="templates.rename" xml:space="preserve"><value>Rename</value></data>
|
||||||
|
<data name="templates.delete" xml:space="preserve"><value>Delete</value></data>
|
||||||
|
<data name="templates.siteurl" xml:space="preserve"><value>Source Site URL</value></data>
|
||||||
|
<data name="templates.name" xml:space="preserve"><value>Template Name</value></data>
|
||||||
|
<data name="templates.newtitle" xml:space="preserve"><value>New Site Title</value></data>
|
||||||
|
<data name="templates.newalias" xml:space="preserve"><value>New Site Alias</value></data>
|
||||||
|
<data name="templates.options" xml:space="preserve"><value>Capture Options</value></data>
|
||||||
|
<data name="templates.opt.libraries" xml:space="preserve"><value>Libraries</value></data>
|
||||||
|
<data name="templates.opt.folders" xml:space="preserve"><value>Folders</value></data>
|
||||||
|
<data name="templates.opt.permissions" xml:space="preserve"><value>Permission Groups</value></data>
|
||||||
|
<data name="templates.opt.logo" xml:space="preserve"><value>Site Logo</value></data>
|
||||||
|
<data name="templates.opt.settings" xml:space="preserve"><value>Site Settings</value></data>
|
||||||
|
<data name="templates.empty" xml:space="preserve"><value>No templates saved yet.</value></data>
|
||||||
|
<!-- Phase 4: Shared bulk operation strings -->
|
||||||
|
<data name="bulk.confirm.title" xml:space="preserve"><value>Confirm Operation</value></data>
|
||||||
|
<data name="bulk.confirm.proceed" xml:space="preserve"><value>Proceed</value></data>
|
||||||
|
<data name="bulk.confirm.cancel" xml:space="preserve"><value>Cancel</value></data>
|
||||||
|
<data name="bulk.confirm.message" xml:space="preserve"><value>{0} — Proceed?</value></data>
|
||||||
|
<data name="bulk.result.success" xml:space="preserve"><value>Completed: {0} succeeded, {1} failed</value></data>
|
||||||
|
<data name="bulk.result.allfailed" xml:space="preserve"><value>All {0} items failed.</value></data>
|
||||||
|
<data name="bulk.result.allsuccess" xml:space="preserve"><value>All {0} items completed successfully.</value></data>
|
||||||
|
<data name="bulk.exportfailed" xml:space="preserve"><value>Export Failed Items</value></data>
|
||||||
|
<data name="bulk.retryfailed" xml:space="preserve"><value>Retry Failed</value></data>
|
||||||
|
<data name="bulk.validation.invalid" xml:space="preserve"><value>{0} rows have validation errors. Fix and re-import.</value></data>
|
||||||
|
<data name="bulk.csvimport.title" xml:space="preserve"><value>Select CSV File</value></data>
|
||||||
|
<data name="bulk.csvimport.filter" xml:space="preserve"><value>CSV Files (*.csv)|*.csv</value></data>
|
||||||
|
<!-- Phase 4: Folder browser dialog -->
|
||||||
|
<data name="folderbrowser.title" xml:space="preserve"><value>Select Folder</value></data>
|
||||||
|
<data name="folderbrowser.loading" xml:space="preserve"><value>Loading folder tree...</value></data>
|
||||||
|
<data name="folderbrowser.select" xml:space="preserve"><value>Select</value></data>
|
||||||
|
<data name="folderbrowser.cancel" xml:space="preserve"><value>Cancel</value></data>
|
||||||
|
<!-- Phase 6: Global Site Selection toolbar -->
|
||||||
|
<data name="toolbar.selectSites" xml:space="preserve">
|
||||||
|
<value>Select Sites</value>
|
||||||
|
</data>
|
||||||
|
<data name="toolbar.selectSites.tooltip" xml:space="preserve">
|
||||||
|
<value>Select target sites for all tabs</value>
|
||||||
|
</data>
|
||||||
|
<data name="toolbar.selectSites.tooltipDisabled" xml:space="preserve">
|
||||||
|
<value>Connect to a tenant first</value>
|
||||||
|
</data>
|
||||||
|
<data name="toolbar.globalSites.count" xml:space="preserve">
|
||||||
|
<value>{0} site(s) selected</value>
|
||||||
|
</data>
|
||||||
|
<data name="toolbar.globalSites.none" xml:space="preserve">
|
||||||
|
<value>No sites selected</value>
|
||||||
|
</data>
|
||||||
|
<!-- Phase 7: User Access Audit -->
|
||||||
|
<data name="tab.userAccessAudit" xml:space="preserve">
|
||||||
|
<value>User Access Audit</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.grp.users" xml:space="preserve">
|
||||||
|
<value>Select Users</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.grp.sites" xml:space="preserve">
|
||||||
|
<value>Target Sites</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.grp.options" xml:space="preserve">
|
||||||
|
<value>Scan Options</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.search.placeholder" xml:space="preserve">
|
||||||
|
<value>Search users by name or email...</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.users.selected" xml:space="preserve">
|
||||||
|
<value>{0} user(s) selected</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.btn.run" xml:space="preserve">
|
||||||
|
<value>Run Audit</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.btn.exportCsv" xml:space="preserve">
|
||||||
|
<value>Export CSV</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.btn.exportHtml" xml:space="preserve">
|
||||||
|
<value>Export HTML</value>
|
||||||
|
</data>
|
||||||
|
<data name="export.split.label" xml:space="preserve">
|
||||||
|
<value>Split</value>
|
||||||
|
</data>
|
||||||
|
<data name="export.split.single" xml:space="preserve">
|
||||||
|
<value>Single file</value>
|
||||||
|
</data>
|
||||||
|
<data name="export.split.bySite" xml:space="preserve">
|
||||||
|
<value>By site</value>
|
||||||
|
</data>
|
||||||
|
<data name="export.split.byUser" xml:space="preserve">
|
||||||
|
<value>By user</value>
|
||||||
|
</data>
|
||||||
|
<data name="export.html.layout.label" xml:space="preserve">
|
||||||
|
<value>HTML layout</value>
|
||||||
|
</data>
|
||||||
|
<data name="export.html.layout.separate" xml:space="preserve">
|
||||||
|
<value>Separate files</value>
|
||||||
|
</data>
|
||||||
|
<data name="export.html.layout.tabbed" xml:space="preserve">
|
||||||
|
<value>Single tabbed file</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.summary.total" xml:space="preserve">
|
||||||
|
<value>Total Accesses</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.summary.sites" xml:space="preserve">
|
||||||
|
<value>Sites</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.summary.highPriv" xml:space="preserve">
|
||||||
|
<value>High Privilege</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.toggle.byUser" xml:space="preserve">
|
||||||
|
<value>By User</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.toggle.bySite" xml:space="preserve">
|
||||||
|
<value>By Site</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.filter.placeholder" xml:space="preserve">
|
||||||
|
<value>Filter results...</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.noUsers" xml:space="preserve">
|
||||||
|
<value>Select at least one user to audit.</value>
|
||||||
|
</data>
|
||||||
|
<data name="audit.noSites" xml:space="preserve">
|
||||||
|
<value>Select at least one site to scan.</value>
|
||||||
|
</data>
|
||||||
|
<!-- Phase 9: Storage Visualization Charts -->
|
||||||
|
<data name="stor.chart.title" xml:space="preserve"><value>Storage by File Type</value></data>
|
||||||
|
<data name="stor.chart.donut" xml:space="preserve"><value>Donut Chart</value></data>
|
||||||
|
<data name="stor.chart.bar" xml:space="preserve"><value>Bar Chart</value></data>
|
||||||
|
<data name="stor.chart.toggle" xml:space="preserve"><value>Chart View:</value></data>
|
||||||
|
<data name="stor.chart.nodata" xml:space="preserve"><value>Run a storage scan to see file type breakdown.</value></data>
|
||||||
|
<!-- Phase 12: Logo UI -->
|
||||||
|
<data name="settings.logo.title" xml:space="preserve"><value>MSP Logo</value></data>
|
||||||
|
<data name="settings.logo.browse" xml:space="preserve"><value>Import</value></data>
|
||||||
|
<data name="settings.logo.clear" xml:space="preserve"><value>Clear</value></data>
|
||||||
|
<data name="settings.logo.nopreview" xml:space="preserve"><value>No logo configured</value></data>
|
||||||
|
<data name="profile.logo.title" xml:space="preserve"><value>Client Logo</value></data>
|
||||||
|
<data name="profile.logo.browse" xml:space="preserve"><value>Import</value></data>
|
||||||
|
<data name="profile.logo.clear" xml:space="preserve"><value>Clear</value></data>
|
||||||
|
<data name="profile.logo.autopull" xml:space="preserve"><value>Pull from Entra</value></data>
|
||||||
|
<data name="profile.logo.nopreview" xml:space="preserve"><value>No logo configured</value></data>
|
||||||
|
<!-- Phase 14: Directory Browse UI -->
|
||||||
|
<data name="audit.mode.search" xml:space="preserve"><value>Search</value></data>
|
||||||
|
<data name="audit.mode.browse" xml:space="preserve"><value>Browse Directory</value></data>
|
||||||
|
<data name="directory.grp.browse" xml:space="preserve"><value>User Directory</value></data>
|
||||||
|
<data name="directory.btn.load" xml:space="preserve"><value>Load Directory</value></data>
|
||||||
|
<data name="directory.btn.cancel" xml:space="preserve"><value>Cancel</value></data>
|
||||||
|
<data name="directory.filter.placeholder" xml:space="preserve"><value>Filter users...</value></data>
|
||||||
|
<data name="directory.chk.guests" xml:space="preserve"><value>Include guests</value></data>
|
||||||
|
<data name="directory.status.count" xml:space="preserve"><value>users</value></data>
|
||||||
|
<data name="directory.hint.doubleclick" xml:space="preserve"><value>Double-click a user to add to audit</value></data>
|
||||||
|
<data name="directory.col.name" xml:space="preserve"><value>Name</value></data>
|
||||||
|
<data name="directory.col.upn" xml:space="preserve"><value>Email</value></data>
|
||||||
|
<data name="directory.col.department" xml:space="preserve"><value>Department</value></data>
|
||||||
|
<data name="directory.col.jobtitle" xml:space="preserve"><value>Job Title</value></data>
|
||||||
|
<data name="directory.col.type" xml:space="preserve"><value>Type</value></data>
|
||||||
|
<!-- Phase 16: Report Consolidation Toggle -->
|
||||||
|
<data name="audit.grp.export" xml:space="preserve"><value>Export Options</value></data>
|
||||||
|
<data name="chk.merge.permissions" xml:space="preserve"><value>Merge duplicate permissions</value></data>
|
||||||
|
<data name="chk.hide.system.group.raw" xml:space="preserve"><value>Hide raw system group names (SharingLinks, Limited Access)</value></data>
|
||||||
|
<data name="chk.exclude.sharing.links" xml:space="preserve"><value>Exclude sharing links</value></data>
|
||||||
|
<data name="chk.exclude.system.groups" xml:space="preserve"><value>Exclude system groups (Limited Access)</value></data>
|
||||||
|
<!-- Phase 19: App Registration & Removal -->
|
||||||
|
<data name="profile.register" xml:space="preserve"><value>Register App</value></data>
|
||||||
|
<data name="profile.remove" xml:space="preserve"><value>Remove App</value></data>
|
||||||
|
<data name="profile.register.checking" xml:space="preserve"><value>Checking permissions...</value></data>
|
||||||
|
<data name="profile.register.registering" xml:space="preserve"><value>Registering application...</value></data>
|
||||||
|
<data name="profile.register.success" xml:space="preserve"><value>Application registered successfully</value></data>
|
||||||
|
<data name="profile.register.failed" xml:space="preserve"><value>Registration failed</value></data>
|
||||||
|
<data name="profile.register.noperm" xml:space="preserve"><value>Insufficient permissions for automatic registration</value></data>
|
||||||
|
<data name="profile.remove.removing" xml:space="preserve"><value>Removing application...</value></data>
|
||||||
|
<data name="profile.remove.success" xml:space="preserve"><value>Application removed successfully</value></data>
|
||||||
|
<data name="profile.fallback.title" xml:space="preserve"><value>Manual Registration Required</value></data>
|
||||||
|
<data name="profile.fallback.step1" xml:space="preserve"><value>1. Go to Azure Portal > App registrations > New registration</value></data>
|
||||||
|
<data name="profile.fallback.step2" xml:space="preserve"><value>2. Name: 'SharePoint Toolbox - {0}', Supported account types: Single tenant</value></data>
|
||||||
|
<data name="profile.fallback.step3" xml:space="preserve"><value>3. Redirect URI: Public client, https://login.microsoftonline.com/common/oauth2/nativeclient</value></data>
|
||||||
|
<data name="profile.fallback.step4" xml:space="preserve"><value>4. Under API permissions, add: Microsoft Graph (User.Read, User.Read.All, Group.Read.All, Directory.Read.All) and SharePoint (AllSites.FullControl)</value></data>
|
||||||
|
<data name="profile.fallback.step5" xml:space="preserve"><value>5. Grant admin consent for all permissions</value></data>
|
||||||
|
<data name="profile.fallback.step6" xml:space="preserve"><value>6. Copy the Application (client) ID and paste it in the Client ID field above</value></data>
|
||||||
|
<!-- Phase 18: Auto-Take Ownership -->
|
||||||
|
<data name="settings.ownership.title" xml:space="preserve"><value>Site Ownership</value></data>
|
||||||
|
<data name="settings.ownership.auto" xml:space="preserve"><value>Automatically take site collection admin ownership on access denied</value></data>
|
||||||
|
<data name="settings.ownership.description" xml:space="preserve"><value>When enabled, the app will automatically elevate to site collection admin when a scan encounters an access denied error. Requires Tenant Admin permissions.</value></data>
|
||||||
|
<data name="permissions.elevated.tooltip" xml:space="preserve"><value>This site was automatically elevated — ownership was taken to complete the scan</value></data>
|
||||||
|
<!-- Report export localization -->
|
||||||
|
<data name="report.title.user_access" xml:space="preserve"><value>User Access Audit Report</value></data>
|
||||||
|
<data name="report.title.user_access_consolidated" xml:space="preserve"><value>User Access Audit Report (Consolidated)</value></data>
|
||||||
|
<data name="report.title.permissions" xml:space="preserve"><value>SharePoint Permissions Report</value></data>
|
||||||
|
<data name="report.title.permissions_simplified" xml:space="preserve"><value>SharePoint Permissions Report (Simplified)</value></data>
|
||||||
|
<data name="report.title.storage" xml:space="preserve"><value>SharePoint Storage Metrics</value></data>
|
||||||
|
<data name="report.title.duplicates" xml:space="preserve"><value>SharePoint Duplicate Detection Report</value></data>
|
||||||
|
<data name="report.title.duplicates_short" xml:space="preserve"><value>Duplicate Detection Report</value></data>
|
||||||
|
<data name="report.title.search" xml:space="preserve"><value>SharePoint File Search Results</value></data>
|
||||||
|
<data name="report.title.search_short" xml:space="preserve"><value>File Search Results</value></data>
|
||||||
|
<data name="report.title.versions" xml:space="preserve"><value>SharePoint Version Cleanup Report</value></data>
|
||||||
|
<data name="report.title.versions_short" xml:space="preserve"><value>Version Cleanup Report</value></data>
|
||||||
|
<data name="report.stat.total_accesses" xml:space="preserve"><value>Total Accesses</value></data>
|
||||||
|
<data name="report.stat.users_audited" xml:space="preserve"><value>Users Audited</value></data>
|
||||||
|
<data name="report.stat.sites_scanned" xml:space="preserve"><value>Sites Scanned</value></data>
|
||||||
|
<data name="report.stat.high_privilege" xml:space="preserve"><value>High Privilege</value></data>
|
||||||
|
<data name="report.stat.external_users" xml:space="preserve"><value>External Users</value></data>
|
||||||
|
<data name="report.stat.total_entries" xml:space="preserve"><value>Total Entries</value></data>
|
||||||
|
<data name="report.stat.unique_permission_sets" xml:space="preserve"><value>Unique Permission Sets</value></data>
|
||||||
|
<data name="report.stat.distinct_users_groups" xml:space="preserve"><value>Distinct Users/Groups</value></data>
|
||||||
|
<data name="report.stat.libraries" xml:space="preserve"><value>Libraries</value></data>
|
||||||
|
<data name="report.stat.files" xml:space="preserve"><value>Files</value></data>
|
||||||
|
<data name="report.stat.total_size" xml:space="preserve"><value>Total Size</value></data>
|
||||||
|
<data name="report.stat.version_size" xml:space="preserve"><value>Version Size</value></data>
|
||||||
|
<data name="report.badge.guest" xml:space="preserve"><value>Guest</value></data>
|
||||||
|
<data name="report.badge.direct" xml:space="preserve"><value>Direct</value></data>
|
||||||
|
<data name="report.badge.group" xml:space="preserve"><value>Group</value></data>
|
||||||
|
<data name="report.badge.inherited" xml:space="preserve"><value>Inherited</value></data>
|
||||||
|
<data name="report.badge.unique" xml:space="preserve"><value>Unique</value></data>
|
||||||
|
<data name="report.view.by_user" xml:space="preserve"><value>By User</value></data>
|
||||||
|
<data name="report.view.by_site" xml:space="preserve"><value>By Site</value></data>
|
||||||
|
<data name="report.filter.placeholder_results" xml:space="preserve"><value>Filter results...</value></data>
|
||||||
|
<data name="report.filter.placeholder_permissions" xml:space="preserve"><value>Filter permissions...</value></data>
|
||||||
|
<data name="report.filter.placeholder_rows" xml:space="preserve"><value>Filter rows…</value></data>
|
||||||
|
<data name="report.filter.label" xml:space="preserve"><value>Filter:</value></data>
|
||||||
|
<data name="report.col.site" xml:space="preserve"><value>Site</value></data>
|
||||||
|
<data name="report.col.sites" xml:space="preserve"><value>Sites</value></data>
|
||||||
|
<data name="report.col.object_type" xml:space="preserve"><value>Object Type</value></data>
|
||||||
|
<data name="report.col.object" xml:space="preserve"><value>Object</value></data>
|
||||||
|
<data name="report.col.permission_level" xml:space="preserve"><value>Permission Level</value></data>
|
||||||
|
<data name="report.col.access_type" xml:space="preserve"><value>Access Type</value></data>
|
||||||
|
<data name="report.col.granted_through" xml:space="preserve"><value>Granted Through</value></data>
|
||||||
|
<data name="report.col.user" xml:space="preserve"><value>User</value></data>
|
||||||
|
<data name="report.col.title" xml:space="preserve"><value>Title</value></data>
|
||||||
|
<data name="report.col.url" xml:space="preserve"><value>URL</value></data>
|
||||||
|
<data name="report.col.users_groups" xml:space="preserve"><value>Users/Groups</value></data>
|
||||||
|
<data name="report.col.simplified" xml:space="preserve"><value>Simplified</value></data>
|
||||||
|
<data name="report.col.risk" xml:space="preserve"><value>Risk</value></data>
|
||||||
|
<data name="report.col.library_folder" xml:space="preserve"><value>Library / Folder</value></data>
|
||||||
|
<data name="report.col.last_modified" xml:space="preserve"><value>Last Modified</value></data>
|
||||||
|
<data name="report.col.name" xml:space="preserve"><value>Name</value></data>
|
||||||
|
<data name="report.col.library" xml:space="preserve"><value>Library</value></data>
|
||||||
|
<data name="report.col.path" xml:space="preserve"><value>Path</value></data>
|
||||||
|
<data name="report.col.size" xml:space="preserve"><value>Size</value></data>
|
||||||
|
<data name="report.col.created" xml:space="preserve"><value>Created</value></data>
|
||||||
|
<data name="report.col.modified" xml:space="preserve"><value>Modified</value></data>
|
||||||
|
<data name="report.col.created_by" xml:space="preserve"><value>Created By</value></data>
|
||||||
|
<data name="report.col.modified_by" xml:space="preserve"><value>Modified By</value></data>
|
||||||
|
<data name="report.col.file_name" xml:space="preserve"><value>File Name</value></data>
|
||||||
|
<data name="report.col.extension" xml:space="preserve"><value>Extension</value></data>
|
||||||
|
<data name="report.col.file_type" xml:space="preserve"><value>File Type</value></data>
|
||||||
|
<data name="report.col.file_count" xml:space="preserve"><value>File Count</value></data>
|
||||||
|
<data name="report.col.error" xml:space="preserve"><value>Error</value></data>
|
||||||
|
<data name="report.col.timestamp" xml:space="preserve"><value>Timestamp</value></data>
|
||||||
|
<data name="report.col.number" xml:space="preserve"><value>#</value></data>
|
||||||
|
<data name="report.col.group" xml:space="preserve"><value>Group</value></data>
|
||||||
|
<data name="report.col.total_size_mb" xml:space="preserve"><value>Total Size (MB)</value></data>
|
||||||
|
<data name="report.col.version_size_mb" xml:space="preserve"><value>Version Size (MB)</value></data>
|
||||||
|
<data name="report.col.size_mb" xml:space="preserve"><value>Size (MB)</value></data>
|
||||||
|
<data name="report.col.size_bytes" xml:space="preserve"><value>Size (bytes)</value></data>
|
||||||
|
<data name="report.text.accesses" xml:space="preserve"><value>accesses</value></data>
|
||||||
|
<data name="report.text.access_es" xml:space="preserve"><value>access(es)</value></data>
|
||||||
|
<data name="report.text.sites_parens" xml:space="preserve"><value>site(s)</value></data>
|
||||||
|
<data name="report.text.permissions_parens" xml:space="preserve"><value>permission(s)</value></data>
|
||||||
|
<data name="report.text.copies" xml:space="preserve"><value>copies</value></data>
|
||||||
|
<data name="report.text.duplicate_groups_found" xml:space="preserve"><value>duplicate group(s) found.</value></data>
|
||||||
|
<data name="report.text.results_parens" xml:space="preserve"><value>result(s)</value></data>
|
||||||
|
<data name="report.text.of" xml:space="preserve"><value>of</value></data>
|
||||||
|
<data name="report.text.shown" xml:space="preserve"><value>shown</value></data>
|
||||||
|
<data name="report.text.generated" xml:space="preserve"><value>Generated</value></data>
|
||||||
|
<data name="report.text.generated_colon" xml:space="preserve"><value>Generated:</value></data>
|
||||||
|
<data name="report.text.members_unavailable" xml:space="preserve"><value>members unavailable</value></data>
|
||||||
|
<data name="report.text.empty_group" xml:space="preserve"><value>Empty group</value></data>
|
||||||
|
<data name="report.text.link" xml:space="preserve"><value>Link</value></data>
|
||||||
|
<data name="report.text.no_ext" xml:space="preserve"><value>(no ext)</value></data>
|
||||||
|
<data name="report.text.no_extension" xml:space="preserve"><value>(no extension)</value></data>
|
||||||
|
<data name="report.text.high_priv" xml:space="preserve"><value>high-priv</value></data>
|
||||||
|
<data name="report.section.storage_by_file_type" xml:space="preserve"><value>Storage by File Type</value></data>
|
||||||
|
<data name="report.section.library_details" xml:space="preserve"><value>Library Details</value></data>
|
||||||
|
<!-- Site picker dialog -->
|
||||||
|
<data name="sitepicker.title" xml:space="preserve"><value>Select Sites</value></data>
|
||||||
|
<data name="sitepicker.filter" xml:space="preserve"><value>Filter:</value></data>
|
||||||
|
<data name="sitepicker.type" xml:space="preserve"><value>Type:</value></data>
|
||||||
|
<data name="sitepicker.type.all" xml:space="preserve"><value>All</value></data>
|
||||||
|
<data name="sitepicker.type.team" xml:space="preserve"><value>Team sites (MS Teams)</value></data>
|
||||||
|
<data name="sitepicker.type.communication" xml:space="preserve"><value>Communication</value></data>
|
||||||
|
<data name="sitepicker.type.classic" xml:space="preserve"><value>Classic</value></data>
|
||||||
|
<data name="sitepicker.type.other" xml:space="preserve"><value>Other</value></data>
|
||||||
|
<data name="sitepicker.size" xml:space="preserve"><value>Size (MB):</value></data>
|
||||||
|
<data name="sitepicker.size.min" xml:space="preserve"><value>min</value></data>
|
||||||
|
<data name="sitepicker.size.max" xml:space="preserve"><value>max</value></data>
|
||||||
|
<data name="sitepicker.col.title" xml:space="preserve"><value>Title</value></data>
|
||||||
|
<data name="sitepicker.col.url" xml:space="preserve"><value>URL</value></data>
|
||||||
|
<data name="sitepicker.col.type" xml:space="preserve"><value>Type</value></data>
|
||||||
|
<data name="sitepicker.col.size" xml:space="preserve"><value>Size</value></data>
|
||||||
|
<data name="sitepicker.btn.load" xml:space="preserve"><value>Load Sites</value></data>
|
||||||
|
<data name="sitepicker.btn.selectAll" xml:space="preserve"><value>Select All</value></data>
|
||||||
|
<data name="sitepicker.btn.deselectAll" xml:space="preserve"><value>Deselect All</value></data>
|
||||||
|
<data name="sitepicker.btn.ok" xml:space="preserve"><value>OK</value></data>
|
||||||
|
<data name="sitepicker.btn.cancel" xml:space="preserve"><value>Cancel</value></data>
|
||||||
|
<data name="sitepicker.status.loading" xml:space="preserve"><value>Loading sites...</value></data>
|
||||||
|
<data name="sitepicker.status.loaded" xml:space="preserve"><value>{0} sites loaded.</value></data>
|
||||||
|
<data name="sitepicker.status.shown" xml:space="preserve"><value>{0} / {1} sites shown.</value></data>
|
||||||
|
<data name="sitepicker.status.error" xml:space="preserve"><value>Error: {0}</value></data>
|
||||||
|
<data name="sitepicker.kind.teamsite" xml:space="preserve"><value>Team site</value></data>
|
||||||
|
<data name="sitepicker.kind.communication" xml:space="preserve"><value>Communication</value></data>
|
||||||
|
<data name="sitepicker.kind.classic" xml:space="preserve"><value>Classic</value></data>
|
||||||
|
<data name="sitepicker.kind.other" xml:space="preserve"><value>Other</value></data>
|
||||||
|
<!-- Common UI -->
|
||||||
|
<data name="common.valid" xml:space="preserve"><value>Valid</value></data>
|
||||||
|
<data name="common.errors" xml:space="preserve"><value>Errors</value></data>
|
||||||
|
<data name="common.close" xml:space="preserve"><value>Close</value></data>
|
||||||
|
<data name="common.new_folder" xml:space="preserve"><value>+ New Folder</value></data>
|
||||||
|
<data name="common.guest" xml:space="preserve"><value>Guest</value></data>
|
||||||
|
<!-- InputDialog -->
|
||||||
|
<data name="input.title" xml:space="preserve"><value>Input</value></data>
|
||||||
|
<!-- ProfileManagementDialog -->
|
||||||
|
<data name="profmgmt.title" xml:space="preserve"><value>Manage Profiles</value></data>
|
||||||
|
<data name="profmgmt.group" xml:space="preserve"><value>Profiles</value></data>
|
||||||
|
<!-- Duplicates columns -->
|
||||||
|
<data name="duplicates.col.group" xml:space="preserve"><value>Group</value></data>
|
||||||
|
<data name="duplicates.col.copies" xml:space="preserve"><value>Copies</value></data>
|
||||||
|
<!-- Folder structure levels -->
|
||||||
|
<data name="folderstruct.col.level1" xml:space="preserve"><value>Level 1</value></data>
|
||||||
|
<data name="folderstruct.col.level2" xml:space="preserve"><value>Level 2</value></data>
|
||||||
|
<data name="folderstruct.col.level3" xml:space="preserve"><value>Level 3</value></data>
|
||||||
|
<data name="folderstruct.col.level4" xml:space="preserve"><value>Level 4</value></data>
|
||||||
|
<!-- Permissions extra columns -->
|
||||||
|
<data name="perm.col.unique_perms" xml:space="preserve"><value>Unique Perms</value></data>
|
||||||
|
<data name="perm.col.permission_levels" xml:space="preserve"><value>Permission Levels</value></data>
|
||||||
|
<data name="perm.col.principal_type" xml:space="preserve"><value>Principal Type</value></data>
|
||||||
|
<!-- Storage summary labels -->
|
||||||
|
<data name="storage.lbl.total_size_colon" xml:space="preserve"><value>Total Size: </value></data>
|
||||||
|
<data name="storage.lbl.version_size_colon" xml:space="preserve"><value>Version Size: </value></data>
|
||||||
|
<data name="storage.lbl.files_colon" xml:space="preserve"><value>Files: </value></data>
|
||||||
|
<!-- Templates columns -->
|
||||||
|
<data name="templates.col.source" xml:space="preserve"><value>Source</value></data>
|
||||||
|
<data name="templates.col.captured" xml:space="preserve"><value>Captured</value></data>
|
||||||
|
<!-- Transfer view -->
|
||||||
|
<data name="transfer.text.files_selected" xml:space="preserve"><value> file(s) selected</value></data>
|
||||||
|
<data name="transfer.chk.include_source" xml:space="preserve"><value>Include source folder at destination</value></data>
|
||||||
|
<data name="transfer.chk.include_source.tooltip" xml:space="preserve"><value>When on, recreate the source folder under the destination. When off, drop contents directly into the destination folder.</value></data>
|
||||||
|
<data name="transfer.chk.copy_contents" xml:space="preserve"><value>Copy folder contents</value></data>
|
||||||
|
<data name="transfer.chk.copy_contents.tooltip" xml:space="preserve"><value>When on (default), transfer files inside the folder. When off, only the folder is created at the destination.</value></data>
|
||||||
|
<!-- Shared ViewModel errors and statuses -->
|
||||||
|
<data name="err.no_tenant" xml:space="preserve"><value>No tenant connected.</value></data>
|
||||||
|
<data name="err.no_tenant_connected" xml:space="preserve"><value>No tenant selected. Please connect to a tenant first.</value></data>
|
||||||
|
<data name="err.no_profile_selected" xml:space="preserve"><value>No tenant profile selected. Please connect first.</value></data>
|
||||||
|
<data name="err.no_sites_selected" xml:space="preserve"><value>Select at least one site from the toolbar.</value></data>
|
||||||
|
<data name="err.no_users_selected" xml:space="preserve"><value>Add at least one user to audit.</value></data>
|
||||||
|
<data name="err.no_valid_rows" xml:space="preserve"><value>No valid rows to process. Import a CSV first.</value></data>
|
||||||
|
<data name="err.template_name_required" xml:space="preserve"><value>Template name is required.</value></data>
|
||||||
|
<data name="err.site_title_required" xml:space="preserve"><value>New site title is required.</value></data>
|
||||||
|
<data name="err.site_alias_required" xml:space="preserve"><value>New site alias is required.</value></data>
|
||||||
|
<data name="err.transfer_source_required" xml:space="preserve"><value>Source site and library must be selected.</value></data>
|
||||||
|
<data name="err.transfer_dest_required" xml:space="preserve"><value>Destination site and library must be selected.</value></data>
|
||||||
|
<data name="err.library_title_required" xml:space="preserve"><value>Library title is required.</value></data>
|
||||||
|
<!-- Templates status -->
|
||||||
|
<data name="templates.status.capturing" xml:space="preserve"><value>Capturing template...</value></data>
|
||||||
|
<data name="templates.status.success" xml:space="preserve"><value>Template captured successfully.</value></data>
|
||||||
|
<data name="templates.status.capture_failed" xml:space="preserve"><value>Capture failed: {0}</value></data>
|
||||||
|
<data name="templates.status.applying" xml:space="preserve"><value>Applying template...</value></data>
|
||||||
|
<data name="templates.status.applied" xml:space="preserve"><value>Template applied. Site created at: {0}</value></data>
|
||||||
|
<data name="templates.status.apply_failed" xml:space="preserve"><value>Apply failed: {0}</value></data>
|
||||||
|
<!-- UI text -->
|
||||||
|
<data name="audit.searching" xml:space="preserve"><value>Searching...</value></data>
|
||||||
|
<!-- Report text -->
|
||||||
|
<data name="report.text.users_parens" xml:space="preserve"><value>user(s)</value></data>
|
||||||
|
<data name="report.text.files_unit" xml:space="preserve"><value>files</value></data>
|
||||||
|
<data name="report.text.sites_unit" xml:space="preserve"><value>sites</value></data>
|
||||||
|
<data name="report.text.entries_unit" xml:space="preserve"><value>entries</value></data>
|
||||||
|
<!-- Help / Info button strings -->
|
||||||
|
<data name="help.perm.simplified.title" xml:space="preserve"><value>Simplified Permissions Mode</value></data>
|
||||||
|
<data name="help.perm.simplified.body" xml:space="preserve"><value>Groups raw SharePoint permissions into readable labels (Owner, Editor, Contributor, Reader, View-Only) and color-codes rows by risk level. Useful for a quick security overview without permission-level jargon.</value></data>
|
||||||
|
<data name="help.perm.merge.title" xml:space="preserve"><value>Merge Permissions</value></data>
|
||||||
|
<data name="help.perm.merge.body" xml:space="preserve"><value>When enabled, multiple permission entries for the same user or group are consolidated into a single row in the export, reducing report size. Disable to see every individual permission assignment separately.</value></data>
|
||||||
|
<data name="help.perm.hidesys.title" xml:space="preserve"><value>Hide System Groups</value></data>
|
||||||
|
<data name="help.perm.hidesys.body" xml:space="preserve"><value>Removes automatically-created SharePoint system groups from results (e.g. "Excel Services Viewers", "SharingLinks.*" groups). These groups are managed internally by SharePoint and are typically not relevant for user access audits.</value></data>
|
||||||
|
<data name="help.perm.excl.sharing.title" xml:space="preserve"><value>Exclude Sharing Links</value></data>
|
||||||
|
<data name="help.perm.excl.sharing.body" xml:space="preserve"><value>Removes sharing link entries from results and exports (e.g. "Anyone with the link", organisation-wide links). Useful when you only care about direct user and group permissions.</value></data>
|
||||||
|
<data name="help.perm.excl.system.title" xml:space="preserve"><value>Exclude System Groups (Limited Access)</value></data>
|
||||||
|
<data name="help.perm.excl.system.body" xml:space="preserve"><value>Removes "Limited Access System Group For Web/List" entries from results and exports. SharePoint creates these automatically when a user has item-level access; they are rarely relevant for user access audits.</value></data>
|
||||||
|
<data name="help.perm.inherited.title" xml:space="preserve"><value>Include Inherited Permissions</value></data>
|
||||||
|
<data name="help.perm.inherited.body" xml:space="preserve"><value>By default only objects with unique (broken) permissions are reported. Enable this to also include objects that inherit permissions from a parent, giving a complete picture of who can access every item.</value></data>
|
||||||
|
<data name="help.perm.splitmode.title" xml:space="preserve"><value>Export Split Mode</value></data>
|
||||||
|
<data name="help.perm.splitmode.body" xml:space="preserve"><value>Single File: all results are saved in one CSV or HTML file.
|
||||||
|
|
||||||
|
Split by Site: creates a separate file for each site collection. Useful when auditing large multi-site tenants to keep individual files manageable.</value></data>
|
||||||
|
<data name="help.search.title" xml:space="preserve"><value>KQL File Search</value></data>
|
||||||
|
<data name="help.search.body" xml:space="preserve"><value>Searches files across your SharePoint sites using KQL (Keyword Query Language). The keyword field is optional — leave it empty to return all files matching only the active filters. Combine date range, author, and library filters to narrow results.</value></data>
|
||||||
|
<data name="help.search.regex.title" xml:space="preserve"><value>Filename Regex Filter</value></data>
|
||||||
|
<data name="help.search.regex.body" xml:space="preserve"><value>Post-filters results client-side using a .NET regular expression matched against file names. Example: \.pdf$ matches only PDF files. Leave blank to skip this filter. The expression is case-insensitive.</value></data>
|
||||||
|
<data name="help.versions.policy.title" xml:space="preserve"><value>Version Cleanup Policy</value></data>
|
||||||
|
<data name="help.versions.policy.body" xml:space="preserve"><value>Permanently deletes old document versions from SharePoint libraries. Only the N most recent versions are kept — older ones are removed permanently and cannot be recovered. Run a preview scan first to see what will be deleted.</value></data>
|
||||||
|
<data name="help.versions.keepfirst.title" xml:space="preserve"><value>Keep First Version</value></data>
|
||||||
|
<data name="help.versions.keepfirst.body" xml:space="preserve"><value>Always preserves version 1.0 (the original) of each document, regardless of the "Keep Last N" setting. Useful to maintain an audit trail of a document's initial state.</value></data>
|
||||||
|
<data name="help.versions.confirm.title" xml:space="preserve"><value>Confirm Before Delete</value></data>
|
||||||
|
<data name="help.versions.confirm.body" xml:space="preserve"><value>When enabled, a confirmation dialog appears for each file before its versions are deleted. Uncheck for unattended batch processing.</value></data>
|
||||||
|
<data name="help.dup.criteria.title" xml:space="preserve"><value>Duplicate Matching Criteria</value></data>
|
||||||
|
<data name="help.dup.criteria.body" xml:space="preserve"><value>Two items are flagged as duplicates when their names match AND all checked additional criteria also match. More criteria checked = fewer groups, but more precise matches. Using name only finds files with the same filename anywhere in the site, regardless of content.</value></data>
|
||||||
|
<data name="help.transfer.incsource.title" xml:space="preserve"><value>Include Source Folder</value></data>
|
||||||
|
<data name="help.transfer.incsource.body" xml:space="preserve"><value>When enabled, the source folder itself is recreated at the destination (e.g. transferring "Reports" creates a "Reports/" folder at the target). When disabled, only the contents inside the folder are transferred — useful when merging into an existing destination folder.</value></data>
|
||||||
|
<data name="help.transfer.copycontent.title" xml:space="preserve"><value>Copy Folder Contents Only</value></data>
|
||||||
|
<data name="help.transfer.copycontent.body" xml:space="preserve"><value>When enabled, only the files and subfolders inside the selected folder are transferred — the selected folder itself is not recreated at the destination.</value></data>
|
||||||
|
<data name="help.transfer.conflict.title" xml:space="preserve"><value>File Conflict Policy</value></data>
|
||||||
|
<data name="help.transfer.conflict.body" xml:space="preserve"><value>Defines what happens when a file with the same name already exists at the destination:
|
||||||
|
|
||||||
|
• Skip — leave the existing destination file unchanged.
|
||||||
|
• Overwrite — replace the destination file with the source file.
|
||||||
|
• Rename — keep both by appending a number suffix to the transferred file's name.</value></data>
|
||||||
|
<data name="help.bulkmembers.title" xml:space="preserve"><value>Bulk Add Members — CSV Format</value></data>
|
||||||
|
<data name="help.bulkmembers.body" xml:space="preserve"><value>The CSV file must contain these columns (headers required, order is flexible):
|
||||||
|
• GroupName — the exact SharePoint group name
|
||||||
|
• Email — the user's email address
|
||||||
|
• Role — Member, Owner, or Visitor
|
||||||
|
|
||||||
|
Click "Load Example" to open a pre-filled sample file.</value></data>
|
||||||
|
<data name="help.bulksites.title" xml:space="preserve"><value>Bulk Create Sites — CSV Format</value></data>
|
||||||
|
<data name="help.bulksites.body" xml:space="preserve"><value>The CSV file must contain these columns:
|
||||||
|
• Name — the display name for the new site
|
||||||
|
• Alias — URL alias (no spaces; becomes part of the site URL)
|
||||||
|
• Type — TeamSite or CommunicationSite
|
||||||
|
• Owners — comma-separated list of owner email addresses
|
||||||
|
|
||||||
|
Click "Load Example" to open a pre-filled sample file.</value></data>
|
||||||
|
<data name="help.folderstruct.title" xml:space="preserve"><value>Create Folder Structure — CSV Format</value></data>
|
||||||
|
<data name="help.folderstruct.body" xml:space="preserve"><value>Creates a folder hierarchy inside a SharePoint library from a CSV file. Each row defines one folder path using up to 4 levels (Level1–Level4). Leave deeper level columns empty for shallower paths.
|
||||||
|
|
||||||
|
Example row: Contracts | 2024 | Q1 | (empty)
|
||||||
|
Creates: Library / Contracts / 2024 / Q1</value></data>
|
||||||
|
<data name="help.templates.capture.title" xml:space="preserve"><value>Capture Site Template</value></data>
|
||||||
|
<data name="help.templates.capture.body" xml:space="preserve"><value>Saves the currently selected site's structure (libraries, folder hierarchy, permissions, settings, and logo) as a reusable template stored locally on your machine. The source site is not modified in any way.
|
||||||
|
|
||||||
|
Select which elements to include using the checkboxes above.</value></data>
|
||||||
|
<data name="help.templates.apply.title" xml:space="preserve"><value>Apply Template to New Site</value></data>
|
||||||
|
<data name="help.templates.apply.body" xml:space="preserve"><value>Creates a brand-new SharePoint site and reproduces the structure captured in the selected template — including libraries, folders, permissions, settings, and logo. The source template and original site are not affected.
|
||||||
|
|
||||||
|
Provide a display name and URL alias for the new site before clicking Apply.</value></data>
|
||||||
|
<data name="help.audit.mode.title" xml:space="preserve"><value>Search vs Browse Mode</value></data>
|
||||||
|
<data name="help.audit.mode.body" xml:space="preserve"><value>Search Mode: type a name or email to find a specific user via Azure AD. Matching users appear in a list — click to select them for the audit.
|
||||||
|
|
||||||
|
Browse Mode: loads all users in your tenant directory. Use the filter box to narrow the list, then double-click a row to add the user to the audit.</value></data>
|
||||||
|
<data name="help.audit.vs.perms.title" xml:space="preserve"><value>User Access Audit vs Permissions Audit</value></data>
|
||||||
|
<data name="help.audit.vs.perms.body" xml:space="preserve"><value>The Permissions tab scans objects (libraries, folders, items) and shows who has access to each one.
|
||||||
|
|
||||||
|
This tab does the reverse: you select one or more users and it finds every object they can access — including access granted via SharePoint groups or Active Directory groups.</value></data>
|
||||||
|
<data name="help.storage.hidden.title" xml:space="preserve"><value>Hidden Libraries</value></data>
|
||||||
|
<data name="help.storage.hidden.body" xml:space="preserve"><value>Scans SharePoint libraries hidden from the site's normal navigation (e.g. Site Assets, Style Library, Form Templates). These can consume significant storage and are often overlooked in routine audits.</value></data>
|
||||||
|
<data name="help.storage.preservation.title" xml:space="preserve"><value>Preservation Hold Library</value></data>
|
||||||
|
<data name="help.storage.preservation.body" xml:space="preserve"><value>A hidden SharePoint library that stores versions of documents modified or deleted while a Microsoft Purview / Microsoft 365 Compliance retention policy is active. It can grow very large over time without being visible to normal site users.</value></data>
|
||||||
|
</root>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
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.
|
||||||
|
/// </summary>
|
||||||
|
public class TranslationSource
|
||||||
|
{
|
||||||
|
public static readonly TranslationSource Instance = new();
|
||||||
|
|
||||||
|
private ResourceManager _resourceManager = Strings.ResourceManager;
|
||||||
|
private CultureInfo _currentCulture = CultureInfo.CurrentUICulture;
|
||||||
|
|
||||||
|
private TranslationSource() { }
|
||||||
|
|
||||||
|
public string this[string key] =>
|
||||||
|
_resourceManager.GetString(key, _currentCulture) ?? $"[{key}]";
|
||||||
|
|
||||||
|
public CultureInfo CurrentCulture
|
||||||
|
{
|
||||||
|
get => _currentCulture;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (Equals(_currentCulture, value)) return;
|
||||||
|
_currentCulture = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetCulture(string lang)
|
||||||
|
{
|
||||||
|
CurrentCulture = lang switch
|
||||||
|
{
|
||||||
|
"fr" => new CultureInfo("fr"),
|
||||||
|
_ => CultureInfo.InvariantCulture
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+275
@@ -0,0 +1,275 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
|
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||||
|
using Serilog;
|
||||||
|
using SharepointToolbox.Web.Core.Config;
|
||||||
|
using SharepointToolbox.Web.Core.Models;
|
||||||
|
using SharepointToolbox.Web.Infrastructure.Auth;
|
||||||
|
using SharepointToolbox.Web.Infrastructure.OAuth;
|
||||||
|
using SharepointToolbox.Web.Infrastructure.Persistence;
|
||||||
|
using SharepointToolbox.Web.Services;
|
||||||
|
using SharepointToolbox.Web.Services.Audit;
|
||||||
|
using SharepointToolbox.Web.Services.Auth;
|
||||||
|
using SharepointToolbox.Web.Services.Export;
|
||||||
|
using SharepointToolbox.Web.Services.OAuth;
|
||||||
|
using SharepointToolbox.Web.Services.Session;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// ── Serilog ───────────────────────────────────────────────────────────────────
|
||||||
|
var dataFolder = builder.Configuration["DataFolder"] ?? "/data";
|
||||||
|
Directory.CreateDirectory(dataFolder);
|
||||||
|
|
||||||
|
Log.Logger = new LoggerConfiguration()
|
||||||
|
.ReadFrom.Configuration(builder.Configuration)
|
||||||
|
.WriteTo.Console()
|
||||||
|
.WriteTo.File(
|
||||||
|
Path.Combine(dataFolder, "logs", "app-.log"),
|
||||||
|
rollingInterval: RollingInterval.Day,
|
||||||
|
retainedFileCountLimit: 30)
|
||||||
|
.CreateLogger();
|
||||||
|
|
||||||
|
builder.Host.UseSerilog();
|
||||||
|
|
||||||
|
// ── Blazor / Razor Components ─────────────────────────────────────────────────
|
||||||
|
builder.Services.AddRazorComponents()
|
||||||
|
.AddInteractiveServerComponents();
|
||||||
|
builder.Services.AddHttpContextAccessor();
|
||||||
|
|
||||||
|
// ── Authentication ────────────────────────────────────────────────────────────
|
||||||
|
if (builder.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
// Dev: cookie-only, no OIDC. /account/login auto-signs in a hardcoded Admin.
|
||||||
|
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||||
|
.AddCookie(options =>
|
||||||
|
{
|
||||||
|
options.LoginPath = "/account/login";
|
||||||
|
options.LogoutPath = "/account/logout";
|
||||||
|
options.Cookie.SameSite = SameSiteMode.Lax;
|
||||||
|
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
||||||
|
options.ExpireTimeSpan = TimeSpan.FromHours(8);
|
||||||
|
options.SlidingExpiration = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
builder.Services.AddAuthentication(options =>
|
||||||
|
{
|
||||||
|
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
||||||
|
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
|
||||||
|
})
|
||||||
|
.AddCookie(options =>
|
||||||
|
{
|
||||||
|
options.LoginPath = "/account/login";
|
||||||
|
options.LogoutPath = "/account/logout";
|
||||||
|
// Auth state lives entirely in the browser cookie (Data Protection encrypted)
|
||||||
|
options.SessionStore = null;
|
||||||
|
options.Cookie.SameSite = SameSiteMode.Lax;
|
||||||
|
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
||||||
|
options.ExpireTimeSpan = TimeSpan.FromHours(8);
|
||||||
|
options.SlidingExpiration = true;
|
||||||
|
})
|
||||||
|
.AddOpenIdConnect(options =>
|
||||||
|
{
|
||||||
|
var oidc = builder.Configuration.GetSection("Oidc");
|
||||||
|
options.Authority = $"https://login.microsoftonline.com/{oidc["TenantId"]}/v2.0";
|
||||||
|
options.ClientId = oidc["ClientId"];
|
||||||
|
options.ClientSecret = oidc["ClientSecret"];
|
||||||
|
options.ResponseType = OpenIdConnectResponseType.Code;
|
||||||
|
options.SaveTokens = true;
|
||||||
|
options.Scope.Add("openid");
|
||||||
|
options.Scope.Add("profile");
|
||||||
|
options.Scope.Add("email");
|
||||||
|
options.GetClaimsFromUserInfoEndpoint = true;
|
||||||
|
options.MapInboundClaims = false;
|
||||||
|
options.TokenValidationParameters.NameClaimType = "preferred_username";
|
||||||
|
|
||||||
|
options.Events.OnTokenValidated = async ctx =>
|
||||||
|
{
|
||||||
|
var userService = ctx.HttpContext.RequestServices.GetRequiredService<IUserService>();
|
||||||
|
await userService.ProvisionAsync(ctx.Principal!);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.Services.AddAuthorization();
|
||||||
|
|
||||||
|
// ── Memory cache (used by OAuth flow cache) ───────────────────────────────────
|
||||||
|
builder.Services.AddMemoryCache();
|
||||||
|
builder.Services.AddHttpClient("oauth");
|
||||||
|
|
||||||
|
// ── ClientConnect options ─────────────────────────────────────────────────────
|
||||||
|
builder.Services.Configure<ClientConnectOptions>(builder.Configuration.GetSection("ClientConnect"));
|
||||||
|
|
||||||
|
// ── App config ────────────────────────────────────────────────────────────────
|
||||||
|
builder.Services.Configure<AppConfiguration>(opt =>
|
||||||
|
{
|
||||||
|
opt.DataFolder = dataFolder;
|
||||||
|
opt.ExportsFolder = Path.Combine(dataFolder, "exports");
|
||||||
|
Directory.CreateDirectory(opt.ExportsFolder);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Persistence (Singleton — files on disk) ───────────────────────────────────
|
||||||
|
builder.Services.AddSingleton(new ProfileRepository(Path.Combine(dataFolder, "profiles.json")));
|
||||||
|
builder.Services.AddSingleton(new SettingsRepository(Path.Combine(dataFolder, "settings.json")));
|
||||||
|
builder.Services.AddSingleton(new TemplateRepository(Path.Combine(dataFolder, "templates")));
|
||||||
|
builder.Services.AddSingleton(new UserRepository(Path.Combine(dataFolder, "users.json")));
|
||||||
|
builder.Services.AddSingleton(new AuditRepository(Path.Combine(dataFolder, "audit.jsonl")));
|
||||||
|
|
||||||
|
// ── Auth infrastructure ───────────────────────────────────────────────────────
|
||||||
|
builder.Services.AddSingleton<IUserService, UserService>();
|
||||||
|
builder.Services.AddSingleton<IOAuthFlowCache, OAuthFlowCache>();
|
||||||
|
builder.Services.AddHttpClient<ITokenRefreshService, TokenRefreshService>();
|
||||||
|
builder.Services.AddHttpClient<IAppRegistrationService, AppRegistrationService>();
|
||||||
|
builder.Services.AddScoped<GraphClientFactory>();
|
||||||
|
|
||||||
|
// ── User session (Scoped = one per Blazor circuit = one per browser tab) ─────
|
||||||
|
builder.Services.AddScoped<IUserSessionService, UserSessionService>();
|
||||||
|
builder.Services.AddScoped<IUserContextAccessor, UserContextAccessor>();
|
||||||
|
builder.Services.AddScoped<ISessionCredentialStore, SessionCredentialStore>();
|
||||||
|
|
||||||
|
// ── Audit (Scoped — reads user context from circuit) ─────────────────────────
|
||||||
|
builder.Services.AddScoped<IAuditService, AuditService>();
|
||||||
|
|
||||||
|
// ── Business services (Scoped — each user circuit gets its own instances) ─────
|
||||||
|
builder.Services.AddScoped<ISessionManager, SessionManager>();
|
||||||
|
builder.Services.AddScoped<IPermissionsService, PermissionsService>();
|
||||||
|
builder.Services.AddScoped<IStorageService, StorageService>();
|
||||||
|
builder.Services.AddScoped<ISearchService, SearchService>();
|
||||||
|
builder.Services.AddScoped<IDuplicatesService, DuplicatesService>();
|
||||||
|
builder.Services.AddScoped<IFileTransferService, FileTransferService>();
|
||||||
|
builder.Services.AddScoped<IBulkMemberService, BulkMemberService>();
|
||||||
|
builder.Services.AddScoped<IBulkSiteService, BulkSiteService>();
|
||||||
|
builder.Services.AddScoped<IVersionCleanupService, VersionCleanupService>();
|
||||||
|
builder.Services.AddScoped<IUserAccessAuditService, UserAccessAuditService>();
|
||||||
|
builder.Services.AddScoped<IGraphUserDirectoryService, GraphUserDirectoryService>();
|
||||||
|
builder.Services.AddScoped<IFolderStructureService, FolderStructureService>();
|
||||||
|
builder.Services.AddScoped<ITemplateService, TemplateService>();
|
||||||
|
builder.Services.AddScoped<ICsvValidationService, CsvValidationService>();
|
||||||
|
builder.Services.AddScoped<ISharePointGroupResolver, SharePointGroupResolver>();
|
||||||
|
builder.Services.AddScoped<IOwnershipElevationService, OwnershipElevationService>();
|
||||||
|
|
||||||
|
// ── Export services (Scoped) ──────────────────────────────────────────────────
|
||||||
|
builder.Services.AddScoped<CsvExportService>();
|
||||||
|
builder.Services.AddScoped<HtmlExportService>();
|
||||||
|
builder.Services.AddScoped<StorageCsvExportService>();
|
||||||
|
builder.Services.AddScoped<StorageHtmlExportService>();
|
||||||
|
builder.Services.AddScoped<SearchCsvExportService>();
|
||||||
|
builder.Services.AddScoped<SearchHtmlExportService>();
|
||||||
|
builder.Services.AddScoped<DuplicatesCsvExportService>();
|
||||||
|
builder.Services.AddScoped<DuplicatesHtmlExportService>();
|
||||||
|
builder.Services.AddScoped<UserAccessCsvExportService>();
|
||||||
|
builder.Services.AddScoped<UserAccessHtmlExportService>();
|
||||||
|
builder.Services.AddScoped<VersionCleanupHtmlExportService>();
|
||||||
|
builder.Services.AddScoped<BulkResultCsvExportService>();
|
||||||
|
builder.Services.AddScoped<WebExportService>();
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
if (!app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
||||||
|
app.UseHsts();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.UseStaticFiles();
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
app.UseAntiforgery();
|
||||||
|
|
||||||
|
// ── Login / Logout endpoints ──────────────────────────────────────────────────
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.MapGet("/account/login", async (HttpContext ctx, string? returnUrl, IUserService userService) =>
|
||||||
|
{
|
||||||
|
const string devEmail = "dev@local.test";
|
||||||
|
const string devName = "Dev Admin";
|
||||||
|
|
||||||
|
// Provision the dev user in users.json (first run = Admin)
|
||||||
|
var provisionPrincipal = new ClaimsPrincipal(new ClaimsIdentity(
|
||||||
|
new[] { new Claim("preferred_username", devEmail), new Claim("name", devName) },
|
||||||
|
CookieAuthenticationDefaults.AuthenticationScheme));
|
||||||
|
var user = await userService.ProvisionAsync(provisionPrincipal);
|
||||||
|
|
||||||
|
// Sign in with full claims including app_role for HTTP endpoints
|
||||||
|
var principal = new ClaimsPrincipal(new ClaimsIdentity(
|
||||||
|
new Claim[] {
|
||||||
|
new("preferred_username", devEmail),
|
||||||
|
new("name", devName),
|
||||||
|
new("app_role", user.Role.ToString()),
|
||||||
|
},
|
||||||
|
CookieAuthenticationDefaults.AuthenticationScheme));
|
||||||
|
|
||||||
|
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
|
||||||
|
ctx.Response.Redirect(string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapGet("/account/logout", async (HttpContext ctx) =>
|
||||||
|
{
|
||||||
|
await ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
ctx.Response.Redirect("/");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
app.MapGet("/account/login", async (HttpContext ctx, string? returnUrl) =>
|
||||||
|
{
|
||||||
|
var props = new AuthenticationProperties
|
||||||
|
{
|
||||||
|
RedirectUri = string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl
|
||||||
|
};
|
||||||
|
await ctx.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, props);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapGet("/account/logout", async (HttpContext ctx) =>
|
||||||
|
{
|
||||||
|
await ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
await ctx.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme,
|
||||||
|
new AuthenticationProperties { RedirectUri = "/" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── OAuth2 connect endpoints ──────────────────────────────────────────────────
|
||||||
|
app.MapOAuthEndpoints();
|
||||||
|
|
||||||
|
// ── File download endpoint ────────────────────────────────────────────────────
|
||||||
|
app.MapGet("/export/download/{fileName}", async (string fileName, IOptions<AppConfiguration> opts, HttpContext ctx) =>
|
||||||
|
{
|
||||||
|
if (!ctx.User.Identity?.IsAuthenticated ?? true) return Results.Unauthorized();
|
||||||
|
var path = Path.Combine(opts.Value.ExportsFolder, Path.GetFileName(fileName));
|
||||||
|
if (!File.Exists(path)) return Results.NotFound();
|
||||||
|
var bytes = await File.ReadAllBytesAsync(path);
|
||||||
|
var ct = fileName.EndsWith(".csv") ? "text/csv" : "text/html";
|
||||||
|
return Results.File(bytes, ct, fileName);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Audit CSV download ────────────────────────────────────────────────────────
|
||||||
|
app.MapGet("/audit/export", async (AuditRepository auditRepo, HttpContext ctx) =>
|
||||||
|
{
|
||||||
|
if (!ctx.User.Identity?.IsAuthenticated ?? true) return Results.Unauthorized();
|
||||||
|
// Role check via the app-role claim set during OIDC provisioning
|
||||||
|
var rolesClaim = ctx.User.FindFirst("app_role")?.Value;
|
||||||
|
if (rolesClaim != nameof(UserRole.Admin)) return Results.Forbid();
|
||||||
|
var entries = await auditRepo.LoadAllAsync();
|
||||||
|
var sb = new System.Text.StringBuilder();
|
||||||
|
sb.AppendLine("Timestamp,UserEmail,UserDisplay,UserRole,Action,Client,Sites,Details");
|
||||||
|
foreach (var e in entries.OrderByDescending(x => x.Timestamp))
|
||||||
|
{
|
||||||
|
string Esc(string v) => v.Contains(',') || v.Contains('"') || v.Contains('\n')
|
||||||
|
? $"\"{v.Replace("\"", "\"\"")}\"" : v;
|
||||||
|
sb.AppendLine(string.Join(",",
|
||||||
|
Esc(e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")),
|
||||||
|
Esc(e.UserEmail), Esc(e.UserDisplay), Esc(e.UserRole.ToString()),
|
||||||
|
Esc(e.Action), Esc(e.ClientName),
|
||||||
|
Esc(string.Join("; ", e.Sites)), Esc(e.Details)));
|
||||||
|
}
|
||||||
|
return Results.File(System.Text.Encoding.UTF8.GetBytes(sb.ToString()), "text/csv", "audit-log.csv");
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapRazorComponents<SharepointToolbox.Web.Components.App>()
|
||||||
|
.AddInteractiveServerRenderMode();
|
||||||
|
|
||||||
|
app.Run();
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "http://localhost:5000",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user