Files
wms-app/app/login/invited_onboarding.php
T
Thanakorn SandClaude Sonnet 4.6 b4b1f5cbec Security hardening: invited user onboarding flow (C1–N7)
- C1: verify.php now filters license='owner' — invite tokens no longer accepted
- C1: onboarding API rejects non-owner sessions
- C2: Existing-user invite requires explicit acceptance via accept_invite.php
- C2: New accept_invite.php page and API engine added
- C2: inviteUser() generates token + expiry for existing users; resendInvite() handles active users
- C3: session_regenerate_id(true) before writing invite session keys on both invite pages
- C4: invited_onboarding API wraps activation in transaction with SELECT FOR UPDATE; rowCount check added; SQLSTATE 23000 caught cleanly
- C5: inviteUser() and resendInvite() two-table writes wrapped in transactions
- M2: removeUser() wrapped in transaction with FOR UPDATE; clears default_company on active user removal
- M4: Logged-in user guard added to invited_onboarding.php and accept_invite.php
- M5: manage_users.php uses $server_url instead of HTTP_HOST for invite URLs
- M6: Username regex enforces 3-32 chars; reserved name blocklist added
- N5: searchUsers() changed from LIKE fuzzy search to exact email match only
- N7: resendInvite() rate-limited to once per 60s via invite_resent_at column
- Schema: company_map_user gains invite_expires_at and invite_resent_at columns

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 10:18:40 +07:00

267 lines
10 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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 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
$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;
}
// 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_token'] = $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" minlength="3" maxlength="32">
<div class="form-text">3–32 characters. 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"
oninput="on_password_input(this.value)">
<button class="btn btn-outline-secondary toggle-pw" type="button" data-target="password">
<i class="ti ti-eye"></i>
</button>
</div>
<div class="mt-2">
<div class="progress" style="height:5px;">
<div id="pw_strength_bar" class="progress-bar"
style="width:0%;transition:width .25s,background-color .25s;border-radius:4px;"></div>
</div>
<div class="d-flex justify-content-between align-items-start mt-1 gap-2">
<small id="pw_strength_label" class="fw-semibold" style="white-space:nowrap;">—</small>
<small id="pw_feedback" class="text-muted text-end"></small>
</div>
</div>
<div class="form-text">Minimum required strength: <strong>Strong (3/4)</strong></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 src="https://cdn.jsdelivr.net/npm/zxcvbn@4.4.2/dist/zxcvbn.js"></script>
<script>
const STRENGTH_LEVELS = [
{ label: 'Very weak', color: '#dc3545', pct: 20 },
{ label: 'Weak', color: '#fd7e14', pct: 40 },
{ label: 'Fair', color: '#ffc107', pct: 60 },
{ label: 'Strong', color: '#198754', pct: 80 },
{ label: 'Very strong', color: '#0d6efd', pct: 100 },
];
var pw_score = -1;
function on_password_input(pw) {
if (!pw) {
pw_score = -1;
$('#pw_strength_bar').css({ width: '0%', backgroundColor: '' });
$('#pw_strength_label').text('—').css('color', '');
$('#pw_feedback').text('');
return;
}
const user_inputs = [$('#name').val(), $('#surname').val(), $('#username').val()].filter(Boolean);
const result = zxcvbn(pw, user_inputs);
pw_score = result.score;
const lvl = STRENGTH_LEVELS[pw_score];
$('#pw_strength_bar').css({ width: lvl.pct + '%', backgroundColor: lvl.color });
$('#pw_strength_label').text(lvl.label).css('color', lvl.color);
$('#pw_feedback').text(result.feedback.warning || result.feedback.suggestions[0] || '');
}
$(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 (pw_score < 3) {
bootbox.alert('Password is too weak. Please choose a stronger password (Strong or above).');
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>