Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
3 / 3
CRAP
100.00% covered (success)
100.00%
1 / 1
PasswordResetService
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
3 / 3
3
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 sendResetLink
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
 buildEmailHtml
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace App\Domain\Auth\Service;
6
7use App\Domain\Auth\Data\UserAuthData;
8use App\Domain\Auth\Repository\AuthRepository;
9use App\Domain\Notification\Data\EmailMessage;
10use App\Domain\Notification\Service\EmailServiceInterface;
11use DateTimeImmutable;
12
13use function bin2hex;
14use function random_bytes;
15use function sprintf;
16
17/**
18 * Issues single-use password-reset links.
19 *
20 * Extracted from ForgotPasswordAction (FSC-124) so the same token-generate →
21 * store → email path serves both the public forgot-password flow and the
22 * admin "send a reset link on the customer's behalf" flow. The caller resolves
23 * the base URL (it needs the request; see AppUrlResolver) and passes it in,
24 * keeping this service request-agnostic and unit-testable.
25 */
26final readonly class PasswordResetService
27{
28    private const int TOKEN_EXPIRY_HOURS = 1;
29
30    public function __construct(
31        private AuthRepository $authRepository,
32        private EmailServiceInterface $emailService,
33    ) {}
34
35    /**
36     * Mint a reset token for the user, persist it, and email the link.
37     *
38     * @param UserAuthData $user  the account receiving the link
39     * @param string $appUrl      already-resolved base URL (no trailing slash)
40     */
41    public function sendResetLink(UserAuthData $user, string $appUrl): void
42    {
43        $token = bin2hex(random_bytes(32));
44        $expiresAt = new DateTimeImmutable('+' . self::TOKEN_EXPIRY_HOURS . ' hour');
45
46        $this->authRepository->storePasswordResetToken($user->userId, $token, $expiresAt);
47
48        $resetUrl = sprintf('%s/reset-password?token=%s', $appUrl, $token);
49
50        $this->emailService->send(new EmailMessage(
51            to: $user->email,
52            subject: 'FlowState Capital - Password Reset',
53            bodyHtml: $this->buildEmailHtml($user->username, $resetUrl),
54            bodyText: sprintf(
55                "Hi %s,\n\nYou requested a password reset. Visit this link within %d hour:\n\n%s\n\nIf you didn't request this, you can ignore this email.\n\nFlowState Capital",
56                $user->username,
57                self::TOKEN_EXPIRY_HOURS,
58                $resetUrl,
59            ),
60        ));
61    }
62
63    private function buildEmailHtml(string $username, string $resetUrl): string
64    {
65        return <<<HTML
66            <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
67                <h2 style="color: #1976d2;">FlowState Capital</h2>
68                <p>Hi {$username},</p>
69                <p>You requested a password reset. Click the button below to set a new password:</p>
70                <p style="text-align: center; margin: 30px 0;">
71                    <a href="{$resetUrl}"
72                       style="background-color: #1976d2; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">
73                        Reset Password
74                    </a>
75                </p>
76                <p style="color: #666; font-size: 14px;">
77                    This link expires in 1 hour. If you didn't request this, you can safely ignore this email.
78                </p>
79                <hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">
80                <p style="color: #999; font-size: 12px;">FlowState Capital LLC</p>
81            </div>
82            HTML;
83    }
84}