diff --git a/core/src/main/java/org/keycloak/representations/idm/AuthenticationExecutionInfoRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/AuthenticationExecutionInfoRepresentation.java index 7ff1fa1364..49c9f53abe 100755 --- a/core/src/main/java/org/keycloak/representations/idm/AuthenticationExecutionInfoRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/idm/AuthenticationExecutionInfoRepresentation.java @@ -30,6 +30,7 @@ public class AuthenticationExecutionInfoRepresentation implements Serializable { protected String requirement; protected String displayName; protected String alias; + protected String description; protected List requirementChoices; protected Boolean configurable; protected Boolean authenticationFlow; @@ -63,6 +64,14 @@ public class AuthenticationExecutionInfoRepresentation implements Serializable { this.alias = alias; } + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + public String getRequirement() { return requirement; } diff --git a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/AuthenticationManagementResource.java b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/AuthenticationManagementResource.java index bd89d78c9a..b549f49050 100644 --- a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/AuthenticationManagementResource.java +++ b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/AuthenticationManagementResource.java @@ -88,6 +88,11 @@ public interface AuthenticationManagementResource { @Consumes(MediaType.APPLICATION_JSON) Response copy(@PathParam("flowAlias") String flowAlias, Map data); + @Path("/flows/{id}") + @PUT + @Consumes(MediaType.APPLICATION_JSON) + void updateFlow(@PathParam("id") String id, AuthenticationFlowRepresentation flow); + @Path("/flows/{flowAlias}/executions/flow") @POST @Consumes(MediaType.APPLICATION_JSON) diff --git a/services/src/main/java/org/keycloak/services/resources/admin/AuthenticationManagementResource.java b/services/src/main/java/org/keycloak/services/resources/admin/AuthenticationManagementResource.java index 2d73fd4d0d..a9551f8dab 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/AuthenticationManagementResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/AuthenticationManagementResource.java @@ -255,6 +255,7 @@ public class AuthenticationManagementResource { @PUT @NoCache @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) public Response updateFlow(@PathParam("id") String id, AuthenticationFlowRepresentation flow) { auth.realm().requireManageRealm(); @@ -264,10 +265,32 @@ public class AuthenticationManagementResource { return ErrorResponse.exists("Failed to update flow with empty alias name"); } + //check if updating a correct flow + AuthenticationFlowModel checkFlow = realm.getAuthenticationFlowById(id); + if (checkFlow == null) { + session.getTransactionManager().setRollbackOnly(); + throw new NotFoundException("Illegal execution"); + } + + //if a different flow with the same name does already exist, throw an exception + if (realm.getFlowByAlias(flow.getAlias()) != null && !checkFlow.getAlias().equals(flow.getAlias())) { + return ErrorResponse.exists("Flow alias name already exists"); + } + + //if the name changed + if (!checkFlow.getAlias().equals(flow.getAlias())) { + checkFlow.setAlias(flow.getAlias()); + } + + //check if the description changed + if (!checkFlow.getDescription().equals(flow.getDescription())) { + checkFlow.setDescription(flow.getDescription()); + } + + //update the flow flow.setId(existingFlow.getId()); realm.updateAuthenticationFlow(RepresentationToModel.toModel(flow)); adminEvent.operation(OperationType.UPDATE).resourcePath(session.getContext().getUri()).representation(flow).success(); - return Response.accepted(flow).build(); } @@ -533,6 +556,7 @@ public class AuthenticationManagementResource { rep.getRequirementChoices().add(AuthenticationExecutionModel.Requirement.DISABLED.name()); } rep.setDisplayName(flowRef.getAlias()); + rep.setDescription(flowRef.getDescription()); rep.setConfigurable(false); rep.setId(execution.getId()); rep.setAuthenticationFlow(execution.isAuthenticatorFlow()); @@ -571,16 +595,16 @@ public class AuthenticationManagementResource { } /** - * Update authentication executions of a flow - * + * Update authentication executions of a Flow * @param flowAlias Flow alias - * @param rep + * @param rep AuthenticationExecutionInfoRepresentation */ @Path("/flows/{flowAlias}/executions") @PUT @NoCache + @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) - public void updateExecutions(@PathParam("flowAlias") String flowAlias, AuthenticationExecutionInfoRepresentation rep) { + public Response updateExecutions(@PathParam("flowAlias") String flowAlias, AuthenticationExecutionInfoRepresentation rep) { auth.realm().requireManageRealm(); AuthenticationFlowModel flow = realm.getFlowByAlias(flowAlias); @@ -599,7 +623,38 @@ public class AuthenticationManagementResource { model.setRequirement(AuthenticationExecutionModel.Requirement.valueOf(rep.getRequirement())); realm.updateAuthenticatorExecution(model); adminEvent.operation(OperationType.UPDATE).resource(ResourceType.AUTH_EXECUTION).resourcePath(session.getContext().getUri()).representation(rep).success(); + return Response.accepted(flow).build(); } + + //executions can't have name and description updated + if (rep.getAuthenticationFlow() == null) { return Response.accepted(flow).build();} + + //check if updating a correct flow + AuthenticationFlowModel checkFlow = realm.getAuthenticationFlowById(rep.getFlowId()); + if (checkFlow == null) { + session.getTransactionManager().setRollbackOnly(); + throw new NotFoundException("Illegal execution"); + } + + //if a different flow with the same name does already exist, throw an exception + if (realm.getFlowByAlias(rep.getDisplayName()) != null && !checkFlow.getAlias().equals(rep.getDisplayName())) { + return ErrorResponse.exists("Flow alias name already exists"); + } + + //if the name changed + if (!checkFlow.getAlias().equals(rep.getDisplayName())) { + checkFlow.setAlias(rep.getDisplayName()); + } + + //check if the description changed + if (!checkFlow.getDescription().equals(rep.getDescription())) { + checkFlow.setDescription(rep.getDescription()); + } + + //update the flow + realm.updateAuthenticationFlow(checkFlow); + adminEvent.operation(OperationType.UPDATE).resource(ResourceType.AUTH_EXECUTION).resourcePath(session.getContext().getUri()).representation(rep).success(); + return Response.accepted(flow).build(); } /** diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/ExecutionTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/ExecutionTest.java index 5401257b87..0e08b30eca 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/ExecutionTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/ExecutionTest.java @@ -32,6 +32,8 @@ import org.keycloak.representations.idm.AuthenticationExecutionRepresentation; import org.keycloak.representations.idm.AuthenticationFlowRepresentation; import org.keycloak.representations.idm.AuthenticatorConfigRepresentation; import org.keycloak.testsuite.admin.ApiUtil; +import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude; +import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer; import org.keycloak.testsuite.arquillian.annotation.EnableFeature; import org.keycloak.testsuite.util.AdminEventPaths; import org.keycloak.testsuite.util.AssertAdminEvents; @@ -44,8 +46,6 @@ import java.util.List; import java.util.Map; import static org.hamcrest.Matchers.hasItems; -import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude; -import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer; /** * @author Marko Strukelj @@ -286,9 +286,9 @@ public class ExecutionTest extends AbstractAuthenticationTest { } // Update execution with not-existent ID - SHOULD FAIL + AuthenticationExecutionInfoRepresentation executionRep2 = new AuthenticationExecutionInfoRepresentation(); + executionRep2.setId("not-existent"); try { - AuthenticationExecutionInfoRepresentation executionRep2 = new AuthenticationExecutionInfoRepresentation(); - executionRep2.setId("not-existent"); authMgmtResource.updateExecutions("new-client-flow", executionRep2); Assert.fail("Not expected to update not-existent execution"); } catch (NotFoundException nfe) { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/FlowTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/FlowTest.java index 4bef0cf760..a2ae16c2a3 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/FlowTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/FlowTest.java @@ -19,25 +19,25 @@ package org.keycloak.testsuite.admin.authentication; import org.junit.Assert; import org.junit.Test; - import org.keycloak.events.admin.OperationType; import org.keycloak.events.admin.ResourceType; import org.keycloak.representations.idm.AuthenticationExecutionExportRepresentation; +import org.keycloak.representations.idm.AuthenticationExecutionInfoRepresentation; import org.keycloak.representations.idm.AuthenticationFlowRepresentation; -import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.testsuite.util.AdminEventPaths; -import org.keycloak.testsuite.util.AssertAdminEvents; import javax.ws.rs.BadRequestException; +import javax.ws.rs.ClientErrorException; import javax.ws.rs.NotFoundException; import javax.ws.rs.core.Response; +import javax.ws.rs.core.Response.Status; import java.util.HashMap; import java.util.List; import java.util.Map; -import javax.ws.rs.core.Response.Status; -import static org.hamcrest.Matchers.*; -import static org.keycloak.testsuite.util.Matchers.*; +import static org.hamcrest.Matchers.containsString; +import static org.keycloak.testsuite.util.Matchers.body; +import static org.keycloak.testsuite.util.Matchers.statusCodeIs; /** * @author Marko Strukelj @@ -253,6 +253,7 @@ public class FlowTest extends AbstractAuthenticationTest { copyOfBrowser = authMgmtResource.getFlow(copyOfBrowser.getId()); Assert.assertNotNull(copyOfBrowser); compareFlows(browser, copyOfBrowser); + authMgmtResource.deleteFlow(copyOfBrowser.getId()); } @Test @@ -275,4 +276,138 @@ public class FlowTest extends AbstractAuthenticationTest { assertAdminEvents.assertEvent(REALM_NAME, OperationType.CREATE, AdminEventPaths.authAddExecutionFlowPath("parent"), params, ResourceType.AUTH_EXECUTION_FLOW); } + @Test + //KEYCLOAK-12741 + //test editing of authentication flows + public void editFlowTest() { + List flows; + + //copy an existing one first + HashMap params = new HashMap<>(); + params.put("newName", "Copy of browser"); + Response response = authMgmtResource.copy("browser", params); + assertAdminEvents.assertEvent(REALM_NAME, OperationType.CREATE, AdminEventPaths.authCopyFlowPath("browser"), params, ResourceType.AUTH_FLOW); + try { + Assert.assertEquals("Copy flow", 201, response.getStatus()); + } finally { + response.close(); + } + + //load the newly copied flow + flows = authMgmtResource.getFlows(); + AuthenticationFlowRepresentation testFlow = findFlowByAlias("Copy of browser", flows); + //Set a new unique name. Should succeed + testFlow.setAlias("Copy of browser2"); + authMgmtResource.updateFlow(testFlow.getId(), testFlow); + assertAdminEvents.assertEvent(REALM_NAME, OperationType.UPDATE, AdminEventPaths.authEditFlowPath(testFlow.getId()), ResourceType.AUTH_FLOW); + flows = authMgmtResource.getFlows(); + Assert.assertEquals("Copy of browser2", findFlowByAlias("Copy of browser2", flows).getAlias()); + + //Create new flow and edit the old one to have the new ones name + AuthenticationFlowRepresentation newFlow = newFlow("New Flow", "Test description", "basic-flow", true, false); + createFlow(newFlow); + // check that new flow is returned in a children list + flows = authMgmtResource.getFlows(); + AuthenticationFlowRepresentation found = findFlowByAlias("New Flow", flows); + + Assert.assertNotNull("created flow visible in parent", found); + compareFlows(newFlow, found); + + //try to update old flow with alias that already exists + testFlow.setAlias("New Flow"); + try { + authMgmtResource.updateFlow(found.getId(), testFlow); + } catch (ClientErrorException exception){ + //expoected + } + flows = authMgmtResource.getFlows(); + + //name should be the same for the old Flow + Assert.assertEquals("Copy of browser2", findFlowByAlias("Copy of browser2", flows).getAlias()); + + //Only update the description + found.setDescription("New description"); + authMgmtResource.updateFlow(found.getId(), found); + flows = authMgmtResource.getFlows(); + + Assert.assertEquals("New description", findFlowByAlias("New Flow", flows).getDescription()); + assertAdminEvents.assertEvent(REALM_NAME, OperationType.UPDATE, AdminEventPaths.authEditFlowPath(found.getId()), ResourceType.AUTH_FLOW); + + //Update name and description + found.setAlias("New Flow2"); + found.setDescription("New description2"); + authMgmtResource.updateFlow(found.getId(), found); + flows = authMgmtResource.getFlows(); + + Assert.assertEquals("New Flow2", findFlowByAlias("New Flow2", flows).getAlias()); + Assert.assertEquals("New description2", findFlowByAlias("New Flow2", flows).getDescription()); + assertAdminEvents.assertEvent(REALM_NAME, OperationType.UPDATE, AdminEventPaths.authEditFlowPath(found.getId()), ResourceType.AUTH_FLOW); + Assert.assertNull(findFlowByAlias("New Flow", flows)); + + authMgmtResource.deleteFlow(testFlow.getId()); + authMgmtResource.deleteFlow(found.getId()); + } + + @Test + public void editExecutionFlowTest() { + HashMap params = new HashMap<>(); + List executionReps; + //create new parent flow + AuthenticationFlowRepresentation newFlow = newFlow("Parent-Flow", "This is a parent flow", "basic-flow", true, false); + createFlow(newFlow); + + //create a child sub flow + params.put("alias", "Child-Flow"); + params.put("description", "This is a child flow"); + params.put("provider", "registration-page-form"); + params.put("type", "basic-flow"); + + authMgmtResource.addExecutionFlow("Parent-Flow", params); + assertAdminEvents.assertEvent(REALM_NAME, OperationType.CREATE, AdminEventPaths.authAddExecutionFlowPath("Parent-Flow"), params, ResourceType.AUTH_EXECUTION_FLOW); + + executionReps = authMgmtResource.getExecutions("Parent-Flow"); + + //create another with the same name of the previous one. Should fail to create + params = new HashMap<>(); + params.put("alias", "Child-Flow"); + params.put("description", "This is another child flow"); + params.put("provider", "registration-page-form"); + params.put("type", "basic-flow"); + + try { + authMgmtResource.addExecutionFlow("Parent-Flow", params); + Assert.fail("addExecutionFlow the alias already exist"); + } catch (Exception expected) { + // Expected + } + + AuthenticationExecutionInfoRepresentation found = executionReps.get(0); + found.setDisplayName("Parent-Flow"); + + try { + authMgmtResource.updateExecutions("Parent-Flow", found); + } catch (ClientErrorException exception){ + //expected + } + + //edit both name and description + found.setDisplayName("Child-Flow2"); + found.setDescription("This is another child flow2"); + + authMgmtResource.updateExecutions("Parent-Flow", found); + assertAdminEvents.assertEvent(REALM_NAME, OperationType.UPDATE, AdminEventPaths.authUpdateExecutionPath("Parent-Flow"), ResourceType.AUTH_EXECUTION); + executionReps = authMgmtResource.getExecutions("Parent-Flow"); + Assert.assertEquals("Child-Flow2", executionReps.get(0).getDisplayName()); + Assert.assertEquals("This is another child flow2", executionReps.get(0).getDescription()); + + //edit only description + found.setDescription("This is another child flow3"); + authMgmtResource.updateExecutions("Parent-Flow", found); + + assertAdminEvents.assertEvent(REALM_NAME, OperationType.UPDATE, AdminEventPaths.authUpdateExecutionPath("Parent-Flow"), ResourceType.AUTH_EXECUTION); + executionReps = authMgmtResource.getExecutions("Parent-Flow"); + Assert.assertEquals("Child-Flow2", executionReps.get(0).getDisplayName()); + Assert.assertEquals("This is another child flow3", executionReps.get(0).getDescription()); + } + } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/AdminEventPaths.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/AdminEventPaths.java index 0992b5e277..e322432559 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/AdminEventPaths.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/AdminEventPaths.java @@ -390,6 +390,11 @@ public class AdminEventPaths { return uri.toString(); } + public static String authEditFlowPath(String flowId) { + URI uri = UriBuilder.fromUri(authMgmtBasePath()).path(AuthenticationManagementResource.class, "updateFlow") + .build(flowId); + return uri.toString(); + } public static String authAddExecutionFlowPath(String flowAlias) { URI uri = UriBuilder.fromUri(authMgmtBasePath()).path(AuthenticationManagementResource.class, "addExecutionFlow") .build(flowAlias); diff --git a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties index f7d05075cb..95a3aec187 100644 --- a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties +++ b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties @@ -1150,6 +1150,7 @@ cut=Cut paste=Paste create-group=Create group create-authenticator-execution=Create Authenticator Execution +edit-flow=Edit Flow create-form-action-execution=Create Form Action Execution create-top-level-form=Create Top Level Form flow.alias.tooltip=Specifies display name for the flow. @@ -1284,6 +1285,7 @@ started=Started logout-all-sessions=Log out all sessions logout=Logout new-name=New Name +new-description=New Description ok=Ok attributes=Attributes role-mappings=Role Mappings diff --git a/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js b/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js index e420aac9d1..1c9778c60f 100644 --- a/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js +++ b/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js @@ -2229,9 +2229,9 @@ module.controller('CreateExecutionCtrl', function($scope, realm, parentFlow, for module.controller('AuthenticationFlowsCtrl', function($scope, $route, realm, flows, selectedFlow, LastFlowSelected, Dialog, - AuthenticationFlows, AuthenticationFlowsCopy, AuthenticationFlowExecutions, + AuthenticationFlows, AuthenticationFlowsCopy, AuthenticationFlowsUpdate, AuthenticationFlowExecutions, AuthenticationExecution, AuthenticationExecutionRaisePriority, AuthenticationExecutionLowerPriority, - $modal, Notifications, CopyDialog, $location) { + $modal, Notifications, CopyDialog, UpdateDialog, $location) { $scope.realm = realm; $scope.flows = flows; @@ -2342,6 +2342,18 @@ module.controller('AuthenticationFlowsCtrl', function($scope, $route, realm, flo }; + $scope.editFlow = function(flow) { + var copy = angular.copy(flow); + UpdateDialog.open('Update Authentication Flow', copy.alias, copy.description, function(name, desc) { + copy.alias = name; + copy.description = desc; + AuthenticationFlowsUpdate.update({realm: realm.realm, flow: flow.id}, copy, function() { + $location.url("/realms/" + realm.realm + '/authentication/flows/' + name); + Notifications.success("Flow updated"); + }); + }) + }; + $scope.addFlow = function() { $location.url("/realms/" + realm.realm + '/authentication/flows/' + $scope.flow.id + '/create/flow/execution/' + $scope.flow.id); @@ -2379,6 +2391,22 @@ module.controller('AuthenticationFlowsCtrl', function($scope, $route, realm, flo }; + $scope.editExecutionFlow = function(execution) { + var copy = angular.copy(execution); + delete copy.empties; + delete copy.levels; + delete copy.preLevels; + delete copy.postLevels; + UpdateDialog.open('Update Execution Flow', copy.displayName, copy.description, function(name, desc) { + copy.displayName = name; + copy.description = desc; + AuthenticationFlowExecutions.update({realm: realm.realm, alias: $scope.flow.alias}, copy, function() { + Notifications.success("Execution Flow updated"); + setupForm(); + }); + }) + }; + $scope.removeExecution = function(execution) { console.log('removeExecution: ' + execution.id); var exeOrFlow = execution.authenticationFlow ? 'flow' : 'execution'; diff --git a/themes/src/main/resources/theme/base/admin/resources/js/services.js b/themes/src/main/resources/theme/base/admin/resources/js/services.js index a165a4875c..3594add7f5 100755 --- a/themes/src/main/resources/theme/base/admin/resources/js/services.js +++ b/themes/src/main/resources/theme/base/admin/resources/js/services.js @@ -137,6 +137,35 @@ module.service('CopyDialog', function($modal) { return dialog; }); +module.service('UpdateDialog', function($modal) { + var dialog = {}; + dialog.open = function (title, name, desc, success) { + var controller = function($scope, $modalInstance, title) { + $scope.title = title; + $scope.name = { value: name }; + $scope.description = { value: desc }; + $scope.ok = function () { + console.log('ok with name: ' + $scope.name + 'and description: ' + $scope.description); + $modalInstance.close(); + success($scope.name.value, $scope.description.value); + }; + $scope.cancel = function () { + $modalInstance.dismiss('cancel'); + }; + } + $modal.open({ + templateUrl: resourceUrl + '/templates/kc-edit.html', + controller: controller, + resolve: { + title: function() { + return title; + } + } + }); + }; + return dialog; +}); + module.factory('Notifications', function($rootScope, $timeout) { // time (in ms) the notifications are shown var delay = 5000; @@ -1745,6 +1774,19 @@ module.factory('AuthenticationFlowsCopy', function($resource) { alias : '@alias' }); }); + +module.factory('AuthenticationFlowsUpdate', function($resource) { + return $resource(authUrl + '/admin/realms/:realm/authentication/flows/:flow', { + realm : '@realm', + flow : '@flow' + }, { + update : { + method : 'PUT' + } + }); +}); + + module.factory('AuthenticationConfigDescription', function($resource) { return $resource(authUrl + '/admin/realms/:realm/authentication/config-description/:provider', { realm : '@realm', diff --git a/themes/src/main/resources/theme/base/admin/resources/partials/authentication-flows.html b/themes/src/main/resources/theme/base/admin/resources/partials/authentication-flows.html index 3ef54b398a..eb8e72110f 100755 --- a/themes/src/main/resources/theme/base/admin/resources/partials/authentication-flows.html +++ b/themes/src/main/resources/theme/base/admin/resources/partials/authentication-flows.html @@ -18,6 +18,7 @@ + @@ -36,6 +37,7 @@ {{execution.displayName|capitalize}}({{execution.alias}}) +    @@ -53,6 +55,7 @@
  • {{:: 'delete' | translate}}
  • {{:: 'add-execution' | translate}}
  • {{:: 'add-flow' | translate}}
  • +
  • {{:: 'edit-flow' | translate}}
  • {{:: 'config' | translate}}
  • {{:: 'config' | translate}}
  • diff --git a/themes/src/main/resources/theme/base/admin/resources/templates/kc-edit.html b/themes/src/main/resources/theme/base/admin/resources/templates/kc-edit.html new file mode 100644 index 0000000000..0b98b294f5 --- /dev/null +++ b/themes/src/main/resources/theme/base/admin/resources/templates/kc-edit.html @@ -0,0 +1,22 @@ + + + \ No newline at end of file