AEM CQ 561 - Adding Password Expiry Feature to Classic UI


AEM 61 - Configuration property Maximum Password Age available in Apache Jackrabbit Oak UserConfiguration service (http://localhost:4502/system/console/configMgr/org.apache.jackrabbit.oak.security.user.UserConfigurationImpl) available in CRX Oak can be used to set the password expiry in days. Check this ticket for more information



Goal


For users existing in CQ, this extension helps providing a Password Expiry like feature in Classic UI. Check Demo, Source Code and Package Install

Assuming, only the administrators have access to /useradmin console and user nodes

Package logic adds a new textfield widget passwordExpiryInDays to the form in User Admin console (http://localhost:4502/useradmin)



Stored in CRX (http://localhost:4502/crx/de) as passwordExpiryInDays



When a value exists for the property passwordExpiryInDays on any user profile node (eg. /home/users/geometrixx/author/profile), logic checks if the value of  passwordLastChanged date (this is set when user changes the password using Password Expired dialog, explained in next section) on user node (eg. /home/users/geometrixx/author ) + passwordExpiryInDays on user profile node (eg. /home/users/geometrixx/author/profile) is LESS than today's date on server; if it is, Password Expired dialog starts annoying the user on Classic UI admin and authoring. if passwordLastChanged doesn't exist, logic works with user's jcr:created date


On Admin UI



On Authoring UI




When user enters new password, the servlet /bin/experience-aem/pm/expiry sets property passwordLastChanged on user node

Please note, this works on Classic UI only and since most of the authoring activity happens on Classic UI in 561, the touch UI was not handled for password expiry. More enhancements are yet to be added like Password Strength, Forgot Password, Password Expiry Reminders etc.


Solution


We need a servlet GetPasswordOptions with doGet method returning json data for user password expired and doPost setting passwordLastChanged on user node. The package is created using multimodule-content-package-archetype, for detailed notes on how to create a package using multimodule-content-package-archetype in Intellij check this post

package apps.experienceaem.pm;

import org.apache.jackrabbit.api.security.user.Authorizable;
import org.apache.felix.scr.annotations.Component;
import org.apache.felix.scr.annotations.Properties;
import org.apache.felix.scr.annotations.Property;
import org.apache.felix.scr.annotations.Service;
import org.apache.jackrabbit.api.JackrabbitSession;
import org.apache.sling.api.SlingHttpServletRequest;
import org.apache.sling.api.SlingHttpServletResponse;
import org.apache.sling.api.resource.ResourceResolver;
import org.apache.sling.api.resource.ValueMap;
import org.apache.sling.api.servlets.SlingAllMethodsServlet;
import org.apache.sling.commons.json.io.JSONWriter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.jcr.Node;
import javax.jcr.Session;
import javax.servlet.ServletException;
import java.io.IOException;
import java.util.Calendar;
import java.util.Date;

@Component(metatype = false)
@Service
@Properties({
        @Property(name = "sling.servlet.paths", value = "/bin/experience-aem/pm/expiry"),
        @Property(name = "sling.servlet.methods", value = { "GET", "POST" } )
})
public class GetPasswordOptions extends SlingAllMethodsServlet {

    private final static Logger log = LoggerFactory.getLogger(GetPasswordOptions.class);

    @Override
    protected void doGet(SlingHttpServletRequest request, SlingHttpServletResponse response)
            throws ServletException, IOException {
        response.setContentType("application/json");
        response.setCharacterEncoding("utf-8");

        try{
            ResourceResolver resolver = request.getResourceResolver();
            JackrabbitSession session = (JackrabbitSession)resolver.adaptTo(Session.class);
            Authorizable user = session.getUserManager().getAuthorizable(session.getUserID());

            ValueMap profileMap = resolver.getResource(user.getPath() + "/profile").adaptTo(ValueMap.class);

            JSONWriter jw = new JSONWriter(response.getWriter());
            jw.object();

            if(!profileMap.containsKey("passwordExpiryInDays")){
                jw.key("expired").value(false);
            }else{
                int passwordExpiryInDays = profileMap.get("passwordExpiryInDays", Integer.class);

                ValueMap resMap = resolver.getResource(user.getPath()).adaptTo(ValueMap.class);
                Date lastChangedDate = resMap.containsKey("passwordLastChanged") ? resMap.get("passwordLastChanged", Date.class)
                                                        : resMap.get("jcr:created", Date.class);

                jw.key("passwordLastChanged").value(lastChangedDate);

                //calculate the expiry date based on server time
                Calendar expiryDate = Calendar.getInstance();
                expiryDate.setTime(lastChangedDate);
                expiryDate.add(Calendar.DAY_OF_YEAR, passwordExpiryInDays);

                Calendar today = Calendar.getInstance();
                jw.key("expired").value(expiryDate.getTimeInMillis() < today.getTimeInMillis());
                jw.key("userPath").value(user.getPath());
                jw.key("passwordExpiryInDays").value(passwordExpiryInDays);
            }

            jw.endObject();
        }catch(Exception e){
            log.error("Error", e);
            throw new ServletException(e);
        }
    }

    @Override
    protected void doPost(SlingHttpServletRequest request, SlingHttpServletResponse response)
            throws ServletException, IOException {
        response.setContentType("application/json");
        response.setCharacterEncoding("utf-8");

        try{
            ResourceResolver resolver = request.getResourceResolver();
            JackrabbitSession session = (JackrabbitSession)resolver.adaptTo(Session.class);

            Authorizable user = session.getUserManager().getAuthorizable(session.getUserID());
            Node node = resolver.getResource(user.getPath()).adaptTo(Node.class);

            //set the last changed date to time on server
            node.setProperty("passwordLastChanged", Calendar.getInstance());
            session.save();

            JSONWriter jw = new JSONWriter(response.getWriter());
            jw.object().key("success").value("success").endObject();
        }catch(Exception e){
            log.error("Error", e);
            throw new ServletException(e);
        }
    }
}


On Classic UI, we need to add the Password Expiry (days) widget to user form and necessary logic to mask the UI when user password has expired, so create a clientlib

1) Login to CRXDE Lite (http://localhost:4502/crx/de) and create folder /apps/password-expiration

2) Create node /apps/password-expiration/clientlib of type cq:ClientLibraryFolder and add a String property categories with value cq.security

3) Create file (nt:file) /apps/password-expiration/clientlib/js.txt and add

                       password-expiration.js

4) Create file (nt:file) /apps/password-expiration/clientlib/password-expiration.js and add the following code

CQ.Ext.ns("ExperienceAEM.PasswordMgmt");

ExperienceAEM.PasswordMgmt = {
    getChangePasswordDialog: function(userPath, passwordExpiryInDays){
        var dialogCfg = CQ.Util.copyObject(CQ.UserInfo.PASSWORD_DIALOG_CFG);

        dialogCfg.title = "Password Expired";
        dialogCfg.buttons = CQ.Dialog.OK;
        dialogCfg.closable = false;

        dialogCfg.ok = function(){
            var dialog = this;

            var find = function(panel, name){
                return panel.findBy(function(comp){
                    return comp["name"] == name;
                }, panel);
            };

            var currentPassword = find(dialog, ":currentPassword");
            var passwords = find(dialog, "rep:password");

            if(currentPassword[0].getValue() && passwords[0].getValue() && passwords[1].getValue()){
                if(passwords[0].getValue() == passwords[1].getValue()){
                    var options = {
                        _charset_: "utf-8",
                        ":status": "browser",
                        ":currentPassword": currentPassword[0].getValue(),
                        "rep:password": passwords[0].getValue()
                    };

                    $.ajax({
                        url: userPath + ".rw.html",
                        dataType: "html",
                        data: options,
                        success: function(){
                            $.ajax({
                                url: "/bin/experience-aem/pm/expiry",
                                dataType: "json",
                                success: function(){
                                    CQ.Notification.notify("Password changed","New password expires in " + passwordExpiryInDays + " days");

                                    dialog.close();
                                    CQ.Ext.getBody().unmask();

                                    if (( window.location.pathname == "/cf" ) || ( window.location.pathname.indexOf("/content") == 0)) {
                                        location.reload();
                                    }
                                },
                                error: function(j, t, e){
                                    alert("Error changing password, couldn't set last changed date");
                                },
                                type: 'POST'
                            });
                        },
                        error: function(j, t, e){
                            alert("Either the old password is incorrect or there is some error");
                        },
                        type: 'POST'
                    });
                }
            }
        };

        return CQ.WCM.getDialog(dialogCfg);
    },

    showChangePasswordDialog: function(){
        $.ajax({ url: "/bin/experience-aem/pm/expiry", dataType: "json", async: false,
            success: function(data){
                var expired = data["expired"];

                if(expired){
                    var dialog = this.getChangePasswordDialog(data["userPath"], data["passwordExpiryInDays"]);
                    CQ.Ext.getBody().mask();

                    if (( window.location.pathname == "/cf" ) || ( window.location.pathname.indexOf("/content") == 0)) {
                        var cf = CQ.WCM.getContentFinder();

                        if(cf){
                            cf.getEl().mask();
                        }

                        if (CQ.WCM.isEditMode() || CQ.WCM.isDesignMode()) {
                            CQ.WCM.on("sidekickready", function(sk){
                                sk.getEl().mask()
                            });
                        }
                    }

                    dialog.show();
                }
            }.createDelegate(this),
            type: "GET"
        });
    },

    addExpiryOptions: function(propPanel){
        var userForm = propPanel.userForm;
        var emailComp = userForm.find('name', 'email')[0];

        var passwordExpiryInDays = {
            "xtype":"textfield",
            "fieldLabel": "Expire password (days)",
            "anchor":"15%",
            "name":"passwordExpiryInDays"
        };

        userForm.insert(userForm.items.indexOf(emailComp) + 1, passwordExpiryInDays);

        userForm.setAutoScroll(true);
        userForm.doLayout();
    }
};

(function() {
    var PM = ExperienceAEM.PasswordMgmt;

    CQ.Ext.onReady(function(){
        PM.showChangePasswordDialog();
    });

    if(window.location.pathname == "/useradmin"){
        var fields = CQ.security.data.AuthRecord.FIELDS;
        fields.push({"name": "passwordExpiryInDays"});
        fields.push({"name": "passwordExpiryReminderInDays"});

        var UA_INTERVAL = setInterval(function(){
            var userAdmin = CQ.Ext.getCmp("cq-useradmin");

            if(userAdmin && userAdmin.userProperties){
                clearInterval(UA_INTERVAL);
                PM.addExpiryOptions(userAdmin.userProperties);
            }
        }, 250);
    }
})();




AEM CQ 6 - MSM, Setting JCR Permissions on Live Copy Components

Goal


This post is for MSM (Multi Site Manager) administrators who need more control on live copy components. A live copy author can click Cancel Inheritance on page components to break the inheritance relation of component with blue print site.

Using jcr:write privileges on the component nodes at JCR level, we can restrict live copy authors from breaking the relation and modifying content. Check Demo, Download Source Code and Package Install

The package was tested and works on 561; For a different approach (does not provide detail component level permissions) on dealing with the same problem check this post

Before we move to the solution part, here is how it works

1) Create a live copy Geo One (/content/geometrixx-outdoors1) for site Geometrixx Outdoors Site (/content/geometrixx-outdoors)

2) Blueprint site admin open's the page (eg. http://localhost:4502/cf#/content/geometrixx-outdoors/en/toolbar/about-us.html) in author environment for providing permissions on live copy components. Click the component menu option Set Cancel Inheritance. This option is added by the extension



3) Set Cancel Inheritance menu option opens the following dialog. Here you select live copy, user/group on which the privilege jcr:write needs to be set



   Click ok and a notification appears on the top right of screen




4) So for live copy page /content/geometrixx-outdoors1/en/toolbar/about-us, component  /jcr:content/par/text_8d9e user author is denied the jcr:write permission



5) Login to CRXDE Lite http://localhost:4502/crx/de and browse to node of live copy Geo One node /content/geometrixx-outdoors1/en/toolbar/about-us/jcr:content/par/text_8d9e. The jcr:write permission is denied to author



6) Login as author and try to click Cancel Inheritance of the component, on live copy Geo One page http://localhost:4502/cf#/content/geometrixx-outdoors1/en/toolbar/about-us.html



   Error is shown on top of screen



7) When user author clicks on the Cancel Inheritance of dialog, dialog becomes editable but any content added is not saved and UI shows error

Dialog becomes editable on clicking cancel inheritance lock icon



Click ok and error pops



8) So using the extension a blueprint admin can control which users/groups can modify the components of a live copy page.

Even without the extension, a blueprint site admin can set the permissions on page component nodes by visiting CRXDE, but setting privileges in the context of page is more intuitive which is what this extension provides

Solution


We need a servlet to handle the get/set of privileges and clientlib for the UI parts. The package install above is created using the multimodule-content-package-archetype, for detailed notes on how to create a package using multimodule-content-package-archetype in Intellij check this post

Servlet


The doPost method in below servlet creates jcr:write privilege on the live copy component node and doGet returns the privilege on live copy node for a principal, as json

package apps.experienceaem.msm;

import org.apache.commons.lang3.StringUtils;
import org.apache.felix.scr.annotations.*;
import org.apache.felix.scr.annotations.Properties;
import org.apache.jackrabbit.api.JackrabbitSession;
import org.apache.jackrabbit.api.security.JackrabbitAccessControlEntry;
import org.apache.jackrabbit.api.security.JackrabbitAccessControlList;
import org.apache.jackrabbit.api.security.JackrabbitAccessControlPolicy;
import org.apache.jackrabbit.api.security.user.Authorizable;
import org.apache.jackrabbit.api.security.user.UserManager;
import org.apache.sling.api.SlingHttpServletRequest;
import org.apache.sling.api.SlingHttpServletResponse;
import org.apache.sling.api.servlets.SlingAllMethodsServlet;
import org.apache.sling.commons.json.JSONArray;
import org.apache.sling.commons.json.JSONException;
import org.apache.sling.commons.json.JSONObject;
import org.apache.sling.commons.json.io.JSONWriter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.jcr.Session;
import javax.jcr.security.*;
import javax.servlet.ServletException;
import java.io.IOException;

@Component(metatype = false)
@Service
@Properties({
    @Property(name = "sling.servlet.paths", value = "/bin/experience-aem/msm/acl"),
    @Property(name = "sling.servlet.methods", value = { "GET", "POST" } )
})
public class AccessControl extends SlingAllMethodsServlet {

    private final static Logger log = LoggerFactory.getLogger(AccessControl.class);

    /**
     * Returns the ACL of path
     *
     * @param session
     * @param path
     * @return
     * @throws Exception
     */
    private JackrabbitAccessControlList getACL(Session session, String path) throws Exception{
        AccessControlManager acMgr = session.getAccessControlManager();

        JackrabbitAccessControlList acl = null;
        AccessControlPolicyIterator app = acMgr.getApplicablePolicies(path);

        while (app.hasNext()) {
            AccessControlPolicy pol = app.nextAccessControlPolicy();

            if (pol instanceof JackrabbitAccessControlPolicy) {
                acl = (JackrabbitAccessControlList) pol;
                break;
            }
        }

        if(acl == null){
            for (AccessControlPolicy pol : acMgr.getPolicies(path)) {
                if (pol instanceof JackrabbitAccessControlPolicy) {
                    acl = (JackrabbitAccessControlList) pol;
                    break;
                }
            }
        }

        return acl;
    }

    @Override
    protected void doPost(SlingHttpServletRequest request, SlingHttpServletResponse response)
                        throws ServletException, IOException {
        String liveCopies = request.getParameter("liveCopies");
        String path = request.getParameter("path");
        String principal = request.getParameter("principal");
        String type = request.getParameter("type");

        if(StringUtils.isEmpty(liveCopies) || StringUtils.isEmpty(path) || StringUtils.isEmpty(principal)){
            throw new RuntimeException("Required parameters missing");
        }

        if(StringUtils.isEmpty(type)){
            type = "ALLOW";
        }

        try{
            Session session = request.getResourceResolver().adaptTo(Session.class);
            AccessControlManager acMgr = session.getAccessControlManager();

            for(String copy: liveCopies.split(",")){
                String compPath = copy + path;
                JackrabbitAccessControlList acl = getACL(session, compPath);

                if(acl == null){
                    throw new RuntimeException("ACL not found for  path: " + compPath);
                }

                UserManager uMgr = ((JackrabbitSession) session).getUserManager();
                Authorizable authorizable = uMgr.getAuthorizable(principal);

                Privilege[] p = new Privilege[]{ acMgr.privilegeFromName(Privilege.JCR_WRITE) };
                acl.addEntry(authorizable.getPrincipal(), p, type.equalsIgnoreCase("ALLOW"));

                acMgr.setPolicy(compPath, acl);
            }

            session.save();

            JSONWriter jw = new JSONWriter(response.getWriter());
            jw.object().key("success").value("success").endObject();
        }catch(Exception e){
            log.error("Error adding acl in path - " + path + ", for - " + liveCopies, e);
            throw new ServletException(e);
        }
    }

    @Override
    protected void doGet(SlingHttpServletRequest request, SlingHttpServletResponse response)
            throws ServletException, IOException {
        response.setContentType("application/json");
        response.setCharacterEncoding("utf-8");

        String path = request.getParameter("path");
        String privilege = request.getParameter("privilege");

        try{
            Session session = request.getResourceResolver().adaptTo(Session.class);

            Privilege privileges[] = null; String pName = null;
            JSONObject map = null, allow = new JSONObject(), deny = new JSONObject();
            JSONArray arr = null;

            JackrabbitAccessControlList acl = getACL(session, path);
            AccessControlEntry entries[] = acl.getAccessControlEntries();

            for(AccessControlEntry entry : entries){
                privileges = entry.getPrivileges();

                map = ((JackrabbitAccessControlEntry)entry).isAllow() ? allow : deny;

                for(Privilege p : privileges){
                    pName = p.getName();

                    if(StringUtils.isNotEmpty(privilege) && !privilege.equals(pName)){
                        continue;
                    }

                    try{
                        arr = (JSONArray)map.get(pName);
                    }catch(JSONException je){
                        arr = new JSONArray();
                        map.put(pName, arr);
                    }

                    arr.put(entry.getPrincipal().getName());
                }
            }

            JSONWriter jw = new JSONWriter(response.getWriter());
            jw.object().key("allow").value(allow).key("deny").value(deny).endObject();
        }catch(Exception e){
            log.error("Error getting privileges for path - " + path, e);
            throw new ServletException(e);
        }
    }
}


UI Client lib


We need a clientlib with necessary JS code for adding Set Cancel Inheritance menu option and subsequent dialog

1) Login to CRXDE Lite (http://localhost:4502/crx/de) and create folder /apps/msm-disable-cancel-inheritance-users

2) Create node /apps/msm-disable-cancel-inheritance-users/clientlib of type cq:ClientLibraryFolder and add a String property categories with value cq.widgets

3) Create file (nt:file) /apps/msm-disable-cancel-inheritance-users/clientlib/js.txt and add

                       disable-cancel-inheritance-for-users.js

4) Create file (nt:file) /apps/msm-disable-cancel-inheritance-users/clientlib/disable-cancel-inheritance-for-users.js and add the following code

CQ.Ext.ns("ExperienceAEM.MSM");

ExperienceAEM.MSM.Blueprint = {
    liveCopies: [],

    //the dialog for user to choose the live copy, user/group
    getCancelInheritanceDialog: function(path){
        path = path.substring(path.indexOf("/jcr:content"));

        var allow = new CQ.Ext.form.Label( { text: "Allow : Select a live copy" });
        var deny = new CQ.Ext.form.Label( { text: "Deny : Select a live copy" });

        var getLiveCopies = function(lBox){
            var lCopies = lBox.getValue();

            if(lCopies == "ALL"){
                var items = lBox.getStore().data.items;
                lCopies = [];

                CQ.Ext.each(items, function(item){
                    if(item.id == "ALL"){
                        return;
                    }

                    lCopies.push(item.id);
                });

                lCopies = lCopies.join(",");
            }

            return lCopies;
        };

        //updates the labels with users/groups allow/deny the jcr:write permission
        var showPrivileges = function(lBox){
            if(lBox.getValue() == "ALL"){
                allow.setText("Allow : Select a live copy");
                deny.setText("Deny : Select a live copy");
                return;
            }

            //get the allow/deny permissions as json
            $.ajax({ url: "/bin/experience-aem/msm/acl", dataType: "json",
                data: { path : lBox.getValue() + path, privilege: "jcr:write" },
                success: function(data){
                    var privs = data["allow"];

                    if(privs && !CQ.Ext.isEmpty(privs["jcr:write"])){
                        allow.setText("Allow : " + privs["jcr:write"].join(" "));
                    }else{
                        allow.setText("Allow : None set");
                    }

                    privs = data["deny"];

                    if(privs && privs["jcr:write"]){
                        deny.setText("Deny : " + privs["jcr:write"].join(" "));
                    }else{
                        deny.setText("Deny : None set");
                    }
                },
                type: "GET"
            });
        };

        var dialogConfig = {
            "jcr:primaryType": "cq:Dialog",
            title: "Set Inheritance Options - " + path,
            modal: true,
            width: 600,
            height: 300,
            items: [{
                xtype: "panel",
                layout: "form",
                bodyStyle :"padding: 20px",
                items: [{
                            xtype: "panel",
                            border: false,
                            bodyStyle :"margin-bottom: 10px",
                            items: allow
                        },{
                            xtype: "panel",
                            border: false,
                            bodyStyle :"margin-bottom: 25px",
                            items: deny
                        },{
                            anchor: "95%",
                            xtype: "combo",
                            style: "margin-bottom: 20px",
                            mode: 'local',
                            fieldLabel: "Select Live Copy",
                            store: new CQ.Ext.data.ArrayStore({
                                id: 0,
                                fields: [ 'id', 'text' ],
                                data: this.liveCopies
                            }),
                            valueField: 'id',
                            displayField: 'text',
                            triggerAction: "all",
                            listeners:{
                                scope: this,
                                'select': function(combo){
                                    //when a livecopy is selected, make an ajax call to get the jcr:write permission 
                                    showPrivileges(combo);
                                }
                            }
                        },{
                            valueField: "id",
                            displayField: "name",
                            fieldLabel: "Select User/Group",
                            style: "margin-bottom: 20px",
                            autoSelect: true,
                            xtype: "authselection"
                        },{
                            xtype: 'radiogroup',
                            columns: 6,
                            fieldLabel: "Cancel Inheritance ",
                            items: [{
                                boxLabel: ' Allow',
                                name: 'type',
                                value: 'ALLOW',
                                checked: true
                            },{
                                name: 'type',
                                boxLabel: ' Deny',
                                value: 'DENY',
                                checked: false
                        }]
                    }]
            }],
            ok: function () {
                var lBox = this.findByType("combo")[0];
                var uBox = this.findByType("authselection")[0];
                var tBox = this.findByType("radiogroup")[0];

                var options = {
                    path: path,
                    liveCopies: getLiveCopies(lBox),
                    principal: uBox.getValue(),
                    type: tBox.getValue().value
                };

                this.close();

                //save the user/group allow/deny privileges on the live copy component
                $.ajax({
                    url: "/bin/experience-aem/msm/acl",
                    dataType: "json",
                    data: options,
                    success: function(){
                        CQ.Notification.notify("Cancel Inheritance","Access controls set for " + options.principal);
                    },
                    error: function(){
                        CQ.Notification.notify("Cancel Inheritance","Error setting access controls for " + options.principal);
                    },
                    type: 'POST'
                });
            }
        };

        return CQ.WCM.getDialog(dialogConfig);
    },

    //get the livecopies for a blueprint. If the site has not live copies "Set Cancel Inheritance" menu option is not shown
    readLiveCopies: function(){
        var sk = CQ.WCM.getSidekick();

        $.ajax({ url: sk.getPath() + "/jcr:content.blueprint.json", async: false, dataType: "json",
            success: function(data){
                if(!data){
                    return;
                }

                var liveCopies = data["msm:targets"];

                //return if there are no live copies
                if(CQ.Ext.isEmpty(liveCopies)){
                    return;
                }

                this.liveCopies.push( [ "ALL", "All" ] );

                CQ.Ext.each(liveCopies, function(lCopy){
                    this.liveCopies.push([ lCopy, lCopy ])
                }, this);
            }.createDelegate(this)
        });
    },

    //browse editables and add the Set Cancel Inheritance menu option
    addSetCancelInheritance: function () {
        this.readLiveCopies();

        if(CQ.Ext.isEmpty(this.liveCopies)){
            return;
        }

        var editables = CQ.utils.WCM.getEditables();

        CQ.Ext.iterate(editables, function (path, editable) {
            if(!editable.addElementEventListener){
                return;
            }

            editable.addElementEventListener(editable.element.dom, "contextmenu", function () {
                var component = this.element.linkedEditComponent;

                if (!component || !component.menuComponent) {
                    return;
                }

                var menu = component.menuComponent;

                if (menu.cancelInheritanceSet) {
                    return;
                }

                menu.addSeparator();

                menu.add({
                    text: "Set Cancel Inheritance",
                    handler: function () {
                        var dialog = ExperienceAEM.MSM.Blueprint.getCancelInheritanceDialog(path);
                        dialog.show();
                    }
                });

                menu.cancelInheritanceSet = true;
            }, true, editable);
        });
    }
};

(function() {
    var E = ExperienceAEM.MSM.Blueprint;

    if (( window.location.pathname == "/cf" ) || ( window.location.pathname.indexOf("/content") == 0)) {
        if (CQ.WCM.isEditMode()) {
            CQ.WCM.on("editablesready", E.addSetCancelInheritance, E);
        }
    }
})();




AEM CQ 561 - Hide Sidekick

Goal


Hide the Sidekick in page authoring, just in case you need to :)

Solution


In you page component jsp add the following script

<script type="text/javascript">
    (function(){
            CQ.WCM.on("editablesready", function(){
                CQ.WCM.getSidekick().hide();
            });
    })();
</script>

An other way is to add the js logic in a client lib

1) Login to CRXDE Lite (http://localhost:4502/crx/de) and create folder /apps/hide-sidekick

2) Create node /apps/hide-sidekick/clientlib of type cq:ClientLibraryFolder and add a String property categories with value experience-aem.sidekick

3) Create file (nt:file) /apps/hide-sidekick/clientlib/js.txt and add

                       hide-sidekick.js

4) Create file (nt:file) /apps/hide-sidekick/clientlib/hide-sidekick.js and add the following code

(function(){
    if( ( window.location.pathname == "/cf" ) || ( window.location.pathname.indexOf("/content") == 0)){
        CQ.WCM.on("editablesready", function(){
            CQ.WCM.getSidekick().hide();
        });
    }
})();

5) Include the clientlib in your page component jsp

                     <cq:includeClientLib categories="experience-aem.sidekick"/>

AEM CQ 561 - Add a new Image Metadata Field in Touch UI

Goal


Add a new metadata field for assets in Touch UI (Coral UI). Here we add a field for author to enter overlay text, that can be displayed on image while rendering. View demo



Solution


1) Login to CRXDE Lite (http://localhost:4502/crx/de) and create folder /apps/touch-ui-add-dam-metadata-field

2) Create node /apps/touch-ui-add-dam-metadata-field/clientlib of type cq:ClientLibraryFolder and add a String property categories with value cq.gui.metadataeditor (if there is requirement for other extensions in Touch UI and you donot like creating different clientlibs for each, a more broader category is granite.ui.foundation)

3) Create file (nt:file) /apps/touch-ui-add-dam-metadata-field/clientlib/js.txt and add

                       add-overlay-text.js

4) Create file (nt:file) /apps/touch-ui-add-dam-metadata-field/clientlib/add-overlay-text.js and add the following code

(function(document, Granite, $) {
    "use strict";

    var ns = ".foundation-form";

    $(document).on("foundation-mode-change" + ns, function(e, mode, group) {
        if(group !== "cq-damadmin-admin-bulkproperties"){
            return;
        }

        if (mode !== "edit" && mode !== "default"){
            return;
        }

        //the id is defined here /libs/dam/gui/content/assets/metadataeditor/items/content
        var form = $("#assetpropertiesform");

        var overlayTextField = $(form).find("[name='./jcr:content/metadata/overlayText']");

        //field already added
        if(overlayTextField && overlayTextField.length > 0){
            return;
        }

        var assetPath = $(form).attr("action");
        assetPath = assetPath.substring(0, assetPath.lastIndexOf(".html"));

        $.ajax({
            url: assetPath + "/jcr:content/metadata.json",
            dataType: "json",
            success: function(data){
                var value = data["overlayText"];

                if(!value){
                    value = "";
                }

                overlayTextField = "<div class='grid-1'>" +
                                        "<label>" +
                                            "<span>Overlay Text</span>" +
                                            "<span class='foundation-field-editable'>" +
                                            "<span class='foundation-field-readonly'>" + value + "</span>" +
                                            "<input type='text' size=54 name='./jcr:content/metadata/overlayText' value='" + value + "' class='foundation-field-edit' />" +
                                            "</span>" +
                                        "</label>" +
                                    "</div>";

                var asset = $(form).find(".assets-metadata-view");
                asset.append(overlayTextField);
            }
        });
    });
})(document, Granite, Granite.$);

In the above code, logic listens to granite framework foundation-mode-change event, checks the dam editor mode for edit and adds overlayText field



AEM CQ 56 - Disable HTML5SmartImage Map Tools

Goal


Disable some of the not required html5smartimage (CQ.html5.form.SmartImage) Image Map tools. Image map tools allow an author to create hotspots on image as shapes (Rectangle, Circle etc. ) by specifying the mapParameter in image configuration. Here we disable shapes other than Rectangle forcing the author to use only rectangles while creating hotspots

Package Install




Solution


We need a clientlib to add necessary js logic for disabling the tools

1) Login to CRXDE Lite (http://localhost:4502/crx/de) and create folder /apps/disable-imagemap-tools

2) Create node /apps/disable-imagemap-tools/clientlib of type cq:ClientLibraryFolder and add a String property categories with value cq.widgets. Here if you cannot use otb category cq.widgets, add a custom category (my.custom.clientlib) and make sure you conditionally include the clientlib in page component jsp (<cq:includeClientLib categories="my.custom.clientlib" />)

3) Create file (nt:file) /apps/disable-imagemap-tools/clientlib/js.txt and add

                       disable-tools.js

4) Create file (nt:file) /apps/disable-imagemap-tools/clientlib/disable-tools.js and add the following code

CQ.Ext.ns("ExperienceAEM");

ExperienceAEM.Html5SmartImage = {
    mapToolRectangleOnly: function(image){
        var mapTool = null;

        CQ.Ext.each(image.imageToolDefs, function(tool){
            if(tool.toolId == "smartimageMap"){
                mapTool = tool;
            }
        });

        var toolBar = mapTool.userInterface.getTopToolbar();

        var tools = toolBar.findBy(function(comp){
            return comp["toggleGroup"] == "mapperTools";
        }, toolBar);

        CQ.Ext.each(tools, function(tool){
            if( (tool.text != "Rectangle") && (tool.text != "Edit") ){
                tool.setDisabled(true);
            }
        });
    }
};

5) Add a listener on component's html5smartimage widget. Please note that it is not advisable modifying foundation components (say /libs/foundation/components/image). This solution is for custom components. So i have an image component /apps/disable-imagemap-tools/image and adding a listener on the custom image component's node /apps/disable-imagemap-tools/image/dialog/items/image




Here we are registering a loadcontent event listener with function created in the clientlib above

                       function(f) { ExperienceAEM.Html5SmartImage.mapToolRectangleOnly(f); }

Image instance is passed to the js function; logic executes on image load and disables shapes other than Rectangle

AEM CQ 561 - Add a button to Sidekick Bottom Toolbar

Goal


Add a button for viewing the page in wcmmode disabled. Here, on clicking the button added to sidekick bottom toolbar (beside Edit button), allows the user to view page in wcm disabled mode.

Demo



Solution


1) Login to CRXDE Lite (http://localhost:4502/crx/de) and create folder /apps/sidekick-button-wcmmode-disabled

2) Create node /apps/sidekick-button-wcmmode-disabled/clientlib of type cq:ClientLibraryFolder and add a String property categories with value cq.widgets

3) We need a button icon, so create file (nt:file) /apps/sidekick-button-wcmmode-disabled/clientlib/css.txt and add

                       wcmmode-disabled.css

4) Add the following code in /apps/sidekick-button-wcmmode-disabled/clientlib/wcmmode-disabled.css


#CQ .cq-sidekick .x-window-bbar .cq-sidekick-wcmmode-disabled {
    background-image:url(wcmmode-disabled.png);
}


5) Download and check-in the image wcmmode-disabled.png to /apps/sidekick-button-wcmmode-disabled/clientlib

6) Create file (nt:file) /apps/sidekick-button-wcmmode-disabled/clientlib/js.txt and add

                       add-wcmmode-disabled-button.js

7) Create file (nt:file) /apps/sidekick-button-wcmmode-disabled/clientlib/add-wcmmode-disabled-button.js and add the following code

CQ.Ext.ns("ExperienceAEM");

ExperienceAEM.Sidekick = {
    WCMMODE_DISABLED_BUTTON_ID: "experience-aem-sk-button-wcmmode-disabled",

    //add the button to sidekick bottom bar
    addWCMModeDisabled: function(sk){
        var bbar = sk.getBottomToolbar();
        var dButton = bbar.getComponent(0);

        //if the sidekick is reloaded, remove existing and add a fresh one
        if(dButton.getId() == this.WCMMODE_DISABLED_BUTTON_ID){
            bbar.remove(dButton, true);
        }

        dButton = new CQ.Ext.Button({
            id: this.WCMMODE_DISABLED_BUTTON_ID,
            iconCls: "cq-sidekick-wcmmode-disabled",
            tooltip: {
                title: "Disabled",
                text: "Switch to wcmmode=disabled"
            },
            handler: function() {
                var win = CQ.WCM.isContentWindow(window) ?  window.parent : window;
                win.location.href = sk.getPath() + ".html?wcmmode=disabled";
            },
            scope: sk
        });

        //add the button as first component in bottom toolbar
        bbar.insert(0, dButton );
    }
};

(function(){
    var E = ExperienceAEM.Sidekick;

    if( ( window.location.pathname == "/cf" ) || ( window.location.pathname.indexOf("/content") == 0)){
        //when the sidekick is ready CQ fires sidekickready event 
        CQ.WCM.on("sidekickready", function(sk){
            //after the sidekick content is loaded, add wcmmode disabled button 
            sk.on("loadcontent", function(){
                E.addWCMModeDisabled(sk);
            });
        });
    }
})();




AEM CQ 56 - Disable Cancel Inheritance in Live Copies

Goal


Multi Site Manager is a fantastic concept, very useful in creating multinational sites. You create a source site, create live copies of it for each region and maintain the relationship between source site and live copies, so that any updates on source can be rolled out to live copies

An admin of live copy can break the inheritance relationship with source, by clicking on a component's Cancel Inheritance Lock Icon

In this post we'll work on logic to provide an option to live copy creator, to disable cancel inheritance in live copies. So the live copy author cannot edit components rolled out from source; he can always add new components, but cannot alter existing ones. Check demo and Download package install. For a more granular approach on how to set the Cancel Inheritance option for various users/groups check this post

when a live copy creator checks the Disable Cancel Inheritance option in Create Size Wizard, a property cq:isDisableCancelInheritance is set to true on the live copy root jcr:content node
     


Page dialogs, components read the property cq:isDisableCancelInheritance and act accordinly



Solution - 1


With few JS lines of code we can disable the cancel inheritance options

1) Login to CRXDE Lite, create folder (nt:folder) /apps/msm-disable-cancel-inheritance

3) Create clientlib (type cq:ClientLibraryFolder/apps/msm-disable-cancel-inheritance/clientlib and set a property categories of String type to cq.widgets

4) Create file ( type nt:file ) /apps/msm-disable-cancel-inheritance/clientlib/js.txt, add the following

                         disable-cancel-inheritance.js

5) Create file ( type nt:file ) /apps/msm-disable-cancel-inheritance/clientlib/disable-cancel-inheritance.js, add the following code

CQ.Ext.ns("ExperienceAEM");

ExperienceAEM.CreateSite = {
    disableCancelInheritanceFlag: false,

    //add the disable cancel inheritance option to create site wizard
    addDisableCancelInheritance: function(grid){
        var toolBar = grid.getTopToolbar();

        var newMenu = toolBar.findBy(function(comp){
            return comp["iconCls"] == "cq-siteadmin-create-page-icon";
        }, toolBar)[0];

        var newSite = newMenu.menu.findBy(function(comp){
            return comp["iconCls"] == "cq-siteadmin-create-site-icon";
        }, newMenu)[0];

        newSite.on('click', function(){
            var dlg = CQ.WCM.getDialog("", "cq-siteadmin-csw", true);

            dlg.navHandler = function(d) {
                CQ.wcm.CreateSiteWizard.prototype.navHandler.call(this, d);
                var idx = this.activePage + d;

                //we are at the live copy page in wizard
                if(idx == 4){
                    var liveCopyPanel = this.wizPanel.layout.activeItem;
                    liveCopyPanel.add(new CQ.Ext.form.Checkbox({
                        fieldDescription: "Live copy owners will not be able to cancel component inheritance",
                        fieldLabel: 'Disable Cancel Inheritance',
                        name: "./cq:isDisableCancelInheritance",
                        inputValue: true,
                        checked: false
                    }));
                    liveCopyPanel.doLayout();
                }
            };
        })
    },

    disableCancelInheritance: function(){
        var sk = CQ.WCM.getSidekick();
        var pathTokens = sk.getPath().split("/");
        var siteSourcePath = "/" + pathTokens[1] + "/" + pathTokens[2] + "/jcr:content.json";

        $.ajax({ url: siteSourcePath, async: false, dataType: "json",
            success: function(data){
                this.disableCancelInheritanceFlag = eval(data["cq:isDisableCancelInheritance"]);
            }.createDelegate(this)
        });

        if(!this.disableCancelInheritanceFlag){
            return;
        }

        var editables = CQ.utils.WCM.getEditables();

        CQ.Ext.iterate(editables, function(path, editable) {
            if(!editable.addElementEventListener){
                if(editable.liveStatus){
                    editable.liveStatus.setDisabled(true);
                    editable.liveStatus.setTooltip("Creator of this livecopy has disabled cancel inheritance");
                }
                return;
            }

            editable.on(CQ.wcm.EditBase.EVENT_BEFORE_EDIT, function(){
                var INTERVAL = setInterval(function(){
                    var dialog = editable.dialogs[CQ.wcm.EditBase.EDIT];

                    if(dialog){
                        clearInterval(INTERVAL);

                        if(dialog.editLockButton){
                            dialog.editLockButton.setDisabled(true);
                            dialog.editLockButton.setTooltip("Creator of this livecopy has disabled cancel inheritance");
                        }
                    }
                }, 200);
            });

            //disable some inheritance specific options in context menu
            editable.addElementEventListener(editable.element.dom, "contextmenu" , function(){
                var msm = this["msm:liveRelationship"];

                if(!msm || !msm["msm:status"] || !msm["msm:status"]["msm:isSourceExisting"]){
                    return;
                }

                var component = this.element.linkedEditComponent;

                if (!component || !component.menuComponent) {
                    return;
                }

                var menu = component.menuComponent;
                var opts = [ menu.find('text', "Delete"), menu.find('text', "Cut") ];

                CQ.Ext.each(opts, function(opt){
                    if(opt && opt.length > 0){
                        opt[0].setDisabled(true);
                    }
                });
            }, true, editable);

            //disable the top right lock icon of editable
            editable.element.on('click', function(){
                var lock = this.highlight.lock;

                if(lock){
                    lock.getEl().dom.title = "Creator of this livecopy has disabled cancel inheritance";
                    lock.setDisabled(true);
                    lock.getEl().removeAllListeners();
                }
            }, editable);
        });

        //disable the sidekick lock status button which allows cancel inheritance of editables
        var lcsBut = sk.liveCopyStatusButton;

        if(lcsBut){
            lcsBut.setDisabled(true);
            lcsBut.setTooltip("Creator of this livecopy has disabled livecopy status");
        }else{
            sk.on('loadcontent', function(){
                lcsBut = sk.liveCopyStatusButton;
                lcsBut.setDisabled(true);
                lcsBut.setTooltip("Creator of this livecopy has disabled livecopy status");
            });
        }
    },

    //register this function as listener for "loadcontent" event on dialog
    disableSkCancelInheritance: function(dialog){
        if(!this.disableCancelInheritanceFlag){
            return;
        }

        var fields = CQ.Util.findFormFields(dialog.formPanel);

        CQ.Ext.iterate(fields, function(name, f){
            CQ.Ext.each(f, function(field){
                if(field.lockPanel){
                    field.lockPanel.setDisabled(true);
                }else if(field.fieldEditLockBtn){
                    field.fieldEditLockBtn.setDisabled(true);
                    field.fieldEditLockBtn.setTooltip("Creator of this livecopy has disabled cancel inheritance");
                }
            })
        });
    }
};

(function(){
    var E = ExperienceAEM.CreateSite;

    if(window.location.pathname == "/siteadmin"){
        var INTERVAL = setInterval(function(){
            var grid = CQ.Ext.getCmp("cq-siteadmin-grid");

            if(grid){
                clearInterval(INTERVAL);
                E.addDisableCancelInheritance(grid);
            }
        }, 250);
    }else if( ( window.location.pathname == "/cf" ) || ( window.location.pathname.indexOf("/content") == 0)){
        if(CQ.WCM.isEditMode()){
            CQ.WCM.on("editablesready", E.disableCancelInheritance, E);
        }
    }
})();

4) In the above code addDisableCancelInheritance function adds a checkbox option Disable Cancel Inheritance to create size wizard; when checked the live copy user cannot disable cancel inheritance, when unchecked the live copies will have cancel inheritance lock enabled

5) The following logic in disableCancelInheritance function, iterates through available editables on page, and disables the lock icon in edit dialogs

 editable.on(CQ.wcm.EditBase.EVENT_BEFORE_EDIT, function(){
                var INTERVAL = setInterval(function(){
                    var dialog = editable.dialogs[CQ.wcm.EditBase.EDIT];

                    if(dialog){
                        clearInterval(INTERVAL);
                        dialog.editLockButton.setDisabled(true);
                        dialog.editLockButton.setTooltip("Creator of this livecopy has disabled cancel inheritance");
                    }
                }, 200);
            });

6) We should also disable some context menu options that allow the user to cancel inheritance like deleting a component rolled out from source. Below logic in function disableCancelInheritance does the part of disabling such context menu options

editable.addElementEventListener(editable.element.dom, "contextmenu" , function(){
 var msm = this["msm:liveRelationship"];
 ....
 ....
 ....
}, true, editable);

7) The Page properties dialog of sidekick also allows user to cancel inheritance. The function disableSkCancelInheritance takes care of cancel inheritance lock icons in page properties. For this logic to execute we register a loadcontent event listener on page component dialog

                   function(d){ ExperienceAEM.CreateSite.disableSkCancelInheritance(d); }


This is a manual task and user has to add listener on dialogs of "disable cancel inheritance required page components". I couldn't find a way to automate it, which would have been ideal

8) This extension was not extensively tested with all available integrations like Sitecatalyst, Target. There is scope for some options that come with integrations allowing user to cancel inheritance, so there is always room for improvement

Leave a comment if you find bugs...


Solution 2 - Deny Cancel Inheritance to User/Group


A privilege at JCR level also gives an option to restrict authors from cancelling inheritance - check this post. Consider a scenario where a live copy administrator would like to restrict user author from cancelling inheritance of a paragraph..

1) In CRXDE Lite (http://localhost:4502/crx/de) browse to par of live copy page (eg. /content/my-blueprint-site1/en/summer/jcr:content/par)

2) In the right pane, click on Access Control -> Add Entry

3) Search for user author, Select Type as Deny, Check jcr:nodeTypeManagement privilege and Click ok

4) A text component added in par inherits the ACL

5) when user author tries to cancel the relationship by clicking on Cancel Inheritance Lock icon, he sees error.

6) Deny jcr:nodeTypeManagement should be set on every live copy page paragraph for NOT allowing cancel inheritance for a user/group. Rollout from blueprint will not effect the deny permission set.