+, 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"
+);
+
?>
-
+ Portal user ID = intval($_SESSION['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