This commit is contained in:
2026-06-02 15:46:13 +02:00
25 changed files with 951 additions and 215 deletions
@@ -0,0 +1,91 @@
@page "/account/change-password"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@inject IUserService UserService
@inject IUserContextAccessor UserContext
@inject IAuditService Audit
@rendermode InteractiveServer
@using SharepointToolbox.Web.Core.Models
@using SharepointToolbox.Web.Services.Audit
@using SharepointToolbox.Web.Services.Auth
@using SharepointToolbox.Web.Services.Session
<h1 class="page-title">Change Password</h1>
@if (!UserContext.IsAuthenticated)
{
<div class="alert alert-error">You must be signed in.</div>
return;
}
@if (_user is null)
{
<p class="page-subtitle">Loading…</p>
}
else if (_user.Provider != AuthProvider.Local)
{
<div class="alert alert-info">
Your account signs in with Microsoft (Entra). Manage its password in your Microsoft account.
</div>
}
else
{
@if (!string.IsNullOrEmpty(_message))
{
<div class="alert @(_isError ? "alert-error" : "alert-success")">@_message</div>
}
<div class="card" style="max-width:420px">
<label class="form-label" for="cur">Current password</label>
<input id="cur" class="form-input" type="password" @bind="_current" autocomplete="current-password" />
<label class="form-label" for="new" style="margin-top:12px">New password</label>
<input id="new" class="form-input" type="password" @bind="_new" autocomplete="new-password" />
<label class="form-label" for="confirm" style="margin-top:12px">Confirm new password</label>
<input id="confirm" class="form-input" type="password" @bind="_confirm" autocomplete="new-password" />
<div style="margin-top:14px">
<button class="btn btn-primary" @onclick="SubmitAsync">Change password</button>
</div>
</div>
}
@code {
private AppUser? _user;
private string _current = string.Empty;
private string _new = string.Empty;
private string _confirm = string.Empty;
private string _message = string.Empty;
private bool _isError;
protected override async Task OnInitializedAsync()
{
if (UserContext.IsAuthenticated)
_user = await UserService.GetByEmailAsync(UserContext.Email);
}
private async Task SubmitAsync()
{
if (_user is null) return;
if (string.IsNullOrWhiteSpace(_new) || _new != _confirm)
{
_message = "New passwords do not match.";
_isError = true;
return;
}
var ok = await UserService.ChangePasswordAsync(_user.Id, _current, _new);
if (ok)
{
await Audit.LogAsync("PasswordChanged", "", Array.Empty<string>(),
$"Changed own password ({_user.Email}).");
_message = "Password changed.";
_isError = false;
_current = _new = _confirm = string.Empty;
}
else
{
_message = "Current password is incorrect.";
_isError = true;
}
}
}
+1 -1
View File
@@ -51,7 +51,7 @@ else
@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;white-space:nowrap">@e.Timestamp.ToLocalTime().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>
+116 -3
View File
@@ -2,14 +2,16 @@
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@inject IUserService UserService
@inject IUserContextAccessor UserContext
@inject IAuditService Audit
@inject NavigationManager Nav
@rendermode InteractiveServer
@using SharepointToolbox.Web.Core.Models
@using SharepointToolbox.Web.Services.Audit
@using SharepointToolbox.Web.Services.Auth
@using SharepointToolbox.Web.Services.Session
<h1 class="page-title">User Management</h1>
<p class="page-subtitle">Manage technician accounts and roles. Auto-provisioned on first OIDC login.</p>
<p class="page-subtitle">Manage technician accounts and roles. Entra users are auto-provisioned on first OIDC login; local users are created here.</p>
@if (!UserContext.IsAuthenticated || UserContext.Role != UserRole.Admin)
{
@@ -19,9 +21,39 @@
@if (!string.IsNullOrEmpty(_message))
{
<div class="alert @(_isError ? "alert-error" : "alert-info")">@_message</div>
<div class="alert @(_isError ? "alert-error" : "alert-success")">@_message</div>
}
<div class="card">
<h2 class="card-title">Create local user</h2>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;max-width:640px">
<div>
<label class="form-label" for="new-email">Email</label>
<input id="new-email" class="form-input" type="email" @bind="_newEmail" placeholder="user@example.com" />
</div>
<div>
<label class="form-label" for="new-name">Display name</label>
<input id="new-name" class="form-input" type="text" @bind="_newName" placeholder="Jane Doe" />
</div>
<div>
<label class="form-label" for="new-role">Role</label>
<select id="new-role" class="form-input" @bind="_newRole">
@foreach (var role in Enum.GetValues<UserRole>())
{
<option value="@role">@role</option>
}
</select>
</div>
<div>
<label class="form-label" for="new-pw">Password</label>
<input id="new-pw" class="form-input" type="password" @bind="_newPassword" autocomplete="new-password" />
</div>
</div>
<div style="margin-top:12px">
<button class="btn btn-primary" @onclick="CreateLocalUserAsync">Create user</button>
</div>
</div>
@if (_users.Count == 0)
{
<div class="alert alert-info">No users provisioned yet.</div>
@@ -34,6 +66,7 @@ else
<tr style="border-bottom:2px solid var(--border)">
<th style="text-align:left;padding:8px">User</th>
<th style="text-align:left;padding:8px">Email</th>
<th style="text-align:left;padding:8px">Source</th>
<th style="text-align:left;padding:8px">Role</th>
<th style="text-align:left;padding:8px">Last Login</th>
<th style="text-align:left;padding:8px">Actions</th>
@@ -45,6 +78,11 @@ else
<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">
<span class="chip @(user.Provider == AuthProvider.Local ? "chip-blue" : "chip-green")">
@(user.Provider == AuthProvider.Local ? "Local" : "Entra")
</span>
</td>
<td style="padding:8px">
<select class="form-input" style="width:130px"
value="@user.Role"
@@ -57,7 +95,11 @@ else
</select>
</td>
<td style="padding:8px">@(user.LastLogin?.ToString("yyyy-MM-dd HH:mm") ?? "Never")</td>
<td style="padding:8px">
<td style="padding:8px;white-space:nowrap">
@if (user.Provider == AuthProvider.Local)
{
<button class="btn btn-secondary btn-sm" @onclick="() => OpenReset(user)">Reset password</button>
}
@if (user.Email != UserContext.Email)
{
<button class="btn btn-danger btn-sm" @onclick="() => DeleteUserAsync(user)">Remove</button>
@@ -74,23 +116,92 @@ else
</div>
}
@if (_resetUser is not null)
{
<div class="card" style="max-width:420px">
<h2 class="card-title">Reset password — @_resetUser.DisplayName</h2>
<label class="form-label" for="reset-pw">New password</label>
<input id="reset-pw" class="form-input" type="password" @bind="_resetPassword" autocomplete="new-password" />
<div style="margin-top:12px;display:flex;gap:8px">
<button class="btn btn-primary" @onclick="ResetPasswordAsync">Set password</button>
<button class="btn btn-secondary" @onclick="() => _resetUser = null">Cancel</button>
</div>
</div>
}
@code {
private List<AppUser> _users = new();
private string _message = string.Empty;
private bool _isError;
private string _newEmail = string.Empty;
private string _newName = string.Empty;
private UserRole _newRole = UserRole.TechN0;
private string _newPassword = string.Empty;
private AppUser? _resetUser;
private string _resetPassword = string.Empty;
protected override async Task OnInitializedAsync()
{
_users = (await UserService.GetAllAsync()).ToList();
}
private async Task CreateLocalUserAsync()
{
try
{
var user = await UserService.CreateLocalUserAsync(_newEmail, _newName, _newRole, _newPassword);
_users.Add(user);
await Audit.LogAsync("UserCreated", "", Array.Empty<string>(),
$"Created local user {user.Email} ({user.DisplayName}) with role {user.Role}.");
_message = $"Local user {user.DisplayName} created.";
_isError = false;
_newEmail = _newName = _newPassword = string.Empty;
_newRole = UserRole.TechN0;
}
catch (Exception ex)
{
_message = $"Error: {ex.Message}";
_isError = true;
}
}
private void OpenReset(AppUser user)
{
_resetUser = user;
_resetPassword = string.Empty;
}
private async Task ResetPasswordAsync()
{
if (_resetUser is null) return;
try
{
await UserService.SetPasswordAsync(_resetUser.Id, _resetPassword);
await Audit.LogAsync("PasswordReset", "", Array.Empty<string>(),
$"Reset password for local user {_resetUser.Email} ({_resetUser.DisplayName}).");
_message = $"Password reset for {_resetUser.DisplayName}.";
_isError = false;
_resetUser = null;
}
catch (Exception ex)
{
_message = $"Error: {ex.Message}";
_isError = true;
}
}
private async Task OnRoleChange(AppUser user, ChangeEventArgs e)
{
if (!Enum.TryParse<UserRole>(e.Value?.ToString(), out var newRole)) return;
try
{
var oldRole = user.Role;
await UserService.UpdateRoleAsync(user.Id, newRole);
user.Role = newRole;
await Audit.LogAsync("RoleChanged", "", Array.Empty<string>(),
$"Changed role for {user.Email} ({user.DisplayName}) from {oldRole} to {newRole}.");
_message = $"Role updated for {user.DisplayName}.";
_isError = false;
}
@@ -105,6 +216,8 @@ else
{
await UserService.DeleteAsync(user.Id);
_users.Remove(user);
await Audit.LogAsync("UserDeleted", "", Array.Empty<string>(),
$"Removed {user.Provider} user {user.Email} ({user.DisplayName}), role {user.Role}.");
_message = $"User {user.DisplayName} removed.";
_isError = false;
}
+4 -7
View File
@@ -17,10 +17,7 @@
@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>
<SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_sites" Single="true" />
<div class="form-group">
<label class="form-label">CSV File (GroupName, GroupUrl, Email, Role)</label>
<InputFile OnChange="LoadFile" accept=".csv" class="form-input" />
@@ -74,7 +71,7 @@
}
@code {
private string _siteUrl = string.Empty;
private List<SiteInfo> _sites = new();
private List<CsvValidationRow<BulkMemberRow>> _rows = new();
private bool _running; private string _status = string.Empty, _error = string.Empty;
private int _current, _total;
@@ -94,8 +91,8 @@
_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();
if (string.IsNullOrWhiteSpace(_siteUrl)) { _error = "Please enter a site URL."; _running = false; return; }
var siteUrl = _siteUrl.Trim();
var siteUrl = _sites.FirstOrDefault()?.Url;
if (string.IsNullOrWhiteSpace(siteUrl)) { _error = "Please select a site."; _running = false; return; }
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
try
{
+3
View File
@@ -7,6 +7,7 @@
@inject DuplicatesCsvExportService CsvExport
@inject DuplicatesHtmlExportService HtmlExport
@inject WebExportService WebExport
@inject IAuditService Audit
@rendermode InteractiveServer
<h1 class="page-title">Duplicate Detection</h1>
@@ -116,6 +117,8 @@
}
_bySite = bySite; _results = flat;
_status = $"Found {_results.Count} duplicate groups across {_sites.Count} site(s).";
await Audit.LogAsync("DuplicateScan", Session.CurrentProfile?.Name ?? "", _sites.Select(s => s.Url),
$"{_results.Count} groups; mode={_mode} lib=[{_library}]");
}
catch (OperationCanceledException) { _status = "Cancelled."; }
catch (Exception ex) { _error = ex.Message; }
+11 -16
View File
@@ -14,11 +14,8 @@
<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>
<SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_srcSites" Single="true" />
<div class="form-row mt-8">
<div class="form-group">
<label class="form-label">Source Library</label>
<input class="form-input" @bind="_srcLibrary" placeholder="Shared Documents" />
@@ -32,11 +29,8 @@
<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>
<SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_dstSites" Single="true" />
<div class="form-row mt-8">
<div class="form-group">
<label class="form-label">Destination Library</label>
<input class="form-input" @bind="_dstLibrary" placeholder="Shared Documents" />
@@ -102,8 +96,9 @@
}
@code {
private string _srcSiteUrl = string.Empty, _srcLibrary = string.Empty, _srcFolder = string.Empty;
private string _dstSiteUrl = string.Empty, _dstLibrary = string.Empty, _dstFolder = string.Empty;
private List<SiteInfo> _srcSites = new(), _dstSites = new();
private string _srcLibrary = string.Empty, _srcFolder = string.Empty;
private string _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;
@@ -118,10 +113,10 @@
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
try
{
if (string.IsNullOrWhiteSpace(_srcSiteUrl)) { _error = "Please enter a source site URL."; return; }
if (string.IsNullOrWhiteSpace(_dstSiteUrl)) { _error = "Please enter a destination site URL."; return; }
var srcUrl = _srcSiteUrl.Trim();
var dstUrl = _dstSiteUrl.Trim();
var srcUrl = _srcSites.FirstOrDefault()?.Url;
var dstUrl = _dstSites.FirstOrDefault()?.Url;
if (string.IsNullOrWhiteSpace(srcUrl)) { _error = "Please select a source site."; return; }
if (string.IsNullOrWhiteSpace(dstUrl)) { _error = "Please select a destination site."; return; }
var job = new TransferJob
{
SourceSiteUrl = srcUrl, SourceLibrary = _srcLibrary, SourceFolderPath = _srcFolder,
+6 -8
View File
@@ -15,11 +15,8 @@
@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>
<SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_sites" Single="true" />
<div class="form-row mt-8">
<div class="form-group">
<label class="form-label">Library Title</label>
<input class="form-input" @bind="_libraryTitle" placeholder="Shared Documents" />
@@ -56,7 +53,8 @@
}
@code {
private string _siteUrl = string.Empty, _libraryTitle = string.Empty;
private List<SiteInfo> _sites = new();
private string _libraryTitle = string.Empty;
private List<CsvValidationRow<FolderStructureRow>> _rows = new();
private bool _running; private string _status = string.Empty, _error = string.Empty;
private int _current, _total;
@@ -75,8 +73,8 @@
_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();
if (string.IsNullOrWhiteSpace(_siteUrl)) { _error = "Please enter a site URL."; _running = false; return; }
var siteUrl = _siteUrl.Trim();
var siteUrl = _sites.FirstOrDefault()?.Url;
if (string.IsNullOrWhiteSpace(siteUrl)) { _error = "Please select a site."; _running = false; return; }
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
try
{
+3
View File
@@ -7,6 +7,7 @@
@inject CsvExportService CsvExport
@inject HtmlExportService HtmlExport
@inject WebExportService WebExport
@inject IAuditService Audit
@rendermode InteractiveServer
<h1 class="page-title">Permissions Audit</h1>
@@ -127,6 +128,8 @@
}
_bySite = bySite; _results = flat;
_status = $"Scan complete: {_results.Count} entries across {_sites.Count} site(s).";
await Audit.LogAsync("PermissionsScan", Session.CurrentProfile?.Name ?? "", _sites.Select(s => s.Url),
$"{_results.Count} entries; inherited={_includeInherited} folders={_scanFolders} depth={_folderDepth} subsites={_includeSubsites}");
}
catch (OperationCanceledException) { _status = "Cancelled."; }
catch (Exception ex) { _error = ex.Message; }
+3
View File
@@ -7,6 +7,7 @@
@inject SearchCsvExportService CsvExport
@inject SearchHtmlExportService HtmlExport
@inject WebExportService WebExport
@inject IAuditService Audit
@rendermode InteractiveServer
<h1 class="page-title">File Search</h1>
@@ -129,6 +130,8 @@
}
_bySite = bySite; _results = flat;
_status = $"Found {_results.Count} files across {_sites.Count} site(s).";
await Audit.LogAsync("FileSearch", Session.CurrentProfile?.Name ?? "", _sites.Select(s => s.Url),
$"{_results.Count} files; ext=[{_extensions}] regex=[{_regex}] lib=[{_library}]");
}
catch (OperationCanceledException) { _status = "Cancelled."; }
catch (Exception ex) { _error = ex.Message; }
+3
View File
@@ -7,6 +7,7 @@
@inject StorageCsvExportService CsvExport
@inject StorageHtmlExportService HtmlExport
@inject WebExportService WebExport
@inject IAuditService Audit
@rendermode InteractiveServer
<h1 class="page-title">Storage Metrics</h1>
@@ -120,6 +121,8 @@
}
_bySite = bySite; _results = flat;
_status = $"Complete: {_results.Count} nodes across {_sites.Count} site(s).";
await Audit.LogAsync("StorageScan", Session.CurrentProfile?.Name ?? "", _sites.Select(s => s.Url),
$"{_results.Count} nodes; depth={_folderDepth} subsites={_includeSubsites} hidden={_includeHidden} recycle={_includeRecycleBin}");
}
catch (OperationCanceledException) { _status = "Cancelled."; }
catch (Exception ex) { _error = ex.Message; }
+5 -7
View File
@@ -17,11 +17,8 @@
<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">
<SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_captureSites" Single="true" />
<div class="form-group mt-8">
<label class="form-label">Template Name</label>
<input class="form-input" @bind="_captureName" placeholder="My Template" />
</div>
@@ -88,7 +85,8 @@
</div>
@code {
private string _captureUrl = string.Empty, _captureName = string.Empty;
private List<SiteInfo> _captureSites = new();
private string _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;
@@ -105,7 +103,7 @@
{
_error = string.Empty; _successMsg = string.Empty; _running = true;
_cts = new CancellationTokenSource();
var siteUrl = string.IsNullOrWhiteSpace(_captureUrl) ? Session.CurrentProfile!.TenantUrl : _captureUrl.Trim();
var siteUrl = _captureSites.FirstOrDefault()?.Url ?? Session.CurrentProfile!.TenantUrl;
var progress = new Progress<OperationProgress>(p => { _status = p.Message; InvokeAsync(StateHasChanged); });
try
{
+83 -12
View File
@@ -3,9 +3,11 @@
@inject IUserSessionService Session
@inject ISessionManager SessionMgr
@inject IUserAccessAuditService AuditSvc
@inject IGraphUserDirectoryService GraphSvc
@inject UserAccessCsvExportService CsvExport
@inject UserAccessHtmlExportService HtmlExport
@inject WebExportService WebExport
@inject IAuditService Audit
@rendermode InteractiveServer
<h1 class="page-title">User Access Audit</h1>
@@ -14,16 +16,45 @@
@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&#10;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 class="form-group">
<div class="flex-row">
<label class="form-label" style="margin:0">Users</label>
<div class="spacer"></div>
<label style="font-weight:normal"><input type="checkbox" @bind="_includeGuests" /> Include guests</label>
<button class="btn btn-secondary btn-sm" @onclick="LoadUsers" disabled="@_loadingUsers">
@(_loadingUsers ? $"Loading… ({_loadCount})" : "Load Users")
</button>
</div>
@if (_directoryUsers.Count > 0)
{
<div class="flex-row mt-8">
<input class="form-input" style="width:260px" @bind="_userFilter" @bind:event="oninput" placeholder="Filter by name or email…" />
<span class="text-muted">@_selectedEmails.Count selected</span>
<div class="spacer"></div>
<button class="btn btn-link btn-sm" @onclick="SelectAllFiltered">Select all (@FilteredUsers.Count())</button>
<button class="btn btn-link btn-sm" @onclick="ClearSelection">Clear</button>
</div>
<div class="user-select-list">
@foreach (var u in FilteredUsers.Take(500))
{
var email = u.Mail ?? u.UserPrincipalName;
<label class="user-select-row">
<input type="checkbox" checked="@_selectedEmails.Contains(email)"
@onchange="e => ToggleUser(email, (bool)e.Value!)" />
<span class="user-select-name">@u.DisplayName</span>
<span class="text-muted">@email</span>
@if (u.UserType == "Guest") { <span class="chip chip-yellow">Guest</span> }
</label>
}
</div>
@if (FilteredUsers.Count() > 500) { <div class="text-muted mt-8">Showing first 500. Refine filter to narrow.</div> }
}
<label class="form-label mt-8">Additional emails (one per line)</label>
<textarea class="form-textarea" @bind="_users" placeholder="alice@contoso.com&#10;bob@contoso.com" rows="2"></textarea>
</div>
<SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_sites" />
<div class="form-row">
<div class="form-group" style="display:flex;align-items:center;gap:16px;padding-top:20px">
<label><input type="checkbox" @bind="_includeInherited" /> Include inherited</label>
@@ -74,7 +105,43 @@
}
@code {
private string _users = string.Empty, _sites = string.Empty;
private string _users = string.Empty;
private bool _includeGuests, _loadingUsers;
private int _loadCount;
private string _userFilter = string.Empty;
private List<GraphDirectoryUser> _directoryUsers = new();
private readonly HashSet<string> _selectedEmails = new(StringComparer.OrdinalIgnoreCase);
private List<SiteInfo> _sites = new();
private IEnumerable<GraphDirectoryUser> FilteredUsers => string.IsNullOrWhiteSpace(_userFilter)
? _directoryUsers
: _directoryUsers.Where(u => u.DisplayName.Contains(_userFilter, StringComparison.OrdinalIgnoreCase)
|| u.UserPrincipalName.Contains(_userFilter, StringComparison.OrdinalIgnoreCase)
|| (u.Mail?.Contains(_userFilter, StringComparison.OrdinalIgnoreCase) ?? false));
private async Task LoadUsers()
{
_error = string.Empty; _loadingUsers = true; _loadCount = 0;
var progress = new Progress<int>(c => { _loadCount = c; InvokeAsync(StateHasChanged); });
try
{
_directoryUsers = (await GraphSvc.GetUsersAsync(Session.CurrentProfile!, _includeGuests, progress)).ToList();
}
catch (Exception ex) { _error = ex.Message; }
finally { _loadingUsers = false; await InvokeAsync(StateHasChanged); }
}
private void ToggleUser(string email, bool selected)
{
if (selected) _selectedEmails.Add(email); else _selectedEmails.Remove(email);
}
private void SelectAllFiltered()
{
foreach (var u in FilteredUsers) _selectedEmails.Add(u.Mail ?? u.UserPrincipalName);
}
private void ClearSelection() => _selectedEmails.Clear();
private bool _includeInherited, _includeSubsites, _scanFolders = true;
private bool _running; private string _status = string.Empty, _error = string.Empty;
private int _current, _total;
@@ -85,9 +152,11 @@
{
_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();
var userList = _selectedEmails
.Concat(_users.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
if (!userList.Any()) { _error = "Select at least one user or enter an email."; _running = false; return; }
var siteList = _sites.ToList();
if (!siteList.Any()) siteList.Add(new SiteInfo(Session.CurrentProfile!.TenantUrl, Session.CurrentProfile.Name));
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
try
@@ -95,6 +164,8 @@
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.";
await Audit.LogAsync("UserAccessAudit", Session.CurrentProfile?.Name ?? "", siteList.Select(s => s.Url),
$"{_results.Count} entries for {userList.Count} user(s)");
}
catch (OperationCanceledException) { _status = "Cancelled."; }
catch (Exception ex) { _error = ex.Message; }
+3
View File
@@ -2,6 +2,7 @@
@attribute [Authorize]
@inject IUserSessionService Session
@inject IGraphUserDirectoryService GraphSvc
@inject IAuditService Audit
@rendermode InteractiveServer
<h1 class="page-title">User Directory</h1>
@@ -67,6 +68,8 @@
{
_users = (await GraphSvc.GetUsersAsync(Session.CurrentProfile!, _includeGuests, progress)).ToList();
_status = $"Loaded {_users.Count} users.";
await Audit.LogAsync("UserDirectoryLoad", Session.CurrentProfile?.Name ?? "", Array.Empty<string>(),
$"{_users.Count} users; guests={_includeGuests}");
}
catch (Exception ex) { _error = ex.Message; }
finally { _running = false; await InvokeAsync(StateHasChanged); }
+7 -12
View File
@@ -15,13 +15,8 @@
@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">
<SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_sites" Single="true" />
<div class="flex-row mt-8">
<button class="btn btn-secondary" @onclick="LoadLibraries" disabled="@_loading">
@(_loading ? "Loading…" : "Load Libraries")
</button>
@@ -96,7 +91,7 @@
}
@code {
private string _siteUrl = string.Empty;
private List<SiteInfo> _sites = new();
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;
@@ -109,8 +104,8 @@
_loading = true; _error = string.Empty;
try
{
if (string.IsNullOrWhiteSpace(_siteUrl)) { _error = "Please enter a site URL."; return; }
var siteUrl = _siteUrl.Trim();
var siteUrl = _sites.FirstOrDefault()?.Url;
if (string.IsNullOrWhiteSpace(siteUrl)) { _error = "Please select a site."; return; }
_libraries = (await Elevation.RunAsync(async c =>
{
var ctx = await SessionMgr.GetOrCreateContextAsync(siteUrl, Session.CurrentProfile!, c);
@@ -127,8 +122,8 @@
{
_error = string.Empty; _results.Clear(); _running = true;
_cts = new CancellationTokenSource();
if (string.IsNullOrWhiteSpace(_siteUrl)) { _error = "Please enter a site URL."; _running = false; return; }
var siteUrl = _siteUrl.Trim();
var siteUrl = _sites.FirstOrDefault()?.Url;
if (string.IsNullOrWhiteSpace(siteUrl)) { _error = "Please select a site."; _running = false; return; }
var progress = new Progress<OperationProgress>(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); });
try
{