Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
ForgotPasswordAction
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 3
42
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 __invoke
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
20
 buildEmailHtml
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace App\Action\Auth;
6
7use App\Domain\Auth\Repository\AuthRepository;
8use App\Domain\Notification\Data\EmailMessage;
9use App\Domain\Notification\Service\EmailServiceInterface;
10use App\Renderer\JsonRenderer;
11use App\Support\Row;
12use DateTimeImmutable;
13use Psr\Http\Message\ResponseInterface;
14use Psr\Http\Message\ServerRequestInterface;
15
16use function bin2hex;
17use function random_bytes;
18use function sprintf;
19
20/**
21 * POST /api/auth/forgot-password
22 *
23 * Accepts { email } and sends a password reset link if the account exists.
24 * Always returns 200 to prevent email enumeration.
25 */
26final readonly class ForgotPasswordAction
27{
28    private const int TOKEN_EXPIRY_HOURS = 1;
29
30    /**
31     * @param array<string, mixed> $emailConfig
32     * @param AuthRepository $authRepository
33     * @param EmailServiceInterface $emailService
34     * @param JsonRenderer $renderer
35     */
36    public function __construct(
37        private AuthRepository $authRepository,
38        private EmailServiceInterface $emailService,
39        private JsonRenderer $renderer,
40        private array $emailConfig,
41    ) {}
42
43    public function __invoke(
44        ServerRequestInterface $request,
45        ResponseInterface $response,
46    ): ResponseInterface {
47        $data = (array)$request->getParsedBody();
48        $email = Row::nullableString($data, 'email') ?? '';
49
50        if ($email !== '') {
51            $user = $this->authRepository->findUserByEmail($email);
52
53            if ($user !== null && $user->isActive) {
54                $token = bin2hex(random_bytes(32));
55                $expiresAt = new DateTimeImmutable('+' . self::TOKEN_EXPIRY_HOURS . ' hour');
56
57                $this->authRepository->storePasswordResetToken($user->userId, $token, $expiresAt);
58
59                $appUrl = Row::nullableString($this->emailConfig, 'app_url') ?? 'http://localhost:3000';
60                $resetUrl = sprintf('%s/reset-password?token=%s', $appUrl, $token);
61
62                $this->emailService->send(new EmailMessage(
63                    to: $email,
64                    subject: 'FlowState Capital - Password Reset',
65                    bodyHtml: $this->buildEmailHtml($user->username, $resetUrl),
66                    bodyText: sprintf(
67                        "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",
68                        $user->username,
69                        self::TOKEN_EXPIRY_HOURS,
70                        $resetUrl,
71                    ),
72                ));
73            }
74        }
75
76        return $this->renderer->json($response, [
77            'success' => true,
78            'message' => 'If an account with that email exists, a reset link has been sent.',
79        ]);
80    }
81
82    private function buildEmailHtml(string $username, string $resetUrl): string
83    {
84        return <<<HTML
85            <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
86                <h2 style="color: #1976d2;">FlowState Capital</h2>
87                <p>Hi {$username},</p>
88                <p>You requested a password reset. Click the button below to set a new password:</p>
89                <p style="text-align: center; margin: 30px 0;">
90                    <a href="{$resetUrl}"
91                       style="background-color: #1976d2; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">
92                        Reset Password
93                    </a>
94                </p>
95                <p style="color: #666; font-size: 14px;">
96                    This link expires in 1 hour. If you didn't request this, you can safely ignore this email.
97                </p>
98                <hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">
99                <p style="color: #999; font-size: 12px;">FlowState Capital LLC</p>
100            </div>
101            HTML;
102    }
103}