add email signup flow
This commit is contained in:
parent
9945163a2f
commit
3dc9204979
27 changed files with 629 additions and 24 deletions
|
|
@ -36,9 +36,14 @@ class AuthenticateUser
|
|||
throw new UnauthorizedException('invalid credentials');
|
||||
}
|
||||
|
||||
$passwordHash = $user->getPasswordHash();
|
||||
if ($passwordHash === null) {
|
||||
throw new UnauthorizedException('invalid credentials');
|
||||
}
|
||||
|
||||
$passwordMatches = $this->hasher->verify(
|
||||
$request->password,
|
||||
$user->getPasswordHash(),
|
||||
$passwordHash,
|
||||
);
|
||||
if (! $passwordMatches) {
|
||||
throw new UnauthorizedException('invalid credentials');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
<?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,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
<?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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
<?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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<?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;
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
<?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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<?php
|
||||
|
||||
namespace App\Email\EmailConfirmationToken\UseCases;
|
||||
|
||||
use App\User\User;
|
||||
|
||||
final readonly class CreateEmailConfirmationTokenRequest
|
||||
{
|
||||
public function __construct(
|
||||
public ?User $user,
|
||||
public ?int $minuteOffset,
|
||||
) {}
|
||||
}
|
||||
8
backend/app/Email/EmailFactory.php
Normal file
8
backend/app/Email/EmailFactory.php
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?php
|
||||
|
||||
namespace App\Email;
|
||||
|
||||
interface EmailFactory
|
||||
{
|
||||
public function makeConfirmationEmail(string $token): string;
|
||||
}
|
||||
14
backend/app/Email/Emailer.php
Normal file
14
backend/app/Email/Emailer.php
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<?php
|
||||
|
||||
namespace App\Email;
|
||||
|
||||
use App\Shared\ValueObject\EmailAddress;
|
||||
|
||||
interface Emailer
|
||||
{
|
||||
public function send(
|
||||
EmailAddress $recipient,
|
||||
string $subject,
|
||||
string $body,
|
||||
): void;
|
||||
}
|
||||
25
backend/app/Email/LaravelEmailFactory.php
Normal file
25
backend/app/Email/LaravelEmailFactory.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?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.';
|
||||
}
|
||||
}
|
||||
25
backend/app/Email/LaravelEmailer.php
Normal file
25
backend/app/Email/LaravelEmailer.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?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,7 +10,12 @@ use App\Exceptions\BadRequestException;
|
|||
use App\Exceptions\UnauthorizedException;
|
||||
use App\Http\Middleware\AuthMiddleware;
|
||||
use App\Shared\Http\RequestInput;
|
||||
use App\User\UseCases\ConfirmUserEmail\ConfirmUserEmail;
|
||||
use App\User\UseCases\ConfirmUserEmail\ConfirmUserEmailRequest;
|
||||
use App\User\UseCases\SignupUser\SignupUser;
|
||||
use App\User\UseCases\SignupUser\SignupUserRequest;
|
||||
use App\User\User;
|
||||
use DomainException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Cookie;
|
||||
|
|
@ -18,11 +23,62 @@ use Symfony\Component\HttpFoundation\Cookie;
|
|||
class AuthController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private SignupUser $signupUser,
|
||||
private ConfirmUserEmail $confirmUserEmail,
|
||||
private AuthenticateUser $authenticateUser,
|
||||
private CreateSession $createSession,
|
||||
private Logout $logout,
|
||||
) {}
|
||||
|
||||
public function signup(Request $request): JsonResponse
|
||||
{
|
||||
$input = new RequestInput($request);
|
||||
|
||||
try {
|
||||
$this->signupUser->execute(new SignupUserRequest(
|
||||
email: $input->string('email'),
|
||||
));
|
||||
} catch (BadRequestException $exception) {
|
||||
return new JsonResponse(
|
||||
['error' => $exception->getMessage()],
|
||||
400,
|
||||
);
|
||||
} catch (DomainException $exception) {
|
||||
return new JsonResponse(
|
||||
['error' => $exception->getMessage()],
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
return new JsonResponse(null, 201);
|
||||
}
|
||||
|
||||
public function confirmEmail(Request $request): JsonResponse
|
||||
{
|
||||
$input = new RequestInput($request);
|
||||
|
||||
try {
|
||||
$user = $this->confirmUserEmail->execute(
|
||||
new ConfirmUserEmailRequest(
|
||||
token: $input->string('token'),
|
||||
password: $input->string('password'),
|
||||
),
|
||||
);
|
||||
} catch (BadRequestException $exception) {
|
||||
return new JsonResponse(
|
||||
['error' => $exception->getMessage()],
|
||||
400,
|
||||
);
|
||||
} catch (DomainException $exception) {
|
||||
return new JsonResponse(
|
||||
['error' => $exception->getMessage()],
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
return $this->authenticatedResponse($user);
|
||||
}
|
||||
|
||||
public function login(Request $request): JsonResponse
|
||||
{
|
||||
$input = new RequestInput($request);
|
||||
|
|
@ -44,23 +100,7 @@ class AuthController extends Controller
|
|||
);
|
||||
}
|
||||
|
||||
$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,
|
||||
));
|
||||
return $this->authenticatedResponse($user);
|
||||
}
|
||||
|
||||
public function me(Request $request): JsonResponse
|
||||
|
|
@ -105,4 +145,24 @@ class AuthController extends Controller
|
|||
'email' => $user->getEmail()->value(),
|
||||
];
|
||||
}
|
||||
|
||||
private function authenticatedResponse(User $user): JsonResponse
|
||||
{
|
||||
$session = $this->createSession->execute($user);
|
||||
$response = new JsonResponse([
|
||||
'user' => $this->userPayload($user),
|
||||
]);
|
||||
|
||||
return $response->withCookie(Cookie::create(
|
||||
name: AuthMiddleware::COOKIE_NAME,
|
||||
value: $session->getToken(),
|
||||
expire: $session->getExpiresAt()->getTimestamp(),
|
||||
path: '/',
|
||||
domain: null,
|
||||
secure: false,
|
||||
httpOnly: true,
|
||||
raw: false,
|
||||
sameSite: Cookie::SAMESITE_LAX,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,12 @@ use App\Auth\RandomTokenGenerator;
|
|||
use App\Auth\SessionRepository;
|
||||
use App\Auth\SystemClock;
|
||||
use App\Auth\TokenGenerator;
|
||||
use App\Email\EmailConfirmationToken\EloquentEmailConfirmationTokenRepository;
|
||||
use App\Email\EmailConfirmationToken\EmailConfirmationTokenRepository;
|
||||
use App\Email\Emailer;
|
||||
use App\Email\EmailFactory;
|
||||
use App\Email\LaravelEmailer;
|
||||
use App\Email\LaravelEmailFactory;
|
||||
use App\User\EloquentUserRepository;
|
||||
use App\User\UserRepository;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
|
@ -33,6 +39,12 @@ class AppServiceProvider extends ServiceProvider
|
|||
SessionRepository::class,
|
||||
EloquentSessionRepository::class,
|
||||
);
|
||||
$this->app->bind(
|
||||
EmailConfirmationTokenRepository::class,
|
||||
EloquentEmailConfirmationTokenRepository::class,
|
||||
);
|
||||
$this->app->bind(Emailer::class, LaravelEmailer::class);
|
||||
$this->app->bind(EmailFactory::class, LaravelEmailFactory::class);
|
||||
$this->app->bind(PasswordHasher::class, BcryptPasswordHasher::class);
|
||||
$this->app->bind(TokenGenerator::class, RandomTokenGenerator::class);
|
||||
$this->app->bind(Clock::class, SystemClock::class);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,6 @@ final readonly class CreateUserDto
|
|||
{
|
||||
public function __construct(
|
||||
public EmailAddress $email,
|
||||
public string $passwordHash,
|
||||
public ?string $passwordHash,
|
||||
) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\User;
|
||||
|
||||
use App\Shared\ValueObject\EmailAddress;
|
||||
use DomainException;
|
||||
|
||||
class EloquentUserRepository implements UserRepository
|
||||
{
|
||||
|
|
@ -38,6 +39,22 @@ class EloquentUserRepository implements UserRepository
|
|||
return $this->toDomain($model);
|
||||
}
|
||||
|
||||
public function update(User $user): User
|
||||
{
|
||||
$model = UserModel::find($user->getId());
|
||||
if ($model === null) {
|
||||
throw new DomainException(
|
||||
"User with id {$user->getId()} not found",
|
||||
);
|
||||
}
|
||||
|
||||
$model->email = $user->getEmail()->value();
|
||||
$model->passwordHash = $user->getPasswordHash();
|
||||
$model->save();
|
||||
|
||||
return $this->toDomain($model);
|
||||
}
|
||||
|
||||
private function toDomain(UserModel $model): User
|
||||
{
|
||||
return new User(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<?php
|
||||
|
||||
namespace App\User\UseCases\ConfirmUserEmail;
|
||||
|
||||
final readonly class ConfirmUserEmailRequest
|
||||
{
|
||||
public function __construct(
|
||||
public ?string $token,
|
||||
public ?string $password,
|
||||
) {}
|
||||
}
|
||||
79
backend/app/User/UseCases/SignupUser/SignupUser.php
Normal file
79
backend/app/User/UseCases/SignupUser/SignupUser.php
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<?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;
|
||||
|
||||
final readonly class User
|
||||
final class User
|
||||
{
|
||||
public function __construct(
|
||||
private int $id,
|
||||
private EmailAddress $email,
|
||||
private string $passwordHash,
|
||||
private ?string $passwordHash,
|
||||
) {}
|
||||
|
||||
public function getId(): int
|
||||
|
|
@ -22,8 +22,13 @@ final readonly class User
|
|||
return $this->email;
|
||||
}
|
||||
|
||||
public function getPasswordHash(): string
|
||||
public function getPasswordHash(): ?string
|
||||
{
|
||||
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 string $email
|
||||
* @property string $passwordHash
|
||||
* @property string|null $passwordHash
|
||||
*
|
||||
* @method static Builder<static>|UserModel newModelQuery()
|
||||
* @method static Builder<static>|UserModel newQuery()
|
||||
|
|
|
|||
|
|
@ -11,4 +11,6 @@ interface UserRepository
|
|||
public function find(int $id): ?User;
|
||||
|
||||
public function findByEmail(EmailAddress $email): ?User;
|
||||
|
||||
public function update(User $user): User;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,8 @@ return [
|
|||
|
||||
'url' => env('APP_URL', 'http://localhost'),
|
||||
|
||||
'frontend_url' => env('FRONTEND_URL', 'http://localhost:5173'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Timezone
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ return new class extends Migration
|
|||
Schema::create('users', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('email')->unique();
|
||||
$table->string('passwordHash');
|
||||
$table->string('passwordHash')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
<?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,6 +5,8 @@ use App\Http\Middleware\AuthMiddleware;
|
|||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::post('/login', [AuthController::class, 'login']);
|
||||
Route::post('/signup', [AuthController::class, 'signup']);
|
||||
Route::post('/confirm-email', [AuthController::class, 'confirmEmail']);
|
||||
|
||||
Route::middleware(AuthMiddleware::class)->group(function (): void {
|
||||
Route::get('/me', [AuthController::class, 'me']);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue