507 lines
22 KiB
Plaintext
507 lines
22 KiB
Plaintext
@page "/Users"
|
|
@using Butter.Dtos.User
|
|
@using Butter.Types
|
|
@using MilkStream.Client.Services
|
|
|
|
@inject NavigationManager NavigationManager
|
|
@inject LoginService LoginService
|
|
@inject UserService UserService
|
|
@inject IJSRuntime JSRuntime
|
|
|
|
<PageTitle>Users</PageTitle>
|
|
|
|
@if (_isLoading) {
|
|
<LoadSpinner />
|
|
} else if (_users == null) {
|
|
<div class="alert alert-danger">Failed to load users.</div>
|
|
} else {
|
|
<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="OpenCreateModal">
|
|
<i class="bi bi-person-plus"></i> Create User
|
|
</button>
|
|
</div>
|
|
|
|
@if (!string.IsNullOrEmpty(_error)) {
|
|
<div class="alert alert-danger py-1 alert-dismissible fade show">
|
|
@_error
|
|
<button type="button" class="btn-close" @onclick="() => _error = null"></button>
|
|
</div>
|
|
}
|
|
@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>
|
|
}
|
|
|
|
<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>
|
|
|
|
@* 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 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 _filteredSorted) {
|
|
var isSelf = user.Id == LoginService.LoggedUser?.Id;
|
|
<tr class="@(user.DeletedAt != null ? "table-secondary" : user.IsBanned ? "table-warning" : "")">
|
|
<td>
|
|
<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 (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>
|
|
}
|
|
</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>
|
|
<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 {
|
|
private List<UserInfoDto>? _users;
|
|
private bool _isLoading = true;
|
|
private string? _error;
|
|
private string? _successMessage;
|
|
private string _searchQuery = "";
|
|
|
|
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");
|
|
return;
|
|
}
|
|
if (LoginService.LoggedUser?.AccessLevel != EAccessLevel.Admin) {
|
|
NavigationManager.NavigateTo("/");
|
|
return;
|
|
}
|
|
await LoadUsers();
|
|
}
|
|
|
|
private async Task LoadUsers() {
|
|
_isLoading = true;
|
|
_error = null;
|
|
StateHasChanged();
|
|
|
|
_users = await UserService.GetAllUsersAsync();
|
|
_isLoading = false;
|
|
}
|
|
|
|
private void ToggleSort(string column) {
|
|
if (_sortColumn == column) {
|
|
_sortAsc = !_sortAsc;
|
|
} else {
|
|
_sortColumn = column;
|
|
_sortAsc = true;
|
|
}
|
|
}
|
|
|
|
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) {
|
|
_showCreateModal = false;
|
|
_successMessage = $"User '{_createModel.Username}' created.";
|
|
await LoadUsers();
|
|
} else {
|
|
_createError = "Failed to create user. Email or username may already exist.";
|
|
}
|
|
}
|
|
|
|
private void OpenEditModal(UserInfoDto user) {
|
|
_editUser = user;
|
|
_editUsername = user.Username ?? "";
|
|
_editEmail = user.Email ?? "";
|
|
_editAccessLevel = user.AccessLevel;
|
|
_editError = null;
|
|
}
|
|
|
|
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 {
|
|
_editError = error ?? "Failed to update user.";
|
|
}
|
|
}
|
|
|
|
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.";
|
|
}
|
|
}
|
|
|
|
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.";
|
|
}
|
|
}
|
|
|
|
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 = _passwordUser.Id,
|
|
Password = _adminNewPassword
|
|
};
|
|
var (result, error) = await UserService.UpdateUserAsync(dto);
|
|
if (result != null) {
|
|
_successMessage = $"Password changed for '{_passwordUser.Username}'.";
|
|
await ClosePasswordModal();
|
|
} else {
|
|
_passwordError = error ?? "Failed to change password.";
|
|
}
|
|
}
|
|
|
|
private static string AccessLevelBadge(EAccessLevel level) => level switch {
|
|
EAccessLevel.Admin => "bg-danger",
|
|
EAccessLevel.Curator => "bg-warning text-dark",
|
|
_ => "bg-secondary"
|
|
};
|
|
}
|