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. * Invited is the people asked to join who aren't in yet: the invites waiting * or run out (functions/user_invite.php), to send again, get the link to * pass on, or cancel. A link made to pass on is shown once, at the top. * * Client portal is the other people who sign in: the contacts with a login to * the client portal, at every client (functions/portal.php). Each row is the * contact, their client, how they sign in and when they last did - to email * a password link or the invite again, turn two-step sign-in off, and turn * their access off or back on (admin/post/portal_users.php). They are made * from a client's Contacts page, or by a primary contact in the portal. * * 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"; // 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; // The search is for the side being looked at - agents and their invites, or portal users - and leaves the other side's counts alone $on_portal = ($_GET['show'] ?? '') === 'portal'; $agent_q = $on_portal ? '' : $q; $portal_q = $on_portal ? $q : ''; $filters = "user_type = 1 AND (user_name LIKE '%$agent_q%' OR user_email LIKE '%$agent_q%' OR role_name LIKE '%$agent_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'], 'invited' => ['label' => 'Invited', 'icon' => 'fa-paper-plane', 'where' => null, 'title' => 'Asked to join and not in yet - their invite waiting, or run out'], 'archived' => ['label' => 'Archived', 'icon' => 'fa-archive', 'where' => 'user_archived_at IS NOT NULL', 'title' => 'Gone - restore one with a new password'], 'portal' => ['label' => 'Client portal', 'icon' => 'fa-user-circle', 'where' => null, 'title' => 'The contacts who sign in to the client portal, at every client'], ]; if (!userInvitesReady()) { unset($shows['invited']); } $show = isset($shows[$_GET['show'] ?? '']) ? ($_GET['show'] ?? '') : ''; // Client portal - contacts, not agents, so a list of their own: narrowed to a client, and by how they sign in $portal_joins = "JOIN contacts ON contact_user_id = users.user_id JOIN clients ON client_id = contact_client_id"; $portal_never_sql = "NOT EXISTS (SELECT 1 FROM logs WHERE log_user_id = users.user_id AND log_type = 'Client Login' AND log_action = 'Success')"; $portal_accesses = [ '' => ['label' => 'Can sign in', 'icon' => 'fas fa-check-circle', 'where' => PORTAL_USER_ON_SQL], 'local' => ['label' => 'Password', 'icon' => 'fas fa-key', 'where' => PORTAL_USER_ON_SQL . " AND user_auth_method = 'local'"], 'azure' => ['label' => 'Microsoft 365', 'icon' => 'fab fa-microsoft', 'where' => PORTAL_USER_ON_SQL . " AND user_auth_method = 'azure'"], 'nomfa' => ['label' => 'No two-step', 'icon' => 'fas fa-unlock', 'where' => PORTAL_USER_ON_SQL . " AND user_auth_method = 'local' AND (user_token IS NULL OR user_token = '')"], 'never' => ['label' => 'Never signed in', 'icon' => 'fas fa-user-clock', 'where' => PORTAL_USER_ON_SQL . " AND $portal_never_sql"], 'off' => ['label' => 'Access off', 'icon' => 'fas fa-ban', 'where' => 'NOT (' . PORTAL_USER_ON_SQL . ')'], ]; $portal_access = isset($portal_accesses[$_GET['access'] ?? '']) ? ($_GET['access'] ?? '') : ''; $portal_client = intval($_GET['client'] ?? 0); $portal_filters = "user_type = 2 AND (contact_name LIKE '%$portal_q%' OR user_email LIKE '%$portal_q%' OR client_name LIKE '%$portal_q%')" . ($portal_client ? " AND client_id = $portal_client" : ''); // The invites - not users yet, so a list of their own $invite_filters = "invite_accepted_at IS NULL AND invite_cancelled_at IS NULL AND (invite_name LIKE '%$agent_q%' OR invite_email LIKE '%$agent_q%' OR role_name LIKE '%$agent_q%')" . ($role_filter ? " AND invite_role_id = $role_filter" : ''); $invite_joins = "LEFT JOIN user_roles ON role_id = invite_role_id LEFT JOIN users inviter ON inviter.user_id = invite_created_by AND invite_created_by > 0"; // 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", ]; // ...and the same headers sort the invites: when it runs out for Sign in, when it was sent for Added $invite_sortable_columns = [ 'user_name' => "invite_name $order", 'user_email' => "invite_email $order", 'role_name' => "role_is_admin DESC, role_name $order, invite_name ASC", 'user_status' => "invite_expires_at $order, invite_name ASC", 'user_created_at' => "invite_created_at $order, invite_name ASC", ]; // ...and the portal users: their contact's name, their client, how they sign in, when they last did $portal_last_login_sql = "(SELECT MAX(log_id) FROM logs WHERE log_user_id = users.user_id AND log_type = 'Client Login' AND log_action = 'Success')"; $portal_sortable_columns = [ 'user_name' => "contact_name $order", 'user_email' => "user_email $order, contact_name ASC", 'client_name' => "client_name $order, contact_name ASC", 'user_status' => "user_auth_method $order, (user_token IS NULL OR user_token = '') $order, contact_name ASC", 'last_login' => "$portal_last_login_sql IS NULL, $portal_last_login_sql $order, contact_name ASC", ]; if (!isset(($show === 'invited' ? $invite_sortable_columns : ($show === 'portal' ? $portal_sortable_columns : $sortable_columns))[$sort])) { $sort = 'user_name'; } $users = []; $invites = []; $portal_users = []; if ($show === 'portal') { $sql = mysqli_query($mysqli, "SELECT SQL_CALC_FOUND_ROWS users.user_id, user_email, user_auth_method, user_status, user_token, user_archived_at, user_created_at, contact_id, contact_name, contact_title, contact_photo, contact_archived_at, client_id, client_name, client_archived_at FROM users $portal_joins WHERE $portal_filters AND {$portal_accesses[$portal_access]['where']} ORDER BY {$portal_sortable_columns[$sort]}, users.user_id ASC LIMIT $record_from, $record_to"); $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()")); while ($row = mysqli_fetch_assoc($sql)) { $portal_users[intval($row['user_id'])] = $row; } } elseif ($show === 'invited') { $sql = mysqli_query($mysqli, "SELECT SQL_CALC_FOUND_ROWS user_invites.*, role_name, role_is_admin, inviter.user_name AS invite_by_name FROM user_invites $invite_joins WHERE $invite_filters ORDER BY {$invite_sortable_columns[$sort]}, invite_id ASC LIMIT $record_from, $record_to"); $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()")); while ($row = mysqli_fetch_assoc($sql)) { $invites[] = $row; } } else { $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()")); 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)); // For the portal view: their last sign in, the browsers that skip their code, and the clients to narrow it to $portal_logins = portalLastSignIns(array_keys($portal_users)); $portal_remembered = userRememberCounts(array_keys($portal_users)); $portal_clients = []; if ($show === 'portal') { $sql = mysqli_query($mysqli, "SELECT client_id, client_name, client_abbreviation, COUNT(*) AS portal_users FROM users $portal_joins WHERE user_type = 2 GROUP BY client_id, client_name, client_abbreviation ORDER BY client_name ASC"); while ($row = mysqli_fetch_assoc($sql)) { $portal_clients[intval($row['client_id'])] = $row; } } $portal_mail_ready = portalMailReady(); $portal_two_step = function ($row) use ($config_client_portal_require_mfa) { // Two-step sign-in is theirs to turn on, unless the portal requires it - so no alarm for one without if (trim((string) $row['user_token']) !== '') { return 'Two-step'; } if (intval($config_client_portal_require_mfa)) { return 'Two-step pending'; } return 'No two-step'; }; // How many in each view, over what the search and role leave $user_views = array_filter($shows, function ($s) { return $s['where'] !== null; }); $view_counts = []; $row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT " . implode(', ', array_map(function ($s) { return "COALESCE(SUM({$s['where']}), 0)"; }, $user_views)) . " FROM users $joins WHERE $filters")); foreach (array_keys($user_views) as $i => $key) { $view_counts[$key] = intval($row[$i]); } if (isset($shows['invited'])) { $view_counts['invited'] = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM user_invites $invite_joins WHERE $invite_filters"))[0]); } // Those who can sign in to the portal $view_counts['portal'] = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM users $portal_joins WHERE $portal_filters AND " . PORTAL_USER_ON_SQL))[0]); // Agent user limit (set in config.php, 0 / unset = unlimited) - an invite waiting holds a seat $agent_user_limit = getAgentUserLimit(); $agent_user_count = getActiveAgentUserCount(); $agent_invites_holding = userInvitesHoldingSeats(); $agent_user_limit_reached = $agent_user_limit > 0 && $agent_user_count + $agent_invites_holding >= $agent_user_limit; // A managed instance - the user its manager signs in as for support, and the limit when the manager sets it $managed_support_id = managedSupportUserId(); $managed_limit_by = managedLocks()['seat_limit'] ? managedManagerName() : ''; // An invite link to pass on - shown this once $invite_link = userInviteTakeLink(); $invite_mail_ready = userInviteMailReady(); $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 '' . $label . ($sort === $column ? " $order_icon" : '') . ''; }; $csrf = $_SESSION['csrf_token']; ?>

Users 0) { ?> , of the agent user limit "> of seats

The invite link for
Send it to yourself - by chat, text or your own email. With it they choose their own password and join as . It works for days and is only shown now - Get the link again makes a new one.
The client portal is off, so nobody here can sign in - turn it on in Modules.
text-nowrap table-light"> $row) { $contact_id = intval($row['contact_id']); $client_id = intval($row['client_id']); $off_why = portalUserOffWhy($row); $is_on = $off_why === ''; $is_local = $row['user_auth_method'] === 'local'; $has_mfa = trim((string) $row['user_token']) !== ''; $can_turn_on = !$is_on && empty($row['client_archived_at']) && empty($row['contact_archived_at']); $name = escapeHtml($row['contact_name']); $contact_url = "/agent/contact.php?client_id=$client_id&contact_id=$contact_id"; $client_link = '' . escapeHtml($row['client_name']) . '' . (!empty($row['client_archived_at']) ? ' (archived)' : ''); $login = $portal_logins[$user_id] ?? null; $remember_count = $portal_remembered[$user_id] ?? 0; $sign_in_pills = $is_on ? '' . ($is_local ? 'Password' : 'Microsoft 365') . '' . ($is_local ? ' ' . $portal_two_step($row) : '') : 'Access off'; ?>
remembered browser
text-nowrap table-light"> Role removed' : '' . escapeHtml($invite['role_name']); $invite_pill = '' . $invite_label['label'] . ''; ?>
Clients
MFA required
·
Emailed 1 ? " · $invite_sent times" : '' ?> A link to pass on
text-nowrap table-light"> $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; ?>
Clients
You Support
No role
remembered browser
'portal users', 'icon' => 'fa-user-circle', 'filtered' => $q !== '' || $portal_client || $portal_access !== '', 'views' => ['show'], 'title' => 'Nobody signs in to the client portal yet', 'text' => "Give a contact portal access from their client's Contacts page - they show here, whichever client they are at.", ] : [ 'thing' => 'users', 'icon' => 'fa-users', 'filtered' => $q !== '' || $role_filter, 'archived' => $show === 'archived', 'views' => ['show'], 'view' => $show === 'nomfa' ? ['title' => 'Everyone active signs in with MFA', 'icon' => 'fa-check'] : ($show === 'invited' ? ['title' => 'No invites waiting', 'text' => 'Everyone invited has joined. Invite someone with New User.', 'icon' => 'fa-paper-plane'] : emptyStatePill($shows, $show)), ]; require_once "../includes/filter_footer.php"; ?>