diff --git a/app/assets/utils/classes/UserManager.php b/app/assets/utils/classes/UserManager.php index 72bd38c..a4093b3 100644 --- a/app/assets/utils/classes/UserManager.php +++ b/app/assets/utils/classes/UserManager.php @@ -180,19 +180,21 @@ class UserManager { public function searchUsers(string $keyword): array { if ($keyword === '') return []; + // Exact email match only — LIKE across the global user table leaks + // names and usernames of users belonging to other companies. $sth = $this->pdo->prepare( "SELECT u.user_id, u.username, u.name, u.surname, u.email FROM user u - WHERE u.email LIKE :kw + WHERE u.email = :email + AND u.status = 'active' AND u.user_id NOT IN ( SELECT user_id FROM company_map_user WHERE company_id = :company_id ) - ORDER BY u.email ASC - LIMIT 10" + LIMIT 1" ); $sth->execute([ - ':kw' => '%' . $keyword . '%', + ':email' => $keyword, ':company_id' => $this->company_id, ]); return $sth->fetchAll(PDO::FETCH_ASSOC); @@ -234,26 +236,38 @@ class UserManager { } $sth = $this->pdo->prepare( - "SELECT map_id FROM company_map_user + "SELECT map_id, invite_token FROM company_map_user WHERE company_id = :company_id AND user_id = :user_id LIMIT 1" ); $sth->execute([':company_id' => $this->company_id, ':user_id' => $target_user_id]); - if ($sth->fetch()) { + $existing = $sth->fetch(PDO::FETCH_ASSOC); + if ($existing) { + if ($existing['invite_token']) { + throw new Exception('An invitation is already pending for this user. Use Resend Invite to refresh it.'); + } throw new Exception('This user is already a member of your company.'); } + // Existing user must explicitly accept — generate token and send email + $invite_token = bin2hex(random_bytes(32)); + $expires_at = date('Y-m-d H:i:s', strtotime('+7 days')); + $this->pdo->prepare( - "INSERT INTO company_map_user (company_id, user_id, role, app_access, created_at) - VALUES (:company_id, :user_id, :role, :app_access, NOW())" + "INSERT INTO company_map_user + (company_id, user_id, role, app_access, invite_token, invite_expires_at, created_at) + VALUES + (:company_id, :user_id, :role, :app_access, :token, :expires, NOW())" )->execute([ ':company_id' => $this->company_id, ':user_id' => $target_user_id, ':role' => $role, ':app_access' => $app_access, + ':token' => $invite_token, + ':expires' => $expires_at, ]); - return ['new_user' => false, 'email' => $target['email'], 'token' => null]; + return ['new_user' => false, 'email' => $target['email'], 'token' => $invite_token]; } // Email not in system — create a pending invited account @@ -261,32 +275,43 @@ class UserManager { $expires_at = date('Y-m-d H:i:s', strtotime('+7 days')); $temp_username = 'invited_' . bin2hex(random_bytes(8)); - $this->pdo->prepare( - "INSERT INTO user - (username, name, surname, email, password, status, license, default_company, - profile_picture, verify_token, verify_expires_at) - VALUES - (:username, '', '', :email, '', 'pending', 'user', :default_company, - '', :token, :expires)" - )->execute([ - ':username' => $temp_username, - ':email' => $email, - ':default_company' => $this->company_id, - ':token' => $invite_token, - ':expires' => $expires_at, - ]); - $new_user_id = (int)$this->pdo->lastInsertId(); + $this->pdo->beginTransaction(); + try { + $this->pdo->prepare( + "INSERT INTO user + (username, name, surname, email, password, status, license, default_company, + profile_picture, verify_token, verify_expires_at) + VALUES + (:username, '', '', :email, '', 'pending', 'user', :default_company, + '', :token, :expires)" + )->execute([ + ':username' => $temp_username, + ':email' => $email, + ':default_company' => $this->company_id, + ':token' => $invite_token, + ':expires' => $expires_at, + ]); + $new_user_id = (int)$this->pdo->lastInsertId(); - $this->pdo->prepare( - "INSERT INTO company_map_user (company_id, user_id, role, app_access, invite_token, created_at) - VALUES (:company_id, :user_id, :role, :app_access, :token, NOW())" - )->execute([ - ':company_id' => $this->company_id, - ':user_id' => $new_user_id, - ':role' => $role, - ':app_access' => $app_access, - ':token' => $invite_token, - ]); + $this->pdo->prepare( + "INSERT INTO company_map_user + (company_id, user_id, role, app_access, invite_token, invite_expires_at, created_at) + VALUES + (:company_id, :user_id, :role, :app_access, :token, :expires, NOW())" + )->execute([ + ':company_id' => $this->company_id, + ':user_id' => $new_user_id, + ':role' => $role, + ':app_access' => $app_access, + ':token' => $invite_token, + ':expires' => $expires_at, + ]); + + $this->pdo->commit(); + } catch (Exception $e) { + $this->pdo->rollBack(); + throw $e; + } return ['new_user' => true, 'email' => $email, 'token' => $invite_token]; } @@ -306,7 +331,7 @@ class UserManager { if (!$map_id) throw new Exception('Invalid request.'); $sth = $this->pdo->prepare( - "SELECT u.user_id, u.email, u.status, u.license, m.invite_token + "SELECT u.user_id, u.email, u.status, u.license, m.invite_token, m.invite_resent_at FROM company_map_user m JOIN user u ON u.user_id = m.user_id WHERE m.map_id = :map_id AND m.company_id = :company_id @@ -315,25 +340,44 @@ class UserManager { $sth->execute([':map_id' => $map_id, ':company_id' => $this->company_id]); $row = $sth->fetch(PDO::FETCH_ASSOC); - if (!$row) throw new Exception('User not found.'); - if ($row['license'] !== 'user') throw new Exception('Cannot resend invite to an owner account.'); - if ($row['status'] !== 'pending') throw new Exception('User has already accepted the invitation.'); - if (!$row['invite_token']) throw new Exception('No pending invitation found for this user.'); + if (!$row) throw new Exception('User not found.'); + if (!$row['invite_token']) throw new Exception('No pending invitation found for this user.'); - $new_token = bin2hex(random_bytes(32)); - $expires_at = date('Y-m-d H:i:s', strtotime('+7 days')); + // Rate limit — one resend per 60 seconds + if ($row['invite_resent_at'] && + strtotime($row['invite_resent_at']) > time() - 60) { + throw new Exception('Please wait before resending the invitation.'); + } - $this->pdo->prepare( - "UPDATE user SET verify_token = :token, verify_expires_at = :expires - WHERE user_id = :uid" - )->execute([':token' => $new_token, ':expires' => $expires_at, ':uid' => (int)$row['user_id']]); + $is_new_user = ($row['license'] === 'user' && $row['status'] === 'pending'); + $new_token = bin2hex(random_bytes(32)); + $expires_at = date('Y-m-d H:i:s', strtotime('+7 days')); - $this->pdo->prepare( - "UPDATE company_map_user SET invite_token = :token - WHERE map_id = :map_id AND company_id = :company_id" - )->execute([':token' => $new_token, ':map_id' => $map_id, ':company_id' => $this->company_id]); + $this->pdo->beginTransaction(); + try { + // Brand-new pending accounts also need user.verify_token updated (used by invited_onboarding.php) + if ($is_new_user) { + $this->pdo->prepare( + "UPDATE user SET verify_token = :token, verify_expires_at = :expires + WHERE user_id = :uid" + )->execute([':token' => $new_token, ':expires' => $expires_at, ':uid' => (int)$row['user_id']]); + } - return ['email' => $row['email'], 'token' => $new_token]; + $this->pdo->prepare( + "UPDATE company_map_user + SET invite_token = :token, + invite_expires_at = :expires, + invite_resent_at = NOW() + WHERE map_id = :map_id AND company_id = :company_id" + )->execute([':token' => $new_token, ':expires' => $expires_at, ':map_id' => $map_id, ':company_id' => $this->company_id]); + + $this->pdo->commit(); + } catch (Exception $e) { + $this->pdo->rollBack(); + throw $e; + } + + return ['email' => $row['email'], 'token' => $new_token, 'is_new_user' => $is_new_user]; } /** @@ -437,21 +481,43 @@ class UserManager { if ($row['role'] === 'owner') throw new Exception('The owner cannot be removed.'); if ((int)$row['user_id'] === $this->user_id) throw new Exception('You cannot remove yourself.'); - $this->pdo->prepare( - "DELETE FROM company_map_user - WHERE map_id = :map_id AND company_id = :company_id" - )->execute([':map_id' => $map_id, ':company_id' => $this->company_id]); + $target_uid = (int)$row['user_id']; - // If this was a pending invited account that was never activated, delete - // the placeholder user row so the email is free for future invitations. - $sth = $this->pdo->prepare( - "SELECT license, status FROM user WHERE user_id = :uid LIMIT 1" - ); - $sth->execute([':uid' => (int)$row['user_id']]); - $u = $sth->fetch(PDO::FETCH_ASSOC); - if ($u && $u['license'] === 'user' && $u['status'] === 'pending') { - $this->pdo->prepare("DELETE FROM user WHERE user_id = :uid") - ->execute([':uid' => (int)$row['user_id']]); + $this->pdo->beginTransaction(); + try { + // Lock the user row first — if invited_onboarding.php is activating this + // account at the same moment, one will wait rather than both proceeding + // with stale status data. + $sth = $this->pdo->prepare( + "SELECT license, status, default_company FROM user + WHERE user_id = :uid LIMIT 1 FOR UPDATE" + ); + $sth->execute([':uid' => $target_uid]); + $u = $sth->fetch(PDO::FETCH_ASSOC); + + $this->pdo->prepare( + "DELETE FROM company_map_user + WHERE map_id = :map_id AND company_id = :company_id" + )->execute([':map_id' => $map_id, ':company_id' => $this->company_id]); + + if ($u) { + if ($u['license'] === 'user' && $u['status'] === 'pending') { + // Never activated — delete the placeholder row so the email is free + $this->pdo->prepare("DELETE FROM user WHERE user_id = :uid") + ->execute([':uid' => $target_uid]); + } elseif ((int)$u['default_company'] === $this->company_id) { + // Active user removed from their default company — clear it so they + // are not left pointing at a company they no longer belong to + $this->pdo->prepare( + "UPDATE user SET default_company = 0 WHERE user_id = :uid" + )->execute([':uid' => $target_uid]); + } + } + + $this->pdo->commit(); + } catch (Exception $e) { + $this->pdo->rollBack(); + throw $e; } } } diff --git a/app/login/accept_invite.php b/app/login/accept_invite.php new file mode 100644 index 0000000..522352d --- /dev/null +++ b/app/login/accept_invite.php @@ -0,0 +1,158 @@ + +
+You are already signed in. Please sign out first before accepting an invitation.
+ Go to Dashboard +