Updated the content display model to pass in a list of the allowed actions (using the letter of the permission). We then dynamically generate the action buttons on the content editor based on what the user is actually allowed to do and the current state of the content. Fixed up some content saving validation for Create + Publish at the same time, since we never allowed that behavior before we now have to check for both permissions during this one execution. Updated the Unpublish method to check for publish permissions - unpublish is an action but it is not permission assignable, you can only unpublish if you can publish. Fixes the user session timeout display timer.

This commit is contained in:
Shannon
2013-10-31 16:51:08 +11:00
parent ab6a26445e
commit 1ead3f9b6a
12 changed files with 276 additions and 66 deletions

View File

@@ -54,7 +54,7 @@ namespace Umbraco.Tests.Controllers.WebApiEditors
var userService = userServiceMock.Object;
//act/assert
Assert.Throws<HttpResponseException>(() => ContentController.CheckPermissions(new Dictionary<string, object>(), user, userService, contentService, 1234, 'F'));
Assert.Throws<HttpResponseException>(() => ContentController.CheckPermissions(new Dictionary<string, object>(), user, userService, contentService, 1234, new[] { 'F' }));
}
[Test]
@@ -77,7 +77,7 @@ namespace Umbraco.Tests.Controllers.WebApiEditors
var userService = userServiceMock.Object;
//act
var result = ContentController.CheckPermissions(new Dictionary<string, object>(), user, userService, contentService, 1234, 'F');
var result = ContentController.CheckPermissions(new Dictionary<string, object>(), user, userService, contentService, 1234, new[] { 'F'});
//assert
Assert.IsFalse(result);
@@ -106,7 +106,7 @@ namespace Umbraco.Tests.Controllers.WebApiEditors
var userService = userServiceMock.Object;
//act
var result = ContentController.CheckPermissions(new Dictionary<string, object>(), user, userService, contentService, 1234, 'F');
var result = ContentController.CheckPermissions(new Dictionary<string, object>(), user, userService, contentService, 1234, new[] { 'F'});
//assert
Assert.IsFalse(result);
@@ -135,7 +135,7 @@ namespace Umbraco.Tests.Controllers.WebApiEditors
var userService = userServiceMock.Object;
//act
var result = ContentController.CheckPermissions(new Dictionary<string, object>(), user, userService, contentService, 1234, 'F');
var result = ContentController.CheckPermissions(new Dictionary<string, object>(), user, userService, contentService, 1234, new[] { 'F'});
//assert
Assert.IsTrue(result);
@@ -223,7 +223,7 @@ namespace Umbraco.Tests.Controllers.WebApiEditors
var userService = userServiceMock.Object;
//act
var result = ContentController.CheckPermissions(new Dictionary<string, object>(), user, userService, null, -1, 'A');
var result = ContentController.CheckPermissions(new Dictionary<string, object>(), user, userService, null, -1, new[] { 'A'});
//assert
Assert.IsTrue(result);
@@ -247,7 +247,7 @@ namespace Umbraco.Tests.Controllers.WebApiEditors
var userService = userServiceMock.Object;
//act
var result = ContentController.CheckPermissions(new Dictionary<string, object>(), user, userService, null, -1, 'B');
var result = ContentController.CheckPermissions(new Dictionary<string, object>(), user, userService, null, -1, new[] { 'B'});
//assert
Assert.IsFalse(result);
@@ -271,7 +271,7 @@ namespace Umbraco.Tests.Controllers.WebApiEditors
var userService = userServiceMock.Object;
//act
var result = ContentController.CheckPermissions(new Dictionary<string, object>(), user, userService, null, -20, 'A');
var result = ContentController.CheckPermissions(new Dictionary<string, object>(), user, userService, null, -20, new[] { 'A'});
//assert
Assert.IsTrue(result);
@@ -295,7 +295,7 @@ namespace Umbraco.Tests.Controllers.WebApiEditors
var userService = userServiceMock.Object;
//act
var result = ContentController.CheckPermissions(new Dictionary<string, object>(), user, userService, null, -20, 'B');
var result = ContentController.CheckPermissions(new Dictionary<string, object>(), user, userService, null, -20, new[] { 'B'});
//assert
Assert.IsFalse(result);

View File

@@ -6,7 +6,13 @@ angular.module("umbraco.directives")
.directive('hotkey', function ($window, keyboardService, $log) {
return function (scope, el, attrs) {
var keyCombo = attrs["hotkey"];
//support data binding
var keyCombo = scope.$eval(attrs["hotkey"]);
if (!keyCombo) {
keyCombo = attrs["hotkey"];
}
keyboardService.bind(keyCombo, function() {
var element = $(el);

View File

@@ -5,7 +5,8 @@ angular.module("umbraco")
$scope.history = historyService.current;
$scope.logout = function () {
userService.logout().then(function() {
userService.logout().then(function () {
$scope.remainingAuthSeconds = 0;
$scope.hide();
});
};
@@ -16,14 +17,28 @@ angular.module("umbraco")
};
//Manually update the remaining timeout seconds
function updateTimeout() {
$timeout(function () {
$scope.user = userService.getCurrentUser();
//manually execute the digest against this scope only
$scope.$digest();
updateTimeout(); //keep going (recurse)
}, 1000, false); // 1 second, do NOT execute a global digest
}
updateTimeout();
function updateTimeout() {
$timeout(function () {
if ($scope.remainingAuthSeconds > 0) {
$scope.remainingAuthSeconds--;
$scope.$digest();
//recurse
updateTimeout();
}
}, 1000, false); // 1 second, do NOT execute a global digest
}
//get the user
userService.getCurrentUser().then(function (user) {
$scope.user = user;
if ($scope.user) {
$scope.remainingAuthSeconds = $scope.user.remainingAuthSeconds;
//set the timer
updateTimeout();
}
});
});

View File

@@ -12,7 +12,7 @@
<p class="muted">
<small>
<localize key="user_sessionExpires" />: {{user.remainingAuthSeconds | timespan}}</small>
<localize key="user_sessionExpires" />: {{remainingAuthSeconds | timespan}}</small>
</p>
</div>

View File

@@ -7,17 +7,94 @@
* The controller for the content editor
*/
function ContentEditController($scope, $routeParams, $q, $timeout, $window, contentResource, navigationService, localizationService, notificationsService, angularHelper, serverValidationManager, contentEditingHelper, fileManager, formHelper) {
contentResource.checkPermission("P", $routeParams.id).then(function(hasPermission){
var key = "buttons_sendToPublish"
if(hasPermission){
key = "buttons_saveAndPublish";
$scope.defaultButton = null;
$scope.subButtons = [];
//This sets up the action buttons based on what permissions the user has.
//The allowedActions parameter contains a list of chars, each represents a button by permission so
//here we'll build the buttons according to the chars of the user.
function configureButtons(content) {
//reset
$scope.subButtons = [];
//This is the ideal button order but depends on circumstance, we'll use this array to create the button list
// Publish, SendToPublish, Save
var buttonOrder = ["U", "H", "A"];
//Create the first button (primary button)
//We cannot have the Save or SaveAndPublish buttons if they don't have create permissions when we are creating a new item.
if (!$routeParams.create || _.contains(content.allowedActions, "C")) {
for (var b in buttonOrder) {
if (_.contains(content.allowedActions, buttonOrder[b])) {
$scope.defaultButton = createButtonDefinition(buttonOrder[b]);
break;
}
}
}
localizationService.localize(key).then(function(label){
$scope.publishButtonLabel = label;
});
});
//Now we need to make the drop down button list, this is also slightly tricky because:
//We cannot have any buttons if there's no default button above.
//We cannot have the unpublish button (Z) when there's no publish permission.
//We cannot have the unpublish button (Z) when the item is not published.
if ($scope.defaultButton) {
//get the last index of the button order
var lastIndex = _.indexOf(buttonOrder, $scope.defaultButton.letter);
//add the remaining
for (var i = lastIndex + 1; i < buttonOrder.length; i++) {
if (_.contains(content.allowedActions, buttonOrder[i])) {
$scope.subButtons.push(createButtonDefinition(buttonOrder[i]));
}
}
//if we are not creating, then we should add unpublish too,
// so long as it's already published and if the user has access to publish
if (!$routeParams.create) {
if (content.publishDate && _.contains(content.allowedActions,"U")) {
$scope.subButtons.push(createButtonDefinition("Z"));
}
}
}
}
function createButtonDefinition(ch) {
switch (ch) {
case "U":
//publish action
return {
letter: ch,
labelKey: "buttons_saveAndPublish",
handler: $scope.saveAndPublish,
hotKey: "ctrl+p"
};
case "H":
//send to publish
return {
letter: ch,
labelKey: "buttons_saveToPublish",
handler: $scope.saveAndPublish,
hotKey: "ctrl+t"
};
case "A":
//save
return {
letter: ch,
labelKey: "buttons_save",
handler: $scope.save,
hotKey: "ctrl+s"
};
case "Z":
//unpublish
return {
letter: ch,
labelKey: "content_unPublish",
handler: $scope.unPublish
};
default:
return null;
}
}
if ($routeParams.create) {
@@ -25,7 +102,8 @@ function ContentEditController($scope, $routeParams, $q, $timeout, $window, cont
contentResource.getScaffold($routeParams.id, $routeParams.doctype)
.then(function(data) {
$scope.loaded = true;
$scope.content = data;
$scope.content = data;
configureButtons($scope.content);
});
}
else {
@@ -34,6 +112,7 @@ function ContentEditController($scope, $routeParams, $q, $timeout, $window, cont
.then(function(data) {
$scope.loaded = true;
$scope.content = data;
configureButtons($scope.content);
//just get the cached version, no need to force a reload
navigationService.syncPath(data.path.split(","), false);
@@ -64,6 +143,8 @@ function ContentEditController($scope, $routeParams, $q, $timeout, $window, cont
rebindCallback: contentEditingHelper.reBindChangedProperties($scope.content, data)
});
configureButtons(data);
navigationService.syncPath(data.path.split(","), true);
});
}
@@ -85,6 +166,8 @@ function ContentEditController($scope, $routeParams, $q, $timeout, $window, cont
rebindCallback: contentEditingHelper.reBindChangedProperties($scope.content, data)
});
configureButtons(data);
navigationService.syncPath(data.path.split(","), true);
}, function(err) {
@@ -126,6 +209,8 @@ function ContentEditController($scope, $routeParams, $q, $timeout, $window, cont
rebindCallback: contentEditingHelper.reBindChangedProperties($scope.content, data)
});
configureButtons(data);
//fetch tree
navigationService.syncPath(data.path.split(","), true);
@@ -147,6 +232,14 @@ function ContentEditController($scope, $routeParams, $q, $timeout, $window, cont
return deferred.promise;
};
/** this method is called for all action buttons and then we proxy based on the btn definition */
$scope.performAction = function(btn) {
if (!btn || !angular.isFunction(btn.handler)) {
throw "btn.handler must be a function reference";
}
btn.handler.apply(this);
};
}
angular.module("umbraco").controller("Umbraco.Editors.Content.EditController", ContentEditController);

View File

@@ -25,26 +25,26 @@
</a>
</div>
<div class="btn-group">
<a class="btn btn-success" href="#" ng-click="saveAndPublish()" prevent-default data-hotkey="ctrl+p">{{publishButtonLabel}}</a>
<a class="btn btn-success dropdown-toggle" data-toggle="dropdown">
<div class="btn-group" ng-if="defaultButton">
<!-- primary button -->
<a class="btn btn-success" href="#" ng-click="performAction(defaultButton)" prevent-default data-hotkey="{{defaultButton.hotKey}}">
<localize key="{{defaultButton.labelKey}}">{{defaultButton.labelKey}}</localize>
</a>
<a class="btn btn-success dropdown-toggle" data-toggle="dropdown" ng-if="subButtons.length > 0">
<span class="caret"></span>
</a>
<!-- sub buttons -->
<ul class="dropdown-menu" role="menu" aria-labelledby="dLabel" ng-if="subButtons.length > 0">
<li ng-repeat="btn in subButtons">
<a href="#" ng-click="performAction(btn)" prevent-default data-hotkey="{{btn.hotKey}}">
<localize key="{{btn.labelKey}}">{{btn.labelKey}}</localize>
</a>
</li>
</ul>
<ul class="dropdown-menu" role="menu" aria-labelledby="dLabel">
<li>
<a href="#" ng-click="save()" prevent-default data-hotkey="ctrl+s">
<localize key="buttons_save">Save</localize>
</a>
</li>
<li ng-show="content.publishDate">
<a href="#" ng-click="unPublish()" prevent-default>
<localize key="content_unPublish">Unpublish</localize>
</a>
</li>
</ul>
</div>
</div>
</div>

View File

@@ -480,7 +480,10 @@ namespace Umbraco.Web.Editors
/// </summary>
/// <param name="id"></param>
/// <returns></returns>
[EnsureUserPermissionForContent("id", 'Z')]
//TODO: Unpublish is NOT an assignable permission therefore this won't work, I'd assume to unpublish you'd need to be able to publish??!
// still waiting on feedback from HQ.
//[EnsureUserPermissionForContent("id", 'Z')]
[EnsureUserPermissionForContent("id", 'U')]
public ContentItemDisplay PostUnPublish(int id)
{
var foundContent = GetObjectFromRequest(() => Services.ContentService.GetById(id));
@@ -490,6 +493,9 @@ namespace Umbraco.Web.Editors
Services.ContentService.UnPublish(foundContent);
var content = Mapper.Map<IContent, ContentItemDisplay>(foundContent);
content.AddSuccessNotification(ui.Text("content", "unPublish"), ui.Text("speechBubbles", "contentUnpublished"));
return content;
}
@@ -599,7 +605,7 @@ namespace Umbraco.Web.Editors
/// <param name="userService"></param>
/// <param name="contentService"></param>
/// <param name="nodeId">The content to lookup, if the contentItem is not specified</param>
/// <param name="permissionToCheck"></param>
/// <param name="permissionsToCheck"></param>
/// <param name="contentItem">Specifies the already resolved content item to check against</param>
/// <returns></returns>
internal static bool CheckPermissions(
@@ -608,7 +614,7 @@ namespace Umbraco.Web.Editors
IUserService userService,
IContentService contentService,
int nodeId,
char? permissionToCheck = null,
char[] permissionsToCheck = null,
IContent contentItem = null)
{
@@ -642,19 +648,22 @@ namespace Umbraco.Web.Editors
return false;
}
if (permissionToCheck.HasValue == false)
if (permissionsToCheck == null || permissionsToCheck.Any() == false)
{
return true;
}
var permission = userService.GetPermissions(user, nodeId).FirstOrDefault();
if (permission != null && permission.AssignedPermissions.Contains(permissionToCheck.Value.ToString(CultureInfo.InvariantCulture)))
{
return true;
}
return false;
var allowed = true;
foreach (var p in permissionsToCheck)
{
if (permission == null || permission.AssignedPermissions.Contains(p.ToString(CultureInfo.InvariantCulture)) == false)
{
allowed = false;
}
}
return allowed;
}
}

View File

@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Web.Http.Controllers;
@@ -58,25 +59,24 @@ namespace Umbraco.Web.Editors
//We now need to validate that the user is allowed to be doing what they are doing.
//Based on the action we need to check different permissions.
//Then if it is new, we need to lookup those permissions on the parent!
char permissionToCheck;
var permissionToCheck = new List<char>();
IContent contentToCheck = null;
int contentIdToCheck;
switch (contentItem.Action)
{
case ContentSaveAction.Save:
permissionToCheck = ActionUpdate.Instance.Letter;
permissionToCheck.Add(ActionUpdate.Instance.Letter);
contentToCheck = contentItem.PersistedContent;
contentIdToCheck = contentToCheck.Id;
break;
case ContentSaveAction.Publish:
permissionToCheck = ActionPublish.Instance.Letter;
permissionToCheck.Add(ActionPublish.Instance.Letter);
contentToCheck = contentItem.PersistedContent;
contentIdToCheck = contentToCheck.Id;
break;
case ContentSaveAction.PublishNew:
case ContentSaveAction.SaveNew:
default:
permissionToCheck = ActionNew.Instance.Letter;
permissionToCheck.Add(ActionNew.Instance.Letter);
if (contentItem.ParentId != Constants.System.Root)
{
contentToCheck = ContentService.GetById(contentItem.ParentId);
@@ -87,6 +87,24 @@ namespace Umbraco.Web.Editors
contentIdToCheck = contentItem.ParentId;
}
break;
case ContentSaveAction.PublishNew:
//Publish new requires both ActionNew AND ActionPublish
permissionToCheck.Add(ActionNew.Instance.Letter);
permissionToCheck.Add(ActionPublish.Instance.Letter);
if (contentItem.ParentId != Constants.System.Root)
{
contentToCheck = ContentService.GetById(contentItem.ParentId);
contentIdToCheck = contentToCheck.Id;
}
else
{
contentIdToCheck = contentItem.ParentId;
}
break;
default:
throw new ArgumentOutOfRangeException();
}
if (ContentController.CheckPermissions(
@@ -95,7 +113,7 @@ namespace Umbraco.Web.Editors
UserService,
ContentService,
contentIdToCheck,
permissionToCheck,
permissionToCheck.ToArray(),
contentToCheck) == false)
{
actionContext.Response = actionContext.Request.CreateUserNoAccessResponse();

View File

@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.Serialization;
using System.Web.Http;
@@ -28,6 +29,14 @@ namespace Umbraco.Web.Models.ContentEditing
[DataMember(Name = "urls")]
public string[] Urls { get; set; }
/// <summary>
/// The allowed 'actions' based on the user's permissions - Create, Update, Publish, Send to publish
/// </summary>
/// <remarks>
/// Each char represents a button which we can then map on the front-end to the correct actions
/// </remarks>
[DataMember(Name = "allowedActions")]
public IEnumerable<char> AllowedActions { get; set; }
}
}

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Linq.Expressions;
using System.Runtime.Serialization;
@@ -13,6 +14,7 @@ using Umbraco.Core.Services;
using Umbraco.Web.Models.ContentEditing;
using umbraco;
using Umbraco.Web.Routing;
using umbraco.BusinessLogic.Actions;
namespace Umbraco.Web.Models.Mapping
{
@@ -54,6 +56,8 @@ namespace Umbraco.Web.Models.Mapping
: content.GetContentUrls()))
.ForMember(display => display.Properties, expression => expression.Ignore())
.ForMember(display => display.Tabs, expression => expression.ResolveUsing<TabsAndPropertiesResolver>())
.ForMember(display => display.AllowedActions, expression => expression.ResolveUsing(
new ActionButtonsResolver(new Lazy<IUserService>(() => applicationContext.Services.UserService))))
.AfterMap(MapGenericCustomProperties);
//FROM IContent TO ContentItemBasic<ContentPropertyBasic, IContent>
@@ -157,5 +161,60 @@ namespace Umbraco.Web.Models.Mapping
return null;
}
/// <summary>
/// Creates the list of action buttons allowed for this user - Publish, Send to publish, save, unpublish returned as the button's 'letter'
/// </summary>
private class ActionButtonsResolver : ValueResolver<IContent, IEnumerable<char>>
{
private readonly Lazy<IUserService> _userService;
public ActionButtonsResolver(Lazy<IUserService> userService)
{
_userService = userService;
}
protected override IEnumerable<char> ResolveCore(IContent source)
{
if (UmbracoContext.Current == null)
{
//cannot check permissions without a context
return Enumerable.Empty<char>();
}
var svc = _userService.Value;
var permissions = svc.GetPermissions(UmbracoContext.Current.Security.CurrentUser, source.Id)
.FirstOrDefault();
if (permissions == null)
{
return Enumerable.Empty<char>();
}
var result = new List<char>();
//can they publish ?
if (permissions.AssignedPermissions.Contains(ActionPublish.Instance.Letter.ToString(CultureInfo.InvariantCulture)))
{
result.Add(ActionPublish.Instance.Letter);
}
//can they send to publish ?
if (permissions.AssignedPermissions.Contains(ActionToPublish.Instance.Letter.ToString(CultureInfo.InvariantCulture)))
{
result.Add(ActionToPublish.Instance.Letter);
}
//can they save ?
if (permissions.AssignedPermissions.Contains(ActionUpdate.Instance.Letter.ToString(CultureInfo.InvariantCulture)))
{
result.Add(ActionUpdate.Instance.Letter);
}
//can they create ?
if (permissions.AssignedPermissions.Contains(ActionNew.Instance.Letter.ToString(CultureInfo.InvariantCulture)))
{
result.Add(ActionNew.Instance.Letter);
}
return result;
}
}
}
}

View File

@@ -100,7 +100,7 @@ namespace Umbraco.Web.WebApi.Filters
actionContext.Request.Properties,
UmbracoContext.Current.Security.CurrentUser,
ApplicationContext.Current.Services.UserService,
ApplicationContext.Current.Services.ContentService, nodeId, _permissionToCheck))
ApplicationContext.Current.Services.ContentService, nodeId, _permissionToCheck.HasValue ? new[]{_permissionToCheck.Value}: null))
{
base.OnActionExecuting(actionContext);
}

View File

@@ -7,6 +7,7 @@ namespace umbraco.BusinessLogic.Actions
/// <summary>
/// This action is invoked upon creation of a document
/// </summary>
[Obsolete("This class is no longer used and will be removed in future versions")]
public class ActionNewFolder : IAction
{
//create singleton