Umbraco V8, Variants and limiting editor access by language - an adventure story

Written by @marcemarc on Wednesday, 27 January 2021

The ‘new’ version of Umbraco V8 trumpeted the arrival of language variants into the core of Umbraco.

Previously on Umbraco

Previously on Umbraco, for large multilingual content managed offerings you would implement multiple content tree structures, one for each language and ‘relate’ alternate translations of pages together using relations OR you would use the ‘Vorto package’ to enable you to edit variations of language on a single node. 

undefined

The decision on which approach to use depended largely on two factors: 

1) how the editors would update the content, eg one multi-lingual editor handling multiple-translations of a site - Vorto would win hands down - if different editors in different countries, editing only one language: the multi-tree approach had more going for it. 

2) Whether the sites would be translated 1-1, same images, same layout, same pages, just 'translated text' OR whether actually the flexibility would be needed to have different pages in different territories to bend and shape the content to suit the culture it was being published too.

The desire for a mechanism to ‘vary’ content in Umbraco for different audiences, but managed 'on the same content item', had been on a lot of people’s wish lists and regularly Codegarden talks would focus on how a solution had cleverly worked around these deficiencies to deliver personalisation of some kind or a multi-lingual setup to draw deep impressed breaths from the Umbraco developer brethren.

To implement this 'properly' in the Umbraco core would mean changes to the underlying database tables, which meant it was an ideal candidate for the next major version at the time (V8) - and implementing a‘Vorto-like’ language variants experience was seen as a way to validate the underlying structural changes that would deliver the future exciting prospects of variants and deliver a ‘new feature’ at the same time.

The reason why I give this vague take on the history, (and this may not be exactly what happened!) - is that I think the aim of language variants in V8 wasn’t to necessarily implement a completely nuanced, feature-complete language editing experience in Umbraco that would work in all scenarios, (even if it may have felt to have been marketed in this way) - but more to introduce the concept of ‘variants’, that would enable language variant functionality to be iterated and improved upon over time and pave the way for ‘other things’ to 'vary' - the fruits of which people are now harvesting in the uMarketingSuite package.

I think the context helps understand some of the anomalies, and really, to be fair, it turns out that ‘language variants’ probably aren’t a ‘simple’ first variant implementation, because language editing ‘is different’ to a single editor just varying content for a different audience, language and translations and cultures is hard the whole world over - but what initially exists in V8, with the side-by-side editing is pretty cool.

[Aside - But also,  I should declare I AM bitter about variants, as I suspect it’s the reason why we’ve lost the ‘Change Document Type’ functionality from the right-click menu in the content tree - because the changes in the database structure + complexity of the UI to explain you are changing the doctype for each different language variation... well 'would be quite hard to do' - and what if the new doctype ‘doesn’t vary’? etc. I miss 'Change Document Type' as a thing.]

undefined

Anyway, Anyway, this far in and I'm already using double Anyways... 

The adventure starts here

Anyway, nearly two years on from V8’s release I’m implementing a repository of global products - it isn’t a website in itself but rather a central location for Products and their technical details to be updated/translated. The products will be available to display in multiple 'other Umbraco sites' in multiple territories and multiple languages - via an API layer (in this case driven rather robustly and scalably by Azure Search).

This makes V8 Variants a good candidate for the solution, it will provide the side-by-side translating of content experience (which I have to say, demo’d very well...) and there will be a single point of truth for a product, and it's language variations. Some properties for a product will be ‘fixed’, eg 'Product Code', and some can be varied by language, eg the 'Product Summary'.

However, one thing to be aware of, is that in Umbraco, permissions for backoffice Users' content managing actions are ‘node’ based, so if you give somebody permission to publish a product, they can choose to edit and publish that product in each of its language variations. 

undefined

There is an issue on the tracker, https://github.com/umbraco/Umbraco-CMS/issues/3830 where the super genius Kjac identifies the problem and proposes a nice solution.

In our scenario this isn’t too much of a problem, as the products will likely be translated once, and not be updated too often, the ‘marketing information’ will be supplied in the Umbraco sites that ultimately display the products, this is only really for the technical information, so the chances of new territory editors logging in, and accidentally updating and publishing the wrong language variant is unlikely. We trust them to tick the right box.

undefined

That said, having seen the demo, the client assumed ‘it would be possible’ to set which languages a backoffice User had access to edit and publish and were surprised it was not possible.

I guess if you are building a language variant editing scenario that IS one of the User journeys that you would reasonably expect to have been catered for, but in the slightly differently focussed scenario of validating the concept of variants, you can see how that journey might not have been included!

Yet! - nobody disputes it isn't a good idea, it will undoubtedly be implemented at some point in the future.

Setting Sail

Our client wanted to know what would be involved if they did REALLY need this functionality, so I carried out a quick development spike to see what could be achieved by utilising the existing extension points of Umbraco… 

… this is how I got on.

Assigning Permissions

First, we need a way of saying ‘Can the current backoffice user manage a particular language?’ - it’s possible to set a default language for a User, but only 'one language', what if a user needs to be able to manage multiple languages? Well, Umbraco has the functionality of ‘User Groups’ so this seems the natural way to manage who can see what, without any additional extension of Umbraco

We created some new User Groups, and as there aren't really any ways to extend User Groups with metadata and because I love a good convention... we followed one:

  • LanguageEditors_de-DE
  • LanguageEditors_es-ES
  • LanguageEditors_en-GB

By following this naming convention it allows us to neatly map a User Group to a culture code - ok it’s a hack! By tacking on the culture code of the group though - it means new groups can be created in the future, without having to change the implementation code. It's easy to understand. 

undefined

The thinking here is: ‘any user’ in a User Group starting with ‘LanguageEditors’ is someone who needs to have their language variant options restricted. If you are not in one of these groups, Umbraco will operate in the standard way for you... seeing all variants…

(if you are unhappy with this convention approach, I suggest you stop reading now, as there is a much more outrageous hack later on)

Applying the convention

Our old friend the EditorModelEventManager.SendingContentModel event to the rescue. - https://our.umbraco.com/documentation/reference/events/EditorModel-Events/

Normally you’d use this event to set default values for properties, it’s fired ‘just before’ a backoffice node is displayed in Umbraco for editing, and it exposes the complete Model of the editable content, including all variants (e.Model.Variants)  and basically, it enables you to tweak things at the last moment! 

So if we check that the page being loaded for editing ‘has variants’ - we can get a reference to the Current User and check their User Group membership to see if they are in a ‘LanguageEditors’ group, and if they are - use this list of groups to filter the variants they are allowed to see. 

private void EditorModelEventManager_SendingContentModel(System.Web.Http.Filters.HttpActionExecutedContext sender, EditorModelEventArgs<Umbraco.Web.Models.ContentEditing.ContentItemDisplay> e)
      {
          bool hasVariants = e.Model.Variants.Count() > 1;
          // remove variants from users in Language Restricted User Groups
          if (hasVariants) {
              using (UmbracoContextReference umbracoContextReference = _umbracoContextFactory.EnsureUmbracoContext()) {
                  var umbracoContext = umbracoContextReference.UmbracoContext;
                  IUser currentUser = umbracoContext.Security.CurrentUser;
                  var languageGroups = currentUser.GetCurrentUserLanguageGroups();
                  bool hasLanguageGroupRestrictions = languageGroups.Any();
                  if (hasLanguageGroupRestrictions) {              
                          e.Model.Variants = e.Model.Variants.Where(f => f.Language != null && (languageGroups.Contains(f.Language.IsoCode.ToLower())));
                  }                  
              }
          }              
      }

and the custom extension method on IUser, that retrieves the groups looks like this:


public static class UserLanguageGroupExtensions  {
      public static IEnumerable<string> GetCurrentUserLanguageGroups(this IUser currentUser) {
          List<string> languageGroups = new List<string>();
          foreach (var group in currentUser.Groups) {
              // check for 'special LanguageEditors group membership
              if (group.Name.StartsWith("LanguageEditors_")) {
                  //strip of the culture from the end of the language User Group name...
                  languageGroups.Add(group.Name.Split(new char[] { '_' }, StringSplitOptions.RemoveEmptyEntries)[1].ToLower());
              }
          }
          return languageGroups;
      }
    }

Now, when the user creates or edits a page in Umbraco, only the language variants matching the User Groups, are displayed for 'switching to' and 'side-by-side' comparison…

undefined

and if a user saves or publishes the page, the checkbox options only match the languages they are allowed to manage. Neat!

undefined

This is great, but if you want the editor to see the fallback default language, perhaps English, for side by side comparison, but you don't want the editor to be able to change the content of the fallbacck default language, then it gets a bit messy...

Populating default values for variants

One option is to again use the EditorModelEventManager_SendingContentModel event - and  ‘read’ all the existing fallback language property values - and use these as ‘default values’ for the variants that haven’t been set yet… 

The trick here is to realise that all variants have a 'State' and those that haven't been created yet have the ContentSavedState.NotCreated state - so any new variants get the default language values as umm defaults:


if (hasVariants) {
var defaultEnglishVariant = e.Model.Variants.FirstOrDefault(f => f.State != Umbraco.Web.Models.ContentEditing.ContentSavedState.NotCreated && f.Language.IsoCode == "en-GB");
if (defaultEnglishVariant != null) {
// we have english variant
// get existing English variant values
var defaultEnglishVariantProperties = defaultEnglishVariant.Tabs.SelectMany(f => f.Properties);
// now set the name to be the English name for all uncreated variants
foreach (var variant in e.Model.Variants.Where(f => f.State == Umbraco.Web.Models.ContentEditing.ContentSavedState.NotCreated && f.Language.IsoCode != "en-GB")) {                      
variant.Name = defaultEnglishVariant.Name;
foreach (var property in variant.Tabs.SelectMany(f=>f.Properties)) {
property.Value = defaultEnglishVariantProperties.FirstOrDefault(f=>f.Alias.InvariantEquals(property.Alias))?.Value;
}  
}
}
}

This provides No side-by-side comparison - but the translator can see the default values they are aiming to translate…

Beside-by-side ourselves

But we want the lovely side-by-side comparison that we demo to everyone…

So I thought a bit more…

Could we allow the editor to see the fall back English version?, for side-by-side translation, but then put a ‘hard stop’ on saving ‘anything’ if the English version was accidentally updated...

For this, I looked at the Content Saving event of the Content Service, and the IsSavingCulture extension method of the ContentSavingEventArgs, which allows you to check if a particular language culture is being saved during the event, eg if the English Version was being saved, I could cancel the save operation:


private void ContentService_Saving(IContentService sender, Events.ContentSavingEventArgs e) {
     IUser currentUser = default;
     List<string> languageGroups = new List<string>();
     bool isSavingAVariantWithoutPermission = false;
     bool checkLanguageGroups = false;

     using (UmbracoContextReference umbracoContextReference = _umbracoContextFactory.EnsureUmbracoContext())  {
          var umbracoContext = umbracoContextReference.UmbracoContext;
          currentUser = umbracoContext.Security.CurrentUser;
          languageGroups = currentUser.GetCurrentUserLanguageGroups().ToList();
          checkLanguageGroups = languageGroups.Any();
     }

     foreach (var entity in e.SavedEntities) {
          if (checkLanguageGroups && !isSavingAVariantWithoutPermission) {
               //cultures being saved
               var savingCultures = entity.AvailableCultures.Where(f => e.IsSavingCulture(entity, f)).ToList();
               isSavingAVariantWithoutPermission = CheckCurrentUserIsSavingAVariantWithoutPermission(languageGroups, savingCultures);
          }      }
     if ( isSavingAVariantWithoutPermission) {
          e.Cancel = true;
          e.Messages.Add(new Events.EventMessage("Saving Cancelled", "You do not have permissions to save all these language variations", Events.EventMessageType.Error));
     } }

undefined

This enables us to update the logic in the EditorModelEventManager_SendingContentModel to ALWAYS allow the default language, en-GB, in this case to be displayed for people who have these restricted LanguageEditors permissions:


     if (hasLanguageGroupRestrictions) { 
          e.Model.Variants = e.Model.Variants.Where(f => f.Language != null && (languageGroups.Contains(f.Language.IsoCode.ToLower()) || f.Language.IsoCode == "en-GB"));
    }

We've cracked it... side-by-side comparison and restricted editors can't save or update the default language, it's not a perfect UX, getting the error message, but default content is safe... we are there... 

... but not quite ... if the editor switches to view the default language, and clicks in one of the fields in the default language accidentally, even if they don't update a value - and seemingly sometimes even if they only open it for side-by-side comparison - it's likely the angularJS will mark the default language variant as 'dirty' - and even though nothing has changed, the IsSavingCulture method will return true for the default language variant and our code above will stop the editor saving anything!

... a few idle click tests shows this will happen quite a lot... grrr

What we really really want is the properties on the fallback English culture to be ‘visible’ but not editable - like a ‘read only’ state…

Read Only

Wait a minute, didn’t we see a ‘Read Only’ option for each variant property when we were trying the option of ‘just’ copying the English values across - hang on, this is it, we just need to set that to true in the EditorModelEventManager_SendingContentModel event for each property of the default language variant and I bet we won’t even need the ContentSaving event to ‘stop the save’, ‘stop the save’...


if (!languageGroups.Contains("en-gb")) {
// set default entries to be readonly
foreach (var variant in e.Model.Variants) {
if (variant.Language != null && variant.Language.IsoCode == "en-GB") {                        
foreach (var tab in variant.Tabs) {
foreach (var property in tab.Properties) {                                    
property.Readonly = true;
}
}
}
}
}

Et voila…

zut alors!

(still not sure I can use French phrases in a post-Brexit world) 

undefined

The property is still ‘editable’ how come? I'm fairly sure I know what should happen when I set a property to be Read Only! 

Sadly, it turns out the ‘Read Only’ property doesn’t change the UI !!- the angularJS property editors aren’t looking for this value, even though it is set to true on their 'model', and I 'bet wrong' - the property editors don’t all magically become ‘read-only’ when this is set - they operate normally… (though any changes aren’t saved!) 

It’s been raised as an issue here, but there is a need to identify a use case:

https://github.com/umbraco/Umbraco-CMS/issues/7906

Thwarted - read-only another way?

Thwarted once more, but hang on let's not give up - looking at the property editor model again - I can also change the ‘view’ that the property editor is using for each property to become the  ‘readonlyvalue’ (this is the view used for the non-editable label property editor):


   foreach (var tab in variant.Tabs) {
        foreach (var property in tab.Properties) {
             property.View = "readonlyvalue";
             property.Readonly = true;
         }
    }

This works reasonably well for the rich text editor or textbox properties, the text values of the properties are displayed, and can be used for side by side editing - but pickers or the grid only render their ugly data eg just display the Guids of the picked items or the JSON. This just makes it look like the page hasn't loaded properly, but would I suppose be viable if you only had textboxes and rich text areas to compare between variants.

undefined

It also breaks the display of non-varying property content when you switch to the non-default language variants view - normally these non-editable properties display their editor and the content but appear greyed out and looks really odd if this is the raw data instead… and gives off the scent that something is broken.

The 'readonlyvalue' view is only really for the 'label' editor - but in theory you could create a custom read-only version of each property editor view and use this trick to switch each type to a nice rendering of the way the editor stores its content in a read-only fashion... sigh... which would feel like a lot of work! and I just want it 'to work'.

What I want is the properties to look like they normally do, but just be greyed out and non-editable - in fact EXACTLY like those non-varying properties when they are displayed on a language variant doc-type, you still see it, but it’s greyed out…

undefined

I wondered how is that done?

So I had a look at the source code and I found an angularJS helper: propertyEditorDisabled(property) that is called for each property, what’s the logic here? 

https://github.com/umbraco/Umbraco-CMS/blob/34e80d86e8c0b754f6b7a02e307f53cb32806bbe/src/Umbraco.Web.UI.Client/src/common/directives/components/content/umbtabbedcontent.directive.js#L142


var canEditCulture = !contentLanguage ||
// If the property culture equals the content culture it can be edited
property.culture === contentLanguage.culture ||
// A culture-invariant property can only be edited by the default language variant
(property.culture == null && contentLanguage.isDefault);

So basically a property becomes 'read-only' if the 'culture of the property' doesn’t match the 'culture of the page' being edited (eg for a non-varying by culture property)...

(hmm, might have a look at this later, would it be a good place to implement the ‘readonly’ setting?) 

Outrageous Hack

So now I give you our 'outrageous hack' - all we need to do, to trigger the core to make the property editor disabled and read-only and still show it’s content but greyed out… is to set the language culture of the property to be different to the language culture of the page… 

… lets go with en-CX - which of course is the language culture of Christmas Island: https://en.wikipedia.org/wiki/Christmas_Island 

(yes, no need to tweet me - I know this hack won’t work for websites that are built with the Christmas Island culture in mind… but I felt it was obscure enough for most sites… and unlikely to cause a clash!)


  foreach (var tab in variant.Tabs) {
       foreach (var property in tab.Properties) {
              property.Culture = "en-CX";
              property.Readonly = true;
}
  }

Which is exactly how we want it to behave! - the fallback language properties are visible to the editor for the side-by-side comparison, but aren’t editable, and are greyed out to indicate as such, this prevents the fallback language from becoming ‘dirty’ or accidentally updated.

undefined

We almost don’t need the Content Service in place with it’s 'hard stop': but even in this setup it’s still possible for an editor to change the ‘Name’ of the default fallback language content - so if total lockdown is your aim, you can keep the contentservice saving event, and with this 'Christmas Island hack' in place, the IsSavingCulture method performs reliably.

Blimey, we got there in the end!

But also, we probably don't want editors to be able to 'publish' pages of different language/cultures either, if they are not in those groups - and on the ListView they can pick multiple items, and choose to publish multiple items - a dialog appears with checkboxes for each variant, and they can tick all of them, and potentially publish something that shouldn’t be published yet. 

undefined

Sigh, and there is also the main language switching navigation dropdown too - you can still see all the language dropdown options there, even if you can’t edit them…

undefined

There are multiple places this happens, where all the languages are made visible to the editor, so somehow the hack still feels incomplete…

Bonus Hack

But one thing I noticed with all these situations - they all call the languageResource.getAll() method to get a list of all languages to display the options to the editor - which calls the api endpoint:

/umbraco/backoffice/UmbracoApi/Language/GetAllLanguages 

Now if you are not familiar with intercepting AngularJS requests - have a read of this excellent (5yr old article - still very relevant today in this context) from 24 days in Umbraco and the wunderkind Matt Brailsford: 

https://24days.in/umbraco-cms/2015/umbraco-7-back-office-tweaks/

And you’ll see there’s something further we can do - we can intercept all requests that are made to this get all languages endpoint - check the current user’s user groups, if they are in a language editor user group, we can use the convention again to filter out the ‘GetAllLanguages’ response, so it effectively becomes GetAllLanguagesForThisUser… (if User isn’t in a LanguageEditor group it will work as standard)

 Yes it’s the same hack - this time in javascript: 


angular.module('umbraco.services').config([
  '$httpProvider',
  function ($httpProvider) { 
      $httpProvider.interceptors.push(function ($q, $injector) {
          return {
              'response': function (response) { 
                  // Intercept requests content editor data responses
                  if (response.config.url.indexOf("/umbraco/backoffice/UmbracoApi/Language/GetAllLanguages") === 0) {
                        var userLanguageGroups = [];
                      var hasLanguageGroups = false;
                      var userService = $injector.get('userService');
                       // get Current User
                      userService.getCurrentUser().then(function (currentUser) {
                          if (currentUser !== null && currentUser.userGroups.length > 0) {
                              // loop for all groups and see if any are special 'language groups' ones to determine whether any filtering should take place
                              var i;
                              for (i = 0; i < currentUser.userGroups.length; i++) {
                                  console.log(currentUser.userGroups[i]);
                                  var userGroupName = currentUser.userGroups[i];
                                  // check group name starts with languageEditors
                                  if (userGroupName.startsWith("languageEditors")) {
                                      hasLanguageGroups = true;
                                        // split language group name on _ and add language code to userLanguageGroups array
                                      var languageGroupCultureCode = userGroupName.split("_")[1];
                                        userLanguageGroups.push(languageGroupCultureCode)
                                  }
                              }
                          }
                          // check if hasLanguageGroups is true
                            if (hasLanguageGroups) {
                              // if so loop through response.data and filter out any cultures not in the list of user languagesGroups
                              var i = response.data.length;
                              while (i--) {
                                  var availableCulture = response.data[i].culture.replace("-", "");
                                 // remove the culture from the response
                                    if (userLanguageGroups.indexOf(availableCulture) === -1) {
                                      response.data.splice(i, 1);
                                  } 
                              }
                          }
                      });
                  }
                  return response;
              }
          };
      });
  }]);

And now in the language navigation dropdown, you only get the languages that match the language editor groups people are in…

undefined

and when dialogs are shown to ask you to tick which variant for publishing purposes, only checkboxes are shown for those the editor is allowed to check.

undefined

There is of course a potential issue if GetAllLanguages changes in a later version of Umbraco, or is used in a piece of functionality that it’s not ok to filter by the current user groups… but hey we’ve just hacked the properties to all have the culture of the Christmas Islands - so I think this is worth the risk.. and also it's a good chance this will be implemented 'properly' one day in the core.

The end

Anyway, that’s the gist, the output of this 'deep dive' - to enable some editor restriction of the languages that they can view/edit in the Umbraco backoffice when using V8 and variants and using only existing extension points, a naming convention, and ermm some ‘hacks’. 

Hope this helps someone, or you could just trust the editors to tick the right box, I'm sure it will be ok if you do.