hashToken($plainToken); return AuthorizedUser::query() ->where(function ($q) use ($hash, $plainToken) { $q->where('token', $hash)->orWhere('token', $plainToken); }) ->where('created_at', '>=', now()->subHours(self::TOKEN_TTL_HOURS)) ->first(); } /** * CI setPassword / savePassword — active row with rotating token. */ public function findActiveSetupRow(int $authorizedUserId, string $plainToken): ?AuthorizedUser { if ($plainToken === '') { return null; } $hash = $this->hashToken($plainToken); return AuthorizedUser::query() ->where(function ($q) use ($hash, $plainToken) { $q->where('token', $hash)->orWhere('token', $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]; } /** * CI `create` — when email is not a registered user, respond success without leaking (privacy). * * @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]; } $plain = bin2hex(random_bytes(48)); $tokenHash = $this->hashToken($plain); $phone = trim((string) ($user->cellphone ?? '')); $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' => trim((string) ($user->gender ?? '')) !== '' ? (string) $user->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:
' .'' .'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(); }); } }