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:
@@ -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)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Randall.Application.Admin.AddUser;
|
||||
|
||||
public record AddUserCommand(string Email, string Name, string Password);
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -59,3 +59,9 @@ export interface AdminUser {
|
||||
isApproved: boolean;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
export interface AddUserRequest {
|
||||
email: string;
|
||||
name: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
@@ -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={{
|
||||
padding: '8px 16px', borderRadius: 99,
|
||||
background: SAGE, border: `1px solid ${SAGE_DEEP}`,
|
||||
color: PURPLE_DEEP, fontSize: 12, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user