Add fleshed out admin page

This commit is contained in:
Josh-Heaps
2026-02-09 14:38:58 -07:00
parent 14d748de80
commit e69e5663ce
8 changed files with 788 additions and 7 deletions
+184
View File
@@ -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);
+40
View File
@@ -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();
+28
View File
@@ -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">
+12 -3
View File
@@ -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();
} }
} }
} }
+355
View File
@@ -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;
}
}
+155
View File
@@ -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">&times;</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);
};
})();