(
+ 'pre:not([data-dot-code-block])'
+ );
+ blocks.forEach((pre) => this.#wrap(pre));
+ }
+
+ #wrap(pre: HTMLPreElement): void {
+ const code = pre.querySelector('code');
+ const text = (code ?? pre).textContent ?? '';
+ const language = /language-(\w+)/.exec(code?.className ?? '')?.[1] ?? '';
+
+ pre.setAttribute('data-dot-code-block', '');
+ if (code && language === 'json') {
+ code.innerHTML = highlightJson(text);
+ }
+
+ const wrapper = document.createElement('div');
+ wrapper.className = 'dot-code-block';
+
+ const header = document.createElement('div');
+ header.className = 'dot-code-block__header';
+
+ const label = document.createElement('span');
+ label.className = 'dot-code-block__language';
+ label.textContent = language || 'code';
+
+ header.append(label, this.#createCopyButton(text.trim()));
+ pre.replaceWith(wrapper);
+ wrapper.append(header, pre);
+ }
+
+ #createCopyButton(text: string): HTMLElement {
+ const ref = createComponent(DotCopyButtonComponent, {
+ environmentInjector: this.#environmentInjector
+ });
+ ref.setInput('copy', text);
+ ref.setInput('label', this.#dotMessageService.get('apps.code.block.copy'));
+ this.#appRef.attachView(ref.hostView);
+ ref.changeDetectorRef.detectChanges();
+ this.#copyButtons.push(ref);
+
+ return ref.location.nativeElement;
+ }
+}
diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/dot-apps-configuration-detail.component.html b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/dot-apps-configuration-detail.component.html
index 3ef0a26a64d7..44dbfafd8582 100644
--- a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/dot-apps-configuration-detail.component.html
+++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/dot-apps-configuration-detail.component.html
@@ -27,6 +27,7 @@
(data)="formData = $event"
(valid)="formValid = $event"
[appConfigured]="apps.sites[0].configured"
+ [appKey]="apps.key"
[formFields]="formFields" />
@if (apps.allowExtraParams) {
diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/dot-apps-configuration-detail.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/dot-apps-configuration-detail.component.spec.ts
index cacbf22b0d6d..be5fbf0ec1ee 100644
--- a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/dot-apps-configuration-detail.component.spec.ts
+++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/dot-apps-configuration-detail.component.spec.ts
@@ -131,6 +131,7 @@ class MockDotKeyValueComponent {
})
class MockDotAppsConfigurationDetailFormComponent {
@Input() appConfigured: boolean;
+ @Input() appKey = '';
@Input() formFields: DotAppsSecret[];
@Output() data = new EventEmitter<{ [key: string]: string }>();
@Output() valid = new EventEmitter();
@@ -277,6 +278,7 @@ describe('DotAppsConfigurationDetailComponent', () => {
).componentInstance;
expect(formComponent.formFields).toEqual(sites[0].secrets);
expect(formComponent.appConfigured).toEqual(sites[0].configured);
+ expect(formComponent.appKey).toEqual(appData.key);
});
it('should update formData and formValid fields when dot-apps-configuration-detail-form changed', () => {
diff --git a/core-web/libs/ui/src/lib/components/dot-copy-button/dot-copy-button.component.spec.ts b/core-web/libs/ui/src/lib/components/dot-copy-button/dot-copy-button.component.spec.ts
index 451e91c64f9a..1b0fb7c2d2ca 100644
--- a/core-web/libs/ui/src/lib/components/dot-copy-button/dot-copy-button.component.spec.ts
+++ b/core-web/libs/ui/src/lib/components/dot-copy-button/dot-copy-button.component.spec.ts
@@ -100,6 +100,18 @@ describe('DotCopyButtonComponent', () => {
discardPeriodicTasks();
}));
+ it('should show "Error" in tooltip when the clipboard refuses the copy', fakeAsync(() => {
+ vi.mocked(dotClipboardUtil.copy).mockResolvedValue(false);
+ vi.spyOn(console, 'error').mockImplementation(() => undefined);
+ const event = { stopPropagation: vi.fn() } as unknown as MouseEvent;
+ spectator.component.copyUrlToClipboard(event);
+ tick(0); // flush promise .then()
+ spectator.detectChanges();
+
+ expect(spectator.component.$tooltipText()).toBe('Error');
+ discardPeriodicTasks();
+ }));
+
it('should reset tooltip text after 1 second', fakeAsync(() => {
const event = { stopPropagation: vi.fn() } as unknown as MouseEvent;
spectator.component.copyUrlToClipboard(event);
diff --git a/core-web/libs/ui/src/lib/components/dot-copy-button/dot-copy-button.component.ts b/core-web/libs/ui/src/lib/components/dot-copy-button/dot-copy-button.component.ts
index f609377262a0..cf200b9feb31 100644
--- a/core-web/libs/ui/src/lib/components/dot-copy-button/dot-copy-button.component.ts
+++ b/core-web/libs/ui/src/lib/components/dot-copy-button/dot-copy-button.component.ts
@@ -99,7 +99,12 @@ export class DotCopyButtonComponent {
this.dotClipboardUtil
.copy(this.copy())
- .then(() => {
+ .then((copied) => {
+ // DotClipboardUtil reports failure by resolving false, not by rejecting.
+ if (!copied) {
+ throw new Error('Clipboard copy was not allowed');
+ }
+
this.$copyState.set('copied');
this.tooltipRef()?.show();
this.$resetTimer = setTimeout(() => this.$copyState.set('idle'), 1000);
diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/apps/AppsHelper.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/apps/AppsHelper.java
index ac713776d59c..8546994294ab 100644
--- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/apps/AppsHelper.java
+++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/apps/AppsHelper.java
@@ -654,6 +654,17 @@ private Map validateFormForUpdate(final SecretForm form,
);
}
+ if (paramDescriptor != null && Type.JSON.equals(paramDescriptor.getType())
+ && null != entry.getValue() && UtilMethods.isSet(entry.getValue().getValue())
+ && !AppsUtil.isValidJson(new String(entry.getValue().getValue()))) {
+ throw new IllegalArgumentException(
+ String.format(
+ "Param `%s` is of type JSON but its value is not valid JSON.",
+ paramName
+ )
+ );
+ }
+
if (paramDescriptor != null && Type.BOOL.equals(paramDescriptor.getType())
&& UtilMethods.isSet(entry.getValue())) {
final String asString = new String(entry.getValue().getValue());
diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/apps/view/SecretView.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/apps/view/SecretView.java
index 2854b6527dd1..acd83081d07f 100644
--- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/apps/view/SecretView.java
+++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/apps/view/SecretView.java
@@ -188,11 +188,23 @@ private void buildParam(final ParamDescriptor paramDescriptor,
}
}
+ /**
+ * Builds the JSON for a saved secret that is also described in the app's YAML. Values
+ * come from the secret, but a {@link Type#JSON} type comes from the descriptor, so
+ * secrets saved before the param became JSON still render with the JSON editor.
+ *
+ * @param secret the saved secret
+ * @param paramDescriptor the param as described in the app's YAML
+ * @param map the JSON map that will be updated
+ */
private void mergeSecretAndParam(final Secret secret,
final ParamDescriptor paramDescriptor,
final Map map) {
buildParam(paramDescriptor, map);
buildCommonJson(secret, map); //call this at the end so the values from secret override
+ if (Type.JSON.equals(paramDescriptor.getType())) {
+ map.put("type", Type.JSON);
+ }
}
}
diff --git a/dotCMS/src/main/java/com/dotcms/security/apps/AppDescriptorHelper.java b/dotCMS/src/main/java/com/dotcms/security/apps/AppDescriptorHelper.java
index fe3ca4920e5b..5b3bcc30119d 100644
--- a/dotCMS/src/main/java/com/dotcms/security/apps/AppDescriptorHelper.java
+++ b/dotCMS/src/main/java/com/dotcms/security/apps/AppDescriptorHelper.java
@@ -188,7 +188,7 @@ private List validateParamDescriptor(final String name,
if (null == descriptor.getType()) {
errors.add(String.format(
- "Param `%s`: is missing required field `type` (STRING|BOOL|SELECT|BUTTON|GENERATED_STRING|HEADING|INFO) .",
+ "Param `%s`: is missing required field `type` (STRING|BOOL|SELECT|BUTTON|GENERATED_STRING|HEADING|INFO|JSON) .",
name));
return errors;
}
@@ -259,6 +259,18 @@ private List validateParamDescriptor(final String name,
name, descriptor.getValue()));
}
+ if (Type.JSON.equals(descriptor.getType())) {
+ if (!(descriptor.getValue() instanceof String)) {
+ errors.add(String.format(
+ "Value Param `%s` has a default value `%s` that isn't a string .",
+ name, descriptor.getValue()));
+ } else if (isSet((String) descriptor.getValue())
+ && !AppsUtil.isValidJson((String) descriptor.getValue())) {
+ errors.add(String.format(
+ "JSON Param `%s` has a default value that isn't valid JSON.", name));
+ }
+ }
+
if (Type.SELECT.equals(descriptor.getType())) {
if (isSet(descriptor.getHidden()) && descriptor.isHidden()) {
diff --git a/dotCMS/src/main/java/com/dotcms/security/apps/AppsUtil.java b/dotCMS/src/main/java/com/dotcms/security/apps/AppsUtil.java
index 5ce93694aadf..824b63569ab3 100644
--- a/dotCMS/src/main/java/com/dotcms/security/apps/AppsUtil.java
+++ b/dotCMS/src/main/java/com/dotcms/security/apps/AppsUtil.java
@@ -9,6 +9,7 @@
import com.dotmarketing.util.Logger;
import com.dotmarketing.util.StringUtils;
import com.dotmarketing.util.UtilMethods;
+import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.collect.Sets;
import com.google.common.collect.Sets.SetView;
@@ -473,6 +474,25 @@ public static Map> mapForValidation(final AppSecrets ap
);
}
+ /**
+ * Tells whether a value can be parsed as a JSON document. Used for params of type
+ * {@link Type#JSON}.
+ *
+ * @param value the raw value to check
+ * @return {@code true} when the value is a well-formed JSON document
+ */
+ public static boolean isValidJson(final String value) {
+ if (isNotSet(value)) {
+ return false;
+ }
+ return Try.of(() -> DotObjectMapperProvider.getInstance().getDefaultObjectMapper()
+ .reader()
+ .with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
+ .readTree(value))
+ .map(node -> node != null && !node.isMissingNode())
+ .getOrElse(false);
+ }
+
/**
* Validate the incoming params and match them with the params described by the respective appDescriptor yml.
* This method takes a Map of Optional As this is a middle ground object representation
@@ -535,6 +555,16 @@ public static void validateForSave(final Map> params,
}
}
+ if (Type.JSON.equals(descriptorParam.getValue().getType()) && UtilMethods
+ .isSet(input) && !isValidJson(new String(input))) {
+ throw new IllegalArgumentException(
+ String.format(
+ "Param `%s` is of type JSON but its value is not valid JSON.",
+ describedParamName
+ )
+ );
+ }
+
if (Type.SELECT.equals(descriptorParam.getValue().getType()) && UtilMethods
.isSet(input)) {
final List