From fecd1cabfff84db22a02c4c3e39c4041d3040ae5 Mon Sep 17 00:00:00 2001 From: johnnyq Date: Wed, 14 Oct 2026 23:33:29 -0400 Subject: [PATCH] Credentials makeover: rebuilt credentials list with kind icons, username copy, password show/copy and live 2FA code buttons fetched on demand, where it opens, what it is for (asset, contact, vendors, licenses, services), password age pills and last used, filters for Starred, With 2FA, No 2FA, Not changed in 90 days and Shared with counts, search across notes, second link, tags, asset and contact, open straight to a credential with ?open=ID; new credential quick look with login, age, last viewed by, links with unlink, notes, open share links with turn off, and history; tabbed new/edit form with password generator (passphrase or random), strength meter, 2FA secret that accepts spaces or an otpauth:// link and shows the current code to check, copy buttons on both links, prefill from asset, contact or vendor; new Change password window that dates and logs the change; new Share window from anywhere (contact or email, 1 hour to 1 month, gone after opened once by default, link copied); same login controls on asset and contact pages and quick looks, vendor Logins tab, license page and quick look, service quick look, client overview favourites and search; credential pickers show icons and say when a credential moves from another asset or contact; client overview Passwords due for a change attention item and Active share links limited to live links; rotation report with selectable span, per client counts and oldest first; more export columns; demo data gets 2FA secrets, varied password ages and logins attached to their assets. Fixes passwords being blanked when the vault session had expired, other clients' assets, contacts and tags accepted on save, bulk star using the wrong permission, credential links to assets and contacts not checking the client, the edit or link permission, or which asset or contact an unlink was for, credentials left pointing at deleted assets and contacts, share links left open after a credential was deleted, import carrying values between rows and accepting any file type, 2FA secrets saved with spaces or lowercase giving no code, the service quick look listing credentials without credential access and repeating assets, an asset login with only a username being lost, and the API not dating new passwords. Adds functions/credential.php and js/credentials.js; removes credential_model.php and the old credential show password/OTP scripts. No DB change. --- admin/post/demo_data_model.php | 31 +- agent/ajax.php | 19 +- agent/asset.php | 118 +-- agent/client_overview.php | 33 +- agent/contact.php | 94 +-- agent/credentials.php | 741 +++++++++--------- agent/global_search.php | 23 +- agent/js/credential_show_otp_via_id.js | 16 - agent/js/credential_show_password_via_id.js | 40 - agent/license.php | 59 +- agent/modals/asset/asset.php | 48 +- agent/modals/asset/asset_link_credential.php | 69 +- agent/modals/contact/contact.php | 44 +- .../contact/contact_link_credential.php | 67 +- agent/modals/credential/credential.php | 188 +++++ agent/modals/credential/credential_add.php | 248 +----- agent/modals/credential/credential_edit.php | 244 +----- agent/modals/credential/credential_form.php | 375 +++++++++ agent/modals/credential/credential_rotate.php | 103 +++ agent/modals/credential/credential_share.php | 132 ++++ agent/modals/link/link.php | 2 +- agent/modals/service/service.php | 44 +- agent/modals/software/software.php | 27 +- agent/post/asset.php | 47 +- agent/post/contact.php | 24 +- agent/post/credential.php | 564 ++++++------- agent/post/credential_model.php | 22 - agent/post/service.php | 6 +- agent/reports/credential_rotation.php | 149 ++-- agent/vendor.php | 16 +- api/v1/assets/delete.php | 3 + api/v1/contacts/delete.php | 3 + api/v1/credentials/create.php | 2 +- api/v1/credentials/credential_model.php | 4 +- api/v1/credentials/delete.php | 3 + css/itflow_custom.css | 84 ++ functions.php | 1 + functions/asset.php | 5 +- functions/client_overview.php | 17 +- functions/credential.php | 515 ++++++++++++ functions/export.php | 7 + functions/links.php | 17 +- functions/search.php | 5 +- guest/guest_ajax.php | 5 +- includes/footer.php | 1 + js/credentials.js | 155 ++++ post/misc.php | 9 + 47 files changed, 2643 insertions(+), 1786 deletions(-) delete mode 100644 agent/js/credential_show_otp_via_id.js delete mode 100644 agent/js/credential_show_password_via_id.js create mode 100644 agent/modals/credential/credential.php create mode 100644 agent/modals/credential/credential_form.php create mode 100644 agent/modals/credential/credential_rotate.php create mode 100644 agent/modals/credential/credential_share.php delete mode 100644 agent/post/credential_model.php create mode 100644 functions/credential.php create mode 100644 js/credentials.js diff --git a/admin/post/demo_data_model.php b/admin/post/demo_data_model.php index dd116be30..c1b9ee85f 100644 --- a/admin/post/demo_data_model.php +++ b/admin/post/demo_data_model.php @@ -367,6 +367,17 @@ function demoFirstId($mysqli, $table, $id_column, $archived_column = '') { // Deterministic stand-in for a serial number - same input, same serial, so a // reload after a removal does not produce a whole new set of numbers +/** A 2FA seed that comes out the same on every run - 32 base32 characters from the seed. */ +function demoTotpSeed($seed) { + $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + $bytes = md5('itflow-demo-totp-' . $seed, true) . md5('itflow-demo-totp-2-' . $seed, true); + $out = ''; + for ($i = 0; $i < 32; $i++) { + $out .= $alphabet[ord($bytes[$i]) % 32]; + } + return $out; +} + function demoSerial($seed, $length = 10) { return strtoupper(substr(md5('itflow-demo-' . $seed), 0, $length)); } @@ -1342,9 +1353,12 @@ function demoBuildClient($mysqli, $profile, $index, $context, &$counts) { 'credential_password' => encryptCredentialEntry(demoSerial($profile['abbreviation'] . $credential[0], 14) . '!aA1'), 'credential_note' => $credential[4], 'credential_favorite' => $credential[6], + // A 2FA seed on the logins that have one, so the codes count down like the real thing + 'credential_otp_secret' => !empty($credential[7]) ? demoTotpSeed($profile['abbreviation'] . $credential[0]) : '', 'credential_client_id' => $client_id, 'credential_created_at' => demoMonthDateTime($months, 8, 16, 10), - 'credential_password_changed_at' => demoMonthDateTime(max(0, min(4, $months)), 8, 16, 10), + // Some changed lately, some due, one well over a year - never before the client was taken on + 'credential_password_changed_at' => demoDateTime(min($credential[8] ?? 30, max(0, $months * 30 - 10)), 16, 10), 'credential_accessed_at' => demoDateTime(($index + 2) % 15, 11, 30), ]); $credential_ids[$credential[0]] = $credential_id; @@ -1480,6 +1494,8 @@ function demoBuildClient($mysqli, $profile, $index, $context, &$counts) { foreach ($assets as $asset) { if (!empty($asset['credential']) && $asset['credential'] === $credential_name) { demoLink($mysqli, 'asset_credentials', 'credential_id', $credential_id, 'asset_id', $asset['id']); + // The credential's own asset - what the asset's Credentials tab and the credential show + mysqli_query($mysqli, "UPDATE credentials SET credential_asset_id = " . intval($asset['id']) . " WHERE credential_id = " . intval($credential_id) . " AND credential_asset_id = 0"); } } } @@ -4500,13 +4516,14 @@ function demoExpandProfile($spec) { // Credentials - names line up with demoAssetCredentialName so the vault // entries attach to the kit they actually open $mail_admin_uri = $mail === 'Google Workspace' ? 'https://admin.google.com' : 'https://admin.microsoft.com'; + // [name, description, uri, username, note, tags, starred, has 2FA, password last changed (days ago)] $credentials = [ - ['Firewall Administrator', 'Edge firewall management interface', 'https://192.168.10.1', 'msp-admin', 'Local administrator on the edge firewall. Rotated at every quarterly review.', ['Firewall', 'Local Admin', 'Rotate Quarterly'], 1], - [$mail . ' Administrator', 'Tenant administration for mail and identity', $mail_admin_uri, 'msp-admin@' . $domain, 'Break glass administrator. MFA enforced - the token lives with the on call phone.', ['Microsoft 365', 'Domain Admin', 'Break Glass'], 1], - ['Hypervisor and Storage Administrator', $foss ? 'Proxmox VE and TrueNAS management' : 'Hypervisor and NAS management', 'https://192.168.20.10:8006', 'msp-admin', 'Root equivalent. Use the individual accounts for day to day work and this only for maintenance.', ['Hypervisor', 'Local Admin', 'Break Glass'], 0], - ['Network Controller', 'UniFi controller for switching and wireless', 'https://192.168.10.12', 'msp-' . strtolower($short), 'Controller runs on the management stack, not on site.', ['Switch', 'Wireless', 'Shared'], 0], - ['Backup Console', $foss ? 'Proxmox Backup Server and Synology Active Backup' : 'Backup platform for servers and endpoints', 'https://backup.example', 'msp-' . strtolower($short), 'Console login for restore requests and job monitoring.', ['Backup Console', 'Service Account'], 0], - ['Registrar and DNS', 'Domain registrar and DNS control panel', 'https://registrar.example', 'billing@' . $domain, 'Registrar account is in the client name with us as technical contact.', ['Registrar and DNS', 'Shared'], 0], + ['Firewall Administrator', 'Edge firewall management interface', 'https://192.168.10.1', 'msp-admin', 'Local administrator on the edge firewall. Rotated at every quarterly review.', ['Firewall', 'Local Admin', 'Rotate Quarterly'], 1, 0, 25], + [$mail . ' Administrator', 'Tenant administration for mail and identity', $mail_admin_uri, 'msp-admin@' . $domain, 'Break glass administrator. MFA enforced - the token lives with the on call phone.', ['Microsoft 365', 'Domain Admin', 'Break Glass'], 1, 1, 120], + ['Hypervisor and Storage Administrator', $foss ? 'Proxmox VE and TrueNAS management' : 'Hypervisor and NAS management', 'https://192.168.20.10:8006', 'msp-admin', 'Root equivalent. Use the individual accounts for day to day work and this only for maintenance.', ['Hypervisor', 'Local Admin', 'Break Glass'], 0, 0, 430], + ['Network Controller', 'UniFi controller for switching and wireless', 'https://192.168.10.12', 'msp-' . strtolower($short), 'Controller runs on the management stack, not on site.', ['Switch', 'Wireless', 'Shared'], 0, 0, 60], + ['Backup Console', $foss ? 'Proxmox Backup Server and Synology Active Backup' : 'Backup platform for servers and endpoints', 'https://backup.example', 'msp-' . strtolower($short), 'Console login for restore requests and job monitoring.', ['Backup Console', 'Service Account'], 0, 1, 210], + ['Registrar and DNS', 'Domain registrar and DNS control panel', 'https://registrar.example', 'billing@' . $domain, 'Registrar account is in the client name with us as technical contact.', ['Registrar and DNS', 'Shared'], 0, 1, 15], ]; // Services - the last two entries say which asset types run it and which diff --git a/agent/ajax.php b/agent/ajax.php index aa53fc16d..fa9921bb2 100644 --- a/agent/ajax.php +++ b/agent/ajax.php @@ -817,6 +817,10 @@ if (isset($_GET['share_generate_link'])) { // Logging logAudit("Share", "Create", "$session_name created shared link for $item_type - $item_name", $client_id, $item_id); + // ...and on the credential itself, so its history says who it went out to + if ($item_type == "Credential") { + logAudit("Credential", "Share", "$session_name shared credential $item_name" . ($item_email ? " with $item_email" : '') . ", expiring in $item_expires_friendly", $client_id, $item_id); + } } @@ -1167,13 +1171,19 @@ if (isset($_GET['get_totp_token_via_id'])) { $credential_id = intval($_GET['credential_id']); $sql = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT credential_name, credential_otp_secret, credential_client_id FROM credentials WHERE credential_id = $credential_id")); + if (!$sql) { + echo json_encode(''); + exit; + } $name = escapeSql($sql['credential_name']); $totp_secret = $sql['credential_otp_secret']; $client_id = intval($sql['credential_client_id']); enforceClientAccess(); - $otp = TokenAuth6238::getTokenCode(strtoupper($totp_secret)); + // Seeds saved before they were tidied on save may still carry spaces, dashes or the whole otpauth:// link + $totp_seed = credentialOtpSeed($totp_secret); + $otp = $totp_seed ? TokenAuth6238::getTokenCode($totp_seed) : ''; echo json_encode($otp); // Logging @@ -1816,6 +1826,10 @@ if (isset($_GET['get_credential_via_id'])) { $credential_id = intval($_GET['credential_id']); $sql = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT credential_name, credential_username, credential_password, credential_client_id FROM credentials WHERE credential_id = $credential_id")); + if (!$sql) { + echo json_encode(['error' => 'That credential no longer exists.']); + exit; + } $name = escapeSql($sql['credential_name']); $client_id = intval($sql['credential_client_id']); @@ -1827,6 +1841,9 @@ if (isset($_GET['get_credential_via_id'])) { ); echo json_encode($response); + // When it was last used - the credentials list sorts by it. Kept off the updated date, which means an edit + mysqli_query($mysqli, "UPDATE credentials SET credential_accessed_at = NOW(), credential_updated_at = credential_updated_at WHERE credential_id = $credential_id"); + // Only log if this user hasn't viewed this credential recently (mirrors TOTP dedup) $check_recent_view = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(log_id) AS recent_view FROM logs WHERE log_type = 'Credential' AND log_action = 'View' AND log_user_id = $session_user_id AND log_entity_id = $credential_id AND log_client_id = $client_id AND log_created_at > (NOW() - INTERVAL 5 MINUTE)")); diff --git a/agent/asset.php b/agent/asset.php index d2b369f2a..72b8e4383 100644 --- a/agent/asset.php +++ b/agent/asset.php @@ -103,6 +103,7 @@ if (mysqli_num_rows($sql) == 0) { $can_edit = lookupUserPermission('module_support') >= 2; $can_delete = lookupUserPermission('module_support') >= 3; $can_credentials = lookupUserPermission('module_credential') >= 1; + $can_credentials_edit = lookupUserPermission('module_credential') >= 2; // The rack it sits in, if any $asset_rack = rackPositionForAsset($asset_id); @@ -211,18 +212,8 @@ if (mysqli_num_rows($sql) == 0) { $document_count = mysqli_num_rows($sql_related_documents); // Credentials - $credential_count = 0; - if ($can_credentials) { - $sql_related_credentials = mysqli_query($mysqli, " - SELECT credential_id, credential_name, credential_description, credential_uri, - credential_username, credential_otp_secret - FROM credentials - WHERE credential_asset_id = $asset_id - AND credential_archived_at IS NULL - ORDER BY credential_name ASC - "); - $credential_count = mysqli_num_rows($sql_related_credentials); - } + $related_credentials = $can_credentials ? credentialRows("credential_asset_id = $asset_id AND credential_client_id = $client_id") : []; + $credential_count = count($related_credentials); // Licenses it holds a seat on, with each one's seats in use and the key it was given $held_software = softwareHeldBy('asset', $asset_id); @@ -337,8 +328,8 @@ if (mysqli_num_rows($sql) == 0) { Recurring Ticket - - + + Credential @@ -365,7 +356,7 @@ if (mysqli_num_rows($sql) == 0) { License - + Credential @@ -734,98 +725,24 @@ if (mysqli_num_rows($sql) == 0) {
Logins for this asset. Passwords and codes are fetched only when you ask for them. - +
-
- -
- - - - - - - - - - - - - - - - - - - - - - - -
NameUsernamePasswordOTPURI
- -
-
- - - - - - - - - - - - Hover... - - - - - - - - - - - - - -
-
+ + false, + 'unlink' => function ($row) use ($asset_id, $can_edit) { + return $can_edit && credentialCan(2) ? "post.php?unlink_credential_from_asset&asset_id=$asset_id&credential_id=" . intval($row['credential_id']) . "&csrf_token=" . $_SESSION['csrf_token'] : ''; + }, + ]) ?>
No credentials for this asset.
@@ -1213,7 +1130,6 @@ if (mysqli_num_rows($sql) == 0) {
- - - -
- - - - - - - - - - - - OTP - - - + + + + + + + + + +
@@ -378,6 +373,10 @@ $money_format = function ($amount) use ($currency_format, $client_currency_code, } elseif ($item['item_type'] === 'File') { $item_tile = fileTypeTile($item['file_ext'], 'sm'); $item_label = "$item_label"; + } elseif ($item['item_type'] === 'Credential' && $item['item_name']) { + // Its tile, opening its quick look - with the password and 2FA there for whoever may see them + $item_tile = credentialTile(['credential_name' => $item['item_name'], 'credential_uri' => $item['credential_uri'], 'credential_description' => $item['credential_description']], 'sm'); + $item_label = $access['credentials'] ? "$item_label" : $item_label; } else { $item_tile = ''; } @@ -547,8 +546,6 @@ $money_format = function ($amount) use ($currency_format, $client_currency_code, - - diff --git a/agent/contact.php b/agent/contact.php index deee03f74..9891f2fdc 100644 --- a/agent/contact.php +++ b/agent/contact.php @@ -72,6 +72,7 @@ $contact_role_line = implode(' · ', array_filter([$contact_title, $contac $can_edit = lookupUserPermission('module_client') >= 2; $can_delete = lookupUserPermission('module_client') >= 3; $can_credentials = lookupUserPermission('module_credential') >= 1; +$can_credentials_edit = lookupUserPermission('module_credential') >= 2; $can_support = lookupUserPermission('module_support') >= 1; $can_support_edit = lookupUserPermission('module_support') >= 2; @@ -93,17 +94,8 @@ $sql_related_assets = mysqli_query($mysqli, "SELECT asset_id, asset_name, asset_ $asset_count = mysqli_num_rows($sql_related_assets); // Credentials - only for agents who may see them -$credential_count = 0; -if ($can_credentials) { - $sql_related_credentials = mysqli_query($mysqli, "SELECT credential_id, credential_name, credential_description, credential_uri, - credential_username, credential_otp_secret - FROM credentials - WHERE credential_contact_id = $contact_id - AND credential_archived_at IS NULL - ORDER BY credential_name ASC" - ); - $credential_count = mysqli_num_rows($sql_related_credentials); -} +$related_credentials = $can_credentials ? credentialRows("credential_contact_id = $contact_id AND credential_client_id = $client_id") : []; +$credential_count = count($related_credentials); // Tickets they raised or are the contact on - open ones first $related_tickets = []; @@ -276,8 +268,8 @@ if (!$can_support) { Asset - - + + Credential @@ -303,7 +295,7 @@ if (!$can_support) { Asset - + Credential @@ -531,75 +523,23 @@ if (!$can_support) {
Their logins. Passwords and codes are fetched only when you ask for them. - +
-
- -
- - - - - - - - - - - - - - - - -
NameUsernamePasswordOTPURI
- -
-
- - - - - Hover... - - - - - - - -
-
+ + function ($row) use ($contact_id, $can_edit) { + return $can_edit && credentialCan(2) ? "post.php?unlink_credential_from_contact&contact_id=$contact_id&credential_id=" . intval($row['credential_id']) . "&csrf_token=" . $_SESSION['csrf_token'] : ''; + }, + ]) ?>
No credentials for .
@@ -897,8 +837,6 @@ if (!$can_support) {
- - - - - - = 2; + +// Client Filter (all clients) +$client = ''; +if (!$client_url && !empty($_GET['client'])) { + $client = intval($_GET['client']); + $client_query = "AND credential_client_id = $client"; +} + +// Location Filter (one client) - credentials for the assets at a site +$location_filter = $client_url ? intval($_GET['location'] ?? 0) : 0; +$location_query = $location_filter ? "AND c.credential_asset_id IN (SELECT asset_id FROM assets WHERE asset_location_id = $location_filter)" : ''; + +// Tags Filter - a credential with any of them if (isset($_GET['tags']) && is_array($_GET['tags']) && !empty($_GET['tags'])) { - // Sanitize each element of the tags array - $sanitizedTags = array_map('intval', $_GET['tags']); - // Convert the sanitized tags into a comma-separated string - $tag_filter = implode(",", $sanitizedTags); - $tag_query = "AND tags.tag_id IN ($tag_filter)"; + $tag_filter = implode(",", array_map('intval', $_GET['tags'])); + $tag_query = "AND c.credential_id IN (SELECT credential_id FROM credential_tags WHERE tag_id IN ($tag_filter))"; } else { $tag_filter = 0; $tag_query = ''; } -if (!$client_url) { - // Client Filter - if (isset($_GET['client']) & !empty($_GET['client'])) { - $client_query = 'AND (credential_client_id = ' . intval($_GET['client']) . ')'; - $client = intval($_GET['client']); - } else { - // Default - any - $client_query = ''; - $client = ''; - } -} +// Which ones - the pills along the top +$rotate_days = CREDENTIAL_ROTATE_DAYS; +$shows = [ + '' => ['label' => 'All', 'where' => ''], + 'starred' => ['label' => 'Starred', 'icon' => 'fa-star', 'where' => 'c.credential_favorite = 1'], + 'otp' => ['label' => 'With 2FA', 'icon' => 'fa-clock', 'where' => "c.credential_otp_secret <> ''"], + 'nootp' => ['label' => 'No 2FA', 'icon' => 'fa-unlock', 'where' => "(c.credential_otp_secret IS NULL OR c.credential_otp_secret = '')"], + 'due' => ['label' => "Not changed in $rotate_days days", 'icon' => 'fa-history', 'where' => "(c.credential_password_changed_at IS NULL OR c.credential_password_changed_at < NOW() - INTERVAL $rotate_days DAY)"], + 'shared' => ['label' => 'Shared', 'icon' => 'fa-share-alt', 'where' => "c.credential_id IN (SELECT item_related_id FROM shared_items WHERE item_type = 'Credential' AND item_active = 1 AND item_expire_at > NOW() AND (COALESCE(item_view_limit, 0) = 0 OR item_views < item_view_limit))"], +]; +$show = isset($shows[$_GET['show'] ?? '']) ? ($_GET['show'] ?? '') : ''; +$show_query = $shows[$show]['where'] ? 'AND ' . $shows[$show]['where'] : ''; -// Location Filter -if ($client_url && isset($_GET['location']) && !empty($_GET['location'])) { - $location_query = 'AND (a.asset_location_id = ' . intval($_GET['location']) . ')'; - $location_query_innerjoin = 'INNER JOIN assets a on a.asset_id = c.credential_asset_id '; - $location_filter = intval($_GET['location']); -} else { - // Default - any - $location_query_innerjoin = ''; - $location_query = ''; - $location_filter = ''; +// Only these sort - filter_header.php will hand through any lowercase word, and +// an unknown column takes the page down with a mysqli exception +$sortable_columns = ['credential_name', 'credential_uri', 'credential_password_changed_at', 'credential_accessed_at', 'client_name', 'credential_created_at']; +if (!in_array($sort, $sortable_columns, true)) { + $sort = 'credential_name'; } +$sort_column = $sort === 'client_name' ? 'client_name' : "c.$sort"; + +$search_query = "AND (c.credential_name LIKE '%$q%' OR c.credential_description LIKE '%$q%' OR c.credential_uri LIKE '%$q%' OR c.credential_uri_2 LIKE '%$q%' + OR c.credential_note LIKE '%$q%' OR client_name LIKE '%$q%' OR asset_name LIKE '%$q%' OR contact_name LIKE '%$q%' + OR c.credential_id IN (SELECT credential_id FROM credential_tags JOIN tags USING (tag_id) WHERE tag_name LIKE '%$q%'))"; +$scope_query = clientScopeSql('credential_client_id'); $sql = mysqli_query( $mysqli, - "SELECT SQL_CALC_FOUND_ROWS c.credential_id AS c_credential_id, c.*, credential_tags.*, tags.*, clients.*, contacts.*, assets.* + "SELECT SQL_CALC_FOUND_ROWS c.credential_id, c.credential_name, c.credential_description, c.credential_uri, c.credential_uri_2, c.credential_username, + c.credential_otp_secret, c.credential_favorite, c.credential_archived_at, c.credential_password_changed_at, c.credential_accessed_at, + c.credential_client_id, client_name, contact_id, contact_name, contact_photo, contact_archived_at, asset_id, asset_name, asset_type, asset_archived_at FROM credentials c - LEFT JOIN credential_tags ON credential_tags.credential_id = c.credential_id - LEFT JOIN tags ON tags.tag_id = credential_tags.tag_id LEFT JOIN clients ON client_id = credential_client_id - LEFT JOIN contacts ON contact_id = credential_contact_id - LEFT JOIN assets ON asset_id = credential_asset_id - $location_query_innerjoin + LEFT JOIN contacts ON contact_id = credential_contact_id AND contact_client_id = credential_client_id + LEFT JOIN assets ON asset_id = credential_asset_id AND asset_client_id = credential_client_id WHERE $archive_query $tag_query - AND (c.credential_name LIKE '%$q%' OR c.credential_description LIKE '%$q%' OR c.credential_uri LIKE '%$q%' OR tag_name LIKE '%$q%' OR client_name LIKE '%$q%') + $search_query $location_query - " . clientScopeSql('credential_client_id') . " + $show_query + $scope_query $client_query - GROUP BY c.credential_id - ORDER BY c.credential_favorite DESC, $sort $order LIMIT $record_from, $record_to" + ORDER BY c.credential_favorite DESC, $sort_column $order, c.credential_name ASC LIMIT $record_from, $record_to" ); $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()")); +$credentials = []; +while ($row = mysqli_fetch_assoc($sql)) { + $credentials[intval($row['credential_id'])] = $row; +} + +// Tags, links and share links for the whole page at once, not per row +$credentials_tags = tagsForRecords('credential', array_keys($credentials)); +$credentials_links = credentialLinks(array_keys($credentials)); +$credentials_shares = credentialShares(array_keys($credentials)); + +// How many of each, for the pills - everything in scope, before the pill itself +$counts = ['' => 0]; +$count_sql = []; +foreach ($shows as $key => $s) { + $count_sql[] = $key === '' ? 'COUNT(*)' : "COALESCE(SUM({$s['where']}), 0)"; +} +$row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT " . implode(', ', $count_sql) . " + FROM credentials c + LEFT JOIN clients ON client_id = credential_client_id + LEFT JOIN contacts ON contact_id = credential_contact_id AND contact_client_id = credential_client_id + LEFT JOIN assets ON asset_id = credential_asset_id AND asset_client_id = credential_client_id + WHERE $archive_query $tag_query $search_query $location_query $scope_query $client_query")); +foreach (array_keys($shows) as $i => $key) { + $counts[$key] = intval($row[$i]); +} + +$filter_url = function ($changes) use ($client_url) { + $params = array_merge($_GET, $changes); + unset($params['page']); + return '?' . http_build_query(array_filter($params, function ($v) { return $v !== '' && $v !== null; })); +}; + ?>

Credentials

- = 2) { ?> -
- - -