192 lines
6.4 KiB
PHP
192 lines
6.4 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Parents;
|
|
|
|
use App\Models\AuthorizedUser;
|
|
use App\Models\User;
|
|
use App\Services\ApplicationUrlService;
|
|
use App\Services\Email\EmailDispatchService;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
/**
|
|
* legacy {@see \App\Controllers\View\AuthorizedUsersController} parity.
|
|
*/
|
|
class AuthorizedUsersManagementService
|
|
{
|
|
private const TOKEN_TTL_HOURS = 24;
|
|
|
|
public function __construct(
|
|
private EmailDispatchService $mail,
|
|
private ApplicationUrlService $urls,
|
|
) {}
|
|
|
|
public function hashToken(string $plain): string
|
|
{
|
|
return hash('sha256', $plain);
|
|
}
|
|
|
|
/**
|
|
* legacy create() first step — lookup by token after invite email (pending row, created within TTL).
|
|
*
|
|
* Tokens are stored as SHA-256 hashes; we only ever look up by hash so that
|
|
* a leaked database row cannot be replayed as a plaintext token.
|
|
*/
|
|
public function findPendingByIncomingToken(string $plainToken): ?AuthorizedUser
|
|
{
|
|
if ($plainToken === '') {
|
|
return null;
|
|
}
|
|
|
|
return AuthorizedUser::query()
|
|
->where('token', $this->hashToken($plainToken))
|
|
->where('created_at', '>=', now()->subHours(self::TOKEN_TTL_HOURS))
|
|
->first();
|
|
}
|
|
|
|
/**
|
|
* legacy setPassword / savePassword — active row with rotating token.
|
|
*/
|
|
public function findActiveSetupRow(int $authorizedUserId, string $plainToken): ?AuthorizedUser
|
|
{
|
|
if ($plainToken === '' || $authorizedUserId <= 0) {
|
|
return null;
|
|
}
|
|
|
|
return AuthorizedUser::query()
|
|
->where('token', $this->hashToken($plainToken))
|
|
->where('authorized_user_id', $authorizedUserId)
|
|
->where('status', 'Active')
|
|
->where('updated_at', '>=', now()->subHours(self::TOKEN_TTL_HOURS))
|
|
->first();
|
|
}
|
|
|
|
/**
|
|
* @return array{redirect_url: string}
|
|
*/
|
|
public function confirmEmailClick(string $plainToken): array
|
|
{
|
|
$row = $this->findPendingByIncomingToken($plainToken);
|
|
if (! $row) {
|
|
throw new \InvalidArgumentException('Invalid or expired confirmation link.');
|
|
}
|
|
|
|
$nextPlain = bin2hex(random_bytes(48));
|
|
$nextHash = $this->hashToken($nextPlain);
|
|
|
|
$row->status = 'Active';
|
|
$row->token = $nextHash;
|
|
$row->save();
|
|
|
|
$url = $this->urls->inviteSetPasswordFormUrl((int) $row->authorized_user_id, $nextPlain);
|
|
|
|
return ['redirect_url' => $url];
|
|
}
|
|
|
|
/**
|
|
* legacy `create` — when email is not a registered user, respond success without leaking (privacy).
|
|
*
|
|
* Refuses self-invites and silently re-uses an existing invite row to avoid
|
|
* letting a parent spam another user with confirmation emails.
|
|
*
|
|
* @return array{created: bool, record: AuthorizedUser|null}
|
|
*/
|
|
public function inviteByEmail(int $primaryUserId, string $email): array
|
|
{
|
|
$email = strtolower(trim($email));
|
|
|
|
$user = User::query()
|
|
->whereRaw('LOWER(email) = ?', [$email])
|
|
->first();
|
|
|
|
if (! $user) {
|
|
return ['created' => false, 'record' => null];
|
|
}
|
|
|
|
// A parent cannot invite themselves as their own authorized user.
|
|
if ((int) $user->id === $primaryUserId) {
|
|
return ['created' => false, 'record' => null];
|
|
}
|
|
|
|
// If the same parent already invited this user, rotate the token instead
|
|
// of creating a duplicate row (prevents spam + race-driven duplicates).
|
|
$existing = AuthorizedUser::query()
|
|
->where('user_id', $primaryUserId)
|
|
->where('authorized_user_id', (int) $user->id)
|
|
->first();
|
|
|
|
$plain = bin2hex(random_bytes(48));
|
|
$tokenHash = $this->hashToken($plain);
|
|
|
|
$phone = trim((string) ($user->cellphone ?? ''));
|
|
$gender = trim((string) ($user->gender ?? ''));
|
|
|
|
if ($existing) {
|
|
$existing->update([
|
|
'firstname' => (string) ($user->firstname ?? ''),
|
|
'lastname' => (string) ($user->lastname ?? ''),
|
|
'phone_number' => $phone !== '' ? $phone : '-',
|
|
'gender' => $gender !== '' ? $gender : '-',
|
|
'email' => $email,
|
|
'token' => $tokenHash,
|
|
'status' => 'Pending',
|
|
]);
|
|
$record = $existing;
|
|
} else {
|
|
$record = AuthorizedUser::query()->create([
|
|
'user_id' => $primaryUserId,
|
|
'authorized_user_id' => (int) $user->id,
|
|
'firstname' => (string) ($user->firstname ?? ''),
|
|
'lastname' => (string) ($user->lastname ?? ''),
|
|
'phone_number' => $phone !== '' ? $phone : '-',
|
|
'gender' => $gender !== '' ? $gender : '-',
|
|
'email' => $email,
|
|
'relation_to_user' => null,
|
|
'token' => $tokenHash,
|
|
'status' => 'Pending',
|
|
]);
|
|
}
|
|
|
|
$this->sendConfirmationEmail($email, $plain);
|
|
|
|
return ['created' => true, 'record' => $record];
|
|
}
|
|
|
|
public function sendConfirmationEmail(string $email, string $plainToken): void
|
|
{
|
|
$confirmLink = $this->urls->inviteConfirmUrl($plainToken);
|
|
$body = '<p>You have been added as an authorized user for another account. Click the link below to confirm your access:</p>'
|
|
.'<p><a href="'.e($confirmLink).'">Confirm Access</a></p>'
|
|
.'<p>If you did not request this, please ignore this email.</p>';
|
|
|
|
$this->mail->send($email, 'Authorized User Confirmation', $body, 'notifications');
|
|
}
|
|
|
|
/**
|
|
* @throws \InvalidArgumentException
|
|
*/
|
|
public function setAuthorizedUserPassword(int $authorizedUserId, int $postedUserId, string $plainToken, string $password): void
|
|
{
|
|
if ($postedUserId !== $authorizedUserId || $authorizedUserId <= 0) {
|
|
throw new \InvalidArgumentException('Invalid request.');
|
|
}
|
|
|
|
$row = $this->findActiveSetupRow($authorizedUserId, $plainToken);
|
|
if (! $row) {
|
|
throw new \InvalidArgumentException('Invalid or expired confirmation link.');
|
|
}
|
|
|
|
$user = User::query()->find($authorizedUserId);
|
|
if (! $user) {
|
|
throw new \InvalidArgumentException('User not found.');
|
|
}
|
|
|
|
DB::transaction(function () use ($user, $row, $password) {
|
|
$user->password = $password;
|
|
$user->save();
|
|
|
|
$row->token = null;
|
|
$row->save();
|
|
});
|
|
}
|
|
}
|