From 93f5f022e46ae4a85df342b0881bdb5a96609053 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 31 Jul 2026 11:34:50 +0300 Subject: [PATCH 1/9] test password login --- .../tests/Feature/Auth/AuthMiddlewareTest.php | 1 + .../Auth/EloquentSessionRepositoryTest.php | 2 + .../tests/Feature/Auth/LoginEndpointTest.php | 163 ++++++++++++++++++ backend/tests/Feature/Auth/MeEndpointTest.php | 1 + .../Feature/Database/DatabaseSeederTest.php | 13 ++ .../User/EloquentUserRepositoryTest.php | 40 +++++ 6 files changed, 220 insertions(+) create mode 100644 backend/tests/Feature/Auth/LoginEndpointTest.php diff --git a/backend/tests/Feature/Auth/AuthMiddlewareTest.php b/backend/tests/Feature/Auth/AuthMiddlewareTest.php index 8d84a1d..e9b6d25 100644 --- a/backend/tests/Feature/Auth/AuthMiddlewareTest.php +++ b/backend/tests/Feature/Auth/AuthMiddlewareTest.php @@ -101,6 +101,7 @@ class AuthMiddlewareTest extends TestCase ): User { $user = app(UserRepository::class)->create(new CreateUserDto( email: new EmailAddress('user@example.com'), + password: 'correct-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 32d170d..0ce4b5f 100644 --- a/backend/tests/Feature/Auth/EloquentSessionRepositoryTest.php +++ b/backend/tests/Feature/Auth/EloquentSessionRepositoryTest.php @@ -20,6 +20,7 @@ class EloquentSessionRepositoryTest extends TestCase { $user = app(UserRepository::class)->create(new CreateUserDto( email: new EmailAddress('user@example.com'), + password: 'correct-password', )); $createdAt = $this->utc('2026-07-31T12:00:00'); $expiresAt = $this->utc('2026-08-07T12:00:00'); @@ -61,6 +62,7 @@ class EloquentSessionRepositoryTest extends TestCase { $user = app(UserRepository::class)->create(new CreateUserDto( email: new EmailAddress('user@example.com'), + password: 'correct-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 new file mode 100644 index 0000000..ba01a77 --- /dev/null +++ b/backend/tests/Feature/Auth/LoginEndpointTest.php @@ -0,0 +1,163 @@ +currentTime = new DateTimeImmutable( + '2026-07-31T12:00:00', + new DateTimeZone('UTC'), + ); + $this->app->instance( + Clock::class, + new FakeClock($this->currentTime), + ); + config()->set('session.lifetime', 120); + config()->set('session.path', '/'); + config()->set('session.secure', true); + config()->set('session.same_site', 'lax'); + } + + public function test_login_validates_its_request(): void + { + $this->postJson('/api/login') + ->assertUnprocessable() + ->assertJsonValidationErrors(['email', 'password']); + + $this->postJson('/api/login', [ + 'email' => 'invalid-email', + 'password' => 'password', + ])->assertUnprocessable() + ->assertJsonValidationErrors(['email']); + } + + public function test_login_rejects_invalid_credentials_generically(): void + { + $this->createUser(); + + $this->postJson('/api/login', [ + 'email' => 'user@example.com', + 'password' => 'wrong-password', + ])->assertUnauthorized() + ->assertExactJson(['error' => 'invalid_credentials']) + ->assertCookieMissing(AuthMiddleware::COOKIE_NAME); + + $this->postJson('/api/login', [ + 'email' => 'unknown@example.com', + 'password' => 'correct-password', + ])->assertUnauthorized() + ->assertExactJson(['error' => 'invalid_credentials']) + ->assertCookieMissing(AuthMiddleware::COOKIE_NAME); + + $this->assertDatabaseCount('sessions', 0); + } + + public function test_login_creates_a_session_and_returns_the_user(): void + { + $user = $this->createUser(); + + $response = $this->postJson('/api/login', [ + 'email' => ' user@EXAMPLE.COM ', + 'password' => 'correct-password', + ]); + + $response->assertOk()->assertExactJson([ + 'user' => [ + 'id' => $user->getId(), + 'email' => 'user@example.com', + ], + ]); + + $cookie = $this->findAuthCookie( + $response->headers->getCookies(), + ); + $token = $cookie->getValue(); + + $this->assertMatchesRegularExpression( + '/^[a-f0-9]{64}$/', + $token, + ); + $this->assertTrue($cookie->isHttpOnly()); + $this->assertTrue($cookie->isSecure()); + $this->assertSame('/', $cookie->getPath()); + $this->assertSame('lax', $cookie->getSameSite()); + $this->assertSame( + $this->currentTime->modify('+120 minutes')->getTimestamp(), + $cookie->getExpiresTime(), + ); + + $session = app(SessionRepository::class)->findByToken($token); + + $this->assertNotNull($session); + $this->assertSame($user->getId(), $session->getUser()->getId()); + $this->assertEquals( + $this->currentTime, + $session->getCreatedAt(), + ); + $this->assertEquals( + $this->currentTime->modify('+120 minutes'), + $session->getExpiresAt(), + ); + } + + public function test_login_throttles_repeated_attempts(): void + { + $this->createUser(); + + for ($attempt = 1; $attempt <= 5; $attempt++) { + $this->postJson('/api/login', [ + 'email' => 'user@example.com', + 'password' => 'wrong-password', + ])->assertUnauthorized(); + } + + $this->postJson('/api/login', [ + 'email' => 'user@example.com', + 'password' => 'wrong-password', + ])->assertStatus(429); + } + + private function createUser(): User + { + return app(UserRepository::class)->create(new CreateUserDto( + email: new EmailAddress('user@example.com'), + password: 'correct-password', + )); + } + + /** + * @param array $cookies + */ + private function findAuthCookie(array $cookies): Cookie + { + foreach ($cookies as $cookie) { + if ($cookie->getName() === AuthMiddleware::COOKIE_NAME) { + return $cookie; + } + } + + $this->fail('The authentication cookie was not set.'); + } +} diff --git a/backend/tests/Feature/Auth/MeEndpointTest.php b/backend/tests/Feature/Auth/MeEndpointTest.php index ec19fc3..988fd85 100644 --- a/backend/tests/Feature/Auth/MeEndpointTest.php +++ b/backend/tests/Feature/Auth/MeEndpointTest.php @@ -25,6 +25,7 @@ class MeEndpointTest extends TestCase ); $user = app(UserRepository::class)->create(new CreateUserDto( email: new EmailAddress('user@example.com'), + password: 'correct-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 26b6b06..361021d 100644 --- a/backend/tests/Feature/Database/DatabaseSeederTest.php +++ b/backend/tests/Feature/Database/DatabaseSeederTest.php @@ -2,6 +2,8 @@ namespace Tests\Feature\Database; +use App\Shared\ValueObject\EmailAddress; +use App\User\UserRepository; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -17,6 +19,17 @@ class DatabaseSeederTest extends TestCase $this->assertDatabaseHas('users', [ 'email' => 'user@example.com', ]); + $this->assertDatabaseMissing('users', [ + 'email' => 'user@example.com', + 'password' => 'password', + ]); $this->assertDatabaseCount('users', 1); + + $user = app(UserRepository::class)->findByCredentials( + new EmailAddress('user@example.com'), + 'password', + ); + + $this->assertNotNull($user); } } diff --git a/backend/tests/Feature/User/EloquentUserRepositoryTest.php b/backend/tests/Feature/User/EloquentUserRepositoryTest.php index 207523f..0d26dbb 100644 --- a/backend/tests/Feature/User/EloquentUserRepositoryTest.php +++ b/backend/tests/Feature/User/EloquentUserRepositoryTest.php @@ -17,6 +17,7 @@ class EloquentUserRepositoryTest extends TestCase $repository = app(UserRepository::class); $user = $repository->create(new CreateUserDto( email: new EmailAddress('Founder@EXAMPLE.COM'), + password: 'correct-password', )); $this->assertGreaterThan(0, $user->getId()); @@ -28,6 +29,10 @@ class EloquentUserRepositoryTest extends TestCase 'id' => $user->getId(), 'email' => 'Founder@example.com', ]); + $this->assertDatabaseMissing('users', [ + 'id' => $user->getId(), + 'password' => 'correct-password', + ]); $foundUser = $repository->find($user->getId()); @@ -45,4 +50,39 @@ class EloquentUserRepositoryTest extends TestCase $this->assertNull($repository->find(999)); } + + public function test_it_finds_a_user_with_matching_credentials(): void + { + $repository = app(UserRepository::class); + $createdUser = $repository->create(new CreateUserDto( + email: new EmailAddress('user@example.com'), + password: 'correct-password', + )); + + $foundUser = $repository->findByCredentials( + new EmailAddress('user@EXAMPLE.COM'), + 'correct-password', + ); + + $this->assertNotNull($foundUser); + $this->assertSame($createdUser->getId(), $foundUser->getId()); + } + + public function test_it_rejects_non_matching_credentials(): void + { + $repository = app(UserRepository::class); + $repository->create(new CreateUserDto( + email: new EmailAddress('user@example.com'), + password: 'correct-password', + )); + + $this->assertNull($repository->findByCredentials( + new EmailAddress('user@example.com'), + 'wrong-password', + )); + $this->assertNull($repository->findByCredentials( + new EmailAddress('unknown@example.com'), + 'correct-password', + )); + } } From ebfe147ca4a20aeb787ab4e45e2da32198f158a6 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 31 Jul 2026 11:38:05 +0300 Subject: [PATCH 2/9] add password login --- README.md | 7 ++ backend/.env.example | 1 + .../app/Http/Controllers/AuthController.php | 104 +++++++++++++++++- backend/app/Http/Requests/LoginRequest.php | 40 +++++++ backend/app/User/CreateUserDto.php | 1 + backend/app/User/EloquentUserRepository.php | 20 ++++ backend/app/User/UserModel.php | 3 +- backend/app/User/UserRepository.php | 7 ++ .../0001_01_01_000000_create_users_table.php | 1 + backend/database/seeders/UserSeeder.php | 9 +- backend/routes/api.php | 3 + nix/shell-hook.sh | 2 + 12 files changed, 192 insertions(+), 6 deletions(-) create mode 100644 backend/app/Http/Requests/LoginRequest.php diff --git a/README.md b/README.md index 739c8cf..e800d97 100644 --- a/README.md +++ b/README.md @@ -87,3 +87,10 @@ 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/.env.example b/backend/.env.example index 027ae21..f94c680 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -33,6 +33,7 @@ SESSION_LIFETIME=120 SESSION_ENCRYPT=false SESSION_PATH=/ SESSION_DOMAIN=null +SESSION_SECURE_COOKIE=true BROADCAST_CONNECTION=log FILESYSTEM_DISK=local diff --git a/backend/app/Http/Controllers/AuthController.php b/backend/app/Http/Controllers/AuthController.php index de82e1a..60edf14 100644 --- a/backend/app/Http/Controllers/AuthController.php +++ b/backend/app/Http/Controllers/AuthController.php @@ -2,22 +2,118 @@ namespace App\Http\Controllers; +use App\Auth\Clock; +use App\Auth\CreateSessionDto; +use App\Auth\SessionRepository; +use App\Http\Middleware\AuthMiddleware; +use App\Http\Requests\LoginRequest; +use App\Shared\ValueObject\EmailAddress; use App\User\User; +use App\User\UserRepository; +use DateInterval; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Symfony\Component\HttpFoundation\Cookie; class AuthController extends Controller { + public function login( + LoginRequest $request, + UserRepository $userRepository, + SessionRepository $sessionRepository, + Clock $clock, + ): JsonResponse + { + /** @var array{email: string, password: string} $credentials */ + $credentials = $request->validated(); + $user = $userRepository->findByCredentials( + new EmailAddress($credentials['email']), + $credentials['password'], + ); + if ($user === null) { + return new JsonResponse( + ['error' => 'invalid_credentials'], + 401, + ); + } + + $sessionLifetime = (int) config('session.lifetime', 120); + $createdAt = $clock->now(); + $expiresAt = $createdAt->add( + new DateInterval("PT{$sessionLifetime}M"), + ); + $session = $sessionRepository->create(new CreateSessionDto( + token: bin2hex(random_bytes(32)), + user: $user, + createdAt: $createdAt, + expiresAt: $expiresAt, + )); + + $response = new JsonResponse([ + 'user' => $this->userPayload($user), + ]); + $response->headers->setCookie(new Cookie( + name: AuthMiddleware::COOKIE_NAME, + value: $session->getToken(), + expire: $session->getExpiresAt(), + path: $this->cookiePath(), + domain: $this->cookieDomain(), + secure: (bool) config('session.secure', false), + httpOnly: true, + sameSite: $this->cookieSameSite(), + )); + + return $response; + } + public function me(Request $request): JsonResponse { /** @var User $user */ $user = $request->attributes->get('user'); return new JsonResponse([ - 'user' => [ - 'id' => $user->getId(), - 'email' => $user->getEmail()->value(), - ], + 'user' => $this->userPayload($user), ]); } + + /** + * @return array{id: int, email: string} + */ + private function userPayload(User $user): array + { + return [ + 'id' => $user->getId(), + 'email' => $user->getEmail()->value(), + ]; + } + + private function cookiePath(): string + { + $path = config('session.path', '/'); + + return is_string($path) ? $path : '/'; + } + + private function cookieDomain(): ?string + { + $domain = config('session.domain'); + + return is_string($domain) ? $domain : null; + } + + /** + * @return ''|'lax'|'none'|'strict'|null + */ + private function cookieSameSite(): ?string + { + $sameSite = config('session.same_site', 'lax'); + + return match ($sameSite) { + '' => '', + Cookie::SAMESITE_LAX => Cookie::SAMESITE_LAX, + Cookie::SAMESITE_NONE => Cookie::SAMESITE_NONE, + Cookie::SAMESITE_STRICT => Cookie::SAMESITE_STRICT, + default => null, + }; + } } diff --git a/backend/app/Http/Requests/LoginRequest.php b/backend/app/Http/Requests/LoginRequest.php new file mode 100644 index 0000000..99fc8da --- /dev/null +++ b/backend/app/Http/Requests/LoginRequest.php @@ -0,0 +1,40 @@ +> + */ + public function rules(): array + { + return [ + 'email' => [ + 'required', + 'string', + 'email', + 'max:255', + ], + 'password' => [ + 'required', + 'string', + ], + ]; + } + + protected function prepareForValidation(): void + { + $email = $this->input('email'); + if (is_string($email)) { + $this->merge(['email' => trim($email)]); + } + } +} diff --git a/backend/app/User/CreateUserDto.php b/backend/app/User/CreateUserDto.php index 735320b..f035859 100644 --- a/backend/app/User/CreateUserDto.php +++ b/backend/app/User/CreateUserDto.php @@ -8,5 +8,6 @@ final readonly class CreateUserDto { public function __construct( public EmailAddress $email, + public string $password, ) {} } diff --git a/backend/app/User/EloquentUserRepository.php b/backend/app/User/EloquentUserRepository.php index 5996528..14a8530 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 Illuminate\Support\Facades\Hash; class EloquentUserRepository implements UserRepository { @@ -10,6 +11,7 @@ class EloquentUserRepository implements UserRepository { $model = UserModel::create([ 'email' => $dto->email->value(), + 'password' => Hash::make($dto->password), ]); return $this->toDomain($model); @@ -25,6 +27,24 @@ class EloquentUserRepository implements UserRepository return $this->toDomain($model); } + public function findByCredentials( + EmailAddress $email, + string $password, + ): ?User + { + $model = UserModel::query() + ->where('email', $email->value()) + ->first(); + if ( + $model === null + || ! Hash::check($password, $model->password) + ) { + return null; + } + + return $this->toDomain($model); + } + private function toDomain(UserModel $model): User { return new User( diff --git a/backend/app/User/UserModel.php b/backend/app/User/UserModel.php index 74f3211..83cd7e2 100644 --- a/backend/app/User/UserModel.php +++ b/backend/app/User/UserModel.php @@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Model; /** * @property int $id * @property string $email + * @property string $password * * @method static Builder|UserModel newModelQuery() * @method static Builder|UserModel newQuery() @@ -16,7 +17,7 @@ use Illuminate\Database\Eloquent\Model; * * @mixin \Eloquent */ -#[Fillable(['email'])] +#[Fillable(['email', 'password'])] class UserModel extends Model { protected $table = 'users'; diff --git a/backend/app/User/UserRepository.php b/backend/app/User/UserRepository.php index 5fcd6eb..ca5d783 100644 --- a/backend/app/User/UserRepository.php +++ b/backend/app/User/UserRepository.php @@ -2,9 +2,16 @@ namespace App\User; +use App\Shared\ValueObject\EmailAddress; + interface UserRepository { public function create(CreateUserDto $dto): User; public function find(int $id): ?User; + + public function findByCredentials( + EmailAddress $email, + string $password, + ): ?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 dafb6c2..f488882 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,6 +11,7 @@ return new class extends Migration Schema::create('users', function (Blueprint $table): void { $table->id(); $table->string('email')->unique(); + $table->string('password'); }); } diff --git a/backend/database/seeders/UserSeeder.php b/backend/database/seeders/UserSeeder.php index 886e4c9..bc21285 100644 --- a/backend/database/seeders/UserSeeder.php +++ b/backend/database/seeders/UserSeeder.php @@ -4,13 +4,20 @@ namespace Database\Seeders; use App\User\UserModel; use Illuminate\Database\Seeder; +use Illuminate\Support\Facades\Hash; class UserSeeder extends Seeder { + public const string EMAIL = 'user@example.com'; + + public const string PASSWORD = 'password'; + public function run(): void { UserModel::firstOrCreate([ - 'email' => 'user@example.com', + 'email' => self::EMAIL, + ], [ + 'password' => Hash::make(self::PASSWORD), ]); } } diff --git a/backend/routes/api.php b/backend/routes/api.php index d8fba04..e0bfb0e 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -4,6 +4,9 @@ use App\Http\Controllers\AuthController; use App\Http\Middleware\AuthMiddleware; use Illuminate\Support\Facades\Route; +Route::post('/login', [AuthController::class, 'login']) + ->middleware('throttle:5,1'); + Route::middleware(AuthMiddleware::class)->group(function (): void { Route::get('/me', [AuthController::class, 'me']); }); diff --git a/nix/shell-hook.sh b/nix/shell-hook.sh index 73d1874..cbaedba 100644 --- a/nix/shell-hook.sh +++ b/nix/shell-hook.sh @@ -45,6 +45,7 @@ DEV_DB_PORT="5432" DEV_DB_DATABASE="$PGDATABASE" DEV_DB_USERNAME="$PGUSER" DEV_DB_PASSWORD="" +DEV_SESSION_SECURE_COOKIE="true" DEV_MAIL_MAILER="smtp" DEV_MAIL_HOST="127.0.0.1" DEV_MAIL_PORT="$MAILPIT_SMTP_PORT" @@ -107,6 +108,7 @@ set_env_value DB_PORT "$DEV_DB_PORT" set_env_value DB_DATABASE "$DEV_DB_DATABASE" set_env_value DB_USERNAME "$DEV_DB_USERNAME" set_env_value DB_PASSWORD "$DEV_DB_PASSWORD" +set_env_value SESSION_SECURE_COOKIE "$DEV_SESSION_SECURE_COOKIE" set_env_value MAIL_MAILER "$DEV_MAIL_MAILER" set_env_value MAIL_HOST "$DEV_MAIL_HOST" set_env_value MAIL_PORT "$DEV_MAIL_PORT" From 1dff3f6976e5d7871234079eddfa40a7a5d039e6 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 31 Jul 2026 11:41:07 +0300 Subject: [PATCH 3/9] test frontend password login --- frontend/website/cypress/e2e/login.cy.ts | 170 +++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 frontend/website/cypress/e2e/login.cy.ts diff --git a/frontend/website/cypress/e2e/login.cy.ts b/frontend/website/cypress/e2e/login.cy.ts new file mode 100644 index 0000000..516b742 --- /dev/null +++ b/frontend/website/cypress/e2e/login.cy.ts @@ -0,0 +1,170 @@ +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 a generic 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', 'Email or password is incorrect.') + .and('be.visible') + cy.location('pathname').should('equal', '/login') + }) + + it('shows backend field validation errors', () => { + cy.intercept('POST', '**/api/login', { + statusCode: 422, + body: { + message: 'The email field must be a valid email address.', + errors: { + email: ['The email field must be a valid email address.'], + }, + }, + }).as('login') + + cy.visit('/login') + fillLoginForm() + cy.get('form').submit() + cy.wait('@login') + + cy.get('#login-email-error') + .should( + 'have.text', + 'The email field must be a valid email address.', + ) + .and('be.visible') + }) + + it('shows throttling and malformed-response errors', () => { + cy.intercept('POST', '**/api/login', { + statusCode: 429, + body: { message: 'Too Many Attempts.' }, + }).as('throttledLogin') + + cy.visit('/login') + fillLoginForm() + cy.get('form').submit() + cy.wait('@throttledLogin') + cy.get('[role="alert"]').should( + 'have.text', + 'Too many login attempts. Try again in a minute.', + ) + + cy.intercept('POST', '**/api/login', { + statusCode: 200, + body: { user: { id: 7 } }, + }).as('malformedLogin') + + 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') + }) +}) From 3da9c586c38eaa740653f7bfff44d9be920e25af Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 31 Jul 2026 11:44:19 +0300 Subject: [PATCH 4/9] wire frontend password login --- frontend/website/src/components/AuthForm.vue | 38 ++++++- .../website/src/components/AuthTextField.vue | 38 +++++++ frontend/website/src/stores/auth.ts | 99 +++++++++++++++++++ frontend/website/src/views/LoginView.vue | 67 ++++++++++++- 4 files changed, 239 insertions(+), 3 deletions(-) diff --git a/frontend/website/src/components/AuthForm.vue b/frontend/website/src/components/AuthForm.vue index 709091f..1296d51 100644 --- a/frontend/website/src/components/AuthForm.vue +++ b/frontend/website/src/components/AuthForm.vue @@ -1,16 +1,34 @@ @@ -60,6 +78,22 @@ 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 e9ec98c..3e5b087 100644 --- a/frontend/website/src/components/AuthTextField.vue +++ b/frontend/website/src/components/AuthTextField.vue @@ -5,7 +5,20 @@ 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) + } +} @@ -62,6 +83,23 @@ 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 399a301..6460027 100644 --- a/frontend/website/src/stores/auth.ts +++ b/frontend/website/src/stores/auth.ts @@ -13,7 +13,27 @@ const meResponseSchema = z.object({ user: authUserSchema, }) +const invalidCredentialsResponseSchema = z.object({ + error: z.literal('invalid_credentials'), +}) + +const loginValidationResponseSchema = z.object({ + errors: z.object({ + email: z.array(z.string()).optional(), + password: z.array(z.string()).optional(), + }), +}) + export type AuthUser = z.infer +export type LoginFieldErrors = Partial> +export type LoginResult = + | { + success: true + } + | { + success: false + fieldErrors: LoginFieldErrors + } export const useAuthStore = defineStore('auth', () => { const user = ref(null) @@ -57,11 +77,90 @@ 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 { success: true } + } + + user.value = null + if ( + response.status === 401 && + invalidCredentialsResponseSchema.safeParse(responseBody).success + ) { + error.value = 'Email or password is incorrect.' + + return { success: false, fieldErrors: {} } + } + + if (response.status === 422) { + const validationResponse = loginValidationResponseSchema.safeParse(responseBody) + if (validationResponse.success) { + return { + success: false, + fieldErrors: firstLoginFieldErrors(validationResponse.data.errors), + } + } + } + + if (response.status === 429) { + error.value = 'Too many login attempts. Try again in a minute.' + + return { success: false, fieldErrors: {} } + } + + error.value = 'Unable to log in. Please try again.' + + return { success: false, fieldErrors: {} } + } catch { + user.value = null + error.value = 'Unable to log in. Please try again.' + + return { success: false, fieldErrors: {} } + } finally { + loading.value = false + } + } + return { user, loading, error, isAuthenticated, fetchMe, + login, } }) + +function firstLoginFieldErrors( + errors: Partial>, +): LoginFieldErrors { + const fieldErrors: LoginFieldErrors = {} + const emailError = errors.email?.[0] + const passwordError = errors.password?.[0] + + if (emailError !== undefined) { + fieldErrors.email = emailError + } + if (passwordError !== undefined) { + fieldErrors.password = passwordError + } + + return fieldErrors +} diff --git a/frontend/website/src/views/LoginView.vue b/frontend/website/src/views/LoginView.vue index e4215a4..2045c38 100644 --- a/frontend/website/src/views/LoginView.vue +++ b/frontend/website/src/views/LoginView.vue @@ -1,7 +1,60 @@