Compare commits
10 commits
879c294582
...
2e140cd8d8
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e140cd8d8 | |||
| 9510842dc4 | |||
| 942ee4e6e7 | |||
| d52a8bf3b5 | |||
| 299efe0839 | |||
| b3266b38c8 | |||
| 3da9c586c3 | |||
| 1dff3f6976 | |||
| ebfe147ca4 | |||
| 93f5f022e4 |
43 changed files with 1159 additions and 30 deletions
|
|
@ -87,3 +87,10 @@ Future versions of Attainly may include:
|
||||||
Attainly is built around a simple idea:
|
Attainly is built around a simple idea:
|
||||||
|
|
||||||
Large goals become attainable when they are broken into clear, scheduled steps and completed consistently over time.
|
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`
|
||||||
|
|
|
||||||
16
backend/app/Auth/BcryptPasswordHasher.php
Normal file
16
backend/app/Auth/BcryptPasswordHasher.php
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Auth;
|
||||||
|
|
||||||
|
class BcryptPasswordHasher implements PasswordHasher
|
||||||
|
{
|
||||||
|
public function hash(string $password): string
|
||||||
|
{
|
||||||
|
return password_hash($password, PASSWORD_DEFAULT);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verify(string $password, string $hash): bool
|
||||||
|
{
|
||||||
|
return password_verify($password, $hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
backend/app/Auth/PasswordHasher.php
Normal file
10
backend/app/Auth/PasswordHasher.php
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Auth;
|
||||||
|
|
||||||
|
interface PasswordHasher
|
||||||
|
{
|
||||||
|
public function hash(string $password): string;
|
||||||
|
|
||||||
|
public function verify(string $password, string $hash): bool;
|
||||||
|
}
|
||||||
11
backend/app/Auth/RandomTokenGenerator.php
Normal file
11
backend/app/Auth/RandomTokenGenerator.php
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Auth;
|
||||||
|
|
||||||
|
class RandomTokenGenerator implements TokenGenerator
|
||||||
|
{
|
||||||
|
public function generate(): string
|
||||||
|
{
|
||||||
|
return bin2hex(random_bytes(32));
|
||||||
|
}
|
||||||
|
}
|
||||||
8
backend/app/Auth/TokenGenerator.php
Normal file
8
backend/app/Auth/TokenGenerator.php
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Auth;
|
||||||
|
|
||||||
|
interface TokenGenerator
|
||||||
|
{
|
||||||
|
public function generate(): string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Auth\UseCases\AuthenticateUser;
|
||||||
|
|
||||||
|
use App\Auth\PasswordHasher;
|
||||||
|
use App\Exceptions\BadRequestException;
|
||||||
|
use App\Exceptions\UnauthorizedException;
|
||||||
|
use App\Shared\ValueObject\EmailAddress;
|
||||||
|
use App\User\User;
|
||||||
|
use App\User\UserRepository;
|
||||||
|
|
||||||
|
class AuthenticateUser
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private UserRepository $userRepo,
|
||||||
|
private PasswordHasher $hasher,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws BadRequestException
|
||||||
|
* @throws UnauthorizedException
|
||||||
|
*/
|
||||||
|
public function execute(AuthenticateUserRequest $request): User
|
||||||
|
{
|
||||||
|
if ($request->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Auth\UseCases\AuthenticateUser;
|
||||||
|
|
||||||
|
class AuthenticateUserRequest
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public ?string $email,
|
||||||
|
public ?string $password,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
34
backend/app/Auth/UseCases/CreateSession/CreateSession.php
Normal file
34
backend/app/Auth/UseCases/CreateSession/CreateSession.php
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Auth\UseCases\CreateSession;
|
||||||
|
|
||||||
|
use App\Auth\Clock;
|
||||||
|
use App\Auth\CreateSessionDto;
|
||||||
|
use App\Auth\Session;
|
||||||
|
use App\Auth\SessionRepository;
|
||||||
|
use App\Auth\TokenGenerator;
|
||||||
|
use App\User\User;
|
||||||
|
|
||||||
|
class CreateSession
|
||||||
|
{
|
||||||
|
private const SESSION_LIFETIME = '+7 days';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private SessionRepository $sessionRepo,
|
||||||
|
private TokenGenerator $tokenGenerator,
|
||||||
|
private Clock $clock,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function execute(User $user): Session
|
||||||
|
{
|
||||||
|
$now = $this->clock->now();
|
||||||
|
$expiresAt = $now->modify(self::SESSION_LIFETIME);
|
||||||
|
|
||||||
|
return $this->sessionRepo->create(new CreateSessionDto(
|
||||||
|
token: $this->tokenGenerator->generate(),
|
||||||
|
user: $user,
|
||||||
|
createdAt: $now,
|
||||||
|
expiresAt: $expiresAt,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
7
backend/app/Exceptions/BadRequestException.php
Normal file
7
backend/app/Exceptions/BadRequestException.php
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exceptions;
|
||||||
|
|
||||||
|
use DomainException;
|
||||||
|
|
||||||
|
class BadRequestException extends DomainException {}
|
||||||
7
backend/app/Exceptions/UnauthorizedException.php
Normal file
7
backend/app/Exceptions/UnauthorizedException.php
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exceptions;
|
||||||
|
|
||||||
|
use DomainException;
|
||||||
|
|
||||||
|
class UnauthorizedException extends DomainException {}
|
||||||
|
|
@ -2,22 +2,83 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Auth\UseCases\AuthenticateUser\AuthenticateUser;
|
||||||
|
use App\Auth\UseCases\AuthenticateUser\AuthenticateUserRequest;
|
||||||
|
use App\Auth\UseCases\CreateSession\CreateSession;
|
||||||
|
use App\Exceptions\BadRequestException;
|
||||||
|
use App\Exceptions\UnauthorizedException;
|
||||||
|
use App\Http\Middleware\AuthMiddleware;
|
||||||
|
use App\Shared\Http\RequestInput;
|
||||||
use App\User\User;
|
use App\User\User;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Cookie;
|
||||||
|
|
||||||
class AuthController extends Controller
|
class AuthController extends Controller
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private AuthenticateUser $authenticateUser,
|
||||||
|
private CreateSession $createSession,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function login(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$input = new RequestInput($request);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$user = $this->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
|
public function me(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
/** @var User $user */
|
/** @var User $user */
|
||||||
$user = $request->attributes->get('user');
|
$user = $request->attributes->get('user');
|
||||||
|
|
||||||
return new JsonResponse([
|
return new JsonResponse([
|
||||||
'user' => [
|
'user' => $this->userPayload($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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,14 @@
|
||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Auth\BcryptPasswordHasher;
|
||||||
use App\Auth\Clock;
|
use App\Auth\Clock;
|
||||||
use App\Auth\EloquentSessionRepository;
|
use App\Auth\EloquentSessionRepository;
|
||||||
|
use App\Auth\PasswordHasher;
|
||||||
|
use App\Auth\RandomTokenGenerator;
|
||||||
use App\Auth\SessionRepository;
|
use App\Auth\SessionRepository;
|
||||||
use App\Auth\SystemClock;
|
use App\Auth\SystemClock;
|
||||||
|
use App\Auth\TokenGenerator;
|
||||||
use App\User\EloquentUserRepository;
|
use App\User\EloquentUserRepository;
|
||||||
use App\User\UserRepository;
|
use App\User\UserRepository;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
|
|
@ -29,6 +33,8 @@ class AppServiceProvider extends ServiceProvider
|
||||||
SessionRepository::class,
|
SessionRepository::class,
|
||||||
EloquentSessionRepository::class,
|
EloquentSessionRepository::class,
|
||||||
);
|
);
|
||||||
|
$this->app->bind(PasswordHasher::class, BcryptPasswordHasher::class);
|
||||||
|
$this->app->bind(TokenGenerator::class, RandomTokenGenerator::class);
|
||||||
$this->app->bind(Clock::class, SystemClock::class);
|
$this->app->bind(Clock::class, SystemClock::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
23
backend/app/Shared/Http/RequestInput.php
Normal file
23
backend/app/Shared/Http/RequestInput.php
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Shared\Http;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class RequestInput
|
||||||
|
{
|
||||||
|
public function __construct(private Request $request) {}
|
||||||
|
|
||||||
|
public function string(string $key): ?string
|
||||||
|
{
|
||||||
|
$value = $this->request->input($key);
|
||||||
|
if (is_string($value)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
if (is_int($value) || is_float($value) || is_bool($value)) {
|
||||||
|
return (string) $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,5 +8,6 @@ final readonly class CreateUserDto
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public EmailAddress $email,
|
public EmailAddress $email,
|
||||||
|
public string $passwordHash,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ class EloquentUserRepository implements UserRepository
|
||||||
{
|
{
|
||||||
$model = UserModel::create([
|
$model = UserModel::create([
|
||||||
'email' => $dto->email->value(),
|
'email' => $dto->email->value(),
|
||||||
|
'passwordHash' => $dto->passwordHash,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $this->toDomain($model);
|
return $this->toDomain($model);
|
||||||
|
|
@ -25,11 +26,24 @@ class EloquentUserRepository implements UserRepository
|
||||||
return $this->toDomain($model);
|
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
|
private function toDomain(UserModel $model): User
|
||||||
{
|
{
|
||||||
return new User(
|
return new User(
|
||||||
id: $model->id,
|
id: $model->id,
|
||||||
email: new EmailAddress($model->email),
|
email: new EmailAddress($model->email),
|
||||||
|
passwordHash: $model->passwordHash,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ final readonly class User
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private int $id,
|
private int $id,
|
||||||
private EmailAddress $email,
|
private EmailAddress $email,
|
||||||
|
private string $passwordHash,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function getId(): int
|
public function getId(): int
|
||||||
|
|
@ -20,4 +21,9 @@ final readonly class User
|
||||||
{
|
{
|
||||||
return $this->email;
|
return $this->email;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getPasswordHash(): string
|
||||||
|
{
|
||||||
|
return $this->passwordHash;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||||
/**
|
/**
|
||||||
* @property int $id
|
* @property int $id
|
||||||
* @property string $email
|
* @property string $email
|
||||||
|
* @property string $passwordHash
|
||||||
*
|
*
|
||||||
* @method static Builder<static>|UserModel newModelQuery()
|
* @method static Builder<static>|UserModel newModelQuery()
|
||||||
* @method static Builder<static>|UserModel newQuery()
|
* @method static Builder<static>|UserModel newQuery()
|
||||||
|
|
@ -16,7 +17,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||||
*
|
*
|
||||||
* @mixin \Eloquent
|
* @mixin \Eloquent
|
||||||
*/
|
*/
|
||||||
#[Fillable(['email'])]
|
#[Fillable(['email', 'passwordHash'])]
|
||||||
class UserModel extends Model
|
class UserModel extends Model
|
||||||
{
|
{
|
||||||
protected $table = 'users';
|
protected $table = 'users';
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,13 @@
|
||||||
|
|
||||||
namespace App\User;
|
namespace App\User;
|
||||||
|
|
||||||
|
use App\Shared\ValueObject\EmailAddress;
|
||||||
|
|
||||||
interface UserRepository
|
interface UserRepository
|
||||||
{
|
{
|
||||||
public function create(CreateUserDto $dto): User;
|
public function create(CreateUserDto $dto): User;
|
||||||
|
|
||||||
public function find(int $id): ?User;
|
public function find(int $id): ?User;
|
||||||
|
|
||||||
|
public function findByEmail(EmailAddress $email): ?User;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ return new class extends Migration
|
||||||
Schema::create('users', function (Blueprint $table): void {
|
Schema::create('users', function (Blueprint $table): void {
|
||||||
$table->id();
|
$table->id();
|
||||||
$table->string('email')->unique();
|
$table->string('email')->unique();
|
||||||
|
$table->string('passwordHash');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,29 @@
|
||||||
|
|
||||||
namespace Database\Seeders;
|
namespace Database\Seeders;
|
||||||
|
|
||||||
use App\User\UserModel;
|
use App\Auth\PasswordHasher;
|
||||||
|
use App\Shared\ValueObject\EmailAddress;
|
||||||
|
use App\User\CreateUserDto;
|
||||||
|
use App\User\UserRepository;
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
class UserSeeder extends Seeder
|
class UserSeeder extends Seeder
|
||||||
{
|
{
|
||||||
|
public const string EMAIL = 'user@example.com';
|
||||||
|
|
||||||
|
public const string PASSWORD = 'password';
|
||||||
|
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
UserModel::firstOrCreate([
|
$userRepository = app(UserRepository::class);
|
||||||
'email' => 'user@example.com',
|
$email = new EmailAddress(self::EMAIL);
|
||||||
]);
|
if ($userRepository->findByEmail($email) !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$userRepository->create(new CreateUserDto(
|
||||||
|
email: $email,
|
||||||
|
passwordHash: app(PasswordHasher::class)->hash(self::PASSWORD),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ use App\Http\Controllers\AuthController;
|
||||||
use App\Http\Middleware\AuthMiddleware;
|
use App\Http\Middleware\AuthMiddleware;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::post('/login', [AuthController::class, 'login']);
|
||||||
|
|
||||||
Route::middleware(AuthMiddleware::class)->group(function (): void {
|
Route::middleware(AuthMiddleware::class)->group(function (): void {
|
||||||
Route::get('/me', [AuthController::class, 'me']);
|
Route::get('/me', [AuthController::class, 'me']);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
18
backend/tests/Fakes/FakePasswordHasher.php
Normal file
18
backend/tests/Fakes/FakePasswordHasher.php
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Fakes;
|
||||||
|
|
||||||
|
use App\Auth\PasswordHasher;
|
||||||
|
|
||||||
|
class FakePasswordHasher implements PasswordHasher
|
||||||
|
{
|
||||||
|
public function hash(string $password): string
|
||||||
|
{
|
||||||
|
return 'hashed:'.$password;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verify(string $password, string $hash): bool
|
||||||
|
{
|
||||||
|
return $this->hash($password) === $hash;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
backend/tests/Fakes/FakeTokenGenerator.php
Normal file
28
backend/tests/Fakes/FakeTokenGenerator.php
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Fakes;
|
||||||
|
|
||||||
|
use App\Auth\TokenGenerator;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class FakeTokenGenerator implements TokenGenerator
|
||||||
|
{
|
||||||
|
private int $callCount = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string[] $tokens
|
||||||
|
*/
|
||||||
|
public function __construct(private array $tokens) {}
|
||||||
|
|
||||||
|
public function generate(): string
|
||||||
|
{
|
||||||
|
if ($this->callCount >= count($this->tokens)) {
|
||||||
|
throw new RuntimeException('FakeTokenGenerator exhausted');
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = $this->tokens[$this->callCount];
|
||||||
|
$this->callCount++;
|
||||||
|
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
}
|
||||||
56
backend/tests/Fakes/FakeUserRepository.php
Normal file
56
backend/tests/Fakes/FakeUserRepository.php
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Fakes;
|
||||||
|
|
||||||
|
use App\Shared\ValueObject\EmailAddress;
|
||||||
|
use App\User\CreateUserDto;
|
||||||
|
use App\User\User;
|
||||||
|
use App\User\UserRepository;
|
||||||
|
|
||||||
|
class FakeUserRepository implements UserRepository
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var array<int, User>
|
||||||
|
*/
|
||||||
|
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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -101,6 +101,7 @@ class AuthMiddlewareTest extends TestCase
|
||||||
): User {
|
): User {
|
||||||
$user = app(UserRepository::class)->create(new CreateUserDto(
|
$user = app(UserRepository::class)->create(new CreateUserDto(
|
||||||
email: new EmailAddress('user@example.com'),
|
email: new EmailAddress('user@example.com'),
|
||||||
|
passwordHash: 'hashed-password',
|
||||||
));
|
));
|
||||||
app(SessionRepository::class)->create(new CreateSessionDto(
|
app(SessionRepository::class)->create(new CreateSessionDto(
|
||||||
token: $token,
|
token: $token,
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ class EloquentSessionRepositoryTest extends TestCase
|
||||||
{
|
{
|
||||||
$user = app(UserRepository::class)->create(new CreateUserDto(
|
$user = app(UserRepository::class)->create(new CreateUserDto(
|
||||||
email: new EmailAddress('user@example.com'),
|
email: new EmailAddress('user@example.com'),
|
||||||
|
passwordHash: 'hashed-password',
|
||||||
));
|
));
|
||||||
$createdAt = $this->utc('2026-07-31T12:00:00');
|
$createdAt = $this->utc('2026-07-31T12:00:00');
|
||||||
$expiresAt = $this->utc('2026-08-07T12: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(
|
$user = app(UserRepository::class)->create(new CreateUserDto(
|
||||||
email: new EmailAddress('user@example.com'),
|
email: new EmailAddress('user@example.com'),
|
||||||
|
passwordHash: 'hashed-password',
|
||||||
));
|
));
|
||||||
$repository = app(SessionRepository::class);
|
$repository = app(SessionRepository::class);
|
||||||
$repository->create(new CreateSessionDto(
|
$repository->create(new CreateSessionDto(
|
||||||
|
|
|
||||||
47
backend/tests/Feature/Auth/LoginEndpointTest.php
Normal file
47
backend/tests/Feature/Auth/LoginEndpointTest.php
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Auth;
|
||||||
|
|
||||||
|
use App\Auth\PasswordHasher;
|
||||||
|
use App\Auth\SessionRepository;
|
||||||
|
use App\Http\Middleware\AuthMiddleware;
|
||||||
|
use App\Shared\ValueObject\EmailAddress;
|
||||||
|
use App\User\CreateUserDto;
|
||||||
|
use App\User\UserRepository;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class LoginEndpointTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_login_returns_user_and_sets_session_cookie(): void
|
||||||
|
{
|
||||||
|
$email = 'user@example.com';
|
||||||
|
$password = 'correct-password';
|
||||||
|
$passwordHash = app(PasswordHasher::class)->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(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,7 @@ class MeEndpointTest extends TestCase
|
||||||
);
|
);
|
||||||
$user = app(UserRepository::class)->create(new CreateUserDto(
|
$user = app(UserRepository::class)->create(new CreateUserDto(
|
||||||
email: new EmailAddress('user@example.com'),
|
email: new EmailAddress('user@example.com'),
|
||||||
|
passwordHash: 'hashed-password',
|
||||||
));
|
));
|
||||||
app(SessionRepository::class)->create(new CreateSessionDto(
|
app(SessionRepository::class)->create(new CreateSessionDto(
|
||||||
token: 'valid-token',
|
token: 'valid-token',
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@
|
||||||
|
|
||||||
namespace Tests\Feature\Database;
|
namespace Tests\Feature\Database;
|
||||||
|
|
||||||
|
use App\Auth\PasswordHasher;
|
||||||
|
use App\Shared\ValueObject\EmailAddress;
|
||||||
|
use App\User\UserRepository;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
|
@ -17,6 +20,20 @@ class DatabaseSeederTest extends TestCase
|
||||||
$this->assertDatabaseHas('users', [
|
$this->assertDatabaseHas('users', [
|
||||||
'email' => 'user@example.com',
|
'email' => 'user@example.com',
|
||||||
]);
|
]);
|
||||||
|
$this->assertDatabaseMissing('users', [
|
||||||
|
'email' => 'user@example.com',
|
||||||
|
'passwordHash' => 'password',
|
||||||
|
]);
|
||||||
$this->assertDatabaseCount('users', 1);
|
$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(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ class EloquentUserRepositoryTest extends TestCase
|
||||||
$repository = app(UserRepository::class);
|
$repository = app(UserRepository::class);
|
||||||
$user = $repository->create(new CreateUserDto(
|
$user = $repository->create(new CreateUserDto(
|
||||||
email: new EmailAddress('Founder@EXAMPLE.COM'),
|
email: new EmailAddress('Founder@EXAMPLE.COM'),
|
||||||
|
passwordHash: 'hashed-password',
|
||||||
));
|
));
|
||||||
|
|
||||||
$this->assertGreaterThan(0, $user->getId());
|
$this->assertGreaterThan(0, $user->getId());
|
||||||
|
|
@ -28,6 +29,10 @@ class EloquentUserRepositoryTest extends TestCase
|
||||||
'id' => $user->getId(),
|
'id' => $user->getId(),
|
||||||
'email' => 'Founder@example.com',
|
'email' => 'Founder@example.com',
|
||||||
]);
|
]);
|
||||||
|
$this->assertDatabaseHas('users', [
|
||||||
|
'id' => $user->getId(),
|
||||||
|
'passwordHash' => 'hashed-password',
|
||||||
|
]);
|
||||||
|
|
||||||
$foundUser = $repository->find($user->getId());
|
$foundUser = $repository->find($user->getId());
|
||||||
|
|
||||||
|
|
@ -37,6 +42,10 @@ class EloquentUserRepositoryTest extends TestCase
|
||||||
$user->getEmail()->value(),
|
$user->getEmail()->value(),
|
||||||
$foundUser->getEmail()->value(),
|
$foundUser->getEmail()->value(),
|
||||||
);
|
);
|
||||||
|
$this->assertSame(
|
||||||
|
$user->getPasswordHash(),
|
||||||
|
$foundUser->getPasswordHash(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_returns_null_for_an_unknown_user(): void
|
public function test_it_returns_null_for_an_unknown_user(): void
|
||||||
|
|
@ -45,4 +54,28 @@ class EloquentUserRepositoryTest extends TestCase
|
||||||
|
|
||||||
$this->assertNull($repository->find(999));
|
$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'),
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,7 @@ class AuthMiddlewareTest extends TestCase
|
||||||
return new User(
|
return new User(
|
||||||
id: 7,
|
id: 7,
|
||||||
email: new EmailAddress('user@example.com'),
|
email: new EmailAddress('user@example.com'),
|
||||||
|
passwordHash: 'hashed-password',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ class SessionTest extends TestCase
|
||||||
$user = new User(
|
$user = new User(
|
||||||
id: 7,
|
id: 7,
|
||||||
email: new EmailAddress('user@example.com'),
|
email: new EmailAddress('user@example.com'),
|
||||||
|
passwordHash: 'hashed-password',
|
||||||
);
|
);
|
||||||
$createdAt = $this->utc('2026-07-31T12:00:00');
|
$createdAt = $this->utc('2026-07-31T12:00:00');
|
||||||
$expiresAt = $this->utc('2026-08-07T12:00:00');
|
$expiresAt = $this->utc('2026-08-07T12:00:00');
|
||||||
|
|
@ -40,6 +41,7 @@ class SessionTest extends TestCase
|
||||||
user: new User(
|
user: new User(
|
||||||
id: 7,
|
id: 7,
|
||||||
email: new EmailAddress('user@example.com'),
|
email: new EmailAddress('user@example.com'),
|
||||||
|
passwordHash: 'hashed-password',
|
||||||
),
|
),
|
||||||
createdAt: $this->utc('2026-07-31T12:00:00'),
|
createdAt: $this->utc('2026-07-31T12:00:00'),
|
||||||
expiresAt: $expiresAt,
|
expiresAt: $expiresAt,
|
||||||
|
|
|
||||||
104
backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php
Normal file
104
backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Auth\UseCases;
|
||||||
|
|
||||||
|
use App\Auth\UseCases\AuthenticateUser\AuthenticateUser;
|
||||||
|
use App\Auth\UseCases\AuthenticateUser\AuthenticateUserRequest;
|
||||||
|
use App\Exceptions\BadRequestException;
|
||||||
|
use App\Exceptions\UnauthorizedException;
|
||||||
|
use App\Shared\ValueObject\EmailAddress;
|
||||||
|
use App\User\CreateUserDto;
|
||||||
|
use App\User\User;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Tests\Fakes\FakePasswordHasher;
|
||||||
|
use Tests\Fakes\FakeUserRepository;
|
||||||
|
|
||||||
|
class AuthenticateUserTest extends TestCase
|
||||||
|
{
|
||||||
|
private FakeUserRepository $userRepository;
|
||||||
|
|
||||||
|
private FakePasswordHasher $passwordHasher;
|
||||||
|
|
||||||
|
private AuthenticateUser $useCase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->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),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
70
backend/tests/Unit/Auth/UseCases/CreateSessionTest.php
Normal file
70
backend/tests/Unit/Auth/UseCases/CreateSessionTest.php
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Auth\UseCases;
|
||||||
|
|
||||||
|
use App\Auth\UseCases\CreateSession\CreateSession;
|
||||||
|
use App\Shared\ValueObject\EmailAddress;
|
||||||
|
use App\User\User;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use DateTimeZone;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Tests\Fakes\FakeClock;
|
||||||
|
use Tests\Fakes\FakeSessionRepository;
|
||||||
|
use Tests\Fakes\FakeTokenGenerator;
|
||||||
|
|
||||||
|
class CreateSessionTest extends TestCase
|
||||||
|
{
|
||||||
|
private DateTimeImmutable $now;
|
||||||
|
|
||||||
|
private FakeSessionRepository $sessionRepository;
|
||||||
|
|
||||||
|
private CreateSession $useCase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
127
backend/tests/Unit/Http/Controllers/AuthControllerTest.php
Normal file
127
backend/tests/Unit/Http/Controllers/AuthControllerTest.php
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Auth\UseCases\AuthenticateUser\AuthenticateUser;
|
||||||
|
use App\Auth\UseCases\CreateSession\CreateSession;
|
||||||
|
use App\Http\Controllers\AuthController;
|
||||||
|
use App\Http\Middleware\AuthMiddleware;
|
||||||
|
use App\Shared\ValueObject\EmailAddress;
|
||||||
|
use App\User\CreateUserDto;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use DateTimeZone;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Tests\Fakes\FakeClock;
|
||||||
|
use Tests\Fakes\FakePasswordHasher;
|
||||||
|
use Tests\Fakes\FakeSessionRepository;
|
||||||
|
use Tests\Fakes\FakeTokenGenerator;
|
||||||
|
use Tests\Fakes\FakeUserRepository;
|
||||||
|
|
||||||
|
class AuthControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
private FakeUserRepository $userRepository;
|
||||||
|
|
||||||
|
private FakePasswordHasher $passwordHasher;
|
||||||
|
|
||||||
|
private FakeSessionRepository $sessionRepository;
|
||||||
|
|
||||||
|
private AuthController $controller;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->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),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,12 +8,17 @@ use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
class UserTest extends TestCase
|
class UserTest extends TestCase
|
||||||
{
|
{
|
||||||
public function test_it_exposes_its_identity_and_email(): void
|
public function test_it_exposes_its_identity_email_and_password_hash(): void
|
||||||
{
|
{
|
||||||
$email = new EmailAddress('user@example.com');
|
$email = new EmailAddress('user@example.com');
|
||||||
$user = new User(id: 42, email: $email);
|
$user = new User(
|
||||||
|
id: 42,
|
||||||
|
email: $email,
|
||||||
|
passwordHash: 'hashed-password',
|
||||||
|
);
|
||||||
|
|
||||||
$this->assertSame(42, $user->getId());
|
$this->assertSame(42, $user->getId());
|
||||||
$this->assertSame($email, $user->getEmail());
|
$this->assertSame($email, $user->getEmail());
|
||||||
|
$this->assertSame('hashed-password', $user->getPasswordHash());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
151
frontend/website/cypress/e2e/login.cy.ts
Normal file
151
frontend/website/cypress/e2e/login.cy.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1,16 +1,34 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps<{
|
defineProps<{
|
||||||
submitLabel: string
|
submitLabel: string
|
||||||
|
submittingLabel?: string
|
||||||
|
submitting?: boolean
|
||||||
|
error?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
submit: []
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<form class="auth-form" @submit.prevent>
|
<form
|
||||||
|
class="auth-form"
|
||||||
|
:aria-busy="submitting === true ? 'true' : undefined"
|
||||||
|
novalidate
|
||||||
|
@submit.prevent="$emit('submit')"
|
||||||
|
>
|
||||||
<div class="auth-form__fields">
|
<div class="auth-form__fields">
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit">{{ submitLabel }}</button>
|
<p v-if="error !== undefined" class="auth-form__error" role="alert">
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button type="submit" :disabled="submitting">
|
||||||
|
{{ submitting && submittingLabel ? submittingLabel : submitLabel }}
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -60,6 +78,22 @@ button:focus-visible {
|
||||||
outline-offset: 0.2rem;
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
button {
|
button {
|
||||||
transition: none;
|
transition: none;
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,20 @@ defineProps<{
|
||||||
type: 'email' | 'password' | 'text'
|
type: 'email' | 'password' | 'text'
|
||||||
autocomplete: string
|
autocomplete: string
|
||||||
placeholder: 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -17,7 +30,15 @@ defineProps<{
|
||||||
:name="id"
|
:name="id"
|
||||||
:autocomplete="autocomplete"
|
:autocomplete="autocomplete"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
|
:value="modelValue"
|
||||||
|
:disabled="disabled"
|
||||||
|
:aria-invalid="error === undefined ? undefined : 'true'"
|
||||||
|
:aria-describedby="error === undefined ? undefined : `${id}-error`"
|
||||||
|
@input="updateValue"
|
||||||
/>
|
/>
|
||||||
|
<p v-if="error !== undefined" :id="`${id}-error`" class="text-field__error">
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -62,6 +83,23 @@ input:focus {
|
||||||
box-shadow: 0 0 0 3px rgb(77 125 109 / 16%);
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
input {
|
input {
|
||||||
transition: none;
|
transition: none;
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,12 @@ const meResponseSchema = z.object({
|
||||||
user: authUserSchema,
|
user: authUserSchema,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const loginErrorResponseSchema = z.object({
|
||||||
|
error: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
export type AuthUser = z.infer<typeof authUserSchema>
|
export type AuthUser = z.infer<typeof authUserSchema>
|
||||||
|
export type LoginFieldErrors = Partial<Record<'email' | 'password', string>>
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const user = ref<AuthUser | null>(null)
|
const user = ref<AuthUser | null>(null)
|
||||||
|
|
@ -57,11 +62,51 @@ export const useAuthStore = defineStore('auth', () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function login(email: string, password: string): Promise<boolean> {
|
||||||
|
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 {
|
return {
|
||||||
user,
|
user,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
fetchMe,
|
fetchMe,
|
||||||
|
login,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,58 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
import AuthForm from '@/components/AuthForm.vue'
|
import AuthForm from '@/components/AuthForm.vue'
|
||||||
import AuthLayout from '@/components/AuthLayout.vue'
|
import AuthLayout from '@/components/AuthLayout.vue'
|
||||||
import AuthTextField from '@/components/AuthTextField.vue'
|
import AuthTextField from '@/components/AuthTextField.vue'
|
||||||
|
import { type LoginFieldErrors, useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const emailSchema = z.string().email()
|
||||||
|
const email = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const fieldErrors = ref<LoginFieldErrors>({})
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const { loading, error } = storeToRefs(authStore)
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
async function submitLogin(): Promise<void> {
|
||||||
|
const normalizedEmail = email.value.trim()
|
||||||
|
fieldErrors.value = validateLogin(normalizedEmail, password.value)
|
||||||
|
if (Object.keys(fieldErrors.value).length > 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const loggedIn = await authStore.login(normalizedEmail, password.value)
|
||||||
|
if (!loggedIn) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await router.push(safeLoginRedirect(route.query.redirect))
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateLogin(submittedEmail: string, submittedPassword: string): LoginFieldErrors {
|
||||||
|
const errors: LoginFieldErrors = {}
|
||||||
|
|
||||||
|
if (!emailSchema.safeParse(submittedEmail).success) {
|
||||||
|
errors.email = 'Enter a valid email address.'
|
||||||
|
}
|
||||||
|
if (submittedPassword === '') {
|
||||||
|
errors.password = 'Enter your password.'
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeLoginRedirect(redirect: unknown): string {
|
||||||
|
if (typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//')) {
|
||||||
|
return redirect
|
||||||
|
}
|
||||||
|
|
||||||
|
return '/dashboard'
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -10,13 +61,22 @@ import AuthTextField from '@/components/AuthTextField.vue'
|
||||||
title="Welcome back"
|
title="Welcome back"
|
||||||
description="Pick up where you left off and keep your momentum going."
|
description="Pick up where you left off and keep your momentum going."
|
||||||
>
|
>
|
||||||
<AuthForm submit-label="Log in">
|
<AuthForm
|
||||||
|
submit-label="Log in"
|
||||||
|
submitting-label="Logging in..."
|
||||||
|
:submitting="loading"
|
||||||
|
:error="error ?? undefined"
|
||||||
|
@submit="submitLogin"
|
||||||
|
>
|
||||||
<AuthTextField
|
<AuthTextField
|
||||||
id="login-email"
|
id="login-email"
|
||||||
label="Email address"
|
label="Email address"
|
||||||
type="email"
|
type="email"
|
||||||
autocomplete="email"
|
autocomplete="email"
|
||||||
placeholder="you@example.com"
|
placeholder="you@example.com"
|
||||||
|
v-model="email"
|
||||||
|
:error="fieldErrors.email"
|
||||||
|
:disabled="loading"
|
||||||
/>
|
/>
|
||||||
<AuthTextField
|
<AuthTextField
|
||||||
id="login-password"
|
id="login-password"
|
||||||
|
|
@ -24,6 +84,9 @@ import AuthTextField from '@/components/AuthTextField.vue'
|
||||||
type="password"
|
type="password"
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
placeholder="Enter your password"
|
placeholder="Enter your password"
|
||||||
|
v-model="password"
|
||||||
|
:error="fieldErrors.password"
|
||||||
|
:disabled="loading"
|
||||||
/>
|
/>
|
||||||
</AuthForm>
|
</AuthForm>
|
||||||
|
|
||||||
|
|
|
||||||
7
justfile
Normal file
7
justfile
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
set shell := ["bash", "-c"]
|
||||||
|
|
||||||
|
default:
|
||||||
|
@just --list
|
||||||
|
|
||||||
|
fresh:
|
||||||
|
cd backend && php artisan migrate:fresh --seed
|
||||||
|
|
@ -11,20 +11,6 @@ processes:
|
||||||
initial_delay_seconds: 1
|
initial_delay_seconds: 1
|
||||||
period_seconds: 2
|
period_seconds: 2
|
||||||
|
|
||||||
migrate:
|
|
||||||
command: php artisan migrate --force
|
|
||||||
working_dir: ./backend
|
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: process_healthy
|
|
||||||
|
|
||||||
seed:
|
|
||||||
command: php artisan db:seed --force
|
|
||||||
working_dir: ./backend
|
|
||||||
depends_on:
|
|
||||||
migrate:
|
|
||||||
condition: process_completed_successfully
|
|
||||||
|
|
||||||
mailpit:
|
mailpit:
|
||||||
command: mailpit --smtp 127.0.0.1:${MAILPIT_SMTP_PORT:-2525} --listen 127.0.0.1:${MAILPIT_UI_PORT:-8025}
|
command: mailpit --smtp 127.0.0.1:${MAILPIT_SMTP_PORT:-2525} --listen 127.0.0.1:${MAILPIT_UI_PORT:-8025}
|
||||||
readiness_probe:
|
readiness_probe:
|
||||||
|
|
@ -39,8 +25,8 @@ processes:
|
||||||
command: php artisan serve --host=127.0.0.1 --port=${BACKEND_PORT:-8001}
|
command: php artisan serve --host=127.0.0.1 --port=${BACKEND_PORT:-8001}
|
||||||
working_dir: ./backend
|
working_dir: ./backend
|
||||||
depends_on:
|
depends_on:
|
||||||
seed:
|
postgres:
|
||||||
condition: process_completed_successfully
|
condition: process_healthy
|
||||||
mailpit:
|
mailpit:
|
||||||
condition: process_healthy
|
condition: process_healthy
|
||||||
readiness_probe:
|
readiness_probe:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue