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
This commit is contained in:
2026-05-01 16:18:12 +02:00
parent 161a5aa85e
commit d41559f013
10 changed files with 322 additions and 7 deletions

View File

@@ -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<IActionResult> 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<IActionResult> GetAllUsers(CancellationToken ct)
{

View File

@@ -0,0 +1,3 @@
namespace Randall.Application.Admin.AddUser;
public record AddUserCommand(string Email, string Name, string Password);

View File

@@ -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<Result<AdminUserDto>> HandleAsync(AddUserCommand command, CancellationToken ct = default)
{
var exists = await userRepository.ExistsByEmailAsync(command.Email, ct);
if (exists)
return Result.Failure<AdminUserDto>("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<AdminUserDto>(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));
}
}

View File

@@ -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<RegisterHandler>();
services.AddScoped<LoginHandler>();
services.AddScoped<AddUserHandler>();
services.AddScoped<GetAllUsersHandler>();
services.AddScoped<GetPendingUsersHandler>();
services.AddScoped<ApproveUserHandler>();

View File

@@ -146,4 +146,85 @@ public class AdminTests(CustomWebApplicationFactory factory) : IClassFixture<Cus
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task AddUser_WithoutAuth_Returns401()
{
var client = factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/admin/users",
new { Email = "new@test.com", Name = "New User", Password = "Test@1234" });
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task AddUser_WithNonAdmin_Returns403()
{
var email = $"{Guid.NewGuid():N}@test.com";
var token = await AuthHelper.CreateApprovedUserAndLoginAsync(factory, email);
var client = factory.CreateClient();
AuthHelper.SetBearerToken(client, token);
var response = await client.PostAsJsonAsync("/api/admin/users",
new { Email = $"{Guid.NewGuid():N}@test.com", Name = "New User", Password = "Test@1234" });
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task AddUser_WithAdmin_Returns201WithUserDetails()
{
var client = factory.CreateClient();
var adminLogin = await AuthHelper.LoginAsAdminAsync(client);
AuthHelper.SetBearerToken(client, adminLogin.Token);
var email = $"{Guid.NewGuid():N}@test.com";
var response = await client.PostAsJsonAsync("/api/admin/users",
new { Email = email, Name = "New User", Password = "Test@1234" });
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var user = await response.Content.ReadFromJsonAsync<AdminUserResponse>(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);
}
}

View File

@@ -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<Program>
{
base.Dispose(disposing);
if (disposing && File.Exists(_dbPath))
{
SqliteConnection.ClearAllPools();
File.Delete(_dbPath);
}
}
}

View File

@@ -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<RegisterPendingResponse>(res);
},
async addUser(data: AddUserRequest): Promise<AdminUser> {
const res = await fetch(`${BASE}/admin/users`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(data),
});
return handleResponse<AdminUser>(res);
},
getAdminUsers(): Promise<AdminUser[]> {
return fetch(`${BASE}/admin/users`, {
headers: authHeaders(),

View File

@@ -59,3 +59,9 @@ export interface AdminUser {
isApproved: boolean;
isAdmin: boolean;
}
export interface AddUserRequest {
email: string;
name: string;
password: string;
}

View File

@@ -28,6 +28,10 @@ export function AdminPage({ onLogout }: AdminPageProps) {
const [deletingId, setDeletingId] = useState<string | null>(null);
const [confirmAdminId, setConfirmAdminId] = useState<string | null>(null);
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(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) {
}}
/>
</div>
<button style={{
<button
onClick={() => { setAddForm({ name: '', email: '', password: '' }); setAddError(''); setShowAddModal(true); }}
style={{
padding: '8px 16px', borderRadius: 99,
background: SAGE, border: `1px solid ${SAGE_DEEP}`,
color: PURPLE_DEEP, fontSize: 12, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}>
}}
>
+ Add user
</button>
</div>
@@ -438,6 +461,96 @@ export function AdminPage({ onLogout }: AdminPageProps) {
</div>
</div>
</div>
{/* Add user modal */}
{showAddModal && (
<div
onClick={() => 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,
}}
>
<div
onClick={(e) => 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",
}}
>
<h2 style={{
margin: '0 0 24px',
fontFamily: "'Rubik Mono One', monospace",
fontSize: 22, fontWeight: 400, letterSpacing: '-0.02em', color: PURPLE,
}}>
Add user
</h2>
<form onSubmit={handleAddUser} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{(['name', 'email', 'password'] as const).map((field) => (
<div key={field}>
<label style={{
display: 'block', fontSize: 10, letterSpacing: '0.16em',
textTransform: 'uppercase', color: PURPLE, opacity: 0.7,
fontWeight: 500, marginBottom: 6,
}}>
{field}
</label>
<input
type={field === 'password' ? 'password' : field === 'email' ? 'email' : 'text'}
value={addForm[field]}
onChange={(e) => 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',
}}
/>
</div>
))}
{addError && (
<p style={{ margin: 0, fontSize: 12, color: '#c0392b' }}>{addError}</p>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10, marginTop: 8 }}>
<button
type="button"
onClick={() => setShowAddModal(false)}
style={{
background: 'none', border: 'none', padding: '8px 14px',
fontSize: 13, color: PURPLE, opacity: 0.65,
cursor: 'pointer', fontFamily: 'inherit',
}}
>
Cancel
</button>
<button
type="submit"
disabled={addSubmitting}
style={{
padding: '8px 20px', borderRadius: 99,
background: addSubmitting ? SAGE : PURPLE,
border: `1px solid ${addSubmitting ? SAGE_DEEP : PURPLE_DEEP}`,
color: addSubmitting ? PURPLE_DEEP : '#fff',
fontSize: 12, fontWeight: 500,
cursor: addSubmitting ? 'default' : 'pointer',
fontFamily: 'inherit',
}}
>
{addSubmitting ? '…' : 'Add user'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@@ -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();
});
});