From 9945163a2f632a24ecde3f9c6f830ac0185df99a Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Mon, 3 Aug 2026 20:22:50 +0300 Subject: [PATCH 1/4] test email signup flow --- .../FakeEmailConfirmationTokenRepository.php | 69 ++++++++ backend/tests/Fakes/FakeEmailFactory.php | 22 +++ backend/tests/Fakes/FakeEmailer.php | 48 ++++++ backend/tests/Fakes/FakeUserRepository.php | 7 + .../Feature/Auth/ConfirmEmailEndpointTest.php | 70 ++++++++ .../tests/Feature/Auth/SignupEndpointTest.php | 50 ++++++ .../User/EloquentUserRepositoryTest.php | 19 +++ .../Auth/UseCases/AuthenticateUserTest.php | 16 ++ .../Http/Controllers/AuthControllerTest.php | 31 ++++ .../User/UseCases/ConfirmUserEmailTest.php | 149 ++++++++++++++++++ .../Unit/User/UseCases/SignupUserTest.php | 135 ++++++++++++++++ backend/tests/Unit/User/UserTest.php | 15 ++ 12 files changed, 631 insertions(+) create mode 100644 backend/tests/Fakes/FakeEmailConfirmationTokenRepository.php create mode 100644 backend/tests/Fakes/FakeEmailFactory.php create mode 100644 backend/tests/Fakes/FakeEmailer.php create mode 100644 backend/tests/Feature/Auth/ConfirmEmailEndpointTest.php create mode 100644 backend/tests/Feature/Auth/SignupEndpointTest.php create mode 100644 backend/tests/Unit/User/UseCases/ConfirmUserEmailTest.php create mode 100644 backend/tests/Unit/User/UseCases/SignupUserTest.php diff --git a/backend/tests/Fakes/FakeEmailConfirmationTokenRepository.php b/backend/tests/Fakes/FakeEmailConfirmationTokenRepository.php new file mode 100644 index 0000000..d0a8332 --- /dev/null +++ b/backend/tests/Fakes/FakeEmailConfirmationTokenRepository.php @@ -0,0 +1,69 @@ + + */ + private array $tokens = []; + + public function create( + CreateEmailConfirmationTokenDto $dto, + ): EmailConfirmationToken { + $id = count($this->tokens) + 1; + $token = new EmailConfirmationToken( + id: $id, + user: $dto->user, + availableTo: $dto->availableTo, + token: $dto->token, + ); + $this->tokens[$id] = $token; + + return $this->copy($token); + } + + public function findByToken(string $token): ?EmailConfirmationToken + { + foreach ($this->tokens as $candidate) { + if ($candidate->getToken() === $token) { + return $this->copy($candidate); + } + } + + return null; + } + + public function findByUser(User $user): ?EmailConfirmationToken + { + foreach ($this->tokens as $candidate) { + if ($candidate->getUser()->getId() === $user->getId()) { + return $this->copy($candidate); + } + } + + return null; + } + + public function delete(int $id): void + { + unset($this->tokens[$id]); + } + + private function copy( + EmailConfirmationToken $token, + ): EmailConfirmationToken { + return new EmailConfirmationToken( + id: $token->getId(), + user: $token->getUser(), + availableTo: $token->getAvailableTo(), + token: $token->getToken(), + ); + } +} diff --git a/backend/tests/Fakes/FakeEmailFactory.php b/backend/tests/Fakes/FakeEmailFactory.php new file mode 100644 index 0000000..562ccb1 --- /dev/null +++ b/backend/tests/Fakes/FakeEmailFactory.php @@ -0,0 +1,22 @@ +lastToken = $token; + + return "confirm with {$token}"; + } + + public function getLastToken(): ?string + { + return $this->lastToken; + } +} diff --git a/backend/tests/Fakes/FakeEmailer.php b/backend/tests/Fakes/FakeEmailer.php new file mode 100644 index 0000000..4fb55ce --- /dev/null +++ b/backend/tests/Fakes/FakeEmailer.php @@ -0,0 +1,48 @@ +sendCount++; + $this->lastRecipient = $recipient; + $this->lastSubject = $subject; + $this->lastBody = $body; + } + + public function getSendCount(): int + { + return $this->sendCount; + } + + public function getLastRecipient(): ?EmailAddress + { + return $this->lastRecipient; + } + + public function getLastSubject(): ?string + { + return $this->lastSubject; + } + + public function getLastBody(): ?string + { + return $this->lastBody; + } +} diff --git a/backend/tests/Fakes/FakeUserRepository.php b/backend/tests/Fakes/FakeUserRepository.php index 3672f87..df20238 100644 --- a/backend/tests/Fakes/FakeUserRepository.php +++ b/backend/tests/Fakes/FakeUserRepository.php @@ -45,6 +45,13 @@ class FakeUserRepository implements UserRepository return null; } + public function update(User $user): User + { + $this->users[$user->getId()] = $this->copy($user); + + return $this->copy($user); + } + private function copy(User $user): User { return new User( diff --git a/backend/tests/Feature/Auth/ConfirmEmailEndpointTest.php b/backend/tests/Feature/Auth/ConfirmEmailEndpointTest.php new file mode 100644 index 0000000..62a9479 --- /dev/null +++ b/backend/tests/Feature/Auth/ConfirmEmailEndpointTest.php @@ -0,0 +1,70 @@ +create(new CreateUserDto( + email: new EmailAddress('user@example.com'), + passwordHash: null, + )); + $token = app(CreateEmailConfirmationToken::class)->execute( + new CreateEmailConfirmationTokenRequest( + user: $user, + minuteOffset: 10, + ), + ); + + $response = $this->postJson('/api/confirm-email', [ + 'token' => $token->getToken(), + 'password' => 'password123', + ]); + + $response->assertOk(); + $response->assertJsonPath('user.email', 'user@example.com'); + $confirmedUser = $userRepository->find($user->getId()); + $this->assertNotNull($confirmedUser); + $passwordHash = $confirmedUser->getPasswordHash(); + $this->assertNotNull($passwordHash); + $this->assertTrue( + app(PasswordHasher::class)->verify('password123', $passwordHash), + ); + $this->assertNull( + app(EmailConfirmationTokenRepository::class) + ->findByToken($token->getToken()), + ); + $cookie = $response->getCookie(AuthMiddleware::COOKIE_NAME, false); + $this->assertNotNull($cookie); + $this->assertNotNull( + app(SessionRepository::class)->findByToken($cookie->getValue()), + ); + } + + public function test_confirmation_rejects_an_unknown_token(): void + { + $response = $this->postJson('/api/confirm-email', [ + 'token' => 'unknown-token', + 'password' => 'password123', + ]); + + $response->assertConflict(); + $response->assertJson(['error' => 'token not found']); + } +} diff --git a/backend/tests/Feature/Auth/SignupEndpointTest.php b/backend/tests/Feature/Auth/SignupEndpointTest.php new file mode 100644 index 0000000..8514aa1 --- /dev/null +++ b/backend/tests/Feature/Auth/SignupEndpointTest.php @@ -0,0 +1,50 @@ +postJson('/api/signup', [ + 'email' => 'Founder@EXAMPLE.COM', + ]); + + $response->assertCreated(); + $user = app(UserRepository::class)->findByEmail( + new EmailAddress('Founder@example.com'), + ); + $this->assertNotNull($user); + $this->assertNull($user->getPasswordHash()); + $this->assertNotNull( + app(EmailConfirmationTokenRepository::class) + ->findByUser($user), + ); + } + + public function test_signup_rejects_an_existing_confirmed_account(): void + { + app(UserRepository::class)->create(new CreateUserDto( + email: new EmailAddress('user@example.com'), + passwordHash: 'hashed-password', + )); + + $response = $this->postJson('/api/signup', [ + 'email' => 'user@example.com', + ]); + + $response->assertConflict(); + $response->assertJson([ + 'error' => 'user@example.com already has an account', + ]); + } +} diff --git a/backend/tests/Feature/User/EloquentUserRepositoryTest.php b/backend/tests/Feature/User/EloquentUserRepositoryTest.php index 5741cb5..1b94bea 100644 --- a/backend/tests/Feature/User/EloquentUserRepositoryTest.php +++ b/backend/tests/Feature/User/EloquentUserRepositoryTest.php @@ -78,4 +78,23 @@ class EloquentUserRepositoryTest extends TestCase new EmailAddress('unknown@example.com'), )); } + + public function test_it_persists_confirmation_of_a_pending_user(): void + { + $repository = app(UserRepository::class); + $user = $repository->create(new CreateUserDto( + email: new EmailAddress('pending@example.com'), + passwordHash: null, + )); + + $this->assertNull($user->getPasswordHash()); + $user->setPasswordHash('hashed-password'); + $updatedUser = $repository->update($user); + + $this->assertSame('hashed-password', $updatedUser->getPasswordHash()); + $this->assertDatabaseHas('users', [ + 'id' => $user->getId(), + 'passwordHash' => 'hashed-password', + ]); + } } diff --git a/backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php b/backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php index 585af22..0b3fa6a 100644 --- a/backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php +++ b/backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php @@ -64,6 +64,22 @@ class AuthenticateUserTest extends TestCase )); } + public function test_pending_user_throws_unauthorized(): void + { + $this->userRepository->create(new CreateUserDto( + email: new EmailAddress('user@example.com'), + passwordHash: null, + )); + + $this->expectException(UnauthorizedException::class); + $this->expectExceptionMessage('invalid credentials'); + + $this->useCase->execute(new AuthenticateUserRequest( + email: 'user@example.com', + password: 'correct-password', + )); + } + public function test_wrong_password_throws_unauthorized(): void { $this->createUser('correct-password'); diff --git a/backend/tests/Unit/Http/Controllers/AuthControllerTest.php b/backend/tests/Unit/Http/Controllers/AuthControllerTest.php index 5372272..9e136c5 100644 --- a/backend/tests/Unit/Http/Controllers/AuthControllerTest.php +++ b/backend/tests/Unit/Http/Controllers/AuthControllerTest.php @@ -5,15 +5,21 @@ namespace Tests\Unit\Http\Controllers; use App\Auth\UseCases\AuthenticateUser\AuthenticateUser; use App\Auth\UseCases\CreateSession\CreateSession; use App\Auth\UseCases\Logout\Logout; +use App\Email\EmailConfirmationToken\UseCases\CreateEmailConfirmationToken; use App\Http\Controllers\AuthController; use App\Http\Middleware\AuthMiddleware; use App\Shared\ValueObject\EmailAddress; use App\User\CreateUserDto; +use App\User\UseCases\ConfirmUserEmail\ConfirmUserEmail; +use App\User\UseCases\SignupUser\SignupUser; use DateTimeImmutable; use DateTimeZone; use Illuminate\Http\Request; use PHPUnit\Framework\TestCase; use Tests\Fakes\FakeClock; +use Tests\Fakes\FakeEmailConfirmationTokenRepository; +use Tests\Fakes\FakeEmailer; +use Tests\Fakes\FakeEmailFactory; use Tests\Fakes\FakePasswordHasher; use Tests\Fakes\FakeSessionRepository; use Tests\Fakes\FakeTokenGenerator; @@ -47,7 +53,32 @@ class AuthControllerTest extends TestCase )), ); $logout = new Logout($this->sessionRepository); + $tokenRepository = new FakeEmailConfirmationTokenRepository; + $signupUser = new SignupUser( + $this->userRepository, + new CreateEmailConfirmationToken( + $tokenRepository, + new FakeClock(new DateTimeImmutable( + '2026-07-31T12:00:00', + new DateTimeZone('UTC'), + )), + new FakeTokenGenerator(['email-token']), + ), + new FakeEmailer, + new FakeEmailFactory, + ); + $confirmUserEmail = new ConfirmUserEmail( + $tokenRepository, + $this->userRepository, + $this->passwordHasher, + new FakeClock(new DateTimeImmutable( + '2026-07-31T12:00:00', + new DateTimeZone('UTC'), + )), + ); $this->controller = new AuthController( + $signupUser, + $confirmUserEmail, $authenticateUser, $createSession, $logout, diff --git a/backend/tests/Unit/User/UseCases/ConfirmUserEmailTest.php b/backend/tests/Unit/User/UseCases/ConfirmUserEmailTest.php new file mode 100644 index 0000000..ba59277 --- /dev/null +++ b/backend/tests/Unit/User/UseCases/ConfirmUserEmailTest.php @@ -0,0 +1,149 @@ +now = new DateTimeImmutable( + '2026-08-03T12:00:00', + new DateTimeZone('UTC'), + ); + $this->userRepository = new FakeUserRepository; + $this->tokenRepository = new FakeEmailConfirmationTokenRepository; + $this->confirmUserEmail = new ConfirmUserEmail( + $this->tokenRepository, + $this->userRepository, + new FakePasswordHasher, + new FakeClock($this->now), + ); + } + + public function test_it_sets_the_password_and_consumes_the_token(): void + { + $this->createPendingUserToken( + 'confirmation-token', + $this->now->modify('+10 minutes'), + ); + + $confirmedUser = $this->confirmUserEmail->execute( + new ConfirmUserEmailRequest( + token: 'confirmation-token', + password: 'password123', + ), + ); + + $this->assertSame('hashed:password123', $confirmedUser->getPasswordHash()); + $this->assertSame( + 'hashed:password123', + $this->userRepository->find($confirmedUser->getId()) + ?->getPasswordHash(), + ); + $this->assertNull( + $this->tokenRepository->findByToken('confirmation-token'), + ); + } + + public function test_it_rejects_an_expired_token(): void + { + $this->createPendingUserToken( + 'expired-token', + $this->now->modify('-1 minute'), + ); + + $this->expectException(DomainException::class); + $this->expectExceptionMessage('token expired'); + + $this->confirmUserEmail->execute(new ConfirmUserEmailRequest( + token: 'expired-token', + password: 'password123', + )); + } + + public function test_it_rejects_an_unknown_token(): void + { + $this->expectException(DomainException::class); + $this->expectExceptionMessage('token not found'); + + $this->confirmUserEmail->execute(new ConfirmUserEmailRequest( + token: 'unknown-token', + password: 'password123', + )); + } + + public function test_it_requires_a_token(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('token is required'); + + $this->confirmUserEmail->execute(new ConfirmUserEmailRequest( + token: null, + password: 'password123', + )); + } + + public function test_it_requires_a_password(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('password is required'); + + $this->confirmUserEmail->execute(new ConfirmUserEmailRequest( + token: 'confirmation-token', + password: null, + )); + } + + public function test_it_rejects_a_short_password(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage( + 'password must be at least 8 characters', + ); + + $this->confirmUserEmail->execute(new ConfirmUserEmailRequest( + token: 'confirmation-token', + password: 'short', + )); + } + + private function createPendingUserToken( + string $token, + DateTimeImmutable $availableTo, + ): void { + $user = $this->userRepository->create(new CreateUserDto( + email: new EmailAddress('user@example.com'), + passwordHash: null, + )); + $this->tokenRepository->create( + new CreateEmailConfirmationTokenDto( + user: $user, + availableTo: $availableTo, + token: $token, + ), + ); + } +} diff --git a/backend/tests/Unit/User/UseCases/SignupUserTest.php b/backend/tests/Unit/User/UseCases/SignupUserTest.php new file mode 100644 index 0000000..64e21a5 --- /dev/null +++ b/backend/tests/Unit/User/UseCases/SignupUserTest.php @@ -0,0 +1,135 @@ +userRepository = new FakeUserRepository; + $this->tokenRepository = new FakeEmailConfirmationTokenRepository; + $this->emailer = new FakeEmailer; + $this->emailFactory = new FakeEmailFactory; + $createToken = new CreateEmailConfirmationToken( + $this->tokenRepository, + new FakeClock(new DateTimeImmutable( + '2026-08-03T12:00:00', + new DateTimeZone('UTC'), + )), + new FakeTokenGenerator(['first-token', 'second-token']), + ); + $this->signupUser = new SignupUser( + $this->userRepository, + $createToken, + $this->emailer, + $this->emailFactory, + ); + } + + public function test_it_creates_a_pending_user_token_and_email(): void + { + $this->signupUser->execute(new SignupUserRequest( + email: ' Founder@EXAMPLE.COM ', + )); + + $user = $this->userRepository->findByEmail( + new EmailAddress('Founder@example.com'), + ); + $this->assertNotNull($user); + $this->assertNull($user->getPasswordHash()); + $token = $this->tokenRepository->findByUser($user); + $this->assertNotNull($token); + $this->assertSame('first-token', $token->getToken()); + $this->assertSame( + '2026-08-03T12:10:00+00:00', + $token->getAvailableTo()->format('c'), + ); + $this->assertSame(1, $this->emailer->getSendCount()); + $this->assertSame( + 'Founder@example.com', + $this->emailer->getLastRecipient()?->value(), + ); + $this->assertSame( + 'Confirm your Attainly email', + $this->emailer->getLastSubject(), + ); + $this->assertSame('first-token', $this->emailFactory->getLastToken()); + } + + public function test_it_replaces_the_token_for_a_pending_user(): void + { + $request = new SignupUserRequest(email: 'user@example.com'); + $this->signupUser->execute($request); + $this->signupUser->execute($request); + + $user = $this->userRepository->findByEmail( + new EmailAddress('user@example.com'), + ); + $this->assertNotNull($user); + $this->assertSame( + 'second-token', + $this->tokenRepository->findByUser($user)?->getToken(), + ); + $this->assertSame(2, $this->emailer->getSendCount()); + } + + public function test_it_rejects_an_existing_confirmed_account(): void + { + $this->userRepository->create(new CreateUserDto( + email: new EmailAddress('user@example.com'), + passwordHash: 'hashed-password', + )); + + $this->expectException(DomainException::class); + $this->expectExceptionMessage('user@example.com already has an account'); + + $this->signupUser->execute(new SignupUserRequest( + email: 'user@example.com', + )); + } + + public function test_it_rejects_a_missing_email(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('email is required'); + + $this->signupUser->execute(new SignupUserRequest(email: null)); + } + + public function test_it_rejects_an_invalid_email(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('email must be valid'); + + $this->signupUser->execute(new SignupUserRequest( + email: 'not-an-email', + )); + } +} diff --git a/backend/tests/Unit/User/UserTest.php b/backend/tests/Unit/User/UserTest.php index b3c6873..105d994 100644 --- a/backend/tests/Unit/User/UserTest.php +++ b/backend/tests/Unit/User/UserTest.php @@ -21,4 +21,19 @@ class UserTest extends TestCase $this->assertSame($email, $user->getEmail()); $this->assertSame('hashed-password', $user->getPasswordHash()); } + + public function test_it_can_confirm_a_pending_user_with_a_password(): void + { + $user = new User( + id: 42, + email: new EmailAddress('user@example.com'), + passwordHash: null, + ); + + $this->assertNull($user->getPasswordHash()); + + $user->setPasswordHash('hashed-password'); + + $this->assertSame('hashed-password', $user->getPasswordHash()); + } } From 3dc92049792430c62421111e930f0d0f703a964d Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Mon, 3 Aug 2026 20:26:08 +0300 Subject: [PATCH 2/4] add email signup flow --- .../AuthenticateUser/AuthenticateUser.php | 7 +- .../CreateEmailConfirmationTokenDto.php | 15 +++ ...oquentEmailConfirmationTokenRepository.php | 68 ++++++++++++++ .../EmailConfirmationToken.php | 36 +++++++ .../EmailConfirmationTokenModel.php | 42 +++++++++ .../EmailConfirmationTokenRepository.php | 18 ++++ .../UseCases/CreateEmailConfirmationToken.php | 48 ++++++++++ .../CreateEmailConfirmationTokenRequest.php | 13 +++ backend/app/Email/EmailFactory.php | 8 ++ backend/app/Email/Emailer.php | 14 +++ backend/app/Email/LaravelEmailFactory.php | 25 +++++ backend/app/Email/LaravelEmailer.php | 25 +++++ .../app/Http/Controllers/AuthController.php | 94 +++++++++++++++---- backend/app/Providers/AppServiceProvider.php | 12 +++ backend/app/User/CreateUserDto.php | 2 +- backend/app/User/EloquentUserRepository.php | 17 ++++ .../ConfirmUserEmail/ConfirmUserEmail.php | 61 ++++++++++++ .../ConfirmUserEmailRequest.php | 11 +++ .../User/UseCases/SignupUser/SignupUser.php | 79 ++++++++++++++++ .../UseCases/SignupUser/SignupUserRequest.php | 8 ++ backend/app/User/User.php | 11 ++- backend/app/User/UserModel.php | 2 +- backend/app/User/UserRepository.php | 2 + backend/config/app.php | 2 + .../0001_01_01_000000_create_users_table.php | 2 +- ...create_email_confirmation_tokens_table.php | 29 ++++++ backend/routes/api.php | 2 + 27 files changed, 629 insertions(+), 24 deletions(-) create mode 100644 backend/app/Email/EmailConfirmationToken/CreateEmailConfirmationTokenDto.php create mode 100644 backend/app/Email/EmailConfirmationToken/EloquentEmailConfirmationTokenRepository.php create mode 100644 backend/app/Email/EmailConfirmationToken/EmailConfirmationToken.php create mode 100644 backend/app/Email/EmailConfirmationToken/EmailConfirmationTokenModel.php create mode 100644 backend/app/Email/EmailConfirmationToken/EmailConfirmationTokenRepository.php create mode 100644 backend/app/Email/EmailConfirmationToken/UseCases/CreateEmailConfirmationToken.php create mode 100644 backend/app/Email/EmailConfirmationToken/UseCases/CreateEmailConfirmationTokenRequest.php create mode 100644 backend/app/Email/EmailFactory.php create mode 100644 backend/app/Email/Emailer.php create mode 100644 backend/app/Email/LaravelEmailFactory.php create mode 100644 backend/app/Email/LaravelEmailer.php create mode 100644 backend/app/User/UseCases/ConfirmUserEmail/ConfirmUserEmail.php create mode 100644 backend/app/User/UseCases/ConfirmUserEmail/ConfirmUserEmailRequest.php create mode 100644 backend/app/User/UseCases/SignupUser/SignupUser.php create mode 100644 backend/app/User/UseCases/SignupUser/SignupUserRequest.php create mode 100644 backend/database/migrations/2026_08_03_000000_create_email_confirmation_tokens_table.php diff --git a/backend/app/Auth/UseCases/AuthenticateUser/AuthenticateUser.php b/backend/app/Auth/UseCases/AuthenticateUser/AuthenticateUser.php index 7e8c92c..19a4b9d 100644 --- a/backend/app/Auth/UseCases/AuthenticateUser/AuthenticateUser.php +++ b/backend/app/Auth/UseCases/AuthenticateUser/AuthenticateUser.php @@ -36,9 +36,14 @@ class AuthenticateUser throw new UnauthorizedException('invalid credentials'); } + $passwordHash = $user->getPasswordHash(); + if ($passwordHash === null) { + throw new UnauthorizedException('invalid credentials'); + } + $passwordMatches = $this->hasher->verify( $request->password, - $user->getPasswordHash(), + $passwordHash, ); if (! $passwordMatches) { throw new UnauthorizedException('invalid credentials'); diff --git a/backend/app/Email/EmailConfirmationToken/CreateEmailConfirmationTokenDto.php b/backend/app/Email/EmailConfirmationToken/CreateEmailConfirmationTokenDto.php new file mode 100644 index 0000000..1464bb1 --- /dev/null +++ b/backend/app/Email/EmailConfirmationToken/CreateEmailConfirmationTokenDto.php @@ -0,0 +1,15 @@ + $dto->user->getId(), + 'token' => $dto->token, + 'available_to' => $dto->availableTo, + ]); + + return $this->toDomain($model); + } + + public function findByToken(string $token): ?EmailConfirmationToken + { + $model = EmailConfirmationTokenModel::query() + ->where('token', $token) + ->first(); + + return $model === null ? null : $this->toDomain($model); + } + + public function findByUser(User $user): ?EmailConfirmationToken + { + $model = EmailConfirmationTokenModel::query() + ->where('user_id', $user->getId()) + ->first(); + + return $model === null ? null : $this->toDomain($model); + } + + public function delete(int $id): void + { + EmailConfirmationTokenModel::where('id', $id)->delete(); + } + + private function toDomain( + EmailConfirmationTokenModel $model, + ): EmailConfirmationToken { + $user = $this->userRepository->find($model->user_id); + if ($user === null) { + throw new DomainException( + "User with id {$model->user_id} not found", + ); + } + + return new EmailConfirmationToken( + id: $model->id, + user: $user, + availableTo: $model->available_to, + token: $model->token, + ); + } +} diff --git a/backend/app/Email/EmailConfirmationToken/EmailConfirmationToken.php b/backend/app/Email/EmailConfirmationToken/EmailConfirmationToken.php new file mode 100644 index 0000000..b19e5b1 --- /dev/null +++ b/backend/app/Email/EmailConfirmationToken/EmailConfirmationToken.php @@ -0,0 +1,36 @@ +id; + } + + public function getUser(): User + { + return $this->user; + } + + public function getAvailableTo(): DateTimeImmutable + { + return $this->availableTo; + } + + public function getToken(): string + { + return $this->token; + } +} diff --git a/backend/app/Email/EmailConfirmationToken/EmailConfirmationTokenModel.php b/backend/app/Email/EmailConfirmationToken/EmailConfirmationTokenModel.php new file mode 100644 index 0000000..1b4f14f --- /dev/null +++ b/backend/app/Email/EmailConfirmationToken/EmailConfirmationTokenModel.php @@ -0,0 +1,42 @@ +|EmailConfirmationTokenModel newModelQuery() + * @method static Builder|EmailConfirmationTokenModel newQuery() + * @method static Builder|EmailConfirmationTokenModel query() + * + * @mixin \Eloquent + */ +#[Fillable([ + 'user_id', + 'token', + 'available_to', +])] +class EmailConfirmationTokenModel extends Model +{ + protected $table = 'email_confirmation_tokens'; + + public $timestamps = false; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'available_to' => 'immutable_datetime', + ]; + } +} diff --git a/backend/app/Email/EmailConfirmationToken/EmailConfirmationTokenRepository.php b/backend/app/Email/EmailConfirmationToken/EmailConfirmationTokenRepository.php new file mode 100644 index 0000000..ed71f62 --- /dev/null +++ b/backend/app/Email/EmailConfirmationToken/EmailConfirmationTokenRepository.php @@ -0,0 +1,18 @@ +user === null) { + throw new BadRequestException('user is required'); + } + if ($request->minuteOffset === null) { + throw new BadRequestException('minuteOffset is required'); + } + + $existingToken = $this->tokenRepository->findByUser($request->user); + if ($existingToken !== null) { + $this->tokenRepository->delete($existingToken->getId()); + } + + return $this->tokenRepository->create( + new CreateEmailConfirmationTokenDto( + user: $request->user, + availableTo: $this->clock->now()->modify( + "+{$request->minuteOffset} minutes", + ), + token: $this->tokenGenerator->generate(), + ), + ); + } +} diff --git a/backend/app/Email/EmailConfirmationToken/UseCases/CreateEmailConfirmationTokenRequest.php b/backend/app/Email/EmailConfirmationToken/UseCases/CreateEmailConfirmationTokenRequest.php new file mode 100644 index 0000000..92a67a5 --- /dev/null +++ b/backend/app/Email/EmailConfirmationToken/UseCases/CreateEmailConfirmationTokenRequest.php @@ -0,0 +1,13 @@ +mailer->raw( + $body, + function (Message $message) use ($recipient, $subject): void { + $message->to($recipient->value())->subject($subject); + }, + ); + } +} diff --git a/backend/app/Http/Controllers/AuthController.php b/backend/app/Http/Controllers/AuthController.php index 4fdaea2..623367d 100644 --- a/backend/app/Http/Controllers/AuthController.php +++ b/backend/app/Http/Controllers/AuthController.php @@ -10,7 +10,12 @@ use App\Exceptions\BadRequestException; use App\Exceptions\UnauthorizedException; use App\Http\Middleware\AuthMiddleware; use App\Shared\Http\RequestInput; +use App\User\UseCases\ConfirmUserEmail\ConfirmUserEmail; +use App\User\UseCases\ConfirmUserEmail\ConfirmUserEmailRequest; +use App\User\UseCases\SignupUser\SignupUser; +use App\User\UseCases\SignupUser\SignupUserRequest; use App\User\User; +use DomainException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Cookie; @@ -18,11 +23,62 @@ use Symfony\Component\HttpFoundation\Cookie; class AuthController extends Controller { public function __construct( + private SignupUser $signupUser, + private ConfirmUserEmail $confirmUserEmail, private AuthenticateUser $authenticateUser, private CreateSession $createSession, private Logout $logout, ) {} + public function signup(Request $request): JsonResponse + { + $input = new RequestInput($request); + + try { + $this->signupUser->execute(new SignupUserRequest( + email: $input->string('email'), + )); + } catch (BadRequestException $exception) { + return new JsonResponse( + ['error' => $exception->getMessage()], + 400, + ); + } catch (DomainException $exception) { + return new JsonResponse( + ['error' => $exception->getMessage()], + 409, + ); + } + + return new JsonResponse(null, 201); + } + + public function confirmEmail(Request $request): JsonResponse + { + $input = new RequestInput($request); + + try { + $user = $this->confirmUserEmail->execute( + new ConfirmUserEmailRequest( + token: $input->string('token'), + password: $input->string('password'), + ), + ); + } catch (BadRequestException $exception) { + return new JsonResponse( + ['error' => $exception->getMessage()], + 400, + ); + } catch (DomainException $exception) { + return new JsonResponse( + ['error' => $exception->getMessage()], + 409, + ); + } + + return $this->authenticatedResponse($user); + } + public function login(Request $request): JsonResponse { $input = new RequestInput($request); @@ -44,23 +100,7 @@ class AuthController extends Controller ); } - $session = $this->createSession->execute($user); - - $response = new JsonResponse([ - 'user' => $this->userPayload($user), - ], 200); - - return $response->withCookie(Cookie::create( - name: AuthMiddleware::COOKIE_NAME, - value: $session->getToken(), - expire: $session->getExpiresAt()->getTimestamp(), - path: '/', - domain: null, - secure: false, - httpOnly: true, - raw: false, - sameSite: Cookie::SAMESITE_LAX, - )); + return $this->authenticatedResponse($user); } public function me(Request $request): JsonResponse @@ -105,4 +145,24 @@ class AuthController extends Controller 'email' => $user->getEmail()->value(), ]; } + + private function authenticatedResponse(User $user): JsonResponse + { + $session = $this->createSession->execute($user); + $response = new JsonResponse([ + 'user' => $this->userPayload($user), + ]); + + return $response->withCookie(Cookie::create( + name: AuthMiddleware::COOKIE_NAME, + value: $session->getToken(), + expire: $session->getExpiresAt()->getTimestamp(), + path: '/', + domain: null, + secure: false, + httpOnly: true, + raw: false, + sameSite: Cookie::SAMESITE_LAX, + )); + } } diff --git a/backend/app/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php index 36ac77d..2255353 100644 --- a/backend/app/Providers/AppServiceProvider.php +++ b/backend/app/Providers/AppServiceProvider.php @@ -10,6 +10,12 @@ use App\Auth\RandomTokenGenerator; use App\Auth\SessionRepository; use App\Auth\SystemClock; use App\Auth\TokenGenerator; +use App\Email\EmailConfirmationToken\EloquentEmailConfirmationTokenRepository; +use App\Email\EmailConfirmationToken\EmailConfirmationTokenRepository; +use App\Email\Emailer; +use App\Email\EmailFactory; +use App\Email\LaravelEmailer; +use App\Email\LaravelEmailFactory; use App\User\EloquentUserRepository; use App\User\UserRepository; use Carbon\CarbonImmutable; @@ -33,6 +39,12 @@ class AppServiceProvider extends ServiceProvider SessionRepository::class, EloquentSessionRepository::class, ); + $this->app->bind( + EmailConfirmationTokenRepository::class, + EloquentEmailConfirmationTokenRepository::class, + ); + $this->app->bind(Emailer::class, LaravelEmailer::class); + $this->app->bind(EmailFactory::class, LaravelEmailFactory::class); $this->app->bind(PasswordHasher::class, BcryptPasswordHasher::class); $this->app->bind(TokenGenerator::class, RandomTokenGenerator::class); $this->app->bind(Clock::class, SystemClock::class); diff --git a/backend/app/User/CreateUserDto.php b/backend/app/User/CreateUserDto.php index e0267b6..bb8e5d7 100644 --- a/backend/app/User/CreateUserDto.php +++ b/backend/app/User/CreateUserDto.php @@ -8,6 +8,6 @@ final readonly class CreateUserDto { public function __construct( public EmailAddress $email, - public string $passwordHash, + public ?string $passwordHash, ) {} } diff --git a/backend/app/User/EloquentUserRepository.php b/backend/app/User/EloquentUserRepository.php index cee7817..9982439 100644 --- a/backend/app/User/EloquentUserRepository.php +++ b/backend/app/User/EloquentUserRepository.php @@ -3,6 +3,7 @@ namespace App\User; use App\Shared\ValueObject\EmailAddress; +use DomainException; class EloquentUserRepository implements UserRepository { @@ -38,6 +39,22 @@ class EloquentUserRepository implements UserRepository return $this->toDomain($model); } + public function update(User $user): User + { + $model = UserModel::find($user->getId()); + if ($model === null) { + throw new DomainException( + "User with id {$user->getId()} not found", + ); + } + + $model->email = $user->getEmail()->value(); + $model->passwordHash = $user->getPasswordHash(); + $model->save(); + + return $this->toDomain($model); + } + private function toDomain(UserModel $model): User { return new User( diff --git a/backend/app/User/UseCases/ConfirmUserEmail/ConfirmUserEmail.php b/backend/app/User/UseCases/ConfirmUserEmail/ConfirmUserEmail.php new file mode 100644 index 0000000..a247a40 --- /dev/null +++ b/backend/app/User/UseCases/ConfirmUserEmail/ConfirmUserEmail.php @@ -0,0 +1,61 @@ +token === null || $request->token === '') { + throw new BadRequestException('token is required'); + } + if ($request->password === null || $request->password === '') { + throw new BadRequestException('password is required'); + } + if (strlen($request->password) < 8) { + throw new BadRequestException( + 'password must be at least 8 characters', + ); + } + + $token = $this->tokenRepository->findByToken($request->token); + if ($token === null) { + throw new DomainException('token not found'); + } + if ($token->getAvailableTo() < $this->clock->now()) { + throw new DomainException('token expired'); + } + + $user = $token->getUser(); + if ($user->getPasswordHash() !== null) { + throw new DomainException('account already confirmed'); + } + + $user->setPasswordHash( + $this->passwordHasher->hash($request->password), + ); + $confirmedUser = $this->userRepository->update($user); + $this->tokenRepository->delete($token->getId()); + + return $confirmedUser; + } +} diff --git a/backend/app/User/UseCases/ConfirmUserEmail/ConfirmUserEmailRequest.php b/backend/app/User/UseCases/ConfirmUserEmail/ConfirmUserEmailRequest.php new file mode 100644 index 0000000..05bf21c --- /dev/null +++ b/backend/app/User/UseCases/ConfirmUserEmail/ConfirmUserEmailRequest.php @@ -0,0 +1,11 @@ +email === null || trim($request->email) === '') { + throw new BadRequestException('email is required'); + } + + try { + $email = new EmailAddress($request->email); + } catch (InvalidArgumentException) { + throw new BadRequestException('email must be valid'); + } + + $user = $this->findOrCreatePendingUser($email); + $token = $this->createToken->execute( + new CreateEmailConfirmationTokenRequest( + user: $user, + minuteOffset: 10, + ), + ); + $body = $this->emailFactory->makeConfirmationEmail( + $token->getToken(), + ); + $this->emailer->send( + $user->getEmail(), + 'Confirm your Attainly email', + $body, + ); + } + + /** + * @throws DomainException + */ + private function findOrCreatePendingUser(EmailAddress $email): User + { + $user = $this->userRepository->findByEmail($email); + if ($user === null) { + return $this->userRepository->create(new CreateUserDto( + email: $email, + passwordHash: null, + )); + } + if ($user->getPasswordHash() !== null) { + throw new DomainException( + "{$email->value()} already has an account", + ); + } + + return $user; + } +} diff --git a/backend/app/User/UseCases/SignupUser/SignupUserRequest.php b/backend/app/User/UseCases/SignupUser/SignupUserRequest.php new file mode 100644 index 0000000..0788038 --- /dev/null +++ b/backend/app/User/UseCases/SignupUser/SignupUserRequest.php @@ -0,0 +1,8 @@ +email; } - public function getPasswordHash(): string + public function getPasswordHash(): ?string { return $this->passwordHash; } + + public function setPasswordHash(string $passwordHash): void + { + $this->passwordHash = $passwordHash; + } } diff --git a/backend/app/User/UserModel.php b/backend/app/User/UserModel.php index d2c64e3..bc4f960 100644 --- a/backend/app/User/UserModel.php +++ b/backend/app/User/UserModel.php @@ -9,7 +9,7 @@ use Illuminate\Database\Eloquent\Model; /** * @property int $id * @property string $email - * @property string $passwordHash + * @property string|null $passwordHash * * @method static Builder|UserModel newModelQuery() * @method static Builder|UserModel newQuery() diff --git a/backend/app/User/UserRepository.php b/backend/app/User/UserRepository.php index 4805f3f..995fde0 100644 --- a/backend/app/User/UserRepository.php +++ b/backend/app/User/UserRepository.php @@ -11,4 +11,6 @@ interface UserRepository public function find(int $id): ?User; public function findByEmail(EmailAddress $email): ?User; + + public function update(User $user): User; } diff --git a/backend/config/app.php b/backend/config/app.php index 1f8dd27..5877288 100644 --- a/backend/config/app.php +++ b/backend/config/app.php @@ -54,6 +54,8 @@ return [ 'url' => env('APP_URL', 'http://localhost'), + 'frontend_url' => env('FRONTEND_URL', 'http://localhost:5173'), + /* |-------------------------------------------------------------------------- | Application Timezone 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 index 065daab..fa5685e 100644 --- a/backend/database/migrations/0001_01_01_000000_create_users_table.php +++ b/backend/database/migrations/0001_01_01_000000_create_users_table.php @@ -11,7 +11,7 @@ return new class extends Migration Schema::create('users', function (Blueprint $table): void { $table->id(); $table->string('email')->unique(); - $table->string('passwordHash'); + $table->string('passwordHash')->nullable(); }); } diff --git a/backend/database/migrations/2026_08_03_000000_create_email_confirmation_tokens_table.php b/backend/database/migrations/2026_08_03_000000_create_email_confirmation_tokens_table.php new file mode 100644 index 0000000..c57c688 --- /dev/null +++ b/backend/database/migrations/2026_08_03_000000_create_email_confirmation_tokens_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('user_id') + ->unique() + ->constrained('users') + ->cascadeOnDelete(); + $table->string('token', 64)->unique(); + $table->timestamp('available_to'); + }, + ); + } + + public function down(): void + { + Schema::dropIfExists('email_confirmation_tokens'); + } +}; diff --git a/backend/routes/api.php b/backend/routes/api.php index 7d7d7c1..d471fcf 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -5,6 +5,8 @@ use App\Http\Middleware\AuthMiddleware; use Illuminate\Support\Facades\Route; Route::post('/login', [AuthController::class, 'login']); +Route::post('/signup', [AuthController::class, 'signup']); +Route::post('/confirm-email', [AuthController::class, 'confirmEmail']); Route::middleware(AuthMiddleware::class)->group(function (): void { Route::get('/me', [AuthController::class, 'me']); From e47535f91c88f360bd051d66fe61abaa0a576d8c Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Mon, 3 Aug 2026 20:30:55 +0300 Subject: [PATCH 3/4] test frontend signup flow --- .../website/cypress/e2e/confirm-email.cy.ts | 77 +++++++++++++++++++ frontend/website/cypress/e2e/guest-auth.cy.ts | 20 ++--- frontend/website/cypress/e2e/signup.cy.ts | 63 +++++++++++++++ 3 files changed, 145 insertions(+), 15 deletions(-) create mode 100644 frontend/website/cypress/e2e/confirm-email.cy.ts create mode 100644 frontend/website/cypress/e2e/signup.cy.ts diff --git a/frontend/website/cypress/e2e/confirm-email.cy.ts b/frontend/website/cypress/e2e/confirm-email.cy.ts new file mode 100644 index 0000000..075909d --- /dev/null +++ b/frontend/website/cypress/e2e/confirm-email.cy.ts @@ -0,0 +1,77 @@ +const authenticatedUser = { + id: 7, + email: 'user@example.com', +} + +describe('email confirmation', () => { + beforeEach(() => { + cy.intercept('GET', '**/api/me', { + statusCode: 401, + body: { error: 'unauthenticated' }, + }).as('me') + }) + + it('chooses a password, confirms the account, and opens the dashboard', () => { + cy.intercept('POST', '**/api/confirm-email', (request) => { + expect(request.headers.accept).to.equal('application/json') + expect(request.body).to.deep.equal({ + token: 'confirmation-token', + password: 'password123', + }) + request.reply({ + statusCode: 200, + body: { user: authenticatedUser }, + }) + }).as('confirmEmail') + + cy.visit('/confirm-email?token=confirmation-token') + cy.get('#confirm-email-password').type('password123') + cy.get('#confirm-email-password-confirmation').type('password123') + cy.get('form').submit() + cy.wait('@confirmEmail') + + cy.location('pathname').should('equal', '/dashboard') + cy.get('h1').should('have.text', 'Your next step starts here.') + }) + + it('validates password length and confirmation before submitting', () => { + cy.intercept('POST', '**/api/confirm-email').as('confirmEmail') + + cy.visit('/confirm-email?token=confirmation-token') + cy.get('#confirm-email-password').type('short') + cy.get('#confirm-email-password-confirmation').type('different') + cy.get('form').submit() + + cy.get('#confirm-email-password-error') + .should('have.text', 'Password must be at least 8 characters.') + .and('be.visible') + cy.get('#confirm-email-password-confirmation-error') + .should('have.text', 'Passwords do not match.') + .and('be.visible') + cy.get('@confirmEmail.all').should('have.length', 0) + }) + + it('shows confirmation errors from the backend', () => { + cy.intercept('POST', '**/api/confirm-email', { + statusCode: 409, + body: { error: 'token expired' }, + }).as('confirmEmail') + + cy.visit('/confirm-email?token=expired-token') + cy.get('#confirm-email-password').type('password123') + cy.get('#confirm-email-password-confirmation').type('password123') + cy.get('form').submit() + cy.wait('@confirmEmail') + + cy.get('[role="alert"]') + .should('have.text', 'token expired') + .and('be.visible') + cy.location('pathname').should('equal', '/confirm-email') + }) + + it('redirects a confirmation route without a token to signup', () => { + cy.visit('/confirm-email') + + cy.location('pathname').should('equal', '/signup') + }) +}) diff --git a/frontend/website/cypress/e2e/guest-auth.cy.ts b/frontend/website/cypress/e2e/guest-auth.cy.ts index 2dea54b..7c0e66c 100644 --- a/frontend/website/cypress/e2e/guest-auth.cy.ts +++ b/frontend/website/cypress/e2e/guest-auth.cy.ts @@ -42,28 +42,18 @@ describe('guest authentication pages', () => { 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.get('#signup-name').should('not.exist') + cy.get('#signup-password').should('not.exist') + cy.get('#signup-password-confirmation').should('not.exist') + cy.get('button[type="submit"]').should('have.text', 'Continue with email') cy.contains('a', 'Log in').click() cy.location('pathname').should('equal', '/login') }) - it('keeps UI-only form submissions on their current route', () => { + it('keeps invalid form submissions on their current route', () => { cy.visit('/login') cy.get('form').submit() cy.location('pathname').should('equal', '/login') diff --git a/frontend/website/cypress/e2e/signup.cy.ts b/frontend/website/cypress/e2e/signup.cy.ts new file mode 100644 index 0000000..6496499 --- /dev/null +++ b/frontend/website/cypress/e2e/signup.cy.ts @@ -0,0 +1,63 @@ +describe('email signup', () => { + beforeEach(() => { + cy.intercept('GET', '**/api/me', { + statusCode: 401, + body: { error: 'unauthenticated' }, + }).as('me') + }) + + it('requests a confirmation email and shows the check-email page', () => { + cy.intercept('POST', '**/api/signup', (request) => { + expect(request.headers.accept).to.equal('application/json') + expect(request.body).to.deep.equal({ email: 'user@example.com' }) + request.reply({ statusCode: 201 }) + }).as('signup') + + cy.visit('/signup') + cy.get('#signup-email').type(' user@example.com ') + cy.get('form').submit() + cy.wait('@signup') + + cy.location('pathname').should('equal', '/check-email') + cy.get('h1').should('have.text', 'Check your email') + cy.contains('We sent you a link to confirm your signup.').should('be.visible') + }) + + it('validates the email before submitting', () => { + cy.intercept('POST', '**/api/signup').as('signup') + + cy.visit('/signup') + cy.get('#signup-email').type('not-an-email') + cy.get('form').submit() + + cy.get('#signup-email-error') + .should('have.text', 'Enter a valid email address.') + .and('be.visible') + cy.get('#signup-email').should('have.attr', 'aria-invalid', 'true') + cy.get('@signup.all').should('have.length', 0) + cy.location('pathname').should('equal', '/signup') + }) + + it('shows backend signup errors', () => { + cy.intercept('POST', '**/api/signup', { + statusCode: 409, + body: { error: 'user@example.com already has an account' }, + }).as('signup') + + cy.visit('/signup') + cy.get('#signup-email').type('user@example.com') + cy.get('form').submit() + cy.wait('@signup') + + cy.get('[role="alert"]') + .should('have.text', 'user@example.com already has an account') + .and('be.visible') + cy.location('pathname').should('equal', '/signup') + }) + + it('redirects direct check-email visits back to signup', () => { + cy.visit('/check-email') + + cy.location('pathname').should('equal', '/signup') + }) +}) From ed0d02959a97db10bb53e24f039b1e3944131104 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Mon, 3 Aug 2026 20:34:16 +0300 Subject: [PATCH 4/4] wire frontend email signup --- frontend/website/src/router/index.ts | 26 +++++ frontend/website/src/stores/auth.ts | 91 +++++++++++++++- frontend/website/src/views/CheckEmailView.vue | 39 +++++++ .../website/src/views/ConfirmEmailView.vue | 100 ++++++++++++++++++ frontend/website/src/views/SignupView.vue | 65 ++++++++---- 5 files changed, 297 insertions(+), 24 deletions(-) create mode 100644 frontend/website/src/views/CheckEmailView.vue create mode 100644 frontend/website/src/views/ConfirmEmailView.vue diff --git a/frontend/website/src/router/index.ts b/frontend/website/src/router/index.ts index 66d053a..c55b993 100644 --- a/frontend/website/src/router/index.ts +++ b/frontend/website/src/router/index.ts @@ -29,6 +29,32 @@ const router = createRouter({ guestOnly: true, }, }, + { + path: '/check-email', + name: 'check-email', + component: () => import('@/views/CheckEmailView.vue'), + meta: { + guestOnly: true, + }, + beforeEnter: () => { + if (!useAuthStore().signupCompleted) { + return { name: 'signup' } + } + }, + }, + { + path: '/confirm-email', + name: 'confirm-email', + component: () => import('@/views/ConfirmEmailView.vue'), + meta: { + guestOnly: true, + }, + beforeEnter: (to) => { + if (typeof to.query.token !== 'string' || to.query.token === '') { + return { name: 'signup' } + } + }, + }, { path: '/dashboard', name: 'dashboard', diff --git a/frontend/website/src/stores/auth.ts b/frontend/website/src/stores/auth.ts index fc9eef3..b834955 100644 --- a/frontend/website/src/stores/auth.ts +++ b/frontend/website/src/stores/auth.ts @@ -13,17 +13,20 @@ const meResponseSchema = z.object({ user: authUserSchema, }) -const loginErrorResponseSchema = z.object({ +const authErrorResponseSchema = z.object({ error: z.string(), }) export type AuthUser = z.infer export type LoginFieldErrors = Partial> +export type SignupFieldErrors = Partial> +export type ConfirmEmailFieldErrors = Partial> export const useAuthStore = defineStore('auth', () => { const user = ref(null) const loading = ref(false) const error = ref(null) + const signupCompleted = ref(false) const isAuthenticated = computed(() => user.value !== null) async function fetchMe(): Promise { @@ -85,7 +88,7 @@ export const useAuthStore = defineStore('auth', () => { } user.value = null - const errorResponse = loginErrorResponseSchema.safeParse(responseBody) + const errorResponse = authErrorResponseSchema.safeParse(responseBody) error.value = errorResponse.success ? errorResponse.data.error : 'Unable to log in. Please try again.' @@ -101,6 +104,76 @@ export const useAuthStore = defineStore('auth', () => { } } + async function signup(email: string): Promise { + loading.value = true + error.value = null + signupCompleted.value = false + + try { + const response = await fetch(`${API_BASE}/api/signup`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email }), + }) + + if (response.status === 201) { + signupCompleted.value = true + + return true + } + + error.value = await responseError(response, 'Unable to sign up. Please try again.') + + return false + } catch { + error.value = 'Unable to sign up. Please try again.' + + return false + } finally { + loading.value = false + } + } + + async function confirmEmail(token: string, password: string): Promise { + loading.value = true + error.value = null + + try { + const response = await fetch(`${API_BASE}/api/confirm-email`, { + method: 'POST', + credentials: 'include', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ token, password }), + }) + + if (response.status === 200) { + const responseBody: unknown = await response.json() + user.value = meResponseSchema.parse(responseBody).user + signupCompleted.value = false + + return true + } + + user.value = null + error.value = await responseError(response, 'Unable to confirm your email. Please try again.') + + return false + } catch { + user.value = null + error.value = 'Unable to confirm your email. Please try again.' + + return false + } finally { + loading.value = false + } + } + async function logout(): Promise { try { await fetch(`${API_BASE}/api/logout`, { @@ -119,9 +192,23 @@ export const useAuthStore = defineStore('auth', () => { user, loading, error, + signupCompleted, isAuthenticated, fetchMe, login, + signup, + confirmEmail, logout, } }) + +async function responseError(response: Response, fallback: string): Promise { + try { + const responseBody: unknown = await response.json() + const parsedError = authErrorResponseSchema.safeParse(responseBody) + + return parsedError.success ? parsedError.data.error : fallback + } catch { + return fallback + } +} diff --git a/frontend/website/src/views/CheckEmailView.vue b/frontend/website/src/views/CheckEmailView.vue new file mode 100644 index 0000000..dc7d672 --- /dev/null +++ b/frontend/website/src/views/CheckEmailView.vue @@ -0,0 +1,39 @@ + + + + + diff --git a/frontend/website/src/views/ConfirmEmailView.vue b/frontend/website/src/views/ConfirmEmailView.vue new file mode 100644 index 0000000..13966dc --- /dev/null +++ b/frontend/website/src/views/ConfirmEmailView.vue @@ -0,0 +1,100 @@ + + + diff --git a/frontend/website/src/views/SignupView.vue b/frontend/website/src/views/SignupView.vue index 123bf72..9ba636d 100644 --- a/frontend/website/src/views/SignupView.vue +++ b/frontend/website/src/views/SignupView.vue @@ -1,7 +1,40 @@