diff --git a/client/activity.php b/client/activity.php new file mode 100644 index 000000000..3e64725b2 --- /dev/null +++ b/client/activity.php @@ -0,0 +1,138 @@ + 0 && $page > $total_pages) { + $page = $total_pages; + $offset = ($page - 1) * $records_per_page; +} + +$sql_activity = mysqli_query( + $mysqli, + "SELECT log_action, log_created_at, log_description, log_ip, log_type FROM logs + WHERE $log_scope + ORDER BY log_id DESC + LIMIT $records_per_page OFFSET $offset" +); + +?> + +
+

Your activity

+ Back to profile +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
WhenTypeWhat happenedFrom
+ + 1) { ?> +
+
+

+ Page of + — records +

+
+
+ +
+
+ + + + +
+
+ + + + + + + + diff --git a/client/includes/header.php b/client/includes/header.php index ea8b9e14f..263990415 100644 --- a/client/includes/header.php +++ b/client/includes/header.php @@ -40,9 +40,23 @@ header("X-Frame-Options: DENY"); // Legacy which left this portal without .text-bold, the bg-dark text pairing or any theme. --> + + + + + - +, which this portal's CSP + (default-src 'self') would block outright. Empty when the company has + no country set, which js/phone_inputs.js reads as "let the library + decide". */ ?> + @@ -125,6 +139,7 @@ header("X-Frame-Options: DENY"); // Legacy diff --git a/client/post.php b/client/post.php index 81ef1953b..a079f644f 100644 --- a/client/post.php +++ b/client/post.php @@ -164,6 +164,102 @@ if (isset($_POST['add_ticket_comment'])) { } } + + +if (isset($_POST['set_contact_phone'])) { + + validateCSRFToken(); + + /* + * SCOPING: as with the PIN above, the row updated is the logged-in + * contact's own, from the session. No contact_id parameter exists on this + * handler, so there is nothing to point at somebody else's record with. + * + * Sanitising exactly as agent/post/contact_model.php does - digits only for + * every phone field - so a number typed in the portal is stored in the same + * shape as one typed by an agent and formatPhoneNumber() renders both the + * same way. Anything else would make the portal the odd one out. + */ + $phone_country_code = preg_replace("/[^0-9]/", '', $_POST['phone_country_code'] ?? ''); + $phone = preg_replace("/[^0-9]/", '', $_POST['phone'] ?? ''); + $extension = preg_replace("/[^0-9]/", '', $_POST['extension'] ?? ''); + $mobile_country_code = preg_replace("/[^0-9]/", '', $_POST['mobile_country_code'] ?? ''); + $mobile = preg_replace("/[^0-9]/", '', $_POST['mobile'] ?? ''); + + // Columns are varchar(200), country codes varchar(10) + $phone_country_code = substr($phone_country_code, 0, 10); + $mobile_country_code = substr($mobile_country_code, 0, 10); + $phone = substr($phone, 0, 200); + $extension = substr($extension, 0, 200); + $mobile = substr($mobile, 0, 200); + + // A country code on its own is not a number - drop it rather than store a + // dangling code the formatter would try to render + if (empty($phone)) { + $phone_country_code = ''; + $extension = ''; + } + if (empty($mobile)) { + $mobile_country_code = ''; + } + + mysqli_query($mysqli, "UPDATE contacts SET + contact_phone_country_code = '$phone_country_code', + contact_phone = '$phone', + contact_extension = '$extension', + contact_mobile_country_code = '$mobile_country_code', + contact_mobile = '$mobile' + WHERE contact_id = $session_contact_id AND contact_client_id = $session_client_id"); + + logAudit("Contact", "Edit", "Client contact $session_contact_name updated their phone numbers in the client portal", $session_client_id, $session_contact_id); + + flashAlert("Phone numbers updated"); + + redirect('profile.php'); + +} + +if (isset($_POST['set_contact_pin'])) { + + validateCSRFToken(); + + /* + * SCOPING: the row updated is the logged-in contact's own, taken from the + * session. There is no contact_id parameter on this handler by design, so + * there is nothing for a contact to point at somebody else's record with. + * + * No capability gate: the PIN belongs to the contact, not to a portal + * section, so every signed-in contact manages their own - same as the + * password change above. + */ + $pin = trim($_POST['pin'] ?? ''); + + if ($pin === '') { + flashAlert("Enter a PIN, or leave the page to keep the current one", 'error'); + redirect('profile.php'); + } + + if (strlen($pin) < 4) { + flashAlert("Your PIN needs to be at least 4 characters", 'error'); + redirect('profile.php'); + } + + // contact_pin is varchar(255) - trim to fit rather than let an over-long + // value error out under strict mode + $pin = escapeSql(substr($pin, 0, 255)); + + mysqli_query($mysqli, "UPDATE contacts SET contact_pin = '$pin' WHERE contact_id = $session_contact_id AND contact_client_id = $session_client_id"); + + // The PIN itself never goes in the log - it is a verification secret, and + // an audit trail an agent can read would defeat the point of having one + logAudit("Contact", "Edit", "Client contact $session_contact_name set their phone PIN in the client portal", $session_client_id, $session_contact_id); + + flashAlert("Phone PIN updated"); + + redirect('profile.php'); + +} + if (isset($_GET['approve_ticket_task'])) { validateCSRFToken(); diff --git a/client/profile.php b/client/profile.php index 9df4ef2a9..dbc613adf 100644 --- a/client/profile.php +++ b/client/profile.php @@ -4,45 +4,501 @@ * User profile */ +// Read by client/includes/header.php and footer.php - see the note there. +// Must be set before inc_all.php, which pulls the header in. +$portal_load_phone_inputs = true; + header("Content-Security-Policy: default-src 'self'"); require_once 'includes/inc_all.php'; +/* + * check_login.php runs these through escapeSql(), which is for writing to the + * database rather than printing. It leaves a backslash in front of any quote in + * the value - O'Brien renders as O\'Brien - which is why the name was already + * wrapped in stripslashes() here. Email, PIN and company were printed raw, and + * the company name had no escaping of any kind. Normalise all four in one place + * and escape at the point of output. + */ +$contact_name = escapeHtml(stripslashes($session_contact_name)); +$contact_email = escapeHtml(stripslashes($session_contact_email)); +$contact_pin = escapeHtml(stripslashes($session_contact_pin)); +$client_name = escapeHtml(stripslashes($session_client_name)); + +/* + * check_login.php loads the handful of contact columns every portal page needs. + * The rest are only wanted here, so they are fetched on this page rather than + * added to a query that runs on every request. Scoped to the session contact + * and their client - nothing here reads an id from the request. + */ +$row = mysqli_fetch_assoc(mysqli_query( + $mysqli, + "SELECT contact_department, contact_extension, contact_location_id, contact_mobile, + contact_mobile_country_code, contact_phone, contact_phone_country_code + FROM contacts + WHERE contact_id = $session_contact_id AND contact_client_id = $session_client_id + LIMIT 1" +)); + +$contact_title = escapeHtml(stripslashes($session_contact_title)); +$contact_department = escapeHtml($row['contact_department']); +$contact_phone_country_code = escapeHtml($row['contact_phone_country_code']); +$contact_extension = escapeHtml($row['contact_extension']); +$contact_mobile_country_code = escapeHtml($row['contact_mobile_country_code']); +$contact_location_id = intval($row['contact_location_id']); + +/* + * Two renderings of the same digits. The tables show them formatted for + * reading; the modal inputs get formatPhoneNumber(..., false), which is the + * form-input variant - intl-tel-input runs in separateDialCode mode, so the + * visible input holds the national number only and the dial code lives in the + * hidden field beside it. + */ +$contact_phone = escapeHtml(formatPhoneNumber($row['contact_phone'], $row['contact_phone_country_code'])); +$contact_mobile = escapeHtml(formatPhoneNumber($row['contact_mobile'], $row['contact_mobile_country_code'])); +$contact_phone_input = escapeHtml(formatPhoneNumber($row['contact_phone'], $row['contact_phone_country_code'], false)); +$contact_mobile_input = escapeHtml(formatPhoneNumber($row['contact_mobile'], $row['contact_mobile_country_code'], false)); + +/* + * The location is joined on client too, so a contact whose location_id points + * at another client's row - stale data, a moved contact - gets nothing rather + * than another company's address. + */ +$contact_location = ''; +$contact_location_address = ''; +if (!empty($contact_location_id)) { + $location = mysqli_fetch_assoc(mysqli_query( + $mysqli, + "SELECT location_address, location_city, location_country, location_name, location_state, location_zip + FROM locations + WHERE location_id = $contact_location_id + AND location_client_id = $session_client_id + AND location_archived_at IS NULL + LIMIT 1" + )); + if ($location) { + $contact_location = escapeHtml($location['location_name']); + $contact_location_address = nl2br(escapeHtml(formatAddress( + $location['location_address'], + $location['location_city'], + $location['location_state'], + $location['location_zip'], + $location['location_country'] + ))); + } +} + +$login_method = $_SESSION['login_method'] ?? 'local'; + +if ($login_method === 'local') { + $login_method_display = 'Password'; +} elseif ($login_method === 'azure') { + $login_method_display = 'Microsoft account'; +} else { + $login_method_display = escapeHtml(ucfirst($login_method)); +} + +/* + * These three decide which sections of the portal a contact can reach - the + * same flags contactCan() switches on - so the answer to "why can't I see + * invoices" is on this page rather than only in the agent's copy of the record. + * + * All three are booleans. The billing row used to be written as + * ($session_contact_is_billing_contact == $session_contact_id), comparing a + * bool to a contact id. PHP casts the id to bool for that comparison, so it + * gave the right answer for every real contact and would only have misreported + * at id 0 - working by luck rather than by meaning. + */ +$contact_roles = [ + ['Primary', (bool) $session_contact_primary, 'Everything in the portal'], + ['Billing', (bool) $session_contact_is_billing_contact, 'Invoices, quotes and payments'], + ['Technical', (bool) $session_contact_is_technical_contact, 'Assets, documents, domains and contacts'], +]; + +/* + * Recent activity, both scoped on log_user_id - the portal user this contact + * signs in as. logAudit() fills that from the $session_user_id global, and + * login.php deliberately sets it before logging a client login ("Option B" in + * that file), so both the sign-ins and the actions carry it. An agent working + * on this client writes their own user id, so their work never appears here. + * + * Successful sign-ins only. Failed attempts are logged without a reliable user + * id - login.php cannot always resolve one from a bad email - so they cannot + * honestly be attributed to this contact. + */ +$sql_logins = mysqli_query( + $mysqli, + "SELECT log_created_at, log_ip FROM logs + WHERE log_type = 'Client Login' + AND log_action = 'Success' + AND log_user_id = $session_user_id + AND log_client_id = $session_client_id + ORDER BY log_id DESC + LIMIT 5" +); + +$sql_actions = mysqli_query( + $mysqli, + "SELECT log_action, log_created_at, log_description, log_type FROM logs + WHERE log_user_id = $session_user_id + AND log_client_id = $session_client_id + AND log_type != 'Client Login' + ORDER BY log_id DESC + LIMIT 5" +); + ?> -

Profile

+

Profile

+
-

Name:

-

Email:

-

PIN:

-

Client:

-
-

Client Primary Contact:

-

Client Technical Contact:

-

Client Billing Contact:

-
-

Login via:

-

User ID:

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Your details
Name
Email
Title
Department
Company
Location + + +
+ +
Phone + + Not set + + + + ext. + + + +
Mobile + + Not set + + + + +
+ + + To change your name, email, title, department or location, raise a ticket and we will + update them for you. + - - -
-
-

Password

-
- -
- -
- - -
-
- -
- + +
+ + + + + + + + + + + + + + + + + + + + + + + +
Portal access
+ contact +
+
+ + Yes + + No + +
Sign in with + + + + +
+ Phone PIN +
Confirms it is you when you call +
+ + Not set + + + + +
+ +
+ +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
Recent sign-ins
No sign-ins recorded yet.
+ + + Somewhere here you do not recognise? Raise a ticket and change your password. + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
Recent activity
Nothing recorded yet.
+ + View all your activity + +
+ +
+ +

+ Portal user ID — quote this if we ask for it. +

+ + + + + + + + + + diff --git a/js/app.js b/js/app.js index 92459ee76..9bfb0c6d2 100644 --- a/js/app.js +++ b/js/app.js @@ -621,7 +621,17 @@ function itflowInit() { }); // Phone inputs - itflowStep('phone-inputs', initPhoneInputs); + // js/phone_inputs.js is only loaded where phone fields exist - the agent + // footer and the portal profile page. Referencing the bare identifier here + // would throw a ReferenceError anywhere it is absent (setup/index.php loads + // app.js on its own), and that throw happens while evaluating the argument, + // before itflowStep's try/catch can contain it - taking every step after + // this one down with it. + itflowStep('phone-inputs', function () { + if (typeof initPhoneInputs === 'function') { + initPhoneInputs(); + } + }); } // modal_footer.php re-loads this file on every ajax modal open, so run now if @@ -923,173 +933,3 @@ function itflowWatchNetworkIp(networkId, ipId) { } - -/** - * intl-tel-input on every phone field. - * - * ITFlow stores the dial code and the number in separate columns - * (contact_phone_country_code / contact_phone and friends), so the library runs - * in separateDialCode mode: its dropdown owns the dial code, the visible input - * holds only the national number. That keeps the existing schema, the API and - * every render site untouched. - * - * Markup contract: - * - * - * - * Which country a field starts on: - * - * A saved record ALWAYS keeps the dial code it was saved with. Anything else - * silently rewrites data - open a UK contact under a US client, close the - * modal, and its +44 would have been saved back as +1. - * - * Context only decides WHICH country claims that code, since a code is not a - * country (+1 covers 25 of them). In order: the address Country picker named - * by data-itflow-phone-country-select on the input, then - * data-itflow-phone-country on the form (the contact modals use this for the - * client's country), then the same attribute on (the company's). - * If none of them claims the stored code, the code's canonical country wins - - * priority 0 in the library's own data, i.e. US for +1 rather than whichever - * territory happens to sort first. - * - * With nothing stored - a new record - context is the whole answer. - */ -function initPhoneInputs() { - if (typeof window.intlTelInput !== 'function') { - return; - } - - document.querySelectorAll('input[data-itflow-phone]').forEach(function (el) { - // One field must never take the rest down with it. The first version of - // this called v17 API names that v29 dropped, and because the whole - // sweep shared one try/catch the throw on the first phone field meant - // every mobile and fax input after it silently never initialised. - try { - initOnePhoneInput(el); - } catch (e) { - console.error('itflow phone input failed:', el.name, e); - } - }); -} - -function initOnePhoneInput(el) { - // modal_footer.php re-executes this file on every ajax modal open, so - // without a guard each open would stack another instance on the input. - if (el.dataset.itiReady) { - return; - } - - var form = el.form; - var hidden = form ? form.querySelector('input[name="' + el.dataset.itflowPhone + '"]') : null; - if (!hidden) { - return; - } - el.dataset.itiReady = '1'; - - var countrySelect = null; - if (form && el.dataset.itflowPhoneCountrySelect) { - countrySelect = form.querySelector('[name="' + el.dataset.itflowPhoneCountrySelect + '"]'); - } - - var stored = (hidden.value || '').replace(/[^0-9]/g, ''); - var context = isoFromSelect(countrySelect) - || (form ? (form.dataset.itflowPhoneCountry || '') : '') - || (document.body.dataset.itflowPhoneCountry || ''); - - var initial = stored ? isoForDialCode(stored, context) : context; - - var iti = window.intlTelInput(el, { - initialCountry: initial.toLowerCase(), - separateDialCode: true, - countrySearch: true, - formatAsYouType: true - }); - - var sync = function () { - var country = iti.getSelectedCountry(); - hidden.value = country && country.dialCode ? country.dialCode : ''; - }; - - // Only write back on load when we know the stored code survived. If it - // resolved to nothing - a code no country uses - leave the field exactly as - // saved rather than blanking it; the user picking a country will set it. - var selected = iti.getSelectedCountry(); - if (!stored || (selected && selected.dialCode === stored)) { - sync(); - } - - el.addEventListener('countrychange', sync); - - // Follow the address country picker while the form is open. - if (countrySelect) { - countrySelect.addEventListener('change', function () { - var iso2 = isoFromSelect(countrySelect); - if (iso2) { - iti.setSelectedCountry(iso2.toLowerCase()); - sync(); - } - }); - } - - // Belt and braces for a form saved without ever opening the dropdown. - if (form && !form.dataset.itiSyncBound) { - form.dataset.itiSyncBound = '1'; - form.addEventListener('submit', function () { - form.querySelectorAll('input[data-itflow-phone]').forEach(function (input) { - var target = form.querySelector('input[name="' + input.dataset.itflowPhone + '"]'); - var inst = window.intlTelInput.getInstance(input); - if (target && inst) { - var c = inst.getSelectedCountry(); - target.value = c && c.dialCode ? c.dialCode : ''; - } - }); - }); - } -} - -/** - * ISO2 for the country a