Files
itflow/functions/asset.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

484 lines
21 KiB
PHP

<?php
/*
* Assets - how an asset is shown: the colour of its type, the badge for its
* status, the icon for its operating system and how its warranty and age read.
* Shared by the assets list (agent/assets.php), the asset page
* (agent/asset.php) and the quick-look modal (agent/modals/asset/asset.php).
*
* Everything here takes raw values and returns plain strings or arrays of
* them - escape anything that came from the database before echoing it.
*/
/** The colour family an asset type is drawn in - a CSS class suffix (itflow-asset-tone-*). */
function assetTypeTone($asset_type) {
$tones = [
'Laptop' => 'workstation',
'Desktop' => 'workstation',
'Server' => 'server',
'Virtual Machine' => 'virtual',
'Switch' => 'network',
'Firewall/Router' => 'firewall',
'Access Point' => 'wireless',
'Phone' => 'phone',
'Mobile Phone' => 'phone',
'Tablet' => 'phone',
'Printer' => 'peripheral',
'Display' => 'peripheral',
'Camera' => 'peripheral',
];
return $tones[(string) $asset_type] ?? 'other';
}
/**
* Bootstrap colour for an asset status. A colour set on the status in
* Admin > Categories wins; otherwise the built-in statuses get a sensible
* default and anything else is grey.
*
* Returns ['class' => bootstrap tone or '', 'style' => inline background or '']
*/
function assetStatusBadge($status, $category_color = '') {
$category_color = trim((string) $category_color);
if (preg_match('/^(#[0-9a-f]{3,8}|[a-z]{3,24})$/i', $category_color)) {
return ['class' => '', 'style' => "background-color: $category_color"];
}
$defaults = [
'Deployed' => 'success',
'Ready to Deploy' => 'info',
'Out for Repair' => 'warning',
'Lost' => 'danger',
'Stolen' => 'danger',
'Retired' => 'secondary',
];
return ['class' => $defaults[(string) $status] ?? 'secondary', 'style' => ''];
}
/** Status colours from Admin > Categories, keyed by status name (raw). One query per page. */
function assetStatusColors() {
global $mysqli;
static $colors = null;
if ($colors === null) {
$colors = [];
$sql = mysqli_query($mysqli, "SELECT category_name, category_color FROM categories WHERE category_type = 'asset_status' AND category_archived_at IS NULL");
while ($row = mysqli_fetch_assoc($sql)) {
$colors[$row['category_name']] = (string) $row['category_color'];
}
}
return $colors;
}
/** The colour a status is drawn in, as a CSS value - the same as its badge. */
function assetStatusColor($status, $category_color = '') {
$badge = assetStatusBadge($status, $category_color);
return $badge['style'] ? trim(substr($badge['style'], strlen('background-color:'))) : "var(--bs-{$badge['class']})";
}
/** The status badge, ready to echo. */
function assetStatusPill($status) {
$status = (string) $status;
if ($status === '') {
return '';
}
$badge = assetStatusBadge($status, assetStatusColors()[$status] ?? '');
$class = $badge['class'] ? "text-bg-{$badge['class']}" : 'text-white';
$style = $badge['style'] ? " style=\"{$badge['style']}\"" : '';
return "<span class=\"badge rounded-pill fw-normal $class\"$style>" . escapeHtml($status) . "</span>";
}
/** OS name fragments and their brand icons (Font Awesome 5), first match wins. Also drives the asset form's OS icon. */
function assetOsIconMap() {
return [
'windows' => 'fab fa-windows',
'macos' => 'fab fa-apple',
'mac os' => 'fab fa-apple',
'ios' => 'fab fa-apple',
'ipados' => 'fab fa-apple',
'android' => 'fab fa-android',
'ubuntu' => 'fab fa-ubuntu',
'red hat' => 'fab fa-redhat',
'rhel' => 'fab fa-redhat',
'centos' => 'fab fa-centos',
'fedora' => 'fab fa-fedora',
'suse' => 'fab fa-suse',
'freebsd' => 'fab fa-freebsd',
'pfsense' => 'fab fa-freebsd',
'opnsense' => 'fab fa-freebsd',
'truenas' => 'fab fa-freebsd',
'raspberry' => 'fab fa-raspberry-pi',
'linux' => 'fab fa-linux',
'debian' => 'fab fa-linux',
'mint' => 'fab fa-linux',
'proxmox' => 'fab fa-linux',
'pop!_os' => 'fab fa-linux',
'firmware' => 'fas fa-microchip',
'routeros' => 'fas fa-microchip',
];
}
/** A brand icon for the operating system, where one is recognisable (Font Awesome 5 class). */
function assetOsIcon($os) {
$os = strtolower((string) $os);
foreach (assetOsIconMap() as $needle => $icon) {
if (strpos($os, $needle) !== false) {
return $icon;
}
}
return 'fas fa-compact-disc';
}
/**
* Where the warranty stands, or null when there's no date.
*
* Returns ['tone' => danger|warning|success, 'short' => "Expired" / "23 days left" / "Covered",
* 'text' => "Expired 3 months ago" / "Expires in 23 days" / "Until 2028-11-08",
* 'days' => days left (negative once expired)]
*/
function assetWarranty($warranty_expire, $soon_days = 90) {
$warranty_expire = trim((string) $warranty_expire);
if ($warranty_expire === '' || strpos($warranty_expire, '0000-00-00') === 0) {
return null;
}
$expires = DateTime::createFromFormat('!Y-m-d', substr($warranty_expire, 0, 10));
if (!$expires) {
return null;
}
$today = new DateTime('today');
$days = (int) $today->diff($expires)->format('%r%a');
$date = $expires->format('Y-m-d');
if ($days < 0) {
return ['tone' => 'danger', 'short' => 'Expired', 'text' => 'Expired ' . timeAgo($date), 'days' => $days, 'date' => $date];
}
if ($days <= $soon_days) {
$left = $days === 0 ? 'today' : ($days === 1 ? '1 day left' : "$days days left");
return ['tone' => 'warning', 'short' => $left, 'text' => $days === 0 ? 'Expires today' : "Expires in $days day" . ($days === 1 ? '' : 's'), 'days' => $days, 'date' => $date];
}
return ['tone' => 'success', 'short' => 'Covered', 'text' => "Until $date", 'days' => $days, 'date' => $date];
}
/** "3 yrs 2 mos" since a date, or '' without one. For purchase / install dates. */
function assetAge($date) {
$date = trim((string) $date);
if ($date === '' || strpos($date, '0000-00-00') === 0) {
return '';
}
$from = DateTime::createFromFormat('!Y-m-d', substr($date, 0, 10));
if (!$from) {
return '';
}
$diff = $from->diff(new DateTime('today'));
if ($diff->invert) {
return '';
}
$parts = [];
if ($diff->y) {
$parts[] = $diff->y . ' yr' . ($diff->y === 1 ? '' : 's');
}
if ($diff->m) {
$parts[] = $diff->m . ' mo' . ($diff->m === 1 ? '' : 's');
}
if (!$parts) {
return $diff->d <= 1 ? 'new' : $diff->d . ' days';
}
return implode(' ', $parts);
}
/**
* The asset's links (URI, URI 2, client URI) as [label, url] pairs, skipping
* empty ones. Raw - escapeUrl() each url before it goes in an href.
*/
function assetLinks($uri, $uri_2, $uri_client) {
$links = [];
foreach ([['Open', $uri], ['Open 2', $uri_2], ['Client link', $uri_client]] as [$label, $url]) {
$url = trim((string) $url);
if ($url !== '') {
$links[] = [$label, $url];
}
}
return $links;
}
/** A short name for a link, by what it opens: "Web", "SSH", "RDP"... The full URL goes in its title. */
function assetLinkLabel($url) {
$scheme = strtolower((string) parse_url((string) $url, PHP_URL_SCHEME));
$labels = ['http' => 'Web', 'https' => 'Web', 'ssh' => 'SSH', 'telnet' => 'Telnet', 'rdp' => 'RDP', 'vnc' => 'VNC',
'ftp' => 'FTP', 'sftp' => 'SFTP', 'smb' => 'SMB'];
return $labels[$scheme] ?? ($scheme !== '' ? strtoupper($scheme) : 'Link');
}
/** Link schemes and their icons. Also drives the asset form's link icons. */
function assetLinkIconMap() {
return [
'ssh' => 'fas fa-terminal',
'telnet' => 'fas fa-terminal',
'rdp' => 'fas fa-desktop',
'vnc' => 'fas fa-desktop',
'ftp' => 'fas fa-folder-open',
'sftp' => 'fas fa-folder-open',
'smb' => 'fas fa-folder-open',
'http' => 'fas fa-globe',
'https' => 'fas fa-globe',
];
}
/** Icon for a link by its scheme. */
function assetLinkIcon($url) {
$scheme = strtolower((string) parse_url((string) $url, PHP_URL_SCHEME));
return assetLinkIconMap()[$scheme] ?? 'fas fa-external-link-alt';
}
/**
* $id if that location / contact / vendor / network / asset belongs to the
* client, else 0. The handlers run their picks through this so a hand-edited
* POST can't hang another client's contact or location off an asset.
*/
function assetClientOwnedId($kind, $id, $client_id) {
global $mysqli;
$tables = [
'location' => ['locations', 'location_id', 'location_client_id'],
'contact' => ['contacts', 'contact_id', 'contact_client_id'],
'vendor' => ['vendors', 'vendor_id', 'vendor_client_id'],
'network' => ['networks', 'network_id', 'network_client_id'],
'asset' => ['assets', 'asset_id', 'asset_client_id'],
];
$id = intval($id);
$client_id = intval($client_id);
if (!$id || !isset($tables[$kind])) {
return 0;
}
[$table, $id_column, $client_column] = $tables[$kind];
$sql = mysqli_query($mysqli, "SELECT 1 FROM $table WHERE $id_column = $id AND $client_column = $client_id LIMIT 1");
return mysqli_num_rows($sql) ? $id : 0;
}
/*
* Quick pickers (js/quick_pick.js) for an asset's status, location and who it
* is assigned to - changed where they show on the assets list and the asset
* page instead of through Edit. Wrapped in quickPick() (functions/ticket_fields.php)
* with the asset's client as the scope, since locations and contacts are per client.
*/
/** The asset fields a quick picker can change. */
const ASSET_QUICK_FIELDS = ['asset_status', 'asset_location', 'asset_contact'];
/** The asset and what its pickers show, by id - the row assetQuickHtml() and assetContactDetails() draw from. */
function assetQuickRow($asset_id) {
global $mysqli;
return mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT asset_id, asset_name, asset_status, asset_client_id,
asset_location_id, location_name, location_archived_at,
asset_contact_id, contact_name, contact_photo, contact_archived_at, contact_email,
contact_phone, contact_phone_country_code, contact_extension, contact_mobile, contact_mobile_country_code
FROM assets
LEFT JOIN locations ON location_id = asset_location_id
LEFT JOIN contacts ON contact_id = asset_contact_id
WHERE asset_id = " . intval($asset_id))) ?: null;
}
/**
* What a picker shows for the value it holds now. $style is 'list' (a pill,
* on the assets list and in the asset page header) or 'detail' (a line in the
* asset page's Where & who card). $row is raw, as assetQuickRow() returns it.
*/
function assetQuickHtml($field, $row, $style = 'list') {
$client_id = intval($row['asset_client_id'] ?? 0);
$pill = 'badge rounded-pill fw-normal d-inline-flex align-items-center gap-1 itflow-asset-pill';
$empty = "$pill itflow-asset-pill-empty";
if ($field === 'asset_status') {
return assetStatusPill($row['asset_status'] ?? '') ?: "<span class=\"$empty\">No status</span>";
}
if ($field === 'asset_location') {
if (!intval($row['asset_location_id'] ?? 0) || (string) ($row['location_name'] ?? '') === '') {
return $style === 'detail'
? '<span class="text-body-secondary">No location</span>'
: "<span class=\"$empty\"><i class=\"fas fa-map-marker-alt\"></i>No location</span>";
}
$archived = !empty($row['location_archived_at']);
$name = '<span class="text-truncate' . ($archived ? ' text-decoration-line-through' : '') . '"' . ($archived ? ' title="Archived location"' : '') . '>' . escapeHtml($row['location_name']) . '</span>';
return $style === 'detail'
? '<span class="d-inline-flex align-items-center gap-2 min-w-0' . ($archived ? ' text-danger' : '') . "\">$name</span>"
: "<span class=\"$pill " . ($archived ? 'text-danger-emphasis bg-danger-subtle' : 'text-body-emphasis bg-body-secondary') . "\"><i class=\"fas fa-map-marker-alt text-body-secondary\"></i>$name</span>";
}
if ($field === 'asset_contact') {
if (!intval($row['asset_contact_id'] ?? 0) || (string) ($row['contact_name'] ?? '') === '') {
return $style === 'detail'
? '<span class="text-body-secondary">Nobody</span>'
: "<span class=\"$empty\"><i class=\"fas fa-user-slash\"></i>Nobody</span>";
}
$archived = !empty($row['contact_archived_at']);
$avatar = contactAvatar($client_id, $row['asset_contact_id'], $row['contact_name'], $row['contact_photo'] ?? '', 'xs');
$name = '<span class="text-truncate' . ($archived ? ' text-decoration-line-through' : '') . '"' . ($archived ? ' title="Archived contact"' : '') . '>' . escapeHtml($row['contact_name']) . '</span>';
return $style === 'detail'
? "<span class=\"d-inline-flex align-items-center gap-2 min-w-0\">$avatar$name</span>"
: "<span class=\"$pill itflow-asset-pill-person " . ($archived ? 'text-body-secondary' : 'text-body-emphasis') . " bg-body-secondary\">$avatar$name</span>";
}
return '';
}
/** Under the asset page's Assigned picker: how to reach that person, and a link to them. Redrawn when the contact changes. */
function assetContactDetails($row) {
$contact_id = intval($row['asset_contact_id'] ?? 0);
if (!$contact_id || (string) ($row['contact_name'] ?? '') === '') {
return '';
}
$client_id = intval($row['asset_client_id']);
$email = escapeHtml($row['contact_email'] ?? '');
$phone = escapeHtml(formatPhoneNumber($row['contact_phone'] ?? '', $row['contact_phone_country_code'] ?? ''));
$extension = escapeHtml($row['contact_extension'] ?? '');
$mobile = escapeHtml(formatPhoneNumber($row['contact_mobile'] ?? '', $row['contact_mobile_country_code'] ?? ''));
$html = '';
if ($email) {
$html .= "<div class=\"small text-truncate\"><a href=\"mailto:$email\">$email</a><button type=\"button\" class=\"itflow-copy clipboardjs\" title=\"Copy email\" data-clipboard-text=\"$email\"><i class=\"far fa-copy\"></i></button></div>";
}
if ($phone) {
$html .= "<div class=\"small\"><i class=\"fas fa-fw fa-phone text-secondary me-1\"></i>$phone" . ($extension ? " x$extension" : '') . '</div>';
}
if ($mobile) {
$html .= "<div class=\"small\"><i class=\"fas fa-fw fa-mobile-alt text-secondary me-1\"></i>$mobile</div>";
}
$html .= "<div class=\"small\"><a href=\"contact.php?client_id=$client_id&contact_id=$contact_id\">Open contact<i class=\"fas fa-arrow-right ms-1\"></i></a></div>";
return $html;
}
/**
* The choices a picker offers, in js/quick_pick.js's option shape. Locations
* and contacts are the client's own (null if the agent can't reach that client).
*/
function assetQuickOptions($field, $client_id) {
global $mysqli;
$client_id = intval($client_id);
if ($field === 'asset_status') {
$options = [['value' => '', 'label' => 'No status', 'icon' => 'fas fa-minus', 'none' => true]];
$sql = mysqli_query($mysqli, "SELECT category_name, category_color FROM categories
WHERE category_type = 'asset_status' AND category_archived_at IS NULL
ORDER BY category_order ASC, category_name ASC");
while ($row = mysqli_fetch_assoc($sql)) {
$options[] = ['value' => $row['category_name'], 'label' => $row['category_name'], 'color' => assetStatusColor($row['category_name'], $row['category_color'])];
}
return $options;
}
if (!$client_id || !hasClientAccess($client_id)) {
return null;
}
if ($field === 'asset_location') {
$options = [['value' => '0', 'label' => 'No location', 'icon' => 'fas fa-ban', 'none' => true]];
$sql = mysqli_query($mysqli, "SELECT location_id, location_name, location_primary, location_city, location_state FROM locations
WHERE location_client_id = $client_id AND location_archived_at IS NULL
ORDER BY location_primary DESC, location_name ASC");
while ($row = mysqli_fetch_assoc($sql)) {
$place = implode(', ', array_filter([trim((string) $row['location_city']), trim((string) $row['location_state'])]));
$description = implode(' · ', array_filter([intval($row['location_primary']) ? 'Primary' : '', $place]));
$options[] = ['value' => (string) intval($row['location_id']), 'label' => $row['location_name'], 'icon' => 'fas fa-map-marker-alt', 'description' => $description];
}
return $options;
}
if ($field === 'asset_contact') {
$options = [['value' => '0', 'label' => 'Nobody', 'icon' => 'fas fa-user-slash', 'none' => true]];
$sql = mysqli_query($mysqli, "SELECT contact_id, contact_name, contact_title, contact_photo FROM contacts
WHERE contact_client_id = $client_id AND contact_archived_at IS NULL
ORDER BY contact_name ASC");
while ($row = mysqli_fetch_assoc($sql)) {
$options[] = ['value' => (string) intval($row['contact_id']), 'label' => $row['contact_name'], 'description' => (string) $row['contact_title']]
+ contactAvatarData($client_id, $row['contact_id'], $row['contact_name'], $row['contact_photo']);
}
return $options;
}
return null;
}
/**
* Change one field on one asset, as Edit Asset would - audit log and asset
* history included. The caller checks the agent may edit assets of its
* client. Returns [ok, plain-text message].
*/
function assetQuickSet($asset_id, $field, $value) {
global $mysqli, $session_name;
$asset_id = intval($asset_id);
$row = assetQuickRow($asset_id);
if (!$row) {
return [false, 'That asset no longer exists.'];
}
$client_id = intval($row['asset_client_id']);
$asset_name = escapeSql($row['asset_name']);
$value = trim((string) $value);
if ($field === 'asset_status') {
if ($value !== '' && !isset(assetStatusColors()[$value])) {
return [false, 'Pick one of the asset statuses.'];
}
if ($value === (string) $row['asset_status']) {
return [true, ''];
}
$status = escapeSql($value);
mysqli_query($mysqli, "UPDATE assets SET asset_status = '$status' WHERE asset_id = $asset_id");
$what = $value === '' ? "cleared the status on $asset_name" : "set status to $status on $asset_name";
$message = $value === '' ? 'Status cleared' : "Status set to $value";
} elseif ($field === 'asset_location') {
$location_id = assetClientOwnedId('location', $value, $client_id);
if (intval($value) && !$location_id) {
return [false, 'That location is not one of this client\'s.'];
}
if ($location_id === intval($row['asset_location_id'])) {
return [true, ''];
}
$location_name = $location_id ? (string) mysqli_fetch_row(mysqli_query($mysqli, "SELECT location_name FROM locations WHERE location_id = $location_id"))[0] : '';
$status = escapeSql($row['asset_status']);
mysqli_query($mysqli, "UPDATE assets SET asset_location_id = $location_id WHERE asset_id = $asset_id");
$what = $location_id ? "assigned asset $asset_name to location " . escapeSql($location_name) : "removed asset $asset_name from its location";
$message = $location_id ? "Moved to $location_name" : 'Location removed';
} elseif ($field === 'asset_contact') {
$contact_id = assetClientOwnedId('contact', $value, $client_id);
if (intval($value) && !$contact_id) {
return [false, 'That contact is not one of this client\'s.'];
}
if ($contact_id === intval($row['asset_contact_id'])) {
return [true, ''];
}
$contact_name = $contact_id ? (string) mysqli_fetch_row(mysqli_query($mysqli, "SELECT contact_name FROM contacts WHERE contact_id = $contact_id"))[0] : '';
$status = escapeSql($row['asset_status']);
mysqli_query($mysqli, "UPDATE assets SET asset_contact_id = $contact_id WHERE asset_id = $asset_id");
$what = $contact_id ? "assigned asset $asset_name to contact " . escapeSql($contact_name) : "unassigned asset $asset_name";
$message = $contact_id ? "Assigned to $contact_name" : 'Unassigned';
} else {
return [false, 'Unknown field.'];
}
logAudit("Asset", "Edit", "$session_name $what", $client_id, $asset_id);
mysqli_query($mysqli, "INSERT INTO asset_history SET asset_history_status = '$status', asset_history_description = '$session_name $what', asset_history_asset_id = $asset_id");
return [true, $message];
}
/** What quick_pick.js redraws after a change: the value in both looks, plus the contact details on the asset page. */
function assetQuickRecord($asset_id, $field) {
$row = assetQuickRow($asset_id);
$current = [
'asset_status' => (string) $row['asset_status'],
'asset_location' => (string) intval($row['asset_location_id']),
'asset_contact' => (string) intval($row['asset_contact_id']),
][$field];
$record = ['current' => $current, 'html' => ['list' => assetQuickHtml($field, $row, 'list'), 'detail' => assetQuickHtml($field, $row, 'detail')]];
if ($field === 'asset_contact') {
$record['related'] = ['asset-contact-' . intval($asset_id) => assetContactDetails($row)];
}
return $record;
}