Files
itflow/admin/settings_client_portal.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

129 lines
8.5 KiB
PHP

<?php
/*
* Admin > Client Portal - two-step sign-in for the portal, and the
* announcements shown on its home page (to every client, or one) between
* their start and end.
*/
require_once "includes/inc_all_admin.php";
$show_archived = isset($_GET['archived']);
$sql = mysqli_query($mysqli, "SELECT announcement_id, announcement_title, announcement_body, announcement_tone, announcement_client_id,
announcement_starts_at, announcement_ends_at, announcement_created_at, announcement_archived_at, client_name, user_name
FROM portal_announcements
LEFT JOIN clients ON client_id = announcement_client_id
LEFT JOIN users ON user_id = announcement_created_by
WHERE announcement_archived_at IS " . ($show_archived ? 'NOT NULL' : 'NULL') . "
ORDER BY COALESCE(announcement_starts_at, announcement_created_at) DESC, announcement_id DESC");
$archived_count = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM portal_announcements WHERE announcement_archived_at IS NOT NULL"))[0]);
$state_badges = ['showing' => 'text-bg-success', 'scheduled' => 'text-bg-info', 'ended' => 'text-bg-secondary', 'archived' => 'text-bg-secondary'];
?>
<div class="card card-dark mb-3">
<div class="card-header py-3">
<h3 class="card-title"><i class="fas fa-fw fa-user-shield me-2"></i>Client Portal</h3>
</div>
<div class="card-body">
<?php if (!$config_client_portal_enable) { ?>
<div class="alert alert-warning">The client portal is off - turn it on under <a href="settings_module.php">Modules</a>.</div>
<?php } ?>
<form action="post.php" method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<div class="mb-3">
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input" name="config_client_portal_require_mfa" value="1" id="requireMfa"<?= $config_client_portal_require_mfa ? ' checked' : '' ?>>
<label class="form-check-label" for="requireMfa">Require two-step sign-in</label>
</div>
<small class="text-secondary d-block">Contacts who sign in with a password are asked for a code from an authenticator app too. Anyone without it set up is taken to set it up before anything else. Microsoft 365 sign-ins are left to Microsoft.</small>
</div>
<button type="submit" name="edit_client_portal_settings" class="btn btn-primary text-bold"><i class="fas fa-check me-2"></i>Save</button>
</form>
</div>
</div>
<div class="card">
<div class="card-header bg-dark py-2">
<h3 class="card-title mt-2"><i class="fas fa-fw fa-bullhorn me-2"></i>Announcements</h3>
<div class="card-tools">
<button type="button" class="btn btn-primary ajax-modal" data-modal-url="modals/portal_announcement/portal_announcement_add.php" data-modal-size="lg"><i class="fas fa-plus me-2"></i>New Announcement</button>
</div>
</div>
<div class="card-header py-2 d-flex flex-wrap align-items-center gap-2">
<span class="text-secondary small me-auto">Shown at the top of the portal's home page - to every client, or one - between their start and end.</span>
<div class="btn-group btn-group-sm">
<a class="btn btn-outline-secondary<?= $show_archived ? '' : ' active' ?>" href="settings_client_portal.php">Current</a>
<a class="btn btn-outline-secondary<?= $show_archived ? ' active' : '' ?>" href="settings_client_portal.php?archived=1">Archived <span class="badge text-bg-secondary ms-1"><?= $archived_count ?></span></a>
</div>
</div>
<div class="table-responsive-sm">
<table class="table table-borderless table-hover mb-0">
<?php if (mysqli_num_rows($sql)) { ?>
<thead class="text-dark">
<tr>
<th class="ps-3">Announcement</th>
<th>For</th>
<th>When</th>
<th class="text-center">Action</th>
</tr>
</thead>
<?php } ?>
<tbody>
<?php while ($row = mysqli_fetch_assoc($sql)) {
$announcement_id = intval($row['announcement_id']);
$state = portalAnnouncementState($row);
$tone_key = isset(PORTAL_ANNOUNCEMENT_TONES[$row['announcement_tone']]) ? $row['announcement_tone'] : 'info';
$when = [];
$when[] = $row['announcement_starts_at'] ? 'From ' . date('M j, Y g:i A', strtotime($row['announcement_starts_at'])) : 'From ' . date('M j, Y', strtotime($row['announcement_created_at']));
$when[] = $row['announcement_ends_at'] ? 'until ' . date('M j, Y g:i A', strtotime($row['announcement_ends_at'])) : 'until archived';
$preview = mb_strimwidth(trim(preg_replace('/\s+/', ' ', (string) $row['announcement_body'])), 0, 140, '...'); ?>
<tr>
<td class="ps-3">
<div class="d-flex align-items-start gap-2">
<span class="itflow-announcement-dot is-<?= $tone_key ?> mt-2" title="<?= escapeHtml(PORTAL_ANNOUNCEMENT_TONES[$tone_key]['label']) ?>"></span>
<div class="min-w-0">
<a class="ajax-modal fw-semibold" href="#" data-modal-url="modals/portal_announcement/portal_announcement_edit.php?id=<?= $announcement_id ?>" data-modal-size="lg"><?= escapeHtml($row['announcement_title']) ?></a>
<span class="badge rounded-pill <?= $state_badges[$state['key']] ?> ms-1"><?= $state['label'] ?></span>
<?php if ($preview !== '') { ?><div class="small text-secondary"><?= escapeHtml($preview) ?></div><?php } ?>
</div>
</div>
</td>
<td><?= intval($row['announcement_client_id']) ? escapeHtml($row['client_name'] ?? 'A client no longer here') : '<span class="badge text-bg-dark">Every client</span>' ?></td>
<td class="small"><?= escapeHtml(implode(' ', $when)) ?><div class="text-secondary">Posted by <?= escapeHtml($row['user_name'] ?: 'someone') ?></div></td>
<td>
<div class="dropdown dropstart text-center">
<button class="btn btn-secondary btn-sm" title="More actions" data-bs-toggle="dropdown"><i class="fas fa-ellipsis-h"></i></button>
<div class="dropdown-menu">
<a class="dropdown-item ajax-modal" href="#" data-modal-url="modals/portal_announcement/portal_announcement_edit.php?id=<?= $announcement_id ?>" data-modal-size="lg"><i class="fas fa-fw fa-edit me-2"></i>Edit</a>
<?php if ($row['announcement_archived_at']) { ?>
<a class="dropdown-item" href="post.php?restore_portal_announcement=<?= $announcement_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>"><i class="fas fa-fw fa-redo me-2"></i>Restore</a>
<?php } else { ?>
<a class="dropdown-item text-danger" href="post.php?archive_portal_announcement=<?= $announcement_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>"><i class="fas fa-fw fa-archive me-2"></i>Archive</a>
<?php } ?>
<div class="dropdown-divider"></div>
<a class="dropdown-item text-danger text-bold confirm-link" href="post.php?delete_portal_announcement=<?= $announcement_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>"><i class="fas fa-fw fa-trash me-2"></i>Delete</a>
</div>
</div>
</td>
</tr>
<?php } ?>
</tbody>
</table>
</div>
<?php if (!mysqli_num_rows($sql)) { ?>
<?= emptyStateHtml([
'title' => $show_archived ? 'No archived announcements' : 'No announcements',
'text' => $show_archived ? '' : 'Planned maintenance, an outage, a new service - a note at the top of every client\'s portal, or one client\'s.',
'icon' => 'fa-bullhorn',
'filtered' => false,
'actions' => $show_archived ? [] : [['label' => 'New Announcement', 'modal' => 'modals/portal_announcement/portal_announcement_add.php', 'size' => 'lg']],
]) ?>
<?php } ?>
</div>
<?php
require_once "../includes/footer.php";