Roles (admin/roles.php, admin/modals/role/*, * admin/post/roles.php). */ /** The levels a role can give a module. */ const ROLE_LEVELS = [0 => 'None', 1 => 'Read', 2 => 'Modify', 3 => 'Full']; /** A level's colour - None grey, Read blue, Modify amber, Full red. */ const ROLE_LEVEL_TONES = [0 => 'secondary', 1 => 'primary', 2 => 'warning', 3 => 'danger']; /* * The modules a role gives levels for, by module_name: label, icon, about * (what is in it) and levels (what each level adds - Modify and Full take in * the ones before). 'max' is the highest level that means anything. */ function roleModuleInfo() { return [ 'module_client' => [ 'label' => 'Clients', 'icon' => 'fa-users', 'about' => 'Clients, contacts, locations and vendors', 'levels' => [1 => 'See them', 2 => 'Add, edit, import and archive them', 3 => 'Delete them, and anonymize a contact'], ], 'module_support' => [ 'label' => 'Support', 'icon' => 'fa-life-ring', 'about' => 'Tickets, projects, assets and documentation', 'levels' => [1 => 'See them', 2 => 'Work tickets and projects, and add, edit and archive assets and documentation', 3 => 'Delete them'], ], 'module_credential' => [ 'label' => 'Credentials', 'icon' => 'fa-key', 'about' => 'Client usernames, passwords and 2FA codes', 'levels' => [1 => 'See and copy them', 2 => 'Add, edit and share them', 3 => 'Delete them'], ], 'module_sales' => [ 'label' => 'Sales', 'icon' => 'fa-file-invoice', 'about' => 'Quotes, invoices, recurring invoices and products', 'levels' => [1 => 'See them', 2 => 'Create, edit and send them - and record payments, with Financial at Modify too', 3 => 'Delete them, and edit or refund a payment'], ], 'module_financial' => [ 'label' => 'Financial', 'icon' => 'fa-piggy-bank', 'about' => 'Payments, income, expenses, accounts, budgets and trips', 'levels' => [1 => 'See them', 2 => 'Add, edit and archive them, and reconcile accounts', 3 => 'Delete them, and undo a reconciliation'], ], 'module_reporting' => [ 'label' => 'Reports', 'icon' => 'fa-chart-line', 'about' => 'The reports - each needs read access to what it reports on too', 'levels' => [1 => 'Open the reports'], 'max' => 1, ], 'module_ai' => [ 'label' => 'AI', 'icon' => 'fa-robot', 'about' => 'Rewording, summaries and drafts, by the AI providers in Admin - each needs access to what it works on too', 'levels' => [1 => 'Use AI'], 'max' => 1, ], ]; } /* * The modules, in their order - module_id => module_name, label, icon, * about, levels and max. One the list above doesn't know is named from its * module_name and its description. */ function roleModules() { global $mysqli; static $modules = null; if ($modules !== null) { return $modules; } $info = roleModuleInfo(); $modules = []; $sql = mysqli_query($mysqli, "SELECT module_id, module_name, module_description FROM modules ORDER BY module_id ASC"); while ($row = mysqli_fetch_assoc($sql)) { $name = $row['module_name']; $modules[intval($row['module_id'])] = ($info[$name] ?? [ 'label' => ucfirst(str_replace('module_', '', $name)), 'icon' => 'fa-cube', 'about' => (string) $row['module_description'], 'levels' => [1 => 'See it', 2 => 'Add, edit and archive', 3 => 'Delete'], ]) + ['module_name' => $name, 'max' => 3]; } return $modules; } /** Each role's levels - role_id => [module_id => level]. All roles when $role_ids is empty. */ function roleLevels(array $role_ids = []) { global $mysqli; $where = $role_ids ? 'WHERE user_role_id IN (' . implode(',', array_map('intval', $role_ids)) . ')' : ''; $levels = []; $sql = mysqli_query($mysqli, "SELECT user_role_id, module_id, user_role_permission_level FROM user_role_permissions $where"); while ($row = mysqli_fetch_row($sql)) { if (intval($row[2]) > 0) { $levels[intval($row[0])][intval($row[1])] = min(3, intval($row[2])); } } return $levels; } /* * The roles, admins first then by name - role_id => the row with levels * ([module_id => level]), agents (active ones) and agents_archived. All of * them, or the current or archived ones. */ function roleRows($archived = null) { global $mysqli; $where = $archived === null ? '1' : ($archived ? 'role_archived_at IS NOT NULL' : 'role_archived_at IS NULL'); $rows = []; $sql = mysqli_query($mysqli, "SELECT role_id, role_name, role_description, role_is_admin, role_created_at, role_updated_at, role_archived_at, (SELECT COUNT(*) FROM users WHERE user_role_id = role_id AND user_type = 1 AND user_archived_at IS NULL) AS agents, (SELECT COUNT(*) FROM users WHERE user_role_id = role_id AND user_type = 1 AND user_archived_at IS NOT NULL) AS agents_archived FROM user_roles WHERE role_type = 1 AND $where ORDER BY role_is_admin DESC, role_name ASC"); while ($row = mysqli_fetch_assoc($sql)) { $rows[intval($row['role_id'])] = $row; } $levels = roleLevels(array_keys($rows)); foreach ($rows as $role_id => $row) { $rows[$role_id]['levels'] = $levels[$role_id] ?? []; } return $rows; } /** One role as roleRows() gives it, or null. */ function roleRow($role_id) { $role_id = intval($role_id); foreach (roleRows() as $id => $row) { if ($id === $role_id) { return $row; } } return null; } /** A few of each role's active agents for its row - role_id => [user rows], up to $limit each. */ function roleAgents(array $role_ids, $limit = 5) { global $mysqli; $agents = []; if (!$role_ids) { return $agents; } $sql = mysqli_query($mysqli, "SELECT user_id, user_name, user_avatar, user_role_id FROM users WHERE user_type = 1 AND user_archived_at IS NULL AND user_role_id IN (" . implode(',', array_map('intval', $role_ids)) . ") ORDER BY user_name ASC"); while ($row = mysqli_fetch_assoc($sql)) { $role_id = intval($row['user_role_id']); if (count($agents[$role_id] ?? []) < $limit) { $agents[$role_id][] = $row; } } return $agents; } /* * What a role opens, as pills - one per module it has, its level in that * level's colour; an admin role as one pill. HTML. */ function roleAccessHtml(array $row) { if (intval($row['role_is_admin'])) { return 'Admin - everything'; } $pills = []; foreach (roleModules() as $module_id => $module) { $level = min(intval($row['levels'][$module_id] ?? 0), $module['max']); if (!$level) { continue; } $tone = ROLE_LEVEL_TONES[$level]; $title = $module['label'] . ' - ' . implode('; ', array_slice($module['levels'], 0, $level)); $pills[] = '' . escapeHtml($module['label']) . ($module['max'] > 1 ? ' ยท ' . ROLE_LEVELS[$level] : '') . ''; } return $pills ? '' . implode('', $pills) . '' : 'Nothing yet - it opens no part of ITFlow'; } /* * The New, Edit and Copy role window's fields, checked - [fields, error]. * fields: name, description, admin (0 or 1) and levels ([module_id => * level], every module, held to what it can have). $role_id is the role * being edited, 0 for a new one. */ function roleFromInput(array $in, $role_id = 0) { global $mysqli; $name = trim(mb_substr(preg_replace('/\s+/u', ' ', strip_tags((string) ($in['name'] ?? ''))), 0, 200)); $description = trim(mb_substr(strip_tags((string) ($in['description'] ?? '')), 0, 200)); if ($name === '') { return [null, 'A role needs a name.']; } $taken = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT role_name, role_archived_at FROM user_roles WHERE LOWER(role_name) = LOWER('" . escapeSql($name) . "') AND role_id <> " . intval($role_id) . " LIMIT 1")); if ($taken) { return [null, 'There is already a role called ' . $taken['role_name'] . ($taken['role_archived_at'] ? ' - it is archived, so restore it instead' : '') . '.']; } $levels = []; foreach (roleModules() as $module_id => $module) { $level = intval($in['levels'][$module_id] ?? 0); $levels[$module_id] = max(0, min($level, $module['max'])); } return [['name' => $name, 'description' => $description, 'admin' => !empty($in['admin']) ? 1 : 0, 'levels' => $levels], '']; } /* * Why a role can't stop being an admin role - '' when it can. Not your own * (you'd be shut out of this page), and not while its agents are the only * active admins. */ function roleAdminBlocker(array $row) { global $mysqli, $session_user_role; if (!intval($row['role_is_admin'])) { return ''; } if (intval($row['role_id']) === intval($session_user_role)) { return 'It is your own role - another admin can change it.'; } $others = 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 role_id <> " . intval($row['role_id'])))[0]); if ($others === 0 && intval($row['agents'])) { return 'Its agents are the only admins - nobody would be left to run Admin.'; } return ''; } /* Why a role can't be archived - '' when it can. Not while active agents have it or invites waiting are for it, nor your own. */ function roleArchiveBlocker(array $row) { global $session_user_role; if (intval($row['role_id']) === intval($session_user_role)) { return 'It is your own role.'; } if (intval($row['agents'])) { $n = intval($row['agents']); return $n . ' agent' . ($n === 1 ? ' has' : 's have') . ' it - give them another role first.'; } if (($n = userInvitesWithRole($row['role_id'])) > 0) { return $n . ' invite' . ($n === 1 ? ' is' : 's are') . ' waiting with it - cancel ' . ($n === 1 ? 'it' : 'them') . ' in Users, Invited, and invite again with another role.'; } return ''; } /* * Why a role can't be deleted - '' when it can. As archiving, and not while * an archived agent has it either, as they'd come back with no role. */ function roleDeleteBlocker(array $row) { $blocker = roleArchiveBlocker($row); if ($blocker !== '') { return $blocker; } if (intval($row['agents_archived'])) { $n = intval($row['agents_archived']); return $n . ' archived agent' . ($n === 1 ? ' has' : 's have') . ' it - archive the role instead.'; } return ''; } /* * What changed on a role, in words for the audit log - "admin taken off, * Sales Read to Modify, Reports given Read". Raw; '' when nothing did. */ function roleChangeText(array $before, array $fields) { $changes = []; if (($before['role_name'] ?? '') !== $fields['name']) { $changes[] = 'renamed from ' . $before['role_name']; } if ((string) ($before['role_description'] ?? '') !== $fields['description']) { $changes[] = 'description changed'; } if (intval($before['role_is_admin'] ?? 0) !== $fields['admin']) { $changes[] = $fields['admin'] ? 'made an admin role' : 'admin taken off'; } foreach (roleModules() as $module_id => $module) { $was = intval($before['levels'][$module_id] ?? 0); $now = intval($fields['levels'][$module_id] ?? 0); if ($was !== $now) { $changes[] = $module['label'] . ' ' . ROLE_LEVELS[$was] . ' to ' . ROLE_LEVELS[$now]; } } return implode(', ', $changes); } /* Save a role's fields (roleFromInput()) - a new role when $role_id is 0. Returns its id. */ function roleSave(array $fields, $role_id = 0) { global $mysqli; $role_id = intval($role_id); $set = "role_name = '" . escapeSql($fields['name']) . "', role_description = '" . escapeSql($fields['description']) . "', role_is_admin = " . intval($fields['admin']); if ($role_id) { mysqli_query($mysqli, "UPDATE user_roles SET $set WHERE role_id = $role_id"); } else { mysqli_query($mysqli, "INSERT INTO user_roles SET $set, role_type = 1"); $role_id = intval(mysqli_insert_id($mysqli)); } // An admin role keeps its levels too, for if it stops being one mysqli_query($mysqli, "DELETE FROM user_role_permissions WHERE user_role_id = $role_id"); foreach ($fields['levels'] as $module_id => $level) { if ($level > 0) { mysqli_query($mysqli, "INSERT INTO user_role_permissions SET user_role_id = $role_id, module_id = " . intval($module_id) . ", user_role_permission_level = " . intval($level)); } } return $role_id; } /* Archive or restore a role. */ function roleSetArchived($role_id, $archived) { global $mysqli; mysqli_query($mysqli, "UPDATE user_roles SET role_archived_at = " . ($archived ? 'NOW()' : 'NULL') . " WHERE role_id = " . intval($role_id)); } /* Delete a role and its levels - check roleDeleteBlocker() first. */ function roleDelete($role_id) { global $mysqli; $role_id = intval($role_id); mysqli_query($mysqli, "DELETE FROM user_role_permissions WHERE user_role_id = $role_id"); mysqli_query($mysqli, "DELETE FROM user_roles WHERE role_id = $role_id"); }