Compare commits

16 Commits

Author SHA1 Message Date
d41559f013 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
2026-05-01 16:18:12 +02:00
161a5aa85e fix(frontend): remove unused variables causing tsc build failure
Remove unused `auth` prop from AdminPage (and its call site in App.tsx)
and unused `maxDate` variable in PlannerPage. Both triggered TS6133 under
noUnusedLocals, causing `npm run build` to exit with code 2 in Docker.
2026-04-30 16:51:01 +02:00
3a63ec8dcc test(e2e): update Playwright suite for Respellion UI 2026-04-30 16:35:28 +02:00
74a05253e7 feat(frontend): implement Respellion house-style design 2026-04-30 16:35:06 +02:00
Robert van Diest
4468e7b891 refactor(domain): introduce UserFactory and WorkplaceFactory for reconstitution
Consistent with ReservationFactory — moves Reconstitute out of the entity
into a dedicated factory class, and makes the reconstitution constructor
internal to restrict direct instantiation to within the domain assembly.
Adds unit tests for all three factories.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 17:13:55 +01:00
Robert van Diest
da698224a7 fix(docker): pull node and nginx from ECR Public mirror
GitHub Actions runner IP is banned from Docker Hub. Using
public.ecr.aws/docker/library as a temporary workaround.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 17:04:41 +01:00
Robert van Diest
72751f6491 fix(docker): prefix compose image names with localhost to prevent Docker Hub lookups
randall/frontend and randall/backend were being resolved as docker.io
paths, causing unwanted registry auth attempts. The localhost/ prefix
marks them as local-only images. Also restores docker/setup-buildx-action
with the docker driver, and reverts the ECR Public mirror workaround
since it was never the root cause.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 17:02:40 +01:00
Robert van Diest
a7ad3d6ebf fix(docker): pull node and nginx from ECR Public mirror
Avoids Docker Hub rate limits and auth issues in CI by using
public.ecr.aws/docker/library instead of docker.io.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 17:00:06 +01:00
Robert van Diest
b09b40b888 chore(ci): remove docker/setup-buildx-action step
BuildKit is enabled by default on ubuntu-latest runners.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 16:56:36 +01:00
Robert van Diest
bc64d2ad5c fix(ci): use docker driver for buildx to avoid Docker Hub auth
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 16:54:07 +01:00
Robert van Diest
106acedce8 refactor(domain): introduce ReservationFactory for reconstitution
Replace the Reconstitute static method on Reservation with a dedicated
ReservationFactory class. The reconstitution constructor is now internal,
restricting direct instantiation to within the domain assembly while
keeping the factory as the explicit entry point for rehydrating from storage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 16:49:28 +01:00
Robert van Diest
649c11b21a refactor(persistence): separate storage models from domain entities
Introduce dedicated *Record POCOs (ReservationRecord, UserRecord,
WorkplaceRecord) as EF Core targets, keeping domain entities free of
persistence concerns. Static mapper classes handle conversion between
layers, and repositories track (domain, record) pairs to sync mutations
back before saving. Domain entities gain Reconstitute() factory methods
to bypass validation when rehydrating from storage.

Also fixes GetByEmployeeAsync to exclude past reservations (Date >= today).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 16:45:20 +01:00
Robert van Diest
4c71a1b4c8 chore(docker): optimize builds and name images
- Add BuildKit cache mounts for NuGet and npm to persist package
  caches across builds
- Skip redundant restore on dotnet publish with --no-restore
- Add --prefer-offline to npm ci to prefer cached tarballs
- Tag images as randall/backend:latest and randall/frontend:latest
  via compose image: key

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 16:21:13 +01:00
Robert van Diest
cba080acfe chore(git): add Claude-specific gitignore entries
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 16:06:01 +01:00
Robert van Diest
d28daa8361 chore(claude): add custom /commit slash command
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 16:01:44 +01:00
Robert van Diest
5677e4626f chore(claude): remove local settings file
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 16:00:34 +01:00
54 changed files with 2396 additions and 584 deletions

View File

@@ -0,0 +1,31 @@
# Git Commit
Look at all staged changes using `git diff --cached` and create a descriptive git commit message that follows these rules:
- Use conventional commit format: `type(scope): description`
- Types: feat, fix, docs, refactor, test, chore
- Keep the subject line under 72 characters
- Add a short body if the change is complex and needs explanation
- Be specific about what changed and why
Then run the commit with that message.
## Examples of good commit messages
- `feat(auth): add JWT token refresh endpoint`
- `fix(api): handle null response from payment gateway`
- `refactor(db): extract connection pooling into separate module`
- `docs(readme): add setup instructions for Windows`
```
---
**How to run it**
In Claude Code:
```
/commit
```
Or with a hint:
```
/commit this fixes the login bug we discussed

View File

@@ -1,18 +0,0 @@
{
"permissions": {
"allow": [
"Bash(dotnet new:*)",
"Bash(dotnet sln:*)",
"Bash(dotnet add:*)",
"Bash(dotnet build:*)",
"Bash(dotnet tool:*)",
"Bash(dotnet ef:*)",
"Bash(timeout 8 dotnet run)",
"Bash(npm create:*)",
"Bash(npm install:*)",
"Bash(npm run:*)",
"Bash(npx playwright:*)",
"Bash(dotnet test:*)"
]
}
}

View File

@@ -53,6 +53,8 @@ jobs:
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
with:
driver: docker
- name: Build images - name: Build images
run: docker compose -f cicd/docker/docker-compose.yml build run: docker compose -f cicd/docker/docker-compose.yml build

7
.gitignore vendored
View File

@@ -52,3 +52,10 @@ tests/e2e/.cache/
# ------------------------- # -------------------------
*.log *.log
logs/ logs/
# -------------------------
# Claude
# -------------------------
.claude/settings.local.json
.claude/cache/
.claude/sessions/

View File

@@ -8,12 +8,14 @@ COPY src/backend/src/Randall.Domain/Randall.Domain.csproj s
COPY src/backend/src/Randall.Application/Randall.Application.csproj src/backend/src/Randall.Application/ COPY src/backend/src/Randall.Application/Randall.Application.csproj src/backend/src/Randall.Application/
COPY src/backend/src/Randall.Infrastructure/Randall.Infrastructure.csproj src/backend/src/Randall.Infrastructure/ COPY src/backend/src/Randall.Infrastructure/Randall.Infrastructure.csproj src/backend/src/Randall.Infrastructure/
COPY src/backend/src/Randall.Api/Randall.Api.csproj src/backend/src/Randall.Api/ COPY src/backend/src/Randall.Api/Randall.Api.csproj src/backend/src/Randall.Api/
RUN dotnet restore "src/backend/src/Randall.Api/Randall.Api.csproj" RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
dotnet restore "src/backend/src/Randall.Api/Randall.Api.csproj"
# Build — copy source only (no bin/obj/db files) # Build — copy source only (no bin/obj/db files)
COPY src/backend/src/ src/backend/src/ COPY src/backend/src/ src/backend/src/
RUN dotnet publish "src/backend/src/Randall.Api/Randall.Api.csproj" \ RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
-c Release -o /app/publish dotnet publish "src/backend/src/Randall.Api/Randall.Api.csproj" \
-c Release --no-restore -o /app/publish
# Runtime stage # Runtime stage
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime

View File

@@ -1,10 +1,11 @@
# Build stage # Build stage
FROM node:22-alpine AS build FROM public.ecr.aws/docker/library/node:22-alpine AS build
WORKDIR /app WORKDIR /app
# Install dependencies — copy lockfiles first to cache the npm layer # Install dependencies — copy lockfiles first to cache the npm layer
COPY src/frontend/package.json src/frontend/package-lock.json ./ COPY src/frontend/package.json src/frontend/package-lock.json ./
RUN npm ci RUN --mount=type=cache,id=npm,target=/root/.npm \
npm ci --prefer-offline
# Copy source # Copy source
COPY src/frontend/index.html ./ COPY src/frontend/index.html ./
@@ -17,7 +18,7 @@ COPY src/frontend/public/ ./public/
RUN npm run build RUN npm run build
# Serve stage # Serve stage
FROM nginx:alpine AS runtime FROM public.ecr.aws/docker/library/nginx:alpine AS runtime
COPY --from=build /app/dist /usr/share/nginx/html COPY --from=build /app/dist /usr/share/nginx/html
COPY cicd/docker/nginx.conf /etc/nginx/conf.d/default.conf COPY cicd/docker/nginx.conf /etc/nginx/conf.d/default.conf

View File

@@ -1,5 +1,6 @@
services: services:
backend: backend:
image: localhost/randall/backend:latest
build: build:
context: ../.. context: ../..
dockerfile: cicd/docker/Dockerfile.backend dockerfile: cicd/docker/Dockerfile.backend
@@ -20,6 +21,7 @@ services:
start_period: 10s start_period: 10s
frontend: frontend:
image: localhost/randall/frontend:latest
build: build:
context: ../.. context: ../..
dockerfile: cicd/docker/Dockerfile.frontend dockerfile: cicd/docker/Dockerfile.frontend

View File

@@ -1,6 +1,7 @@
using System.Security.Claims; using System.Security.Claims;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Randall.Application.Admin.AddUser;
using Randall.Application.Admin.ApproveUser; using Randall.Application.Admin.ApproveUser;
using Randall.Application.Admin.DeleteUser; using Randall.Application.Admin.DeleteUser;
using Randall.Application.Admin.GetAllUsers; using Randall.Application.Admin.GetAllUsers;
@@ -9,10 +10,13 @@ using Randall.Application.Admin.MakeAdmin;
namespace Randall.Api.Admin; namespace Randall.Api.Admin;
public record AddUserRequest(string Email, string Name, string Password);
[ApiController] [ApiController]
[Route("api/admin")] [Route("api/admin")]
[Authorize] [Authorize]
public class AdminController( public class AdminController(
AddUserHandler addUserHandler,
GetAllUsersHandler getAllUsersHandler, GetAllUsersHandler getAllUsersHandler,
GetPendingUsersHandler getPendingUsersHandler, GetPendingUsersHandler getPendingUsersHandler,
ApproveUserHandler approveUserHandler, ApproveUserHandler approveUserHandler,
@@ -23,6 +27,16 @@ public class AdminController(
private Guid RequesterId => Guid.Parse(User.FindFirstValue(System.Security.Claims.ClaimTypes.NameIdentifier) private Guid RequesterId => Guid.Parse(User.FindFirstValue(System.Security.Claims.ClaimTypes.NameIdentifier)
?? User.FindFirstValue("sub")!); ?? 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")] [HttpGet("users")]
public async Task<IActionResult> GetAllUsers(CancellationToken ct) 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 Microsoft.Extensions.DependencyInjection;
using Randall.Application.Admin.AddUser;
using Randall.Application.Admin.ApproveUser; using Randall.Application.Admin.ApproveUser;
using Randall.Application.Admin.DeleteUser; using Randall.Application.Admin.DeleteUser;
using Randall.Application.Admin.GetAllUsers; using Randall.Application.Admin.GetAllUsers;
@@ -22,6 +23,7 @@ public static class DependencyInjection
{ {
services.AddScoped<RegisterHandler>(); services.AddScoped<RegisterHandler>();
services.AddScoped<LoginHandler>(); services.AddScoped<LoginHandler>();
services.AddScoped<AddUserHandler>();
services.AddScoped<GetAllUsersHandler>(); services.AddScoped<GetAllUsersHandler>();
services.AddScoped<GetPendingUsersHandler>(); services.AddScoped<GetPendingUsersHandler>();
services.AddScoped<ApproveUserHandler>(); services.AddScoped<ApproveUserHandler>();

View File

@@ -13,11 +13,16 @@ public class Reservation : Entity
public ReservationStatus Status { get; private set; } public ReservationStatus Status { get; private set; }
public DateTime CreatedAt { get; private set; } public DateTime CreatedAt { get; private set; }
// EF Core constructor internal Reservation(
private Reservation() : base() Guid id, Guid workplaceId, string employeeEmail, string employeeName,
DateOnly date, ReservationStatus status, DateTime createdAt) : base(id)
{ {
EmployeeEmail = string.Empty; WorkplaceId = workplaceId;
EmployeeName = string.Empty; EmployeeEmail = employeeEmail;
EmployeeName = employeeName;
Date = date;
Status = status;
CreatedAt = createdAt;
} }
private Reservation(Guid workplaceId, string employeeEmail, string employeeName, DateOnly date) private Reservation(Guid workplaceId, string employeeEmail, string employeeName, DateOnly date)

View File

@@ -0,0 +1,9 @@
namespace Randall.Domain.Reservations;
public static class ReservationFactory
{
public static Reservation Reconstitute(
Guid id, Guid workplaceId, string employeeEmail, string employeeName,
DateOnly date, ReservationStatus status, DateTime createdAt) =>
new(id, workplaceId, employeeEmail, employeeName, date, status, createdAt);
}

View File

@@ -10,11 +10,13 @@ public class User : Entity
public bool IsApproved { get; private set; } public bool IsApproved { get; private set; }
public bool IsAdmin { get; private set; } public bool IsAdmin { get; private set; }
private User() : base() internal User(Guid id, string email, string name, string passwordHash, bool isApproved, bool isAdmin) : base(id)
{ {
Email = string.Empty; Email = email;
Name = string.Empty; Name = name;
PasswordHash = string.Empty; PasswordHash = passwordHash;
IsApproved = isApproved;
IsAdmin = isAdmin;
} }
private User(string email, string name, string passwordHash, bool isAdmin) : base() private User(string email, string name, string passwordHash, bool isAdmin) : base()

View File

@@ -0,0 +1,7 @@
namespace Randall.Domain.Users;
public static class UserFactory
{
public static User Reconstitute(Guid id, string email, string name, string passwordHash, bool isApproved, bool isAdmin) =>
new(id, email, name, passwordHash, isApproved, isAdmin);
}

View File

@@ -8,10 +8,11 @@ public class Workplace : Entity
public string Location { get; private set; } public string Location { get; private set; }
public bool IsActive { get; private set; } public bool IsActive { get; private set; }
private Workplace() : base() internal Workplace(Guid id, string name, string location, bool isActive) : base(id)
{ {
Name = string.Empty; Name = name;
Location = string.Empty; Location = location;
IsActive = isActive;
} }
public Workplace(string name, string location) : base() public Workplace(string name, string location) : base()

View File

@@ -0,0 +1,7 @@
namespace Randall.Domain.Workplaces;
public static class WorkplaceFactory
{
public static Workplace Reconstitute(Guid id, string name, string location, bool isActive) =>
new(id, name, location, isActive);
}

View File

@@ -0,0 +1,120 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Randall.Infrastructure.Persistence;
#nullable disable
namespace Randall.Infrastructure.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260327154255_SeparateStorageModels")]
partial class SeparateStorageModels
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
modelBuilder.Entity("Randall.Infrastructure.Persistence.Records.ReservationRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT");
b.Property<string>("EmployeeEmail")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("EmployeeName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<Guid>("WorkplaceId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("EmployeeEmail", "Date");
b.HasIndex("WorkplaceId", "Date");
b.ToTable("Reservations", (string)null);
});
modelBuilder.Entity("Randall.Infrastructure.Persistence.Records.UserRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<bool>("IsAdmin")
.HasColumnType("INTEGER");
b.Property<bool>("IsApproved")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Email")
.IsUnique();
b.ToTable("Users", (string)null);
});
modelBuilder.Entity("Randall.Infrastructure.Persistence.Records.WorkplaceRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<string>("Location")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Workplaces", (string)null);
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Randall.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class SeparateStorageModels : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

View File

@@ -17,7 +17,7 @@ namespace Randall.Infrastructure.Migrations
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
modelBuilder.Entity("Randall.Domain.Reservations.Reservation", b => modelBuilder.Entity("Randall.Infrastructure.Persistence.Records.ReservationRecord", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -51,10 +51,10 @@ namespace Randall.Infrastructure.Migrations
b.HasIndex("WorkplaceId", "Date"); b.HasIndex("WorkplaceId", "Date");
b.ToTable("Reservations"); b.ToTable("Reservations", (string)null);
}); });
modelBuilder.Entity("Randall.Domain.Users.User", b => modelBuilder.Entity("Randall.Infrastructure.Persistence.Records.UserRecord", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -85,10 +85,10 @@ namespace Randall.Infrastructure.Migrations
b.HasIndex("Email") b.HasIndex("Email")
.IsUnique(); .IsUnique();
b.ToTable("Users"); b.ToTable("Users", (string)null);
}); });
modelBuilder.Entity("Randall.Domain.Workplaces.Workplace", b => modelBuilder.Entity("Randall.Infrastructure.Persistence.Records.WorkplaceRecord", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -109,7 +109,7 @@ namespace Randall.Infrastructure.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("Workplaces"); b.ToTable("Workplaces", (string)null);
}); });
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }

View File

@@ -1,28 +1,28 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Randall.Domain.Reservations; using Randall.Infrastructure.Persistence.Records;
using Randall.Domain.Users;
using Randall.Domain.Workplaces;
namespace Randall.Infrastructure.Persistence; namespace Randall.Infrastructure.Persistence;
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options) public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{ {
public DbSet<Workplace> Workplaces => Set<Workplace>(); public DbSet<WorkplaceRecord> Workplaces => Set<WorkplaceRecord>();
public DbSet<Reservation> Reservations => Set<Reservation>(); public DbSet<ReservationRecord> Reservations => Set<ReservationRecord>();
public DbSet<User> Users => Set<User>(); public DbSet<UserRecord> Users => Set<UserRecord>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
modelBuilder.Entity<Workplace>(entity => modelBuilder.Entity<WorkplaceRecord>(entity =>
{ {
entity.ToTable("Workplaces");
entity.HasKey(w => w.Id); entity.HasKey(w => w.Id);
entity.Property(w => w.Name).IsRequired().HasMaxLength(100); entity.Property(w => w.Name).IsRequired().HasMaxLength(100);
entity.Property(w => w.Location).IsRequired().HasMaxLength(200); entity.Property(w => w.Location).IsRequired().HasMaxLength(200);
entity.Property(w => w.IsActive).IsRequired(); entity.Property(w => w.IsActive).IsRequired();
}); });
modelBuilder.Entity<Reservation>(entity => modelBuilder.Entity<ReservationRecord>(entity =>
{ {
entity.ToTable("Reservations");
entity.HasKey(r => r.Id); entity.HasKey(r => r.Id);
entity.Property(r => r.WorkplaceId).IsRequired(); entity.Property(r => r.WorkplaceId).IsRequired();
entity.Property(r => r.EmployeeEmail).IsRequired().HasMaxLength(200); entity.Property(r => r.EmployeeEmail).IsRequired().HasMaxLength(200);
@@ -35,8 +35,9 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
entity.HasIndex(r => new { r.EmployeeEmail, r.Date }); entity.HasIndex(r => new { r.EmployeeEmail, r.Date });
}); });
modelBuilder.Entity<User>(entity => modelBuilder.Entity<UserRecord>(entity =>
{ {
entity.ToTable("Users");
entity.HasKey(u => u.Id); entity.HasKey(u => u.Id);
entity.Property(u => u.Email).IsRequired().HasMaxLength(200); entity.Property(u => u.Email).IsRequired().HasMaxLength(200);
entity.Property(u => u.Name).IsRequired().HasMaxLength(200); entity.Property(u => u.Name).IsRequired().HasMaxLength(200);

View File

@@ -0,0 +1,34 @@
using Randall.Domain.Reservations;
using Randall.Infrastructure.Persistence.Records;
namespace Randall.Infrastructure.Persistence.Mappers;
public static class ReservationMapper
{
public static Reservation ToDomain(ReservationRecord record) =>
ReservationFactory.Reconstitute(
record.Id,
record.WorkplaceId,
record.EmployeeEmail,
record.EmployeeName,
record.Date,
record.Status,
record.CreatedAt);
public static ReservationRecord ToRecord(Reservation domain) =>
new()
{
Id = domain.Id,
WorkplaceId = domain.WorkplaceId,
EmployeeEmail = domain.EmployeeEmail,
EmployeeName = domain.EmployeeName,
Date = domain.Date,
Status = domain.Status,
CreatedAt = domain.CreatedAt,
};
public static void SyncToRecord(Reservation domain, ReservationRecord record)
{
record.Status = domain.Status;
}
}

View File

@@ -0,0 +1,33 @@
using Randall.Domain.Users;
using Randall.Infrastructure.Persistence.Records;
namespace Randall.Infrastructure.Persistence.Mappers;
public static class UserMapper
{
public static User ToDomain(UserRecord record) =>
UserFactory.Reconstitute(
record.Id,
record.Email,
record.Name,
record.PasswordHash,
record.IsApproved,
record.IsAdmin);
public static UserRecord ToRecord(User domain) =>
new()
{
Id = domain.Id,
Email = domain.Email,
Name = domain.Name,
PasswordHash = domain.PasswordHash,
IsApproved = domain.IsApproved,
IsAdmin = domain.IsAdmin,
};
public static void SyncToRecord(User domain, UserRecord record)
{
record.IsApproved = domain.IsApproved;
record.IsAdmin = domain.IsAdmin;
}
}

View File

@@ -0,0 +1,23 @@
using Randall.Domain.Workplaces;
using Randall.Infrastructure.Persistence.Records;
namespace Randall.Infrastructure.Persistence.Mappers;
public static class WorkplaceMapper
{
public static Workplace ToDomain(WorkplaceRecord record) =>
WorkplaceFactory.Reconstitute(
record.Id,
record.Name,
record.Location,
record.IsActive);
public static WorkplaceRecord ToRecord(Workplace domain) =>
new()
{
Id = domain.Id,
Name = domain.Name,
Location = domain.Location,
IsActive = domain.IsActive,
};
}

View File

@@ -0,0 +1,14 @@
using Randall.Domain.Reservations;
namespace Randall.Infrastructure.Persistence.Records;
public class ReservationRecord
{
public Guid Id { get; set; }
public Guid WorkplaceId { get; set; }
public string EmployeeEmail { get; set; } = string.Empty;
public string EmployeeName { get; set; } = string.Empty;
public DateOnly Date { get; set; }
public ReservationStatus Status { get; set; }
public DateTime CreatedAt { get; set; }
}

View File

@@ -0,0 +1,11 @@
namespace Randall.Infrastructure.Persistence.Records;
public class UserRecord
{
public Guid Id { get; set; }
public string Email { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string PasswordHash { get; set; } = string.Empty;
public bool IsApproved { get; set; }
public bool IsAdmin { get; set; }
}

View File

@@ -0,0 +1,9 @@
namespace Randall.Infrastructure.Persistence.Records;
public class WorkplaceRecord
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Location { get; set; } = string.Empty;
public bool IsActive { get; set; }
}

View File

@@ -1,23 +1,40 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Randall.Domain.Reservations; using Randall.Domain.Reservations;
using Randall.Infrastructure.Persistence.Mappers;
using Randall.Infrastructure.Persistence.Records;
namespace Randall.Infrastructure.Persistence.Reservations; namespace Randall.Infrastructure.Persistence.Reservations;
public class ReservationRepository(AppDbContext context) : IReservationRepository public class ReservationRepository(AppDbContext context) : IReservationRepository
{ {
public Task<Reservation?> GetByIdAsync(Guid id, CancellationToken ct = default) => private readonly Dictionary<Guid, (Reservation Domain, ReservationRecord Record)> _tracked = [];
context.Reservations.FirstOrDefaultAsync(r => r.Id == id, ct);
public async Task<IReadOnlyList<Reservation>> GetByEmployeeAsync(string employeeEmail, CancellationToken ct = default) => public async Task<Reservation?> GetByIdAsync(Guid id, CancellationToken ct = default)
await context.Reservations {
.Where(r => r.EmployeeEmail.ToLower() == employeeEmail.ToLower()) var record = await context.Reservations.FirstOrDefaultAsync(r => r.Id == id, ct);
if (record is null) return null;
var domain = ReservationMapper.ToDomain(record);
_tracked[domain.Id] = (domain, record);
return domain;
}
public async Task<IReadOnlyList<Reservation>> GetByEmployeeAsync(string employeeEmail, CancellationToken ct = default)
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var records = await context.Reservations
.Where(r => r.EmployeeEmail.ToLower() == employeeEmail.ToLower() && r.Date >= today)
.ToListAsync(ct); .ToListAsync(ct);
return records.Select(ReservationMapper.ToDomain).ToList();
}
public async Task<IReadOnlyList<Reservation>> GetByWorkplaceAndDateAsync( public async Task<IReadOnlyList<Reservation>> GetByWorkplaceAndDateAsync(
Guid workplaceId, DateOnly date, CancellationToken ct = default) => Guid workplaceId, DateOnly date, CancellationToken ct = default)
await context.Reservations {
var records = await context.Reservations
.Where(r => r.WorkplaceId == workplaceId && r.Date == date) .Where(r => r.WorkplaceId == workplaceId && r.Date == date)
.ToListAsync(ct); .ToListAsync(ct);
return records.Select(ReservationMapper.ToDomain).ToList();
}
public Task<bool> ExistsActiveForEmployeeOnDateAsync( public Task<bool> ExistsActiveForEmployeeOnDateAsync(
string employeeEmail, DateOnly date, CancellationToken ct = default) => string employeeEmail, DateOnly date, CancellationToken ct = default) =>
@@ -36,14 +53,24 @@ public class ReservationRepository(AppDbContext context) : IReservationRepositor
ct); ct);
public async Task<IReadOnlyList<Reservation>> GetActiveReservationsForDateAsync( public async Task<IReadOnlyList<Reservation>> GetActiveReservationsForDateAsync(
DateOnly date, CancellationToken ct = default) => DateOnly date, CancellationToken ct = default)
await context.Reservations {
var records = await context.Reservations
.Where(r => r.Date == date && r.Status == ReservationStatus.Active) .Where(r => r.Date == date && r.Status == ReservationStatus.Active)
.ToListAsync(ct); .ToListAsync(ct);
return records.Select(ReservationMapper.ToDomain).ToList();
public async Task AddAsync(Reservation reservation, CancellationToken ct = default) => }
await context.Reservations.AddAsync(reservation, ct);
public async Task AddAsync(Reservation reservation, CancellationToken ct = default)
public Task SaveChangesAsync(CancellationToken ct = default) => {
context.SaveChangesAsync(ct); var record = ReservationMapper.ToRecord(reservation);
await context.Reservations.AddAsync(record, ct);
}
public Task SaveChangesAsync(CancellationToken ct = default)
{
foreach (var (domain, record) in _tracked.Values)
ReservationMapper.SyncToRecord(domain, record);
return context.SaveChangesAsync(ct);
}
} }

View File

@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
using Randall.Application.Common; using Randall.Application.Common;
using Randall.Domain.Users; using Randall.Domain.Users;
using Randall.Domain.Workplaces; using Randall.Domain.Workplaces;
using Randall.Infrastructure.Persistence.Mappers;
namespace Randall.Infrastructure.Persistence.Seeding; namespace Randall.Infrastructure.Persistence.Seeding;
@@ -25,7 +26,7 @@ public static class DatabaseSeeder
// Seed workplaces only if none exist yet // Seed workplaces only if none exist yet
if (!await context.Workplaces.AnyAsync()) if (!await context.Workplaces.AnyAsync())
{ {
var workplaces = ExpectedWorkplaces.Select(w => new Workplace(w.Name, w.Location)); var workplaces = ExpectedWorkplaces.Select(w => WorkplaceMapper.ToRecord(new Workplace(w.Name, w.Location)));
context.Workplaces.AddRange(workplaces); context.Workplaces.AddRange(workplaces);
await context.SaveChangesAsync(); await context.SaveChangesAsync();
} }
@@ -35,7 +36,7 @@ public static class DatabaseSeeder
{ {
var hash = passwordHasher.Hash(AdminPassword); var hash = passwordHasher.Hash(AdminPassword);
var admin = User.Create(AdminEmail, "Admin", hash, isAdmin: true).Value!; var admin = User.Create(AdminEmail, "Admin", hash, isAdmin: true).Value!;
context.Users.Add(admin); context.Users.Add(UserMapper.ToRecord(admin));
await context.SaveChangesAsync(); await context.SaveChangesAsync();
} }
} }

View File

@@ -1,37 +1,73 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Randall.Domain.Users; using Randall.Domain.Users;
using Randall.Infrastructure.Persistence.Mappers;
using Randall.Infrastructure.Persistence.Records;
namespace Randall.Infrastructure.Persistence.Users; namespace Randall.Infrastructure.Persistence.Users;
public class UserRepository(AppDbContext context) : IUserRepository public class UserRepository(AppDbContext context) : IUserRepository
{ {
public Task<User?> GetByEmailAsync(string email, CancellationToken ct = default) => private readonly Dictionary<Guid, (User Domain, UserRecord Record)> _tracked = [];
context.Users.FirstOrDefaultAsync(u => u.Email == email.ToLowerInvariant(), ct);
public Task<User?> GetByIdAsync(Guid id, CancellationToken ct = default) => private User Track(UserRecord record)
context.Users.FirstOrDefaultAsync(u => u.Id == id, ct); {
var domain = UserMapper.ToDomain(record);
_tracked[domain.Id] = (domain, record);
return domain;
}
public async Task<User?> GetByEmailAsync(string email, CancellationToken ct = default)
{
var record = await context.Users.FirstOrDefaultAsync(u => u.Email == email.ToLowerInvariant(), ct);
return record is null ? null : Track(record);
}
public async Task<User?> GetByIdAsync(Guid id, CancellationToken ct = default)
{
var record = await context.Users.FirstOrDefaultAsync(u => u.Id == id, ct);
return record is null ? null : Track(record);
}
public Task<bool> ExistsByEmailAsync(string email, CancellationToken ct = default) => public Task<bool> ExistsByEmailAsync(string email, CancellationToken ct = default) =>
context.Users.AnyAsync(u => u.Email == email.ToLowerInvariant(), ct); context.Users.AnyAsync(u => u.Email == email.ToLowerInvariant(), ct);
public Task<List<User>> GetPendingAsync(CancellationToken ct = default) => public async Task<List<User>> GetPendingAsync(CancellationToken ct = default)
context.Users.Where(u => !u.IsApproved && !u.IsAdmin).ToListAsync(ct); {
var records = await context.Users.Where(u => !u.IsApproved && !u.IsAdmin).ToListAsync(ct);
return records.Select(UserMapper.ToDomain).ToList();
}
public Task<List<User>> GetAllAsync(CancellationToken ct = default) => public async Task<List<User>> GetAllAsync(CancellationToken ct = default)
context.Users.OrderBy(u => u.Name).ToListAsync(ct); {
var records = await context.Users.OrderBy(u => u.Name).ToListAsync(ct);
return records.Select(UserMapper.ToDomain).ToList();
}
public Task<int> CountAdminsAsync(CancellationToken ct = default) => public Task<int> CountAdminsAsync(CancellationToken ct = default) =>
context.Users.CountAsync(u => u.IsAdmin); context.Users.CountAsync(u => u.IsAdmin);
public Task<List<User>> GetAllNonAdminAsync(CancellationToken ct = default) => public async Task<List<User>> GetAllNonAdminAsync(CancellationToken ct = default)
context.Users.Where(u => !u.IsAdmin).OrderBy(u => u.Name).ToListAsync(ct); {
var records = await context.Users.Where(u => !u.IsAdmin).OrderBy(u => u.Name).ToListAsync(ct);
public async Task AddAsync(User user, CancellationToken ct = default) => return records.Select(UserMapper.ToDomain).ToList();
await context.Users.AddAsync(user, ct); }
public void Delete(User user) => public async Task AddAsync(User user, CancellationToken ct = default)
context.Users.Remove(user); {
var record = UserMapper.ToRecord(user);
public Task SaveChangesAsync(CancellationToken ct = default) => await context.Users.AddAsync(record, ct);
context.SaveChangesAsync(ct); }
public void Delete(User user)
{
if (_tracked.TryGetValue(user.Id, out var entry))
context.Users.Remove(entry.Record);
}
public Task SaveChangesAsync(CancellationToken ct = default)
{
foreach (var (domain, record) in _tracked.Values)
UserMapper.SyncToRecord(domain, record);
return context.SaveChangesAsync(ct);
}
} }

View File

@@ -1,13 +1,20 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Randall.Domain.Workplaces; using Randall.Domain.Workplaces;
using Randall.Infrastructure.Persistence.Mappers;
namespace Randall.Infrastructure.Persistence.Workplaces; namespace Randall.Infrastructure.Persistence.Workplaces;
public class WorkplaceRepository(AppDbContext context) : IWorkplaceRepository public class WorkplaceRepository(AppDbContext context) : IWorkplaceRepository
{ {
public Task<Workplace?> GetByIdAsync(Guid id, CancellationToken ct = default) => public async Task<Workplace?> GetByIdAsync(Guid id, CancellationToken ct = default)
context.Workplaces.FirstOrDefaultAsync(w => w.Id == id, ct); {
var record = await context.Workplaces.FirstOrDefaultAsync(w => w.Id == id, ct);
public async Task<IReadOnlyList<Workplace>> GetAllActiveAsync(CancellationToken ct = default) => return record is null ? null : WorkplaceMapper.ToDomain(record);
await context.Workplaces.Where(w => w.IsActive).ToListAsync(ct); }
public async Task<IReadOnlyList<Workplace>> GetAllActiveAsync(CancellationToken ct = default)
{
var records = await context.Workplaces.Where(w => w.IsActive).ToListAsync(ct);
return records.Select(WorkplaceMapper.ToDomain).ToList();
}
} }

View File

@@ -146,4 +146,85 @@ public class AdminTests(CustomWebApplicationFactory factory) : IClassFixture<Cus
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); 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.Hosting;
using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
namespace Randall.Api.IntegrationTests; namespace Randall.Api.IntegrationTests;
@@ -28,6 +29,9 @@ public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{ {
base.Dispose(disposing); base.Dispose(disposing);
if (disposing && File.Exists(_dbPath)) if (disposing && File.Exists(_dbPath))
{
SqliteConnection.ClearAllPools();
File.Delete(_dbPath); File.Delete(_dbPath);
} }
} }
}

View File

@@ -0,0 +1,61 @@
using Randall.Domain.Reservations;
namespace Randall.Domain.UnitTests.Reservations;
public class ReservationFactoryTests
{
private static readonly Guid Id = Guid.NewGuid();
private static readonly Guid WorkplaceId = Guid.NewGuid();
private const string Email = "jane@company.com";
private const string Name = "Jane Smith";
private static readonly DateOnly Date = new(2026, 6, 1);
private static readonly DateTime CreatedAt = new(2026, 5, 1, 12, 0, 0, DateTimeKind.Utc);
[Fact]
public void Reconstitute_MapsAllFieldsExactly()
{
var reservation = ReservationFactory.Reconstitute(Id, WorkplaceId, Email, Name, Date, ReservationStatus.Active, CreatedAt);
Assert.Equal(Id, reservation.Id);
Assert.Equal(WorkplaceId, reservation.WorkplaceId);
Assert.Equal(Email, reservation.EmployeeEmail);
Assert.Equal(Name, reservation.EmployeeName);
Assert.Equal(Date, reservation.Date);
Assert.Equal(ReservationStatus.Active, reservation.Status);
Assert.Equal(CreatedAt, reservation.CreatedAt);
}
[Fact]
public void Reconstitute_PreservesProvidedId()
{
var reservation = ReservationFactory.Reconstitute(Id, WorkplaceId, Email, Name, Date, ReservationStatus.Active, CreatedAt);
Assert.Equal(Id, reservation.Id);
}
[Fact]
public void Reconstitute_CanRestoreActiveReservation()
{
var reservation = ReservationFactory.Reconstitute(Id, WorkplaceId, Email, Name, Date, ReservationStatus.Active, CreatedAt);
Assert.Equal(ReservationStatus.Active, reservation.Status);
}
[Fact]
public void Reconstitute_CanRestoreCancelledReservation()
{
var reservation = ReservationFactory.Reconstitute(Id, WorkplaceId, Email, Name, Date, ReservationStatus.Cancelled, CreatedAt);
Assert.Equal(ReservationStatus.Cancelled, reservation.Status);
}
[Fact]
public void Reconstitute_CanRestorePastReservation()
{
var pastDate = new DateOnly(2020, 1, 1);
var reservation = ReservationFactory.Reconstitute(Id, WorkplaceId, Email, Name, pastDate, ReservationStatus.Active, CreatedAt);
Assert.Equal(pastDate, reservation.Date);
}
}

View File

@@ -0,0 +1,58 @@
using Randall.Domain.Users;
namespace Randall.Domain.UnitTests.Users;
public class UserFactoryTests
{
private static readonly Guid Id = Guid.NewGuid();
private const string Email = "jane@company.com";
private const string Name = "Jane Smith";
private const string PasswordHash = "hashed-password";
[Fact]
public void Reconstitute_MapsAllFieldsExactly()
{
var user = UserFactory.Reconstitute(Id, Email, Name, PasswordHash, isApproved: true, isAdmin: false);
Assert.Equal(Id, user.Id);
Assert.Equal(Email, user.Email);
Assert.Equal(Name, user.Name);
Assert.Equal(PasswordHash, user.PasswordHash);
Assert.True(user.IsApproved);
Assert.False(user.IsAdmin);
}
[Fact]
public void Reconstitute_PreservesProvidedId()
{
var user = UserFactory.Reconstitute(Id, Email, Name, PasswordHash, isApproved: false, isAdmin: false);
Assert.Equal(Id, user.Id);
}
[Fact]
public void Reconstitute_CanRestoreApprovedNonAdminUser()
{
var user = UserFactory.Reconstitute(Id, Email, Name, PasswordHash, isApproved: true, isAdmin: false);
Assert.True(user.IsApproved);
Assert.False(user.IsAdmin);
}
[Fact]
public void Reconstitute_CanRestoreUnapprovedUser()
{
var user = UserFactory.Reconstitute(Id, Email, Name, PasswordHash, isApproved: false, isAdmin: false);
Assert.False(user.IsApproved);
}
[Fact]
public void Reconstitute_CanRestoreAdminUser()
{
var user = UserFactory.Reconstitute(Id, Email, Name, PasswordHash, isApproved: true, isAdmin: true);
Assert.True(user.IsAdmin);
Assert.True(user.IsApproved);
}
}

View File

@@ -0,0 +1,45 @@
using Randall.Domain.Workplaces;
namespace Randall.Domain.UnitTests.Workplaces;
public class WorkplaceFactoryTests
{
private static readonly Guid Id = Guid.NewGuid();
private const string Name = "D13";
private const string Location = "Pod A";
[Fact]
public void Reconstitute_MapsAllFieldsExactly()
{
var workplace = WorkplaceFactory.Reconstitute(Id, Name, Location, isActive: true);
Assert.Equal(Id, workplace.Id);
Assert.Equal(Name, workplace.Name);
Assert.Equal(Location, workplace.Location);
Assert.True(workplace.IsActive);
}
[Fact]
public void Reconstitute_PreservesProvidedId()
{
var workplace = WorkplaceFactory.Reconstitute(Id, Name, Location, isActive: true);
Assert.Equal(Id, workplace.Id);
}
[Fact]
public void Reconstitute_CanRestoreActiveWorkplace()
{
var workplace = WorkplaceFactory.Reconstitute(Id, Name, Location, isActive: true);
Assert.True(workplace.IsActive);
}
[Fact]
public void Reconstitute_CanRestoreInactiveWorkplace()
{
var workplace = WorkplaceFactory.Reconstitute(Id, Name, Location, isActive: false);
Assert.False(workplace.IsActive);
}
}

View File

@@ -4,7 +4,10 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title> <title>randall</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Rubik+Mono+One&family=Inter:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -40,7 +40,7 @@ export default function App() {
<Route path="/" element={<PlannerPage auth={auth} onLogout={handleLogout} />} /> <Route path="/" element={<PlannerPage auth={auth} onLogout={handleLogout} />} />
<Route <Route
path="/admin" path="/admin"
element={auth.isAdmin ? <AdminPage /> : <Navigate to="/" replace />} element={auth.isAdmin ? <AdminPage onLogout={handleLogout} /> : <Navigate to="/" replace />}
/> />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>

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'; const BASE = '/api';
@@ -38,6 +38,15 @@ export const api = {
return handleResponse<RegisterPendingResponse>(res); 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[]> { getAdminUsers(): Promise<AdminUser[]> {
return fetch(`${BASE}/admin/users`, { return fetch(`${BASE}/admin/users`, {
headers: authHeaders(), headers: authHeaders(),

View File

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

View File

@@ -7,6 +7,12 @@ interface CancelModalProps {
onClose: () => void; onClose: () => void;
} }
const PURPLE = '#5b4fc7';
const PURPLE_DEEP = '#3f33a8';
const SAGE = '#c7d4b8';
const SAGE_DEEP = '#a9bb96';
const PAPER = '#f4f3ee';
export function CancelModal({ deskName, date, onConfirm, onClose }: CancelModalProps) { export function CancelModal({ deskName, date, onConfirm, onClose }: CancelModalProps) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -24,30 +30,77 @@ export function CancelModal({ deskName, date, onConfirm, onClose }: CancelModalP
} }
return ( return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
<div <div
className="bg-white rounded-2xl shadow-xl p-8 w-full max-w-md mx-4" style={{
position: 'fixed', inset: 0,
background: 'rgba(42,31,107,0.35)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 50,
}}
onClick={onClose}
>
<div
role="dialog"
aria-modal="true"
style={{
background: PAPER, borderRadius: 18, padding: 32,
width: '100%', maxWidth: 380, margin: '0 16px',
border: '1px solid rgba(91,79,199,0.12)',
boxShadow: '0 20px 60px -12px rgba(42,31,107,0.25)',
}}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<h2 className="text-xl font-semibold text-slate-800 mb-1">Cancel reservation</h2> <div style={{
<p className="text-sm text-slate-500 mb-6"> fontSize: 11, letterSpacing: '0.18em', textTransform: 'uppercase',
Cancel your booking for <span className="font-medium text-slate-700">{deskName}</span> on{' '} color: PURPLE, opacity: 0.7, marginBottom: 8, fontWeight: 500,
<span className="font-medium text-slate-700">{date}</span>? }}>
</p> Cancel reservation
</div>
<div style={{
fontFamily: "'Rubik Mono One', monospace",
fontSize: 36, letterSpacing: '-0.03em', color: PURPLE,
lineHeight: 0.95, marginBottom: 6,
}}>
{deskName}
</div>
<div style={{ fontSize: 13, color: PURPLE, opacity: 0.65, marginBottom: 24 }}>
{date}
</div>
{error && <p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg mb-4">{error}</p>} {error && (
<div style={{
fontSize: 13, color: '#c0392b',
background: 'rgba(192,57,43,0.08)',
borderRadius: 8, padding: '10px 14px',
marginBottom: 18,
}}>
{error}
</div>
)}
<div className="flex gap-3"> <div style={{ display: 'flex', gap: 10 }}>
<button <button
onClick={onClose} onClick={onClose}
className="flex-1 px-4 py-2 text-sm font-medium text-slate-600 bg-slate-100 rounded-lg hover:bg-slate-200 transition-colors" style={{
flex: 1, padding: '11px 16px', borderRadius: 99,
background: SAGE, border: `1px solid ${SAGE_DEEP}`,
color: PURPLE_DEEP, fontSize: 13, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
> >
Keep it Keep it
</button> </button>
<button <button
onClick={handleConfirm} onClick={handleConfirm}
disabled={loading} disabled={loading}
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-red-500 rounded-lg hover:bg-red-600 disabled:opacity-50 transition-colors" style={{
flex: 1, padding: '11px 16px', borderRadius: 99,
background: PURPLE_DEEP, border: `1.5px solid rgba(42,31,107,0.8)`,
color: '#fff', fontSize: 13, fontWeight: 500,
cursor: loading ? 'default' : 'pointer',
fontFamily: 'inherit',
opacity: loading ? 0.6 : 1,
}}
> >
{loading ? 'Cancelling…' : 'Cancel reservation'} {loading ? 'Cancelling…' : 'Cancel reservation'}
</button> </button>

View File

@@ -1,72 +1,104 @@
import { useState } from 'react';
interface DeskProps { interface DeskProps {
name: string; name: string;
available: boolean; available: boolean;
reserved: boolean; // reserved by the current user reserved: boolean;
reservedBy?: string; // name of whoever reserved it (when taken by someone else) reservedBy?: string;
rotate?: 'cw' | 'ccw'; rotate?: 'cw' | 'ccw';
onClick: () => void; onClick: () => void;
} }
const PURPLE = '#5b4fc7';
const PURPLE_DEEP = '#3f33a8';
const SAGE = '#c7d4b8';
const SAGE_DEEP = '#a9bb96';
const PAPER = '#f4f3ee';
export function Desk({ name, available, reserved, reservedBy, rotate, onClick }: DeskProps) { export function Desk({ name, available, reserved, reservedBy, rotate, onClick }: DeskProps) {
let bgColor: string; const [hover, setHover] = useState(false);
let borderColor: string;
let textColor: string;
let deskColor: string;
let cursor: string;
let title: string;
if (reserved) { const isMine = reserved;
bgColor = 'bg-blue-50'; const isFree = !reserved && available;
borderColor = 'border-blue-400'; const isTaken = !reserved && !available;
textColor = 'text-blue-700';
deskColor = '#93c5fd';
cursor = 'cursor-pointer hover:bg-blue-100';
title = 'Your reservation — click to cancel';
} else if (available) {
bgColor = 'bg-emerald-50';
borderColor = 'border-emerald-400';
textColor = 'text-emerald-700';
deskColor = '#6ee7b7';
cursor = 'cursor-pointer hover:bg-emerald-100 hover:scale-105';
title = `Reserve ${name}`;
} else {
bgColor = 'bg-slate-50';
borderColor = 'border-slate-300';
textColor = 'text-slate-500';
deskColor = '#cbd5e1';
cursor = 'cursor-default';
title = reservedBy ? `Reserved by ${reservedBy}` : `${name} is taken`;
}
// Truncate long names to fit the tile const svgFill = isMine ? PURPLE_DEEP : isFree ? PURPLE : 'rgba(91,79,199,0.25)';
const displayName = reservedBy && reservedBy.length > 7
const displayLabel = isFree
? 'free'
: isMine
? 'yours'
: reservedBy
? reservedBy.length > 7
? reservedBy.slice(0, 6) + '…' ? reservedBy.slice(0, 6) + '…'
: reservedBy; : reservedBy
: 'taken';
return ( return (
<button <button
className={`flex flex-col items-center justify-center w-20 rounded-xl border-2 font-semibold transition-all select-none py-2 px-1 gap-0.5 ${bgColor} ${borderColor} ${textColor} ${cursor}`} onMouseEnter={() => setHover(true)}
onClick={available || reserved ? onClick : undefined} onMouseLeave={() => setHover(false)}
title={title} onClick={isTaken ? undefined : onClick}
title={
isMine
? `${name} — your reservation (click to cancel)`
: isFree
? `Reserve ${name}`
: reservedBy
? `Reserved by ${reservedBy}`
: `${name} is taken`
}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
width: 74,
height: 80,
padding: '4px',
gap: 1,
borderRadius: 12,
background: isMine ? SAGE : isFree ? PAPER : 'transparent',
border: isMine
? `1.5px solid ${SAGE_DEEP}`
: isFree
? '1px solid rgba(91,79,199,0.18)'
: '1px dashed rgba(91,79,199,0.18)',
color: isMine ? PURPLE_DEEP : isFree ? PURPLE : 'rgba(91,79,199,0.45)',
cursor: isTaken ? 'default' : 'pointer',
boxShadow: isMine
? '0 6px 16px -8px rgba(91,79,199,0.25)'
: hover && isFree
? '0 8px 20px -10px rgba(91,79,199,0.20)'
: 'none',
transform: hover && !isTaken ? 'translateY(-2px)' : 'translateY(0)',
transition: 'transform 220ms cubic-bezier(0.34,1.56,0.64,1), box-shadow 200ms, border-color 160ms',
fontFamily: 'inherit',
outline: 'none',
}}
> >
{/* Top-down desk: rectangular surface + screen stripe */} <svg
<svg viewBox="0 0 48 48" className={`w-10 h-10 ${rotate === 'cw' ? 'rotate-90' : rotate === 'ccw' ? '-rotate-90' : ''}`} fill="none" aria-hidden="true"> viewBox="0 0 48 48"
{/* Desk surface (wide rectangle, ~1.6:1 ratio) */} width={28}
<rect x="2" y="10" width="44" height="28" rx="2.5" fill={deskColor} /> height={28}
{/* Screen stripe — centered, ~30% of desk width, small margin from back edge */} fill="none"
<rect x="17" y="13" width="14" height="5" rx="1.5" fill="#1e293b" /> aria-hidden="true"
{/* Chair — small square in front of desk */} style={{
<rect x="18" y="41" width="12" height="8" rx="2" fill={deskColor} /> transform: rotate === 'cw' ? 'rotate(90deg)' : 'rotate(-90deg)',
transition: 'transform 200ms',
flexShrink: 0,
}}
>
<rect x="2" y="10" width="44" height="28" rx="2.5" fill={svgFill} />
<rect x="17" y="13" width="14" height="5" rx="1.5" fill={PURPLE_DEEP} opacity="0.7" />
<rect x="18" y="41" width="12" height="8" rx="2" fill={svgFill} />
</svg> </svg>
<span style={{ fontSize: 11, fontWeight: 600, letterSpacing: '-0.01em', lineHeight: 1 }}>
<span className="text-xs font-semibold leading-none">{name}</span> {name}
{reserved && <span className="text-[10px] opacity-75 leading-none">Mine</span>} </span>
{available && <span className="text-[10px] opacity-75 leading-none">Free</span>} <span style={{ fontSize: 9, opacity: 0.7, fontWeight: 500, lineHeight: 1.1, textAlign: 'center' }}>
{!reserved && !available && ( {displayLabel}
<span className="text-[10px] opacity-75 leading-tight text-center px-0.5">
{displayName ?? 'Taken'}
</span> </span>
)}
</button> </button>
); );
} }

View File

@@ -9,20 +9,39 @@ interface ScheduleItem {
} }
interface DeskPodProps { interface DeskPodProps {
label: string;
desks: ScheduleItem[]; desks: ScheduleItem[];
myReservedIds: Set<string>; myReservedIds: Set<string>;
onDeskClick: (desk: ScheduleItem) => void; onDeskClick: (desk: ScheduleItem) => void;
} }
export function DeskPod({ desks, myReservedIds, onDeskClick }: DeskPodProps) { export function DeskPod({ label, desks, myReservedIds, onDeskClick }: DeskPodProps) {
const left = [...desks.slice(0, 4)].reverse(); const left = [...desks.slice(0, 4)].reverse();
const right = [...desks.slice(4, 8)].reverse(); const right = [...desks.slice(4, 8)].reverse();
return ( return (
<div className="flex flex-col items-center gap-2"> <div style={{ display: 'flex', flexDirection: 'column', gap: 10, flex: '0 0 auto' }}>
<div className="bg-white border-2 border-slate-200 rounded-2xl p-5 shadow-sm"> <div style={{
<div className="flex gap-6"> fontSize: 10,
<div className="flex flex-col gap-3"> letterSpacing: '0.18em',
textTransform: 'uppercase',
color: 'var(--purple)',
opacity: 0.6,
fontWeight: 500,
}}>
{label}
</div>
<div style={{
display: 'inline-flex',
gap: 10,
padding: '10px',
background: 'rgba(255,255,255,0.4)',
borderRadius: 14,
border: '1px solid rgba(91,79,199,0.08)',
width: 'fit-content',
alignSelf: 'flex-start',
}}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{left.map((desk) => ( {left.map((desk) => (
<Desk <Desk
key={desk.id} key={desk.id}
@@ -35,10 +54,8 @@ export function DeskPod({ desks, myReservedIds, onDeskClick }: DeskPodProps) {
/> />
))} ))}
</div> </div>
<div style={{ width: 1, background: 'rgba(91,79,199,0.10)', alignSelf: 'stretch' }} />
<div className="w-px bg-slate-100" /> <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div className="flex flex-col gap-3">
{right.map((desk) => ( {right.map((desk) => (
<Desk <Desk
key={desk.id} key={desk.id}
@@ -53,6 +70,5 @@ export function DeskPod({ desks, myReservedIds, onDeskClick }: DeskPodProps) {
</div> </div>
</div> </div>
</div> </div>
</div>
); );
} }

View File

@@ -5,6 +5,11 @@ interface MyReservationsProps {
onCancel: (reservation: Reservation) => void; onCancel: (reservation: Reservation) => void;
} }
const PURPLE = '#5b4fc7';
const PURPLE_DEEP = '#3f33a8';
const SAGE = '#c7d4b8';
const SAGE_DEEP = '#a9bb96';
export function MyReservations({ reservations, onCancel }: MyReservationsProps) { export function MyReservations({ reservations, onCancel }: MyReservationsProps) {
const upcoming = reservations const upcoming = reservations
.filter((r) => r.status === 'Active') .filter((r) => r.status === 'Active')
@@ -12,24 +17,46 @@ export function MyReservations({ reservations, onCancel }: MyReservationsProps)
if (upcoming.length === 0) { if (upcoming.length === 0) {
return ( return (
<p className="text-sm text-slate-400 text-center py-4">No upcoming reservations.</p> <p style={{ fontSize: 13, color: PURPLE, opacity: 0.4, textAlign: 'center', padding: '8px 0', margin: 0 }}>
No upcoming reservations.
</p>
); );
} }
return ( return (
<ul className="flex flex-col gap-2"> <ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
{upcoming.map((r) => ( {upcoming.map((r) => (
<li <li
key={r.id} key={r.id}
className="flex items-center justify-between bg-white border border-slate-200 rounded-xl px-4 py-3" style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 0',
borderBottom: '1px solid rgba(91,79,199,0.08)',
}}
> >
<div> <div>
<span className="font-medium text-slate-800">{r.workplaceName}</span> <div style={{ fontSize: 13, fontWeight: 600, color: PURPLE, lineHeight: 1 }}>
<div className="text-xs text-slate-400 mt-0.5">{r.date}</div> {r.workplaceName}
</div>
<div style={{ fontSize: 11, color: PURPLE, opacity: 0.55, marginTop: 3 }}>
{r.date}
</div>
</div> </div>
<button <button
onClick={() => onCancel(r)} onClick={() => onCancel(r)}
className="text-xs text-red-500 hover:text-red-700 font-medium px-3 py-1 rounded-lg hover:bg-red-50 transition-colors" style={{
padding: '5px 12px',
borderRadius: 99,
background: SAGE,
border: `1px solid ${SAGE_DEEP}`,
color: PURPLE_DEEP,
fontSize: 11,
fontWeight: 500,
cursor: 'pointer',
fontFamily: 'inherit',
}}
> >
Cancel Cancel
</button> </button>

View File

@@ -7,6 +7,12 @@ interface ReservationModalProps {
onClose: () => void; onClose: () => void;
} }
const PURPLE = '#5b4fc7';
const PURPLE_DEEP = '#3f33a8';
const SAGE = '#c7d4b8';
const SAGE_DEEP = '#a9bb96';
const PAPER = '#f4f3ee';
export function ReservationModal({ deskName, date, onConfirm, onClose }: ReservationModalProps) { export function ReservationModal({ deskName, date, onConfirm, onClose }: ReservationModalProps) {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -24,29 +30,77 @@ export function ReservationModal({ deskName, date, onConfirm, onClose }: Reserva
} }
return ( return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
<div <div
className="bg-white rounded-2xl shadow-xl p-8 w-full max-w-sm mx-4" style={{
position: 'fixed', inset: 0,
background: 'rgba(42,31,107,0.35)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 50,
}}
onClick={onClose}
>
<div
role="dialog"
aria-modal="true"
style={{
background: PAPER, borderRadius: 18, padding: 32,
width: '100%', maxWidth: 360, margin: '0 16px',
border: '1px solid rgba(91,79,199,0.12)',
boxShadow: '0 20px 60px -12px rgba(42,31,107,0.25)',
}}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<h2 className="text-xl font-semibold text-slate-800 mb-1">Reserve desk</h2> <div style={{
<p className="text-sm text-slate-500 mb-6"> fontSize: 11, letterSpacing: '0.18em', textTransform: 'uppercase',
<span className="font-medium text-slate-700">{deskName}</span> &mdash; {date} color: PURPLE, opacity: 0.7, marginBottom: 8, fontWeight: 500,
</p> }}>
Reserve desk
</div>
<div style={{
fontFamily: "'Rubik Mono One', monospace",
fontSize: 36, letterSpacing: '-0.03em', color: PURPLE,
lineHeight: 0.95, marginBottom: 6,
}}>
{deskName}
</div>
<div style={{ fontSize: 13, color: PURPLE, opacity: 0.65, marginBottom: 24 }}>
{date}
</div>
{error && <p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg mb-4">{error}</p>} {error && (
<div style={{
fontSize: 13, color: '#c0392b',
background: 'rgba(192,57,43,0.08)',
borderRadius: 8, padding: '10px 14px',
marginBottom: 18,
}}>
{error}
</div>
)}
<div className="flex gap-3"> <div style={{ display: 'flex', gap: 10 }}>
<button <button
onClick={onClose} onClick={onClose}
className="flex-1 px-4 py-2 text-sm font-medium text-slate-600 bg-slate-100 rounded-lg hover:bg-slate-200 transition-colors" style={{
flex: 1, padding: '11px 16px', borderRadius: 99,
background: SAGE, border: `1px solid ${SAGE_DEEP}`,
color: PURPLE_DEEP, fontSize: 13, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
> >
Cancel Cancel
</button> </button>
<button <button
onClick={handleConfirm} onClick={handleConfirm}
disabled={loading} disabled={loading}
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-emerald-500 rounded-lg hover:bg-emerald-600 disabled:opacity-50 transition-colors" style={{
flex: 1, padding: '11px 16px', borderRadius: 99,
background: PURPLE, border: `1.5px solid ${PURPLE_DEEP}`,
color: '#fff', fontSize: 13, fontWeight: 500,
cursor: loading ? 'default' : 'pointer',
fontFamily: 'inherit',
opacity: loading ? 0.6 : 1,
}}
> >
{loading ? 'Reserving…' : 'Confirm'} {loading ? 'Reserving…' : 'Confirm'}
</button> </button>

View File

@@ -1,12 +1,27 @@
@import "tailwindcss"; @import "tailwindcss";
:root {
--bg: #e6e7e0;
--paper: #f4f3ee;
--ink: #2a1f6b;
--purple: #5b4fc7;
--purple-deep: #3f33a8;
--sage: #c7d4b8;
--sage-deep: #a9bb96;
}
*, *::before, *::after { *, *::before, *::after {
box-sizing: border-box; box-sizing: border-box;
} }
body { body {
margin: 0; margin: 0;
font-family: system-ui, 'Segoe UI', Roboto, sans-serif; font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background-color: #f8fafc; background-color: var(--bg);
color: #0f172a; color: var(--ink);
}
button:focus-visible {
outline: 2px solid var(--purple);
outline-offset: 2px;
} }

View File

@@ -3,16 +3,35 @@ import { useNavigate } from 'react-router-dom';
import { api } from '../api/client'; import { api } from '../api/client';
import type { AdminUser } from '../api/types'; import type { AdminUser } from '../api/types';
export function AdminPage() { const PURPLE = '#5b4fc7';
const PURPLE_DEEP = '#3f33a8';
const SAGE = '#c7d4b8';
const SAGE_DEEP = '#a9bb96';
const PAPER = '#f4f3ee';
interface AdminPageProps {
onLogout: () => void;
}
function monogram(name: string): string {
return name.split(' ').map((n) => n[0]).join('').slice(0, 2).toUpperCase();
}
export function AdminPage({ onLogout }: AdminPageProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const [users, setUsers] = useState<AdminUser[]>([]); const [users, setUsers] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [search, setSearch] = useState('');
const [approvingId, setApprovingId] = useState<string | null>(null); const [approvingId, setApprovingId] = useState<string | null>(null);
const [makingAdminId, setMakingAdminId] = useState<string | null>(null); const [makingAdminId, setMakingAdminId] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null); const [deletingId, setDeletingId] = useState<string | null>(null);
const [confirmAdminId, setConfirmAdminId] = useState<string | null>(null); const [confirmAdminId, setConfirmAdminId] = useState<string | null>(null);
const [confirmDeleteId, setConfirmDeleteId] = 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() { async function load() {
setLoading(true); setLoading(true);
@@ -44,7 +63,7 @@ export function AdminPage() {
setMakingAdminId(id); setMakingAdminId(id);
try { try {
await api.makeAdmin(id); await api.makeAdmin(id);
setUsers((prev) => prev.filter((u) => u.id !== id)); setUsers((prev) => prev.map((u) => u.id === id ? { ...u, isAdmin: true } : u));
setConfirmAdminId(null); setConfirmAdminId(null);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to make user admin'); setError(err instanceof Error ? err.message : 'Failed to make user admin');
@@ -66,147 +85,472 @@ export function AdminPage() {
} }
} }
const pending = users.filter((u) => !u.isApproved); async function handleAddUser(e: React.FormEvent) {
const approved = users.filter((u) => u.isApproved); e.preventDefault();
setAddSubmitting(true);
function UserRow({ user }: { user: AdminUser }) { setAddError('');
const busy = approvingId === user.id || makingAdminId === user.id || deletingId === user.id; try {
const newUser = await api.addUser(addForm);
return ( setUsers((prev) => [...prev, newUser]);
<li className="flex items-center justify-between bg-white border border-slate-200 rounded-xl px-5 py-4 shadow-sm"> setShowAddModal(false);
<div> setAddForm({ name: '', email: '', password: '' });
<div className="flex items-center gap-2"> } catch (err) {
<p className="font-medium text-slate-800">{user.name}</p> setAddError(err instanceof Error ? err.message : 'Failed to add user');
{user.isAdmin && ( } finally {
<span className="text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-amber-100 text-amber-700"> setAddSubmitting(false);
Admin }
</span>
)}
</div>
<p className="text-sm text-slate-500">{user.email}</p>
</div>
<div className="flex gap-2 items-center">
{!user.isApproved && (
<button
onClick={() => handleApprove(user.id)}
disabled={busy || confirmAdminId === user.id || confirmDeleteId === user.id}
className="text-sm font-medium px-4 py-1.5 rounded-lg bg-emerald-500 text-white hover:bg-emerald-600 disabled:opacity-50 transition-colors"
>
{approvingId === user.id ? '…' : 'Approve'}
</button>
)}
{!user.isAdmin && confirmAdminId === user.id ? (
<>
<span className="text-sm text-slate-500">Make admin?</span>
<button
onClick={() => handleMakeAdmin(user.id)}
disabled={makingAdminId === user.id}
className="text-sm font-medium px-4 py-1.5 rounded-lg bg-amber-500 text-white hover:bg-amber-600 disabled:opacity-50 transition-colors"
>
{makingAdminId === user.id ? '…' : 'Yes'}
</button>
<button
onClick={() => setConfirmAdminId(null)}
className="text-sm text-slate-400 hover:text-slate-600 transition-colors"
>
Cancel
</button>
</>
) : (
!user.isAdmin && (
<button
onClick={() => setConfirmAdminId(user.id)}
disabled={busy || confirmDeleteId === user.id}
className="text-sm font-medium px-4 py-1.5 rounded-lg bg-amber-500 text-white hover:bg-amber-600 disabled:opacity-50 transition-colors"
>
Make admin
</button>
)
)}
{confirmDeleteId === user.id ? (
<>
<span className="text-sm text-slate-500">Sure?</span>
<button
onClick={() => handleDelete(user.id)}
disabled={deletingId === user.id}
className="text-sm font-medium px-4 py-1.5 rounded-lg bg-red-500 text-white hover:bg-red-600 disabled:opacity-50 transition-colors"
>
{deletingId === user.id ? '…' : 'Yes, delete'}
</button>
<button
onClick={() => setConfirmDeleteId(null)}
className="text-sm text-slate-400 hover:text-slate-600 transition-colors"
>
Cancel
</button>
</>
) : (
<button
onClick={() => setConfirmDeleteId(user.id)}
disabled={busy || confirmAdminId === user.id}
className="text-sm font-medium px-4 py-1.5 rounded-lg bg-red-500 text-white hover:bg-red-600 disabled:opacity-50 transition-colors"
>
Delete
</button>
)}
</div>
</li>
);
} }
const filtered = users.filter((u) => {
const q = search.toLowerCase();
return !q || u.name.toLowerCase().includes(q) || u.email.toLowerCase().includes(q);
});
const totalUsers = users.length;
const approvedCount = users.filter((u) => u.isApproved).length;
const adminCount = users.filter((u) => u.isAdmin).length;
const pendingCount = users.filter((u) => !u.isApproved).length;
const kpis = [
{ label: 'Total users', value: String(totalUsers).padStart(2, '0') },
{ label: 'Approved', value: String(approvedCount).padStart(2, '0') },
{ label: 'Admins', value: String(adminCount).padStart(2, '0') },
{ label: 'Pending', value: String(pendingCount).padStart(2, '0') },
];
const TABLE_COLS = '2fr 2fr 1fr 1fr auto';
return ( return (
<div className="min-h-screen bg-slate-50"> <div style={{ background: 'var(--bg)', minHeight: '100vh' }}>
<header className="bg-white border-b border-slate-200 px-6 py-4 shadow-sm"> <div style={{
<div className="max-w-3xl mx-auto flex items-center justify-between"> maxWidth: 1100,
<div> margin: '0 auto',
<h1 className="text-xl font-semibold text-slate-800">Admin Portal</h1> height: '100vh',
<p className="text-xs text-slate-400 mt-0.5">Manage user accounts</p> display: 'flex',
</div> flexDirection: 'column',
<button overflow: 'hidden',
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif",
}}>
{/* Header */}
<header style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '22px 36px', flexShrink: 0,
}}>
{/* Wordmark + Admin badge */}
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<div
onClick={() => navigate('/')} onClick={() => navigate('/')}
className="text-sm text-slate-400 hover:text-slate-600 transition-colors" style={{
display: 'flex', alignItems: 'center', gap: 1,
color: PURPLE,
fontFamily: "'Rubik Mono One', monospace",
fontSize: 18, letterSpacing: '-0.02em', cursor: 'pointer',
}}
> >
Back to planner <span style={{ opacity: 0.6 }}>{'{'}</span>
<span style={{ padding: '0 4px' }}>randall</span>
<span style={{ opacity: 0.6 }}>{'}'}</span>
</div>
<span style={{
fontSize: 11, letterSpacing: '0.16em', textTransform: 'uppercase',
color: PURPLE, padding: '3px 9px', borderRadius: 99,
border: '1px solid rgba(91,79,199,0.25)', fontWeight: 500,
}}>
Admin
</span>
</div>
{/* Nav */}
<div style={{ display: 'flex', alignItems: 'center', gap: 28, fontSize: 13, color: PURPLE }}>
<span style={{ fontWeight: 500 }}>Users</span>
<button
onClick={onLogout}
style={{
padding: '7px 18px', borderRadius: 99,
background: SAGE, border: `1px solid ${SAGE_DEEP}`,
color: PURPLE_DEEP, fontSize: 12, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
>
Sign out
</button> </button>
</div> </div>
</header> </header>
<main className="max-w-3xl mx-auto px-6 py-8 flex flex-col gap-8"> {/* Body */}
{loading && <p className="text-sm text-slate-400 text-center py-12">Loading</p>} <div style={{
{error && <p className="text-sm text-red-500 text-center py-4">{error}</p>} padding: '8px 36px 32px',
display: 'flex', flexDirection: 'column', gap: 18,
minHeight: 0, flex: 1, overflow: 'hidden',
}}>
{/* Hero */}
<div>
<div style={{
fontSize: 11, letterSpacing: '0.18em', textTransform: 'uppercase',
color: PURPLE, opacity: 0.7, marginBottom: 12, fontWeight: 500,
}}>
Users · The Hague HQ
</div>
<h1 style={{
margin: 0,
fontFamily: "'Rubik Mono One', monospace",
fontSize: 42, fontWeight: 400, letterSpacing: '-0.02em',
lineHeight: 0.95, color: PURPLE,
}}>
Who can <span style={{ color: SAGE_DEEP }}>book?</span>
</h1>
</div>
{/* KPI strip */}
{!loading && ( {!loading && (
<> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4,1fr)', gap: 12 }}>
<section> {kpis.map((k) => (
<h2 className="text-sm font-semibold text-slate-500 uppercase tracking-widest mb-3"> <div key={k.label} style={{
Pending approval background: PAPER, borderRadius: 14, padding: '14px 16px',
</h2> border: '1px solid rgba(91,79,199,0.10)',
{pending.length === 0 ? ( }}>
<p className="text-sm text-slate-400">No pending accounts.</p> <div style={{
) : ( fontSize: 10, letterSpacing: '0.16em', textTransform: 'uppercase',
<ul className="flex flex-col gap-3"> color: PURPLE, opacity: 0.7, fontWeight: 500,
{pending.map((u) => <UserRow key={u.id} user={u} />)} }}>
</ul> {k.label}
</div>
<div style={{
fontFamily: "'Rubik Mono One', monospace",
fontSize: 30, letterSpacing: '-0.02em', color: PURPLE,
marginTop: 4, lineHeight: 1,
}}>
{k.value}
</div>
</div>
))}
</div>
)} )}
</section>
<section> {/* Users table card */}
<h2 className="text-sm font-semibold text-slate-500 uppercase tracking-widest mb-3"> <div style={{
Approved accounts background: PAPER, borderRadius: 18,
</h2> border: '1px solid rgba(91,79,199,0.10)',
{approved.length === 0 ? ( flex: 1, display: 'flex', flexDirection: 'column',
<p className="text-sm text-slate-400">No approved accounts yet.</p> minHeight: 0, overflow: 'hidden',
) : ( }}>
<ul className="flex flex-col gap-3"> {/* Toolbar */}
{approved.map((u) => <UserRow key={u.id} user={u} />)} <div style={{
</ul> display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '14px 18px',
borderBottom: '1px solid rgba(91,79,199,0.10)',
flexShrink: 0,
}}>
<div style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '7px 12px', borderRadius: 99,
border: '1px solid rgba(91,79,199,0.18)',
background: 'rgba(255,255,255,0.4)',
fontSize: 13, color: PURPLE, minWidth: 260,
}}>
<span style={{ opacity: 0.6 }}></span>
<input
type="text"
placeholder="Search by name or email"
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{
background: 'none', border: 'none', outline: 'none',
fontSize: 13, color: PURPLE, fontFamily: 'inherit',
flex: 1, opacity: search ? 1 : 0.6,
}}
/>
</div>
<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>
{/* Table head */}
<div style={{
display: 'grid', gridTemplateColumns: TABLE_COLS,
padding: '10px 18px',
fontSize: 10, letterSpacing: '0.16em', textTransform: 'uppercase',
color: PURPLE, opacity: 0.7, fontWeight: 500,
borderBottom: '1px solid rgba(91,79,199,0.08)',
flexShrink: 0,
}}>
<span>Name</span>
<span>Email</span>
<span>Role</span>
<span>Status</span>
<span>Actions</span>
</div>
{/* Table rows */}
<div style={{ overflow: 'auto', flex: 1 }}>
{loading && (
<p style={{ textAlign: 'center', color: PURPLE, opacity: 0.5, fontSize: 13, margin: '32px 0' }}>
Loading
</p>
)} )}
</section> {error && (
<p style={{ textAlign: 'center', color: '#c0392b', fontSize: 13, margin: '24px 0' }}>
{error}
</p>
)}
{!loading && filtered.map((u, i) => {
const busy = approvingId === u.id || makingAdminId === u.id || deletingId === u.id;
const isLastRow = i === filtered.length - 1;
return (
<div key={u.id} data-testid="user-row" style={{
display: 'grid', gridTemplateColumns: TABLE_COLS,
alignItems: 'center',
padding: '12px 18px',
fontSize: 13, color: PURPLE_DEEP,
borderBottom: isLastRow ? 'none' : '1px solid rgba(91,79,199,0.06)',
}}>
{/* Name */}
<span style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{
width: 26, height: 26, borderRadius: '50%',
background: SAGE, border: `1px solid ${SAGE_DEEP}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 10, fontWeight: 600, color: PURPLE_DEEP,
flexShrink: 0,
}}>
{monogram(u.name)}
</span>
<span style={{ fontWeight: 500 }}>{u.name}</span>
</span>
{/* Email */}
<span style={{ color: PURPLE, opacity: 0.85, fontSize: 12 }}>{u.email}</span>
{/* Role badge */}
<span>
<span data-testid="role-badge" style={{
fontSize: 10, letterSpacing: '0.14em', textTransform: 'uppercase',
padding: '2px 8px', borderRadius: 99, fontWeight: 500,
background: u.isAdmin ? SAGE : 'transparent',
border: `1px solid ${u.isAdmin ? SAGE_DEEP : 'rgba(91,79,199,0.20)'}`,
color: u.isAdmin ? PURPLE_DEEP : PURPLE,
}}>
{u.isAdmin ? 'admin' : 'employee'}
</span>
</span>
{/* Status */}
<span style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: PURPLE }}>
<span style={{
width: 7, height: 7, borderRadius: '50%', flexShrink: 0,
background: u.isApproved ? SAGE_DEEP : 'rgba(91,79,199,0.30)',
}} />
{u.isApproved ? 'Active' : 'Pending'}
</span>
{/* Actions */}
<span style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
{!u.isApproved && (
confirmAdminId !== u.id && confirmDeleteId !== u.id && (
<button
onClick={() => handleApprove(u.id)}
disabled={busy}
style={{
padding: '3px 10px', borderRadius: 99, fontSize: 11, fontWeight: 500,
background: SAGE, border: `1px solid ${SAGE_DEEP}`,
color: PURPLE_DEEP, cursor: busy ? 'default' : 'pointer',
fontFamily: 'inherit', opacity: busy ? 0.5 : 1,
}}
>
{approvingId === u.id ? '…' : 'Approve'}
</button>
)
)}
{confirmAdminId === u.id ? (
<>
<span style={{ fontSize: 12, color: PURPLE, opacity: 0.7 }}>Make admin?</span>
<button
onClick={() => handleMakeAdmin(u.id)}
disabled={!!makingAdminId}
style={{
padding: '3px 10px', borderRadius: 99, fontSize: 11, fontWeight: 500,
background: PURPLE, border: `1px solid ${PURPLE_DEEP}`,
color: '#fff', cursor: 'pointer', fontFamily: 'inherit',
}}
>
{makingAdminId === u.id ? '…' : 'Yes'}
</button>
<button
onClick={() => setConfirmAdminId(null)}
style={{
background: 'none', border: 'none', fontSize: 12,
color: PURPLE, opacity: 0.6, cursor: 'pointer', fontFamily: 'inherit',
}}
>
No
</button>
</>
) : confirmDeleteId === u.id ? (
<>
<span style={{ fontSize: 12, color: PURPLE, opacity: 0.7 }}>Delete?</span>
<button
onClick={() => handleDelete(u.id)}
disabled={!!deletingId}
style={{
padding: '3px 10px', borderRadius: 99, fontSize: 11, fontWeight: 500,
background: PURPLE_DEEP, border: `1px solid ${PURPLE_DEEP}`,
color: '#fff', cursor: 'pointer', fontFamily: 'inherit',
}}
>
{deletingId === u.id ? '…' : 'Yes'}
</button>
<button
onClick={() => setConfirmDeleteId(null)}
style={{
background: 'none', border: 'none', fontSize: 12,
color: PURPLE, opacity: 0.6, cursor: 'pointer', fontFamily: 'inherit',
}}
>
No
</button>
</>
) : (
<>
{!u.isAdmin && (
<button
onClick={() => setConfirmAdminId(u.id)}
disabled={busy}
style={{
background: 'none', border: 'none', padding: 0,
fontSize: 12, color: PURPLE, opacity: busy ? 0.4 : 0.65,
cursor: busy ? 'default' : 'pointer', fontFamily: 'inherit',
}}
>
Admin
</button>
)}
<button
onClick={() => setConfirmDeleteId(u.id)}
disabled={busy}
style={{
background: 'none', border: 'none', padding: 0,
fontSize: 12, color: PURPLE, opacity: busy ? 0.4 : 0.65,
cursor: busy ? 'default' : 'pointer', fontFamily: 'inherit',
}}
>
Delete
</button>
</> </>
)} )}
</main> </span>
</div>
);
})}
{!loading && filtered.length === 0 && !error && (
<p style={{ textAlign: 'center', color: PURPLE, opacity: 0.4, fontSize: 13, margin: '32px 0' }}>
{search ? 'No users match your search.' : 'No users found.'}
</p>
)}
</div>
</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> </div>
); );
} }

View File

@@ -6,6 +6,34 @@ interface AuthPageProps {
onAuth: (auth: AuthResponse) => void; onAuth: (auth: AuthResponse) => void;
} }
const PURPLE = '#5b4fc7';
const PURPLE_DEEP = '#3f33a8';
const SAGE = '#c7d4b8';
const SAGE_DEEP = '#a9bb96';
const PAPER = '#f4f3ee';
const inputStyle: React.CSSProperties = {
padding: '12px 14px',
borderRadius: 12,
border: '1px solid rgba(91,79,199,0.18)',
background: PAPER,
color: PURPLE_DEEP,
fontSize: 14,
fontFamily: 'inherit',
outline: 'none',
width: '100%',
boxSizing: 'border-box',
};
const fieldLabelStyle: React.CSSProperties = {
fontSize: 11,
letterSpacing: '0.14em',
textTransform: 'uppercase',
color: PURPLE,
opacity: 0.7,
fontWeight: 500,
};
export function AuthPage({ onAuth }: AuthPageProps) { export function AuthPage({ onAuth }: AuthPageProps) {
const [mode, setMode] = useState<'login' | 'register' | 'pending'>('login'); const [mode, setMode] = useState<'login' | 'register' | 'pending'>('login');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
@@ -14,7 +42,7 @@ export function AuthPage({ onAuth }: AuthPageProps) {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
async function handleSubmit(e: { preventDefault: () => void }) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
setError(''); setError('');
setLoading(true); setLoading(true);
@@ -34,101 +62,220 @@ export function AuthPage({ onAuth }: AuthPageProps) {
} }
return ( return (
<div className="min-h-screen bg-slate-50 flex flex-col items-center justify-center px-4"> <div style={{ background: 'var(--bg)', minHeight: '100vh', display: 'flex' }}>
<div className="w-full max-w-sm"> <div style={{
<div className="text-center mb-8"> maxWidth: 1100,
<h1 className="text-2xl font-semibold text-slate-800">Office Planner</h1> width: '100%',
<p className="text-sm text-slate-400 mt-1">Reserve your workspace</p> margin: '0 auto',
height: '100vh',
display: 'flex',
position: 'relative',
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif",
}}>
{/* Wordmark */}
<div style={{
position: 'absolute', top: 22, left: 36,
display: 'flex', alignItems: 'center', gap: 1,
color: PURPLE,
fontFamily: "'Rubik Mono One', monospace",
fontSize: 18, letterSpacing: '-0.02em',
}}>
<span style={{ opacity: 0.6 }}>{'{'}</span>
<span style={{ padding: '0 4px' }}>randall</span>
<span style={{ opacity: 0.6 }}>{'}'}</span>
</div> </div>
{mode === 'pending' && ( {/* Left editorial column */}
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-8 text-center"> <div style={{
<div className="text-3xl mb-4"></div> flex: 1,
<h2 className="text-base font-semibold text-slate-800 mb-2">Account pending approval</h2> display: 'flex',
<p className="text-sm text-slate-500 mb-6"> flexDirection: 'column',
justifyContent: 'center',
padding: '0 80px',
}}>
<div style={{
fontSize: 11, letterSpacing: '0.18em', textTransform: 'uppercase',
color: PURPLE, opacity: 0.7, marginBottom: 14, fontWeight: 500,
}}>
The Hague HQ · Sign in
</div>
<h1 style={{
margin: 0,
fontFamily: "'Rubik Mono One', monospace",
fontSize: 54, fontWeight: 400,
letterSpacing: '-0.02em', lineHeight: 0.95,
color: PURPLE, maxWidth: 520,
}}>
Find your <span style={{ color: SAGE_DEEP }}>desk.</span>
</h1>
<p style={{
margin: '14px 0 0', maxWidth: 480,
fontSize: 14, lineHeight: 1.55,
color: PURPLE, opacity: 0.85,
}}>
Reserve a desk for today or up to two weeks ahead. Sign in with your Randall email.
</p>
</div>
{/* Right card column */}
<div style={{
width: 430,
display: 'flex',
alignItems: 'center',
padding: '0 60px 0 0',
}}>
{mode === 'pending' ? (
<div style={{
width: '100%', background: PAPER, borderRadius: 18, padding: 28,
border: '1px solid rgba(91,79,199,0.10)',
}}>
<div style={{
fontSize: 11, letterSpacing: '0.18em', textTransform: 'uppercase',
color: PURPLE, opacity: 0.7, marginBottom: 16, fontWeight: 500,
}}>
Account pending
</div>
<div style={{
fontFamily: "'Rubik Mono One', monospace",
fontSize: 32, color: PURPLE, lineHeight: 0.95,
letterSpacing: '-0.02em', marginBottom: 12,
}}>
Almost there.
</div>
<p style={{ fontSize: 13, color: PURPLE, opacity: 0.75, lineHeight: 1.5, margin: '0 0 22px' }}>
Your account has been created. An administrator will review and approve it shortly. Your account has been created. An administrator will review and approve it shortly.
</p> </p>
<button <button
onClick={() => { setMode('login'); setError(''); }} onClick={() => { setMode('login'); setError(''); }}
className="text-sm text-emerald-600 hover:text-emerald-700 font-medium" style={{
background: 'none', border: 'none', padding: 0,
fontSize: 13, color: PURPLE, opacity: 0.7, cursor: 'pointer',
fontFamily: 'inherit', textDecoration: 'underline',
}}
> >
Back to sign in Back to sign in
</button> </button>
</div> </div>
)} ) : (
<div style={{
{mode !== 'pending' && <div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-8"> width: '100%', background: PAPER, borderRadius: 18, padding: 28,
<div className="flex rounded-lg bg-slate-100 p-1 mb-6"> border: '1px solid rgba(91,79,199,0.10)',
}}>
{/* Tab switcher */}
<div style={{
display: 'flex', gap: 6, marginBottom: 22,
background: 'rgba(91,79,199,0.06)', padding: 4, borderRadius: 99,
}}>
{(['login', 'register'] as const).map((m) => (
<button <button
onClick={() => { setMode('login'); setError(''); }} key={m}
className={`flex-1 py-1.5 text-sm font-medium rounded-md transition-colors ${ onClick={() => { setMode(m); setError(''); }}
mode === 'login' ? 'bg-white text-slate-800 shadow-sm' : 'text-slate-500 hover:text-slate-700' style={{
}`} flex: 1, padding: '8px 10px', borderRadius: 99, border: 'none',
background: mode === m ? PAPER : 'transparent',
color: mode === m ? PURPLE_DEEP : PURPLE,
fontWeight: 500, fontSize: 12, cursor: 'pointer', fontFamily: 'inherit',
boxShadow: mode === m ? '0 2px 6px -2px rgba(91,79,199,0.18)' : 'none',
}}
> >
Sign in {m === 'login' ? 'Sign in' : 'Register'}
</button>
<button
onClick={() => { setMode('register'); setError(''); }}
className={`flex-1 py-1.5 text-sm font-medium rounded-md transition-colors ${
mode === 'register' ? 'bg-white text-slate-800 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`}
>
Create account
</button> </button>
))}
</div> </div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4"> <form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{mode === 'register' && ( <label style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div> <span style={fieldLabelStyle}>Email</span>
<label className="block text-sm font-medium text-slate-700 mb-1">Full name</label>
<input
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Jane Smith"
className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-400"
/>
</div>
)}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Work email</label>
<input <input
type="email" type="email"
required required
placeholder="you@randall.local"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
placeholder="jane@company.com" style={inputStyle}
className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-400"
/> />
</div> </label>
<div> <label style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label className="block text-sm font-medium text-slate-700 mb-1">Password</label> <span style={fieldLabelStyle}>Password</span>
<input <input
type="password" type="password"
required required
placeholder="••••••••"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••" style={inputStyle}
className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-400"
/> />
</div> </label>
{mode === 'register' && (
<label style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<span style={fieldLabelStyle}>Full name</span>
<input
type="text"
required
placeholder="Jane Smith"
value={name}
onChange={(e) => setName(e.target.value)}
style={inputStyle}
/>
</label>
)}
{error && ( {error && (
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg">{error}</p> <div style={{
fontSize: 13, color: '#c0392b',
background: 'rgba(192,57,43,0.08)',
borderRadius: 8, padding: '10px 14px',
}}>
{error}
</div>
)} )}
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
className="w-full py-2 text-sm font-medium text-white bg-emerald-500 rounded-lg hover:bg-emerald-600 disabled:opacity-50 transition-colors mt-1" style={{
padding: '13px 16px', borderRadius: 99, marginTop: 6,
background: SAGE, border: `1px solid ${SAGE_DEEP}`,
color: PURPLE_DEEP, fontSize: 13, fontWeight: 500,
cursor: loading ? 'default' : 'pointer', fontFamily: 'inherit',
opacity: loading ? 0.6 : 1,
}}
> >
{loading ? '…' : mode === 'login' ? 'Sign in' : 'Create account'} {loading ? '…' : mode === 'login' ? 'Sign in' : 'Create account'}
</button> </button>
</form> </form>
</div>}
<div style={{
textAlign: 'center', fontSize: 12, color: PURPLE,
opacity: 0.7, marginTop: 16,
}}>
{mode === 'login' ? (
<>No account?{' '}
<span
onClick={() => { setMode('register'); setError(''); }}
style={{ color: PURPLE, cursor: 'pointer', textDecoration: 'underline' }}
>
Register
</span>
</>
) : (
<>Have one?{' '}
<span
onClick={() => { setMode('login'); setError(''); }}
style={{ color: PURPLE, cursor: 'pointer', textDecoration: 'underline' }}
>
Sign in
</span>
</>
)}
</div>
</div>
)}
</div>
</div> </div>
</div> </div>
); );

View File

@@ -7,6 +7,12 @@ import { ReservationModal } from '../components/ReservationModal';
import { CancelModal } from '../components/CancelModal'; import { CancelModal } from '../components/CancelModal';
import { MyReservations } from '../components/MyReservations'; import { MyReservations } from '../components/MyReservations';
const PURPLE = '#5b4fc7';
const PURPLE_DEEP = '#3f33a8';
const SAGE = '#c7d4b8';
const SAGE_DEEP = '#a9bb96';
const PAPER = '#f4f3ee';
function toIsoDate(date: Date): string { function toIsoDate(date: Date): string {
const m = String(date.getMonth() + 1).padStart(2, '0'); const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0'); const d = String(date.getDate()).padStart(2, '0');
@@ -25,6 +31,49 @@ function formatDisplayDate(isoDate: string): string {
}); });
} }
function formatKickerDate(isoDate: string): string {
const [y, m, d] = isoDate.split('-').map(Number);
const date = new Date(y, m - 1, d);
const weekday = date.toLocaleDateString('en-GB', { weekday: 'short' }).toUpperCase();
const month = date.toLocaleDateString('en-GB', { month: 'short' }).toUpperCase();
return `${weekday}, ${d} ${month} · THE HAGUE HQ`;
}
function formatRelativeTime(isoString: string): string {
const created = new Date(isoString);
const now = new Date();
const mins = Math.floor((now.getTime() - created.getTime()) / 60000);
if (mins < 1) return 'Just now';
if (mins < 60) return `${mins} min ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
return 'Earlier today';
}
function getDayOffset(isoDate: string, isoToday: string): number {
const [y1, m1, d1] = isoDate.split('-').map(Number);
const [y2, m2, d2] = isoToday.split('-').map(Number);
const a = new Date(y1, m1 - 1, d1).getTime();
const b = new Date(y2, m2 - 1, d2).getTime();
return Math.round((a - b) / (1000 * 60 * 60 * 24));
}
function getTrailingWord(dayOffset: number): string {
if (dayOffset === 0) return 'today?';
if (dayOffset === 1) return 'tomorrow?';
return 'then?';
}
function formatDayCell(isoDate: string): { weekday: string; day: number; monthTag: string | null } {
const [y, m, d] = isoDate.split('-').map(Number);
const date = new Date(y, m - 1, d);
const weekday = date.toLocaleDateString('en-GB', { weekday: 'short' }).toUpperCase().slice(0, 3);
const monthTag = d === 1
? date.toLocaleDateString('en-GB', { month: 'short' }).toUpperCase()
: null;
return { weekday, day: d, monthTag };
}
interface PlannerPageProps { interface PlannerPageProps {
auth: AuthResponse; auth: AuthResponse;
onLogout: () => void; onLogout: () => void;
@@ -34,7 +83,7 @@ export function PlannerPage({ auth, onLogout }: PlannerPageProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const today = toIsoDate(new Date()); const today = toIsoDate(new Date());
const maxDate = toIsoDate(new Date(Date.now() + 14 * 24 * 60 * 60 * 1000)); const MAX_OFFSET = 13;
const [selectedDate, setSelectedDate] = useState(today); const [selectedDate, setSelectedDate] = useState(today);
const [schedule, setSchedule] = useState<WorkplaceScheduleItem[]>([]); const [schedule, setSchedule] = useState<WorkplaceScheduleItem[]>([]);
@@ -65,6 +114,30 @@ export function PlannerPage({ auth, onLogout }: PlannerPageProps) {
.map((r) => r.workplaceId), .map((r) => r.workplaceId),
); );
const myDeskOnDate = myReservations.find(
(r) => r.status === 'Active' && r.date === selectedDate,
);
const myDeskSchedule = myDeskOnDate
? schedule.find((w) => w.id === myDeskOnDate.workplaceId)
: null;
const neighbours = myDeskSchedule
? schedule
.filter(
(w) =>
w.location === myDeskSchedule.location &&
!w.isAvailable &&
w.id !== myDeskSchedule.id &&
w.reservedBy !== null,
)
.map((w) => w.reservedBy!.split(' ')[0])
.join(', ')
: '';
const freeCount = schedule.filter((w) => w.isAvailable).length;
const dayOffset = getDayOffset(selectedDate, today);
const trailingWord = getTrailingWord(dayOffset);
const dayStrip = Array.from({ length: 14 }, (_, i) => offsetDate(today, i));
function handleDeskClick(desk: WorkplaceScheduleItem) { function handleDeskClick(desk: WorkplaceScheduleItem) {
if (myReservedIdsOnDate.has(desk.id)) { if (myReservedIdsOnDate.has(desk.id)) {
const res = myReservations.find( const res = myReservations.find(
@@ -93,93 +166,380 @@ export function PlannerPage({ auth, onLogout }: PlannerPageProps) {
const podA = schedule.filter((w) => w.location === 'Pod A'); const podA = schedule.filter((w) => w.location === 'Pod A');
const podB = schedule.filter((w) => w.location === 'Pod B'); const podB = schedule.filter((w) => w.location === 'Pod B');
const arrowBtnStyle = (disabled: boolean): React.CSSProperties => ({
background: 'none',
border: 'none',
cursor: disabled ? 'default' : 'pointer',
color: PURPLE,
opacity: disabled ? 0.35 : 0.65,
fontSize: 16,
padding: '0 4px',
fontFamily: 'inherit',
flexShrink: 0,
lineHeight: 1,
});
return ( return (
<div className="min-h-screen bg-slate-50"> /* Outer bg fills the full viewport */
<header className="bg-white border-b border-slate-200 px-6 py-4 shadow-sm"> <div style={{ background: 'var(--bg)', minHeight: '100vh' }}>
<div className="max-w-5xl mx-auto flex items-center justify-between"> {/* Centred 1100 px frame */}
<div> <div style={{
<h1 className="text-xl font-semibold text-slate-800">Office Planner</h1> maxWidth: 1100,
<p className="text-xs text-slate-400 mt-0.5">Reserve your workspace up to 2 weeks ahead</p> margin: '0 auto',
height: '100vh',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}>
{/* Header */}
<header style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '22px 36px',
flexShrink: 0,
}}>
{/* Wordmark */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: 1,
color: PURPLE,
fontFamily: "'Rubik Mono One', 'Major Mono Display', monospace",
fontSize: 18,
letterSpacing: '-0.02em',
}}>
<span style={{ fontWeight: 400, opacity: 0.6 }}>{'{'}</span>
<span style={{ padding: '0 4px' }}>randall</span>
<span style={{ fontWeight: 400, opacity: 0.6 }}>{'}'}</span>
</div> </div>
<div className="flex items-center gap-4">
<span className="text-sm text-slate-600">{auth.name}</span> {/* Nav */}
<div style={{ display: 'flex', alignItems: 'center', gap: 28, fontSize: 13, color: PURPLE }}>
<span style={{ fontWeight: 500 }}>Today</span>
{auth.isAdmin && ( {auth.isAdmin && (
<button <button
onClick={() => navigate('/admin')} onClick={() => navigate('/admin')}
className="text-sm text-slate-400 hover:text-slate-600 transition-colors" style={{
background: 'none', border: 'none', fontFamily: 'inherit',
fontSize: 13, color: PURPLE, opacity: 0.6, cursor: 'pointer', padding: 0,
}}
> >
Admin portal Admin
</button> </button>
)} )}
<button <button
onClick={onLogout} onClick={onLogout}
className="text-sm text-slate-400 hover:text-slate-600 transition-colors" style={{
padding: '7px 18px',
borderRadius: 99,
background: SAGE,
border: `1px solid ${SAGE_DEEP}`,
color: PURPLE_DEEP,
fontSize: 12,
fontWeight: 500,
cursor: 'pointer',
fontFamily: 'inherit',
}}
> >
Sign out Sign out
</button> </button>
</div> <span style={{ fontSize: 12, color: PURPLE, opacity: 0.6, cursor: 'default' }}>NL </span>
</div> </div>
</header> </header>
<main className="max-w-5xl mx-auto px-6 py-8 flex flex-col gap-8"> {/* Body */}
<section className="flex items-center gap-3 flex-wrap"> <div style={{
<label className="text-sm font-medium text-slate-600 whitespace-nowrap">Date</label> display: 'flex',
flex: 1,
gap: 32,
padding: '8px 36px 32px',
minHeight: 0,
overflow: 'hidden',
}}>
{/* Left column */}
<div style={{
flex: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
gap: 22,
overflow: 'auto',
}}>
{/* Editorial hero */}
<div>
{/* Kicker */}
<div style={{
fontSize: 11,
letterSpacing: '0.18em',
textTransform: 'uppercase',
color: PURPLE,
opacity: 0.7,
fontWeight: 500,
marginBottom: 14,
}}>
{formatKickerDate(selectedDate)}
</div>
{/* Headline */}
<h1 style={{
margin: 0,
fontFamily: "'Rubik Mono One', 'Major Mono Display', monospace",
fontSize: 46,
fontWeight: 400,
letterSpacing: '-0.02em',
lineHeight: 0.95,
color: PURPLE,
}}>
Where to sit{' '}
<span style={{ color: SAGE_DEEP }}>{trailingWord}</span>
</h1>
{/* Body copy */}
<p style={{
margin: '10px 0 0',
maxWidth: 480,
fontSize: 13,
lineHeight: 1.5,
color: PURPLE,
opacity: 0.85,
}}>
{loadingFloor
? 'Loading floor plan…'
: floorError
? floorError
: myDeskOnDate
? `${freeCount} ${freeCount === 1 ? 'desk is' : 'desks are'} free, you're holding ${myDeskOnDate.workplaceName}. Pick another or swap with a teammate.`
: `${freeCount} ${freeCount === 1 ? 'desk is' : 'desks are'} free. Pick one to hold your spot.`}
</p>
{/* 14-day date strip */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 18 }}>
<button <button
onClick={() => setSelectedDate(offsetDate(selectedDate, -1))} onClick={() => setSelectedDate(offsetDate(selectedDate, -1))}
disabled={selectedDate <= today} disabled={dayOffset === 0}
className="px-2.5 py-1.5 text-sm rounded-lg border border-slate-300 bg-white hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors" style={arrowBtnStyle(dayOffset === 0)}
> >
</button> </button>
<input <div style={{ display: 'flex', gap: 5, flex: 1 }}>
type="date" {dayStrip.map((dateIso) => {
value={selectedDate} const isActive = dateIso === selectedDate;
min={today} const { weekday, day, monthTag } = formatDayCell(dateIso);
max={maxDate} return (
onChange={(e) => setSelectedDate(e.target.value)} <button
className="border border-slate-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-400 bg-white" key={dateIso}
/> data-date={dateIso}
onClick={() => setSelectedDate(dateIso)}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
flex: '1 0 0',
padding: '6px 2px',
borderRadius: 10,
background: isActive ? SAGE : 'transparent',
border: isActive
? `1.5px solid ${SAGE_DEEP}`
: '1px solid rgba(91,79,199,0.18)',
cursor: 'pointer',
fontFamily: 'inherit',
gap: 2,
transition: 'background 150ms, border-color 150ms',
}}
>
<span style={{
fontSize: 10,
fontWeight: 500,
letterSpacing: '0.16em',
textTransform: 'uppercase',
color: PURPLE,
opacity: isActive ? 0.85 : 0.55,
lineHeight: 1,
}}>
{weekday}
</span>
<span style={{
fontFamily: "'Rubik Mono One', monospace",
fontSize: 18,
color: isActive ? PURPLE_DEEP : PURPLE,
lineHeight: 1,
}}>
{day}
</span>
{monthTag && (
<span style={{
fontSize: 8,
fontWeight: 500,
letterSpacing: '0.12em',
textTransform: 'uppercase',
color: PURPLE,
opacity: 0.5,
lineHeight: 1,
}}>
{monthTag}
</span>
)}
</button>
);
})}
</div>
<button <button
onClick={() => setSelectedDate(offsetDate(selectedDate, 1))} onClick={() => setSelectedDate(offsetDate(selectedDate, 1))}
disabled={selectedDate >= maxDate} disabled={dayOffset >= MAX_OFFSET}
className="px-2.5 py-1.5 text-sm rounded-lg border border-slate-300 bg-white hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors" style={arrowBtnStyle(dayOffset >= MAX_OFFSET)}
> >
</button> </button>
<span className="text-sm text-slate-500 font-medium">{formatDisplayDate(selectedDate)}</span> </div>
</section> </div>
<div className="flex gap-6 text-xs text-slate-500"> {/* Floor card */}
<span className="flex items-center gap-1.5"> <div style={{
<span className="w-3 h-3 rounded bg-emerald-400 inline-block" /> background: PAPER,
Available click to reserve borderRadius: 18,
padding: 22,
border: '1px solid rgba(91,79,199,0.10)',
display: 'flex',
flexDirection: 'column',
}}>
{/* Legend */}
<div style={{
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'center',
marginBottom: 16,
gap: 14,
fontSize: 11,
color: PURPLE,
opacity: 0.75,
}}>
<span style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<span style={{
width: 8, height: 8, borderRadius: 2,
background: PAPER, border: '1px solid rgba(91,79,199,0.4)',
display: 'inline-block', flexShrink: 0,
}} />
free
</span> </span>
<span className="flex items-center gap-1.5"> <span style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<span className="w-3 h-3 rounded bg-blue-400 inline-block" /> <span style={{
Your reservation click to cancel width: 8, height: 8, borderRadius: 2,
background: SAGE, border: `1px solid ${SAGE_DEEP}`,
display: 'inline-block', flexShrink: 0,
}} />
yours
</span> </span>
<span className="flex items-center gap-1.5"> <span style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<span className="w-3 h-3 rounded bg-slate-300 inline-block" /> <span style={{
Taken hover to see who width: 8, height: 8, borderRadius: 2,
border: '1px dashed rgba(91,79,199,0.4)',
display: 'inline-block', flexShrink: 0,
}} />
taken
</span> </span>
</div> </div>
<section> {/* Pods */}
{loadingFloor && <p className="text-sm text-slate-400 text-center py-12">Loading floor plan</p>}
{floorError && <p className="text-sm text-red-500 text-center py-12">{floorError}</p>}
{!loadingFloor && !floorError && ( {!loadingFloor && !floorError && (
<div className="flex flex-wrap gap-12 justify-center"> <div style={{
<DeskPod desks={podA} myReservedIds={myReservedIdsOnDate} onDeskClick={handleDeskClick} /> display: 'flex',
<DeskPod desks={podB} myReservedIds={myReservedIdsOnDate} onDeskClick={handleDeskClick} /> gap: 36,
justifyContent: 'center',
alignItems: 'flex-start',
}}>
<DeskPod label="Pod A" desks={podA} myReservedIds={myReservedIdsOnDate} onDeskClick={handleDeskClick} />
<DeskPod label="Pod B" desks={podB} myReservedIds={myReservedIdsOnDate} onDeskClick={handleDeskClick} />
</div> </div>
)} )}
</section> {loadingFloor && (
<p style={{ textAlign: 'center', color: PURPLE, opacity: 0.5, fontSize: 13, margin: '32px 0' }}>
Loading
</p>
)}
{floorError && (
<p style={{ textAlign: 'center', color: '#c0392b', fontSize: 13, margin: '32px 0' }}>
{floorError}
</p>
)}
</div>
</div>
<section className="bg-white border border-slate-200 rounded-2xl p-6 shadow-sm"> {/* Right rail */}
<h2 className="text-base font-semibold text-slate-700 mb-4">My reservations</h2> <div style={{ width: 300, display: 'flex', flexDirection: 'column', gap: 14, overflow: 'auto', flexShrink: 0 }}>
{/* Your desk card */}
<div style={{
background: PAPER,
borderRadius: 18,
padding: 22,
border: '1px solid rgba(91,79,199,0.10)',
flexShrink: 0,
}}>
<div style={{
fontSize: 11, letterSpacing: '0.18em', textTransform: 'uppercase',
color: PURPLE, opacity: 0.7, marginBottom: 8, fontWeight: 500,
}}>
Your desk
</div>
<div style={{
fontFamily: "'Rubik Mono One', monospace",
fontSize: 46, letterSpacing: '-0.03em', color: PURPLE,
lineHeight: 0.95, marginBottom: 6,
}}>
{myDeskOnDate?.workplaceName ?? '—'}
</div>
<div style={{ fontSize: 13, color: PURPLE, opacity: 0.75, marginBottom: 18 }}>
{myDeskOnDate
? `${myDeskOnDate.workplaceLocation} · Held ${formatRelativeTime(myDeskOnDate.createdAt)}`
: 'No desk held for this day'}
</div>
<div style={{
display: 'flex', flexDirection: 'column', gap: 7,
fontSize: 13, color: PURPLE, marginBottom: 18,
borderTop: '1px solid rgba(91,79,199,0.10)', paddingTop: 14,
}}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ opacity: 0.65 }}>Date</span>
<span>{formatDisplayDate(selectedDate)}</span>
</div>
{myDeskOnDate && (
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ opacity: 0.65 }}>Neighbours</span>
<span style={{ textAlign: 'right', maxWidth: 140 }}>
{neighbours || '—'}
</span>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ opacity: 0.65 }}>Streak</span>
<span></span>
</div>
</div>
</div>
{/* My reservations card */}
<div style={{
background: PAPER,
borderRadius: 18,
padding: 22,
border: '1px solid rgba(91,79,199,0.10)',
}}>
<div style={{
fontSize: 11, letterSpacing: '0.18em', textTransform: 'uppercase',
color: PURPLE, opacity: 0.7, marginBottom: 14, fontWeight: 500,
}}>
My reservations
</div>
<MyReservations reservations={myReservations} onCancel={setCancelTarget} /> <MyReservations reservations={myReservations} onCancel={setCancelTarget} />
</section> </div>
</main> </div>
</div>
</div>
{reserveTarget && ( {reserveTarget && (
<ReservationModal <ReservationModal

View File

@@ -7,7 +7,7 @@ export default defineConfig({
retries: 0, retries: 0,
reporter: [['list'], ['html', { open: 'never' }]], reporter: [['list'], ['html', { open: 'never' }]],
use: { use: {
baseURL: process.env.BASE_URL ?? 'http://localhost', baseURL: process.env.BASE_URL ?? 'http://localhost:5173',
trace: 'on-first-retry', trace: 'on-first-retry',
screenshot: 'only-on-failure', screenshot: 'only-on-failure',
headless: !!process.env.CI, headless: !!process.env.CI,

View File

@@ -6,41 +6,96 @@ test.describe('Admin portal', () => {
await loginAsAdmin(page); await loginAsAdmin(page);
}); });
test('admin user sees the Admin portal button in the header', async ({ page }) => { test('admin user sees the Admin button in the header', async ({ page }) => {
await expect(page.getByRole('button', { name: 'Admin portal' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Admin' })).toBeVisible();
}); });
test('navigates to the admin portal', async ({ page }) => { test('navigates to the admin portal', async ({ page }) => {
await page.getByRole('button', { name: 'Admin portal' }).click(); await page.getByRole('button', { name: 'Admin' }).click();
await page.waitForURL('/admin'); await page.waitForURL('/admin');
await expect(page.getByRole('heading', { name: 'Admin Portal' })).toBeVisible(); await expect(page.getByRole('heading', { name: /Who can book/i })).toBeVisible();
await expect(page.getByText('Manage user accounts')).toBeVisible(); await expect(page.getByText(/Users.*The Hague HQ/i)).toBeVisible();
}); });
test('admin portal lists the admin account under Approved accounts', async ({ page }) => { test('admin portal lists the admin account', async ({ page }) => {
await page.goto('/admin'); await page.getByRole('button', { name: 'Admin' }).click();
await page.waitForURL('/admin');
await expect(page.getByText('Approved accounts')).toBeVisible(); await expect(page.getByText('admin@randall.local')).toBeVisible();
// Target the name paragraph inside the admin's list item const adminRow = page.locator('[data-testid="user-row"]').filter({ hasText: 'admin@randall.local' });
const adminRow = page.locator('li').filter({ hasText: 'admin@randall.local' }); await expect(adminRow.locator('[data-testid="role-badge"]').filter({ hasText: /^admin$/i })).toBeVisible();
await expect(adminRow.getByRole('paragraph').filter({ hasText: /^Admin$/ })).toBeVisible();
}); });
test('admin account has the Admin badge and no Make admin button', async ({ page }) => { test('admin account has the admin role badge and no make-admin button', async ({ page }) => {
await page.goto('/admin'); await page.getByRole('button', { name: 'Admin' }).click();
await page.waitForURL('/admin');
const adminRow = page.locator('li').filter({ hasText: 'admin@randall.local' }); const adminRow = page.locator('[data-testid="user-row"]').filter({ hasText: 'admin@randall.local' });
// The badge is a <span> with class bg-amber-100 await expect(adminRow.locator('[data-testid="role-badge"]').filter({ hasText: /^admin$/i })).toBeVisible();
await expect(adminRow.locator('span').filter({ hasText: /^Admin$/ })).toBeVisible(); // The make-admin action button is not rendered for users who are already admin
await expect(adminRow.getByRole('button', { name: 'Make admin' })).not.toBeVisible(); await expect(adminRow.getByRole('button', { name: 'Admin' })).not.toBeVisible();
}); });
test('Back to planner link returns to the planner', async ({ page }) => { test('wordmark navigates back to the planner', async ({ page }) => {
await page.goto('/admin'); await page.getByRole('button', { name: 'Admin' }).click();
await page.getByRole('button', { name: '← Back to planner' }).click(); await page.waitForURL('/admin');
await page.locator('span', { hasText: 'randall' }).first().click();
await page.waitForURL('/'); await page.waitForURL('/');
await expect(page.getByText('Reserve your workspace up to 2 weeks ahead')).toBeVisible(); 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();
}); });
}); });

View File

@@ -7,22 +7,22 @@ test.describe('Authentication', () => {
}); });
test('shows the sign-in form on initial load', async ({ page }) => { test('shows the sign-in form on initial load', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Office Planner' })).toBeVisible(); await expect(page.getByRole('heading', { name: /Find your desk/i })).toBeVisible();
await expect(page.getByPlaceholder('jane@company.com')).toBeVisible(); await expect(page.getByPlaceholder('you@randall.local')).toBeVisible();
await expect(page.getByPlaceholder('••••••••')).toBeVisible(); await expect(page.getByPlaceholder('••••••••')).toBeVisible();
await expect(page.locator('button[type="submit"]')).toHaveText('Sign in'); await expect(page.locator('button[type="submit"]')).toHaveText('Sign in');
}); });
test('signs in with valid credentials and reaches the planner', async ({ page }) => { test('signs in with valid credentials and reaches the planner', async ({ page }) => {
await page.getByPlaceholder('jane@company.com').fill(ADMIN_EMAIL); await page.getByPlaceholder('you@randall.local').fill(ADMIN_EMAIL);
await page.getByPlaceholder('••••••••').fill(ADMIN_PASSWORD); await page.getByPlaceholder('••••••••').fill(ADMIN_PASSWORD);
await page.locator('button[type="submit"]').click(); await page.locator('button[type="submit"]').click();
await expect(page.getByText('Reserve your workspace up to 2 weeks ahead')).toBeVisible(); await expect(page.getByRole('heading', { name: /Where to sit/i })).toBeVisible();
}); });
test('shows an error for an unknown email', async ({ page }) => { test('shows an error for an unknown email', async ({ page }) => {
await page.getByPlaceholder('jane@company.com').fill('nobody@example.com'); await page.getByPlaceholder('you@randall.local').fill('nobody@example.com');
await page.getByPlaceholder('••••••••').fill('anything'); await page.getByPlaceholder('••••••••').fill('anything');
await page.locator('button[type="submit"]').click(); await page.locator('button[type="submit"]').click();
@@ -30,7 +30,7 @@ test.describe('Authentication', () => {
}); });
test('shows an error for a wrong password', async ({ page }) => { test('shows an error for a wrong password', async ({ page }) => {
await page.getByPlaceholder('jane@company.com').fill(ADMIN_EMAIL); await page.getByPlaceholder('you@randall.local').fill(ADMIN_EMAIL);
await page.getByPlaceholder('••••••••').fill('wrongpassword'); await page.getByPlaceholder('••••••••').fill('wrongpassword');
await page.locator('button[type="submit"]').click(); await page.locator('button[type="submit"]').click();
@@ -39,25 +39,25 @@ test.describe('Authentication', () => {
test('registers a new account and shows pending approval message', async ({ page }) => { test('registers a new account and shows pending approval message', async ({ page }) => {
// Switch to register mode // Switch to register mode
await page.locator('button').filter({ hasText: 'Create account' }).first().click(); await page.locator('button').filter({ hasText: 'Register' }).first().click();
await page.getByPlaceholder('Jane Smith').fill('Test User'); await page.getByPlaceholder('you@randall.local').fill(`testuser+${Date.now()}@example.com`);
await page.getByPlaceholder('jane@company.com').fill(`testuser+${Date.now()}@example.com`);
await page.getByPlaceholder('••••••••').fill('Test@1234'); await page.getByPlaceholder('••••••••').fill('Test@1234');
await page.getByPlaceholder('Jane Smith').fill('Test User');
await page.locator('button[type="submit"]').click(); await page.locator('button[type="submit"]').click();
await expect(page.getByText('Account pending approval')).toBeVisible(); await expect(page.getByText('Almost there.')).toBeVisible();
await expect(page.getByText(/administrator will review/i)).toBeVisible(); await expect(page.getByText(/administrator will review/i)).toBeVisible();
}); });
test('signs out and returns to the sign-in form', async ({ page }) => { test('signs out and returns to the sign-in form', async ({ page }) => {
await page.getByPlaceholder('jane@company.com').fill(ADMIN_EMAIL); await page.getByPlaceholder('you@randall.local').fill(ADMIN_EMAIL);
await page.getByPlaceholder('••••••••').fill(ADMIN_PASSWORD); await page.getByPlaceholder('••••••••').fill(ADMIN_PASSWORD);
await page.locator('button[type="submit"]').click(); await page.locator('button[type="submit"]').click();
await page.waitForURL('/'); await page.waitForURL('/');
await page.getByRole('button', { name: 'Sign out' }).click(); await page.getByRole('button', { name: 'Sign out' }).click();
await expect(page.locator('button[type="submit"]')).toHaveText('Sign in'); await expect(page.locator('button[type="submit"]')).toHaveText('Sign in');
}); });
}); });

View File

@@ -6,20 +6,25 @@ export const ADMIN_NAME = 'Admin';
export async function loginAs(page: Page, email: string, password: string) { export async function loginAs(page: Page, email: string, password: string) {
await page.goto('/'); await page.goto('/');
await page.getByPlaceholder('jane@company.com').fill(email); await page.getByPlaceholder('you@randall.local').fill(email);
await page.getByPlaceholder('••••••••').fill(password); await page.getByPlaceholder('••••••••').fill(password);
await page.locator('button[type="submit"]').click(); await page.locator('button[type="submit"]').click();
await page.waitForURL('/'); await page.waitForURL('/');
await page.getByText('Reserve your workspace up to 2 weeks ahead').waitFor(); await page.locator('h1').waitFor();
} }
export async function loginAsAdmin(page: Page) { export async function loginAsAdmin(page: Page) {
await loginAs(page, ADMIN_EMAIL, ADMIN_PASSWORD); await loginAs(page, ADMIN_EMAIL, ADMIN_PASSWORD);
} }
/** Returns today's date offset by `days` in yyyy-MM-dd format */ /** Returns today's date offset by `days` in yyyy-MM-dd format. */
export function offsetDate(days: number): string { export function offsetDate(days: number): string {
const d = new Date(); const d = new Date();
d.setDate(d.getDate() + days); d.setDate(d.getDate() + days);
return d.toISOString().split('T')[0]; return d.toISOString().split('T')[0];
} }
/** Clicks the day-strip cell for today + `days` offset. */
export async function selectDate(page: Page, days: number) {
await page.locator(`[data-date="${offsetDate(days)}"]`).click();
}

View File

@@ -1,5 +1,5 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { loginAsAdmin, offsetDate } from './helpers'; import { loginAsAdmin, offsetDate, selectDate } from './helpers';
test.describe('Planner', () => { test.describe('Planner', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
@@ -8,20 +8,24 @@ test.describe('Planner', () => {
test('displays both pods with 8 desks each', async ({ page }) => { test('displays both pods with 8 desks each', async ({ page }) => {
// Wait for floor plan to load — at least one free desk must be visible // Wait for floor plan to load — at least one free desk must be visible
await expect(page.getByRole('button', { name: 'D1 Free' })).toBeVisible(); await expect(page.getByRole('button', { name: /^D1 free$/i })).toBeVisible();
// All 16 desk buttons contain a desk label (D1D16) in their text content // All 16 desk buttons contain a desk label (D1D16) in their text content
const allDesks = page.getByRole('button').filter({ hasText: /D\d+/ }); const allDesks = page.getByRole('button').filter({ hasText: /^D\d+/ });
await expect(allDesks).toHaveCount(16); await expect(allDesks).toHaveCount(16);
}); });
test('date picker is constrained to today and 14 days ahead', async ({ page }) => { test('date strip shows 14 days and respects boundaries', async ({ page }) => {
const input = page.locator('input[type="date"]'); // 14 day-strip cells are rendered (one per bookable day)
const today = offsetDate(0); await expect(page.locator('[data-date]')).toHaveCount(14);
const max = offsetDate(14);
await expect(input).toHaveAttribute('min', today); // Today and the last bookable day are both present
await expect(input).toHaveAttribute('max', max); await expect(page.locator(`[data-date="${offsetDate(0)}"]`)).toBeVisible();
await expect(page.locator(`[data-date="${offsetDate(13)}"]`)).toBeVisible();
// Clicking the last day disables the forward arrow
await page.locator(`[data-date="${offsetDate(13)}"]`).click();
await expect(page.getByRole('button', { name: '→' })).toBeDisabled();
}); });
test('previous-day button is disabled when on today', async ({ page }) => { test('previous-day button is disabled when on today', async ({ page }) => {
@@ -29,72 +33,60 @@ test.describe('Planner', () => {
}); });
test('next-day button is disabled when on the maximum date', async ({ page }) => { test('next-day button is disabled when on the maximum date', async ({ page }) => {
const input = page.locator('input[type="date"]'); await page.locator(`[data-date="${offsetDate(13)}"]`).click();
await input.fill(offsetDate(14));
await input.dispatchEvent('change');
await expect(page.getByRole('button', { name: '→' })).toBeDisabled(); await expect(page.getByRole('button', { name: '→' })).toBeDisabled();
}); });
test('reserves a desk and shows it as Mine', async ({ page }) => { test('reserves a desk and shows it as yours', async ({ page }) => {
const targetDate = offsetDate(7); await selectDate(page, 7);
const input = page.locator('input[type="date"]');
await input.fill(targetDate);
await input.dispatchEvent('change');
await page.getByRole('button', { name: 'D1 Free' }).click(); await page.getByRole('button', { name: /^D1 free$/i }).click();
await expect(page.getByRole('heading', { name: 'Reserve desk' })).toBeVisible(); await expect(page.getByRole('dialog').getByText('Reserve desk')).toBeVisible();
await page.getByRole('button', { name: 'Confirm' }).click(); await page.getByRole('button', { name: 'Confirm' }).click();
await expect(page.getByRole('button', { name: /^D1\s+Mine/ })).toBeVisible(); await expect(page.getByRole('button', { name: /^D1 yours$/i })).toBeVisible();
// Clean up // Clean up
await page.getByRole('button', { name: /^D1\s+Mine/ }).click(); await page.getByRole('button', { name: /^D1 yours$/i }).click();
await page.getByRole('button', { name: 'Cancel reservation' }).click(); await page.getByRole('button', { name: 'Cancel reservation' }).click();
}); });
test('reserved desk appears in My reservations', async ({ page }) => { test('reserved desk appears in My reservations', async ({ page }) => {
const targetDate = offsetDate(8); await selectDate(page, 8);
const input = page.locator('input[type="date"]');
await input.fill(targetDate);
await input.dispatchEvent('change');
await page.getByRole('button', { name: 'D2 Free' }).click(); await page.getByRole('button', { name: /D2.*free/i }).click();
await page.getByRole('button', { name: 'Confirm' }).click(); await page.getByRole('button', { name: 'Confirm' }).click();
await expect(page.getByRole('heading', { name: 'My reservations' })).toBeVisible(); await expect(page.getByText('My reservations')).toBeVisible();
await expect(page.locator('section').filter({ hasText: 'My reservations' }).getByText('D2')).toBeVisible(); await expect(page.locator('li').filter({ hasText: 'D2' })).toBeVisible();
// Clean up // Clean up
await page.getByRole('button', { name: /^D2\s+Mine/ }).click(); await page.getByRole('button', { name: /D2.*yours/i }).click();
await page.getByRole('button', { name: 'Cancel reservation' }).click(); await page.getByRole('button', { name: 'Cancel reservation' }).click();
}); });
test('cancels a reservation and desk returns to Free', async ({ page }) => { test('cancels a reservation and desk returns to free', async ({ page }) => {
const targetDate = offsetDate(9); await selectDate(page, 9);
const input = page.locator('input[type="date"]');
await input.fill(targetDate);
await input.dispatchEvent('change');
// Reserve // Reserve
await page.getByRole('button', { name: 'D3 Free' }).click(); await page.getByRole('button', { name: /D3.*free/i }).click();
await page.getByRole('button', { name: 'Confirm' }).click(); await page.getByRole('button', { name: 'Confirm' }).click();
await expect(page.getByRole('button', { name: /^D3\s+Mine/ })).toBeVisible(); await expect(page.getByRole('button', { name: /D3.*yours/i })).toBeVisible();
// Cancel // Cancel
await page.getByRole('button', { name: /^D3\s+Mine/ }).click(); await page.getByRole('button', { name: /D3.*yours/i }).click();
await expect(page.getByRole('heading', { name: 'Cancel reservation' })).toBeVisible(); await expect(page.getByRole('dialog')).toBeVisible();
await page.getByRole('button', { name: 'Cancel reservation' }).click(); await page.getByRole('button', { name: 'Cancel reservation' }).click();
await expect(page.getByRole('button', { name: 'D3 Free' })).toBeVisible(); await expect(page.getByRole('button', { name: /D3.*free/i })).toBeVisible();
}); });
test('dismisses the reservation modal when clicking Cancel', async ({ page }) => { test('dismisses the reservation modal when clicking Cancel', async ({ page }) => {
await page.getByRole('button', { name: 'D4 Free' }).click(); await page.getByRole('button', { name: /D4.*free/i }).click();
await expect(page.getByRole('heading', { name: 'Reserve desk' })).toBeVisible(); await expect(page.getByRole('dialog').getByText('Reserve desk')).toBeVisible();
// Scope to the modal overlay to avoid matching Cancel buttons in My reservations await page.getByRole('dialog').getByRole('button', { name: 'Cancel' }).click();
await page.locator('.fixed.inset-0').getByRole('button', { name: 'Cancel' }).click(); await expect(page.getByRole('dialog')).not.toBeVisible();
await expect(page.getByRole('heading', { name: 'Reserve desk' })).not.toBeVisible();
}); });
}); });