mirror of
https://github.com/itflow-org/itflow
synced 2026-11-11 15:07:24 +00:00
584 lines
26 KiB
PHP
584 lines
26 KiB
PHP
<?php
|
|
|
|
/*
|
|
* Agent invites - how someone new joins your team in ITFlow.
|
|
*
|
|
* An admin invites them from New User (Admin > Users) or Get started > Your
|
|
* team: who they are, their role, whether MFA is required, the clients they
|
|
* see and a picture are kept with the invite, and they get an email with a
|
|
* link (or the admin passes the link on). The link opens /join.php, where
|
|
* they choose their own password - nobody else ever knows it - and only
|
|
* then are they an agent. Until it is used, cancelled or runs out, an invite
|
|
* holds a seat under the agent user limit, but signs nobody in.
|
|
*
|
|
* The link carries a token of which only the SHA-256 is kept. It runs out
|
|
* after USER_INVITE_LINK_DAYS. Sending it again or getting the link makes a
|
|
* new token, so a link given out before stops working; cancelling stops it.
|
|
*
|
|
* Their copy of the credential vault: an agent's password unlocks their own
|
|
* copy of the vault's key. With an invite nobody has their password yet, so
|
|
* the admin's session seals the key under the link's token (invite_vault,
|
|
* the way it is sealed under a password), and accepting opens it with the
|
|
* token in the link and seals it again under the password they chose. Only
|
|
* the token's hash is kept, so the database alone can't open it. It is
|
|
* sealed again with every new token, and wiped once the invite is accepted,
|
|
* cancelled or has run out.
|
|
*/
|
|
|
|
const USER_INVITE_LINK_DAYS = 7;
|
|
|
|
/** Has the 3.5.1 update run? Until it has, there are no invites and New User sets a password. */
|
|
function userInvitesReady() {
|
|
global $mysqli;
|
|
static $ready = null;
|
|
if ($ready === null) {
|
|
if (defined('CURRENT_DATABASE_VERSION')) {
|
|
$version = CURRENT_DATABASE_VERSION;
|
|
} else {
|
|
$result = mysqli_query($mysqli, "SELECT config_current_database_version FROM settings WHERE company_id = 1");
|
|
$version = $result && ($row = mysqli_fetch_row($result)) ? (string) $row[0] : '0';
|
|
}
|
|
$ready = version_compare($version, '3.5.1', '>=');
|
|
}
|
|
return $ready;
|
|
}
|
|
|
|
/** Can an invite be emailed - mail set up, with an address to send from? */
|
|
function userInviteMailReady() {
|
|
return emailCapturing() || (emailReady() && filter_var(emailFrom()['email'], FILTER_VALIDATE_EMAIL));
|
|
}
|
|
|
|
/*
|
|
* ---------- an invite ----------
|
|
*/
|
|
|
|
/** An invite, with its role and who sent it - or null. */
|
|
function userInviteRow($invite_id) {
|
|
global $mysqli;
|
|
$invite_id = intval($invite_id);
|
|
if (!$invite_id || !userInvitesReady()) {
|
|
return null;
|
|
}
|
|
return mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT user_invites.*, role_name, role_is_admin, role_archived_at,
|
|
inviter.user_name AS invite_by_name, inviter.user_status AS invite_by_status, inviter.user_archived_at AS invite_by_archived_at
|
|
FROM user_invites
|
|
LEFT JOIN user_roles ON role_id = invite_role_id
|
|
LEFT JOIN users inviter ON inviter.user_id = invite_created_by AND invite_created_by > 0
|
|
WHERE invite_id = $invite_id LIMIT 1")) ?: null;
|
|
}
|
|
|
|
/** The invite waiting for an address - in date or run out, not accepted or cancelled - or null. */
|
|
function userInviteFor($email) {
|
|
global $mysqli;
|
|
if (!userInvitesReady()) {
|
|
return null;
|
|
}
|
|
$email_sql = escapeSql(trim((string) $email));
|
|
$invite_id = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT invite_id FROM user_invites
|
|
WHERE invite_email = '$email_sql' AND invite_accepted_at IS NULL AND invite_cancelled_at IS NULL
|
|
ORDER BY invite_id DESC LIMIT 1"))[0] ?? 0);
|
|
return $invite_id ? userInviteRow($invite_id) : null;
|
|
}
|
|
|
|
/** Where an invite stands: pending, expired, accepted or cancelled. */
|
|
function userInviteState(array $row) {
|
|
if (!empty($row['invite_accepted_at'])) {
|
|
return 'accepted';
|
|
}
|
|
if (!empty($row['invite_cancelled_at'])) {
|
|
return 'cancelled';
|
|
}
|
|
if (strtotime((string) $row['invite_expires_at']) <= time()) {
|
|
return 'expired';
|
|
}
|
|
return 'pending';
|
|
}
|
|
|
|
/** How a waiting invite stands, as a badge: Waiting (and how long it has left) or Ran out. ['label', 'title', 'tone'] */
|
|
function userInviteStateLabel(array $row) {
|
|
if (userInviteState($row) === 'expired') {
|
|
return ['label' => 'Ran out', 'title' => 'The link ran out on ' . date('M j, Y', strtotime($row['invite_expires_at'])) . ' - send it again for a new one', 'tone' => 'warning'];
|
|
}
|
|
$left = strtotime((string) $row['invite_expires_at']) - time();
|
|
$days = intval(floor($left / 86400));
|
|
return ['label' => 'Waiting', 'title' => 'The link works for ' . ($days >= 1 ? $days . ' more day' . ($days === 1 ? '' : 's') : 'less than a day'), 'tone' => 'info'];
|
|
}
|
|
|
|
/** The client rules an invite gives - ['allow' => [client_id => name], 'deny' => [...]], as userClientAccess(). */
|
|
function userInviteClientAccess(array $row) {
|
|
global $mysqli;
|
|
$access = ['allow' => [], 'deny' => []];
|
|
$rules = json_decode((string) ($row['invite_client_access'] ?? ''), true);
|
|
$ids = [];
|
|
foreach (['allow', 'deny'] as $type) {
|
|
foreach ((array) ($rules[$type] ?? []) as $client_id) {
|
|
if (intval($client_id)) {
|
|
$ids[intval($client_id)] = $type;
|
|
}
|
|
}
|
|
}
|
|
if ($ids) {
|
|
$sql = mysqli_query($mysqli, "SELECT client_id, client_name FROM clients WHERE client_id IN (" . implode(',', array_keys($ids)) . ") ORDER BY client_name");
|
|
while ($client = mysqli_fetch_assoc($sql)) {
|
|
$access[$ids[intval($client['client_id'])]][intval($client['client_id'])] = $client['client_name'];
|
|
}
|
|
}
|
|
return $access;
|
|
}
|
|
|
|
/** Who sent an invite, in words - their name, or '' when they are gone. */
|
|
function userInviteBy(array $row) {
|
|
return trim((string) ($row['invite_by_name'] ?? ''));
|
|
}
|
|
|
|
/** The link that opens the join page for an invite and its token. */
|
|
function userInviteUrl($invite_id, $token) {
|
|
global $config_base_url;
|
|
return "https://$config_base_url/join.php?" . http_build_query(['invite' => intval($invite_id), 'token' => (string) $token]);
|
|
}
|
|
|
|
/*
|
|
* ---------- seats ----------
|
|
*/
|
|
|
|
/**
|
|
* Invites in date and waiting - each holds a seat under the agent user
|
|
* limit until it is used, cancelled or runs out. Leaving out $except_invite_id.
|
|
*/
|
|
function userInvitesHoldingSeats($except_invite_id = 0) {
|
|
global $mysqli;
|
|
if (!userInvitesReady()) {
|
|
return 0;
|
|
}
|
|
return intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM user_invites
|
|
WHERE invite_accepted_at IS NULL AND invite_cancelled_at IS NULL AND invite_expires_at > NOW() AND invite_id <> " . intval($except_invite_id)))[0]);
|
|
}
|
|
|
|
/** Invites waiting - in date or run out - with a role. For a role that is to be archived. */
|
|
function userInvitesWithRole($role_id) {
|
|
global $mysqli;
|
|
if (!userInvitesReady()) {
|
|
return 0;
|
|
}
|
|
return intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM user_invites
|
|
WHERE invite_role_id = " . intval($role_id) . " AND invite_accepted_at IS NULL AND invite_cancelled_at IS NULL"))[0]);
|
|
}
|
|
|
|
/*
|
|
* ---------- the vault ----------
|
|
*/
|
|
|
|
/** The vault's key from the signed-in admin's session - '' when their session can't open it. */
|
|
function userInviteSessionVaultKey() {
|
|
$ciphertext = (string) ($_SESSION['user_encryption_session_ciphertext'] ?? '');
|
|
$iv = base64_decode((string) ($_SESSION['user_encryption_session_iv'] ?? ''));
|
|
$key = base64_decode((string) ($_COOKIE['user_encryption_session_key'] ?? ''));
|
|
if ($ciphertext === '' || $iv === '' || $key === '') {
|
|
return '';
|
|
}
|
|
$master = openssl_decrypt($ciphertext, 'aes-128-cbc', $key, 0, $iv);
|
|
return is_string($master) ? $master : '';
|
|
}
|
|
|
|
/**
|
|
* A new link for an invite: its token kept as a SHA-256, running out
|
|
* USER_INVITE_LINK_DAYS from now, with the vault's key from the signed-in
|
|
* admin's session sealed under it. The token, for the link - or '' when the
|
|
* session can't open the vault, and nothing is changed.
|
|
*/
|
|
function userInviteNewToken($invite_id) {
|
|
global $mysqli;
|
|
$master = userInviteSessionVaultKey();
|
|
if ($master === '') {
|
|
return '';
|
|
}
|
|
$token = bin2hex(random_bytes(24));
|
|
$hash = hash('sha256', $token);
|
|
$vault = mysqli_real_escape_string($mysqli, setupFirstUserSpecificKey($token, $master));
|
|
mysqli_query($mysqli, "UPDATE user_invites SET invite_token_hash = '$hash', invite_vault = '$vault', invite_expires_at = NOW() + INTERVAL " . USER_INVITE_LINK_DAYS . " DAY
|
|
WHERE invite_id = " . intval($invite_id));
|
|
return $token;
|
|
}
|
|
|
|
/**
|
|
* The invite a link is for - its token right - with where it stands
|
|
* (userInviteState(), or gone for a link that matches nothing):
|
|
* ['row' => row or null, 'state' => ...].
|
|
*/
|
|
function userInviteByLink($invite_id, $token) {
|
|
$token = is_string($token) ? $token : '';
|
|
if (!preg_match('/^[a-f0-9]{48}$/', $token)) {
|
|
return ['row' => null, 'state' => 'gone'];
|
|
}
|
|
$row = userInviteRow($invite_id);
|
|
if (!$row || !hash_equals((string) $row['invite_token_hash'], hash('sha256', $token))) {
|
|
return ['row' => null, 'state' => 'gone'];
|
|
}
|
|
return ['row' => $row, 'state' => userInviteState($row)];
|
|
}
|
|
|
|
/*
|
|
* ---------- the picture ----------
|
|
*/
|
|
|
|
/** Where an invite's picture is kept until they join. */
|
|
function userInviteAvatarDir($invite_id) {
|
|
return dirname(__DIR__) . '/uploads/users/invites/' . intval($invite_id);
|
|
}
|
|
|
|
/** Take an uploaded picture for an invite, in place of any it had. True when set, false with $error when not. */
|
|
function userInviteSaveAvatar($invite_id, $file, &$error = '') {
|
|
global $mysqli;
|
|
$invite_id = intval($invite_id);
|
|
$dir = userInviteAvatarDir($invite_id);
|
|
$old = (string) (mysqli_fetch_row(mysqli_query($mysqli, "SELECT invite_avatar FROM user_invites WHERE invite_id = $invite_id"))[0] ?? '');
|
|
$new_name = userAvatarStore($file, $dir, $error);
|
|
if ($new_name === '') {
|
|
return false;
|
|
}
|
|
if ($old !== '' && basename($old) === $old && is_file("$dir/$old")) {
|
|
unlink("$dir/$old");
|
|
}
|
|
mysqli_query($mysqli, "UPDATE user_invites SET invite_avatar = '" . escapeSql($new_name) . "' WHERE invite_id = $invite_id");
|
|
return true;
|
|
}
|
|
|
|
/** Remove an invite's picture, and its folder. */
|
|
function userInviteRemoveAvatar($invite_id) {
|
|
global $mysqli;
|
|
$invite_id = intval($invite_id);
|
|
$dir = userInviteAvatarDir($invite_id);
|
|
if (is_dir($dir)) {
|
|
foreach (glob("$dir/*") ?: [] as $file) {
|
|
if (is_file($file)) {
|
|
unlink($file);
|
|
}
|
|
}
|
|
@rmdir($dir);
|
|
}
|
|
mysqli_query($mysqli, "UPDATE user_invites SET invite_avatar = NULL WHERE invite_id = $invite_id");
|
|
}
|
|
|
|
/** An invite's picture, or the initials of the name on it - as userAvatar(). */
|
|
function userInviteAvatar(array $row, $size = '') {
|
|
$avatar = (string) ($row['invite_avatar'] ?? '');
|
|
if ($avatar !== '' && basename($avatar) === $avatar) {
|
|
return '<img class="itflow-contact-avatar' . ($size ? " itflow-contact-avatar-$size" : '') . '" src="/uploads/users/invites/' . intval($row['invite_id']) . '/' . escapeHtml(rawurlencode($avatar)) . '" alt="" loading="lazy">';
|
|
}
|
|
return userAvatar(intval($row['invite_id'] ?? 0), $row['invite_name'] ?? '', '', $size);
|
|
}
|
|
|
|
/*
|
|
* ---------- inviting ----------
|
|
*/
|
|
|
|
/**
|
|
* Keep an invite for someone, from New User or Get started - $in name and
|
|
* email (checked already), role_id, force_mfa and access (the Client access
|
|
* radios: client_id => allow / deny / ''). One already waiting for the
|
|
* address is changed to this, and keeps its picture unless $avatar has a new
|
|
* one. ['invite_id', 'again' => true when one was waiting, 'avatar_error'].
|
|
*/
|
|
function userInviteSave(array $in, $avatar = null) {
|
|
global $mysqli, $session_user_id;
|
|
$email = trim((string) $in['email']);
|
|
$name_sql = escapeSql(mb_substr(trim((string) $in['name']), 0, 200));
|
|
$email_sql = escapeSql($email);
|
|
$role_id = intval($in['role_id']);
|
|
$force_mfa = !empty($in['force_mfa']) ? 1 : 0;
|
|
$by = intval($session_user_id);
|
|
|
|
// The rules for clients that exist
|
|
$rules = ['allow' => [], 'deny' => []];
|
|
$wanted = [];
|
|
foreach ((array) ($in['access'] ?? []) as $client_id => $type) {
|
|
if (($type === 'allow' || $type === 'deny') && intval($client_id)) {
|
|
$wanted[intval($client_id)] = $type;
|
|
}
|
|
}
|
|
if ($wanted) {
|
|
$sql = mysqli_query($mysqli, "SELECT client_id FROM clients WHERE client_id IN (" . implode(',', array_keys($wanted)) . ") ORDER BY client_id");
|
|
while ($client = mysqli_fetch_row($sql)) {
|
|
$rules[$wanted[intval($client[0])]][] = intval($client[0]);
|
|
}
|
|
}
|
|
$access_sql = $rules['allow'] || $rules['deny'] ? "'" . mysqli_real_escape_string($mysqli, json_encode($rules)) . "'" : 'NULL';
|
|
|
|
$open = userInviteFor($email);
|
|
if ($open) {
|
|
$invite_id = intval($open['invite_id']);
|
|
mysqli_query($mysqli, "UPDATE user_invites SET invite_name = '$name_sql', invite_email = '$email_sql', invite_role_id = $role_id, invite_force_mfa = $force_mfa,
|
|
invite_client_access = $access_sql, invite_created_by = $by WHERE invite_id = $invite_id");
|
|
} else {
|
|
mysqli_query($mysqli, "INSERT INTO user_invites SET invite_name = '$name_sql', invite_email = '$email_sql', invite_role_id = $role_id, invite_force_mfa = $force_mfa,
|
|
invite_client_access = $access_sql, invite_expires_at = NOW(), invite_created_by = $by");
|
|
$invite_id = intval(mysqli_insert_id($mysqli));
|
|
}
|
|
|
|
$avatar_error = '';
|
|
if (!empty($avatar['tmp_name'])) {
|
|
userInviteSaveAvatar($invite_id, $avatar, $avatar_error);
|
|
}
|
|
return ['invite_id' => $invite_id, 'again' => (bool) $open, 'avatar_error' => $avatar_error];
|
|
}
|
|
|
|
/**
|
|
* Give an invite a new link - so one given out before stops working - and
|
|
* email it, or with $mail false only make it, for the admin to pass on.
|
|
* ['url', 'mailed'], or null with $error (HTML-safe) when the link can't be
|
|
* made (the admin's session can't open the vault) or the email can't go.
|
|
*/
|
|
function userInviteSend($invite_id, $mail, &$error = '') {
|
|
$error = '';
|
|
$token = userInviteNewToken($invite_id);
|
|
if ($token === '') {
|
|
$error = 'Your session can\'t open the credential vault, so the invite can\'t carry their copy of it. Sign out and in again, then send it.';
|
|
return null;
|
|
}
|
|
if ($mail && !userInviteMail($invite_id, $token)) {
|
|
$error = 'The invite email could not be queued - check Admin > Mail, or get the link and send it to them yourself.';
|
|
return null;
|
|
}
|
|
return ['url' => userInviteUrl($invite_id, $token), 'mailed' => (bool) $mail];
|
|
}
|
|
|
|
/**
|
|
* Show an invite's link once, at the top of Admin > Users, for the admin to
|
|
* pass on - it is never kept, so it can't be shown again (getting it again
|
|
* makes a new one).
|
|
*/
|
|
function userInviteShowLink($invite_id, $url) {
|
|
$_SESSION['user_invite_link'] = ['invite_id' => intval($invite_id), 'url' => (string) $url];
|
|
}
|
|
|
|
/** The link to show once, with its invite - and forget it. ['url', 'row'] or null. */
|
|
function userInviteTakeLink() {
|
|
$link = $_SESSION['user_invite_link'] ?? null;
|
|
unset($_SESSION['user_invite_link']);
|
|
$row = is_array($link) ? userInviteRow($link['invite_id'] ?? 0) : null;
|
|
if (!$row || userInviteState($row) !== 'pending') {
|
|
return null;
|
|
}
|
|
return ['url' => (string) $link['url'], 'row' => $row];
|
|
}
|
|
|
|
/**
|
|
* Email an invite its link - $token from userInviteNewToken(). Says who sent
|
|
* it, the role, and that they choose their own password. Notes when it went.
|
|
* False when it can't go.
|
|
*/
|
|
function userInviteMail($invite_id, $token) {
|
|
global $mysqli;
|
|
$row = userInviteRow($invite_id);
|
|
if (!$row || !userInviteMailReady() || !filter_var($row['invite_email'], FILTER_VALIDATE_EMAIL)) {
|
|
return false;
|
|
}
|
|
$company_name = trim((string) (billingCompany()['company_name'] ?? ''));
|
|
$app = userMailAppName();
|
|
$where = $company_name !== '' ? "$company_name on $app" : $app;
|
|
$by = userInviteBy($row);
|
|
$role = trim((string) ($row['role_name'] ?? ''));
|
|
$name = trim((string) $row['invite_name']);
|
|
|
|
$intro = [($by !== '' ? "$by has invited you" : 'You are invited') . " to join $where" . ($role !== '' ? ", as $role" : '') . '.',
|
|
'Accept with the button below - you choose your own password there, and you are ready to sign in. It takes a minute.'];
|
|
if (intval($row['invite_force_mfa'])) {
|
|
$intro[] = 'Have an authenticator app on your phone ready: you set up multi-factor authentication the first time you sign in.';
|
|
}
|
|
$summary = ['Invited' => (string) $row['invite_email']];
|
|
if ($role !== '') {
|
|
$summary['Role'] = $role;
|
|
}
|
|
$sent = (bool) emailQueue([['email' => (string) $row['invite_email'], 'name' => $name, 'greet' => $name !== '' ? $name : null,
|
|
'footnote' => 'You received this because ' . ($by !== '' ? $by : 'an administrator') . " invited this address to $where."]],
|
|
($by !== '' ? "$by invited you" : 'You are invited') . " to $where", [
|
|
'kind' => 'Your account', 'tone' => 'primary', 'preview' => 'Choose your password and you are in',
|
|
'title' => "Join $where",
|
|
'intro' => $intro,
|
|
'summary' => $summary,
|
|
'button' => ['label' => 'Accept the invite', 'url' => userInviteUrl($row['invite_id'], $token)],
|
|
'after' => ['The invite works for ' . USER_INVITE_LINK_DAYS . ' days. If you were not expecting it, you can ignore this email - nothing is set up unless the link is used.'],
|
|
]);
|
|
if ($sent && !emailCapturing()) {
|
|
mysqli_query($mysqli, "UPDATE user_invites SET invite_sent_at = NOW(), invite_sent_count = invite_sent_count + 1 WHERE invite_id = " . intval($row['invite_id']));
|
|
}
|
|
return $sent;
|
|
}
|
|
|
|
/** Cancel an invite that is waiting - its link stops working, and what it kept goes. False when it isn't waiting. */
|
|
function userInviteCancel($invite_id) {
|
|
global $mysqli;
|
|
$invite_id = intval($invite_id);
|
|
mysqli_query($mysqli, "UPDATE user_invites SET invite_cancelled_at = NOW(), invite_vault = ''
|
|
WHERE invite_id = $invite_id AND invite_accepted_at IS NULL AND invite_cancelled_at IS NULL");
|
|
if (mysqli_affected_rows($mysqli) !== 1) {
|
|
return false;
|
|
}
|
|
userInviteRemoveAvatar($invite_id);
|
|
return true;
|
|
}
|
|
|
|
/*
|
|
* ---------- accepting ----------
|
|
*/
|
|
|
|
/**
|
|
* Accept an invite - $invite from userInviteByLink() (pending), $token the
|
|
* link's, $in what they put in: name, password and confirm. Makes them an
|
|
* agent with what the invite set - role, MFA, client access, picture - and
|
|
* their copy of the vault under their password, spends the link and tells
|
|
* whoever sent it. The new agent's user_id, or 0 with $error (plain text).
|
|
*/
|
|
function userInviteAccept(array $invite, $token, array $in, &$error = '') {
|
|
global $mysqli, $session_user_id;
|
|
$error = '';
|
|
$invite_id = intval($invite['invite_id']);
|
|
$email = trim((string) $invite['invite_email']);
|
|
$by = userInviteBy($invite);
|
|
$ask = $by !== '' ? "ask $by" : 'ask your administrator';
|
|
|
|
$name = trim(preg_replace('/\s+/', ' ', strip_tags(mb_substr((string) ($in['name'] ?? ''), 0, 200))));
|
|
$password = (string) ($in['password'] ?? '');
|
|
if ($name === '') {
|
|
$error = 'Put in your name.';
|
|
return 0;
|
|
}
|
|
if (($problem = userPasswordProblem($password)) !== '') {
|
|
$error = $problem;
|
|
return 0;
|
|
}
|
|
if ($password !== (string) ($in['confirm'] ?? '')) {
|
|
$error = 'The two passwords are not the same - type it again in both.';
|
|
return 0;
|
|
}
|
|
|
|
// Still theirs to take: the address free, the role there, a seat
|
|
$taken = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT user_id, user_type FROM users WHERE user_email = '" . escapeSql($email) . "' AND user_archived_at IS NULL LIMIT 1"));
|
|
if ($taken) {
|
|
$error = intval($taken['user_type']) === 1
|
|
? 'This address signs in already - sign in with it. Forgotten the password? ' . ucfirst($ask) . ' to set a new one.'
|
|
: 'This address signs in to the client portal, so it can\'t be an agent\'s too - ' . $ask . ' about it.';
|
|
return 0;
|
|
}
|
|
if (($invite['role_name'] ?? null) === null) {
|
|
$error = 'The role you were invited with has been removed - ' . $ask . ' to invite you again.';
|
|
return 0;
|
|
}
|
|
$limit = getAgentUserLimit();
|
|
if ($limit > 0 && getActiveAgentUserCount() + userInvitesHoldingSeats($invite_id) >= $limit) {
|
|
$error = 'Every agent seat is taken - ' . $ask . ' to free one, then open this link again.';
|
|
return 0;
|
|
}
|
|
$master = decryptUserSpecificKey((string) $invite['invite_vault'], (string) $token);
|
|
if (!is_string($master) || $master === '') {
|
|
$error = 'This invite can\'t be used - ' . $ask . ' to send it again.';
|
|
return 0;
|
|
}
|
|
|
|
// Spend the link - one press, or one of two at once, gets through
|
|
mysqli_query($mysqli, "UPDATE user_invites SET invite_accepted_at = NOW(), invite_vault = ''
|
|
WHERE invite_id = $invite_id AND invite_accepted_at IS NULL AND invite_cancelled_at IS NULL AND invite_expires_at > NOW()
|
|
AND invite_token_hash = '" . mysqli_real_escape_string($mysqli, (string) $invite['invite_token_hash']) . "'");
|
|
if (mysqli_affected_rows($mysqli) !== 1) {
|
|
$error = 'That link has been used already.';
|
|
return 0;
|
|
}
|
|
|
|
// The agent, their copy of the vault under the password they chose
|
|
$name_sql = escapeSql($name);
|
|
$hash = mysqli_real_escape_string($mysqli, password_hash($password, PASSWORD_DEFAULT));
|
|
$vault = mysqli_real_escape_string($mysqli, setupFirstUserSpecificKey($password, $master));
|
|
$role_id = intval($invite['invite_role_id']);
|
|
mysqli_query($mysqli, "INSERT INTO users SET user_name = '$name_sql', user_email = '" . escapeSql($email) . "', user_password = '$hash', user_specific_encryption_ciphertext = '$vault',
|
|
user_auth_method = 'local', user_type = 1, user_status = 1, user_role_id = $role_id");
|
|
$user_id = intval(mysqli_insert_id($mysqli));
|
|
mysqli_query($mysqli, "INSERT INTO user_settings SET user_id = $user_id, user_config_force_mfa = " . (intval($invite['invite_force_mfa']) ? 1 : 0));
|
|
if (!intval($invite['role_is_admin'])) {
|
|
$access = userInviteClientAccess($invite);
|
|
userSetClientAccess($user_id, array_fill_keys(array_keys($access['allow']), 'allow') + array_fill_keys(array_keys($access['deny']), 'deny'));
|
|
}
|
|
|
|
// Their picture, from the invite to their own folder
|
|
$avatar = (string) ($invite['invite_avatar'] ?? '');
|
|
$from = userInviteAvatarDir($invite_id) . "/$avatar";
|
|
if ($avatar !== '' && basename($avatar) === $avatar && is_file($from)) {
|
|
$dir = dirname(__DIR__) . "/uploads/users/$user_id";
|
|
if (!is_dir($dir)) {
|
|
mkdir($dir, 0755, true);
|
|
}
|
|
if (rename($from, "$dir/$avatar")) {
|
|
mysqli_query($mysqli, "UPDATE users SET user_avatar = '" . escapeSql($avatar) . "' WHERE user_id = $user_id");
|
|
}
|
|
}
|
|
userInviteRemoveAvatar($invite_id);
|
|
|
|
// The invite is theirs now; any other waiting for the address is done with
|
|
mysqli_query($mysqli, "UPDATE user_invites SET invite_user_id = $user_id WHERE invite_id = $invite_id");
|
|
mysqli_query($mysqli, "UPDATE user_invites SET invite_cancelled_at = NOW(), invite_vault = ''
|
|
WHERE invite_email = '" . escapeSql($email) . "' AND invite_id <> $invite_id AND invite_accepted_at IS NULL AND invite_cancelled_at IS NULL");
|
|
|
|
// In their name - it was them
|
|
$session_user_id = $user_id;
|
|
$role = (string) $invite['role_name'];
|
|
logAudit("User", "Create", escapeSql("$name ($email) accepted " . ($by !== '' ? "$by's" : 'an') . " invite and joined as $role"), 0, $user_id);
|
|
if (intval($invite['invite_created_by']) && intval($invite['invite_by_status'] ?? 0) === 1 && empty($invite['invite_by_archived_at'])) {
|
|
mysqli_query($mysqli, "INSERT INTO notifications SET notification_type = 'User', notification = '" . escapeSql(mb_substr("$name accepted your invite and joined as $role", 0, 1000)) . "',
|
|
notification_action = '" . escapeSql('/admin/users.php?q=' . rawurlencode($email)) . "', notification_entity_id = $user_id, notification_user_id = " . intval($invite['invite_created_by']));
|
|
}
|
|
return $user_id;
|
|
}
|
|
|
|
/** How an agent joined, when it was by an invite - ['by', 'at'] or null. */
|
|
function userInviteJoined($user_id) {
|
|
global $mysqli;
|
|
if (!userInvitesReady() || !intval($user_id)) {
|
|
return null;
|
|
}
|
|
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT invite_accepted_at, inviter.user_name AS invite_by_name FROM user_invites
|
|
LEFT JOIN users inviter ON inviter.user_id = invite_created_by AND invite_created_by > 0
|
|
WHERE invite_user_id = " . intval($user_id) . " AND invite_accepted_at IS NOT NULL ORDER BY invite_accepted_at DESC LIMIT 1"));
|
|
return $row ? ['by' => trim((string) $row['invite_by_name']), 'at' => $row['invite_accepted_at']] : null;
|
|
}
|
|
|
|
/** Invites waiting - in date or run out - newest first, with their role. For Get started. */
|
|
function userInvitesWaiting() {
|
|
global $mysqli;
|
|
$rows = [];
|
|
if (!userInvitesReady()) {
|
|
return $rows;
|
|
}
|
|
$sql = mysqli_query($mysqli, "SELECT user_invites.*, role_name, role_is_admin FROM user_invites LEFT JOIN user_roles ON role_id = invite_role_id
|
|
WHERE invite_accepted_at IS NULL AND invite_cancelled_at IS NULL ORDER BY invite_created_at DESC, invite_id DESC");
|
|
while ($row = mysqli_fetch_assoc($sql)) {
|
|
$rows[] = $row;
|
|
}
|
|
return $rows;
|
|
}
|
|
|
|
/*
|
|
* ---------- nightly ----------
|
|
*/
|
|
|
|
/**
|
|
* Tidy the invites: one that has run out loses what it kept of the vault
|
|
* (sending it again seals it afresh), and cancelled ones, and ones that ran
|
|
* out, go after 90 days with their picture. How many went.
|
|
*/
|
|
function userInvitesNightly() {
|
|
global $mysqli;
|
|
if (!userInvitesReady()) {
|
|
return 0;
|
|
}
|
|
mysqli_query($mysqli, "UPDATE user_invites SET invite_vault = '' WHERE invite_vault <> '' AND invite_accepted_at IS NULL AND invite_expires_at < NOW()");
|
|
$gone = 0;
|
|
$sql = mysqli_query($mysqli, "SELECT invite_id FROM user_invites WHERE invite_accepted_at IS NULL AND (
|
|
(invite_cancelled_at IS NOT NULL AND invite_cancelled_at < NOW() - INTERVAL 90 DAY)
|
|
OR (invite_cancelled_at IS NULL AND invite_expires_at < NOW() - INTERVAL 90 DAY))");
|
|
while ($row = mysqli_fetch_row($sql)) {
|
|
userInviteRemoveAvatar($row[0]);
|
|
mysqli_query($mysqli, "DELETE FROM user_invites WHERE invite_id = " . intval($row[0]));
|
|
$gone += max(0, intval(mysqli_affected_rows($mysqli)));
|
|
}
|
|
return $gone;
|
|
}
|