Feature: Major spruce up of the client portal profile page, added Department, Location, Title, Phone with Edit, editing Pin, Recent signins and recent activity along with a seperate activity page

This commit is contained in:
johnnyq
2026-08-28 16:08:23 -04:00
parent 194841e7f8
commit 10fb40f36f
9 changed files with 983 additions and 201 deletions

138
client/activity.php Normal file
View File

@@ -0,0 +1,138 @@
<?php
/*
* Client Portal
* Everything this contact has done in the portal, and every sign-in
*/
header("Content-Security-Policy: default-src 'self'");
require_once "includes/inc_all.php";
/*
* No capability gate: this is the contact's own record of their own activity,
* not a section of the portal. Scoped on log_user_id - the portal user this
* contact signs in as - so an agent working this client never appears here,
* and on log_client_id as a second fence.
*/
$page = intval($_GET['page'] ?? 1);
if ($page < 1) {
$page = 1;
}
$records_per_page = 25;
$offset = ($page - 1) * $records_per_page;
$log_scope = "log_user_id = $session_user_id AND log_client_id = $session_client_id";
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(log_id) AS total FROM logs WHERE $log_scope"));
$total_records = intval($row['total']);
$total_pages = (int) ceil($total_records / $records_per_page);
// A page number past the end would show nothing at all with no way back
if ($total_pages > 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"
);
?>
<div class="d-flex justify-content-between align-items-center mb-3">
<h3 class="mb-0">Your activity</h3>
<a class="btn btn-secondary" href="profile.php"><i class="fa fa-fw fa-user me-2"></i>Back to profile</a>
</div>
<hr>
<div class="row">
<div class="col-md-12">
<?php if ($total_records == 0) { ?>
<?= portalEmptyState('Nothing has been recorded on your account yet.') ?>
<?php } else { ?>
<table class="table table-bordered border border-dark">
<thead class="table-dark">
<tr>
<th>When</th>
<th>Type</th>
<th>What happened</th>
<th>From</th>
</tr>
</thead>
<tbody>
<?php
while ($row = mysqli_fetch_assoc($sql_activity)) {
$log_type = escapeHtml($row['log_type']);
$log_action = escapeHtml($row['log_action']);
$log_description = escapeHtml($row['log_description']);
$log_ip = escapeHtml($row['log_ip']);
// Sign-ins are the rows people scan this page for, so they
// get the accent rather than sitting in the same grey as
// every password change
if ($row['log_type'] === 'Client Login') {
$log_badge_color = 'primary';
$log_label = 'Sign-in';
} else {
$log_badge_color = 'secondary';
$log_label = "$log_type $log_action";
}
?>
<tr>
<td class="text-nowrap"><?= portalDateTime($row['log_created_at']) ?></td>
<td><span class="p-2 badge text-bg-<?= $log_badge_color ?>"><?= $log_label ?></span></td>
<td><?= $log_description ?></td>
<td class="font-monospace"><?= $log_ip ?></td>
</tr>
<?php
}
?>
</tbody>
</table>
<?php if ($total_pages > 1) { ?>
<div class="row align-items-center">
<div class="col-sm">
<p class="text-muted mb-0">
Page <strong><?= $page ?></strong> of <strong><?= $total_pages ?></strong>
&mdash; <strong><?= $total_records ?></strong> records
</p>
</div>
<div class="col-sm">
<ul class="pagination justify-content-sm-end mb-0">
<li class="page-item <?= $page <= 1 ? 'disabled' : '' ?>">
<a class="page-link" href="?page=<?= $page - 1 ?>">Previous</a>
</li>
<li class="page-item <?= $page >= $total_pages ? 'disabled' : '' ?>">
<a class="page-link" href="?page=<?= $page + 1 ?>">Next</a>
</li>
</ul>
</div>
</div>
<?php } ?>
<?php } ?>
</div>
</div>
<?php
require_once "includes/footer.php";

View File

@@ -72,6 +72,43 @@ function enforceContactCan($capability) {
}
}
/*
* A timestamp a person can read, in the company's configured date and time
* format rather than the raw DATETIME the database hands back.
*
* Today and yesterday are named instead of dated, because on an activity list
* the recent rows are the ones being scanned and "Today at 4:12 PM" answers
* "was that just now?" faster than a date does. Anything older gets the full
* date, since by then the date is the useful part.
*
* Returns HTML-escaped output - callers print it directly.
*/
function portalDateTime($datetime) {
global $config_date_format, $config_time_format;
if (empty($datetime)) {
return '';
}
$timestamp = strtotime($datetime);
if ($timestamp === false) {
return escapeHtml($datetime);
}
$time = date($config_time_format, $timestamp);
$day = date('Y-m-d', $timestamp);
if ($day === date('Y-m-d')) {
return escapeHtml("Today at $time");
}
if ($day === date('Y-m-d', strtotime('-1 day'))) {
return escapeHtml("Yesterday at $time");
}
return escapeHtml(date($config_date_format, $timestamp) . " at $time");
}
/*
* The empty state for the portal's list pages.
*

View File

@@ -69,6 +69,11 @@
<script src="/libs/sweetalert2/js/sweetalert2.min.js"></script>
<script src="/js/confirm_modal.js"></script>
<?php if (!empty($portal_load_phone_inputs)) { ?>
<script src="/libs/intl-tel-input/js/intlTelInputWithUtils.min.js"></script>
<script src="/js/phone_inputs.js"></script>
<?php } ?>
<script src="/js/keepalive.js"></script>
</body>

View File

@@ -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. -->
<link rel="stylesheet" href="/css/itflow_custom.css">
<?php /* Only the pages that set this flag before including inc_all.php pull
in intl-tel-input - it is ~200KB of JS and flag sprites, and one
page uses it. */ ?>
<?php if (!empty($portal_load_phone_inputs)) { ?>
<link rel="stylesheet" href="/libs/intl-tel-input/css/intlTelInput.min.css">
<?php } ?>
</head>
<body class="bg-body-tertiary theme-<?= escapeHtml($config_theme) ?>" data-lte-primary="<?= escapeHtml($config_theme) ?>">
<?php /* Same country bridge as the agent header: intl-tel-input wants an ISO2
code, the companies table stores a country NAME. Given as a data
attribute rather than an inline <script>, 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". */ ?>
<body class="bg-body-tertiary theme-<?= escapeHtml($config_theme) ?>" data-lte-primary="<?= escapeHtml($config_theme) ?>"
data-itflow-phone-country="<?= escapeHtml($country_iso2_array[$session_company_country] ?? '') ?>">
<!-- Navbar -->
@@ -125,6 +139,7 @@ header("X-Frame-Options: DENY"); // Legacy
</a>
<div class="dropdown-menu">
<a class="dropdown-item" href="/client/profile.php"><i class="fas fa-fw fa-user me-2"></i>Account</a>
<a class="dropdown-item" href="/client/activity.php"><i class="fas fa-fw fa-list me-2"></i>Activity</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="/client/post.php?logout"><i class="fas fa-fw fa-sign-out-alt me-2"></i>Sign out</a>
</div>

View File

@@ -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();

View File

@@ -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"
);
?>
<h2>Profile</h2>
<h3>Profile</h3>
<hr>
<p>Name: <?= stripslashes(escapeHtml($session_contact_name)) ?></p>
<p>Email: <?= $session_contact_email ?></p>
<p>PIN: <?= $session_contact_pin ?></p>
<p>Client: <?= $session_client_name ?></p>
<br>
<p>Client Primary Contact: <?php if ($session_contact_primary == 1) {echo "Yes"; } else {echo "No";} ?></p>
<p>Client Technical Contact: <?php if ($session_contact_is_technical_contact) {echo "Yes"; } else {echo "No";} ?></p>
<p>Client Billing Contact: <?php if ($session_contact_is_billing_contact == $session_contact_id) {echo "Yes"; } else {echo "No";} ?></p>
<br>
<p>Login via: <?= $_SESSION['login_method'] ?> </p>
<p>User ID: <?= $_SESSION['user_id'] ?> </p>
<div class="row">
<div class="col-lg-7 mb-4">
<table class="table table-bordered border border-dark">
<thead class="table-dark">
<tr>
<th colspan="2">Your details</th>
</tr>
</thead>
<tbody>
<tr>
<th class="w-25">Name</th>
<td><?= $contact_name ?></td>
</tr>
<tr>
<th>Email</th>
<td><?= $contact_email ?></td>
</tr>
<?php if (!empty($contact_title)) { ?>
<tr>
<th>Title</th>
<td><?= $contact_title ?></td>
</tr>
<?php } ?>
<?php if (!empty($contact_department)) { ?>
<tr>
<th>Department</th>
<td><?= $contact_department ?></td>
</tr>
<?php } ?>
<tr>
<th>Company</th>
<td><?= $client_name ?></td>
</tr>
<?php if (!empty($contact_location)) { ?>
<tr>
<th>Location</th>
<td>
<?= $contact_location ?>
<?php if (!empty($contact_location_address)) { ?>
<br><small class="text-muted"><?= $contact_location_address ?></small>
<?php } ?>
</td>
</tr>
<?php } ?>
<tr>
<th>Phone</th>
<td>
<?php if (empty($contact_phone)) { ?>
<span class="text-muted">Not set</span>
<?php } else { ?>
<?= $contact_phone ?>
<?php if (!empty($contact_extension)) { ?>
<span class="text-muted">ext. <?= $contact_extension ?></span>
<?php } ?>
<?php } ?>
<button type="button" class="btn btn-sm btn-outline-secondary float-end"
data-bs-toggle="modal" data-bs-target="#phoneModal">Edit</button>
</td>
</tr>
<tr>
<th>Mobile</th>
<td>
<?php if (empty($contact_mobile)) { ?>
<span class="text-muted">Not set</span>
<?php } else { ?>
<?= $contact_mobile ?>
<?php } ?>
<button type="button" class="btn btn-sm btn-outline-secondary float-end"
data-bs-toggle="modal" data-bs-target="#phoneModal">Edit</button>
</td>
</tr>
</tbody>
</table>
<small class="text-muted">
To change your name, email, title, department or location, raise a ticket and we will
update them for you.
</small>
<!-- // Show option to change password if auth provider is local -->
<?php if ($_SESSION['login_method'] == 'local'): ?>
<hr>
<div class="col-md-6">
<h4>Password</h4>
<form action="post.php" method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<div class="mb-3">
<label>New Password</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-lock"></i></span>
<input type="password" class="form-control" minlength="8" required data-toggle="password" name="new_password" placeholder="Leave blank for no change" autocomplete="new-password">
</div>
</div>
<button type="submit" name="edit_profile" class="btn btn-primary text-bold mt-3"><i class="fas fa-check me-2"></i>Save password</button>
</form>
</div>
<?php endif ?>
<div class="col-lg-5 mb-4">
<table class="table table-bordered border border-dark">
<thead class="table-dark">
<tr>
<th colspan="2">Portal access</th>
</tr>
</thead>
<tbody>
<?php foreach ($contact_roles as $contact_role) { ?>
<tr>
<th class="w-50">
<?= $contact_role[0] ?> contact
<br><small class="text-muted fw-normal"><?= $contact_role[2] ?></small>
</th>
<td class="align-middle">
<?php if ($contact_role[1]) { ?>
<span class="p-2 badge text-bg-success">Yes</span>
<?php } else { ?>
<span class="p-2 badge text-bg-secondary">No</span>
<?php } ?>
</td>
</tr>
<?php } ?>
<tr>
<th>Sign in with</th>
<td class="align-middle">
<?= $login_method_display ?>
<?php if ($login_method === 'local') { ?>
<button type="button" class="btn btn-sm btn-outline-secondary float-end"
data-bs-toggle="modal" data-bs-target="#passwordModal">Change</button>
<?php } ?>
</td>
</tr>
<tr>
<th>
Phone PIN
<br><small class="text-muted fw-normal">Confirms it is you when you call</small>
</th>
<td class="align-middle">
<?php if (empty($contact_pin)) { ?>
<span class="text-muted">Not set</span>
<?php } else { ?>
<span class="font-monospace"><?= $contact_pin ?></span>
<?php } ?>
<button type="button" class="btn btn-sm btn-outline-secondary float-end"
data-bs-toggle="modal" data-bs-target="#pinModal">
<?= empty($contact_pin) ? 'Set' : 'Change' ?>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="row">
<div class="col-lg-5 mb-4">
<table class="table table-bordered border border-dark">
<thead class="table-dark">
<tr>
<th colspan="2">Recent sign-ins</th>
</tr>
</thead>
<tbody>
<?php if (mysqli_num_rows($sql_logins) == 0) { ?>
<tr>
<td colspan="2" class="text-muted">No sign-ins recorded yet.</td>
</tr>
<?php } else { ?>
<?php while ($row = mysqli_fetch_assoc($sql_logins)) { ?>
<tr>
<td><?= portalDateTime($row['log_created_at']) ?></td>
<td class="font-monospace"><?= escapeHtml($row['log_ip']) ?></td>
</tr>
<?php } ?>
<?php } ?>
</tbody>
</table>
<small class="text-muted">
Somewhere here you do not recognise? Raise a ticket and change your password.
</small>
</div>
<div class="col-lg-7 mb-4">
<table class="table table-bordered border border-dark">
<thead class="table-dark">
<tr>
<th colspan="2">Recent activity</th>
</tr>
</thead>
<tbody>
<?php if (mysqli_num_rows($sql_actions) == 0) { ?>
<tr>
<td colspan="2" class="text-muted">Nothing recorded yet.</td>
</tr>
<?php } else { ?>
<?php while ($row = mysqli_fetch_assoc($sql_actions)) { ?>
<tr>
<td class="text-nowrap"><?= portalDateTime($row['log_created_at']) ?></td>
<td><?= escapeHtml($row['log_description']) ?></td>
</tr>
<?php } ?>
<?php } ?>
</tbody>
</table>
<a href="activity.php"><i class="fa fa-fw fa-list me-2"></i>View all your activity</a>
</div>
</div>
<p class="text-muted">
<small>Portal user ID <?= intval($_SESSION['user_id']) ?> &mdash; quote this if we ask for it.</small>
</p>
<div class="modal fade" id="phoneModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form action="post.php" method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<div class="modal-header bg-dark">
<h5 class="modal-title text-white"><i class="fa fa-fw fa-phone me-2"></i>Your phone numbers</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="text-muted">
Keeping these current means we can reach you about a ticket without hunting
for a number. Clear a field to remove it.
</p>
<label for="contactPhone">Phone / <span class="text-secondary">Extension</span></label>
<div class="row g-2">
<div class="col-8 mb-3">
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-phone"></i></span>
<input type="hidden" name="phone_country_code" value="<?= $contact_phone_country_code ?>">
<input type="tel" class="form-control" id="contactPhone" name="phone"
placeholder="Phone Number" maxlength="200" value="<?= $contact_phone_input ?>"
data-itflow-phone="phone_country_code">
</div>
</div>
<div class="col-4 mb-3">
<input type="text" class="form-control" id="contactExtension" name="extension"
inputmode="numeric" maxlength="200" placeholder="ext."
value="<?= $contact_extension ?>">
</div>
</div>
<label for="contactMobile">Mobile</label>
<div class="mb-3">
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-mobile-alt"></i></span>
<input type="hidden" name="mobile_country_code" value="<?= $contact_mobile_country_code ?>">
<input type="tel" class="form-control" id="contactMobile" name="mobile"
placeholder="Mobile Number" maxlength="200" value="<?= $contact_mobile_input ?>"
data-itflow-phone="mobile_country_code">
</div>
</div>
</div>
<div class="modal-footer">
<button type="submit" name="set_contact_phone" class="btn btn-primary text-bold">
<i class="fas fa-check me-2"></i>Save
</button>
<button type="button" class="btn btn-light" data-bs-dismiss="modal">
<i class="fas fa-times me-2"></i>Cancel
</button>
</div>
</form>
</div>
</div>
</div>
<div class="modal fade" id="pinModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form action="post.php" method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<div class="modal-header bg-dark">
<h5 class="modal-title text-white">
<i class="fa fa-fw fa-key me-2"></i><?= empty($contact_pin) ? 'Set a phone PIN' : 'Change your phone PIN' ?>
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="text-muted">
We ask for this to confirm it is really you when you call. Pick something you
will remember but that is not easy to guess.
</p>
<div class="mb-3">
<label for="contactPin"><?= empty($contact_pin) ? 'New PIN' : 'Replace with' ?></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-key"></i></span>
<input type="text" class="form-control" id="contactPin" name="pin"
minlength="4" maxlength="255" placeholder="At least 4 characters"
autocomplete="off" required>
</div>
</div>
</div>
<div class="modal-footer">
<button type="submit" name="set_contact_pin" class="btn btn-primary text-bold">
<i class="fas fa-check me-2"></i><?= empty($contact_pin) ? 'Save PIN' : 'Update PIN' ?>
</button>
<button type="button" class="btn btn-light" data-bs-dismiss="modal">
<i class="fas fa-times me-2"></i>Cancel
</button>
</div>
</form>
</div>
</div>
</div>
<?php if ($login_method === 'local') { ?>
<div class="modal fade" id="passwordModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form action="post.php" method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<div class="modal-header bg-dark">
<h5 class="modal-title text-white"><i class="fa fa-fw fa-lock me-2"></i>Change your password</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="newPassword">New password</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-lock"></i></span>
<input type="password" class="form-control" id="newPassword" name="new_password"
minlength="8" placeholder="At least 8 characters" autocomplete="new-password">
</div>
</div>
</div>
<div class="modal-footer">
<button type="submit" name="edit_profile" class="btn btn-primary text-bold">
<i class="fas fa-check me-2"></i>Save password
</button>
<button type="button" class="btn btn-light" data-bs-dismiss="modal">
<i class="fas fa-times me-2"></i>Cancel
</button>
</div>
</form>
</div>
</div>
</div>
<?php } ?>
<?php
require_once 'includes/footer.php';

View File

@@ -56,6 +56,7 @@ if (basename(dirname($_SERVER['REQUEST_URI'])) === 'guest') { ?>
<script src="/js/keepalive.js"></script>
<script src="/libs/DataTables/datatables.min.js"></script>
<script src="/libs/intl-tel-input/js/intlTelInputWithUtils.min.js"></script>
<script src="/js/phone_inputs.js"></script>
<!-- AdminLTE App -->
<script src="/libs/adminlte/js/adminlte.min.js"></script>

182
js/app.js
View File

@@ -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:
* <input type="hidden" name="phone_country_code" value="1">
* <input type="tel" name="phone" data-itflow-phone="phone_country_code">
*
* 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 <body> (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 <select> is currently on.
*
* Each <option> carries data-iso2, stamped by PHP from $country_iso2_array, so
* the 194-entry name -> ISO2 map never has to be duplicated into JS or shipped
* to the browser as a blob. It also sidesteps an inline <script>, which the
* CSP work would have to unpick later.
*/
function isoFromSelect(select) {
if (!select) {
return '';
}
var option = select.selectedOptions ? select.selectedOptions[0] : null;
return option && option.dataset ? (option.dataset.iso2 || '') : '';
}
/**
* Which country to show for a stored dial code.
*
* Prefers the contextual country when it actually uses that code, otherwise the
* canonical one. Plain .find() is wrong here - the library's data is in name
* order, so +1 would resolve to American Samoa. Priority 0 is the library's own
* marker for the country that owns a shared code.
*/
function isoForDialCode(dialCode, preferIso2) {
if (!dialCode || typeof window.intlTelInput.getAllCountries !== 'function') {
return '';
}
var matches = window.intlTelInput.getAllCountries().filter(function (c) {
return c.dialCode === dialCode;
});
if (!matches.length) {
return '';
}
if (preferIso2) {
var preferred = matches.find(function (c) {
return c.iso2 === preferIso2.toLowerCase();
});
if (preferred) {
return preferred.iso2;
}
}
return matches.reduce(function (best, c) {
return (c.priority || 0) < (best.priority || 0) ? c : best;
}).iso2;
}

194
js/phone_inputs.js Normal file
View File

@@ -0,0 +1,194 @@
/*
* ITFlow - intl-tel-input wiring, shared by the agent side and the client portal.
*
* Lived in js/app.js until the portal needed it too. app.js cannot be loaded in
* the portal - it calls DataTables, TinyMCE, Tom Select, Flatpickr and IMask
* unconditionally with no typeof guards, none of which the portal loads - so
* the choice was to copy this or to split it out. Copying it would have left
* two implementations of a subtle piece of logic to drift apart, so it is here.
*
* app.js still drives it on the agent side via itflowStep('phone-inputs', ...),
* which is what re-runs it for ajax modals. The portal has no app.js, so this
* file self-starts on DOMContentLoaded below. Both paths are safe together:
* initOnePhoneInput() carries its own re-entry guard.
*/
/**
* 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:
* <input type="hidden" name="phone_country_code" value="1">
* <input type="tel" name="phone" data-itflow-phone="phone_country_code">
*
* 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 <body> (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 <select> is currently on.
*
* Each <option> carries data-iso2, stamped by PHP from $country_iso2_array, so
* the 194-entry name -> ISO2 map never has to be duplicated into JS or shipped
* to the browser as a blob. It also sidesteps an inline <script>, which the
* CSP work would have to unpick later.
*/
function isoFromSelect(select) {
if (!select) {
return '';
}
var option = select.selectedOptions ? select.selectedOptions[0] : null;
return option && option.dataset ? (option.dataset.iso2 || '') : '';
}
/**
* Which country to show for a stored dial code.
*
* Prefers the contextual country when it actually uses that code, otherwise the
* canonical one. Plain .find() is wrong here - the library's data is in name
* order, so +1 would resolve to American Samoa. Priority 0 is the library's own
* marker for the country that owns a shared code.
*/
function isoForDialCode(dialCode, preferIso2) {
if (!dialCode || typeof window.intlTelInput.getAllCountries !== 'function') {
return '';
}
var matches = window.intlTelInput.getAllCountries().filter(function (c) {
return c.dialCode === dialCode;
});
if (!matches.length) {
return '';
}
if (preferIso2) {
var preferred = matches.find(function (c) {
return c.iso2 === preferIso2.toLowerCase();
});
if (preferred) {
return preferred.iso2;
}
}
return matches.reduce(function (best, c) {
return (c.priority || 0) < (best.priority || 0) ? c : best;
}).iso2;
}
/*
* Self-start for pages with no app.js - the client portal. On the agent side
* app.js has already run by the time this fires and every input carries its
* data-itiReady guard, so this is a no-op there rather than a double init.
*/
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initPhoneInputs);
} else {
initPhoneInputs();
}