diff --git a/backend/app/Auth/UseCases/AuthenticateUser/AuthenticateUser.php b/backend/app/Auth/UseCases/AuthenticateUser/AuthenticateUser.php index 19a4b9d..7e8c92c 100644 --- a/backend/app/Auth/UseCases/AuthenticateUser/AuthenticateUser.php +++ b/backend/app/Auth/UseCases/AuthenticateUser/AuthenticateUser.php @@ -36,14 +36,9 @@ class AuthenticateUser throw new UnauthorizedException('invalid credentials'); } - $passwordHash = $user->getPasswordHash(); - if ($passwordHash === null) { - throw new UnauthorizedException('invalid credentials'); - } - $passwordMatches = $this->hasher->verify( $request->password, - $passwordHash, + $user->getPasswordHash(), ); if (! $passwordMatches) { throw new UnauthorizedException('invalid credentials'); diff --git a/backend/app/Email/EmailConfirmationToken/CreateEmailConfirmationTokenDto.php b/backend/app/Email/EmailConfirmationToken/CreateEmailConfirmationTokenDto.php deleted file mode 100644 index 1464bb1..0000000 --- a/backend/app/Email/EmailConfirmationToken/CreateEmailConfirmationTokenDto.php +++ /dev/null @@ -1,15 +0,0 @@ - $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 deleted file mode 100644 index b19e5b1..0000000 --- a/backend/app/Email/EmailConfirmationToken/EmailConfirmationToken.php +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index 1b4f14f..0000000 --- a/backend/app/Email/EmailConfirmationToken/EmailConfirmationTokenModel.php +++ /dev/null @@ -1,42 +0,0 @@ -|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 deleted file mode 100644 index ed71f62..0000000 --- a/backend/app/Email/EmailConfirmationToken/EmailConfirmationTokenRepository.php +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 92a67a5..0000000 --- a/backend/app/Email/EmailConfirmationToken/UseCases/CreateEmailConfirmationTokenRequest.php +++ /dev/null @@ -1,13 +0,0 @@ -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 623367d..4fdaea2 100644 --- a/backend/app/Http/Controllers/AuthController.php +++ b/backend/app/Http/Controllers/AuthController.php @@ -10,12 +10,7 @@ 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; @@ -23,62 +18,11 @@ 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); @@ -100,7 +44,23 @@ class AuthController extends Controller ); } - return $this->authenticatedResponse($user); + $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, + )); } public function me(Request $request): JsonResponse @@ -145,24 +105,4 @@ 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 2255353..36ac77d 100644 --- a/backend/app/Providers/AppServiceProvider.php +++ b/backend/app/Providers/AppServiceProvider.php @@ -10,12 +10,6 @@ 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; @@ -39,12 +33,6 @@ 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 bb8e5d7..e0267b6 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 9982439..cee7817 100644 --- a/backend/app/User/EloquentUserRepository.php +++ b/backend/app/User/EloquentUserRepository.php @@ -3,7 +3,6 @@ namespace App\User; use App\Shared\ValueObject\EmailAddress; -use DomainException; class EloquentUserRepository implements UserRepository { @@ -39,22 +38,6 @@ 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 deleted file mode 100644 index a247a40..0000000 --- a/backend/app/User/UseCases/ConfirmUserEmail/ConfirmUserEmail.php +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 05bf21c..0000000 --- a/backend/app/User/UseCases/ConfirmUserEmail/ConfirmUserEmailRequest.php +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 0788038..0000000 --- a/backend/app/User/UseCases/SignupUser/SignupUserRequest.php +++ /dev/null @@ -1,8 +0,0 @@ -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 bc4f960..d2c64e3 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|null $passwordHash + * @property string $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 995fde0..4805f3f 100644 --- a/backend/app/User/UserRepository.php +++ b/backend/app/User/UserRepository.php @@ -11,6 +11,4 @@ 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 5877288..1f8dd27 100644 --- a/backend/config/app.php +++ b/backend/config/app.php @@ -54,8 +54,6 @@ 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 fa5685e..065daab 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')->nullable(); + $table->string('passwordHash'); }); } 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 deleted file mode 100644 index c57c688..0000000 --- a/backend/database/migrations/2026_08_03_000000_create_email_confirmation_tokens_table.php +++ /dev/null @@ -1,29 +0,0 @@ -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 d471fcf..7d7d7c1 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -5,8 +5,6 @@ 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']); diff --git a/backend/tests/Fakes/FakeEmailConfirmationTokenRepository.php b/backend/tests/Fakes/FakeEmailConfirmationTokenRepository.php deleted file mode 100644 index d0a8332..0000000 --- a/backend/tests/Fakes/FakeEmailConfirmationTokenRepository.php +++ /dev/null @@ -1,69 +0,0 @@ - - */ - 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 deleted file mode 100644 index 562ccb1..0000000 --- a/backend/tests/Fakes/FakeEmailFactory.php +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index 4fb55ce..0000000 --- a/backend/tests/Fakes/FakeEmailer.php +++ /dev/null @@ -1,48 +0,0 @@ -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 df20238..3672f87 100644 --- a/backend/tests/Fakes/FakeUserRepository.php +++ b/backend/tests/Fakes/FakeUserRepository.php @@ -45,13 +45,6 @@ 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 deleted file mode 100644 index 62a9479..0000000 --- a/backend/tests/Feature/Auth/ConfirmEmailEndpointTest.php +++ /dev/null @@ -1,70 +0,0 @@ -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 deleted file mode 100644 index 8514aa1..0000000 --- a/backend/tests/Feature/Auth/SignupEndpointTest.php +++ /dev/null @@ -1,50 +0,0 @@ -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 1b94bea..5741cb5 100644 --- a/backend/tests/Feature/User/EloquentUserRepositoryTest.php +++ b/backend/tests/Feature/User/EloquentUserRepositoryTest.php @@ -78,23 +78,4 @@ 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 0b3fa6a..585af22 100644 --- a/backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php +++ b/backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php @@ -64,22 +64,6 @@ 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 9e136c5..5372272 100644 --- a/backend/tests/Unit/Http/Controllers/AuthControllerTest.php +++ b/backend/tests/Unit/Http/Controllers/AuthControllerTest.php @@ -5,21 +5,15 @@ 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; @@ -53,32 +47,7 @@ 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 deleted file mode 100644 index ba59277..0000000 --- a/backend/tests/Unit/User/UseCases/ConfirmUserEmailTest.php +++ /dev/null @@ -1,149 +0,0 @@ -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 deleted file mode 100644 index 64e21a5..0000000 --- a/backend/tests/Unit/User/UseCases/SignupUserTest.php +++ /dev/null @@ -1,135 +0,0 @@ -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 105d994..b3c6873 100644 --- a/backend/tests/Unit/User/UserTest.php +++ b/backend/tests/Unit/User/UserTest.php @@ -21,19 +21,4 @@ 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()); - } } diff --git a/frontend/website/cypress/e2e/confirm-email.cy.ts b/frontend/website/cypress/e2e/confirm-email.cy.ts deleted file mode 100644 index 075909d..0000000 --- a/frontend/website/cypress/e2e/confirm-email.cy.ts +++ /dev/null @@ -1,77 +0,0 @@ -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 7c0e66c..2dea54b 100644 --- a/frontend/website/cypress/e2e/guest-auth.cy.ts +++ b/frontend/website/cypress/e2e/guest-auth.cy.ts @@ -42,18 +42,28 @@ 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('#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.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 invalid form submissions on their current route', () => { + it('keeps UI-only 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 deleted file mode 100644 index 6496499..0000000 --- a/frontend/website/cypress/e2e/signup.cy.ts +++ /dev/null @@ -1,63 +0,0 @@ -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') - }) -}) diff --git a/frontend/website/src/router/index.ts b/frontend/website/src/router/index.ts index c55b993..66d053a 100644 --- a/frontend/website/src/router/index.ts +++ b/frontend/website/src/router/index.ts @@ -29,32 +29,6 @@ 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 b834955..fc9eef3 100644 --- a/frontend/website/src/stores/auth.ts +++ b/frontend/website/src/stores/auth.ts @@ -13,20 +13,17 @@ const meResponseSchema = z.object({ user: authUserSchema, }) -const authErrorResponseSchema = z.object({ +const loginErrorResponseSchema = 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 { @@ -88,7 +85,7 @@ export const useAuthStore = defineStore('auth', () => { } user.value = null - const errorResponse = authErrorResponseSchema.safeParse(responseBody) + const errorResponse = loginErrorResponseSchema.safeParse(responseBody) error.value = errorResponse.success ? errorResponse.data.error : 'Unable to log in. Please try again.' @@ -104,76 +101,6 @@ 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`, { @@ -192,23 +119,9 @@ 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 deleted file mode 100644 index dc7d672..0000000 --- a/frontend/website/src/views/CheckEmailView.vue +++ /dev/null @@ -1,39 +0,0 @@ - - - - - diff --git a/frontend/website/src/views/ConfirmEmailView.vue b/frontend/website/src/views/ConfirmEmailView.vue deleted file mode 100644 index 13966dc..0000000 --- a/frontend/website/src/views/ConfirmEmailView.vue +++ /dev/null @@ -1,100 +0,0 @@ - - - diff --git a/frontend/website/src/views/SignupView.vue b/frontend/website/src/views/SignupView.vue index 9ba636d..123bf72 100644 --- a/frontend/website/src/views/SignupView.vue +++ b/frontend/website/src/views/SignupView.vue @@ -1,40 +1,7 @@