diff --git a/.gitignore b/.gitignore index 26f9fa8f8..9ef3c4914 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ ### Project-specific -ui.apps/src/main/content/jcr_root/apps/acm/spa +ui.apps/src/main/content/jcr_root/apps/acm/gui/spa/build/ /var # Created by https://www.gitignore.io/api/eclipse,java,maven 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/README.md b/README.md index 26938b4e1..7d1ae6e1c 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,10 @@ It works seamlessly across AEM on-premise, AMS, and AEMaaCS environments. - [Permissions Management](#permissions-management) - [Data Imports \& Exports](#data-imports--exports) - [Installation](#installation) + - [Package Installation](#package-installation) + - [Tools Access Configuration](#tools-access-configuration) + - [Feature Permissions](#feature-permissions) + - [API Permissions](#api-permissions) - [Compatibility](#compatibility) - [Documentation](#documentation) - [Usage](#usage) @@ -107,6 +111,8 @@ By simplifying data import implementation, ACM allows developers to focus more o ## Installation +### Package Installation + The ready-to-install AEM packages are available on: - [GitHub releases](https://github.com/wttech/acm/releases). @@ -155,27 +161,62 @@ Adjust file 'all/pom.xml': Repeat the same for [ui.content.example](https://central.sonatype.com/artifact/dev.vml.es/acm.ui.content.example) package if you want to install demonstrative ACM scripts to get you started quickly. -3. Consider refining the ACL settings - - The default settings are defined in the [repo init OSGi config](https://github.com/wttech/acm/blob/main/ui.config/src/main/content/jcr_root/apps/acm-config/osgiconfig/config/org.apache.sling.jcr.repoinit.RepositoryInitializer~acmcore.config), which effectively restrict access to the tool and script execution to administrators only - a recommended practice for production environments. - If you require further customization, you can create your own repo init OSGi config to override or extend the default configuration. - - For example: - ```ini - service.ranking=I"100" - scripts=[" - set ACL for everyone - deny jcr:read on /apps/acm - deny jcr:read on /apps/cq/core/content/nav/tools/acm - end - - create group acm-users - set ACL for acm-users - allow jcr:read on /apps/acm - allow jcr:read on /apps/cq/core/content/nav/tools/acm - end - "] - ``` +### Tools Access Configuration + +The default settings are defined in the [repo init OSGi config](https://github.com/wttech/acm/blob/main/ui.config/src/main/content/jcr_root/apps/acm-config/osgiconfig/config/org.apache.sling.jcr.repoinit.RepositoryInitializer~acmcore.config), which effectively restrict access to the tool and script execution to administrators only - a recommended practice for production environments. + +If you require further customization, you can create your own repo init OSGi config to override or extend the default configuration. + +#### Feature Permissions + +ACM supports fine-grained permission control through individual features. This allows you to grant specific capabilities to different user groups without providing full access to ACM tool. For a complete list of available features, see the [ACM features directory](https://github.com/wttech/acm/tree/main/ui.apps/src/main/content/jcr_root/apps/acm/feature). + +**Example: Create groups for full and limited access:** + +```ini +service.ranking=I"100" +scripts=[" + set ACL for everyone + deny jcr:read on /apps/cq/core/content/nav/tools/acm + deny jcr:read on /apps/acm + end + + create group acm-admins + set ACL for acm-admins + allow jcr:read on /apps/cq/core/content/nav/tools/acm + allow jcr:read on /apps/acm + end + + create group acm-script-users + set ACL for acm-script-users + allow jcr:read on /apps/cq/core/content/nav/tools/acm + allow jcr:read on /apps/acm/gui + allow jcr:read on /apps/acm/api + + allow jcr:read on /apps/acm/feature/script/list + allow jcr:read on /apps/acm/feature/script/view + allow jcr:read on /apps/acm/feature/execution/view + + allow jcr:read on /conf/acm/settings/script + end +"] +``` + +Later on when AEM is running, just assign users to the created groups (`acm-admins` or `acm-script-users`) to grant them the corresponding access. + +#### API Permissions + +Access to ACM's REST API endpoints is controlled through nodes under `/apps/acm/api`. For a complete list of available endpoints, see the [ACM API directory](https://github.com/wttech/acm/tree/main/ui.apps/src/main/content/jcr_root/apps/acm/api). + +**Important:** Code execution requires authorization at three levels: API endpoint, feature, and e.g. script path. Example: + +```ini +set ACL for acm-automation-user + allow jcr:read on /apps/acm/api + allow jcr:read on /apps/acm/feature + allow jcr:read on /conf/acm/settings/script +end +``` ## Compatibility 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/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/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 2fa979ce0..6908364e2 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,8 @@ 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.ScriptRepository; +import dev.vml.es.acm.core.state.Permissions; import dev.vml.es.acm.core.util.DateUtils; import dev.vml.es.acm.core.util.ResolverUtils; import dev.vml.es.acm.core.util.StringUtil; @@ -130,6 +132,32 @@ 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) { + return isFeatureEnabled(executable, resolver) && isExecutableAvailable(executable, resolver); + } + + private boolean isFeatureEnabled(Executable executable, ResourceResolver resolver) { + if (Executable.CONSOLE_ID.equals(executable.getId())) { + return Permissions.check(Permissions.Feature.CONSOLE_EXECUTE, resolver); + } + return Permissions.check(Permissions.Feature.SCRIPT_EXECUTE, resolver); + } + + private boolean isExecutableAvailable(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, @@ -223,7 +251,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(); diff --git a/core/src/main/java/dev/vml/es/acm/core/gui/Spa.java b/core/src/main/java/dev/vml/es/acm/core/gui/Spa.java index ee3480614..201a30fb9 100644 --- a/core/src/main/java/dev/vml/es/acm/core/gui/Spa.java +++ b/core/src/main/java/dev/vml/es/acm/core/gui/Spa.java @@ -1,5 +1,6 @@ package dev.vml.es.acm.core.gui; +import dev.vml.es.acm.core.AcmConstants; import java.util.Optional; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -15,7 +16,7 @@ public class Spa { private static final Logger LOG = LoggerFactory.getLogger(Spa.class); - private static final String ASSETS_ROOT = "/apps/acm/spa/assets"; + private static final String ASSETS_ROOT = AcmConstants.APPS_ROOT + "/gui/spa/build/assets"; @Self private SlingHttpServletRequest request; 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..dbc604ef8 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,6 +5,7 @@ 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.util.Collections; import javax.servlet.Servlet; @@ -37,6 +38,12 @@ 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 as maintenance manage feature is not permitted!")); + 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/ExecuteCodeServlet.java b/core/src/main/java/dev/vml/es/acm/core/servlet/ExecuteCodeServlet.java index deb88bb35..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,6 +50,10 @@ protected void doPost(SlingHttpServletRequest request, SlingHttpServletResponse } Code code = input.getCode(); + if (!executor.authorize(code, request.getResourceResolver())) { + 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..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,6 +55,10 @@ protected void doPost(SlingHttpServletRequest request, SlingHttpServletResponse } Code code = input.getCode(); + if (!executor.authorize(code, request.getResourceResolver())) { + 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/servlet/ScriptServlet.java b/core/src/main/java/dev/vml/es/acm/core/servlet/ScriptServlet.java index 917912cc6..4d8cee431 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,8 +127,8 @@ protected void doGet(SlingHttpServletRequest request, SlingHttpServletResponse r @Override protected void doPost(SlingHttpServletRequest request, SlingHttpServletResponse response) throws IOException { - if (!spaSettings.isScriptManagementEnabled()) { - respondJson(response, error("Script management is disabled!")); + if (!Permissions.check(Permissions.Feature.SCRIPT_MANAGE, request.getResourceResolver())) { + respondJson(response, forbidden("Script management feature is not permitted!")); 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..9b361eb4c 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,12 +5,14 @@ import static dev.vml.es.acm.core.util.ServletUtils.respondJson; 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 +53,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(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..4cef858b9 --- /dev/null +++ b/core/src/main/java/dev/vml/es/acm/core/state/Permissions.java @@ -0,0 +1,61 @@ +package dev.vml.es.acm.core.state; + +import dev.vml.es.acm.core.AcmConstants; +import dev.vml.es.acm.core.repo.Repo; +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; + +public class Permissions implements Serializable { + + public enum Feature { + CONSOLE_VIEW, + CONSOLE_EXECUTE, + EXECUTION_LIST, + EXECUTION_VIEW, + MAINTENANCE_VIEW, + MAINTENANCE_MANAGE, + SCRIPT_LIST, + SCRIPT_VIEW, + SCRIPT_EXECUTE, + SCRIPT_MANAGE, + SNIPPET_LIST, + } + + 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("_", "."); + } + + private static String featureNodePath(Feature f) { + return f.name().toLowerCase().replace("_", "/"); + } + + public Map getFeatures() { + return features; + } +} 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/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/.content.xml b/ui.apps/src/main/content/jcr_root/apps/acm/.content.xml index b3f1728ff..7148f72ca 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,6 @@ jcr:mixinTypes="[rep:AccessControllable]" jcr:primaryType="sling:Folder"> + - 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"/> 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.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/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/console/view/.content.xml b/ui.apps/src/main/content/jcr_root/apps/acm/feature/console/view/.content.xml new file mode 100644 index 000000000..d3ee0e8e1 --- /dev/null +++ b/ui.apps/src/main/content/jcr_root/apps/acm/feature/console/view/.content.xml @@ -0,0 +1,4 @@ + + diff --git a/ui.apps/src/main/content/jcr_root/apps/acm/feature/execution/.content.xml b/ui.apps/src/main/content/jcr_root/apps/acm/feature/execution/.content.xml new file mode 100644 index 000000000..d3ee0e8e1 --- /dev/null +++ b/ui.apps/src/main/content/jcr_root/apps/acm/feature/execution/.content.xml @@ -0,0 +1,4 @@ + + diff --git a/ui.apps/src/main/content/jcr_root/apps/acm/feature/execution/list/.content.xml b/ui.apps/src/main/content/jcr_root/apps/acm/feature/execution/list/.content.xml new file mode 100644 index 000000000..d3ee0e8e1 --- /dev/null +++ b/ui.apps/src/main/content/jcr_root/apps/acm/feature/execution/list/.content.xml @@ -0,0 +1,4 @@ + + diff --git a/ui.apps/src/main/content/jcr_root/apps/acm/feature/execution/view/.content.xml b/ui.apps/src/main/content/jcr_root/apps/acm/feature/execution/view/.content.xml new file mode 100644 index 000000000..d3ee0e8e1 --- /dev/null +++ b/ui.apps/src/main/content/jcr_root/apps/acm/feature/execution/view/.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/maintenance/view/.content.xml b/ui.apps/src/main/content/jcr_root/apps/acm/feature/maintenance/view/.content.xml new file mode 100644 index 000000000..d3ee0e8e1 --- /dev/null +++ b/ui.apps/src/main/content/jcr_root/apps/acm/feature/maintenance/view/.content.xml @@ -0,0 +1,4 @@ + + diff --git a/ui.apps/src/main/content/jcr_root/apps/acm/feature/script/.content.xml b/ui.apps/src/main/content/jcr_root/apps/acm/feature/script/.content.xml new file mode 100644 index 000000000..d3ee0e8e1 --- /dev/null +++ b/ui.apps/src/main/content/jcr_root/apps/acm/feature/script/.content.xml @@ -0,0 +1,4 @@ + + diff --git a/ui.apps/src/main/content/jcr_root/apps/acm/feature/script/execute/.content.xml b/ui.apps/src/main/content/jcr_root/apps/acm/feature/script/execute/.content.xml new file mode 100644 index 000000000..d3ee0e8e1 --- /dev/null +++ b/ui.apps/src/main/content/jcr_root/apps/acm/feature/script/execute/.content.xml @@ -0,0 +1,4 @@ + + diff --git a/ui.apps/src/main/content/jcr_root/apps/acm/feature/script/list/.content.xml b/ui.apps/src/main/content/jcr_root/apps/acm/feature/script/list/.content.xml new file mode 100644 index 000000000..d3ee0e8e1 --- /dev/null +++ b/ui.apps/src/main/content/jcr_root/apps/acm/feature/script/list/.content.xml @@ -0,0 +1,4 @@ + + diff --git a/ui.apps/src/main/content/jcr_root/apps/acm/feature/script/manage/.content.xml b/ui.apps/src/main/content/jcr_root/apps/acm/feature/script/manage/.content.xml new file mode 100644 index 000000000..d3ee0e8e1 --- /dev/null +++ b/ui.apps/src/main/content/jcr_root/apps/acm/feature/script/manage/.content.xml @@ -0,0 +1,4 @@ + + diff --git a/ui.apps/src/main/content/jcr_root/apps/acm/feature/script/view/.content.xml b/ui.apps/src/main/content/jcr_root/apps/acm/feature/script/view/.content.xml new file mode 100644 index 000000000..d3ee0e8e1 --- /dev/null +++ b/ui.apps/src/main/content/jcr_root/apps/acm/feature/script/view/.content.xml @@ -0,0 +1,4 @@ + + diff --git a/ui.apps/src/main/content/jcr_root/apps/acm/feature/snippet/.content.xml b/ui.apps/src/main/content/jcr_root/apps/acm/feature/snippet/.content.xml new file mode 100644 index 000000000..d3ee0e8e1 --- /dev/null +++ b/ui.apps/src/main/content/jcr_root/apps/acm/feature/snippet/.content.xml @@ -0,0 +1,4 @@ + + diff --git a/ui.apps/src/main/content/jcr_root/apps/acm/feature/snippet/list/.content.xml b/ui.apps/src/main/content/jcr_root/apps/acm/feature/snippet/list/.content.xml new file mode 100644 index 000000000..d3ee0e8e1 --- /dev/null +++ b/ui.apps/src/main/content/jcr_root/apps/acm/feature/snippet/list/.content.xml @@ -0,0 +1,4 @@ + + 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.. + 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. diff --git a/ui.frontend/src/App.tsx b/ui.frontend/src/App.tsx index 5fe3d9262..5d4a881f3 100644 --- a/ui.frontend/src/App.tsx +++ b/ui.frontend/src/App.tsx @@ -8,34 +8,12 @@ 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 } 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, - }, - }); - + const [state, setState] = useState(StateDefault); const isFetching = useRef(false); useEffect(() => { diff --git a/ui.frontend/src/Route.tsx b/ui.frontend/src/Route.tsx new file mode 100644 index 000000000..e52bfd29f --- /dev/null +++ b/ui.frontend/src/Route.tsx @@ -0,0 +1,18 @@ +import { Navigate } from 'react-router-dom'; +import { useAppState } from './hooks/app'; +import { FeatureId } from './types/main'; + +interface RouteProps { + children: React.ReactNode; + featureId?: FeatureId; +} + +export function Route({ children, featureId }: RouteProps) { + const state = useAppState(); + + 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 ( - 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/Footer.tsx b/ui.frontend/src/components/Footer.tsx index d4791c2ae..4694ca6b5 100644 --- a/ui.frontend/src/components/Footer.tsx +++ b/ui.frontend/src/components/Footer.tsx @@ -8,7 +8,7 @@ const Footer = () => { - VML Logo + VML Logo @@ -19,7 +19,7 @@ const Footer = () => { - GitHub + GitHub View 'Content Manager' on GitHub diff --git a/ui.frontend/src/components/Header.tsx b/ui.frontend/src/components/Header.tsx index 27186724e..9d83095fd 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,35 +21,45 @@ const Header = () => { - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + ); }; 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..991121ca7 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('script.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..09764b46a 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('script.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/index.ts b/ui.frontend/src/index.ts index aa62cd4a9..86ae06e7a 100644 --- a/ui.frontend/src/index.ts +++ b/ui.frontend/src/index.ts @@ -28,6 +28,6 @@ axios.interceptors.request.use( // Initialize Monaco Editor to be using embedded resources (to avoid CORS/CSP issues) monacoLoader.config({ paths: { - vs: isProduction() ? `${window.origin}/apps/acm/spa/js/monaco-editor/vs` : `http://localhost:${devServerPort}/acm/js/monaco-editor/vs`, + vs: isProduction() ? `${window.origin}/apps/acm/gui/spa/build/js/monaco-editor/vs` : `http://localhost:${devServerPort}/acm/js/monaco-editor/vs`, }, }); diff --git a/ui.frontend/src/pages/ConsolePage.tsx b/ui.frontend/src/pages/ConsolePage.tsx index c5130c63b..83044dd6a 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'; @@ -32,6 +32,8 @@ import { StorageKeys } from '../utils/storage'; const ConsolePage = () => { const appState = useAppState(); const pausedExecution = !appState.healthStatus.healthy; + const executeEnabled = useFeatureEnabled('console.execute'); + const scriptsManageEnabled = useFeatureEnabled('script.manage'); const [selectedTab, setSelectedTab] = useState<'code' | 'output'>('code'); const [code, setCode] = useState(() => localStorage.getItem(StorageKeys.EDITOR_CODE) || undefined); @@ -137,8 +139,8 @@ const ConsolePage = () => { - - + + diff --git a/ui.frontend/src/pages/ScriptView.tsx b/ui.frontend/src/pages/ScriptView.tsx index 1bd0a3662..bb78f0092 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