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 = '

You have been added as an authorized user for another account. Click the link below to confirm your access:

' .'

Confirm Access

' .'

If you did not request this, please ignore this email.

'; $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(); }); } }