fix(forms): never submit an invalid form; unlock password-change dialog
Playwright Tests / test (push) Canceled after 0s

serializeObject() returns null for an invalid form, but most callers sent
the result anyway. Add $.fn.rejectInvalid (stop the spinner, let the
browser report the offending field) and use it at every call site.

Bugs fixed:
- Password change: on a mismatch the spinner kept running, and the custom
  validity on "npw" was never cleared, so the browser blocked every later
  submit even after the passwords matched. It is now cleared as soon as
  either password field is edited.
- sendForm: options.complete referenced an undefined jqXHR (ReferenceError)
  and an invalid form was posted with partial data.
- login.send: an invalid form threw a TypeError on qs.loginaccount.
- postXT({ form }): an invalid form was posted with empty data.
- Form dialogs without required fields had no "Bitte überprüfen Sie Ihre
  Eingaben" note, so a rejected submit showed nothing.

Hardening: null guards for the user, user-group, re-login, SMS password,
admin specialties and report-parameter submits.

Add e2e_tests/form-validation.cjs covering each path against the real
scripts; it fails on the previous code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Stefan
2026-10-06 18:11:05 +02:00
co-authored by Claude Opus 5.5
parent 4145ab699c
commit 4443bba63c
20 changed files with 231 additions and 31 deletions
+11 -2
View File
@@ -130,9 +130,18 @@ $fis.resetPass = function (id, fds) {
title: $t.rsp || '',
submit: function (e) {
var c = $(this).ldng(1);
var qs = $.extend({ loginaccount: $ocms.auth.account || '' }, c.serializeObject(true, { typedvalues: true }));
var values = c.serializeObject(true, { typedvalues: true });
if (values === null) { c.rejectInvalid(); return; }
var qs = $.extend({ loginaccount: $ocms.auth.account || '' }, values);
if ((qs.npw || '') !== (qs.npwc || '')) {
c.find('input[name="npw"]:first')[0].setCustomValidity($t.pnm);
/* The browser blocks every later submit while npw carries a custom error, so it
must be cleared as soon as either password field is edited again. */
var npw = c.find('input[name="npw"]:first');
npw[0].setCustomValidity($t.pnm);
c.find('input[name="npw"], input[name="npwc"]').off('input.pnm').on('input.pnm', function () {
npw[0].setCustomValidity('');
});
c.rejectInvalid();
} else {
$ocms.postXT({
url: $ocms.url('account/changepassword'), data: qs, success: function (response) {
+3 -2
View File
@@ -48,9 +48,10 @@ let $rep = {
};
let scl = function (ev) {
//console.debug(ev);
let btn = $(this), rx = ev.data;
let btn = $(this), rx = ev.data, params = btn.serializeObject();
if (params === null) { return; }
$ocms.postXT({
url: $ocms.url('rep/gct/' + rx.name), data: btn.serializeObject(), success: (response) => {
url: $ocms.url('rep/gct/' + rx.name), data: params, success: (response) => {
btn.addClass('selected').siblings().removeClass('selected');
$('#listframe').rC('fix').aC('hd');
repfrm.html(response);
@@ -55,6 +55,7 @@ let $spc = {
let f = c.form[0];
if (typeof f.reportValidity === 'function' && !f.reportValidity()) { return; }
let values = c.form.serializeObject(true, { typedvalues: false });
if (values === null) { c.form.rejectInvalid(); return; }
if (typeof options.confirm === 'function' && !confirm(options.confirm(values))) { return; }
c.result.hide();
btn.prop('disabled', true).text($spt.working);
+3
View File
@@ -106,6 +106,7 @@ let $usr = {
submit: function (e) {
let c = $(this).ldng(1);
let qs = c.serializeObject(true, { typedvalues: true });
if (qs === null) { c.rejectInvalid(); return; }
$ocms.postXT({
url: $ocms.url('usr/create'), data: qs, success: function (response) {
alert(response.emailed === true ? $uct.create_sent : $uct.create_noemail);
@@ -141,6 +142,7 @@ let $usr = {
submit: function (e) {
let c = $(this).ldng(1);
let qs = c.serializeObject(true, { typedvalues: true });
if (qs === null) { c.rejectInvalid(); return; }
$ocms.postXT({
url: $ocms.url('usr/pwdreset'), data: qs, success: function (response) {
alert(response.emailed === true ? $uct.pwdreset_sent : $uct.pwdreset_noemail);
@@ -162,6 +164,7 @@ let $usr = {
submit: function (e) {
let c = $(this).ldng(1);
let qs = c.serializeObject(true, { typedvalues: true });
if (qs === null) { c.rejectInvalid(); return; }
$ocms.postXT({
url: $ocms.url('usr/email'), data: qs, success: function () {
c.trigger('modal_close');
+2
View File
@@ -58,6 +58,7 @@ let $ugp = {
submit: function (e) {
let c = $(this).ldng(1);
let qs = c.serializeObject(true, { typedvalues: true });
if (qs === null) { c.rejectInvalid(); return; }
$ocms.postXT({
url: $ocms.url('usrgrp/create'), data: qs, success: function () {
c.trigger('modal_close');
@@ -79,6 +80,7 @@ let $ugp = {
submit: function (e) {
let c = $(this).ldng(1);
let qs = c.serializeObject(true, { typedvalues: true });
if (qs === null) { c.rejectInvalid(); return; }
$ocms.postXT({
url: $ocms.url('usrgrp/rename'), data: qs, success: function () {
c.trigger('modal_close');
+14 -4
View File
@@ -488,6 +488,7 @@ $ocms.postXT = function (options) {
}
if (options.form instanceof jQuery) {
options.data = options.form.serializeObject();
if (options.data === null) { options.form.rejectInvalid(); return; }
options.contentType = 'form-data';
} else if (options.lzw instanceof jQuery) {
options.data.lzw = $.ccLZW(options.lzw.serializeAnything(true)).join(',');
@@ -720,6 +721,13 @@ $ocms.vbl_send = function (ev) {
c.toggleClass('invalid', !valid);
return valid ? result : null;
};
/* For a form whose serializeObject() returned null: stop its loading indicator and let the
browser point at the offending field, instead of submitting partial data. */
$.fn.rejectInvalid = function () {
var c = $(this).ldng(0), f = c.closest('form')[0] || c.find('form')[0];
if (f && typeof f.reportValidity === 'function') { f.reportValidity(); }
return c;
};
$.fn.sendForm = function (url, success, options) {
var form = $(this);
options = options || {};
@@ -734,11 +742,13 @@ $ocms.vbl_send = function (ev) {
if (typeof options.error === 'function') { options.error.call(this, jqXHR); } else {
$ocms.failure.call(this, jqXHR);
}
}, complete: function () {
}, complete: function (jqXHR) {
form.ldng(0);
if (typeof options.complete === 'function') { options.complete.call(this, jqXHR); }
}
};
var formdata = form.serializeObject();
if (formdata === null) { form.rejectInvalid(); return; }
var fle = form.find('input[type="file"]');
postoptions.data = new FormData();
if (fle.length > 0) {
@@ -747,8 +757,7 @@ $ocms.vbl_send = function (ev) {
postoptions.data.append('file_lastmodified', $ocms.isodt(value.lastModifiedDate));
});
}
var formdata = form.serializeObject();
$.each(formdata || {}, function (di, dx) {
$.each(formdata, function (di, dx) {
postoptions.data.append(di, dx);
});
form.ldng();
@@ -781,7 +790,7 @@ $ocms.login = {
return false;
} else {
var qs = f.serializeObject();
qs.loginaccount = ne(qs.loginaccount, $ocms.auth.account || $ocms.auth.requestedaccount || '');
if (qs === null) { f.rejectInvalid(); return false; }
qs.loginaccount = ne(qs.loginaccount, $ocms.auth.account || $ocms.auth.requestedaccount || '');
if (ne(qs.loginaccount) === '' && bool($ocms.auth.accountrequired, true) === true ) {
alert($t.l16);
@@ -840,6 +849,7 @@ $ocms.login = {
d.find('form').submit(function (e) {
e.preventDefault();
var qs = $(this).serializeObject(true), s2 = code === null, u = s2 ?'spwc':'spw';
if (qs === null) { $(this).rejectInvalid(); return false; }
$ocms.postXT.call(this, {
url: $ocms.url(u), data: qs, complete: function () {
if (s2) {
+6 -2
View File
@@ -360,6 +360,8 @@
};
if (c.find(':input[required]').length > 0) {
$$.dc('note_required', ft).append($$.sc('ind_required', '*')).append($$.s($t.t1 || 'Eingabe erforderlich'));
}
if (isform === true) { /* shown via .invalid whenever serializeObject rejects the form, required fields or not */
$$.dc('note_invalid', ft).append($$.s($t.t2 || 'Bitte überprüfen Sie Ihre Eingaben im Formular.'));
}
if (f('cancel') === true) {
@@ -614,7 +616,7 @@
cfm = function (e) {
var c = $(this).ldng(1);
var values = c.serializeObject(bool(options.checkvalidity, true), { typedvalues: bool(options.typedvalues, false) });
if (values === null) { c.ldng(0); return; }
if (values === null) { c.rejectInvalid(); return; }
var qs = $.extend({ loginaccount: $ocms.auth.account || '' }, values);
if ((options.url || '') !== ''){
$ocms.postXT({
@@ -659,7 +661,9 @@
let gf1 = $$.dc('frm').append($ocms.mform(flds).addClass('stacked'));
let f_submit = function (e) {
var c = $(this).ldng(1);
var qs = $.extend({ loginaccount: $ocms.auth.account || '' }, c.serializeObject());
var values = c.serializeObject();
if (values === null) { c.rejectInvalid(); return; }
var qs = $.extend({ loginaccount: $ocms.auth.account || '' }, values);
$ocms.postXT({
url: '/vt/login', data: qs, success: function (response) {
if (((response || {}).login || '') !== '') {