From 5e87f6fa25989676455133fd3d49d319094797bb Mon Sep 17 00:00:00 2001 From: johnnyq Date: Sat, 17 Oct 2026 20:43:45 -0400 Subject: [PATCH] Roles stage 1: Admin > Roles rebuilt showing each role's access per module as level-colored pills or Admin - everything, its agents with a link to Users filtered by role, search, Current and Archived tabs, and Edit, Copy, Archive, Restore and Delete greyed out with the reason when blocked; one role window for New, Edit and Copy with an Admin switch and a None, Read, Modify or Full level per module, each level listing what it allows, Reports limited to None and Read, and a copy dropping the Built-in description prefix; hardcoded role 3 checks replaced by guards so admin can't be taken off your own role or the only admins' role, archive needs no active agents, delete needs no agents at all, and names are unique in any case with the description optional; Administrator role can now be opened and renamed; role names escaped in flash messages, fixing an XSS; audit log records what changed on a role; lookupUserPermission reads the role's levels once per request instead of one query per check; new functions/roles.php, shared role_form.php and js/role_form.js; database 3.1.6 removes orphaned and duplicate user_role_permissions rows, clamps levels to 3 and adds a primary key on role and module; changelog updated --- CHANGELOG.md | 20 +- admin/database_updates/3.1.6.php | 25 +++ admin/modals/role/role_add.php | 203 +++----------------- admin/modals/role/role_edit.php | 200 +++----------------- admin/modals/role/role_form.php | 87 +++++++++ admin/post/roles.php | 144 +++++++++------ admin/roles.php | 291 +++++++++++++++-------------- css/itflow_custom.css | 15 ++ db.sql | 3 +- functions.php | 1 + functions/auth.php | 35 ++-- functions/roles.php | 306 +++++++++++++++++++++++++++++++ js/role_form.js | 39 ++++ 13 files changed, 798 insertions(+), 571 deletions(-) create mode 100644 admin/database_updates/3.1.6.php create mode 100644 admin/modals/role/role_form.php create mode 100644 functions/roles.php create mode 100644 js/role_form.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bec38562..a2206bb29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ This file documents all notable changes made to ITFlow. ### Upgrading to 26.11 -Update from Maintenance > Update — Queue Update hands the job to cron, which updates the files and applies this release's database changes (2.7.9 to 3.1.5) in the same pass. Nothing else is required. +Update from Maintenance > Update — Queue Update hands the job to cron, which updates the files and applies this release's database changes (2.7.9 to 3.1.6) in the same pass. Nothing else is required. This is a big release, with thirty-six database steps in one go, so take a backup before you update. @@ -805,6 +805,15 @@ If you are coming from 26.08 or earlier, the database update still has to be run - The invoice, quote and recurring invoice exports have a Category column, and the asset export a Status column, to tick. - Admin > Categories links what uses a category to these lists filtered by it too: quotes, recurring invoices, assets, licenses and racks. - API: read the categories - every kind the key can see, one kind, one by id or by name, archived ones too - with how many records have each. +- **Roles** (rebuilt) + - Admin > Roles shows what each role opens at a glance - its level of each part of ITFlow in that level's color, or Admin - everything - and its agents, opening Users filtered to them. Search, and Current and Archived tabs. + - New, Edit and Copy are one window. Admin is a switch; otherwise each part of ITFlow - Clients, Support, Credentials, Sales, Financial and Reports - gets None, Read, Modify or Full, with a line for each level saying what it lets them do, ticked as you pick. Reports only has None and Read, all it uses. A change reaches the role's agents from the next page they open. + - Copy starts a new role from another's levels. + - Archive a role nobody has to stop it being given out, and Restore it. Delete one no agent has, archived ones included. Archive and Delete are greyed out with the reason when they can't be used. + - The Administrator role can now be opened and renamed. No admin role can lose admin while it is your own, so nobody can lock themselves out of Admin. + - Role names are unique, in any case, and the description is optional. + - The audit log says what changed on a role: "Sales Read to Modify, Reports None to Read". + - A page reads the agent's role once, where it used to ask the database again at every check - dozens of times on a busy page. ### Bug Fixes @@ -899,6 +908,8 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Clients: saving a client with no referral made a referral with no name, and typing one that matched an archived referral made a second of it. - Licenses: the tab counts ignored the license type filter, so a tab could say more than it showed. - Racks: the Archived button broke onto two lines on a laptop-sized screen. +- Roles: a role's name went into the "created" and "edited" messages as it was typed, so markup in it ran on the page. +- Roles: an admin role could have admin taken off while its agents were the admins using it, your own included, shutting them out of Admin. Archived roles couldn't be seen or brought back. - License templates: the type picker showed a stray "<" at the top. - Admin > Settings > Defaults: Payment Method and Expense Payment Method listed the payment method categories that moved to Admin > Payment Methods in 2.3.1, so a new install had nothing to pick. They list Admin > Payment Methods now. - Messages, hints and window titles were reworded where they were unclear, and bulk windows say "1 asset" or "3 assets" correctly. @@ -1297,6 +1308,13 @@ If you are coming from 26.08 or earlier, the database update still has to be run - `selectOptionColor()` turns an icon kept as `fa-name` into `fas fa-name`. - `admin/settings_default.php` reads `paymentMethods()`, keeping a saved value that is no longer one. - CSS: `.itflow-category`, `-mark`, `-name` and `-badge` (`categoryChip()`), `.itflow-category-swatch-none`, `.itflow-category-kinds` and `.itflow-category-grip`. +- Roles (database 3.1.6): + - The migration deletes `user_role_permissions` rows of a role or module that is gone or of level 0 or less, keeps the highest level (held to 3) where a role has a module twice, and adds `PRIMARY KEY (user_role_id, module_id)`. + - `lookupUserPermission()` reads the session role's levels in one query and keeps them for the request (a `static`, keyed by role). It still returns `3` for an admin and `false` for none. + - New `functions/roles.php`, loaded by `functions.php`: `ROLE_LEVELS`, `ROLE_LEVEL_TONES`, `roleModuleInfo()` (by `module_name`: `label`, `icon`, `about`, `levels` - what each adds - and `max`), `roleModules()` (`module_id` => that, from the `modules` table, a module it doesn't know named from its row), `roleLevels($role_ids)`, `roleRows($archived)` (with `levels`, `agents` and `agents_archived`), `roleRow()`, `roleAgents($role_ids, $limit)`, `roleAccessHtml($row)`, `roleFromInput($in, $role_id)` (`[$fields, $error]`: `name`, `description`, `admin`, `levels`), `roleAdminBlocker()`, `roleArchiveBlocker()`, `roleDeleteBlocker()`, `roleChangeText($before, $fields)`, `roleSave($fields, $role_id)` (an admin role keeps its levels), `roleSetArchived()` and `roleDelete()`. + - Admin: `admin/roles.php` is rebuilt (`q`, `archived`; no paging or sorting). `admin/modals/role/role_form.php` is shared by `role_add.php` (`copy` - a role to start from) and `role_edit.php` (`id`), with `js/role_form.js`. `admin/post/roles.php`: `add_role` and `edit_role` (`role_id`, `name`, `description`, `admin`, `levels[module_id]` 0 to 3 - the old `role_name`, `role_description`, `role_is_admin` and `module_id##module_name` fields are gone), and GET `archive_role`, `restore_role` and `delete_role`. Audit entries are User Role Create, Edit (with what changed), Archive, Restore and Delete. + - The hard-coded `role_id !== 3` checks on the roles page are gone; the guards are `roleAdminBlocker()` and the archive and delete blockers. + - CSS: `.itflow-role-agents` and `.itflow-role-level-list`. - Empty states: every list page sets `$empty_state` before including `includes/filter_footer.php`, and `includes/inc_empty_state.php` draws it with `emptyStateHtml()` (`functions/format.php`) when the list has nothing - `thing`, `icon`, `title`, `text`, `actions` (`label`, `modal` or `href`, `size`, `icon`; the first is the main one), `archived`, `view`, `filtered`, `views`, `match_title`, `match_text` and `clear_url`. `emptyStateFiltered($views)` reads the GET string for a search or filter, leaving out paging, sorting, `client_id`, `archived` and the page's views, and `emptyStateClearUrl($views)` is the page without them. `emptyStatePill($pills, $key, $default, $good)` builds a pill's own state from the page's pill array (`label`, `icon`, `title`, `alarm`), with a tick for `alarm` pills and the keys in `$good`. Files, notifications and racks call `emptyStateHtml()` in their own layout; files and notifications set `$empty_state_skip`. A page that sets nothing still gets one, worded from `$page_title`. The pages' own empty blocks (`.itflow-asset-empty` under a list) are gone. CSS: `.itflow-empty-state` with `-icon` (a grey round tile), `-title`, `-text` and `-actions`. - Removed unused helpers and files: - `credentialLink()`, `projectLink()`, `linkCounts()`, `assetContactAvatar()`, `clientOverviewPersonInitials()`, and `getMonthlyTax()` and `getQuarterlyTax()`, which looked a tax up by name diff --git a/admin/database_updates/3.1.6.php b/admin/database_updates/3.1.6.php new file mode 100644 index 000000000..e9b14da4c --- /dev/null +++ b/admin/database_updates/3.1.6.php @@ -0,0 +1,25 @@ + + - -
+ -
- - +if (!$role) { ?> + + + -
+ +
- + +
+
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+
+ > + +
Every part of ITFlow, every client, and Admin - users, roles and settings.
+ + +
It stays an admin role:
+ +
+
+ +
> + + $module) { + $level = min(intval($r_levels[$module_id] ?? 0), $module['max']); + $label = escapeHtml($module['label']); ?> +
+
+
+ +
+
+
+
+
+
+ $level_label) { + if ($value > $module['max']) { + break; + } + $id = "roleLevel{$module_id}_$value"; ?> + > + + +
+
+
    + $text) { + $on = $value <= $level; ?> +
  • >
  • + +
+
+ +
+
>An admin role opens every part of ITFlow, so there are no levels to set. Its levels are kept, for if it stops being one.
+
+ + diff --git a/admin/post/roles.php b/admin/post/roles.php index d2c21e606..8b7ec982d 100644 --- a/admin/post/roles.php +++ b/admin/post/roles.php @@ -1,7 +1,8 @@ Roles) - making, changing, copying, archiving, restoring + * and deleting them. The work is in functions/roles.php. */ defined('FROM_POST_HANDLER') || die("Direct file access is not allowed"); @@ -10,31 +11,21 @@ if (isset($_POST['add_role'])) { validateCSRFToken(); - $name = escapeSql($_POST['role_name']); - $description = escapeSql($_POST['role_description']); - $admin = intval($_POST['role_is_admin']); - - mysqli_query($mysqli, "INSERT INTO user_roles SET role_name = '$name', role_description = '$description', role_is_admin = $admin"); - - $role_id = mysqli_insert_id($mysqli); - - // Insert role permissions (only if not admin) - if ($admin == 0) { - foreach ($_POST as $key => $value) { - if (str_contains($key, '##module_')) { - $module_id = intval(explode('##', $key)[0]); - $access_level = intval($value); - - if ($access_level > 0) { - mysqli_query($mysqli, "INSERT INTO user_role_permissions SET user_role_id = $role_id, module_id = $module_id, user_role_permission_level = $access_level"); - } - } - } + [$fields, $error] = roleFromInput($_POST); + if ($error !== '') { + flashAlert(escapeHtml($error), 'error'); + redirect(); } - logAudit("User Role", "Create", "$session_name created user role $name", 0, $role_id); + $role_id = roleSave($fields); - flashAlert("User Role $name created"); + $access = $fields['admin'] ? 'an admin role' : (implode(', ', array_filter(array_map(function ($module_id) use ($fields) { + $level = $fields['levels'][$module_id]; + return $level ? roleModules()[$module_id]['label'] . ' ' . ROLE_LEVELS[$level] : ''; + }, array_keys($fields['levels'])))) ?: 'no access'); + logAudit("User Role", "Create", "$session_name created role " . escapeSql($fields['name']) . " - " . escapeSql($access), 0, $role_id); + + flashAlert("Role " . escapeHtml($fields['name']) . " created"); redirect(); @@ -44,30 +35,30 @@ if (isset($_POST['edit_role'])) { validateCSRFToken(); - $role_id = intval($_POST['role_id']); - $name = escapeSql($_POST['role_name']); - $description = escapeSql($_POST['role_description']); - $admin = intval($_POST['role_is_admin']); - - mysqli_query($mysqli, "UPDATE user_roles SET role_name = '$name', role_description = '$description', role_is_admin = $admin WHERE role_id = $role_id"); - - // Update role access levels - mysqli_query($mysqli, "DELETE FROM user_role_permissions WHERE user_role_id = $role_id"); - foreach ($_POST as $key => $value) { - if (str_contains($key, '##module_')){ - $module_id = intval(explode('##', $key)[0]); - $access_level = intval($value); - - if ($access_level > 0) { - mysqli_query($mysqli, "INSERT INTO user_role_permissions SET user_role_id = $role_id, module_id = $module_id, user_role_permission_level = $access_level"); - } - } + $row = roleRow(intval($_POST['role_id'] ?? 0)); + if (!$row) { + flashAlert("That role is gone - it may have been deleted", 'error'); + redirect(); + } + $role_id = intval($row['role_id']); + [$fields, $error] = roleFromInput($_POST, $role_id); + if ($error === '' && intval($row['role_is_admin']) && !$fields['admin'] && ($blocker = roleAdminBlocker($row)) !== '') { + $error = "$row[role_name] stays an admin role: $blocker"; + } + if ($error !== '') { + flashAlert(escapeHtml($error), 'error'); + redirect(); } - logAudit("User Role", "Edit", "$session_name edited user role $name", 0, $role_id); + $changes = roleChangeText($row, $fields); + roleSave($fields, $role_id); - flashAlert("User Role $name edited"); + if ($changes !== '') { + logAudit("User Role", "Edit", "$session_name edited role " . escapeSql($fields['name']) . " - " . escapeSql($changes), 0, $role_id); + } + + flashAlert("Role " . escapeHtml($fields['name']) . " " . ($changes !== '' ? 'saved' : 'unchanged')); redirect(); @@ -77,24 +68,65 @@ if (isset($_GET['archive_role'])) { validateCSRFToken(); - $role_id = intval($_GET['archive_role']); - - // Check role isn't in use - $sql_role_user_count = mysqli_query($mysqli, "SELECT COUNT(user_id) FROM users WHERE user_role_id = $role_id AND user_archived_at IS NULL"); - $role_user_count = mysqli_fetch_row($sql_role_user_count)[0]; - if ($role_user_count != 0) { - flashAlert("Role must not in use to archive it", 'error'); - + $row = roleRow(intval($_GET['archive_role'])); + if (!$row) { + flashAlert("That role is gone - it may have been deleted", 'error'); + redirect(); + } + if (($blocker = roleArchiveBlocker($row)) !== '') { + flashAlert("" . escapeHtml($row['role_name']) . " wasn't archived: " . escapeHtml($blocker), 'error'); redirect(); } - mysqli_query($mysqli, "UPDATE user_roles SET role_archived_at = NOW() WHERE role_id = $role_id"); + roleSetArchived($row['role_id'], true); - $role_name = escapeSql(getFieldById('user_roles', $role_id, 'role_name')); + logAudit("User Role", "Archive", "$session_name archived role " . escapeSql($row['role_name']), 0, $row['role_id']); - logAudit("User Role", "Archive", "$session_name archived user role $role_name", 0, $role_id); - - flashAlert("User Role $role_name archived", 'error'); + flashAlert("Role " . escapeHtml($row['role_name']) . " archived"); + + redirect(); + +} + +if (isset($_GET['restore_role'])) { + + validateCSRFToken(); + + $row = roleRow(intval($_GET['restore_role'])); + if (!$row) { + flashAlert("That role is gone - it may have been deleted", 'error'); + redirect(); + } + + roleSetArchived($row['role_id'], false); + + logAudit("User Role", "Restore", "$session_name restored role " . escapeSql($row['role_name']), 0, $row['role_id']); + + flashAlert("Role " . escapeHtml($row['role_name']) . " restored"); + + redirect(); + +} + +if (isset($_GET['delete_role'])) { + + validateCSRFToken(); + + $row = roleRow(intval($_GET['delete_role'])); + if (!$row) { + flashAlert("That role is gone - it may have been deleted", 'error'); + redirect(); + } + if (($blocker = roleDeleteBlocker($row)) !== '') { + flashAlert("" . escapeHtml($row['role_name']) . " wasn't deleted: " . escapeHtml($blocker), 'error'); + redirect(); + } + + roleDelete($row['role_id']); + + logAudit("User Role", "Delete", "$session_name deleted role " . escapeSql($row['role_name']), 0, $row['role_id']); + + flashAlert("Role " . escapeHtml($row['role_name']) . " deleted", 'error'); redirect(); diff --git a/admin/roles.php b/admin/roles.php index 26ca7b91c..04c7b1a74 100644 --- a/admin/roles.php +++ b/admin/roles.php @@ -1,154 +1,173 @@ Roles - what each role lets its agents open: an admin role opens + * everything, any other a level of each module. Each shows its access at a + * glance and its agents (opening Users filtered to them). A role can be + * changed, copied as the start of another, archived - kept for the agents + * archived with it, not offered for new ones - or, while nobody has it, + * deleted. The work is in functions/roles.php; the handlers are + * admin/post/roles.php. + */ require_once "includes/inc_all_admin.php"; -$sql = mysqli_query( - $mysqli, - "SELECT SQL_CALC_FOUND_ROWS * FROM user_roles - WHERE (role_name LIKE '%$q%' OR role_description LIKE '%$q%') - AND role_archived_at IS NULL - ORDER BY $sort $order LIMIT $record_from, $record_to" -); +$show_archived = isset($_GET['archived']); +$search = trim((string) ($_GET['q'] ?? '')); -$num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()")); +$all_roles = roleRows(); +$roles = []; +$archived_count = 0; +foreach ($all_roles as $role_id => $row) { + $archived_count += $row['role_archived_at'] ? 1 : 0; + if ((bool) $row['role_archived_at'] !== $show_archived) { + continue; + } + if ($search !== '' && mb_stripos($row['role_name'], $search) === false && mb_stripos((string) $row['role_description'], $search) === false) { + continue; + } + $roles[$role_id] = $row; +} +$agents = roleAgents(array_keys($roles), 5); + +// This page with some of its query changed - null drops a key +$page_url = function (array $change = []) use ($show_archived, $search) { + $query = array_merge(['q' => $search !== '' ? $search : null, 'archived' => $show_archived ? 1 : null], $change); + return 'roles.php' . (($query = http_build_query(array_filter($query, function ($v) { + return $v !== null; + }))) ? "?$query" : ''); +}; + +$csrf = $_SESSION['csrf_token']; ?> -
-
-

Roles

-
-
- -
+
+
+

Roles

+
+ +
+
+ +
+

A role sets what its agents can open - an admin role everything, including Admin; any other a level of each part of ITFlow. Which clients an agent sees is set on the agent, in Users.

+
+ +
+ +
-
-
- -
-
-
- - -
-
-
- -
-
- - text-nowrap"> + + + + +
+
+ + - - - - + + + + - - - - - - - - - - + + $row) { + $name = escapeHtml($row['role_name']); + $is_admin = intval($row['role_is_admin']) === 1; + $is_mine = $role_id === intval($session_user_role); + $edit_url = "modals/role/role_edit.php?id=$role_id"; + $agent_count = intval($row['agents']); + $archive_blocker = roleArchiveBlocker($row); + $delete_blocker = roleDeleteBlocker($row); + // The agents with it - a few faces and how many, opening Users filtered to them + ob_start(); + if ($agent_count) { ?> + + + + + agent + + + Nobody + +
archived
+ - - -
- - Role - - Members - - Admin - - ActionRoleAccessAgentsAction
- href="#"> -
- -
-
-
-
-
-
-
- - - -
-
- 'roles', - 'icon' => 'fa-user-shield', - 'actions' => [['label' => 'New Role', 'modal' => 'modals/role/role_add.php', 'icon' => 'fa-user-plus']], - ]; - ?> - + + +
+ +
+ + Yours + +
+ + +
Archived
+ + +
+
+
+
+ + + + + + + + + +
+ + 'roles', + 'title' => $show_archived ? 'No archived roles' : 'No roles yet', + 'text' => $show_archived ? 'An archived role stays with the agents archived with it, but isn\'t given to anyone new.' : 'A role sets what its agents can open.', + 'icon' => 'fa-user-shield', + 'filtered' => $search !== '', + 'clear_url' => $page_url(['q' => null]), + 'actions' => $show_archived ? [] : [['label' => 'New Role', 'modal' => 'modals/role/role_add.php', 'size' => 'lg']], + ]) ?> + +
+ Roles - a role's agents as overlapping faces, and in the role + window what a level lets them do, the lines it doesn't greyed out */ +.itflow-role-agents { + display: inline-flex; +} +.itflow-role-agents > .itflow-contact-avatar + .itflow-contact-avatar { + margin-left: -.5rem; +} +.itflow-role-agents > .itflow-contact-avatar { + box-shadow: 0 0 0 2px var(--bs-body-bg); +} +.itflow-role-level-list li.is-off { + color: var(--bs-secondary-color); + opacity: .7; +} /* A category's icon beside its name in a list (categoryMarkHtml()) - in its colour, set as the dot it stands in for is */ .itflow-category-icon { diff --git a/db.sql b/db.sql index 4a6fb30a3..2cccb9050 100644 --- a/db.sql +++ b/db.sql @@ -3582,7 +3582,8 @@ DROP TABLE IF EXISTS `user_role_permissions`; CREATE TABLE `user_role_permissions` ( `user_role_id` int(11) NOT NULL, `module_id` int(11) NOT NULL, - `user_role_permission_level` int(11) NOT NULL + `user_role_permission_level` int(11) NOT NULL, + PRIMARY KEY (`user_role_id`,`module_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; /*!40101 SET character_set_client = @saved_cs_client */; diff --git a/functions.php b/functions.php index 1c4079fd6..9426bef13 100644 --- a/functions.php +++ b/functions.php @@ -20,6 +20,7 @@ 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/roles.php'; require_once __DIR__ . '/functions/asset_interface.php'; require_once __DIR__ . '/functions/rack.php'; require_once __DIR__ . '/functions/asset.php'; diff --git a/functions/auth.php b/functions/auth.php index 2841028d1..5dfd15f46 100644 --- a/functions/auth.php +++ b/functions/auth.php @@ -3,38 +3,29 @@ // Role and permission enforcement // Split from the former monolithic functions.php -// When provided a module name (e.g. module_support), returns the associated permission level (false=none, 1=read, 2=write, 3=full) +// When provided a module name (e.g. module_support), returns the associated permission level (false=none, 1=read, 2=write, 3=full). +// The role's levels are read once a request - a page asks dozens of times. function lookupUserPermission($module) { global $mysqli, $session_is_admin, $session_user_role; + static $levels = []; if (isset($session_is_admin) && $session_is_admin === true) { return 3; } - $module = escapeSql($module); - - $sql = mysqli_query( - $mysqli, - "SELECT - user_role_permissions.user_role_permission_level - FROM - modules - JOIN - user_role_permissions - ON - modules.module_id = user_role_permissions.module_id - WHERE - module_name = '$module' AND user_role_permissions.user_role_id = $session_user_role" - ); - - $row = mysqli_fetch_assoc($sql); - - if (isset($row['user_role_permission_level'])) { - return intval($row['user_role_permission_level']); + $role_id = intval($session_user_role ?? 0); + if (!isset($levels[$role_id])) { + $levels[$role_id] = []; + $sql = mysqli_query($mysqli, "SELECT module_name, MAX(user_role_permission_level) FROM modules + JOIN user_role_permissions ON modules.module_id = user_role_permissions.module_id + WHERE user_role_permissions.user_role_id = $role_id GROUP BY module_name"); + while ($row = mysqli_fetch_row($sql)) { + $levels[$role_id][$row[0]] = intval($row[1]); + } } // Default return for no module permission - return false; + return !empty($levels[$role_id][$module]) ? $levels[$role_id][$module] : false; } // Enforce admin portal access - single canonical admin gate ($session_is_admin) diff --git a/functions/roles.php b/functions/roles.php new file mode 100644 index 000000000..631335e2c --- /dev/null +++ b/functions/roles.php @@ -0,0 +1,306 @@ + 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, + ], + ]; +} + +/* + * 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, 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.'; + } + 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"); +} diff --git a/js/role_form.js b/js/role_form.js new file mode 100644 index 000000000..4931b29ee --- /dev/null +++ b/js/role_form.js @@ -0,0 +1,39 @@ +// New Role and Edit Role (admin/modals/role/role_form.php). An admin role +// opens everything, so its levels are hidden while Admin is on. Picking a +// module's level ticks off what that level lets its agents do - each level +// takes in the ones before it. The server checks it all again. +// +// Wrapped in an IIFE because a modal can be opened, closed and opened again +// in one page load, which re-runs this file. + +(function () { + + document.querySelectorAll('[data-role-form]:not([data-wired])').forEach(function (form) { + form.setAttribute('data-wired', '1'); + + const admin = form.querySelector('[data-role-admin]'); + const levels = form.querySelector('[data-role-levels]'); + const note = form.querySelector('[data-role-admin-note]'); + + function showLevels() { + levels.hidden = admin.checked; + note.hidden = !admin.checked; + } + admin.addEventListener('change', showLevels); + + form.querySelectorAll('[data-role-module]').forEach(function (module) { + module.addEventListener('change', function (e) { + if (e.target.type !== 'radio') { + return; + } + const level = parseInt(e.target.value, 10); + module.querySelectorAll('[data-role-level]').forEach(function (line) { + const on = parseInt(line.dataset.roleLevel, 10) <= level; + line.classList.toggle('is-off', !on); + line.querySelector('i').className = 'fas fa-fw me-1 ' + (on ? 'fa-check text-success' : 'fa-times'); + }); + }); + }); + }); + +})();