Merge branch 'main' of https://git.azuze.fr/kawa/SharepointToolbox-Web
This commit is contained in:
@@ -18,18 +18,21 @@
|
||||
<div class="app-layout">
|
||||
<aside class="sidebar @(_sidebarCollapsed ? "collapsed" : "")">
|
||||
<div class="sidebar-header">
|
||||
<div class="logo">
|
||||
<span class="logo-mark">SP</span>
|
||||
<span class="logo-text">SP Toolbox</span>
|
||||
<button class="toggle-btn" @onclick="ToggleSidebar">☰</button>
|
||||
</div>
|
||||
<button class="toggle-btn @(_sidebarCollapsed ? "collapsed" : "")" @onclick="ToggleSidebar" title="Toggle sidebar">›</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">
|
||||
<div class="identity-badge">
|
||||
<div class="identity-name">
|
||||
@UserContext.DisplayName
|
||||
</div>
|
||||
<div style="font-size:11px;color:var(--text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
|
||||
<div class="identity-email">
|
||||
@UserContext.Email
|
||||
</div>
|
||||
<div style="margin-top:4px">
|
||||
@@ -55,80 +58,55 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="nav-search">
|
||||
<span class="nav-icon">🔍</span>
|
||||
<input type="text" class="nav-search-input" placeholder="Search…"
|
||||
@bind="_navFilter" @bind:event="oninput" />
|
||||
@if (!string.IsNullOrEmpty(_navFilter))
|
||||
{
|
||||
<button class="nav-search-clear" @onclick="ClearFilter" title="Clear">✕</button>
|
||||
}
|
||||
</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)
|
||||
@{
|
||||
string? lastSection = null;
|
||||
var items = VisibleNavItems().ToList();
|
||||
}
|
||||
@foreach (var item in items)
|
||||
{
|
||||
<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>
|
||||
if (item.Section != lastSection)
|
||||
{
|
||||
lastSection = item.Section;
|
||||
if (!string.IsNullOrEmpty(item.Section))
|
||||
{
|
||||
<div class="nav-divider">@item.Section</div>
|
||||
}
|
||||
}
|
||||
<NavLink href="@item.Href" Match="@(item.Href == "/" ? NavLinkMatch.All : NavLinkMatch.Prefix)" class="nav-item">
|
||||
<span class="nav-icon">@item.Icon</span><span class="nav-label">@item.Label</span>
|
||||
</NavLink>
|
||||
}
|
||||
|
||||
@* Admin-only section *@
|
||||
@if (UserContext.Role == UserRole.Admin)
|
||||
@if (items.Count == 0)
|
||||
{
|
||||
<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>
|
||||
<div class="nav-empty">No match</div>
|
||||
}
|
||||
</nav>
|
||||
|
||||
<NavLink href="/settings" class="nav-item">
|
||||
<span class="nav-icon">🔧</span><span class="nav-label">Settings</span>
|
||||
</NavLink>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<a href="/account/logout" class="nav-item" style="color:var(--text-muted)">
|
||||
<a href="/account/logout" class="nav-item">
|
||||
<span class="nav-icon">🚪</span><span class="nav-label">Logout</span>
|
||||
</a>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
</nav>
|
||||
<button class="nav-item theme-toggle" @onclick="ToggleTheme">
|
||||
<span class="nav-icon">🌙</span>
|
||||
<span class="nav-label">@(_dark ? "Light Mode" : "Dark Mode")</span>
|
||||
<span class="switch @(_dark ? "on" : "")"></span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="content">
|
||||
@@ -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<NavItem> 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);
|
||||
@@ -210,6 +231,19 @@
|
||||
private async Task RefreshCredentialState()
|
||||
{
|
||||
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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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 bob@contoso.com" 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>
|
||||
<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>
|
||||
|
||||
@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 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; }
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="site-picker">
|
||||
<div class="flex-row" style="gap:8px;align-items:flex-end">
|
||||
<div class="form-group" style="flex:1">
|
||||
<label class="form-label">Sites</label>
|
||||
<label class="form-label">@(Single ? "Site" : "Sites")</label>
|
||||
<input class="form-input" @bind="_filter" @bind:event="oninput" placeholder="Filter loaded sites by name or URL…" />
|
||||
</div>
|
||||
<button class="btn btn-secondary" @onclick="LoadSites" disabled="@_loading">
|
||||
@@ -19,7 +19,10 @@
|
||||
@if (_all.Count > 0)
|
||||
{
|
||||
<div class="flex-row mt-8" style="gap:12px;align-items:center">
|
||||
@if (!Single)
|
||||
{
|
||||
<button class="btn btn-link btn-sm" @onclick="SelectAllFiltered">Select all (@Filtered.Count())</button>
|
||||
}
|
||||
<button class="btn btn-link btn-sm" @onclick="ClearSelection">Clear</button>
|
||||
<span class="spacer"></span>
|
||||
<span class="count-badge">@SelectedSites.Count selected</span>
|
||||
@@ -28,7 +31,14 @@
|
||||
@foreach (var s in Filtered)
|
||||
{
|
||||
<label style="display:flex;align-items:center;gap:8px;padding:3px 6px;cursor:pointer">
|
||||
@if (Single)
|
||||
{
|
||||
<input type="radio" name="@_radioName" checked="@IsSelected(s)" @onchange="e => SelectSingle(s)" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<input type="checkbox" checked="@IsSelected(s)" @onchange="e => Toggle(s, (bool)(e.Value ?? false))" />
|
||||
}
|
||||
<span>@s.Title</span>
|
||||
<span class="text-muted" style="font-size:11px">@s.Url</span>
|
||||
</label>
|
||||
@@ -41,7 +51,7 @@
|
||||
}
|
||||
else if (!_loading)
|
||||
{
|
||||
<div class="text-muted mt-8" style="font-size:12px">Click “Load sites” to list the tenant’s SharePoint sites, then tick the ones to scan.</div>
|
||||
<div class="text-muted mt-8" style="font-size:12px">Click “Load sites” to list the tenant’s SharePoint sites, then @(Single ? "pick one." : "tick the ones to scan.")</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -50,7 +60,9 @@
|
||||
[Parameter] public List<SiteInfo> SelectedSites { get; set; } = new();
|
||||
[Parameter] public EventCallback<List<SiteInfo>> 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<SiteInfo> _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<SiteInfo> { s };
|
||||
await SelectedSitesChanged.InvokeAsync(SelectedSites);
|
||||
}
|
||||
|
||||
private async Task SelectAllFiltered()
|
||||
{
|
||||
foreach (var s in Filtered)
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>Identity source. Entra = OIDC-provisioned, Local = password-based account.</summary>
|
||||
public AuthProvider Provider { get; set; } = AuthProvider.Entra;
|
||||
|
||||
/// <summary>PasswordHasher output. Only set for <see cref="AuthProvider.Local"/> users.</summary>
|
||||
public string? PasswordHash { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset? LastLogin { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace SharepointToolbox.Web.Core.Models;
|
||||
|
||||
public enum AuthProvider
|
||||
{
|
||||
/// <summary>Microsoft Entra (OIDC) — auto-provisioned on login.</summary>
|
||||
Entra = 0,
|
||||
/// <summary>Local password account — created by an Admin.</summary>
|
||||
Local = 1
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.Antiforgery;
|
||||
|
||||
namespace SharepointToolbox.Web.Infrastructure.Auth;
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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
|
||||
? "<div class=\"alert alert-error\">Invalid email or password.</div>"
|
||||
: string.Empty;
|
||||
|
||||
var entraButton = showEntra
|
||||
? $"<a class=\"btn btn-secondary btn-block\" href=\"/account/login/entra?returnUrl={ru}\">Sign in with Microsoft</a><div class=\"login-divider\">or</div>"
|
||||
: string.Empty;
|
||||
|
||||
var devButton = showDevButton
|
||||
? $"<div class=\"login-divider\">dev</div><a class=\"btn btn-secondary btn-block\" href=\"/account/login/dev?returnUrl={ru}\">Quick sign in as Dev Admin</a>"
|
||||
: string.Empty;
|
||||
|
||||
return $$"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Sign in — SharePoint Toolbox</title>
|
||||
<link rel="stylesheet" href="/app.css" />
|
||||
<style>
|
||||
body { display:flex; align-items:center; justify-content:center; min-height:100vh; margin:0; background:var(--bg); }
|
||||
.login-card { width:360px; max-width:92vw; }
|
||||
.login-card h1 { font-size:20px; margin:0 0 4px; color:var(--text); }
|
||||
.login-card .sub { font-size:13px; color:var(--text-muted); margin:0 0 18px; }
|
||||
.login-divider { display:flex; align-items:center; gap:10px; color:var(--text-muted); font-size:12px; margin:18px 0; }
|
||||
.login-divider::before, .login-divider::after { content:""; flex:1; height:1px; background:var(--border); }
|
||||
.field { margin-bottom:12px; }
|
||||
.btn-block { width:100%; justify-content:center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card login-card">
|
||||
<h1>SharePoint Toolbox</h1>
|
||||
<p class="sub">Sign in to continue</p>
|
||||
{{error}}
|
||||
{{entraButton}}
|
||||
<form method="post" action="/account/local-login">
|
||||
<input type="hidden" name="returnUrl" value="{{ru}}" />
|
||||
<input type="hidden" name="{{afField}}" value="{{afToken}}" />
|
||||
<div class="field">
|
||||
<label class="form-label" for="email">Email</label>
|
||||
<input class="form-input" id="email" name="email" type="email" autocomplete="username" required autofocus />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="form-label" for="password">Password</label>
|
||||
<input class="form-input" id="password" name="password" type="password" autocomplete="current-password" required />
|
||||
</div>
|
||||
<button class="btn btn-primary btn-block" type="submit">Sign in</button>
|
||||
</form>
|
||||
{{devButton}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
""";
|
||||
}
|
||||
}
|
||||
+57
-12
@@ -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<IPasswordHasher<AppUser>, PasswordHasher<AppUser>>();
|
||||
builder.Services.AddSingleton<IUserService, UserService>();
|
||||
builder.Services.AddSingleton<IOAuthFlowCache, OAuthFlowCache>();
|
||||
builder.Services.AddSingleton<IEntraDeviceCodeFlow, EntraDeviceCodeFlow>();
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -6,11 +6,26 @@ namespace SharepointToolbox.Web.Services.Auth;
|
||||
public interface IUserService
|
||||
{
|
||||
/// <summary>Auto-provision on first OIDC login; update LastLogin on subsequent logins.
|
||||
/// First user ever becomes Admin automatically.</summary>
|
||||
/// First user ever becomes Admin automatically. Tags the user as <see cref="AuthProvider.Entra"/>.</summary>
|
||||
Task<AppUser> ProvisionAsync(ClaimsPrincipal principal);
|
||||
|
||||
Task<AppUser?> GetByEmailAsync(string email);
|
||||
Task<IReadOnlyList<AppUser>> GetAllAsync();
|
||||
Task UpdateRoleAsync(string userId, UserRole role);
|
||||
Task DeleteAsync(string userId);
|
||||
|
||||
/// <summary>Create a local password-based account. First user ever becomes Admin.</summary>
|
||||
/// <exception cref="InvalidOperationException">Email already in use.</exception>
|
||||
Task<AppUser> CreateLocalUserAsync(string email, string displayName, UserRole role, string password);
|
||||
|
||||
/// <summary>Validate local credentials. Returns the user and updates LastLogin on success; null otherwise.
|
||||
/// Only matches <see cref="AuthProvider.Local"/> accounts.</summary>
|
||||
Task<AppUser?> ValidateLocalCredentialsAsync(string email, string password);
|
||||
|
||||
/// <summary>Admin reset — set a local user's password without knowing the current one.</summary>
|
||||
Task SetPasswordAsync(string userId, string newPassword);
|
||||
|
||||
/// <summary>Self-service — change own password after verifying the current one.</summary>
|
||||
/// <returns>true if the current password matched and the change was saved.</returns>
|
||||
Task<bool> ChangePasswordAsync(string userId, string currentPassword, string newPassword);
|
||||
}
|
||||
|
||||
@@ -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<AppUser> _hasher;
|
||||
|
||||
public UserService(UserRepository repo) { _repo = repo; }
|
||||
public UserService(UserRepository repo, IPasswordHasher<AppUser> hasher)
|
||||
{
|
||||
_repo = repo;
|
||||
_hasher = hasher;
|
||||
}
|
||||
|
||||
public async Task<AppUser> 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<AppUser> 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<AppUser?> 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<bool> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class WebExportService
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records the download as a "ReportExport" audit entry. The file name encodes
|
||||
/// the report kind (search_, permissions_, storage_, …) and timestamp.
|
||||
/// </summary>
|
||||
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<string>(), $"{fileName} ({sizeKb} KB)");
|
||||
}
|
||||
}
|
||||
|
||||
+177
-55
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user