1) user invitation 2) app access control 3) txn quota guard
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
/**
|
||||
* invited_onboarding.php — Complete account setup for an invited user.
|
||||
*
|
||||
* Called by: invited_onboarding.php page AJAX after the user fills in
|
||||
* their name, username, and password.
|
||||
*
|
||||
* The invited user was created with license='user', status='pending', and
|
||||
* default_company = the inviting company. This endpoint activates the account
|
||||
* so they can log in using the inviting company's SMTP for OTP delivery.
|
||||
*
|
||||
* Full flow:
|
||||
* 1. Session guard — rejects if 'invited_user_id' is missing.
|
||||
* 2. CSRF check.
|
||||
* 3. Re-validate token against DB (expiry + status='pending' + license='user').
|
||||
* 4. Validate and sanitise input fields.
|
||||
* 5. Username format and uniqueness check.
|
||||
* 6. Password match and strength check.
|
||||
* 7. Hash password.
|
||||
* 8. UPDATE user: name, surname, username, password, status='active',
|
||||
* verify_token=NULL, verify_expires_at=NULL.
|
||||
* 9. UPDATE company_map_user: invite_token=NULL.
|
||||
* 10. Return { success: 1 }.
|
||||
*/
|
||||
|
||||
require '../../session.php';
|
||||
require '../../config.php';
|
||||
require '../../preset.php';
|
||||
define('UNAUTHENTICATED_ROUTE', true);
|
||||
require '../../assets/utils/db_auth.php';
|
||||
require '../../assets/utils/classes/PasswordManager.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$answer = ['success' => 0, 'message' => ''];
|
||||
|
||||
// ── Step 1: Session guard ─────────────────────────────────────────────────────
|
||||
if (empty($_SESSION['invited_user_id']) || empty($_SESSION['invited_token'])) {
|
||||
$answer['message'] = 'Invalid session. Please use your invitation link.';
|
||||
http_response_code(403);
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
$user_id = (int)$_SESSION['invited_user_id'];
|
||||
$token = $_SESSION['invited_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));
|
||||
}
|
||||
}
|
||||
|
||||
$data = json_decode($_POST['json'] ?? '{}', true) ?: [];
|
||||
|
||||
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 ───────────────────────────────────
|
||||
$name = trim($data['name'] ?? '');
|
||||
$surname = trim($data['surname'] ?? '');
|
||||
$username = strtolower(trim($data['username'] ?? ''));
|
||||
$password = $data['password'] ?? '';
|
||||
$confirm = $data['confirm_password'] ?? '';
|
||||
|
||||
if (!$name || !$surname || !$username || !$password || !$confirm) {
|
||||
throw new Exception('All fields are required.');
|
||||
}
|
||||
|
||||
// ── Step 5: Username format and uniqueness ────────────────────────────────
|
||||
if (!preg_match('/^[a-z0-9_]+$/', $username)) {
|
||||
throw new Exception('Username 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");
|
||||
$sth->execute([':u' => $username, ':uid' => $user_id]);
|
||||
if ($sth->fetchColumn()) {
|
||||
throw new Exception('Username is already taken. Please choose another.');
|
||||
}
|
||||
|
||||
// ── Step 6: Password match and strength ───────────────────────────────────
|
||||
if ($password !== $confirm) {
|
||||
throw new Exception('Passwords do not match.');
|
||||
}
|
||||
|
||||
$pm = new PasswordManager($pdo1, $include_url);
|
||||
$result = $pm->checkStrength($password, [$name, $surname, $username]);
|
||||
if ($result['score'] < PasswordManager::MIN_SCORE) {
|
||||
$msg = $result['warning'] ?: ($result['suggestions'][0] ?? 'Please choose a stronger password.');
|
||||
throw new Exception('Password is too weak. ' . $msg);
|
||||
}
|
||||
|
||||
// ── Step 7–8: Hash and activate account ──────────────────────────────────
|
||||
$hashed = password_hash($password, PASSWORD_BCRYPT);
|
||||
|
||||
$pdo1->prepare(
|
||||
"UPDATE user
|
||||
SET name = :name,
|
||||
surname = :surname,
|
||||
username = :username,
|
||||
password = :password,
|
||||
status = 'active',
|
||||
verify_token = NULL,
|
||||
verify_expires_at = NULL
|
||||
WHERE user_id = :uid"
|
||||
)->execute([
|
||||
':name' => $name,
|
||||
':surname' => $surname,
|
||||
':username' => $username,
|
||||
':password' => $hashed,
|
||||
':uid' => $user_id,
|
||||
]);
|
||||
|
||||
// ── Step 9: Clear invite token from company_map_user ─────────────────────
|
||||
$pdo1->prepare(
|
||||
"UPDATE company_map_user SET invite_token = NULL WHERE user_id = :uid"
|
||||
)->execute([':uid' => $user_id]);
|
||||
|
||||
// ── Step 10: Clear session invite keys ────────────────────────────────────
|
||||
unset($_SESSION['invited_user_id'], $_SESSION['invited_token']);
|
||||
|
||||
$answer['success'] = 1;
|
||||
$answer['message'] = 'Account setup complete.';
|
||||
|
||||
} catch (Exception $e) {
|
||||
$answer['message'] = $e->getMessage();
|
||||
http_response_code(400);
|
||||
}
|
||||
|
||||
exit(json_encode($answer));
|
||||
@@ -124,10 +124,13 @@ $_SESSION["login_name"] = $temp["name"];
|
||||
$_SESSION["login_surname"] = $temp["surname"];
|
||||
$_SESSION["login_company_id"] = $temp["default_company"];
|
||||
$_SESSION["login_profile_picture"] = $temp["profile_picture"] ?? '';
|
||||
$_SESSION["login_app_access"] = $temp["app_access"] ?? 'wms';
|
||||
$_SESSION["login_license"] = $temp["license"] ?? 'user';
|
||||
// license='owner' means the user holds their own subscription — use user.app_access.
|
||||
// license='user' means they were invited — use company_map_user.app_access instead.
|
||||
$_SESSION["login_app_access"] = $temp["app_access"] ?? 'wms';
|
||||
|
||||
$role_sth = $pdo1->prepare(
|
||||
"SELECT role FROM company_map_user
|
||||
"SELECT role, app_access FROM company_map_user
|
||||
WHERE company_id = :company_id AND user_id = :user_id
|
||||
LIMIT 1"
|
||||
);
|
||||
@@ -135,7 +138,12 @@ $role_sth->execute([
|
||||
':company_id' => $_SESSION["login_company_id"],
|
||||
':user_id' => $_SESSION["login_user_id"],
|
||||
]);
|
||||
$_SESSION["login_role"] = $role_sth->fetchColumn() ?: 'viewer';
|
||||
$map_row = $role_sth->fetch(PDO::FETCH_ASSOC);
|
||||
$_SESSION["login_role"] = $map_row['role'] ?? 'viewer';
|
||||
|
||||
if (($temp['license'] ?? 'owner') !== 'owner') {
|
||||
$_SESSION["login_app_access"] = $map_row['app_access'] ?? 'wms';
|
||||
}
|
||||
|
||||
// ── Step 6: Respond ───────────────────────────────────────────────────────────
|
||||
$answer["success"] = 1;
|
||||
|
||||
@@ -191,11 +191,17 @@ try {
|
||||
// ── Step 11: Map user as company owner ───────────────────────────────────
|
||||
// company_map_user is the many-to-many table between users and companies.
|
||||
// 'owner' role grants full admin access within the company.
|
||||
// app_access mirrors the owner's license (user.app_access) so the column
|
||||
// is never NULL and switch_branch reads consistent data.
|
||||
$sth = $pdo1->prepare("SELECT app_access FROM user WHERE user_id = :u LIMIT 1");
|
||||
$sth->execute([':u' => $user_id]);
|
||||
$owner_app_access = $sth->fetchColumn() ?: 'wms';
|
||||
|
||||
$sth = $pdo1->prepare("
|
||||
INSERT INTO company_map_user (company_id, user_id, role, created_at)
|
||||
VALUES (:company_id, :user_id, 'owner', NOW())
|
||||
INSERT INTO company_map_user (company_id, user_id, role, app_access, created_at)
|
||||
VALUES (:company_id, :user_id, 'owner', :app_access, NOW())
|
||||
");
|
||||
$sth->execute([':company_id' => $company_id, ':user_id' => $user_id]);
|
||||
$sth->execute([':company_id' => $company_id, ':user_id' => $user_id, ':app_access' => $owner_app_access]);
|
||||
db_check($sth, $answer);
|
||||
|
||||
// ── Step 12: Activate user account and set default company ───────────────
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
require '../session.php';
|
||||
require '../config.php';
|
||||
require '../dbconn.php';
|
||||
|
||||
$token = trim($_GET['token'] ?? '');
|
||||
|
||||
if (!$token) {
|
||||
header('Location: ' . $server_url . 'login/index.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
// Validate token — must match a pending invited user (license='user') that has not expired
|
||||
$sth = $pdo1->prepare(
|
||||
"SELECT u.user_id, u.email, u.verify_expires_at, c.company_name
|
||||
FROM user u
|
||||
JOIN company_map_user m ON m.user_id = u.user_id
|
||||
JOIN company_list c ON c.company_id = m.company_id
|
||||
WHERE u.verify_token = :token
|
||||
AND u.status = 'pending'
|
||||
AND u.license = 'user'
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':token' => $token]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
// Determine error state: cancelled (no row) or expired (row found but past expiry)
|
||||
$invite_error = null;
|
||||
if (!$row) {
|
||||
$invite_error = 'cancelled';
|
||||
} elseif (strtotime($row['verify_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 your administrator to send a new invitation.']
|
||||
: ['icon' => 'ti-user-x', 'title' => 'Invitation Cancelled',
|
||||
'body' => 'This invitation has been cancelled. Please contact your administrator if you believe this is a mistake.'];
|
||||
?>
|
||||
<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;
|
||||
}
|
||||
|
||||
$_SESSION['invited_user_id'] = (int)$row['user_id'];
|
||||
$_SESSION['invited_token'] = $token;
|
||||
|
||||
if (empty($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
|
||||
$company_name = htmlspecialchars($row['company_name']);
|
||||
$invite_email = htmlspecialchars($row['email']);
|
||||
|
||||
require '../include_header.php';
|
||||
?>
|
||||
|
||||
<body>
|
||||
|
||||
<div class="container py-5" style="max-width:520px;">
|
||||
|
||||
<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">Complete your account setup to join <strong><?php echo $company_name ?></strong>.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body p-5">
|
||||
<h2 class="fs-5 mb-1"><i class="ti ti-user-check me-2"></i>Account Setup</h2>
|
||||
<p class="text-muted small mb-4">Your email: <strong><?php echo $invite_email ?></strong></p>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">First Name <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="name" placeholder="First name">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Last Name <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="surname" placeholder="Last name">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Username <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="username" placeholder="Lowercase letters, numbers, underscores">
|
||||
<div class="form-text">Used to log in. Cannot be changed later.</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Password <span class="text-danger">*</span></label>
|
||||
<div class="input-group">
|
||||
<input type="password" class="form-control" id="password" placeholder="Choose a strong password">
|
||||
<button class="btn btn-outline-secondary toggle-pw" type="button" data-target="password">
|
||||
<i class="ti ti-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Confirm Password <span class="text-danger">*</span></label>
|
||||
<div class="input-group">
|
||||
<input type="password" class="form-control" id="confirm_password" placeholder="Repeat your password">
|
||||
<button class="btn btn-outline-secondary toggle-pw" type="button" data-target="confirm_password">
|
||||
<i class="ti ti-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end mt-4">
|
||||
<button class="btn btn-primary px-5" id="btn_finish" onclick="finish_setup()">
|
||||
<i class="ti ti-rocket me-1"></i>Complete Setup
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
|
||||
$(function () {
|
||||
$(document).on('click', '.toggle-pw', function () {
|
||||
const $input = $('#' + $(this).data('target'));
|
||||
const isText = $input.attr('type') === 'text';
|
||||
$input.attr('type', isText ? 'password' : 'text');
|
||||
$(this).find('i').toggleClass('ti-eye ti-eye-off');
|
||||
});
|
||||
});
|
||||
|
||||
function finish_setup() {
|
||||
const name = $('#name').val().trim();
|
||||
const surname = $('#surname').val().trim();
|
||||
const username = $('#username').val().trim();
|
||||
const password = $('#password').val();
|
||||
const confirm = $('#confirm_password').val();
|
||||
|
||||
if (!name || !surname || !username || !password || !confirm) {
|
||||
bootbox.alert('All fields are required.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirm) {
|
||||
bootbox.alert('Passwords do not match.');
|
||||
return;
|
||||
}
|
||||
|
||||
const $btn = $('#btn_finish');
|
||||
$btn.prop('disabled', true).html('<i class="ti ti-loader-2 me-1"></i>Setting up…');
|
||||
|
||||
ajax_request({
|
||||
url: '<?php echo $server_url?>login/api/engine/invited_onboarding.php',
|
||||
autoPrepare: false,
|
||||
data: { json: JSON.stringify({ name, surname, username, password, confirm_password: confirm }) },
|
||||
onSuccess: function () {
|
||||
bootbox.alert('Account setup complete! Please sign in.', function () {
|
||||
window.location.href = '<?php echo $server_url?>login/index.php';
|
||||
});
|
||||
},
|
||||
onError: function () {
|
||||
$btn.prop('disabled', false).html('<i class="ti ti-rocket me-1"></i>Complete Setup');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user