['label' => 'Every email', 'text' => 'When a ticket is raised for you, every reply and update, when it is fixed and when it is closed'], 'replies' => ['label' => 'Replies and fixes only', 'text' => 'Our replies and when it is fixed - not the emails saying a ticket was raised or closed'], ]; /** About the company's other tickets - primary and technical contacts (contacts.contact_company_ticket_emails). */ const PORTAL_COMPANY_TICKET_EMAILS = [ 'none' => ['label' => 'Only the tickets I am on', 'text' => 'Nothing about tickets other people raise'], 'new' => ['label' => 'New tickets', 'text' => 'An email when anyone raises one - in the portal, by email or by calling us'], 'all' => ['label' => 'New tickets and every update', 'text' => 'As if you were copied in on all of them'], ]; /** How an announcement looks - its badge and icon. */ const PORTAL_ANNOUNCEMENT_TONES = [ 'info' => ['label' => 'Notice', 'icon' => 'fa-info-circle'], 'warning' => ['label' => 'Heads up', 'icon' => 'fa-exclamation-triangle'], 'danger' => ['label' => 'Urgent', 'icon' => 'fa-exclamation-circle'], ]; /* * ---------- password links ---------- * * The token goes out in the link and only its SHA-256 is kept, with when it * runs out (user_password_reset_token_expire), so a copy of the database holds * no link that works. Using a link spends it; a new one replaces the last. */ /** A link for a portal user to set their password, working for $minutes. '' for no such portal user. */ function portalPasswordLink($user_id, $minutes) { global $mysqli, $config_base_url; $user_id = intval($user_id); $minutes = max(1, intval($minutes)); $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT user_email, contact_client_id FROM users JOIN contacts ON contact_user_id = user_id WHERE user_id = $user_id AND user_type = 2 LIMIT 1")); if (!$row) { return ''; } $token = bin2hex(random_bytes(24)); $hash = hash('sha256', $token); mysqli_query($mysqli, "UPDATE users SET user_password_reset_token = '$hash', user_password_reset_token_expire = NOW() + INTERVAL $minutes MINUTE WHERE user_id = $user_id"); return "https://$config_base_url/client/login_reset.php?" . http_build_query(['email' => $row['user_email'], 'token' => $token, 'client' => intval($row['contact_client_id'])]); } /** The portal user a link is for - its token right and not run out, a password account still in use - or null. */ function portalPasswordLinkUser($email, $token, $client_id) { global $mysqli; $email = trim((string) $email); $token = (string) $token; $client_id = intval($client_id); if (!$client_id || !filter_var($email, FILTER_VALIDATE_EMAIL) || !preg_match('/^[A-Za-z0-9]{16,128}$/', $token)) { return null; } $email_sql = escapeSql($email); $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT user_id, user_email, user_password_reset_token, contact_id, contact_name FROM users JOIN contacts ON contact_user_id = user_id WHERE user_email = '$email_sql' AND contact_client_id = $client_id AND contact_archived_at IS NULL AND user_type = 2 AND user_auth_method = 'local' AND user_status = 1 AND user_archived_at IS NULL AND user_password_reset_token IS NOT NULL AND user_password_reset_token_expire > NOW() LIMIT 1")); if (!$row || !hash_equals((string) $row['user_password_reset_token'], hash('sha256', $token))) { return null; } return $row; } /** Spend a portal user's password link - used, or no longer wanted (access turned off, a Microsoft account). */ function portalPasswordLinkClear($user_id) { global $mysqli; mysqli_query($mysqli, "UPDATE users SET user_password_reset_token = NULL, user_password_reset_token_expire = NULL WHERE user_id = " . intval($user_id)); } /** When a portal user last signed in, or null for never. */ function portalContactLastSignIn($user_id) { global $mysqli; $user_id = intval($user_id); if (!$user_id) { return null; } $row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT MAX(log_created_at) FROM logs WHERE log_user_id = $user_id AND log_type = 'Client Login' AND log_action = 'Success'")); return $row[0] ?? null; } /* * ---------- access ---------- */ /** * Whether a contact can sign in to the portal, and how: key none (never set * up), off (turned off), local (a password) or azure (Microsoft) - with a * label, icon and class for portalStateBadge() in client/functions.php. From * a row with user_id, user_auth_method, user_status and user_archived_at * (LEFT JOIN users). */ function portalContactAccess(array $row) { if (empty($row['user_id'])) { return ['key' => 'none', 'label' => 'No portal access', 'icon' => 'fa-minus-circle', 'class' => 'itflow-portal-state-closed']; } $method = (string) ($row['user_auth_method'] ?? ''); if (!in_array($method, ['local', 'azure'], true) || intval($row['user_status'] ?? 0) !== 1 || !empty($row['user_archived_at'])) { return ['key' => 'off', 'label' => 'Portal access off', 'icon' => 'fa-ban', 'class' => 'itflow-portal-state-closed']; } return ['key' => $method, 'label' => $method === 'azure' ? 'Signs in with Microsoft' : 'Signs in with a password', 'icon' => 'fa-check', 'class' => 'itflow-portal-state-resolved']; } /* * ---------- every client's portal users (Admin > Users > Client portal) ---------- */ /** In SQL, over users joined to its contact and client: can sign in - a way to, not disabled, and nothing archived. */ const PORTAL_USER_ON_SQL = "user_auth_method IN ('local', 'azure') AND user_status = 1 AND user_archived_at IS NULL AND contact_archived_at IS NULL AND client_archived_at IS NULL"; /** * A portal user with the contact it signs in as and their client - raw, or * null when it isn't a portal user or has no contact. 'portal_on' is whether * they can sign in, and 'portal_off_why' says why not, in words. */ function portalUserRow($user_id) { global $mysqli; $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT users.user_id, user_name, user_email, user_auth_method, user_status, user_token, user_archived_at, user_created_at, contact_id, contact_name, contact_email, contact_title, contact_photo, contact_archived_at, client_id, client_name, client_archived_at FROM users JOIN contacts ON contact_user_id = users.user_id JOIN clients ON client_id = contact_client_id WHERE users.user_id = " . intval($user_id) . " AND user_type = 2 LIMIT 1")); if (!$row) { return null; } $row['portal_off_why'] = portalUserOffWhy($row); $row['portal_on'] = $row['portal_off_why'] === ''; return $row; } /** Why a portal user can't sign in, in words - '' when they can. From a row with the user, contact and client columns. */ function portalUserOffWhy(array $row) { if (!empty($row['client_archived_at'])) { return 'Their client is archived'; } if (!empty($row['contact_archived_at']) || !empty($row['user_archived_at'])) { return 'The contact is archived'; } if (intval($row['user_status'] ?? 0) !== 1) { return 'Their login is disabled'; } return in_array((string) ($row['user_auth_method'] ?? ''), ['local', 'azure'], true) ? '' : 'Portal access is turned off'; } /** * The last time each of some portal users signed in - [user_id => log row * with log_created_at, log_ip, log_user_agent], and nothing for one who never * has. For userLoginHtml(). */ function portalLastSignIns(array $user_ids) { global $mysqli; $user_ids = array_values(array_filter(array_map('intval', $user_ids))); $logins = []; if ($user_ids) { $sql = mysqli_query($mysqli, "SELECT log_user_id, log_created_at, log_ip, log_user_agent FROM logs WHERE log_id IN (SELECT MAX(log_id) FROM logs WHERE log_user_id IN (" . implode(',', $user_ids) . ") AND log_type = 'Client Login' AND log_action = 'Success' GROUP BY log_user_id)"); while ($row = mysqli_fetch_assoc($sql)) { $logins[intval($row['log_user_id'])] = $row; } } return $logins; } /** * Turn a portal user's access off, as a contact's page and the portal's own * People page do: no way to sign in, so the portal signs them out on their * next click (client/includes/check_login.php), and no password link or * remembered browser left to get back in with. */ function portalUserAccessOff($user_id) { global $mysqli; $user_id = intval($user_id); mysqli_query($mysqli, "UPDATE users SET user_auth_method = '', user_password_reset_token = NULL, user_password_reset_token_expire = NULL WHERE user_id = $user_id AND user_type = 2"); mysqli_query($mysqli, "DELETE remember_tokens FROM remember_tokens JOIN users ON user_id = remember_token_user_id WHERE user_id = $user_id AND user_type = 2"); } /** * Turn a portal user's access back on with $method (local or azure). Only for * one whose contact and client aren't archived, and whose email no other * account signs in with now. '' when done, or why not. */ function portalUserAccessOn($user_id, $method) { global $mysqli; $row = portalUserRow($user_id); if (!$row) { return 'That portal user no longer exists'; } if (!empty($row['client_archived_at']) || !empty($row['contact_archived_at'])) { return ($row['client_archived_at'] ? 'Their client' : 'The contact') . ' is archived - restore it first'; } if (!isset(portalSignInMethods()[$method])) { $method = 'local'; } $error = ''; if (userCheckIdentity(['name' => $row['contact_name'], 'email' => $row['user_email']], $row['user_id'], $error) === null) { return strip_tags($error); } mysqli_query($mysqli, "UPDATE users SET user_auth_method = '" . escapeSql($method) . "', user_status = 1, user_archived_at = NULL WHERE user_id = " . intval($row['user_id']) . " AND user_type = 2"); return ''; } /** The ways a portal user can sign in here - a password, and Microsoft when it is set up. method => label. */ function portalSignInMethods() { global $config_azure_client_id; $methods = ['local' => 'With a password']; if (!empty($config_azure_client_id)) { $methods['azure'] = 'With their Microsoft 365 account'; } return $methods; } /* * ---------- two-step sign-in ---------- * * A portal user's authenticator secret is users.user_token, as an agent's is, * and a browser ticked to be remembered is a remember_tokens row - so sign-in * (login.php) treats both alike. Microsoft 365 accounts are left to Microsoft. */ /** How many browsers this user ticked to be remembered, still in date. */ function portalRememberedBrowsers($user_id) { global $mysqli, $config_login_remember_me_expire; $days = max(1, intval($config_login_remember_me_expire)); return intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM remember_tokens WHERE remember_token_user_id = " . intval($user_id) . " AND remember_token_created_at > NOW() - INTERVAL $days DAY"))[0]); } /** Turn two-step sign-in off for a portal user and forget their remembered browsers. */ function portalMfaReset($user_id) { global $mysqli; $user_id = intval($user_id); mysqli_query($mysqli, "UPDATE users SET user_token = '' WHERE user_id = $user_id AND user_type = 2"); mysqli_query($mysqli, "DELETE FROM remember_tokens WHERE remember_token_user_id = $user_id"); } /** The QR code an authenticator app scans for a portal user - an SVG, '' without GD (then the secret is typed in). */ function portalMfaQrSvg($secret, $email) { global $mysqli; if (!extension_loaded('gd')) { return ''; } require_once dirname(__DIR__) . '/libs/barcode/barcode.php'; $company = mysqli_fetch_row(mysqli_query($mysqli, "SELECT company_name FROM companies WHERE company_id = 1")); $issuer = trim((string) ($company[0] ?? '')) ?: 'Client portal'; $uri = 'otpauth://totp/' . rawurlencode($issuer) . ':' . rawurlencode((string) $email) . '?secret=' . rawurlencode((string) $secret) . '&issuer=' . rawurlencode($issuer); $svg = (new barcode_generator())->render_svg('qr', $uri, ['w' => 200, 'h' => 200, 'p' => 0]); $svg = preg_replace('/^<\?xml[^>]*>\s*/', '', (string) $svg); return preg_replace('/ (string) $email, 'name' => (string) $name, 'greet' => trim((string) $name) !== '' ? (string) $name : null, 'footnote' => 'You received this because of a change to your client portal account. If it was not you, reply to this email straight away.']], (string) $subject, array_filter([ 'kind' => 'Client portal', 'tone' => 'primary', 'title' => (string) $subject, 'preview' => (string) ((array) $intro)[0], 'intro' => (array) $intro, 'button' => $button, ])); } /* * Tell a contact their portal account changed - $change: password (changed * in Your account), password_link (set from an emailed link), mfa_on, mfa_off * (by them), mfa_off_by_us (an agent turned it off). False when it can't go. */ function portalMailAccountChange($email, $name, $change) { $company = (string) (billingCompany()['company_name'] ?? ''); $changes = [ 'password' => ["Your $company client portal password was changed", ["The password for your $company client portal account was just changed from Your account.", 'If that was you, there is nothing more to do.']], 'password_link' => ["Your $company client portal password was changed", ["The password for your $company client portal account ($email) was just set from an emailed link.", 'If that was you, there is nothing more to do.']], 'mfa_on' => ['Two-step sign-in is on for your client portal account', ["Signing in to the $company client portal now asks for a code from your authenticator app, as well as your password.", 'Lost your phone? Ask us and we will reset it.']], 'mfa_off' => ['Two-step sign-in is off for your client portal account', ["Signing in to the $company client portal now asks for your password alone.", 'You can turn two-step sign-in back on from Your account.']], 'mfa_off_by_us' => ['Two-step sign-in was turned off for your client portal account', ['We turned off two-step sign-in for your client portal account, so it asks only for your password now.', 'You can turn it back on from Your account in the portal.']], ]; if (!isset($changes[$change])) { return false; } return portalAccountEmail($email, $name, $changes[$change][0], $changes[$change][1]); } /** * Invite a contact to the client portal - a link to set their password (good * for PORTAL_INVITE_LINK_DAYS, the reset page's own link), or for a Microsoft * account a link to the sign-in page. Never a password. $by is who gave them * access, raw - a primary contact in the portal, or '' when it was us. * False when it can't go. */ function portalMailInvite($user_id, $by = '') { global $mysqli, $config_base_url; $user_id = intval($user_id); $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT user_email, user_auth_method, contact_name, client_name FROM users JOIN contacts ON contact_user_id = user_id JOIN clients ON client_id = contact_client_id WHERE user_id = $user_id AND user_type = 2 AND user_auth_method IN ('local', 'azure') LIMIT 1")); if (!$row || !portalMailReady() || !filter_var($row['user_email'], FILTER_VALIDATE_EMAIL)) { return false; } $company_name = (string) (billingCompany()['company_name'] ?? ''); $by = trim((string) $by); $who = $by !== '' ? $by : $company_name; if ($row['user_auth_method'] === 'local') { $button = ['label' => 'Set your password', 'url' => portalPasswordLink($user_id, PORTAL_INVITE_LINK_DAYS * 1440)]; $how = 'Set a password with the button below, then sign in with this email address.'; $after = 'The link works once, for ' . PORTAL_INVITE_LINK_DAYS . ' days. After that, choose Forgot password on the sign-in page.'; } else { portalPasswordLinkClear($user_id); $button = ['label' => 'Sign in', 'url' => "https://$config_base_url/login.php"]; $how = 'Sign in with your Microsoft 365 account - choose Login with Microsoft on the sign-in page.'; $after = 'Questions? Reply to this email.'; } return (bool) emailQueue([['email' => $row['user_email'], 'name' => $row['contact_name'], 'footnote' => "You received this because $who added you to the $company_name client portal."]], "Your $company_name client portal", [ 'kind' => 'Client portal', 'tone' => 'primary', 'preview' => "Your access to the $company_name client portal", 'title' => "Your $company_name client portal", 'intro' => [($by !== '' ? "$by has" : 'We have') . " given you access to the $company_name client portal for " . $row['client_name'] . ' - where you can raise and follow support tickets.', $how], 'summary' => ['Sign in as' => (string) $row['user_email']], 'button' => $button, 'after' => [$after], ]); } /** A Forgot password link for a portal user, good for PORTAL_RESET_LINK_MINUTES. False when it can't go. */ function portalMailPasswordReset($user_id) { global $mysqli; $user_id = intval($user_id); $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT user_email, contact_name FROM users JOIN contacts ON contact_user_id = user_id WHERE user_id = $user_id AND user_type = 2 AND user_auth_method = 'local' LIMIT 1")); if (!$row || !portalMailReady()) { return false; } $company_name = (string) (billingCompany()['company_name'] ?? ''); return (bool) emailQueue([['email' => $row['user_email'], 'name' => $row['contact_name'], 'footnote' => 'You received this because someone asked to reset the password for this client portal account.']], "Reset your $company_name client portal password", [ 'kind' => 'Client portal', 'tone' => 'primary', 'preview' => 'A link to choose a new password', 'title' => "Reset your $company_name client portal password", 'intro' => ["Someone - probably you - asked to reset the password for your $company_name client portal account.", 'Choose a new one with the button below.'], 'summary' => ['Account' => (string) $row['user_email']], 'button' => ['label' => 'Set a new password', 'url' => portalPasswordLink($user_id, PORTAL_RESET_LINK_MINUTES)], 'after' => ['The link works once, for the next ' . PORTAL_RESET_LINK_MINUTES . ' minutes. If you did not ask for it, ignore this email - your password stays as it is.'], ]); } /* * ---------- announcements ---------- */ /** The announcements a client's portal shows now - to everyone and to this client - newest first. */ function portalAnnouncementsFor($client_id) { global $mysqli; $client_id = intval($client_id); $rows = []; $sql = mysqli_query($mysqli, "SELECT announcement_id, announcement_title, announcement_body, announcement_tone, announcement_starts_at, announcement_created_at FROM portal_announcements WHERE announcement_archived_at IS NULL AND announcement_client_id IN (0, $client_id) AND (announcement_starts_at IS NULL OR announcement_starts_at <= NOW()) AND (announcement_ends_at IS NULL OR announcement_ends_at > NOW()) ORDER BY COALESCE(announcement_starts_at, announcement_created_at) DESC, announcement_id DESC LIMIT 5"); while ($row = mysqli_fetch_assoc($sql)) { $rows[] = $row; } return $rows; } /** An announcement's text as HTML - escaped, its lines kept, and web addresses made links. */ function portalAnnouncementBodyHtml($text) { $html = escapeHtml(trim((string) $text)); $html = preg_replace_callback('~\bhttps?://[^\s<]+~i', function ($m) { $url = $m[0]; $tail = ''; while ($url !== '' && strpos('.,;:!?)', substr($url, -1)) !== false) { $tail = substr($url, -1) . $tail; $url = substr($url, 0, -1); } return '' . $url . '' . $tail; }, $html); return nl2br($html); } /** Where an announcement stands - ['key' => scheduled|showing|ended|archived, 'label' => ...]. */ function portalAnnouncementState(array $row) { $now = time(); if (!empty($row['announcement_archived_at'])) { return ['key' => 'archived', 'label' => 'Archived']; } if (!empty($row['announcement_ends_at']) && strtotime($row['announcement_ends_at']) <= $now) { return ['key' => 'ended', 'label' => 'Ended']; } if (!empty($row['announcement_starts_at']) && strtotime($row['announcement_starts_at']) > $now) { return ['key' => 'scheduled', 'label' => 'Scheduled']; } return ['key' => 'showing', 'label' => 'Showing']; } /* * ---------- View as contact ---------- * * An agent opens the portal as one of a client's contacts to see what they * see - read only, from the contact's page. Allowed for an agent who may see * that client and has read access to clients and support; billing shows only * when they also have read access to sales and financial. Checked again on * every portal page (client/includes/check_login.php), so taking an agent's * access away ends it. */ /** What an agent may see of the portal as a contact, or null when they may not. */ function portalViewAsAccess($agent_user_id, $contact_id) { global $mysqli, $config_client_portal_enable; $agent_user_id = intval($agent_user_id); $contact_id = intval($contact_id); if (intval($config_client_portal_enable) !== 1 || !$agent_user_id || !$contact_id) { return null; } $agent = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT user_name, user_password, user_type, user_status, user_archived_at, user_role_id, role_is_admin, user_token, user_config_force_mfa FROM users LEFT JOIN user_roles ON role_id = user_role_id LEFT JOIN user_settings ON user_settings.user_id = users.user_id WHERE users.user_id = $agent_user_id LIMIT 1")); if (!$agent || intval($agent['user_type']) !== 1 || intval($agent['user_status']) !== 1 || $agent['user_archived_at'] !== null) { return null; } // Still has to set up the MFA their account requires (includes/load_user_session.php) if (intval($agent['user_config_force_mfa'] ?? 0) === 1 && trim((string) $agent['user_token']) === '') { return null; } $admin = intval($agent['role_is_admin'] ?? 0) === 1; $level = function ($module) use ($mysqli, $admin, $agent) { if ($admin) { return 3; } $module = escapeSql($module); $row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT user_role_permission_level FROM user_role_permissions JOIN modules ON modules.module_id = user_role_permissions.module_id WHERE module_name = '$module' AND user_role_id = " . intval($agent['user_role_id']) . " LIMIT 1")); return intval($row[0] ?? 0); }; if ($level('module_client') < 1 || $level('module_support') < 1) { return null; } $contact = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT contact_id, contact_name, contact_client_id, contact_user_id, client_name FROM contacts JOIN clients ON client_id = contact_client_id WHERE contact_id = $contact_id AND contact_archived_at IS NULL AND client_archived_at IS NULL LIMIT 1")); if (!$contact) { return null; } $client_id = intval($contact['contact_client_id']); if (!$admin) { $allow = $deny = []; $sql = mysqli_query($mysqli, "SELECT client_id, permission_type FROM user_client_permissions WHERE user_id = $agent_user_id"); while ($row = mysqli_fetch_assoc($sql)) { if ($row['permission_type'] === 'deny') { $deny[] = intval($row['client_id']); } else { $allow[] = intval($row['client_id']); } } if (($allow && !in_array($client_id, $allow, true)) || in_array($client_id, $deny, true)) { return null; } } return [ 'contact_id' => $contact_id, 'contact_name' => (string) $contact['contact_name'], 'client_id' => $client_id, 'client_name' => (string) $contact['client_name'], 'user_id' => intval($contact['contact_user_id']), 'agent_user_id' => $agent_user_id, 'agent_name' => (string) $agent['user_name'], 'agent_session_stamp' => userSessionStamp($agent['user_password'] ?? ''), // client/includes/check_login.php 'billing' => $level('module_sales') >= 1 && $level('module_financial') >= 1, ]; }