Modernize Administration > Users and the agent Account pages: new users list with Users/Admins/No MFA/Disabled/Archived views, role filter, status and MFA pills, remembered browsers, client access summary, last successful sign in with device and IP, seat badge, quick look modal, bulk role/require MFA/forget browsers/activate/disable, shared New/Edit user form with Details/Sign in/Client access tabs (icon prefixed inputs, picture preview, generated passwords, Only/Never client rules with search and set all), archive modal with ticket hand-over, restore requiring a new password, incident response limited to agents and forgetting remembered browsers, export role filter and MFA/last sign in/client access columns; Account pages reworked into Profile/Security/Preferences/Activity with current password required to change email, password or turn MFA off, inline SVG MFA QR code, remembered browser list with forget one/all, rows per page preference; adds functions/user.php shared helpers, validates email format and uniqueness, sets password hash and vault key from the exact typed password, purifies signatures, fixes Activity showing other agents' sign ins, removes dead user invite modal and user_model, and fixes the networks export link from All Clients with no filter

This commit is contained in:
johnnyq
2026-10-15 21:29:40 -04:00
parent 2fc9c7bfe3
commit 99ecc2d4a4
28 changed files with 2703 additions and 1573 deletions

116
admin/modals/user/user.php Normal file
View File

@@ -0,0 +1,116 @@
<?php
/*
* User quick look - from Administration > Users. Who they are, whether and
* how they sign in, the clients they see, the work they have open, and their
* latest sign ins.
*/
require_once '../../includes/modal_header.php';
$user = userForModal($_GET['id'] ?? 0);
$user_id = intval($user['user_id']);
$is_self = $user_id === intval($session_user_id);
$is_archived = !empty($user['user_archived_at']);
$is_admin = intval($user['role_is_admin']) === 1;
$display_name = $is_archived ? trim(str_replace(' (archived)', '', $user['user_name'])) : $user['user_name'];
$access = userClientAccess([$user_id])[$user_id];
$remembered = userRememberCounts([$user_id])[$user_id];
[$open_tickets, $recurring_tickets] = userOpenWork($user_id);
$recent = userRecentLogins($user_id, 6);
ob_start();
?>
<div class="modal-header bg-dark" data-bs-theme="dark">
<div class="d-flex align-items-center gap-3 min-w-0">
<?= userAvatar($user_id, $display_name, $user['user_avatar'], 'lg') ?>
<div class="min-w-0">
<h5 class="modal-title fw-semibold text-break"><?= escapeHtml($display_name) ?><?php if ($is_self) { ?> <span class="badge rounded-pill bg-primary-subtle text-primary-emphasis fw-normal small align-middle">You</span><?php } ?></h5>
<div class="small text-body-secondary d-flex flex-wrap column-gap-3">
<span><?= escapeHtml($user['user_email']) ?><button type="button" class="itflow-copy clipboardjs" title="Copy email" data-clipboard-text="<?= escapeHtml($user['user_email']) ?>"><i class="far fa-copy"></i></button></span>
<span><i class="fas fa-fw <?= $is_admin ? 'fa-user-shield' : 'fa-user-tag' ?> me-1"></i><?= escapeHtml($user['role_name'] ?? 'No role') ?></span>
</div>
</div>
</div>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="d-flex flex-wrap gap-1 mb-3">
<?= userStatusPill($user['user_status'], $is_archived) ?>
<?php if (!$is_archived) { ?><?= userMfaPill($user['user_token'], $user['user_config_force_mfa']) ?><?php } ?>
<?php if ($is_admin) { ?><span class="badge rounded-pill bg-primary-subtle text-primary-emphasis fw-normal"><i class="fas fa-user-shield me-1"></i>Admin</span><?php } ?>
</div>
<dl class="itflow-asset-dl mb-0">
<dt>Clients</dt>
<dd>
<?= escapeHtml(userClientAccessText($access, $is_admin)) ?>
<?php if (!$is_admin && $access['allow']) { ?>
<div class="small"><span class="text-success-emphasis fw-semibold">Only</span> <?= escapeHtml(implode(', ', $access['allow'])) ?></div>
<?php } ?>
<?php if (!$is_admin && $access['deny']) { ?>
<div class="small"><span class="text-danger-emphasis fw-semibold">Never</span> <?= escapeHtml(implode(', ', $access['deny'])) ?></div>
<?php } ?>
</dd>
<?php if (!$is_archived) { ?>
<dt>Remembered</dt>
<dd>
<?php if ($remembered) { ?>
<?= $remembered ?> browser<?= $remembered === 1 ? '' : 's' ?> skip<?= $remembered === 1 ? 's' : '' ?> their MFA code
<?php } else { ?>
<span class="text-body-secondary">No browsers</span>
<?php } ?>
</dd>
<dt>Open work</dt>
<dd>
<?php if ($open_tickets) { ?>
<a href="/agent/tickets.php?assigned=<?= $user_id ?>"><?= $open_tickets ?> open ticket<?= $open_tickets === 1 ? '' : 's' ?></a>
<?php } else { ?>
<span class="text-body-secondary">No open tickets</span>
<?php } ?>
<?php if ($recurring_tickets) { ?>
<span class="text-body-secondary">· <?= $recurring_tickets ?> recurring</span>
<?php } ?>
</dd>
<?php } ?>
<dt>Added</dt>
<dd><span title="<?= escapeHtml($user['user_created_at']) ?>"><?= escapeHtml(timeAgo($user['user_created_at'])) ?></span></dd>
<?php if ($is_archived) { ?>
<dt>Archived</dt>
<dd><span title="<?= escapeHtml($user['user_archived_at']) ?>"><?= escapeHtml(timeAgo($user['user_archived_at'])) ?></span></dd>
<?php } ?>
</dl>
<h6 class="text-body-secondary text-uppercase small fw-semibold mt-4 mb-2">Latest sign ins</h6>
<?php if ($recent) { ?>
<div class="d-flex flex-column gap-2 small">
<?php foreach ($recent as $log) { ?>
<?= userLoginLineHtml($log) ?>
<?php } ?>
</div>
<?php } else { ?>
<div class="small text-body-secondary">Never signed in.</div>
<?php } ?>
</div>
<div class="modal-footer">
<a class="btn btn-outline-secondary me-auto" href="audit_logs.php?user=<?= $user_id ?>"><i class="fas fa-history me-2"></i>Audit log</a>
<?php if ($is_self) { ?>
<a class="btn btn-primary" href="/agent/user/user_details.php"><i class="fas fa-user-cog me-2"></i>Your account</a>
<?php } elseif ($is_archived) { ?>
<button type="button" class="btn btn-primary ajax-modal" data-modal-url="modals/user/user_restore.php?id=<?= $user_id ?>"><i class="fas fa-redo-alt me-2"></i>Restore</button>
<?php } else { ?>
<button type="button" class="btn btn-primary ajax-modal" data-modal-size="lg" data-modal-url="modals/user/user_edit.php?id=<?= $user_id ?>"><i class="fas fa-user-edit me-2"></i>Edit</button>
<?php } ?>
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Close</button>
</div>
<?php
require_once "../../../includes/modal_footer.php";

View File

@@ -1,7 +1,19 @@
<?php
/*
* New User - an agent who signs in to ITFlow. ?role= starts it on that role
* (the role the users list is filtered to).
*/
require_once '../../includes/modal_header.php';
$is_new = true;
$role_id = intval($_GET['role'] ?? 0);
$form = [
'user_role_id' => isset(userRoles()[$role_id]) ? $role_id : 0,
'user_config_force_mfa' => 0,
];
ob_start();
?>
@@ -11,148 +23,12 @@ ob_start();
</div>
<form action="post.php" method="post" enctype="multipart/form-data" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<div class="modal-body">
<ul class="nav nav-pills nav-justified mb-3">
<li class="nav-item">
<a class="nav-link active" data-bs-toggle="pill" href="#pills-user-details">Details</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="pill" href="#pills-user-access">Restrict Access</a>
</li>
</ul>
<hr>
<div class="tab-content">
<div class="tab-pane fade show active" id="pills-user-details">
<div class="mb-3">
<label>Name <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-user"></i></span>
<input type="text" class="form-control" name="name" placeholder="Full Name" maxlength="200" required autofocus>
</div>
</div>
<div class="mb-3">
<label>Email <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-envelope"></i></span>
<input type="email" class="form-control" name="email" placeholder="Email Address" maxlength="200" required>
</div>
</div>
<div class="mb-3">
<label>Password <strong class="text-danger">*</strong></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" data-toggle="password" name="password" id="password" placeholder="Enter a Password" autocomplete="new-password" minlength="8" required>
<span class="input-group-text"><i class="fa fa-fw fa-eye"></i></span>
<span class="btn btn-default"><i class="fa fa-fw fa-question" onclick="generatePassword()"></i></span>
</div>
</div>
<div class="mb-3">
<label>Role <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-user-shield"></i></span>
<select class="form-select select2" name="role" required>
<option value="">- Role -</option>
<?php
$sql_user_roles = mysqli_query($mysqli, "SELECT role_id, role_name FROM user_roles WHERE role_archived_at IS NULL");
while ($row = mysqli_fetch_assoc($sql_user_roles)) {
$role_id = intval($row['role_id']);
$role_name = escapeHtml($row['role_name']);
?>
<option value="<?= $role_id ?>"><?= $role_name ?></option>
<?php } ?>
</select>
</div>
</div>
<div class="mb-3">
<label>Avatar</label>
<input type="file" class="form-control" accept="image/*" name="file">
</div>
<div class="mb-3" <?php if(empty($config_smtp_host)) { echo "hidden"; } ?>>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="sendEmailCheckBox" name="send_email" value="" checked>
<label for="sendEmailCheckBox" class="form-check-label">
Send user e-mail with login details?
</label>
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="forceMFACheckBox" name="force_mfa" value=1>
<label for="forceMFACheckBox" class="form-check-label">
Force MFA
</label>
</div>
</div>
</div>
<div class="tab-pane fade" id="pills-user-access">
<div class="alert alert-info">
<strong>Allow</strong> restricts the user to the selected clients (no Allow = full access). <strong>Deny</strong> blocks a client regardless of Allow. Admin users are unaffected.
</div>
<div class="mb-2">
<small class="text-muted me-2">Set all:</small>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="document.querySelectorAll('#accessTable .perm-none').forEach(r => r.checked = true);">No Rule</button>
<button type="button" class="btn btn-sm btn-outline-success" onclick="document.querySelectorAll('#accessTable .perm-allow').forEach(r => r.checked = true);">Allow</button>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="document.querySelectorAll('#accessTable .perm-deny').forEach(r => r.checked = true);">Deny</button>
</div>
<div class="table-responsive">
<table class="table table-sm table-hover mb-0" id="accessTable">
<thead>
<tr>
<th>Client</th>
<th class="text-center" style="width: 90px;">No Rule</th>
<th class="text-center text-success" style="width: 90px;">Allow</th>
<th class="text-center text-danger" style="width: 90px;">Deny</th>
</tr>
</thead>
<tbody>
<?php
$sql_client_select = mysqli_query($mysqli, "SELECT client_id, client_name FROM clients WHERE client_archived_at IS NULL ORDER BY client_name ASC");
while ($row = mysqli_fetch_assoc($sql_client_select)) {
$client_id = intval($row['client_id']);
$client_name = escapeHtml($row['client_name']);
?>
<tr>
<td class="align-middle"><?= $client_name ?></td>
<td class="text-center align-middle">
<input type="radio" class="form-check-input perm-none" name="client_permission[<?= $client_id ?>]" value="" checked>
</td>
<td class="text-center align-middle">
<input type="radio" class="form-check-input perm-allow" name="client_permission[<?= $client_id ?>]" value="allow">
</td>
<td class="text-center align-middle">
<input type="radio" class="form-check-input perm-deny" name="client_permission[<?= $client_id ?>]" value="deny">
</td>
</tr>
<?php } ?>
</tbody>
</table>
</div>
</div>
</div>
<?php if (agentUserLimitReached()) { ?>
<div class="alert alert-danger small"><?= agentUserLimitMessage() ?></div>
<?php } ?>
<?php require 'user_form.php'; ?>
</div>
<div class="modal-footer">
<button type="submit" name="add_user" class="btn btn-primary text-bold"><i class="fas fa-check me-2"></i>Create</button>
@@ -160,21 +36,5 @@ ob_start();
</div>
</form>
<script>
function generatePassword() {
itflowGet(
"/agent/ajax.php", {
get_readable_pass: 'true'
},
function(data) {
const password = JSON.parse(data);
document.getElementById("password").value = password;
}
);
}
</script>
<?php
require_once "../../../includes/modal_footer.php";

View File

@@ -1,33 +1,46 @@
<?php
/*
* Incident response - when an agent's account may be in someone else's
* hands: every other agent gets a new password, shown once on the next page,
* and their remembered browsers are forgotten so MFA is asked for again.
*/
require_once '../../includes/modal_header.php';
$agent_count = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM users WHERE user_type = 1 AND user_archived_at IS NULL AND user_id <> " . intval($session_user_id)))[0]);
ob_start();
?>
<div class="modal-body">
<div class="mb-4" style="text-align: center;">
<i class="far fas fa-10x fa-skull-crossbones text-danger mb-3 mt-3"></i>
<h2>Incident Response: Agent Password Reset</h2>
<br>
<div class="alert alert-danger" role="alert">
<b>This is a potentially destructive function.<br>It is intended to be used as part of a potential security incident.</b>
</div>
<h6 class="mb-4 text-secondary"><b>All ITFlow agent passwords will be reset and shown to you </b><i>(except yours - change yours first!)</i>.<br/><br/>You should communicate temporary passwords to agents out of band (e.g. via a phone call) and require they are changed ASAP.</h6>
<form action="post.php" method="POST">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<div class="row col-7 offset-4">
<div class="input-group">
<input type="password" class="form-control" placeholder="Enter your account password to continue" name="admin_password" required>
</div>
</div>
<br>
<button class="btn btn-danger" type="submit" name="ir_reset_user_password"><i class="fas fa-fw fa-key me-2"></i>Reset passwords</button>
</form>
</div>
<button type="button" class="btn btn-outline-secondary btn-lg px-5 me-4" data-bs-dismiss="modal">Cancel</button>
<div class="modal-header bg-danger">
<h5 class="modal-title text-white"><i class="fas fa-fw fa-skull-crossbones me-2"></i>Incident response</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<form action="post.php" method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<div class="modal-body">
<p class="fw-semibold mb-2">Reset the password of every other agent - <?= $agent_count ?> of them.</p>
<ul class="small mb-3 ps-3">
<li>Each gets a new password, shown to you once on the next page - give them out by phone, not email, and have them changed.</li>
<li>Their remembered browsers are forgotten, so anyone with MFA is asked for a code again.</li>
<li>Not yours - change your own first, from your account.</li>
<li>Client portal logins are left alone.</li>
</ul>
<label class="form-label">Your password <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-fw fa-lock"></i></span>
<input type="password" class="form-control" data-toggle="password" name="admin_password" placeholder="To confirm it's you" autocomplete="current-password" required autofocus>
<span class="input-group-text"><i class="fa fa-fw fa-eye"></i></span>
</div>
</div>
<div class="modal-footer">
<button type="submit" name="ir_reset_user_password" class="btn btn-danger text-bold" <?= $agent_count ? '' : 'disabled' ?>><i class="fas fa-key me-2"></i>Reset <?= $agent_count ?> password<?= $agent_count === 1 ? '' : 's' ?></button>
<button type="button" class="btn btn-light" data-bs-dismiss="modal"><i class="fas fa-times me-2"></i>Cancel</button>
</div>
</form>
<?php
require_once "../../../includes/modal_footer.php";

View File

@@ -1,76 +1,79 @@
<?php
/*
* Archive User - they can no longer sign in, and their open tickets and
* recurring tickets go to another agent (or to nobody).
*/
require_once '../../includes/modal_header.php';
$user_id = intval($_GET['id']);
$user = userForModal($_GET['id'] ?? 0);
$user_id = intval($user['user_id']);
[$open_tickets, $recurring_tickets] = userOpenWork($user_id);
$is_last_admin = userIsLastAdmin($user);
$sql = mysqli_query($mysqli, "SELECT user_avatar, user_email, user_name FROM users WHERE users.user_id = $user_id LIMIT 1");
// Who can take their work - active agents, not them
$agents = [];
$sql = mysqli_query($mysqli, "SELECT user_id, user_name, user_avatar FROM users
WHERE user_type = 1 AND user_status = 1 AND user_archived_at IS NULL AND user_id <> $user_id ORDER BY user_name ASC");
while ($row = mysqli_fetch_assoc($sql)) {
$agents[] = $row;
}
$row = mysqli_fetch_assoc($sql);
$user_name = escapeHtml($row['user_name']);
$user_email = escapeHtml($row['user_email']);
$user_avatar = escapeHtml($row['user_avatar']);
$user_initials = escapeHtml(initials($user_name));
$sql_related_tickets = mysqli_query($mysqli, "SELECT 1 FROM tickets
WHERE ticket_assigned_to = $user_id AND ticket_resolved_at IS NULL AND ticket_closed_at IS NULL");
$ticket_count = mysqli_num_rows($sql_related_tickets);
// Related Recurring Tickets Query
$sql_related_recurring_tickets = mysqli_query($mysqli, "SELECT 1 FROM recurring_tickets WHERE recurring_ticket_assigned_to = $user_id");
$recurring_ticket_count = mysqli_num_rows($sql_related_recurring_tickets);
// Generate the HTML form content using output buffering.
ob_start();
?>
<div class="modal-header bg-dark">
<h5 class="modal-title"><i class="fas fa-fw fa-user-slash me-2"></i>Archiving user:
<strong><?= $user_name ?></strong></h5>
<h5 class="modal-title text-truncate"><i class="fas fa-fw fa-archive me-2"></i>Archive <?= escapeHtml($user['user_name']) ?></h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<form action="post.php" method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<input type="hidden" name="user_id" value="<?= $user_id ?>">
<div class="modal-body">
<center class="mb-3">
<?php if (!empty($user_avatar)) { ?>
<img class="img-fluid" src="<?= "../uploads/users/$user_id/$user_avatar" ?>">
<?php } else { ?>
<span class="fa-stack fa-4x">
<i class="fa fa-circle fa-stack-2x text-secondary"></i>
<span class="fa fa-stack-1x text-white"><?= $user_initials ?></span>
</span>
<?php } ?>
</center>
<div class="mb-3">
<label>Reassign <?= $ticket_count ?> Open Tickets and <?= $recurring_ticket_count ?> Recurring Tickets To:</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-user"></i></span>
<select class="form-select select2" name="ticket_assign" required>
<option value="0">No one</option>
<?php
$sql_users = mysqli_query($mysqli, "SELECT user_id, user_name, user_avatar FROM users WHERE user_type = 1 AND user_archived_at IS NULL");
while ($row = mysqli_fetch_assoc($sql_users)) {
$user_id_select = intval($row['user_id']);
$user_name_select = escapeHtml($row['user_name']);
?>
<option value="<?= $user_id_select ?>"<?= selectOptionUser($row['user_id'], $row['user_name'], $row['user_avatar'] ?? '') ?>><?= $user_name_select ?></option>
<?php } ?>
</select>
<div class="d-flex align-items-center gap-3 mb-3">
<?= userAvatar($user_id, $user['user_name'], $user['user_avatar'], 'lg') ?>
<div class="min-w-0">
<div class="fw-semibold"><?= escapeHtml($user['user_name']) ?></div>
<div class="small text-body-secondary"><?= escapeHtml($user['user_email']) ?> · <?= escapeHtml($user['role_name'] ?? 'No role') ?></div>
</div>
</div>
<?php if ($user_id === intval($session_user_id)) { ?>
<div class="alert alert-danger small mb-0">You can't archive yourself.</div>
<?php } elseif ($is_last_admin) { ?>
<div class="alert alert-danger small mb-0"><i class="fas fa-user-shield me-2"></i>The only active admin - archived, there would be nobody to run ITFlow. Make someone else an admin first.</div>
<?php } else { ?>
<ul class="small mb-3 ps-3">
<li>They're signed out, and can't sign in.</li>
<li>Their password and their copy of the credential vault key are wiped - restoring them sets a new password.</li>
<li>Everything they did stays, under their name.</li>
</ul>
<label class="form-label">
<?php if ($open_tickets || $recurring_tickets) { ?>
Hand over <?= $open_tickets ?> open ticket<?= $open_tickets === 1 ? '' : 's' ?><?= $recurring_tickets ? " and $recurring_tickets recurring" : '' ?> to
<?php } else { ?>
Their open tickets go to <span class="fw-normal text-body-secondary">(they have none now)</span>
<?php } ?>
</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-fw fa-user-check"></i></span>
<select class="form-select select2" name="ticket_assign">
<option value="0" data-icon="fas fa-user-slash">Nobody - unassigned</option>
<?php foreach ($agents as $row) { ?>
<option value="<?= intval($row['user_id']) ?>"<?= selectOptionUser($row['user_id'], $row['user_name'], $row['user_avatar'] ?? '') ?>><?= escapeHtml($row['user_name']) ?></option>
<?php } ?>
</select>
</div>
<div class="form-text">Each ticket's history says who it went to, and why.</div>
<?php } ?>
</div>
<div class="modal-footer">
<button type="submit" name="archive_user" class="btn btn-danger text-bold"><i class="fas fa-archive me-2"></i>Archive</button>
<?php if ($user_id !== intval($session_user_id) && !$is_last_admin) { ?>
<button type="submit" name="archive_user" class="btn btn-danger text-bold"><i class="fas fa-archive me-2"></i>Archive</button>
<?php } ?>
<button type="button" class="btn btn-light" data-bs-dismiss="modal"><i class="fas fa-times me-2"></i>Cancel</button>
</div>
</form>

View File

@@ -0,0 +1,45 @@
<?php
/*
* Bulk - give the users ticked on the users list one role. Never the admin
* themselves, and the only active admin keeps an admin role.
*/
require_once '../../includes/modal_header.php';
$user_ids = array_values(array_diff(array_unique(array_filter(array_map('intval', (array) ($_GET['user_ids'] ?? [])))), [intval($session_user_id)]));
$count = count($user_ids);
$roles = userRoles();
ob_start();
?>
<div class="modal-header bg-dark">
<h5 class="modal-title"><i class="fas fa-fw fa-user-tag me-2"></i>Role for <strong><?= $count ?></strong> user<?= $count === 1 ? '' : 's' ?></h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<form action="post.php" method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<?php foreach ($user_ids as $user_id) { ?><input type="hidden" name="user_ids[]" value="<?= $user_id ?>"><?php } ?>
<div class="modal-body">
<label class="form-label">Role <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-fw fa-user-shield"></i></span>
<select class="form-select select2" name="role" required>
<option value="">- Pick a role -</option>
<?php foreach ($roles as $role_id => $role) { ?>
<option value="<?= $role_id ?>"<?= selectOptionRole($role) ?>><?= escapeHtml($role['role_name']) ?></option>
<?php } ?>
</select>
</div>
<div class="form-text">The only active admin keeps an admin role, so there is always someone to run ITFlow.</div>
</div>
<div class="modal-footer">
<button type="submit" name="bulk_role_users" class="btn btn-primary text-bold" <?= $count ? '' : 'disabled' ?>><i class="fas fa-check me-2"></i>Set role</button>
<button type="button" class="btn btn-light" data-bs-dismiss="modal"><i class="fas fa-times me-2"></i>Cancel</button>
</div>
</form>
<?php
require_once "../../../includes/modal_footer.php";

View File

@@ -1,233 +1,44 @@
<?php
/*
* Edit User - their details, role, password, MFA and the clients they see.
* Not the admin's own account: that is changed from their Account pages.
*/
require_once '../../includes/modal_header.php';
$user_id = intval($_GET['id']);
$sql = mysqli_query($mysqli, "SELECT user_avatar, user_config_force_mfa, user_email, user_name, user_role_id, user_token FROM users
LEFT JOIN user_settings ON users.user_id = user_settings.user_id
WHERE users.user_id = $user_id LIMIT 1"
);
$row = mysqli_fetch_assoc($sql);
$user_name = escapeHtml($row['user_name']);
$user_email = escapeHtml($row['user_email']);
$user_avatar = escapeHtml($row['user_avatar']);
$user_token = escapeHtml($row['user_token']);
$user_config_force_mfa = intval($row['user_config_force_mfa']);
$user_role_id = intval($row['user_role_id']);
$user_initials = escapeHtml(initials($user_name));
// Get User Client Access Permissions (allow + deny)
$user_client_access_sql = mysqli_query($mysqli,"SELECT client_id, permission_type FROM user_client_permissions WHERE user_id = $user_id");
$client_allow_array = []; // allow
$client_deny_array = []; // deny
while ($row = mysqli_fetch_assoc($user_client_access_sql)) {
if ($row['permission_type'] === 'deny') {
$client_deny_array[] = intval($row['client_id']);
} else {
$client_allow_array[] = intval($row['client_id']);
}
}
$form = userForModal($_GET['id'] ?? 0);
$user_id = intval($form['user_id']);
$is_new = false;
ob_start();
?>
<div class="modal-header bg-dark">
<h5 class="modal-title"><i class="fas fa-fw fa-user-edit me-2"></i>Editing user:
<strong><?= $user_name ?></strong></h5>
<h5 class="modal-title text-truncate"><i class="fas fa-fw fa-user-edit me-2"></i><?= escapeHtml($form['user_name']) ?></h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<form action="post.php" method="post" enctype="multipart/form-data" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<input type="hidden" name="user_id" value="<?= $user_id ?>">
<?php if ($user_id === intval($session_user_id)) { ?>
<div class="modal-body">
<ul class="nav nav-pills nav-justified mb-3">
<li class="nav-item">
<a class="nav-link active" data-bs-toggle="pill" href="#pills-user-details<?= $user_id ?>">Details</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="pill" href="#pills-user-access<?= $user_id ?>">Restrict Access</a>
</li>
</ul>
<hr>
<div class="tab-content">
<div class="tab-pane fade show active" id="pills-user-details<?= $user_id ?>">
<center class="mb-3">
<?php if (!empty($user_avatar)) { ?>
<img class="img-fluid" src="<?= "../uploads/users/$user_id/$user_avatar" ?>">
<?php } else { ?>
<span class="fa-stack fa-4x">
<i class="fa fa-circle fa-stack-2x text-secondary"></i>
<span class="fa fa-stack-1x text-white"><?= $user_initials ?></span>
</span>
<?php } ?>
</center>
<div class="mb-3">
<label>Name <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-user"></i></span>
<input type="text" class="form-control" name="name" placeholder="Full Name" maxlength="200"
value="<?= $user_name ?>" required>
</div>
</div>
<div class="mb-3">
<label>Email <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-envelope"></i></span>
<input type="email" class="form-control" name="email" placeholder="Email Address" maxlength="200"
value="<?= $user_email ?>" required>
</div>
</div>
<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" data-toggle="password" name="new_password" id="password"
placeholder="Leave Blank For No Password Change" autocomplete="new-password">
<span class="input-group-text"><i class="fa fa-fw fa-eye"></i></span>
<span class="btn btn-default"><i class="fa fa-fw fa-question" onclick="generatePassword()"></i></span>
</div>
</div>
<div class="mb-3">
<label>Role <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-user-shield"></i></span>
<select class="form-select select2" name="role" required>
<?php
$sql_user_roles = mysqli_query($mysqli, "SELECT role_id, role_name FROM user_roles WHERE role_archived_at IS NULL");
while ($row = mysqli_fetch_assoc($sql_user_roles)) {
$role_id = intval($row['role_id']);
$role_name = escapeHtml($row['role_name']);
?>
<option <?php if ($role_id == $user_role_id) {echo "selected";} ?> value="<?= $role_id ?>"><?= $role_name ?></option>
<?php } ?>
</select>
</div>
</div>
<div class="mb-3">
<label>Avatar</label>
<input type="file" class="form-control" accept="image/*" name="file">
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="forceMFACheckBox<?= $user_id ?>" name="force_mfa" value="1" <?php if($user_config_force_mfa == 1){ echo "checked"; } ?>>
<label for="forceMFACheckBox<?= $user_id ?>" class="form-check-label">
Force MFA
</label>
</div>
</div>
<?php if (!empty($user_token)) { ?>
<div class="mb-3">
<label>2FA</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-id-card"></i></span>
<select class="form-select" name="2fa">
<option value="">Keep enabled</option>
<option value="disable">Disable</option>
</select>
</div>
</div>
<?php } ?>
</div>
<div class="tab-pane fade" id="pills-user-access<?= $user_id ?>">
<div class="alert alert-info">
<strong>Allow</strong> restricts the user to the selected clients (no Allow = full access). <strong>Deny</strong> blocks a client regardless of Allow. Admin users are unaffected.
</div>
<div class="mb-2">
<small class="text-muted me-2">Set all:</small>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="document.querySelectorAll('#accessTable<?= $user_id ?> .perm-none').forEach(r => r.checked = true);">No Rule</button>
<button type="button" class="btn btn-sm btn-outline-success" onclick="document.querySelectorAll('#accessTable<?= $user_id ?> .perm-allow').forEach(r => r.checked = true);">Allow</button>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="document.querySelectorAll('#accessTable<?= $user_id ?> .perm-deny').forEach(r => r.checked = true);">Deny</button>
</div>
<div class="table-responsive">
<table class="table table-sm table-hover mb-0" id="accessTable<?= $user_id ?>">
<thead>
<tr>
<th>Client</th>
<th class="text-center" style="width: 90px;">No Rule</th>
<th class="text-center text-success" style="width: 90px;">Allow</th>
<th class="text-center text-danger" style="width: 90px;">Deny</th>
</tr>
</thead>
<tbody>
<?php
$sql_client_select = mysqli_query($mysqli, "SELECT client_id, client_name FROM clients WHERE client_archived_at IS NULL ORDER BY client_name ASC");
while ($row = mysqli_fetch_assoc($sql_client_select)) {
$client_id_select = intval($row['client_id']);
$client_name_select = escapeHtml($row['client_name']);
$client_is_allow = in_array($client_id_select, $client_allow_array);
$client_is_deny = in_array($client_id_select, $client_deny_array);
?>
<tr>
<td class="align-middle"><?= $client_name_select ?></td>
<td class="text-center align-middle">
<input type="radio" class="form-check-input perm-none" name="client_permission[<?= $client_id_select ?>]" value="" <?php if (!$client_is_allow && !$client_is_deny) { echo "checked"; } ?>>
</td>
<td class="text-center align-middle">
<input type="radio" class="form-check-input perm-allow" name="client_permission[<?= $client_id_select ?>]" value="allow" <?php if ($client_is_allow) { echo "checked"; } ?>>
</td>
<td class="text-center align-middle">
<input type="radio" class="form-check-input perm-deny" name="client_permission[<?= $client_id_select ?>]" value="deny" <?php if ($client_is_deny) { echo "checked"; } ?>>
</td>
</tr>
<?php } ?>
</tbody>
</table>
</div>
</div>
</div>
<p class="mb-0">That's you - change your own details, password and MFA from <a href="/agent/user/user_details.php">your account</a>, so you can't lock yourself out from here.</p>
</div>
<div class="modal-footer">
<button type="submit" name="edit_user" 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>
<button type="button" class="btn btn-light" data-bs-dismiss="modal"><i class="fas fa-times me-2"></i>Close</button>
</div>
</form>
<?php } else { ?>
<form action="post.php" method="post" enctype="multipart/form-data" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<input type="hidden" name="user_id" value="<?= $user_id ?>">
<script>
function generatePassword() {
// Send a GET request to ajax.php as ajax.php?get_readable_pass=true
itflowGet(
"/agent/ajax.php", {
get_readable_pass: 'true'
},
function(data) {
//If we get a response from post.php, parse it as JSON
const password = JSON.parse(data);
document.getElementById("password").value = password;
}
);
}
</script>
<div class="modal-body">
<?php require 'user_form.php'; ?>
</div>
<div class="modal-footer">
<button type="submit" name="edit_user" 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>
<?php } ?>
<?php
require_once "../../../includes/modal_footer.php";

View File

@@ -1,21 +1,13 @@
<?php
require_once '../../../includes/modal_header.php';
enforceAdminPermission();
require_once '../../includes/modal_header.php';
// Pre-filled from the users page's current filters. Everything here is editable -
// the export runs whatever this form posts, not whatever the page happened to show.
$client_id = intval($_GET['client_id'] ?? 0);
if ($client_id) {
enforceClientAccess();
}
// Search Filter
$q_filter = $_GET['q'] ?? '';
// Archived Filter
$archived_filter = (isset($_GET['archived']) && $_GET['archived'] == 1) ? 1 : 0;
$roles = userRoles();
$role_filter = isset($roles[intval($_GET['role'] ?? 0)]) ? intval($_GET['role']) : 0;
ob_start();
@@ -36,19 +28,32 @@ ob_start();
<?php exportTabsFiltersOpen(); ?>
<div class="mb-3">
<label>Search</label>
<label class="form-label">Search</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-search"></i></span>
<input type="text" class="form-control" name="q" value="<?= stripslashes(escapeHtml($q_filter)) ?>" placeholder="Name or email">
<span class="input-group-text"><i class="fa fa-fw fa-search"></i></span>
<input type="text" class="form-control" name="q" value="<?= escapeHtml($q_filter) ?>" placeholder="Name, email or role">
</div>
</div>
<div class="mb-3">
<label>Archived</label>
<label class="form-label">Role</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-archive"></i></span>
<span class="input-group-text"><i class="fa fa-fw fa-user-shield"></i></span>
<select class="form-select select2" name="role">
<option value="">- Any role -</option>
<?php foreach ($roles as $role_id => $role) { ?>
<option value="<?= $role_id ?>" <?= $role_filter === $role_id ? 'selected' : '' ?><?= selectOptionRole($role) ?>><?= escapeHtml($role['role_name']) ?></option>
<?php } ?>
</select>
</div>
</div>
<div class="mb-3">
<label class="form-label">Archived</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-archive"></i></span>
<select class="form-select select2" name="archived">
<option <?php if (!$archived_filter) { echo "selected"; } ?> value="0">Active only</option>
<option <?php if (!$archived_filter) { echo "selected"; } ?> value="0">Not archived</option>
<option <?php if ($archived_filter) { echo "selected"; } ?> value="1">Archived only</option>
</select>
</div>

View File

@@ -0,0 +1,377 @@
<?php
/*
* The fields of New User and Edit User - included by user_add.php and
* user_edit.php, which set $form (the user, raw - or the defaults for a new
* one) and $is_new. Posts what admin/post/users.php add_user / edit_user read.
*
* Tabs: Details (picture, name, email, role), Sign in (password, MFA) and
* Client access - which clients a role without admin is limited to or kept
* from. An admin sees every client, so for an admin role that tab just says
* so (any rules are kept, in case the role changes back).
*/
isset($mysqli) || die("Direct file access is not allowed");
$form_user_id = intval($form['user_id'] ?? 0);
$form_id = 'userForm' . ($is_new ? 'New' : $form_user_id);
$form_role_id = intval($form['user_role_id'] ?? 0);
$form_roles = userRoles($form_role_id);
$form_has_mfa = trim((string) ($form['user_token'] ?? '')) !== '';
$form_has_avatar = trim((string) ($form['user_avatar'] ?? '')) !== '';
$form_access = $form_user_id ? userClientAccess([$form_user_id])[$form_user_id] : ['allow' => [], 'deny' => []];
// Every client - and an archived one only when it has a rule, so saving does not drop it. Those with a rule first.
$form_clients = [];
$sql = mysqli_query($mysqli, "SELECT client_id, client_name, client_abbreviation, client_archived_at FROM clients
WHERE client_archived_at IS NULL OR client_id IN (SELECT client_id FROM user_client_permissions WHERE user_id = $form_user_id)
ORDER BY client_id IN (SELECT client_id FROM user_client_permissions WHERE user_id = $form_user_id) DESC, client_archived_at IS NOT NULL, client_name ASC");
while ($row = mysqli_fetch_assoc($sql)) {
$form_clients[] = $row;
}
$value = function ($key) use ($form) {
return escapeHtml($form[$key] ?? '');
};
?>
<ul class="nav nav-pills nav-fill flex-nowrap overflow-auto mb-3 itflow-asset-form-tabs" role="tablist">
<li class="nav-item">
<a class="nav-link active" data-bs-toggle="pill" href="#<?= $form_id ?>Details"><i class="fas fa-fw fa-user me-2 d-none d-lg-inline"></i>Details</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="pill" href="#<?= $form_id ?>Security"><i class="fas fa-fw fa-key me-2 d-none d-lg-inline"></i>Sign in</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="pill" href="#<?= $form_id ?>Access"><i class="fas fa-fw fa-building me-2 d-none d-lg-inline"></i>Client access</a>
</li>
</ul>
<div class="tab-content" id="<?= $form_id ?>">
<!-- Who they are -->
<div class="tab-pane fade show active" id="<?= $form_id ?>Details">
<div class="d-flex align-items-center gap-3 mb-3">
<span data-user-avatar-preview><?= userAvatar($form_user_id ?: 0, $form['user_name'] ?? '', $form['user_avatar'] ?? '', 'lg') ?></span>
<div class="flex-grow-1 min-w-0">
<label class="form-label mb-1">Picture</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-fw fa-image"></i></span>
<input type="file" class="form-control" name="avatar" accept="image/jpeg,image/png,image/gif,image/webp" data-user-avatar-input>
</div>
<div class="d-flex flex-wrap align-items-center column-gap-3 mt-1">
<span class="form-text mt-0">JPG, PNG, GIF or WebP, up to 5 MB. Without one, their initials.</span>
<?php if ($form_has_avatar) { ?>
<div class="form-check small mb-0">
<input class="form-check-input" type="checkbox" name="remove_avatar" value="1" id="<?= $form_id ?>RemoveAvatar" data-user-avatar-remove>
<label class="form-check-label" for="<?= $form_id ?>RemoveAvatar">Remove the picture</label>
</div>
<?php } ?>
</div>
</div>
</div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Name <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-fw fa-user"></i></span>
<input type="text" class="form-control" name="name" placeholder="First and last name" maxlength="200" value="<?= $value('user_name') ?>" required <?= $is_new ? 'autofocus' : '' ?> data-user-name>
</div>
</div>
<div class="col-md-6">
<label class="form-label">Email <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-fw fa-envelope"></i></span>
<input type="email" class="form-control" name="email" placeholder="They sign in with this" maxlength="200" value="<?= $value('user_email') ?>" required>
</div>
<?php if (!$is_new) { ?>
<div class="form-text">Changed, they sign in with the new one - the old address is told.</div>
<?php } ?>
</div>
<div class="col-12">
<label class="form-label">Role <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-fw fa-user-shield"></i></span>
<select class="form-select select2" name="role" required data-user-role>
<?php if (!isset($form_roles[$form_role_id])) { ?>
<option value="">- Pick a role -</option>
<?php } ?>
<?php foreach ($form_roles as $role_id => $role) { ?>
<option value="<?= $role_id ?>" <?= $form_role_id === $role_id ? 'selected' : '' ?> data-role-admin="<?= intval($role['role_is_admin']) ?>"<?= selectOptionRole($role) ?>><?= escapeHtml($role['role_name']) ?><?= $role['role_archived_at'] ? ' (archived)' : '' ?></option>
<?php } ?>
</select>
</div>
<div class="form-text">What they can see and change. <a href="roles.php" target="_blank">Roles <i class="fas fa-external-link-alt small"></i></a></div>
</div>
</div>
</div>
<!-- How they sign in -->
<div class="tab-pane fade" id="<?= $form_id ?>Security">
<div class="row g-3">
<div class="col-12">
<label class="form-label"><?= $is_new ? 'Password <strong class="text-danger">*</strong>' : 'New password' ?></label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-fw fa-lock"></i></span>
<input type="password" class="form-control font-monospace" data-toggle="password" name="<?= $is_new ? 'password' : 'new_password' ?>" id="<?= $form_id ?>Password"
placeholder="<?= $is_new ? 'At least ' . USER_PASSWORD_MIN . ' characters' : 'Leave blank to keep their password' ?>" minlength="<?= USER_PASSWORD_MIN ?>" autocomplete="new-password" <?= $is_new ? 'required' : '' ?> data-user-password>
<span class="input-group-text"><i class="fa fa-fw fa-eye"></i></span>
<button class="btn btn-outline-secondary clipboardjs" type="button" title="Copy password" data-clipboard-target="#<?= $form_id ?>Password"><i class="far fa-fw fa-copy"></i></button>
<button class="btn btn-outline-secondary" type="button" title="Make one up - words that are easy to read out" data-user-generate><i class="fas fa-fw fa-magic"></i></button>
</div>
<div class="form-text">
<?php if ($is_new) { ?>
Give it to them yourself, or have it emailed - either way, they should change it once they're in.
<?php } else { ?>
Set, they're emailed that it changed. It also unlocks their copy of the credential vault, so it is set here rather than reset by email.
<?php } ?>
</div>
</div>
<?php if ($is_new) { ?>
<div class="col-12">
<?php if (!empty($config_smtp_host)) { ?>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" name="send_email" value="1" id="<?= $form_id ?>SendEmail" checked>
<label class="form-check-label" for="<?= $form_id ?>SendEmail">Email them their sign in details</label>
<div class="form-text mt-0">The address, the password and where to sign in.</div>
</div>
<?php } else { ?>
<div class="small text-body-secondary"><i class="fas fa-fw fa-envelope me-1"></i>Mail isn't set up, so nothing is emailed - give them their password yourself.</div>
<?php } ?>
</div>
<?php } ?>
<div class="col-12">
<div class="border rounded p-3">
<div class="d-flex align-items-center gap-2 mb-2">
<span class="fw-semibold"><i class="fas fa-fw fa-mobile-alt text-body-secondary me-1"></i>Multi-factor authentication</span>
<?php if (!$is_new) { ?><?= userMfaPill($form['user_token'] ?? '', $form['user_config_force_mfa'] ?? 0) ?><?php } ?>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" name="force_mfa" value="1" id="<?= $form_id ?>ForceMfa" <?= intval($form['user_config_force_mfa'] ?? 0) ? 'checked' : '' ?>>
<label class="form-check-label" for="<?= $form_id ?>ForceMfa">Require it</label>
<div class="form-text mt-0">Without it set up, they set it up at their next sign in - and they cannot turn it off.</div>
</div>
<?php if ($form_has_mfa) { ?>
<div class="form-check mt-2">
<input class="form-check-input" type="checkbox" name="reset_mfa" value="1" id="<?= $form_id ?>ResetMfa">
<label class="form-check-label" for="<?= $form_id ?>ResetMfa">Reset it</label>
<div class="form-text mt-0">For a lost or replaced phone - their authenticator app is forgotten, and so are their remembered browsers.</div>
</div>
<?php } ?>
</div>
</div>
</div>
</div>
<!-- The clients they see -->
<div class="tab-pane fade" id="<?= $form_id ?>Access">
<input type="hidden" name="client_permission_shown" value="1">
<div data-user-access-admin hidden>
<div class="alert alert-info small d-flex gap-2">
<i class="fas fa-user-shield mt-1"></i>
<div>An admin sees every client, so these rules don't apply to them. Rules already set are kept, in case the role changes back.</div>
</div>
</div>
<div data-user-access-rules>
<div class="small text-body-secondary mb-2">
<strong class="text-success-emphasis">Only</strong> limits them to those clients - with none, they see every client.
<strong class="text-danger-emphasis">Never</strong> keeps them out of a client, whatever else is set.
</div>
<div class="d-flex flex-wrap gap-2 align-items-center mb-2">
<div class="input-group input-group-sm flex-grow-1" style="max-width: 18rem">
<span class="input-group-text"><i class="fas fa-fw fa-search"></i></span>
<input type="search" class="form-control" placeholder="Find a client" data-user-access-filter aria-label="Find a client">
</div>
<div class="btn-group btn-group-sm" role="group" aria-label="Set every client shown">
<button type="button" class="btn btn-outline-secondary" data-user-access-all="" title="Every client shown">Clear</button>
<button type="button" class="btn btn-outline-success" data-user-access-all="allow" title="Every client shown">All only</button>
<button type="button" class="btn btn-outline-danger" data-user-access-all="deny" title="Every client shown">All never</button>
</div>
<span class="small fw-semibold ms-auto" data-user-access-summary></span>
</div>
<div class="border rounded itflow-user-access">
<table class="table table-sm table-hover align-middle mb-0">
<tbody>
<?php foreach ($form_clients as $client) {
$cid = intval($client['client_id']);
$rule = isset($form_access['allow'][$cid]) ? 'allow' : (isset($form_access['deny'][$cid]) ? 'deny' : '');
$tone = clientInitialsTone($cid);
$radio = "{$form_id}Client{$cid}";
?>
<tr data-user-access-row data-name="<?= escapeHtml(mb_strtolower($client['client_name'] . ' ' . $client['client_abbreviation'])) ?>">
<td class="ps-2">
<span class="itflow-client-avatar itflow-client-avatar-xs bg-<?= $tone ?>-subtle text-<?= $tone ?>-emphasis me-2" aria-hidden="true"><?= escapeHtml(clientInitials($client['client_abbreviation'], $client['client_name'])) ?></span><?= escapeHtml($client['client_name']) ?>
<?php if ($client['client_archived_at']) { ?><span class="badge rounded-pill text-bg-secondary fw-normal ms-1">Archived</span><?php } ?>
</td>
<td class="text-end pe-2 text-nowrap">
<div class="btn-group btn-group-sm" role="group" aria-label="<?= escapeHtml($client['client_name']) ?>">
<input type="radio" class="btn-check" name="client_permission[<?= $cid ?>]" value="" id="<?= $radio ?>None" <?= $rule === '' ? 'checked' : '' ?>>
<label class="btn btn-outline-secondary" for="<?= $radio ?>None" title="No rule">-</label>
<input type="radio" class="btn-check" name="client_permission[<?= $cid ?>]" value="allow" id="<?= $radio ?>Allow" <?= $rule === 'allow' ? 'checked' : '' ?>>
<label class="btn btn-outline-success" for="<?= $radio ?>Allow">Only</label>
<input type="radio" class="btn-check" name="client_permission[<?= $cid ?>]" value="deny" id="<?= $radio ?>Deny" <?= $rule === 'deny' ? 'checked' : '' ?>>
<label class="btn btn-outline-danger" for="<?= $radio ?>Deny">Never</label>
</div>
</td>
</tr>
<?php } ?>
<?php if (!$form_clients) { ?>
<tr><td class="text-body-secondary p-3">No clients yet.</td></tr>
<?php } ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
(function () {
var root = document.getElementById(<?= json_encode($form_id) ?>);
if (!root) {
return;
}
var form = root.closest('form');
// The picture - shown as soon as it is picked
var avatarInput = root.querySelector('[data-user-avatar-input]');
var avatarPreview = root.querySelector('[data-user-avatar-preview]');
var avatarOriginal = avatarPreview.innerHTML;
var avatarRemove = root.querySelector('[data-user-avatar-remove]');
avatarInput.addEventListener('change', function () {
var file = avatarInput.files && avatarInput.files[0];
if (file && /^image\//.test(file.type)) {
var img = document.createElement('img');
img.className = 'itflow-contact-avatar itflow-contact-avatar-lg';
img.alt = '';
img.src = URL.createObjectURL(file);
avatarPreview.replaceChildren(img);
if (avatarRemove) {
avatarRemove.checked = false;
}
} else {
avatarPreview.innerHTML = avatarOriginal;
}
});
if (avatarRemove) {
avatarRemove.addEventListener('change', function () {
avatarPreview.classList.toggle('opacity-25', avatarRemove.checked);
if (avatarRemove.checked) {
avatarInput.value = '';
avatarPreview.innerHTML = avatarOriginal;
}
});
}
// No picture - their initials, as the name is typed
var nameInput = root.querySelector('[data-user-name]');
nameInput.addEventListener('input', function () {
var initials = avatarPreview.querySelector('span.itflow-contact-avatar');
if (initials && !(avatarInput.files && avatarInput.files.length)) {
var words = nameInput.value.trim().split(/\s+/).filter(Boolean).slice(0, 2);
initials.textContent = words.map(function (w) { return w.charAt(0).toUpperCase(); }).join('') || '?';
}
});
// Make up a password - words, easy to read out over the phone
var password = root.querySelector('[data-user-password]');
root.querySelector('[data-user-generate]').addEventListener('click', function () {
itflowGet('/agent/ajax.php', { get_readable_pass: 'true' }, function (data) {
password.value = JSON.parse(data);
password.type = 'text';
var eye = password.closest('.input-group').querySelector('.fa-eye');
if (eye) {
eye.classList.replace('fa-eye', 'fa-eye-slash');
}
password.dispatchEvent(new Event('input'));
});
});
// Client access: an admin sees everything, so the rules are put away
var role = root.querySelector('[data-user-role]');
var adminNote = root.querySelector('[data-user-access-admin]');
var rules = root.querySelector('[data-user-access-rules]');
function roleChanged() {
var option = role.options[role.selectedIndex];
var isAdmin = option && option.dataset.roleAdmin === '1';
adminNote.hidden = !isAdmin;
rules.classList.toggle('opacity-50', isAdmin);
}
role.addEventListener('change', roleChanged);
roleChanged();
// What the rules add up to
var summary = root.querySelector('[data-user-access-summary]');
function summarize() {
var allow = root.querySelectorAll('[data-user-access-row] input[value="allow"]:checked').length;
var deny = root.querySelectorAll('[data-user-access-row] input[value="deny"]:checked').length;
var text, tone;
if (allow) {
text = 'Sees ' + allow + ' client' + (allow === 1 ? '' : 's') + ' only';
tone = 'text-warning-emphasis';
} else if (deny) {
text = 'Sees all but ' + deny + ' client' + (deny === 1 ? '' : 's');
tone = 'text-warning-emphasis';
} else {
text = 'Sees every client';
tone = 'text-success-emphasis';
}
summary.textContent = text;
summary.className = 'small fw-semibold ms-auto ' + tone;
}
root.addEventListener('change', function (e) {
if (e.target.matches('[data-user-access-row] input')) {
summarize();
}
});
summarize();
// Find a client, and set every one shown at once
var filter = root.querySelector('[data-user-access-filter]');
filter.addEventListener('input', function () {
var term = filter.value.trim().toLowerCase();
root.querySelectorAll('[data-user-access-row]').forEach(function (row) {
row.hidden = term !== '' && row.dataset.name.indexOf(term) === -1;
});
});
// Enter in the filter would submit the whole form
filter.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
}
});
root.querySelectorAll('[data-user-access-all]').forEach(function (button) {
button.addEventListener('click', function () {
var value = button.dataset.userAccessAll;
root.querySelectorAll('[data-user-access-row]').forEach(function (row) {
if (!row.hidden) {
var radio = row.querySelector('input[value="' + value + '"]');
if (radio) {
radio.checked = true;
}
}
});
summarize();
});
});
// A required field on a tab not showing - show that tab, so the browser can point at it
form.addEventListener('invalid', function (e) {
var pane = e.target.closest('.tab-pane');
if (pane && !pane.classList.contains('active')) {
var link = form.querySelector('[href="#' + pane.id + '"]');
if (link) {
bootstrap.Tab.getOrCreateInstance(link).show();
}
}
}, true);
})();
</script>

View File

@@ -1,43 +0,0 @@
<?php
require_once '../../includes/modal_header.php';
ob_start();
?>
<div class="modal-header bg-dark">
<h5 class="modal-title"><i class="fas fa-fw fa-user-plus"></i>Invite User</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<form action="post.php" method="post" enctype="multipart/form-data" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<div class="modal-body">
<div class="mb-3">
<label>Email <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-envelope"></i></span>
<input type="email" class="form-control" name="email" placeholder="Email Address" maxlength="200" required>
</div>
</div>
<div class="mb-3">
<label>Role <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-user-shield"></i></span>
<select class="form-select select2" name="role" required>
<option value="">- Role -</option>
<!-- //TODO: Pull from roles -->
</select>
</div>
</div>
</div>
<div class="modal-footer">
<button type="submit" name="invite_user" class="btn btn-primary text-bold"><i class="fas fa-paper-plane me-2"></i>Send Invite</button>
<button type="button" class="btn btn-light" data-bs-dismiss="modal"><i class="fa fa-times me-2"></i>Cancel</button>
</div>
</form>
<?php
require_once "../../../includes/modal_footer.php";

View File

@@ -1,73 +1,73 @@
<?php
/*
* Restore User - back from the archive, with a role and a new password (the
* old one, and their copy of the vault key, were wiped when archived).
*/
require_once '../../includes/modal_header.php';
$user_id = intval($_GET['id']);
$user = userForModal($_GET['id'] ?? 0);
$user_id = intval($user['user_id']);
$name = trim(str_replace(' (archived)', '', $user['user_name']));
$role_id = intval($user['user_role_id']);
$roles = userRoles($role_id);
$email_taken = mysqli_num_rows(mysqli_query($mysqli, "SELECT 1 FROM users WHERE user_email = '" . escapeSql($user['user_email']) . "' AND user_archived_at IS NULL AND user_id <> $user_id"));
$sql = mysqli_query($mysqli, "SELECT user_avatar, user_email, user_name, user_role_id FROM users WHERE user_id = $user_id AND user_archived_at IS NOT NULL LIMIT 1");
$row = mysqli_fetch_assoc($sql);
$user_name = str_replace(" (archived)", "", $row['user_name']); //Removed (archived) from user_name
$user_name = escapeHtml($user_name);
$user_email = escapeHtml($row['user_email']);
$user_avatar = escapeHtml($row['user_avatar']);
$user_initials = initials($user_name);
$user_role_id = intval($row['user_role_id']);
// Generate the HTML form content using output buffering.
ob_start();
?>
<div class="modal-header bg-dark">
<h5 class="modal-title"><i class="fas fa-fw fa-redo-alt me-2"></i>Restoring user:
<strong><?= $user_name ?></strong></h5>
<h5 class="modal-title text-truncate"><i class="fas fa-fw fa-redo-alt me-2"></i>Restore <?= escapeHtml($name) ?></h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<form action="post.php" method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<input type="hidden" name="user_id" value="<?= $user_id ?>">
<div class="modal-body">
<center class="mb-3">
<?php if (!empty($user_avatar)) { ?>
<img class="img-fluid" src="<?= "../uploads/users/$user_id/$user_avatar" ?>">
<?php } else { ?>
<span class="fa-stack fa-4x">
<i class="fa fa-circle fa-stack-2x text-secondary"></i>
<span class="fa fa-stack-1x text-white"><?= $user_initials ?></span>
</span>
<?php } ?>
</center>
<div class="mb-3">
<label>Set a 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" data-toggle="password" name="new_password"
placeholder="Enter a new password" autocomplete="new-password" required>
<span class="input-group-text"><i class="fa fa-fw fa-eye"></i></span>
<div class="d-flex align-items-center gap-3 mb-3">
<?= userAvatar($user_id, $name, $user['user_avatar'], 'lg') ?>
<div class="min-w-0">
<div class="fw-semibold"><?= escapeHtml($name) ?></div>
<div class="small text-body-secondary"><?= escapeHtml($user['user_email']) ?> · archived <?= escapeHtml(timeAgo($user['user_archived_at'])) ?></div>
</div>
</div>
<?php if (agentUserLimitReached()) { ?>
<div class="alert alert-danger small"><?= agentUserLimitMessage() ?></div>
<?php } ?>
<?php if ($email_taken) { ?>
<div class="alert alert-warning small"><i class="fas fa-exclamation-triangle me-2"></i><strong><?= escapeHtml($user['user_email']) ?></strong> signs in to another account now - change that one's email first.</div>
<?php } ?>
<div class="mb-3">
<label>Role <strong class="text-danger">*</strong></label>
<label class="form-label">New password <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-user-shield"></i></span>
<span class="input-group-text"><i class="fas fa-fw fa-lock"></i></span>
<input type="password" class="form-control font-monospace" data-toggle="password" name="new_password" id="userRestorePassword<?= $user_id ?>"
placeholder="At least <?= USER_PASSWORD_MIN ?> characters" minlength="<?= USER_PASSWORD_MIN ?>" autocomplete="new-password" required>
<span class="input-group-text"><i class="fa fa-fw fa-eye"></i></span>
<button class="btn btn-outline-secondary clipboardjs" type="button" title="Copy password" data-clipboard-target="#userRestorePassword<?= $user_id ?>"><i class="far fa-fw fa-copy"></i></button>
<button class="btn btn-outline-secondary" type="button" title="Make one up - words that are easy to read out" data-user-restore-generate><i class="fas fa-fw fa-magic"></i></button>
</div>
<div class="form-text">Their old password was wiped when they were archived - give them this one, and have them change it.</div>
</div>
<div>
<label class="form-label">Role <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-fw fa-user-shield"></i></span>
<select class="form-select select2" name="role" required>
<?php
$sql_user_roles = mysqli_query($mysqli, "SELECT role_id, role_name FROM user_roles WHERE role_archived_at IS NULL");
while ($row = mysqli_fetch_assoc($sql_user_roles)) {
$role_id = intval($row['role_id']);
$role_name = escapeHtml($row['role_name']);
?>
<option <?php if ($role_id == $user_role_id) {echo "selected";} ?> value="<?= $role_id ?>"><?= $role_name ?></option>
<?php if (!isset($roles[$role_id])) { ?>
<option value="">- Pick a role -</option>
<?php } ?>
<?php foreach ($roles as $id => $role) { ?>
<option value="<?= $id ?>" <?= $id === $role_id ? 'selected' : '' ?><?= selectOptionRole($role) ?>><?= escapeHtml($role['role_name']) ?><?= $role['role_archived_at'] ? ' (archived)' : '' ?></option>
<?php } ?>
</select>
</div>
</div>
</div>
<div class="modal-footer">
<button type="submit" name="restore_user" class="btn btn-primary text-bold"><i class="fas fa-check me-2"></i>Restore</button>
@@ -75,5 +75,17 @@ ob_start();
</div>
</form>
<script>
(function () {
var password = document.getElementById('userRestorePassword<?= $user_id ?>');
password.closest('form').querySelector('[data-user-restore-generate]').addEventListener('click', function () {
itflowGet('/agent/ajax.php', { get_readable_pass: 'true' }, function (data) {
password.value = JSON.parse(data);
password.type = 'text';
});
});
})();
</script>
<?php
require_once "../../../includes/modal_footer.php";

View File

@@ -1,8 +0,0 @@
<?php
defined('FROM_POST_HANDLER') || die("Direct file access is not allowed");
$name = escapeSql($_POST['name']);
$email = escapeSql($_POST['email']);
$role = intval($_POST['role']);
$force_mfa = intval($_POST['force_mfa'] ?? 0);

View File

@@ -2,10 +2,25 @@
/*
* ITFlow - GET/POST request handler for user (agent) management
*
* Every change goes through functions/user.php: an email no other account
* signs in with, a role that exists, a password of at least
* USER_PASSWORD_MIN characters set together with the agent's vault key, a
* picture that really is one. An admin changes their own account from their
* Account pages, not here, and the last active admin cannot be demoted,
* disabled or archived - there would be nobody left to undo it.
*/
defined('FROM_POST_HANDLER') || die("Direct file access is not allowed");
/** Refuse a change that would leave no active admin. */
function userGuardLastAdmin($row, $what) {
if (userIsLastAdmin($row)) {
flashAlert("<strong>" . $row['user_name'] . "</strong> is the only active admin - $what would leave nobody to run ITFlow. Make someone else an admin first.", 'error');
redirect();
}
}
if (isset($_POST['add_user'])) {
validateCSRFToken();
@@ -15,97 +30,57 @@ if (isset($_POST['add_user'])) {
redirect();
}
require_once 'user_model.php';
$password = password_hash(trim($_POST['password']), PASSWORD_DEFAULT);
$user_specific_encryption_ciphertext = encryptUserSpecificKey(trim($_POST['password']));
mysqli_query($mysqli, "INSERT INTO users SET user_name = '$name', user_email = '$email', user_password = '$password', user_specific_encryption_ciphertext = '$user_specific_encryption_ciphertext', user_role_id = $role");
$error = '';
$identity = userCheckIdentity($_POST, 0, $error);
$role = $identity ? userCheckRole($_POST['role'] ?? 0, 0, $error) : null;
$password = (string) ($_POST['password'] ?? '');
if ($role && ($problem = userPasswordProblem($password))) {
$error = $problem;
}
if (!$identity || !$role || $error !== '') {
flashAlert($error, 'error');
redirect();
}
[$name, $email] = $identity;
$role_id = intval($role['role_id']);
mysqli_query($mysqli, "INSERT INTO users SET user_name = '" . escapeSql($name) . "', user_email = '" . escapeSql($email) . "', user_password = '', user_role_id = $role_id");
$user_id = mysqli_insert_id($mysqli);
userSetPassword($user_id, $password);
// Add Client Access (allow / deny per client)
if (isset($_POST['client_permission'])) {
foreach ($_POST['client_permission'] as $perm_client_id => $perm_type) {
$perm_client_id = intval($perm_client_id);
if ($perm_type === 'allow' || $perm_type === 'deny') {
mysqli_query($mysqli, "INSERT INTO user_client_permissions SET user_id = $user_id, client_id = $perm_client_id, permission_type = '$perm_type'");
}
}
}
if (!file_exists("../uploads/users/$user_id/")) {
mkdir("../uploads/users/$user_id");
}
// Check for and process image/photo
$extended_alert_description = '';
if (isset($_FILES['file']['tmp_name'])) {
if ($new_file_name = checkFileUpload($_FILES['file'], array('jpg', 'jpeg', 'gif', 'png', 'webp'))) {
$file_tmp_path = $_FILES['file']['tmp_name'];
// directory in which the uploaded file will be moved
$upload_file_dir = "../uploads/users/$user_id/";
$dest_path = $upload_file_dir . $new_file_name;
move_uploaded_file($file_tmp_path, $dest_path);
// Set Avatar
mysqli_query($mysqli, "UPDATE users SET user_avatar = '$new_file_name' WHERE user_id = $user_id");
$extended_alert_description = '. File successfully uploaded.';
}
}
// Create Settings
$force_mfa = !empty($_POST['force_mfa']) ? 1 : 0;
mysqli_query($mysqli, "INSERT INTO user_settings SET user_id = $user_id, user_config_force_mfa = $force_mfa");
$sql = mysqli_query($mysqli,"SELECT company_name FROM companies WHERE company_id = 1");
$row = mysqli_fetch_assoc($sql);
$company_name = escapeSql($row['company_name']);
// Sanitize Config vars from load_global_settings.php
$config_mail_from_name = escapeSql($config_mail_from_name);
$config_mail_from_email = escapeSql($config_mail_from_email);
$config_ticket_from_email = escapeSql($config_ticket_from_email);
$config_login_key_secret = mysqli_real_escape_string($mysqli, $config_login_key_secret);
$config_base_url = escapeSql($config_base_url);
// Send user e-mail, if specified
if (isset($_POST['send_email']) && !empty($config_smtp_host) && filter_var($email, FILTER_VALIDATE_EMAIL)) {
$password = mysqli_real_escape_string($mysqli, $_POST['password']);
// Only hand out the login key when the gate is actually enabled - same test as post/logout.php
$login_url = "https://$config_base_url/login.php";
if ($config_login_key_required == 1) {
$login_url .= "?key=$config_login_key_secret";
}
$subject = "Your new $company_name ITFlow account";
$body = "Hello $name,<br><br>An ITFlow account has been setup for you. Please change your password upon login. <br><br>Username: $email <br>Password: $password<br>Login URL: $login_url<br><br>--<br>$company_name - Support<br>$config_ticket_from_email";
$data = [
[
'from' => $config_mail_from_email,
'from_name' => $config_mail_from_name,
'recipient' => $email,
'recipient_name' => $name,
'subject' => $subject,
'body' => $body
]
];
$mail = addToMailQueue($data);
if ($mail !== true) {
mysqli_query($mysqli, "INSERT INTO notifications SET notification_type = 'Mail', notification = 'Failed to send email to $email'");
mysqli_query($mysqli, "INSERT INTO logs SET log_type = 'Mail', log_action = 'Error', log_description = 'Failed to send email to $email regarding $subject. $mail', log_ip = '$session_ip', log_user_agent = '$session_user_agent', log_user_id = $session_user_id, log_entity_id = $user_id");
}
if (!intval($role['role_is_admin'])) {
userSetClientAccess($user_id, $_POST['client_permission'] ?? []);
}
logAudit("User", "Create", "$session_name created user $name", 0, $user_id);
$notes = [];
$avatar_error = '';
if (!empty($_FILES['avatar']['tmp_name']) && !userSaveAvatar($user_id, $_FILES['avatar'], $avatar_error)) {
$notes[] = $avatar_error . ' The user was created without one';
}
flashAlert("User <strong>$name</strong> created" . $extended_alert_description);
// Their sign in details by email - the password goes as typed, so it is theirs to change
if (!empty($_POST['send_email']) && !empty($config_smtp_host)) {
$company_name = (string) mysqli_fetch_row(mysqli_query($mysqli, "SELECT company_name FROM companies WHERE company_id = 1"))[0];
$login_url = "https://$config_base_url/login.php" . ($config_login_key_required == 1 ? "?key=$config_login_key_secret" : '');
$mail = userQueueMail($email, $name, "Your new $company_name ITFlow account",
'Hello ' . escapeHtml($name) . ',<br><br>An ITFlow account has been set up for you. Please change your password after you sign in'
. ($force_mfa ? ', and have an authenticator app ready - you will set up multi-factor authentication the first time' : '') . '.<br><br>'
. 'Email: ' . escapeHtml($email) . '<br>Password: ' . escapeHtml($password) . '<br>Sign in at: ' . escapeHtml($login_url)
. '<br><br>--<br>' . escapeHtml($company_name) . ' - Support<br>' . escapeHtml($config_ticket_from_email));
if ($mail === true) {
$notes[] = 'their sign in details were emailed to them';
} else {
$notes[] = 'the email with their sign in details could not be queued';
logApp('Mail', 'error', 'Failed to queue the new account email for ' . $email . ": $mail");
}
}
logAudit("User", "Create", "$session_name created user " . escapeSql($name), 0, $user_id);
flashAlert("User <strong>$name</strong> created" . ($notes ? ' - ' . implode('; ', $notes) : ''), $avatar_error ? 'warning' : 'success');
redirect();
@@ -115,80 +90,62 @@ if (isset($_POST['edit_user'])) {
validateCSRFToken();
require_once 'user_model.php';
$row = userForRequest($_POST['user_id'] ?? 0);
$user_id = intval($row['user_id']);
$user_id = intval($_POST['user_id']);
$error = '';
$identity = userCheckIdentity($_POST, $user_id, $error);
$role = $identity ? userCheckRole($_POST['role'] ?? 0, $row['user_role_id'], $error) : null;
$new_password = (string) ($_POST['new_password'] ?? '');
if ($role && $new_password !== '' && ($problem = userPasswordProblem($new_password))) {
$error = $problem;
}
if (!$identity || !$role || $error !== '') {
flashAlert($error, 'error');
redirect();
}
[$name, $email] = $identity;
if (intval($row['role_is_admin']) && !intval($role['role_is_admin'])) {
userGuardLastAdmin($row, 'a role without admin');
}
$new_password = trim($_POST['new_password']);
mysqli_query($mysqli, "UPDATE users SET user_name = '" . escapeSql($name) . "', user_email = '" . escapeSql($email) . "', user_role_id = " . intval($role['role_id']) . " WHERE user_id = $user_id");
$force_mfa = !empty($_POST['force_mfa']) ? 1 : 0;
mysqli_query($mysqli, "INSERT INTO user_settings SET user_id = $user_id, user_config_force_mfa = $force_mfa ON DUPLICATE KEY UPDATE user_config_force_mfa = $force_mfa");
// Update Client Access (allow / deny per client)
mysqli_query($mysqli,"DELETE FROM user_client_permissions WHERE user_id = $user_id");
if (isset($_POST['client_permission'])) {
foreach ($_POST['client_permission'] as $perm_client_id => $perm_type) {
$perm_client_id = intval($perm_client_id);
if ($perm_type === 'allow' || $perm_type === 'deny') {
mysqli_query($mysqli, "INSERT INTO user_client_permissions SET user_id = $user_id, client_id = $perm_client_id, permission_type = '$perm_type'");
}
// An admin sees every client - their rules are left as they were, in case the role changes back
if (!intval($role['role_is_admin']) && isset($_POST['client_permission_shown'])) {
userSetClientAccess($user_id, $_POST['client_permission'] ?? []);
}
$changes = [];
if ($new_password !== '') {
userSetPassword($user_id, $new_password);
$changes[] = 'password changed';
userNotifyChange($name, $email, 'an administrator set a new password');
}
if (!empty($_POST['reset_mfa']) && trim((string) $row['user_token']) !== '') {
userResetMfa($user_id);
$changes[] = 'MFA reset';
userNotifyChange($name, $email, 'an administrator turned off multi-factor authentication' . ($force_mfa ? ' - you will set it up again at your next sign in' : ''));
}
if (strcasecmp($email, $row['user_email']) !== 0) {
$changes[] = 'email changed';
userNotifyChange($name, $row['user_email'], 'its email address is now ' . $email);
}
$avatar_error = '';
if (!empty($_POST['remove_avatar'])) {
userRemoveAvatar($user_id);
$changes[] = 'avatar removed';
} elseif (!empty($_FILES['avatar']['tmp_name'])) {
if (userSaveAvatar($user_id, $_FILES['avatar'], $avatar_error)) {
$changes[] = 'avatar updated';
}
}
// Get current Avatar
$sql = mysqli_query($mysqli, "SELECT user_avatar FROM users WHERE user_id = $user_id");
$row = mysqli_fetch_assoc($sql);
$existing_file_name = escapeSql($row['user_avatar']);
logAudit("User", "Edit", "$session_name edited user " . escapeSql($name) . ($changes ? ' - ' . escapeSql(implode(', ', $changes)) : ''), 0, $user_id);
$extended_log_description = '';
if (!empty($_POST['2fa'])) {
$two_fa = $_POST['2fa'];
}
if (!file_exists("../uploads/users/$user_id/")) {
mkdir("../uploads/users/$user_id");
}
// Check for and process image/photo
$extended_alert_description = '';
if (isset($_FILES['file']['tmp_name'])) {
if ($new_file_name = checkFileUpload($_FILES['file'], array('jpg', 'jpeg', 'gif', 'png', 'webp'))) {
$file_tmp_path = $_FILES['file']['tmp_name'];
// directory in which the uploaded file will be moved
$upload_file_dir = "../uploads/users/$user_id/";
$dest_path = $upload_file_dir . $new_file_name;
move_uploaded_file($file_tmp_path, $dest_path);
// Delete old file
unlink("../uploads/users/$user_id/$existing_file_name");
// Set Avatar
mysqli_query($mysqli, "UPDATE users SET user_avatar = '$new_file_name' WHERE user_id = $user_id");
$extended_alert_description = '. File successfully uploaded.';
}
}
mysqli_query($mysqli, "UPDATE users SET user_name = '$name', user_email = '$email', user_role_id = $role WHERE user_id = $user_id");
if (!empty($new_password)) {
$new_password = password_hash($new_password, PASSWORD_DEFAULT);
$user_specific_encryption_ciphertext = encryptUserSpecificKey(trim($_POST['new_password']));
mysqli_query($mysqli, "UPDATE users SET user_password = '$new_password', user_specific_encryption_ciphertext = '$user_specific_encryption_ciphertext' WHERE user_id = $user_id");
//Extended Logging
$extended_log_description .= ", password changed";
}
if (!empty($two_fa) && $two_fa == 'disable') {
mysqli_query($mysqli, "UPDATE users SET user_token = '' WHERE user_id = '$user_id'");
mysqli_query($mysqli, "INSERT INTO logs SET log_type = 'User', log_action = 'Modify', log_description = '$session_name disabled 2FA for $name', log_ip = '$session_ip', log_user_agent = '$session_user_agent', log_user_id = $session_user_id");
}
//Update User Settings
mysqli_query($mysqli, "UPDATE user_settings SET user_config_force_mfa = $force_mfa WHERE user_id = $user_id");
logAudit("User", "Edit", "$session_name edited user $name", 0, $user_id);
flashAlert("User <strong>$name</strong> updated" . $extended_alert_description);
flashAlert("User <strong>$name</strong> saved" . ($changes ? ' - ' . implode(', ', $changes) : '') . ($avatar_error ? ". $avatar_error" : ''), $avatar_error ? 'warning' : 'success');
redirect();
@@ -198,22 +155,20 @@ if (isset($_GET['activate_user'])) {
validateCSRFToken();
$user_id = intval($_GET['activate_user']);
$row = userForRequest($_GET['activate_user']);
$user_id = intval($row['user_id']);
// Only a user who isn't already using a seat can push the count over the limit
$seat_row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(user_id) FROM users WHERE user_id = $user_id AND user_type = 1 AND user_status = 1 AND user_archived_at IS NULL"));
if (intval($seat_row[0]) === 0 && agentUserLimitReached()) {
if ((intval($row['user_status']) !== 1 || $row['user_archived_at']) && agentUserLimitReached()) {
flashAlert(agentUserLimitMessage(), 'error');
redirect();
}
$user_name = escapeSql(getFieldById('users', $user_id, 'user_name'));
mysqli_query($mysqli, "UPDATE users SET user_status = 1 WHERE user_id = $user_id");
logAudit("User", "Activate", "$session_name activated user $user_name", 0, $user_id);
logAudit("User", "Activate", "$session_name activated user " . escapeSql($row['user_name']), 0, $user_id);
flashAlert("User <strong>$user_name</strong> activated");
flashAlert("User <strong>" . $row['user_name'] . "</strong> can sign in again");
redirect();
@@ -223,30 +178,16 @@ if (isset($_GET['disable_user'])) {
validateCSRFToken();
$user_id = intval($_GET['disable_user']);
$user_name = escapeSql(getFieldById('users', $user_id, 'user_name'));
$row = userForRequest($_GET['disable_user']);
$user_id = intval($row['user_id']);
userGuardLastAdmin($row, 'disabling them');
mysqli_query($mysqli, "UPDATE users SET user_status = 0 WHERE user_id = $user_id");
userHandOverWork($user_id, 0, 'when ' . escapeSql($row['user_name']) . ' was disabled');
// Un-assign tickets. Collect them first so each one gets a history entry -
// otherwise a whole queue silently goes unassigned with nothing to explain it
$affected_ticket_ids = array();
$sql_affected_tickets = mysqli_query($mysqli, "SELECT ticket_id FROM tickets WHERE ticket_assigned_to = $user_id AND ticket_closed_at IS NULL");
while ($affected_ticket_row = mysqli_fetch_assoc($sql_affected_tickets)) {
$affected_ticket_ids[] = intval($affected_ticket_row['ticket_id']);
}
logAudit("User", "Disable", "$session_name disabled user " . escapeSql($row['user_name']), 0, $user_id);
mysqli_query($mysqli, "UPDATE tickets SET ticket_assigned_to = 0 WHERE ticket_assigned_to = $user_id AND ticket_closed_at IS NULL");
mysqli_query($mysqli, "UPDATE recurring_tickets SET recurring_ticket_assigned_to = 0 WHERE recurring_ticket_assigned_to = $user_id");
foreach ($affected_ticket_ids as $affected_ticket_id) {
logTicketHistory($affected_ticket_id, "$session_name unassigned the ticket when $user_name was disabled");
}
logAudit("User", "Disable", "$session_name disabled user $user_name", 0, $user_id);
flashAlert("User <strong>$user_name</strong> disabled", 'error');
flashAlert("User <strong>" . $row['user_name'] . "</strong> disabled - they are signed out, and their open tickets are unassigned", 'error');
redirect();
@@ -256,15 +197,32 @@ if (isset($_GET['revoke_remember_me'])) {
validateCSRFToken();
$user_id = intval($_GET['revoke_remember_me']);
$user_name = escapeSql(getFieldById('users', $user_id, 'user_name'));
$row = userForRequest($_GET['revoke_remember_me']);
$user_id = intval($row['user_id']);
mysqli_query($mysqli, "DELETE FROM remember_tokens WHERE remember_token_user_id = $user_id");
logAudit("User", "Edit", "$session_name revoked all remember me tokens for user $user_name", 0, $user_id);
logAudit("User", "Edit", "$session_name revoked all remember me tokens for user " . escapeSql($row['user_name']), 0, $user_id);
flashAlert("User <strong>$user_name</strong> remember me tokens revoked", 'error');
flashAlert("Remembered browsers forgotten for <strong>" . $row['user_name'] . "</strong> - every browser asks for their MFA code again");
redirect();
}
if (isset($_GET['reset_user_mfa'])) {
validateCSRFToken();
$row = userForRequest($_GET['reset_user_mfa']);
$user_id = intval($row['user_id']);
userResetMfa($user_id);
userNotifyChange($row['user_name'], $row['user_email'], 'an administrator turned off multi-factor authentication' . (intval($row['user_config_force_mfa']) ? ' - you will set it up again at your next sign in' : ''));
logAudit("User", "Edit", "$session_name reset MFA for user " . escapeSql($row['user_name']), 0, $user_id);
flashAlert("MFA reset for <strong>" . $row['user_name'] . "</strong>" . (intval($row['user_config_force_mfa']) ? ' - they set it up again at their next sign in' : ' - they sign in with their password alone until they turn it on'));
redirect();
@@ -274,38 +232,26 @@ if (isset($_POST['archive_user'])) {
validateCSRFToken();
$user_id = intval($_POST['user_id']);
$ticket_assign = intval($_POST['ticket_assign']);
$password = password_hash(randomString(), PASSWORD_DEFAULT);
$row = userForRequest($_POST['user_id'] ?? 0);
$user_id = intval($row['user_id']);
userGuardLastAdmin($row, 'archiving them');
$user_name = escapeSql(getFieldById('users', $user_id, 'user_name'));
// Un-assign / Re-assign tickets. Same as above - collect them first so the
// move shows up on each ticket's history
$affected_ticket_ids = array();
$sql_affected_tickets = mysqli_query($mysqli, "SELECT ticket_id FROM tickets WHERE ticket_assigned_to = $user_id AND ticket_closed_at IS NULL AND ticket_resolved_at IS NULL");
while ($affected_ticket_row = mysqli_fetch_assoc($sql_affected_tickets)) {
$affected_ticket_ids[] = intval($affected_ticket_row['ticket_id']);
// Their work to an active agent, or to nobody
$to_user_id = intval($_POST['ticket_assign'] ?? 0);
if ($to_user_id && ($to_user_id === $user_id || !mysqli_num_rows(mysqli_query($mysqli, "SELECT 1 FROM users WHERE user_id = $to_user_id AND user_type = 1 AND user_status = 1 AND user_archived_at IS NULL")))) {
$to_user_id = 0;
}
userHandOverWork($user_id, $to_user_id, 'when ' . escapeSql($row['user_name']) . ' was archived');
mysqli_query($mysqli, "UPDATE tickets SET ticket_assigned_to = $ticket_assign WHERE ticket_assigned_to = $user_id AND ticket_closed_at IS NULL AND ticket_resolved_at IS NULL");
mysqli_query($mysqli, "UPDATE recurring_tickets SET recurring_ticket_assigned_to = $ticket_assign WHERE recurring_ticket_assigned_to = $user_id");
// They can no longer sign in, and their copy of the vault key is gone - restoring sets a new password
$archived_name = escapeSql(mb_substr($row['user_name'], 0, 189) . ' (archived)');
$scrambled = escapeSql(password_hash(randomString(), PASSWORD_DEFAULT));
mysqli_query($mysqli, "UPDATE users SET user_name = '$archived_name', user_password = '$scrambled', user_status = 0, user_specific_encryption_ciphertext = '', user_archived_at = NOW() WHERE user_id = $user_id");
mysqli_query($mysqli, "DELETE FROM remember_tokens WHERE remember_token_user_id = $user_id");
$reassigned_to_name = $ticket_assign ? escapeSql(getFieldById('users', $ticket_assign, 'user_name')) : '';
foreach ($affected_ticket_ids as $affected_ticket_id) {
if ($reassigned_to_name) {
logTicketHistory($affected_ticket_id, "$session_name reassigned the ticket to $reassigned_to_name when $user_name was archived");
} else {
logTicketHistory($affected_ticket_id, "$session_name unassigned the ticket when $user_name was archived");
}
}
logAudit("User", "Archive", "$session_name archived user " . escapeSql($row['user_name']), 0, $user_id);
// Archive user query
mysqli_query($mysqli, "UPDATE users SET user_name = '$user_name (archived)', user_password = '$password', user_status = 0, user_specific_encryption_ciphertext = '', user_archived_at = NOW() WHERE user_id = $user_id");
logAudit("User", "Archive", "$session_name archived user $user_name", 0, $user_id);
flashAlert("User <strong>$user_name</strong> archived", 'error');
flashAlert("User <strong>" . $row['user_name'] . "</strong> archived" . ($to_user_id ? ' - their open tickets went to ' . getFieldById('users', $to_user_id, 'user_name') : ''), 'error');
redirect();
@@ -315,34 +261,133 @@ if (isset($_POST['restore_user'])) {
validateCSRFToken();
$user_id = intval($_POST['user_id']);
$row = userForRequest($_POST['user_id'] ?? 0);
$user_id = intval($row['user_id']);
// Only a user who isn't already using a seat can push the count over the limit
$seat_row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(user_id) FROM users WHERE user_id = $user_id AND user_type = 1 AND user_status = 1 AND user_archived_at IS NULL"));
if (intval($seat_row[0]) === 0 && agentUserLimitReached()) {
if (agentUserLimitReached()) {
flashAlert(agentUserLimitMessage(), 'error');
redirect();
}
$new_password = trim($_POST['new_password']);
$role = intval($_POST['role']);
$user_name = getFieldById('users', $user_id, 'user_name');
$user_name = escapeSql(str_replace(" (archived)", "", $user_name)); //Removed (archived) from user_name
// Restore user query
mysqli_query($mysqli, "UPDATE users SET user_name = '$user_name', user_status = 1, user_role_id = $role, user_archived_at = NULL WHERE user_id = $user_id");
if (!empty($new_password)) {
$new_password = password_hash($new_password, PASSWORD_DEFAULT);
$user_specific_encryption_ciphertext = encryptUserSpecificKey(trim($_POST['new_password']));
mysqli_query($mysqli, "UPDATE users SET user_password = '$new_password', user_specific_encryption_ciphertext = '$user_specific_encryption_ciphertext' WHERE user_id = $user_id");
//Extended Logging
$extended_log_description .= ", password changed";
$error = '';
$role = userCheckRole($_POST['role'] ?? 0, $row['user_role_id'], $error);
$password = (string) ($_POST['new_password'] ?? '');
if ($role && ($problem = userPasswordProblem($password))) {
$error = $problem;
}
$name = trim(str_replace(' (archived)', '', $row['user_name']));
if ($role && mysqli_num_rows(mysqli_query($mysqli, "SELECT 1 FROM users WHERE user_email = '" . escapeSql($row['user_email']) . "' AND user_archived_at IS NULL AND user_id <> $user_id"))) {
$error = '<strong>' . $row['user_email'] . '</strong> signs in to another account now - change that one\'s email first.';
}
if (!$role || $error !== '') {
flashAlert($error, 'error');
redirect();
}
logAudit("User", "Restored", "$session_name restored user $user_name", 0, $user_id);
mysqli_query($mysqli, "UPDATE users SET user_name = '" . escapeSql($name) . "', user_status = 1, user_role_id = " . intval($role['role_id']) . ", user_archived_at = NULL WHERE user_id = $user_id");
userSetPassword($user_id, $password);
flashAlert("User <strong>$user_name</strong> restored");
logAudit("User", "Restore", "$session_name restored user " . escapeSql($name), 0, $user_id);
flashAlert("User <strong>$name</strong> restored - give them their new password");
redirect();
}
if (isset($_POST['bulk_role_users'])) {
validateCSRFToken();
$error = '';
$role = userCheckRole($_POST['role'] ?? 0, 0, $error);
if (!$role) {
flashAlert($error, 'error');
redirect();
}
$count = 0;
$kept = [];
foreach (usersForBulk() as $user_id => $row) {
if (intval($row['role_is_admin']) && !intval($role['role_is_admin']) && userIsLastAdmin($row)) {
$kept[] = $row['user_name'];
continue;
}
mysqli_query($mysqli, "UPDATE users SET user_role_id = " . intval($role['role_id']) . " WHERE user_id = $user_id");
logAudit("User", "Edit", "$session_name set the role of user " . escapeSql($row['user_name']) . " to " . escapeSql($role['role_name']), 0, $user_id);
$count++;
}
flashAlert("<strong>$count</strong> user" . ($count === 1 ? '' : 's') . " now " . ($count === 1 ? 'has' : 'have') . " the <strong>" . $role['role_name'] . "</strong> role" . ($kept ? ' - ' . implode(', ', $kept) . ' kept theirs, as the only admin' : ''), $kept ? 'warning' : 'success');
redirect();
}
if (isset($_POST['bulk_require_mfa_users']) || isset($_POST['bulk_unrequire_mfa_users'])) {
validateCSRFToken();
$force = isset($_POST['bulk_require_mfa_users']) ? 1 : 0;
$count = 0;
foreach (usersForBulk() as $user_id => $row) {
mysqli_query($mysqli, "INSERT INTO user_settings SET user_id = $user_id, user_config_force_mfa = $force ON DUPLICATE KEY UPDATE user_config_force_mfa = $force");
logAudit("User", "Edit", "$session_name " . ($force ? 'required' : 'stopped requiring') . " MFA for user " . escapeSql($row['user_name']), 0, $user_id);
$count++;
}
flashAlert(($force ? "MFA required for" : "MFA no longer required for") . " <strong>$count</strong> user" . ($count === 1 ? '' : 's') . ($force ? ' - anyone without it sets it up at their next sign in' : ''));
redirect();
}
if (isset($_POST['bulk_revoke_remember_users'])) {
validateCSRFToken();
$count = 0;
foreach (usersForBulk() as $user_id => $row) {
mysqli_query($mysqli, "DELETE FROM remember_tokens WHERE remember_token_user_id = $user_id");
logAudit("User", "Edit", "$session_name revoked all remember me tokens for user " . escapeSql($row['user_name']), 0, $user_id);
$count++;
}
flashAlert("Remembered browsers forgotten for <strong>$count</strong> user" . ($count === 1 ? '' : 's'));
redirect();
}
if (isset($_POST['bulk_disable_users']) || isset($_POST['bulk_activate_users'])) {
validateCSRFToken();
$activate = isset($_POST['bulk_activate_users']);
$count = 0;
$kept = [];
foreach (usersForBulk() as $user_id => $row) {
if ($row['user_archived_at'] || intval($row['user_status']) === ($activate ? 1 : 0)) {
continue;
}
if ($activate && agentUserLimitReached()) {
$kept[] = $row['user_name'];
continue;
}
if (!$activate && userIsLastAdmin($row)) {
$kept[] = $row['user_name'];
continue;
}
mysqli_query($mysqli, "UPDATE users SET user_status = " . ($activate ? 1 : 0) . " WHERE user_id = $user_id");
if (!$activate) {
userHandOverWork($user_id, 0, 'when ' . escapeSql($row['user_name']) . ' was disabled');
}
logAudit("User", $activate ? "Activate" : "Disable", "$session_name " . ($activate ? 'activated' : 'disabled') . " user " . escapeSql($row['user_name']), 0, $user_id);
$count++;
}
$why = $activate ? 'the agent user limit is reached' : 'the only admin';
flashAlert(($activate ? 'Activated' : 'Disabled') . " <strong>$count</strong> user" . ($count === 1 ? '' : 's') . ($kept ? ' - not ' . implode(', ', $kept) . " ($why)" : ''), $kept ? 'warning' : ($activate ? 'success' : 'error'));
redirect();
@@ -359,7 +404,6 @@ if (isExportRequest('export_users')) {
// Filters inherited from the users page - mirrors admin/users.php
$filter_summary = [];
// Archived Filter
if (isset($_POST['archived']) && $_POST['archived'] == 1) {
$archive_query = "user_archived_at IS NOT NULL";
$filter_summary['Archived'] = 'Archived only';
@@ -367,23 +411,33 @@ if (isExportRequest('export_users')) {
$archive_query = "user_archived_at IS NULL";
}
// Search Filter
$q = escapeSql($_POST['q'] ?? '');
if (!empty($q)) {
if ($q !== '') {
$filter_summary['Search'] = $_POST['q'];
}
$sql = mysqli_query(
$mysqli,
"SELECT role_name, user_created_at, user_email, user_name, user_status FROM users
LEFT JOIN user_roles ON user_role_id = role_id
WHERE (user_name LIKE '%$q%' OR user_email LIKE '%$q%')
AND user_type = 1
AND $archive_query
ORDER BY user_name ASC"
);
$role_query = '';
$role = userRoles()[intval($_POST['role'] ?? 0)] ?? null;
if ($role) {
$role_query = 'AND user_role_id = ' . intval($role['role_id']);
$filter_summary['Role'] = $role['role_name'];
}
$num_rows = mysqli_num_rows($sql);
$rows = [];
$sql = mysqli_query($mysqli, "SELECT users.user_id, role_name, role_is_admin, user_created_at, user_email, user_name, user_status, user_token, user_config_force_mfa FROM users
LEFT JOIN user_roles ON user_role_id = role_id
LEFT JOIN user_settings ON user_settings.user_id = users.user_id
WHERE (user_name LIKE '%$q%' OR user_email LIKE '%$q%' OR role_name LIKE '%$q%')
AND user_type = 1
AND $archive_query $role_query
ORDER BY user_name ASC");
while ($row = mysqli_fetch_assoc($sql)) {
$rows[intval($row['user_id'])] = $row;
}
$logins = userLastLogins(array_keys($rows));
$access = userClientAccess(array_keys($rows));
$num_rows = count($rows);
if ($num_rows > 0) {
@@ -391,17 +445,11 @@ if (isExportRequest('export_users')) {
$export = beginExport('users', $format, "$session_company_name-Users", 'Users', summarizeExportFilters($filter_summary));
while ($row = mysqli_fetch_assoc($sql)) {
$user_status = intval($row['user_status']);
if ($user_status == 2) {
$row['user_status_display'] = "Invited";
} elseif ($user_status == 1) {
$row['user_status_display'] = "Active";
} else {
$row['user_status_display'] = "Disabled";
}
foreach ($rows as $user_id => $row) {
$row['user_status_display'] = USER_STATUSES[intval($row['user_status'])][0] ?? 'Disabled';
$row['user_mfa_display'] = trim((string) $row['user_token']) !== '' ? 'On' : (intval($row['user_config_force_mfa']) ? 'Required, not set up' : 'Off');
$row['user_last_login'] = $logins[$user_id]['log_created_at'] ?? 'Never';
$row['user_client_access'] = userClientAccessText($access[$user_id], intval($row['role_is_admin']));
addExportRow($export, $row);
}
@@ -416,40 +464,77 @@ if (isExportRequest('export_users')) {
if (isset($_POST['ir_reset_user_password'])) {
// Incident response: allow mass reset of agent passwords
// Incident response: every other agent gets a new password, shown once here
validateCSRFToken();
// Confirm logged-in user password, for security
$admin_password = $_POST['admin_password'];
$sql = mysqli_query($mysqli, "SELECT user_password FROM users WHERE user_id = $session_user_id");
$userRow = mysqli_fetch_assoc($sql);
if (!password_verify($admin_password, $userRow['user_password'])) {
flashAlert("Incorrect password.", 'error');
$user_row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT user_password FROM users WHERE user_id = $session_user_id"));
if (!password_verify((string) ($_POST['admin_password'] ?? ''), $user_row['user_password'])) {
flashAlert("Incorrect password - nothing was reset.", 'error');
redirect();
}
// Get agents/users, other than the current user
$sql_users = mysqli_query($mysqli, "SELECT user_email, user_id FROM users WHERE (user_archived_at IS NULL AND user_id != $session_user_id)");
// Reset passwords
// Agents only - client portal logins have their own reset, and no vault key to rewrite
$reset = [];
$sql_users = mysqli_query($mysqli, "SELECT user_id, user_name, user_email FROM users WHERE user_type = 1 AND user_archived_at IS NULL AND user_id <> $session_user_id ORDER BY user_name");
while ($row = mysqli_fetch_assoc($sql_users)) {
$user_id = intval($row['user_id']);
$user_email = escapeSql($row['user_email']);
$new_password = randomString();
$user_specific_encryption_ciphertext = encryptUserSpecificKey(trim($new_password));
echo $user_email . " -- " . $new_password; // Show
$new_password = password_hash($new_password, PASSWORD_DEFAULT);
mysqli_query($mysqli, "UPDATE users SET user_password = '$new_password', user_specific_encryption_ciphertext = '$user_specific_encryption_ciphertext' WHERE user_id = $user_id");
echo "<br><br>";
$new_password = generateReadablePassword(4);
userSetPassword($row['user_id'], $new_password);
// A remembered browser would skip their MFA code - in an incident, it must not
mysqli_query($mysqli, "DELETE FROM remember_tokens WHERE remember_token_user_id = " . intval($row['user_id']));
$reset[] = $row + ['password' => $new_password];
}
logAudit("User", "Edit", "$session_name reset ALL user passwords");
logAudit("User", "Edit", "$session_name reset the passwords of " . count($reset) . " agent(s) and forgot their remembered browsers (incident response)");
exit; // Stay on the plain text password page
// Shown once, not kept - a plain page to read out or copy, and nothing else on it
header('Cache-Control: no-store');
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>Incident response - new agent passwords</title>
<link rel="stylesheet" href="/libs/fontawesome-free/css/all.min.css">
<link rel="stylesheet" href="/libs/adminlte/css/adminlte.min.css">
</head>
<body class="bg-body-tertiary">
<div class="container py-4" style="max-width: 52rem">
<div class="alert alert-danger d-flex gap-3">
<i class="fas fa-skull-crossbones fa-2x"></i>
<div>
<div class="fw-semibold">New passwords for <?= count($reset) ?> agent<?= count($reset) === 1 ? '' : 's' ?> - shown once</div>
<div class="small">Give each one to its agent out of band (a phone call, not email), and have them change it. Their remembered browsers were forgotten, so MFA is asked for again. Leave this page and the passwords are gone.</div>
</div>
</div>
<div class="card">
<table class="table table-hover align-middle mb-0">
<thead class="table-light"><tr><th>Agent</th><th>Email</th><th>New password</th></tr></thead>
<tbody>
<?php foreach ($reset as $row) { ?>
<tr>
<td class="fw-semibold"><?= escapeHtml($row['user_name']) ?></td>
<td><?= escapeHtml($row['user_email']) ?></td>
<td class="font-monospace user-select-all"><?= escapeHtml($row['password']) ?></td>
</tr>
<?php } ?>
<?php if (!$reset) { ?>
<tr><td colspan="3" class="text-body-secondary">There are no other agents.</td></tr>
<?php } ?>
</tbody>
</table>
</div>
<div class="mt-3 d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" onclick="window.print()"><i class="fas fa-print me-2"></i>Print</button>
<a class="btn btn-primary" href="/admin/users.php"><i class="fas fa-arrow-left me-2"></i>Back to Users</a>
</div>
</div>
</body>
</html>
<?php
exit;
}

View File

@@ -1,260 +1,386 @@
<?php
/*
* Administration > Users - the agents who sign in to ITFlow. Each row is who
* they are, their role, whether they can sign in and how (MFA, browsers that
* skip the code), the clients they can see and when they last signed in. The
* pills split them: everyone, admins, no MFA, disabled - and the archived.
*
* The admin's own row is shown but not changed here: that is their Account
* pages, so nobody locks themselves out by mistake.
*/
// Default Column Sortby Filter
$sort = "user_name";
$order = "ASC";
require_once "includes/inc_all_admin.php";
$sql = mysqli_query(
$mysqli,
"SELECT SQL_CALC_FOUND_ROWS role_name, user_archived_at, user_avatar, user_config_force_mfa, user_email,
user_settings.user_id, user_name, user_role_id, user_status, user_token FROM users
LEFT JOIN user_roles ON user_role_id = role_id
LEFT JOIN user_settings ON users.user_id = user_settings.user_id
WHERE (user_name LIKE '%$q%' OR user_email LIKE '%$q%')
AND user_type = 1
AND user_$archive_query
ORDER BY $sort $order LIMIT $record_from, $record_to"
);
// filter_header.php fills in canned_date=custom when no range was asked for - not a filter here, so not carried into links
if (($_GET['canned_date'] ?? '') === 'custom' && empty($_GET['dtf'])) {
unset($_GET['canned_date']);
}
$roles = userRoles();
$role_filter = isset($roles[intval($_GET['role'] ?? 0)]) ? intval($_GET['role']) : 0;
$filters = "user_type = 1 AND (user_name LIKE '%$q%' OR user_email LIKE '%$q%' OR role_name LIKE '%$q%')" . ($role_filter ? " AND user_role_id = $role_filter" : '');
$joins = "LEFT JOIN user_roles ON user_role_id = role_id
LEFT JOIN user_settings ON user_settings.user_id = users.user_id";
// The views
$shows = [
'' => ['label' => 'Users', 'icon' => 'fa-users', 'where' => 'user_archived_at IS NULL', 'title' => 'Everyone not archived'],
'admins' => ['label' => 'Admins', 'icon' => 'fa-user-shield', 'where' => 'user_archived_at IS NULL AND role_is_admin = 1', 'title' => 'Every client and every setting'],
'nomfa' => ['label' => 'No MFA', 'icon' => 'fa-unlock', 'where' => "user_archived_at IS NULL AND user_status = 1 AND (user_token IS NULL OR user_token = '')", 'title' => 'Active, and signing in with a password alone'],
'disabled' => ['label' => 'Disabled', 'icon' => 'fa-ban', 'where' => 'user_archived_at IS NULL AND user_status <> 1', 'title' => 'Cannot sign in'],
'archived' => ['label' => 'Archived', 'icon' => 'fa-archive', 'where' => 'user_archived_at IS NOT NULL', 'title' => 'Gone - restore one with a new password'],
];
$show = isset($shows[$_GET['show'] ?? '']) ? ($_GET['show'] ?? '') : '';
// Only these sort - an unknown column would take the page down
$last_login_sql = "(SELECT MAX(log_id) FROM logs WHERE log_user_id = users.user_id AND log_type = 'Login' AND log_action = 'Success')";
$sortable_columns = [
'user_name' => "user_name $order",
'user_email' => "user_email $order",
'role_name' => "role_is_admin DESC, role_name $order, user_name ASC",
'user_status' => "user_status $order, user_name ASC",
'last_login' => "$last_login_sql IS NULL, $last_login_sql $order, user_name ASC",
'user_created_at' => "user_created_at $order, user_name ASC",
];
if (!isset($sortable_columns[$sort])) {
$sort = 'user_name';
}
$sql = mysqli_query($mysqli, "SELECT SQL_CALC_FOUND_ROWS users.user_id, user_name, user_email, user_status, user_token, user_avatar, user_created_at, user_archived_at,
user_role_id, role_name, role_is_admin, user_config_force_mfa
FROM users $joins
WHERE $filters AND {$shows[$show]['where']}
ORDER BY {$sortable_columns[$sort]}, users.user_id ASC
LIMIT $record_from, $record_to");
$num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
$users = [];
while ($row = mysqli_fetch_assoc($sql)) {
$users[intval($row['user_id'])] = $row;
}
$logins = userLastLogins(array_keys($users));
$access = userClientAccess(array_keys($users));
$remembered = userRememberCounts(array_keys($users));
// How many in each view, over what the search and role leave
$view_counts = [];
$row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT " . implode(', ', array_map(function ($s) {
return "COALESCE(SUM({$s['where']}), 0)";
}, $shows)) . " FROM users $joins WHERE $filters"));
foreach (array_keys($shows) as $i => $key) {
$view_counts[$key] = intval($row[$i]);
}
// Agent user limit (set in config.php, 0 / unset = unlimited)
$agent_user_limit = getAgentUserLimit();
$agent_user_count = getActiveAgentUserCount();
$agent_user_limit_reached = $agent_user_limit > 0 && $agent_user_count >= $agent_user_limit;
$filter_url = function ($changes) {
$params = array_merge($_GET, $changes);
unset($params['page']);
return '?' . http_build_query(array_filter($params, function ($v) { return $v !== '' && $v !== null; }));
};
$sort_link = function ($column, $label) use ($url_query_strings_sort, $sort, $disp, $order_icon) {
return '<a class="text-reset text-decoration-none" href="?' . escapeHtml($url_query_strings_sort) . '&amp;sort=' . $column . '&amp;order=' . $disp . '">'
. $label . ($sort === $column ? " $order_icon" : '') . '</a>';
};
$csrf = $_SESSION['csrf_token'];
?>
<div class="card">
<div class="card-header bg-dark py-2">
<h3 class="card-title mt-2"><i class="fas fa-fw fa-users me-2"></i>Users
<?php if ($agent_user_limit > 0) { ?>
<span class="badge text-bg-<?= $agent_user_limit_reached ? 'danger' : 'secondary' ?> ms-2" title="Active agent users / agent user limit">
<?= $agent_user_count ?> of <?= $agent_user_limit ?> agent users
<span class="badge rounded-pill text-bg-<?= $agent_user_limit_reached ? 'danger' : 'secondary' ?> fw-normal ms-2" title="Active agents, of the agent user limit set for this installation">
<?= $agent_user_count ?> of <?= $agent_user_limit ?> seats
</span>
<?php } ?>
</h3>
<div class="card-tools">
<div class="btn-group">
<?php if ($agent_user_limit_reached) { ?>
<button type="button" class="btn btn-primary" disabled title="Agent user limit reached">
<i class="fas fa-fw fa-user-plus me-2"></i>New User
<button type="button" class="btn btn-primary" disabled title="Every seat is taken - disable or archive a user first">
<i class="fas fa-user-plus"></i><span class="d-none d-lg-inline ms-2">New User</span>
</button>
<?php } else { ?>
<button type="button" class="btn btn-primary ajax-modal" data-modal-url="modals/user/user_add.php">
<i class="fas fa-fw fa-user-plus me-2"></i>New User
<button type="button" class="btn btn-primary ajax-modal" data-modal-size="lg" data-modal-url="modals/user/user_add.php?role=<?= $role_filter ?>">
<i class="fas fa-user-plus"></i><span class="d-none d-lg-inline ms-2">New User</span>
</button>
<?php } ?>
<button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown"></button>
<div class="dropdown-menu">
<!--<a class="dropdown-item text-dark ajax-modal" href="#" data-modal-url="modals/user/user_invite.php"><i class="fas fa-paper-plane me-2"></i>Invite User</a>-->
<?php if ($num_rows[0] > 1) { ?>
<a class="dropdown-item text-dark ajax-modal" href="#"
data-modal-url="<?= buildExportModalUrl('modals/user/user_export.php', ['archived', 'q']) ?>">
<i class="fa fa-fw fa-download me-2"></i>Export
</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item text-danger ajax-modal" href="#"
data-modal-url="modals/user/user_all_reset_password.php"
data-modal-size="lg">
<i class="fas fa-skull-crossbones me-2"></i>IR
</a>
<?php } ?>
<button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-label="More"></button>
<div class="dropdown-menu dropdown-menu-end">
<a class="dropdown-item ajax-modal" href="#" data-modal-url="<?= buildExportModalUrl('modals/user/user_export.php', ['q', 'role'], ['archived' => $show === 'archived' ? 1 : null]) ?>">
<i class="fa fa-fw fa-download text-secondary me-2"></i>Export
</a>
<a class="dropdown-item" href="roles.php">
<i class="fa fa-fw fa-user-tag text-secondary me-2"></i>Roles
</a>
<a class="dropdown-item" href="settings_security.php">
<i class="fa fa-fw fa-shield-alt text-secondary me-2"></i>Security settings
</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item text-danger ajax-modal" href="#" data-modal-url="modals/user/user_all_reset_password.php">
<i class="fas fa-fw fa-skull-crossbones me-2"></i>Incident response
</a>
</div>
</div>
</div>
</div>
<div class="card-header py-3">
<form autocomplete="off">
<input type="hidden" name="show" value="<?= escapeHtml($show) ?>">
<div class="row g-2 align-items-center">
<div class="col-md-4">
<div class="col-lg-4 col-md-6">
<div class="input-group">
<input type="search" class="form-control" name="q" value="<?php if (isset($q)) {echo stripslashes(escapeHtml($q));} ?>" placeholder="Search Users">
<button class="btn btn-primary" title="Search"><i class="fa fa-search"></i></button>
<input type="search" class="form-control" name="q" value="<?= escapeHtml($_GET['q'] ?? '') ?>" placeholder="Search users..." title="Name, email or role">
<button class="btn btn-dark" title="Search"><i class="fa fa-search"></i></button>
</div>
</div>
<div class="col-md-8">
<div class="btn-group float-end">
<a href="?archived=<?php if($archived == 1){ echo 0; } else { echo 1; } ?>"
class="btn btn-<?php if($archived == 1){ echo"primary"; } else { echo "default"; } ?>">
<i class="fa fa-fw fa-archive me-2"></i>Archived
</a>
<div class="col-lg-3 col-md-6">
<select class="form-select select2" name="role" onchange="this.form.submit()">
<option value="">- Any role -</option>
<?php foreach ($roles as $role_id => $role) { ?>
<option value="<?= $role_id ?>" <?= $role_filter === $role_id ? 'selected' : '' ?><?= selectOptionRole($role) ?>><?= escapeHtml($role['role_name']) ?></option>
<?php } ?>
</select>
</div>
<div class="col-lg-5 col-md-12">
<div class="d-flex justify-content-end gap-2">
<div class="dropdown" id="bulkActionButton" hidden>
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
<i class="fas fa-fw fa-layer-group me-2"></i>Bulk Action (<span id="selectedCount">0</span>)
</button>
<div class="dropdown-menu dropdown-menu-end">
<a class="dropdown-item ajax-modal" href="#" data-modal-url="modals/user/user_bulk_role.php" data-bulk="true">
<i class="fas fa-fw fa-user-tag text-secondary me-2"></i>Role
</a>
<button class="dropdown-item" type="submit" form="bulkActions" name="bulk_require_mfa_users">
<i class="fas fa-fw fa-lock text-secondary me-2"></i>Require MFA
</button>
<button class="dropdown-item" type="submit" form="bulkActions" name="bulk_unrequire_mfa_users">
<i class="fas fa-fw fa-lock-open text-secondary me-2"></i>Stop requiring MFA
</button>
<button class="dropdown-item" type="submit" form="bulkActions" name="bulk_revoke_remember_users">
<i class="fas fa-fw fa-laptop text-secondary me-2"></i>Forget remembered browsers
</button>
<?php if ($show !== 'archived') { ?>
<div class="dropdown-divider"></div>
<button class="dropdown-item" type="submit" form="bulkActions" name="bulk_activate_users">
<i class="fas fa-fw fa-user-check text-secondary me-2"></i>Activate
</button>
<button class="dropdown-item text-danger confirm-link" type="submit" form="bulkActions" name="bulk_disable_users"
data-confirm-text="They are signed out and cannot sign in, and their open tickets and recurring tickets are unassigned. Activating them again does not give those back."
data-confirm-button="Disable">
<i class="fas fa-fw fa-user-slash me-2"></i>Disable
</button>
<?php } ?>
</div>
</div>
</div>
</div>
</div>
</form>
<nav class="nav nav-pills itflow-asset-tabs mt-2 flex-nowrap overflow-auto">
<?php foreach ($shows as $key => $view) {
if ($key === 'archived') { ?><span class="vr mx-1 my-1"></span><?php } ?>
<a class="nav-link text-nowrap <?= $show === $key ? 'active' : '' ?>" href="<?= escapeHtml($filter_url(['show' => $key === '' || $show === $key ? null : $key])) ?>" title="<?= escapeHtml($view['title']) ?>">
<i class="fas fa-fw <?= $view['icon'] ?> me-1"></i><?= $view['label'] ?> <span class="badge rounded-pill ms-1"><?= $view_counts[$key] ?></span>
</a>
<?php } ?>
</nav>
</div>
<div class="table-responsive-sm">
<table class="table table-striped table-borderless table-hover mb-0">
<thead class="text-dark <?php if ($num_rows[0] == 0) { echo "d-none"; } ?>">
<tr>
<th class="text-center">
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=user_name&order=<?= $disp ?>">
Name <?php if ($sort == 'user_name') { echo $order_icon; } ?>
</a>
</th>
<th>
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=user_email&order=<?= $disp ?>">
Email <?php if ($sort == 'user_email') { echo $order_icon; } ?>
</a>
</th>
<th>
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=role_name&order=<?= $disp ?>">
Role <?php if ($sort == 'role_name') { echo $order_icon; } ?>
</a>
</th>
<th>
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=user_status&order=<?= $disp ?>">
Status <?php if ($sort == 'user_status') { echo $order_icon; } ?>
</a>
</th>
<th class="text-center">MFA</th>
<th>
Last Login
</th>
<th class="text-center">Action</th>
</tr>
</thead>
<tbody>
<?php
while ($row = mysqli_fetch_assoc($sql)) {
$user_id = intval($row['user_id']);
$user_name = escapeHtml($row['user_name']);
$user_email = escapeHtml($row['user_email']);
$user_status = intval($row['user_status']);
if ($user_status == 2) {
$user_status_display = "<span class='text-info'>Invited</span>";
} elseif ($user_status == 1) {
$user_status_display = "<span class='text-success'>Active</span>";
} else{
$user_status_display = "<span class='text-danger'>Disabled</span>";
}
$user_avatar = escapeHtml($row['user_avatar']);
$user_token = escapeHtml($row['user_token']);
if(empty($user_token)) {
$mfa_status_display = "<i class='fas fa-fw fa-unlock text-danger'></i>";
} else {
$mfa_status_display = "<i class='fas fa-fw fa-lock text-success'></i>";
}
$user_config_force_mfa = intval($row['user_config_force_mfa']);
$user_role = intval($row['user_role_id']);
$user_role_display = escapeHtml($row['role_name']);
$user_archived_at = escapeHtml($row['user_archived_at']);
$user_initials = escapeHtml(initials($user_name));
<form id="bulkActions" action="post.php" method="post">
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
$sql_last_login = mysqli_query(
$mysqli,
"SELECT log_created_at, log_ip, log_user_agent FROM logs
WHERE log_user_id = $user_id AND log_type = 'Login'
ORDER BY log_id DESC LIMIT 1"
);
if (mysqli_num_rows($sql_last_login) == 0) {
$last_login = "<span class='text-bold'>Never logged in</span>";
} else {
$row = mysqli_fetch_assoc($sql_last_login);
$log_created_at = escapeHtml($row['log_created_at']);
$log_ip = escapeHtml($row['log_ip']);
$log_user_agent = escapeHtml($row['log_user_agent']);
$log_user_os = getOS($log_user_agent);
$log_user_browser = getWebBrowser($log_user_agent);
$last_login = "$log_created_at<small class='text-secondary'><div class='mt-1'>$log_user_os</div><div class='mt-1'>$log_user_browser</div><div class='mt-1'><i class='fa fa-fw fa-globe'></i> $log_ip</div></small>";
}
// Get User Client Access Permissions
$user_client_access_sql = mysqli_query($mysqli,"SELECT client_id FROM user_client_permissions WHERE user_id = $user_id");
$client_access_array = [];
while ($row = mysqli_fetch_assoc($user_client_access_sql)) {
$client_access_array[] = intval($row['client_id']);
}
$sql_remember_tokens = mysqli_query($mysqli, "SELECT 1 FROM remember_tokens WHERE remember_token_user_id = $user_id");
$remember_token_count = mysqli_num_rows($sql_remember_tokens);
?>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0 itflow-asset-table itflow-ticket-list itflow-user-list">
<thead class="<?php if (!$users) { echo "d-none"; } ?> text-nowrap table-light">
<tr>
<td class="text-center">
<a href="#" title="UserID: <?= $user_id ?>"
<?php if ($user_id !== $session_user_id) { // Prevent modifying self ?>
class="ajax-modal"
data-modal-url="modals/user/user_edit.php?id=<?= $user_id ?>"
<?php } ?>
>
<?php if (!empty($user_avatar)) { ?>
<img class="img-size-50 rounded-circle" src="<?= "../uploads/users/$user_id/$user_avatar" ?>">
<?php } else { ?>
<span class="fa-stack fa-2x">
<i class="fa fa-circle fa-stack-2x text-secondary"></i>
<span class="fa fa-stack-1x text-white"><?= $user_initials ?></span>
</span>
<br>
<?php } ?>
<div class="text-secondary"><?= $user_name ?></div>
</a>
</td>
<td><a href="mailto:<?= $user_email ?>"><?= $user_email ?></a></td>
<td><?= $user_role_display ?></td>
<td><?= $user_status_display ?></td>
<td class="text-center"><?= $mfa_status_display ?></td>
<td><?= $last_login ?></td>
<td>
<?php if ($user_id !== $session_user_id) { // Prevent modifying self ?>
<div class="dropdown dropstart text-center">
<button class="btn btn-secondary btn-sm" title="More actions" type="button" 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/user/user_edit.php?id=<?= $user_id ?>">
<i class="fas fa-fw fa-user-edit me-2"></i>Edit
</a>
<?php if ($remember_token_count > 0) { ?>
<a class="dropdown-item" href="post.php?revoke_remember_me=<?= $user_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>"><i class="fas fa-fw fa-ban me-2"></i>Revoke <?= $remember_token_count ?> Remember Tokens
</a>
<?php } ?>
<?php if ($user_status == 0) { ?>
<a class="dropdown-item text-success" href="post.php?activate_user=<?= $user_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-user-check me-2"></i>Activate
</a>
<?php }elseif ($user_status == 1) { ?>
<a class="dropdown-item text-danger confirm-link" data-confirm-text="Their open tickets and recurring tickets will be unassigned. Activating them again does not reassign anything." data-confirm-button="Disable" href="post.php?disable_user=<?= $user_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-user-slash me-2"></i>Disable
</a>
<?php } ?>
<?php if ($user_archived_at) { ?>
<div class="dropdown-divider"></div>
<a class="dropdown-item text-info ajax-modal" href="#" data-modal-url="modals/user/user_restore.php?id=<?= $user_id ?>">
<i class="fas fa-fw fa-redo-alt me-2"></i>Restore
</a>
<th class="checkbox-column border-end">
<div class="form-check">
<input class="form-check-input" id="selectAllCheckbox" type="checkbox" onclick="checkAll(this)" title="Select all on this page">
</div>
</th>
<th><?= $sort_link('user_name', 'User') ?> <span class="fw-normal text-body-secondary small ms-2"><?= $sort_link('user_email', 'by email') ?></span></th>
<th class="d-none d-md-table-cell"><?= $sort_link('role_name', 'Role') ?></th>
<th class="d-none d-md-table-cell"><?= $sort_link('user_status', 'Sign in') ?></th>
<th class="d-none d-xl-table-cell">Clients</th>
<th class="d-none d-lg-table-cell"><?= $sort_link('last_login', 'Last signed in') ?></th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($users as $user_id => $row) {
$is_self = $user_id === intval($session_user_id);
$is_archived = !empty($row['user_archived_at']);
$is_active = intval($row['user_status']) === 1 && !$is_archived;
$is_admin = intval($row['role_is_admin']) === 1;
$has_mfa = trim((string) $row['user_token']) !== '';
$display_name = $is_archived ? trim(str_replace(' (archived)', '', $row['user_name'])) : $row['user_name'];
$name = escapeHtml($display_name);
$quick_url = "modals/user/user.php?id=$user_id";
$user_access = $access[$user_id];
$access_title = $is_admin ? 'Admins see every client' : trim(
($user_access['allow'] ? 'Only: ' . implode(', ', $user_access['allow']) . '. ' : '')
. ($user_access['deny'] ? 'Kept out of: ' . implode(', ', $user_access['deny']) . '.' : '')
);
$remember_count = $remembered[$user_id] ?? 0;
?>
<tr class="itflow-ticket-row <?= !$has_mfa && $is_active ? 'is-warning' : '' ?>">
<td class="checkbox-column bg-light border-end">
<div class="form-check">
<?php if ($is_self) { ?>
<input class="form-check-input" type="checkbox" disabled title="That's you - change your own account from Account">
<?php } else { ?>
<div class="dropdown-divider"></div>
<a class="dropdown-item text-danger ajax-modal" href="#" data-modal-url="modals/user/user_archive.php?id=<?= $user_id ?>">
<i class="fas fa-fw fa-archive me-2"></i>Archive
</a>
<input class="form-check-input bulk-select" type="checkbox" name="user_ids[]" value="<?= $user_id ?>" aria-label="Select <?= $name ?>">
<?php } ?>
</div>
</div>
<?php } ?>
</td>
</tr>
</td>
<?php
}
<!-- Who -->
<td class="itflow-ticket-main itflow-user-main">
<div class="d-flex align-items-center gap-3">
<a class="ajax-modal text-decoration-none <?= $is_active ? '' : 'opacity-50' ?>" href="#" data-modal-url="<?= $quick_url ?>" tabindex="-1" aria-hidden="true"><?= userAvatar($user_id, $display_name, $row['user_avatar']) ?></a>
<div class="min-w-0">
<a class="ajax-modal <?= $is_active ? 'fw-semibold text-body' : 'text-body-secondary' ?>" href="#" data-modal-url="<?= $quick_url ?>"><?= $name ?></a>
<?php if ($is_self) { ?><span class="badge rounded-pill bg-primary-subtle text-primary-emphasis fw-normal ms-1">You</span><?php } ?>
<div class="small text-body-secondary text-truncate">
<?= escapeHtml($row['user_email']) ?><button type="button" class="itflow-copy clipboardjs" title="Copy email" data-clipboard-text="<?= escapeHtml($row['user_email']) ?>"><i class="far fa-copy"></i></button>
</div>
<!-- On a phone the role and sign in columns are off to the side - they come along -->
<div class="d-md-none small text-body-secondary"><i class="fas fa-fw <?= $is_admin ? 'fa-user-shield' : 'fa-user-tag' ?> me-1"></i><?= escapeHtml($row['role_name'] ?? 'No role') ?></div>
<div class="d-md-none d-flex flex-wrap gap-1 mt-1">
<?= userStatusPill($row['user_status'], $is_archived) ?>
<?php if (!$is_archived) { ?><?= userMfaPill($row['user_token'], $row['user_config_force_mfa']) ?><?php } ?>
</div>
</div>
</div>
</td>
?>
<td class="d-none d-md-table-cell text-nowrap">
<?php if ($row['role_name'] === null) { ?>
<span class="text-danger-emphasis small"><i class="fas fa-exclamation-triangle me-1"></i>No role</span>
<?php } else { ?>
<i class="fas fa-fw <?= $is_admin ? 'fa-user-shield text-primary' : 'fa-user-tag text-body-secondary' ?> me-1"></i><?= escapeHtml($row['role_name']) ?>
<?php } ?>
</td>
</tbody>
</table>
</div>
<?php require_once "../includes/filter_footer.php";
?>
<!-- Can they sign in, and how -->
<td class="d-none d-md-table-cell">
<div class="d-flex flex-wrap gap-1">
<?= userStatusPill($row['user_status'], $is_archived) ?>
<?php if (!$is_archived) { ?><?= userMfaPill($row['user_token'], $row['user_config_force_mfa']) ?><?php } ?>
</div>
<?php if ($remember_count && !$is_archived) { ?>
<div class="small text-body-secondary mt-1 text-nowrap" title="Browsers that skip the MFA code - remember me, at sign in"><i class="fas fa-fw fa-laptop me-1"></i><?= $remember_count ?> remembered browser<?= $remember_count === 1 ? '' : 's' ?></div>
<?php } ?>
<?php if ($is_archived) { ?>
<div class="small text-body-secondary mt-1" title="<?= escapeHtml($row['user_archived_at']) ?>"><?= escapeHtml(timeAgo($row['user_archived_at'])) ?></div>
<?php } ?>
</td>
<td class="d-none d-xl-table-cell small <?= $is_admin ? 'text-body-secondary' : '' ?>" title="<?= escapeHtml($access_title) ?>">
<?php if (!$is_admin && ($user_access['allow'] || $user_access['deny'])) { ?><i class="fas fa-fw fa-filter text-warning me-1"></i><?php } ?><?= escapeHtml(userClientAccessText($user_access, $is_admin)) ?>
</td>
<td class="d-none d-lg-table-cell small"><?= userLoginHtml($logins[$user_id] ?? null) ?></td>
<td class="text-end">
<div class="dropdown dropstart">
<button class="btn btn-sm btn-light" title="More actions" type="button" data-bs-toggle="dropdown"><i class="fas fa-fw fa-ellipsis-v"></i></button>
<div class="dropdown-menu">
<a class="dropdown-item ajax-modal" href="#" data-modal-url="<?= $quick_url ?>">
<i class="fas fa-fw fa-eye text-secondary me-2"></i>Quick look
</a>
<a class="dropdown-item" href="audit_logs.php?user=<?= $user_id ?>">
<i class="fas fa-fw fa-history text-secondary me-2"></i>Audit log
</a>
<?php if ($is_self) { ?>
<a class="dropdown-item" href="/agent/user/user_details.php">
<i class="fas fa-fw fa-user-cog text-secondary me-2"></i>Your account
</a>
<?php } elseif ($is_archived) { ?>
<div class="dropdown-divider"></div>
<a class="dropdown-item ajax-modal" href="#" data-modal-url="modals/user/user_restore.php?id=<?= $user_id ?>">
<i class="fas fa-fw fa-redo-alt text-secondary me-2"></i>Restore
</a>
<?php } else { ?>
<a class="dropdown-item ajax-modal" href="#" data-modal-size="lg" data-modal-url="modals/user/user_edit.php?id=<?= $user_id ?>">
<i class="fas fa-fw fa-user-edit text-secondary me-2"></i>Edit
</a>
<?php if ($has_mfa) { ?>
<a class="dropdown-item confirm-link" href="post.php?reset_user_mfa=<?= $user_id ?>&csrf_token=<?= $csrf ?>"
data-confirm-text="<?= $name ?> signs in with their password alone<?= intval($row['user_config_force_mfa']) ? ' until they set MFA up again, which they are asked to at their next sign in' : ' until they turn MFA on again' ?>. For a lost or replaced phone."
data-confirm-button="Reset MFA">
<i class="fas fa-fw fa-mobile-alt text-secondary me-2"></i>Reset MFA
</a>
<?php } ?>
<?php if ($remember_count) { ?>
<a class="dropdown-item" href="post.php?revoke_remember_me=<?= $user_id ?>&csrf_token=<?= $csrf ?>">
<i class="fas fa-fw fa-laptop text-secondary me-2"></i>Forget <?= $remember_count ?> remembered browser<?= $remember_count === 1 ? '' : 's' ?>
</a>
<?php } ?>
<div class="dropdown-divider"></div>
<?php if ($is_active) { ?>
<a class="dropdown-item text-danger confirm-link" href="post.php?disable_user=<?= $user_id ?>&csrf_token=<?= $csrf ?>"
data-confirm-text="<?= $name ?> is signed out and cannot sign in, and their open tickets and recurring tickets are unassigned. Activating them again does not give those back."
data-confirm-button="Disable">
<i class="fas fa-fw fa-user-slash me-2"></i>Disable
</a>
<?php } else { ?>
<a class="dropdown-item" href="post.php?activate_user=<?= $user_id ?>&csrf_token=<?= $csrf ?>">
<i class="fas fa-fw fa-user-check text-secondary me-2"></i>Activate
</a>
<?php } ?>
<a class="dropdown-item text-danger ajax-modal" href="#" data-modal-url="modals/user/user_archive.php?id=<?= $user_id ?>">
<i class="fas fa-fw fa-archive me-2"></i>Archive
</a>
<?php } ?>
</div>
</div>
</td>
</tr>
<?php } ?>
</tbody>
</table>
</div>
</form>
<?php if (!$users) { ?>
<div class="itflow-asset-empty">
<i class="fas fa-users fa-2x mb-2 opacity-50"></i>
<div>
<?php if ($q !== '' || $role_filter) { ?>
No users match.
<?php } elseif ($show === 'nomfa') { ?>
Everyone active signs in with MFA.
<?php } elseif ($show === 'archived') { ?>
No archived users.
<?php } elseif ($show === 'disabled') { ?>
Nobody is disabled.
<?php } else { ?>
No users here.
<?php } ?>
</div>
</div>
<?php } ?>
<?php require_once "../includes/filter_footer.php"; ?>
</div>
<script src="../js/bulk_actions.js"></script>
<?php
require_once "../includes/footer.php";

View File

@@ -160,7 +160,7 @@ $csrf = $_SESSION['csrf_token'];
<button type="button" class="btn btn-primary dropdown-toggle <?= $can_edit ? 'dropdown-toggle-split' : '' ?>" data-bs-toggle="dropdown"><?= $can_edit ? '' : '<i class="fas fa-ellipsis-h"></i>' ?></button>
<div class="dropdown-menu dropdown-menu-end">
<?php if ($num_rows[0] > 0) { ?>
<a class="dropdown-item text-dark ajax-modal" href="#" data-modal-url="<?= buildExportModalUrl('modals/network/network_export.php', ['client_id', 'client', 'location', 'type', 'q']) ?>&archived=<?= $show === 'archived' ? 1 : 0 ?>">
<a class="dropdown-item text-dark ajax-modal" href="#" data-modal-url="<?= buildExportModalUrl('modals/network/network_export.php', ['client_id', 'client', 'location', 'type', 'q'], ['archived' => $show === 'archived' ? 1 : null]) ?>">
<i class="fa fa-fw fa-download me-2"></i>Export
</a>
<?php } ?>

View File

@@ -0,0 +1,38 @@
<?php
/*
* Setting up MFA - the QR code to scan (drawn here, so the secret never goes
* out in an image URL), the secret to type in when it can't be scanned, and
* the code from the app to prove it worked. Inside the enable_mfa form of
* user_mfa_modal.php and mfa_enforcement.php, which set $mfa_secret.
*/
isset($mysqli) || die("Direct file access is not allowed");
$mfa_qr = userMfaQrSvg($mfa_secret, $session_email);
$mfa_secret_grouped = trim(chunk_split($mfa_secret, 4, ' '));
?>
<ol class="itflow-mfa-steps small text-start ps-3 mb-3">
<li>Open your authenticator app - Microsoft or Google Authenticator, 1Password, Authy, Bitwarden - and add an account.</li>
<li>Scan this code<?= $mfa_qr ? '' : ' - or, as it can\'t be shown here, type the secret in' ?>:</li>
</ol>
<div class="text-center mb-3">
<?php if ($mfa_qr) { ?>
<div class="itflow-mfa-qr-box"><?= $mfa_qr ?></div>
<?php } ?>
<div class="small text-body-secondary mt-2">Can't scan it? Type this secret in:</div>
<div class="itflow-copy-always">
<span class="font-monospace fw-semibold user-select-all"><?= escapeHtml($mfa_secret_grouped) ?></span>
<button type="button" class="itflow-copy clipboardjs" title="Copy secret" data-clipboard-text="<?= escapeHtml($mfa_secret) ?>"><i class="far fa-copy"></i></button>
</div>
</div>
<ol class="itflow-mfa-steps small text-start ps-3 mb-2" start="3">
<li>Enter the 6-digit code it shows:</li>
</ol>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-fw fa-mobile-alt"></i></span>
<input type="text" class="form-control form-control-lg font-monospace text-center itflow-mfa-code" inputmode="numeric" pattern="[0-9]{6}" minlength="6" maxlength="6" name="verify_code" placeholder="000000" autocomplete="one-time-code" required>
</div>

View File

@@ -11,46 +11,48 @@
<!-- Sidebar -->
<div class="sidebar-wrapper">
<!-- Who is signed in -->
<div class="d-flex align-items-center gap-2 px-3 pt-3 pb-1 text-white-50 small min-w-0">
<?= userAvatar($session_user_id, stripslashes($session_name), $session_avatar, 'md') ?>
<div class="min-w-0">
<div class="text-white text-truncate"><?= stripslashes(escapeHtml($session_name)) ?></div>
<div class="text-truncate"><?= escapeHtml($session_email) ?></div>
</div>
</div>
<!-- Sidebar Menu -->
<nav>
<ul class="nav nav-pills sidebar-menu flex-column mt-2" data-lte-toggle="treeview" role="menu" data-accordion="false">
<li class="nav-item">
<a href="/agent/user/user_details.php" class="nav-link <?php if (basename($_SERVER["PHP_SELF"]) == "user_details.php") { echo "active"; } ?>">
<i class="nav-icon fas fa-user"></i>
<p>Details</p>
</a>
</li>
<li class="nav-item mt-2">
<a href="/agent/user/user_security.php" class="nav-link <?php if (basename($_SERVER["PHP_SELF"]) == "user_security.php") { echo "active"; } ?>">
<i class="nav-icon fas fa-shield-alt"></i>
<p>Security</p>
</a>
</li>
<li class="nav-item mt-2">
<a href="/agent/user/user_preferences.php" class="nav-link <?php if (basename($_SERVER["PHP_SELF"]) == "user_preferences.php") { echo "active"; } ?>">
<i class="nav-icon fas fa-cogs"></i>
<p>Preferences</p>
</a>
</li>
<li class="nav-item mt-2">
<a href="/agent/user/user_activity.php" class="nav-link <?php if (basename($_SERVER["PHP_SELF"]) == "user_activity.php") { echo "active"; } ?>">
<i class="nav-icon fas fa-clock"></i>
<p>Activity</p>
</a>
</li>
<?php
$account_pages = [
'user_details.php' => ['fa-id-badge', 'Profile'],
'user_security.php' => ['fa-shield-alt', 'Security'],
'user_preferences.php' => ['fa-sliders-h', 'Preferences'],
'user_activity.php' => ['fa-history', 'Activity'],
];
foreach ($account_pages as $page => [$icon, $label]) { ?>
<li class="nav-item">
<a href="/agent/user/<?= $page ?>" class="nav-link <?= basename($_SERVER["PHP_SELF"]) === $page ? 'active' : '' ?>">
<i class="nav-icon fas <?= $icon ?>"></i>
<p><?= $label ?></p>
</a>
</li>
<?php } ?>
<?php if ($session_is_admin) { ?>
<li class="nav-header">ADMINISTRATION</li>
<li class="nav-item">
<a href="/admin/users.php" class="nav-link">
<i class="nav-icon fas fa-users"></i>
<p>Users</p>
</a>
</li>
<?php } ?>
</ul>
</nav>
<!-- /.sidebar-menu -->
<div class="mb-3"></div>
</div>
<!-- /.sidebar -->
</aside>

View File

@@ -12,13 +12,9 @@ $company_logo = escapeHtml($row['company_logo']);
// Only generate the token once and store it in session:
if (empty($_SESSION['mfa_token'])) {
$token = generateTotpSecret();
$_SESSION['mfa_token'] = $token;
$_SESSION['mfa_token'] = generateTotpSecret();
}
$token = $_SESSION['mfa_token'];
// Generate QR Code
$data = "otpauth://totp/ITFlow:$session_email?secret=$token";
$mfa_secret = $_SESSION['mfa_token'];
?>
@@ -65,26 +61,17 @@ $data = "otpauth://totp/ITFlow:$session_email?secret=$token";
<div class="card">
<div class="card-body login-card-body text-center">
<p class="login-box-msg">Multi-Factor Authentication Enforced</p>
<p class="login-box-msg fw-semibold mb-1">Set up multi-factor authentication</p>
<p class="small text-body-secondary">Your administrator requires it - once it's set up, signing in takes your password and a code from your phone.</p>
<form action="post.php" method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<img src='../../libs/barcode/barcode.php?f=png&s=qr&d=<?= $data ?>' data-bs-toggle="tooltip" title="Scan QR code into your MFA App">
<?php require __DIR__ . '/includes/mfa_setup.php'; ?>
<p class="itflow-copy-always">
<small data-bs-toggle="tooltip" title="Can't Scan? Copy and paste this code into your app"><?= $token ?></small>
<button type='button' class='itflow-copy clipboardjs' title='Copy secret' data-clipboard-text='<?= $token ?>'><i class='far fa-copy'></i></button>
</p>
<div class="input-group mb-3">
<input type="text" class="form-control" inputmode="numeric" pattern="[0-9]*" minlength="6" maxlength="6" name="verify_code" placeholder="Enter 6 digit code to verify MFA" required>
<div class="input-group-text">
<span class="fas fa-lock"></span>
</div>
</div>
<button type="submit" name="enable_mfa" class="btn btn-primary w-100 mb-3"><i class="fa fa-check me-2"></i>Enable MFA</button>
<div class="mb-3"></div>
<button type="submit" name="enable_mfa" class="btn btn-primary w-100 mb-2"><i class="fa fa-check me-2"></i>Turn on MFA</button>
<a class="small text-body-secondary" href="post.php?logout">Sign out</a>
</form>
</div>

View File

@@ -1,15 +1,17 @@
<?php
require_once '../../libs/totp/totp.php';
// Only generate the token once and store it in session:
/*
* Turn on MFA - from Your account > Security. The secret is made once and
* kept in the session until a code from it is checked, so the modal opening
* again (a wrong code) shows the same one.
*/
require_once dirname(__DIR__, 3) . '/libs/totp/totp.php';
if (empty($_SESSION['mfa_token'])) {
$token = generateTotpSecret();
$_SESSION['mfa_token'] = $token;
$_SESSION['mfa_token'] = generateTotpSecret();
}
$token = $_SESSION['mfa_token'];
// Generate QR Code
$data = "otpauth://totp/ITFlow:$session_email?secret=$token";
$mfa_secret = $_SESSION['mfa_token'];
?>
@@ -17,30 +19,16 @@ $data = "otpauth://totp/ITFlow:$session_email?secret=$token";
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-dark">
<h5 class="modal-title"><i class="fa fa-fw fa-lock me-2"></i>Multi-Factor Authentication</h5>
<h5 class="modal-title"><i class="fas fa-fw fa-mobile-alt me-2"></i>Turn on multi-factor authentication</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<form action="post.php" method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<div class="modal-body">
<div class="text-center">
<img src='../../libs/barcode/barcode.php?f=png&s=qr&d=<?= $data ?>'>
<p class="itflow-copy-always"><span class='text-secondary'>Secret:</span> <?= $token ?>
<button type='button' class='itflow-copy clipboardjs' title='Copy secret' data-clipboard-text='<?= $token ?>'><i class='far fa-copy'></i></button>
</p>
</div>
<div class="mb-3">
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-lock"></i></span>
<input type="text" class="form-control" inputmode="numeric" pattern="[0-9]*" minlength="6" maxlength="6" name="verify_code" placeholder="Enter 6 digit code to verify MFA" required>
</div>
</div>
<?php require __DIR__ . '/../includes/mfa_setup.php'; ?>
</div>
<div class="modal-footer">
<button type="submit" name="enable_mfa" class="btn btn-primary text-bold"><i class="fa fa-check me-2"></i>Enable</button>
<button type="submit" name="enable_mfa" class="btn btn-primary text-bold"><i class="fa fa-check me-2"></i>Turn on</button>
<button type="button" class="btn btn-light" data-bs-dismiss="modal"><i class="fa fa-times me-2"></i>Cancel</button>
</div>
</form>

View File

@@ -1,94 +1,68 @@
<?php
/*
* ITFlow - GET/POST request handler for user profiles (tech/agent)
* ITFlow - GET/POST request handler for an agent's own account
*
* Their details, picture and signature; their password and MFA; their
* preferences. Changing what signs them in - the email, the password - and
* turning MFA off take their current password, so a session left open is not
* enough to take the account over; the old address is told when the email
* changes, and they are emailed when the password changes or MFA goes off.
*/
defined('FROM_POST_HANDLER') || die("Direct file access is not allowed");
/** Is this the signed in agent's current password? */
function userCurrentPasswordOk($password) {
global $mysqli, $session_user_id;
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT user_password FROM users WHERE user_id = " . intval($session_user_id)));
return $row && password_verify((string) $password, $row['user_password']);
}
if (isset($_POST['edit_your_user_details'])) {
validateCSRFToken();
$name = escapeSql($_POST['name']);
$email = escapeSql($_POST['email']);
$signature = mysqli_escape_string($mysqli,$_POST['signature']);
$me = userRow($session_user_id);
$existing_file_name = escapeSql(getFieldById('users', $session_user_id, 'user_avatar'));
$logout = false;
$extended_log_description = '';
// Email notification when password or email is changed
$user_old_email_sql = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT user_email FROM users WHERE user_id = $session_user_id"));
$user_old_email = escapeSql($user_old_email_sql['user_email']);
// Sanitize Config Vars from get_settings.php and Session Vars from check_login.php
$config_mail_from_name = escapeSql($config_mail_from_name);
$config_mail_from_email = escapeSql($config_mail_from_email);
$config_app_name = escapeSql($config_app_name);
if (!empty($config_smtp_host) && ($user_old_email !== $email)) {
$details = "Your email address was changed. New email: $email.";
$subject = "$config_app_name account update confirmation for $name";
$body = "Hi $name, <br><br>Your $config_app_name account has been updated, details below: <br><br> <b>$details</b> <br><br> If you did not perform this change, contact your $config_app_name administrator immediately. <br><br>Thanks, <br>ITFlow<br>$session_company_name";
$data = [
[
'from' => $config_mail_from_email,
'from_name' => $config_mail_from_name,
'recipient' => $user_old_email,
'recipient_name' => $name,
'subject' => $subject,
'body' => $body
]
];
$mail = addToMailQueue($data);
}
// Photo
if (isset($_FILES['avatar']['tmp_name'])) {
if ($new_file_name = checkFileUpload($_FILES['avatar'], array('jpg', 'jpeg', 'gif', 'png', 'webp'))) {
$file_tmp_path = $_FILES['avatar']['tmp_name'];
// directory in which the uploaded file will be moved
$upload_file_dir = "../../uploads/users/$session_user_id/";
$dest_path = $upload_file_dir . $new_file_name;
if (!file_exists("$upload_file_dir")) {
mkdir("$upload_file_dir");
}
move_uploaded_file($file_tmp_path, $dest_path);
// Delete old file
unlink("../../uploads/users/$session_user_id/$existing_file_name");
// Set Avatar
mysqli_query($mysqli,"UPDATE users SET user_avatar = '$new_file_name' WHERE user_id = $session_user_id");
// Extended Logging
$extended_log_description .= ", avatar updated";
}
}
mysqli_query($mysqli,"UPDATE users SET user_name = '$name', user_email = '$email' WHERE user_id = $session_user_id");
mysqli_query($mysqli,"UPDATE user_settings SET user_config_signature = '$signature' WHERE user_id = $session_user_id");
logAudit("User Account", "Edit", "$session_name edited their account $extended_log_description");
flashAlert("User details updated");
if ($logout) {
redirect('post.php?logout');
} else {
$error = '';
$identity = userCheckIdentity($_POST, $session_user_id, $error);
if (!$identity) {
flashAlert($error, 'error');
redirect();
}
[$name, $email] = $identity;
$email_changed = strcasecmp($email, $me['user_email']) !== 0;
if ($email_changed && !userCurrentPasswordOk($_POST['current_password'] ?? '')) {
flashAlert('Your email signs you in - enter your current password to change it.', 'error');
redirect();
}
// The signature goes on emailed replies - kept to HTML that is safe to send
$signature = mysqli_real_escape_string($mysqli, documentHtmlPurify((string) ($_POST['signature'] ?? '')));
mysqli_query($mysqli, "UPDATE users SET user_name = '" . escapeSql($name) . "', user_email = '" . escapeSql($email) . "' WHERE user_id = $session_user_id");
mysqli_query($mysqli, "INSERT INTO user_settings SET user_id = $session_user_id, user_config_signature = '$signature' ON DUPLICATE KEY UPDATE user_config_signature = '$signature'");
$changes = [];
if ($email_changed) {
userNotifyChange($name, $me['user_email'], 'its email address is now ' . $email);
$changes[] = 'email changed';
}
$avatar_error = '';
if (!empty($_POST['remove_avatar'])) {
userRemoveAvatar($session_user_id);
$changes[] = 'picture removed';
} elseif (!empty($_FILES['avatar']['tmp_name']) && userSaveAvatar($session_user_id, $_FILES['avatar'], $avatar_error)) {
$changes[] = 'picture updated';
}
logAudit("User Account", "Edit", "$session_name edited their account" . ($changes ? ' - ' . escapeSql(implode(', ', $changes)) : ''));
flashAlert("Your details are saved" . ($avatar_error ? " - $avatar_error" : ''), $avatar_error ? 'warning' : 'success');
redirect();
}
@@ -96,15 +70,11 @@ if (isset($_GET['clear_your_user_avatar'])) {
validateCSRFToken();
$user_avatar = escapeSql(getFieldById('users', $session_user_id, 'user_avatar'));
unlink("../../uploads/users/$session_user_id/$user_avatar");
mysqli_query($mysqli,"UPDATE users SET user_avatar = NULL WHERE user_id = $session_user_id");
userRemoveAvatar($session_user_id);
logAudit("User Account", "Edit", "$session_name cleared their avatar");
flashAlert("Avatar cleared", 'error');
flashAlert("Picture removed");
redirect();
@@ -114,49 +84,28 @@ if (isset($_POST['edit_your_user_password'])) {
validateCSRFToken();
$new_password = trim($_POST['new_password']);
$new_password = (string) ($_POST['new_password'] ?? '');
if (empty($new_password)) {
if (!userCurrentPasswordOk($_POST['current_password'] ?? '')) {
flashAlert('That is not your current password - nothing was changed.', 'error');
redirect('user_security.php');
}
if ($problem = userPasswordProblem($new_password)) {
flashAlert($problem, 'error');
redirect('user_security.php');
}
if ($new_password !== (string) ($_POST['confirm_password'] ?? '')) {
flashAlert('The new password and its confirmation are not the same.', 'error');
redirect('user_security.php');
}
// Email notification when password or email is changed
$user_sql = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT user_name, user_email FROM users WHERE user_id = $session_user_id"));
$name = escapeSql($user_sql['user_name']);
$user_email = escapeSql($user_sql['user_email']);
// Sanitize Config Vars from get_settings.php and Session Vars from check_login.php
$config_mail_from_name = escapeSql($config_mail_from_name);
$config_mail_from_email = escapeSql($config_mail_from_email);
$config_app_name = escapeSql($config_app_name);
if (!empty($config_smtp_host)){
$details = "Your password was changed.";
$subject = "$config_app_name account update confirmation for $name";
$body = "Hi $name, <br><br>Your $config_app_name account has been updated, details below: <br><br> <b>$details</b> <br><br> If you did not perform this change, contact your $config_app_name administrator immediately. <br><br>Thanks, <br>$config_app_name";
$data = [
[
'from' => $config_mail_from_email,
'from_name' => $config_mail_from_name,
'recipient' => $user_email,
'recipient_name' => $name,
'subject' => $subject,
'body' => $body
]
];
$mail = addToMailQueue($data);
}
$new_password = password_hash($new_password, PASSWORD_DEFAULT);
$user_specific_encryption_ciphertext = encryptUserSpecificKey($_POST['new_password']);
mysqli_query($mysqli,"UPDATE users SET user_password = '$new_password', user_specific_encryption_ciphertext = '$user_specific_encryption_ciphertext' WHERE user_id = $session_user_id");
$me = userRow($session_user_id);
userSetPassword($session_user_id, $new_password);
userNotifyChange($me['user_name'], $me['user_email'], 'its password was changed');
logAudit("User Account", "Edit", "$session_name changed their password");
flashAlert("Your password was updated");
flashAlert("Your password was changed - sign in with the new one");
redirect('post.php?logout');
}
@@ -165,34 +114,19 @@ if (isset($_POST['edit_your_user_preferences'])) {
validateCSRFToken();
$calendar_first_day = intval($_POST['calendar_first_day']);
$dark_mode = intval($_POST['dark_mode'] ?? 0);
// Calendar
if (isset($calendar_first_day)) {
mysqli_query($mysqli, "UPDATE user_settings SET user_config_calendar_first_day = $calendar_first_day, user_config_theme_dark = $dark_mode WHERE user_id = $session_user_id");
$calendar_first_day = intval($_POST['calendar_first_day'] ?? 0) === 1 ? 1 : 0;
$dark_mode = intval($_POST['dark_mode'] ?? 0) === 1 ? 1 : 0;
$per_page = intval($_POST['records_per_page'] ?? 10);
if (!in_array($per_page, [5, 10, 20, 50, 100, 500], true)) {
$per_page = 10;
}
// Enable extension access, only if it isn't already setup (user doesn't have cookie)
if (isset($_POST['extension']) && $_POST['extension'] == 'Yes') {
if (!isset($_COOKIE['user_extension_key'])) {
$extension_key = randomString(32);
mysqli_query($mysqli, "UPDATE users SET user_extension_key = '$extension_key' WHERE user_id = $session_user_id");
mysqli_query($mysqli, "INSERT INTO user_settings SET user_id = $session_user_id, user_config_calendar_first_day = $calendar_first_day, user_config_theme_dark = $dark_mode, user_config_records_per_page = $per_page
ON DUPLICATE KEY UPDATE user_config_calendar_first_day = $calendar_first_day, user_config_theme_dark = $dark_mode, user_config_records_per_page = $per_page");
$extended_log_description .= "enabled browser extension access";
$logout = true;
}
}
logAudit("User Account", "Edit", "$session_name updated their preferences");
// Disable extension access
if (!isset($_POST['extension'])) {
mysqli_query($mysqli, "UPDATE users SET user_extension_key = '' WHERE user_id = $session_user_id");
$extended_log_description .= "disabled browser extension access";
}
logAudit("User Account", "Edit", "$session_name $extended_log_description");
flashAlert("User preferences updated");
flashAlert("Your preferences are saved");
redirect();
@@ -205,55 +139,42 @@ if (isset($_POST['enable_mfa'])) {
require_once "../../libs/totp/totp.php";
// Grab the code from the user
$verify_code = trim($_POST['verify_code']);
// Ensure it's numeric
if (!ctype_digit($verify_code)) {
$verify_code = preg_replace('/\s+/', '', (string) ($_POST['verify_code'] ?? ''));
if (!preg_match('/^[0-9]{6}$/', $verify_code)) {
$verify_code = '';
}
// Grab the secret from the session
$token = $_SESSION['mfa_token'] ?? '';
$from_enforcement = isset($_SERVER['HTTP_REFERER']) && basename(parse_url($_SERVER['HTTP_REFERER'], PHP_URL_PATH)) === 'mfa_enforcement.php';
// Verify
if (TokenAuth6238::verify($token, $verify_code, 1)) {
if ($token !== '' && $verify_code !== '' && TokenAuth6238::verify($token, $verify_code, 1)) {
// SUCCESS
mysqli_query($mysqli,"UPDATE users SET user_token = '$token' WHERE user_id = $session_user_id");
mysqli_query($mysqli, "UPDATE users SET user_token = '" . escapeSql($token) . "' WHERE user_id = $session_user_id");
// Delete any existing MFA tokens - these browsers should be re-validated
mysqli_query($mysqli, "DELETE FROM remember_tokens WHERE remember_token_user_id = $session_user_id");
logAudit("User Account", "Edit", "$session_name enabled MFA on their account");
flashAlert("Multi-Factor authentication enabled");
flashAlert("Multi-factor authentication is on - you will be asked for a code when you sign in");
// Clear the mfa_token from the session to avoid re-use.
unset($_SESSION['mfa_token']);
// Check if the previous page is mfa_enforcement.php
if (isset($_SERVER['HTTP_REFERER'])) {
$previousPage = basename(parse_url($_SERVER['HTTP_REFERER'], PHP_URL_PATH));
if ($previousPage === 'mfa_enforcement.php') {
// Redirect back to mfa_enforcement.php
redirect("../$config_start_page");
}
if ($from_enforcement) {
redirect("../$config_start_page");
}
} else {
// FAILURE
flashAlert("Verification code invalid, please try again.", 'error');
// Set a flag to automatically open the MFA modal again
flashAlert("That code did not match - check the time on your phone, and try the newest code.", 'error');
// Open the set up again, with the same secret
$_SESSION['show_mfa_modal'] = true;
// Check if the previous page is mfa_enforcement.php
if (isset($_SERVER['HTTP_REFERER'])) {
$previousPage = basename(parse_url($_SERVER['HTTP_REFERER'], PHP_URL_PATH));
if ($previousPage === 'mfa_enforcement.php') {
// Redirect back to mfa_enforcement.php
redirect();
}
if ($from_enforcement) {
redirect();
}
}
@@ -261,48 +182,28 @@ if (isset($_POST['enable_mfa'])) {
}
if (isset($_GET['disable_mfa'])){
if ($session_user_config_force_mfa) {
flashAlert("Multi-Factor authentication cannot be disabled for your account", 'error');
redirect();
}
if (isset($_POST['disable_your_mfa'])) {
validateCSRFToken();
mysqli_query($mysqli,"UPDATE users SET user_token = '' WHERE user_id = $session_user_id");
// Delete any existing MFA tokens - these browsers should be re-validated
mysqli_query($mysqli, "DELETE FROM remember_tokens WHERE remember_token_user_id = $session_user_id");
// Sanitize Config Vars from get_settings.php and Session Vars from check_login.php
$config_mail_from_name = escapeSql($config_mail_from_name);
$config_mail_from_email = escapeSql($config_mail_from_email);
$config_app_name = escapeSql($config_app_name);
// Email notification
if (!empty($config_smtp_host)) {
$subject = "$config_app_name account update confirmation for $session_name";
$body = "Hi $session_name, <br><br>Your $config_app_name account has been updated, details below: <br><br> <b>2FA was disabled.</b> <br><br> If you did not perform this change, contact your $config_app_name administrator immediately. <br><br>Thanks, <br>ITFlow<br>$session_company_name";
$data = [
[
'from' => $config_mail_from_email,
'from_name' => $config_mail_from_name,
'recipient' => $session_email,
'recipient_name' => $session_name,
'subject' => $subject,
'body' => $body
]
];
$mail = addToMailQueue($data);
if ($session_user_config_force_mfa) {
flashAlert("Your administrator requires multi-factor authentication on your account - it cannot be turned off.", 'error');
redirect("user_security.php");
}
if (!userCurrentPasswordOk($_POST['current_password'] ?? '')) {
flashAlert('That is not your current password - MFA is still on.', 'error');
redirect("user_security.php");
}
$me = userRow($session_user_id);
userResetMfa($session_user_id);
userNotifyChange($me['user_name'], $me['user_email'], 'multi-factor authentication was turned off');
logAudit("User Account", "Edit", "$session_name disabled MFA on their account");
flashAlert("Multi-Factor authentication disabled", 'error');
flashAlert("Multi-factor authentication is off", 'error');
redirect();
redirect("user_security.php");
}
@@ -310,13 +211,28 @@ if (isset($_POST['revoke_your_2fa_remember_tokens'])) {
validateCSRFToken();
// Delete tokens
mysqli_query($mysqli, "DELETE FROM remember_tokens WHERE remember_token_user_id = $session_user_id");
logAudit("User Account", "Edit", "$session_name revoked all their remember-me tokens");
flashAlert("Remember me tokens revoked", 'error');
flashAlert("Every remembered browser is forgotten - each asks for your code at the next sign in");
redirect();
redirect("user_security.php");
}
if (isset($_POST['revoke_your_remember_token'])) {
validateCSRFToken();
$token_id = intval($_POST['revoke_your_remember_token']);
mysqli_query($mysqli, "DELETE FROM remember_tokens WHERE remember_token_id = $token_id AND remember_token_user_id = $session_user_id");
if (mysqli_affected_rows($mysqli)) {
logAudit("User Account", "Edit", "$session_name revoked a remember-me token");
flashAlert("That browser is forgotten - it asks for your code at the next sign in");
}
redirect("user_security.php");
}

View File

@@ -1,98 +1,92 @@
<?php
/*
* Your account > Activity - your latest sign ins (and wrong MFA codes), and
* the latest things you did. Yours only.
*/
require_once "includes/inc_all_user.php";
$logins = userRecentLogins($session_user_id, 20);
$sql_recent_logins = mysqli_query($mysqli, "SELECT log_created_at, log_id, log_ip, log_user_agent FROM logs
WHERE log_type = 'Login' OR log_type = 'Login 2FA' AND log_action = 'Success' AND log_user_id = $session_user_id
ORDER BY log_id DESC LIMIT 3"
);
$activity = [];
$sql = mysqli_query($mysqli, "SELECT log_id, log_type, log_action, log_description, log_created_at, log_client_id, client_name FROM logs
LEFT JOIN clients ON client_id = log_client_id AND log_client_id > 0
WHERE log_user_id = $session_user_id AND log_type NOT IN ('Login', 'Login 2FA', 'Logout')
ORDER BY log_id DESC LIMIT 25");
while ($row = mysqli_fetch_assoc($sql)) {
$activity[] = $row;
}
$sql_recent_logs = mysqli_query($mysqli, "SELECT log_action, log_created_at, log_description, log_id, log_type FROM logs
WHERE log_user_id = $session_user_id AND log_type NOT LIKE 'Login'
ORDER BY log_id DESC LIMIT 5"
);
$action_icons = [
'Create' => 'fa-plus text-success',
'Edit' => 'fa-pen text-info',
'Modify' => 'fa-pen text-info',
'Delete' => 'fa-trash-alt text-danger',
'Archive' => 'fa-archive text-secondary',
'Restore' => 'fa-redo text-secondary',
'Export' => 'fa-download text-secondary',
'Upload' => 'fa-upload text-secondary',
'View' => 'fa-eye text-secondary',
'Reply' => 'fa-reply text-primary',
];
?>
<div class="card card-dark">
<div class="card-header">
<h3 class="card-title"><i class="fas fa-fw fa-sign-in-alt me-2"></i>Your Recent Sign ins</h3>
</div>
<table class="table table-borderless">
<tbody>
<?php
<div class="row g-3">
while ($row = mysqli_fetch_assoc($sql_recent_logins)) {
$log_id = intval($row['log_id']);
$log_ip = escapeHtml($row['log_ip']);
$log_user_agent = escapeHtml($row['log_user_agent']);
$log_user_os = getOS($log_user_agent);
$log_user_browser = getWebBrowser($log_user_agent);
$log_created_at = escapeHtml($row['log_created_at']);
?>
<tr>
<td><i class="fa fa-fw fa-clock text-secondary me-2"></i><?= $log_created_at ?></td>
<td><?= $log_user_os ?></td>
<td><?= $log_user_browser ?></td>
<td><i class='fa fa-fw fa-globe text-secondary'></i> <?= $log_ip ?></td>
</tr>
<?php } ?>
</tbody>
</table>
<?php if (isset($session_is_admin) && $session_is_admin === true) { ?>
<div class="card-footer">
<a href="../../admin/audit_logs.php?q=<?= "$session_name successfully logged in" ?>">See More...</a>
<div class="col-xl-5">
<div class="card h-100 mb-0">
<div class="card-header bg-dark">
<h3 class="card-title"><i class="fas fa-fw fa-sign-in-alt me-2"></i>Your sign ins</h3>
</div>
<?php if ($logins) { ?>
<ul class="list-group list-group-flush small">
<?php foreach ($logins as $log) { ?>
<li class="list-group-item"><?= userLoginLineHtml($log) ?></li>
<?php } ?>
</ul>
<div class="card-footer small text-body-secondary">Not you? Change your password, and tell an administrator.</div>
<?php } else { ?>
<div class="card-body small text-body-secondary">None yet.</div>
<?php } ?>
</div>
<?php } ?>
</div>
<div class="card card-dark">
<div class="card-header">
<h3 class="card-title"><i class="fas fa-fw fa-history me-2"></i>Your Recent Activity</h3>
</div>
<table class="table">
<tbody>
<?php
while ($row = mysqli_fetch_assoc($sql_recent_logs)) {
$log_id = intval($row['log_id']);
$log_type = escapeHtml($row['log_type']);
$log_action = escapeHtml($row['log_action']);
$log_description = escapeHtml($row['log_description']);
$log_created_at = escapeHtml($row['log_created_at']);
if ($log_action == 'Create') {
$log_icon = "plus text-success";
} elseif ($log_action == 'Modify') {
$log_icon = "edit text-info";
} elseif ($log_action == 'Delete') {
$log_icon = "trash-alt text-danger";
} else {
$log_icon = "pencil";
}
?>
<tr>
<td><i class="fa fa-fw fa-clock text-secondary me-2"></i><?= $log_created_at ?></td>
<td><strong><i class="fa fa-fw text-secondary fa-<?= $log_icon ?>"></i> <?= $log_type ?></strong></td>
<td><span class="text-secondary"><?= $log_description ?></span></td>
</tr>
<?php
}
?>
</tbody>
</table>
<?php if (isset($session_is_admin) && $session_is_admin === true) { ?>
<div class="card-footer">
<a href="../../admin/audit_logs.php?q=<?= escapeHtml($session_name) ?>">See More...</a>
<div class="col-xl-7">
<div class="card h-100 mb-0">
<div class="card-header bg-dark">
<h3 class="card-title"><i class="fas fa-fw fa-history me-2"></i>What you did</h3>
<?php if ($session_is_admin) { ?>
<div class="card-tools">
<a class="btn btn-sm btn-outline-light" href="/admin/audit_logs.php?user=<?= intval($session_user_id) ?>">Audit log<i class="fas fa-arrow-right ms-2"></i></a>
</div>
<?php } ?>
</div>
<?php if ($activity) { ?>
<ul class="list-group list-group-flush small">
<?php foreach ($activity as $log) { ?>
<li class="list-group-item d-flex gap-2 align-items-start">
<i class="fas fa-fw <?= $action_icons[$log['log_action']] ?? 'fa-circle text-secondary small' ?> mt-1" title="<?= escapeHtml($log['log_action']) ?>"></i>
<div class="min-w-0 flex-grow-1">
<div class="text-break"><?= escapeHtml($log['log_description']) ?></div>
<div class="text-body-secondary d-flex flex-wrap column-gap-2">
<span title="<?= escapeHtml($log['log_created_at']) ?>"><?= escapeHtml(timeAgo($log['log_created_at'])) ?></span>
<span><?= escapeHtml($log['log_type']) ?> · <?= escapeHtml($log['log_action']) ?></span>
<?php if ($log['client_name']) { ?>
<a href="/agent/client_overview.php?client_id=<?= intval($log['log_client_id']) ?>"><?= escapeHtml($log['client_name']) ?></a>
<?php } ?>
</div>
</div>
</li>
<?php } ?>
</ul>
<?php } else { ?>
<div class="card-body small text-body-secondary">Nothing yet.</div>
<?php } ?>
</div>
<?php } ?>
</div>
</div>
<?php

View File

@@ -1,71 +1,149 @@
<?php
/*
* Your account > Profile - your picture, name, email and the signature that
* goes on your ticket replies. Your email signs you in, so changing it takes
* your current password, and the old address is told.
*/
require_once "includes/inc_all_user.php";
$me = userRow($session_user_id);
$has_avatar = trim((string) $me['user_avatar']) !== '';
?>
<div class="card card-dark">
<div class="card-header">
<h3 class="card-title"><i class="fas fa-cog me-2"></i>User Preferences</h3>
<div class="card">
<div class="card-header bg-dark">
<h3 class="card-title"><i class="fas fa-fw fa-id-badge me-2"></i>Profile</h3>
</div>
<div class="card-body">
<form action="post.php" method="post" enctype="multipart/form-data" autocomplete="off" id="profileForm">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<form action="post.php" method="post" enctype="multipart/form-data" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<div class="row">
<div class="col-md-3 text-center">
<?php if($session_avatar) { ?>
<img class="img-thumbnail" src="<?= "../../uploads/users/$session_user_id/" . escapeHtml($session_avatar) ?>">
<a href="post.php?clear_your_user_avatar&csrf_token=<?= $_SESSION['csrf_token'] ?>" class="btn btn-outline-danger w-100">Remove Avatar</a>
<hr>
<?php } ?>
<div class="mb-3">
<label>Upload Avatar</label>
<input type="file" class="form-control" accept="image/*" name="avatar">
<div class="card-body">
<div class="row g-4">
<div class="col-lg-3">
<div class="d-flex flex-lg-column align-items-center gap-3 text-lg-center">
<span data-profile-avatar-preview><?= userAvatar($session_user_id, $me['user_name'], $me['user_avatar'], 'xl') ?></span>
<div class="w-100 min-w-0">
<label class="form-label small mb-1" for="profileAvatar">Picture</label>
<input type="file" class="form-control form-control-sm" id="profileAvatar" name="avatar" accept="image/jpeg,image/png,image/gif,image/webp" data-profile-avatar-input>
<div class="form-text">JPG, PNG, GIF or WebP, up to 5 MB.</div>
<?php if ($has_avatar) { ?>
<div class="form-check small mt-1 d-inline-block text-start">
<input class="form-check-input" type="checkbox" name="remove_avatar" value="1" id="profileRemoveAvatar" data-profile-avatar-remove>
<label class="form-check-label" for="profileRemoveAvatar">Remove it - show my initials</label>
</div>
<?php } ?>
</div>
</div>
</div>
<div class="col-md-9">
<div class="mb-3">
<label>Name <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-user"></i></span>
<input type="text" class="form-control" name="name" placeholder="Full Name" maxlength="200" value="<?= stripslashes(escapeHtml($session_name)) ?>" required>
<div class="col-lg-9">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Name <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-fw fa-user"></i></span>
<input type="text" class="form-control" name="name" placeholder="First and last name" maxlength="200" value="<?= escapeHtml($me['user_name']) ?>" required>
</div>
</div>
<div class="col-md-6">
<label class="form-label">Email <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-fw fa-envelope"></i></span>
<input type="email" class="form-control" name="email" placeholder="You sign in with this" maxlength="200" value="<?= escapeHtml($me['user_email']) ?>" required data-profile-email data-original="<?= escapeHtml($me['user_email']) ?>">
</div>
</div>
<div class="col-12" data-profile-current hidden>
<div class="border border-warning-subtle bg-warning-subtle rounded p-3">
<label class="form-label">Your current password <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-fw fa-lock"></i></span>
<input type="password" class="form-control" data-toggle="password" name="current_password" placeholder="To change the email you sign in with" autocomplete="current-password">
<span class="input-group-text"><i class="fa fa-fw fa-eye"></i></span>
</div>
<div class="form-text">You'll sign in with the new email from now on - your old address is told it changed.</div>
</div>
</div>
<div class="col-md-6">
<label class="form-label">Role</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-fw <?= intval($me['role_is_admin']) ? 'fa-user-shield' : 'fa-user-tag' ?>"></i></span>
<input type="text" class="form-control" value="<?= escapeHtml($me['role_name'] ?? 'No role') ?>" disabled>
</div>
<div class="form-text">Set by an administrator.</div>
</div>
<div class="col-md-6">
<label class="form-label">Sign in</label>
<div class="d-flex flex-wrap align-items-center gap-2 pt-1">
<?= userMfaPill($me['user_token'], $me['user_config_force_mfa']) ?>
<a class="small" href="user_security.php">Password and MFA</a>
</div>
</div>
<div class="col-12">
<label class="form-label">Signature</label>
<textarea class="form-control tinymceTicket" name="signature" rows="4" placeholder="Added to the end of your ticket replies"><?= escapeHtml($me['user_config_signature'] ?? '') ?></textarea>
<div class="form-text">Added to the end of your ticket replies and the emails they send.</div>
</div>
</div>
<div class="mb-3">
<label>Role</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-user-shield"></i></span>
<input type="text" class="form-control" value="<?= escapeHtml($session_user_role_display) ?>" disabled>
</div>
</div>
<div class="mb-3">
<label>Email <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-envelope"></i></span>
<input type="email" class="form-control" name="email" placeholder="Email Address" maxlength="200" value="<?= escapeHtml($session_email) ?>" required>
</div>
</div>
<div class="mb-3">
<label>Signature</label>
<textarea class="form-control tinymceTicket" name="signature" rows="4" placeholder="Create a signature automatically appended to tickets, emails etc"><?= escapeHtml(getFieldById('user_settings',$session_user_id,'user_config_signature')) ?>
</textarea>
</div>
<button type="submit" name="edit_your_user_details" class="btn btn-primary btn-responsive"><i class="fas fa-check me-2"></i>Save</button>
</div>
</div>
</form>
</div>
</div>
<div class="card-footer">
<button type="submit" name="edit_your_user_details" class="btn btn-primary"><i class="fas fa-check me-2"></i>Save</button>
</div>
</form>
</div>
<script>
(function () {
var form = document.getElementById('profileForm');
// The picture - shown as soon as it is picked
var input = form.querySelector('[data-profile-avatar-input]');
var preview = form.querySelector('[data-profile-avatar-preview]');
var original = preview.innerHTML;
var remove = form.querySelector('[data-profile-avatar-remove]');
input.addEventListener('change', function () {
var file = input.files && input.files[0];
if (file && /^image\//.test(file.type)) {
var img = document.createElement('img');
img.className = 'itflow-contact-avatar itflow-contact-avatar-xl';
img.alt = '';
img.src = URL.createObjectURL(file);
preview.replaceChildren(img);
preview.classList.remove('opacity-25');
if (remove) {
remove.checked = false;
}
} else {
preview.innerHTML = original;
}
});
if (remove) {
remove.addEventListener('change', function () {
if (remove.checked) {
input.value = '';
preview.innerHTML = original;
}
preview.classList.toggle('opacity-25', remove.checked);
});
}
// A new email takes your current password
var email = form.querySelector('[data-profile-email]');
var current = form.querySelector('[data-profile-current]');
var currentInput = current.querySelector('input');
function emailChanged() {
var changed = email.value.trim().toLowerCase() !== email.dataset.original.toLowerCase();
current.hidden = !changed;
currentInput.required = changed;
}
email.addEventListener('input', emailChanged);
emailChanged();
})();
</script>
<?php
require_once "../../includes/footer.php";

View File

@@ -1,47 +1,70 @@
<?php
/*
* Your account > Preferences - how ITFlow looks and behaves for you: light
* or dark, the day calendars start on, and how many rows a list shows.
*/
require_once "includes/inc_all_user.php";
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT user_config_calendar_first_day FROM user_settings WHERE user_id = $session_user_id"));
$user_config_calendar_first_day = intval($row['user_config_calendar_first_day']);
$me = userRow($session_user_id);
$calendar_first_day = intval($me['user_config_calendar_first_day']);
$per_page = intval($me['user_config_records_per_page']) ?: 10;
?>
<div class="card card-dark">
<div class="card-header">
<h3 class="card-title"><i class="fas fa-fw fa-globe me-2"></i>Preferences</h3>
<div class="card">
<div class="card-header bg-dark">
<h3 class="card-title"><i class="fas fa-fw fa-sliders-h me-2"></i>Preferences</h3>
</div>
<div class="card-body">
<form action="post.php" method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<form action="post.php" method="post" enctype="multipart/form-data" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<div class="card-body">
<div class="row g-4">
<div class="col-12">
<label class="form-label d-block">Theme</label>
<div class="d-flex flex-wrap gap-2" role="group" aria-label="Theme">
<input class="btn-check" type="radio" name="dark_mode" id="themeLight" value="0" autocomplete="off" <?= $user_config_theme_dark === 0 ? 'checked' : '' ?>>
<label class="btn btn-outline-secondary itflow-theme-pick" for="themeLight">
<span class="itflow-theme-swatch is-light" aria-hidden="true"></span><i class="fas fa-sun me-2"></i>Light
</label>
<input class="btn-check" type="radio" name="dark_mode" id="themeDark" value="1" autocomplete="off" <?= $user_config_theme_dark === 1 ? 'checked' : '' ?>>
<label class="btn btn-outline-secondary itflow-theme-pick" for="themeDark">
<span class="itflow-theme-swatch is-dark" aria-hidden="true"></span><i class="fas fa-moon me-2"></i>Dark
</label>
</div>
</div>
<div class="mb-3">
<h5>Dark Mode</h5>
<div class="col-md-6">
<label class="form-label">Calendars start on</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-fw fa-calendar-day"></i></span>
<select class="form-select select2" name="calendar_first_day">
<option value="0" <?= $calendar_first_day === 0 ? 'selected' : '' ?>>Sunday</option>
<option value="1" <?= $calendar_first_day === 1 ? 'selected' : '' ?>>Monday</option>
</select>
</div>
</div>
<div class="btn-group" role="group">
<input class="btn-check" type="radio" name="dark_mode" id="light-mode" autocomplete="off" <?php if ($user_config_theme_dark === 0) { echo "checked"; } ?>>
<label class="btn btn-outline-primary" for="light-mode"><i class="fas fa-sun me-2"></i>Light</label>
<input class="btn-check" type="radio" name="dark_mode" id="dark-mode" autocomplete="off" value="1" <?php if ($user_config_theme_dark === 1) { echo "checked"; } ?>>
<label class="btn btn-outline-dark" for="dark-mode"><i class="fas fa-moon me-2"></i>Dark</label>
<div class="col-md-6">
<label class="form-label">Rows on a list</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-fw fa-list-ol"></i></span>
<select class="form-select select2" name="records_per_page">
<?php foreach ([5, 10, 20, 50, 100, 500] as $n) { ?>
<option value="<?= $n ?>" <?= $per_page === $n ? 'selected' : '' ?>><?= $n ?></option>
<?php } ?>
</select>
</div>
<div class="form-text">Also changed from the bottom of any list.</div>
</div>
</div>
<div class="mb-3">
<label>Calendar starts on<strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-calendar-day"></i></span>
<select class="form-select select2" name="calendar_first_day" required>
<option <?php if ($user_config_calendar_first_day == '0') { echo "selected"; } ?> value="0" >Sunday</option>
<option <?php if ($user_config_calendar_first_day == '1') { echo "selected"; } ?> value="1" >Monday</option>
</select>
</div>
</div>
</div>
<div class="card-footer">
<button type="submit" name="edit_your_user_preferences" class="btn btn-primary"><i class="fas fa-check me-2"></i>Save</button>
</form>
</div>
</div>
</form>
</div>
<?php

View File

@@ -1,96 +1,200 @@
<?php
/*
* Your account > Security - your password, multi-factor authentication, and
* the browsers that skip your MFA code. Changing the password or turning MFA
* off takes your current password; you are emailed either way.
*/
require_once "includes/inc_all_user.php";
// User remember me tokens
$sql_remember_tokens = mysqli_query($mysqli, "SELECT remember_token_created_at, remember_token_id FROM remember_tokens WHERE remember_token_user_id = $session_user_id");
$remember_token_count = mysqli_num_rows($sql_remember_tokens);
$me = userRow($session_user_id);
$has_mfa = trim((string) $me['user_token']) !== '';
$forced = intval($me['user_config_force_mfa']) === 1;
$browsers = userRememberedBrowsers($session_user_id);
$remember_days = max(1, intval($config_login_remember_me_expire));
?>
<div class="card card-dark">
<div class="card-header">
<h3 class="card-title"><i class="fas fa-shield-alt me-2"></i>Your Password</h3>
</div>
<div class="card-body">
<form action="post.php" method="post" enctype="multipart/form-data" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<div class="row g-3">
<div class="mb-3">
<label>Your New Password <strong class="text-danger">*</strong></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" data-toggle="password" name="new_password" placeholder="Leave blank for no change" autocomplete="new-password" minlength="8" required>
<span class="input-group-text"><i class="fa fa-fw fa-eye"></i></span>
</div>
<!-- Password -->
<div class="col-xl-6">
<div class="card h-100 mb-0">
<div class="card-header bg-dark">
<h3 class="card-title"><i class="fas fa-fw fa-key me-2"></i>Password</h3>
</div>
<form action="post.php" method="post" autocomplete="off" id="passwordForm">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<div class="card-body">
<div class="mb-3">
<label class="form-label">Current password <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-fw fa-lock"></i></span>
<input type="password" class="form-control" data-toggle="password" name="current_password" autocomplete="current-password" required>
<span class="input-group-text"><i class="fa fa-fw fa-eye"></i></span>
</div>
</div>
<div class="mb-3">
<label class="form-label">New password <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-fw fa-key"></i></span>
<input type="password" class="form-control" data-toggle="password" name="new_password" placeholder="At least <?= USER_PASSWORD_MIN ?> characters" minlength="<?= USER_PASSWORD_MIN ?>" autocomplete="new-password" required data-password-new>
<span class="input-group-text"><i class="fa fa-fw fa-eye"></i></span>
</div>
</div>
<div>
<label class="form-label">New password, again <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-fw fa-check-double"></i></span>
<input type="password" class="form-control" name="confirm_password" minlength="<?= USER_PASSWORD_MIN ?>" autocomplete="new-password" required data-password-confirm>
</div>
<div class="form-text" data-password-match>You're signed out once it's changed, to sign in with the new one.</div>
</div>
</div>
<div class="card-footer">
<button type="submit" name="edit_your_user_password" class="btn btn-primary"><i class="fas fa-check me-2"></i>Change password</button>
</div>
</form>
</div>
</div>
<button type="submit" name="edit_your_user_password" class="btn btn-primary"><i class="fas fa-check me-2"></i>Change</button>
<!-- MFA -->
<div class="col-xl-6">
<div class="card h-100 mb-0">
<div class="card-header bg-dark">
<h3 class="card-title"><i class="fas fa-fw fa-mobile-alt me-2"></i>Multi-factor authentication</h3>
</div>
<div class="card-body">
<div class="d-flex align-items-start gap-3">
<span class="itflow-mfa-state <?= $has_mfa ? 'is-on' : 'is-off' ?>"><i class="fas <?= $has_mfa ? 'fa-lock' : 'fa-unlock' ?>"></i></span>
<div class="min-w-0">
<div class="fw-semibold"><?= $has_mfa ? 'On' : 'Off' ?><?= $forced ? ' <span class="badge rounded-pill bg-secondary-subtle text-secondary-emphasis fw-normal ms-1">Required by your administrator</span>' : '' ?></div>
<div class="small text-body-secondary">
<?php if ($has_mfa) { ?>
Signing in takes your password and a code from your authenticator app.
<?php } else { ?>
Signing in takes only your password. With MFA it also takes a code from an app on your phone - so a stolen password alone doesn't get anyone in.
<?php } ?>
</div>
</div>
</div>
</form>
<div class="float-end">
<?php if (empty($session_token)) { ?>
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#enableMFAModal">
<i class="fas fa-lock me-2"></i>Enable MFA
</button>
<?php require_once "modals/user_mfa_modal.php"; ?>
<?php if ($has_mfa && !$forced) { ?>
<div class="collapse mt-3" id="disableMfa">
<form action="post.php" method="post" autocomplete="off" class="border rounded p-3">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<label class="form-label">Your password <strong class="text-danger">*</strong></label>
<div class="input-group mb-2">
<span class="input-group-text"><i class="fas fa-fw fa-lock"></i></span>
<input type="password" class="form-control" data-toggle="password" name="current_password" placeholder="To turn MFA off" autocomplete="current-password" required>
<span class="input-group-text"><i class="fa fa-fw fa-eye"></i></span>
</div>
<button type="submit" name="disable_your_mfa" class="btn btn-danger btn-sm"><i class="fas fa-unlock me-2"></i>Turn MFA off</button>
</form>
</div>
<?php } ?>
</div>
<div class="card-footer">
<?php if (!$has_mfa) { ?>
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#enableMFAModal"><i class="fas fa-lock me-2"></i>Turn on MFA</button>
<?php } elseif (!$forced) { ?>
<button type="button" class="btn btn-outline-danger" data-bs-toggle="collapse" data-bs-target="#disableMfa" aria-expanded="false"><i class="fas fa-unlock me-2"></i>Turn off</button>
<span class="small text-body-secondary ms-2">New phone? Turn it off, then on again with the new one.</span>
<?php } else { ?>
<span class="small text-body-secondary">New phone? Ask an administrator to reset your MFA - you set it up again at your next sign in.</span>
<?php } ?>
</div>
</div>
</div>
<!-- Remembered browsers -->
<div class="col-12">
<div class="card mb-0">
<div class="card-header bg-dark">
<h3 class="card-title"><i class="fas fa-fw fa-laptop me-2"></i>Remembered browsers</h3>
<?php if (count($browsers) > 1) { ?>
<div class="card-tools">
<form action="post.php" method="post">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<button type="submit" name="revoke_your_2fa_remember_tokens" class="btn btn-sm btn-outline-light"><i class="fas fa-times me-2"></i>Forget all</button>
</form>
</div>
<?php } ?>
</div>
<?php if ($browsers) { ?>
<ul class="list-group list-group-flush">
<?php foreach ($browsers as $browser) {
$login = $browser['login'];
?>
<li class="list-group-item d-flex align-items-center gap-3">
<i class="fas fa-fw fa-laptop fa-lg text-body-secondary"></i>
<div class="flex-grow-1 min-w-0">
<div>
<?= $login ? userDeviceHtml($login['log_user_agent']) : '<span class="text-body-secondary">A browser</span>' ?>
<?php if ($browser['is_current']) { ?><span class="badge rounded-pill bg-primary-subtle text-primary-emphasis fw-normal ms-1">This browser</span><?php } ?>
</div>
<div class="small text-body-secondary">
Remembered <span title="<?= escapeHtml($browser['remember_token_created_at']) ?>"><?= escapeHtml(timeAgo($browser['remember_token_created_at'])) ?></span>
<?php if ($login) { ?> from <span class="font-monospace"><?= escapeHtml($login['log_ip']) ?></span><?php } ?>
· skips the code until <?= escapeHtml(date('M j', strtotime($browser['remember_token_expires_at']))) ?>
</div>
</div>
<form action="post.php" method="post">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<button type="submit" name="revoke_your_remember_token" value="<?= intval($browser['remember_token_id']) ?>" class="btn btn-sm btn-outline-secondary" title="It asks for your code at the next sign in">Forget</button>
</form>
</li>
<?php } ?>
</ul>
<?php } else { ?>
<a href="post.php?disable_mfa&csrf_token=<?= $_SESSION['csrf_token'] ?>" class="btn btn-danger confirm-link"><i class="fas fa-unlock me-2"></i>Disable MFA</a>
<div class="card-body small text-body-secondary">
<?php if ($has_mfa) { ?>
None. Tick <strong>Remember me</strong> when you enter your code at sign in, and that browser skips the code for <?= $remember_days ?> days.
<?php } else { ?>
None - browsers are remembered once MFA is on, so you're not asked for a code every time.
<?php } ?>
</div>
<?php } ?>
</div>
</div>
</div>
<?php if ($remember_token_count > 0) { ?>
<div class="card card-dark">
<div class="card-header py-3">
<h3 class="card-title"><i class="fas fa-fw fa-clock me-2"></i>2FA Remember-Me Tokens</h3>
</div>
<div class="card-body">
<?php if (!$has_mfa) {
require_once "modals/user_mfa_modal.php";
} ?>
<ul>
<?php while ($row = mysqli_fetch_assoc($sql_remember_tokens)) {
$token_id = intval($row['remember_token_id']);
$token_created = escapeHtml($row['remember_token_created_at']);
echo "<li>ID: $token_id | Created: $token_created</li>";
} ?>
</ul>
<form action="post.php" method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<button type="submit" name="revoke_your_2fa_remember_tokens" class="btn btn-danger w-100 mt-3"><i class="fas fa-exclamation-triangle me-2"></i>Revoke Remember-Me Tokens</button>
</form>
</div>
</div>
<?php } ?>
<script>
(function () {
// The new password, typed twice - said as soon as they match, or don't
var form = document.getElementById('passwordForm');
var next = form.querySelector('[data-password-new]');
var again = form.querySelector('[data-password-confirm]');
var hint = form.querySelector('[data-password-match]');
var hintText = hint.textContent;
function check() {
var differs = again.value !== '' && again.value !== next.value;
again.setCustomValidity(differs ? 'The two new passwords are not the same' : '');
again.classList.toggle('is-invalid', differs);
again.classList.toggle('is-valid', again.value !== '' && !differs && next.value.length >= <?= USER_PASSWORD_MIN ?>);
hint.textContent = differs ? 'Not the same as the new password above.' : hintText;
hint.classList.toggle('text-danger', differs);
}
next.addEventListener('input', check);
again.addEventListener('input', check);
})();
</script>
<?php
// Show the error alert if it exists:
if (!empty($_SESSION['alert_type']) && $_SESSION['alert_type'] == 'error') {
echo "<div class='alert alert-danger'>" . alertMessageHtml($_SESSION['alert_message']) . "</div>";
// Clear it so it doesn't persist on refresh
unset($_SESSION['alert_type']);
unset($_SESSION['alert_message']);
}
// If the user just failed a TOTP verification, auto-open the modal:
if (!empty($_SESSION['show_mfa_modal'])) {
echo "
// A code that didn't match - open the set up again, with the same secret
if (!empty($_SESSION['show_mfa_modal']) && !$has_mfa) { ?>
<script>
document.addEventListener('DOMContentLoaded', function() {
// jQuery or vanilla JS to open the modal
document.addEventListener('DOMContentLoaded', function () {
bootstrap.Modal.getOrCreateInstance(document.getElementById('enableMFAModal')).show();
});
</script>";
unset($_SESSION['show_mfa_modal']);
}
</script>
<?php }
unset($_SESSION['show_mfa_modal']);
require_once "../../includes/footer.php";

View File

@@ -5333,3 +5333,98 @@ img.itflow-contact-avatar {
background: var(--bs-secondary-bg);
vertical-align: middle;
}
/* Users - admin/users.php, admin/modals/user/*, and your account (agent/user/*).
functions/user.php userAvatar() uses the contact avatar classes; xl is the
profile page and the account menu. */
.itflow-contact-avatar-xl {
width: 5rem;
height: 5rem;
font-size: 1.6rem;
}
.itflow-user-main {
min-width: 15rem;
}
.itflow-user-list td {
padding-top: .6rem;
padding-bottom: .6rem;
}
/* The account menu in the top bar - initials when there is no picture */
.app-header .navbar-nav > .user-menu .nav-link .itflow-contact-avatar {
width: 1.75rem;
height: 1.75rem;
}
.itflow-user-header .itflow-contact-avatar {
width: 5.5rem;
height: 5.5rem;
margin: 0 auto;
border: 3px solid rgba(255, 255, 255, .2);
font-size: 1.8rem;
}
/* Client access: one row per client, scrolling inside the modal */
.itflow-user-access {
max-height: 22rem;
overflow-y: auto;
}
.itflow-user-access .btn-group-sm > .btn {
min-width: 3.25rem;
}
/* MFA set up: the QR code on white, whatever the theme - an authenticator
app reads dark on light - and the code, spaced out like the app shows it */
.itflow-mfa-qr-box {
display: inline-block;
padding: .25rem;
background: #fff;
border: 1px solid var(--bs-border-color);
border-radius: var(--bs-border-radius-lg);
line-height: 0;
}
.itflow-mfa-qr {
width: 12rem;
height: 12rem;
}
.itflow-mfa-code {
letter-spacing: .4em;
}
.itflow-mfa-code::placeholder {
letter-spacing: .4em;
opacity: .35;
}
.itflow-mfa-state {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
font-size: 1rem;
}
.itflow-mfa-state.is-on {
background: var(--bs-success-bg-subtle);
color: var(--bs-success-text-emphasis);
}
.itflow-mfa-state.is-off {
background: var(--bs-danger-bg-subtle);
color: var(--bs-danger-text-emphasis);
}
/* Preferences: what light and dark look like */
.itflow-theme-pick {
display: inline-flex;
align-items: center;
gap: .5rem;
padding: .5rem .9rem;
}
.itflow-theme-swatch {
display: inline-block;
width: 1.75rem;
height: 1.25rem;
border: 1px solid var(--bs-border-color);
border-radius: var(--bs-border-radius-sm);
}
.itflow-theme-swatch.is-light {
background: linear-gradient(90deg, #343a40 0 30%, #f8f9fa 30%);
}
.itflow-theme-swatch.is-dark {
background: linear-gradient(90deg, #343a40 0 30%, #212529 30%);
}

View File

@@ -19,6 +19,7 @@ require_once __DIR__ . '/functions/files.php';
require_once __DIR__ . '/functions/domain.php';
require_once __DIR__ . '/functions/certificate.php';
require_once __DIR__ . '/functions/network.php';
require_once __DIR__ . '/functions/user.php';
require_once __DIR__ . '/functions/asset_interface.php';
require_once __DIR__ . '/functions/rack.php';
require_once __DIR__ . '/functions/asset.php';

View File

@@ -409,6 +409,9 @@ function getExportColumns($export_type) {
'user_email' => ['label' => 'Email', 'weight' => 2],
'role_name' => ['label' => 'Role'],
'user_status_display' => ['label' => 'Status'],
'user_mfa_display' => ['label' => 'MFA'],
'user_last_login' => ['label' => 'Last Sign In'],
'user_client_access' => ['label' => 'Client Access', 'weight' => 2, 'default' => false],
'user_created_at' => ['label' => 'Creation Date'],
],

509
functions/user.php Normal file
View File

@@ -0,0 +1,509 @@
<?php
/*
* ITFlow - agents (users with user_type 1): how one is shown, what an admin
* may change about them, and the checks every change goes through.
*
* Shared by Administration > Users (admin/users.php, admin/modals/user/*,
* admin/post/users.php) and the agent's own account pages (agent/user/*).
* An agent's password also unlocks the credential vault (their copy of the
* master key, user_specific_encryption_ciphertext), so a password is only
* ever set through userSetPassword(), which writes both together.
*/
/** What a user's status is: [status => label, tone]. 2 is the old invite state - shown, never set. */
const USER_STATUSES = [
1 => ['Active', 'success'],
0 => ['Disabled', 'secondary'],
2 => ['Invited', 'info'],
];
/** The shortest password ITFlow takes for an agent. */
const USER_PASSWORD_MIN = 8;
/** Their picture, or their initials on a colour that stays the same for them. $size as contactAvatar(). */
function userAvatar($user_id, $name, $avatar, $size = '') {
$class = 'itflow-contact-avatar' . ($size ? " itflow-contact-avatar-$size" : '');
$avatar = html_entity_decode((string) $avatar, ENT_QUOTES, 'UTF-8');
if ($avatar !== '') {
return '<img class="' . $class . '" src="/uploads/users/' . intval($user_id) . '/' . escapeHtml(rawurlencode($avatar)) . '" alt="" loading="lazy">';
}
$tone = clientInitialsTone($user_id);
return '<span class="' . $class . ' bg-' . $tone . '-subtle text-' . $tone . '-emphasis" aria-hidden="true">' . escapeHtml(personInitials($name) ?: '?') . '</span>';
}
/** A user's status as a pill. */
function userStatusPill($status, $archived = false) {
if ($archived) {
return '<span class="badge rounded-pill text-bg-secondary fw-normal"><i class="fas fa-archive me-1"></i>Archived</span>';
}
[$label, $tone] = USER_STATUSES[intval($status)] ?? USER_STATUSES[0];
$icon = ['success' => 'fa-check-circle', 'secondary' => 'fa-ban', 'info' => 'fa-paper-plane'][$tone];
return '<span class="badge rounded-pill bg-' . $tone . '-subtle text-' . $tone . '-emphasis fw-normal"><i class="fas ' . $icon . ' me-1"></i>' . $label . '</span>';
}
/**
* Their MFA as a pill: on, required but not set up yet (they are asked at
* their next sign in), or off.
*/
function userMfaPill($token, $forced) {
if (trim((string) $token) !== '') {
return '<span class="badge rounded-pill bg-success-subtle text-success-emphasis fw-normal" title="Signs in with a code from an authenticator app' . (intval($forced) ? ' - required' : '') . '"><i class="fas fa-lock me-1"></i>MFA' . (intval($forced) ? ' · required' : '') . '</span>';
}
if (intval($forced)) {
return '<span class="badge rounded-pill bg-warning-subtle text-warning-emphasis fw-normal" title="Required - they set it up at their next sign in"><i class="fas fa-hourglass-half me-1"></i>MFA pending</span>';
}
return '<span class="badge rounded-pill bg-danger-subtle text-danger-emphasis fw-normal" title="Password only"><i class="fas fa-unlock me-1"></i>No MFA</span>';
}
/** One agent by id, with their settings and role - raw, or null. */
function userRow($user_id) {
global $mysqli;
$user_id = intval($user_id);
if (!$user_id) {
return null;
}
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT users.user_id, user_name, user_email, user_auth_method, user_type, user_status, user_token, user_avatar,
user_created_at, user_updated_at, user_archived_at, user_role_id, role_name, role_is_admin, role_archived_at,
user_config_force_mfa, user_config_calendar_first_day, user_config_theme_dark, user_config_records_per_page, user_config_signature
FROM users
LEFT JOIN user_settings ON user_settings.user_id = users.user_id
LEFT JOIN user_roles ON role_id = user_role_id
WHERE users.user_id = $user_id AND user_type = 1 LIMIT 1"));
return $row ?: null;
}
/**
* For admin post handlers: the agent a request names - or back with an error
* when there is none. With $not_self, the admin's own account is refused:
* that is changed from their Account pages, so nobody locks themselves out
* from here.
*/
function userForRequest($user_id, $not_self = true) {
global $session_user_id;
$row = userRow($user_id);
if (!$row) {
flashAlert('That user no longer exists.', 'error');
redirect();
}
if ($not_self && intval($row['user_id']) === intval($session_user_id)) {
flashAlert('Change your own account from <strong>Account</strong> in the top right.', 'error');
redirect();
}
return $row;
}
/** For admin modals: the same, or the modal's error. */
function userForModal($user_id) {
$row = userRow($user_id);
if (!$row) {
echo json_encode(['error' => 'That user no longer exists.']);
exit;
}
return $row;
}
/** The agents a bulk action names (user_ids[]), never the admin themselves - [id => row]. */
function usersForBulk() {
global $session_user_id;
$rows = [];
foreach (array_unique(array_map('intval', (array) ($_POST['user_ids'] ?? []))) as $user_id) {
$row = userRow($user_id);
if ($row && $user_id !== intval($session_user_id)) {
$rows[$user_id] = $row;
}
}
return $rows;
}
/** The roles an agent can have - [role_id => row], admins first. */
function userRoles($include_role_id = 0) {
global $mysqli;
$roles = [];
$sql = mysqli_query($mysqli, "SELECT role_id, role_name, role_description, role_is_admin, role_archived_at FROM user_roles
WHERE role_type = 1 AND (role_archived_at IS NULL OR role_id = " . intval($include_role_id) . ")
ORDER BY role_is_admin DESC, role_name ASC");
while ($row = mysqli_fetch_assoc($sql)) {
$roles[intval($row['role_id'])] = $row;
}
return $roles;
}
/** A role in a picker - a shield for an admin role, and what it is for underneath. */
function selectOptionRole($role) {
return selectOptionData([
'icon' => 'fas ' . (intval($role['role_is_admin']) ? 'fa-user-shield' : 'fa-user-tag'),
'description' => implode(' · ', array_filter([intval($role['role_is_admin']) ? 'Administrator - every client, every setting' : '', $role['role_description']])),
]);
}
/** How many active agents with an admin role there are, leaving out $except_user_id. */
function userActiveAdminCount($except_user_id = 0) {
global $mysqli;
return intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM users JOIN user_roles ON role_id = user_role_id
WHERE user_type = 1 AND user_status = 1 AND user_archived_at IS NULL AND role_is_admin = 1 AND user_id <> " . intval($except_user_id)))[0]);
}
/** Would taking this agent out of the admins (a new role, disabled, archived) leave nobody to run the place? */
function userIsLastAdmin($row) {
return intval($row['role_is_admin']) === 1 && intval($row['user_status']) === 1 && empty($row['user_archived_at'])
&& userActiveAdminCount($row['user_id']) === 0;
}
/**
* The name and email of New User / Edit User / the agent's own details,
* checked - [name, email] raw, or null with $error. The email signs them in,
* so no other account that can sign in may have it.
*/
function userCheckIdentity($post, $user_id, &$error = '') {
global $mysqli;
$name = trim((string) ($post['name'] ?? ''));
$email = trim((string) ($post['email'] ?? ''));
if ($name === '') {
$error = 'A user needs a name.';
return null;
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$error = '<strong>' . ($email ?: 'Nothing') . '</strong> is not an email address.';
return null;
}
$email_sql = escapeSql($email);
$taken = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT user_id, user_type FROM users WHERE user_email = '$email_sql' AND user_archived_at IS NULL AND user_id <> " . intval($user_id) . " LIMIT 1"));
if ($taken) {
$error = '<strong>' . $email . '</strong> already signs in to ' . (intval($taken['user_type']) === 1 ? 'another agent account' : 'a client portal account') . ' - each account needs its own email.';
return null;
}
return [mb_substr($name, 0, 200), mb_substr($email, 0, 200)];
}
/** A role an admin may give: one that exists and is not archived (or the one they have). Its row, or null with $error. */
function userCheckRole($role_id, $current_role_id, &$error = '') {
$roles = userRoles($current_role_id);
$role_id = intval($role_id);
if (!isset($roles[$role_id]) || ($roles[$role_id]['role_archived_at'] && $role_id !== intval($current_role_id))) {
$error = 'Pick a role for them.';
return null;
}
return $roles[$role_id];
}
/** Is this a password ITFlow takes? '' when it is, the reason when not. */
function userPasswordProblem($password) {
if (strlen((string) $password) < USER_PASSWORD_MIN) {
return 'A password is at least ' . USER_PASSWORD_MIN . ' characters.';
}
return '';
}
/**
* Set an agent's password - the hash they sign in with, and their copy of
* the vault key under it. Exactly what was typed, as the sign in page checks
* exactly what is typed.
*/
function userSetPassword($user_id, $password) {
global $mysqli;
$hash = escapeSql(password_hash((string) $password, PASSWORD_DEFAULT));
$ciphertext = escapeSql(encryptUserSpecificKey((string) $password));
mysqli_query($mysqli, "UPDATE users SET user_password = '$hash', user_specific_encryption_ciphertext = '$ciphertext' WHERE user_id = " . intval($user_id));
}
/** Turn an agent's MFA off - and forget the browsers it trusted, which have to sign in afresh. */
function userResetMfa($user_id) {
global $mysqli;
$user_id = intval($user_id);
mysqli_query($mysqli, "UPDATE users SET user_token = '' WHERE user_id = $user_id");
mysqli_query($mysqli, "DELETE FROM remember_tokens WHERE remember_token_user_id = $user_id");
}
/**
* Take an uploaded picture as an agent's avatar - a real image (checked,
* not trusted by its name), up to 5 MB - replacing the old one. True when
* set, false with $error when not.
*/
function userSaveAvatar($user_id, $file, &$error = '') {
global $mysqli;
$user_id = intval($user_id);
if (empty($file['tmp_name']) || ($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) {
return false;
}
$new_name = checkFileUpload($file, ['jpg', 'jpeg', 'gif', 'png', 'webp']);
$image = $new_name ? @getimagesize($file['tmp_name']) : false;
if (!$new_name || !$image || !in_array($image[2], [IMAGETYPE_JPEG, IMAGETYPE_GIF, IMAGETYPE_PNG, IMAGETYPE_WEBP], true)) {
$error = 'The avatar has to be a JPG, PNG, GIF or WebP picture.';
return false;
}
if ($file['size'] > 5 * 1024 * 1024) {
$error = 'The avatar can be up to 5 MB.';
return false;
}
$dir = dirname(__DIR__) . "/uploads/users/$user_id";
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
if (!move_uploaded_file($file['tmp_name'], "$dir/$new_name")) {
$error = 'The avatar could not be saved.';
return false;
}
userRemoveAvatar($user_id);
mysqli_query($mysqli, "UPDATE users SET user_avatar = '" . escapeSql($new_name) . "' WHERE user_id = $user_id");
return true;
}
/** Remove an agent's avatar - the file and the reference to it. */
function userRemoveAvatar($user_id) {
global $mysqli;
$user_id = intval($user_id);
$old = (string) getFieldById('users', $user_id, 'user_avatar');
if ($old !== '' && basename($old) === $old && is_file(dirname(__DIR__) . "/uploads/users/$user_id/$old")) {
unlink(dirname(__DIR__) . "/uploads/users/$user_id/$old");
}
mysqli_query($mysqli, "UPDATE users SET user_avatar = NULL WHERE user_id = $user_id");
}
/**
* The clients an agent is limited to or kept from, from the Client access
* tab's radios (client_permission[client_id] = allow / deny / '') - replaces
* whatever they had. Only clients that exist are written.
*/
function userSetClientAccess($user_id, $permissions) {
global $mysqli;
$user_id = intval($user_id);
mysqli_query($mysqli, "DELETE FROM user_client_permissions WHERE user_id = $user_id");
foreach ((array) $permissions as $client_id => $type) {
$client_id = intval($client_id);
if (($type === 'allow' || $type === 'deny') && $client_id) {
mysqli_query($mysqli, "INSERT INTO user_client_permissions (user_id, client_id, permission_type)
SELECT $user_id, client_id, '$type' FROM clients WHERE client_id = $client_id");
}
}
}
/**
* The client rules of some agents - [user_id => ['allow' => [client_id =>
* name], 'deny' => [...]]].
*/
function userClientAccess(array $user_ids) {
global $mysqli;
$user_ids = array_values(array_filter(array_map('intval', $user_ids)));
$access = [];
foreach ($user_ids as $id) {
$access[$id] = ['allow' => [], 'deny' => []];
}
if ($user_ids) {
$sql = mysqli_query($mysqli, "SELECT user_id, user_client_permissions.client_id, permission_type, client_name FROM user_client_permissions
JOIN clients ON clients.client_id = user_client_permissions.client_id
WHERE user_id IN (" . implode(',', $user_ids) . ") ORDER BY client_name");
while ($row = mysqli_fetch_assoc($sql)) {
$access[intval($row['user_id'])][$row['permission_type'] === 'deny' ? 'deny' : 'allow'][intval($row['client_id'])] = $row['client_name'];
}
}
return $access;
}
/** What an agent can see, in words: "All clients", "3 clients", "All but 1". An admin sees everything. */
function userClientAccessText($access, $is_admin) {
if ($is_admin) {
return 'All clients (admin)';
}
$allow = count($access['allow']);
$deny = count($access['deny']);
if ($allow) {
return $allow . ' client' . ($allow === 1 ? '' : 's') . ($deny ? ", $deny kept out" : '');
}
return $deny ? "All but $deny client" . ($deny === 1 ? '' : 's') : 'All clients';
}
/**
* The last successful sign in of some agents - [user_id => log row with
* log_created_at, log_ip, log_user_agent], or nothing for one never signed in.
*/
function userLastLogins(array $user_ids) {
global $mysqli;
$user_ids = array_values(array_filter(array_map('intval', $user_ids)));
$logins = [];
if ($user_ids) {
$sql = mysqli_query($mysqli, "SELECT log_user_id, log_created_at, log_ip, log_user_agent FROM logs
WHERE log_id IN (SELECT MAX(log_id) FROM logs WHERE log_user_id IN (" . implode(',', $user_ids) . ") AND log_type = 'Login' AND log_action = 'Success' GROUP BY log_user_id)");
while ($row = mysqli_fetch_assoc($sql)) {
$logins[intval($row['log_user_id'])] = $row;
}
}
return $logins;
}
/**
* An agent's latest sign ins and failed MFA codes, newest first - log rows
* with log_action, log_created_at, log_ip, log_user_agent. Theirs only.
*/
function userRecentLogins($user_id, $limit = 10) {
global $mysqli;
$rows = [];
$sql = mysqli_query($mysqli, "SELECT log_id, log_action, log_description, log_created_at, log_ip, log_user_agent FROM logs
WHERE log_user_id = " . intval($user_id) . " AND log_type IN ('Login', 'Login 2FA')
ORDER BY log_id DESC LIMIT " . max(1, intval($limit)));
while ($row = mysqli_fetch_assoc($sql)) {
$rows[] = $row;
}
return $rows;
}
/** One of those as a line - a tick for a sign in, a cross for a failed code - with when and from where. Raw in, HTML out. */
function userLoginLineHtml($log) {
$ok = $log['log_action'] === 'Success';
return '<div class="d-flex gap-2 align-items-start">'
. '<i class="fas fa-fw ' . ($ok ? 'fa-check-circle text-success' : 'fa-times-circle text-danger') . ' mt-1" title="' . escapeHtml($log['log_action']) . '"></i>'
. '<div class="min-w-0"><span title="' . escapeHtml($log['log_created_at']) . '">' . escapeHtml(timeAgo($log['log_created_at'])) . '</span>'
. ($ok ? '' : ' <span class="text-danger-emphasis">' . escapeHtml($log['log_action'] === 'MFA Failed' ? 'wrong MFA code' : $log['log_action']) . '</span>')
. '<div class="small text-body-secondary">' . userDeviceHtml($log['log_user_agent']) . ' · <span class="font-monospace">' . escapeHtml($log['log_ip']) . '</span></div></div>'
. '</div>';
}
/** The browsers that skip an agent's MFA code (remember me) - [user_id => count]. */
function userRememberCounts(array $user_ids) {
global $mysqli, $config_login_remember_me_expire;
$user_ids = array_values(array_filter(array_map('intval', $user_ids)));
$counts = array_fill_keys($user_ids, 0);
if ($user_ids) {
$days = max(1, intval($config_login_remember_me_expire ?? 30));
$sql = mysqli_query($mysqli, "SELECT remember_token_user_id, COUNT(*) FROM remember_tokens
WHERE remember_token_user_id IN (" . implode(',', $user_ids) . ") AND remember_token_created_at > NOW() - INTERVAL $days DAY GROUP BY remember_token_user_id");
while ($row = mysqli_fetch_row($sql)) {
$counts[intval($row[0])] = intval($row[1]);
}
}
return $counts;
}
/**
* The browsers that skip an agent's MFA code, newest first - each with when
* it was remembered, until when, whether it is the browser asking, and the
* sign in it was remembered at (for the device and address - the token
* itself records neither).
*/
function userRememberedBrowsers($user_id) {
global $mysqli, $config_login_remember_me_expire;
$user_id = intval($user_id);
$days = max(1, intval($config_login_remember_me_expire ?? 30));
$cookie = (string) ($_COOKIE['rememberme'] ?? '');
$rows = [];
$sql = mysqli_query($mysqli, "SELECT remember_token_id, remember_token_token, remember_token_created_at,
remember_token_created_at + INTERVAL $days DAY AS remember_token_expires_at
FROM remember_tokens
WHERE remember_token_user_id = $user_id AND remember_token_created_at > NOW() - INTERVAL $days DAY
ORDER BY remember_token_id DESC");
while ($row = mysqli_fetch_assoc($sql)) {
$created = escapeSql($row['remember_token_created_at']);
$row['login'] = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT log_created_at, log_ip, log_user_agent FROM logs
WHERE log_user_id = $user_id AND log_type = 'Login' AND log_action = 'Success'
AND log_created_at BETWEEN '$created' - INTERVAL 2 MINUTE AND '$created' + INTERVAL 2 MINUTE
ORDER BY ABS(TIMESTAMPDIFF(SECOND, log_created_at, '$created')) LIMIT 1"));
$row['is_current'] = $cookie !== '' && hash_equals((string) $row['remember_token_token'], $cookie);
unset($row['remember_token_token']);
$rows[] = $row;
}
return $rows;
}
/** The device a sign in came from - "Windows Chrome" with their icons - from its user agent. HTML. */
function userDeviceHtml($user_agent) {
$parts = array_filter([getOS((string) $user_agent), getWebBrowser((string) $user_agent)], function ($part) {
return $part !== '-';
});
return $parts ? implode(' ', $parts) : 'Unknown device';
}
/** A sign in, in words: when, and from what - "2 hours ago · Windows · Chrome · 203.0.113.9". Raw values in, HTML out. */
function userLoginHtml($log, $with_ip = true) {
if (!$log) {
return '<span class="text-body-secondary">Never signed in</span>';
}
return '<span title="' . escapeHtml($log['log_created_at']) . '">' . escapeHtml(timeAgo($log['log_created_at'])) . '</span>'
. '<div class="small text-body-secondary text-nowrap">' . userDeviceHtml($log['log_user_agent'])
. ($with_ip ? ' · <span class="font-monospace">' . escapeHtml($log['log_ip']) . '</span>' : '') . '</div>';
}
/**
* The QR code an authenticator app scans to set up MFA, drawn here as an
* SVG - the secret is never put in an image URL, where it would land in the
* web server's access log.
*/
function userMfaQrSvg($secret, $email) {
global $config_app_name;
// libs/barcode/barcode.php stops the page outright without GD - then it is the secret to type in
if (!extension_loaded('gd')) {
return '';
}
require_once dirname(__DIR__) . '/libs/barcode/barcode.php';
$issuer = (string) ($config_app_name ?: 'ITFlow');
$uri = 'otpauth://totp/' . rawurlencode($issuer) . ':' . rawurlencode((string) $email) . '?secret=' . rawurlencode((string) $secret) . '&issuer=' . rawurlencode($issuer);
$generator = new barcode_generator();
$svg = $generator->render_svg('qr', $uri, ['w' => 200, 'h' => 200, 'p' => 0]);
// Shown inline in the page, not as an image - the XML prolog has to go, and it scales with its box
$svg = preg_replace('/^<\?xml[^>]*>\s*/', '', (string) $svg);
return preg_replace('/<svg /', '<svg class="itflow-mfa-qr" role="img" aria-label="QR code for your authenticator app" ', $svg, 1);
}
/** An agent's work not finished - [tickets not closed, recurring tickets] assigned to them. */
function userOpenWork($user_id) {
global $mysqli;
$user_id = intval($user_id);
return [
intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM tickets WHERE ticket_assigned_to = $user_id AND ticket_closed_at IS NULL"))[0]),
intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM recurring_tickets WHERE recurring_ticket_assigned_to = $user_id"))[0]),
];
}
/**
* Hand an agent's tickets that are not closed, and their recurring tickets,
* to another agent - or to nobody ($to_user_id 0) - each ticket's history
* saying so and why ("when Sam was archived").
*/
function userHandOverWork($user_id, $to_user_id, $why) {
global $mysqli, $session_name;
$user_id = intval($user_id);
$to_user_id = intval($to_user_id);
$to_name = $to_user_id ? (string) getFieldById('users', $to_user_id, 'user_name') : '';
$ticket_ids = [];
$sql = mysqli_query($mysqli, "SELECT ticket_id FROM tickets WHERE ticket_assigned_to = $user_id AND ticket_closed_at IS NULL");
while ($row = mysqli_fetch_row($sql)) {
$ticket_ids[] = intval($row[0]);
}
mysqli_query($mysqli, "UPDATE tickets SET ticket_assigned_to = $to_user_id WHERE ticket_assigned_to = $user_id AND ticket_closed_at IS NULL");
mysqli_query($mysqli, "UPDATE recurring_tickets SET recurring_ticket_assigned_to = $to_user_id WHERE recurring_ticket_assigned_to = $user_id");
foreach ($ticket_ids as $ticket_id) {
logTicketHistory($ticket_id, $to_name !== '' ? "$session_name reassigned the ticket to " . escapeSql($to_name) . " $why" : "$session_name unassigned the ticket $why");
}
}
/**
* Queue an email to an agent from the system mailbox. addToMailQueue() takes
* values ready for SQL, so they are escaped here - the body with
* mysqli_real_escape_string(), not escapeSql(), which would strip its HTML.
* True when queued, or the error.
*/
function userQueueMail($recipient, $recipient_name, $subject, $body_html) {
global $mysqli, $config_mail_from_email, $config_mail_from_name;
$escape = function ($value) use ($mysqli) {
return mysqli_real_escape_string($mysqli, (string) $value);
};
return addToMailQueue([[
'from' => $escape($config_mail_from_email),
'from_name' => $escape($config_mail_from_name),
'recipient' => $escape($recipient),
'recipient_name' => $escape($recipient_name),
'subject' => $escape($subject),
'body' => $escape($body_html),
]]);
}
/** An agent's own email, sent from the system - their account was changed. Queued; nothing when mail is not set up. */
function userNotifyChange($name, $email, $what) {
global $config_smtp_host, $config_app_name, $session_company_name;
if (empty($config_smtp_host) || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
return;
}
$app = $config_app_name ?: 'ITFlow';
userQueueMail($email, $name, "$app account update for $name",
'Hi ' . escapeHtml($name) . ',<br><br>Your ' . escapeHtml($app) . ' account was changed: <b>' . escapeHtml($what) . '</b><br><br>'
. 'If this was not you, contact your ' . escapeHtml($app) . ' administrator straight away.<br><br>Thanks,<br>' . escapeHtml($session_company_name ?? $app));
}

View File

@@ -73,26 +73,16 @@
<li class="nav-item dropdown user-menu">
<a href="#" class="nav-link" data-bs-toggle="dropdown">
<?php if (empty($session_avatar)) { ?>
<i class="fas fa-user-circle me-1"></i>
<?php }else{ ?>
<img src="<?= "/uploads/users/$session_user_id/$session_avatar" ?>"
class="user-image rounded-circle">
<?php } ?>
<span class="me-md-2" data-bs-theme="light"><?= userAvatar($session_user_id, stripslashes($session_name), $session_avatar, 'sm') ?></span>
<span class="d-none d-md-inline dropdown-toggle"><?= stripslashes(escapeHtml($session_name)) ?></span>
</a>
<ul class="dropdown-menu dropdown-menu-lg dropdown-menu-end">
<!-- User image -->
<li class="user-header bg-dark">
<?php if (empty($session_avatar)) { ?>
<i class="fas fa-user-circle fa-6x"></i>
<?php }else{ ?>
<img src="<?= "/uploads/users/$session_user_id/$session_avatar" ?>" class="rounded-circle">
<?php } ?>
<li class="user-header bg-dark itflow-user-header">
<span class="d-inline-block" data-bs-theme="light"><?= userAvatar($session_user_id, stripslashes($session_name), $session_avatar, 'xl') ?></span>
<p>
<?= stripslashes(escapeHtml($session_name)) ?>
<small><?= escapeHtml($session_user_role_display) ?></small>
<small><?= escapeHtml($session_email) ?> · <?= escapeHtml(stripslashes($session_user_role_display)) ?></small>
</p>
</li>
<!-- Menu Footer-->