Änderungen von Dokument ClassEditSheet

Zuletzt geändert von Holger Engels am 2026/09/22 10:06

Von Version 1.1
bearbeitet von Holger Engels
am 2025/08/05 10:26
Änderungskommentar: Install extension [org.xwiki.platform:xwiki-platform-appwithinminutes-ui/17.6.0]
Auf Version 2.1
bearbeitet von Holger Engels
am 2026/09/22 10:06
Änderungskommentar: Install extension [org.xwiki.platform:xwiki-platform-appwithinminutes-ui/18.7.0]

Zusammenfassung

Details

Seiteneigenschaften
Dokument-Autor
... ... @@ -1,1 +1,1 @@
1 -XWiki.hengels
1 +XWiki.holgerengels
Inhalt
... ... @@ -329,6 +329,9 @@
329 329   #set ($xclass = $doc.getxWikiClass().getXWikiClass())
330 330   #set ($discard = $xclass.addField($field.name, $field))
331 331   #set ($discard = $field.setObject($xclass))
332 + ## Set the class document author to the current user so that customDisplay code is rendered with the current user's
333 + ## rights, not the class document author's rights.
334 + #set ($discard = $doc.document.authors.setEffectiveMetadataAuthor($services.user.currentUserReference))
332 332   #displayField($doc.getxWikiClass().get($field.name) $formFieldDoc)
333 333   #else
334 334   Unsupported form field.
... ... @@ -381,6 +381,9 @@
381 381   ##
382 382   ## Display the field.
383 383   #set ($field = $doc.getxWikiClass().get($fieldName))
387 + ## Set the class document author to the current user so that customDisplay code is rendered with the current user's
388 + ## rights, not the class document author's rights.
389 + #set ($discard = $doc.document.authors.setEffectiveMetadataAuthor($services.user.currentUserReference))
384 384   #displayPropertyEditInput($field, "${doc.fullName}_0_", $object)
385 385  #end
386 386  
... ... @@ -389,6 +389,9 @@
389 389   *#
390 390  #macro (displayEditForm)
391 391   #set ($discard = $xwiki.jsx.use('AppWithinMinutes.ClassEditSheet'))
398 + ## Reusable helper used to recover gracefully when a preview or field-add request is rejected because the CSRF
399 + ## token expired (e.g. after a server restart).
400 + #set ($discard = $xwiki.jsx.use('AppWithinMinutes.Code.CSRFTokenRefresh'))
392 392   #set ($discard = $xwiki.ssx.use('AppWithinMinutes.ClassEditSheet'))
393 393   #set ($discard = $xwiki.ssx.use('AppWithinMinutes.ClassSheetGenerator'))
394 394   #if ("$!request.wizard" == 'true')
... ... @@ -407,10 +407,21 @@
407 407   * Displays either the edit class form or a new form field. The later is used when adding a new form field via AJAX.
408 408   *#
409 409  #macro (doEdit)
410 - #if ("$!request.field" != '')
411 - #displayNewField()
412 - #elseif ("$!request.preview" == 'true')
413 - #previewField()
419 + #if ("$!request.field" != '' || "$!request.preview" == 'true')
420 + ## CSRF protection.
421 + #if (!$services.csrf.isTokenValid($request.form_token))
422 + $response.setStatus(403)
423 + ## Return a fresh token (using the same JSON shape as XWikiAction#csrfTokenCheck) so that the client can recover
424 + ## from an expired token (e.g. after a server restart) by refreshing it, after an explicit user confirmation,
425 + ## and retrying the request. Returning the token in the response is safe because the same-origin policy prevents
426 + ## a CSRF attacker from reading it.
427 + $response.setContentType('application/json')
428 + $jsontool.serialize({'errorType': 'CSRF', 'newToken': $services.csrf.getToken()})
429 + #elseif ("$!request.field" != '')
430 + #displayNewField()
431 + #else
432 + #previewField()
433 + #end
414 414   #else
415 415   ## Make sure that only the sheet content is rendered when the class is saved using AJAX.
416 416   <div class="hidden">
... ... @@ -418,6 +418,9 @@
418 418   #if ($request.wizard == 'true')
419 419   ## Preserve the wizard mode.
420 420   <input type="hidden" name="wizard" value="true" />
441 + ## Provide the URL of the next wizard step as xredirect value (used by the AJAX Save & View implementation).
442 + #set ($appWizardNextStepURL = "#getNextStepURL()")
443 + <input type="hidden" id="xredirect" name="xredirect" value="$escapetool.xml($appWizardNextStepURL)" />
421 421   #end
422 422   ## Compute the application title to be used as the wizard step title.
423 423   #getAppTitle
... ... @@ -655,6 +655,26 @@
655 655  #end
656 656  
657 657  #**
681 + * Prints the URL of the next wizard step.
682 + *#
683 +#macro (getNextStepURL)
684 + #set ($className = $stringtool.removeEnd($doc.fullName, 'Class'))
685 + #set ($templateProviderReference = $services.model.resolveDocument("${className}TemplateProvider"))
686 + #set ($queryString = {
687 + 'wizard': true,
688 + 'sheet': 'AppWithinMinutes.TemplateProviderEditSheet'
689 + })
690 + #if (!$xwiki.exists($templateProviderReference))
691 + #set ($discard = $queryString.putAll({
692 + 'form_token': $services.csrf.getToken(),
693 + 'template': 'XWiki.TemplateProviderTemplate',
694 + 'parent': $doc.fullName
695 + }))
696 + #end
697 +$xwiki.getURL($templateProviderReference, 'edit', $escapetool.url($queryString))##
698 +#end
699 +
700 +#**
658 658   * Updates and saves the class definition, the class sheet and the class template.
659 659   *#
660 660  #macro (doSave)
... ... @@ -665,24 +665,19 @@
665 665   #updateAndSaveSheet
666 666   #updateAndSaveTranslations
667 667   #if ($action == 'save')
668 - #if ($errorMessage)
711 + #if ($request.getHeader('X-Requested-With') == 'XMLHttpRequest')
712 + ## When saving through AJAX, don't redirect and don't render an error page. Let the client-side handle both.
713 + #if ($errorMessage)
714 + $response.setStatus(400)
715 + #rawResponse($errorMessage, 'text/plain')
716 + #else
717 + $response.setStatus(204)
718 + #end
719 + #elseif ($errorMessage)
669 669   #error($errorMessage)
670 670   #elseif ("$!request.wizard" == 'true')
671 671   ## Redirect to next wizard step.
672 - #set ($className = $stringtool.removeEnd($doc.fullName, 'Class'))
673 - #set ($templateProviderReference = $services.model.resolveDocument("${className}TemplateProvider"))
674 - #set ($queryString = {
675 - 'wizard': true,
676 - 'sheet': 'AppWithinMinutes.TemplateProviderEditSheet'
677 - })
678 - #if (!$xwiki.exists($templateProviderReference))
679 - #set ($discard = $queryString.putAll({
680 - 'form_token': $services.csrf.getToken(),
681 - 'template': 'XWiki.TemplateProviderTemplate',
682 - 'parent': $doc.fullName
683 - }))
684 - #end
685 - $response.sendRedirect($xwiki.getURL($templateProviderReference, 'edit', $escapetool.url($queryString)))
723 + $response.sendRedirect("#getNextStepURL()")
686 686   #else
687 687   ## Redirect to view mode.
688 688   $response.sendRedirect($doc.getURL())
... ... @@ -689,7 +689,8 @@
689 689   #end
690 690   #else
691 691   #if ($errorMessage)
692 - $response.sendError(400, $errorMessage)
730 + $response.setStatus(400)
731 + #rawResponse($errorMessage, 'text/plain')
693 693   #else
694 694   $response.setStatus(204)
695 695   #end
XWiki.JavaScriptExtension[2]
Code
... ... @@ -10,15 +10,42 @@
10 10   enhance : function(data) {
11 11   this.container.insert({top: this._createToolBox()});
12 12   if (data) {
13 - this.container.addClassName('loading');
14 - new Ajax.Request(data, {
15 - method: 'get',
16 - onSuccess: this._display.bind(this)
17 - });
13 + this._loadField(data);
18 18   } else {
19 19   this._display(null);
20 20   }
21 21   },
18 + _loadField : function (url) {
19 + // The field URL embeds the CSRF token that was valid when the page was rendered. Always refresh it from the
20 + // current form_token input (the single source of truth, updated when the token is refreshed after an expired
21 + // token recovery) so that, once the token has been refreshed, adding further fields does not prompt again.
22 + const formToken = this.container.up('form')?.down('input[name="form_token"]');
23 + if (formToken) {
24 + const fieldURL = new URL(url, window.location.href);
25 + fieldURL.searchParams.set('form_token', formToken.value);
26 + url = fieldURL.toString();
27 + }
28 + this.container.addClassName('loading');
29 + const self = this;
30 + new Ajax.Request(url, {
31 + method: 'get',
32 + onSuccess: this._display.bind(this),
33 + // A 403 means the CSRF token expired (e.g. the server was restarted while the user was editing). Offer to
34 + // refresh the token and retry, instead of leaving the loading spinner spinning forever.
35 + on403: function (response) {
36 + this.container.removeClassName('loading');
37 + require(['xwiki-appwithinminutes-csrf'], function (csrf) {
38 + csrf.recoverFromExpiredToken(response, function () {
39 + // The token has been refreshed in the form: retry, _loadField will pick up the fresh token.
40 + self._loadField(url);
41 + });
42 + });
43 + }.bind(this),
44 + onFailure: function () {
45 + this.container.removeClassName('loading');
46 + }.bind(this)
47 + });
48 + },
22 22   getContainer : function() {
23 23   return this.container;
24 24   },
... ... @@ -138,6 +138,12 @@
138 138   // Add parameters required by the AJAX preview request.
139 139   this.previewData.set('preview', true);
140 140   this.previewData.set('xpage', 'plain');
168 + // Find and add the mandatory CSRF token.
169 + const editForm = this.container.up('form');
170 + const formToken = editForm && editForm.down('input[name="form_token"]');
171 + if (formToken) {
172 + this.previewData.set('form_token', formToken.value);
173 + }
141 141   },
142 142   _maybePreview : function() {
143 143   if(!this.previewData) {
... ... @@ -169,6 +169,7 @@
169 169   var preview = this.getViewer().down('dd');
170 170   if (preview) {
171 171   preview.addClassName('loading');
205 + const self = this;
172 172   // Firefox 3.6 doesn't resolve the empty string to the current page URL so we have to explicitly specify it.
173 173   new Ajax.Request(window.location.href, {
174 174   parameters: data.toQueryString(),
... ... @@ -178,6 +178,20 @@
178 178   this.previewData = data;
179 179   document.fire('xwiki:class:previewField', {field: this});
180 180   document.fire('xwiki:dom:updated', {elements: [preview]});
215 + }.bind(this),
216 + // A 403 means the CSRF token expired (e.g. the server was restarted while the user was editing). Offer to
217 + // refresh the token and retry the preview, instead of leaving the loading spinner spinning forever.
218 + on403: function (response) {
219 + preview.removeClassName('loading');
220 + require(['xwiki-appwithinminutes-csrf'], function (csrf) {
221 + csrf.recoverFromExpiredToken(response, function (token) {
222 + data.set('form_token', token);
223 + self._preview(data);
224 + });
225 + });
226 + }.bind(this),
227 + onFailure: function () {
228 + preview.removeClassName('loading');
181 181   }.bind(this)
182 182   });
183 183   }
... ... @@ -303,7 +303,7 @@
303 303   prettyNameInput.ariaLabel = '$escapetool.javascript($services.localization.render('platform.appwithinminutes.classEditorFieldPrettyNameToolTip'))';
304 304   // Replace the preview label with the pretty name input.
305 305   var previewLabel = field.getViewer().down('label');
306 - // We unset the for attribute, so that this label will not be set implicitely OR explicitely anymore.
354 + // We unset the for attribute, so that this label will not be set implicitly OR explicitly anymore.
307 307   previewLabel.writeAttribute('for', '');
308 308   previewLabel.removeChild(previewLabel.lastChild);
309 309   previewLabel.insert(prettyNameInput);
... ... @@ -532,21 +532,14 @@
532 532   // Remove the preview button.
533 533   form.down("input[name='action_preview']").remove();
534 534  
535 - // Rename the save buttons.
536 - form.down("input[name='action_save']").name = 'xaction_save';
537 - form.down("input[name='action_saveandcontinue']").name = 'xaction_saveandcontinue';
583 + // Let actionButtons.js submit a custom value for the submit buttons to avoid triggering the standard save action
584 + // on the server side.
585 + form.down("input[name='action_save']").dataset.submitValue = 'xaction_save';
586 + form.down("input[name='action_saveandcontinue']").dataset.submitValue = 'xaction_saveandcontinue';
538 538  
539 - // Make sure the 'Save & Continue' button is submitted when clicked.
540 - document.observe('xwiki:class:save', function(event) {
541 - event = event.memo.originalEvent;
542 - if (event.memo['continue']) {
543 - var submitButton = event.memo.originalEvent.element();
544 - submitButton.insert({after: new Element('input', {type: 'hidden', 'name': submitButton.name})});
545 - (function () {
546 - submitButton.next().remove();
547 - }).defer();
548 - }
549 - }.bindAsEventListener(window));
588 + // actionButtons.js detects this as a custom form due to the submit URL that is not the standard one.
589 + // Force the standard behavior by setting the customForm attribute to false.
590 + form.dataset.customForm = 'false';
550 550  
551 551   // Be prepared to enhance some of the field properties.
552 552   new XWiki.FormFieldNameManager();