From d41559f0134ab7935ebd8b05389f0fc9e9a52a6b Mon Sep 17 00:00:00 2001 From: Robert van Diest Date: Fri, 1 May 2026 16:18:12 +0200 Subject: [PATCH] feat(admin): add create-user flow with API, UI, and tests Admins can now add users directly from the admin portal via a modal form (name, email, password). Created users are pre-approved and can log in immediately. - POST /api/admin/users endpoint (AddUserHandler, AddUserCommand) - Add user modal in AdminPage with inline error handling - 5 new integration tests covering auth, happy path, duplicate email, and immediate login; fix SQLite file-lock on test cleanup via SqliteConnection.ClearAllPools() - 4 new E2E tests covering modal open/close, happy path, and duplicate email error --- .../src/Randall.Api/Admin/AdminController.cs | 14 ++ .../Admin/AddUser/AddUserCommand.cs | 3 + .../Admin/AddUser/AddUserHandler.cs | 30 +++++ .../DependencyInjection.cs | 2 + .../Admin/AdminTests.cs | 81 ++++++++++++ .../CustomWebApplicationFactory.cs | 4 + src/frontend/src/api/client.ts | 11 +- src/frontend/src/api/types.ts | 6 + src/frontend/src/pages/AdminPage.tsx | 125 +++++++++++++++++- tests/e2e/tests/admin.spec.ts | 53 ++++++++ 10 files changed, 322 insertions(+), 7 deletions(-) create mode 100644 src/backend/src/Randall.Application/Admin/AddUser/AddUserCommand.cs create mode 100644 src/backend/src/Randall.Application/Admin/AddUser/AddUserHandler.cs diff --git a/src/backend/src/Randall.Api/Admin/AdminController.cs b/src/backend/src/Randall.Api/Admin/AdminController.cs index 566a208..bef1b00 100644 --- a/src/backend/src/Randall.Api/Admin/AdminController.cs +++ b/src/backend/src/Randall.Api/Admin/AdminController.cs @@ -1,6 +1,7 @@ using System.Security.Claims; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Randall.Application.Admin.AddUser; using Randall.Application.Admin.ApproveUser; using Randall.Application.Admin.DeleteUser; using Randall.Application.Admin.GetAllUsers; @@ -9,10 +10,13 @@ using Randall.Application.Admin.MakeAdmin; namespace Randall.Api.Admin; +public record AddUserRequest(string Email, string Name, string Password); + [ApiController] [Route("api/admin")] [Authorize] public class AdminController( + AddUserHandler addUserHandler, GetAllUsersHandler getAllUsersHandler, GetPendingUsersHandler getPendingUsersHandler, ApproveUserHandler approveUserHandler, @@ -23,6 +27,16 @@ public class AdminController( private Guid RequesterId => Guid.Parse(User.FindFirstValue(System.Security.Claims.ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")!); + [HttpPost("users")] + public async Task AddUser([FromBody] AddUserRequest request, CancellationToken ct) + { + if (!IsAdmin) return Forbid(); + var result = await addUserHandler.HandleAsync(new AddUserCommand(request.Email, request.Name, request.Password), ct); + if (!result.IsSuccess) + return BadRequest(new ProblemDetails { Detail = result.Error }); + return CreatedAtAction(nameof(GetAllUsers), result.Value); + } + [HttpGet("users")] public async Task GetAllUsers(CancellationToken ct) { diff --git a/src/backend/src/Randall.Application/Admin/AddUser/AddUserCommand.cs b/src/backend/src/Randall.Application/Admin/AddUser/AddUserCommand.cs new file mode 100644 index 0000000..d101a28 --- /dev/null +++ b/src/backend/src/Randall.Application/Admin/AddUser/AddUserCommand.cs @@ -0,0 +1,3 @@ +namespace Randall.Application.Admin.AddUser; + +public record AddUserCommand(string Email, string Name, string Password); diff --git a/src/backend/src/Randall.Application/Admin/AddUser/AddUserHandler.cs b/src/backend/src/Randall.Application/Admin/AddUser/AddUserHandler.cs new file mode 100644 index 0000000..a6246e8 --- /dev/null +++ b/src/backend/src/Randall.Application/Admin/AddUser/AddUserHandler.cs @@ -0,0 +1,30 @@ +using Randall.Application.Admin.GetAllUsers; +using Randall.Application.Common; +using Randall.Domain.Common; +using Randall.Domain.Users; + +namespace Randall.Application.Admin.AddUser; + +public class AddUserHandler(IUserRepository userRepository, IPasswordHasher passwordHasher) +{ + public async Task> HandleAsync(AddUserCommand command, CancellationToken ct = default) + { + var exists = await userRepository.ExistsByEmailAsync(command.Email, ct); + if (exists) + return Result.Failure("An account with this email already exists."); + + var hash = passwordHasher.Hash(command.Password); + + var result = User.Create(command.Email, command.Name, hash); + if (!result.IsSuccess) + return Result.Failure(result.Error!); + + var user = result.Value!; + user.Approve(); + + await userRepository.AddAsync(user, ct); + await userRepository.SaveChangesAsync(ct); + + return Result.Success(new AdminUserDto(user.Id, user.Name, user.Email, user.IsApproved, user.IsAdmin)); + } +} diff --git a/src/backend/src/Randall.Application/DependencyInjection.cs b/src/backend/src/Randall.Application/DependencyInjection.cs index 13921a4..01c6e64 100644 --- a/src/backend/src/Randall.Application/DependencyInjection.cs +++ b/src/backend/src/Randall.Application/DependencyInjection.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using Randall.Application.Admin.AddUser; using Randall.Application.Admin.ApproveUser; using Randall.Application.Admin.DeleteUser; using Randall.Application.Admin.GetAllUsers; @@ -22,6 +23,7 @@ public static class DependencyInjection { services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/backend/tests/integration/Randall.Api.IntegrationTests/Admin/AdminTests.cs b/src/backend/tests/integration/Randall.Api.IntegrationTests/Admin/AdminTests.cs index ee66665..65da9fe 100644 --- a/src/backend/tests/integration/Randall.Api.IntegrationTests/Admin/AdminTests.cs +++ b/src/backend/tests/integration/Randall.Api.IntegrationTests/Admin/AdminTests.cs @@ -146,4 +146,85 @@ public class AdminTests(CustomWebApplicationFactory factory) : IClassFixture(AuthHelper.JsonOptions); + Assert.NotNull(user); + Assert.Equal(email, user.Email); + Assert.Equal("New User", user.Name); + Assert.True(user.IsApproved); + Assert.False(user.IsAdmin); + } + + [Fact] + public async Task AddUser_DuplicateEmail_ReturnsBadRequest() + { + var client = factory.CreateClient(); + var adminLogin = await AuthHelper.LoginAsAdminAsync(client); + AuthHelper.SetBearerToken(client, adminLogin.Token); + + var email = $"{Guid.NewGuid():N}@test.com"; + await client.PostAsJsonAsync("/api/admin/users", + new { Email = email, Name = "First User", Password = "Test@1234" }); + + var response = await client.PostAsJsonAsync("/api/admin/users", + new { Email = email, Name = "Duplicate User", Password = "Test@1234" }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task AddUser_CreatedUserCanLoginImmediately() + { + var adminClient = factory.CreateClient(); + var adminLogin = await AuthHelper.LoginAsAdminAsync(adminClient); + AuthHelper.SetBearerToken(adminClient, adminLogin.Token); + + var email = $"{Guid.NewGuid():N}@test.com"; + const string password = "Test@1234"; + await adminClient.PostAsJsonAsync("/api/admin/users", + new { Email = email, Name = "Direct User", Password = password }); + + var loginClient = factory.CreateClient(); + var login = await AuthHelper.LoginAsync(loginClient, email, password); + + Assert.NotEmpty(login.Token); + } } diff --git a/src/backend/tests/integration/Randall.Api.IntegrationTests/CustomWebApplicationFactory.cs b/src/backend/tests/integration/Randall.Api.IntegrationTests/CustomWebApplicationFactory.cs index b20ad3e..46ba4e0 100644 --- a/src/backend/tests/integration/Randall.Api.IntegrationTests/CustomWebApplicationFactory.cs +++ b/src/backend/tests/integration/Randall.Api.IntegrationTests/CustomWebApplicationFactory.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Data.Sqlite; using Microsoft.Extensions.Configuration; namespace Randall.Api.IntegrationTests; @@ -28,6 +29,9 @@ public class CustomWebApplicationFactory : WebApplicationFactory { base.Dispose(disposing); if (disposing && File.Exists(_dbPath)) + { + SqliteConnection.ClearAllPools(); File.Delete(_dbPath); + } } } diff --git a/src/frontend/src/api/client.ts b/src/frontend/src/api/client.ts index 17e3504..df44264 100644 --- a/src/frontend/src/api/client.ts +++ b/src/frontend/src/api/client.ts @@ -1,4 +1,4 @@ -import type { AdminUser, AuthResponse, CreateReservationRequest, PendingUser, RegisterPendingResponse, Reservation, WorkplaceScheduleItem } from './types'; +import type { AddUserRequest, AdminUser, AuthResponse, CreateReservationRequest, PendingUser, RegisterPendingResponse, Reservation, WorkplaceScheduleItem } from './types'; const BASE = '/api'; @@ -38,6 +38,15 @@ export const api = { return handleResponse(res); }, + async addUser(data: AddUserRequest): Promise { + const res = await fetch(`${BASE}/admin/users`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify(data), + }); + return handleResponse(res); + }, + getAdminUsers(): Promise { return fetch(`${BASE}/admin/users`, { headers: authHeaders(), diff --git a/src/frontend/src/api/types.ts b/src/frontend/src/api/types.ts index c5f9414..93f710a 100644 --- a/src/frontend/src/api/types.ts +++ b/src/frontend/src/api/types.ts @@ -59,3 +59,9 @@ export interface AdminUser { isApproved: boolean; isAdmin: boolean; } + +export interface AddUserRequest { + email: string; + name: string; + password: string; +} diff --git a/src/frontend/src/pages/AdminPage.tsx b/src/frontend/src/pages/AdminPage.tsx index 07661b0..32dd0a6 100644 --- a/src/frontend/src/pages/AdminPage.tsx +++ b/src/frontend/src/pages/AdminPage.tsx @@ -28,6 +28,10 @@ export function AdminPage({ onLogout }: AdminPageProps) { const [deletingId, setDeletingId] = useState(null); const [confirmAdminId, setConfirmAdminId] = useState(null); const [confirmDeleteId, setConfirmDeleteId] = useState(null); + const [showAddModal, setShowAddModal] = useState(false); + const [addForm, setAddForm] = useState({ name: '', email: '', password: '' }); + const [addError, setAddError] = useState(''); + const [addSubmitting, setAddSubmitting] = useState(false); async function load() { setLoading(true); @@ -81,6 +85,22 @@ export function AdminPage({ onLogout }: AdminPageProps) { } } + async function handleAddUser(e: React.FormEvent) { + e.preventDefault(); + setAddSubmitting(true); + setAddError(''); + try { + const newUser = await api.addUser(addForm); + setUsers((prev) => [...prev, newUser]); + setShowAddModal(false); + setAddForm({ name: '', email: '', password: '' }); + } catch (err) { + setAddError(err instanceof Error ? err.message : 'Failed to add user'); + } finally { + setAddSubmitting(false); + } + } + const filtered = users.filter((u) => { const q = search.toLowerCase(); return !q || u.name.toLowerCase().includes(q) || u.email.toLowerCase().includes(q); @@ -241,12 +261,15 @@ export function AdminPage({ onLogout }: AdminPageProps) { }} /> - @@ -438,6 +461,96 @@ export function AdminPage({ onLogout }: AdminPageProps) { + + {/* Add user modal */} + {showAddModal && ( +
setShowAddModal(false)} + style={{ + position: 'fixed', inset: 0, + background: 'rgba(63,51,168,0.18)', backdropFilter: 'blur(2px)', + display: 'flex', alignItems: 'center', justifyContent: 'center', + zIndex: 100, + }} + > +
e.stopPropagation()} + style={{ + background: PAPER, borderRadius: 20, + border: '1px solid rgba(91,79,199,0.15)', + padding: '32px 32px 28px', + width: 380, boxShadow: '0 8px 40px rgba(63,51,168,0.14)', + fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif", + }} + > +

+ Add user +

+
+ {(['name', 'email', 'password'] as const).map((field) => ( +
+ + setAddForm((prev) => ({ ...prev, [field]: e.target.value }))} + required + style={{ + width: '100%', boxSizing: 'border-box', + padding: '9px 13px', borderRadius: 10, + border: '1px solid rgba(91,79,199,0.22)', + background: 'rgba(255,255,255,0.7)', + fontSize: 13, color: PURPLE_DEEP, fontFamily: 'inherit', + outline: 'none', + }} + /> +
+ ))} + {addError && ( +

{addError}

+ )} +
+ + +
+
+
+
+ )} ); } diff --git a/tests/e2e/tests/admin.spec.ts b/tests/e2e/tests/admin.spec.ts index fcef95b..1f16b39 100644 --- a/tests/e2e/tests/admin.spec.ts +++ b/tests/e2e/tests/admin.spec.ts @@ -45,4 +45,57 @@ test.describe('Admin portal', () => { await page.waitForURL('/'); await expect(page.getByRole('heading', { name: /Where to sit/i })).toBeVisible(); }); + + test('+ Add user button opens the add-user modal', async ({ page }) => { + await page.getByRole('button', { name: 'Admin' }).click(); + await page.waitForURL('/admin'); + + await page.getByRole('button', { name: '+ Add user' }).click(); + + await expect(page.getByRole('heading', { name: 'Add user' })).toBeVisible(); + await expect(page.locator('form input[type="text"]')).toBeVisible(); + await expect(page.locator('form input[type="email"]')).toBeVisible(); + await expect(page.locator('form input[type="password"]')).toBeVisible(); + }); + + test('add-user modal closes when Cancel is clicked', async ({ page }) => { + await page.getByRole('button', { name: 'Admin' }).click(); + await page.waitForURL('/admin'); + + await page.getByRole('button', { name: '+ Add user' }).click(); + await expect(page.getByRole('heading', { name: 'Add user' })).toBeVisible(); + + await page.locator('form').getByRole('button', { name: 'Cancel' }).click(); + + await expect(page.getByRole('heading', { name: 'Add user' })).not.toBeVisible(); + }); + + test('can add a new user via the modal and see them in the table', async ({ page }) => { + await page.getByRole('button', { name: 'Admin' }).click(); + await page.waitForURL('/admin'); + + const email = `adduser+${Date.now()}@test.com`; + + await page.getByRole('button', { name: '+ Add user' }).click(); + await page.locator('form input[type="text"]').fill('New Test User'); + await page.locator('form input[type="email"]').fill(email); + await page.locator('form input[type="password"]').fill('Test@1234'); + await page.locator('form button[type="submit"]').click(); + + await expect(page.getByText(email)).toBeVisible(); + }); + + test('add-user modal shows an error for a duplicate email', async ({ page }) => { + await page.getByRole('button', { name: 'Admin' }).click(); + await page.waitForURL('/admin'); + + await page.getByRole('button', { name: '+ Add user' }).click(); + await page.locator('form input[type="text"]').fill('Duplicate'); + await page.locator('form input[type="email"]').fill('admin@randall.local'); + await page.locator('form input[type="password"]').fill('Test@1234'); + await page.locator('form button[type="submit"]').click(); + + await expect(page.getByText(/already exists/i)).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Add user' })).toBeVisible(); + }); });