UI fixes: confirm irreversible actions, keep reply drafts guarded, re-enable CSP on portal recurring invoices

- Add confirm-link to 14 one-click delete/disable links (asset, contact,
  files, templates, logo, MFA, calendar event, disable user, cancel schedule)
- confirm_modal.js treats btn-danger / btn-outline-danger as destructive
- Replace per-editor window.onbeforeunload with one guard over all editors
- New js/autosubmit.js replaces inline onchange in the portal; CSP back on
  recurring_invoices.php
- Fix blank user name in disable_user audit log
This commit is contained in:
johnnyq
2026-10-08 16:47:12 -04:00
parent cd7ebc0a5f
commit 49106e2a80
17 changed files with 85 additions and 54 deletions

View File

@@ -143,7 +143,7 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
<i class="fas fa-fw fa-edit me-2"></i>Edit
</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item text-danger text-bold" href="post.php?delete_contract_template=<?= $id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<a class="dropdown-item text-danger text-bold confirm-link" href="post.php?delete_contract_template=<?= $id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-trash me-2"></i>Delete
</a>
</div>

View File

@@ -111,7 +111,7 @@
<i class="fas fa-fw fa-edit me-2"></i>Edit
</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item text-danger text-bold" href="post.php?delete_document_template=<?= $document_template_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<a class="dropdown-item text-danger text-bold confirm-link" href="post.php?delete_document_template=<?= $document_template_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-trash me-2"></i>Delete
</a>
</div>

View File

@@ -231,7 +231,7 @@ if (isset($_GET['disable_user'])) {
logTicketHistory($affected_ticket_id, "$session_name unassigned the ticket when $user_name was disabled");
}
logAudit("User", "Disable", "$session_name disabled user $name", 0, $user_id);
logAudit("User", "Disable", "$session_name disabled user $user_name", 0, $user_id);
flashAlert("User <strong>$user_name</strong> disabled", 'error');

View File

@@ -43,7 +43,7 @@ $company_initials = escapeHtml(initials($company_name));
<div class="col-md-3 text-center">
<?php if ($company_logo) { ?>
<img class="img-thumbnail" src="<?= "../uploads/settings/$company_logo" ?>">
<a href="post.php?remove_company_logo&csrf_token=<?= $_SESSION['csrf_token'] ?>" class="btn btn-outline-danger w-100">Remove Logo</a>
<a href="post.php?remove_company_logo&csrf_token=<?= $_SESSION['csrf_token'] ?>" class="btn btn-outline-danger w-100 confirm-link">Remove Logo</a>
<hr>
<?php } ?>
<div class="mb-3">

View File

@@ -206,7 +206,7 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
<i class="fas fa-fw fa-user-check me-2"></i>Activate
</a>
<?php }elseif ($user_status == 1) { ?>
<a class="dropdown-item text-danger" href="post.php?disable_user=<?= $user_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<a class="dropdown-item text-danger confirm-link" data-confirm-text="Their open tickets and recurring tickets will be unassigned. Activating them again does not reassign anything." data-confirm-button="Disable" href="post.php?disable_user=<?= $user_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-user-slash me-2"></i>Disable
</a>
<?php } ?>

View File

@@ -638,7 +638,7 @@ if (isset($_GET['asset_id'])) {
</a>
<?php if ($session_user_role == 3 && $interface_primary == 0): ?>
<div class="dropdown-divider"></div>
<a class="dropdown-item text-danger text-bold" href="post.php?delete_asset_interface=<?= $interface_id; ?>&csrf_token=<?= $_SESSION['csrf_token']; ?>">
<a class="dropdown-item text-danger text-bold confirm-link" href="post.php?delete_asset_interface=<?= $interface_id; ?>&csrf_token=<?= $_SESSION['csrf_token']; ?>">
<i class="fas fa-fw fa-trash me-2"></i>Delete
</a>
<?php endif; ?>
@@ -760,7 +760,7 @@ if (isset($_GET['asset_id'])) {
</a>
<?php if ($session_user_role == 3) { ?>
<div class="dropdown-divider"></div>
<a class="dropdown-item text-danger text-bold" href="post.php?delete_credential=<?= $credential_id; ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<a class="dropdown-item text-danger text-bold confirm-link" href="post.php?delete_credential=<?= $credential_id; ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-trash me-2"></i>Delete
</a>
<?php } ?>
@@ -1295,7 +1295,7 @@ if (isset($_GET['asset_id'])) {
</a>
<?php if (lookupUserPermission("module_support") >= 3) { ?>
<div class="dropdown-divider"></div>
<a class="dropdown-item text-danger text-bold" href="post.php?delete_asset_note=<?= $asset_note_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<a class="dropdown-item text-danger text-bold confirm-link" href="post.php?delete_asset_note=<?= $asset_note_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-trash me-2"></i>Delete
</a>
<?php } ?>

View File

@@ -512,7 +512,7 @@ if (isset($_GET['contact_id'])) {
<a class="dropdown-item text-danger" href="post.php?archive_asset=<?= $asset_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-archive me-2"></i>Archive
</a>
<a class="dropdown-item text-danger text-bold" href="post.php?delete_asset=<?= $asset_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<a class="dropdown-item text-danger text-bold confirm-link" href="post.php?delete_asset=<?= $asset_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-trash me-2"></i>Delete
</a>
<?php } ?>
@@ -649,7 +649,7 @@ if (isset($_GET['contact_id'])) {
</a>
<?php if ($session_user_role == 3) { ?>
<div class="dropdown-divider"></div>
<a class="dropdown-item text-danger text-bold" href="post.php?delete_credential=<?= $credential_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<a class="dropdown-item text-danger text-bold confirm-link" href="post.php?delete_credential=<?= $credential_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-trash me-2"></i>Delete
</a>
<?php } ?>
@@ -1150,7 +1150,7 @@ if (isset($_GET['contact_id'])) {
</a>
<?php if ($session_user_role == 3) { ?>
<div class="dropdown-divider"></div>
<a class="dropdown-item text-danger text-bold" href="post.php?delete_contact_note=<?= $contact_note_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<a class="dropdown-item text-danger text-bold confirm-link" href="post.php?delete_contact_note=<?= $contact_note_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-trash me-2"></i>Delete
</a>
<?php } ?>

View File

@@ -1239,7 +1239,7 @@ $num_root_items = intval($row_root_files['num']) + intval($row_root_docs['num'])
</a>
<?php if ($session_user_role == 3) { ?>
<div class="dropdown-divider"></div>
<a class="dropdown-item text-danger text-bold" href="post.php?delete_document=<?= $document_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<a class="dropdown-item text-danger text-bold confirm-link" href="post.php?delete_document=<?= $document_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-trash me-2"></i>Delete
</a>
<?php } ?>

View File

@@ -222,7 +222,7 @@ ob_start();
</div>
<div class="modal-footer">
<a class="btn btn-default text-danger me-auto" href="post.php?delete_event=<?= $event_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>"><i class="fa fa-calendar-times me-2"></i><?= empty($event_repeat) ? 'Delete' : 'Delete series' ?></a>
<a class="btn btn-default text-danger me-auto confirm-link" href="post.php?delete_event=<?= $event_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>"><i class="fa fa-calendar-times me-2"></i><?= empty($event_repeat) ? 'Delete' : 'Delete series' ?></a>
<button type="submit" name="edit_event" class="btn btn-primary text-bold"><i class="fa fa-check me-2"></i>Save</button>
<button type="button" class="btn btn-light" data-bs-dismiss="modal"><i class="fa fa-times me-2"></i>Cancel</button>
</div>

View File

@@ -62,7 +62,7 @@ ob_start();
<div class="modal-footer">
<?php if ($ticket_scheduled_for) { ?>
<a href="post.php?cancel_ticket_schedule=<?= $ticket_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>" class="btn btn-danger text-bold">
<a href="post.php?cancel_ticket_schedule=<?= $ticket_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>" class="btn btn-danger text-bold confirm-link" data-confirm-text="A cancellation email goes to the assigned agent, and to the contact and watchers if client notifications are on." data-confirm-button="Cancel scheduled time">
<i class="fa fa-trash me-2"></i>Cancel Scheduled Time
</a>
<?php } ?>

View File

@@ -37,7 +37,7 @@ $remember_token_count = mysqli_num_rows($sql_remember_tokens);
<?php require_once "modals/user_mfa_modal.php"; ?>
<?php } else { ?>
<a href="post.php?disable_mfa&csrf_token=<?= $_SESSION['csrf_token'] ?>" class="btn btn-danger"><i class="fas fa-unlock me-2"></i>Disable MFA</a>
<a href="post.php?disable_mfa&csrf_token=<?= $_SESSION['csrf_token'] ?>" class="btn btn-danger confirm-link"><i class="fas fa-unlock me-2"></i>Disable MFA</a>
<?php } ?>
</div>

View File

@@ -80,6 +80,7 @@
<script src="/libs/sweetalert2/js/sweetalert2.min.js"></script>
<script src="/js/confirm_modal.js"></script>
<script src="/js/autosubmit.js"></script>
<?php if (!empty($portal_load_phone_inputs)) { ?>
<script src="/libs/intl-tel-input/js/intlTelInputWithUtils.min.js"></script>

View File

@@ -4,7 +4,7 @@
* Invoices for PTC
*/
// header("Content-Security-Policy: default-src 'self'"); -- JQ 2025-07-09 - BREAKS onchange(submit)
header("Content-Security-Policy: default-src 'self'");
require_once "includes/inc_all.php";
@@ -86,7 +86,7 @@ $payment_provider_threshold = floatval($row['payment_provider_threshold']);
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<input type="hidden" name="set_recurring_payment" value="1">
<input type="hidden" name="recurring_invoice_id" value="<?= $recurring_invoice_id ?>">
<select class="form-select select2" name="saved_payment_id" onchange="this.form.submit()">
<select class="form-select" name="saved_payment_id" data-autosubmit>
<option value="0">Disabled</option>
<?php
while ($row = mysqli_fetch_assoc($sql)) {

View File

@@ -33,7 +33,7 @@ $all_tickets = mysqli_query($mysqli, "SELECT ticket_id, ticket_prefix, ticket_nu
<div class="mb-3">
<form method="get">
<label>Ticket Status</label>
<select class="form-select" name="status" onchange="this.form.submit()">
<select class="form-select" name="status" data-autosubmit>
<option value="%" <?php if ($status == "%") {echo "selected";}?> >Any</option>
<option value="Open" <?php if ($status == "Open") {echo "selected";}?> >Open</option>
<option value="Closed" <?php if ($status == "Closed") {echo "selected";}?> >Closed</option>

View File

@@ -205,14 +205,6 @@ function itflowInit() {
plugins: 'link image lists table code codesample fullscreen autoresize',
setup: function (editor) {
editor.on('init', function() {
window.onbeforeunload = function() {
// If editor is dirty AND not inside a visible modal → warn
const inVisibleModal = editor.getContainer()?.closest('.modal.show');
if (!inVisibleModal && editor.isDirty()) {
return "You have unsaved changes. Are you sure you want to leave?";
}
};
// When the modal closes, mark editor clean
const modal = editor.getContainer()?.closest('.modal');
if (modal) {
@@ -260,14 +252,6 @@ function itflowInit() {
license_key: 'gpl',
setup: function(editor) {
editor.on('init', function() {
window.onbeforeunload = function() {
// If editor is dirty AND not inside a visible modal → warn
const inVisibleModal = editor.getContainer()?.closest('.modal.show');
if (!inVisibleModal && editor.isDirty()) {
return "You have unsaved changes. Are you sure you want to leave?";
}
};
// When the modal closes, mark editor clean
const modal = editor.getContainer()?.closest('.modal');
if (modal) {
@@ -389,14 +373,6 @@ function itflowInit() {
license_key: 'gpl',
setup: function(editor) {
editor.on('init', function() {
window.onbeforeunload = function() {
// If editor is dirty AND not inside a visible modal → warn
const inVisibleModal = editor.getContainer()?.closest('.modal.show');
if (!inVisibleModal && editor.isDirty()) {
return "You have unsaved changes. Are you sure you want to leave?";
}
};
// When the modal closes, mark editor clean
const modal = editor.getContainer()?.closest('.modal');
if (modal) {
@@ -507,14 +483,6 @@ function itflowInit() {
setup: function(editor) {
editor.on('init', function() {
window.onbeforeunload = function() {
// If editor is dirty AND not inside a visible modal → warn
const inVisibleModal = editor.getContainer()?.closest('.modal.show');
if (!inVisibleModal && editor.isDirty()) {
return "You have unsaved changes. Are you sure you want to leave?";
}
};
// When the modal closes, mark editor clean
const modal = editor.getContainer()?.closest('.modal');
if (modal) {
@@ -752,6 +720,40 @@ itflowBindOnce('itflowModalFocus', 'shown.bs.modal', '.modal', function () {
this.focus();
});
/**
* Warn before leaving the page with unsaved editor content.
*
* Each TinyMCE config used to assign window.onbeforeunload for its own editor.
* That is a single slot, so whichever editor initialised last owned it. Opening
* any ajax modal with an editor (Edit Ticket, Edit Reply) handed the slot to the
* modal's editor, and once that modal closed nothing guarded the page's own
* editor - a half-written ticket reply was lost on the next link click with no
* prompt. One listener now checks every live editor.
*
* Editors inside an open modal are skipped, as before: the modal is where that
* work gets submitted or discarded. Editors from a closed ajax modal are removed
* by js/ajax_modal.js, so tinymce.get() no longer returns them.
*
* Flagged on window because modal_footer.php re-loads this file on every ajax
* modal open.
*/
if (!window.itflowEditorUnloadGuard) {
window.itflowEditorUnloadGuard = true;
window.addEventListener('beforeunload', function (e) {
if (typeof tinymce === 'undefined') {
return;
}
const unsaved = tinymce.get().some(function (editor) {
const inVisibleModal = editor.getContainer()?.closest('.modal.show');
return !inVisibleModal && editor.isDirty();
});
if (unsaved) {
e.preventDefault();
e.returnValue = '';
}
});
}
/**
* Let TinyMCE's popups take focus inside a modal.
*

27
js/autosubmit.js Normal file
View File

@@ -0,0 +1,27 @@
/*
* ITFlow - submit a form as soon as one of its controls changes.
*
* Markup contract:
* <select name="status" data-autosubmit> ... </select>
*
* Replaces inline onchange="this.form.submit()". The client portal sends
* Content-Security-Policy: default-src 'self', which blocks inline event
* handlers outright, so any page using that pattern had to run without the
* policy. A bound listener in a same-origin file is allowed.
*
* Delegated on document, so it needs no init call and also covers markup added
* after load. requestSubmit() rather than submit(): it runs the form's own
* validation and fires the submit event, the same as pressing a submit button.
*/
document.addEventListener('change', function (e) {
const control = e.target.closest('[data-autosubmit]');
if (!control || !control.form) {
return;
}
if (typeof control.form.requestSubmit === 'function') {
control.form.requestSubmit();
} else {
control.form.submit();
}
});

View File

@@ -15,9 +15,9 @@
* data-confirm-button confirm label (default: "Yes")
* data-confirm-icon warning|error|question|info|success
*
* A link whose classes mark it destructive (text-danger) defaults to a red
* confirm button and a warning icon, so the 92 delete links read as dangerous
* without touching any of them.
* A link whose classes mark it destructive (text-danger, btn-danger or
* btn-outline-danger) defaults to a red confirm button and a warning icon, so
* the delete links read as dangerous without touching any of them.
*/
document.addEventListener('click', function (e) {
const link = e.target.closest('a.confirm-link, button.confirm-link');
@@ -32,7 +32,8 @@ document.addEventListener('click', function (e) {
const submitter = link.tagName === 'BUTTON' ? link : null;
const form = submitter ? (submitter.form || submitter.closest('form')) : null;
const destructive = link.classList.contains('text-danger');
const destructive = ['text-danger', 'btn-danger', 'btn-outline-danger']
.some(function (cls) { return link.classList.contains(cls); });
Swal.fire({
// SweetAlert2 has no idea about Bootstrap's mode - it keys its own dark