Files
itflow/agent/global_search.php
johnnyq fecd1cabff 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.
2026-10-14 23:33:29 -04:00

240 lines
12 KiB
PHP

<?php
/*
* Full search results - where Enter in the search palette lands.
*
* Same search as the palette (functions/search.php), with three differences:
* up to 25 per type instead of 5, a total for each type, and $deep, which
* also searches ticket details, ticket conversations and document text.
*
* Credential passwords are never in this page's markup. They are revealed and
* copied through the same audited endpoint as the credentials list.
*/
require_once "includes/inc_all.php";
$search_query = globalSearchTerms($_GET['query'] ?? '')['raw'];
$search_query_display = escapeHtml($search_query);
$search_results = [];
if (mb_strlen($search_query) >= 2) {
$search_results = globalSearch($search_query, 25, true, true);
}
$search_total = array_sum(array_column($search_results, 'total'));
// Usernames for the credentials shown - decrypted here, the same as the
// credentials list does, and only for rows that already passed the search's gates
$credential_usernames = [];
$credential_has_otp = [];
foreach ($search_results as $search_group) {
if ($search_group['key'] === 'credentials') {
$credential_ids = implode(',', array_map('intval', array_column($search_group['items'], 'id')));
$sql_credential_usernames = mysqli_query($mysqli, "SELECT credential_id, credential_username, credential_otp_secret FROM credentials WHERE credential_id IN ($credential_ids)");
while ($row = mysqli_fetch_assoc($sql_credential_usernames)) {
$credential_usernames[intval($row['credential_id'])] = credentialUsername($row);
$credential_has_otp[intval($row['credential_id'])] = trim((string) $row['credential_otp_secret']) !== '';
}
}
}
/** Escaped text with every occurrence of the query wrapped in <mark>. */
function searchResultHighlight($text, $query) {
$text = (string) $text;
if ($query === '' || $text === '') {
return escapeHtml($text);
}
$output = '';
$offset = 0;
$length = mb_strlen($query);
while (($position = mb_stripos($text, $query, $offset)) !== false) {
$output .= escapeHtml(mb_substr($text, $offset, $position - $offset));
$output .= '<mark>' . escapeHtml(mb_substr($text, $position, $length)) . '</mark>';
$offset = $position + $length;
}
return $output . escapeHtml(mb_substr($text, $offset));
}
function searchResultBadge($badge) {
if (empty($badge['text'])) {
return '';
}
$text = escapeHtml($badge['text']);
if (!empty($badge['variant']) && in_array($badge['variant'], ['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'light', 'dark'], true)) {
return "<span class='badge rounded-pill text-bg-{$badge['variant']}'>$text</span>";
}
if (!empty($badge['hex']) && preg_match('/^#[0-9a-f]{3,8}$/i', $badge['hex'])) {
return "<span class='badge rounded-pill text-white' style='background-color: {$badge['hex']}'>$text</span>";
}
return "<span class='badge rounded-pill text-bg-secondary'>$text</span>";
}
?>
<div class="card">
<div class="card-header bg-dark py-2">
<h3 class="card-title mt-2"><i class="fas fa-fw fa-search me-2"></i>Search</h3>
</div>
<div class="card-header py-3">
<form autocomplete="off">
<div class="input-group">
<input type="search" class="form-control" name="query" value="<?= $search_query_display ?>"
placeholder="Search clients, tickets, assets, credentials, documents and more" maxlength="100" autofocus>
<button class="btn btn-primary" type="submit" title="Search"><i class="fas fa-search"></i></button>
</div>
</form>
<?php if (count($search_results) > 1) { ?>
<nav class="d-flex flex-wrap gap-1 mt-3" aria-label="Jump to a result type">
<?php foreach ($search_results as $search_group) { ?>
<a class="btn btn-sm btn-light border" href="#search-<?= escapeHtml($search_group['key']) ?>">
<i class="fas fa-fw fa-<?= escapeHtml($search_group['icon']) ?> me-1"></i><?= escapeHtml($search_group['label']) ?>
<span class="badge text-bg-secondary ms-1"><?= intval($search_group['total']) ?></span>
</a>
<?php } ?>
</nav>
<?php } ?>
</div>
<div class="card-body">
<?php if ($search_query === '') { ?>
<div class="text-center text-secondary py-5">
<i class="fas fa-4x fa-search mb-3 d-block" aria-hidden="true"></i>
<h5>Search everything in one place</h5>
<p class="mb-0">
Names, emails, phone numbers, IP and MAC addresses, serials, ticket, invoice and quote numbers -
plus ticket conversations and document text.<br>
Press <kbd>Ctrl</kbd> + <kbd>K</kbd> on any page to search without leaving it.
</p>
</div>
<?php } elseif (mb_strlen($search_query) < 2) { ?>
<div class="text-center text-secondary py-5">
<i class="fas fa-4x fa-keyboard mb-3 d-block" aria-hidden="true"></i>
<h5>Type at least two characters</h5>
</div>
<?php } elseif (!$search_results) { ?>
<div class="text-center text-secondary py-5">
<i class="fas fa-4x fa-search mb-3 d-block" aria-hidden="true"></i>
<h5>Nothing matches "<?= $search_query_display ?>"</h5>
<p class="mb-0">Check the spelling, or try part of a name, a number or an address.</p>
</div>
<?php } else { ?>
<p class="text-secondary mb-3">
<?= $search_total ?> result<?= $search_total === 1 ? '' : 's' ?> for "<strong><?= $search_query_display ?></strong>"
</p>
<?php foreach ($search_results as $search_group) {
$group_key = escapeHtml($search_group['key']);
$group_icon = escapeHtml($search_group['icon']);
$group_label = escapeHtml($search_group['label']);
$group_total = intval($search_group['total']);
$group_shown = count($search_group['items']);
?>
<div class="card mb-3 global-search-group" id="search-<?= $group_key ?>">
<div class="card-header py-2 d-flex align-items-center">
<h3 class="card-title mb-0 me-auto">
<i class="fas fa-fw fa-<?= $group_icon ?> me-2 text-secondary"></i><?= $group_label ?>
<span class="badge text-bg-secondary ms-1"><?= $group_total ?></span>
</h3>
<?php if ($search_group['list_url']) { ?>
<a class="small" href="<?= escapeHtml($search_group['list_url']) ?>">
Open in <?= $group_label ?><i class="fas fa-fw fa-arrow-right ms-1"></i>
</a>
<?php } ?>
</div>
<div class="list-group list-group-flush">
<?php foreach ($search_group['items'] as $item) {
$item_url = escapeHtml($item['url']);
?>
<div class="list-group-item d-flex align-items-center gap-3 py-2">
<?php if (!empty($item['tile']) && preg_match('/^[a-z]+$/', $item['tile']['tone']) && preg_match('/^[a-z0-9-]+$/', $item['tile']['icon'])) { ?>
<span class="itflow-file-icon itflow-file-icon-sm itflow-file-tone-<?= $item['tile']['tone'] ?>" title="<?= escapeHtml($item['tile']['label']) ?>" aria-hidden="true"><i class="fas fa-fw fa-<?= $item['tile']['icon'] ?>"></i></span>
<?php } ?>
<?php if (!empty($item['avatar'])) {
// A contact - their avatar, as everywhere else they appear
$avatar = $item['avatar'];
if (!empty($avatar['image'])) { ?>
<img class="itflow-contact-avatar itflow-contact-avatar-sm" src="<?= escapeHtml($avatar['image']) ?>" alt="" loading="lazy">
<?php } else { ?>
<span class="itflow-contact-avatar itflow-contact-avatar-sm bg-<?= escapeHtml($avatar['tone']) ?>-subtle text-<?= escapeHtml($avatar['tone']) ?>-emphasis" aria-hidden="true"><?= escapeHtml($avatar['initials']) ?></span>
<?php }
} ?>
<div class="flex-grow-1 text-truncate">
<a href="<?= $item_url ?>">
<?php if (!empty($item['ref'])) { ?>
<span class="font-monospace text-secondary small me-2"><?= searchResultHighlight($item['ref'], $search_query) ?></span>
<?php } ?>
<?= searchResultHighlight($item['title'], $search_query) ?>
</a>
<?php if (!empty($item['subtitle'])) { ?>
<div class="small text-secondary text-truncate"><?= searchResultHighlight($item['subtitle'], $search_query) ?></div>
<?php } ?>
<?php if (!empty($item['snippet'])) { ?>
<div class="small text-secondary text-wrap"><?= searchResultHighlight($item['snippet'], $search_query) ?></div>
<?php } ?>
</div>
<?php if ($search_group['key'] === 'credentials') {
$credential_id = intval($item['id']);
$credential_username = $credential_usernames[$credential_id] ?? '';
?>
<div class="text-nowrap d-none d-md-flex align-items-center small">
<?php if ($credential_username !== '') { ?>
<?= credentialUsernameHtml($credential_username) ?>
<?php } ?>
</div>
<div class="text-nowrap d-flex align-items-center gap-2 small">
<?= credentialPasswordHtml($credential_id) ?>
<?= credentialOtpHtml($credential_id, !empty($credential_has_otp[$credential_id])) ?>
</div>
<?php } ?>
<?php if (!empty($item['badge'])) { ?>
<div class="text-nowrap"><?= searchResultBadge($item['badge']) ?></div>
<?php } ?>
<?php if (!empty($item['context'])) { ?>
<div class="small text-secondary text-truncate d-none d-sm-block global-search-context"><?= escapeHtml($item['context']) ?></div>
<?php } ?>
</div>
<?php } ?>
</div>
<?php if ($group_total > $group_shown) { ?>
<div class="card-footer small text-secondary py-2">
Showing <?= $group_shown ?> of <?= $group_total ?>.
<?php if ($search_group['list_url']) { ?>
<a href="<?= escapeHtml($search_group['list_url']) ?>">See them all in <?= $group_label ?></a>
<?php } else { ?>
Add more words to narrow it down.
<?php } ?>
</div>
<?php } ?>
</div>
<?php } ?>
<?php } ?>
</div>
</div>
<?php
require_once "../includes/footer.php";