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 = () => { - - - + + + + + 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 ( - 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 ( - 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 ( - 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 ( - - - - + + + + + + - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + ); }; 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