Files
itflow/functions/credential.php
johnnyq b5dc8883f1 Tags stage 1: Admin > Tags rebuilt with the seven kinds in a side nav with counts, each tag shown as it looks with its description, how many records have it (and how many of those are archived) linking to the list filtered by it, and the recurring invoice lines that count by it, search by name or description, sort by name or most used, Current and Archived tabs, Edit, Merge into, Archive, Restore and Delete per tag and bulk merge, archive, restore and delete; new shared tag window with live preview, description, color swatches or any color or hex, searchable icon grid or any Font Awesome icon by name, and kind changeable only while unused; merge window that moves records onto the kept tag once each and repoints recurring seat lines; archiving now works, with archived tags kept on their records but left out of record forms, the tag picker and the list and export filters unless already chosen; tag names unique within their kind, though tags that already share a name can still be saved; delete refused while a recurring seat line counts by the tag; record forms let admins type a new tag inline, replacing the + admin window that reloaded the page and failed for non-admins, and every kind now saves through one tagSetForRecord that keeps only its own kind's tags, each once, fixing the error on a tag sent twice for clients, contacts and assets; typing an archived tag's name restores it, and the picker refuses putting an archived tag on more records; recurring line window keeps its tag once archived; pills pick dark or white text by luminance and carry the description; removes the legacy agent tag handler and tag_model files; database 3.1.3 adds tag_description and normalises stored tag colors to hex and icons to bare names; changelog updated
2026-10-17 13:59:13 -04:00

494 lines
25 KiB
PHP

<?php
/*
* Credentials - a client's logins: the firewall admin, the Microsoft 365
* break-glass account, the registrar, the Wi-Fi key.
*
* Usernames and passwords are encrypted with the vault key that opens with
* the agent's session (functions/security.php); nothing here changes that.
* Passwords and 2FA codes are only ever fetched when asked for, through
* agent/ajax.php (get_credential_via_id, get_totp_token_via_id), which check
* the module and the client and log the view - the HTML these helpers draw
* carries the credential id, never the secret.
*
* A credential belongs to one asset and/or one contact (columns on the
* credential), and is linked to vendors, licenses and services through link
* tables (functions/links.php).
*/
/** How long a password can go unchanged before it is called stale - the same 90 days the rotation report uses. */
const CREDENTIAL_ROTATE_DAYS = 90;
/** Changed within this many days is fresh; between this and a year, due; over a year, overdue. */
const CREDENTIAL_OVERDUE_DAYS = 365;
/** May this agent do this with credentials? 1 is to see them (and reveal, copy and share), 2 to change them, 3 to delete them. */
function credentialCan($level) {
return lookupUserPermission('module_credential') >= $level;
}
/** One credential by id with its client, contact and asset - raw, or null. */
function credentialRow($credential_id) {
global $mysqli;
$credential_id = intval($credential_id);
if (!$credential_id) {
return null;
}
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT credentials.*, client_name,
contact_id, contact_name, contact_title, contact_photo, contact_archived_at,
asset_id, asset_name, asset_type, asset_archived_at
FROM credentials
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 credential_id = $credential_id
LIMIT 1"));
return $row ?: null;
}
/**
* What kind of login it is, from where it opens and what it is called - an
* icon and a tone for its tile: an admin account, Wi-Fi, a remote desktop,
* a shell, a database, a mailbox, a web console, or a plain key.
*/
function credentialKind($row) {
$uri = strtolower(trim((string) ($row['credential_uri'] ?? '')));
$text = strtolower(($row['credential_name'] ?? '') . ' ' . ($row['credential_description'] ?? ''));
$kinds = [
['/wi-?fi|wireless|ssid|psk/', '', 'fa-wifi', 'wireless'],
['/vpn/', '', 'fa-shield-alt', 'firewall'],
['/\b(admin|administrator|root|break ?glass|domain admin)\b/', '', 'fa-user-shield', 'firewall'],
['/database|\bsql\b|mysql|postgres/', '', 'fa-database', 'virtual'],
['/', '#^(rdp|vnc|rustdesk|anydesk|splashtop|connectwise)://#', 'fa-desktop', 'workstation'],
['/', '#^(ssh|telnet|sftp)://#', 'fa-terminal', 'server'],
['/', '#^(smb|file|dav|webdav|ftp|ftps)://#', 'fa-folder-open', 'peripheral'],
['/\b(mail|email|mailbox|365|exchange|workspace)\b/', '', 'fa-envelope', 'phone'],
['/', '#^https?://#', 'fa-globe', 'network'],
];
foreach ($kinds as [$name_match, $uri_match, $icon, $tone]) {
if ($uri_match !== '' && preg_match($uri_match, $uri)) {
return ['icon' => $icon, 'tone' => $tone];
}
if ($uri_match === '' && $name_match !== '/' && preg_match($name_match, $text)) {
return ['icon' => $icon, 'tone' => $tone];
}
}
return ['icon' => 'fa-key', 'tone' => 'other'];
}
/** The credential's tile - its kind's icon on its tone. $size '' | 'sm' | 'lg'. */
function credentialTile($row, $size = '') {
$kind = credentialKind($row);
$size_class = in_array($size, ['sm', 'lg'], true) ? " itflow-asset-icon-$size" : '';
return '<span class="itflow-asset-icon' . $size_class . ' itflow-asset-tone-' . $kind['tone'] . '" aria-hidden="true"><i class="fas fa-fw ' . $kind['icon'] . '"></i></span>';
}
/**
* How old the password is: 'fresh' (changed in the last 90 days), 'due'
* (90 days to a year) or 'overdue' (over a year), with the days and a line
* to show. Null when it has never been recorded.
*/
function credentialAge($changed_at) {
$changed_at = trim((string) $changed_at);
if ($changed_at === '' || strpos($changed_at, '0000-00-00') === 0) {
return null;
}
$changed = strtotime($changed_at);
if (!$changed) {
return null;
}
$days = max(0, intval(floor((time() - $changed) / 86400)));
if ($days >= CREDENTIAL_OVERDUE_DAYS) {
$state = 'overdue';
$tone = 'danger';
} elseif ($days >= CREDENTIAL_ROTATE_DAYS) {
$state = 'due';
$tone = 'warning';
} else {
$state = 'fresh';
$tone = 'success';
}
$text = $days === 0 ? 'Changed today' : ($days === 1 ? 'Changed yesterday' : ($days < 60 ? "Changed $days days ago" : 'Changed ' . timeAgo(date('Y-m-d', $changed))));
return ['state' => $state, 'tone' => $tone, 'days' => $days, 'text' => $text, 'date' => date('Y-m-d', $changed)];
}
/** The age as a soft pill - green, amber when due for rotation, red when overdue. '' when unknown. */
function credentialAgePill($age, $short = false) {
if (!$age) {
return '';
}
$tone = $age['tone'];
$label = $short ? ($age['days'] < 60 ? $age['days'] . 'd' : ($age['days'] < 730 ? round($age['days'] / 30) . 'mo' : round($age['days'] / 365) . 'y')) : $age['text'];
$title = $age['text'] . ($age['state'] === 'fresh' ? '' : ' - due for rotation');
return '<span class="badge rounded-pill bg-' . $tone . '-subtle text-' . $tone . '-emphasis border border-' . $tone . '-subtle fw-normal" title="' . escapeHtml($title) . '">'
. '<i class="fas fa-' . ($age['state'] === 'fresh' ? 'check' : 'history') . ' me-1"></i>' . escapeHtml($label) . '</span>';
}
/**
* A 2FA seed as it is stored - the base32 secret, uppercase, without spaces
* or dashes - from what was pasted: the secret as an app shows it ("abcd efgh
* ...") or a whole otpauth:// link from the QR code. '' for nothing, false
* when it is not a seed.
*/
function credentialOtpSeed($input) {
$input = trim((string) $input);
if ($input === '') {
return '';
}
if (stripos($input, 'otpauth://') === 0) {
$query = [];
parse_str((string) parse_url($input, PHP_URL_QUERY), $query);
$input = (string) ($query['secret'] ?? '');
}
$seed = strtoupper(preg_replace('/[\s\-]+/', '', $input));
$seed = rtrim($seed, '=');
if ($seed === '' || !preg_match('/^[A-Z2-7]+$/', $seed) || strlen($seed) < 16) {
return false;
}
return $seed;
}
/**
* The credential fields a New / Edit form posts, as a SQL SET list - the
* username and password encrypted, the 2FA seed tidied, only the client's
* own contact and asset. Null with $error set when it cannot be saved: no
* name, a field too long, a 2FA seed that is not one, or a vault that did
* not open with this session (encrypting would otherwise write nothing over
* the password).
*/
function credentialFormSet($post, $client_id, &$error = '') {
$error = '';
if (trim((string) ($post['name'] ?? '')) === '') {
$error = 'A credential needs a name.';
return null;
}
if ($too_long = checkCredentialLengths($post)) {
$error = "The $too_long is too long to store.";
return null;
}
$otp = credentialOtpSeed($post['otp_secret'] ?? '');
if ($otp === false) {
$error = "That 2FA secret is not one - paste the key the site shows under \"can't scan the code\", or the otpauth:// link.";
return null;
}
$username = encryptCredentialEntry(trim((string) ($post['username'] ?? '')));
$password = encryptCredentialEntry(trim((string) ($post['password'] ?? '')));
if ($username === false || $password === false) {
$error = 'Your vault session has expired, so the login could not be encrypted - sign out and back in, then try again.';
return null;
}
$text = function ($field, $length) use ($post) {
return "'" . escapeSql(mb_substr(trim((string) ($post[$field] ?? '')), 0, $length)) . "'";
};
$fields = [
'credential_name' => $text('name', 200),
'credential_description' => $text('description', 500),
'credential_uri' => $text('uri', 500),
'credential_uri_2' => $text('uri_2', 500),
'credential_username' => "'" . escapeSql($username) . "'",
'credential_password' => "'" . escapeSql($password) . "'",
'credential_otp_secret' => "'" . escapeSql($otp) . "'",
'credential_note' => "'" . escapeSql(trim((string) ($post['note'] ?? ''))) . "'",
'credential_favorite' => intval($post['favorite'] ?? 0) ? 1 : 0,
'credential_contact_id' => assetClientOwnedId('contact', $post['contact'] ?? 0, $client_id),
'credential_asset_id' => assetClientOwnedId('asset', $post['asset'] ?? 0, $client_id),
];
$set = [];
foreach ($fields as $column => $value) {
$set[] = "$column = $value";
}
return implode(', ', $set);
}
/** Replace a credential's tags with these - credential tags only, and an admin can type a new one in (tagSetForRecord()). */
function credentialSetTags($credential_id, $tag_ids) {
tagSetForRecord('credential', $credential_id, (array) $tag_ids);
}
/** The username as stored, decrypted - '' when there is none or the vault is closed. */
function credentialUsername($row) {
$ciphertext = (string) ($row['credential_username'] ?? '');
if ($ciphertext === '') {
return '';
}
$plain = decryptCredentialEntry($ciphertext);
return $plain === false ? '' : (string) $plain;
}
/** The username with its copy button, or a dash. Raw in. */
function credentialUsernameHtml($username, $empty = '-') {
$username = escapeHtml($username);
if ($username === '') {
return '<span class="text-body-secondary">' . $empty . '</span>';
}
return '<span class="itflow-credential-user text-truncate">' . $username . '</span><button type="button" class="itflow-copy clipboardjs" title="Copy username" data-clipboard-text="' . $username . '"><i class="far fa-copy"></i></button>';
}
/**
* The password as dots, with an eye that fetches and shows it for a little
* while and a copy button that fetches and copies it - both logged as a view
* by agent/ajax.php. Nothing secret is in the page until asked for.
*/
function credentialPasswordHtml($credential_id) {
$credential_id = intval($credential_id);
// Copying is the point here, so its copy button shows all the time (.itflow-copy-always)
return '<span class="itflow-credential-pw itflow-copy-always" data-credential-id="' . $credential_id . '">'
. '<span class="itflow-credential-pw-mask" aria-hidden="true">&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;</span>'
. '<span class="itflow-credential-pw-value font-monospace" hidden></span>'
. '<button type="button" class="itflow-credential-reveal" title="Show password" aria-label="Show password" aria-pressed="false"><i class="fas fa-eye"></i></button>'
. '<button type="button" class="itflow-copy itflow-credential-copy" title="Copy password"><i class="far fa-copy"></i></button>'
. '</span>';
}
/**
* The current 2FA code, fetched when clicked, with how long it has left - it
* follows on to the next code while it is on show, and a second click
* copies it. '' when the credential has no 2FA.
*/
function credentialOtpHtml($credential_id, $has_otp) {
if (!$has_otp) {
return '';
}
$credential_id = intval($credential_id);
return '<button type="button" class="itflow-credential-otp" data-credential-otp="' . $credential_id . '" title="Show the 2FA code">'
. '<i class="far fa-clock me-1"></i><span class="itflow-credential-otp-code">2FA</span><span class="itflow-credential-otp-time"></span></button>';
}
/** Where the login is used - the link, opening in a new tab, and a copy button. '' without one. Raw in. */
function credentialUriHtml($uri, $max = 34) {
$uri = trim((string) $uri);
if ($uri === '') {
return '';
}
$href = escapeUrl($uri);
$label = escapeHtml(truncate(preg_replace('#^https?://#i', '', $uri), $max));
return '<a href="' . $href . '" target="_blank" rel="noopener noreferrer" title="' . escapeHtml($uri) . '">' . $label . '<i class="fas fa-external-link-alt ms-1 small"></i></a>'
. '<button type="button" class="itflow-copy clipboardjs" title="Copy link" data-clipboard-text="' . escapeHtml($uri) . '"><i class="far fa-copy"></i></button>';
}
/** A credential in a picker - its kind's icon and a quieter line under it (functions/format.php selectOptionData). */
function selectOptionCredential($row, $description = '') {
return selectOptionData(['icon' => 'fas ' . credentialKind($row)['icon'], 'description' => $description, 'keywords' => $row['credential_uri'] ?? '']);
}
/**
* Credentials to list somewhere - an asset's, a contact's, a vendor's or a
* license's - each with its client, contact and asset like credentialRow(),
* starred first then by name, keyed by id. $where is SQL the caller built
* from intval'd ids. Archived ones are left out unless asked for.
*/
function credentialRows($where, $include_archived = false) {
global $mysqli;
$sql = mysqli_query($mysqli, "SELECT credentials.*, client_name,
contact_id, contact_name, contact_title, contact_photo, contact_archived_at,
asset_id, asset_name, asset_type, asset_archived_at
FROM credentials
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 ($where)" . ($include_archived ? '' : ' AND credential_archived_at IS NULL') . "
ORDER BY credential_favorite DESC, credential_name ASC
LIMIT 500");
$rows = [];
while ($sql && $row = mysqli_fetch_assoc($sql)) {
$rows[intval($row['credential_id'])] = $row;
}
return $rows;
}
/** The asset and contact a credential belongs to, each as a small link - HTML pieces, maybe none. */
function credentialOwnersHtml($row) {
$pieces = [];
if (!empty($row['asset_id'])) {
$pieces[] = '<span class="itflow-contact-ref"><span class="itflow-asset-icon itflow-asset-icon-sm itflow-vendor-tile-xs itflow-asset-tone-' . assetTypeTone($row['asset_type']) . '" aria-hidden="true"><i class="fas fa-fw fa-' . getAssetIcon($row['asset_type']) . '"></i></span>'
. '<a class="ajax-modal text-truncate" href="#" data-modal-size="lg" data-modal-url="modals/asset/asset.php?id=' . intval($row['asset_id']) . '">' . escapeHtml($row['asset_name']) . '</a></span>';
}
if (!empty($row['contact_id'])) {
$pieces[] = contactLink(intval($row['credential_client_id']), $row['contact_id'], $row['contact_name'], $row['contact_photo'], ['archived' => !empty($row['contact_archived_at'])]);
}
return $pieces;
}
/**
* Credentials as a table, the same wherever they are listed outside the
* credentials page - an asset's or contact's tab, a vendor's logins, a
* license's: the credential (opening its quick look), the login with the
* password and 2FA code on asking, how old the password is, and the actions.
* Options: 'for' adds the asset and contact it belongs to under its name
* (on a vendor's or license's list, where that differs); 'unlink' is a function
* of the row giving the URL that unlinks it from this page's record ('' for
* none).
*/
function credentialTableHtml(array $rows, array $options = []) {
$can_edit = credentialCan(2);
$can_share = shareCanMake('Credential');
$show_for = !empty($options['for']);
$unlink = $options['unlink'] ?? null;
$csrf = $_SESSION['csrf_token'] ?? '';
$html = '<div class="table-responsive"><table class="table table-hover align-middle mb-0 itflow-credential-table">'
. '<thead class="text-nowrap table-light"><tr><th>Credential</th><th>Login</th><th>Changed</th><th></th></tr></thead><tbody>';
foreach ($rows as $row) {
$id = intval($row['credential_id']);
$name = escapeHtml($row['credential_name']);
$description = escapeHtml($row['credential_description']);
$archived = !empty($row['credential_archived_at']);
$quick = "modals/credential/credential.php?id=$id";
$has_otp = trim((string) $row['credential_otp_secret']) !== '';
$html .= '<tr><td class="itflow-credential-name-cell"><div class="d-flex align-items-center gap-2">' . credentialTile($row, 'sm') . '<div class="min-w-0">'
. '<a class="fw-semibold text-body ajax-modal" href="#" data-modal-url="' . $quick . '">' . $name . '</a>'
. (intval($row['credential_favorite']) ? '<i class="fas fa-star text-warning small ms-1" title="Starred"></i>' : '')
. ($description !== '' ? '<div class="small text-body-secondary text-truncate itflow-asset-desc" title="' . $description . '">' . $description . '</div>' : '');
$owners = $show_for ? credentialOwnersHtml($row) : [];
if ($owners) {
$html .= '<div class="small d-flex flex-wrap column-gap-3 row-gap-1 mt-1 itflow-credential-owners">' . implode('', $owners) . '</div>';
}
$html .= '</div></div></td>';
$html .= '<td><div class="d-flex align-items-center small"><i class="fas fa-fw fa-user text-body-secondary me-1"></i>' . credentialUsernameHtml(credentialUsername($row)) . '</div>'
. '<div class="d-flex align-items-center flex-wrap gap-2 mt-1 small">' . credentialPasswordHtml($id) . credentialOtpHtml($id, $has_otp) . '</div>';
foreach (array_filter([$row['credential_uri'] ?? '', $row['credential_uri_2'] ?? '']) as $uri) {
$html .= '<div class="small text-nowrap mt-1">' . credentialUriHtml($uri, 34) . '</div>';
}
$html .= '</td>';
$html .= '<td class="text-nowrap">' . (credentialAgePill(credentialAge($row['credential_password_changed_at']), true) ?: '<span class="text-body-secondary">-</span>') . '</td>';
$actions = '<a class="dropdown-item ajax-modal" href="#" data-modal-url="' . $quick . '"><i class="fas fa-fw fa-eye text-secondary me-2"></i>Quick look</a>';
if ($can_edit && !$archived) {
$actions .= '<a class="dropdown-item ajax-modal" href="#" data-modal-size="lg" data-modal-url="modals/credential/credential_edit.php?id=' . $id . '"><i class="fas fa-fw fa-edit text-secondary me-2"></i>Edit</a>'
. '<a class="dropdown-item ajax-modal" href="#" data-modal-url="modals/credential/credential_rotate.php?id=' . $id . '"><i class="fas fa-fw fa-sync-alt text-secondary me-2"></i>Change password</a>';
}
if ($can_share && !$archived) {
$actions .= '<a class="dropdown-item ajax-modal" href="#" data-modal-url="modals/share/share_add.php?type=Credential&amp;id=' . $id . '"><i class="fas fa-fw fa-share-alt text-secondary me-2"></i>Share</a>';
}
$unlink_url = $unlink ? (string) $unlink($row) : '';
if ($unlink_url !== '' || ($can_edit && !$archived)) {
$actions .= '<div class="dropdown-divider"></div>';
}
if ($unlink_url !== '') {
$actions .= '<a class="dropdown-item" href="' . escapeHtml($unlink_url) . '"><i class="fas fa-fw fa-unlink text-secondary me-2"></i>Unlink</a>';
}
if ($can_edit && !$archived) {
$actions .= '<a class="dropdown-item text-danger confirm-link" href="post.php?archive_credential=' . $id . '&amp;csrf_token=' . escapeHtml($csrf) . '"><i class="fas fa-fw fa-archive me-2"></i>Archive</a>';
}
$html .= '<td class="text-end"><div class="dropdown dropstart"><button class="btn btn-sm btn-light" title="More actions" type="button" data-bs-toggle="dropdown"><i class="fas fa-fw fa-ellipsis-v"></i></button>'
. '<div class="dropdown-menu">' . $actions . '</div></div></td></tr>';
}
return $html . '</tbody></table></div>';
}
/**
* Credentials as a short list for the quick looks (an asset's, a contact's):
* each one's tile and name opening its own quick look, the login with the
* password and 2FA code on asking, and how old the password is. Rows from
* credentialRows().
*/
function credentialListHtml(array $rows) {
$html = '<ul class="list-unstyled mb-0 itflow-links">';
foreach ($rows as $row) {
$link_row = $row + ['id' => $row['credential_id'], 'name' => $row['credential_name']];
$html .= '<li class="itflow-link-row">' . linkRowHtml('credential', $link_row, $row['credential_client_id'])
. credentialAgePill(credentialAge($row['credential_password_changed_at']), true) . '</li>';
}
return $html . '</ul>';
}
/**
* What each credential is linked to beyond its own asset and contact -
* vendors, licenses and services - for the whole page at once:
* [credential_id => [['type', 'id', 'name'], ...]].
*/
function credentialLinks(array $credential_ids) {
global $mysqli;
$credential_ids = array_values(array_unique(array_filter(array_map('intval', $credential_ids))));
$links = array_fill_keys($credential_ids, []);
if (!$credential_ids) {
return $links;
}
$in = implode(',', $credential_ids);
$sources = [
'vendor' => "SELECT l.credential_id, v.vendor_id, v.vendor_name FROM vendor_credentials l JOIN vendors v ON v.vendor_id = l.vendor_id AND v.vendor_archived_at IS NULL WHERE l.credential_id IN ($in) ORDER BY v.vendor_name",
'software' => "SELECT l.credential_id, s.software_id, s.software_name FROM software_credentials l JOIN software s ON s.software_id = l.software_id AND s.software_archived_at IS NULL WHERE l.credential_id IN ($in) ORDER BY s.software_name",
'service' => "SELECT l.credential_id, s.service_id, s.service_name FROM service_credentials l JOIN services s ON s.service_id = l.service_id AND s.service_archived_at IS NULL WHERE l.credential_id IN ($in) ORDER BY s.service_name",
];
foreach ($sources as $type => $query) {
$sql = mysqli_query($mysqli, $query);
while ($row = mysqli_fetch_row($sql)) {
$links[intval($row[0])][] = [$type, intval($row[1]), $row[2]];
}
}
return $links;
}
/** The share links still open for each credential, for the whole page at once: [credential_id => [share rows]]. */
function credentialShares(array $credential_ids) {
global $mysqli;
$credential_ids = array_values(array_unique(array_filter(array_map('intval', $credential_ids))));
$shares = array_fill_keys($credential_ids, []);
if (!$credential_ids) {
return $shares;
}
$sql = mysqli_query($mysqli, "SELECT item_id, item_related_id, item_recipient, item_note, item_views, item_view_limit, item_created_at, item_expire_at
FROM shared_items
WHERE item_type = 'Credential' AND " . shareOpenSql() . "
AND item_related_id IN (" . implode(',', $credential_ids) . ")
ORDER BY item_expire_at ASC");
while ($row = mysqli_fetch_assoc($sql)) {
$shares[intval($row['item_related_id'])][] = $row;
}
return $shares;
}
/**
* Who did what with a credential - its audit log entries (views, 2FA code
* views, edits, rotations, links, shares), newest first.
*/
function credentialHistory($credential_id, $limit = 50) {
global $mysqli;
$credential_id = intval($credential_id);
$limit = max(1, intval($limit));
$sql = mysqli_query($mysqli, "SELECT log_action, log_description, log_created_at, user_name FROM logs
LEFT JOIN users ON user_id = log_user_id
WHERE log_type = 'Credential' AND log_entity_id = $credential_id
ORDER BY log_id DESC LIMIT $limit");
$rows = [];
while ($row = mysqli_fetch_assoc($sql)) {
$rows[] = $row;
}
return $rows;
}
/** The last time someone looked at the password or the 2FA code, and who - or null. */
function credentialLastViewed($credential_id) {
global $mysqli;
$credential_id = intval($credential_id);
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT log_created_at, user_name FROM logs
LEFT JOIN users ON user_id = log_user_id
WHERE log_type = 'Credential' AND log_entity_id = $credential_id AND log_action IN ('View', 'View TOTP')
ORDER BY log_id DESC LIMIT 1"));
return $row ?: null;
}
/**
* An asset or contact is going for good - its credentials stay, for no asset
* or no contact, rather than pointing at an id that no longer exists.
*/
function credentialsForget($kind, $id) {
global $mysqli;
$column = ['asset' => 'credential_asset_id', 'contact' => 'credential_contact_id'][$kind] ?? null;
$id = intval($id);
if (!$column || !$id) {
return;
}
mysqli_query($mysqli, "UPDATE credentials SET $column = 0 WHERE $column = $id");
}
/** Deleting a credential for good - its open share links go with it. Links and tags cascade. */
function credentialDeleteForGood($credential_id) {
global $mysqli;
$credential_id = intval($credential_id);
mysqli_query($mysqli, "DELETE FROM shared_items WHERE item_type = 'Credential' AND item_related_id = $credential_id");
mysqli_query($mysqli, "DELETE FROM credentials WHERE credential_id = $credential_id");
}