Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.30% covered (success)
91.30%
21 / 23
33.33% covered (danger)
33.33%
1 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
SendInvestorPasswordResetAction
91.30% covered (success)
91.30%
21 / 23
33.33% covered (danger)
33.33%
1 / 3
9.05
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __invoke
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
 requireAdmin
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
6.02
1<?php
2
3declare(strict_types=1);
4
5namespace App\Action\Admin;
6
7use App\Domain\Auth\Data\AdminActor;
8use App\Domain\Auth\Data\UserAuthData;
9use App\Domain\Auth\Service\AdminPasswordService;
10use App\Domain\Exception\AuthenticationException;
11use App\Domain\Exception\ForbiddenException;
12use App\Domain\Exception\ValidationException;
13use App\Renderer\JsonRenderer;
14use App\Support\AppUrlResolver;
15use App\Support\Row;
16use Psr\Http\Message\ResponseInterface;
17use Psr\Http\Message\ServerRequestInterface;
18
19/**
20 * POST /api/admin/investors/{investorId}/send-password-reset
21 *
22 * Admin-triggered self-service reset: emails the customer a single-use reset
23 * link so they can set their own password (FSC-124).
24 *
25 * The /api/admin group is JWT-gated but not role-gated, so — like
26 * ImpersonateUserAction — this verifies the caller is staff in-action; the
27 * service then refuses to target another staff account.
28 */
29final readonly class SendInvestorPasswordResetAction
30{
31    /**
32     * @param array<string, mixed> $emailConfig
33     * @param AdminPasswordService $adminPasswordService
34     * @param JsonRenderer $renderer
35     */
36    public function __construct(
37        private AdminPasswordService $adminPasswordService,
38        private JsonRenderer $renderer,
39        private array $emailConfig,
40    ) {}
41
42    /**
43     * @param array<string, string> $args
44     * @param ServerRequestInterface $request
45     * @param ResponseInterface $response
46     */
47    public function __invoke(
48        ServerRequestInterface $request,
49        ResponseInterface $response,
50        array $args,
51    ): ResponseInterface {
52        $actor = $this->requireAdmin($request);
53
54        $investorId = (int)($args['investorId'] ?? 0);
55        if ($investorId <= 0) {
56            throw new ValidationException('Invalid investor ID');
57        }
58
59        $appUrl = AppUrlResolver::resolve($request, Row::nullableString($this->emailConfig, 'app_url'));
60
61        $target = $this->adminPasswordService->sendResetLinkForInvestor($investorId, $appUrl, $actor);
62
63        return $this->renderer->json($response, [
64            'success' => true,
65            'message' => "A password reset link has been sent to {$target->email}.",
66        ]);
67    }
68
69    private function requireAdmin(ServerRequestInterface $request): AdminActor
70    {
71        $user = $request->getAttribute('user');
72        if (!$user instanceof UserAuthData) {
73            throw new AuthenticationException('User not authenticated');
74        }
75        if ($user->role !== 'admin' && $user->role !== 'super_admin') {
76            throw new ForbiddenException('Only admins can reset customer passwords');
77        }
78
79        $clientIp = $request->getAttribute('clientIp');
80
81        return new AdminActor(
82            userId: $user->userId,
83            username: $user->username,
84            ipAddress: is_string($clientIp) ? $clientIp : null,
85            userAgent: $request->getHeaderLine('User-Agent') ?: null,
86        );
87    }
88}