fix(classifieds,admin): blokada duplikatów przez double/triple-click
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions

B2B ogłoszenia mogły zostać stworzone 3x (user 81 Bormax 14.04.2026
w ciągu 2 sekund) — brak dedup window server-side i disable submit
button. Rozszerzam zabezpieczenie także na announcements i board
meeting form.

- classifieds POST /nowe: odrzuć duplikat z ostatnich 60s (ten sam
  author+company+title) → redirect do istniejącego z flash info
- classifieds new.html: disable submitBtn + "Wysyłanie..." po
  walidacji; ponowne kliknięcie blokowane event.preventDefault
- announcements_form.html + board/meeting_form.html: jednolity
  handler disable wszystkich button[type="submit"] po pierwszym
  submit

Forum topic/reply już miały analogiczne zabezpieczenie (bez zmian).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-04-14 16:35:14 +02:00
parent 958b967df2
commit 6c248b4773
4 changed files with 64 additions and 2 deletions

View File

@ -116,6 +116,18 @@ def new():
).first(): ).first():
form_company_id = current_user.company_id form_company_id = current_user.company_id
# Deduplikacja double/triple-click: jeśli ten sam autor+firma+tytuł
# wpadły w ostatnich 60 sekundach — traktuj jako powtórkę przesyłu formularza.
recent_duplicate = db.query(Classified).filter(
Classified.author_id == current_user.id,
Classified.company_id == form_company_id,
Classified.title == title,
Classified.created_at >= datetime.now() - timedelta(seconds=60),
).order_by(Classified.created_at.desc()).first()
if recent_duplicate:
flash('To ogłoszenie właśnie dodano — wyświetlamy istniejące.', 'info')
return redirect(url_for('.classifieds_view', classified_id=recent_duplicate.id))
classified = Classified( classified = Classified(
author_id=current_user.id, author_id=current_user.id,
company_id=form_company_id, company_id=form_company_id,

View File

@ -169,7 +169,7 @@
{% endif %} {% endif %}
<div class="form-section"> <div class="form-section">
<form method="POST"> <form method="POST" id="announcementForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- Basic Info --> <!-- Basic Info -->
@ -301,6 +301,24 @@
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
// Anty double/triple-click — blokada wielokrotnego submitu
(function() {
const form = document.getElementById('announcementForm');
if (!form) return;
form.addEventListener('submit', function(e) {
const btns = form.querySelectorAll('button[type="submit"]');
if (Array.from(btns).some(function(b) { return b.disabled; })) {
e.preventDefault();
return;
}
btns.forEach(function(b) {
b.dataset.originalText = b.textContent.trim();
b.disabled = true;
b.textContent = 'Wysyłanie...';
});
});
})();
// Character counter for excerpt // Character counter for excerpt
const excerptTextarea = document.getElementById('excerpt'); const excerptTextarea = document.getElementById('excerpt');
const excerptCounter = document.getElementById('excerpt-counter'); const excerptCounter = document.getElementById('excerpt-counter');

View File

@ -723,6 +723,24 @@
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
// Anty double/triple-click — blokada wielokrotnego submitu
(function() {
const form = document.getElementById('meetingForm');
if (!form) return;
form.addEventListener('submit', function(e) {
const btns = form.querySelectorAll('button[type="submit"]');
if (Array.from(btns).some(function(b) { return b.disabled; })) {
e.preventDefault();
return;
}
btns.forEach(function(b) {
b.dataset.originalText = b.innerHTML;
b.disabled = true;
b.textContent = 'Wysyłanie...';
});
});
})();
// Tab switching // Tab switching
document.querySelectorAll('.form-tab').forEach(tab => { document.querySelectorAll('.form-tab').forEach(tab => {
tab.addEventListener('click', function() { tab.addEventListener('click', function() {

View File

@ -435,7 +435,13 @@ var quill = new Quill('#quill-editor', {
// their green check immediately on page load. // their green check immediately on page load.
refreshTitle(); refreshCat(); refreshRadios(); refreshDesc(); refreshTitle(); refreshCat(); refreshRadios(); refreshDesc();
var submitBtn = document.getElementById('submitBtn');
document.getElementById('classifiedForm').addEventListener('submit', function(e) { document.getElementById('classifiedForm').addEventListener('submit', function(e) {
// Anty double/triple-click — jeśli już wysyłamy, blokuj kolejny submit
if (submitBtn && submitBtn.disabled) {
e.preventDefault();
return;
}
var html = quill.root.innerHTML; var html = quill.root.innerHTML;
var empty = (html === '<p><br></p>' || quill.getText().trim() === ''); var empty = (html === '<p><br></p>' || quill.getText().trim() === '');
if (empty) { if (empty) {
@ -447,6 +453,10 @@ var quill = new Quill('#quill-editor', {
} }
qc && qc.classList.remove('field-error'); qc && qc.classList.remove('field-error');
document.getElementById('description').value = html; document.getElementById('description').value = html;
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.textContent = 'Wysyłanie...';
}
}); });
})(); })();
@ -565,7 +575,11 @@ var quill = new Quill('#quill-editor', {
filesMap.forEach(file => formData.append('attachments[]', file)); filesMap.forEach(file => formData.append('attachments[]', file));
fetch(form.action, { method: 'POST', body: formData }) fetch(form.action, { method: 'POST', body: formData })
.then(resp => { if (resp.redirected) { window.location.href = resp.url; } else { window.location.reload(); } }) .then(resp => { if (resp.redirected) { window.location.href = resp.url; } else { window.location.reload(); } })
.catch(() => alert('Błąd wysyłania')); .catch(() => {
alert('Błąd wysyłania');
var btn = document.getElementById('submitBtn');
if (btn) { btn.disabled = false; btn.textContent = 'Dodaj ogłoszenie'; }
});
}); });
function formatFileSize(bytes) { function formatFileSize(bytes) {