Files
MilkyShots/MilkStream.Client/Components/Pages/AdminUsers.razor

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"
};
}