mirror of
https://github.com/itflow-org/itflow
synced 2026-11-11 15:07:24 +00:00
404 lines
21 KiB
PHP
404 lines
21 KiB
PHP
<?php
|
|
|
|
/*
|
|
* The client portal's accounts, shared by the portal, the sign-in pages, the
|
|
* agent side and cron: links to set a password (an invite, Forgot password),
|
|
* two-step sign-in, the ticket emails a contact asked for, announcements on
|
|
* the portal's home page, and View as contact for agents.
|
|
*/
|
|
|
|
const PORTAL_RESET_LINK_MINUTES = 60; // Forgot password
|
|
const PORTAL_INVITE_LINK_DAYS = 7; // an invite to set a first password
|
|
const PORTAL_RESET_REQUEST_GAP_MINUTES = 5; // one Forgot password email per account in this long
|
|
|
|
/** The ticket emails a contact can ask for about their own tickets (contacts.contact_ticket_emails). */
|
|
const PORTAL_TICKET_EMAILS = [
|
|
'all' => ['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'];
|
|
}
|
|
|
|
/** 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('/<svg /', '<svg class="itflow-mfa-qr" role="img" aria-label="QR code for your authenticator app" ', $svg, 1);
|
|
}
|
|
|
|
/*
|
|
* ---------- emails to contacts ----------
|
|
*/
|
|
|
|
/** Can the portal's emails go out - mail set up, with a system mailbox to send from? */
|
|
function portalMailReady() {
|
|
return emailCapturing() || (emailReady() && filter_var(emailFrom()['email'], FILTER_VALIDATE_EMAIL));
|
|
}
|
|
|
|
/**
|
|
* An email to a contact about their portal account - a password changed,
|
|
* two-step sign-in turned on or off - from the system mailbox. Values raw;
|
|
* $intro is plain text. False when it can't go.
|
|
*/
|
|
function portalAccountEmail($email, $name, $subject, $intro, $button = null) {
|
|
if (!portalMailReady()) {
|
|
return false;
|
|
}
|
|
return (bool) emailQueue([['email' => (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 '<a href="' . $url . '" target="_blank" rel="noopener noreferrer">' . $url . '</a>' . $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,
|
|
];
|
|
}
|