diff --git a/Components/Layout/MainLayout.razor b/Components/Layout/MainLayout.razor index 14a000d..7565c3f 100644 --- a/Components/Layout/MainLayout.razor +++ b/Components/Layout/MainLayout.razor @@ -18,18 +18,21 @@
@@ -147,7 +125,49 @@ @code { private bool _sidebarCollapsed; + private bool _dark; + private string _navFilter = string.Empty; private bool _hasCredentials; + + private sealed record NavItem(string Href, string Icon, string Label, string Section, string Scope); + + private static readonly NavItem[] AllNavItems = + { + new("/", "🏠", "Home", "", "always"), + new("/permissions", "πŸ”", "Permissions", "", "profile"), + new("/storage", "πŸ’Ύ", "Storage", "", "profile"), + new("/duplicates", "πŸ“‹", "Duplicates", "", "profile"), + new("/versions", "πŸ—‚οΈ", "Version Cleanup", "", "profile"), + new("/transfer", "πŸ“¦", "File Transfer", "", "profile"), + new("/bulk-members", "πŸ‘₯", "Bulk Members", "Bulk", "profile"), + new("/bulk-sites", "🌐", "Bulk Sites", "Bulk", "profile"), + new("/folder-structure", "πŸ“", "Folder Structure", "Bulk", "profile"), + new("/user-audit", "πŸ‘€", "User Access Audit","Audit", "profile"), + new("/user-directory", "πŸ“–", "User Directory", "Audit", "profile"), + new("/templates", "πŸ“", "Templates", "Config", "profile"), + new("/profiles", "βš™οΈ", "Client Profiles", "Admin", "admin"), + new("/admin/users", "πŸ‘₯", "User Management", "Admin", "admin"), + new("/admin/audit", "πŸ“‹", "Audit Logs", "Admin", "admin"), + new("/settings", "πŸ”§", "Settings", "", "always"), + new("/account/change-password","πŸ”‘", "Change Password", "", "auth"), + }; + + private IEnumerable VisibleNavItems() + { + var filter = _navFilter?.Trim() ?? string.Empty; + return AllNavItems + .Where(i => i.Scope switch + { + "profile" => Session.HasProfile, + "admin" => UserContext.Role == UserRole.Admin, + "auth" => UserContext.IsAuthenticated, + _ => true + }) + .Where(i => filter.Length == 0 + || i.Label.Contains(filter, StringComparison.OrdinalIgnoreCase)); + } + + private void ClearFilter() => _navFilter = string.Empty; private string _credUsername = string.Empty; private SessionCredentialsModal? _credModal; @@ -155,6 +175,7 @@ { Session.ProfileChanged += OnProfileChanged; UserContext.Initialized += OnUserContextInitialized; + _dark = string.Equals(Session.Settings.Theme, "Dark", StringComparison.OrdinalIgnoreCase); } private void OnUserContextInitialized() => InvokeAsync(StateHasChanged); @@ -209,7 +230,20 @@ private async Task RefreshCredentialState() { - var tokens = await CredStore.GetAsync(); + var tokens = await CredStore.GetAsync(); + + // Session tokens are tenant-bound (refresh token issued for the profile's TenantId/ClientId). + // After switching profiles the stored tokens may belong to the *previous* tenant β€” treat those + // as stale: drop them and the cached CSOM contexts so we don't keep operating against the old + // profile, and so the connect prompt below fires for the new one. + if (tokens is not null && Session.CurrentProfile is { } profile && + !string.Equals(tokens.TenantId, profile.TenantId, StringComparison.OrdinalIgnoreCase)) + { + await CredStore.ClearAsync(); + await SessionManager.ClearAllAsync(); + tokens = null; + } + _hasCredentials = tokens is not null && !string.IsNullOrEmpty(tokens.RefreshToken); _credUsername = tokens?.UserPrincipalName ?? string.Empty; await InvokeAsync(StateHasChanged); @@ -234,8 +268,11 @@ { InvokeAsync(async () => { - StateHasChanged(); - // New profile selected β†’ prompt for credentials if none + // Re-evaluate credentials against the newly selected profile. This drops tokens + // left over from the previous profile (different tenant), so a switch never keeps + // operating on the old connection. + await RefreshCredentialState(); + // New profile selected and no valid credentials for it β†’ prompt to connect. if (Session.HasProfile && !_hasCredentials && _credModal is not null) await _credModal.ShowAsync(); }); @@ -252,6 +289,22 @@ private void ToggleSidebar() => _sidebarCollapsed = !_sidebarCollapsed; + private async Task ToggleTheme() + { + _dark = !_dark; + var theme = _dark ? "Dark" : "Light"; + var s = Session.Settings; + Session.UpdateSettings(new AppSettings + { + DataFolder = s.DataFolder, + Lang = s.Lang, + AutoTakeOwnership = s.AutoTakeOwnership, + Theme = theme, + MspLogo = s.MspLogo + }); + await JS.InvokeVoidAsync("sptb.setTheme", theme); + } + private static string RoleChipClass(UserRole role) => role switch { UserRole.Admin => "chip-red", diff --git a/Components/Pages/Account/ChangePassword.razor b/Components/Pages/Account/ChangePassword.razor new file mode 100644 index 0000000..41f91ae --- /dev/null +++ b/Components/Pages/Account/ChangePassword.razor @@ -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 + +

Change Password

+ +@if (!UserContext.IsAuthenticated) +{ +
You must be signed in.
+ return; +} + +@if (_user is null) +{ +

Loading…

+} +else if (_user.Provider != AuthProvider.Local) +{ +
+ Your account signs in with Microsoft (Entra). Manage its password in your Microsoft account. +
+} +else +{ + @if (!string.IsNullOrEmpty(_message)) + { +
@_message
+ } +
+ + + + + + + + + +
+ +
+
+} + +@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(), + $"Changed own password ({_user.Email})."); + _message = "Password changed."; + _isError = false; + _current = _new = _confirm = string.Empty; + } + else + { + _message = "Current password is incorrect."; + _isError = true; + } + } +} diff --git a/Components/Pages/Admin/AuditLogs.razor b/Components/Pages/Admin/AuditLogs.razor index aaccb3f..4a155fd 100644 --- a/Components/Pages/Admin/AuditLogs.razor +++ b/Components/Pages/Admin/AuditLogs.razor @@ -51,7 +51,7 @@ else @foreach (var e in _filtered) { - @e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss") + @e.Timestamp.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss") @e.UserDisplay
@e.UserEmail @e.UserRole @e.Action diff --git a/Components/Pages/Admin/UserManagement.razor b/Components/Pages/Admin/UserManagement.razor index c1364f4..3a3c706 100644 --- a/Components/Pages/Admin/UserManagement.razor +++ b/Components/Pages/Admin/UserManagement.razor @@ -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

User Management

-

Manage technician accounts and roles. Auto-provisioned on first OIDC login.

+

Manage technician accounts and roles. Entra users are auto-provisioned on first OIDC login; local users are created here.

@if (!UserContext.IsAuthenticated || UserContext.Role != UserRole.Admin) { @@ -19,9 +21,39 @@ @if (!string.IsNullOrEmpty(_message)) { -
@_message
+
@_message
} +
+

Create local user

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+ @if (_users.Count == 0) {
No users provisioned yet.
@@ -34,6 +66,7 @@ else User Email + Source Role Last Login Actions @@ -45,6 +78,11 @@ else @user.DisplayName @user.Email + + + @(user.Provider == AuthProvider.Local ? "Local" : "Entra") + + +
+ + +
+
+} + @code { private List _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(), + $"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(), + $"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(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(), + $"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(), + $"Removed {user.Provider} user {user.Email} ({user.DisplayName}), role {user.Role}."); _message = $"User {user.DisplayName} removed."; _isError = false; } diff --git a/Components/Pages/BulkMembers.razor b/Components/Pages/BulkMembers.razor index d003e4b..8eb5635 100644 --- a/Components/Pages/BulkMembers.razor +++ b/Components/Pages/BulkMembers.razor @@ -17,10 +17,7 @@ @if (UserContext.Role < UserRole.TechN1) { return; }
-
- - -
+
@@ -74,7 +71,7 @@ } @code { - private string _siteUrl = string.Empty; + private List _sites = new(); private List> _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(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); }); try { diff --git a/Components/Pages/Duplicates.razor b/Components/Pages/Duplicates.razor index d5a4d21..ea79253 100644 --- a/Components/Pages/Duplicates.razor +++ b/Components/Pages/Duplicates.razor @@ -7,6 +7,7 @@ @inject DuplicatesCsvExportService CsvExport @inject DuplicatesHtmlExportService HtmlExport @inject WebExportService WebExport +@inject IAuditService Audit @rendermode InteractiveServer

Duplicate Detection

@@ -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; } diff --git a/Components/Pages/FileTransfer.razor b/Components/Pages/FileTransfer.razor index 1328bb7..a3bf2c5 100644 --- a/Components/Pages/FileTransfer.razor +++ b/Components/Pages/FileTransfer.razor @@ -14,11 +14,8 @@
Source
-
-
- - -
+ +
@@ -32,11 +29,8 @@
Destination
-
-
- - -
+ +
@@ -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 _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(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, diff --git a/Components/Pages/FolderStructure.razor b/Components/Pages/FolderStructure.razor index 0e0176d..413d3e3 100644 --- a/Components/Pages/FolderStructure.razor +++ b/Components/Pages/FolderStructure.razor @@ -15,11 +15,8 @@ @if (UserContext.Role < UserRole.TechN1) { return; }
-
-
- - -
+ +
@@ -56,7 +53,8 @@ } @code { - private string _siteUrl = string.Empty, _libraryTitle = string.Empty; + private List _sites = new(); + private string _libraryTitle = string.Empty; private List> _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(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); }); try { diff --git a/Components/Pages/Permissions.razor b/Components/Pages/Permissions.razor index 42b13f4..a3762d6 100644 --- a/Components/Pages/Permissions.razor +++ b/Components/Pages/Permissions.razor @@ -7,6 +7,7 @@ @inject CsvExportService CsvExport @inject HtmlExportService HtmlExport @inject WebExportService WebExport +@inject IAuditService Audit @rendermode InteractiveServer

Permissions Audit

@@ -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; } diff --git a/Components/Pages/Search.razor b/Components/Pages/Search.razor index 8d9ea20..9b66d6e 100644 --- a/Components/Pages/Search.razor +++ b/Components/Pages/Search.razor @@ -7,6 +7,7 @@ @inject SearchCsvExportService CsvExport @inject SearchHtmlExportService HtmlExport @inject WebExportService WebExport +@inject IAuditService Audit @rendermode InteractiveServer

File Search

@@ -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; } diff --git a/Components/Pages/Storage.razor b/Components/Pages/Storage.razor index f70edb4..7c7569a 100644 --- a/Components/Pages/Storage.razor +++ b/Components/Pages/Storage.razor @@ -7,6 +7,7 @@ @inject StorageCsvExportService CsvExport @inject StorageHtmlExportService HtmlExport @inject WebExportService WebExport +@inject IAuditService Audit @rendermode InteractiveServer

Storage Metrics

@@ -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; } diff --git a/Components/Pages/Templates.razor b/Components/Pages/Templates.razor index 1d920b7..200094b 100644 --- a/Components/Pages/Templates.razor +++ b/Components/Pages/Templates.razor @@ -17,11 +17,8 @@
Capture Template
-
- - -
-
+ +
@@ -88,7 +85,8 @@
@code { - private string _captureUrl = string.Empty, _captureName = string.Empty; + private List _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(p => { _status = p.Message; InvokeAsync(StateHasChanged); }); try { diff --git a/Components/Pages/UserAccessAudit.razor b/Components/Pages/UserAccessAudit.razor index f2f3fb7..0b626ef 100644 --- a/Components/Pages/UserAccessAudit.razor +++ b/Components/Pages/UserAccessAudit.razor @@ -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

User Access Audit

@@ -14,16 +16,45 @@ @if (!Session.HasProfile) { return; }
-
-
- - -
-
- - +
+
+ +
+ +
+ + @if (_directoryUsers.Count > 0) + { +
+ + @_selectedEmails.Count selected +
+ + +
+
+ @foreach (var u in FilteredUsers.Take(500)) + { + var email = u.Mail ?? u.UserPrincipalName; + + } +
+ @if (FilteredUsers.Count() > 500) {
Showing first 500. Refine filter to narrow.
} + } + + +
+
@@ -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 _directoryUsers = new(); + private readonly HashSet _selectedEmails = new(StringComparer.OrdinalIgnoreCase); + private List _sites = new(); + + private IEnumerable 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(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(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; } diff --git a/Components/Pages/UserDirectory.razor b/Components/Pages/UserDirectory.razor index c7ff813..f604ed4 100644 --- a/Components/Pages/UserDirectory.razor +++ b/Components/Pages/UserDirectory.razor @@ -2,6 +2,7 @@ @attribute [Authorize] @inject IUserSessionService Session @inject IGraphUserDirectoryService GraphSvc +@inject IAuditService Audit @rendermode InteractiveServer

User Directory

@@ -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(), + $"{_users.Count} users; guests={_includeGuests}"); } catch (Exception ex) { _error = ex.Message; } finally { _running = false; await InvokeAsync(StateHasChanged); } diff --git a/Components/Pages/VersionCleanup.razor b/Components/Pages/VersionCleanup.razor index 9db5b46..14df99a 100644 --- a/Components/Pages/VersionCleanup.razor +++ b/Components/Pages/VersionCleanup.razor @@ -15,13 +15,8 @@ @if (UserContext.Role < UserRole.TechN1) { return; }
-
-
- - -
-
-
+ +
@@ -96,7 +91,7 @@ } @code { - private string _siteUrl = string.Empty; + private List _sites = new(); private int _keepLast = 5; private bool _keepFirst; private List _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(p => { _status = p.Message; _current = p.Current; _total = p.Total; InvokeAsync(StateHasChanged); }); try { diff --git a/Components/Shared/SitePicker.razor b/Components/Shared/SitePicker.razor index c933b3c..cf74739 100644 --- a/Components/Shared/SitePicker.razor +++ b/Components/Shared/SitePicker.razor @@ -3,7 +3,7 @@
- +
+ @if (!Single) + { + + } @SelectedSites.Count selected @@ -28,7 +31,14 @@ @foreach (var s in Filtered) { @@ -41,7 +51,7 @@ } else if (!_loading) { -
Click β€œLoad sites” to list the tenant’s SharePoint sites, then tick the ones to scan.
+
Click β€œLoad sites” to list the tenant’s SharePoint sites, then @(Single ? "pick one." : "tick the ones to scan.")
}
@@ -50,7 +60,9 @@ [Parameter] public List SelectedSites { get; set; } = new(); [Parameter] public EventCallback> SelectedSitesChanged { get; set; } [Parameter] public bool Disabled { get; set; } + [Parameter] public bool Single { get; set; } + private readonly string _radioName = "sp-" + Guid.NewGuid().ToString("N"); private List _all = new(); private string _filter = string.Empty; private bool _loading; @@ -91,6 +103,12 @@ await SelectedSitesChanged.InvokeAsync(SelectedSites); } + private async Task SelectSingle(SiteInfo s) + { + SelectedSites = new List { s }; + await SelectedSitesChanged.InvokeAsync(SelectedSites); + } + private async Task SelectAllFiltered() { foreach (var s in Filtered) diff --git a/Core/Models/AppUser.cs b/Core/Models/AppUser.cs index 71bd629..ab5eaea 100644 --- a/Core/Models/AppUser.cs +++ b/Core/Models/AppUser.cs @@ -6,6 +6,13 @@ public class AppUser public string Email { get; set; } = string.Empty; public string DisplayName { get; set; } = string.Empty; public UserRole Role { get; set; } = UserRole.TechN0; + + /// Identity source. Entra = OIDC-provisioned, Local = password-based account. + public AuthProvider Provider { get; set; } = AuthProvider.Entra; + + /// PasswordHasher output. Only set for users. + public string? PasswordHash { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset? LastLogin { get; set; } } diff --git a/Core/Models/AuthProvider.cs b/Core/Models/AuthProvider.cs new file mode 100644 index 0000000..ff5e6eb --- /dev/null +++ b/Core/Models/AuthProvider.cs @@ -0,0 +1,9 @@ +namespace SharepointToolbox.Web.Core.Models; + +public enum AuthProvider +{ + /// Microsoft Entra (OIDC) β€” auto-provisioned on login. + Entra = 0, + /// Local password account β€” created by an Admin. + Local = 1 +} diff --git a/Infrastructure/Auth/LoginPageRenderer.cs b/Infrastructure/Auth/LoginPageRenderer.cs new file mode 100644 index 0000000..f5ed4cd --- /dev/null +++ b/Infrastructure/Auth/LoginPageRenderer.cs @@ -0,0 +1,80 @@ +using System.Net; +using Microsoft.AspNetCore.Antiforgery; + +namespace SharepointToolbox.Web.Infrastructure.Auth; + +/// Renders the combined login page (Microsoft / Entra button + local credential form) +/// as a self-contained static HTML response. Lives outside the interactive Blazor circuit so +/// the POST handler can issue the auth cookie directly on the HTTP request. +public static class LoginPageRenderer +{ + public static string Build( + HttpContext ctx, + IAntiforgery antiforgery, + string? returnUrl, + bool showError, + bool showEntra = true, + bool showDevButton = false) + { + var tokens = antiforgery.GetAndStoreTokens(ctx); + var ru = WebUtility.HtmlEncode(returnUrl ?? "/"); + var afField = WebUtility.HtmlEncode(tokens.FormFieldName); + var afToken = WebUtility.HtmlEncode(tokens.RequestToken); + + var error = showError + ? "
Invalid email or password.
" + : string.Empty; + + var entraButton = showEntra + ? $"Sign in with Microsoft
or
" + : string.Empty; + + var devButton = showDevButton + ? $"
dev
Quick sign in as Dev Admin" + : string.Empty; + + return $$""" + + + + + + Sign in β€” SharePoint Toolbox + + + + + + + +"""; + } +} diff --git a/Program.cs b/Program.cs index e557626..33a2ae4 100644 --- a/Program.cs +++ b/Program.cs @@ -2,6 +2,8 @@ using System.Security.Claims; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.AspNetCore.Antiforgery; +using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Serilog; @@ -120,6 +122,7 @@ builder.Services.AddSingleton(new UserRepository(Path.Combine(dataFolder, "users builder.Services.AddSingleton(new AuditRepository(Path.Combine(dataFolder, "audit.jsonl"))); // ── Auth infrastructure ─────────────────────────────────────────────────────── +builder.Services.AddSingleton, PasswordHasher>(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -187,20 +190,32 @@ app.UseAuthorization(); app.UseAntiforgery(); // ── Login / Logout endpoints ────────────────────────────────────────────────── -if (app.Environment.IsDevelopment()) +var isDev = app.Environment.IsDevelopment(); + +// Combined login page. Dev: local form + "Quick sign in as Dev Admin" (no OIDC scheme registered). +// Prod: local form + "Sign in with Microsoft". +app.MapGet("/account/login", (HttpContext ctx, IAntiforgery antiforgery, string? returnUrl, bool? error) => { - app.MapGet("/account/login", async (HttpContext ctx, string? returnUrl, IUserService userService) => + var html = LoginPageRenderer.Build( + ctx, antiforgery, returnUrl, error == true, + showEntra: !isDev, + showDevButton: isDev); + return Results.Content(html, "text/html"); +}); + +if (isDev) +{ + // Dev shortcut: provision + sign in the hardcoded Dev Admin (first run = Admin). + app.MapGet("/account/login/dev", async (HttpContext ctx, string? returnUrl, IUserService userService) => { const string devEmail = "dev@local.test"; const string devName = "Dev Admin"; - // Provision the dev user in users.json (first run = Admin) var provisionPrincipal = new ClaimsPrincipal(new ClaimsIdentity( new[] { new Claim("preferred_username", devEmail), new Claim("name", devName) }, CookieAuthenticationDefaults.AuthenticationScheme)); var user = await userService.ProvisionAsync(provisionPrincipal); - // Sign in with full claims including app_role for HTTP endpoints var principal = new ClaimsPrincipal(new ClaimsIdentity( new Claim[] { new("preferred_username", devEmail), @@ -212,16 +227,11 @@ if (app.Environment.IsDevelopment()) await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal); ctx.Response.Redirect(string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl); }); - - app.MapGet("/account/logout", async (HttpContext ctx) => - { - await ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); - ctx.Response.Redirect("/"); - }); } else { - app.MapGet("/account/login", async (HttpContext ctx, string? returnUrl) => + // Microsoft / Entra OIDC challenge (the "Sign in with Microsoft" button). + app.MapGet("/account/login/entra", async (HttpContext ctx, string? returnUrl) => { var props = new AuthenticationProperties { @@ -229,14 +239,49 @@ else }; await ctx.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, props); }); +} - app.MapGet("/account/logout", async (HttpContext ctx) => - { - await ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); +// Local password sign-in β€” available in every environment. +app.MapPost("/account/local-login", async (HttpContext ctx, IAntiforgery antiforgery, IUserService userService) => +{ + try { await antiforgery.ValidateRequestAsync(ctx); } + catch (AntiforgeryValidationException) { return Results.BadRequest(); } + + var form = await ctx.Request.ReadFormAsync(); + var email = form["email"].ToString(); + var password = form["password"].ToString(); + var returnUrl = form["returnUrl"].ToString(); + var safeReturn = string.IsNullOrEmpty(returnUrl) || !returnUrl.StartsWith('/') ? "/" : returnUrl; + + var user = await userService.ValidateLocalCredentialsAsync(email, password); + if (user is null) + return Results.Redirect($"/account/login?error=true&returnUrl={Uri.EscapeDataString(safeReturn)}"); + + var principal = new ClaimsPrincipal(new ClaimsIdentity( + new Claim[] + { + new("preferred_username", user.Email), + new("name", user.DisplayName), + new("app_role", user.Role.ToString()), + new("auth_provider", nameof(AuthProvider.Local)), + }, + CookieAuthenticationDefaults.AuthenticationScheme)); + + await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal); + return Results.Redirect(safeReturn); +}); + +app.MapGet("/account/logout", async (HttpContext ctx) => +{ + // Local/dev accounts only hold the cookie; Entra accounts also have an OIDC session to end. + var isLocal = ctx.User.HasClaim("auth_provider", nameof(AuthProvider.Local)); + await ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + if (!isDev && !isLocal && ctx.User.Identity?.IsAuthenticated == true) await ctx.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme, new AuthenticationProperties { RedirectUri = "/" }); - }); -} + else + ctx.Response.Redirect("/"); +}); // ── OAuth2 connect endpoints ────────────────────────────────────────────────── app.MapOAuthEndpoints(); diff --git a/Services/Audit/AuditService.cs b/Services/Audit/AuditService.cs index 1d84198..274ef9c 100644 --- a/Services/Audit/AuditService.cs +++ b/Services/Audit/AuditService.cs @@ -42,7 +42,7 @@ public class AuditService : IAuditService foreach (var e in entries.OrderByDescending(x => x.Timestamp)) { sb.AppendLine(string.Join(",", - CsvEscape(e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")), + CsvEscape(e.Timestamp.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss")), CsvEscape(e.UserEmail), CsvEscape(e.UserDisplay), CsvEscape(e.UserRole.ToString()), diff --git a/Services/Auth/IUserService.cs b/Services/Auth/IUserService.cs index a48d288..1dbbb29 100644 --- a/Services/Auth/IUserService.cs +++ b/Services/Auth/IUserService.cs @@ -6,11 +6,26 @@ namespace SharepointToolbox.Web.Services.Auth; public interface IUserService { /// Auto-provision on first OIDC login; update LastLogin on subsequent logins. - /// First user ever becomes Admin automatically. + /// First user ever becomes Admin automatically. Tags the user as . Task ProvisionAsync(ClaimsPrincipal principal); Task GetByEmailAsync(string email); Task> GetAllAsync(); Task UpdateRoleAsync(string userId, UserRole role); Task DeleteAsync(string userId); + + /// Create a local password-based account. First user ever becomes Admin. + /// Email already in use. + Task CreateLocalUserAsync(string email, string displayName, UserRole role, string password); + + /// Validate local credentials. Returns the user and updates LastLogin on success; null otherwise. + /// Only matches accounts. + Task ValidateLocalCredentialsAsync(string email, string password); + + /// Admin reset β€” set a local user's password without knowing the current one. + Task SetPasswordAsync(string userId, string newPassword); + + /// Self-service β€” change own password after verifying the current one. + /// true if the current password matched and the change was saved. + Task ChangePasswordAsync(string userId, string currentPassword, string newPassword); } diff --git a/Services/Auth/UserService.cs b/Services/Auth/UserService.cs index c5b2a11..b289d2b 100644 --- a/Services/Auth/UserService.cs +++ b/Services/Auth/UserService.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using Microsoft.AspNetCore.Identity; using SharepointToolbox.Web.Core.Models; using SharepointToolbox.Web.Infrastructure.Persistence; @@ -7,8 +8,13 @@ namespace SharepointToolbox.Web.Services.Auth; public class UserService : IUserService { private readonly UserRepository _repo; + private readonly IPasswordHasher _hasher; - public UserService(UserRepository repo) { _repo = repo; } + public UserService(UserRepository repo, IPasswordHasher hasher) + { + _repo = repo; + _hasher = hasher; + } public async Task ProvisionAsync(ClaimsPrincipal principal) { @@ -38,6 +44,7 @@ public class UserService : IUserService Email = email, DisplayName = display, Role = role, + Provider = AuthProvider.Entra, CreatedAt = DateTimeOffset.UtcNow, LastLogin = DateTimeOffset.UtcNow }; @@ -59,4 +66,87 @@ public class UserService : IUserService } public Task DeleteAsync(string userId) => _repo.DeleteAsync(userId); + + public async Task CreateLocalUserAsync(string email, string displayName, UserRole role, string password) + { + email = email.Trim(); + if (string.IsNullOrWhiteSpace(email)) + throw new InvalidOperationException("Email is required."); + if (string.IsNullOrWhiteSpace(password)) + throw new InvalidOperationException("Password is required."); + + if (await _repo.FindByEmailAsync(email) is not null) + throw new InvalidOperationException($"A user with email '{email}' already exists."); + + // First user ever β†’ Admin; otherwise use the requested role + var all = await _repo.LoadAsync(); + var effectiveRole = all.Count == 0 ? UserRole.Admin : role; + + var user = new AppUser + { + Email = email, + DisplayName = string.IsNullOrWhiteSpace(displayName) ? email : displayName.Trim(), + Role = effectiveRole, + Provider = AuthProvider.Local, + CreatedAt = DateTimeOffset.UtcNow, + LastLogin = null + }; + user.PasswordHash = _hasher.HashPassword(user, password); + await _repo.UpsertAsync(user); + return user; + } + + public async Task ValidateLocalCredentialsAsync(string email, string password) + { + var user = await _repo.FindByEmailAsync(email); + if (user is null || user.Provider != AuthProvider.Local || string.IsNullOrEmpty(user.PasswordHash)) + return null; + + var result = _hasher.VerifyHashedPassword(user, user.PasswordHash, password); + if (result == PasswordVerificationResult.Failed) + return null; + + // Transparently upgrade the hash if the algorithm parameters changed + if (result == PasswordVerificationResult.SuccessRehashNeeded) + user.PasswordHash = _hasher.HashPassword(user, password); + + user.LastLogin = DateTimeOffset.UtcNow; + await _repo.UpsertAsync(user); + return user; + } + + public async Task SetPasswordAsync(string userId, string newPassword) + { + if (string.IsNullOrWhiteSpace(newPassword)) + throw new InvalidOperationException("Password is required."); + + var users = (await _repo.LoadAsync()).ToList(); + var user = users.FirstOrDefault(u => u.Id == userId) + ?? throw new KeyNotFoundException($"User {userId} not found."); + if (user.Provider != AuthProvider.Local) + throw new InvalidOperationException("Only local accounts have passwords."); + + user.PasswordHash = _hasher.HashPassword(user, newPassword); + await _repo.UpsertAsync(user); + } + + public async Task ChangePasswordAsync(string userId, string currentPassword, string newPassword) + { + if (string.IsNullOrWhiteSpace(newPassword)) + throw new InvalidOperationException("New password is required."); + + var users = (await _repo.LoadAsync()).ToList(); + var user = users.FirstOrDefault(u => u.Id == userId) + ?? throw new KeyNotFoundException($"User {userId} not found."); + if (user.Provider != AuthProvider.Local || string.IsNullOrEmpty(user.PasswordHash)) + return false; + + var result = _hasher.VerifyHashedPassword(user, user.PasswordHash, currentPassword); + if (result == PasswordVerificationResult.Failed) + return false; + + user.PasswordHash = _hasher.HashPassword(user, newPassword); + await _repo.UpsertAsync(user); + return true; + } } diff --git a/Services/Export/WebExportService.cs b/Services/Export/WebExportService.cs index b877847..2d33546 100644 --- a/Services/Export/WebExportService.cs +++ b/Services/Export/WebExportService.cs @@ -1,29 +1,41 @@ using System.Text; using Microsoft.AspNetCore.Components; using Microsoft.JSInterop; +using SharepointToolbox.Web.Services.Audit; +using SharepointToolbox.Web.Services.Session; namespace SharepointToolbox.Web.Services.Export; /// /// Triggers browser file downloads from Blazor Server components. /// Converts string export outputs to bytes and invokes JS download. +/// Every download is audit-logged as a report-export action. /// public class WebExportService { - private readonly IJSRuntime _js; + private readonly IJSRuntime _js; + private readonly IAuditService _audit; + private readonly IUserSessionService _session; - public WebExportService(IJSRuntime js) { _js = js; } + public WebExportService(IJSRuntime js, IAuditService audit, IUserSessionService session) + { + _js = js; + _audit = audit; + _session = session; + } public async Task DownloadCsvAsync(string content, string fileName) { var bytes = new UTF8Encoding(encoderShouldEmitUTF8Identifier: true).GetBytes(content); await _js.InvokeVoidAsync("sptb.downloadFile", fileName, "text/csv;charset=utf-8", Convert.ToBase64String(bytes)); + await LogExportAsync(fileName, bytes.Length); } public async Task DownloadHtmlAsync(string content, string fileName) { var bytes = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(content); await _js.InvokeVoidAsync("sptb.downloadFile", fileName, "text/html;charset=utf-8", Convert.ToBase64String(bytes)); + await LogExportAsync(fileName, bytes.Length); } /// @@ -33,5 +45,17 @@ public class WebExportService public async Task DownloadBytesAsync(byte[] content, string fileName, string mime) { await _js.InvokeVoidAsync("sptb.downloadFile", fileName, mime, Convert.ToBase64String(content)); + await LogExportAsync(fileName, content.Length); + } + + /// + /// Records the download as a "ReportExport" audit entry. The file name encodes + /// the report kind (search_, permissions_, storage_, …) and timestamp. + /// + private Task LogExportAsync(string fileName, int byteCount) + { + var client = _session.CurrentProfile?.Name ?? string.Empty; + var sizeKb = (byteCount / 1024.0).ToString("F1"); + return _audit.LogAsync("ReportExport", client, Array.Empty(), $"{fileName} ({sizeKb} KB)"); } } diff --git a/wwwroot/app.css b/wwwroot/app.css index 63840eb..dc03f1e 100644 --- a/wwwroot/app.css +++ b/wwwroot/app.css @@ -1,15 +1,22 @@ :root { - --sidebar-width: 220px; - --sidebar-collapsed-width: 54px; - --bg: #f5f5f5; - --sidebar-bg: #1a1a2e; - --sidebar-text: #e0e0e0; - --sidebar-hover: #2d2d4e; - --sidebar-active: #0078d4; + --sidebar-width: 248px; + --sidebar-collapsed-width: 78px; + --bg: #eef0f7; + --page-bg: #eef0f7; + --sidebar-bg: #ffffff; + --sidebar-text: #3f4254; + --sidebar-muted: #8a8d9b; + --sidebar-hover: #f2f3f9; + --sidebar-accent: #5b5bd6; + --sidebar-active: #5b5bd6; --card-bg: #fff; - --border: #e0e0e0; - --accent: #0078d4; - --accent-dark: #005a9e; + --surface-hover: #f2f3f9; + --th-bg: #f4f5fb; + --input-bg: #fff; + --border: #e6e7f0; + --accent: #5b5bd6; + --accent-dark: #4a4ac0; + --accent-soft: rgba(91,91,214,.12); --danger: #d13438; --success: #107c10; --warn: #797673; @@ -17,6 +24,12 @@ --text-muted: #605e5c; --surface-2: #2d2d4e; --font: 'Segoe UI', system-ui, sans-serif; + /* shape + depth β€” match sidebar */ + --radius-lg: 20px; + --radius-md: 12px; + --radius-sm: 10px; + --shadow-card: 0 10px 34px rgba(30,30,70,.10); + --shadow-soft: 0 6px 16px rgba(91,91,214,.22); } *, *::before, *::after { box-sizing: border-box; } @@ -27,12 +40,15 @@ body { } /* ── Layout ── */ -.app-layout { display: flex; height: 100vh; overflow: hidden; } +.app-layout { display: flex; height: 100vh; overflow: hidden; background: var(--page-bg); } .sidebar { width: var(--sidebar-width); min-width: var(--sidebar-width); background: var(--sidebar-bg); color: var(--sidebar-text); display: flex; flex-direction: column; + margin: 14px 0 14px 14px; + border-radius: 20px; + box-shadow: 0 10px 34px rgba(30,30,70,.10); transition: width 0.2s, min-width 0.2s; overflow: hidden; } @@ -40,54 +56,143 @@ body { .sidebar.collapsed .nav-label, .sidebar.collapsed .profile-name, .sidebar.collapsed .logo-text, +.sidebar.collapsed .logo, +.sidebar.collapsed .switch, .sidebar.collapsed .nav-divider { display: none; } +.sidebar.collapsed .sidebar-header { justify-content: center; } +.sidebar.collapsed .nav-item, +.sidebar.collapsed .nav-search { justify-content: center; gap: 0; } .sidebar-header { display: flex; align-items: center; justify-content: space-between; - padding: 14px 12px; border-bottom: 1px solid rgba(255,255,255,.1); - flex-shrink: 0; + padding: 18px 16px 14px; flex-shrink: 0; } -.logo-text { font-weight: 700; font-size: 15px; color: #fff; white-space: nowrap; } -.toggle-btn { background: none; border: none; color: var(--sidebar-text); cursor: pointer; font-size: 18px; padding: 2px 4px; } +.logo { display: flex; align-items: center; gap: 11px; overflow: hidden; } +.logo-mark { + width: 38px; height: 38px; flex-shrink: 0; border-radius: 11px; + background: linear-gradient(135deg, #6d6df0, #5b5bd6); + color: #fff; font-weight: 700; font-size: 14px; letter-spacing: .5px; + display: flex; align-items: center; justify-content: center; +} +.logo-text { font-weight: 700; font-size: 16px; color: var(--sidebar-text); white-space: nowrap; } +.toggle-btn { + width: 26px; height: 26px; flex-shrink: 0; border-radius: 50%; border: none; + background: var(--sidebar-accent); color: #fff; cursor: pointer; + font-size: 15px; line-height: 1; display: flex; align-items: center; justify-content: center; + transition: transform .2s; +} +.toggle-btn.collapsed { transform: rotate(180deg); } .profile-badge { display: flex; align-items: center; gap: 8px; - padding: 8px 12px; background: rgba(255,255,255,.07); - border-bottom: 1px solid rgba(255,255,255,.08); - font-size: 12px; + margin: 0 12px 8px; padding: 8px 11px; border-radius: 11px; + background: var(--sidebar-hover); font-size: 12px; } .profile-icon { font-size: 16px; } .profile-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.nav-menu { flex: 1; overflow-y: auto; padding: 8px 0; } -.nav-divider { padding: 12px 12px 4px; font-size: 10px; text-transform: uppercase; letter-spacing: .8px; color: rgba(255,255,255,.4); } +.identity-badge { + display: flex; flex-direction: column; + margin: 0 12px 10px; padding: 11px 12px; + background: var(--sidebar-hover); border-radius: 13px; + overflow: hidden; +} +.identity-name { font-size: 12.5px; font-weight: 600; color: var(--sidebar-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.identity-email { font-size: 11px; color: var(--sidebar-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.sidebar.collapsed .identity-badge { display: none; } + +.nav-search { + display: flex; align-items: center; gap: 10px; + margin: 2px 12px 8px; padding: 9px 12px; border-radius: 12px; + background: var(--sidebar-hover); color: var(--sidebar-muted); + font-size: 13.5px; white-space: nowrap; +} +.nav-search-input { + flex: 1; min-width: 0; border: none; background: none; outline: none; + color: var(--sidebar-text); font-family: inherit; font-size: 13.5px; +} +.nav-search-input::placeholder { color: var(--sidebar-muted); } +.nav-search-clear { + border: none; background: none; cursor: pointer; padding: 0 2px; + color: var(--sidebar-muted); font-size: 12px; line-height: 1; +} +.nav-search-clear:hover { color: var(--sidebar-text); } +.sidebar.collapsed .nav-search-input, +.sidebar.collapsed .nav-search-clear { display: none; } +.nav-empty { padding: 12px; font-size: 12px; color: var(--sidebar-muted); text-align: center; } + +.nav-menu { flex: 1; overflow-y: auto; padding: 2px 12px 8px; } +.nav-divider { padding: 14px 8px 6px; font-size: 10px; text-transform: uppercase; letter-spacing: .8px; color: var(--sidebar-muted); } .nav-item { - display: flex; align-items: center; gap: 10px; - padding: 9px 14px; color: var(--sidebar-text); text-decoration: none; - font-size: 13.5px; border-left: 3px solid transparent; - transition: background 0.15s; - white-space: nowrap; + display: flex; align-items: center; gap: 12px; width: 100%; + padding: 10px 12px; margin: 2px 0; border-radius: 12px; + color: var(--sidebar-text); text-decoration: none; + font-size: 13.5px; white-space: nowrap; + border: none; background: none; cursor: pointer; font-family: inherit; text-align: left; + transition: background 0.15s, color 0.15s, box-shadow 0.15s; } .nav-item:hover { background: var(--sidebar-hover); } -.nav-item.active { background: rgba(0,120,212,.2); border-left-color: var(--accent); color: #fff; } +.nav-item.active { + background: var(--sidebar-accent); color: #fff; + box-shadow: 0 6px 16px rgba(91,91,214,.35); +} .nav-icon { font-size: 16px; min-width: 22px; text-align: center; } +.nav-label { overflow: hidden; text-overflow: ellipsis; } + +.sidebar-footer { + flex-shrink: 0; padding: 8px 12px 14px; margin-top: 4px; + border-top: 1px solid var(--sidebar-hover); +} +.theme-toggle { color: var(--sidebar-text); } +.switch { + margin-left: auto; width: 38px; height: 20px; border-radius: 20px; + background: #cfd2e0; position: relative; flex-shrink: 0; transition: background .15s; +} +.switch::after { + content: ''; position: absolute; top: 2px; left: 2px; + width: 16px; height: 16px; border-radius: 50%; background: #fff; + box-shadow: 0 1px 2px rgba(0,0,0,.2); transition: left .15s; +} +.switch.on { background: var(--sidebar-accent); } +.switch.on::after { left: 20px; } .content { flex: 1; overflow-y: auto; padding: 24px 28px; } +/* ── Sidebar dark variant ── */ +[data-theme="dark"] { + --page-bg: #15161c; + --bg: #15161c; + --sidebar-bg: #1f2128; + --sidebar-text: #e7e8ee; + --sidebar-muted: #9a9db0; + --sidebar-hover: #2a2c36; + /* content surfaces */ + --card-bg: #1f2128; + --surface-hover: #2a2c36; + --th-bg: #262834; + --input-bg: #262834; + --border: #313442; + --text: #e7e8ee; + --text-muted: #9a9db0; + --shadow-card: 0 10px 34px rgba(0,0,0,.35); +} +[data-theme="dark"] .nav-search:hover { background: #32343f; } +[data-theme="dark"] .switch { background: #3a3d4a; } + /* ── Cards ── */ -.card { background: var(--card-bg); border-radius: 6px; border: 1px solid var(--border); padding: 20px; margin-bottom: 16px; } -.card-title { font-size: 16px; font-weight: 600; margin: 0 0 12px 0; color: var(--text); } +.card { background: var(--card-bg); border-radius: var(--radius-lg); border: none; box-shadow: var(--shadow-card); padding: 22px 24px; margin-bottom: 16px; } +.card-title { font-size: 16px; font-weight: 700; margin: 0 0 12px 0; color: var(--text); } /* ── Forms ── */ .form-group { margin-bottom: 14px; } .form-label { display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px; color: var(--text-muted); text-transform: uppercase; letter-spacing: .4px; } .form-input, .form-select, .form-textarea { - width: 100%; padding: 8px 10px; border: 1px solid #ccc; border-radius: 4px; - font-size: 14px; font-family: var(--font); background: #fff; - transition: border-color 0.15s; + width: 100%; padding: 9px 12px; border: 1px solid var(--border); border-radius: var(--radius-md); + font-size: 14px; font-family: var(--font); background: var(--input-bg); color: var(--text); + transition: border-color 0.15s, box-shadow 0.15s; } -.form-input:focus, .form-select:focus, .form-textarea:focus { outline: none; border-color: var(--accent); } +.form-input:focus, .form-select:focus, .form-textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } .form-textarea { min-height: 80px; resize: vertical; } .form-row { display: flex; gap: 12px; flex-wrap: wrap; } .form-row .form-group { flex: 1; min-width: 180px; } @@ -95,35 +200,51 @@ body { /* ── Buttons ── */ .btn { display: inline-flex; align-items: center; gap: 6px; - padding: 8px 16px; border-radius: 4px; cursor: pointer; - font-size: 14px; font-family: var(--font); font-weight: 500; - border: 1px solid transparent; transition: background 0.15s, opacity 0.15s; + padding: 9px 18px; border-radius: var(--radius-md); cursor: pointer; + font-size: 14px; font-family: var(--font); font-weight: 600; + border: 1px solid transparent; transition: background 0.15s, box-shadow 0.15s, opacity 0.15s; white-space: nowrap; } .btn:disabled { opacity: .5; cursor: not-allowed; } -.btn-primary { background: var(--accent); color: #fff; border-color: var(--accent-dark); } -.btn-primary:hover:not(:disabled) { background: var(--accent-dark); } -.btn-secondary { background: #fff; color: var(--text); border-color: #ccc; } -.btn-secondary:hover:not(:disabled) { background: #f0f0f0; } -.btn-danger { background: var(--danger); color: #fff; border-color: #a4262c; } -.btn-danger:hover:not(:disabled) { background: #a4262c; } -.btn-sm { padding: 5px 10px; font-size: 12px; } +.btn-primary { background: var(--accent); color: #fff; border-color: transparent; } +.btn-primary:hover:not(:disabled) { background: var(--accent-dark); box-shadow: var(--shadow-soft); } +.btn-secondary { background: var(--card-bg); color: var(--text); border-color: var(--border); } +.btn-secondary:hover:not(:disabled) { background: var(--surface-hover); } +.btn-danger { background: var(--danger); color: #fff; border-color: transparent; } +.btn-danger:hover:not(:disabled) { background: #a4262c; box-shadow: 0 6px 16px rgba(209,52,56,.28); } +.btn-sm { padding: 5px 12px; font-size: 12px; border-radius: var(--radius-sm); } +.btn-link { background: none; border-color: transparent; color: var(--accent); padding: 5px 8px; } +.btn-link:hover:not(:disabled) { text-decoration: underline; } + +/* ── User multi-select list ── */ +.user-select-list { + margin-top: 8px; max-height: 280px; overflow-y: auto; + border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--card-bg); +} +.user-select-row { + display: flex; align-items: center; gap: 10px; + padding: 7px 12px; border-bottom: 1px solid var(--border); cursor: pointer; font-weight: normal; +} +.user-select-row:last-child { border-bottom: none; } +.user-select-row:hover { background: var(--surface-hover); } +.user-select-name { font-weight: 500; } /* ── Progress ── */ -.progress-bar { height: 6px; background: #e0e0e0; border-radius: 3px; overflow: hidden; margin: 8px 0; } +.progress-bar { height: 6px; background: var(--surface-hover); border-radius: 3px; overflow: hidden; margin: 8px 0; } .progress-fill { height: 100%; background: var(--accent); border-radius: 3px; transition: width 0.3s; } .progress-msg { font-size: 12px; color: var(--text-muted); margin-bottom: 4px; } /* ── Tables ── */ -.data-table-wrap { overflow-x: auto; } +.data-table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: var(--radius-md); } .data-table { width: 100%; border-collapse: collapse; font-size: 13px; } -.data-table th { background: #f0f0f0; padding: 8px 12px; text-align: left; font-weight: 600; border-bottom: 2px solid var(--border); white-space: nowrap; } -.data-table td { padding: 6px 12px; border-bottom: 1px solid var(--border); word-break: break-word; } -.data-table tr:hover td { background: #f7f9fd; } +.data-table th { background: var(--th-bg); padding: 10px 12px; text-align: left; font-weight: 700; border-bottom: 1px solid var(--border); white-space: nowrap; color: var(--text-muted); } +.data-table td { padding: 8px 12px; border-bottom: 1px solid var(--border); word-break: break-word; } +.data-table tr:last-child td { border-bottom: none; } +.data-table tr:hover td { background: var(--surface-hover); } .data-table .num { text-align: right; font-variant-numeric: tabular-nums; } /* ── Alerts ── */ -.alert { padding: 10px 14px; border-radius: 4px; margin: 8px 0; font-size: 13px; } +.alert { padding: 11px 15px; border-radius: var(--radius-md); margin: 8px 0; font-size: 13px; } .alert-error { background: #fde7e9; border: 1px solid #f4abab; color: #831111; } .alert-success { background: #dff6dd; border: 1px solid #92c47a; color: #215732; } .alert-info { background: #e8f4fd; border: 1px solid #84bae3; color: #1b4b72; } @@ -148,10 +269,11 @@ body { .count-badge { background: var(--accent); color: #fff; border-radius: 12px; padding: 1px 8px; font-size: 11px; font-weight: 700; } /* ── Site picker ── */ -.site-list { max-height: 300px; overflow-y: auto; border: 1px solid var(--border); border-radius: 4px; } -.site-item { display: flex; align-items: center; gap: 8px; padding: 8px 10px; cursor: pointer; border-bottom: 1px solid var(--border); font-size: 13px; } -.site-item:hover { background: #f5f5f5; } -.site-item.selected { background: #e8f1fb; } +.site-list { max-height: 300px; overflow-y: auto; border: 1px solid var(--border); border-radius: var(--radius-md); } +.site-item { display: flex; align-items: center; gap: 8px; padding: 9px 12px; cursor: pointer; border-bottom: 1px solid var(--border); font-size: 13px; } +.site-item:last-child { border-bottom: none; } +.site-item:hover { background: var(--surface-hover); } +.site-item.selected { background: var(--accent-soft); } /* ── CSV validation table ── */ .val-valid td { background: #f0fff4; } @@ -171,8 +293,8 @@ body { animation: modal-fade .15s ease-out; } .modal-dialog { - background: var(--card-bg); border-radius: 8px; - box-shadow: 0 10px 40px rgba(0, 0, 0, .25); + background: var(--card-bg); border-radius: var(--radius-lg); + box-shadow: 0 20px 60px rgba(30, 30, 70, .28); width: 100%; max-width: 440px; max-height: 90vh; overflow-y: auto; padding: 24px; animation: modal-rise .15s ease-out; @@ -198,7 +320,7 @@ body { /* ── Feature cards (Home) ── */ .feature-card { cursor: pointer; transition: box-shadow .15s, transform .15s; } -.feature-card:hover { box-shadow: 0 2px 8px rgba(0, 120, 212, .2); transform: translateY(-1px); } +.feature-card:hover { box-shadow: 0 14px 36px rgba(91, 91, 214, .22); transform: translateY(-2px); } /* ── Theme (light-only palette; System resolves via JS) ── */ [data-theme="light"] { color-scheme: light; }