keycloak-aplcache

admin console ui

11/9/2016 8:34:07 PM

Details

services/pom.xml 5(+5 -0)

diff --git a/services/pom.xml b/services/pom.xml
index dc33a3d..f642178 100755
--- a/services/pom.xml
+++ b/services/pom.xml
@@ -64,6 +64,11 @@
             <scope>provided</scope>
         </dependency>
         <dependency>
+            <groupId>org.keycloak</groupId>
+            <artifactId>keycloak-ldap-storage</artifactId>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
             <groupId>org.jboss.spec.javax.servlet</groupId>
             <artifactId>jboss-servlet-api_3.0_spec</artifactId>
         </dependency>
diff --git a/services/src/main/java/org/keycloak/services/resources/admin/ComponentResource.java b/services/src/main/java/org/keycloak/services/resources/admin/ComponentResource.java
index c2bba87..56c7ce7 100644
--- a/services/src/main/java/org/keycloak/services/resources/admin/ComponentResource.java
+++ b/services/src/main/java/org/keycloak/services/resources/admin/ComponentResource.java
@@ -136,7 +136,7 @@ public class ComponentResource {
         } catch (ComponentValidationException e) {
             return localizedErrorResponse(e);
         } catch (IllegalArgumentException e) {
-            throw new BadRequestException();
+            throw new BadRequestException(e);
         }
     }
 
@@ -205,50 +205,59 @@ public class ComponentResource {
         return ErrorResponse.error(message, Response.Status.BAD_REQUEST);
     }
 
+    /**
+     * List of subcomponent types that are available to configure for a particular parent component.
+     *
+     * @param parentId
+     * @param subtype
+     * @return
+     */
     @GET
-    @Path("{id}/sub-component-config")
+    @Path("{id}/sub-component-types")
     @Produces(MediaType.APPLICATION_JSON)
     @NoCache
-    public ComponentTypeRepresentation getSubcomponentConfig(@PathParam("id") String id, @QueryParam("type") String providerType, @QueryParam("id") String providerId) {
+    public List<ComponentTypeRepresentation> getSubcomponentConfig(@PathParam("id") String parentId, @QueryParam("type") String subtype) {
         auth.requireView();
-        ComponentModel parent = realm.getComponent(id);
+        ComponentModel parent = realm.getComponent(parentId);
         if (parent == null) {
-            throw new NotFoundException("Could not find component");
+            throw new NotFoundException("Could not find parent component");
+        }
+        if (subtype == null) {
+            throw new BadRequestException("must specify a subtype");
         }
         Class<? extends Provider> providerClass = null;
         try {
-            providerClass = (Class<? extends Provider>)Class.forName(providerType);
+            providerClass = (Class<? extends Provider>)Class.forName(subtype);
         } catch (ClassNotFoundException e) {
             throw new RuntimeException(e);
         }
-        ProviderFactory factory = session.getKeycloakSessionFactory().getProviderFactory(providerClass, providerId);
-        if (factory == null) {
-            throw new NotFoundException("Could not find subcomponent factory");
+        List<ComponentTypeRepresentation> subcomponents = new LinkedList<>();
+        for (ProviderFactory factory : session.getKeycloakSessionFactory().getProviderFactories(providerClass)) {
+            ComponentTypeRepresentation rep = new ComponentTypeRepresentation();
+            rep.setId(factory.getId());
+            if (!(factory instanceof ComponentFactory)) {
+                continue;
+            }
+            ComponentFactory componentFactory = (ComponentFactory)factory;
 
-        }
-        if (!(factory instanceof ComponentFactory)) {
-            throw new NotFoundException("Not a component factory");
+            rep.setHelpText(componentFactory.getHelpText());
+            List<ProviderConfigProperty> props = null;
+            Map<String, Object> metadata = null;
+            if (factory instanceof SubComponentFactory) {
+                props = ((SubComponentFactory)factory).getConfigProperties(realm, parent);
+                metadata = ((SubComponentFactory)factory).getTypeMetadata(realm, parent);
 
-        }
-        ComponentFactory componentFactory = (ComponentFactory)factory;
-        ComponentTypeRepresentation rep = new ComponentTypeRepresentation();
-        rep.setId(providerId);
-        rep.setHelpText(componentFactory.getHelpText());
-        List<ProviderConfigProperty> props = null;
-        Map<String, Object> metadata = null;
-        if (factory instanceof SubComponentFactory) {
-            props = ((SubComponentFactory)factory).getConfigProperties(realm, parent);
-            metadata = ((SubComponentFactory)factory).getTypeMetadata(realm, parent);
+            } else {
+                props = componentFactory.getConfigProperties();
+                metadata = componentFactory.getTypeMetadata();
+            }
 
-        } else {
-            props = componentFactory.getConfigProperties();
-            metadata = componentFactory.getTypeMetadata();
+            List<ConfigPropertyRepresentation> propReps =  ModelToRepresentation.toRepresentation(props);
+            rep.setProperties(propReps);
+            rep.setMetadata(metadata);
+            subcomponents.add(rep);
         }
-
-        List<ConfigPropertyRepresentation> propReps =  ModelToRepresentation.toRepresentation(props);
-        rep.setProperties(propReps);
-        rep.setMetadata(metadata);
-        return rep;
+        return subcomponents;
     }
 
 
diff --git a/services/src/main/java/org/keycloak/services/resources/admin/UserStorageProviderResource.java b/services/src/main/java/org/keycloak/services/resources/admin/UserStorageProviderResource.java
index 9fde51f..8e29779 100644
--- a/services/src/main/java/org/keycloak/services/resources/admin/UserStorageProviderResource.java
+++ b/services/src/main/java/org/keycloak/services/resources/admin/UserStorageProviderResource.java
@@ -24,9 +24,14 @@ import org.keycloak.component.ComponentModel;
 import org.keycloak.events.admin.OperationType;
 import org.keycloak.models.KeycloakSession;
 import org.keycloak.models.RealmModel;
+import org.keycloak.provider.ProviderFactory;
+import org.keycloak.services.ServicesLogger;
 import org.keycloak.services.managers.UserStorageSyncManager;
 import org.keycloak.storage.UserStorageProvider;
 import org.keycloak.storage.UserStorageProviderModel;
+import org.keycloak.storage.ldap.LDAPStorageProvider;
+import org.keycloak.storage.ldap.LDAPStorageProviderFactory;
+import org.keycloak.storage.ldap.mappers.LDAPStorageMapper;
 import org.keycloak.storage.user.SynchronizationResult;
 
 import javax.ws.rs.POST;
@@ -119,6 +124,47 @@ public class UserStorageProviderResource {
         return syncResult;
     }
 
+    /**
+     * Trigger sync of mapper data related to ldap mapper (roles, groups, ...)
+     *
+     * @return
+     */
+    @POST
+    @Path("{parentId}/mappers/{id}/sync")
+    @NoCache
+    @Produces(MediaType.APPLICATION_JSON)
+    public SynchronizationResult syncMapperData(@PathParam("parentId") String parentId, @PathParam("id") String mapperId, @QueryParam("direction") String direction) {
+        auth.requireManage();
+
+        ComponentModel parentModel = realm.getComponent(parentId);
+        if (parentModel == null) throw new NotFoundException("Parent model not found");
+        ComponentModel mapperModel = realm.getComponent(mapperId);
+        if (mapperModel == null) throw new NotFoundException("Mapper model not found");
+        LDAPStorageMapper mapper = session.getProvider(LDAPStorageMapper.class, mapperModel);
+        ProviderFactory factory = session.getKeycloakSessionFactory().getProviderFactory(LDAPStorageProvider.class, parentModel.getProviderId());
+
+        LDAPStorageProviderFactory providerFactory = (LDAPStorageProviderFactory)factory;
+        LDAPStorageProvider federationProvider = providerFactory.create(session, parentModel);
+
+        ServicesLogger.LOGGER.syncingDataForMapper(mapperModel.getName(), mapperModel.getProviderId(), direction);
+
+        SynchronizationResult syncResult;
+        if ("fedToKeycloak".equals(direction)) {
+            syncResult = mapper.syncDataFromFederationProviderToKeycloak(mapperModel, federationProvider, session, realm);
+        } else if ("keycloakToFed".equals(direction)) {
+            syncResult = mapper.syncDataFromKeycloakToFederationProvider(mapperModel, federationProvider, session, realm);
+        } else {
+            throw new NotFoundException("Unknown direction: " + direction);
+        }
+
+        Map<String, Object> eventRep = new HashMap<>();
+        eventRep.put("action", direction);
+        eventRep.put("result", syncResult);
+        adminEvent.operation(OperationType.ACTION).resourcePath(uriInfo).representation(eventRep).success();
+        return syncResult;
+    }
+
+
 
 
 }
diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/federation/storage/UserStorageTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/federation/storage/UserStorageTest.java
index a2b2636..6e79d69 100644
--- a/testsuite/integration/src/test/java/org/keycloak/testsuite/federation/storage/UserStorageTest.java
+++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/federation/storage/UserStorageTest.java
@@ -132,7 +132,7 @@ public class UserStorageTest {
 
     }
 
-    //@Test
+    @Test
     public void testIDE() throws Exception {
         Thread.sleep(100000000);
     }
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 b330a3c..fcf02a4 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
@@ -1215,6 +1215,10 @@ credential-types=Credential Types
 manage-user-password=Manage Password
 disable-credentials=Disable Credentials
 credential-reset-actions=Credential Reset
+ldap-mappers=LDAP Mappers
+create-ldap-mapper=Create LDAP mapper
+
+
 
 
 
diff --git a/themes/src/main/resources/theme/base/admin/resources/js/app.js b/themes/src/main/resources/theme/base/admin/resources/js/app.js
index 223c94e..e3b5cc5 100755
--- a/themes/src/main/resources/theme/base/admin/resources/js/app.js
+++ b/themes/src/main/resources/theme/base/admin/resources/js/app.js
@@ -1545,6 +1545,60 @@ module.config([ '$routeProvider', function($routeProvider) {
             },
             controller : 'GenericUserStorageCtrl'
         })
+        .when('/realms/:realm/ldap-mappers/:componentId', {
+            templateUrl : function(params){ return resourceUrl + '/partials/user-storage-ldap-mappers.html'; },
+            resolve : {
+                realm : function(RealmLoader) {
+                    return RealmLoader();
+                },
+                provider : function(ComponentLoader) {
+                    return ComponentLoader();
+                },
+                mappers : function(ComponentsLoader, $route) {
+                    return ComponentsLoader.loadComponents($route.current.params.componentId, 'org.keycloak.storage.ldap.mappers.LDAPStorageMapper');
+                }
+            },
+            controller : 'LDAPMapperListCtrl'
+        })
+        .when('/create/ldap-mappers/:realm/:componentId', {
+            templateUrl : function(params){ return resourceUrl + '/partials/user-storage-ldap-mapper-detail.html'; },
+            resolve : {
+                realm : function(RealmLoader) {
+                    return RealmLoader();
+                },
+                provider : function(ComponentLoader) {
+                    return ComponentLoader();
+                },
+                mapperTypes : function(SubComponentTypesLoader, $route) {
+                    return SubComponentTypesLoader.loadComponents($route.current.params.componentId, 'org.keycloak.storage.ldap.mappers.LDAPStorageMapper');
+                },
+                clients : function(ClientListLoader) {
+                    return ClientListLoader();
+                }
+            },
+            controller : 'LDAPMapperCreateCtrl'
+        })
+        .when('/realms/:realm/ldap-mappers/:componentId/mappers/:mapperId', {
+            templateUrl : function(params){ return resourceUrl + '/partials/user-storage-ldap-mapper-detail.html'; },
+            resolve : {
+                realm : function(RealmLoader) {
+                    return RealmLoader();
+                },
+                provider : function(ComponentLoader) {
+                    return ComponentLoader();
+                },
+                mapperTypes : function(SubComponentTypesLoader, $route) {
+                    return SubComponentTypesLoader.loadComponents($route.current.params.componentId, 'org.keycloak.storage.ldap.mappers.LDAPStorageMapper');
+                },
+                mapper : function(LDAPMapperLoader) {
+                    return LDAPMapperLoader();
+                },
+                clients : function(ClientListLoader) {
+                    return ClientListLoader();
+                }
+            },
+            controller : 'LDAPMapperCtrl'
+        })
         .when('/realms/:realm/user-federation', {
             templateUrl : resourceUrl + '/partials/user-federation.html',
             resolve : {
@@ -2458,6 +2512,15 @@ module.directive('kcTabsUserFederation', function () {
     }
 });
 
+module.directive('kcTabsLdap', function () {
+    return {
+        scope: true,
+        restrict: 'E',
+        replace: true,
+        templateUrl: resourceUrl + '/templates/kc-tabs-ldap.html'
+    }
+});
+
 module.controller('RoleSelectorModalCtrl', function($scope, realm, config, configName, RealmRoles, Client, ClientRole, $modalInstance) {
     $scope.selectedRealmRole = {
         role: undefined
diff --git a/themes/src/main/resources/theme/base/admin/resources/js/controllers/users.js b/themes/src/main/resources/theme/base/admin/resources/js/controllers/users.js
index c55534d..c3b7e1f 100755
--- a/themes/src/main/resources/theme/base/admin/resources/js/controllers/users.js
+++ b/themes/src/main/resources/theme/base/admin/resources/js/controllers/users.js
@@ -1870,6 +1870,183 @@ module.controller('LDAPUserStorageCtrl', function($scope, $location, Notificatio
 
 });
 
+module.controller('LDAPTabCtrl', function(Dialog, $scope, Current, Notifications, $location) {
+    $scope.removeUserFederation = function() {
+        Dialog.confirmDelete($scope.instance.name, 'ldap provider', function() {
+            $scope.instance.$remove({
+                realm : Current.realm.realm,
+                componentId : $scope.instance.id
+            }, function() {
+                $location.url("/realms/" + Current.realm.realm + "/user-federation");
+                Notifications.success("The provider has been deleted.");
+            });
+        });
+    };
+});
+
+
+module.controller('LDAPMapperListCtrl', function($scope, $location, Notifications, $route, Dialog, realm, provider, mappers) {
+    console.log('LDAPMapperListCtrl');
+
+    $scope.realm = realm;
+    $scope.provider = provider;
+    $scope.instance = provider;
+
+    $scope.mappers = mappers;
+
+});
+
+module.controller('LDAPMapperCtrl', function($scope, $route, realm,  provider, mapperTypes, mapper, clients, Components, LDAPMapperSync, Notifications, Dialog, $location) {
+    console.log('LDAPMapperCtrl');
+    $scope.realm = realm;
+    $scope.provider = provider;
+    $scope.clients = clients;
+    $scope.create = false;
+    $scope.changed = false;
+
+    for (var i = 0; i < mapperTypes.length; i++) {
+        console.log('mapper.providerId: ' + mapper.providerId);
+        console.log('mapperTypes[i].id ' + mapperTypes[i].id);
+        if (mapperTypes[i].id == mapper.providerId) {
+            $scope.mapperType = mapperTypes[i];
+            break;
+        }
+    }
+
+    if ($scope.mapperType.properties) {
+
+        for (var i = 0; i < $scope.mapperType.properties.length; i++) {
+            var configProperty = $scope.mapperType.properties[i];
+            if (!mapper.config[configProperty.name]) {
+                if (configProperty.defaultValue) {
+                    mapper.config[configProperty.name] = [configProperty.defaultValue];
+                } else {
+                    mapper.config[configProperty.name] = [''];
+                }
+            }
+
+        }
+    }
+    $scope.mapper = angular.copy(mapper);
+
+
+    $scope.$watch('mapper', function() {
+        if (!angular.equals($scope.mapper, mapper)) {
+            $scope.changed = true;
+        }
+    }, true);
+
+    $scope.save = function() {
+        Components.update({realm: realm.realm,
+                componentId: mapper.id
+            },
+            $scope.mapper,  function () {
+                $route.reload();
+                Notifications.success("The mapper has been updated.");
+            }, function (errorResponse) {
+                if (errorResponse.data && errorResponse.data['error_description']) {
+                    Notifications.error(errorResponse.data['error_description']);
+                }
+            });
+    };
+
+    $scope.reset = function() {
+        $scope.mapper = angular.copy(mapper);
+        $scope.changed = false;
+    };
+
+    $scope.remove = function() {
+        Dialog.confirmDelete($scope.mapper.name, 'ldap mapper', function() {
+            Components.remove({
+                realm : realm.realm,
+                componentId : mapper.id
+            }, function() {
+                $location.url("/realms/" + realm.realm + '/ldap-mappers/' + provider.id);
+                Notifications.success("The provider has been deleted.");
+            });
+        });
+    };
+
+    $scope.triggerFedToKeycloakSync = function() {
+        triggerMapperSync("fedToKeycloak")
+    }
+
+    $scope.triggerKeycloakToFedSync = function() {
+        triggerMapperSync("keycloakToFed");
+    }
+
+    function triggerMapperSync(direction) {
+        LDAPMapperSync.save({ direction: direction, realm: realm.realm, parentId: provider.id, mapperId : $scope.mapper.id }, {}, function(syncResult) {
+            Notifications.success("Data synced successfully. " + syncResult.status);
+        }, function(error) {
+            Notifications.error(error.data.errorMessage);
+        });
+    }
+
+});
+
+module.controller('LDAPMapperCreateCtrl', function($scope, realm, provider, mapperTypes, clients, Components, Notifications, Dialog, $location) {
+    console.log('LDAPMapperCreateCtrl');
+    $scope.realm = realm;
+    $scope.provider = provider;
+    $scope.clients = clients;
+    $scope.create = true;
+    $scope.mapper = { config: {}};
+    $scope.mapperTypes = mapperTypes;
+    $scope.mapperType = null;
+    $scope.changed = true;
+
+    $scope.$watch('mapperType', function() {
+        if ($scope.mapperType != null) {
+            $scope.mapper.config = {};
+            if ($scope.mapperType.properties) {
+
+                for (var i = 0; i < $scope.mapperType.properties.length; i++) {
+                    var configProperty = $scope.mapperType.properties[i];
+                    if (!$scope.mapper.config[configProperty.name]) {
+                        if (configProperty.defaultValue) {
+                            $scope.mapper.config[configProperty.name] = [configProperty.defaultValue];
+                        } else {
+                            $scope.mapper.config[configProperty.name] = [''];
+                        }
+                    }
+
+                }
+            }
+        }
+    }, true);
+
+    $scope.save = function() {
+        if ($scope.mapperType == null) {
+            Notifications.error("You need to select mapper type!");
+            return;
+        }
+
+        $scope.mapper.providerId = $scope.mapperType.id;
+        $scope.mapper.providerType = 'org.keycloak.storage.ldap.mappers.LDAPStorageMapper';
+        $scope.mapper.parentId = provider.id;
+
+        Components.save({realm: realm.realm}, $scope.mapper,  function (data, headers) {
+            var l = headers().location;
+            var id = l.substring(l.lastIndexOf("/") + 1);
+
+            $location.url("/realms/" + realm.realm + "/ldap-mappers/" + $scope.mapper.parentId + "/mappers/" + id);
+            Notifications.success("The mapper has been created.");
+        }, function (errorResponse) {
+            if (errorResponse.data && errorResponse.data['error_description']) {
+                Notifications.error(errorResponse.data['error_description']);
+            }
+        });
+    };
+
+    $scope.reset = function() {
+        $location.url("/realms/" + realm.realm + '/ldap-mappers/' + provider.id);
+    };
+
+
+});
+
+
 
 
 
diff --git a/themes/src/main/resources/theme/base/admin/resources/js/loaders.js b/themes/src/main/resources/theme/base/admin/resources/js/loaders.js
index 78fb99f..6d8c3fa 100755
--- a/themes/src/main/resources/theme/base/admin/resources/js/loaders.js
+++ b/themes/src/main/resources/theme/base/admin/resources/js/loaders.js
@@ -143,6 +143,15 @@ module.factory('ComponentLoader', function(Loader, Components, $route, $q) {
     });
 });
 
+module.factory('LDAPMapperLoader', function(Loader, Components, $route, $q) {
+    return Loader.get(Components, function() {
+        return {
+            realm : $route.current.params.realm,
+            componentId: $route.current.params.mapperId
+        }
+    });
+});
+
 module.factory('ComponentsLoader', function(Loader, Components, $route, $q) {
     var componentsLoader = {};
 
@@ -159,6 +168,22 @@ module.factory('ComponentsLoader', function(Loader, Components, $route, $q) {
     return componentsLoader;
 });
 
+module.factory('SubComponentTypesLoader', function(Loader, SubComponentTypes, $route, $q) {
+    var componentsLoader = {};
+
+    componentsLoader.loadComponents = function(parent, componentType) {
+        return Loader.query(SubComponentTypes, function() {
+            return {
+                realm : $route.current.params.realm,
+                componentId : parent,
+                type: componentType
+            }
+        })();
+    };
+
+    return componentsLoader;
+});
+
 module.factory('UserFederationInstanceLoader', function(Loader, UserFederationInstances, $route, $q) {
     return Loader.get(UserFederationInstances, function() {
         return {
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 5b44382..abacb07 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
@@ -1699,6 +1699,13 @@ module.factory('DefaultGroups', function($resource) {
     });
 });
 
+module.factory('SubComponentTypes', function($resource) {
+    return $resource(authUrl + '/admin/realms/:realm/components/:componentId/sub-component-types', {
+        realm: '@realm',
+        componentId: '@componentId'
+    });
+});
+
 module.factory('Components', function($resource, ComponentUtils) {
     return $resource(authUrl + '/admin/realms/:realm/components/:componentId', {
         realm : '@realm',
@@ -1742,4 +1749,14 @@ module.factory('ClientRegistrationPolicyProviders', function($resource) {
     });
 });
 
+module.factory('LDAPMapperSync', function($resource) {
+    return $resource(authUrl + '/admin/realms/:realm/user-storage/:parentId/mappers/:mapperId/sync', {
+        realm : '@realm',
+        componentId : '@componentId',
+        mapperId: '@mapperId'
+    });
+});
+
+
+
 
diff --git a/themes/src/main/resources/theme/base/admin/resources/partials/user-storage-ldap.html b/themes/src/main/resources/theme/base/admin/resources/partials/user-storage-ldap.html
index d81541f..060d7d7 100755
--- a/themes/src/main/resources/theme/base/admin/resources/partials/user-storage-ldap.html
+++ b/themes/src/main/resources/theme/base/admin/resources/partials/user-storage-ldap.html
@@ -5,7 +5,7 @@
         <li data-ng-show="create">{{:: 'add-user-storage-provider' | translate}}</li>
     </ol>
 
-    <kc-tabs-user-storage></kc-tabs-user-storage>
+    <kc-tabs-ldap></kc-tabs-ldap>
 
     <form class="form-horizontal" name="realmForm" novalidate kc-read-only="!access.manageRealm">
         <input type="text" readonly value="this is not a login form" style="display: none;">
diff --git a/themes/src/main/resources/theme/base/admin/resources/partials/user-storage-ldap-mapper-detail.html b/themes/src/main/resources/theme/base/admin/resources/partials/user-storage-ldap-mapper-detail.html
new file mode 100644
index 0000000..72cfea4
--- /dev/null
+++ b/themes/src/main/resources/theme/base/admin/resources/partials/user-storage-ldap-mapper-detail.html
@@ -0,0 +1,64 @@
+<div class="col-sm-9 col-md-10 col-sm-push-3 col-md-push-2">
+    <ol class="breadcrumb">
+        <li><a href="#/realms/{{realm.realm}}/user-federation">{{:: 'user-federation' | translate}}</a></li>
+        <li><a href="#/realms/{{realm.realm}}/user-storage/providers/ldap/{{provider.id}}">{{provider.providerId|capitalize}}</a></li>
+        <li><a href="#/realms/{{realm.realm}}/ldap-mappers/{{provider.id}}">{{:: 'ldap-mappers' | translate}}</a></li>
+        <li class="active" data-ng-show="create">{{:: 'create-ldap-mapper' | translate}}</li>
+        <li class="active" data-ng-hide="create">{{mapper.name}}</li>
+    </ol>
+
+    <h1 data-ng-hide="create">{{mapper.name|capitalize}}<i class="pficon pficon-delete clickable" data-ng-show="!create && access.manageRealm" 
+    	data-ng-hide="changed" data-ng-click="remove()"></i></h1>
+    <h1 data-ng-show="create">{{:: 'add-user-federation-mapper' | translate}}</h1>
+
+    <form class="form-horizontal" name="realmForm" novalidate kc-read-only="!access.manageRealm">
+        <fieldset>
+            <div class="form-group clearfix" data-ng-show="!create">
+                <label class="col-md-2 control-label" for="mapperId">{{:: 'id' | translate}} </label>
+                <div class="col-md-6">
+                    <input class="form-control" id="mapperId" type="text" ng-model="mapper.id" readonly>
+                </div>
+            </div>
+            <div class="form-group clearfix">
+                <label class="col-md-2 control-label" for="name">{{:: 'name' | translate}} <span class="required">*</span></label>
+                <div class="col-md-6">
+                    <input class="form-control" id="name" type="text" ng-model="mapper.name" data-ng-readonly="!create" required>
+                </div>
+                <kc-tooltip>{{:: 'mapper.name.tooltip' | translate}}</kc-tooltip>
+            </div>
+            <div class="form-group" data-ng-show="create">
+                <label class="col-md-2 control-label" for="mapperTypeCreate">{{:: 'mapper-type' | translate}}</label>
+                <div class="col-sm-6">
+                    <div>
+                        <select class="form-control" id="mapperTypeCreate"
+                                ng-model="mapperType"
+                                ng-options="mapperType.id for mapperType in mapperTypes">
+                        </select>
+                    </div>
+                </div>
+                <kc-tooltip>{{mapperType.helpText}}</kc-tooltip>
+            </div>
+            <div class="form-group clearfix" data-ng-hide="create">
+                <label class="col-md-2 control-label" for="mapperType">{{:: 'mapper-type' | translate}}</label>
+                <div class="col-md-6">
+                    <input class="form-control" id="mapperType" type="text" ng-model="mapperType.id" data-ng-readonly="true">
+                </div>
+                <kc-tooltip>{{mapperType.helpText}}</kc-tooltip>
+            </div>
+
+            <kc-component-config realm="realm" config="mapper.config" properties="mapperType.properties"></kc-component-config>
+
+        </fieldset>
+
+        <div class="form-group">
+            <div class="col-md-10 col-md-offset-2" data-ng-show="access.manageRealm">
+                <button kc-save  data-ng-disabled="!changed">Save</button>
+                <button kc-reset data-ng-disabled="!changed">Cancel</button>
+                <button class="btn btn-primary" data-ng-click="triggerFedToKeycloakSync()" data-ng-hide="create || !mapperType.metadata.fedToKeycloakSyncSupported" data-ng-disabled="changed">{{:: mapperType.metadata.fedToKeycloakSyncMessage | translate}}</button>
+                <button class="btn btn-primary" data-ng-click="triggerKeycloakToFedSync()" data-ng-hide="create || !mapperType.metadata.keycloakToFedSyncSupported" data-ng-disabled="changed">{{:: mapperType.metadata.keycloakToFedSyncMessage | translate}}</button>
+            </div>
+        </div>
+    </form>
+</div>
+
+<kc-menu></kc-menu>
\ No newline at end of file
diff --git a/themes/src/main/resources/theme/base/admin/resources/partials/user-storage-ldap-mappers.html b/themes/src/main/resources/theme/base/admin/resources/partials/user-storage-ldap-mappers.html
new file mode 100644
index 0000000..794e83e
--- /dev/null
+++ b/themes/src/main/resources/theme/base/admin/resources/partials/user-storage-ldap-mappers.html
@@ -0,0 +1,46 @@
+<div class="col-sm-9 col-md-10 col-sm-push-3 col-md-push-2">
+    <ol class="breadcrumb">
+        <li><a href="#/realms/{{realm.realm}}/user-federation">{{:: 'user-federation' | translate}}</a></li>
+        <li><a href="#/realms/{{realm.realm}}/user-storage/providers/ldap/{{provider.id}}">{{provider.name|capitalize}}</a></li>
+        <li>{{:: 'ldap-mappers' | translate}}</li>
+    </ol>
+
+    <kc-tabs-ldap></kc-tabs-ldap>
+
+    <table class="table table-striped table-bordered">
+        <thead>
+        <tr>
+            <th class="kc-table-actions" colspan="4">
+                <div class="form-inline">
+                    <div class="form-group">
+                        <div class="input-group">
+                            <input type="text" placeholder="{{:: 'search.placeholder' | translate}}" data-ng-model="search.name" class="form-control search" onkeyup="if(event.keyCode == 13){$(this).next('I').click();}">
+                            <div class="input-group-addon">
+                                <i class="fa fa-search" type="submit"></i>
+                            </div>
+                        </div>
+                    </div>
+                    <div class="pull-right">
+                        <a class="btn btn-primary" href="#/create/ldap-mappers/{{realm.realm}}/{{provider.id}}">{{:: 'create' | translate}}</a>
+                    </div>
+                </div>
+            </th>
+        </tr>
+        <tr data-ng-hide="mappers.length == 0">
+            <th>{{:: 'name' | translate}}</th>
+            <th>{{:: 'type' | translate}}</th>
+        </tr>
+        </thead>
+        <tbody>
+        <tr ng-repeat="mapper in mappers | filter:search">
+            <td><a href="#/realms/{{realm.realm}}/ldap-mappers/{{provider.id}}/mappers/{{mapper.id}}">{{mapper.name}}</a></td>
+            <td>{{mapper.providerId}}</td>
+        </tr>
+        <tr data-ng-show="mappers.length == 0">
+            <td>{{:: 'no-mappers-available' | translate}}</td>
+        </tr>
+        </tbody>
+    </table>
+</div>
+
+<kc-menu></kc-menu>
\ No newline at end of file
diff --git a/themes/src/main/resources/theme/base/admin/resources/templates/kc-tabs-ldap.html b/themes/src/main/resources/theme/base/admin/resources/templates/kc-tabs-ldap.html
new file mode 100644
index 0000000..089a65f
--- /dev/null
+++ b/themes/src/main/resources/theme/base/admin/resources/templates/kc-tabs-ldap.html
@@ -0,0 +1,12 @@
+<div data-ng-controller="LDAPTabCtrl">
+    <h1 data-ng-hide="create">
+        {{instance.displayName|capitalize}}
+        <i class="pficon pficon-delete clickable" data-ng-show="!create && access.manageUsers" data-ng-click="removeUserFederation()"></i>
+    </h1>
+    <h1 data-ng-show="create">{{:: 'add-user-federation-provider' | translate}}</h1>
+
+    <ul class="nav nav-tabs" data-ng-hide="create">
+        <li ng-class="{active: path[4] == 'ldap'}"><a href="#/realms/{{realm.realm}}/user-storage/providers/ldap/{{instance.id}}">{{:: 'settings' | translate}}</a></li>
+        <li ng-class="{active: path[2] == 'ldap-mappers'}"><a href="#/realms/{{realm.realm}}/ldap-mappers/{{instance.id}}">{{:: 'mappers' | translate}}</a></li>
+    </ul>
+</div>