Files
itflow/agent/modals/credential/credential_form.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

366 lines
20 KiB
PHP

<?php
/*
* Credential form fields - shared by credential_add.php and
* credential_edit.php, which set $credential_form_mode ('add' | 'edit'),
* $client_id (0 = pick the client here), $credential_id and the
* $credential_* values (escaped; the username, password and 2FA seed already
* decrypted for an edit, empty for a new one), $credential_tag_ids,
* $credential_contact_id and $credential_asset_id, before including this.
* Posts what credentialFormSet() (functions/credential.php) reads.
*
* Tabs: Login (name, star, username, password with a generator and how
* strong it is, the 2FA secret with the code it makes, where it opens),
* Belongs to (the asset and contact it is for), Details (what it is, tags)
* and Notes.
*
* Should not be accessed directly.
*/
isset($mysqli) || die("Direct file access is not allowed");
$client_options = $contact_options = $asset_options = [];
if ($client_id) {
$sql = mysqli_query($mysqli, "SELECT contact_id, contact_name, contact_title, contact_photo, contact_client_id, contact_archived_at FROM contacts
WHERE contact_client_id = $client_id AND (contact_archived_at IS NULL OR contact_id = " . intval($credential_contact_id) . ")
ORDER BY contact_name ASC");
while ($row = mysqli_fetch_assoc($sql)) {
$contact_options[] = $row;
}
$sql = mysqli_query($mysqli, "SELECT asset_id, asset_name, asset_type, asset_make, asset_model, asset_archived_at, location_name FROM assets
LEFT JOIN locations ON location_id = asset_location_id
WHERE asset_client_id = $client_id AND (asset_archived_at IS NULL OR asset_id = " . intval($credential_asset_id) . ")
ORDER BY asset_name ASC");
while ($row = mysqli_fetch_assoc($sql)) {
$asset_options[] = $row;
}
} else {
$sql = mysqli_query($mysqli, "SELECT client_id, client_name, client_abbreviation FROM clients WHERE client_archived_at IS NULL " . clientScopeSql('clients.client_id') . " ORDER BY client_name ASC");
while ($row = mysqli_fetch_assoc($sql)) {
$client_options[] = $row;
}
}
$form_id = 'credentialForm' . ($credential_form_mode === 'edit' ? $credential_id : 'New');
?>
<ul class="nav nav-pills nav-fill flex-nowrap overflow-auto mb-3 itflow-asset-form-tabs" role="tablist">
<li class="nav-item">
<a class="nav-link active" data-bs-toggle="pill" href="#<?= $form_id ?>Login"><i class="fas fa-fw fa-key me-2 d-none d-lg-inline"></i>Login</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="pill" href="#<?= $form_id ?>Belongs"><i class="fas fa-fw fa-link me-2 d-none d-lg-inline"></i>Belongs to</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="pill" href="#<?= $form_id ?>Details"><i class="fas fa-fw fa-tags me-2 d-none d-lg-inline"></i>Details</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="pill" href="#<?= $form_id ?>Notes"><i class="fas fa-fw fa-sticky-note me-2 d-none d-lg-inline"></i>Notes</a>
</li>
</ul>
<div class="tab-content" id="<?= $form_id ?>">
<!-- The login itself -->
<div class="tab-pane fade show active" id="<?= $form_id ?>Login">
<div class="row g-3">
<?php if (!$client_id) { ?>
<div class="col-12">
<label class="form-label">Client <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-user"></i></span>
<select class="form-select select2" name="client_id" required>
<option value="">- Select Client -</option>
<?php foreach ($client_options as $client_option) { ?>
<option value="<?= intval($client_option['client_id']) ?>"<?= selectOptionClient($client_option['client_id'], $client_option['client_name'], $client_option['client_abbreviation'] ?? '') ?>><?= escapeHtml($client_option['client_name']) ?></option>
<?php } ?>
</select>
</div>
<div class="form-text">Pick the asset and contact it belongs to once it's saved.</div>
</div>
<?php } ?>
<div class="col-md-8">
<label class="form-label">Name <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-fw fa-key"></i></span>
<input type="text" class="form-control" name="name" placeholder="Firewall admin, Microsoft 365 break glass, Guest Wi-Fi" maxlength="200" value="<?= $credential_name ?>" required <?= $credential_form_mode === 'edit' ? '' : 'autofocus' ?>>
</div>
</div>
<div class="col-md-4">
<label class="form-label d-none d-md-block">&nbsp;</label>
<div class="form-check form-switch mt-md-2">
<input class="form-check-input" type="checkbox" role="switch" name="favorite" value="1" id="<?= $form_id ?>Favorite" <?= $credential_favorite ? 'checked' : '' ?>>
<label class="form-check-label" for="<?= $form_id ?>Favorite"><i class="fas fa-star text-warning me-1"></i>Starred</label>
</div>
</div>
<div class="col-12">
<label class="form-label">Username / ID</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-user"></i></span>
<input type="text" class="form-control" name="username" placeholder="Username, email or ID" maxlength="350" value="<?= $credential_username ?>" autocomplete="off">
</div>
</div>
<div class="col-12">
<label class="form-label">Password / key</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-lock"></i></span>
<input type="password" class="form-control font-monospace" data-toggle="password" name="password" placeholder="Password, key or PSK" maxlength="350" value="<?= $credential_password ?>" autocomplete="new-password" data-credential-password>
<span class="input-group-text"><i class="fa fa-fw fa-eye"></i></span>
<button class="btn btn-outline-secondary clipboardjs" title="Copy password" type="button" data-clipboard-target="input[name=password]"><i class="far fa-fw fa-copy"></i></button>
<button class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" title="Make one up"><i class="fas fa-fw fa-magic"></i></button>
<div class="dropdown-menu dropdown-menu-end">
<a class="dropdown-item" href="#" data-credential-generate="passphrase"><i class="fas fa-fw fa-font text-secondary me-2"></i>Passphrase <small class="text-body-secondary">easy to read out</small></a>
<a class="dropdown-item" href="#" data-credential-generate="20"><i class="fas fa-fw fa-random text-secondary me-2"></i>Random, 20 characters</a>
<a class="dropdown-item" href="#" data-credential-generate="32"><i class="fas fa-fw fa-random text-secondary me-2"></i>Random, 32 characters</a>
</div>
</div>
<div class="d-flex align-items-center gap-2 mt-1 small" data-credential-strength hidden>
<div class="progress flex-grow-1" style="height: .35rem;"><div class="progress-bar" role="progressbar"></div></div>
<span class="text-body-secondary text-nowrap" data-credential-strength-label></span>
</div>
</div>
<div class="col-12">
<label class="form-label">2FA secret</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-clock"></i></span>
<input type="password" class="form-control font-monospace" data-toggle="password" name="otp_secret" placeholder="The key behind the QR code, or the otpauth:// link" maxlength="200" value="<?= $credential_otp_secret ?>" autocomplete="off" data-credential-otp-input>
<span class="input-group-text"><i class="fa fa-fw fa-eye"></i></span>
</div>
<div class="form-text" data-credential-otp-check>Every agent who can see this credential gets the current code, so nobody needs the phone.</div>
</div>
<div class="col-md-6">
<label class="form-label">Opens at</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-link"></i></span>
<input type="text" class="form-control" name="uri" placeholder="https://192.168.1.1, rdp://server" maxlength="500" value="<?= $credential_uri ?>">
<button class="btn btn-outline-secondary clipboardjs" title="Copy URI" type="button" data-clipboard-target="input[name=uri]"><i class="far fa-fw fa-copy"></i></button>
</div>
</div>
<div class="col-md-6">
<label class="form-label">Also at</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-link"></i></span>
<input type="text" class="form-control" name="uri_2" placeholder="https://server.company.com:5001" maxlength="500" value="<?= $credential_uri_2 ?>">
<button class="btn btn-outline-secondary clipboardjs" title="Copy URI 2" type="button" data-clipboard-target="input[name=uri_2]"><i class="far fa-fw fa-copy"></i></button>
</div>
</div>
</div>
</div>
<!-- What it is for -->
<div class="tab-pane fade" id="<?= $form_id ?>Belongs">
<?php if ($client_id) { ?>
<div class="row g-3">
<div class="col-12">
<label class="form-label">Asset</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-desktop"></i></span>
<select class="form-select select2" name="asset">
<option value="0">- No asset -</option>
<?php foreach ($asset_options as $asset_option) {
$asset_option_id = intval($asset_option['asset_id']); ?>
<option value="<?= $asset_option_id ?>" <?= intval($credential_asset_id) === $asset_option_id ? 'selected' : '' ?><?= selectOptionAsset($asset_option['asset_type'], implode(' · ', array_filter([trim($asset_option['asset_make'] . ' ' . $asset_option['asset_model']), $asset_option['location_name']]))) ?>><?= escapeHtml($asset_option['asset_name']) ?><?= $asset_option['asset_archived_at'] ? ' (archived)' : '' ?></option>
<?php } ?>
</select>
</div>
<div class="form-text">The device this login is for - it shows on the asset's Credentials tab.</div>
</div>
<div class="col-12">
<label class="form-label">Contact</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-user"></i></span>
<select class="form-select select2" name="contact">
<option value="0">- Nobody -</option>
<?php foreach ($contact_options as $contact_option) {
$contact_option_id = intval($contact_option['contact_id']); ?>
<option value="<?= $contact_option_id ?>" <?= intval($credential_contact_id) === $contact_option_id ? 'selected' : '' ?><?= selectOptionContact($contact_option['contact_client_id'], $contact_option['contact_name'], $contact_option['contact_photo'], $contact_option['contact_title'], $contact_option_id) ?>><?= escapeHtml($contact_option['contact_name']) ?><?= $contact_option['contact_archived_at'] ? ' (archived)' : '' ?></option>
<?php } ?>
</select>
</div>
<div class="form-text">Whose login it is - it shows on their contact page.</div>
</div>
<div class="col-12">
<div class="form-text mt-0"><i class="fas fa-info-circle me-1"></i>Vendors and licenses link from the credential's quick look, or from the vendor or license itself.</div>
</div>
</div>
<?php } else { ?>
<div class="itflow-asset-empty">Pick the client first - the asset and contact are theirs.</div>
<?php } ?>
</div>
<!-- What it is -->
<div class="tab-pane fade" id="<?= $form_id ?>Details">
<div class="row g-3">
<div class="col-12">
<label class="form-label">Description</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-align-left"></i></span>
<input type="text" class="form-control" name="description" placeholder="What it opens, and who uses it" maxlength="500" value="<?= $credential_description ?>">
</div>
</div>
<div class="col-12">
<label class="form-label">Tags</label>
<input type="hidden" name="tags_form" value="1">
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-tags"></i></span>
<select class="form-select select2" name="tags[]" data-placeholder="Domain Admin, Break Glass, Vendor Portal" multiple<?= !empty($session_is_admin) ? ' data-tags="true"' : '' ?>>
<?= tagOptionsHtml('credential', $credential_tag_ids) ?>
</select>
</div>
</div>
</div>
<?php if ($credential_form_mode === 'edit') { ?>
<p class="text-body-secondary small text-end mt-3 mb-0">Credential ID <span class="font-monospace"><?= $credential_id ?></span></p>
<?php } ?>
</div>
<!-- Everything else -->
<div class="tab-pane fade" id="<?= $form_id ?>Notes">
<label class="form-label">Notes</label>
<textarea class="form-control" rows="10" name="note" placeholder="What it is for, who may use it, when it was last rotated and by whom"><?= $credential_note ?></textarea>
<div class="form-text">Notes are not encrypted - keep recovery codes and second passwords in their own credential.</div>
</div>
</div>
<script>
itflowReady(function () {
var form = document.getElementById(<?= json_encode($form_id) ?>).closest('form');
if (form.dataset.credentialFormWired) {
return;
}
form.dataset.credentialFormWired = '1';
// A required field on a tab that isn't showing: show that tab and point at it
var reported = false;
form.addEventListener('invalid', function (e) {
var pane = e.target.closest('.tab-pane');
var tab = pane && form.querySelector('[data-bs-toggle="pill"][href="#' + pane.id + '"]');
if (!tab || pane.classList.contains('active') || reported) {
return;
}
reported = true;
tab.addEventListener('shown.bs.tab', function () {
reported = false;
e.target.reportValidity();
}, { once: true });
bootstrap.Tab.getOrCreateInstance(tab).show();
}, true);
var password = form.querySelector('[data-credential-password]');
var meter = form.querySelector('[data-credential-strength]');
var bar = meter.querySelector('.progress-bar');
var label = meter.querySelector('[data-credential-strength-label]');
// How strong it is - length and the kinds of character in it, roughly in bits
function strength() {
var value = password.value;
meter.hidden = value === '';
if (!value) {
return;
}
var pool = 0;
if (/[a-z]/.test(value)) pool += 26;
if (/[A-Z]/.test(value)) pool += 26;
if (/[0-9]/.test(value)) pool += 10;
if (/[^a-zA-Z0-9]/.test(value)) pool += 32;
var bits = Math.round(value.length * Math.log2(Math.max(pool, 2)));
var levels = [[40, 'Weak', 'danger', 25], [60, 'Fair', 'warning', 50], [80, 'Good', 'info', 75], [Infinity, 'Strong', 'success', 100]];
var level = levels.find(function (l) { return bits < l[0]; });
bar.className = 'progress-bar bg-' + level[2];
bar.style.width = level[3] + '%';
label.textContent = level[1] + ' · ' + value.length + ' characters';
}
password.addEventListener('input', strength);
strength();
// Make one up - a passphrase from the server, or random characters made here
function randomPassword(length) {
var chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%^&*-_=+?';
var bytes = new Uint32Array(length);
window.crypto.getRandomValues(bytes);
return Array.prototype.map.call(bytes, function (b) { return chars[b % chars.length]; }).join('');
}
form.querySelectorAll('[data-credential-generate]').forEach(function (item) {
item.addEventListener('click', function (e) {
e.preventDefault();
var kind = item.dataset.credentialGenerate;
function set(value) {
password.value = value;
password.type = 'text';
password.dispatchEvent(new Event('input'));
}
if (kind === 'passphrase') {
itflowGet('ajax.php', { get_readable_pass: 'true' }, function (data) {
set(JSON.parse(data));
});
} else {
set(randomPassword(parseInt(kind, 10)));
}
});
});
// The 2FA secret: say whether it is one, and show the code it makes now - worked
// out here (where the browser allows it), so a mistyped secret is caught before saving
var otp = form.querySelector('[data-credential-otp-input]');
var check = form.querySelector('[data-credential-otp-check]');
var checkDefault = check.textContent;
function seed(value) {
value = value.trim();
if (/^otpauth:\/\//i.test(value)) {
var match = value.match(/[?&]secret=([^&]+)/i);
value = match ? decodeURIComponent(match[1]) : '';
}
value = value.replace(/[\s-]+/g, '').toUpperCase().replace(/=+$/, '');
return /^[A-Z2-7]{16,}$/.test(value) ? value : null;
}
function base32(value) {
var alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567', bits = '', bytes = [];
value.split('').forEach(function (c) { bits += alphabet.indexOf(c).toString(2).padStart(5, '0'); });
for (var i = 0; i + 8 <= bits.length; i += 8) {
bytes.push(parseInt(bits.substr(i, 8), 2));
}
return new Uint8Array(bytes);
}
function totp(secret) {
var counter = Math.floor(Date.now() / 30000);
var message = new Uint8Array(8);
for (var i = 7; i >= 0; i--) {
message[i] = counter & 0xff;
counter = Math.floor(counter / 256);
}
return crypto.subtle.importKey('raw', base32(secret), { name: 'HMAC', hash: 'SHA-1' }, false, ['sign'])
.then(function (key) { return crypto.subtle.sign('HMAC', key, message); })
.then(function (signature) {
var h = new Uint8Array(signature), o = h[h.length - 1] & 0xf;
var code = ((h[o] & 0x7f) << 24 | h[o + 1] << 16 | h[o + 2] << 8 | h[o + 3]) % 1000000;
return String(code).padStart(6, '0');
});
}
function checkOtp() {
var value = otp.value.trim();
check.classList.remove('text-danger', 'text-success');
if (!value) {
check.textContent = checkDefault;
return;
}
var secret = seed(value);
if (!secret) {
check.classList.add('text-danger');
check.textContent = 'That is not a 2FA secret - it is the long key shown under "can\'t scan the code", or the otpauth:// link.';
return;
}
if (!window.crypto || !crypto.subtle) {
check.textContent = 'Looks like a 2FA secret.';
return;
}
totp(secret).then(function (code) {
check.classList.add('text-success');
check.textContent = 'The code now is ' + code.slice(0, 3) + ' ' + code.slice(3) + ' - check it matches the app.';
});
}
otp.addEventListener('input', checkOtp);
checkOtp();
});
</script>