Compare commits
No commits in common. "783c522f7e4b73f9f0cfc8ddc89b44072ef56c1f" and "a95903ed059c9b1721369b9dd65308e6349a1703" have entirely different histories.
783c522f7e
...
a95903ed05
47 changed files with 63 additions and 1702 deletions
|
|
@ -36,14 +36,9 @@ class AuthenticateUser
|
||||||
throw new UnauthorizedException('invalid credentials');
|
throw new UnauthorizedException('invalid credentials');
|
||||||
}
|
}
|
||||||
|
|
||||||
$passwordHash = $user->getPasswordHash();
|
|
||||||
if ($passwordHash === null) {
|
|
||||||
throw new UnauthorizedException('invalid credentials');
|
|
||||||
}
|
|
||||||
|
|
||||||
$passwordMatches = $this->hasher->verify(
|
$passwordMatches = $this->hasher->verify(
|
||||||
$request->password,
|
$request->password,
|
||||||
$passwordHash,
|
$user->getPasswordHash(),
|
||||||
);
|
);
|
||||||
if (! $passwordMatches) {
|
if (! $passwordMatches) {
|
||||||
throw new UnauthorizedException('invalid credentials');
|
throw new UnauthorizedException('invalid credentials');
|
||||||
|
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Email\EmailConfirmationToken;
|
|
||||||
|
|
||||||
use App\User\User;
|
|
||||||
use DateTimeImmutable;
|
|
||||||
|
|
||||||
final readonly class CreateEmailConfirmationTokenDto
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public User $user,
|
|
||||||
public DateTimeImmutable $availableTo,
|
|
||||||
public string $token,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Email\EmailConfirmationToken;
|
|
||||||
|
|
||||||
use App\User\User;
|
|
||||||
use App\User\UserRepository;
|
|
||||||
use DomainException;
|
|
||||||
|
|
||||||
class EloquentEmailConfirmationTokenRepository implements
|
|
||||||
EmailConfirmationTokenRepository
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private UserRepository $userRepository,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function create(
|
|
||||||
CreateEmailConfirmationTokenDto $dto,
|
|
||||||
): EmailConfirmationToken {
|
|
||||||
$model = EmailConfirmationTokenModel::create([
|
|
||||||
'user_id' => $dto->user->getId(),
|
|
||||||
'token' => $dto->token,
|
|
||||||
'available_to' => $dto->availableTo,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $this->toDomain($model);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function findByToken(string $token): ?EmailConfirmationToken
|
|
||||||
{
|
|
||||||
$model = EmailConfirmationTokenModel::query()
|
|
||||||
->where('token', $token)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
return $model === null ? null : $this->toDomain($model);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function findByUser(User $user): ?EmailConfirmationToken
|
|
||||||
{
|
|
||||||
$model = EmailConfirmationTokenModel::query()
|
|
||||||
->where('user_id', $user->getId())
|
|
||||||
->first();
|
|
||||||
|
|
||||||
return $model === null ? null : $this->toDomain($model);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function delete(int $id): void
|
|
||||||
{
|
|
||||||
EmailConfirmationTokenModel::where('id', $id)->delete();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function toDomain(
|
|
||||||
EmailConfirmationTokenModel $model,
|
|
||||||
): EmailConfirmationToken {
|
|
||||||
$user = $this->userRepository->find($model->user_id);
|
|
||||||
if ($user === null) {
|
|
||||||
throw new DomainException(
|
|
||||||
"User with id {$model->user_id} not found",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new EmailConfirmationToken(
|
|
||||||
id: $model->id,
|
|
||||||
user: $user,
|
|
||||||
availableTo: $model->available_to,
|
|
||||||
token: $model->token,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Email\EmailConfirmationToken;
|
|
||||||
|
|
||||||
use App\User\User;
|
|
||||||
use DateTimeImmutable;
|
|
||||||
|
|
||||||
final readonly class EmailConfirmationToken
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private int $id,
|
|
||||||
private User $user,
|
|
||||||
private DateTimeImmutable $availableTo,
|
|
||||||
private string $token,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function getId(): int
|
|
||||||
{
|
|
||||||
return $this->id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getUser(): User
|
|
||||||
{
|
|
||||||
return $this->user;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getAvailableTo(): DateTimeImmutable
|
|
||||||
{
|
|
||||||
return $this->availableTo;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getToken(): string
|
|
||||||
{
|
|
||||||
return $this->token;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Email\EmailConfirmationToken;
|
|
||||||
|
|
||||||
use DateTimeImmutable;
|
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @property int $id
|
|
||||||
* @property int $user_id
|
|
||||||
* @property string $token
|
|
||||||
* @property DateTimeImmutable $available_to
|
|
||||||
*
|
|
||||||
* @method static Builder<static>|EmailConfirmationTokenModel newModelQuery()
|
|
||||||
* @method static Builder<static>|EmailConfirmationTokenModel newQuery()
|
|
||||||
* @method static Builder<static>|EmailConfirmationTokenModel query()
|
|
||||||
*
|
|
||||||
* @mixin \Eloquent
|
|
||||||
*/
|
|
||||||
#[Fillable([
|
|
||||||
'user_id',
|
|
||||||
'token',
|
|
||||||
'available_to',
|
|
||||||
])]
|
|
||||||
class EmailConfirmationTokenModel extends Model
|
|
||||||
{
|
|
||||||
protected $table = 'email_confirmation_tokens';
|
|
||||||
|
|
||||||
public $timestamps = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'available_to' => 'immutable_datetime',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Email\EmailConfirmationToken;
|
|
||||||
|
|
||||||
use App\User\User;
|
|
||||||
|
|
||||||
interface EmailConfirmationTokenRepository
|
|
||||||
{
|
|
||||||
public function create(
|
|
||||||
CreateEmailConfirmationTokenDto $dto,
|
|
||||||
): EmailConfirmationToken;
|
|
||||||
|
|
||||||
public function findByToken(string $token): ?EmailConfirmationToken;
|
|
||||||
|
|
||||||
public function findByUser(User $user): ?EmailConfirmationToken;
|
|
||||||
|
|
||||||
public function delete(int $id): void;
|
|
||||||
}
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Email\EmailConfirmationToken\UseCases;
|
|
||||||
|
|
||||||
use App\Auth\Clock;
|
|
||||||
use App\Auth\TokenGenerator;
|
|
||||||
use App\Email\EmailConfirmationToken\CreateEmailConfirmationTokenDto;
|
|
||||||
use App\Email\EmailConfirmationToken\EmailConfirmationToken;
|
|
||||||
use App\Email\EmailConfirmationToken\EmailConfirmationTokenRepository;
|
|
||||||
use App\Exceptions\BadRequestException;
|
|
||||||
|
|
||||||
class CreateEmailConfirmationToken
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private EmailConfirmationTokenRepository $tokenRepository,
|
|
||||||
private Clock $clock,
|
|
||||||
private TokenGenerator $tokenGenerator,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws BadRequestException
|
|
||||||
*/
|
|
||||||
public function execute(
|
|
||||||
CreateEmailConfirmationTokenRequest $request,
|
|
||||||
): EmailConfirmationToken {
|
|
||||||
if ($request->user === null) {
|
|
||||||
throw new BadRequestException('user is required');
|
|
||||||
}
|
|
||||||
if ($request->minuteOffset === null) {
|
|
||||||
throw new BadRequestException('minuteOffset is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
$existingToken = $this->tokenRepository->findByUser($request->user);
|
|
||||||
if ($existingToken !== null) {
|
|
||||||
$this->tokenRepository->delete($existingToken->getId());
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->tokenRepository->create(
|
|
||||||
new CreateEmailConfirmationTokenDto(
|
|
||||||
user: $request->user,
|
|
||||||
availableTo: $this->clock->now()->modify(
|
|
||||||
"+{$request->minuteOffset} minutes",
|
|
||||||
),
|
|
||||||
token: $this->tokenGenerator->generate(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Email\EmailConfirmationToken\UseCases;
|
|
||||||
|
|
||||||
use App\User\User;
|
|
||||||
|
|
||||||
final readonly class CreateEmailConfirmationTokenRequest
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public ?User $user,
|
|
||||||
public ?int $minuteOffset,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Email;
|
|
||||||
|
|
||||||
interface EmailFactory
|
|
||||||
{
|
|
||||||
public function makeConfirmationEmail(string $token): string;
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Email;
|
|
||||||
|
|
||||||
use App\Shared\ValueObject\EmailAddress;
|
|
||||||
|
|
||||||
interface Emailer
|
|
||||||
{
|
|
||||||
public function send(
|
|
||||||
EmailAddress $recipient,
|
|
||||||
string $subject,
|
|
||||||
string $body,
|
|
||||||
): void;
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Email;
|
|
||||||
|
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
class LaravelEmailFactory implements EmailFactory
|
|
||||||
{
|
|
||||||
public function makeConfirmationEmail(string $token): string
|
|
||||||
{
|
|
||||||
$configuredFrontendUrl = config('app.frontend_url');
|
|
||||||
if (! is_string($configuredFrontendUrl)) {
|
|
||||||
throw new RuntimeException('FRONTEND_URL must be configured');
|
|
||||||
}
|
|
||||||
|
|
||||||
$frontendUrl = rtrim($configuredFrontendUrl, '/');
|
|
||||||
$confirmationUrl = $frontendUrl
|
|
||||||
.'/confirm-email?token='.urlencode($token);
|
|
||||||
|
|
||||||
return "Welcome to Attainly.\n\n"
|
|
||||||
."Confirm your email and choose a password:\n"
|
|
||||||
."{$confirmationUrl}\n\n"
|
|
||||||
.'This link expires in 10 minutes.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Email;
|
|
||||||
|
|
||||||
use App\Shared\ValueObject\EmailAddress;
|
|
||||||
use Illuminate\Contracts\Mail\Mailer;
|
|
||||||
use Illuminate\Mail\Message;
|
|
||||||
|
|
||||||
class LaravelEmailer implements Emailer
|
|
||||||
{
|
|
||||||
public function __construct(private Mailer $mailer) {}
|
|
||||||
|
|
||||||
public function send(
|
|
||||||
EmailAddress $recipient,
|
|
||||||
string $subject,
|
|
||||||
string $body,
|
|
||||||
): void {
|
|
||||||
$this->mailer->raw(
|
|
||||||
$body,
|
|
||||||
function (Message $message) use ($recipient, $subject): void {
|
|
||||||
$message->to($recipient->value())->subject($subject);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -10,12 +10,7 @@ use App\Exceptions\BadRequestException;
|
||||||
use App\Exceptions\UnauthorizedException;
|
use App\Exceptions\UnauthorizedException;
|
||||||
use App\Http\Middleware\AuthMiddleware;
|
use App\Http\Middleware\AuthMiddleware;
|
||||||
use App\Shared\Http\RequestInput;
|
use App\Shared\Http\RequestInput;
|
||||||
use App\User\UseCases\ConfirmUserEmail\ConfirmUserEmail;
|
|
||||||
use App\User\UseCases\ConfirmUserEmail\ConfirmUserEmailRequest;
|
|
||||||
use App\User\UseCases\SignupUser\SignupUser;
|
|
||||||
use App\User\UseCases\SignupUser\SignupUserRequest;
|
|
||||||
use App\User\User;
|
use App\User\User;
|
||||||
use DomainException;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Symfony\Component\HttpFoundation\Cookie;
|
use Symfony\Component\HttpFoundation\Cookie;
|
||||||
|
|
@ -23,62 +18,11 @@ use Symfony\Component\HttpFoundation\Cookie;
|
||||||
class AuthController extends Controller
|
class AuthController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private SignupUser $signupUser,
|
|
||||||
private ConfirmUserEmail $confirmUserEmail,
|
|
||||||
private AuthenticateUser $authenticateUser,
|
private AuthenticateUser $authenticateUser,
|
||||||
private CreateSession $createSession,
|
private CreateSession $createSession,
|
||||||
private Logout $logout,
|
private Logout $logout,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function signup(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$input = new RequestInput($request);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$this->signupUser->execute(new SignupUserRequest(
|
|
||||||
email: $input->string('email'),
|
|
||||||
));
|
|
||||||
} catch (BadRequestException $exception) {
|
|
||||||
return new JsonResponse(
|
|
||||||
['error' => $exception->getMessage()],
|
|
||||||
400,
|
|
||||||
);
|
|
||||||
} catch (DomainException $exception) {
|
|
||||||
return new JsonResponse(
|
|
||||||
['error' => $exception->getMessage()],
|
|
||||||
409,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new JsonResponse(null, 201);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function confirmEmail(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$input = new RequestInput($request);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$user = $this->confirmUserEmail->execute(
|
|
||||||
new ConfirmUserEmailRequest(
|
|
||||||
token: $input->string('token'),
|
|
||||||
password: $input->string('password'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (BadRequestException $exception) {
|
|
||||||
return new JsonResponse(
|
|
||||||
['error' => $exception->getMessage()],
|
|
||||||
400,
|
|
||||||
);
|
|
||||||
} catch (DomainException $exception) {
|
|
||||||
return new JsonResponse(
|
|
||||||
['error' => $exception->getMessage()],
|
|
||||||
409,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->authenticatedResponse($user);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function login(Request $request): JsonResponse
|
public function login(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$input = new RequestInput($request);
|
$input = new RequestInput($request);
|
||||||
|
|
@ -100,7 +44,23 @@ class AuthController extends Controller
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->authenticatedResponse($user);
|
$session = $this->createSession->execute($user);
|
||||||
|
|
||||||
|
$response = new JsonResponse([
|
||||||
|
'user' => $this->userPayload($user),
|
||||||
|
], 200);
|
||||||
|
|
||||||
|
return $response->withCookie(Cookie::create(
|
||||||
|
name: AuthMiddleware::COOKIE_NAME,
|
||||||
|
value: $session->getToken(),
|
||||||
|
expire: $session->getExpiresAt()->getTimestamp(),
|
||||||
|
path: '/',
|
||||||
|
domain: null,
|
||||||
|
secure: false,
|
||||||
|
httpOnly: true,
|
||||||
|
raw: false,
|
||||||
|
sameSite: Cookie::SAMESITE_LAX,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function me(Request $request): JsonResponse
|
public function me(Request $request): JsonResponse
|
||||||
|
|
@ -145,24 +105,4 @@ class AuthController extends Controller
|
||||||
'email' => $user->getEmail()->value(),
|
'email' => $user->getEmail()->value(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function authenticatedResponse(User $user): JsonResponse
|
|
||||||
{
|
|
||||||
$session = $this->createSession->execute($user);
|
|
||||||
$response = new JsonResponse([
|
|
||||||
'user' => $this->userPayload($user),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $response->withCookie(Cookie::create(
|
|
||||||
name: AuthMiddleware::COOKIE_NAME,
|
|
||||||
value: $session->getToken(),
|
|
||||||
expire: $session->getExpiresAt()->getTimestamp(),
|
|
||||||
path: '/',
|
|
||||||
domain: null,
|
|
||||||
secure: false,
|
|
||||||
httpOnly: true,
|
|
||||||
raw: false,
|
|
||||||
sameSite: Cookie::SAMESITE_LAX,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,6 @@ 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\Auth\TokenGenerator;
|
||||||
use App\Email\EmailConfirmationToken\EloquentEmailConfirmationTokenRepository;
|
|
||||||
use App\Email\EmailConfirmationToken\EmailConfirmationTokenRepository;
|
|
||||||
use App\Email\Emailer;
|
|
||||||
use App\Email\EmailFactory;
|
|
||||||
use App\Email\LaravelEmailer;
|
|
||||||
use App\Email\LaravelEmailFactory;
|
|
||||||
use App\User\EloquentUserRepository;
|
use App\User\EloquentUserRepository;
|
||||||
use App\User\UserRepository;
|
use App\User\UserRepository;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
|
|
@ -39,12 +33,6 @@ class AppServiceProvider extends ServiceProvider
|
||||||
SessionRepository::class,
|
SessionRepository::class,
|
||||||
EloquentSessionRepository::class,
|
EloquentSessionRepository::class,
|
||||||
);
|
);
|
||||||
$this->app->bind(
|
|
||||||
EmailConfirmationTokenRepository::class,
|
|
||||||
EloquentEmailConfirmationTokenRepository::class,
|
|
||||||
);
|
|
||||||
$this->app->bind(Emailer::class, LaravelEmailer::class);
|
|
||||||
$this->app->bind(EmailFactory::class, LaravelEmailFactory::class);
|
|
||||||
$this->app->bind(PasswordHasher::class, BcryptPasswordHasher::class);
|
$this->app->bind(PasswordHasher::class, BcryptPasswordHasher::class);
|
||||||
$this->app->bind(TokenGenerator::class, RandomTokenGenerator::class);
|
$this->app->bind(TokenGenerator::class, RandomTokenGenerator::class);
|
||||||
$this->app->bind(Clock::class, SystemClock::class);
|
$this->app->bind(Clock::class, SystemClock::class);
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,6 @@ final readonly class CreateUserDto
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public EmailAddress $email,
|
public EmailAddress $email,
|
||||||
public ?string $passwordHash,
|
public string $passwordHash,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
namespace App\User;
|
namespace App\User;
|
||||||
|
|
||||||
use App\Shared\ValueObject\EmailAddress;
|
use App\Shared\ValueObject\EmailAddress;
|
||||||
use DomainException;
|
|
||||||
|
|
||||||
class EloquentUserRepository implements UserRepository
|
class EloquentUserRepository implements UserRepository
|
||||||
{
|
{
|
||||||
|
|
@ -39,22 +38,6 @@ class EloquentUserRepository implements UserRepository
|
||||||
return $this->toDomain($model);
|
return $this->toDomain($model);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(User $user): User
|
|
||||||
{
|
|
||||||
$model = UserModel::find($user->getId());
|
|
||||||
if ($model === null) {
|
|
||||||
throw new DomainException(
|
|
||||||
"User with id {$user->getId()} not found",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$model->email = $user->getEmail()->value();
|
|
||||||
$model->passwordHash = $user->getPasswordHash();
|
|
||||||
$model->save();
|
|
||||||
|
|
||||||
return $this->toDomain($model);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function toDomain(UserModel $model): User
|
private function toDomain(UserModel $model): User
|
||||||
{
|
{
|
||||||
return new User(
|
return new User(
|
||||||
|
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\User\UseCases\ConfirmUserEmail;
|
|
||||||
|
|
||||||
use App\Auth\Clock;
|
|
||||||
use App\Auth\PasswordHasher;
|
|
||||||
use App\Email\EmailConfirmationToken\EmailConfirmationTokenRepository;
|
|
||||||
use App\Exceptions\BadRequestException;
|
|
||||||
use App\User\User;
|
|
||||||
use App\User\UserRepository;
|
|
||||||
use DomainException;
|
|
||||||
|
|
||||||
class ConfirmUserEmail
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private EmailConfirmationTokenRepository $tokenRepository,
|
|
||||||
private UserRepository $userRepository,
|
|
||||||
private PasswordHasher $passwordHasher,
|
|
||||||
private Clock $clock,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws BadRequestException
|
|
||||||
* @throws DomainException
|
|
||||||
*/
|
|
||||||
public function execute(ConfirmUserEmailRequest $request): User
|
|
||||||
{
|
|
||||||
if ($request->token === null || $request->token === '') {
|
|
||||||
throw new BadRequestException('token is required');
|
|
||||||
}
|
|
||||||
if ($request->password === null || $request->password === '') {
|
|
||||||
throw new BadRequestException('password is required');
|
|
||||||
}
|
|
||||||
if (strlen($request->password) < 8) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
'password must be at least 8 characters',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$token = $this->tokenRepository->findByToken($request->token);
|
|
||||||
if ($token === null) {
|
|
||||||
throw new DomainException('token not found');
|
|
||||||
}
|
|
||||||
if ($token->getAvailableTo() < $this->clock->now()) {
|
|
||||||
throw new DomainException('token expired');
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = $token->getUser();
|
|
||||||
if ($user->getPasswordHash() !== null) {
|
|
||||||
throw new DomainException('account already confirmed');
|
|
||||||
}
|
|
||||||
|
|
||||||
$user->setPasswordHash(
|
|
||||||
$this->passwordHasher->hash($request->password),
|
|
||||||
);
|
|
||||||
$confirmedUser = $this->userRepository->update($user);
|
|
||||||
$this->tokenRepository->delete($token->getId());
|
|
||||||
|
|
||||||
return $confirmedUser;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\User\UseCases\ConfirmUserEmail;
|
|
||||||
|
|
||||||
final readonly class ConfirmUserEmailRequest
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public ?string $token,
|
|
||||||
public ?string $password,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\User\UseCases\SignupUser;
|
|
||||||
|
|
||||||
use App\Email\EmailConfirmationToken\UseCases\CreateEmailConfirmationToken;
|
|
||||||
use App\Email\EmailConfirmationToken\UseCases\CreateEmailConfirmationTokenRequest;
|
|
||||||
use App\Email\Emailer;
|
|
||||||
use App\Email\EmailFactory;
|
|
||||||
use App\Exceptions\BadRequestException;
|
|
||||||
use App\Shared\ValueObject\EmailAddress;
|
|
||||||
use App\User\CreateUserDto;
|
|
||||||
use App\User\User;
|
|
||||||
use App\User\UserRepository;
|
|
||||||
use DomainException;
|
|
||||||
use InvalidArgumentException;
|
|
||||||
|
|
||||||
class SignupUser
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private UserRepository $userRepository,
|
|
||||||
private CreateEmailConfirmationToken $createToken,
|
|
||||||
private Emailer $emailer,
|
|
||||||
private EmailFactory $emailFactory,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws BadRequestException
|
|
||||||
* @throws DomainException
|
|
||||||
*/
|
|
||||||
public function execute(SignupUserRequest $request): void
|
|
||||||
{
|
|
||||||
if ($request->email === null || trim($request->email) === '') {
|
|
||||||
throw new BadRequestException('email is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$email = new EmailAddress($request->email);
|
|
||||||
} catch (InvalidArgumentException) {
|
|
||||||
throw new BadRequestException('email must be valid');
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = $this->findOrCreatePendingUser($email);
|
|
||||||
$token = $this->createToken->execute(
|
|
||||||
new CreateEmailConfirmationTokenRequest(
|
|
||||||
user: $user,
|
|
||||||
minuteOffset: 10,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
$body = $this->emailFactory->makeConfirmationEmail(
|
|
||||||
$token->getToken(),
|
|
||||||
);
|
|
||||||
$this->emailer->send(
|
|
||||||
$user->getEmail(),
|
|
||||||
'Confirm your Attainly email',
|
|
||||||
$body,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws DomainException
|
|
||||||
*/
|
|
||||||
private function findOrCreatePendingUser(EmailAddress $email): User
|
|
||||||
{
|
|
||||||
$user = $this->userRepository->findByEmail($email);
|
|
||||||
if ($user === null) {
|
|
||||||
return $this->userRepository->create(new CreateUserDto(
|
|
||||||
email: $email,
|
|
||||||
passwordHash: null,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if ($user->getPasswordHash() !== null) {
|
|
||||||
throw new DomainException(
|
|
||||||
"{$email->value()} already has an account",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\User\UseCases\SignupUser;
|
|
||||||
|
|
||||||
final readonly class SignupUserRequest
|
|
||||||
{
|
|
||||||
public function __construct(public ?string $email) {}
|
|
||||||
}
|
|
||||||
|
|
@ -4,12 +4,12 @@ namespace App\User;
|
||||||
|
|
||||||
use App\Shared\ValueObject\EmailAddress;
|
use App\Shared\ValueObject\EmailAddress;
|
||||||
|
|
||||||
final class User
|
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,
|
private string $passwordHash,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function getId(): int
|
public function getId(): int
|
||||||
|
|
@ -22,13 +22,8 @@ final class User
|
||||||
return $this->email;
|
return $this->email;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getPasswordHash(): ?string
|
public function getPasswordHash(): string
|
||||||
{
|
{
|
||||||
return $this->passwordHash;
|
return $this->passwordHash;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setPasswordHash(string $passwordHash): void
|
|
||||||
{
|
|
||||||
$this->passwordHash = $passwordHash;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||||
/**
|
/**
|
||||||
* @property int $id
|
* @property int $id
|
||||||
* @property string $email
|
* @property string $email
|
||||||
* @property string|null $passwordHash
|
* @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()
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,4 @@ interface UserRepository
|
||||||
public function find(int $id): ?User;
|
public function find(int $id): ?User;
|
||||||
|
|
||||||
public function findByEmail(EmailAddress $email): ?User;
|
public function findByEmail(EmailAddress $email): ?User;
|
||||||
|
|
||||||
public function update(User $user): User;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,8 +54,6 @@ return [
|
||||||
|
|
||||||
'url' => env('APP_URL', 'http://localhost'),
|
'url' => env('APP_URL', 'http://localhost'),
|
||||||
|
|
||||||
'frontend_url' => env('FRONTEND_URL', 'http://localhost:5173'),
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Application Timezone
|
| Application Timezone
|
||||||
|
|
|
||||||
|
|
@ -11,7 +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')->nullable();
|
$table->string('passwordHash');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::create(
|
|
||||||
'email_confirmation_tokens',
|
|
||||||
function (Blueprint $table): void {
|
|
||||||
$table->id();
|
|
||||||
$table->foreignId('user_id')
|
|
||||||
->unique()
|
|
||||||
->constrained('users')
|
|
||||||
->cascadeOnDelete();
|
|
||||||
$table->string('token', 64)->unique();
|
|
||||||
$table->timestamp('available_to');
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::dropIfExists('email_confirmation_tokens');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -5,8 +5,6 @@ use App\Http\Middleware\AuthMiddleware;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::post('/login', [AuthController::class, 'login']);
|
Route::post('/login', [AuthController::class, 'login']);
|
||||||
Route::post('/signup', [AuthController::class, 'signup']);
|
|
||||||
Route::post('/confirm-email', [AuthController::class, 'confirmEmail']);
|
|
||||||
|
|
||||||
Route::middleware(AuthMiddleware::class)->group(function (): void {
|
Route::middleware(AuthMiddleware::class)->group(function (): void {
|
||||||
Route::get('/me', [AuthController::class, 'me']);
|
Route::get('/me', [AuthController::class, 'me']);
|
||||||
|
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Fakes;
|
|
||||||
|
|
||||||
use App\Email\EmailConfirmationToken\CreateEmailConfirmationTokenDto;
|
|
||||||
use App\Email\EmailConfirmationToken\EmailConfirmationToken;
|
|
||||||
use App\Email\EmailConfirmationToken\EmailConfirmationTokenRepository;
|
|
||||||
use App\User\User;
|
|
||||||
|
|
||||||
class FakeEmailConfirmationTokenRepository implements EmailConfirmationTokenRepository
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @var array<int, EmailConfirmationToken>
|
|
||||||
*/
|
|
||||||
private array $tokens = [];
|
|
||||||
|
|
||||||
public function create(
|
|
||||||
CreateEmailConfirmationTokenDto $dto,
|
|
||||||
): EmailConfirmationToken {
|
|
||||||
$id = count($this->tokens) + 1;
|
|
||||||
$token = new EmailConfirmationToken(
|
|
||||||
id: $id,
|
|
||||||
user: $dto->user,
|
|
||||||
availableTo: $dto->availableTo,
|
|
||||||
token: $dto->token,
|
|
||||||
);
|
|
||||||
$this->tokens[$id] = $token;
|
|
||||||
|
|
||||||
return $this->copy($token);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function findByToken(string $token): ?EmailConfirmationToken
|
|
||||||
{
|
|
||||||
foreach ($this->tokens as $candidate) {
|
|
||||||
if ($candidate->getToken() === $token) {
|
|
||||||
return $this->copy($candidate);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function findByUser(User $user): ?EmailConfirmationToken
|
|
||||||
{
|
|
||||||
foreach ($this->tokens as $candidate) {
|
|
||||||
if ($candidate->getUser()->getId() === $user->getId()) {
|
|
||||||
return $this->copy($candidate);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function delete(int $id): void
|
|
||||||
{
|
|
||||||
unset($this->tokens[$id]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function copy(
|
|
||||||
EmailConfirmationToken $token,
|
|
||||||
): EmailConfirmationToken {
|
|
||||||
return new EmailConfirmationToken(
|
|
||||||
id: $token->getId(),
|
|
||||||
user: $token->getUser(),
|
|
||||||
availableTo: $token->getAvailableTo(),
|
|
||||||
token: $token->getToken(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Fakes;
|
|
||||||
|
|
||||||
use App\Email\EmailFactory;
|
|
||||||
|
|
||||||
class FakeEmailFactory implements EmailFactory
|
|
||||||
{
|
|
||||||
private ?string $lastToken = null;
|
|
||||||
|
|
||||||
public function makeConfirmationEmail(string $token): string
|
|
||||||
{
|
|
||||||
$this->lastToken = $token;
|
|
||||||
|
|
||||||
return "confirm with {$token}";
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getLastToken(): ?string
|
|
||||||
{
|
|
||||||
return $this->lastToken;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Fakes;
|
|
||||||
|
|
||||||
use App\Email\Emailer;
|
|
||||||
use App\Shared\ValueObject\EmailAddress;
|
|
||||||
|
|
||||||
class FakeEmailer implements Emailer
|
|
||||||
{
|
|
||||||
private int $sendCount = 0;
|
|
||||||
|
|
||||||
private ?EmailAddress $lastRecipient = null;
|
|
||||||
|
|
||||||
private ?string $lastSubject = null;
|
|
||||||
|
|
||||||
private ?string $lastBody = null;
|
|
||||||
|
|
||||||
public function send(
|
|
||||||
EmailAddress $recipient,
|
|
||||||
string $subject,
|
|
||||||
string $body,
|
|
||||||
): void {
|
|
||||||
$this->sendCount++;
|
|
||||||
$this->lastRecipient = $recipient;
|
|
||||||
$this->lastSubject = $subject;
|
|
||||||
$this->lastBody = $body;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getSendCount(): int
|
|
||||||
{
|
|
||||||
return $this->sendCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getLastRecipient(): ?EmailAddress
|
|
||||||
{
|
|
||||||
return $this->lastRecipient;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getLastSubject(): ?string
|
|
||||||
{
|
|
||||||
return $this->lastSubject;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getLastBody(): ?string
|
|
||||||
{
|
|
||||||
return $this->lastBody;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -45,13 +45,6 @@ class FakeUserRepository implements UserRepository
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(User $user): User
|
|
||||||
{
|
|
||||||
$this->users[$user->getId()] = $this->copy($user);
|
|
||||||
|
|
||||||
return $this->copy($user);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function copy(User $user): User
|
private function copy(User $user): User
|
||||||
{
|
{
|
||||||
return new User(
|
return new User(
|
||||||
|
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Feature\Auth;
|
|
||||||
|
|
||||||
use App\Auth\PasswordHasher;
|
|
||||||
use App\Auth\SessionRepository;
|
|
||||||
use App\Email\EmailConfirmationToken\EmailConfirmationTokenRepository;
|
|
||||||
use App\Email\EmailConfirmationToken\UseCases\CreateEmailConfirmationToken;
|
|
||||||
use App\Email\EmailConfirmationToken\UseCases\CreateEmailConfirmationTokenRequest;
|
|
||||||
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 ConfirmEmailEndpointTest extends TestCase
|
|
||||||
{
|
|
||||||
use RefreshDatabase;
|
|
||||||
|
|
||||||
public function test_confirmation_sets_password_and_starts_session(): void
|
|
||||||
{
|
|
||||||
$userRepository = app(UserRepository::class);
|
|
||||||
$user = $userRepository->create(new CreateUserDto(
|
|
||||||
email: new EmailAddress('user@example.com'),
|
|
||||||
passwordHash: null,
|
|
||||||
));
|
|
||||||
$token = app(CreateEmailConfirmationToken::class)->execute(
|
|
||||||
new CreateEmailConfirmationTokenRequest(
|
|
||||||
user: $user,
|
|
||||||
minuteOffset: 10,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/confirm-email', [
|
|
||||||
'token' => $token->getToken(),
|
|
||||||
'password' => 'password123',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonPath('user.email', 'user@example.com');
|
|
||||||
$confirmedUser = $userRepository->find($user->getId());
|
|
||||||
$this->assertNotNull($confirmedUser);
|
|
||||||
$passwordHash = $confirmedUser->getPasswordHash();
|
|
||||||
$this->assertNotNull($passwordHash);
|
|
||||||
$this->assertTrue(
|
|
||||||
app(PasswordHasher::class)->verify('password123', $passwordHash),
|
|
||||||
);
|
|
||||||
$this->assertNull(
|
|
||||||
app(EmailConfirmationTokenRepository::class)
|
|
||||||
->findByToken($token->getToken()),
|
|
||||||
);
|
|
||||||
$cookie = $response->getCookie(AuthMiddleware::COOKIE_NAME, false);
|
|
||||||
$this->assertNotNull($cookie);
|
|
||||||
$this->assertNotNull(
|
|
||||||
app(SessionRepository::class)->findByToken($cookie->getValue()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_confirmation_rejects_an_unknown_token(): void
|
|
||||||
{
|
|
||||||
$response = $this->postJson('/api/confirm-email', [
|
|
||||||
'token' => 'unknown-token',
|
|
||||||
'password' => 'password123',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertConflict();
|
|
||||||
$response->assertJson(['error' => 'token not found']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Feature\Auth;
|
|
||||||
|
|
||||||
use App\Email\EmailConfirmationToken\EmailConfirmationTokenRepository;
|
|
||||||
use App\Shared\ValueObject\EmailAddress;
|
|
||||||
use App\User\CreateUserDto;
|
|
||||||
use App\User\UserRepository;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class SignupEndpointTest extends TestCase
|
|
||||||
{
|
|
||||||
use RefreshDatabase;
|
|
||||||
|
|
||||||
public function test_signup_creates_a_pending_user_and_confirmation_token(): void
|
|
||||||
{
|
|
||||||
$response = $this->postJson('/api/signup', [
|
|
||||||
'email' => 'Founder@EXAMPLE.COM',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertCreated();
|
|
||||||
$user = app(UserRepository::class)->findByEmail(
|
|
||||||
new EmailAddress('Founder@example.com'),
|
|
||||||
);
|
|
||||||
$this->assertNotNull($user);
|
|
||||||
$this->assertNull($user->getPasswordHash());
|
|
||||||
$this->assertNotNull(
|
|
||||||
app(EmailConfirmationTokenRepository::class)
|
|
||||||
->findByUser($user),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_signup_rejects_an_existing_confirmed_account(): void
|
|
||||||
{
|
|
||||||
app(UserRepository::class)->create(new CreateUserDto(
|
|
||||||
email: new EmailAddress('user@example.com'),
|
|
||||||
passwordHash: 'hashed-password',
|
|
||||||
));
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/signup', [
|
|
||||||
'email' => 'user@example.com',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertConflict();
|
|
||||||
$response->assertJson([
|
|
||||||
'error' => 'user@example.com already has an account',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -78,23 +78,4 @@ class EloquentUserRepositoryTest extends TestCase
|
||||||
new EmailAddress('unknown@example.com'),
|
new EmailAddress('unknown@example.com'),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_persists_confirmation_of_a_pending_user(): void
|
|
||||||
{
|
|
||||||
$repository = app(UserRepository::class);
|
|
||||||
$user = $repository->create(new CreateUserDto(
|
|
||||||
email: new EmailAddress('pending@example.com'),
|
|
||||||
passwordHash: null,
|
|
||||||
));
|
|
||||||
|
|
||||||
$this->assertNull($user->getPasswordHash());
|
|
||||||
$user->setPasswordHash('hashed-password');
|
|
||||||
$updatedUser = $repository->update($user);
|
|
||||||
|
|
||||||
$this->assertSame('hashed-password', $updatedUser->getPasswordHash());
|
|
||||||
$this->assertDatabaseHas('users', [
|
|
||||||
'id' => $user->getId(),
|
|
||||||
'passwordHash' => 'hashed-password',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,22 +64,6 @@ class AuthenticateUserTest extends TestCase
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_pending_user_throws_unauthorized(): void
|
|
||||||
{
|
|
||||||
$this->userRepository->create(new CreateUserDto(
|
|
||||||
email: new EmailAddress('user@example.com'),
|
|
||||||
passwordHash: null,
|
|
||||||
));
|
|
||||||
|
|
||||||
$this->expectException(UnauthorizedException::class);
|
|
||||||
$this->expectExceptionMessage('invalid credentials');
|
|
||||||
|
|
||||||
$this->useCase->execute(new AuthenticateUserRequest(
|
|
||||||
email: 'user@example.com',
|
|
||||||
password: 'correct-password',
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_wrong_password_throws_unauthorized(): void
|
public function test_wrong_password_throws_unauthorized(): void
|
||||||
{
|
{
|
||||||
$this->createUser('correct-password');
|
$this->createUser('correct-password');
|
||||||
|
|
|
||||||
|
|
@ -5,21 +5,15 @@ namespace Tests\Unit\Http\Controllers;
|
||||||
use App\Auth\UseCases\AuthenticateUser\AuthenticateUser;
|
use App\Auth\UseCases\AuthenticateUser\AuthenticateUser;
|
||||||
use App\Auth\UseCases\CreateSession\CreateSession;
|
use App\Auth\UseCases\CreateSession\CreateSession;
|
||||||
use App\Auth\UseCases\Logout\Logout;
|
use App\Auth\UseCases\Logout\Logout;
|
||||||
use App\Email\EmailConfirmationToken\UseCases\CreateEmailConfirmationToken;
|
|
||||||
use App\Http\Controllers\AuthController;
|
use App\Http\Controllers\AuthController;
|
||||||
use App\Http\Middleware\AuthMiddleware;
|
use App\Http\Middleware\AuthMiddleware;
|
||||||
use App\Shared\ValueObject\EmailAddress;
|
use App\Shared\ValueObject\EmailAddress;
|
||||||
use App\User\CreateUserDto;
|
use App\User\CreateUserDto;
|
||||||
use App\User\UseCases\ConfirmUserEmail\ConfirmUserEmail;
|
|
||||||
use App\User\UseCases\SignupUser\SignupUser;
|
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use DateTimeZone;
|
use DateTimeZone;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Tests\Fakes\FakeClock;
|
use Tests\Fakes\FakeClock;
|
||||||
use Tests\Fakes\FakeEmailConfirmationTokenRepository;
|
|
||||||
use Tests\Fakes\FakeEmailer;
|
|
||||||
use Tests\Fakes\FakeEmailFactory;
|
|
||||||
use Tests\Fakes\FakePasswordHasher;
|
use Tests\Fakes\FakePasswordHasher;
|
||||||
use Tests\Fakes\FakeSessionRepository;
|
use Tests\Fakes\FakeSessionRepository;
|
||||||
use Tests\Fakes\FakeTokenGenerator;
|
use Tests\Fakes\FakeTokenGenerator;
|
||||||
|
|
@ -53,32 +47,7 @@ class AuthControllerTest extends TestCase
|
||||||
)),
|
)),
|
||||||
);
|
);
|
||||||
$logout = new Logout($this->sessionRepository);
|
$logout = new Logout($this->sessionRepository);
|
||||||
$tokenRepository = new FakeEmailConfirmationTokenRepository;
|
|
||||||
$signupUser = new SignupUser(
|
|
||||||
$this->userRepository,
|
|
||||||
new CreateEmailConfirmationToken(
|
|
||||||
$tokenRepository,
|
|
||||||
new FakeClock(new DateTimeImmutable(
|
|
||||||
'2026-07-31T12:00:00',
|
|
||||||
new DateTimeZone('UTC'),
|
|
||||||
)),
|
|
||||||
new FakeTokenGenerator(['email-token']),
|
|
||||||
),
|
|
||||||
new FakeEmailer,
|
|
||||||
new FakeEmailFactory,
|
|
||||||
);
|
|
||||||
$confirmUserEmail = new ConfirmUserEmail(
|
|
||||||
$tokenRepository,
|
|
||||||
$this->userRepository,
|
|
||||||
$this->passwordHasher,
|
|
||||||
new FakeClock(new DateTimeImmutable(
|
|
||||||
'2026-07-31T12:00:00',
|
|
||||||
new DateTimeZone('UTC'),
|
|
||||||
)),
|
|
||||||
);
|
|
||||||
$this->controller = new AuthController(
|
$this->controller = new AuthController(
|
||||||
$signupUser,
|
|
||||||
$confirmUserEmail,
|
|
||||||
$authenticateUser,
|
$authenticateUser,
|
||||||
$createSession,
|
$createSession,
|
||||||
$logout,
|
$logout,
|
||||||
|
|
|
||||||
|
|
@ -1,149 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Unit\User\UseCases;
|
|
||||||
|
|
||||||
use App\Email\EmailConfirmationToken\CreateEmailConfirmationTokenDto;
|
|
||||||
use App\Exceptions\BadRequestException;
|
|
||||||
use App\Shared\ValueObject\EmailAddress;
|
|
||||||
use App\User\CreateUserDto;
|
|
||||||
use App\User\UseCases\ConfirmUserEmail\ConfirmUserEmail;
|
|
||||||
use App\User\UseCases\ConfirmUserEmail\ConfirmUserEmailRequest;
|
|
||||||
use DateTimeImmutable;
|
|
||||||
use DateTimeZone;
|
|
||||||
use DomainException;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
use Tests\Fakes\FakeClock;
|
|
||||||
use Tests\Fakes\FakeEmailConfirmationTokenRepository;
|
|
||||||
use Tests\Fakes\FakePasswordHasher;
|
|
||||||
use Tests\Fakes\FakeUserRepository;
|
|
||||||
|
|
||||||
class ConfirmUserEmailTest extends TestCase
|
|
||||||
{
|
|
||||||
private DateTimeImmutable $now;
|
|
||||||
|
|
||||||
private FakeUserRepository $userRepository;
|
|
||||||
|
|
||||||
private FakeEmailConfirmationTokenRepository $tokenRepository;
|
|
||||||
|
|
||||||
private ConfirmUserEmail $confirmUserEmail;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
|
||||||
{
|
|
||||||
$this->now = new DateTimeImmutable(
|
|
||||||
'2026-08-03T12:00:00',
|
|
||||||
new DateTimeZone('UTC'),
|
|
||||||
);
|
|
||||||
$this->userRepository = new FakeUserRepository;
|
|
||||||
$this->tokenRepository = new FakeEmailConfirmationTokenRepository;
|
|
||||||
$this->confirmUserEmail = new ConfirmUserEmail(
|
|
||||||
$this->tokenRepository,
|
|
||||||
$this->userRepository,
|
|
||||||
new FakePasswordHasher,
|
|
||||||
new FakeClock($this->now),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_sets_the_password_and_consumes_the_token(): void
|
|
||||||
{
|
|
||||||
$this->createPendingUserToken(
|
|
||||||
'confirmation-token',
|
|
||||||
$this->now->modify('+10 minutes'),
|
|
||||||
);
|
|
||||||
|
|
||||||
$confirmedUser = $this->confirmUserEmail->execute(
|
|
||||||
new ConfirmUserEmailRequest(
|
|
||||||
token: 'confirmation-token',
|
|
||||||
password: 'password123',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->assertSame('hashed:password123', $confirmedUser->getPasswordHash());
|
|
||||||
$this->assertSame(
|
|
||||||
'hashed:password123',
|
|
||||||
$this->userRepository->find($confirmedUser->getId())
|
|
||||||
?->getPasswordHash(),
|
|
||||||
);
|
|
||||||
$this->assertNull(
|
|
||||||
$this->tokenRepository->findByToken('confirmation-token'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_rejects_an_expired_token(): void
|
|
||||||
{
|
|
||||||
$this->createPendingUserToken(
|
|
||||||
'expired-token',
|
|
||||||
$this->now->modify('-1 minute'),
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->expectException(DomainException::class);
|
|
||||||
$this->expectExceptionMessage('token expired');
|
|
||||||
|
|
||||||
$this->confirmUserEmail->execute(new ConfirmUserEmailRequest(
|
|
||||||
token: 'expired-token',
|
|
||||||
password: 'password123',
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_rejects_an_unknown_token(): void
|
|
||||||
{
|
|
||||||
$this->expectException(DomainException::class);
|
|
||||||
$this->expectExceptionMessage('token not found');
|
|
||||||
|
|
||||||
$this->confirmUserEmail->execute(new ConfirmUserEmailRequest(
|
|
||||||
token: 'unknown-token',
|
|
||||||
password: 'password123',
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_requires_a_token(): void
|
|
||||||
{
|
|
||||||
$this->expectException(BadRequestException::class);
|
|
||||||
$this->expectExceptionMessage('token is required');
|
|
||||||
|
|
||||||
$this->confirmUserEmail->execute(new ConfirmUserEmailRequest(
|
|
||||||
token: null,
|
|
||||||
password: 'password123',
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_requires_a_password(): void
|
|
||||||
{
|
|
||||||
$this->expectException(BadRequestException::class);
|
|
||||||
$this->expectExceptionMessage('password is required');
|
|
||||||
|
|
||||||
$this->confirmUserEmail->execute(new ConfirmUserEmailRequest(
|
|
||||||
token: 'confirmation-token',
|
|
||||||
password: null,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_rejects_a_short_password(): void
|
|
||||||
{
|
|
||||||
$this->expectException(BadRequestException::class);
|
|
||||||
$this->expectExceptionMessage(
|
|
||||||
'password must be at least 8 characters',
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->confirmUserEmail->execute(new ConfirmUserEmailRequest(
|
|
||||||
token: 'confirmation-token',
|
|
||||||
password: 'short',
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createPendingUserToken(
|
|
||||||
string $token,
|
|
||||||
DateTimeImmutable $availableTo,
|
|
||||||
): void {
|
|
||||||
$user = $this->userRepository->create(new CreateUserDto(
|
|
||||||
email: new EmailAddress('user@example.com'),
|
|
||||||
passwordHash: null,
|
|
||||||
));
|
|
||||||
$this->tokenRepository->create(
|
|
||||||
new CreateEmailConfirmationTokenDto(
|
|
||||||
user: $user,
|
|
||||||
availableTo: $availableTo,
|
|
||||||
token: $token,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Unit\User\UseCases;
|
|
||||||
|
|
||||||
use App\Email\EmailConfirmationToken\UseCases\CreateEmailConfirmationToken;
|
|
||||||
use App\Exceptions\BadRequestException;
|
|
||||||
use App\Shared\ValueObject\EmailAddress;
|
|
||||||
use App\User\CreateUserDto;
|
|
||||||
use App\User\UseCases\SignupUser\SignupUser;
|
|
||||||
use App\User\UseCases\SignupUser\SignupUserRequest;
|
|
||||||
use DateTimeImmutable;
|
|
||||||
use DateTimeZone;
|
|
||||||
use DomainException;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
use Tests\Fakes\FakeClock;
|
|
||||||
use Tests\Fakes\FakeEmailConfirmationTokenRepository;
|
|
||||||
use Tests\Fakes\FakeEmailer;
|
|
||||||
use Tests\Fakes\FakeEmailFactory;
|
|
||||||
use Tests\Fakes\FakeTokenGenerator;
|
|
||||||
use Tests\Fakes\FakeUserRepository;
|
|
||||||
|
|
||||||
class SignupUserTest extends TestCase
|
|
||||||
{
|
|
||||||
private FakeUserRepository $userRepository;
|
|
||||||
|
|
||||||
private FakeEmailConfirmationTokenRepository $tokenRepository;
|
|
||||||
|
|
||||||
private FakeEmailer $emailer;
|
|
||||||
|
|
||||||
private FakeEmailFactory $emailFactory;
|
|
||||||
|
|
||||||
private SignupUser $signupUser;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
|
||||||
{
|
|
||||||
$this->userRepository = new FakeUserRepository;
|
|
||||||
$this->tokenRepository = new FakeEmailConfirmationTokenRepository;
|
|
||||||
$this->emailer = new FakeEmailer;
|
|
||||||
$this->emailFactory = new FakeEmailFactory;
|
|
||||||
$createToken = new CreateEmailConfirmationToken(
|
|
||||||
$this->tokenRepository,
|
|
||||||
new FakeClock(new DateTimeImmutable(
|
|
||||||
'2026-08-03T12:00:00',
|
|
||||||
new DateTimeZone('UTC'),
|
|
||||||
)),
|
|
||||||
new FakeTokenGenerator(['first-token', 'second-token']),
|
|
||||||
);
|
|
||||||
$this->signupUser = new SignupUser(
|
|
||||||
$this->userRepository,
|
|
||||||
$createToken,
|
|
||||||
$this->emailer,
|
|
||||||
$this->emailFactory,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_creates_a_pending_user_token_and_email(): void
|
|
||||||
{
|
|
||||||
$this->signupUser->execute(new SignupUserRequest(
|
|
||||||
email: ' Founder@EXAMPLE.COM ',
|
|
||||||
));
|
|
||||||
|
|
||||||
$user = $this->userRepository->findByEmail(
|
|
||||||
new EmailAddress('Founder@example.com'),
|
|
||||||
);
|
|
||||||
$this->assertNotNull($user);
|
|
||||||
$this->assertNull($user->getPasswordHash());
|
|
||||||
$token = $this->tokenRepository->findByUser($user);
|
|
||||||
$this->assertNotNull($token);
|
|
||||||
$this->assertSame('first-token', $token->getToken());
|
|
||||||
$this->assertSame(
|
|
||||||
'2026-08-03T12:10:00+00:00',
|
|
||||||
$token->getAvailableTo()->format('c'),
|
|
||||||
);
|
|
||||||
$this->assertSame(1, $this->emailer->getSendCount());
|
|
||||||
$this->assertSame(
|
|
||||||
'Founder@example.com',
|
|
||||||
$this->emailer->getLastRecipient()?->value(),
|
|
||||||
);
|
|
||||||
$this->assertSame(
|
|
||||||
'Confirm your Attainly email',
|
|
||||||
$this->emailer->getLastSubject(),
|
|
||||||
);
|
|
||||||
$this->assertSame('first-token', $this->emailFactory->getLastToken());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_replaces_the_token_for_a_pending_user(): void
|
|
||||||
{
|
|
||||||
$request = new SignupUserRequest(email: 'user@example.com');
|
|
||||||
$this->signupUser->execute($request);
|
|
||||||
$this->signupUser->execute($request);
|
|
||||||
|
|
||||||
$user = $this->userRepository->findByEmail(
|
|
||||||
new EmailAddress('user@example.com'),
|
|
||||||
);
|
|
||||||
$this->assertNotNull($user);
|
|
||||||
$this->assertSame(
|
|
||||||
'second-token',
|
|
||||||
$this->tokenRepository->findByUser($user)?->getToken(),
|
|
||||||
);
|
|
||||||
$this->assertSame(2, $this->emailer->getSendCount());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_rejects_an_existing_confirmed_account(): void
|
|
||||||
{
|
|
||||||
$this->userRepository->create(new CreateUserDto(
|
|
||||||
email: new EmailAddress('user@example.com'),
|
|
||||||
passwordHash: 'hashed-password',
|
|
||||||
));
|
|
||||||
|
|
||||||
$this->expectException(DomainException::class);
|
|
||||||
$this->expectExceptionMessage('user@example.com already has an account');
|
|
||||||
|
|
||||||
$this->signupUser->execute(new SignupUserRequest(
|
|
||||||
email: 'user@example.com',
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_rejects_a_missing_email(): void
|
|
||||||
{
|
|
||||||
$this->expectException(BadRequestException::class);
|
|
||||||
$this->expectExceptionMessage('email is required');
|
|
||||||
|
|
||||||
$this->signupUser->execute(new SignupUserRequest(email: null));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_rejects_an_invalid_email(): void
|
|
||||||
{
|
|
||||||
$this->expectException(BadRequestException::class);
|
|
||||||
$this->expectExceptionMessage('email must be valid');
|
|
||||||
|
|
||||||
$this->signupUser->execute(new SignupUserRequest(
|
|
||||||
email: 'not-an-email',
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -21,19 +21,4 @@ class UserTest extends TestCase
|
||||||
$this->assertSame($email, $user->getEmail());
|
$this->assertSame($email, $user->getEmail());
|
||||||
$this->assertSame('hashed-password', $user->getPasswordHash());
|
$this->assertSame('hashed-password', $user->getPasswordHash());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_can_confirm_a_pending_user_with_a_password(): void
|
|
||||||
{
|
|
||||||
$user = new User(
|
|
||||||
id: 42,
|
|
||||||
email: new EmailAddress('user@example.com'),
|
|
||||||
passwordHash: null,
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->assertNull($user->getPasswordHash());
|
|
||||||
|
|
||||||
$user->setPasswordHash('hashed-password');
|
|
||||||
|
|
||||||
$this->assertSame('hashed-password', $user->getPasswordHash());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
const authenticatedUser = {
|
|
||||||
id: 7,
|
|
||||||
email: 'user@example.com',
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('email confirmation', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.intercept('GET', '**/api/me', {
|
|
||||||
statusCode: 401,
|
|
||||||
body: { error: 'unauthenticated' },
|
|
||||||
}).as('me')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('chooses a password, confirms the account, and opens the dashboard', () => {
|
|
||||||
cy.intercept('POST', '**/api/confirm-email', (request) => {
|
|
||||||
expect(request.headers.accept).to.equal('application/json')
|
|
||||||
expect(request.body).to.deep.equal({
|
|
||||||
token: 'confirmation-token',
|
|
||||||
password: 'password123',
|
|
||||||
})
|
|
||||||
request.reply({
|
|
||||||
statusCode: 200,
|
|
||||||
body: { user: authenticatedUser },
|
|
||||||
})
|
|
||||||
}).as('confirmEmail')
|
|
||||||
|
|
||||||
cy.visit('/confirm-email?token=confirmation-token')
|
|
||||||
cy.get('#confirm-email-password').type('password123')
|
|
||||||
cy.get('#confirm-email-password-confirmation').type('password123')
|
|
||||||
cy.get('form').submit()
|
|
||||||
cy.wait('@confirmEmail')
|
|
||||||
|
|
||||||
cy.location('pathname').should('equal', '/dashboard')
|
|
||||||
cy.get('h1').should('have.text', 'Your next step starts here.')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('validates password length and confirmation before submitting', () => {
|
|
||||||
cy.intercept('POST', '**/api/confirm-email').as('confirmEmail')
|
|
||||||
|
|
||||||
cy.visit('/confirm-email?token=confirmation-token')
|
|
||||||
cy.get('#confirm-email-password').type('short')
|
|
||||||
cy.get('#confirm-email-password-confirmation').type('different')
|
|
||||||
cy.get('form').submit()
|
|
||||||
|
|
||||||
cy.get('#confirm-email-password-error')
|
|
||||||
.should('have.text', 'Password must be at least 8 characters.')
|
|
||||||
.and('be.visible')
|
|
||||||
cy.get('#confirm-email-password-confirmation-error')
|
|
||||||
.should('have.text', 'Passwords do not match.')
|
|
||||||
.and('be.visible')
|
|
||||||
cy.get('@confirmEmail.all').should('have.length', 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows confirmation errors from the backend', () => {
|
|
||||||
cy.intercept('POST', '**/api/confirm-email', {
|
|
||||||
statusCode: 409,
|
|
||||||
body: { error: 'token expired' },
|
|
||||||
}).as('confirmEmail')
|
|
||||||
|
|
||||||
cy.visit('/confirm-email?token=expired-token')
|
|
||||||
cy.get('#confirm-email-password').type('password123')
|
|
||||||
cy.get('#confirm-email-password-confirmation').type('password123')
|
|
||||||
cy.get('form').submit()
|
|
||||||
cy.wait('@confirmEmail')
|
|
||||||
|
|
||||||
cy.get('[role="alert"]')
|
|
||||||
.should('have.text', 'token expired')
|
|
||||||
.and('be.visible')
|
|
||||||
cy.location('pathname').should('equal', '/confirm-email')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('redirects a confirmation route without a token to signup', () => {
|
|
||||||
cy.visit('/confirm-email')
|
|
||||||
|
|
||||||
cy.location('pathname').should('equal', '/signup')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -42,18 +42,28 @@ describe('guest authentication pages', () => {
|
||||||
cy.visit('/signup')
|
cy.visit('/signup')
|
||||||
|
|
||||||
cy.get('h1').should('have.text', 'Start your journey')
|
cy.get('h1').should('have.text', 'Start your journey')
|
||||||
|
cy.get('label[for="signup-name"]').should('have.text', 'Full name')
|
||||||
|
cy.get('#signup-name').should('have.attr', 'autocomplete', 'name')
|
||||||
cy.get('label[for="signup-email"]').should('have.text', 'Email address')
|
cy.get('label[for="signup-email"]').should('have.text', 'Email address')
|
||||||
cy.get('#signup-email').should('have.attr', 'autocomplete', 'email')
|
cy.get('#signup-email').should('have.attr', 'autocomplete', 'email')
|
||||||
cy.get('#signup-name').should('not.exist')
|
cy.get('label[for="signup-password"]').should('have.text', 'Password')
|
||||||
cy.get('#signup-password').should('not.exist')
|
cy.get('#signup-password').should('have.attr', 'autocomplete', 'new-password')
|
||||||
cy.get('#signup-password-confirmation').should('not.exist')
|
cy.get('label[for="signup-password-confirmation"]').should(
|
||||||
cy.get('button[type="submit"]').should('have.text', 'Continue with email')
|
'have.text',
|
||||||
|
'Confirm password',
|
||||||
|
)
|
||||||
|
cy.get('#signup-password-confirmation').should(
|
||||||
|
'have.attr',
|
||||||
|
'autocomplete',
|
||||||
|
'new-password',
|
||||||
|
)
|
||||||
|
cy.get('button[type="submit"]').should('have.text', 'Create account')
|
||||||
|
|
||||||
cy.contains('a', 'Log in').click()
|
cy.contains('a', 'Log in').click()
|
||||||
cy.location('pathname').should('equal', '/login')
|
cy.location('pathname').should('equal', '/login')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps invalid form submissions on their current route', () => {
|
it('keeps UI-only form submissions on their current route', () => {
|
||||||
cy.visit('/login')
|
cy.visit('/login')
|
||||||
cy.get('form').submit()
|
cy.get('form').submit()
|
||||||
cy.location('pathname').should('equal', '/login')
|
cy.location('pathname').should('equal', '/login')
|
||||||
|
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
describe('email signup', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.intercept('GET', '**/api/me', {
|
|
||||||
statusCode: 401,
|
|
||||||
body: { error: 'unauthenticated' },
|
|
||||||
}).as('me')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('requests a confirmation email and shows the check-email page', () => {
|
|
||||||
cy.intercept('POST', '**/api/signup', (request) => {
|
|
||||||
expect(request.headers.accept).to.equal('application/json')
|
|
||||||
expect(request.body).to.deep.equal({ email: 'user@example.com' })
|
|
||||||
request.reply({ statusCode: 201 })
|
|
||||||
}).as('signup')
|
|
||||||
|
|
||||||
cy.visit('/signup')
|
|
||||||
cy.get('#signup-email').type(' user@example.com ')
|
|
||||||
cy.get('form').submit()
|
|
||||||
cy.wait('@signup')
|
|
||||||
|
|
||||||
cy.location('pathname').should('equal', '/check-email')
|
|
||||||
cy.get('h1').should('have.text', 'Check your email')
|
|
||||||
cy.contains('We sent you a link to confirm your signup.').should('be.visible')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('validates the email before submitting', () => {
|
|
||||||
cy.intercept('POST', '**/api/signup').as('signup')
|
|
||||||
|
|
||||||
cy.visit('/signup')
|
|
||||||
cy.get('#signup-email').type('not-an-email')
|
|
||||||
cy.get('form').submit()
|
|
||||||
|
|
||||||
cy.get('#signup-email-error')
|
|
||||||
.should('have.text', 'Enter a valid email address.')
|
|
||||||
.and('be.visible')
|
|
||||||
cy.get('#signup-email').should('have.attr', 'aria-invalid', 'true')
|
|
||||||
cy.get('@signup.all').should('have.length', 0)
|
|
||||||
cy.location('pathname').should('equal', '/signup')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows backend signup errors', () => {
|
|
||||||
cy.intercept('POST', '**/api/signup', {
|
|
||||||
statusCode: 409,
|
|
||||||
body: { error: 'user@example.com already has an account' },
|
|
||||||
}).as('signup')
|
|
||||||
|
|
||||||
cy.visit('/signup')
|
|
||||||
cy.get('#signup-email').type('user@example.com')
|
|
||||||
cy.get('form').submit()
|
|
||||||
cy.wait('@signup')
|
|
||||||
|
|
||||||
cy.get('[role="alert"]')
|
|
||||||
.should('have.text', 'user@example.com already has an account')
|
|
||||||
.and('be.visible')
|
|
||||||
cy.location('pathname').should('equal', '/signup')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('redirects direct check-email visits back to signup', () => {
|
|
||||||
cy.visit('/check-email')
|
|
||||||
|
|
||||||
cy.location('pathname').should('equal', '/signup')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -29,32 +29,6 @@ const router = createRouter({
|
||||||
guestOnly: true,
|
guestOnly: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/check-email',
|
|
||||||
name: 'check-email',
|
|
||||||
component: () => import('@/views/CheckEmailView.vue'),
|
|
||||||
meta: {
|
|
||||||
guestOnly: true,
|
|
||||||
},
|
|
||||||
beforeEnter: () => {
|
|
||||||
if (!useAuthStore().signupCompleted) {
|
|
||||||
return { name: 'signup' }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/confirm-email',
|
|
||||||
name: 'confirm-email',
|
|
||||||
component: () => import('@/views/ConfirmEmailView.vue'),
|
|
||||||
meta: {
|
|
||||||
guestOnly: true,
|
|
||||||
},
|
|
||||||
beforeEnter: (to) => {
|
|
||||||
if (typeof to.query.token !== 'string' || to.query.token === '') {
|
|
||||||
return { name: 'signup' }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/dashboard',
|
path: '/dashboard',
|
||||||
name: 'dashboard',
|
name: 'dashboard',
|
||||||
|
|
|
||||||
|
|
@ -13,20 +13,17 @@ const meResponseSchema = z.object({
|
||||||
user: authUserSchema,
|
user: authUserSchema,
|
||||||
})
|
})
|
||||||
|
|
||||||
const authErrorResponseSchema = z.object({
|
const loginErrorResponseSchema = z.object({
|
||||||
error: z.string(),
|
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 type LoginFieldErrors = Partial<Record<'email' | 'password', string>>
|
||||||
export type SignupFieldErrors = Partial<Record<'email', string>>
|
|
||||||
export type ConfirmEmailFieldErrors = Partial<Record<'password' | 'passwordConfirmation', string>>
|
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const user = ref<AuthUser | null>(null)
|
const user = ref<AuthUser | null>(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const signupCompleted = ref(false)
|
|
||||||
const isAuthenticated = computed(() => user.value !== null)
|
const isAuthenticated = computed(() => user.value !== null)
|
||||||
|
|
||||||
async function fetchMe(): Promise<boolean> {
|
async function fetchMe(): Promise<boolean> {
|
||||||
|
|
@ -88,7 +85,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
user.value = null
|
user.value = null
|
||||||
const errorResponse = authErrorResponseSchema.safeParse(responseBody)
|
const errorResponse = loginErrorResponseSchema.safeParse(responseBody)
|
||||||
error.value = errorResponse.success
|
error.value = errorResponse.success
|
||||||
? errorResponse.data.error
|
? errorResponse.data.error
|
||||||
: 'Unable to log in. Please try again.'
|
: 'Unable to log in. Please try again.'
|
||||||
|
|
@ -104,76 +101,6 @@ export const useAuthStore = defineStore('auth', () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function signup(email: string): Promise<boolean> {
|
|
||||||
loading.value = true
|
|
||||||
error.value = null
|
|
||||||
signupCompleted.value = false
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE}/api/signup`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Accept: 'application/json',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ email }),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.status === 201) {
|
|
||||||
signupCompleted.value = true
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
error.value = await responseError(response, 'Unable to sign up. Please try again.')
|
|
||||||
|
|
||||||
return false
|
|
||||||
} catch {
|
|
||||||
error.value = 'Unable to sign up. Please try again.'
|
|
||||||
|
|
||||||
return false
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmEmail(token: string, password: string): Promise<boolean> {
|
|
||||||
loading.value = true
|
|
||||||
error.value = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE}/api/confirm-email`, {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
headers: {
|
|
||||||
Accept: 'application/json',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ token, password }),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.status === 200) {
|
|
||||||
const responseBody: unknown = await response.json()
|
|
||||||
user.value = meResponseSchema.parse(responseBody).user
|
|
||||||
signupCompleted.value = false
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
user.value = null
|
|
||||||
error.value = await responseError(response, 'Unable to confirm your email. Please try again.')
|
|
||||||
|
|
||||||
return false
|
|
||||||
} catch {
|
|
||||||
user.value = null
|
|
||||||
error.value = 'Unable to confirm your email. Please try again.'
|
|
||||||
|
|
||||||
return false
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function logout(): Promise<void> {
|
async function logout(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await fetch(`${API_BASE}/api/logout`, {
|
await fetch(`${API_BASE}/api/logout`, {
|
||||||
|
|
@ -192,23 +119,9 @@ export const useAuthStore = defineStore('auth', () => {
|
||||||
user,
|
user,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
signupCompleted,
|
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
fetchMe,
|
fetchMe,
|
||||||
login,
|
login,
|
||||||
signup,
|
|
||||||
confirmEmail,
|
|
||||||
logout,
|
logout,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function responseError(response: Response, fallback: string): Promise<string> {
|
|
||||||
try {
|
|
||||||
const responseBody: unknown = await response.json()
|
|
||||||
const parsedError = authErrorResponseSchema.safeParse(responseBody)
|
|
||||||
|
|
||||||
return parsedError.success ? parsedError.data.error : fallback
|
|
||||||
} catch {
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import AuthLayout from '@/components/AuthLayout.vue'
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<AuthLayout
|
|
||||||
eyebrow="One more step"
|
|
||||||
title="Check your email"
|
|
||||||
description="We sent you a link to confirm your signup."
|
|
||||||
>
|
|
||||||
<div class="check-email-message">
|
|
||||||
<p>Open the link in your email to choose a password and finish creating your account.</p>
|
|
||||||
<p>The confirmation link expires in 10 minutes.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template #footer>
|
|
||||||
Already confirmed?
|
|
||||||
<RouterLink to="/login">Log in</RouterLink>
|
|
||||||
</template>
|
|
||||||
</AuthLayout>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.check-email-message {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.8rem;
|
|
||||||
padding: 1.15rem 1.25rem;
|
|
||||||
border: 1px solid #d8dcd7;
|
|
||||||
border-radius: 0.85rem;
|
|
||||||
color: #52605a;
|
|
||||||
background: #fbfcf9;
|
|
||||||
font-size: 0.88rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.check-email-message p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
<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 AuthLayout from '@/components/AuthLayout.vue'
|
|
||||||
import AuthTextField from '@/components/AuthTextField.vue'
|
|
||||||
import { type ConfirmEmailFieldErrors, useAuthStore } from '@/stores/auth'
|
|
||||||
|
|
||||||
const confirmEmailSchema = z
|
|
||||||
.object({
|
|
||||||
password: z.string().min(8, 'Password must be at least 8 characters.'),
|
|
||||||
passwordConfirmation: z.string(),
|
|
||||||
})
|
|
||||||
.refine((values) => values.password === values.passwordConfirmation, {
|
|
||||||
message: 'Passwords do not match.',
|
|
||||||
path: ['passwordConfirmation'],
|
|
||||||
})
|
|
||||||
|
|
||||||
const password = ref('')
|
|
||||||
const passwordConfirmation = ref('')
|
|
||||||
const fieldErrors = ref<ConfirmEmailFieldErrors>({})
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
const { loading, error } = storeToRefs(authStore)
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
async function submitConfirmation(): Promise<void> {
|
|
||||||
const result = confirmEmailSchema.safeParse({
|
|
||||||
password: password.value,
|
|
||||||
passwordConfirmation: passwordConfirmation.value,
|
|
||||||
})
|
|
||||||
if (!result.success) {
|
|
||||||
fieldErrors.value = {}
|
|
||||||
for (const issue of result.error.issues) {
|
|
||||||
const field = issue.path[0]
|
|
||||||
if (field === 'password' || field === 'passwordConfirmation') {
|
|
||||||
fieldErrors.value[field] ??= issue.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = route.query.token
|
|
||||||
if (typeof token !== 'string' || token === '') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fieldErrors.value = {}
|
|
||||||
const confirmationSucceeded = await authStore.confirmEmail(token, result.data.password)
|
|
||||||
if (confirmationSucceeded) {
|
|
||||||
await router.push({ name: 'dashboard' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<AuthLayout
|
|
||||||
eyebrow="Finish your account"
|
|
||||||
title="Choose your password"
|
|
||||||
description="Secure your account, then keep moving toward what matters."
|
|
||||||
>
|
|
||||||
<AuthForm
|
|
||||||
submit-label="Create account"
|
|
||||||
submitting-label="Creating account..."
|
|
||||||
:submitting="loading"
|
|
||||||
:error="error ?? undefined"
|
|
||||||
@submit="submitConfirmation"
|
|
||||||
>
|
|
||||||
<AuthTextField
|
|
||||||
id="confirm-email-password"
|
|
||||||
label="Password"
|
|
||||||
type="password"
|
|
||||||
autocomplete="new-password"
|
|
||||||
placeholder="Create a password"
|
|
||||||
v-model="password"
|
|
||||||
:error="fieldErrors.password"
|
|
||||||
:disabled="loading"
|
|
||||||
/>
|
|
||||||
<AuthTextField
|
|
||||||
id="confirm-email-password-confirmation"
|
|
||||||
label="Confirm password"
|
|
||||||
type="password"
|
|
||||||
autocomplete="new-password"
|
|
||||||
placeholder="Repeat your password"
|
|
||||||
v-model="passwordConfirmation"
|
|
||||||
:error="fieldErrors.passwordConfirmation"
|
|
||||||
:disabled="loading"
|
|
||||||
/>
|
|
||||||
</AuthForm>
|
|
||||||
|
|
||||||
<template #footer>
|
|
||||||
Already have an account?
|
|
||||||
<RouterLink to="/login">Log in</RouterLink>
|
|
||||||
</template>
|
|
||||||
</AuthLayout>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,40 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
|
||||||
import { storeToRefs } from 'pinia'
|
|
||||||
import { 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 SignupFieldErrors, useAuthStore } from '@/stores/auth'
|
|
||||||
|
|
||||||
const signupSchema = z.object({
|
|
||||||
email: z.string().email('Enter a valid email address.'),
|
|
||||||
})
|
|
||||||
const email = ref('')
|
|
||||||
const fieldErrors = ref<SignupFieldErrors>({})
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
const { loading, error } = storeToRefs(authStore)
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
async function submitSignup(): Promise<void> {
|
|
||||||
const normalizedEmail = email.value.trim()
|
|
||||||
const result = signupSchema.safeParse({ email: normalizedEmail })
|
|
||||||
if (!result.success) {
|
|
||||||
fieldErrors.value = {
|
|
||||||
email: result.error.issues[0]?.message ?? 'Enter a valid email address.',
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fieldErrors.value = {}
|
|
||||||
const signupSucceeded = await authStore.signup(normalizedEmail)
|
|
||||||
if (signupSucceeded) {
|
|
||||||
await router.push({ name: 'check-email' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -43,22 +10,34 @@ async function submitSignup(): Promise<void> {
|
||||||
title="Start your journey"
|
title="Start your journey"
|
||||||
description="Create your space to turn ambitious goals into steady progress."
|
description="Create your space to turn ambitious goals into steady progress."
|
||||||
>
|
>
|
||||||
<AuthForm
|
<AuthForm submit-label="Create account">
|
||||||
submit-label="Continue with email"
|
<AuthTextField
|
||||||
submitting-label="Sending link..."
|
id="signup-name"
|
||||||
:submitting="loading"
|
label="Full name"
|
||||||
:error="error ?? undefined"
|
type="text"
|
||||||
@submit="submitSignup"
|
autocomplete="name"
|
||||||
>
|
placeholder="Your full name"
|
||||||
|
/>
|
||||||
<AuthTextField
|
<AuthTextField
|
||||||
id="signup-email"
|
id="signup-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"
|
<AuthTextField
|
||||||
:disabled="loading"
|
id="signup-password"
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
placeholder="Create a password"
|
||||||
|
/>
|
||||||
|
<AuthTextField
|
||||||
|
id="signup-password-confirmation"
|
||||||
|
label="Confirm password"
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
placeholder="Repeat your password"
|
||||||
/>
|
/>
|
||||||
</AuthForm>
|
</AuthForm>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue