From e8367f27f8bf5323aa1f91d74e3b5649b45b817a Mon Sep 17 00:00:00 2001
From: Krystian Panek
Date: Thu, 30 Oct 2025 09:07:20 +0100
Subject: [PATCH 01/18] Authorized execution
---
.../dev/vml/es/acm/core/code/Conditions.java | 2 +-
.../dev/vml/es/acm/core/code/Executable.java | 4 +-
.../vml/es/acm/core/code/ExecutableUtils.java | 4 +-
.../dev/vml/es/acm/core/code/Executor.java | 14 ++++
.../acm/core/servlet/ExecuteCodeServlet.java | 4 ++
.../core/servlet/ExecuteScriptServlet.java | 72 -------------------
.../es/acm/core/servlet/QueueCodeServlet.java | 4 ++
.../vml/es/acm/core/util/ServletResult.java | 4 ++
.../apps/acm/api/execute-script/.content.xml | 5 --
ui.frontend/src/pages/ConsolePage.tsx | 1 +
10 files changed, 33 insertions(+), 81 deletions(-)
delete mode 100644 core/src/main/java/dev/vml/es/acm/core/servlet/ExecuteScriptServlet.java
delete mode 100644 ui.apps/src/main/content/jcr_root/apps/acm/api/execute-script/.content.xml
diff --git a/core/src/main/java/dev/vml/es/acm/core/code/Conditions.java b/core/src/main/java/dev/vml/es/acm/core/code/Conditions.java
index 136ab0eda..2f7bd3274 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/Conditions.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/Conditions.java
@@ -209,7 +209,7 @@ public InstanceInfo instanceInfo() {
// Executable-based
public boolean isConsole() {
- return Executable.ID_CONSOLE.equals(executableId());
+ return Executable.CONSOLE_ID.equals(executableId());
}
public boolean isAutomaticScript() {
diff --git a/core/src/main/java/dev/vml/es/acm/core/code/Executable.java b/core/src/main/java/dev/vml/es/acm/core/code/Executable.java
index f2741ac10..9bfa0af12 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/Executable.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/Executable.java
@@ -5,7 +5,9 @@
public interface Executable extends Serializable {
- String ID_CONSOLE = "console";
+ String CONSOLE_ID = "console";
+
+ String CONSOLE_SCRIPT_PATH = "/conf/acm/settings/script/template/core/console.groovy";
String getId();
diff --git a/core/src/main/java/dev/vml/es/acm/core/code/ExecutableUtils.java b/core/src/main/java/dev/vml/es/acm/core/code/ExecutableUtils.java
index 0b27d4b9d..702ff85ae 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/ExecutableUtils.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/ExecutableUtils.java
@@ -13,7 +13,7 @@ private ExecutableUtils() {
}
public static String nameById(String id) {
- if (Executable.ID_CONSOLE.equals(id)) {
+ if (Executable.CONSOLE_ID.equals(id)) {
return "Console";
}
if (StringUtils.startsWith(id, ScriptType.AUTOMATIC.root() + "/")) {
@@ -30,7 +30,7 @@ public static String nameById(String id) {
}
public static boolean isIdExplicit(String id) {
- return Executable.ID_CONSOLE.equals(id) || StringUtils.startsWith(id, ScriptRepository.ROOT + "/");
+ return Executable.CONSOLE_ID.equals(id) || StringUtils.startsWith(id, ScriptRepository.ROOT + "/");
}
public static boolean isUserExplicit(String userId) {
diff --git a/core/src/main/java/dev/vml/es/acm/core/code/Executor.java b/core/src/main/java/dev/vml/es/acm/core/code/Executor.java
index e818aedb4..cc28c5470 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/Executor.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/Executor.java
@@ -13,6 +13,9 @@
import dev.vml.es.acm.core.osgi.InstanceInfo;
import dev.vml.es.acm.core.osgi.OsgiContext;
import dev.vml.es.acm.core.repo.Locker;
+import dev.vml.es.acm.core.script.Script;
+import dev.vml.es.acm.core.script.ScriptRepository;
+import dev.vml.es.acm.core.script.ScriptType;
import dev.vml.es.acm.core.util.DateUtils;
import dev.vml.es.acm.core.util.ResolverUtils;
import dev.vml.es.acm.core.util.StringUtil;
@@ -372,4 +375,15 @@ private void useLocker(ResourceResolverFactory resolverFactory, Consumer
private void useHistory(ResourceResolverFactory resolverFactory, Consumer consumer) {
ResolverUtils.useContentResolver(resolverFactory, null, r -> consumer.accept(new ExecutionHistory(r)));
}
+
+ public boolean authorize(String executableId, String userId) {
+ return ResolverUtils.queryContentResolver(resolverFactory, userId, resolver -> {
+ String scriptPath = executableId;
+ if (Executable.CONSOLE_ID.equals(executableId)) {
+ scriptPath = Executable.CONSOLE_SCRIPT_PATH;
+ }
+ ScriptRepository repository = new ScriptRepository(resolver);
+ return repository.read(scriptPath).isPresent();
+ });
+ }
}
diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/ExecuteCodeServlet.java b/core/src/main/java/dev/vml/es/acm/core/servlet/ExecuteCodeServlet.java
index deb88bb35..73b5d3eee 100644
--- a/core/src/main/java/dev/vml/es/acm/core/servlet/ExecuteCodeServlet.java
+++ b/core/src/main/java/dev/vml/es/acm/core/servlet/ExecuteCodeServlet.java
@@ -50,6 +50,10 @@ protected void doPost(SlingHttpServletRequest request, SlingHttpServletResponse
}
Code code = input.getCode();
+ if (!executor.authorize(code.getId(), request.getResourceResolver().getUserID())) {
+ respondJson(response, forbidden(String.format("Code from '%s' is not authorized!", code.getId())));
+ return;
+ }
ExecutionMode mode = ExecutionMode.of(input.getMode()).orElse(null);
if (mode == null) {
diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/ExecuteScriptServlet.java b/core/src/main/java/dev/vml/es/acm/core/servlet/ExecuteScriptServlet.java
deleted file mode 100644
index f1cd0580a..000000000
--- a/core/src/main/java/dev/vml/es/acm/core/servlet/ExecuteScriptServlet.java
+++ /dev/null
@@ -1,72 +0,0 @@
-package dev.vml.es.acm.core.servlet;
-
-import static dev.vml.es.acm.core.util.ServletResult.*;
-import static dev.vml.es.acm.core.util.ServletUtils.respondJson;
-import static dev.vml.es.acm.core.util.ServletUtils.stringParam;
-
-import dev.vml.es.acm.core.code.*;
-import dev.vml.es.acm.core.script.Script;
-import dev.vml.es.acm.core.script.ScriptRepository;
-import java.io.IOException;
-import javax.servlet.Servlet;
-import org.apache.sling.api.SlingHttpServletRequest;
-import org.apache.sling.api.SlingHttpServletResponse;
-import org.apache.sling.api.servlets.ServletResolverConstants;
-import org.apache.sling.api.servlets.SlingAllMethodsServlet;
-import org.osgi.service.component.annotations.Component;
-import org.osgi.service.component.annotations.Reference;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@Component(
- immediate = true,
- service = Servlet.class,
- property = {
- ServletResolverConstants.SLING_SERVLET_METHODS + "=POST",
- ServletResolverConstants.SLING_SERVLET_EXTENSIONS + "=json",
- ServletResolverConstants.SLING_SERVLET_RESOURCE_TYPES + "=" + ExecuteScriptServlet.RT
- })
-public class ExecuteScriptServlet extends SlingAllMethodsServlet {
-
- public static final String RT = "acm/api/execute-script";
-
- private static final Logger LOG = LoggerFactory.getLogger(ExecuteScriptServlet.class);
-
- private static final String PATH_PARAM = "path";
-
- @Reference
- private transient Executor executor;
-
- @Override
- protected void doPost(SlingHttpServletRequest request, SlingHttpServletResponse response) throws IOException {
- String path = stringParam(request, PATH_PARAM);
-
- try {
- Script script = new ScriptRepository(request.getResourceResolver())
- .read(path)
- .orElse(null);
- if (script == null) {
- respondJson(response, badRequest(String.format("Script at path '%s' not found!", path)));
- return;
- }
-
- try (ExecutionContext context = executor.createContext(
- ExecutionId.generate(),
- request.getResourceResolver().getUserID(),
- ExecutionMode.RUN,
- script,
- new InputValues(),
- request.getResourceResolver(),
- new CodeOutputMemory())) {
- Execution execution = executor.execute(context);
-
- respondJson(response, ok(String.format("Script at path '%s' executed successfully", path), execution));
- }
- } catch (Exception e) {
- LOG.error("Cannot execute script at path '{}'", path, e);
- respondJson(
- response,
- error(String.format("Script at path '%s' cannot be executed. Error: %s", path, e.getMessage())));
- }
- }
-}
diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeServlet.java b/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeServlet.java
index d0f43c745..b2467aa92 100644
--- a/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeServlet.java
+++ b/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeServlet.java
@@ -55,6 +55,10 @@ protected void doPost(SlingHttpServletRequest request, SlingHttpServletResponse
}
Code code = input.getCode();
+ if (!executor.authorize(code.getId(), request.getResourceResolver().getUserID())) {
+ respondJson(response, forbidden(String.format("Code from '%s' is not authorized!", code.getId())));
+ return;
+ }
try (ExecutionContext context = executor.createContext(
ExecutionId.generate(),
diff --git a/core/src/main/java/dev/vml/es/acm/core/util/ServletResult.java b/core/src/main/java/dev/vml/es/acm/core/util/ServletResult.java
index 07e6a987b..161956eeb 100644
--- a/core/src/main/java/dev/vml/es/acm/core/util/ServletResult.java
+++ b/core/src/main/java/dev/vml/es/acm/core/util/ServletResult.java
@@ -35,6 +35,10 @@ public static ServletResult badRequest(String message) {
return new ServletResult<>(HttpServletResponse.SC_BAD_REQUEST, message);
}
+ public static ServletResult forbidden(String message) {
+ return new ServletResult<>(HttpServletResponse.SC_FORBIDDEN, message);
+ }
+
public static ServletResult error(String message) {
return new ServletResult<>(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, message);
}
diff --git a/ui.apps/src/main/content/jcr_root/apps/acm/api/execute-script/.content.xml b/ui.apps/src/main/content/jcr_root/apps/acm/api/execute-script/.content.xml
deleted file mode 100644
index 103161902..000000000
--- a/ui.apps/src/main/content/jcr_root/apps/acm/api/execute-script/.content.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
diff --git a/ui.frontend/src/pages/ConsolePage.tsx b/ui.frontend/src/pages/ConsolePage.tsx
index c5130c63b..a4fe343ef 100644
--- a/ui.frontend/src/pages/ConsolePage.tsx
+++ b/ui.frontend/src/pages/ConsolePage.tsx
@@ -42,6 +42,7 @@ const ConsolePage = () => {
const { execution, setExecution, executing, setExecuting } = useExecutionPolling(queuedExecution?.id, appState.spaSettings.executionPollInterval);
const [autoscroll, setAutoscroll] = useState(true);
+ // TODO if console template cannot be loaded it means that console is disabled - handle it in UI
useEffect(() => {
if (code === undefined) {
toastRequest({
From 708933c6f1104438c9090ac2bf0d0766eb351230 Mon Sep 17 00:00:00 2001
From: Krystian Panek
Date: Thu, 30 Oct 2025 09:29:34 +0100
Subject: [PATCH 02/18] Console permissions
---
.../dev/vml/es/acm/core/code/Executor.java | 26 +++++++++++--------
.../acm/core/servlet/ExecuteCodeServlet.java | 2 +-
.../es/acm/core/servlet/QueueCodeServlet.java | 2 +-
.../vml/es/acm/core/servlet/StateServlet.java | 9 ++++++-
.../vml/es/acm/core/state/Permissions.java | 16 ++++++++++++
.../java/dev/vml/es/acm/core/state/State.java | 10 ++++++-
ui.frontend/src/App.tsx | 3 +++
ui.frontend/src/components/Header.tsx | 17 +++++++-----
ui.frontend/src/pages/ConsolePage.tsx | 1 -
ui.frontend/src/types/main.ts | 5 ++++
10 files changed, 69 insertions(+), 22 deletions(-)
create mode 100644 core/src/main/java/dev/vml/es/acm/core/state/Permissions.java
diff --git a/core/src/main/java/dev/vml/es/acm/core/code/Executor.java b/core/src/main/java/dev/vml/es/acm/core/code/Executor.java
index cc28c5470..6778109e9 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/Executor.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/Executor.java
@@ -133,6 +133,21 @@ public void onEvent(Event event) {
}
}
+ public boolean authorize(Executable executable, String userId) {
+ return ResolverUtils.queryContentResolver(resolverFactory, userId, resolver -> {
+ return authorize(executable, resolver);
+ });
+ }
+
+ public boolean authorize(Executable executable, ResourceResolver resolver) {
+ String scriptPath = executable.getId();
+ if (Executable.CONSOLE_ID.equals(executable.getId())) {
+ scriptPath = Executable.CONSOLE_SCRIPT_PATH;
+ }
+ ScriptRepository repository = new ScriptRepository(resolver);
+ return repository.read(scriptPath).isPresent();
+ }
+
public ExecutionContext createContext(
String id,
String userId,
@@ -375,15 +390,4 @@ private void useLocker(ResourceResolverFactory resolverFactory, Consumer
private void useHistory(ResourceResolverFactory resolverFactory, Consumer consumer) {
ResolverUtils.useContentResolver(resolverFactory, null, r -> consumer.accept(new ExecutionHistory(r)));
}
-
- public boolean authorize(String executableId, String userId) {
- return ResolverUtils.queryContentResolver(resolverFactory, userId, resolver -> {
- String scriptPath = executableId;
- if (Executable.CONSOLE_ID.equals(executableId)) {
- scriptPath = Executable.CONSOLE_SCRIPT_PATH;
- }
- ScriptRepository repository = new ScriptRepository(resolver);
- return repository.read(scriptPath).isPresent();
- });
- }
}
diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/ExecuteCodeServlet.java b/core/src/main/java/dev/vml/es/acm/core/servlet/ExecuteCodeServlet.java
index 73b5d3eee..f616ce6e4 100644
--- a/core/src/main/java/dev/vml/es/acm/core/servlet/ExecuteCodeServlet.java
+++ b/core/src/main/java/dev/vml/es/acm/core/servlet/ExecuteCodeServlet.java
@@ -50,7 +50,7 @@ protected void doPost(SlingHttpServletRequest request, SlingHttpServletResponse
}
Code code = input.getCode();
- if (!executor.authorize(code.getId(), request.getResourceResolver().getUserID())) {
+ if (!executor.authorize(code.getId(), request.getResourceResolver())) {
respondJson(response, forbidden(String.format("Code from '%s' is not authorized!", code.getId())));
return;
}
diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeServlet.java b/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeServlet.java
index b2467aa92..c5a5836eb 100644
--- a/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeServlet.java
+++ b/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeServlet.java
@@ -55,7 +55,7 @@ protected void doPost(SlingHttpServletRequest request, SlingHttpServletResponse
}
Code code = input.getCode();
- if (!executor.authorize(code.getId(), request.getResourceResolver().getUserID())) {
+ if (!executor.authorize(code.getId(), request.getResourceResolver())) {
respondJson(response, forbidden(String.format("Code from '%s' is not authorized!", code.getId())));
return;
}
diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/StateServlet.java b/core/src/main/java/dev/vml/es/acm/core/servlet/StateServlet.java
index f06cb14f8..6cc1a926a 100644
--- a/core/src/main/java/dev/vml/es/acm/core/servlet/StateServlet.java
+++ b/core/src/main/java/dev/vml/es/acm/core/servlet/StateServlet.java
@@ -4,13 +4,16 @@
import static dev.vml.es.acm.core.util.ServletResult.ok;
import static dev.vml.es.acm.core.util.ServletUtils.respondJson;
+import dev.vml.es.acm.core.code.Executable;
import dev.vml.es.acm.core.code.ExecutionQueue;
+import dev.vml.es.acm.core.code.Executor;
import dev.vml.es.acm.core.gui.SpaSettings;
import dev.vml.es.acm.core.instance.HealthChecker;
import dev.vml.es.acm.core.instance.HealthStatus;
import dev.vml.es.acm.core.mock.MockHttpFilter;
import dev.vml.es.acm.core.mock.MockStatus;
import dev.vml.es.acm.core.osgi.InstanceInfo;
+import dev.vml.es.acm.core.state.Permissions;
import dev.vml.es.acm.core.state.State;
import java.io.IOException;
import javax.servlet.Servlet;
@@ -51,12 +54,16 @@ public class StateServlet extends SlingAllMethodsServlet {
@Reference
private transient SpaSettings spaSettings;
+ @Reference
+ private transient Executor executor;
+
@Override
protected void doGet(SlingHttpServletRequest request, SlingHttpServletResponse response) throws IOException {
try {
HealthStatus healthStatus = healthChecker.checkStatus();
MockStatus mockStatus = mockHttpFilter.checkStatus();
- State state = new State(spaSettings, healthStatus, mockStatus, instanceInfo.getSettings());
+ Permissions permissions = new Permissions(executor.authorize(Executable.CONSOLE_ID, request.getResourceResolver()));
+ State state = new State(spaSettings, healthStatus, mockStatus, instanceInfo.getSettings(), permissions);
respondJson(response, ok("State read successfully", state));
} catch (Exception e) {
diff --git a/core/src/main/java/dev/vml/es/acm/core/state/Permissions.java b/core/src/main/java/dev/vml/es/acm/core/state/Permissions.java
new file mode 100644
index 000000000..f8939179d
--- /dev/null
+++ b/core/src/main/java/dev/vml/es/acm/core/state/Permissions.java
@@ -0,0 +1,16 @@
+package dev.vml.es.acm.core.state;
+
+import java.io.Serializable;
+
+public class Permissions implements Serializable {
+
+ private boolean console;
+
+ public Permissions(boolean console) {
+ this.console = console;
+ }
+
+ public boolean isConsole() {
+ return console;
+ }
+}
diff --git a/core/src/main/java/dev/vml/es/acm/core/state/State.java b/core/src/main/java/dev/vml/es/acm/core/state/State.java
index ed3f0a80e..77cb9c8ca 100644
--- a/core/src/main/java/dev/vml/es/acm/core/state/State.java
+++ b/core/src/main/java/dev/vml/es/acm/core/state/State.java
@@ -16,15 +16,19 @@ public class State implements Serializable {
private final SpaSettings spaSettings;
+ private final Permissions permissions;
+
public State(
SpaSettings spaSettings,
HealthStatus healthStatus,
MockStatus mockStatus,
- InstanceSettings instanceSettings) {
+ InstanceSettings instanceSettings,
+ Permissions permissions) {
this.spaSettings = spaSettings;
this.healthStatus = healthStatus;
this.mockStatus = mockStatus;
this.instanceSettings = instanceSettings;
+ this.permissions = permissions;
}
public HealthStatus getHealthStatus() {
@@ -42,4 +46,8 @@ public InstanceSettings getInstanceSettings() {
public SpaSettings getSpaSettings() {
return spaSettings;
}
+
+ public Permissions getPermissions() {
+ return permissions;
+ }
}
diff --git a/ui.frontend/src/App.tsx b/ui.frontend/src/App.tsx
index 5fe3d9262..9fcb9c3f6 100644
--- a/ui.frontend/src/App.tsx
+++ b/ui.frontend/src/App.tsx
@@ -34,6 +34,9 @@ function App() {
role: InstanceRole.AUTHOR,
type: InstanceType.CLOUD_CONTAINER,
},
+ permissions: {
+ console: false,
+ },
});
const isFetching = useRef(false);
diff --git a/ui.frontend/src/components/Header.tsx b/ui.frontend/src/components/Header.tsx
index 27186724e..ba2cceab7 100644
--- a/ui.frontend/src/components/Header.tsx
+++ b/ui.frontend/src/components/Header.tsx
@@ -7,9 +7,12 @@ import Home from '@spectrum-icons/workflow/Home';
import Maintenance from '@spectrum-icons/workflow/Settings';
import { useLocation } from 'react-router-dom';
import { AppLink } from '../AppLink.tsx';
+import { useAppState } from '../hooks/app.ts';
+import Toggle from './Toggle.tsx';
const Header = () => {
const location = useLocation();
+ const state = useAppState();
return (
@@ -18,12 +21,14 @@ const Header = () => {
-
-
-
- Console
-
-
+
+
+
+
+ Console
+
+
+
diff --git a/ui.frontend/src/pages/ConsolePage.tsx b/ui.frontend/src/pages/ConsolePage.tsx
index a4fe343ef..c5130c63b 100644
--- a/ui.frontend/src/pages/ConsolePage.tsx
+++ b/ui.frontend/src/pages/ConsolePage.tsx
@@ -42,7 +42,6 @@ const ConsolePage = () => {
const { execution, setExecution, executing, setExecuting } = useExecutionPolling(queuedExecution?.id, appState.spaSettings.executionPollInterval);
const [autoscroll, setAutoscroll] = useState(true);
- // TODO if console template cannot be loaded it means that console is disabled - handle it in UI
useEffect(() => {
if (code === undefined) {
toastRequest({
diff --git a/ui.frontend/src/types/main.ts b/ui.frontend/src/types/main.ts
index 89d27d1fc..5661cc1e9 100644
--- a/ui.frontend/src/types/main.ts
+++ b/ui.frontend/src/types/main.ts
@@ -45,6 +45,7 @@ export type State = {
healthStatus: HealthStatus;
mockStatus: MockStatus;
instanceSettings: InstanceSettings;
+ permissions: Permissions;
};
export type SpaSettings = {
@@ -65,6 +66,10 @@ export type MockStatus = {
enabled: boolean;
};
+export type Permissions = {
+ console: boolean;
+};
+
export enum ExecutionFormat {
SUMMARY = 'SUMMARY',
FULL = 'FULL',
From 4c2efc5fb9b2eaa772946d65ab965d2caa715c2d Mon Sep 17 00:00:00 2001
From: Krystian Panek
Date: Thu, 30 Oct 2025 09:35:51 +0100
Subject: [PATCH 03/18] Compile fix
---
.../java/dev/vml/es/acm/core/servlet/ExecuteCodeServlet.java | 2 +-
.../java/dev/vml/es/acm/core/servlet/QueueCodeServlet.java | 2 +-
.../main/java/dev/vml/es/acm/core/servlet/StateServlet.java | 3 ++-
3 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/ExecuteCodeServlet.java b/core/src/main/java/dev/vml/es/acm/core/servlet/ExecuteCodeServlet.java
index f616ce6e4..48ae3ece5 100644
--- a/core/src/main/java/dev/vml/es/acm/core/servlet/ExecuteCodeServlet.java
+++ b/core/src/main/java/dev/vml/es/acm/core/servlet/ExecuteCodeServlet.java
@@ -50,7 +50,7 @@ protected void doPost(SlingHttpServletRequest request, SlingHttpServletResponse
}
Code code = input.getCode();
- if (!executor.authorize(code.getId(), request.getResourceResolver())) {
+ if (!executor.authorize(code, request.getResourceResolver())) {
respondJson(response, forbidden(String.format("Code from '%s' is not authorized!", code.getId())));
return;
}
diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeServlet.java b/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeServlet.java
index c5a5836eb..4a9b2b33f 100644
--- a/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeServlet.java
+++ b/core/src/main/java/dev/vml/es/acm/core/servlet/QueueCodeServlet.java
@@ -55,7 +55,7 @@ protected void doPost(SlingHttpServletRequest request, SlingHttpServletResponse
}
Code code = input.getCode();
- if (!executor.authorize(code.getId(), request.getResourceResolver())) {
+ if (!executor.authorize(code, request.getResourceResolver())) {
respondJson(response, forbidden(String.format("Code from '%s' is not authorized!", code.getId())));
return;
}
diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/StateServlet.java b/core/src/main/java/dev/vml/es/acm/core/servlet/StateServlet.java
index 6cc1a926a..b8eb72a86 100644
--- a/core/src/main/java/dev/vml/es/acm/core/servlet/StateServlet.java
+++ b/core/src/main/java/dev/vml/es/acm/core/servlet/StateServlet.java
@@ -4,6 +4,7 @@
import static dev.vml.es.acm.core.util.ServletResult.ok;
import static dev.vml.es.acm.core.util.ServletUtils.respondJson;
+import dev.vml.es.acm.core.code.Code;
import dev.vml.es.acm.core.code.Executable;
import dev.vml.es.acm.core.code.ExecutionQueue;
import dev.vml.es.acm.core.code.Executor;
@@ -62,7 +63,7 @@ protected void doGet(SlingHttpServletRequest request, SlingHttpServletResponse r
try {
HealthStatus healthStatus = healthChecker.checkStatus();
MockStatus mockStatus = mockHttpFilter.checkStatus();
- Permissions permissions = new Permissions(executor.authorize(Executable.CONSOLE_ID, request.getResourceResolver()));
+ Permissions permissions = new Permissions(executor.authorize(Code.consoleMinimal(), request.getResourceResolver()));
State state = new State(spaSettings, healthStatus, mockStatus, instanceInfo.getSettings(), permissions);
respondJson(response, ok("State read successfully", state));
From f095f2a948c8010d5a5438fc833603785f6f0a1e Mon Sep 17 00:00:00 2001
From: Krystian Panek
Date: Thu, 30 Oct 2025 09:58:45 +0100
Subject: [PATCH 04/18] Minor
---
ui.apps/src/main/content/jcr_root/apps/acm/api/.content.xml | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/ui.apps/src/main/content/jcr_root/apps/acm/api/.content.xml b/ui.apps/src/main/content/jcr_root/apps/acm/api/.content.xml
index 71edada66..d3ee0e8e1 100644
--- a/ui.apps/src/main/content/jcr_root/apps/acm/api/.content.xml
+++ b/ui.apps/src/main/content/jcr_root/apps/acm/api/.content.xml
@@ -1,6 +1,4 @@
-
-
+ jcr:primaryType="nt:unstructured"/>
From 7391341976d0b26cbd8abffc5ccc1430059a6529 Mon Sep 17 00:00:00 2001
From: Krystian Panek
Date: Thu, 30 Oct 2025 10:55:41 +0100
Subject: [PATCH 05/18] Perms by feature nodes
---
.../dev/vml/es/acm/core/code/Executor.java | 2 --
.../vml/es/acm/core/servlet/StateServlet.java | 4 ++--
.../vml/es/acm/core/state/Permissions.java | 3 ++-
ui.frontend/src/App.tsx | 2 +-
ui.frontend/src/Route.tsx | 19 +++++++++++++++++++
ui.frontend/src/router.tsx | 6 ++++--
6 files changed, 28 insertions(+), 8 deletions(-)
create mode 100644 ui.frontend/src/Route.tsx
diff --git a/core/src/main/java/dev/vml/es/acm/core/code/Executor.java b/core/src/main/java/dev/vml/es/acm/core/code/Executor.java
index 6778109e9..e2d97bc17 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/Executor.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/Executor.java
@@ -13,9 +13,7 @@
import dev.vml.es.acm.core.osgi.InstanceInfo;
import dev.vml.es.acm.core.osgi.OsgiContext;
import dev.vml.es.acm.core.repo.Locker;
-import dev.vml.es.acm.core.script.Script;
import dev.vml.es.acm.core.script.ScriptRepository;
-import dev.vml.es.acm.core.script.ScriptType;
import dev.vml.es.acm.core.util.DateUtils;
import dev.vml.es.acm.core.util.ResolverUtils;
import dev.vml.es.acm.core.util.StringUtil;
diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/StateServlet.java b/core/src/main/java/dev/vml/es/acm/core/servlet/StateServlet.java
index b8eb72a86..6c8094830 100644
--- a/core/src/main/java/dev/vml/es/acm/core/servlet/StateServlet.java
+++ b/core/src/main/java/dev/vml/es/acm/core/servlet/StateServlet.java
@@ -5,7 +5,6 @@
import static dev.vml.es.acm.core.util.ServletUtils.respondJson;
import dev.vml.es.acm.core.code.Code;
-import dev.vml.es.acm.core.code.Executable;
import dev.vml.es.acm.core.code.ExecutionQueue;
import dev.vml.es.acm.core.code.Executor;
import dev.vml.es.acm.core.gui.SpaSettings;
@@ -63,7 +62,8 @@ protected void doGet(SlingHttpServletRequest request, SlingHttpServletResponse r
try {
HealthStatus healthStatus = healthChecker.checkStatus();
MockStatus mockStatus = mockHttpFilter.checkStatus();
- Permissions permissions = new Permissions(executor.authorize(Code.consoleMinimal(), request.getResourceResolver()));
+ Permissions permissions =
+ new Permissions(executor.authorize(Code.consoleMinimal(), request.getResourceResolver()));
State state = new State(spaSettings, healthStatus, mockStatus, instanceInfo.getSettings(), permissions);
respondJson(response, ok("State read successfully", state));
diff --git a/core/src/main/java/dev/vml/es/acm/core/state/Permissions.java b/core/src/main/java/dev/vml/es/acm/core/state/Permissions.java
index f8939179d..4caf01453 100644
--- a/core/src/main/java/dev/vml/es/acm/core/state/Permissions.java
+++ b/core/src/main/java/dev/vml/es/acm/core/state/Permissions.java
@@ -2,6 +2,7 @@
import java.io.Serializable;
+// TODO manage permissions by reading perms from nodes: /apps/acm/feature/[console|history]
public class Permissions implements Serializable {
private boolean console;
@@ -9,7 +10,7 @@ public class Permissions implements Serializable {
public Permissions(boolean console) {
this.console = console;
}
-
+
public boolean isConsole() {
return console;
}
diff --git a/ui.frontend/src/App.tsx b/ui.frontend/src/App.tsx
index 9fcb9c3f6..52328a289 100644
--- a/ui.frontend/src/App.tsx
+++ b/ui.frontend/src/App.tsx
@@ -35,7 +35,7 @@ function App() {
type: InstanceType.CLOUD_CONTAINER,
},
permissions: {
- console: false,
+ console: true,
},
});
diff --git a/ui.frontend/src/Route.tsx b/ui.frontend/src/Route.tsx
new file mode 100644
index 000000000..03afd3ccf
--- /dev/null
+++ b/ui.frontend/src/Route.tsx
@@ -0,0 +1,19 @@
+
+import { Navigate } from 'react-router-dom';
+import { State } from './types/main';
+import { useAppState } from './hooks/app';
+
+interface RouteProps {
+ children: React.ReactNode;
+ permission?: keyof State['permissions'];
+}
+
+export function Route({ children, permission }: RouteProps) {
+ const state = useAppState();
+
+ if (permission && (!state.permissions[permission])) {
+ return ;
+ }
+
+ return <>{children}>;
+}
diff --git a/ui.frontend/src/router.tsx b/ui.frontend/src/router.tsx
index 1ae851b8b..f863d35b8 100644
--- a/ui.frontend/src/router.tsx
+++ b/ui.frontend/src/router.tsx
@@ -1,6 +1,7 @@
-import { createHashRouter } from 'react-router-dom';
+import { createHashRouter, Navigate } from 'react-router-dom';
import App from './App';
import ErrorHandler from './ErrorHandler';
+import { Route } from './Route';
import ConsolePage from './pages/ConsolePage';
import DashboardPage from './pages/DashboardPage';
import ExecutionView from './pages/ExecutionView';
@@ -20,11 +21,12 @@ const router = createHashRouter([
{ path: '/scripts/:tab?', element: },
{ path: '/scripts/view/:scriptId', element: },
{ path: '/snippets/:tab?', element: },
- { path: '/console', element: },
+ { path: '/console', element: },
{ path: '/history', element: },
{ path: '/executions', element: },
{ path: '/executions/view/:executionId/:tab?', element: },
{ path: '/maintenance/:tab?', element: },
+ { path: '*', element: },
],
},
]);
From 0d0e571253080d03d27f0d7979e1900060cdb030 Mon Sep 17 00:00:00 2001
From: Krystian Panek
Date: Thu, 13 Nov 2025 10:39:07 +0100
Subject: [PATCH 06/18] Features
---
.vscode/settings.json | 3 ++-
ui.apps/src/main/content/jcr_root/apps/acm/.content.xml | 1 +
.../src/main/content/jcr_root/apps/acm/feature/.content.xml | 4 ++++
.../content/jcr_root/apps/acm/feature/console/.content.xml | 4 ++++
.../content/jcr_root/apps/acm/feature/history/.content.xml | 4 ++++
.../content/jcr_root/apps/acm/feature/scripts/.content.xml | 4 ++++
.../content/jcr_root/apps/acm/feature/snippets/.content.xml | 4 ++++
7 files changed, 23 insertions(+), 1 deletion(-)
create mode 100644 ui.apps/src/main/content/jcr_root/apps/acm/feature/.content.xml
create mode 100644 ui.apps/src/main/content/jcr_root/apps/acm/feature/console/.content.xml
create mode 100644 ui.apps/src/main/content/jcr_root/apps/acm/feature/history/.content.xml
create mode 100644 ui.apps/src/main/content/jcr_root/apps/acm/feature/scripts/.content.xml
create mode 100644 ui.apps/src/main/content/jcr_root/apps/acm/feature/snippets/.content.xml
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 6a174701c..fab8d31e7 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -2,5 +2,6 @@
"search.exclude": {
"**/aem/home": true,
"**/node": true
- }
+ },
+ "java.configuration.updateBuildConfiguration": "interactive"
}
\ No newline at end of file
diff --git a/ui.apps/src/main/content/jcr_root/apps/acm/.content.xml b/ui.apps/src/main/content/jcr_root/apps/acm/.content.xml
index b3f1728ff..46baf933a 100644
--- a/ui.apps/src/main/content/jcr_root/apps/acm/.content.xml
+++ b/ui.apps/src/main/content/jcr_root/apps/acm/.content.xml
@@ -3,6 +3,7 @@
jcr:mixinTypes="[rep:AccessControllable]"
jcr:primaryType="sling:Folder">
+
diff --git a/ui.apps/src/main/content/jcr_root/apps/acm/feature/.content.xml b/ui.apps/src/main/content/jcr_root/apps/acm/feature/.content.xml
new file mode 100644
index 000000000..d3ee0e8e1
--- /dev/null
+++ b/ui.apps/src/main/content/jcr_root/apps/acm/feature/.content.xml
@@ -0,0 +1,4 @@
+
+
diff --git a/ui.apps/src/main/content/jcr_root/apps/acm/feature/console/.content.xml b/ui.apps/src/main/content/jcr_root/apps/acm/feature/console/.content.xml
new file mode 100644
index 000000000..d3ee0e8e1
--- /dev/null
+++ b/ui.apps/src/main/content/jcr_root/apps/acm/feature/console/.content.xml
@@ -0,0 +1,4 @@
+
+
diff --git a/ui.apps/src/main/content/jcr_root/apps/acm/feature/history/.content.xml b/ui.apps/src/main/content/jcr_root/apps/acm/feature/history/.content.xml
new file mode 100644
index 000000000..d3ee0e8e1
--- /dev/null
+++ b/ui.apps/src/main/content/jcr_root/apps/acm/feature/history/.content.xml
@@ -0,0 +1,4 @@
+
+
diff --git a/ui.apps/src/main/content/jcr_root/apps/acm/feature/scripts/.content.xml b/ui.apps/src/main/content/jcr_root/apps/acm/feature/scripts/.content.xml
new file mode 100644
index 000000000..d3ee0e8e1
--- /dev/null
+++ b/ui.apps/src/main/content/jcr_root/apps/acm/feature/scripts/.content.xml
@@ -0,0 +1,4 @@
+
+
diff --git a/ui.apps/src/main/content/jcr_root/apps/acm/feature/snippets/.content.xml b/ui.apps/src/main/content/jcr_root/apps/acm/feature/snippets/.content.xml
new file mode 100644
index 000000000..d3ee0e8e1
--- /dev/null
+++ b/ui.apps/src/main/content/jcr_root/apps/acm/feature/snippets/.content.xml
@@ -0,0 +1,4 @@
+
+
From b08579824f2e32285e94d91c8c402591855a42d0 Mon Sep 17 00:00:00 2001
From: Krystian Panek
Date: Thu, 13 Nov 2025 12:22:16 +0100
Subject: [PATCH 07/18] Docs
---
.../snippet/available/core/condition/changed.yml | 12 +++++++++---
.../available/core/condition/content_changed.yml | 8 ++++++++
2 files changed, 17 insertions(+), 3 deletions(-)
create mode 100644 ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/content_changed.yml
diff --git a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/changed.yml b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/changed.yml
index 4688d7741..e5ca6be3a 100644
--- a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/changed.yml
+++ b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/changed.yml
@@ -1,7 +1,13 @@
group: Condition
-name: condition_content_changed
+name: condition_changed
content: |
- conditions.contentChanged()
+ conditions.changed()
documentation: |
- Execute the script only once per its path.
+ Execute the script only once per its path.
+
When the script content is changed, the script will be executed again.
+ When the instance is changed due to a deployment, such as an instance restart or an OSGi bundle change and the script previously failed, the script will be executed again.
+
+ Alias for `conditions.contentChanged() || conditions.retryIfInstanceChanged()`.
+
+ The most universal and recommended condition to use for automatic scripts.
\ No newline at end of file
diff --git a/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/content_changed.yml b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/content_changed.yml
new file mode 100644
index 000000000..0ba2e0333
--- /dev/null
+++ b/ui.content/src/main/content/jcr_root/conf/acm/settings/snippet/available/core/condition/content_changed.yml
@@ -0,0 +1,8 @@
+group: Condition
+name: condition_content_changed
+content: |
+ conditions.contentChanged()
+documentation: |
+ Execute the script only once per its path.
+
+ When the script content is changed, the script will be executed again.
From 26aee12f82cb238363f3ec86fc31b11bfd48e072 Mon Sep 17 00:00:00 2001
From: Krystian Panek
Date: Thu, 13 Nov 2025 12:38:08 +0100
Subject: [PATCH 08/18] XLS imprs
---
.../script/manual/example/ACME-203_output-csv.groovy | 6 +++---
.../script/manual/example/ACME-203_output-xls.groovy | 7 ++++---
2 files changed, 7 insertions(+), 6 deletions(-)
diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-203_output-csv.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-203_output-csv.groovy
index 1c80cc0d8..10be01fc1 100644
--- a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-203_output-csv.groovy
+++ b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-203_output-csv.groovy
@@ -12,7 +12,7 @@ void describeRun() {
}
void doRun() {
- log.info "Users CSV report generation started"
+ out.info "Users CSV report generation started"
int count = inputs.value("count")
def firstNames = (inputs.value("firstNames")).readLines().findAll { it.trim() }
@@ -41,14 +41,14 @@ void doRun() {
report.out.println("${name},${surname},${birthDate}")
- if (i % 100 == 0) log.info("Generated ${i} users...")
+ if (i % 100 == 0) out.info("Generated ${i} users...")
}
outputs.text("summary") {
value = "Processed ${count} user(s)"
}
- log.info "Users CSV report generation ended successfully"
+ out.success "Users CSV report generation ended successfully"
}
LocalDate randomDateBetween(LocalDate start, LocalDate end) {
diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-203_output-xls.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-203_output-xls.groovy
index bcaf79216..095ecf59e 100644
--- a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-203_output-xls.groovy
+++ b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-203_output-xls.groovy
@@ -16,7 +16,7 @@ void describeRun() {
}
void doRun() {
- log.info "Users XLS report generation started"
+ out.info "Users XLS report generation started"
int count = inputs.value("count")
def firstNames = (inputs.value("firstNames")).readLines().findAll { it.trim() }
@@ -51,11 +51,12 @@ void doRun() {
dateCell.setCellValue(Date.from(birthDate.atStartOfDay(ZoneId.systemDefault()).toInstant()))
dateCell.setCellStyle(dateStyle)
- if (i % 100 == 0) log.info("Generated ${i} users...")
+ if (i % 100 == 0) out.info("Generated ${i} users...")
}
(0..
Date: Thu, 13 Nov 2025 14:04:07 +0100
Subject: [PATCH 09/18] Minor
---
.../vml/es/acm/core/code/CodePrintStream.java | 52 ++++++++++++-------
.../dev/vml/es/acm/core/code/Executor.java | 2 +-
2 files changed, 35 insertions(+), 19 deletions(-)
diff --git a/core/src/main/java/dev/vml/es/acm/core/code/CodePrintStream.java b/core/src/main/java/dev/vml/es/acm/core/code/CodePrintStream.java
index 77c94e771..a3586063d 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/CodePrintStream.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/CodePrintStream.java
@@ -28,7 +28,7 @@ public class CodePrintStream extends PrintStream {
public static final String[] LOGGER_NAMES = {LOGGER_NAME_ACL, LOGGER_NAME_REPO};
// have to match pattern in 'monaco/log.ts'
- private static final DateTimeFormatter LOGGER_TIMESTAMP_FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss.SSS");
+ private static final DateTimeFormatter TIMESTAMP_FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss.SSS");
private final Logger logger;
@@ -40,6 +40,8 @@ public class CodePrintStream extends PrintStream {
private final LogAppender logAppender;
+ private boolean printerTimestamps;
+
public CodePrintStream(OutputStream output, String id) {
super(output);
@@ -48,6 +50,8 @@ public CodePrintStream(OutputStream output, String id) {
this.loggerTimestamps = true;
this.logger = loggerContext.getLogger(id);
this.logAppender = new LogAppender();
+
+ this.printerTimestamps = true;
}
private class LogAppender extends AppenderBase {
@@ -60,7 +64,7 @@ protected void append(ILoggingEvent event) {
if (loggerTimestamps) {
LocalDateTime eventTime = LocalDateTime.ofInstant(
Instant.ofEpochMilli(event.getTimeStamp()), ZoneId.systemDefault());
- String timestamp = eventTime.format(LOGGER_TIMESTAMP_FORMATTER);
+ String timestamp = eventTime.format(TIMESTAMP_FORMATTER);
println(timestamp + " [" + level + "] " + event.getFormattedMessage());
} else {
println('[' + level + "] " + event.getFormattedMessage());
@@ -116,7 +120,7 @@ public boolean isLoggerTimestamps() {
return loggerTimestamps;
}
- public void withLoggerTimestamps(boolean flag) {
+ public void setLoggerTimestamps(boolean flag) {
this.loggerTimestamps = flag;
}
@@ -147,33 +151,45 @@ public void fromLoggers(List loggerNames) {
loggerNames.forEach(this::fromLogger);
}
+ public void setPrinterTimestamps(boolean flag) {
+ this.printerTimestamps = flag;
+ }
+
+ public boolean isPrinterTimestamps() {
+ return printerTimestamps;
+ }
+
+ public void printTimestamped(String level, String message) {
+ printTimestamped(CodePrintLevel.of(level), message);
+ }
+
+ public void printTimestamped(CodePrintLevel level, String message) {
+ if (printerTimestamps) {
+ LocalDateTime now = LocalDateTime.now();
+ String timestamp = now.format(TIMESTAMP_FORMATTER);
+ println(timestamp + " [" + level + "] " + message);
+ } else {
+ println("[" + level + "] " + message);
+ }
+ }
+
public void success(String message) {
- printStamped(CodePrintLevel.SUCCESS, message);
+ printTimestamped(CodePrintLevel.SUCCESS, message);
}
public void info(String message) {
- printStamped(CodePrintLevel.INFO, message);
+ printTimestamped(CodePrintLevel.INFO, message);
}
public void error(String message) {
- printStamped(CodePrintLevel.ERROR, message);
+ printTimestamped(CodePrintLevel.ERROR, message);
}
public void warn(String message) {
- printStamped(CodePrintLevel.WARN, message);
+ printTimestamped(CodePrintLevel.WARN, message);
}
public void debug(String message) {
- printStamped(CodePrintLevel.DEBUG, message);
- }
-
- public void printStamped(String level, String message) {
- printStamped(CodePrintLevel.of(level), message);
- }
-
- public void printStamped(CodePrintLevel level, String message) {
- LocalDateTime now = LocalDateTime.now();
- String timestamp = now.format(LOGGER_TIMESTAMP_FORMATTER);
- println(timestamp + " [" + level + "] " + message);
+ printTimestamped(CodePrintLevel.DEBUG, message);
}
}
diff --git a/core/src/main/java/dev/vml/es/acm/core/code/Executor.java b/core/src/main/java/dev/vml/es/acm/core/code/Executor.java
index b227d8f9b..37ac86b4d 100644
--- a/core/src/main/java/dev/vml/es/acm/core/code/Executor.java
+++ b/core/src/main/java/dev/vml/es/acm/core/code/Executor.java
@@ -239,7 +239,7 @@ private ContextualExecution executeInternal(ExecutionContext context) {
if (config.logPrintingEnabled()) {
context.getOut().fromSelfLogger();
context.getOut().fromLoggers(config.logPrintingNames());
- context.getOut().withLoggerTimestamps(config.logPrintingTimestamps());
+ context.getOut().setLoggerTimestamps(config.logPrintingTimestamps());
}
contentScript.run();
From 4011f1422d8ba16c7c25918671897276c5c781fb Mon Sep 17 00:00:00 2001
From: Krystian Panek
Date: Fri, 14 Nov 2025 12:05:01 +0100
Subject: [PATCH 10/18] Feature node based authorization
---
.../dev/vml/es/acm/core/AcmConstants.java | 2 +
.../dev/vml/es/acm/core/gui/SpaSettings.java | 12 ---
.../vml/es/acm/core/servlet/EventServlet.java | 8 ++
.../es/acm/core/servlet/ScriptServlet.java | 3 +-
.../vml/es/acm/core/servlet/StateServlet.java | 3 +-
.../vml/es/acm/core/state/Permissions.java | 54 ++++++++++++--
.../apps/acm/feature/dashboard/.content.xml | 4 +
.../apps/acm/feature/maintenance/.content.xml | 4 +
.../feature/maintenance/manage/.content.xml | 4 +
.../acm/feature/scripts/manage/.content.xml | 4 +
ui.frontend/src/App.tsx | 28 +------
ui.frontend/src/Route.tsx | 11 ++-
ui.frontend/src/components/CodeExecutor.tsx | 8 +-
ui.frontend/src/components/CodeInput.tsx | 4 +-
.../src/components/CompilationStatus.tsx | 12 ++-
ui.frontend/src/components/DateExplained.tsx | 4 +-
.../src/components/ExecutionAbortButton.tsx | 9 ++-
.../ExecutionHistoryClearButton.tsx | 5 +-
.../src/components/ExecutionsAbortButton.tsx | 25 +++++--
.../src/components/ExecutorBootButton.tsx | 4 +-
.../src/components/ExecutorResetButton.tsx | 5 +-
ui.frontend/src/components/Header.tsx | 68 +++++++++--------
ui.frontend/src/components/HealthChecker.tsx | 4 +-
.../src/components/ScriptAutomaticList.tsx | 4 +-
.../src/components/ScriptManualList.tsx | 4 +-
ui.frontend/src/hooks/app.ts | 7 +-
ui.frontend/src/pages/ConsolePage.tsx | 5 +-
ui.frontend/src/router.tsx | 74 ++++++++++++++++---
ui.frontend/src/types/input.ts | 2 +-
ui.frontend/src/types/main.ts | 38 +++++++++-
30 files changed, 296 insertions(+), 123 deletions(-)
create mode 100644 ui.apps/src/main/content/jcr_root/apps/acm/feature/dashboard/.content.xml
create mode 100644 ui.apps/src/main/content/jcr_root/apps/acm/feature/maintenance/.content.xml
create mode 100644 ui.apps/src/main/content/jcr_root/apps/acm/feature/maintenance/manage/.content.xml
create mode 100644 ui.apps/src/main/content/jcr_root/apps/acm/feature/scripts/manage/.content.xml
diff --git a/core/src/main/java/dev/vml/es/acm/core/AcmConstants.java b/core/src/main/java/dev/vml/es/acm/core/AcmConstants.java
index 0674a8641..51e7e9f73 100644
--- a/core/src/main/java/dev/vml/es/acm/core/AcmConstants.java
+++ b/core/src/main/java/dev/vml/es/acm/core/AcmConstants.java
@@ -8,6 +8,8 @@ public class AcmConstants {
public static final String NOTIFIER_ID = "acm";
+ public static final String APPS_ROOT = "/apps/acm";
+
public static final String SETTINGS_ROOT = "/conf/acm/settings";
public static final String VAR_ROOT = "/var/acm";
diff --git a/core/src/main/java/dev/vml/es/acm/core/gui/SpaSettings.java b/core/src/main/java/dev/vml/es/acm/core/gui/SpaSettings.java
index 0b029c9a3..c042e334d 100644
--- a/core/src/main/java/dev/vml/es/acm/core/gui/SpaSettings.java
+++ b/core/src/main/java/dev/vml/es/acm/core/gui/SpaSettings.java
@@ -22,8 +22,6 @@ public class SpaSettings implements Serializable {
private long scriptStatsLimit;
- private boolean scriptManagementEnabled;
-
@Activate
@Modified
protected void activate(Config config) {
@@ -32,7 +30,6 @@ protected void activate(Config config) {
this.executionCodeOutputChunkSize = config.executionCodeOutputChunkSize();
this.executionFileOutputChunkSize = config.executionFileOutputChunkSize();
this.scriptStatsLimit = config.scriptStatsLimit();
- this.scriptManagementEnabled = config.scriptManagementEnabled();
}
public long getAppStateInterval() {
@@ -55,10 +52,6 @@ public long getScriptStatsLimit() {
return scriptStatsLimit;
}
- public boolean isScriptManagementEnabled() {
- return scriptManagementEnabled;
- }
-
@ObjectClassDefinition(name = "AEM Content Manager - SPA Settings")
public @interface Config {
@@ -83,10 +76,5 @@ public boolean isScriptManagementEnabled() {
description =
"Limit for the number of historical executions to be considered to calculate the average duration.")
long scriptStatsLimit() default 10;
-
- @AttributeDefinition(
- name = "Script Management Enabled",
- description = "Enable or disable script management features (delete, save, sync, etc).")
- boolean scriptManagementEnabled() default true;
}
}
diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/EventServlet.java b/core/src/main/java/dev/vml/es/acm/core/servlet/EventServlet.java
index 5f1449ffa..e09d5f93a 100644
--- a/core/src/main/java/dev/vml/es/acm/core/servlet/EventServlet.java
+++ b/core/src/main/java/dev/vml/es/acm/core/servlet/EventServlet.java
@@ -5,7 +5,10 @@
import dev.vml.es.acm.core.event.EventManager;
import dev.vml.es.acm.core.event.EventType;
+import dev.vml.es.acm.core.state.Permissions;
+
import java.io.IOException;
+import java.security.Permission;
import java.util.Collections;
import javax.servlet.Servlet;
import org.apache.sling.api.SlingHttpServletRequest;
@@ -37,6 +40,11 @@ public class EventServlet extends SlingAllMethodsServlet {
@Override
protected void doPost(SlingHttpServletRequest request, SlingHttpServletResponse response) throws IOException {
+ if (!Permissions.check(Permissions.Feature.MAINTENANCE_MANAGE, request.getResourceResolver())) {
+ respondJson(response, forbidden("Event cannot be dispatched due to insufficient permissions!"));
+ return;
+ }
+
String name = request.getParameter(NAME_PARAM);
EventType event = EventType.of(name).orElse(null);
if (event == null) {
diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/ScriptServlet.java b/core/src/main/java/dev/vml/es/acm/core/servlet/ScriptServlet.java
index 917912cc6..e5a4dd4b5 100644
--- a/core/src/main/java/dev/vml/es/acm/core/servlet/ScriptServlet.java
+++ b/core/src/main/java/dev/vml/es/acm/core/servlet/ScriptServlet.java
@@ -16,6 +16,7 @@
import dev.vml.es.acm.core.servlet.input.ScriptInput;
import dev.vml.es.acm.core.servlet.output.ScriptListOutput;
import dev.vml.es.acm.core.servlet.output.ScriptOutput;
+import dev.vml.es.acm.core.state.Permissions;
import dev.vml.es.acm.core.util.JsonUtils;
import java.io.IOException;
import java.util.Arrays;
@@ -126,7 +127,7 @@ protected void doGet(SlingHttpServletRequest request, SlingHttpServletResponse r
@Override
protected void doPost(SlingHttpServletRequest request, SlingHttpServletResponse response) throws IOException {
- if (!spaSettings.isScriptManagementEnabled()) {
+ if (!Permissions.check(Permissions.Feature.SCRIPTS_MANAGE, request.getResourceResolver())) {
respondJson(response, error("Script management is disabled!"));
return;
}
diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/StateServlet.java b/core/src/main/java/dev/vml/es/acm/core/servlet/StateServlet.java
index 6c8094830..6dee76145 100644
--- a/core/src/main/java/dev/vml/es/acm/core/servlet/StateServlet.java
+++ b/core/src/main/java/dev/vml/es/acm/core/servlet/StateServlet.java
@@ -62,8 +62,7 @@ protected void doGet(SlingHttpServletRequest request, SlingHttpServletResponse r
try {
HealthStatus healthStatus = healthChecker.checkStatus();
MockStatus mockStatus = mockHttpFilter.checkStatus();
- Permissions permissions =
- new Permissions(executor.authorize(Code.consoleMinimal(), request.getResourceResolver()));
+ Permissions permissions = new Permissions(request.getResourceResolver());
State state = new State(spaSettings, healthStatus, mockStatus, instanceInfo.getSettings(), permissions);
respondJson(response, ok("State read successfully", state));
diff --git a/core/src/main/java/dev/vml/es/acm/core/state/Permissions.java b/core/src/main/java/dev/vml/es/acm/core/state/Permissions.java
index 4caf01453..9c067d707 100644
--- a/core/src/main/java/dev/vml/es/acm/core/state/Permissions.java
+++ b/core/src/main/java/dev/vml/es/acm/core/state/Permissions.java
@@ -1,17 +1,59 @@
package dev.vml.es.acm.core.state;
import java.io.Serializable;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import org.apache.sling.api.resource.ResourceResolver;
+
+import dev.vml.es.acm.core.AcmConstants;
+import dev.vml.es.acm.core.repo.Repo;
-// TODO manage permissions by reading perms from nodes: /apps/acm/feature/[console|history]
public class Permissions implements Serializable {
- private boolean console;
+ public enum Feature {
+ DASHBOARD,
+ CONSOLE,
+ HISTORY,
+ SCRIPTS,
+ SCRIPTS_MANAGE,
+ SNIPPETS,
+ MAINTENANCE,
+ MAINTENANCE_MANAGE;
+ }
+
+ private static final String FEATURE_ROOT = AcmConstants.APPS_ROOT + "/feature";
+
+ private final Map features;
+
+ public Permissions(ResourceResolver resolver) {
+ this.features = authorizeFeatures(resolver);
+ }
+
+ public static boolean check(Feature feature, ResourceResolver resolver) {
+ return authorizeFeature(feature, resolver);
+ }
+
+ public static boolean authorizeFeature(Feature f, ResourceResolver resolver) {
+ return Repo.quiet(resolver).get(FEATURE_ROOT + "/" + featureNodePath(f)).exists();
+ }
+
+ public Map authorizeFeatures(ResourceResolver resolver) {
+ return Arrays.stream(Feature.values())
+ .collect(Collectors.toMap(Permissions::featureId, f -> authorizeFeature(f, resolver), (a, b) -> b, LinkedHashMap::new));
+ }
+
+ private static String featureId(Feature f) {
+ return f.name().toLowerCase().replace("_", ".");
+ }
- public Permissions(boolean console) {
- this.console = console;
+ private static String featureNodePath(Feature f) {
+ return f.name().toLowerCase().replace("_", "/");
}
- public boolean isConsole() {
- return console;
+ public Map getFeatures() {
+ return features;
}
}
diff --git a/ui.apps/src/main/content/jcr_root/apps/acm/feature/dashboard/.content.xml b/ui.apps/src/main/content/jcr_root/apps/acm/feature/dashboard/.content.xml
new file mode 100644
index 000000000..d3ee0e8e1
--- /dev/null
+++ b/ui.apps/src/main/content/jcr_root/apps/acm/feature/dashboard/.content.xml
@@ -0,0 +1,4 @@
+
+
diff --git a/ui.apps/src/main/content/jcr_root/apps/acm/feature/maintenance/.content.xml b/ui.apps/src/main/content/jcr_root/apps/acm/feature/maintenance/.content.xml
new file mode 100644
index 000000000..d3ee0e8e1
--- /dev/null
+++ b/ui.apps/src/main/content/jcr_root/apps/acm/feature/maintenance/.content.xml
@@ -0,0 +1,4 @@
+
+
diff --git a/ui.apps/src/main/content/jcr_root/apps/acm/feature/maintenance/manage/.content.xml b/ui.apps/src/main/content/jcr_root/apps/acm/feature/maintenance/manage/.content.xml
new file mode 100644
index 000000000..d3ee0e8e1
--- /dev/null
+++ b/ui.apps/src/main/content/jcr_root/apps/acm/feature/maintenance/manage/.content.xml
@@ -0,0 +1,4 @@
+
+
diff --git a/ui.apps/src/main/content/jcr_root/apps/acm/feature/scripts/manage/.content.xml b/ui.apps/src/main/content/jcr_root/apps/acm/feature/scripts/manage/.content.xml
new file mode 100644
index 000000000..d3ee0e8e1
--- /dev/null
+++ b/ui.apps/src/main/content/jcr_root/apps/acm/feature/scripts/manage/.content.xml
@@ -0,0 +1,4 @@
+
+
diff --git a/ui.frontend/src/App.tsx b/ui.frontend/src/App.tsx
index 52328a289..a526ecf41 100644
--- a/ui.frontend/src/App.tsx
+++ b/ui.frontend/src/App.tsx
@@ -9,36 +9,12 @@ import Footer from './components/Footer';
import Header from './components/Header';
import router from './router';
import { InstanceRole, InstanceType } from './types/aem.ts';
-import { State } from './types/main.ts';
+import { State, StateDefault } from './types/main.ts';
import { apiRequest } from './utils/api';
import { intervalToTimeout } from './utils/spectrum.ts';
function App() {
- const [state, setState] = useState({
- spaSettings: {
- appStateInterval: 3000,
- executionPollInterval: 1400,
- scriptStatsLimit: 20,
- scriptManagementEnabled: false,
- },
- healthStatus: {
- healthy: true,
- issues: [],
- },
- mockStatus: {
- enabled: false,
- },
- instanceSettings: {
- id: 'default',
- timezoneId: 'UTC',
- role: InstanceRole.AUTHOR,
- type: InstanceType.CLOUD_CONTAINER,
- },
- permissions: {
- console: true,
- },
- });
-
+ const [state, setState] = useState(StateDefault);
const isFetching = useRef(false);
useEffect(() => {
diff --git a/ui.frontend/src/Route.tsx b/ui.frontend/src/Route.tsx
index 03afd3ccf..9e44dad46 100644
--- a/ui.frontend/src/Route.tsx
+++ b/ui.frontend/src/Route.tsx
@@ -1,18 +1,17 @@
-
import { Navigate } from 'react-router-dom';
-import { State } from './types/main';
import { useAppState } from './hooks/app';
+import { State } from './types/main';
interface RouteProps {
children: React.ReactNode;
- permission?: keyof State['permissions'];
+ featureId?: keyof State['permissions']['features'];
}
-export function Route({ children, permission }: RouteProps) {
+export function Route({ children, featureId }: RouteProps) {
const state = useAppState();
- if (permission && (!state.permissions[permission])) {
- return ;
+ if (featureId && !state.permissions.features[featureId]) {
+ return ;
}
return <>{children}>;
diff --git a/ui.frontend/src/components/CodeExecutor.tsx b/ui.frontend/src/components/CodeExecutor.tsx
index e89f6d8ee..7d4cf4c11 100644
--- a/ui.frontend/src/components/CodeExecutor.tsx
+++ b/ui.frontend/src/components/CodeExecutor.tsx
@@ -26,6 +26,8 @@ import UserInfo from './UserInfo';
const CodeExecutor = () => {
const appState = useAppState();
+ const maintenanceManage = appState.permissions.features['maintenance.manage'];
+
const navigate = useNavigate();
const [executions, setExecutions] = useState([]);
@@ -82,7 +84,9 @@ const CodeExecutor = () => {
- {executions.length === 0 ? <>Idle> : <>Busy — {executions.length} execution(s)>}
+
+ {executions.length === 0 ? <>Idle> : <>Busy — {executions.length} execution(s)>}
+
@@ -144,7 +148,7 @@ const CodeExecutor = () => {
flex="1"
aria-label="Queued Executions"
renderEmptyState={renderEmptyState}
- selectionMode="multiple"
+ selectionMode={maintenanceManage ? 'multiple' : 'none'}
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys}
marginY="size-200"
diff --git a/ui.frontend/src/components/CodeInput.tsx b/ui.frontend/src/components/CodeInput.tsx
index 11fd7a2b4..0ef7e7418 100644
--- a/ui.frontend/src/components/CodeInput.tsx
+++ b/ui.frontend/src/components/CodeInput.tsx
@@ -29,8 +29,6 @@ import { useInput } from '../hooks/form.ts';
import {
Input,
InputValue,
- stringInputDisplayToMode,
- stringInputDisplayToType,
isBoolInput,
isColorInput,
isDateTimeInput,
@@ -45,6 +43,8 @@ import {
isSelectInput,
isStringInput,
isTextInput,
+ stringInputDisplayToMode,
+ stringInputDisplayToType,
} from '../types/input.ts';
import { Dates } from '../utils/dates.ts';
import { Strings } from '../utils/strings.ts';
diff --git a/ui.frontend/src/components/CompilationStatus.tsx b/ui.frontend/src/components/CompilationStatus.tsx
index 4194f1188..c003aa3c7 100644
--- a/ui.frontend/src/components/CompilationStatus.tsx
+++ b/ui.frontend/src/components/CompilationStatus.tsx
@@ -17,7 +17,11 @@ const CompilationStatus = ({ compiling, syntaxError, compileError, onErrorClick
);
}
if (syntaxError) {
- return Compilation failed — Syntax error ;
+ return (
+
+ Compilation failed — Syntax error
+
+ );
}
if (compileError) {
@@ -32,7 +36,11 @@ const CompilationStatus = ({ compiling, syntaxError, compileError, onErrorClick
);
}
- return Compilation succeeded ;
+ return (
+
+ Compilation succeeded
+
+ );
};
export default CompilationStatus;
diff --git a/ui.frontend/src/components/DateExplained.tsx b/ui.frontend/src/components/DateExplained.tsx
index 08b57cda6..9739007b5 100644
--- a/ui.frontend/src/components/DateExplained.tsx
+++ b/ui.frontend/src/components/DateExplained.tsx
@@ -30,7 +30,9 @@ const DateExplained: React.FC = ({ value }) => {
In your local timezone ({formatter.userTimezone()}), the date and time are {formatter.dateAtUser(value)}.
- {relativeText} {formatter.dateRelative(value)}.
+
+ {relativeText} {formatter.dateRelative(value)}.
+
diff --git a/ui.frontend/src/components/ExecutionAbortButton.tsx b/ui.frontend/src/components/ExecutionAbortButton.tsx
index da371fa62..0c617461e 100644
--- a/ui.frontend/src/components/ExecutionAbortButton.tsx
+++ b/ui.frontend/src/components/ExecutionAbortButton.tsx
@@ -85,16 +85,19 @@ const ExecutionAbortButton: React.FC = ({ execution,
The abort request signals the script to stop, but the script must explicitly check for this signal by calling context.checkAborted().
- If the script doesn't check for abort, it will continue running until it completes naturally. Only if an abort timeout is configured (by default it's not), will the execution be forcefully terminated after the timeout expires.
+ If the script doesn't check for abort, it will continue running until it completes naturally. Only if an abort timeout is configured (by default it's not), will the execution be forcefully terminated
+ after the timeout expires.
- For scripts with loops or long-running operations, add context.checkAborted() at safe checkpoints (e.g., at the beginning of each loop iteration) to enable graceful termination and prevent data corruption.
+ For scripts with loops or long-running operations, add context.checkAborted() at safe checkpoints (e.g., at the beginning of each loop iteration) to enable graceful termination and prevent
+ data corruption.
Warning
- Proceed with aborting only if the requirements above are met.
+ Proceed with aborting only if the requirements above are met.
+
This action cannot be undone.
diff --git a/ui.frontend/src/components/ExecutionHistoryClearButton.tsx b/ui.frontend/src/components/ExecutionHistoryClearButton.tsx
index 5a8d334c3..0d56304ca 100644
--- a/ui.frontend/src/components/ExecutionHistoryClearButton.tsx
+++ b/ui.frontend/src/components/ExecutionHistoryClearButton.tsx
@@ -5,6 +5,7 @@ import Checkmark from '@spectrum-icons/workflow/Checkmark';
import DataRemove from '@spectrum-icons/workflow/DataRemove';
import Flashlight from '@spectrum-icons/workflow/Flashlight';
import React, { useState } from 'react';
+import { useFeatureEnabled } from '../hooks/app.ts';
import { EventType, QueueOutput } from '../types/main.ts';
import { toastRequest } from '../utils/api';
@@ -13,6 +14,8 @@ type ExecutionHistoryClearButtonProps = {
};
const ExecutionHistoryClearButton: React.FC = ({ onClear }) => {
+ const maintenanceManage = useFeatureEnabled('maintenance.manage');
+
const [dialogOpen, setDialogOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
@@ -67,7 +70,7 @@ const ExecutionHistoryClearButton: React.FC =
return (
- setDialogOpen(true)}>
+ setDialogOpen(true)} isDisabled={!maintenanceManage}>
Clear
diff --git a/ui.frontend/src/components/ExecutionsAbortButton.tsx b/ui.frontend/src/components/ExecutionsAbortButton.tsx
index 67489f5ed..3761df7ad 100644
--- a/ui.frontend/src/components/ExecutionsAbortButton.tsx
+++ b/ui.frontend/src/components/ExecutionsAbortButton.tsx
@@ -1,12 +1,13 @@
import { Button, ButtonGroup, Content, Dialog, DialogTrigger, Divider, Heading, InlineAlert, Text } from '@adobe/react-spectrum';
+import AlertIcon from '@spectrum-icons/workflow/Alert';
import Cancel from '@spectrum-icons/workflow/Cancel';
import Checkmark from '@spectrum-icons/workflow/Checkmark';
+import CheckmarkCircle from '@spectrum-icons/workflow/CheckmarkCircle';
+import CloseCircle from '@spectrum-icons/workflow/CloseCircle';
import React, { useState } from 'react';
+import { useAppState } from '../hooks/app.ts';
import { QueueOutput } from '../types/main.ts';
import { toastRequest } from '../utils/api';
-import CheckmarkCircle from '@spectrum-icons/workflow/CheckmarkCircle';
-import CloseCircle from '@spectrum-icons/workflow/CloseCircle';
-import AlertIcon from '@spectrum-icons/workflow/Alert';
type ExecutionsAbortButtonProps = {
selectedKeys: string[];
@@ -14,6 +15,9 @@ type ExecutionsAbortButtonProps = {
};
const ExecutionsAbortButton: React.FC = ({ selectedKeys, onAbort }) => {
+ const appState = useAppState();
+ const maintenanceManage = appState.permissions.features['maintenance.manage'];
+
const [abortDialogOpen, setAbortDialogOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
@@ -46,22 +50,27 @@ const ExecutionsAbortButton: React.FC = ({ selectedK
- Are you sure you want to abort the selected executions?
+
+ Are you sure you want to abort the selected executions?
+
The abort request signals scripts to stop, but scripts must explicitly check for this signal by calling context.checkAborted().
- If scripts don't check for abort, they will continue running until they complete naturally. Only if an abort timeout is configured (by default it's not), will the execution be forcefully terminated after the timeout expires.
+ If scripts don't check for abort, they will continue running until they complete naturally. Only if an abort timeout is configured (by default it's not), will the execution be forcefully terminated after
+ the timeout expires.
- For scripts with loops or long-running operations, add context.checkAborted() at safe checkpoints (e.g., at the beginning of each loop iteration) to enable graceful termination and prevent data corruption.
+ For scripts with loops or long-running operations, add context.checkAborted() at safe checkpoints (e.g., at the beginning of each loop iteration) to enable graceful termination and prevent data
+ corruption.
Warning
- Proceed with aborting only if the requirements above are met.
+ Proceed with aborting only if the requirements above are met.
+
This action cannot be undone.
@@ -81,7 +90,7 @@ const ExecutionsAbortButton: React.FC = ({ selectedK
return (
- setAbortDialogOpen(true)}>
+ setAbortDialogOpen(true)}>
Abort
diff --git a/ui.frontend/src/components/ExecutorBootButton.tsx b/ui.frontend/src/components/ExecutorBootButton.tsx
index 2177e7bd2..a0a0c0bb6 100644
--- a/ui.frontend/src/components/ExecutorBootButton.tsx
+++ b/ui.frontend/src/components/ExecutorBootButton.tsx
@@ -4,6 +4,7 @@ import Cancel from '@spectrum-icons/workflow/Cancel';
import Checkmark from '@spectrum-icons/workflow/Checkmark';
import Launch from '@spectrum-icons/workflow/Launch';
import React, { useState } from 'react';
+import { useFeatureEnabled } from '../hooks/app.ts';
import { EventType, QueueOutput } from '../types/main.ts';
import { toastRequest } from '../utils/api.ts';
@@ -12,6 +13,7 @@ type ExecutionsBootButtonProps = {
};
const ExecutorBootButton: React.FC = ({ onBoot }) => {
+ const maintenanceManage = useFeatureEnabled('maintenance.manage');
const [bootDialogOpen, setBootDialogOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
@@ -64,7 +66,7 @@ const ExecutorBootButton: React.FC = ({ onBoot }) =>
return (
- setBootDialogOpen(true)}>
+ setBootDialogOpen(true)} isDisabled={!maintenanceManage}>
Boot
diff --git a/ui.frontend/src/components/ExecutorResetButton.tsx b/ui.frontend/src/components/ExecutorResetButton.tsx
index c930af46e..14521bd77 100644
--- a/ui.frontend/src/components/ExecutorResetButton.tsx
+++ b/ui.frontend/src/components/ExecutorResetButton.tsx
@@ -4,6 +4,7 @@ import Cancel from '@spectrum-icons/workflow/Cancel';
import Checkmark from '@spectrum-icons/workflow/Checkmark';
import GearsDelete from '@spectrum-icons/workflow/GearsDelete';
import React, { useState } from 'react';
+import { useFeatureEnabled } from '../hooks/app.ts';
import { EventType, QueueOutput } from '../types/main.ts';
import { toastRequest } from '../utils/api.ts';
@@ -12,6 +13,8 @@ type ExecutionsResetButtonProps = {
};
const ExecutorResetButton: React.FC = ({ onReset }) => {
+ const maintenanceManage = useFeatureEnabled('maintenance.manage');
+
const [resetDialogOpen, setResetDialogOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
@@ -64,7 +67,7 @@ const ExecutorResetButton: React.FC = ({ onReset })
return (
- setResetDialogOpen(true)}>
+ setResetDialogOpen(true)} isDisabled={!maintenanceManage}>
Reset
diff --git a/ui.frontend/src/components/Header.tsx b/ui.frontend/src/components/Header.tsx
index ba2cceab7..78957792f 100644
--- a/ui.frontend/src/components/Header.tsx
+++ b/ui.frontend/src/components/Header.tsx
@@ -16,12 +16,14 @@ const Header = () => {
return (
-
-
-
-
-
-
+
+
+
+
+
+
+
+
@@ -29,29 +31,37 @@ const Header = () => {
-
-
-
- Scripts
-
-
-
-
-
- Snippets
-
-
-
-
-
- History
-
-
-
-
-
-
-
+
+
+
+
+ Scripts
+
+
+
+
+
+
+
+ Snippets
+
+
+
+
+
+
+
+ History
+
+
+
+
+
+
+
+
+
+
);
};
diff --git a/ui.frontend/src/components/HealthChecker.tsx b/ui.frontend/src/components/HealthChecker.tsx
index 0fabfedde..a13f39c1f 100644
--- a/ui.frontend/src/components/HealthChecker.tsx
+++ b/ui.frontend/src/components/HealthChecker.tsx
@@ -95,7 +95,9 @@ const HealthChecker = () => {
- {healthIssues.length === 0 ? <>Healthy> : <>Unhealthy — {healthIssues.length} issue(s)>}
+
+ {healthIssues.length === 0 ? <>Healthy> : <>Unhealthy — {healthIssues.length} issue(s)>}
+
diff --git a/ui.frontend/src/components/ScriptAutomaticList.tsx b/ui.frontend/src/components/ScriptAutomaticList.tsx
index b1c032565..34b3bfb79 100644
--- a/ui.frontend/src/components/ScriptAutomaticList.tsx
+++ b/ui.frontend/src/components/ScriptAutomaticList.tsx
@@ -5,7 +5,7 @@ import Magnify from '@spectrum-icons/workflow/Magnify';
import Settings from '@spectrum-icons/workflow/Settings';
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
-import { useAppState } from '../hooks/app';
+import { useAppState, useFeatureEnabled } from '../hooks/app';
import { useFormatter } from '../hooks/formatter';
import { useScripts } from '../hooks/script';
import { instanceOsgiServiceConfigUrl, InstanceOsgiServicePid, InstanceType } from '../types/aem';
@@ -22,7 +22,7 @@ import Toggle from './Toggle';
const ScriptAutomaticList: React.FC = () => {
const appState = useAppState();
- const managementEnabled = appState.spaSettings.scriptManagementEnabled;
+ const managementEnabled = useFeatureEnabled('scripts.manage');
const navigate = useNavigate();
const formatter = useFormatter();
diff --git a/ui.frontend/src/components/ScriptManualList.tsx b/ui.frontend/src/components/ScriptManualList.tsx
index fe41ca1f2..424c35604 100644
--- a/ui.frontend/src/components/ScriptManualList.tsx
+++ b/ui.frontend/src/components/ScriptManualList.tsx
@@ -4,7 +4,7 @@ import NotFound from '@spectrum-icons/illustrations/NotFound';
import Magnify from '@spectrum-icons/workflow/Magnify';
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
-import { useAppState } from '../hooks/app';
+import { useAppState, useFeatureEnabled } from '../hooks/app';
import { useFormatter } from '../hooks/formatter';
import { useScripts } from '../hooks/script';
import { isExecutionNegative } from '../types/execution';
@@ -21,7 +21,7 @@ const ScriptManualList: React.FC = () => {
const type = ScriptType.MANUAL;
const { scripts, loading, loadScripts } = useScripts(type);
const appState = useAppState();
- const managementEnabled = appState.spaSettings.scriptManagementEnabled;
+ const managementEnabled = useFeatureEnabled('scripts.manage');
const navigate = useNavigate();
const formatter = useFormatter();
diff --git a/ui.frontend/src/hooks/app.ts b/ui.frontend/src/hooks/app.ts
index 927536e7e..ab7a20181 100644
--- a/ui.frontend/src/hooks/app.ts
+++ b/ui.frontend/src/hooks/app.ts
@@ -1,6 +1,6 @@
import { useContext } from 'react';
import { AppContext } from '../AppContext';
-import { State } from '../types/main.ts';
+import { FeatureId, State } from '../types/main.ts';
export function useAppState(): State {
const state = useContext(AppContext);
@@ -9,3 +9,8 @@ export function useAppState(): State {
}
return state;
}
+
+export function useFeatureEnabled(id: FeatureId): boolean {
+ const state = useAppState();
+ return state.permissions.features[id] === true;
+}
diff --git a/ui.frontend/src/pages/ConsolePage.tsx b/ui.frontend/src/pages/ConsolePage.tsx
index c5130c63b..865b211e8 100644
--- a/ui.frontend/src/pages/ConsolePage.tsx
+++ b/ui.frontend/src/pages/ConsolePage.tsx
@@ -15,7 +15,7 @@ import ExecutionReviewOutputsButton from '../components/ExecutionReviewOutputsBu
import ExecutorStatusLight from '../components/ExecutorStatusLight.tsx';
import KeyboardShortcutsButton from '../components/KeyboardShortcutsButton';
import Toggle from '../components/Toggle';
-import { useAppState } from '../hooks/app';
+import { useAppState, useFeatureEnabled } from '../hooks/app';
import { useCompilation } from '../hooks/code';
import { useExecutionPolling } from '../hooks/execution';
import { ConsoleDefaultScriptContent, ConsoleDefaultScriptPath } from '../types/console.ts';
@@ -31,6 +31,7 @@ import { StorageKeys } from '../utils/storage';
const ConsolePage = () => {
const appState = useAppState();
+ const scriptsManageEnabled = useFeatureEnabled('scripts.manage');
const pausedExecution = !appState.healthStatus.healthy;
const [selectedTab, setSelectedTab] = useState<'code' | 'output'>('code');
@@ -138,7 +139,7 @@ const ConsolePage = () => {
-
+
diff --git a/ui.frontend/src/router.tsx b/ui.frontend/src/router.tsx
index f863d35b8..460467f3a 100644
--- a/ui.frontend/src/router.tsx
+++ b/ui.frontend/src/router.tsx
@@ -1,7 +1,6 @@
import { createHashRouter, Navigate } from 'react-router-dom';
import App from './App';
import ErrorHandler from './ErrorHandler';
-import { Route } from './Route';
import ConsolePage from './pages/ConsolePage';
import DashboardPage from './pages/DashboardPage';
import ExecutionView from './pages/ExecutionView';
@@ -10,6 +9,7 @@ import MaintenancePage from './pages/MaintenancePage';
import ScriptsPage from './pages/ScriptsPage';
import ScriptView from './pages/ScriptView';
import SnippetsPage from './pages/SnippetsPage';
+import { Route } from './Route';
const router = createHashRouter([
{
@@ -17,15 +17,71 @@ const router = createHashRouter([
element: ,
errorElement: ,
children: [
- { path: '/', element: },
- { path: '/scripts/:tab?', element: },
- { path: '/scripts/view/:scriptId', element: },
- { path: '/snippets/:tab?', element: },
- { path: '/console', element: },
- { path: '/history', element: },
- { path: '/executions', element: },
+ {
+ path: '/',
+ element: (
+
+
+
+ ),
+ },
+ {
+ path: '/scripts/:tab?',
+ element: (
+
+
+
+ ),
+ },
+ {
+ path: '/scripts/view/:scriptId',
+ element: (
+
+
+
+ ),
+ },
+ {
+ path: '/snippets/:tab?',
+ element: (
+
+
+
+ ),
+ },
+ {
+ path: '/console',
+ element: (
+
+
+
+ ),
+ },
+ {
+ path: '/history',
+ element: (
+
+
+
+ ),
+ },
+ {
+ path: '/executions',
+ element: (
+
+
+
+ ),
+ },
{ path: '/executions/view/:executionId/:tab?', element: },
- { path: '/maintenance/:tab?', element: },
+ {
+ path: '/maintenance/:tab?',
+ element: (
+
+
+
+ ),
+ },
{ path: '*', element: },
],
},
diff --git a/ui.frontend/src/types/input.ts b/ui.frontend/src/types/input.ts
index de2babb58..5a481c731 100644
--- a/ui.frontend/src/types/input.ts
+++ b/ui.frontend/src/types/input.ts
@@ -157,7 +157,7 @@ export function stringInputDisplayToType(display: string): string {
}
}
-export function stringInputDisplayToMode(display: string): "url" | "tel" | "email" | "text" | "numeric" | "decimal" {
+export function stringInputDisplayToMode(display: string): 'url' | 'tel' | 'email' | 'text' | 'numeric' | 'decimal' {
switch (display) {
case 'URL':
return 'url';
diff --git a/ui.frontend/src/types/main.ts b/ui.frontend/src/types/main.ts
index 5661cc1e9..cd69e5838 100644
--- a/ui.frontend/src/types/main.ts
+++ b/ui.frontend/src/types/main.ts
@@ -48,11 +48,43 @@ export type State = {
permissions: Permissions;
};
+export const StateDefault: State = {
+ spaSettings: {
+ appStateInterval: 3000,
+ executionPollInterval: 1400,
+ scriptStatsLimit: 20,
+ },
+ healthStatus: {
+ healthy: true,
+ issues: [],
+ },
+ mockStatus: {
+ enabled: false,
+ },
+ instanceSettings: {
+ id: 'default',
+ timezoneId: 'UTC',
+ role: InstanceRole.AUTHOR,
+ type: InstanceType.CLOUD_CONTAINER,
+ },
+ permissions: {
+ features: {
+ console: true,
+ dashboard: true,
+ history: true,
+ snippets: true,
+ scripts: true,
+ 'scripts.manage': false,
+ maintenance: false,
+ 'maintenance.manage': false,
+ },
+ },
+ };
+
export type SpaSettings = {
appStateInterval: number;
executionPollInterval: number;
scriptStatsLimit: number;
- scriptManagementEnabled: boolean;
};
export type InstanceSettings = {
@@ -67,9 +99,11 @@ export type MockStatus = {
};
export type Permissions = {
- console: boolean;
+ features: Record;
};
+export type FeatureId = 'console' | 'dashboard' | 'history' | 'snippets' | 'scripts' | 'scripts.manage' | 'maintenance' | 'maintenance.manage';
+
export enum ExecutionFormat {
SUMMARY = 'SUMMARY',
FULL = 'FULL',
From 64c6d0d9b8db34410d37116ff94b9cd4a923b1fe Mon Sep 17 00:00:00 2001
From: Krystian Panek
Date: Fri, 14 Nov 2025 13:01:29 +0100
Subject: [PATCH 11/18] Script and console execute feature
---
.../vml/es/acm/core/servlet/EventServlet.java | 2 +-
.../es/acm/core/servlet/ScriptServlet.java | 2 +-
.../vml/es/acm/core/state/Permissions.java | 2 +
.../acm/feature/console/execute/.content.xml | 4 ++
.../acm/feature/scripts/execute/.content.xml | 4 ++
ui.frontend/src/App.tsx | 1 -
ui.frontend/src/pages/ConsolePage.tsx | 5 +-
ui.frontend/src/pages/ScriptView.tsx | 21 +++---
ui.frontend/src/types/main.ts | 64 ++++++++++---------
9 files changed, 56 insertions(+), 49 deletions(-)
create mode 100644 ui.apps/src/main/content/jcr_root/apps/acm/feature/console/execute/.content.xml
create mode 100644 ui.apps/src/main/content/jcr_root/apps/acm/feature/scripts/execute/.content.xml
diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/EventServlet.java b/core/src/main/java/dev/vml/es/acm/core/servlet/EventServlet.java
index e09d5f93a..8bcf8f130 100644
--- a/core/src/main/java/dev/vml/es/acm/core/servlet/EventServlet.java
+++ b/core/src/main/java/dev/vml/es/acm/core/servlet/EventServlet.java
@@ -41,7 +41,7 @@ public class EventServlet extends SlingAllMethodsServlet {
@Override
protected void doPost(SlingHttpServletRequest request, SlingHttpServletResponse response) throws IOException {
if (!Permissions.check(Permissions.Feature.MAINTENANCE_MANAGE, request.getResourceResolver())) {
- respondJson(response, forbidden("Event cannot be dispatched due to insufficient permissions!"));
+ respondJson(response, forbidden("Event cannot be dispatched as maintenance manage feature is not permitted!"));
return;
}
diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/ScriptServlet.java b/core/src/main/java/dev/vml/es/acm/core/servlet/ScriptServlet.java
index e5a4dd4b5..ffdad4c4d 100644
--- a/core/src/main/java/dev/vml/es/acm/core/servlet/ScriptServlet.java
+++ b/core/src/main/java/dev/vml/es/acm/core/servlet/ScriptServlet.java
@@ -128,7 +128,7 @@ protected void doGet(SlingHttpServletRequest request, SlingHttpServletResponse r
@Override
protected void doPost(SlingHttpServletRequest request, SlingHttpServletResponse response) throws IOException {
if (!Permissions.check(Permissions.Feature.SCRIPTS_MANAGE, request.getResourceResolver())) {
- respondJson(response, error("Script management is disabled!"));
+ respondJson(response, forbidden("Script management feature is not permitted!"));
return;
}
diff --git a/core/src/main/java/dev/vml/es/acm/core/state/Permissions.java b/core/src/main/java/dev/vml/es/acm/core/state/Permissions.java
index 9c067d707..7595b5725 100644
--- a/core/src/main/java/dev/vml/es/acm/core/state/Permissions.java
+++ b/core/src/main/java/dev/vml/es/acm/core/state/Permissions.java
@@ -16,8 +16,10 @@ public class Permissions implements Serializable {
public enum Feature {
DASHBOARD,
CONSOLE,
+ CONSOLE_EXECUTE,
HISTORY,
SCRIPTS,
+ SCRIPTS_EXECUTE,
SCRIPTS_MANAGE,
SNIPPETS,
MAINTENANCE,
diff --git a/ui.apps/src/main/content/jcr_root/apps/acm/feature/console/execute/.content.xml b/ui.apps/src/main/content/jcr_root/apps/acm/feature/console/execute/.content.xml
new file mode 100644
index 000000000..d3ee0e8e1
--- /dev/null
+++ b/ui.apps/src/main/content/jcr_root/apps/acm/feature/console/execute/.content.xml
@@ -0,0 +1,4 @@
+
+
diff --git a/ui.apps/src/main/content/jcr_root/apps/acm/feature/scripts/execute/.content.xml b/ui.apps/src/main/content/jcr_root/apps/acm/feature/scripts/execute/.content.xml
new file mode 100644
index 000000000..d3ee0e8e1
--- /dev/null
+++ b/ui.apps/src/main/content/jcr_root/apps/acm/feature/scripts/execute/.content.xml
@@ -0,0 +1,4 @@
+
+
diff --git a/ui.frontend/src/App.tsx b/ui.frontend/src/App.tsx
index a526ecf41..5d4a881f3 100644
--- a/ui.frontend/src/App.tsx
+++ b/ui.frontend/src/App.tsx
@@ -8,7 +8,6 @@ import { AppContext } from './AppContext';
import Footer from './components/Footer';
import Header from './components/Header';
import router from './router';
-import { InstanceRole, InstanceType } from './types/aem.ts';
import { State, StateDefault } from './types/main.ts';
import { apiRequest } from './utils/api';
import { intervalToTimeout } from './utils/spectrum.ts';
diff --git a/ui.frontend/src/pages/ConsolePage.tsx b/ui.frontend/src/pages/ConsolePage.tsx
index 865b211e8..4b5ccc63a 100644
--- a/ui.frontend/src/pages/ConsolePage.tsx
+++ b/ui.frontend/src/pages/ConsolePage.tsx
@@ -31,8 +31,9 @@ import { StorageKeys } from '../utils/storage';
const ConsolePage = () => {
const appState = useAppState();
- const scriptsManageEnabled = useFeatureEnabled('scripts.manage');
const pausedExecution = !appState.healthStatus.healthy;
+ const executeEnabled = useFeatureEnabled('console.execute');
+ const scriptsManageEnabled = useFeatureEnabled('scripts.manage');
const [selectedTab, setSelectedTab] = useState<'code' | 'output'>('code');
const [code, setCode] = useState(() => localStorage.getItem(StorageKeys.EDITOR_CODE) || undefined);
@@ -138,7 +139,7 @@ const ConsolePage = () => {
-
+
diff --git a/ui.frontend/src/pages/ScriptView.tsx b/ui.frontend/src/pages/ScriptView.tsx
index 1bd0a3662..ba2dc0442 100644
--- a/ui.frontend/src/pages/ScriptView.tsx
+++ b/ui.frontend/src/pages/ScriptView.tsx
@@ -9,19 +9,20 @@ import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import CodeEditor from '../components/CodeEditor';
import CodeExecuteButton from '../components/CodeExecuteButton';
+import { useFeatureEnabled } from '../hooks/app.ts';
import { NavigationSearchParams, useNavigationTab } from '../hooks/navigation';
import { InputValues } from '../types/input.ts';
import { Description, ExecutionQueryParams, QueueOutput, ScriptOutput } from '../types/main.ts';
import { Script, ScriptType } from '../types/script.ts';
import { toastRequest } from '../utils/api';
+import { ToastTimeoutQuick } from '../utils/spectrum.ts';
import { Urls } from '../utils/url.ts';
-const toastTimeout = 3000;
-
const ScriptView = () => {
const [script, setScript] = useState