Files
itflow/client/activity.php
johnnyq e29369926f Client portal stage 4: accounts - Your account rebuilt with details and an ask-us link, phone numbers, ticket email choices, password change needing the current password and 8 characters with an email notice, two-step sign-in with an authenticator app set up from a QR code, remembered browsers with Forget them, phone PIN and recent sign-ins with the device; activity rebuilt with signing-in and changes tabs and paging; sign-in asks portal users with two-step sign-in for a code, remembers the browser on request and locks an account after five wrong codes in ten minutes, matching portal users by contact_user_id; Admin > Client Portal can require two-step sign-in, sending anyone without it to set it up first, and agents can turn it off for a contact from the contact page; password links stored as SHA-256 and running out after an hour for Forgot password and a week for invites, the nightly run clearing only run-out links, Forgot password limited to one email per account every five minutes and ten requests per address in ten minutes, the reset page rebuilt with the password typed twice and a confirmation email, fixing the empty-token check that let anyone trigger the reset email; contacts choose every ticket email or replies and fixes only, and primary and technical contacts can hear about new tickets at their company or every update, honoured by agent, portal, email parser and recurring ticket emails; announcements for every client or one with tone and start and end dates on the portal home page, managed in Admin > Client Portal; View as contact opens the portal read only as a contact from the agent contact page, audited, with billing shown only when the agent's role reads sales and financial, nothing written or paid while viewing; agent contact page shows two-step status, last sign-in and ticket email choices; database 3.1.0 adds portal_announcements, contact ticket email columns and config_client_portal_require_mfa and hashes outstanding links; new functions/portal.php; changelog updated
2026-10-17 12:05:55 -04:00

109 lines
5.2 KiB
PHP

<?php
/*
* Client Portal
* Activity - everything this contact did in the portal, and every sign-in,
* newest first: ?tab=signins for signing in (and password links, wrong
* codes), ?tab=changes for the rest. 25 to a page.
*
* No capability gate: this is the contact's own record. 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.
*/
header("Content-Security-Policy: default-src 'self'");
require_once "includes/inc_all.php";
$per_page = 25;
$tabs = [
'all' => ['label' => 'All', 'icon' => 'fa-list', 'where' => '1 = 1'],
'signins' => ['label' => 'Signing in', 'icon' => 'fa-sign-in-alt', 'where' => "log_type = 'Client Login'"],
'changes' => ['label' => 'Changes', 'icon' => 'fa-pen', 'where' => "log_type <> 'Client Login'"],
];
$tab = isset($tabs[$_GET['tab'] ?? '']) ? $_GET['tab'] : 'all';
$scope = "log_user_id = $session_user_id AND log_client_id = $session_client_id AND $session_user_id > 0";
$counts = [];
foreach ($tabs as $key => $info) {
$counts[$key] = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM logs WHERE $scope AND {$info['where']}"))[0]);
}
$total = $counts[$tab];
$pages = max(1, (int) ceil($total / $per_page));
$page = min(max(1, intval($_GET['page'] ?? 1)), $pages);
$offset = ($page - 1) * $per_page;
$sql = mysqli_query($mysqli, "SELECT log_type, log_action, log_description, log_ip, log_user_agent, log_created_at FROM logs
WHERE $scope AND {$tabs[$tab]['where']}
ORDER BY log_id DESC LIMIT $offset, $per_page");
/** What a row was, in words, with its icon. */
function activityLabel(array $row) {
$sign_in = [
'Success' => ['Signed in', 'fa-sign-in-alt'],
'MFA Failed' => ['Wrong two-step code', 'fa-exclamation-triangle'],
'Reset Requested' => ['Asked for a password link', 'fa-envelope'],
'Reset Sent' => ['Password link emailed', 'fa-envelope'],
'Password Reset' => ['Set a password from a link', 'fa-key'],
'Failed' => ['Sign-in refused', 'fa-ban'],
];
if ($row['log_type'] === 'Client Login') {
return $sign_in[$row['log_action']] ?? ['Signing in', 'fa-sign-in-alt'];
}
return [trim($row['log_type'] . ' ' . strtolower((string) $row['log_action'])), 'fa-pen'];
}
function activityUrl(array $changes) {
global $tab;
$params = array_filter(array_merge(['tab' => $tab !== 'all' ? $tab : null], $changes), function ($v) { return $v !== null && $v !== ''; });
return 'activity.php' . ($params ? '?' . http_build_query($params) : '');
}
?>
<a class="itflow-portal-back" href="profile.php"><i class="fas fa-chevron-left"></i>Your account</a>
<div class="itflow-portal-page-head">
<div>
<h1>Your activity</h1>
<p>Every time you signed in, and what you did here</p>
</div>
</div>
<nav class="itflow-portal-tabs" aria-label="Kinds of activity">
<?php foreach ($tabs as $key => $info) { ?>
<a class="<?= $tab === $key ? 'active' : '' ?>" href="<?= escapeHtml(activityUrl(['tab' => $key !== 'all' ? $key : null])) ?>"><i class="fas <?= $info['icon'] ?>"></i><?= $info['label'] ?> <span class="badge rounded-pill"><?= $counts[$key] ?></span></a>
<?php } ?>
</nav>
<section class="itflow-portal-panel">
<?php if (!$total) { ?>
<?= emptyStateHtml(['title' => $tab === 'all' ? 'Nothing yet' : 'Nothing here yet', 'text' => 'What you do in the portal shows here.', 'icon' => 'fa-history', 'filtered' => false]) ?>
<?php } else { ?>
<ul class="itflow-portal-list">
<?php while ($row = mysqli_fetch_assoc($sql)) {
[$label, $icon] = activityLabel($row); ?>
<li class="itflow-portal-ticket">
<?= portalTile($icon) ?>
<span class="itflow-portal-ticket-main">
<span class="itflow-portal-ticket-subject d-block"><?= escapeHtml(ucfirst($label)) ?></span>
<span class="d-block small text-break"><?= escapeHtml($row['log_description']) ?></span>
<span class="itflow-portal-ticket-meta"><span><?= portalDateTime($row['log_created_at']) ?></span><span><?= userDeviceHtml($row['log_user_agent']) ?></span><span class="font-monospace"><?= escapeHtml($row['log_ip']) ?></span></span>
</span>
</li>
<?php } ?>
</ul>
<?php if ($pages > 1) { ?>
<div class="itflow-portal-pager">
<span><?= $offset + 1 ?>-<?= min($offset + $per_page, $total) ?> of <?= $total ?></span>
<div class="btn-group">
<a class="btn btn-sm btn-outline-secondary<?= $page <= 1 ? ' disabled' : '' ?>" href="<?= escapeHtml(activityUrl(['page' => $page - 1 > 1 ? $page - 1 : null])) ?>" aria-label="Newer"><i class="fas fa-chevron-left"></i></a>
<a class="btn btn-sm btn-outline-secondary<?= $page >= $pages ? ' disabled' : '' ?>" href="<?= escapeHtml(activityUrl(['page' => $page + 1])) ?>" aria-label="Older"><i class="fas fa-chevron-right"></i></a>
</div>
</div>
<?php } ?>
<?php } ?>
</section>
<?php
require_once "includes/footer.php";