From e69e5663ce4739af5f5d59a9ef3f85b986bee7be Mon Sep 17 00:00:00 2001 From: Josh-Heaps Date: Mon, 9 Feb 2026 14:38:58 -0700 Subject: [PATCH] Add fleshed out admin page --- Media.JoshHeaps.Net/Api/AdminApi.cs | 184 +++++++++ .../Database/012_user_roles.sql | 16 +- Media.JoshHeaps.Net/Pages/Admin.cshtml | 40 ++ .../Pages/AuthenticatedPageModel.cs | 2 +- Media.JoshHeaps.Net/Pages/Landing.cshtml | 28 ++ Media.JoshHeaps.Net/Pages/Landing.cshtml.cs | 15 +- Media.JoshHeaps.Net/wwwroot/css/admin.css | 355 ++++++++++++++++++ Media.JoshHeaps.Net/wwwroot/js/admin.js | 155 ++++++++ 8 files changed, 788 insertions(+), 7 deletions(-) create mode 100644 Media.JoshHeaps.Net/Api/AdminApi.cs create mode 100644 Media.JoshHeaps.Net/wwwroot/css/admin.css create mode 100644 Media.JoshHeaps.Net/wwwroot/js/admin.js diff --git a/Media.JoshHeaps.Net/Api/AdminApi.cs b/Media.JoshHeaps.Net/Api/AdminApi.cs new file mode 100644 index 0000000..a1701e3 --- /dev/null +++ b/Media.JoshHeaps.Net/Api/AdminApi.cs @@ -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 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( + "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 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 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( + "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 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( + "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( + "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( + "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 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 IsAdmin(long userId) + { + return await dbExecutor.ExecuteAsync( + "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); diff --git a/Media.JoshHeaps.Net/Database/012_user_roles.sql b/Media.JoshHeaps.Net/Database/012_user_roles.sql index 127b589..23892e2 100644 --- a/Media.JoshHeaps.Net/Database/012_user_roles.sql +++ b/Media.JoshHeaps.Net/Database/012_user_roles.sql @@ -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 ( id BIGSERIAL PRIMARY KEY, 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, - 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); diff --git a/Media.JoshHeaps.Net/Pages/Admin.cshtml b/Media.JoshHeaps.Net/Pages/Admin.cshtml index 842cfcb..48ecbde 100644 --- a/Media.JoshHeaps.Net/Pages/Admin.cshtml +++ b/Media.JoshHeaps.Net/Pages/Admin.cshtml @@ -5,6 +5,10 @@ Layout = "_Layout"; } +@section Styles { + +} +
@@ -21,4 +25,40 @@ Logout
+ +
+
+

Roles

+
+
+
+ + +
+
+ +
+
+

Users

+
+
+ + + + + + + + + + + +
IDUsernameRolesActions
+
+ +
+ +@section Scripts { + +} diff --git a/Media.JoshHeaps.Net/Pages/AuthenticatedPageModel.cs b/Media.JoshHeaps.Net/Pages/AuthenticatedPageModel.cs index b2fa5a7..c559bf8 100644 --- a/Media.JoshHeaps.Net/Pages/AuthenticatedPageModel.cs +++ b/Media.JoshHeaps.Net/Pages/AuthenticatedPageModel.cs @@ -8,7 +8,7 @@ public abstract class AuthenticatedPageModel : PageModel protected async Task RequireRole(string role, DbExecutor dbExecutor) { var hasRole = await dbExecutor.ExecuteAsync( - "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 }); return hasRole ? null : NotFound(); diff --git a/Media.JoshHeaps.Net/Pages/Landing.cshtml b/Media.JoshHeaps.Net/Pages/Landing.cshtml index d599859..8f23e87 100644 --- a/Media.JoshHeaps.Net/Pages/Landing.cshtml +++ b/Media.JoshHeaps.Net/Pages/Landing.cshtml @@ -92,6 +92,34 @@ + + @if (Model.IsAdmin) + { + +
+
+
+ + + +
+
+
+

Admin

+

Site administration and management tools

+
+
+ +
+ }