Files
itflow/agent/modals/software/software.php
johnnyq a33f98ffa5 Licenses: rebuilt the license list with Renewing soon, Expired, Over seats and No seats assigned tabs, a seat bar (used against bought, red when over) and colour-coded renewal on each row, license type and renewal filters, optional key, purchase and order reference columns, search by vendor, order reference and individual keys, and bulk Renew, Archive, Restore and Delete; new license page (license.php) with renewal and over-seats status in the header, License and Vendor cards (key, order ref, purchase date, vendor account number, phone, email, website, hours, SLA, all copyable), notes that save on blur, and tabs for Seats (each device and user with the key they were given), Keys, Credentials, Documents, Files and History from the audit log; new license quick look used everywhere a license is shown; New and Edit share one tabbed form (Details, License, Seats, Notes) with device and user pickers, a live seat counter, renewal shortcuts and a template picker that replaces the separate Create from Template modal; Renew modal for one license or a bulk selection that moves the renewal date by a term or to a set date, previews the new date for each license and records a new order reference; Manage Seats modal; individual keys pasted one per line and handed out to devices and users, which gives them a seat, with the key returned to the pool when they come off the license; Renewal Ticket opens New Ticket with the subject and details filled in; New Document and Upload from a license link the result to it; devices, users, credentials and files now link to licenses through the shared link modal, which shows the seats left on each license and offers every license type, not just Device; the asset and contact pages and quick looks list licenses with their seats, renewal and the key to install, and get New License; the client overview gets a Licenses over seats card under Needs attention, starred licenses under Favorites and renewals that open the license page; global search finds licenses by name, vendor, order reference or key; the vendor quick look lists that vendor's licenses; the dashboard card opens the Renewing tab; the export gains seats used, individual keys, vendor, order reference and client columns; admin license templates share the license type list and show type icons. Uses the existing software_keys, key assignment, software_credentials and software_files tables. Fixes editing a Subscription or Open Source license clearing its license type, archiving a license removing all of its seats (seats are now kept, matching the API), license forms and seat handlers accepting another client's assets, contacts or vendor, a 500 on duplicate seat links and on forms missing fields, and an extra query per row in the export; license deletes now respect the destructive deletes setting. No DB change
2026-10-14 21:31:30 -04:00

319 lines
19 KiB
PHP

<?php
/*
* License quick look - the license page in a modal, opened from the asset
* and contact pages, the license list, a document's or file's links and the
* search. Header with renewal and seat state, the facts to copy (key, order
* reference, vendor account and phone), quick notes, then tabs for only what
* has something in it: devices, users, keys, credentials, documents, files.
*/
require_once '../../../includes/modal_header.php';
enforceUserPermission('module_support');
$software_id = intval($_GET['id'] ?? 0);
$license = softwareRow($software_id);
if (!$license) {
echo json_encode(['error' => 'That license no longer exists.']);
exit;
}
$client_id = intval($license['software_client_id']);
enforceClientAccess();
$software_name = escapeHtml($license['software_name']);
$software_version = escapeHtml($license['software_version']);
$software_description = escapeHtml($license['software_description']);
$software_purchase_reference = escapeHtml($license['software_purchase_reference']);
$software_notes = escapeHtml($license['software_notes']);
$software_archived = !empty($license['software_archived_at']);
$vendor_id = intval($license['vendor_id']);
$vendor_name = escapeHtml($license['vendor_name']);
$vendor_account = escapeHtml($license['vendor_account_number']);
$vendor_phone = contactPhoneLink($license['vendor_phone'], $license['vendor_phone_country_code'], $license['vendor_extension']);
$seats = softwareSeats($license['seats_used'], $license['software_seats']);
$renewal = softwareRenewal($license['software_expire']);
$can_edit = lookupUserPermission('module_support') >= 2 && !$software_archived;
$can_credentials = lookupUserPermission('module_credential') >= 1;
$holders = softwareSeatHolders($software_id);
$keys = softwareKeys($software_id);
$credentials = $can_credentials ? linkedRecords('software', $software_id, 'credential') : [];
$documents = linkedRecords('software', $software_id, 'document');
$files = linkedRecords('software', $software_id, 'file');
$quick_tabs = array_filter([
'devices' => ['label' => 'Devices', 'count' => count($holders['assets'])],
'users' => ['label' => 'Users', 'count' => count($holders['contacts'])],
'keys' => ['label' => 'Keys', 'count' => count($keys)],
'credentials' => ['label' => 'Credentials', 'count' => count($credentials)],
'documents' => ['label' => 'Documents', 'count' => count($documents)],
'files' => ['label' => 'Files', 'count' => count($files)],
], function ($tab) {
return $tab['count'] > 0;
});
$first_tab = array_key_first($quick_tabs);
$quick_id = "license-quick-$software_id";
ob_start();
?>
<div class="modal-header bg-dark" data-bs-theme="dark">
<div class="d-flex align-items-center gap-3 min-w-0">
<?= softwareTile($license['software_type']) ?>
<div class="min-w-0">
<h5 class="modal-title d-flex flex-wrap align-items-center column-gap-2 row-gap-1">
<span class="fw-semibold text-break"><?= $software_name ?></span>
<?php if ($software_version) { ?><span class="text-body-secondary fw-normal fs-6"><?= $software_version ?></span><?php } ?>
<?php if (intval($license['software_favorite'])) { ?><i class="fas fa-star text-warning fs-6" title="Favorite"></i><?php } ?>
<?php if ($software_archived) { ?><span class="badge rounded-pill text-bg-secondary fw-normal fs-6">Archived</span><?php } ?>
<span class="fs-6"><?= $renewal && $renewal['state'] !== 'active' ? softwareRenewalPill($renewal) : '' ?></span>
<?php if ($seats['state'] === 'over') { ?>
<span class="badge rounded-pill bg-danger-subtle text-danger-emphasis border border-danger-subtle fw-normal fs-6"><?= $seats['over'] ?> over seats</span>
<?php } ?>
</h5>
<div class="small text-body-secondary d-flex flex-wrap column-gap-3 mt-1">
<?php if ($license['software_type']) { ?><span><?= escapeHtml($license['software_type']) ?></span><?php } ?>
<?php if ($license['software_license_type']) { ?><span><i class="fas fa-fw <?= softwareLicenseTypeIcon($license['software_license_type']) ?> me-1"></i><?= escapeHtml($license['software_license_type']) ?></span><?php } ?>
<span><?= escapeHtml($license['client_name']) ?></span>
</div>
</div>
</div>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<?php if ($software_description) { ?>
<div class="text-body-secondary mb-3"><?= $software_description ?></div>
<?php } ?>
<!-- At a glance - the icons say what each line is -->
<div class="row g-2 itflow-asset-glance">
<div class="col-sm-6">
<div class="itflow-glance-item" title="Seats">
<i class="fas fa-fw fa-users"></i>
<div class="flex-grow-1 min-w-0 itflow-seats-glance"><?= softwareSeatBar($seats) ?></div>
</div>
<?php if ($license['software_key']) { ?>
<div class="itflow-glance-item" title="License key">
<i class="fas fa-fw fa-key"></i>
<span class="min-w-0"><?= softwareKeyHtml($license['software_key']) ?></span>
</div>
<?php } elseif ($keys) { ?>
<div class="itflow-glance-item" title="Keys">
<i class="fas fa-fw fa-key"></i>
<span><?= count($keys) ?> individual key<?= count($keys) === 1 ? '' : 's' ?></span>
</div>
<?php } ?>
<?php if ($software_purchase_reference) { ?>
<div class="itflow-glance-item" title="Order reference">
<i class="fas fa-fw fa-receipt"></i>
<span><?= $software_purchase_reference ?><button type="button" class="itflow-copy clipboardjs" title="Copy reference" data-clipboard-text="<?= $software_purchase_reference ?>"><i class="far fa-copy"></i></button></span>
</div>
<?php } ?>
</div>
<div class="col-sm-6">
<div class="itflow-glance-item" title="Renewal">
<i class="fas fa-fw fa-calendar-alt"></i>
<?php if ($renewal) { ?>
<span><?= escapeHtml($renewal['date']) ?> <span class="small text-<?= $renewal['tone'] === 'success' ? 'body-secondary' : $renewal['tone'] . '-emphasis' ?>">&middot; <?= escapeHtml($renewal['text']) ?></span></span>
<?php } else { ?>
<span class="text-body-secondary">No renewal date</span>
<?php } ?>
</div>
<?php if ($vendor_name) { ?>
<div class="itflow-glance-item" title="Vendor">
<i class="fas fa-fw fa-building"></i>
<span class="min-w-0">
<a class="ajax-modal" href="#" data-modal-url="modals/vendor/vendor.php?id=<?= $vendor_id ?>"><?= $vendor_name ?></a>
<?php if ($vendor_phone) { ?><span class="text-body-secondary">&middot;</span> <?= $vendor_phone ?><?php } ?>
</span>
</div>
<?php if ($vendor_account) { ?>
<div class="itflow-glance-item" title="Vendor account number">
<i class="fas fa-fw fa-id-card"></i>
<span><span class="font-monospace"><?= $vendor_account ?></span><button type="button" class="itflow-copy clipboardjs" title="Copy account number" data-clipboard-text="<?= $vendor_account ?>"><i class="far fa-copy"></i></button></span>
</div>
<?php } ?>
<?php } ?>
</div>
</div>
<?php if ($can_edit || $software_notes) { ?>
<textarea class="form-control form-control-sm mt-3 itflow-license-notes" rows="2" placeholder="Notes - saves when you click away" aria-label="Notes"
data-software-id="<?= $software_id ?>" data-csrf="<?= escapeHtml($_SESSION['csrf_token'] ?? '') ?>" <?= $can_edit ? '' : 'readonly' ?>><?= $software_notes ?></textarea>
<?php } ?>
<?php if ($quick_tabs) { ?>
<div class="itflow-asset-related itflow-asset-quick-related mt-3" data-license-quick="<?= $software_id ?>">
<ul class="nav nav-pills itflow-asset-pills mb-2" role="tablist">
<?php foreach ($quick_tabs as $tab_key => $tab) { ?>
<li class="nav-item" role="presentation">
<button class="nav-link text-nowrap <?= $tab_key === $first_tab ? 'active' : '' ?>" data-bs-toggle="pill" data-bs-target="#<?= $quick_id ?>-<?= $tab_key ?>" data-license-quick-tab="<?= $tab_key ?>" type="button" role="tab">
<?= $tab['label'] ?> <span class="badge rounded-pill ms-1"><?= $tab['count'] ?></span>
</button>
</li>
<?php } ?>
</ul>
<div class="tab-content">
<?php if ($holders['assets']) { ?>
<div class="tab-pane fade <?= $first_tab === 'devices' ? 'show active' : '' ?>" id="<?= $quick_id ?>-devices" role="tabpanel">
<ul class="list-unstyled mb-0 itflow-links">
<?php foreach ($holders['assets'] as $holder) { ?>
<li class="itflow-link-row">
<span class="itflow-asset-icon itflow-asset-icon-sm itflow-asset-tone-<?= assetTypeTone($holder['asset_type']) ?>" aria-hidden="true"><i class="fas fa-fw fa-<?= getAssetIcon($holder['asset_type']) ?>"></i></span>
<div class="min-w-0 flex-grow-1">
<div class="d-flex"><a class="ajax-modal text-truncate" href="#" data-modal-size="lg" data-modal-url="modals/asset/asset.php?id=<?= intval($holder['asset_id']) ?>"><?= escapeHtml($holder['asset_name']) ?></a></div>
<div class="small text-body-secondary text-truncate"><?= escapeHtml(implode(' · ', array_filter([trim($holder['asset_make'] . ' ' . $holder['asset_model']), $holder['asset_contact_name']]))) ?></div>
</div>
<?php if ($holder['held_keys']) { ?><span class="small text-nowrap"><?= softwareKeyHtml($holder['held_keys'][0]) ?></span><?php } ?>
</li>
<?php } ?>
</ul>
</div>
<?php } ?>
<?php if ($holders['contacts']) { ?>
<div class="tab-pane fade <?= $first_tab === 'users' ? 'show active' : '' ?>" id="<?= $quick_id ?>-users" role="tabpanel">
<ul class="list-unstyled mb-0 itflow-links">
<?php foreach ($holders['contacts'] as $holder) { ?>
<li class="itflow-link-row">
<?= contactAvatar($holder['contact_client_id'], $holder['contact_id'], $holder['contact_name'], $holder['contact_photo'], 'sm') ?>
<div class="min-w-0 flex-grow-1">
<div class="d-flex"><a class="ajax-modal text-truncate" href="#" data-modal-size="lg" data-modal-url="modals/contact/contact.php?id=<?= intval($holder['contact_id']) ?>"><?= escapeHtml($holder['contact_name']) ?></a></div>
<div class="small text-body-secondary text-truncate"><?= escapeHtml(implode(' · ', array_filter([$holder['contact_title'], $holder['contact_email']]))) ?></div>
</div>
<?php if ($holder['held_keys']) { ?><span class="small text-nowrap"><?= softwareKeyHtml($holder['held_keys'][0]) ?></span><?php } ?>
</li>
<?php } ?>
</ul>
</div>
<?php } ?>
<?php if ($keys) { ?>
<div class="tab-pane fade <?= $first_tab === 'keys' ? 'show active' : '' ?>" id="<?= $quick_id ?>-keys" role="tabpanel">
<ul class="list-group list-group-flush">
<?php foreach ($keys as $key) {
$handed = array_merge(array_column($key['assets'], 'asset_name'), array_column($key['contacts'], 'contact_name')); ?>
<li class="list-group-item px-0 d-flex align-items-center justify-content-between gap-3">
<span class="min-w-0"><?= softwareKeyHtml($key['key']) ?></span>
<?php if ($handed) { ?>
<span class="small text-body-secondary text-truncate"><?= escapeHtml(implode(', ', $handed)) ?></span>
<?php } else { ?>
<span class="badge rounded-pill bg-success-subtle text-success-emphasis border border-success-subtle fw-normal">Free</span>
<?php } ?>
</li>
<?php } ?>
</ul>
</div>
<?php } ?>
<?php if ($credentials) { ?>
<div class="tab-pane fade <?= $first_tab === 'credentials' ? 'show active' : '' ?>" id="<?= $quick_id ?>-credentials" role="tabpanel">
<div class="table-responsive">
<table class="table table-sm table-hover align-middle mb-0">
<tbody>
<?php foreach ($credentials as $credential) {
$credential_id = intval($credential['id']);
$credential_username = escapeHtml(decryptCredentialEntry($credential['credential_username'])); ?>
<tr>
<td>
<a class="ajax-modal" href="#" data-modal-url="modals/credential/credential_edit.php?id=<?= $credential_id ?>"><?= escapeHtml($credential['name']) ?></a>
<?php if ($credential['credential_uri']) { ?><a class="ms-1 small" href="<?= escapeUrl($credential['credential_uri']) ?>" target="_blank" rel="noopener" title="<?= escapeHtml($credential['credential_uri']) ?>"><i class="fas fa-external-link-alt"></i></a><?php } ?>
</td>
<td class="text-nowrap"><?php if ($credential_username) { ?><?= $credential_username ?><button type="button" class="itflow-copy clipboardjs" title="Copy username" data-clipboard-text="<?= $credential_username ?>"><i class="far fa-copy"></i></button><?php } else { ?><span class="text-body-secondary">-</span><?php } ?></td>
<td class="text-nowrap">
<button class="btn btn-sm btn-light" aria-label="Show password" title="Show password" type="button" onclick="showPasswordViaCredentialID(this, <?= $credential_id ?>)"><i class="fas fa-ellipsis-h"></i><i class="fas fa-ellipsis-h"></i></button>
<button type="button" class="itflow-copy" title="Copy password" onclick="copyPasswordViaCredentialID(this, <?= $credential_id ?>)"><i class="far fa-copy"></i></button>
</td>
</tr>
<?php } ?>
</tbody>
</table>
</div>
</div>
<?php /* js/, not ../js/ - this file is injected by js/ajax_modal.js, so a
relative src resolves against the host page's URL (/agent/...) */ ?>
<script src="js/credential_show_password_via_id.js"></script>
<?php } ?>
<?php foreach (['documents' => ['document', $documents], 'files' => ['file', $files]] as $pane => [$to, $linked]) {
if (!$linked) {
continue;
} ?>
<div class="tab-pane fade <?= $first_tab === $pane ? 'show active' : '' ?>" id="<?= $quick_id ?>-<?= $pane ?>" role="tabpanel">
<ul class="list-unstyled mb-0 itflow-links">
<?php foreach ($linked as $linked_row) { ?>
<li class="itflow-link-row"><?= linkRowHtml($to, $linked_row, $client_id) ?></li>
<?php } ?>
</ul>
</div>
<?php } ?>
</div>
</div>
<?php } elseif ($can_edit) { ?>
<div class="text-body-secondary small mt-3">Nothing and nobody on this license yet - <a class="ajax-modal" href="#" data-modal-url="modals/software/software_seats.php?id=<?= $software_id ?>">add devices and users</a>.</div>
<?php } ?>
</div>
<div class="modal-footer">
<a href="license.php?client_id=<?= $client_id ?>&software_id=<?= $software_id ?>" class="btn btn-primary text-bold"><i class="fas fa-arrow-right me-2"></i>Open license</a>
<?php if ($can_edit) { ?>
<button type="button" class="btn btn-outline-secondary ajax-modal" data-modal-url="modals/software/software_seats.php?id=<?= $software_id ?>"><i class="fas fa-users me-2"></i>Seats</button>
<button type="button" class="btn btn-outline-secondary ajax-modal" data-modal-size="lg" data-modal-url="modals/software/software_edit.php?id=<?= $software_id ?>"><i class="fas fa-edit me-2"></i>Edit</button>
<?php } ?>
<button type="button" class="btn btn-light" data-bs-dismiss="modal"><i class="fa fa-times me-2"></i>Close</button>
</div>
<script>
(function () {
// Notes save when you click away - scoped to this modal's own textarea
document.querySelectorAll('.modal .itflow-license-notes:not([data-wired])').forEach(function (notes) {
notes.dataset.wired = '1';
var saved = notes.value;
notes.addEventListener('blur', function () {
if (notes.readOnly || notes.value === saved) {
return;
}
saved = notes.value;
itflowPost('ajax.php', {
software_set_notes: 'TRUE',
csrf_token: notes.dataset.csrf,
software_id: notes.dataset.softwareId,
notes: notes.value
});
});
});
// Open on the tab used last time, if this license has one
var key = 'itflow.licenseQuickTab';
document.querySelectorAll('[data-license-quick]:not([data-wired])').forEach(function (box) {
box.dataset.wired = '1';
var remembered = null;
try {
remembered = localStorage.getItem(key);
} catch (e) {}
var tab = remembered && box.querySelector('[data-license-quick-tab="' + remembered + '"]');
if (tab && !tab.classList.contains('active')) {
bootstrap.Tab.getOrCreateInstance(tab).show();
}
box.addEventListener('shown.bs.tab', function (e) {
try {
localStorage.setItem(key, e.target.dataset.licenseQuickTab);
} catch (err) {}
});
});
})();
</script>
<?php
require_once '../../../includes/modal_footer.php';