mirror of
https://github.com/itflow-org/itflow
synced 2026-09-21 22:21:15 +00:00
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:
138
client/activity.php
Normal file
138
client/activity.php
Normal 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>
|
||||||
|
— <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";
|
||||||
@@ -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.
|
* The empty state for the portal's list pages.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -69,6 +69,11 @@
|
|||||||
<script src="/libs/sweetalert2/js/sweetalert2.min.js"></script>
|
<script src="/libs/sweetalert2/js/sweetalert2.min.js"></script>
|
||||||
<script src="/js/confirm_modal.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>
|
<script src="/js/keepalive.js"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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 left this portal without .text-bold, the bg-dark text pairing or any theme. -->
|
||||||
<link rel="stylesheet" href="/css/itflow_custom.css">
|
<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>
|
</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 -->
|
<!-- Navbar -->
|
||||||
|
|
||||||
@@ -125,6 +139,7 @@ header("X-Frame-Options: DENY"); // Legacy
|
|||||||
</a>
|
</a>
|
||||||
<div class="dropdown-menu">
|
<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/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>
|
<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>
|
<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>
|
</div>
|
||||||
|
|||||||
@@ -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'])) {
|
if (isset($_GET['approve_ticket_task'])) {
|
||||||
|
|
||||||
validateCSRFToken();
|
validateCSRFToken();
|
||||||
|
|||||||
@@ -4,45 +4,501 @@
|
|||||||
* User profile
|
* 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'");
|
header("Content-Security-Policy: default-src 'self'");
|
||||||
|
|
||||||
require_once 'includes/inc_all.php';
|
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>
|
<div class="row">
|
||||||
<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="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>
|
</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']) ?> — 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
|
<?php
|
||||||
require_once 'includes/footer.php';
|
require_once 'includes/footer.php';
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ if (basename(dirname($_SERVER['REQUEST_URI'])) === 'guest') { ?>
|
|||||||
<script src="/js/keepalive.js"></script>
|
<script src="/js/keepalive.js"></script>
|
||||||
<script src="/libs/DataTables/datatables.min.js"></script>
|
<script src="/libs/DataTables/datatables.min.js"></script>
|
||||||
<script src="/libs/intl-tel-input/js/intlTelInputWithUtils.min.js"></script>
|
<script src="/libs/intl-tel-input/js/intlTelInputWithUtils.min.js"></script>
|
||||||
|
<script src="/js/phone_inputs.js"></script>
|
||||||
|
|
||||||
<!-- AdminLTE App -->
|
<!-- AdminLTE App -->
|
||||||
<script src="/libs/adminlte/js/adminlte.min.js"></script>
|
<script src="/libs/adminlte/js/adminlte.min.js"></script>
|
||||||
|
|||||||
182
js/app.js
182
js/app.js
@@ -621,7 +621,17 @@ function itflowInit() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Phone inputs
|
// 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
|
// 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
194
js/phone_inputs.js
Normal 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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user