Merge pull request 'User profile & admin users page redesign (#72)' (#73) from feature/user-pages-redesign into develop

Reviewed-on: #73
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
This commit was merged in pull request #73.
This commit is contained in:
2026-07-10 09:03:47 +00:00
6 changed files with 628 additions and 421 deletions

View File

@@ -45,10 +45,10 @@
<i class="bi bi-search-heart"></i>
</button>
</div>
<span class="text-nowrap small">@loginService.LoggedUser?.Username</span>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-light" type="button" @onclick="OnProfileClick">
<i class="bi bi-person-circle"></i>
<button class="btn btn-outline-light d-flex align-items-center gap-1" type="button" @onclick="OnProfileClick">
<div class="nav-avatar">@(loginService.LoggedUser?.Username?.Length > 0 ? loginService.LoggedUser.Username[..1].ToUpper() : "?")</div>
<span class="small">@loginService.LoggedUser?.Username</span>
</button>
<button class="btn btn-outline-light dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown"></button>
<ul class="dropdown-menu dropdown-menu-end">

View File

@@ -14,3 +14,17 @@
height: 2.5rem;
align-items: center;
}
.nav-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--bs-primary);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 700;
flex-shrink: 0;
}

View File

@@ -6,253 +6,284 @@
@inject NavigationManager NavigationManager
@inject LoginService LoginService
@inject UserService UserService
@inject IJSRuntime JSRuntime
<PageTitle>Users</PageTitle>
@if (_isLoading) {
<LoadSpinner/>
<LoadSpinner />
} else if (_users == null) {
<div class="alert alert-danger">Failed to load users.</div>
} else {
<div class="d-flex align-items-center justify-content-between mb-3">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
<h4 class="m-0"><i class="bi bi-people-fill"></i> Users</h4>
<button class="btn btn-outline-primary btn-sm" @onclick="ToggleCreateForm">
<i class="bi bi-person-plus"></i> Create user
<button class="btn btn-outline-primary btn-sm" @onclick="OpenCreateModal">
<i class="bi bi-person-plus"></i> Create User
</button>
</div>
@if (_createError != null) {
<div class="alert alert-danger py-1">@_createError</div>
}
@if (_error != null) {
@if (!string.IsNullOrEmpty(_error)) {
<div class="alert alert-danger py-1 alert-dismissible fade show">
@_error
<button type="button" class="btn-close" @onclick="ClearError"></button>
<button type="button" class="btn-close" @onclick="() => _error = null"></button>
</div>
}
@if (_successMessage != null) {
<div class="alert alert-success alert-dismissible fade show py-1" role="alert">
@if (!string.IsNullOrEmpty(_successMessage)) {
<div class="alert alert-success alert-dismissible fade show py-1">
@_successMessage
<button type="button" class="btn-close" @onclick="() => _successMessage = null"></button>
</div>
}
@if (_showCreateForm) {
<div class="card border-secondary mb-3">
<div class="card-body">
<h6 class="card-title">Create New User</h6>
<EditForm Model="_createModel" OnValidSubmit="CreateUser">
<DataAnnotationsValidator/>
<div class="row g-2">
<div class="col-md-4">
<label class="form-label">Username</label>
<InputText class="form-control form-control-sm" @bind-Value="_createModel.Username"/>
<ValidationMessage For="() => _createModel.Username"/>
</div>
<div class="col-md-4">
<label class="form-label">Email</label>
<InputText class="form-control form-control-sm" @bind-Value="_createModel.Email"/>
<ValidationMessage For="() => _createModel.Email"/>
</div>
<div class="col-md-4">
<label class="form-label">Password</label>
<InputText type="password" class="form-control form-control-sm" @bind-Value="_createModel.Password"/>
<ValidationMessage For="() => _createModel.Password"/>
</div>
<div class="col-md-4">
<label class="form-label">Access Level</label>
<InputSelect class="form-select form-select-sm" @bind-Value="_createModel.AccessLevel">
<option value="@EAccessLevel.User">User</option>
<option value="@EAccessLevel.Curator">Curator</option>
<option value="@EAccessLevel.Admin">Admin</option>
</InputSelect>
</div>
<div class="col-md-4 d-flex align-items-end">
<button type="submit" class="btn btn-primary btn-sm me-2">Create</button>
<button type="button" class="btn btn-secondary btn-sm" @onclick="ToggleCreateForm">Cancel</button>
</div>
</div>
@if (_createError != null) {
<div class="alert alert-danger mt-2 mb-0 py-1">@_createError</div>
}
</EditForm>
</div>
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
<div class="input-group input-group-sm" style="max-width: 280px;">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" class="form-control" placeholder="Search by name or email..."
@bind="_searchQuery" @bind:event="oninput" />
</div>
}
<small class="text-muted">@_users.Count user@(_users.Count != 1 ? "s" : "") —
<span class="text-success">@_users.Count(u => u.DeletedAt == null && !u.IsBanned) active</span>,
<span class="text-warning">@_users.Count(u => u.IsBanned) banned</span>,
<span class="text-secondary">@_users.Count(u => u.DeletedAt != null) deleted</span>
</small>
</div>
<div class="table-responsive">
@* Desktop table *@
<div class="table-responsive d-none d-md-block">
<table class="table table-hover table-sm align-middle">
<thead class="table-dark">
<tr>
<th>Username</th>
<th class="d-none d-md-table-cell">Email</th>
<th>Role</th>
<th>Status</th>
<th class="d-none d-md-table-cell">Created</th>
<th class="d-none d-lg-table-cell">Last Login</th>
<th class="sortable" @onclick="() => ToggleSort(nameof(UserInfoDto.Username))">
User @SortArrow(nameof(UserInfoDto.Username))
</th>
<th class="sortable d-none d-lg-table-cell" @onclick="() => ToggleSort(nameof(UserInfoDto.Email))">
Email @SortArrow(nameof(UserInfoDto.Email))
</th>
<th class="sortable" @onclick="() => ToggleSort(nameof(UserInfoDto.AccessLevel))">
Role @SortArrow(nameof(UserInfoDto.AccessLevel))
</th>
<th class="sortable" @onclick='() => ToggleSort("status")'>
Status @SortArrow("status")
</th>
<th class="sortable d-none d-lg-table-cell" @onclick="() => ToggleSort(nameof(UserInfoDto.CreatedAt))">
Created @SortArrow(nameof(UserInfoDto.CreatedAt))
</th>
<th class="sortable d-none d-xl-table-cell" @onclick="() => ToggleSort(nameof(UserInfoDto.LastLogin))">
Last Login @SortArrow(nameof(UserInfoDto.LastLogin))
</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var user in _users) {
@foreach (var user in _filteredSorted) {
var isSelf = user.Id == LoginService.LoggedUser?.Id;
var isDeleted = user.DeletedAt != null;
<tr class="@(isDeleted ? "table-secondary" : user.IsBanned ? "table-warning" : "")">
<tr class="@(user.DeletedAt != null ? "table-secondary" : user.IsBanned ? "table-warning" : "")">
<td>
@if (_editingUsername == user.Id) {
<span class="d-flex flex-wrap align-items-center gap-1">
<input type="text" class="form-control form-control-sm" style="min-width:80px;max-width:140px"
@bind="_pendingUsername" @bind:event="oninput"/>
<button class="btn btn-sm btn-success" title="Confirm"
@onclick="() => ConfirmUsername(user)">
<i class="bi bi-check"></i>
</button>
<button class="btn btn-sm btn-outline-secondary" title="Cancel"
@onclick="CancelFieldEdit">
<i class="bi bi-x"></i>
</button>
</span>
} else {
@user.Username
@if (isSelf) { <span class="badge bg-info">You</span> }
<button class="btn btn-sm btn-outline-secondary ms-1" title="Rename"
@onclick="() => StartUsernameEdit(user)">
<i class="bi bi-pencil"></i>
</button>
}
</td>
<td class="d-none d-md-table-cell">
@if (_editingEmail == user.Id) {
<span class="d-flex flex-wrap align-items-center gap-1">
<input type="email" class="form-control form-control-sm" style="min-width:100px;max-width:180px"
@bind="_pendingEmail" @bind:event="oninput"/>
<button class="btn btn-sm btn-success" title="Confirm"
@onclick="() => ConfirmEmail(user)">
<i class="bi bi-check"></i>
</button>
<button class="btn btn-sm btn-outline-secondary" title="Cancel"
@onclick="CancelFieldEdit">
<i class="bi bi-x"></i>
</button>
</span>
} else {
@user.Email
<button class="btn btn-sm btn-outline-secondary ms-1" title="Change email"
@onclick="() => StartEmailEdit(user)">
<i class="bi bi-pencil"></i>
</button>
}
<div class="d-flex align-items-center gap-2">
<div class="user-initials">@(user.Username?.Length > 0 ? user.Username[..1].ToUpper() : "?")</div>
<div>
<span>@user.Username</span>
@if (isSelf) { <span class="badge bg-info ms-1">You</span> }
</div>
</div>
</td>
<td class="d-none d-lg-table-cell">@user.Email</td>
<td><span class="badge @AccessLevelBadge(user.AccessLevel)">@user.AccessLevel</span></td>
<td>
@if (_editingAccessLevel == user.Id) {
<span class="d-flex flex-wrap align-items-center gap-1">
<select class="form-select form-select-sm" style="min-width:80px"
value="@((int)_pendingAccessLevel)"
@onchange="(ChangeEventArgs e) => PendingAccessLevelChanged(e)">
<option value="0">User</option>
<option value="1">Curator</option>
<option value="2">Admin</option>
</select>
<button class="btn btn-sm btn-success" title="Confirm"
@onclick="() => ConfirmAccessLevel(user)">
<i class="bi bi-check"></i>
</button>
<button class="btn btn-sm btn-outline-secondary" title="Cancel"
@onclick="CancelFieldEdit">
<i class="bi bi-x"></i>
</button>
</span>
} else {
<span class="badge @AccessLevelBadge(user.AccessLevel)">@user.AccessLevel</span>
<button class="btn btn-sm btn-outline-secondary ms-1" title="Change role"
@onclick="() => StartAccessLevelEdit(user)">
<i class="bi bi-pencil"></i>
</button>
}
</td>
<td>
@if (isDeleted) {
@if (user.DeletedAt != null) {
<span class="badge bg-secondary">Deleted</span>
<span class="text-muted small">@user.DeletedAt?.ToString("yyyy-MM-dd")</span>
} else if (user.IsBanned) {
<span class="badge bg-danger">Banned</span>
<span class="text-muted small">@user.BannedAt?.ToString("yyyy-MM-dd")</span>
} else {
<span class="badge bg-success">Active</span>
}
</td>
<td class="text-nowrap d-none d-md-table-cell">@user.CreatedAt.ToString("yyyy-MM-dd")</td>
<td class="text-nowrap d-none d-lg-table-cell">@(user.LastLogin?.ToString("yyyy-MM-dd HH:mm") ?? "Never")</td>
<td class="text-nowrap d-none d-lg-table-cell">@user.CreatedAt.ToString("yyyy-MM-dd")</td>
<td class="text-nowrap d-none d-xl-table-cell">@(user.LastLogin?.ToString("yyyy-MM-dd") ?? "")</td>
<td>
<div class="d-none d-sm-flex btn-group btn-group-sm">
@if (!isDeleted) {
@if (user.IsBanned) {
<button class="btn btn-outline-success" title="Unban"
@onclick="() => ToggleBan(user, false)">
<i class="bi bi-unlock"></i>
</button>
} else {
<button class="btn btn-outline-warning" title="Ban"
disabled="@(isSelf)"
@onclick="() => ToggleBan(user, true)">
<i class="bi bi-lock"></i>
</button>
}
@if (_editingPassword == user.Id) {
<span class="d-flex flex-wrap align-items-center gap-1">
<input type="password" class="form-control form-control-sm" style="min-width:60px;max-width:100px"
placeholder="New pw" @bind="_pendingPassword" @bind:event="oninput"/>
<button class="btn btn-sm btn-success" title="Confirm"
@onclick="() => ConfirmPassword(user)">
<i class="bi bi-check"></i>
</button>
<button class="btn btn-sm btn-outline-secondary" title="Cancel"
@onclick="CancelFieldEdit">
<i class="bi bi-x"></i>
</button>
</span>
} else {
<button class="btn btn-outline-secondary" title="Change password"
@onclick="() => StartPasswordEdit(user)">
<i class="bi bi-key"></i>
</button>
}
}
<button class="btn btn-outline-danger" title="Delete"
disabled="@(isSelf || isDeleted)"
@onclick="() => DeleteUser(user)">
<i class="bi bi-trash"></i>
</button>
</div>
<div class="d-sm-none dropdown">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" data-bs-toggle="dropdown" title="Actions">
<i class="bi bi-three-dots"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
@if (!isDeleted) {
@if (user.IsBanned) {
<li><a class="dropdown-item" @onclick="() => ToggleBan(user, false)">
<i class="bi bi-unlock me-1"></i>Unban</a></li>
} else {
<li><a class="dropdown-item @(isSelf ? "disabled" : "")"
@onclick="() => { if(!isSelf) ToggleBan(user, true); }">
<i class="bi bi-lock me-1"></i>Ban</a></li>
}
<li><a class="dropdown-item" @onclick="() => StartPasswordEdit(user)">
<i class="bi bi-key me-1"></i>Change Password</a></li>
}
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item text-danger @(isSelf || isDeleted ? "disabled" : "")"
@onclick="() => { if(!isSelf && !isDeleted) DeleteUser(user); }">
<i class="bi bi-trash me-1"></i>Delete</a></li>
</ul>
</div>
<button class="btn btn-outline-secondary btn-sm" @onclick="() => OpenEditModal(user)">
<i class="bi bi-pencil"></i>
</button>
</td>
</tr>
}
@if (!_filteredSorted.Any()) {
<tr><td colspan="7" class="text-center text-muted py-3">No users match your search.</td></tr>
}
</tbody>
</table>
</div>
@* Mobile cards *@
<div class="d-md-none">
@foreach (var user in _filteredSorted) {
var isSelf = user.Id == LoginService.LoggedUser?.Id;
<div class="card mb-2 @(user.DeletedAt != null ? "border-secondary" : user.IsBanned ? "border-warning" : "")">
<div class="card-body py-2">
<div class="d-flex align-items-center gap-2 mb-1">
<div class="user-initials">@(user.Username?.Length > 0 ? user.Username[..1].ToUpper() : "?")</div>
<div class="flex-grow-1">
<strong>@user.Username</strong>
@if (isSelf) { <span class="badge bg-info">You</span> }
</div>
<button class="btn btn-outline-secondary btn-sm" @onclick="() => OpenEditModal(user)">
<i class="bi bi-pencil"></i>
</button>
</div>
<div class="small text-muted">@user.Email</div>
<div class="d-flex gap-2 mt-1">
<span class="badge @AccessLevelBadge(user.AccessLevel)">@user.AccessLevel</span>
@if (user.DeletedAt != null) {
<span class="badge bg-secondary">Deleted</span>
} else if (user.IsBanned) {
<span class="badge bg-danger">Banned</span>
} else {
<span class="badge bg-success">Active</span>
}
</div>
</div>
</div>
}
@if (!_filteredSorted.Any()) {
<div class="text-center text-muted py-3">No users match your search.</div>
}
</div>
}
@* Create User Modal *@
@if (_showCreateModal) {
<div class="modal-overlay" @onclick="CloseCreateModal">
<div class="modal-content" @onclick:stopPropagation="true">
<div class="modal-header">
<h5><i class="bi bi-person-plus"></i> Create User</h5>
<button class="btn-close" @onclick="CloseCreateModal"></button>
</div>
<div class="modal-body">
@if (!string.IsNullOrEmpty(_createError)) {
<div class="alert alert-danger py-1">@_createError</div>
}
<div class="mb-3">
<label class="form-label">Username</label>
<input type="text" class="form-control" @bind="_createModel.Username" @bind:event="oninput" />
</div>
<div class="mb-3">
<label class="form-label">Email</label>
<input type="email" class="form-control" @bind="_createModel.Email" @bind:event="oninput" />
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<input type="password" class="form-control" @bind="_createModel.Password" @bind:event="oninput" />
</div>
<div class="mb-3">
<label class="form-label">Access Level</label>
<select class="form-select" @bind="_createModel.AccessLevel">
<option value="@EAccessLevel.User">User</option>
<option value="@EAccessLevel.Curator">Curator</option>
<option value="@EAccessLevel.Admin">Admin</option>
</select>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @onclick="CloseCreateModal">Cancel</button>
<button class="btn btn-primary" @onclick="CreateUser">Create</button>
</div>
</div>
</div>
}
@* Edit User Modal *@
@if (_editUser != null) {
<div class="modal-overlay" @onclick="CloseEditModal">
<div class="modal-content" @onclick:stopPropagation="true">
<div class="modal-header">
<h5><i class="bi bi-pencil"></i> Edit @_editUser.Username</h5>
<button class="btn-close" @onclick="CloseEditModal"></button>
</div>
<div class="modal-body">
@if (!string.IsNullOrEmpty(_editError)) {
<div class="alert alert-danger py-1">@_editError</div>
}
<div class="mb-3">
<label class="form-label">Username</label>
<input type="text" class="form-control" @bind="_editUsername" @bind:event="oninput" />
</div>
<div class="mb-3">
<label class="form-label">Email</label>
<input type="email" class="form-control" @bind="_editEmail" @bind:event="oninput" />
</div>
<div class="mb-3">
<label class="form-label">Access Level</label>
<select class="form-select" @bind="_editAccessLevel">
<option value="@EAccessLevel.User">User</option>
<option value="@EAccessLevel.Curator">Curator</option>
<option value="@EAccessLevel.Admin">Admin</option>
</select>
</div>
<hr />
<h6>Danger Zone</h6>
<div class="d-flex flex-wrap gap-2">
@if (_editUser.IsBanned) {
<button class="btn btn-outline-success btn-sm" @onclick="() => ToggleBan(_editUser, false)">
<i class="bi bi-unlock"></i> Unban
</button>
} else {
<button class="btn btn-outline-warning btn-sm" @onclick="() => ToggleBan(_editUser, true)">
<i class="bi bi-lock"></i> Ban
</button>
}
<button class="btn btn-outline-primary btn-sm" @onclick="() => _passwordUser = _editUser">
<i class="bi bi-key"></i> Change Password
</button>
<button class="btn btn-outline-danger btn-sm" disabled="@(_editUser.DeletedAt != null)" @onclick="ConfirmDelete">
<i class="bi bi-trash"></i> Delete
</button>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @onclick="CloseEditModal">Cancel</button>
<button class="btn btn-primary" @onclick="SubmitEditUser" disabled="@(string.IsNullOrWhiteSpace(_editUsername) || string.IsNullOrWhiteSpace(_editEmail))">
Save
</button>
</div>
</div>
</div>
}
@* Admin Change Password Modal *@
@if (_passwordUser != null) {
<div class="modal-overlay" @onclick="ClosePasswordModal">
<div class="modal-content" @onclick:stopPropagation="true">
<div class="modal-header">
<h5><i class="bi bi-key"></i> Change Password — @_passwordUser.Username</h5>
<button class="btn-close" @onclick="ClosePasswordModal"></button>
</div>
<div class="modal-body">
@if (!string.IsNullOrEmpty(_passwordError)) {
<div class="alert alert-danger py-1">@_passwordError</div>
}
<div class="mb-3">
<label class="form-label">New Password</label>
<input type="password" class="form-control" @bind="_adminNewPassword" @bind:event="oninput" />
</div>
<div class="mb-3">
<label class="form-label">Confirm New Password</label>
<input type="password" class="form-control" @bind="_adminConfirmPassword" @bind:event="oninput" />
@if (!string.IsNullOrEmpty(_adminConfirmPassword) && _adminNewPassword != _adminConfirmPassword) {
<small class="text-danger">Passwords do not match</small>
}
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @onclick="ClosePasswordModal">Cancel</button>
<button class="btn btn-primary" disabled="@(string.IsNullOrWhiteSpace(_adminNewPassword) || _adminNewPassword != _adminConfirmPassword)"
@onclick="SubmitAdminPasswordChange">
Update Password
</button>
</div>
</div>
</div>
}
@code {
@@ -260,25 +291,59 @@
private bool _isLoading = true;
private string? _error;
private string? _successMessage;
private bool _showCreateForm;
private Guid? _editingAccessLevel;
private EAccessLevel _pendingAccessLevel;
private Guid? _editingUsername;
private string _pendingUsername = string.Empty;
private Guid? _editingEmail;
private string _pendingEmail = string.Empty;
private Guid? _editingPassword;
private string _pendingPassword = string.Empty;
private string _searchQuery = "";
private UserCreateDto _createModel = new() {
Username = string.Empty,
Email = string.Empty,
Password = string.Empty,
private string? _sortColumn = nameof(UserInfoDto.AccessLevel);
private bool _sortAsc = false;
private bool _showCreateModal;
private readonly UserCreateDto _createModel = new() {
Username = "",
Email = "",
Password = "",
AccessLevel = EAccessLevel.User
};
private string? _createError;
private UserInfoDto? _editUser;
private string _editUsername = "";
private string _editEmail = "";
private EAccessLevel _editAccessLevel;
private string? _editError;
private UserInfoDto? _passwordUser;
private string _adminNewPassword = "";
private string _adminConfirmPassword = "";
private string? _passwordError;
private IEnumerable<UserInfoDto> _filteredSorted {
get {
if (_users == null) return [];
var query = _searchQuery?.Trim().ToLowerInvariant() ?? "";
var filtered = _users
.Where(u => string.IsNullOrEmpty(query)
|| (u.Username?.ToLowerInvariant().Contains(query) ?? false)
|| (u.Email?.ToLowerInvariant().Contains(query) ?? false));
return (_sortColumn, _sortAsc) switch {
(nameof(UserInfoDto.Username), true) => filtered.OrderBy(u => u.Username),
(nameof(UserInfoDto.Username), false) => filtered.OrderByDescending(u => u.Username),
(nameof(UserInfoDto.Email), true) => filtered.OrderBy(u => u.Email),
(nameof(UserInfoDto.Email), false) => filtered.OrderByDescending(u => u.Email),
(nameof(UserInfoDto.AccessLevel), true) => filtered.OrderBy(u => (int)u.AccessLevel),
(nameof(UserInfoDto.AccessLevel), false)=> filtered.OrderByDescending(u => (int)u.AccessLevel),
("status", true) => filtered.OrderBy(u => u.DeletedAt != null ? 2 : u.IsBanned ? 1 : 0),
("status", false) => filtered.OrderByDescending(u => u.DeletedAt != null ? 2 : u.IsBanned ? 1 : 0),
(nameof(UserInfoDto.CreatedAt), true) => filtered.OrderBy(u => u.CreatedAt),
(nameof(UserInfoDto.CreatedAt), false) => filtered.OrderByDescending(u => u.CreatedAt),
(nameof(UserInfoDto.LastLogin), true) => filtered.OrderBy(u => u.LastLogin),
(nameof(UserInfoDto.LastLogin), false) => filtered.OrderByDescending(u => u.LastLogin),
_ => filtered.OrderBy(u => u.Username)
};
}
}
protected override async Task OnInitializedAsync() {
if (!LoginService.IsLoggedIn) {
NavigationManager.NavigateTo("/login");
@@ -293,191 +358,143 @@
private async Task LoadUsers() {
_isLoading = true;
_error = null;
_error = null;
StateHasChanged();
_users = await UserService.GetAllUsersAsync();
_isLoading = false;
}
private void ClearError() {
_error = null;
private void ToggleSort(string column) {
if (_sortColumn == column) {
_sortAsc = !_sortAsc;
} else {
_sortColumn = column;
_sortAsc = true;
}
}
private void ToggleCreateForm() {
_showCreateForm = !_showCreateForm;
_createModel = new() {
Username = string.Empty,
Email = string.Empty,
Password = string.Empty,
AccessLevel = EAccessLevel.User
};
private MarkupString SortArrow(string column) {
if (_sortColumn != column) return (MarkupString)"";
return (MarkupString)(_sortAsc
? "<i class=\"bi bi-sort-up\"></i>"
: "<i class=\"bi bi-sort-down\"></i>");
}
private void OpenCreateModal() {
_createModel.Username = "";
_createModel.Email = "";
_createModel.Password = "";
_createModel.AccessLevel = EAccessLevel.User;
_createError = null;
_showCreateModal = true;
}
private async Task CloseCreateModal() {
_showCreateModal = false;
}
private async Task CreateUser() {
_createError = null;
var result = await UserService.CreateUserAsync(_createModel);
if (result != null) {
_showCreateForm = false;
_successMessage = $"User '{_createModel.Username}' created successfully.";
_showCreateModal = false;
_successMessage = $"User '{_createModel.Username}' created.";
await LoadUsers();
} else {
_createError = "Failed to create user. Email or username may already exist.";
}
}
private async Task ToggleBan(UserInfoDto user, bool ban) {
var dto = new UserUpdateDto {
Id = user.Id,
IsBanned = ban
};
var (updated, _) = await UserService.UpdateUserAsync(dto);
if (updated != null) {
var idx = _users!.FindIndex(u => u.Id == user.Id);
if (idx >= 0) _users[idx] = updated;
_successMessage = ban ? $"'{user.Username}' has been banned." : $"'{user.Username}' has been unbanned.";
} else {
_error = $"Failed to {(ban ? "ban" : "unban")} user '{user.Username}'.";
}
private void OpenEditModal(UserInfoDto user) {
_editUser = user;
_editUsername = user.Username ?? "";
_editEmail = user.Email ?? "";
_editAccessLevel = user.AccessLevel;
_editError = null;
}
private async Task DeleteUser(UserInfoDto user) {
var success = await UserService.DeleteUserAsync(user.Id);
if (success) {
_successMessage = $"User '{user.Username}' has been deleted.";
private async Task CloseEditModal() {
_editUser = null;
_editError = null;
}
private async Task SubmitEditUser() {
if (_editUser == null) return;
_editError = null;
var isSelf = _editUser.Id == LoginService.LoggedUser?.Id;
if (isSelf && _editAccessLevel != EAccessLevel.Admin) {
_editError = "Cannot demote yourself from Admin.";
return;
}
var dto = new UserUpdateDto {
Id = _editUser.Id,
Username = _editUsername,
Email = _editEmail,
AccessLevel = _editAccessLevel
};
var (result, error) = await UserService.UpdateUserAsync(dto);
if (result != null) {
_successMessage = $"'{result.Username}' updated.";
await CloseEditModal();
await LoadUsers();
} else {
_error = $"Failed to delete user '{user.Username}'.";
_editError = error ?? "Failed to update user.";
}
}
private void CancelFieldEdit() {
_editingAccessLevel = null;
_editingUsername = null;
_editingEmail = null;
_editingPassword = null;
}
private void StartAccessLevelEdit(UserInfoDto user) {
CancelFieldEdit();
_editingAccessLevel = user.Id;
_pendingAccessLevel = user.AccessLevel;
}
private void PendingAccessLevelChanged(ChangeEventArgs e) {
if (int.TryParse(e.Value?.ToString(), out var level))
_pendingAccessLevel = (EAccessLevel)level;
}
private async Task ConfirmAccessLevel(UserInfoDto user) {
var isSelf = user.Id == LoginService.LoggedUser?.Id;
if (isSelf && _pendingAccessLevel != EAccessLevel.Admin) {
_error = "Cannot demote yourself from Admin.";
CancelFieldEdit();
return;
private async Task ToggleBan(UserInfoDto user, bool ban) {
var dto = new UserUpdateDto { Id = user.Id, IsBanned = ban };
var (result, _) = await UserService.UpdateUserAsync(dto);
if (result != null) {
_successMessage = ban
? $"'{user.Username}' has been banned."
: $"'{user.Username}' has been unbanned.";
var idx = _users?.FindIndex(u => u.Id == user.Id) ?? -1;
if (idx >= 0) _users![idx] = result;
if (_editUser?.Id == user.Id) _editUser = result;
} else {
_error = $"Failed to {(ban ? "ban" : "unban")} user.";
}
}
if (_pendingAccessLevel == user.AccessLevel) {
CancelFieldEdit();
return;
private async Task ConfirmDelete() {
if (_editUser == null) return;
if (!await JSRuntime.InvokeAsync<bool>("confirm", $"Delete user '{_editUser.Username}'? This can be undone.")) return;
var success = await UserService.DeleteUserAsync(_editUser.Id);
if (success) {
_successMessage = $"User '{_editUser.Username}' deleted.";
await CloseEditModal();
await LoadUsers();
} else {
_editError = "Failed to delete user.";
}
}
_editingAccessLevel = null;
private async Task ClosePasswordModal() {
_passwordUser = null;
_adminNewPassword = "";
_adminConfirmPassword = "";
_passwordError = null;
}
private async Task SubmitAdminPasswordChange() {
if (_passwordUser == null) return;
_passwordError = null;
var dto = new UserUpdateDto {
Id = user.Id,
AccessLevel = _pendingAccessLevel
Id = _passwordUser.Id,
Password = _adminNewPassword
};
var (updated, _) = await UserService.UpdateUserAsync(dto);
if (updated != null) {
var idx = _users!.FindIndex(u => u.Id == user.Id);
if (idx >= 0) _users[idx] = updated;
_successMessage = $"'{user.Username}' role changed to {updated.AccessLevel}.";
var (result, error) = await UserService.UpdateUserAsync(dto);
if (result != null) {
_successMessage = $"Password changed for '{_passwordUser.Username}'.";
await ClosePasswordModal();
} else {
_error = $"Failed to change role for '{user.Username}'.";
}
}
private void StartUsernameEdit(UserInfoDto user) {
CancelFieldEdit();
_editingUsername = user.Id;
_pendingUsername = user.Username;
}
private async Task ConfirmUsername(UserInfoDto user) {
_editingUsername = null;
if (string.IsNullOrWhiteSpace(_pendingUsername)) {
_error = "Username cannot be empty.";
return;
}
var dto = new UserUpdateDto {
Id = user.Id,
Username = _pendingUsername
};
var (updated, _) = await UserService.UpdateUserAsync(dto);
if (updated != null) {
var idx = _users!.FindIndex(u => u.Id == user.Id);
if (idx >= 0) _users[idx] = updated;
_successMessage = $"'{user.Username}' renamed to '{updated.Username}'.";
} else {
_error = $"Failed to rename user '{user.Username}'.";
}
}
private void StartEmailEdit(UserInfoDto user) {
CancelFieldEdit();
_editingEmail = user.Id;
_pendingEmail = user.Email ?? string.Empty;
}
private async Task ConfirmEmail(UserInfoDto user) {
_editingEmail = null;
if (string.IsNullOrWhiteSpace(_pendingEmail)) {
_error = "Email cannot be empty.";
return;
}
var dto = new UserUpdateDto {
Id = user.Id,
Email = _pendingEmail
};
var (updated, _) = await UserService.UpdateUserAsync(dto);
if (updated != null) {
var idx = _users!.FindIndex(u => u.Id == user.Id);
if (idx >= 0) _users[idx] = updated;
_successMessage = $"'{user.Username}' email updated.";
} else {
_error = $"Failed to update email for '{user.Username}'.";
}
}
private void StartPasswordEdit(UserInfoDto user) {
CancelFieldEdit();
_editingPassword = user.Id;
_pendingPassword = string.Empty;
}
private async Task ConfirmPassword(UserInfoDto user) {
_editingPassword = null;
if (string.IsNullOrWhiteSpace(_pendingPassword)) {
_error = "Password cannot be empty.";
return;
}
var dto = new UserUpdateDto {
Id = user.Id,
Password = _pendingPassword
};
var (updated, _) = await UserService.UpdateUserAsync(dto);
if (updated != null) {
_successMessage = $"'{user.Username}' password changed.";
} else {
_error = $"Failed to change password for '{user.Username}'.";
_passwordError = error ?? "Failed to change password.";
}
}

View File

@@ -0,0 +1,84 @@
.user-initials {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--bs-primary);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.85rem;
font-weight: 700;
flex-shrink: 0;
}
.sortable {
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.sortable:hover {
text-decoration: underline;
}
.modal-overlay {
position: fixed;
inset: 0;
z-index: 1060;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
}
.modal-content {
background: var(--bs-body-bg, #222);
color: var(--bs-body-color, #eee);
border-radius: 8px;
width: 90%;
max-width: 500px;
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--bs-border-color, #444);
}
.modal-header h5 {
margin: 0;
}
.modal-body {
padding: 20px;
overflow-y: auto;
flex: 1;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 20px;
border-top: 1px solid var(--bs-border-color, #444);
}
.btn-close {
background: none;
border: none;
color: var(--bs-body-color, #eee);
font-size: 1.5rem;
cursor: pointer;
opacity: 0.7;
}
.btn-close:hover {
opacity: 1;
}

View File

@@ -7,55 +7,94 @@
<div class="d-flex justify-content-center">
<div class="card mb-3" style="max-width: 600px; width: 100%;">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="bi bi-person-circle"></i> @(user?.Username ?? "Username")
</h5>
<span class="badge @RoleBadge">@(user?.AccessLevel.ToString() ?? "")</span>
</div>
<div class="card-body">
<dl class="row mb-0">
@if (isSelf || AdminAccess) {
<dt class="col-sm-4"><i class="bi bi-envelope-at"></i> Email</dt>
<dd class="col-sm-8">@user?.Email</dd>
}
<dt class="col-sm-4"><i class="bi bi-person-fill-lock"></i> Role</dt>
<dd class="col-sm-8">@user?.AccessLevel.ToString()</dd>
<dt class="col-sm-4"><i class="bi bi-calendar-date"></i> Created</dt>
<dd class="col-sm-8">@user?.CreatedAt.ToString("D")</dd>
<dt class="col-sm-4"><i class="bi bi-door-open"></i> Last Login</dt>
<dd class="col-sm-8">@(user?.LastLogin?.ToString("D") ?? "Never")</dd>
<dt class="col-sm-4"><i class="bi bi-ban"></i> Banned</dt>
<dd class="col-sm-8">@(user is {IsBanned: true} ? $"Yes ({user.BannedAt.ToString()})" : "No")</dd>
@if (AdminAccess) {
<dt class="col-sm-4"><i class="bi bi-trash3"></i> Deleted</dt>
<dd class="col-sm-8">@(user is not {DeletedAt: null} ? user!.DeletedAt.ToString() : "No")</dd>
}
</dl>
</div>
@if (isSelf) {
<div class="card-footer d-flex align-items-center gap-2">
@if (!string.IsNullOrEmpty(_pageMessage)) {
<div class="alert alert-success py-1 mb-0 flex-grow-1">@_pageMessage</div>
}
<button class="btn btn-outline-primary btn-sm" @onclick="() => _showModal = true">
<i class="bi bi-key"></i> Change Password
</button>
<div class="card-header d-flex align-items-center gap-3">
<div class="profile-avatar">@_initials</div>
<div class="min-width-0">
<h5 class="mb-0 text-truncate">@(user?.Username ?? "...")</h5>
@if (isSelf || AdminAccess) {
<small class="text-muted text-truncate d-block">@user?.Email</small>
}
</div>
<span class="badge @RoleBadge ms-auto">@(user?.AccessLevel.ToString() ?? "")</span>
</div>
}
</div>
<div class="card-body">
<dl class="row mb-0 g-2">
@if (isSelf || AdminAccess) {
<dt class="col-sm-4 text-muted small">Email</dt>
<dd class="col-sm-8">@user?.Email</dd>
}
<dt class="col-sm-4 text-muted small">Role</dt>
<dd class="col-sm-8">@user?.AccessLevel.ToString()</dd>
<dt class="col-sm-4 text-muted small">Created</dt>
<dd class="col-sm-8">@user?.CreatedAt.ToString("D")</dd>
<dt class="col-sm-4 text-muted small">Last Login</dt>
<dd class="col-sm-8">@(user?.LastLogin?.ToString("D") ?? "Never")</dd>
<dt class="col-sm-4 text-muted small">Banned</dt>
<dd class="col-sm-8">@(user is {IsBanned: true} ? $"Yes ({user.BannedAt.ToString()})" : "No")</dd>
@if (AdminAccess) {
<dt class="col-sm-4 text-muted small">Deleted</dt>
<dd class="col-sm-8">@(user is not {DeletedAt: null} ? user!.DeletedAt.ToString() : "No")</dd>
}
</dl>
</div>
@if (canEdit) {
<div class="card-footer d-flex align-items-center gap-2 flex-wrap">
@if (!string.IsNullOrEmpty(_pageMessage)) {
<div class="alert alert-success py-1 mb-0 flex-grow-1">@_pageMessage</div>
}
<button class="btn btn-outline-primary btn-sm" @onclick="OpenEditModal">
<i class="bi bi-pencil"></i> Edit Profile
</button>
@if (isSelf) {
<button class="btn btn-outline-primary btn-sm" @onclick="() => _showPasswordModal = true">
<i class="bi bi-key"></i> Change Password
</button>
}
</div>
}
</div>
</div>
@if (_showModal) {
<div class="modal-overlay" @onclick="CloseModal">
@if (_showEditModal) {
<div class="modal-overlay" @onclick="CloseEditModal">
<div class="modal-content" @onclick:stopPropagation="true">
<div class="modal-header">
<h5><i class="bi bi-pencil"></i> Edit Profile</h5>
<button class="btn-close" @onclick="CloseEditModal"></button>
</div>
<div class="modal-body">
@if (!string.IsNullOrEmpty(_editError)) {
<div class="alert alert-danger py-1">@_editError</div>
}
<div class="mb-3">
<label class="form-label">Username</label>
<input type="text" class="form-control" @bind="_editUsername" @bind:event="oninput" />
</div>
<div class="mb-3">
<label class="form-label">Email</label>
<input type="email" class="form-control" @bind="_editEmail" @bind:event="oninput" />
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @onclick="CloseEditModal">Cancel</button>
<button class="btn btn-primary" disabled="@(string.IsNullOrWhiteSpace(_editUsername) || string.IsNullOrWhiteSpace(_editEmail))" @onclick="SubmitEditProfile">
Save
</button>
</div>
</div>
</div>
}
@if (_showPasswordModal) {
<div class="modal-overlay" @onclick="ClosePasswordModal">
<div class="modal-content" @onclick:stopPropagation="true">
<div class="modal-header">
<h5><i class="bi bi-key"></i> Change Password</h5>
<button class="btn-close" @onclick="CloseModal"></button>
<button class="btn-close" @onclick="ClosePasswordModal"></button>
</div>
<div class="modal-body">
@if (!string.IsNullOrEmpty(_errorMessage)) {
<div class="alert alert-danger py-1">@_errorMessage</div>
@if (!string.IsNullOrEmpty(_passwordError)) {
<div class="alert alert-danger py-1">@_passwordError</div>
}
<div class="mb-3">
@@ -99,7 +138,7 @@
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @onclick="CloseModal">Cancel</button>
<button class="btn btn-secondary" @onclick="ClosePasswordModal">Cancel</button>
<button class="btn btn-primary" disabled="@(!_canSubmit)" @onclick="SubmitPasswordChange">
Update Password
</button>
@@ -115,14 +154,10 @@
bool isSelf;
bool AdminAccess => LoginService.LoggedUser?.AccessLevel == EAccessLevel.Admin;
bool canEdit => isSelf || AdminAccess;
UserInfoDto? user;
bool _showModal;
string _oldPassword = "";
string _newPassword = "";
string _confirmPassword = "";
string _errorMessage = "";
string _pageMessage = "";
string _initials => user?.Username is { Length: > 0 } u ? u[..1].ToUpper() : "?";
string RoleBadge => user?.AccessLevel switch {
EAccessLevel.Admin => "bg-danger",
@@ -130,6 +165,18 @@
_ => "bg-secondary"
};
bool _showEditModal;
bool _showPasswordModal;
string _editUsername = "";
string _editEmail = "";
string _editError = "";
string _pageMessage = "";
string _oldPassword = "";
string _newPassword = "";
string _confirmPassword = "";
string _passwordError = "";
bool HasMinLength => _newPassword.Length >= 8;
bool HasUpperCase => _newPassword.Any(char.IsUpper);
bool HasLowerCase => _newPassword.Any(char.IsLower);
@@ -164,7 +211,7 @@
protected async override Task OnInitializedAsync() {
await base.OnInitializedAsync();
if (LoginService.IsLoggedIn) await LoadUser();
if (LoginService.IsLoggedIn) await LoadUser();
LoginService.AuthInfoChanged += async (_, _) => await LoadUser();
}
@@ -174,16 +221,47 @@
StateHasChanged();
}
async Task CloseModal() {
_showModal = false;
void OpenEditModal() {
_editUsername = user?.Username ?? "";
_editEmail = user?.Email ?? "";
_editError = "";
_showEditModal = true;
}
async Task CloseEditModal() {
_showEditModal = false;
_editError = "";
}
async Task SubmitEditProfile() {
_editError = "";
var dto = new UserUpdateDto {
Id = UserId,
Username = _editUsername,
Email = _editEmail
};
var (result, error) = await UserService.UpdateUserAsync(dto);
if (result != null) {
_pageMessage = "Profile updated successfully.";
await CloseEditModal();
await LoadUser();
} else {
_editError = error ?? "Failed to update profile.";
}
}
async Task ClosePasswordModal() {
_showPasswordModal = false;
_oldPassword = "";
_newPassword = "";
_confirmPassword = "";
_errorMessage = "";
_passwordError = "";
}
async Task SubmitPasswordChange() {
_errorMessage = "";
_passwordError = "";
var dto = new UserUpdateDto {
Id = UserId,
@@ -194,9 +272,9 @@
var (result, error) = await UserService.UpdateUserAsync(dto);
if (result != null) {
_pageMessage = "Password updated successfully.";
await CloseModal();
await ClosePasswordModal();
} else {
_errorMessage = error ?? "Failed to update password.";
_passwordError = error ?? "Failed to update password.";
}
}

View File

@@ -1,3 +1,17 @@
.profile-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--bs-primary);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
font-weight: 700;
flex-shrink: 0;
}
.modal-overlay {
position: fixed;
inset: 0;