1) user invitation 2) app access control 3) txn quota guard

This commit is contained in:
Thanakorn S
2026-05-21 11:42:47 +07:00
parent 8ec2e89f79
commit 6eeebfeacb
50 changed files with 1062 additions and 113 deletions
+149
View File
@@ -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));
+11 -3
View File
@@ -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;
+9 -3
View File
@@ -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 ───────────────