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:
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
84
MilkStream.Client/Components/Pages/AdminUsers.razor.css
Normal file
84
MilkStream.Client/Components/Pages/AdminUsers.razor.css
Normal 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;
|
||||
}
|
||||
@@ -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.";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user