Initial commit
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user