From 116d1582f3716e33a1356661dc8df93ce4753d8d Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 31 Jul 2026 09:50:27 +0300 Subject: [PATCH 01/16] require direnv for project tools --- ai/backend-context.md | 10 ++++- ai/frontend-context.md | 13 ++++--- ai/shared.md | 83 ++++++++++++++++++++++++++++++------------ 3 files changed, 74 insertions(+), 32 deletions(-) diff --git a/ai/backend-context.md b/ai/backend-context.md index 088ed62..8ab6a97 100644 --- a/ai/backend-context.md +++ b/ai/backend-context.md @@ -92,7 +92,8 @@ pattern. - Rebuild the development database with: ```sh - php artisan migrate:fresh --seed + direnv exec "$(git rev-parse --show-toplevel)" \ + php artisan migrate:fresh --seed ``` - Once a production database exists, replace this policy with additive, @@ -101,7 +102,12 @@ pattern. ## Before completing backend work - Run the focused test during development. -- Run `php artisan test` before completion. +- Run the full test suite before completion: + + ```sh + direnv exec "$(git rev-parse --show-toplevel)" php artisan test + ``` + - Run the Composer static-analysis scripts. - Fix failures caused by the change. Report unrelated baseline failures precisely rather than hiding them or expanding scope without authorization. diff --git a/ai/frontend-context.md b/ai/frontend-context.md index af25c82..3462801 100644 --- a/ai/frontend-context.md +++ b/ai/frontend-context.md @@ -33,13 +33,14 @@ Use npm and keep `package-lock.json` committed. Run commands from Install dependencies in a fresh checkout or worktree: ```sh -npm install +direnv exec "$(git rev-parse --show-toplevel)" npm install ``` Start the development server on the port assigned by the shell hook: ```sh -npm run dev -- --port "$VITE_PORT" +direnv exec "$(git rev-parse --show-toplevel)" \ + npm run dev -- --port "$VITE_PORT" ``` `process-compose` does not start or proxy the frontend. @@ -47,10 +48,10 @@ npm run dev -- --port "$VITE_PORT" The available validation commands are: ```sh -npm run format -npm run lint -npm run type-check -npm run build +direnv exec "$(git rev-parse --show-toplevel)" npm run format +direnv exec "$(git rev-parse --show-toplevel)" npm run lint +direnv exec "$(git rev-parse --show-toplevel)" npm run type-check +direnv exec "$(git rev-parse --show-toplevel)" npm run build ``` `npm run format` and `npm run lint` rewrite files. Review the resulting diff. diff --git a/ai/shared.md b/ai/shared.md index 2f792aa..9c921c2 100644 --- a/ai/shared.md +++ b/ai/shared.md @@ -45,28 +45,43 @@ those changes with the most relevant parser, formatter, dry run, or check. is the user's stack. Do not start, restart, or stop it unless the user asks. - A worktree owns its own isolated stack. The flake shell hook assigns a deterministic port offset and creates worktree-local PostgreSQL state. -- Start a worktree stack from its root with `process-compose up`. For - non-interactive use, run `process-compose up -D` and stop it with - `process-compose down`. +- Start a worktree stack from its root. For non-interactive use, start it + detached and stop it when finished, as shown below. - Do not use `process-compose -t=false` for a detached stack. It can leave an orphaned PostgreSQL process holding the data directory. -- Non-interactive agent shells do not automatically load direnv. A bare - `process-compose`, `php artisan`, or database command from a worktree can - silently use default ports and target the main checkout. -- Prefix worktree stack and database commands with `direnv exec `. - Examples: +- Non-interactive agent shells do not automatically load direnv. Bare project + commands can use missing tools, default ports, or paths from the main + checkout. +- Run project tooling that depends on the repository development environment + through direnv. This includes PHP, Composer, Artisan, npm, tests, builds, + database clients, and services. +- Resolve the direnv target from the worktree containing the agent's current + working directory. Never target the main checkout or a different worktree: ```sh - direnv exec process-compose up -D - direnv exec process-compose down - direnv exec php artisan migrate:fresh --seed + direnv exec "$(git rev-parse --show-toplevel)" + ``` + +- Git and environment-neutral read-only file inspection do not need the + direnv wrapper. +- Worktree stack examples: + + ```sh + direnv exec "$(git rev-parse --show-toplevel)" process-compose up -D + direnv exec "$(git rev-parse --show-toplevel)" process-compose down ``` - Run backend commands from `backend/`, or explicitly change into it in the - command. + command. The direnv target remains the worktree root. - Run frontend commands from `frontend/website/`. - `process-compose` does not start the frontend. Start it separately with - `npm run dev -- --port "$VITE_PORT"` when needed. + the worktree's assigned port when needed: + + ```sh + direnv exec "$(git rev-parse --show-toplevel)" \ + npm run dev -- --port "$VITE_PORT" + ``` + - When a normally valid check fails because a required service is down, surface the environmental failure. Do not skip the check or silently switch to a different database or service. @@ -143,8 +158,11 @@ those changes with the most relevant parser, formatter, dry run, or check. another checkout. Dependency paths and generated files must remain worktree-local. - The shell hook installs backend dependencies but does not install frontend - dependencies. Run `npm install` from `frontend/website/` when provisioning a - fresh worktree. + dependencies. From `frontend/website/`, provision them with: + + ```sh + direnv exec "$(git rev-parse --show-toplevel)" npm install + ``` Do not push anything. Make commits as the TDD workflow requires. @@ -155,12 +173,17 @@ gate affected by the change. ### Backend -- Run tests from `backend/` with `php artisan test`. +- Run tests from `backend/`: + + ```sh + direnv exec "$(git rev-parse --show-toplevel)" php artisan test + ``` + - Run the Composer checks defined by `backend/composer.json`: ```sh - composer types:check - composer test + direnv exec "$(git rev-parse --show-toplevel)" composer types:check + direnv exec "$(git rev-parse --show-toplevel)" composer test ``` - Do not claim a green gate when a command fails. If the failure predates the @@ -171,10 +194,10 @@ gate affected by the change. - Run these commands from `frontend/website/`: ```sh - npm run format - npm run lint - npm run type-check - npm run build + direnv exec "$(git rev-parse --show-toplevel)" npm run format + direnv exec "$(git rev-parse --show-toplevel)" npm run lint + direnv exec "$(git rev-parse --show-toplevel)" npm run type-check + direnv exec "$(git rev-parse --show-toplevel)" npm run build ``` - The formatter and linters rewrite files. Review their changes before @@ -184,8 +207,20 @@ gate affected by the change. ### Environment and integration -- For Nix or shell-hook changes, run `nix fmt` and `nix flake check`. -- For service configuration changes, run `process-compose --dry-run`. +- For Nix or shell-hook changes, run: + + ```sh + direnv exec "$(git rev-parse --show-toplevel)" nix fmt + direnv exec "$(git rev-parse --show-toplevel)" nix flake check + ``` + +- For service configuration changes, run: + + ```sh + direnv exec "$(git rev-parse --show-toplevel)" \ + process-compose --dry-run + ``` + - When a change affects runtime wiring, start the worktree's stack and verify the relevant endpoint or service against that worktree. - If you started the stack only for validation, stop it before finishing. From a8a6177a1d0d09458f290a7d820c9729fd35287c Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 31 Jul 2026 10:08:00 +0300 Subject: [PATCH 02/16] add nssTools for mkcert install --- flake.nix | 1 + 1 file changed, 1 insertion(+) diff --git a/flake.nix b/flake.nix index 68a882c..820c512 100644 --- a/flake.nix +++ b/flake.nix @@ -43,6 +43,7 @@ mailpit process-compose mkcert + nssTools caddy ]; From b5697e1e1f82618aabbaa64e409312a9012bc941 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 31 Jul 2026 09:48:01 +0300 Subject: [PATCH 03/16] test user persistence --- .../User/EloquentUserRepositoryTest.php | 48 +++++++++++++++++++ .../Shared/ValueObject/EmailAddressTest.php | 27 +++++++++++ backend/tests/Unit/User/UserTest.php | 19 ++++++++ 3 files changed, 94 insertions(+) create mode 100644 backend/tests/Feature/User/EloquentUserRepositoryTest.php create mode 100644 backend/tests/Unit/Shared/ValueObject/EmailAddressTest.php create mode 100644 backend/tests/Unit/User/UserTest.php diff --git a/backend/tests/Feature/User/EloquentUserRepositoryTest.php b/backend/tests/Feature/User/EloquentUserRepositoryTest.php new file mode 100644 index 0000000..207523f --- /dev/null +++ b/backend/tests/Feature/User/EloquentUserRepositoryTest.php @@ -0,0 +1,48 @@ +create(new CreateUserDto( + email: new EmailAddress('Founder@EXAMPLE.COM'), + )); + + $this->assertGreaterThan(0, $user->getId()); + $this->assertSame( + 'Founder@example.com', + $user->getEmail()->value(), + ); + $this->assertDatabaseHas('users', [ + 'id' => $user->getId(), + 'email' => 'Founder@example.com', + ]); + + $foundUser = $repository->find($user->getId()); + + $this->assertNotNull($foundUser); + $this->assertSame($user->getId(), $foundUser->getId()); + $this->assertSame( + $user->getEmail()->value(), + $foundUser->getEmail()->value(), + ); + } + + public function test_it_returns_null_for_an_unknown_user(): void + { + $repository = app(UserRepository::class); + + $this->assertNull($repository->find(999)); + } +} diff --git a/backend/tests/Unit/Shared/ValueObject/EmailAddressTest.php b/backend/tests/Unit/Shared/ValueObject/EmailAddressTest.php new file mode 100644 index 0000000..c47b3e4 --- /dev/null +++ b/backend/tests/Unit/Shared/ValueObject/EmailAddressTest.php @@ -0,0 +1,27 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Invalid email address: invalid-email', + ); + + new EmailAddress('invalid-email'); + } + + public function test_it_trims_and_normalizes_the_domain(): void + { + $email = new EmailAddress(' Founder@EXAMPLE.COM '); + + $this->assertSame('Founder@example.com', $email->value()); + } +} diff --git a/backend/tests/Unit/User/UserTest.php b/backend/tests/Unit/User/UserTest.php new file mode 100644 index 0000000..0ac8d53 --- /dev/null +++ b/backend/tests/Unit/User/UserTest.php @@ -0,0 +1,19 @@ +assertSame(42, $user->getId()); + $this->assertSame($email, $user->getEmail()); + } +} From 0777f0da2ff39c6e6a630e0a4210c40702e38042 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 31 Jul 2026 09:49:18 +0300 Subject: [PATCH 04/16] add user entity --- backend/app/Models/User.php | 40 ------ backend/app/Providers/AppServiceProvider.php | 7 +- .../app/Shared/ValueObject/EmailAddress.php | 40 ++++++ backend/app/User/CreateUserDto.php | 12 ++ backend/app/User/EloquentUserRepository.php | 35 ++++++ backend/app/User/User.php | 23 ++++ backend/app/User/UserModel.php | 25 ++++ backend/app/User/UserRepository.php | 10 ++ backend/config/auth.php | 117 ------------------ .../0001_01_01_000000_create_users_table.php | 21 ++++ 10 files changed, 172 insertions(+), 158 deletions(-) delete mode 100644 backend/app/Models/User.php create mode 100644 backend/app/Shared/ValueObject/EmailAddress.php create mode 100644 backend/app/User/CreateUserDto.php create mode 100644 backend/app/User/EloquentUserRepository.php create mode 100644 backend/app/User/User.php create mode 100644 backend/app/User/UserModel.php create mode 100644 backend/app/User/UserRepository.php delete mode 100644 backend/config/auth.php create mode 100644 backend/database/migrations/0001_01_01_000000_create_users_table.php diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php deleted file mode 100644 index 4c7c078..0000000 --- a/backend/app/Models/User.php +++ /dev/null @@ -1,40 +0,0 @@ - - */ - protected function casts(): array - { - return [ - 'email_verified_at' => 'datetime', - 'password' => 'hashed', - ]; - } -} diff --git a/backend/app/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php index f1525e9..fdc3673 100644 --- a/backend/app/Providers/AppServiceProvider.php +++ b/backend/app/Providers/AppServiceProvider.php @@ -2,6 +2,8 @@ namespace App\Providers; +use App\User\EloquentUserRepository; +use App\User\UserRepository; use Carbon\CarbonImmutable; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; @@ -15,7 +17,10 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + $this->app->bind( + UserRepository::class, + EloquentUserRepository::class, + ); } /** diff --git a/backend/app/Shared/ValueObject/EmailAddress.php b/backend/app/Shared/ValueObject/EmailAddress.php new file mode 100644 index 0000000..de15bd9 --- /dev/null +++ b/backend/app/Shared/ValueObject/EmailAddress.php @@ -0,0 +1,40 @@ +normalized = $normalizedEmail; + } + + public function value(): string + { + return $this->normalized; + } +} diff --git a/backend/app/User/CreateUserDto.php b/backend/app/User/CreateUserDto.php new file mode 100644 index 0000000..735320b --- /dev/null +++ b/backend/app/User/CreateUserDto.php @@ -0,0 +1,12 @@ + $dto->email->value(), + ]); + + return $this->toDomain($model); + } + + public function find(int $id): ?User + { + $model = UserModel::find($id); + if ($model === null) { + return null; + } + + return $this->toDomain($model); + } + + private function toDomain(UserModel $model): User + { + return new User( + id: $model->id, + email: new EmailAddress($model->email), + ); + } +} diff --git a/backend/app/User/User.php b/backend/app/User/User.php new file mode 100644 index 0000000..8c9fa9c --- /dev/null +++ b/backend/app/User/User.php @@ -0,0 +1,23 @@ +id; + } + + public function getEmail(): EmailAddress + { + return $this->email; + } +} diff --git a/backend/app/User/UserModel.php b/backend/app/User/UserModel.php new file mode 100644 index 0000000..74f3211 --- /dev/null +++ b/backend/app/User/UserModel.php @@ -0,0 +1,25 @@ +|UserModel newModelQuery() + * @method static Builder|UserModel newQuery() + * @method static Builder|UserModel query() + * + * @mixin \Eloquent + */ +#[Fillable(['email'])] +class UserModel extends Model +{ + protected $table = 'users'; + + public $timestamps = false; +} diff --git a/backend/app/User/UserRepository.php b/backend/app/User/UserRepository.php new file mode 100644 index 0000000..5fcd6eb --- /dev/null +++ b/backend/app/User/UserRepository.php @@ -0,0 +1,10 @@ + [ - 'guard' => env('AUTH_GUARD', 'web'), - 'passwords' => env('AUTH_PASSWORD_BROKER', 'users'), - ], - - /* - |-------------------------------------------------------------------------- - | Authentication Guards - |-------------------------------------------------------------------------- - | - | Next, you may define every authentication guard for your application. - | Of course, a great default configuration has been defined for you - | which utilizes session storage plus the Eloquent user provider. - | - | All authentication guards have a user provider, which defines how the - | users are actually retrieved out of your database or other storage - | system used by the application. Typically, Eloquent is utilized. - | - | Supported: "session" - | - */ - - 'guards' => [ - 'web' => [ - 'driver' => 'session', - 'provider' => 'users', - ], - ], - - /* - |-------------------------------------------------------------------------- - | User Providers - |-------------------------------------------------------------------------- - | - | All authentication guards have a user provider, which defines how the - | users are actually retrieved out of your database or other storage - | system used by the application. Typically, Eloquent is utilized. - | - | If you have multiple user tables or models you may configure multiple - | providers to represent the model / table. These providers may then - | be assigned to any extra authentication guards you have defined. - | - | Supported: "database", "eloquent" - | - */ - - 'providers' => [ - 'users' => [ - 'driver' => 'eloquent', - 'model' => env('AUTH_MODEL', User::class), - ], - - // 'users' => [ - // 'driver' => 'database', - // 'table' => 'users', - // ], - ], - - /* - |-------------------------------------------------------------------------- - | Resetting Passwords - |-------------------------------------------------------------------------- - | - | These configuration options specify the behavior of Laravel's password - | reset functionality, including the table utilized for token storage - | and the user provider that is invoked to actually retrieve users. - | - | The expiry time is the number of minutes that each reset token will be - | considered valid. This security feature keeps tokens short-lived so - | they have less time to be guessed. You may change this as needed. - | - | The throttle setting is the number of seconds a user must wait before - | generating more password reset tokens. This prevents the user from - | quickly generating a very large amount of password reset tokens. - | - */ - - 'passwords' => [ - 'users' => [ - 'provider' => 'users', - 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), - 'expire' => 60, - 'throttle' => 60, - ], - ], - - /* - |-------------------------------------------------------------------------- - | Password Confirmation Timeout - |-------------------------------------------------------------------------- - | - | Here you may define the number of seconds before a password confirmation - | window expires and users are asked to re-enter their password via the - | confirmation screen. By default, the timeout lasts for three hours. - | - */ - - 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), - -]; diff --git a/backend/database/migrations/0001_01_01_000000_create_users_table.php b/backend/database/migrations/0001_01_01_000000_create_users_table.php new file mode 100644 index 0000000..dafb6c2 --- /dev/null +++ b/backend/database/migrations/0001_01_01_000000_create_users_table.php @@ -0,0 +1,21 @@ +id(); + $table->string('email')->unique(); + }); + } + + public function down(): void + { + Schema::dropIfExists('users'); + } +}; From a37c8da5551b16ceae2d4d4064c5dd7bbfb00516 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 31 Jul 2026 09:50:08 +0300 Subject: [PATCH 05/16] test auth sessions --- .../Auth/EloquentSessionRepositoryTest.php | 85 +++++++++++++++++++ backend/tests/Unit/Auth/SessionTest.php | 61 +++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 backend/tests/Feature/Auth/EloquentSessionRepositoryTest.php create mode 100644 backend/tests/Unit/Auth/SessionTest.php diff --git a/backend/tests/Feature/Auth/EloquentSessionRepositoryTest.php b/backend/tests/Feature/Auth/EloquentSessionRepositoryTest.php new file mode 100644 index 0000000..c9f76e2 --- /dev/null +++ b/backend/tests/Feature/Auth/EloquentSessionRepositoryTest.php @@ -0,0 +1,85 @@ +create(new CreateUserDto( + email: new EmailAddress('user@example.com'), + )); + $createdAt = $this->utc('2026-07-31T12:00:00'); + $expiresAt = $this->utc('2026-08-07T12:00:00'); + $repository = app(SessionRepository::class); + + $session = $repository->create(new CreateSessionDto( + token: 'session-token', + user: $user, + createdAt: $createdAt, + expiresAt: $expiresAt, + )); + + $this->assertSame('session-token', $session->getToken()); + $this->assertSame($user, $session->getUser()); + $this->assertDatabaseHas('auth_sessions', [ + 'token' => 'session-token', + 'user_id' => $user->getId(), + ]); + + $foundSession = $repository->findByToken('session-token'); + + $this->assertNotNull($foundSession); + $this->assertSame( + $user->getId(), + $foundSession->getUser()->getId(), + ); + $this->assertEquals($createdAt, $foundSession->getCreatedAt()); + $this->assertEquals($expiresAt, $foundSession->getExpiresAt()); + } + + public function test_it_returns_null_for_an_unknown_token(): void + { + $repository = app(SessionRepository::class); + + $this->assertNull($repository->findByToken('unknown-token')); + } + + public function test_it_deletes_a_session_by_token(): void + { + $user = app(UserRepository::class)->create(new CreateUserDto( + email: new EmailAddress('user@example.com'), + )); + $repository = app(SessionRepository::class); + $repository->create(new CreateSessionDto( + token: 'session-token', + user: $user, + createdAt: $this->utc('2026-07-31T12:00:00'), + expiresAt: $this->utc('2026-08-07T12:00:00'), + )); + + $repository->deleteByToken('session-token'); + + $this->assertNull($repository->findByToken('session-token')); + $this->assertDatabaseMissing('auth_sessions', [ + 'token' => 'session-token', + ]); + } + + private function utc(string $time): DateTimeImmutable + { + return new DateTimeImmutable($time, new DateTimeZone('UTC')); + } +} diff --git a/backend/tests/Unit/Auth/SessionTest.php b/backend/tests/Unit/Auth/SessionTest.php new file mode 100644 index 0000000..7804333 --- /dev/null +++ b/backend/tests/Unit/Auth/SessionTest.php @@ -0,0 +1,61 @@ +utc('2026-07-31T12:00:00'); + $expiresAt = $this->utc('2026-08-07T12:00:00'); + $session = new Session( + token: 'session-token', + user: $user, + createdAt: $createdAt, + expiresAt: $expiresAt, + ); + + $this->assertSame('session-token', $session->getToken()); + $this->assertSame($user, $session->getUser()); + $this->assertSame($createdAt, $session->getCreatedAt()); + $this->assertSame($expiresAt, $session->getExpiresAt()); + } + + public function test_it_expires_at_the_expiry_time(): void + { + $expiresAt = $this->utc('2026-08-07T12:00:00'); + $session = new Session( + token: 'session-token', + user: new User( + id: 7, + email: new EmailAddress('user@example.com'), + ), + createdAt: $this->utc('2026-07-31T12:00:00'), + expiresAt: $expiresAt, + ); + + $this->assertFalse( + $session->isExpired($expiresAt->modify('-1 second')), + ); + $this->assertTrue($session->isExpired($expiresAt)); + $this->assertTrue( + $session->isExpired($expiresAt->modify('+1 second')), + ); + } + + private function utc(string $time): DateTimeImmutable + { + return new DateTimeImmutable($time, new DateTimeZone('UTC')); + } +} From 34e26f81f535ae58293a74f90a1e75640c365e63 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 31 Jul 2026 09:51:39 +0300 Subject: [PATCH 06/16] add auth sessions --- backend/app/Auth/Clock.php | 10 +++ backend/app/Auth/CreateSessionDto.php | 16 +++++ .../app/Auth/EloquentSessionRepository.php | 63 +++++++++++++++++++ backend/app/Auth/Session.php | 41 ++++++++++++ backend/app/Auth/SessionModel.php | 50 +++++++++++++++ backend/app/Auth/SessionRepository.php | 12 ++++ backend/app/Auth/SystemClock.php | 17 +++++ backend/app/Providers/AppServiceProvider.php | 9 +++ ...7_31_000000_create_auth_sessions_table.php | 25 ++++++++ 9 files changed, 243 insertions(+) create mode 100644 backend/app/Auth/Clock.php create mode 100644 backend/app/Auth/CreateSessionDto.php create mode 100644 backend/app/Auth/EloquentSessionRepository.php create mode 100644 backend/app/Auth/Session.php create mode 100644 backend/app/Auth/SessionModel.php create mode 100644 backend/app/Auth/SessionRepository.php create mode 100644 backend/app/Auth/SystemClock.php create mode 100644 backend/database/migrations/2026_07_31_000000_create_auth_sessions_table.php diff --git a/backend/app/Auth/Clock.php b/backend/app/Auth/Clock.php new file mode 100644 index 0000000..b96ad32 --- /dev/null +++ b/backend/app/Auth/Clock.php @@ -0,0 +1,10 @@ + $dto->token, + 'user_id' => $dto->user->getId(), + 'created_at' => $dto->createdAt, + 'expires_at' => $dto->expiresAt, + ]); + + return new Session( + token: $dto->token, + user: $dto->user, + createdAt: $dto->createdAt, + expiresAt: $dto->expiresAt, + ); + } + + public function findByToken(string $token): ?Session + { + $model = SessionModel::find($token); + if ($model === null) { + return null; + } + + $user = $this->userRepository->find($model->user_id); + if ($user === null) { + return null; + } + + return new Session( + token: $model->token, + user: $user, + createdAt: $this->toUtc($model->created_at), + expiresAt: $this->toUtc($model->expires_at), + ); + } + + public function deleteByToken(string $token): void + { + SessionModel::where('token', $token)->delete(); + } + + private function toUtc(CarbonInterface $dateTime): DateTimeImmutable + { + return DateTimeImmutable::createFromInterface($dateTime) + ->setTimezone(new DateTimeZone('UTC')); + } +} diff --git a/backend/app/Auth/Session.php b/backend/app/Auth/Session.php new file mode 100644 index 0000000..5fd9cb1 --- /dev/null +++ b/backend/app/Auth/Session.php @@ -0,0 +1,41 @@ +token; + } + + public function getUser(): User + { + return $this->user; + } + + public function getCreatedAt(): DateTimeImmutable + { + return $this->createdAt; + } + + public function getExpiresAt(): DateTimeImmutable + { + return $this->expiresAt; + } + + public function isExpired(DateTimeImmutable $now): bool + { + return $now >= $this->expiresAt; + } +} diff --git a/backend/app/Auth/SessionModel.php b/backend/app/Auth/SessionModel.php new file mode 100644 index 0000000..75e87e3 --- /dev/null +++ b/backend/app/Auth/SessionModel.php @@ -0,0 +1,50 @@ +|SessionModel newModelQuery() + * @method static Builder|SessionModel newQuery() + * @method static Builder|SessionModel query() + * + * @mixin \Eloquent + */ +#[Fillable([ + 'token', + 'user_id', + 'created_at', + 'expires_at', +])] +class SessionModel extends Model +{ + protected $table = 'auth_sessions'; + + protected $primaryKey = 'token'; + + public $incrementing = false; + + protected $keyType = 'string'; + + public $timestamps = false; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'created_at' => 'datetime', + 'expires_at' => 'datetime', + ]; + } +} diff --git a/backend/app/Auth/SessionRepository.php b/backend/app/Auth/SessionRepository.php new file mode 100644 index 0000000..cabae60 --- /dev/null +++ b/backend/app/Auth/SessionRepository.php @@ -0,0 +1,12 @@ +app->bind( + SessionRepository::class, + EloquentSessionRepository::class, + ); + $this->app->bind(Clock::class, SystemClock::class); } /** diff --git a/backend/database/migrations/2026_07_31_000000_create_auth_sessions_table.php b/backend/database/migrations/2026_07_31_000000_create_auth_sessions_table.php new file mode 100644 index 0000000..995c147 --- /dev/null +++ b/backend/database/migrations/2026_07_31_000000_create_auth_sessions_table.php @@ -0,0 +1,25 @@ +string('token', 64)->primary(); + $table->foreignId('user_id') + ->constrained('users') + ->cascadeOnDelete(); + $table->timestamp('created_at'); + $table->timestamp('expires_at')->index(); + }); + } + + public function down(): void + { + Schema::dropIfExists('auth_sessions'); + } +}; From 19d930b7e85ceb8522bf18c604c5f58bbaf963e8 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 31 Jul 2026 09:53:12 +0300 Subject: [PATCH 07/16] test auth middleware --- backend/tests/Fakes/FakeClock.php | 18 ++ backend/tests/Fakes/FakeSessionRepository.php | 38 +++++ .../tests/Feature/Auth/AuthMiddlewareTest.php | 110 ++++++++++++ .../Auth/Middleware/AuthMiddlewareTest.php | 159 ++++++++++++++++++ 4 files changed, 325 insertions(+) create mode 100644 backend/tests/Fakes/FakeClock.php create mode 100644 backend/tests/Fakes/FakeSessionRepository.php create mode 100644 backend/tests/Feature/Auth/AuthMiddlewareTest.php create mode 100644 backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php diff --git a/backend/tests/Fakes/FakeClock.php b/backend/tests/Fakes/FakeClock.php new file mode 100644 index 0000000..30374d3 --- /dev/null +++ b/backend/tests/Fakes/FakeClock.php @@ -0,0 +1,18 @@ +currentTime; + } +} diff --git a/backend/tests/Fakes/FakeSessionRepository.php b/backend/tests/Fakes/FakeSessionRepository.php new file mode 100644 index 0000000..d64efd5 --- /dev/null +++ b/backend/tests/Fakes/FakeSessionRepository.php @@ -0,0 +1,38 @@ + + */ + private array $sessions = []; + + public function create(CreateSessionDto $dto): Session + { + $session = new Session( + token: $dto->token, + user: $dto->user, + createdAt: $dto->createdAt, + expiresAt: $dto->expiresAt, + ); + $this->sessions[$dto->token] = $session; + + return $session; + } + + public function findByToken(string $token): ?Session + { + return $this->sessions[$token] ?? null; + } + + public function deleteByToken(string $token): void + { + unset($this->sessions[$token]); + } +} diff --git a/backend/tests/Feature/Auth/AuthMiddlewareTest.php b/backend/tests/Feature/Auth/AuthMiddlewareTest.php new file mode 100644 index 0000000..5b9c034 --- /dev/null +++ b/backend/tests/Feature/Auth/AuthMiddlewareTest.php @@ -0,0 +1,110 @@ +get( + '/test/authenticated-user', + function (Request $request): JsonResponse { + $user = $request->attributes->get('user'); + if (! $user instanceof User) { + return new JsonResponse(['error' => 'missing user'], 500); + } + + return new JsonResponse([ + 'id' => $user->getId(), + 'email' => $user->getEmail()->value(), + ]); + }, + ); + } + + public function test_valid_cookie_reaches_the_protected_route(): void + { + $user = $this->createUserAndSession( + token: 'valid-token', + expiresAt: $this->utc('2030-08-07T12:00:00'), + ); + + $response = $this->withUnencryptedCookie( + AuthMiddleware::COOKIE_NAME, + 'valid-token', + )->getJson('/test/authenticated-user'); + + $response->assertOk()->assertExactJson([ + 'id' => $user->getId(), + 'email' => 'user@example.com', + ]); + } + + public function test_missing_cookie_is_rejected(): void + { + $response = $this->getJson('/test/authenticated-user'); + + $response + ->assertStatus(401) + ->assertExactJson(['error' => 'unauthenticated']); + } + + public function test_expired_cookie_is_rejected_and_deleted(): void + { + $this->createUserAndSession( + token: 'expired-token', + expiresAt: $this->utc('2020-08-07T12:00:00'), + ); + + $response = $this->withUnencryptedCookie( + AuthMiddleware::COOKIE_NAME, + 'expired-token', + )->getJson('/test/authenticated-user'); + + $response->assertStatus(401); + $this->assertNull( + app(SessionRepository::class)->findByToken('expired-token'), + ); + } + + private function createUserAndSession( + string $token, + DateTimeImmutable $expiresAt, + ): User { + $user = app(UserRepository::class)->create(new CreateUserDto( + email: new EmailAddress('user@example.com'), + )); + app(SessionRepository::class)->create(new CreateSessionDto( + token: $token, + user: $user, + createdAt: $this->utc('2026-07-31T12:00:00'), + expiresAt: $expiresAt, + )); + + return $user; + } + + private function utc(string $time): DateTimeImmutable + { + return new DateTimeImmutable($time, new DateTimeZone('UTC')); + } +} diff --git a/backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php b/backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php new file mode 100644 index 0000000..65908c7 --- /dev/null +++ b/backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php @@ -0,0 +1,159 @@ +now = new DateTimeImmutable( + '2026-07-31T12:00:00', + new DateTimeZone('UTC'), + ); + $this->sessionRepository = new FakeSessionRepository; + $this->middleware = new AuthMiddleware( + sessionRepository: $this->sessionRepository, + clock: new FakeClock($this->now), + ); + } + + public function test_missing_cookie_returns_unauthenticated(): void + { + $capturedRequest = null; + + $response = $this->middleware->handle( + $this->requestWithToken(null), + $this->captureNextRequest($capturedRequest), + ); + + $this->assertSame(401, $response->getStatusCode()); + $this->assertSame( + ['error' => 'unauthenticated'], + json_decode($response->getContent(), true), + ); + $this->assertNull($capturedRequest); + } + + public function test_empty_cookie_returns_unauthenticated(): void + { + $capturedRequest = null; + + $response = $this->middleware->handle( + $this->requestWithToken(''), + $this->captureNextRequest($capturedRequest), + ); + + $this->assertSame(401, $response->getStatusCode()); + $this->assertNull($capturedRequest); + } + + public function test_unknown_token_returns_unauthenticated(): void + { + $capturedRequest = null; + + $response = $this->middleware->handle( + $this->requestWithToken('unknown-token'), + $this->captureNextRequest($capturedRequest), + ); + + $this->assertSame(401, $response->getStatusCode()); + $this->assertNull($capturedRequest); + } + + public function test_expired_session_is_deleted(): void + { + $this->sessionRepository->create(new CreateSessionDto( + token: 'expired-token', + user: $this->user(), + createdAt: $this->now->modify('-8 days'), + expiresAt: $this->now->modify('-1 day'), + )); + $capturedRequest = null; + + $response = $this->middleware->handle( + $this->requestWithToken('expired-token'), + $this->captureNextRequest($capturedRequest), + ); + + $this->assertSame(401, $response->getStatusCode()); + $this->assertNull($capturedRequest); + $this->assertNull( + $this->sessionRepository->findByToken('expired-token'), + ); + } + + public function test_valid_session_attaches_user_and_calls_next(): void + { + $user = $this->user(); + $this->sessionRepository->create(new CreateSessionDto( + token: 'valid-token', + user: $user, + createdAt: $this->now, + expiresAt: $this->now->modify('+7 days'), + )); + $capturedRequest = null; + + $response = $this->middleware->handle( + $this->requestWithToken('valid-token'), + $this->captureNextRequest($capturedRequest), + ); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertNotNull($capturedRequest); + $this->assertSame( + $user, + $capturedRequest->attributes->get('user'), + ); + } + + private function requestWithToken(?string $token): Request + { + $request = Request::create('/anything', 'GET'); + if ($token !== null) { + $request->cookies->set(AuthMiddleware::COOKIE_NAME, $token); + } + + return $request; + } + + /** + * @param Request|null $capturedRequest + * @return Closure(Request): JsonResponse + */ + private function captureNextRequest( + ?Request &$capturedRequest, + ): Closure { + return function (Request $request) use (&$capturedRequest) { + $capturedRequest = $request; + + return new JsonResponse(['ok' => true]); + }; + } + + private function user(): User + { + return new User( + id: 7, + email: new EmailAddress('user@example.com'), + ); + } +} From 529a3529069afb49cc1986130b8126c8c37ebe7a Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 31 Jul 2026 09:53:38 +0300 Subject: [PATCH 08/16] test sessions table name --- backend/tests/Feature/Auth/EloquentSessionRepositoryTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/tests/Feature/Auth/EloquentSessionRepositoryTest.php b/backend/tests/Feature/Auth/EloquentSessionRepositoryTest.php index c9f76e2..32d170d 100644 --- a/backend/tests/Feature/Auth/EloquentSessionRepositoryTest.php +++ b/backend/tests/Feature/Auth/EloquentSessionRepositoryTest.php @@ -34,7 +34,7 @@ class EloquentSessionRepositoryTest extends TestCase $this->assertSame('session-token', $session->getToken()); $this->assertSame($user, $session->getUser()); - $this->assertDatabaseHas('auth_sessions', [ + $this->assertDatabaseHas('sessions', [ 'token' => 'session-token', 'user_id' => $user->getId(), ]); @@ -73,7 +73,7 @@ class EloquentSessionRepositoryTest extends TestCase $repository->deleteByToken('session-token'); $this->assertNull($repository->findByToken('session-token')); - $this->assertDatabaseMissing('auth_sessions', [ + $this->assertDatabaseMissing('sessions', [ 'token' => 'session-token', ]); } From 8bc9ef728b0109871fe09da586177a63ab957a24 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 31 Jul 2026 09:54:05 +0300 Subject: [PATCH 09/16] use sessions table --- backend/app/Auth/SessionModel.php | 2 +- ..._table.php => 2026_07_31_000000_create_sessions_table.php} | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) rename backend/database/migrations/{2026_07_31_000000_create_auth_sessions_table.php => 2026_07_31_000000_create_sessions_table.php} (81%) diff --git a/backend/app/Auth/SessionModel.php b/backend/app/Auth/SessionModel.php index 75e87e3..0b5092c 100644 --- a/backend/app/Auth/SessionModel.php +++ b/backend/app/Auth/SessionModel.php @@ -27,7 +27,7 @@ use Illuminate\Database\Eloquent\Model; ])] class SessionModel extends Model { - protected $table = 'auth_sessions'; + protected $table = 'sessions'; protected $primaryKey = 'token'; diff --git a/backend/database/migrations/2026_07_31_000000_create_auth_sessions_table.php b/backend/database/migrations/2026_07_31_000000_create_sessions_table.php similarity index 81% rename from backend/database/migrations/2026_07_31_000000_create_auth_sessions_table.php rename to backend/database/migrations/2026_07_31_000000_create_sessions_table.php index 995c147..687d9fb 100644 --- a/backend/database/migrations/2026_07_31_000000_create_auth_sessions_table.php +++ b/backend/database/migrations/2026_07_31_000000_create_sessions_table.php @@ -8,7 +8,7 @@ return new class extends Migration { public function up(): void { - Schema::create('auth_sessions', function (Blueprint $table): void { + Schema::create('sessions', function (Blueprint $table): void { $table->string('token', 64)->primary(); $table->foreignId('user_id') ->constrained('users') @@ -20,6 +20,6 @@ return new class extends Migration public function down(): void { - Schema::dropIfExists('auth_sessions'); + Schema::dropIfExists('sessions'); } }; From 167096b4fedaf5fbf0911465c29d36574f52679a Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 31 Jul 2026 09:55:13 +0300 Subject: [PATCH 10/16] fix auth cookie tests --- .../tests/Feature/Auth/AuthMiddlewareTest.php | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/backend/tests/Feature/Auth/AuthMiddlewareTest.php b/backend/tests/Feature/Auth/AuthMiddlewareTest.php index 5b9c034..bbc07bd 100644 --- a/backend/tests/Feature/Auth/AuthMiddlewareTest.php +++ b/backend/tests/Feature/Auth/AuthMiddlewareTest.php @@ -48,10 +48,11 @@ class AuthMiddlewareTest extends TestCase expiresAt: $this->utc('2030-08-07T12:00:00'), ); - $response = $this->withUnencryptedCookie( - AuthMiddleware::COOKIE_NAME, - 'valid-token', - )->getJson('/test/authenticated-user'); + $response = $this->withCredentials() + ->withUnencryptedCookie( + AuthMiddleware::COOKIE_NAME, + 'valid-token', + )->getJson('/test/authenticated-user'); $response->assertOk()->assertExactJson([ 'id' => $user->getId(), @@ -75,10 +76,11 @@ class AuthMiddlewareTest extends TestCase expiresAt: $this->utc('2020-08-07T12:00:00'), ); - $response = $this->withUnencryptedCookie( - AuthMiddleware::COOKIE_NAME, - 'expired-token', - )->getJson('/test/authenticated-user'); + $response = $this->withCredentials() + ->withUnencryptedCookie( + AuthMiddleware::COOKIE_NAME, + 'expired-token', + )->getJson('/test/authenticated-user'); $response->assertStatus(401); $this->assertNull( From 88a3997bada4ee477040906ce73638fa28058f79 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 31 Jul 2026 09:55:44 +0300 Subject: [PATCH 11/16] add auth middleware --- .../app/Http/Middleware/AuthMiddleware.php | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 backend/app/Http/Middleware/AuthMiddleware.php diff --git a/backend/app/Http/Middleware/AuthMiddleware.php b/backend/app/Http/Middleware/AuthMiddleware.php new file mode 100644 index 0000000..a600e36 --- /dev/null +++ b/backend/app/Http/Middleware/AuthMiddleware.php @@ -0,0 +1,51 @@ +cookie(self::COOKIE_NAME); + if (! is_string($token) || $token === '') { + return $this->unauthorized(); + } + + $session = $this->sessionRepository->findByToken($token); + if ($session === null) { + return $this->unauthorized(); + } + + if ($session->isExpired($this->clock->now())) { + $this->sessionRepository->deleteByToken($token); + + return $this->unauthorized(); + } + + $request->attributes->set('user', $session->getUser()); + + return $next($request); + } + + private function unauthorized(): JsonResponse + { + return new JsonResponse(['error' => 'unauthenticated'], 401); + } +} From ad27a0f03c2eff8e99467ccbcd419c3da2dc097e Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 31 Jul 2026 10:15:35 +0300 Subject: [PATCH 12/16] use native session dates --- backend/app/Auth/EloquentSessionRepository.php | 3 +-- backend/app/Auth/SessionModel.php | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/backend/app/Auth/EloquentSessionRepository.php b/backend/app/Auth/EloquentSessionRepository.php index 5c038d4..fd2ad3a 100644 --- a/backend/app/Auth/EloquentSessionRepository.php +++ b/backend/app/Auth/EloquentSessionRepository.php @@ -3,7 +3,6 @@ namespace App\Auth; use App\User\UserRepository; -use Carbon\CarbonInterface; use DateTimeImmutable; use DateTimeZone; @@ -55,7 +54,7 @@ class EloquentSessionRepository implements SessionRepository SessionModel::where('token', $token)->delete(); } - private function toUtc(CarbonInterface $dateTime): DateTimeImmutable + private function toUtc(DateTimeImmutable $dateTime): DateTimeImmutable { return DateTimeImmutable::createFromInterface($dateTime) ->setTimezone(new DateTimeZone('UTC')); diff --git a/backend/app/Auth/SessionModel.php b/backend/app/Auth/SessionModel.php index 0b5092c..37b30cb 100644 --- a/backend/app/Auth/SessionModel.php +++ b/backend/app/Auth/SessionModel.php @@ -2,7 +2,7 @@ namespace App\Auth; -use Carbon\CarbonInterface; +use DateTimeImmutable; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; @@ -10,8 +10,8 @@ use Illuminate\Database\Eloquent\Model; /** * @property string $token * @property int $user_id - * @property CarbonInterface $created_at - * @property CarbonInterface $expires_at + * @property DateTimeImmutable $created_at + * @property DateTimeImmutable $expires_at * * @method static Builder|SessionModel newModelQuery() * @method static Builder|SessionModel newQuery() From b3c9b0630fc3e558649394f7d8b0d35ffbe0e429 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 31 Jul 2026 10:20:37 +0300 Subject: [PATCH 13/16] freeze auth feature time --- backend/tests/Feature/Auth/AuthMiddlewareTest.php | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/backend/tests/Feature/Auth/AuthMiddlewareTest.php b/backend/tests/Feature/Auth/AuthMiddlewareTest.php index bbc07bd..8d84a1d 100644 --- a/backend/tests/Feature/Auth/AuthMiddlewareTest.php +++ b/backend/tests/Feature/Auth/AuthMiddlewareTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature\Auth; +use App\Auth\Clock; use App\Auth\CreateSessionDto; use App\Auth\SessionRepository; use App\Http\Middleware\AuthMiddleware; @@ -15,16 +16,22 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; +use Tests\Fakes\FakeClock; use Tests\TestCase; class AuthMiddlewareTest extends TestCase { use RefreshDatabase; + private DateTimeImmutable $now; + protected function setUp(): void { parent::setUp(); + $this->now = $this->utc('2026-07-31T12:00:00'); + $this->app->instance(Clock::class, new FakeClock($this->now)); + Route::middleware(AuthMiddleware::class)->get( '/test/authenticated-user', function (Request $request): JsonResponse { @@ -45,7 +52,7 @@ class AuthMiddlewareTest extends TestCase { $user = $this->createUserAndSession( token: 'valid-token', - expiresAt: $this->utc('2030-08-07T12:00:00'), + expiresAt: $this->now->modify('+7 days'), ); $response = $this->withCredentials() @@ -73,7 +80,7 @@ class AuthMiddlewareTest extends TestCase { $this->createUserAndSession( token: 'expired-token', - expiresAt: $this->utc('2020-08-07T12:00:00'), + expiresAt: $this->now->modify('-1 day'), ); $response = $this->withCredentials() @@ -98,7 +105,7 @@ class AuthMiddlewareTest extends TestCase app(SessionRepository::class)->create(new CreateSessionDto( token: $token, user: $user, - createdAt: $this->utc('2026-07-31T12:00:00'), + createdAt: $this->now, expiresAt: $expiresAt, )); From 4e3d8f74b89ec95a6e6e63c126f4237ce50497a8 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 31 Jul 2026 09:46:39 +0300 Subject: [PATCH 14/16] test guest auth pages --- frontend/website/cypress.config.ts | 11 +++ frontend/website/cypress/e2e/guest-auth.cy.ts | 68 +++++++++++++++++++ frontend/website/package.json | 1 + 3 files changed, 80 insertions(+) create mode 100644 frontend/website/cypress.config.ts create mode 100644 frontend/website/cypress/e2e/guest-auth.cy.ts diff --git a/frontend/website/cypress.config.ts b/frontend/website/cypress.config.ts new file mode 100644 index 0000000..a99e605 --- /dev/null +++ b/frontend/website/cypress.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'cypress' + +const frontendPort = process.env.VITE_PORT ?? '5173' + +export default defineConfig({ + e2e: { + baseUrl: `http://127.0.0.1:${frontendPort}`, + supportFile: false, + }, + video: false, +}) diff --git a/frontend/website/cypress/e2e/guest-auth.cy.ts b/frontend/website/cypress/e2e/guest-auth.cy.ts new file mode 100644 index 0000000..e6ac7af --- /dev/null +++ b/frontend/website/cypress/e2e/guest-auth.cy.ts @@ -0,0 +1,68 @@ +describe('guest authentication pages', () => { + it('redirects the root route to login', () => { + cy.visit('/') + + cy.location('pathname').should('equal', '/login') + }) + + it('shows the login form and links to signup', () => { + cy.visit('/login') + + cy.get('h1').should('have.text', 'Welcome back') + cy.get('label[for="login-email"]').should('have.text', 'Email address') + cy.get('#login-email').should('have.attr', 'autocomplete', 'email') + cy.get('label[for="login-password"]').should('have.text', 'Password') + cy.get('#login-password').should('have.attr', 'autocomplete', 'current-password') + cy.get('button[type="submit"]').should('have.text', 'Log in') + + cy.contains('a', 'Create an account').click() + cy.location('pathname').should('equal', '/signup') + }) + + it('shows the signup form and links to login', () => { + cy.visit('/signup') + + cy.get('h1').should('have.text', 'Start your journey') + cy.get('label[for="signup-name"]').should('have.text', 'Full name') + cy.get('#signup-name').should('have.attr', 'autocomplete', 'name') + cy.get('label[for="signup-email"]').should('have.text', 'Email address') + cy.get('#signup-email').should('have.attr', 'autocomplete', 'email') + cy.get('label[for="signup-password"]').should('have.text', 'Password') + cy.get('#signup-password').should('have.attr', 'autocomplete', 'new-password') + cy.get('label[for="signup-password-confirmation"]').should( + 'have.text', + 'Confirm password', + ) + cy.get('#signup-password-confirmation').should( + 'have.attr', + 'autocomplete', + 'new-password', + ) + cy.get('button[type="submit"]').should('have.text', 'Create account') + + cy.contains('a', 'Log in').click() + cy.location('pathname').should('equal', '/login') + }) + + it('keeps UI-only form submissions on their current route', () => { + cy.visit('/login') + cy.get('form').submit() + cy.location('pathname').should('equal', '/login') + + cy.visit('/signup') + cy.get('form').submit() + cy.location('pathname').should('equal', '/signup') + }) + + it('fits the signup page within a mobile viewport', () => { + cy.viewport(390, 844) + cy.visit('/signup') + + cy.get('main').should('be.visible') + cy.document().then((document) => { + expect(document.documentElement.scrollWidth).to.be.at.most( + document.documentElement.clientWidth, + ) + }) + }) +}) diff --git a/frontend/website/package.json b/frontend/website/package.json index 5ba881d..ebd0942 100644 --- a/frontend/website/package.json +++ b/frontend/website/package.json @@ -9,6 +9,7 @@ "preview": "vite preview", "build-only": "vite build", "type-check": "vue-tsc --build", + "test:e2e": "cypress run", "lint": "run-s \"lint:*\"", "lint:oxlint": "oxlint . --fix", "lint:eslint": "eslint . --fix --cache", From edcaf80b981a98aab7db17441a0aee7ddedcfc01 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 31 Jul 2026 09:51:12 +0300 Subject: [PATCH 15/16] test public and protected routes --- frontend/website/cypress.config.ts | 1 + frontend/website/cypress/e2e/guest-auth.cy.ts | 12 +++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/frontend/website/cypress.config.ts b/frontend/website/cypress.config.ts index a99e605..2697f45 100644 --- a/frontend/website/cypress.config.ts +++ b/frontend/website/cypress.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from 'cypress' const frontendPort = process.env.VITE_PORT ?? '5173' export default defineConfig({ + allowCypressEnv: false, e2e: { baseUrl: `http://127.0.0.1:${frontendPort}`, supportFile: false, diff --git a/frontend/website/cypress/e2e/guest-auth.cy.ts b/frontend/website/cypress/e2e/guest-auth.cy.ts index e6ac7af..f4c1ba8 100644 --- a/frontend/website/cypress/e2e/guest-auth.cy.ts +++ b/frontend/website/cypress/e2e/guest-auth.cy.ts @@ -1,8 +1,18 @@ describe('guest authentication pages', () => { - it('redirects the root route to login', () => { + it('shows a public home route to guests', () => { cy.visit('/') + cy.location('pathname').should('equal', '/') + cy.get('h1').should('have.text', 'Make big goals feel possible.') + cy.contains('a', 'Log in').should('have.attr', 'href', '/login') + cy.contains('a', 'Get started').should('have.attr', 'href', '/signup') + }) + + it('redirects guests away from the protected dashboard', () => { + cy.visit('/dashboard') + cy.location('pathname').should('equal', '/login') + cy.location('search').should('include', 'redirect=/dashboard') }) it('shows the login form and links to signup', () => { From 02c947708f51560ef1dd3aec2f334e50a628e831 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 31 Jul 2026 09:56:16 +0300 Subject: [PATCH 16/16] add guest auth pages --- frontend/website/src/App.vue | 10 +- frontend/website/src/components/AuthForm.vue | 68 ++ .../website/src/components/AuthLayout.vue | 426 +++++++++++++ .../website/src/components/AuthTextField.vue | 70 ++ .../website/src/components/BrandWordmark.vue | 71 +++ frontend/website/src/main.ts | 1 + frontend/website/src/router/index.ts | 53 +- frontend/website/src/stores/auth.ts | 10 + frontend/website/src/styles/main.css | 50 ++ frontend/website/src/views/DashboardView.vue | 67 ++ frontend/website/src/views/HomeView.vue | 599 ++++++++++++++++++ frontend/website/src/views/LoginView.vue | 35 + frontend/website/src/views/SignupView.vue | 49 ++ 13 files changed, 1499 insertions(+), 10 deletions(-) create mode 100644 frontend/website/src/components/AuthForm.vue create mode 100644 frontend/website/src/components/AuthLayout.vue create mode 100644 frontend/website/src/components/AuthTextField.vue create mode 100644 frontend/website/src/components/BrandWordmark.vue create mode 100644 frontend/website/src/stores/auth.ts create mode 100644 frontend/website/src/styles/main.css create mode 100644 frontend/website/src/views/DashboardView.vue create mode 100644 frontend/website/src/views/HomeView.vue create mode 100644 frontend/website/src/views/LoginView.vue create mode 100644 frontend/website/src/views/SignupView.vue diff --git a/frontend/website/src/App.vue b/frontend/website/src/App.vue index abfd315..7c2aa3f 100644 --- a/frontend/website/src/App.vue +++ b/frontend/website/src/App.vue @@ -1,11 +1,3 @@ - - - - diff --git a/frontend/website/src/components/AuthForm.vue b/frontend/website/src/components/AuthForm.vue new file mode 100644 index 0000000..709091f --- /dev/null +++ b/frontend/website/src/components/AuthForm.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/frontend/website/src/components/AuthLayout.vue b/frontend/website/src/components/AuthLayout.vue new file mode 100644 index 0000000..2eeace0 --- /dev/null +++ b/frontend/website/src/components/AuthLayout.vue @@ -0,0 +1,426 @@ + + + + + diff --git a/frontend/website/src/components/AuthTextField.vue b/frontend/website/src/components/AuthTextField.vue new file mode 100644 index 0000000..e9ec98c --- /dev/null +++ b/frontend/website/src/components/AuthTextField.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/frontend/website/src/components/BrandWordmark.vue b/frontend/website/src/components/BrandWordmark.vue new file mode 100644 index 0000000..b41aa80 --- /dev/null +++ b/frontend/website/src/components/BrandWordmark.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/frontend/website/src/main.ts b/frontend/website/src/main.ts index fda1e6e..62f3c02 100644 --- a/frontend/website/src/main.ts +++ b/frontend/website/src/main.ts @@ -3,6 +3,7 @@ import { createPinia } from 'pinia' import App from './App.vue' import router from './router' +import './styles/main.css' const app = createApp(App) diff --git a/frontend/website/src/router/index.ts b/frontend/website/src/router/index.ts index e1eab52..b726d17 100644 --- a/frontend/website/src/router/index.ts +++ b/frontend/website/src/router/index.ts @@ -1,8 +1,59 @@ import { createRouter, createWebHistory } from 'vue-router' +import { useAuthStore } from '@/stores/auth' + const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), - routes: [], + routes: [ + { + path: '/', + name: 'home', + component: () => import('@/views/HomeView.vue'), + }, + { + path: '/login', + name: 'login', + component: () => import('@/views/LoginView.vue'), + meta: { + guestOnly: true, + }, + }, + { + path: '/signup', + name: 'signup', + component: () => import('@/views/SignupView.vue'), + meta: { + guestOnly: true, + }, + }, + { + path: '/dashboard', + name: 'dashboard', + component: () => import('@/views/DashboardView.vue'), + meta: { + requiresAuth: true, + }, + }, + ], +}) + +router.beforeEach((to) => { + const authStore = useAuthStore() + + if (to.meta.requiresAuth && !authStore.isAuthenticated) { + return { + name: 'login', + query: { + redirect: to.fullPath, + }, + } + } + + if (to.meta.guestOnly && authStore.isAuthenticated) { + return { + name: 'dashboard', + } + } }) export default router diff --git a/frontend/website/src/stores/auth.ts b/frontend/website/src/stores/auth.ts new file mode 100644 index 0000000..50688f1 --- /dev/null +++ b/frontend/website/src/stores/auth.ts @@ -0,0 +1,10 @@ +import { ref } from 'vue' +import { defineStore } from 'pinia' + +export const useAuthStore = defineStore('auth', () => { + const isAuthenticated = ref(false) + + return { + isAuthenticated, + } +}) diff --git a/frontend/website/src/styles/main.css b/frontend/website/src/styles/main.css new file mode 100644 index 0000000..a121333 --- /dev/null +++ b/frontend/website/src/styles/main.css @@ -0,0 +1,50 @@ +:root { + color: #1b2924; + background: #f3f1eb; + font-family: + Inter, + ui-sans-serif, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; + font-synthesis: none; + text-rendering: optimizeLegibility; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + min-width: 320px; + min-height: 100%; + background: #f3f1eb; +} + +body { + min-width: 320px; + min-height: 100%; + margin: 0; +} + +button, +input { + font: inherit; +} + +button, +a { + -webkit-tap-highlight-color: transparent; +} + +a { + color: inherit; +} + +#app { + min-height: 100vh; + min-height: 100svh; +} diff --git a/frontend/website/src/views/DashboardView.vue b/frontend/website/src/views/DashboardView.vue new file mode 100644 index 0000000..90b1b1d --- /dev/null +++ b/frontend/website/src/views/DashboardView.vue @@ -0,0 +1,67 @@ + + + + + diff --git a/frontend/website/src/views/HomeView.vue b/frontend/website/src/views/HomeView.vue new file mode 100644 index 0000000..b061d02 --- /dev/null +++ b/frontend/website/src/views/HomeView.vue @@ -0,0 +1,599 @@ + + + + + diff --git a/frontend/website/src/views/LoginView.vue b/frontend/website/src/views/LoginView.vue new file mode 100644 index 0000000..e4215a4 --- /dev/null +++ b/frontend/website/src/views/LoginView.vue @@ -0,0 +1,35 @@ + + + diff --git a/frontend/website/src/views/SignupView.vue b/frontend/website/src/views/SignupView.vue new file mode 100644 index 0000000..123bf72 --- /dev/null +++ b/frontend/website/src/views/SignupView.vue @@ -0,0 +1,49 @@ + + +