Security hardening: invited user onboarding flow (C1–N7)
This commit is contained in:
@@ -180,19 +180,21 @@ class UserManager {
|
|||||||
public function searchUsers(string $keyword): array {
|
public function searchUsers(string $keyword): array {
|
||||||
if ($keyword === '') return [];
|
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(
|
$sth = $this->pdo->prepare(
|
||||||
"SELECT u.user_id, u.username, u.name, u.surname, u.email
|
"SELECT u.user_id, u.username, u.name, u.surname, u.email
|
||||||
FROM user u
|
FROM user u
|
||||||
WHERE u.email LIKE :kw
|
WHERE u.email = :email
|
||||||
|
AND u.status = 'active'
|
||||||
AND u.user_id NOT IN (
|
AND u.user_id NOT IN (
|
||||||
SELECT user_id FROM company_map_user
|
SELECT user_id FROM company_map_user
|
||||||
WHERE company_id = :company_id
|
WHERE company_id = :company_id
|
||||||
)
|
)
|
||||||
ORDER BY u.email ASC
|
LIMIT 1"
|
||||||
LIMIT 10"
|
|
||||||
);
|
);
|
||||||
$sth->execute([
|
$sth->execute([
|
||||||
':kw' => '%' . $keyword . '%',
|
':email' => $keyword,
|
||||||
':company_id' => $this->company_id,
|
':company_id' => $this->company_id,
|
||||||
]);
|
]);
|
||||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||||
@@ -234,26 +236,38 @@ class UserManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$sth = $this->pdo->prepare(
|
$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
|
WHERE company_id = :company_id AND user_id = :user_id
|
||||||
LIMIT 1"
|
LIMIT 1"
|
||||||
);
|
);
|
||||||
$sth->execute([':company_id' => $this->company_id, ':user_id' => $target_user_id]);
|
$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.');
|
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(
|
$this->pdo->prepare(
|
||||||
"INSERT INTO company_map_user (company_id, user_id, role, app_access, created_at)
|
"INSERT INTO company_map_user
|
||||||
VALUES (:company_id, :user_id, :role, :app_access, NOW())"
|
(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([
|
)->execute([
|
||||||
':company_id' => $this->company_id,
|
':company_id' => $this->company_id,
|
||||||
':user_id' => $target_user_id,
|
':user_id' => $target_user_id,
|
||||||
':role' => $role,
|
':role' => $role,
|
||||||
':app_access' => $app_access,
|
':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
|
// Email not in system — create a pending invited account
|
||||||
@@ -261,6 +275,8 @@ class UserManager {
|
|||||||
$expires_at = date('Y-m-d H:i:s', strtotime('+7 days'));
|
$expires_at = date('Y-m-d H:i:s', strtotime('+7 days'));
|
||||||
$temp_username = 'invited_' . bin2hex(random_bytes(8));
|
$temp_username = 'invited_' . bin2hex(random_bytes(8));
|
||||||
|
|
||||||
|
$this->pdo->beginTransaction();
|
||||||
|
try {
|
||||||
$this->pdo->prepare(
|
$this->pdo->prepare(
|
||||||
"INSERT INTO user
|
"INSERT INTO user
|
||||||
(username, name, surname, email, password, status, license, default_company,
|
(username, name, surname, email, password, status, license, default_company,
|
||||||
@@ -278,16 +294,25 @@ class UserManager {
|
|||||||
$new_user_id = (int)$this->pdo->lastInsertId();
|
$new_user_id = (int)$this->pdo->lastInsertId();
|
||||||
|
|
||||||
$this->pdo->prepare(
|
$this->pdo->prepare(
|
||||||
"INSERT INTO company_map_user (company_id, user_id, role, app_access, invite_token, created_at)
|
"INSERT INTO company_map_user
|
||||||
VALUES (:company_id, :user_id, :role, :app_access, :token, NOW())"
|
(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([
|
)->execute([
|
||||||
':company_id' => $this->company_id,
|
':company_id' => $this->company_id,
|
||||||
':user_id' => $new_user_id,
|
':user_id' => $new_user_id,
|
||||||
':role' => $role,
|
':role' => $role,
|
||||||
':app_access' => $app_access,
|
':app_access' => $app_access,
|
||||||
':token' => $invite_token,
|
':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];
|
return ['new_user' => true, 'email' => $email, 'token' => $invite_token];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,7 +331,7 @@ class UserManager {
|
|||||||
if (!$map_id) throw new Exception('Invalid request.');
|
if (!$map_id) throw new Exception('Invalid request.');
|
||||||
|
|
||||||
$sth = $this->pdo->prepare(
|
$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
|
FROM company_map_user m
|
||||||
JOIN user u ON u.user_id = m.user_id
|
JOIN user u ON u.user_id = m.user_id
|
||||||
WHERE m.map_id = :map_id AND m.company_id = :company_id
|
WHERE m.map_id = :map_id AND m.company_id = :company_id
|
||||||
@@ -316,24 +341,43 @@ class UserManager {
|
|||||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
if (!$row) throw new Exception('User not found.');
|
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['invite_token']) throw new Exception('No pending invitation found for this user.');
|
||||||
|
|
||||||
|
// 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.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$is_new_user = ($row['license'] === 'user' && $row['status'] === 'pending');
|
||||||
$new_token = bin2hex(random_bytes(32));
|
$new_token = bin2hex(random_bytes(32));
|
||||||
$expires_at = date('Y-m-d H:i:s', strtotime('+7 days'));
|
$expires_at = date('Y-m-d H:i:s', strtotime('+7 days'));
|
||||||
|
|
||||||
|
$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(
|
$this->pdo->prepare(
|
||||||
"UPDATE user SET verify_token = :token, verify_expires_at = :expires
|
"UPDATE user SET verify_token = :token, verify_expires_at = :expires
|
||||||
WHERE user_id = :uid"
|
WHERE user_id = :uid"
|
||||||
)->execute([':token' => $new_token, ':expires' => $expires_at, ':uid' => (int)$row['user_id']]);
|
)->execute([':token' => $new_token, ':expires' => $expires_at, ':uid' => (int)$row['user_id']]);
|
||||||
|
}
|
||||||
|
|
||||||
$this->pdo->prepare(
|
$this->pdo->prepare(
|
||||||
"UPDATE company_map_user SET invite_token = :token
|
"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"
|
WHERE map_id = :map_id AND company_id = :company_id"
|
||||||
)->execute([':token' => $new_token, ':map_id' => $map_id, ':company_id' => $this->company_id]);
|
)->execute([':token' => $new_token, ':expires' => $expires_at, ':map_id' => $map_id, ':company_id' => $this->company_id]);
|
||||||
|
|
||||||
return ['email' => $row['email'], 'token' => $new_token];
|
$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 ($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.');
|
if ((int)$row['user_id'] === $this->user_id) throw new Exception('You cannot remove yourself.');
|
||||||
|
|
||||||
|
$target_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(
|
$this->pdo->prepare(
|
||||||
"DELETE FROM company_map_user
|
"DELETE FROM company_map_user
|
||||||
WHERE map_id = :map_id AND company_id = :company_id"
|
WHERE map_id = :map_id AND company_id = :company_id"
|
||||||
)->execute([':map_id' => $map_id, ':company_id' => $this->company_id]);
|
)->execute([':map_id' => $map_id, ':company_id' => $this->company_id]);
|
||||||
|
|
||||||
// If this was a pending invited account that was never activated, delete
|
if ($u) {
|
||||||
// the placeholder user row so the email is free for future invitations.
|
if ($u['license'] === 'user' && $u['status'] === 'pending') {
|
||||||
$sth = $this->pdo->prepare(
|
// Never activated — delete the placeholder row so the email is free
|
||||||
"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")
|
$this->pdo->prepare("DELETE FROM user WHERE user_id = :uid")
|
||||||
->execute([':uid' => (int)$row['user_id']]);
|
->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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
<?php
|
||||||
|
require '../session.php';
|
||||||
|
require '../config.php';
|
||||||
|
require '../dbconn.php';
|
||||||
|
|
||||||
|
$token = trim($_GET['token'] ?? '');
|
||||||
|
|
||||||
|
if (!$token) {
|
||||||
|
header('Location: ' . $server_url . 'login/index.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject if a user is already logged in — opening an invite link in an active
|
||||||
|
// session would bind invite state into the current session.
|
||||||
|
if (!empty($_SESSION['login_company_id'])) {
|
||||||
|
require '../include_header.php';
|
||||||
|
?>
|
||||||
|
<body>
|
||||||
|
<div class="container py-5" style="max-width:480px;">
|
||||||
|
<div class="text-center mb-5">
|
||||||
|
<a href="<?php echo $server_url?>login/index.php" class="d-inline-block mb-4">
|
||||||
|
<img src="<?php echo $server_url?>assets/images/favicon.png" alt="" width="40"/>
|
||||||
|
<span class="ms-2"><img src="<?php echo $server_url?>assets/images/logo.svg" alt=""></span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="card text-center">
|
||||||
|
<div class="card-body p-5">
|
||||||
|
<i class="ti ti-user-check text-warning mb-3" style="font-size:3rem;"></i>
|
||||||
|
<h2 class="fs-4 mb-2">Already Signed In</h2>
|
||||||
|
<p class="text-muted mb-4">You are already signed in. Please sign out first before accepting an invitation.</p>
|
||||||
|
<a href="<?php echo $server_url?>login/index.php" class="btn btn-primary">Go to Dashboard</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
<?php
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up token — must exist in company_map_user and not be expired
|
||||||
|
$sth = $pdo1->prepare(
|
||||||
|
"SELECT m.map_id, m.role, m.invite_expires_at,
|
||||||
|
c.company_name,
|
||||||
|
u.email, u.name, u.surname
|
||||||
|
FROM company_map_user m
|
||||||
|
JOIN company_list c ON c.company_id = m.company_id
|
||||||
|
JOIN user u ON u.user_id = m.user_id
|
||||||
|
WHERE m.invite_token = :token
|
||||||
|
LIMIT 1"
|
||||||
|
);
|
||||||
|
$sth->execute([':token' => $token]);
|
||||||
|
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$invite_error = null;
|
||||||
|
if (!$row) {
|
||||||
|
$invite_error = 'invalid';
|
||||||
|
} elseif ($row['invite_expires_at'] && strtotime($row['invite_expires_at']) <= time()) {
|
||||||
|
$invite_error = 'expired';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($invite_error) {
|
||||||
|
require '../include_header.php';
|
||||||
|
$msg = $invite_error === 'expired'
|
||||||
|
? ['icon' => 'ti-clock-x', 'title' => 'Invitation Expired',
|
||||||
|
'body' => 'This invitation link has expired. Please contact the company administrator to resend your invitation.']
|
||||||
|
: ['icon' => 'ti-user-x', 'title' => 'Invalid Invitation',
|
||||||
|
'body' => 'This invitation link is invalid or has already been used.'];
|
||||||
|
?>
|
||||||
|
<body>
|
||||||
|
<div class="container py-5" style="max-width:480px;">
|
||||||
|
<div class="text-center mb-5">
|
||||||
|
<a href="<?php echo $server_url?>login/index.php" class="d-inline-block mb-4">
|
||||||
|
<img src="<?php echo $server_url?>assets/images/favicon.png" alt="" width="40"/>
|
||||||
|
<span class="ms-2"><img src="<?php echo $server_url?>assets/images/logo.svg" alt=""></span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="card text-center">
|
||||||
|
<div class="card-body p-5">
|
||||||
|
<i class="ti <?php echo $msg['icon']; ?> text-danger mb-3" style="font-size:3rem;"></i>
|
||||||
|
<h2 class="fs-4 mb-2"><?php echo $msg['title']; ?></h2>
|
||||||
|
<p class="text-muted mb-4"><?php echo $msg['body']; ?></p>
|
||||||
|
<a href="<?php echo $server_url?>login/index.php" class="btn btn-primary">Back to Sign In</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
<?php
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regenerate session ID before binding invite identity to prevent session fixation
|
||||||
|
session_regenerate_id(true);
|
||||||
|
|
||||||
|
$_SESSION['accept_invite_token'] = $token;
|
||||||
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
|
|
||||||
|
$company_name = htmlspecialchars($row['company_name']);
|
||||||
|
$invite_email = htmlspecialchars($row['email']);
|
||||||
|
$invite_role = htmlspecialchars(ucfirst($row['role']));
|
||||||
|
|
||||||
|
require '../include_header.php';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="container py-5" style="max-width:480px;">
|
||||||
|
|
||||||
|
<div class="text-center mb-5">
|
||||||
|
<a href="<?php echo $server_url?>login/index.php" class="d-inline-block mb-4">
|
||||||
|
<img src="<?php echo $server_url?>assets/images/favicon.png" alt="" width="40"/>
|
||||||
|
<span class="ms-2"><img src="<?php echo $server_url?>assets/images/logo.svg" alt=""></span>
|
||||||
|
</a>
|
||||||
|
<h1 class="h4 mb-1">You've been invited!</h1>
|
||||||
|
<p class="text-muted">Accept the invitation to join <strong><?php echo $company_name ?></strong>.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body p-5 text-center">
|
||||||
|
<i class="ti ti-building mb-3 text-primary" style="font-size:3rem;"></i>
|
||||||
|
<h2 class="fs-5 mb-1"><?php echo $company_name ?></h2>
|
||||||
|
<p class="text-muted mb-1">You are invited as: <strong><?php echo $invite_role ?></strong></p>
|
||||||
|
<p class="text-muted small">Account: <?php echo $invite_email ?></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-end mt-4">
|
||||||
|
<button class="btn btn-primary px-5" id="btn_accept" onclick="accept_invite()">
|
||||||
|
<i class="ti ti-check me-1"></i>Accept Invitation
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function accept_invite() {
|
||||||
|
const $btn = $('#btn_accept');
|
||||||
|
$btn.prop('disabled', true).html('<i class="ti ti-loader-2 me-1"></i>Accepting…');
|
||||||
|
|
||||||
|
ajax_request({
|
||||||
|
url: '<?php echo $server_url?>login/api/engine/accept_invite.php',
|
||||||
|
autoPrepare: false,
|
||||||
|
data: { json: JSON.stringify({}) },
|
||||||
|
onSuccess: function () {
|
||||||
|
bootbox.alert('Invitation accepted! You can now sign in.', function () {
|
||||||
|
window.location.href = '<?php echo $server_url?>login/index.php';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: function () {
|
||||||
|
$btn.prop('disabled', false).html('<i class="ti ti-check me-1"></i>Accept Invitation');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* accept_invite.php — Accept a company invitation for an existing user.
|
||||||
|
*
|
||||||
|
* Called by accept_invite.php page AJAX after the user clicks Accept.
|
||||||
|
* The user already has an active account; this just clears the invite_token
|
||||||
|
* on their company_map_user row, making them a full member.
|
||||||
|
*
|
||||||
|
* Full flow:
|
||||||
|
* 1. Session guard — rejects if accept_invite_token is missing.
|
||||||
|
* 2. CSRF check.
|
||||||
|
* 3. Re-validate token against DB (not expired, invite_token still set).
|
||||||
|
* 4. Clear invite_token and invite_expires_at from company_map_user.
|
||||||
|
* 5. Verify exactly one row was updated.
|
||||||
|
* 6. Clear session keys.
|
||||||
|
* 7. Return { success: 1 }.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once '../../../session.php';
|
||||||
|
require_once '../../../config.php';
|
||||||
|
require_once '../../../preset.php';
|
||||||
|
define('UNAUTHENTICATED_ROUTE', true);
|
||||||
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
$answer = ['success' => 0, 'message' => ''];
|
||||||
|
|
||||||
|
// ── Step 1: Session guard ─────────────────────────────────────────────────────
|
||||||
|
if (empty($_SESSION['accept_invite_token'])) {
|
||||||
|
$answer['message'] = 'Invalid session. Please use your invitation link.';
|
||||||
|
http_response_code(403);
|
||||||
|
exit(json_encode($answer));
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = $_SESSION['accept_invite_token'];
|
||||||
|
|
||||||
|
// ── Step 2: CSRF check ────────────────────────────────────────────────────────
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$csrf = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
|
if (empty($csrf) || $csrf !== ($_SESSION['csrf_token'] ?? '')) {
|
||||||
|
http_response_code(403);
|
||||||
|
$answer['message'] = 'Invalid request.';
|
||||||
|
exit(json_encode($answer));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
// ── Step 3: Re-validate token ─────────────────────────────────────────────
|
||||||
|
$sth = $pdo1->prepare(
|
||||||
|
"SELECT map_id FROM company_map_user
|
||||||
|
WHERE invite_token = :token
|
||||||
|
AND invite_expires_at > NOW()
|
||||||
|
LIMIT 1"
|
||||||
|
);
|
||||||
|
$sth->execute([':token' => $token]);
|
||||||
|
if (!$sth->fetchColumn()) {
|
||||||
|
$answer['message'] = 'Invitation has expired or already been used.';
|
||||||
|
http_response_code(403);
|
||||||
|
exit(json_encode($answer));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 4–5: Activate membership ────────────────────────────────────────
|
||||||
|
$stmt = $pdo1->prepare(
|
||||||
|
"UPDATE company_map_user
|
||||||
|
SET invite_token = NULL,
|
||||||
|
invite_expires_at = NULL
|
||||||
|
WHERE invite_token = :token"
|
||||||
|
);
|
||||||
|
$stmt->execute([':token' => $token]);
|
||||||
|
|
||||||
|
if ($stmt->rowCount() !== 1) {
|
||||||
|
$answer['message'] = 'Invitation is no longer valid.';
|
||||||
|
http_response_code(403);
|
||||||
|
exit(json_encode($answer));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 6: Clear session keys ────────────────────────────────────────────
|
||||||
|
unset($_SESSION['accept_invite_token']);
|
||||||
|
|
||||||
|
$answer['success'] = 1;
|
||||||
|
$answer['message'] = 'Invitation accepted.';
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$answer['message'] = $e->getMessage();
|
||||||
|
http_response_code(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
exit(json_encode($answer));
|
||||||
@@ -12,15 +12,17 @@
|
|||||||
* Full flow:
|
* Full flow:
|
||||||
* 1. Session guard — rejects if 'invited_user_id' is missing.
|
* 1. Session guard — rejects if 'invited_user_id' is missing.
|
||||||
* 2. CSRF check.
|
* 2. CSRF check.
|
||||||
* 3. Re-validate token against DB (expiry + status='pending' + license='user').
|
* 3. Validate and sanitise input fields (before acquiring DB locks).
|
||||||
* 4. Validate and sanitise input fields.
|
* 4. Username format check.
|
||||||
* 5. Username format and uniqueness check.
|
* 5. Password match and strength check.
|
||||||
* 6. Password match and strength check.
|
* 6. Hash password.
|
||||||
* 7. Hash password.
|
* 7. BEGIN TRANSACTION — SELECT FOR UPDATE to atomically re-validate token
|
||||||
|
* (expiry + status='pending' + license='user').
|
||||||
* 8. UPDATE user: name, surname, username, password, status='active',
|
* 8. UPDATE user: name, surname, username, password, status='active',
|
||||||
* verify_token=NULL, verify_expires_at=NULL.
|
* verify_token=NULL, verify_expires_at=NULL. Catches SQLSTATE 23000
|
||||||
* 9. UPDATE company_map_user: invite_token=NULL.
|
* (duplicate username). Checks rowCount()=1.
|
||||||
* 10. Return { success: 1 }.
|
* 9. UPDATE company_map_user: invite_token=NULL, invite_expires_at=NULL.
|
||||||
|
* 10. COMMIT. Return { success: 1 }.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require_once '../../../session.php';
|
require_once '../../../session.php';
|
||||||
@@ -58,24 +60,8 @@ $data = json_decode($_POST['json'] ?? '{}', true) ?: [];
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
// ── Step 3: Re-validate token ─────────────────────────────────────────────
|
|
||||||
$sth = $pdo1->prepare(
|
|
||||||
"SELECT user_id FROM user
|
|
||||||
WHERE user_id = :uid
|
|
||||||
AND verify_token = :token
|
|
||||||
AND status = 'pending'
|
|
||||||
AND license = 'user'
|
|
||||||
AND verify_expires_at > NOW()
|
|
||||||
LIMIT 1"
|
|
||||||
);
|
|
||||||
$sth->execute([':uid' => $user_id, ':token' => $token]);
|
|
||||||
if (!$sth->fetchColumn()) {
|
|
||||||
$answer['message'] = 'Invitation has expired or already been used. Please request a new invitation.';
|
|
||||||
http_response_code(403);
|
|
||||||
exit(json_encode($answer));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Step 4: Sanitise and validate input ───────────────────────────────────
|
// ── Step 4: Sanitise and validate input ───────────────────────────────────
|
||||||
|
// Done before the transaction so validation errors don't acquire DB locks.
|
||||||
$name = trim($data['name'] ?? '');
|
$name = trim($data['name'] ?? '');
|
||||||
$surname = trim($data['surname'] ?? '');
|
$surname = trim($data['surname'] ?? '');
|
||||||
$username = strtolower(trim($data['username'] ?? ''));
|
$username = strtolower(trim($data['username'] ?? ''));
|
||||||
@@ -86,15 +72,14 @@ try {
|
|||||||
throw new Exception('All fields are required.');
|
throw new Exception('All fields are required.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Step 5: Username format and uniqueness ────────────────────────────────
|
// ── Step 5: Username format, length, and reserved names ──────────────────
|
||||||
if (!preg_match('/^[a-z0-9_]+$/', $username)) {
|
if (!preg_match('/^[a-z0-9_]{3,32}$/', $username)) {
|
||||||
throw new Exception('Username may only contain lowercase letters, numbers and underscores.');
|
throw new Exception('Username must be 3–32 characters and may only contain lowercase letters, numbers and underscores.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$sth = $pdo1->prepare("SELECT user_id FROM user WHERE username = :u AND user_id != :uid LIMIT 1");
|
$reserved = ['admin', 'owner', 'support', 'root', 'system', 'superuser', 'administrator'];
|
||||||
$sth->execute([':u' => $username, ':uid' => $user_id]);
|
if (in_array($username, $reserved, true)) {
|
||||||
if ($sth->fetchColumn()) {
|
throw new Exception('That username is reserved. Please choose another.');
|
||||||
throw new Exception('Username is already taken. Please choose another.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Step 6: Password match and strength ───────────────────────────────────
|
// ── Step 6: Password match and strength ───────────────────────────────────
|
||||||
@@ -109,10 +94,33 @@ try {
|
|||||||
throw new Exception('Password is too weak. ' . $msg);
|
throw new Exception('Password is too weak. ' . $msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Step 7–8: Hash and activate account ──────────────────────────────────
|
|
||||||
$hashed = password_hash($password, PASSWORD_BCRYPT);
|
$hashed = password_hash($password, PASSWORD_BCRYPT);
|
||||||
|
|
||||||
$pdo1->prepare(
|
// ── Steps 3 + 7–9: Atomic token re-validation and activation ─────────────
|
||||||
|
// SELECT FOR UPDATE locks the row so a concurrent resendInvite or removeUser
|
||||||
|
// cannot mutate the token between our check and the UPDATE.
|
||||||
|
$pdo1->beginTransaction();
|
||||||
|
|
||||||
|
$sth = $pdo1->prepare(
|
||||||
|
"SELECT user_id FROM user
|
||||||
|
WHERE user_id = :uid
|
||||||
|
AND verify_token = :token
|
||||||
|
AND status = 'pending'
|
||||||
|
AND license = 'user'
|
||||||
|
AND verify_expires_at > NOW()
|
||||||
|
LIMIT 1
|
||||||
|
FOR UPDATE"
|
||||||
|
);
|
||||||
|
$sth->execute([':uid' => $user_id, ':token' => $token]);
|
||||||
|
if (!$sth->fetchColumn()) {
|
||||||
|
$pdo1->rollBack();
|
||||||
|
$answer['message'] = 'Invitation has expired or already been used. Please request a new invitation.';
|
||||||
|
http_response_code(403);
|
||||||
|
exit(json_encode($answer));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 7–8: Activate account ────────────────────────────────────────────
|
||||||
|
$stmt = $pdo1->prepare(
|
||||||
"UPDATE user
|
"UPDATE user
|
||||||
SET name = :name,
|
SET name = :name,
|
||||||
surname = :surname,
|
surname = :surname,
|
||||||
@@ -122,19 +130,41 @@ try {
|
|||||||
verify_token = NULL,
|
verify_token = NULL,
|
||||||
verify_expires_at = NULL
|
verify_expires_at = NULL
|
||||||
WHERE user_id = :uid"
|
WHERE user_id = :uid"
|
||||||
)->execute([
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stmt->execute([
|
||||||
':name' => $name,
|
':name' => $name,
|
||||||
':surname' => $surname,
|
':surname' => $surname,
|
||||||
':username' => $username,
|
':username' => $username,
|
||||||
':password' => $hashed,
|
':password' => $hashed,
|
||||||
':uid' => $user_id,
|
':uid' => $user_id,
|
||||||
]);
|
]);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
$pdo1->rollBack();
|
||||||
|
// SQLSTATE 23000 = unique constraint violation (duplicate username)
|
||||||
|
if ($e->getCode() === '23000') {
|
||||||
|
throw new Exception('Username is already taken. Please choose another.');
|
||||||
|
}
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($stmt->rowCount() !== 1) {
|
||||||
|
$pdo1->rollBack();
|
||||||
|
$answer['message'] = 'Invitation is no longer valid.';
|
||||||
|
http_response_code(403);
|
||||||
|
exit(json_encode($answer));
|
||||||
|
}
|
||||||
|
|
||||||
// ── Step 9: Clear invite token from company_map_user ─────────────────────
|
// ── Step 9: Clear invite token from company_map_user ─────────────────────
|
||||||
$pdo1->prepare(
|
$pdo1->prepare(
|
||||||
"UPDATE company_map_user SET invite_token = NULL WHERE user_id = :uid"
|
"UPDATE company_map_user
|
||||||
|
SET invite_token = NULL, invite_expires_at = NULL
|
||||||
|
WHERE user_id = :uid"
|
||||||
)->execute([':uid' => $user_id]);
|
)->execute([':uid' => $user_id]);
|
||||||
|
|
||||||
|
$pdo1->commit();
|
||||||
|
|
||||||
// ── Step 10: Clear session invite keys ────────────────────────────────────
|
// ── Step 10: Clear session invite keys ────────────────────────────────────
|
||||||
unset($_SESSION['invited_user_id'], $_SESSION['invited_token']);
|
unset($_SESSION['invited_user_id'], $_SESSION['invited_token']);
|
||||||
|
|
||||||
@@ -142,6 +172,7 @@ try {
|
|||||||
$answer['message'] = 'Account setup complete.';
|
$answer['message'] = 'Account setup complete.';
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
|
if ($pdo1->inTransaction()) $pdo1->rollBack();
|
||||||
$answer['message'] = $e->getMessage();
|
$answer['message'] = $e->getMessage();
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,17 @@ if (empty($_SESSION['onboarding_user_id'])) {
|
|||||||
|
|
||||||
$user_id = (int)$_SESSION['onboarding_user_id'];
|
$user_id = (int)$_SESSION['onboarding_user_id'];
|
||||||
|
|
||||||
|
// ── Step 1b: License guard ────────────────────────────────────────────────────
|
||||||
|
// Invited users (license='user') must use invited_onboarding.php, not this flow.
|
||||||
|
// If somehow an invited user's session reaches here, reject immediately.
|
||||||
|
$sth = $pdo1->prepare("SELECT license FROM user WHERE user_id = :uid LIMIT 1");
|
||||||
|
$sth->execute([':uid' => $user_id]);
|
||||||
|
if ($sth->fetchColumn() !== 'owner') {
|
||||||
|
$answer['message'] = 'Invalid session.';
|
||||||
|
http_response_code(403);
|
||||||
|
exit(json_encode($answer));
|
||||||
|
}
|
||||||
|
|
||||||
// ── Step 2: CSRF check ────────────────────────────────────────────────────────
|
// ── Step 2: CSRF check ────────────────────────────────────────────────────────
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
$csrf = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
$csrf = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
|
|||||||
@@ -10,6 +10,34 @@
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reject if a user is already logged in — opening an invite link in an active
|
||||||
|
// session would bind a different account's identity into the current session.
|
||||||
|
if (!empty($_SESSION['login_company_id'])) {
|
||||||
|
require '../include_header.php';
|
||||||
|
?>
|
||||||
|
<body>
|
||||||
|
<div class="container py-5" style="max-width:480px;">
|
||||||
|
<div class="text-center mb-5">
|
||||||
|
<a href="<?php echo $server_url?>login/index.php" class="d-inline-block mb-4">
|
||||||
|
<img src="<?php echo $server_url?>assets/images/favicon.png" alt="" width="40"/>
|
||||||
|
<span class="ms-2"><img src="<?php echo $server_url?>assets/images/logo.svg" alt=""></span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="card text-center">
|
||||||
|
<div class="card-body p-5">
|
||||||
|
<i class="ti ti-user-check text-warning mb-3" style="font-size:3rem;"></i>
|
||||||
|
<h2 class="fs-4 mb-2">Already Signed In</h2>
|
||||||
|
<p class="text-muted mb-4">You are already signed in. Please sign out first before accepting an invitation.</p>
|
||||||
|
<a href="<?php echo $server_url?>login/index.php" class="btn btn-primary">Go to Dashboard</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
<?php
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
// Validate token — must match a pending invited user (license='user') that has not expired
|
// Validate token — must match a pending invited user (license='user') that has not expired
|
||||||
$sth = $pdo1->prepare(
|
$sth = $pdo1->prepare(
|
||||||
"SELECT u.user_id, u.email, u.verify_expires_at, c.company_name
|
"SELECT u.user_id, u.email, u.verify_expires_at, c.company_name
|
||||||
@@ -65,12 +93,12 @@
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Regenerate session ID before binding invite identity to prevent session fixation
|
||||||
|
session_regenerate_id(true);
|
||||||
|
|
||||||
$_SESSION['invited_user_id'] = (int)$row['user_id'];
|
$_SESSION['invited_user_id'] = (int)$row['user_id'];
|
||||||
$_SESSION['invited_token'] = $token;
|
$_SESSION['invited_token'] = $token;
|
||||||
|
|
||||||
if (empty($_SESSION['csrf_token'])) {
|
|
||||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
}
|
|
||||||
|
|
||||||
$company_name = htmlspecialchars($row['company_name']);
|
$company_name = htmlspecialchars($row['company_name']);
|
||||||
$invite_email = htmlspecialchars($row['email']);
|
$invite_email = htmlspecialchars($row['email']);
|
||||||
@@ -107,8 +135,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label class="form-label">Username <span class="text-danger">*</span></label>
|
<label class="form-label">Username <span class="text-danger">*</span></label>
|
||||||
<input type="text" class="form-control" id="username" placeholder="Lowercase letters, numbers, underscores">
|
<input type="text" class="form-control" id="username" placeholder="Lowercase letters, numbers, underscores" minlength="3" maxlength="32">
|
||||||
<div class="form-text">Used to log in. Cannot be changed later.</div>
|
<div class="form-text">3–32 characters. Used to log in. Cannot be changed later.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label class="form-label">Password <span class="text-danger">*</span></label>
|
<label class="form-label">Password <span class="text-danger">*</span></label>
|
||||||
|
|||||||
@@ -15,10 +15,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Look up token ─────────────────────────────────────────────
|
// ── Look up token ─────────────────────────────────────────────
|
||||||
|
// license='owner' guard: invited users also have verify_token set, but they
|
||||||
|
// must use invited_onboarding.php — never this flow.
|
||||||
$sth = $pdo1->prepare("
|
$sth = $pdo1->prepare("
|
||||||
SELECT user_id, name, status, verify_expires_at
|
SELECT user_id, name, status, verify_expires_at
|
||||||
FROM user
|
FROM user
|
||||||
WHERE verify_token = :token
|
WHERE verify_token = :token
|
||||||
|
AND license = 'owner'
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
");
|
");
|
||||||
$sth->execute([':token' => $token]);
|
$sth->execute([':token' => $token]);
|
||||||
|
|||||||
@@ -29,21 +29,27 @@
|
|||||||
|
|
||||||
$result = $um->inviteUser($email, $role, $app_access);
|
$result = $um->inviteUser($email, $role, $app_access);
|
||||||
|
|
||||||
if ($result['new_user']) {
|
// Both new and existing users require explicit acceptance via email
|
||||||
$base_url = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http')
|
$invite_url = rtrim($server_url, '/') . ($result['new_user']
|
||||||
. '://' . $_SERVER['HTTP_HOST'] . rtrim($server_url, '/');
|
? '/login/invited_onboarding.php?token=' . $result['token']
|
||||||
$invite_url = $base_url . '/login/invited_onboarding.php?token=' . $result['token'];
|
: '/login/accept_invite.php?token=' . $result['token']);
|
||||||
|
|
||||||
|
$subject = $result['new_user']
|
||||||
|
? 'You have been invited to join the team'
|
||||||
|
: 'You have been invited to join a new company';
|
||||||
|
|
||||||
|
$body_intro = $result['new_user']
|
||||||
|
? 'You have been invited to join the team. Click the button below to set up your account:'
|
||||||
|
: 'You have been invited to join a new company. Click the button below to accept:';
|
||||||
|
|
||||||
require_once '../../../assets/utils/module/mailer.php';
|
require_once '../../../assets/utils/module/mailer.php';
|
||||||
$mailer = new mailer(['pdo1' => $pdo1]);
|
$mailer = new mailer(['pdo1' => $pdo1]);
|
||||||
$mailer->send_email([
|
$mailer->send_email([
|
||||||
'company_id' => $company_id,
|
'company_id' => $company_id,
|
||||||
'to' => $result['email'],
|
'to' => $result['email'],
|
||||||
'subject' => 'You have been invited to join the team',
|
'subject' => $subject,
|
||||||
'message' => implode("\n", [
|
'message' => implode("\n", [
|
||||||
"You have been invited to join the team.",
|
$body_intro,
|
||||||
"",
|
|
||||||
"Click the button below to set up your account:",
|
|
||||||
"",
|
"",
|
||||||
"<a href=\"{$invite_url}\" style=\"display:inline-block;padding:12px 28px;background:#E66239;color:#ffffff;text-decoration:none;border-radius:6px;font-weight:600;\">Accept Invitation</a>",
|
"<a href=\"{$invite_url}\" style=\"display:inline-block;padding:12px 28px;background:#E66239;color:#ffffff;text-decoration:none;border-radius:6px;font-weight:600;\">Accept Invitation</a>",
|
||||||
"",
|
"",
|
||||||
@@ -57,10 +63,7 @@
|
|||||||
'channel_name' => 'WMS',
|
'channel_name' => 'WMS',
|
||||||
'key' => $pinkey,
|
'key' => $pinkey,
|
||||||
]);
|
]);
|
||||||
$answer['message'] = htmlspecialchars($result['email']) . ' has been invited. An email has been sent to complete their registration.';
|
$answer['message'] = htmlspecialchars($result['email']) . ' has been invited. An email has been sent.';
|
||||||
} else {
|
|
||||||
$answer['message'] = htmlspecialchars($result['email']) . ' has been added to your company.';
|
|
||||||
}
|
|
||||||
|
|
||||||
$answer['success'] = 1;
|
$answer['success'] = 1;
|
||||||
|
|
||||||
@@ -85,9 +88,13 @@
|
|||||||
$map_id = (int)($data['map_id'] ?? 0);
|
$map_id = (int)($data['map_id'] ?? 0);
|
||||||
$result = $um->resendInvite($map_id);
|
$result = $um->resendInvite($map_id);
|
||||||
|
|
||||||
$base_url = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http')
|
$invite_url = rtrim($server_url, '/') . ($result['is_new_user']
|
||||||
. '://' . $_SERVER['HTTP_HOST'] . rtrim($server_url, '/');
|
? '/login/invited_onboarding.php?token=' . $result['token']
|
||||||
$invite_url = $base_url . '/login/invited_onboarding.php?token=' . $result['token'];
|
: '/login/accept_invite.php?token=' . $result['token']);
|
||||||
|
|
||||||
|
$body_intro = $result['is_new_user']
|
||||||
|
? 'Your invitation link has been refreshed. Click below to set up your account:'
|
||||||
|
: 'Your invitation link has been refreshed. Click below to accept the invitation:';
|
||||||
|
|
||||||
require_once '../../../assets/utils/module/mailer.php';
|
require_once '../../../assets/utils/module/mailer.php';
|
||||||
$mailer = new mailer(['pdo1' => $pdo1]);
|
$mailer = new mailer(['pdo1' => $pdo1]);
|
||||||
@@ -96,9 +103,7 @@
|
|||||||
'to' => $result['email'],
|
'to' => $result['email'],
|
||||||
'subject' => 'Your invitation link has been resent',
|
'subject' => 'Your invitation link has been resent',
|
||||||
'message' => implode("\n", [
|
'message' => implode("\n", [
|
||||||
"Your invitation link has been refreshed.",
|
$body_intro,
|
||||||
"",
|
|
||||||
"Click the button below to set up your account:",
|
|
||||||
"",
|
"",
|
||||||
"<a href=\"{$invite_url}\" style=\"display:inline-block;padding:12px 28px;background:#E66239;color:#ffffff;text-decoration:none;border-radius:6px;font-weight:600;\">Accept Invitation</a>",
|
"<a href=\"{$invite_url}\" style=\"display:inline-block;padding:12px 28px;background:#E66239;color:#ffffff;text-decoration:none;border-radius:6px;font-weight:600;\">Accept Invitation</a>",
|
||||||
"",
|
"",
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ function run(PDO $pdo, string $sql, string $label): void {
|
|||||||
$pdo->exec($sql);
|
$pdo->exec($sql);
|
||||||
ok($label);
|
ok($label);
|
||||||
} catch (PDOException $e) {
|
} catch (PDOException $e) {
|
||||||
if (str_contains($e->getMessage(), 'already exists')) {
|
if (str_contains($e->getMessage(), 'already exists') ||
|
||||||
|
str_contains($e->getMessage(), 'Duplicate column name')) {
|
||||||
skip($label . ' (already exists)');
|
skip($label . ' (already exists)');
|
||||||
} else {
|
} else {
|
||||||
fail($label . ': ' . $e->getMessage());
|
fail($label . ': ' . $e->getMessage());
|
||||||
@@ -149,6 +150,8 @@ CREATE TABLE IF NOT EXISTS `company_map_user` (
|
|||||||
`role` varchar(15) NOT NULL DEFAULT 'user',
|
`role` varchar(15) NOT NULL DEFAULT 'user',
|
||||||
`app_access` varchar(15) NOT NULL DEFAULT 'wms',
|
`app_access` varchar(15) NOT NULL DEFAULT 'wms',
|
||||||
`invite_token` varchar(64) DEFAULT NULL,
|
`invite_token` varchar(64) DEFAULT NULL,
|
||||||
|
`invite_expires_at` datetime DEFAULT NULL,
|
||||||
|
`invite_resent_at` datetime DEFAULT NULL,
|
||||||
`created_at` datetime DEFAULT NULL,
|
`created_at` datetime DEFAULT NULL,
|
||||||
PRIMARY KEY (`map_id`),
|
PRIMARY KEY (`map_id`),
|
||||||
KEY `user_id` (`user_id`),
|
KEY `user_id` (`user_id`),
|
||||||
@@ -156,6 +159,10 @@ CREATE TABLE IF NOT EXISTS `company_map_user` (
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
|
||||||
", 'company_map_user');
|
", 'company_map_user');
|
||||||
|
|
||||||
|
// Column added for explicit-consent invite flow (existing users)
|
||||||
|
run($pdo, "ALTER TABLE `company_map_user` ADD COLUMN `invite_expires_at` DATETIME DEFAULT NULL AFTER `invite_token`", 'company_map_user.invite_expires_at');
|
||||||
|
run($pdo, "ALTER TABLE `company_map_user` ADD COLUMN `invite_resent_at` DATETIME DEFAULT NULL AFTER `invite_expires_at`", 'company_map_user.invite_resent_at');
|
||||||
|
|
||||||
run($pdo, "
|
run($pdo, "
|
||||||
CREATE TABLE IF NOT EXISTS `company_setting` (
|
CREATE TABLE IF NOT EXISTS `company_setting` (
|
||||||
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
|
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
|||||||
Reference in New Issue
Block a user