diff --git a/README.md b/README.md index e800d97..739c8cf 100644 --- a/README.md +++ b/README.md @@ -87,10 +87,3 @@ Future versions of Attainly may include: Attainly is built around a simple idea: Large goals become attainable when they are broken into clear, scheduled steps and completed consistently over time. - -## Development Login - -The seeded local development account uses these credentials: - -- Email: `user@example.com` -- Password: `password` diff --git a/backend/app/Auth/BcryptPasswordHasher.php b/backend/app/Auth/BcryptPasswordHasher.php deleted file mode 100644 index 0bc4a46..0000000 --- a/backend/app/Auth/BcryptPasswordHasher.php +++ /dev/null @@ -1,16 +0,0 @@ -email === null || $request->email === '') { - throw new BadRequestException('email is required'); - } - if ($request->password === null || $request->password === '') { - throw new BadRequestException('password is required'); - } - - $user = $this->userRepo->findByEmail( - new EmailAddress($request->email) - ); - if ($user === null) { - throw new UnauthorizedException('invalid credentials'); - } - - $passwordMatches = $this->hasher->verify( - $request->password, - $user->getPasswordHash(), - ); - if (! $passwordMatches) { - throw new UnauthorizedException('invalid credentials'); - } - - return $user; - } -} diff --git a/backend/app/Auth/UseCases/AuthenticateUser/AuthenticateUserRequest.php b/backend/app/Auth/UseCases/AuthenticateUser/AuthenticateUserRequest.php deleted file mode 100644 index aa8b1df..0000000 --- a/backend/app/Auth/UseCases/AuthenticateUser/AuthenticateUserRequest.php +++ /dev/null @@ -1,11 +0,0 @@ -clock->now(); - $expiresAt = $now->modify(self::SESSION_LIFETIME); - - return $this->sessionRepo->create(new CreateSessionDto( - token: $this->tokenGenerator->generate(), - user: $user, - createdAt: $now, - expiresAt: $expiresAt, - )); - } -} diff --git a/backend/app/Exceptions/BadRequestException.php b/backend/app/Exceptions/BadRequestException.php deleted file mode 100644 index b900f47..0000000 --- a/backend/app/Exceptions/BadRequestException.php +++ /dev/null @@ -1,7 +0,0 @@ -authenticateUser->execute( - new AuthenticateUserRequest( - email: $input->string('email'), - password: $input->string('password'), - ) - ); - } catch (BadRequestException $exception) { - return new JsonResponse( - ['error' => $exception->getMessage()], 400 - ); - } catch (UnauthorizedException $exception) { - return new JsonResponse( - ['error' => $exception->getMessage()], 401 - ); - } - - $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 { /** @var User $user */ $user = $request->attributes->get('user'); return new JsonResponse([ - 'user' => $this->userPayload($user), + 'user' => [ + 'id' => $user->getId(), + 'email' => $user->getEmail()->value(), + ], ]); } - - /** - * @return array{id: int, email: string} - */ - private function userPayload(User $user): array - { - return [ - 'id' => $user->getId(), - 'email' => $user->getEmail()->value(), - ]; - } } diff --git a/backend/app/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php index 36ac77d..52192d0 100644 --- a/backend/app/Providers/AppServiceProvider.php +++ b/backend/app/Providers/AppServiceProvider.php @@ -2,14 +2,10 @@ namespace App\Providers; -use App\Auth\BcryptPasswordHasher; use App\Auth\Clock; use App\Auth\EloquentSessionRepository; -use App\Auth\PasswordHasher; -use App\Auth\RandomTokenGenerator; use App\Auth\SessionRepository; use App\Auth\SystemClock; -use App\Auth\TokenGenerator; use App\User\EloquentUserRepository; use App\User\UserRepository; use Carbon\CarbonImmutable; @@ -33,8 +29,6 @@ class AppServiceProvider extends ServiceProvider SessionRepository::class, EloquentSessionRepository::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/Shared/Http/RequestInput.php b/backend/app/Shared/Http/RequestInput.php deleted file mode 100644 index 6dffb92..0000000 --- a/backend/app/Shared/Http/RequestInput.php +++ /dev/null @@ -1,23 +0,0 @@ -request->input($key); - if (is_string($value)) { - return $value; - } - if (is_int($value) || is_float($value) || is_bool($value)) { - return (string) $value; - } - - return null; - } -} diff --git a/backend/app/User/CreateUserDto.php b/backend/app/User/CreateUserDto.php index e0267b6..735320b 100644 --- a/backend/app/User/CreateUserDto.php +++ b/backend/app/User/CreateUserDto.php @@ -8,6 +8,5 @@ final readonly class CreateUserDto { public function __construct( public EmailAddress $email, - public string $passwordHash, ) {} } diff --git a/backend/app/User/EloquentUserRepository.php b/backend/app/User/EloquentUserRepository.php index cee7817..5996528 100644 --- a/backend/app/User/EloquentUserRepository.php +++ b/backend/app/User/EloquentUserRepository.php @@ -10,7 +10,6 @@ class EloquentUserRepository implements UserRepository { $model = UserModel::create([ 'email' => $dto->email->value(), - 'passwordHash' => $dto->passwordHash, ]); return $this->toDomain($model); @@ -26,24 +25,11 @@ class EloquentUserRepository implements UserRepository return $this->toDomain($model); } - public function findByEmail(EmailAddress $email): ?User - { - $model = UserModel::query() - ->where('email', $email->value()) - ->first(); - if ($model === null) { - return null; - } - - return $this->toDomain($model); - } - private function toDomain(UserModel $model): User { return new User( id: $model->id, email: new EmailAddress($model->email), - passwordHash: $model->passwordHash, ); } } diff --git a/backend/app/User/User.php b/backend/app/User/User.php index 3501bee..8c9fa9c 100644 --- a/backend/app/User/User.php +++ b/backend/app/User/User.php @@ -9,7 +9,6 @@ final readonly class User public function __construct( private int $id, private EmailAddress $email, - private string $passwordHash, ) {} public function getId(): int @@ -21,9 +20,4 @@ final readonly class User { return $this->email; } - - public function getPasswordHash(): string - { - return $this->passwordHash; - } } diff --git a/backend/app/User/UserModel.php b/backend/app/User/UserModel.php index d2c64e3..74f3211 100644 --- a/backend/app/User/UserModel.php +++ b/backend/app/User/UserModel.php @@ -9,7 +9,6 @@ use Illuminate\Database\Eloquent\Model; /** * @property int $id * @property string $email - * @property string $passwordHash * * @method static Builder|UserModel newModelQuery() * @method static Builder|UserModel newQuery() @@ -17,7 +16,7 @@ use Illuminate\Database\Eloquent\Model; * * @mixin \Eloquent */ -#[Fillable(['email', 'passwordHash'])] +#[Fillable(['email'])] class UserModel extends Model { protected $table = 'users'; diff --git a/backend/app/User/UserRepository.php b/backend/app/User/UserRepository.php index 4805f3f..5fcd6eb 100644 --- a/backend/app/User/UserRepository.php +++ b/backend/app/User/UserRepository.php @@ -2,13 +2,9 @@ namespace App\User; -use App\Shared\ValueObject\EmailAddress; - interface UserRepository { public function create(CreateUserDto $dto): User; public function find(int $id): ?User; - - public function findByEmail(EmailAddress $email): ?User; } 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..dafb6c2 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,6 @@ return new class extends Migration Schema::create('users', function (Blueprint $table): void { $table->id(); $table->string('email')->unique(); - $table->string('passwordHash'); }); } diff --git a/backend/database/seeders/UserSeeder.php b/backend/database/seeders/UserSeeder.php index ef163e1..886e4c9 100644 --- a/backend/database/seeders/UserSeeder.php +++ b/backend/database/seeders/UserSeeder.php @@ -2,29 +2,15 @@ namespace Database\Seeders; -use App\Auth\PasswordHasher; -use App\Shared\ValueObject\EmailAddress; -use App\User\CreateUserDto; -use App\User\UserRepository; +use App\User\UserModel; use Illuminate\Database\Seeder; class UserSeeder extends Seeder { - public const string EMAIL = 'user@example.com'; - - public const string PASSWORD = 'password'; - public function run(): void { - $userRepository = app(UserRepository::class); - $email = new EmailAddress(self::EMAIL); - if ($userRepository->findByEmail($email) !== null) { - return; - } - - $userRepository->create(new CreateUserDto( - email: $email, - passwordHash: app(PasswordHasher::class)->hash(self::PASSWORD), - )); + UserModel::firstOrCreate([ + 'email' => 'user@example.com', + ]); } } diff --git a/backend/routes/api.php b/backend/routes/api.php index d2e63d8..d8fba04 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -4,8 +4,6 @@ use App\Http\Controllers\AuthController; use App\Http\Middleware\AuthMiddleware; use Illuminate\Support\Facades\Route; -Route::post('/login', [AuthController::class, 'login']); - Route::middleware(AuthMiddleware::class)->group(function (): void { Route::get('/me', [AuthController::class, 'me']); }); diff --git a/backend/tests/Fakes/FakePasswordHasher.php b/backend/tests/Fakes/FakePasswordHasher.php deleted file mode 100644 index 9e93325..0000000 --- a/backend/tests/Fakes/FakePasswordHasher.php +++ /dev/null @@ -1,18 +0,0 @@ -hash($password) === $hash; - } -} diff --git a/backend/tests/Fakes/FakeTokenGenerator.php b/backend/tests/Fakes/FakeTokenGenerator.php deleted file mode 100644 index 54926f3..0000000 --- a/backend/tests/Fakes/FakeTokenGenerator.php +++ /dev/null @@ -1,28 +0,0 @@ -callCount >= count($this->tokens)) { - throw new RuntimeException('FakeTokenGenerator exhausted'); - } - - $token = $this->tokens[$this->callCount]; - $this->callCount++; - - return $token; - } -} diff --git a/backend/tests/Fakes/FakeUserRepository.php b/backend/tests/Fakes/FakeUserRepository.php deleted file mode 100644 index 3672f87..0000000 --- a/backend/tests/Fakes/FakeUserRepository.php +++ /dev/null @@ -1,56 +0,0 @@ - - */ - private array $users = []; - - public function create(CreateUserDto $dto): User - { - $id = count($this->users) + 1; - $user = new User( - id: $id, - email: $dto->email, - passwordHash: $dto->passwordHash, - ); - $this->users[$id] = $user; - - return $this->copy($user); - } - - public function find(int $id): ?User - { - $user = $this->users[$id] ?? null; - - return $user === null ? null : $this->copy($user); - } - - public function findByEmail(EmailAddress $email): ?User - { - foreach ($this->users as $user) { - if ($user->getEmail()->value() === $email->value()) { - return $this->copy($user); - } - } - - return null; - } - - private function copy(User $user): User - { - return new User( - id: $user->getId(), - email: $user->getEmail(), - passwordHash: $user->getPasswordHash(), - ); - } -} diff --git a/backend/tests/Feature/Auth/AuthMiddlewareTest.php b/backend/tests/Feature/Auth/AuthMiddlewareTest.php index 91d7be3..8d84a1d 100644 --- a/backend/tests/Feature/Auth/AuthMiddlewareTest.php +++ b/backend/tests/Feature/Auth/AuthMiddlewareTest.php @@ -101,7 +101,6 @@ class AuthMiddlewareTest extends TestCase ): User { $user = app(UserRepository::class)->create(new CreateUserDto( email: new EmailAddress('user@example.com'), - passwordHash: 'hashed-password', )); app(SessionRepository::class)->create(new CreateSessionDto( token: $token, diff --git a/backend/tests/Feature/Auth/EloquentSessionRepositoryTest.php b/backend/tests/Feature/Auth/EloquentSessionRepositoryTest.php index 25f9fe5..32d170d 100644 --- a/backend/tests/Feature/Auth/EloquentSessionRepositoryTest.php +++ b/backend/tests/Feature/Auth/EloquentSessionRepositoryTest.php @@ -20,7 +20,6 @@ class EloquentSessionRepositoryTest extends TestCase { $user = app(UserRepository::class)->create(new CreateUserDto( email: new EmailAddress('user@example.com'), - passwordHash: 'hashed-password', )); $createdAt = $this->utc('2026-07-31T12:00:00'); $expiresAt = $this->utc('2026-08-07T12:00:00'); @@ -62,7 +61,6 @@ class EloquentSessionRepositoryTest extends TestCase { $user = app(UserRepository::class)->create(new CreateUserDto( email: new EmailAddress('user@example.com'), - passwordHash: 'hashed-password', )); $repository = app(SessionRepository::class); $repository->create(new CreateSessionDto( diff --git a/backend/tests/Feature/Auth/LoginEndpointTest.php b/backend/tests/Feature/Auth/LoginEndpointTest.php deleted file mode 100644 index bab28b8..0000000 --- a/backend/tests/Feature/Auth/LoginEndpointTest.php +++ /dev/null @@ -1,47 +0,0 @@ -hash($password); - app(UserRepository::class)->create(new CreateUserDto( - email: new EmailAddress($email), - passwordHash: $passwordHash, - )); - - $response = $this->postJson('/api/login', [ - 'email' => $email, - 'password' => $password, - ]); - - $response->assertOk(); - $response->assertJsonPath('user.email', $email); - - $cookie = $response->getCookie( - AuthMiddleware::COOKIE_NAME, - false, - ); - $this->assertNotNull($cookie); - $this->assertNotNull( - app(SessionRepository::class)->findByToken( - $cookie->getValue(), - ), - ); - } -} diff --git a/backend/tests/Feature/Auth/MeEndpointTest.php b/backend/tests/Feature/Auth/MeEndpointTest.php index 3cabaf1..ec19fc3 100644 --- a/backend/tests/Feature/Auth/MeEndpointTest.php +++ b/backend/tests/Feature/Auth/MeEndpointTest.php @@ -25,7 +25,6 @@ class MeEndpointTest extends TestCase ); $user = app(UserRepository::class)->create(new CreateUserDto( email: new EmailAddress('user@example.com'), - passwordHash: 'hashed-password', )); app(SessionRepository::class)->create(new CreateSessionDto( token: 'valid-token', diff --git a/backend/tests/Feature/Database/DatabaseSeederTest.php b/backend/tests/Feature/Database/DatabaseSeederTest.php index ca1ec91..26b6b06 100644 --- a/backend/tests/Feature/Database/DatabaseSeederTest.php +++ b/backend/tests/Feature/Database/DatabaseSeederTest.php @@ -2,9 +2,6 @@ namespace Tests\Feature\Database; -use App\Auth\PasswordHasher; -use App\Shared\ValueObject\EmailAddress; -use App\User\UserRepository; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -20,20 +17,6 @@ class DatabaseSeederTest extends TestCase $this->assertDatabaseHas('users', [ 'email' => 'user@example.com', ]); - $this->assertDatabaseMissing('users', [ - 'email' => 'user@example.com', - 'passwordHash' => 'password', - ]); $this->assertDatabaseCount('users', 1); - - $user = app(UserRepository::class)->findByEmail( - new EmailAddress('user@example.com'), - ); - - $this->assertNotNull($user); - $this->assertTrue(app(PasswordHasher::class)->verify( - 'password', - $user->getPasswordHash(), - )); } } diff --git a/backend/tests/Feature/User/EloquentUserRepositoryTest.php b/backend/tests/Feature/User/EloquentUserRepositoryTest.php index 5741cb5..207523f 100644 --- a/backend/tests/Feature/User/EloquentUserRepositoryTest.php +++ b/backend/tests/Feature/User/EloquentUserRepositoryTest.php @@ -17,7 +17,6 @@ class EloquentUserRepositoryTest extends TestCase $repository = app(UserRepository::class); $user = $repository->create(new CreateUserDto( email: new EmailAddress('Founder@EXAMPLE.COM'), - passwordHash: 'hashed-password', )); $this->assertGreaterThan(0, $user->getId()); @@ -29,10 +28,6 @@ class EloquentUserRepositoryTest extends TestCase 'id' => $user->getId(), 'email' => 'Founder@example.com', ]); - $this->assertDatabaseHas('users', [ - 'id' => $user->getId(), - 'passwordHash' => 'hashed-password', - ]); $foundUser = $repository->find($user->getId()); @@ -42,10 +37,6 @@ class EloquentUserRepositoryTest extends TestCase $user->getEmail()->value(), $foundUser->getEmail()->value(), ); - $this->assertSame( - $user->getPasswordHash(), - $foundUser->getPasswordHash(), - ); } public function test_it_returns_null_for_an_unknown_user(): void @@ -54,28 +45,4 @@ class EloquentUserRepositoryTest extends TestCase $this->assertNull($repository->find(999)); } - - public function test_it_finds_a_user_by_email(): void - { - $repository = app(UserRepository::class); - $createdUser = $repository->create(new CreateUserDto( - email: new EmailAddress('user@example.com'), - passwordHash: 'hashed-password', - )); - - $foundUser = $repository->findByEmail( - new EmailAddress('user@EXAMPLE.COM'), - ); - - $this->assertNotNull($foundUser); - $this->assertSame($createdUser->getId(), $foundUser->getId()); - } - - public function test_it_returns_null_for_an_unknown_email(): void - { - $repository = app(UserRepository::class); - $this->assertNull($repository->findByEmail( - new EmailAddress('unknown@example.com'), - )); - } } diff --git a/backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php b/backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php index d6a72a1..65908c7 100644 --- a/backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php +++ b/backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php @@ -154,7 +154,6 @@ class AuthMiddlewareTest extends TestCase return new User( id: 7, email: new EmailAddress('user@example.com'), - passwordHash: 'hashed-password', ); } } diff --git a/backend/tests/Unit/Auth/SessionTest.php b/backend/tests/Unit/Auth/SessionTest.php index 264033c..7804333 100644 --- a/backend/tests/Unit/Auth/SessionTest.php +++ b/backend/tests/Unit/Auth/SessionTest.php @@ -16,7 +16,6 @@ class SessionTest extends TestCase $user = new User( id: 7, email: new EmailAddress('user@example.com'), - passwordHash: 'hashed-password', ); $createdAt = $this->utc('2026-07-31T12:00:00'); $expiresAt = $this->utc('2026-08-07T12:00:00'); @@ -41,7 +40,6 @@ class SessionTest extends TestCase user: new User( id: 7, email: new EmailAddress('user@example.com'), - passwordHash: 'hashed-password', ), createdAt: $this->utc('2026-07-31T12:00:00'), expiresAt: $expiresAt, diff --git a/backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php b/backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php deleted file mode 100644 index 585af22..0000000 --- a/backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php +++ /dev/null @@ -1,104 +0,0 @@ -userRepository = new FakeUserRepository; - $this->passwordHasher = new FakePasswordHasher; - $this->useCase = new AuthenticateUser( - $this->userRepository, - $this->passwordHasher, - ); - } - - public function test_null_email_throws_bad_request(): void - { - $this->expectException(BadRequestException::class); - $this->expectExceptionMessage('email is required'); - - $this->useCase->execute(new AuthenticateUserRequest( - email: null, - password: 'correct-password', - )); - } - - public function test_null_password_throws_bad_request(): void - { - $this->expectException(BadRequestException::class); - $this->expectExceptionMessage('password is required'); - - $this->useCase->execute(new AuthenticateUserRequest( - email: 'user@example.com', - password: null, - )); - } - - public function test_unknown_email_throws_unauthorized(): void - { - $this->expectException(UnauthorizedException::class); - $this->expectExceptionMessage('invalid credentials'); - - $this->useCase->execute(new AuthenticateUserRequest( - email: 'unknown@example.com', - password: 'correct-password', - )); - } - - public function test_wrong_password_throws_unauthorized(): void - { - $this->createUser('correct-password'); - $this->expectException(UnauthorizedException::class); - $this->expectExceptionMessage('invalid credentials'); - - $this->useCase->execute(new AuthenticateUserRequest( - email: 'user@example.com', - password: 'wrong-password', - )); - } - - public function test_valid_credentials_return_user(): void - { - $user = $this->createUser('correct-password'); - - $authenticatedUser = $this->useCase->execute( - new AuthenticateUserRequest( - email: 'user@example.com', - password: 'correct-password', - ), - ); - - $this->assertSame($user->getId(), $authenticatedUser->getId()); - $this->assertSame( - $user->getEmail()->value(), - $authenticatedUser->getEmail()->value(), - ); - } - - private function createUser(string $password): User - { - return $this->userRepository->create(new CreateUserDto( - email: new EmailAddress('user@example.com'), - passwordHash: $this->passwordHasher->hash($password), - )); - } -} diff --git a/backend/tests/Unit/Auth/UseCases/CreateSessionTest.php b/backend/tests/Unit/Auth/UseCases/CreateSessionTest.php deleted file mode 100644 index 4d85166..0000000 --- a/backend/tests/Unit/Auth/UseCases/CreateSessionTest.php +++ /dev/null @@ -1,70 +0,0 @@ -now = new DateTimeImmutable( - '2026-07-31T12:00:00', - new DateTimeZone('UTC'), - ); - $this->sessionRepository = new FakeSessionRepository; - $this->useCase = new CreateSession( - $this->sessionRepository, - new FakeTokenGenerator(['session-token']), - new FakeClock($this->now), - ); - } - - public function test_creates_a_seven_day_session_with_generated_token(): void - { - $user = $this->user(); - - $session = $this->useCase->execute($user); - - $this->assertSame('session-token', $session->getToken()); - $this->assertSame($user, $session->getUser()); - $this->assertSame($this->now, $session->getCreatedAt()); - $this->assertEquals( - $this->now->modify('+7 days'), - $session->getExpiresAt(), - ); - } - - public function test_created_session_is_findable_by_token(): void - { - $this->useCase->execute($this->user()); - - $session = $this->sessionRepository->findByToken('session-token'); - - $this->assertNotNull($session); - $this->assertSame(7, $session->getUser()->getId()); - } - - private function user(): User - { - return new User( - id: 7, - email: new EmailAddress('user@example.com'), - passwordHash: 'hashed:correct-password', - ); - } -} diff --git a/backend/tests/Unit/Http/Controllers/AuthControllerTest.php b/backend/tests/Unit/Http/Controllers/AuthControllerTest.php deleted file mode 100644 index cded75c..0000000 --- a/backend/tests/Unit/Http/Controllers/AuthControllerTest.php +++ /dev/null @@ -1,127 +0,0 @@ -userRepository = new FakeUserRepository; - $this->passwordHasher = new FakePasswordHasher; - $this->sessionRepository = new FakeSessionRepository; - $authenticateUser = new AuthenticateUser( - $this->userRepository, - $this->passwordHasher, - ); - $createSession = new CreateSession( - $this->sessionRepository, - new FakeTokenGenerator(['session-token']), - new FakeClock(new DateTimeImmutable( - '2026-07-31T12:00:00', - new DateTimeZone('UTC'), - )), - ); - $this->controller = new AuthController( - $authenticateUser, - $createSession, - ); - } - - public function test_login_returns_user_and_cookie(): void - { - $this->createUser('correct-password'); - - $response = $this->controller->login(new Request([ - 'email' => 'user@example.com', - 'password' => 'correct-password', - ])); - - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame( - 'user@example.com', - json_decode($response->getContent(), true)['user']['email'], - ); - $cookie = $response->headers->getCookies()[0]; - $this->assertSame(AuthMiddleware::COOKIE_NAME, $cookie->getName()); - $this->assertSame('session-token', $cookie->getValue()); - $this->assertTrue($cookie->isHttpOnly()); - $this->assertSame('lax', $cookie->getSameSite()); - $this->assertNotNull( - $this->sessionRepository->findByToken('session-token'), - ); - } - - public function test_login_returns_bad_request_for_missing_email(): void - { - $response = $this->controller->login(new Request([ - 'password' => 'correct-password', - ])); - - $this->assertSame(400, $response->getStatusCode()); - $this->assertSame( - ['error' => 'email is required'], - json_decode($response->getContent(), true), - ); - } - - public function test_login_returns_bad_request_for_missing_password(): void - { - $response = $this->controller->login(new Request([ - 'email' => 'user@example.com', - ])); - - $this->assertSame(400, $response->getStatusCode()); - $this->assertSame( - ['error' => 'password is required'], - json_decode($response->getContent(), true), - ); - } - - public function test_login_returns_unauthorized_for_invalid_credentials(): void - { - $this->createUser('correct-password'); - - $response = $this->controller->login(new Request([ - 'email' => 'user@example.com', - 'password' => 'wrong-password', - ])); - - $this->assertSame(401, $response->getStatusCode()); - $this->assertSame( - ['error' => 'invalid credentials'], - json_decode($response->getContent(), true), - ); - } - - private function createUser(string $password): void - { - $this->userRepository->create(new CreateUserDto( - email: new EmailAddress('user@example.com'), - passwordHash: $this->passwordHasher->hash($password), - )); - } -} diff --git a/backend/tests/Unit/User/UserTest.php b/backend/tests/Unit/User/UserTest.php index b3c6873..0ac8d53 100644 --- a/backend/tests/Unit/User/UserTest.php +++ b/backend/tests/Unit/User/UserTest.php @@ -8,17 +8,12 @@ use PHPUnit\Framework\TestCase; class UserTest extends TestCase { - public function test_it_exposes_its_identity_email_and_password_hash(): void + public function test_it_exposes_its_identity_and_email(): void { $email = new EmailAddress('user@example.com'); - $user = new User( - id: 42, - email: $email, - passwordHash: 'hashed-password', - ); + $user = new User(id: 42, email: $email); $this->assertSame(42, $user->getId()); $this->assertSame($email, $user->getEmail()); - $this->assertSame('hashed-password', $user->getPasswordHash()); } } diff --git a/frontend/website/cypress/e2e/login.cy.ts b/frontend/website/cypress/e2e/login.cy.ts deleted file mode 100644 index 155d227..0000000 --- a/frontend/website/cypress/e2e/login.cy.ts +++ /dev/null @@ -1,151 +0,0 @@ -const authenticatedUser = { - id: 7, - email: 'user@example.com', -} - -function fillLoginForm(): void { - cy.get('#login-email').type('user@example.com') - cy.get('#login-password').type('correct-password') -} - -describe('password login', () => { - beforeEach(() => { - cy.intercept('GET', '**/api/me', { - statusCode: 401, - body: { error: 'unauthenticated' }, - }).as('me') - }) - - it('logs in and redirects to the dashboard', () => { - cy.intercept('POST', '**/api/login', (request) => { - expect(request.body).to.deep.equal({ - email: 'user@example.com', - password: 'correct-password', - }) - request.reply({ - statusCode: 200, - body: { user: authenticatedUser }, - }) - }).as('login') - - cy.visit('/login') - fillLoginForm() - cy.get('form').submit() - cy.wait('@login') - - cy.location('pathname').should('equal', '/dashboard') - }) - - it('returns to a safe internal redirect after login', () => { - cy.intercept('POST', '**/api/login', { - statusCode: 200, - body: { user: authenticatedUser }, - }).as('login') - - cy.visit('/login?redirect=%2Fdashboard%3Ffocus%3Dtoday') - fillLoginForm() - cy.get('form').submit() - cy.wait('@login') - - cy.location('pathname').should('equal', '/dashboard') - cy.location('search').should('equal', '?focus=today') - }) - - it('ignores an unsafe redirect after login', () => { - cy.intercept('POST', '**/api/login', { - statusCode: 200, - body: { user: authenticatedUser }, - }).as('login') - - cy.visit('/login?redirect=https%3A%2F%2Fexample.com') - fillLoginForm() - cy.get('form').submit() - cy.wait('@login') - - cy.location('pathname').should('equal', '/dashboard') - }) - - it('validates required login fields before submitting', () => { - cy.visit('/login') - cy.get('form').submit() - - cy.get('#login-email-error') - .should('have.text', 'Enter a valid email address.') - .and('be.visible') - cy.get('#login-email').should('have.attr', 'aria-invalid', 'true') - cy.get('#login-password-error') - .should('have.text', 'Enter your password.') - .and('be.visible') - cy.get('#login-password').should('have.attr', 'aria-invalid', 'true') - cy.location('pathname').should('equal', '/login') - }) - - it('shows the backend invalid-credentials error', () => { - cy.intercept('POST', '**/api/login', { - statusCode: 401, - body: { error: 'invalid credentials' }, - }).as('login') - - cy.visit('/login') - fillLoginForm() - cy.get('form').submit() - cy.wait('@login') - - cy.get('[role="alert"]') - .should('have.text', 'invalid credentials') - .and('be.visible') - cy.location('pathname').should('equal', '/login') - }) - - it('shows backend request errors', () => { - cy.intercept('POST', '**/api/login', { - statusCode: 400, - body: { error: 'email is required' }, - }).as('login') - - cy.visit('/login') - fillLoginForm() - cy.get('form').submit() - cy.wait('@login') - - cy.get('[role="alert"]') - .should('have.text', 'email is required') - .and('be.visible') - cy.get('#login-email-error').should('not.exist') - }) - - it('shows a malformed-response error', () => { - cy.intercept('POST', '**/api/login', { - statusCode: 200, - body: { user: { id: 7 } }, - }).as('malformedLogin') - - cy.visit('/login') - fillLoginForm() - cy.get('form').submit() - cy.wait('@malformedLogin') - cy.get('[role="alert"]').should( - 'have.text', - 'Unable to log in. Please try again.', - ) - }) - - it('disables the form while login is pending', () => { - cy.intercept('POST', '**/api/login', { - delay: 500, - statusCode: 200, - body: { user: authenticatedUser }, - }).as('login') - - cy.visit('/login') - fillLoginForm() - cy.get('form').submit() - - cy.get('button[type="submit"]') - .should('be.disabled') - .and('have.text', 'Logging in...') - cy.get('#login-email').should('be.disabled') - cy.get('#login-password').should('be.disabled') - cy.wait('@login') - }) -}) diff --git a/frontend/website/src/components/AuthForm.vue b/frontend/website/src/components/AuthForm.vue index 1296d51..709091f 100644 --- a/frontend/website/src/components/AuthForm.vue +++ b/frontend/website/src/components/AuthForm.vue @@ -1,34 +1,16 @@ @@ -78,22 +60,6 @@ button:focus-visible { outline-offset: 0.2rem; } -button:disabled { - border-color: #708079; - background: #708079; - box-shadow: none; - cursor: wait; - transform: none; -} - -.auth-form__error { - margin: -0.65rem 0; - color: #a33f37; - font-size: 0.8rem; - font-weight: 650; - line-height: 1.5; -} - @media (prefers-reduced-motion: reduce) { button { transition: none; diff --git a/frontend/website/src/components/AuthTextField.vue b/frontend/website/src/components/AuthTextField.vue index 3e5b087..e9ec98c 100644 --- a/frontend/website/src/components/AuthTextField.vue +++ b/frontend/website/src/components/AuthTextField.vue @@ -5,20 +5,7 @@ defineProps<{ type: 'email' | 'password' | 'text' autocomplete: string placeholder: string - modelValue?: string - error?: string - disabled?: boolean }>() - -const emit = defineEmits<{ - 'update:modelValue': [value: string] -}>() - -function updateValue(event: Event): void { - if (event.target instanceof HTMLInputElement) { - emit('update:modelValue', event.target.value) - } -} @@ -83,23 +62,6 @@ input:focus { box-shadow: 0 0 0 3px rgb(77 125 109 / 16%); } -input[aria-invalid='true'] { - border-color: #a54e46; -} - -input:disabled { - color: #67736e; - background: #f5f5f2; - cursor: not-allowed; -} - -.text-field__error { - margin: 0; - color: #a33f37; - font-size: 0.76rem; - line-height: 1.4; -} - @media (prefers-reduced-motion: reduce) { input { transition: none; diff --git a/frontend/website/src/stores/auth.ts b/frontend/website/src/stores/auth.ts index d720f18..399a301 100644 --- a/frontend/website/src/stores/auth.ts +++ b/frontend/website/src/stores/auth.ts @@ -13,12 +13,7 @@ const meResponseSchema = z.object({ user: authUserSchema, }) -const loginErrorResponseSchema = z.object({ - error: z.string(), -}) - export type AuthUser = z.infer -export type LoginFieldErrors = Partial> export const useAuthStore = defineStore('auth', () => { const user = ref(null) @@ -62,51 +57,11 @@ export const useAuthStore = defineStore('auth', () => { } } - async function login(email: string, password: string): Promise { - loading.value = true - error.value = null - - try { - const response = await fetch(`${API_BASE}/api/login`, { - method: 'POST', - credentials: 'include', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ email, password }), - }) - const responseBody: unknown = await response.json() - - if (response.status === 200) { - user.value = meResponseSchema.parse(responseBody).user - - return true - } - - user.value = null - const errorResponse = loginErrorResponseSchema.safeParse(responseBody) - error.value = errorResponse.success - ? errorResponse.data.error - : 'Unable to log in. Please try again.' - - return false - } catch { - user.value = null - error.value = 'Unable to log in. Please try again.' - - return false - } finally { - loading.value = false - } - } - return { user, loading, error, isAuthenticated, fetchMe, - login, } }) diff --git a/frontend/website/src/views/LoginView.vue b/frontend/website/src/views/LoginView.vue index 3088dec..e4215a4 100644 --- a/frontend/website/src/views/LoginView.vue +++ b/frontend/website/src/views/LoginView.vue @@ -1,58 +1,7 @@