Add fleshed out admin page
This commit is contained in:
@@ -0,0 +1,184 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace Media.JoshHeaps.Net.Api;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/admin")]
|
||||||
|
public class AdminApi(DbExecutor dbExecutor) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("users")]
|
||||||
|
public async Task<IActionResult> GetUsers([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
|
||||||
|
{
|
||||||
|
var authUserId = GetUserIdFromAuth();
|
||||||
|
if (authUserId == null) return Unauthorized();
|
||||||
|
|
||||||
|
if (!await IsAdmin(authUserId.Value)) return Forbid();
|
||||||
|
|
||||||
|
if (page < 1) page = 1;
|
||||||
|
if (pageSize < 1 || pageSize > 100) pageSize = 20;
|
||||||
|
var offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
var totalCount = await dbExecutor.ExecuteAsync<long>(
|
||||||
|
"SELECT COUNT(*) FROM app.users");
|
||||||
|
|
||||||
|
var users = await dbExecutor.ExecuteListReaderAsync(
|
||||||
|
@"SELECT u.id, u.username, u.email, u.is_active
|
||||||
|
FROM app.users u
|
||||||
|
ORDER BY u.id
|
||||||
|
LIMIT @PageSize OFFSET @Offset",
|
||||||
|
reader => new
|
||||||
|
{
|
||||||
|
Id = reader.GetInt64(0),
|
||||||
|
Username = reader.GetString(1),
|
||||||
|
Email = reader.GetString(2),
|
||||||
|
IsActive = reader.GetBoolean(3)
|
||||||
|
},
|
||||||
|
new { PageSize = pageSize, Offset = offset });
|
||||||
|
|
||||||
|
// Fetch all roles for these users in one query using a subquery
|
||||||
|
var userRoles = users.Count > 0
|
||||||
|
? await dbExecutor.ExecuteListReaderAsync(
|
||||||
|
@"SELECT ur.user_id, r.id, r.name
|
||||||
|
FROM app.user_roles ur
|
||||||
|
JOIN app.roles r ON ur.role_id = r.id
|
||||||
|
WHERE ur.user_id IN (
|
||||||
|
SELECT u.id FROM app.users u ORDER BY u.id LIMIT @PageSize OFFSET @Offset
|
||||||
|
)",
|
||||||
|
reader => new
|
||||||
|
{
|
||||||
|
UserId = reader.GetInt64(0),
|
||||||
|
RoleId = reader.GetInt64(1),
|
||||||
|
RoleName = reader.GetString(2)
|
||||||
|
},
|
||||||
|
new { PageSize = pageSize, Offset = offset })
|
||||||
|
: [];
|
||||||
|
|
||||||
|
var result = users.Select(u => new
|
||||||
|
{
|
||||||
|
u.Id,
|
||||||
|
u.Username,
|
||||||
|
u.Email,
|
||||||
|
u.IsActive,
|
||||||
|
Roles = userRoles.Where(r => r.UserId == u.Id)
|
||||||
|
.Select(r => new { Id = r.RoleId, Name = r.RoleName })
|
||||||
|
.ToList()
|
||||||
|
});
|
||||||
|
|
||||||
|
return Ok(new { users = result, totalCount });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("roles")]
|
||||||
|
public async Task<IActionResult> GetRoles()
|
||||||
|
{
|
||||||
|
var userId = GetUserIdFromAuth();
|
||||||
|
if (userId == null) return Unauthorized();
|
||||||
|
|
||||||
|
if (!await IsAdmin(userId.Value)) return Forbid();
|
||||||
|
|
||||||
|
var roles = await dbExecutor.ExecuteListReaderAsync(
|
||||||
|
"SELECT id, name FROM app.roles ORDER BY id",
|
||||||
|
reader => new { Id = reader.GetInt64(0), Name = reader.GetString(1) });
|
||||||
|
|
||||||
|
return Ok(roles);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("roles")]
|
||||||
|
public async Task<IActionResult> CreateRole([FromBody] CreateRoleRequest request)
|
||||||
|
{
|
||||||
|
var userId = GetUserIdFromAuth();
|
||||||
|
if (userId == null) return Unauthorized();
|
||||||
|
|
||||||
|
if (!await IsAdmin(userId.Value)) return Forbid();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Name))
|
||||||
|
return BadRequest(new { error = "Role name is required" });
|
||||||
|
|
||||||
|
var name = request.Name.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
|
var existing = await dbExecutor.ExecuteAsync<bool>(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM app.roles WHERE name = @Name)",
|
||||||
|
new { Name = name });
|
||||||
|
|
||||||
|
if (existing)
|
||||||
|
return BadRequest(new { error = "Role already exists" });
|
||||||
|
|
||||||
|
var role = await dbExecutor.ExecuteReaderAsync(
|
||||||
|
"INSERT INTO app.roles (name) VALUES (@Name) RETURNING id, name",
|
||||||
|
reader => new { Id = reader.GetInt64(0), Name = reader.GetString(1) },
|
||||||
|
new { Name = name });
|
||||||
|
|
||||||
|
return Ok(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("users/{targetUserId}/roles/{roleId}")]
|
||||||
|
public async Task<IActionResult> AssignRole(long targetUserId, long roleId)
|
||||||
|
{
|
||||||
|
var adminId = GetUserIdFromAuth();
|
||||||
|
if (adminId == null) return Unauthorized();
|
||||||
|
|
||||||
|
if (!await IsAdmin(adminId.Value)) return Forbid();
|
||||||
|
|
||||||
|
var userExists = await dbExecutor.ExecuteAsync<bool>(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM app.users WHERE id = @UserId)",
|
||||||
|
new { UserId = targetUserId });
|
||||||
|
if (!userExists) return NotFound(new { error = "User not found" });
|
||||||
|
|
||||||
|
var roleExists = await dbExecutor.ExecuteAsync<bool>(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM app.roles WHERE id = @RoleId)",
|
||||||
|
new { RoleId = roleId });
|
||||||
|
if (!roleExists) return NotFound(new { error = "Role not found" });
|
||||||
|
|
||||||
|
var alreadyAssigned = await dbExecutor.ExecuteAsync<bool>(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM app.user_roles WHERE user_id = @UserId AND role_id = @RoleId)",
|
||||||
|
new { UserId = targetUserId, RoleId = roleId });
|
||||||
|
if (alreadyAssigned) return Ok(new { success = true });
|
||||||
|
|
||||||
|
await dbExecutor.ExecuteNonQueryAsync(
|
||||||
|
"INSERT INTO app.user_roles (user_id, role_id) VALUES (@UserId, @RoleId)",
|
||||||
|
new { UserId = targetUserId, RoleId = roleId });
|
||||||
|
|
||||||
|
return Ok(new { success = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("users/{targetUserId}/roles/{roleId}")]
|
||||||
|
public async Task<IActionResult> RemoveRole(long targetUserId, long roleId)
|
||||||
|
{
|
||||||
|
var adminId = GetUserIdFromAuth();
|
||||||
|
if (adminId == null) return Unauthorized();
|
||||||
|
|
||||||
|
if (!await IsAdmin(adminId.Value)) return Forbid();
|
||||||
|
|
||||||
|
await dbExecutor.ExecuteNonQueryAsync(
|
||||||
|
"DELETE FROM app.user_roles WHERE user_id = @UserId AND role_id = @RoleId",
|
||||||
|
new { UserId = targetUserId, RoleId = roleId });
|
||||||
|
|
||||||
|
return Ok(new { success = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> IsAdmin(long userId)
|
||||||
|
{
|
||||||
|
return await dbExecutor.ExecuteAsync<bool>(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM app.user_roles ur JOIN app.roles r ON ur.role_id = r.id WHERE ur.user_id = @UserId AND r.name = 'admin')",
|
||||||
|
new { UserId = userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
private long? GetUserIdFromAuth()
|
||||||
|
{
|
||||||
|
var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
if (!string.IsNullOrEmpty(userIdClaim) && long.TryParse(userIdClaim, out var jwtUserId))
|
||||||
|
{
|
||||||
|
return jwtUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
var userIdString = HttpContext.Session.GetString("UserId");
|
||||||
|
if (!string.IsNullOrEmpty(userIdString) && long.TryParse(userIdString, out var sessionUserId))
|
||||||
|
{
|
||||||
|
return sessionUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CreateRoleRequest(string Name);
|
||||||
@@ -1,10 +1,20 @@
|
|||||||
-- User roles table for role-based access control
|
-- Roles table for role-based access control
|
||||||
|
CREATE TABLE IF NOT EXISTS app.roles (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Seed the admin role
|
||||||
|
INSERT INTO app.roles (name) VALUES ('admin') ON CONFLICT (name) DO NOTHING;
|
||||||
|
|
||||||
|
-- User roles junction table
|
||||||
CREATE TABLE IF NOT EXISTS app.user_roles (
|
CREATE TABLE IF NOT EXISTS app.user_roles (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
user_id BIGINT NOT NULL REFERENCES app.users(id),
|
user_id BIGINT NOT NULL REFERENCES app.users(id),
|
||||||
role VARCHAR(50) NOT NULL,
|
role_id BIGINT NOT NULL REFERENCES app.roles(id),
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
CONSTRAINT uq_user_role UNIQUE (user_id, role)
|
CONSTRAINT uq_user_role UNIQUE (user_id, role_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON app.user_roles(user_id);
|
CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON app.user_roles(user_id);
|
||||||
|
|||||||
@@ -5,6 +5,10 @@
|
|||||||
Layout = "_Layout";
|
Layout = "_Layout";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@section Styles {
|
||||||
|
<link rel="stylesheet" href="~/css/admin.css" asp-append-version="true" />
|
||||||
|
}
|
||||||
|
|
||||||
<div class="dashboard-container">
|
<div class="dashboard-container">
|
||||||
<div class="welcome-section">
|
<div class="welcome-section">
|
||||||
<div class="welcome-left">
|
<div class="welcome-left">
|
||||||
@@ -21,4 +25,40 @@
|
|||||||
<a href="/Logout" class="btn btn-danger">Logout</a>
|
<a href="/Logout" class="btn btn-danger">Logout</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Roles</h2>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="roles-list" id="rolesList"></div>
|
||||||
|
<div class="create-role-form">
|
||||||
|
<input type="text" id="newRoleName" placeholder="New role name" class="form-input" />
|
||||||
|
<button id="createRoleBtn" class="btn btn-primary">Create Role</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Users</h2>
|
||||||
|
</div>
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Roles</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="usersTableBody">
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="pagination" id="pagination"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@section Scripts {
|
||||||
|
<script src="~/js/admin.js" asp-append-version="true"></script>
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ public abstract class AuthenticatedPageModel : PageModel
|
|||||||
protected async Task<IActionResult?> RequireRole(string role, DbExecutor dbExecutor)
|
protected async Task<IActionResult?> RequireRole(string role, DbExecutor dbExecutor)
|
||||||
{
|
{
|
||||||
var hasRole = await dbExecutor.ExecuteAsync<bool>(
|
var hasRole = await dbExecutor.ExecuteAsync<bool>(
|
||||||
"SELECT EXISTS(SELECT 1 FROM app.user_roles WHERE user_id = @UserId AND role = @Role)",
|
"SELECT EXISTS(SELECT 1 FROM app.user_roles ur JOIN app.roles r ON ur.role_id = r.id WHERE ur.user_id = @UserId AND r.name = @Role)",
|
||||||
new { UserId, Role = role });
|
new { UserId, Role = role });
|
||||||
|
|
||||||
return hasRole ? null : NotFound();
|
return hasRole ? null : NotFound();
|
||||||
|
|||||||
@@ -92,6 +92,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
@if (Model.IsAdmin)
|
||||||
|
{
|
||||||
|
<a href="/Admin" class="landing-card">
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="card-icon-wrapper">
|
||||||
|
<div class="card-icon">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-text">
|
||||||
|
<h2>Admin</h2>
|
||||||
|
<p class="card-description">Site administration and management tools</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<span class="card-link-text">Open workspace</span>
|
||||||
|
<div class="card-arrow">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||||
|
<polyline points="12 5 19 12 12 19"></polyline>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="landing-footer">
|
<div class="landing-footer">
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Media.JoshHeaps.Net.Pages
|
namespace Media.JoshHeaps.Net.Pages
|
||||||
{
|
{
|
||||||
public class LandingModel : AuthenticatedPageModel
|
public class LandingModel(DbExecutor dbExecutor) : AuthenticatedPageModel
|
||||||
{
|
{
|
||||||
public void OnGet()
|
public bool IsAdmin { get; set; }
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnGetAsync()
|
||||||
{
|
{
|
||||||
RequireAuthentication();
|
RequireAuthentication();
|
||||||
|
LoadUserSession();
|
||||||
|
|
||||||
|
IsAdmin = await dbExecutor.ExecuteAsync<bool>(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM app.user_roles ur JOIN app.roles r ON ur.role_id = r.id WHERE ur.user_id = @UserId AND r.name = 'admin')",
|
||||||
|
new { UserId });
|
||||||
|
|
||||||
|
return Page();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,355 @@
|
|||||||
|
.dashboard-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-section {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 32px 40px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-button {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-button:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
color: var(--accent-primary);
|
||||||
|
transform: translateX(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-button svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-section h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent-primary);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
border-color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--danger);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: var(--danger-hover);
|
||||||
|
border-color: var(--danger-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Admin sections */
|
||||||
|
.admin-section {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Roles list */
|
||||||
|
.roles-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Create role form */
|
||||||
|
.create-role-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus {
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Users table */
|
||||||
|
.table-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table th,
|
||||||
|
.admin-table td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table th {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table td {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table tbody tr:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Role badges in table */
|
||||||
|
.role-badges {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
background: var(--accent-primary);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge .remove-role {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: none;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge .remove-role:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add role dropdown */
|
||||||
|
.add-role-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-role-btn {
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-role-btn:hover {
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-role-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
z-index: 10;
|
||||||
|
min-width: 140px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-role-dropdown.open {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-role-dropdown button {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-role-dropdown button:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pagination */
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 20px;
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination button:hover:not(:disabled) {
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination button:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination .page-info {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.welcome-section {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table th,
|
||||||
|
.admin-table td {
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-role-form {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
(function () {
|
||||||
|
let allRoles = [];
|
||||||
|
let currentPage = 1;
|
||||||
|
const pageSize = 20;
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
await loadRoles();
|
||||||
|
await loadUsers(1);
|
||||||
|
|
||||||
|
document.getElementById('createRoleBtn').addEventListener('click', createRole);
|
||||||
|
document.getElementById('newRoleName').addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') createRole();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!e.target.closest('.add-role-wrapper')) {
|
||||||
|
document.querySelectorAll('.add-role-dropdown.open').forEach(d => d.classList.remove('open'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRoles() {
|
||||||
|
const res = await fetch('/api/admin/roles');
|
||||||
|
if (!res.ok) return;
|
||||||
|
allRoles = await res.json();
|
||||||
|
renderRolesList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRolesList() {
|
||||||
|
const container = document.getElementById('rolesList');
|
||||||
|
container.innerHTML = allRoles.map(r =>
|
||||||
|
`<span class="role-pill">${escapeHtml(r.name)}</span>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRole() {
|
||||||
|
const input = document.getElementById('newRoleName');
|
||||||
|
const name = input.value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
const res = await fetch('/api/admin/roles', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
alert(err.error || 'Failed to create role');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.value = '';
|
||||||
|
await loadRoles();
|
||||||
|
await loadUsers(currentPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUsers(page) {
|
||||||
|
currentPage = page;
|
||||||
|
const res = await fetch(`/api/admin/users?page=${page}&pageSize=${pageSize}`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
renderUsersTable(data.users);
|
||||||
|
renderPagination(data.totalCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUsersTable(users) {
|
||||||
|
const tbody = document.getElementById('usersTableBody');
|
||||||
|
tbody.innerHTML = users.map(user => {
|
||||||
|
const roleBadges = user.roles.map(r =>
|
||||||
|
`<span class="role-badge">
|
||||||
|
${escapeHtml(r.name)}
|
||||||
|
<button class="remove-role" onclick="adminRemoveRole(${user.id}, ${r.id})" title="Remove role">×</button>
|
||||||
|
</span>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
const availableRoles = allRoles.filter(r => !user.roles.some(ur => ur.id === r.id));
|
||||||
|
const addDropdown = availableRoles.length > 0
|
||||||
|
? `<div class="add-role-wrapper">
|
||||||
|
<button class="add-role-btn" onclick="toggleRoleDropdown(this)">+ Add Role</button>
|
||||||
|
<div class="add-role-dropdown">
|
||||||
|
${availableRoles.map(r =>
|
||||||
|
`<button onclick="adminAddRole(${user.id}, ${r.id})">${escapeHtml(r.name)}</button>`
|
||||||
|
).join('')}
|
||||||
|
</div>
|
||||||
|
</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return `<tr>
|
||||||
|
<td>${user.id}</td>
|
||||||
|
<td>${escapeHtml(user.username)}</td>
|
||||||
|
<td><div class="role-badges">${roleBadges}</div></td>
|
||||||
|
<td>${addDropdown}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPagination(totalCount) {
|
||||||
|
const totalPages = Math.ceil(totalCount / pageSize);
|
||||||
|
const container = document.getElementById('pagination');
|
||||||
|
|
||||||
|
if (totalPages <= 1) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<button onclick="adminGoToPage(${currentPage - 1})" ${currentPage <= 1 ? 'disabled' : ''}>Previous</button>
|
||||||
|
<span class="page-info">Page ${currentPage} of ${totalPages}</span>
|
||||||
|
<button onclick="adminGoToPage(${currentPage + 1})" ${currentPage >= totalPages ? 'disabled' : ''}>Next</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = str;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global functions for inline event handlers
|
||||||
|
window.adminAddRole = async function (userId, roleId) {
|
||||||
|
const res = await fetch(`/api/admin/users/${userId}/roles/${roleId}`, { method: 'POST' });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
alert(err.error || 'Failed to add role');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await loadUsers(currentPage);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.adminRemoveRole = async function (userId, roleId) {
|
||||||
|
const res = await fetch(`/api/admin/users/${userId}/roles/${roleId}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
alert(err.error || 'Failed to remove role');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await loadUsers(currentPage);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.toggleRoleDropdown = function (btn) {
|
||||||
|
const dropdown = btn.nextElementSibling;
|
||||||
|
document.querySelectorAll('.add-role-dropdown.open').forEach(d => {
|
||||||
|
if (d !== dropdown) d.classList.remove('open');
|
||||||
|
});
|
||||||
|
dropdown.classList.toggle('open');
|
||||||
|
};
|
||||||
|
|
||||||
|
window.adminGoToPage = function (page) {
|
||||||
|
loadUsers(page);
|
||||||
|
};
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user