From 1400a02798bce933ebd47513462a8c98f92f762d Mon Sep 17 00:00:00 2001
From: Shannon
Date: Fri, 23 Oct 2020 10:10:02 +1100
Subject: [PATCH] Merge branch 'netcore/dev' into
netcore/task/6973-migrating-authenticationcontroller
# Conflicts:
# src/Umbraco.Core/Constants-Security.cs
# src/Umbraco.Infrastructure/BackOffice/BackOfficeClaimsPrincipalFactory.cs
# src/Umbraco.Infrastructure/BackOffice/BackOfficeUserManager.cs
# src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs
# src/Umbraco.Tests.AcceptanceTest/cypress/integration/Tour/backofficeTour.ts
# src/Umbraco.Web.BackOffice/Controllers/BackOfficeServerVariables.cs
# src/Umbraco.Web.BackOffice/Controllers/DenyLocalLoginAuthorizationAttribute.cs
# src/Umbraco.Web.BackOffice/Controllers/UsersController.cs
# src/Umbraco.Web.BackOffice/Extensions/HtmlHelperBackOfficeExtensions.cs
# src/Umbraco.Web.BackOffice/Services/IconService.cs
# src/Umbraco.Web.Common/Security/ExternalSignInAutoLinkOptions.cs
# src/Umbraco.Web.UI.Client/src/common/interceptors/_module.js
# src/Umbraco.Web.UI.Client/src/common/interceptors/requiredheaders.interceptor.js
# src/Umbraco.Web.UI.Client/src/views/common/overlays/user/user.controller.js
# src/Umbraco.Web.UI.NetCore/umbraco/UmbracoBackOffice/Default.cshtml
# src/Umbraco.Web.UI.NetCore/umbraco/config/lang/da.xml
# src/Umbraco.Web.UI.NetCore/umbraco/config/lang/en.xml
# src/Umbraco.Web.UI.NetCore/umbraco/config/lang/en_us.xml
# src/Umbraco.Web.UI/Umbraco/Views/AuthorizeUpgrade.cshtml
# src/Umbraco.Web/Editors/AuthenticationController.cs
# src/Umbraco.Web/Editors/BackOfficeController.cs
# src/Umbraco.Web/Editors/Filters/IsCurrentUserModelFilterAttribute.cs
# src/Umbraco.Web/Security/AppBuilderExtensions.cs
# src/Umbraco.Web/Security/AuthenticationOptionsExtensions.cs
# src/Umbraco.Web/UmbracoDefaultOwinStartup.cs
---
.../BackOffice/BackOfficeIdentityUser.cs | 15 +-
src/Umbraco.Core/Constants-Security.cs | 2 +
.../Models/Identity/ExternalLogin.cs | 24 +
.../Models/Identity/IExternalLogin.cs | 12 +
.../Models/Identity/IIdentityUserLogin.cs | 13 +-
.../Models/Identity/IdentityUserLogin.cs | 22 +-
.../Repositories/IExternalLoginRepository.cs | 5 +-
.../Services/IExternalLoginService.cs | 31 +-
src/Umbraco.Core/Services/IIconService.cs | 4 +-
.../BackOfficeClaimsPrincipalFactory.cs | 7 +
.../BackOffice/BackOfficeUserManager.cs | 15 +
.../BackOffice/BackOfficeUserStore.cs | 61 +-
.../Migrations/Upgrade/UmbracoPlan.cs | 12 +-
.../V_8_9_0/ExternalLoginTableUserData.cs | 20 +
.../Persistence/Dtos/ExternalLoginDto.cs | 12 +
.../Factories/ExternalLoginFactory.cs | 29 +-
.../Implement/ExternalLoginRepository.cs | 74 ++-
.../PropertyEditorsComposer.cs | 2 +-
.../Scheduling/SchedulerComposer.cs | 2 +-
.../Implement/ExternalLoginService.cs | 60 +-
.../Compose/DisabledModelsBuilderComponent.cs | 2 +-
.../cypress/integration/Content/content.ts | 201 ++++--
.../cypress/integration/Members/members.js | 7 +-
.../cypress/integration/Settings/scripts.ts | 94 ++-
.../cypress/integration/Settings/templates.ts | 23 +-
src/Umbraco.Tests.AcceptanceTest/package.json | 2 +-
.../Services/EntityServiceTests.cs | 1 +
.../Services/ExternalLoginServiceTests.cs | 253 ++++++++
src/Umbraco.Tests/Umbraco.Tests.csproj | 13 +
.../Controllers/BackOfficeServerVariables.cs | 3 +-
.../DenyLocalLoginAuthorizationAttribute.cs | 18 +
.../Controllers/UsersController.cs | 69 +-
.../HtmlHelperBackOfficeExtensions.cs | 14 +-
.../Services/IconService.cs | 63 +-
.../Security/ExternalSignInAutoLinkOptions.cs | 9 +
...on-adressbook.svg => icon-addressbook.svg} | 0
.../application/umblogin.directive.js | 71 +-
.../components/buttons/umbbutton.directive.js | 14 +-
.../components/umbconfirmaction.directive.js | 32 +-
.../components/umbicon.directive.js | 36 +-
.../src/common/interceptors/_module.js | 1 +
.../common/services/blockeditor.service.js | 4 +-
.../blockeditormodelobject.service.js | 19 +-
.../services/externallogininfo.service.js | 67 ++
.../src/common/services/focuslock.service.js | 8 +-
.../services/servervalidationmgr.service.js | 2 +-
.../src/common/services/user.service.js | 36 +-
.../src/less/components/card.less | 1 +
.../src/less/components/tree/umb-tree.less | 4 +-
.../less/components/umb-confirm-action.less | 5 -
.../src/less/components/umb-form-check.less | 4 +-
src/Umbraco.Web.UI.Client/src/less/main.less | 7 +
.../blockpicker/blockpicker.html | 3 +-
.../iconpicker/iconpicker.controller.js | 22 +-
.../iconpicker/iconpicker.html | 8 +-
.../mediapicker/mediapicker.controller.js | 20 +-
.../overlays/mediacropdetails.controller.js | 12 +-
.../overlays/mediacropdetails.html | 18 +-
.../sectionpicker/sectionpicker.html | 10 +-
.../common/overlays/user/user.controller.js | 17 +-
.../src/views/common/overlays/user/user.html | 45 +-
.../components/application/umb-login.html | 50 +-
.../components/blockcard/umb-block-card.html | 12 +-
.../components/blockcard/umb-block-card.less | 21 +-
.../components/buttons/umb-button-group.html | 4 +-
.../views/components/buttons/umb-button.html | 1 +
.../components/tags/umb-tags-editor.html | 5 +-
.../views/components/umb-confirm-action.html | 31 +-
.../views/components/umb-groups-builder.html | 10 +-
.../src/views/dataTypes/create.controller.js | 6 +-
.../src/views/dataTypes/create.html | 4 +-
.../dictionary.create.controller.js | 6 +-
.../views/documentTypes/create.controller.js | 6 +-
.../src/views/documentTypes/create.html | 4 +-
.../src/views/mediaTypes/create.controller.js | 6 +-
.../src/views/mediaTypes/create.html | 4 +-
.../inlineblock/inlineblock.editor.less | 2 +-
.../labelblock/labelblock.editor.less | 22 +-
.../unsupportedblock.editor.html | 2 +-
.../unsupportedblock.editor.less | 18 +-
...blocklist.blockconfiguration.controller.js | 59 +-
...t.blockconfiguration.overlay.controller.js | 27 +-
.../umb-block-list-property-editor.html | 8 +-
.../umb-block-list-property-editor.less | 21 +-
.../umbBlockListPropertyEditor.component.js | 4 +-
.../src/views/propertyeditors/grid/grid.html | 8 +-
.../listview/layouts.prevalues.html | 42 +-
.../mediapicker/mediapicker.controller.js | 4 +-
.../mediapicker/mediapicker.html | 2 +-
.../multipletextbox/multipletextbox.html | 9 +-
.../nestedcontent/nestedcontent.controller.js | 70 +-
.../src/views/users/user.controller.js | 6 +-
.../src/views/users/user.html | 1 +
.../users/views/user/details.controller.js | 13 +
.../src/views/users/views/user/details.html | 47 +-
.../users/views/users/users.controller.js | 41 +-
.../src/views/users/views/users/users.html | 3 +-
.../BackOfficeTours/getting-started.json | 2 +-
.../umbraco/UmbracoBackOffice/Default.cshtml | 2 +-
.../umbraco/config/lang/da.xml | 2 +
.../umbraco/config/lang/en.xml | 4 +-
.../umbraco/config/lang/en_us.xml | 4 +-
src/Umbraco.Web.UI/Umbraco.Web.UI.csproj | 2 +
.../Umbraco/Views/AuthorizeUpgrade.cshtml | 64 ++
.../Editors/AuthenticationController.cs | 614 ++++++++++++++++++
.../Editors/BackOfficeController.cs | 605 +++++++++++++++++
src/Umbraco.Web/Editors/ChallengeResult.cs | 54 ++
.../IsCurrentUserModelFilterAttribute.cs | 62 ++
src/Umbraco.Web/HttpCookieExtensions.cs | 8 +-
.../Logging/WebProfilerComponent.cs | 2 +-
.../Logging/WebProfilerComposer.cs | 2 +-
src/Umbraco.Web/OwinExtensions.cs | 18 +-
.../Security/AppBuilderExtensions.cs | 17 +
.../AuthenticationManagerExtensions.cs | 34 +-
.../AuthenticationOptionsExtensions.cs | 98 +--
...ficeExternalLoginProviderErrorMiddlware.cs | 55 ++
.../BackOfficeExternalLoginProviderErrors.cs | 22 +
.../BackOfficeExternalLoginProviderOptions.cs | 53 ++
.../Security/FixWindowsAuthMiddlware.cs | 1 +
.../Security/IdentityAuditEventArgs.cs | 4 +-
src/Umbraco.Web/Security/MembershipHelper.cs | 3 +-
.../Security/SignOutAuditEventArgs.cs | 19 +
.../Security/UserInviteEventArgs.cs | 36 +
src/Umbraco.Web/Umbraco.Web.csproj | 21 +
src/Umbraco.Web/UmbracoDefaultOwinStartup.cs | 5 +-
src/Umbraco.Web/ViewDataExtensions.cs | 81 ++-
126 files changed, 3498 insertions(+), 635 deletions(-)
create mode 100644 src/Umbraco.Core/Models/Identity/ExternalLogin.cs
create mode 100644 src/Umbraco.Core/Models/Identity/IExternalLogin.cs
create mode 100644 src/Umbraco.Infrastructure/Migrations/Upgrade/V_8_9_0/ExternalLoginTableUserData.cs
create mode 100644 src/Umbraco.Tests/Services/ExternalLoginServiceTests.cs
create mode 100644 src/Umbraco.Web.BackOffice/Controllers/DenyLocalLoginAuthorizationAttribute.cs
rename src/Umbraco.Web.UI.Client/src/assets/icons/{icon-adressbook.svg => icon-addressbook.svg} (100%)
create mode 100644 src/Umbraco.Web.UI.Client/src/common/services/externallogininfo.service.js
create mode 100644 src/Umbraco.Web.UI.Client/src/views/users/views/user/details.controller.js
create mode 100644 src/Umbraco.Web.UI/Umbraco/Views/AuthorizeUpgrade.cshtml
create mode 100644 src/Umbraco.Web/Editors/AuthenticationController.cs
create mode 100644 src/Umbraco.Web/Editors/BackOfficeController.cs
create mode 100644 src/Umbraco.Web/Editors/ChallengeResult.cs
create mode 100644 src/Umbraco.Web/Editors/Filters/IsCurrentUserModelFilterAttribute.cs
create mode 100644 src/Umbraco.Web/Security/BackOfficeExternalLoginProviderErrorMiddlware.cs
create mode 100644 src/Umbraco.Web/Security/BackOfficeExternalLoginProviderErrors.cs
create mode 100644 src/Umbraco.Web/Security/BackOfficeExternalLoginProviderOptions.cs
create mode 100644 src/Umbraco.Web/Security/SignOutAuditEventArgs.cs
create mode 100644 src/Umbraco.Web/Security/UserInviteEventArgs.cs
diff --git a/src/Umbraco.Core/BackOffice/BackOfficeIdentityUser.cs b/src/Umbraco.Core/BackOffice/BackOfficeIdentityUser.cs
index 601291c14a..027e7c0904 100644
--- a/src/Umbraco.Core/BackOffice/BackOfficeIdentityUser.cs
+++ b/src/Umbraco.Core/BackOffice/BackOfficeIdentityUser.cs
@@ -287,16 +287,23 @@ namespace Umbraco.Core.BackOffice
{
get
{
- if (_getLogins != null && _getLogins.IsValueCreated == false)
+ // return if it exists
+ if (_logins != null) return _logins;
+
+ _logins = new ObservableCollection();
+
+ // if the callback is there and hasn't been created yet then execute it and populate the logins
+ if (_getLogins != null && !_getLogins.IsValueCreated)
{
- _logins = new ObservableCollection();
foreach (var l in _getLogins.Value)
{
_logins.Add(l);
}
- //now assign events
- _logins.CollectionChanged += Logins_CollectionChanged;
}
+
+ //now assign events
+ _logins.CollectionChanged += Logins_CollectionChanged;
+
return _logins;
}
}
diff --git a/src/Umbraco.Core/Constants-Security.cs b/src/Umbraco.Core/Constants-Security.cs
index 24b8b20731..7589d506c7 100644
--- a/src/Umbraco.Core/Constants-Security.cs
+++ b/src/Umbraco.Core/Constants-Security.cs
@@ -55,6 +55,8 @@
public const string SessionIdClaimType = "http://umbraco.org/2015/02/identity/claims/backoffice/sessionid";
public const string TicketExpiresClaimType = "http://umbraco.org/2020/06/identity/claims/backoffice/ticketexpires";
+ public const string BackOfficeExternalLoginOptionsProperty = "UmbracoBackOfficeExternalLoginOptions";
+
///
/// The claim type for the ASP.NET Identity security stamp
///
diff --git a/src/Umbraco.Core/Models/Identity/ExternalLogin.cs b/src/Umbraco.Core/Models/Identity/ExternalLogin.cs
new file mode 100644
index 0000000000..6e4abf2906
--- /dev/null
+++ b/src/Umbraco.Core/Models/Identity/ExternalLogin.cs
@@ -0,0 +1,24 @@
+using System;
+
+namespace Umbraco.Core.Models.Identity
+{
+ ///
+ public class ExternalLogin : IExternalLogin
+ {
+ public ExternalLogin(string loginProvider, string providerKey, string userData = null)
+ {
+ LoginProvider = loginProvider ?? throw new ArgumentNullException(nameof(loginProvider));
+ ProviderKey = providerKey ?? throw new ArgumentNullException(nameof(providerKey));
+ UserData = userData;
+ }
+
+ ///
+ public string LoginProvider { get; }
+
+ ///
+ public string ProviderKey { get; }
+
+ ///
+ public string UserData { get; }
+ }
+}
diff --git a/src/Umbraco.Core/Models/Identity/IExternalLogin.cs b/src/Umbraco.Core/Models/Identity/IExternalLogin.cs
new file mode 100644
index 0000000000..68f66a5cee
--- /dev/null
+++ b/src/Umbraco.Core/Models/Identity/IExternalLogin.cs
@@ -0,0 +1,12 @@
+namespace Umbraco.Core.Models.Identity
+{
+ ///
+ /// Used to persist external login data for a user
+ ///
+ public interface IExternalLogin
+ {
+ string LoginProvider { get; }
+ string ProviderKey { get; }
+ string UserData { get; }
+ }
+}
diff --git a/src/Umbraco.Core/Models/Identity/IIdentityUserLogin.cs b/src/Umbraco.Core/Models/Identity/IIdentityUserLogin.cs
index 276f601771..feb8af24f3 100644
--- a/src/Umbraco.Core/Models/Identity/IIdentityUserLogin.cs
+++ b/src/Umbraco.Core/Models/Identity/IIdentityUserLogin.cs
@@ -2,23 +2,30 @@
namespace Umbraco.Core.Models.Identity
{
+ // TODO: Merge these in v8! This is here purely for backward compat
+
+ public interface IIdentityUserLoginExtended : IIdentityUserLogin
+ {
+ ///
+ /// Used to store any arbitrary data for the user and external provider - like user tokens returned from the provider
+ ///
+ string UserData { get; set; }
+ }
+
public interface IIdentityUserLogin : IEntity, IRememberBeingDirty
{
///
/// The login provider for the login (i.e. Facebook, Google)
- ///
///
string LoginProvider { get; set; }
///
/// Key representing the login for the provider
- ///
///
string ProviderKey { get; set; }
///
/// User Id for the user who owns this login
- ///
///
int UserId { get; set; }
}
diff --git a/src/Umbraco.Core/Models/Identity/IdentityUserLogin.cs b/src/Umbraco.Core/Models/Identity/IdentityUserLogin.cs
index 5876f420b4..66911b08ac 100644
--- a/src/Umbraco.Core/Models/Identity/IdentityUserLogin.cs
+++ b/src/Umbraco.Core/Models/Identity/IdentityUserLogin.cs
@@ -3,11 +3,11 @@ using Umbraco.Core.Models.Entities;
namespace Umbraco.Core.Models.Identity
{
+
///
/// Entity type for a user's login (i.e. Facebook, Google)
- ///
///
- public class IdentityUserLogin : EntityBase, IIdentityUserLogin
+ public class IdentityUserLogin : EntityBase, IIdentityUserLoginExtended
{
public IdentityUserLogin(string loginProvider, string providerKey, int userId)
{
@@ -25,22 +25,16 @@ namespace Umbraco.Core.Models.Identity
CreateDate = createDate;
}
- ///
- /// The login provider for the login (i.e. Facebook, Google)
- ///
- ///
+ ///
public string LoginProvider { get; set; }
- ///
- /// Key representing the login for the provider
- ///
- ///
+ ///
public string ProviderKey { get; set; }
- ///
- /// User Id for the user who owns this login
- ///
- ///
+ ///
public int UserId { get; set; }
+
+ ///
+ public string UserData { get; set; }
}
}
diff --git a/src/Umbraco.Core/Persistence/Repositories/IExternalLoginRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IExternalLoginRepository.cs
index ed6f2e4fb1..590a3003a7 100644
--- a/src/Umbraco.Core/Persistence/Repositories/IExternalLoginRepository.cs
+++ b/src/Umbraco.Core/Persistence/Repositories/IExternalLoginRepository.cs
@@ -1,11 +1,14 @@
-using System.Collections.Generic;
+using System;
+using System.Collections.Generic;
using Umbraco.Core.Models.Identity;
namespace Umbraco.Core.Persistence.Repositories
{
public interface IExternalLoginRepository : IReadWriteQueryRepository
{
+ [Obsolete("Use the overload specifying IIdentityUserLoginExtended instead")]
void SaveUserLogins(int memberId, IEnumerable logins);
+ void Save(int userId, IEnumerable logins);
void DeleteUserLogins(int memberId);
}
}
diff --git a/src/Umbraco.Core/Services/IExternalLoginService.cs b/src/Umbraco.Core/Services/IExternalLoginService.cs
index d57bb052af..5f0a69eb7e 100644
--- a/src/Umbraco.Core/Services/IExternalLoginService.cs
+++ b/src/Umbraco.Core/Services/IExternalLoginService.cs
@@ -1,4 +1,5 @@
-using System.Collections.Generic;
+using System;
+using System.Collections.Generic;
using Umbraco.Core.Models.Identity;
namespace Umbraco.Core.Services
@@ -15,20 +16,38 @@ namespace Umbraco.Core.Services
///
IEnumerable GetAll(int userId);
+ [Obsolete("Use the overload specifying loginProvider and providerKey instead")]
+ IEnumerable Find(IUserLoginInfo login);
+
///
/// Returns all logins matching the login info - generally there should only be one but in some cases
/// there might be more than one depending on if an administrator has been editing/removing members
///
- ///
+ ///
+ ///
///
- IEnumerable Find(IUserLoginInfo login);
+ IEnumerable Find(string loginProvider, string providerKey);
+
+ [Obsolete("Use the Save method instead")]
+ void SaveUserLogins(int userId, IEnumerable logins);
///
- /// Save user logins
+ /// Saves the external logins associated with the user
///
- ///
+ ///
+ /// The user associated with the logins
+ ///
///
- void SaveUserLogins(int userId, IEnumerable logins);
+ ///
+ /// This will replace all external login provider information for the user
+ ///
+ void Save(int userId, IEnumerable logins);
+
+ ///
+ /// Save a single external login record
+ ///
+ ///
+ void Save(IIdentityUserLoginExtended login);
///
/// Deletes all user logins - normally used when a member is deleted
diff --git a/src/Umbraco.Core/Services/IIconService.cs b/src/Umbraco.Core/Services/IIconService.cs
index 963edb22a5..72174a0504 100644
--- a/src/Umbraco.Core/Services/IIconService.cs
+++ b/src/Umbraco.Core/Services/IIconService.cs
@@ -9,13 +9,13 @@ namespace Umbraco.Core.Services
/// Gets an IconModel containing the icon name and SvgString according to an icon name found at the global icons path
///
///
- ///
+ ///
IconModel GetIcon(string iconName);
///
/// Gets a list of all svg icons found at at the global icons path.
///
- ///
+ /// A list of
IList GetAllIcons();
}
}
diff --git a/src/Umbraco.Infrastructure/BackOffice/BackOfficeClaimsPrincipalFactory.cs b/src/Umbraco.Infrastructure/BackOffice/BackOfficeClaimsPrincipalFactory.cs
index 32f0fbccf6..568c028e67 100644
--- a/src/Umbraco.Infrastructure/BackOffice/BackOfficeClaimsPrincipalFactory.cs
+++ b/src/Umbraco.Infrastructure/BackOffice/BackOfficeClaimsPrincipalFactory.cs
@@ -21,6 +21,13 @@ namespace Umbraco.Core.BackOffice
var baseIdentity = await base.GenerateClaimsAsync(user);
+ // now we can flow any custom claims that the actual user has currently assigned which could be done in the OnExternalLogin callback
+ foreach (var claim in user.Claims)
+ {
+ baseIdentity.AddClaim(new Claim(claim.ClaimType, claim.ClaimValue));
+ }
+
+
var umbracoIdentity = new UmbracoBackOfficeIdentity(
baseIdentity,
user.Id,
diff --git a/src/Umbraco.Infrastructure/BackOffice/BackOfficeUserManager.cs b/src/Umbraco.Infrastructure/BackOffice/BackOfficeUserManager.cs
index 752c6013c6..3c604a77c8 100644
--- a/src/Umbraco.Infrastructure/BackOffice/BackOfficeUserManager.cs
+++ b/src/Umbraco.Infrastructure/BackOffice/BackOfficeUserManager.cs
@@ -391,6 +391,12 @@ namespace Umbraco.Core.BackOffice
public void RaiseInvalidLoginAttemptEvent(IPrincipal currentUser, string username) => OnLoginFailed(CreateArgs(AuditEvent.LoginFailed, currentUser, Constants.Security.SuperUserId, username));
public void RaiseLoginRequiresVerificationEvent(IPrincipal currentUser, int userId) => OnLoginRequiresVerification(CreateArgs(AuditEvent.LoginRequiresVerification, currentUser, userId, string.Empty));
+ internal SignOutAuditEventArgs RaiseLogoutSuccessEvent(int userId)
+ {
+ var args = new SignOutAuditEventArgs(AuditEvent.LogoutSuccess, IpResolver.GetCurrentRequestIpAddress(), affectedUser: userId);
+ OnLogoutSuccess(args);
+ return args;
+ }
public void RaiseLoginSuccessEvent(BackOfficeIdentityUser currentUser, int userId) => OnLoginSuccess(CreateArgs(AuditEvent.LoginSucces, currentUser, userId, string.Empty));
@@ -400,6 +406,10 @@ namespace Umbraco.Core.BackOffice
public void RaiseResetAccessFailedCountEvent(IPrincipal currentUser, int userId) => OnResetAccessFailedCount(CreateArgs(AuditEvent.ResetAccessFailedCount, currentUser, userId, string.Empty));
+ internal void RaiseSendingUserInvite(UserInviteEventArgs args) => OnSendingUserInvite(args);
+ internal bool HasSendingUserInviteEventHandler => SendingUserInvite != null;
+
+ // TODO: Not sure why these are not strongly typed events?? They should be in netcore!
public static event EventHandler AccountLocked;
public static event EventHandler AccountUnlocked;
public static event EventHandler ForgotPasswordRequested;
@@ -412,8 +422,13 @@ namespace Umbraco.Core.BackOffice
public static event EventHandler PasswordReset;
public static event EventHandler ResetAccessFailedCount;
+ ///
+ /// Raised when a user is invited
+ ///
+ public static event EventHandler SendingUserInvite; // this event really has nothing to do with the user manager but was the most convenient place to put it
protected virtual void OnAccountLocked(IdentityAuditEventArgs e) => AccountLocked?.Invoke(this, e);
+ protected virtual void OnSendingUserInvite(UserInviteEventArgs e) => SendingUserInvite?.Invoke(this, e);
protected virtual void OnAccountUnlocked(IdentityAuditEventArgs e) => AccountUnlocked?.Invoke(this, e);
protected virtual void OnForgotPasswordRequested(IdentityAuditEventArgs e) => ForgotPasswordRequested?.Invoke(this, e);
diff --git a/src/Umbraco.Infrastructure/BackOffice/BackOfficeUserStore.cs b/src/Umbraco.Infrastructure/BackOffice/BackOfficeUserStore.cs
index 84fc0e599c..b24ec73332 100644
--- a/src/Umbraco.Infrastructure/BackOffice/BackOfficeUserStore.cs
+++ b/src/Umbraco.Infrastructure/BackOffice/BackOfficeUserStore.cs
@@ -125,9 +125,10 @@ namespace Umbraco.Core.BackOffice
IsLockedOut = user.IsLockedOut,
};
- UpdateMemberProperties(userEntity, user);
+ // we have to remember whether Logins property is dirty, since the UpdateMemberProperties will reset it.
+ var isLoginsPropertyDirty = user.IsPropertyDirty(nameof(BackOfficeIdentityUser.Logins));
- // TODO: We should deal with Roles --> User Groups here which we currently are not doing
+ UpdateMemberProperties(userEntity, user);
_userService.Save(userEntity);
@@ -136,6 +137,16 @@ namespace Umbraco.Core.BackOffice
//re-assign id
user.Id = userEntity.Id;
+ if (isLoginsPropertyDirty)
+ {
+ _externalLoginService.Save(
+ user.Id,
+ user.Logins.Select(x => new ExternalLogin(
+ x.LoginProvider,
+ x.ProviderKey,
+ (x is IIdentityUserLoginExtended extended) ? extended.UserData : null)));
+ }
+
return Task.FromResult(IdentityResult.Success);
}
@@ -151,11 +162,19 @@ namespace Umbraco.Core.BackOffice
ThrowIfDisposed();
if (user == null) throw new ArgumentNullException(nameof(user));
- var found = _userService.GetUserById(user.Id);
+ var asInt = user.Id.TryConvertTo();
+ if (asInt == false)
+ {
+ throw new InvalidOperationException("The user id must be an integer to work with the Umbraco");
+ }
+
+ // TODO: Wrap this in a scope!
+
+ var found = _userService.GetUserById(asInt.Result);
if (found != null)
{
// we have to remember whether Logins property is dirty, since the UpdateMemberProperties will reset it.
- var isLoginsPropertyDirty = user.IsPropertyDirty("Logins");
+ var isLoginsPropertyDirty = user.IsPropertyDirty(nameof(BackOfficeIdentityUser.Logins));
if (UpdateMemberProperties(found, user))
{
@@ -164,8 +183,12 @@ namespace Umbraco.Core.BackOffice
if (isLoginsPropertyDirty)
{
- var logins = await GetLoginsAsync(user);
- _externalLoginService.SaveUserLogins(found.Id, logins.Select(UserLoginInfoWrapper.Wrap));
+ _externalLoginService.Save(
+ found.Id,
+ user.Logins.Select(x => new ExternalLogin(
+ x.LoginProvider,
+ x.ProviderKey,
+ (x is IIdentityUserLoginExtended extended) ? extended.UserData : null)));
}
}
@@ -438,7 +461,7 @@ namespace Umbraco.Core.BackOffice
ThrowIfDisposed();
//get all logins associated with the login id
- var result = _externalLoginService.Find(UserLoginInfoWrapper.Wrap(new UserLoginInfo(loginProvider, providerKey, loginProvider))).ToArray();
+ var result = _externalLoginService.Find(loginProvider, providerKey).ToArray();
if (result.Any())
{
//return the first user that matches the result
@@ -758,7 +781,7 @@ namespace Umbraco.Core.BackOffice
//don't assign anything if nothing has changed as this will trigger the track changes of the model
- if (identityUser.IsPropertyDirty("LastLoginDateUtc")
+ if (identityUser.IsPropertyDirty(nameof(BackOfficeIdentityUser.LastLoginDateUtc))
|| (user.LastLoginDate != default(DateTime) && identityUser.LastLoginDateUtc.HasValue == false)
|| identityUser.LastLoginDateUtc.HasValue && user.LastLoginDate.ToUniversalTime() != identityUser.LastLoginDateUtc.Value)
{
@@ -767,33 +790,33 @@ namespace Umbraco.Core.BackOffice
var dt = identityUser.LastLoginDateUtc == DateTime.MinValue ? DateTime.MinValue : identityUser.LastLoginDateUtc.Value.ToLocalTime();
user.LastLoginDate = dt;
}
- if (identityUser.IsPropertyDirty("LastPasswordChangeDateUtc")
+ if (identityUser.IsPropertyDirty(nameof(BackOfficeIdentityUser.LastPasswordChangeDateUtc))
|| (user.LastPasswordChangeDate != default(DateTime) && identityUser.LastPasswordChangeDateUtc.HasValue == false)
|| identityUser.LastPasswordChangeDateUtc.HasValue && user.LastPasswordChangeDate.ToUniversalTime() != identityUser.LastPasswordChangeDateUtc.Value)
{
anythingChanged = true;
user.LastPasswordChangeDate = identityUser.LastPasswordChangeDateUtc.Value.ToLocalTime();
}
- if (identityUser.IsPropertyDirty("EmailConfirmed")
+ if (identityUser.IsPropertyDirty(nameof(BackOfficeIdentityUser.EmailConfirmed))
|| (user.EmailConfirmedDate.HasValue && user.EmailConfirmedDate.Value != default(DateTime) && identityUser.EmailConfirmed == false)
|| ((user.EmailConfirmedDate.HasValue == false || user.EmailConfirmedDate.Value == default(DateTime)) && identityUser.EmailConfirmed))
{
anythingChanged = true;
user.EmailConfirmedDate = identityUser.EmailConfirmed ? (DateTime?)DateTime.Now : null;
}
- if (identityUser.IsPropertyDirty("Name")
+ if (identityUser.IsPropertyDirty(nameof(BackOfficeIdentityUser.Name))
&& user.Name != identityUser.Name && identityUser.Name.IsNullOrWhiteSpace() == false)
{
anythingChanged = true;
user.Name = identityUser.Name;
}
- if (identityUser.IsPropertyDirty("Email")
+ if (identityUser.IsPropertyDirty(nameof(BackOfficeIdentityUser.Email))
&& user.Email != identityUser.Email && identityUser.Email.IsNullOrWhiteSpace() == false)
{
anythingChanged = true;
user.Email = identityUser.Email;
}
- if (identityUser.IsPropertyDirty("AccessFailedCount")
+ if (identityUser.IsPropertyDirty(nameof(BackOfficeIdentityUser.AccessFailedCount))
&& user.FailedPasswordAttempts != identityUser.AccessFailedCount)
{
anythingChanged = true;
@@ -811,13 +834,13 @@ namespace Umbraco.Core.BackOffice
}
}
- if (identityUser.IsPropertyDirty("UserName")
+ if (identityUser.IsPropertyDirty(nameof(BackOfficeIdentityUser.UserName))
&& user.Username != identityUser.UserName && identityUser.UserName.IsNullOrWhiteSpace() == false)
{
anythingChanged = true;
user.Username = identityUser.UserName;
}
- if (identityUser.IsPropertyDirty("PasswordHash")
+ if (identityUser.IsPropertyDirty(nameof(BackOfficeIdentityUser.PasswordHash))
&& user.RawPasswordValue != identityUser.PasswordHash && identityUser.PasswordHash.IsNullOrWhiteSpace() == false)
{
anythingChanged = true;
@@ -825,19 +848,19 @@ namespace Umbraco.Core.BackOffice
user.PasswordConfiguration = identityUser.PasswordConfig;
}
- if (identityUser.IsPropertyDirty("Culture")
+ if (identityUser.IsPropertyDirty(nameof(BackOfficeIdentityUser.Culture))
&& user.Language != identityUser.Culture && identityUser.Culture.IsNullOrWhiteSpace() == false)
{
anythingChanged = true;
user.Language = identityUser.Culture;
}
- if (identityUser.IsPropertyDirty("StartMediaIds")
+ if (identityUser.IsPropertyDirty(nameof(BackOfficeIdentityUser.StartMediaIds))
&& user.StartMediaIds.UnsortedSequenceEqual(identityUser.StartMediaIds) == false)
{
anythingChanged = true;
user.StartMediaIds = identityUser.StartMediaIds;
}
- if (identityUser.IsPropertyDirty("StartContentIds")
+ if (identityUser.IsPropertyDirty(nameof(BackOfficeIdentityUser.StartContentIds))
&& user.StartContentIds.UnsortedSequenceEqual(identityUser.StartContentIds) == false)
{
anythingChanged = true;
@@ -850,7 +873,7 @@ namespace Umbraco.Core.BackOffice
}
// TODO: Fix this for Groups too
- if (identityUser.IsPropertyDirty("Roles") || identityUser.IsPropertyDirty("Groups"))
+ if (identityUser.IsPropertyDirty(nameof(BackOfficeIdentityUser.Roles)) || identityUser.IsPropertyDirty(nameof(BackOfficeIdentityUser.Groups)))
{
var userGroupAliases = user.Groups.Select(x => x.Alias).ToArray();
diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs
index 7f8c47f92f..3fee15c10a 100644
--- a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs
+++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs
@@ -8,6 +8,7 @@ using Umbraco.Core.Migrations.Upgrade.V_8_0_0;
using Umbraco.Core.Migrations.Upgrade.V_8_0_1;
using Umbraco.Core.Migrations.Upgrade.V_8_1_0;
using Umbraco.Core.Migrations.Upgrade.V_8_6_0;
+using Umbraco.Core.Migrations.Upgrade.V_8_9_0;
namespace Umbraco.Core.Migrations.Upgrade
{
@@ -172,25 +173,28 @@ namespace Umbraco.Core.Migrations.Upgrade
To("{78BAF571-90D0-4D28-8175-EF96316DA789}");
// release-8.0.0
- // to 8.0.1...
+ // to 8.0.1
To("{80C0A0CB-0DD5-4573-B000-C4B7C313C70D}");
// release-8.0.1
- // to 8.1.0...
+ // to 8.1.0
To("{B69B6E8C-A769-4044-A27E-4A4E18D1645A}");
To("{0372A42B-DECF-498D-B4D1-6379E907EB94}");
To("{5B1E0D93-F5A3-449B-84BA-65366B84E2D4}");
- // to 8.6.0...
+ // to 8.6.0
To("{4759A294-9860-46BC-99F9-B4C975CAE580}");
To("{0BC866BC-0665-487A-9913-0290BD0169AD}");
To("{3D67D2C8-5E65-47D0-A9E1-DC2EE0779D6B}");
To("{EE288A91-531B-4995-8179-1D62D9AA3E2E}");
To("{2AB29964-02A1-474D-BD6B-72148D2A53A2}");
- // to 8.7.0...
+ // to 8.7.0
To("{a78e3369-8ea3-40ec-ad3f-5f76929d2b20}");
+ // to 8.9.0
+ To("{B5838FF5-1D22-4F6C-BCEB-F83ACB14B575}");
+
//FINAL
}
}
diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_8_9_0/ExternalLoginTableUserData.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_8_9_0/ExternalLoginTableUserData.cs
new file mode 100644
index 0000000000..45bc1c620b
--- /dev/null
+++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_8_9_0/ExternalLoginTableUserData.cs
@@ -0,0 +1,20 @@
+using Umbraco.Core.Persistence.Dtos;
+
+namespace Umbraco.Core.Migrations.Upgrade.V_8_9_0
+{
+ public class ExternalLoginTableUserData : MigrationBase
+ {
+ public ExternalLoginTableUserData(IMigrationContext context)
+ : base(context)
+ {
+ }
+
+ ///
+ /// Adds new column to the External Login table
+ ///
+ public override void Migrate()
+ {
+ AddColumn(Constants.DatabaseSchema.Tables.ExternalLogin, "userData");
+ }
+ }
+}
diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/ExternalLoginDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/ExternalLoginDto.cs
index 1b774854a6..0a56552000 100644
--- a/src/Umbraco.Infrastructure/Persistence/Dtos/ExternalLoginDto.cs
+++ b/src/Umbraco.Infrastructure/Persistence/Dtos/ExternalLoginDto.cs
@@ -14,9 +14,13 @@ namespace Umbraco.Core.Persistence.Dtos
[PrimaryKeyColumn(Name = "PK_umbracoExternalLogin")]
public int Id { get; set; }
+ // TODO: This is completely missing a FK!!?
+
[Column("userId")]
public int UserId { get; set; }
+ // TODO: There should be an index on both LoginProvider and ProviderKey
+
[Column("loginProvider")]
[Length(4000)]
[NullSetting(NullSetting = NullSettings.NotNull)]
@@ -30,5 +34,13 @@ namespace Umbraco.Core.Persistence.Dtos
[Column("createDate")]
[Constraint(Default = SystemMethods.CurrentDateTime)]
public DateTime CreateDate { get; set; }
+
+ ///
+ /// Used to store any arbitrary data for the user and external provider - like user tokens returned from the provider
+ ///
+ [Column("userData")]
+ [NullSetting(NullSetting = NullSettings.Null)]
+ [SpecialDbType(SpecialDbTypes.NTEXT)]
+ public string UserData { get; set; }
}
}
diff --git a/src/Umbraco.Infrastructure/Persistence/Factories/ExternalLoginFactory.cs b/src/Umbraco.Infrastructure/Persistence/Factories/ExternalLoginFactory.cs
index 4309fe511f..6c1af68acd 100644
--- a/src/Umbraco.Infrastructure/Persistence/Factories/ExternalLoginFactory.cs
+++ b/src/Umbraco.Infrastructure/Persistence/Factories/ExternalLoginFactory.cs
@@ -1,13 +1,17 @@
-using Umbraco.Core.Models.Identity;
+using System;
+using Umbraco.Core.Models.Identity;
using Umbraco.Core.Persistence.Dtos;
namespace Umbraco.Core.Persistence.Factories
{
internal static class ExternalLoginFactory
{
- public static IIdentityUserLogin BuildEntity(ExternalLoginDto dto)
+ public static IIdentityUserLoginExtended BuildEntity(ExternalLoginDto dto)
{
- var entity = new IdentityUserLogin(dto.Id, dto.LoginProvider, dto.ProviderKey, dto.UserId, dto.CreateDate);
+ var entity = new IdentityUserLogin(dto.Id, dto.LoginProvider, dto.ProviderKey, dto.UserId, dto.CreateDate)
+ {
+ UserData = dto.UserData
+ };
// reset dirty initial properties (U4-1946)
entity.ResetDirtyProperties(false);
@@ -16,13 +20,30 @@ namespace Umbraco.Core.Persistence.Factories
public static ExternalLoginDto BuildDto(IIdentityUserLogin entity)
{
+ var asExtended = entity as IIdentityUserLoginExtended;
var dto = new ExternalLoginDto
{
Id = entity.Id,
CreateDate = entity.CreateDate,
LoginProvider = entity.LoginProvider,
ProviderKey = entity.ProviderKey,
- UserId = entity.UserId
+ UserId = entity.UserId,
+ UserData = asExtended?.UserData
+ };
+
+ return dto;
+ }
+
+ public static ExternalLoginDto BuildDto(int userId, IExternalLogin entity, int? id = null)
+ {
+ var dto = new ExternalLoginDto
+ {
+ Id = id ?? default,
+ UserId = userId,
+ LoginProvider = entity.LoginProvider,
+ ProviderKey = entity.ProviderKey,
+ UserData = entity.UserData,
+ CreateDate = DateTime.Now
};
return dto;
diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ExternalLoginRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ExternalLoginRepository.cs
index b984085bdb..017dbfd60b 100644
--- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ExternalLoginRepository.cs
+++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ExternalLoginRepository.cs
@@ -21,25 +21,65 @@ namespace Umbraco.Core.Persistence.Repositories.Implement
public void DeleteUserLogins(int memberId)
{
- Database.Execute("DELETE FROM ExternalLogins WHERE UserId=@userId", new { userId = memberId });
+ Database.Delete("WHERE userId=@userId", new { userId = memberId });
+ }
+
+ public void Save(int userId, IEnumerable logins)
+ {
+ var sql = Sql()
+ .Select()
+ .From()
+ .Where(x => x.UserId == userId)
+ .ForUpdate();
+
+ // deduplicate the logins
+ logins = logins.DistinctBy(x => x.ProviderKey + x.LoginProvider).ToList();
+
+ var toUpdate = new Dictionary();
+ var toDelete = new List();
+ var toInsert = new List(logins);
+
+ var existingLogins = Database.Query(sql).OrderByDescending(x => x.CreateDate).ToList();
+ // used to track duplicates so they can be removed
+ var keys = new HashSet<(string, string)>();
+
+ foreach (var existing in existingLogins)
+ {
+ if (!keys.Add((existing.ProviderKey, existing.LoginProvider)))
+ {
+ // if it already exists we need to remove this one
+ toDelete.Add(existing.Id);
+ }
+ else
+ {
+ var found = logins.FirstOrDefault(x =>
+ x.LoginProvider.Equals(existing.LoginProvider, StringComparison.InvariantCultureIgnoreCase)
+ && x.ProviderKey.Equals(existing.ProviderKey, StringComparison.InvariantCultureIgnoreCase));
+
+ if (found != null)
+ {
+ toUpdate.Add(existing.Id, found);
+ // if it's an update then it's not an insert
+ toInsert.RemoveAll(x => x.ProviderKey == found.ProviderKey && x.LoginProvider == found.LoginProvider);
+ }
+ else
+ {
+ toDelete.Add(existing.Id);
+ }
+ }
+ }
+
+ // do the deletes, updates and inserts
+ if (toDelete.Count > 0)
+ Database.DeleteMany().Where(x => toDelete.Contains(x.Id)).Execute();
+ foreach (var u in toUpdate)
+ Database.Update(ExternalLoginFactory.BuildDto(userId, u.Value, u.Key));
+ Database.InsertBulk(toInsert.Select(i => ExternalLoginFactory.BuildDto(userId, i)));
}
public void SaveUserLogins(int memberId, IEnumerable logins)
{
- //clear out logins for member
- Database.Execute("DELETE FROM umbracoExternalLogin WHERE userId=@userId", new { userId = memberId });
-
- //add them all
- foreach (var l in logins)
- {
- Database.Insert(new ExternalLoginDto
- {
- LoginProvider = l.LoginProvider,
- ProviderKey = l.ProviderKey,
- UserId = memberId,
- CreateDate = DateTime.Now
- });
- }
+ Save(memberId, logins.Select(x => new ExternalLogin(x.LoginProvider, x.ProviderKey)));
}
protected override IIdentityUserLogin PerformGet(int id)
@@ -66,7 +106,7 @@ namespace Umbraco.Core.Persistence.Repositories.Implement
return PerformGetAllOnIds(ids);
}
- var sql = GetBaseQuery(false);
+ var sql = GetBaseQuery(false).OrderByDescending(x => x.CreateDate);
return ConvertFromDtos(Database.Fetch(sql))
.ToArray();// we don't want to re-iterate again!
@@ -102,7 +142,7 @@ namespace Umbraco.Core.Persistence.Repositories.Implement
foreach (var dto in dtos)
{
- yield return Get(dto.Id);
+ yield return ExternalLoginFactory.BuildEntity(dto);
}
}
diff --git a/src/Umbraco.Infrastructure/PropertyEditors/PropertyEditorsComposer.cs b/src/Umbraco.Infrastructure/PropertyEditors/PropertyEditorsComposer.cs
index ce0cf2904f..f22d18484b 100644
--- a/src/Umbraco.Infrastructure/PropertyEditors/PropertyEditorsComposer.cs
+++ b/src/Umbraco.Infrastructure/PropertyEditors/PropertyEditorsComposer.cs
@@ -4,6 +4,6 @@ using Umbraco.Core.Composing;
namespace Umbraco.Web.PropertyEditors
{
[RuntimeLevel(MinLevel = RuntimeLevel.Run)]
- internal class PropertyEditorsComposer : ComponentComposer, ICoreComposer
+ public sealed class PropertyEditorsComposer : ComponentComposer, ICoreComposer
{ }
}
diff --git a/src/Umbraco.Infrastructure/Scheduling/SchedulerComposer.cs b/src/Umbraco.Infrastructure/Scheduling/SchedulerComposer.cs
index 5c56f3d314..8e71004d0c 100644
--- a/src/Umbraco.Infrastructure/Scheduling/SchedulerComposer.cs
+++ b/src/Umbraco.Infrastructure/Scheduling/SchedulerComposer.cs
@@ -12,6 +12,6 @@ namespace Umbraco.Web.Scheduling
/// the task correctly instead of killing it completely when the app domain shuts down.
///
[RuntimeLevel(MinLevel = RuntimeLevel.Run)]
- internal sealed class SchedulerComposer : ComponentComposer, ICoreComposer
+ public sealed class SchedulerComposer : ComponentComposer, ICoreComposer
{ }
}
diff --git a/src/Umbraco.Infrastructure/Services/Implement/ExternalLoginService.cs b/src/Umbraco.Infrastructure/Services/Implement/ExternalLoginService.cs
index e4a3f3638e..b6254e5049 100644
--- a/src/Umbraco.Infrastructure/Services/Implement/ExternalLoginService.cs
+++ b/src/Umbraco.Infrastructure/Services/Implement/ExternalLoginService.cs
@@ -1,4 +1,5 @@
-using System.Collections.Generic;
+using System;
+using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.Logging;
using Umbraco.Core.Events;
@@ -19,53 +20,60 @@ namespace Umbraco.Core.Services.Implement
_externalLoginRepository = externalLoginRepository;
}
- ///
- /// Returns all user logins assigned
- ///
- ///
- ///
+ ///
public IEnumerable GetAll(int userId)
{
using (var scope = ScopeProvider.CreateScope(autoComplete: true))
{
return _externalLoginRepository.Get(Query().Where(x => x.UserId == userId))
- .ToList(); // ToList is important here, must evaluate within uow! // ToList is important here, must evaluate within uow!
+ .ToList();
}
}
- ///
- /// Returns all logins matching the login info - generally there should only be one but in some cases
- /// there might be more than one depending on if an administrator has been editing/removing members
- ///
- ///
- ///
+ [Obsolete("Use the overload specifying loginProvider and providerKey instead")]
public IEnumerable Find(IUserLoginInfo login)
+ {
+ return Find(login.LoginProvider, login.ProviderKey);
+ }
+
+ ///
+ public IEnumerable Find(string loginProvider, string providerKey)
{
using (var scope = ScopeProvider.CreateScope(autoComplete: true))
{
- return _externalLoginRepository.Get(Query().Where(x => x.ProviderKey == login.ProviderKey && x.LoginProvider == login.LoginProvider))
- .ToList(); // ToList is important here, must evaluate within uow! // ToList is important here, must evaluate within uow!
+ return _externalLoginRepository.Get(Query()
+ .Where(x => x.ProviderKey == providerKey && x.LoginProvider == loginProvider))
+ .ToList();
}
}
- ///
- /// Save user logins
- ///
- ///
- ///
+ [Obsolete("Use the Save method instead")]
public void SaveUserLogins(int userId, IEnumerable logins)
+ {
+ Save(userId, logins.Select(x => new ExternalLogin(x.LoginProvider, x.ProviderKey)));
+ }
+
+ ///
+ public void Save(int userId, IEnumerable logins)
{
using (var scope = ScopeProvider.CreateScope())
{
- _externalLoginRepository.SaveUserLogins(userId, logins);
+ _externalLoginRepository.Save(userId, logins);
scope.Complete();
}
}
- ///
- /// Deletes all user logins - normally used when a member is deleted
- ///
- ///
+ ///
+ public void Save(IIdentityUserLoginExtended login)
+ {
+ using (var scope = ScopeProvider.CreateScope())
+ {
+ _externalLoginRepository.Save(login);
+ scope.Complete();
+ }
+ }
+
+ ///
public void DeleteUserLogins(int userId)
{
using (var scope = ScopeProvider.CreateScope())
@@ -74,5 +82,7 @@ namespace Umbraco.Core.Services.Implement
scope.Complete();
}
}
+
+
}
}
diff --git a/src/Umbraco.ModelsBuilder.Embedded/Compose/DisabledModelsBuilderComponent.cs b/src/Umbraco.ModelsBuilder.Embedded/Compose/DisabledModelsBuilderComponent.cs
index 9d63f65f64..2031a23af5 100644
--- a/src/Umbraco.ModelsBuilder.Embedded/Compose/DisabledModelsBuilderComponent.cs
+++ b/src/Umbraco.ModelsBuilder.Embedded/Compose/DisabledModelsBuilderComponent.cs
@@ -7,7 +7,7 @@ namespace Umbraco.ModelsBuilder.Embedded.Compose
///
/// Special component used for when MB is disabled with the legacy MB is detected
///
- internal class DisabledModelsBuilderComponent : IComponent
+ public sealed class DisabledModelsBuilderComponent : IComponent
{
private readonly UmbracoFeatures _features;
diff --git a/src/Umbraco.Tests.AcceptanceTest/cypress/integration/Content/content.ts b/src/Umbraco.Tests.AcceptanceTest/cypress/integration/Content/content.ts
index 23e97043b0..68f31e80bb 100644
--- a/src/Umbraco.Tests.AcceptanceTest/cypress/integration/Content/content.ts
+++ b/src/Umbraco.Tests.AcceptanceTest/cypress/integration/Content/content.ts
@@ -1,11 +1,19 @@
///
-import { DocumentTypeBuilder, ContentBuilder } from 'umbraco-cypress-testhelpers';
+import { DocumentTypeBuilder, ContentBuilder, AliasHelper } from 'umbraco-cypress-testhelpers';
context('Content', () => {
beforeEach(() => {
cy.umbracoLogin(Cypress.env('username'), Cypress.env('password'));
});
+ function refreshContentTree(){
+ // Refresh to update the tree
+ cy.get('li .umb-tree-root:contains("Content")').should("be.visible").rightclick();
+ cy.umbracoContextMenuAction("action-refreshNode").click();
+ // We have to wait in case the execution is slow, otherwise we'll try and click the item before it appears in the UI
+ cy.get('.umb-tree-item__inner').should('exist', {timeout: 10000});
+ }
+
it('Copy content', () => {
const rootDocTypeName = "Test document type";
const childDocTypeName = "Child test document type";
@@ -40,8 +48,8 @@ context('Content', () => {
.withContentTypeAlias(rootDocTypeAlias)
.withAction("saveNew")
.addVariant()
- .withName(nodeName)
- .withSave(true)
+ .withName(nodeName)
+ .withSave(true)
.done()
.build();
@@ -52,8 +60,8 @@ context('Content', () => {
.withAction("saveNew")
.withParent(contentNode["id"])
.addVariant()
- .withName(childNodeName)
- .withSave(true)
+ .withName(childNodeName)
+ .withSave(true)
.done()
.build();
@@ -64,8 +72,8 @@ context('Content', () => {
.withContentTypeAlias(rootDocTypeAlias)
.withAction("saveNew")
.addVariant()
- .withName(anotherNodeName)
- .withSave(true)
+ .withName(anotherNodeName)
+ .withSave(true)
.done()
.build();
@@ -74,8 +82,7 @@ context('Content', () => {
});
// Refresh to update the tree
- cy.get('li .umb-tree-root:contains("Content")').should("be.visible").rightclick();
- cy.umbracoContextMenuAction("action-refreshNode").click();
+ refreshContentTree();
// Copy node
cy.umbracoTreeItem("content", [nodeName, childNodeName]).rightclick({ force: true });
@@ -125,8 +132,8 @@ context('Content', () => {
.withContentTypeAlias(rootDocTypeAlias)
.withAction("saveNew")
.addVariant()
- .withName(nodeName)
- .withSave(true)
+ .withName(nodeName)
+ .withSave(true)
.done()
.build();
@@ -137,8 +144,8 @@ context('Content', () => {
.withAction("saveNew")
.withParent(contentNode["id"])
.addVariant()
- .withName(childNodeName)
- .withSave(true)
+ .withName(childNodeName)
+ .withSave(true)
.done()
.build();
@@ -149,8 +156,8 @@ context('Content', () => {
.withContentTypeAlias(rootDocTypeAlias)
.withAction("saveNew")
.addVariant()
- .withName(anotherNodeName)
- .withSave(true)
+ .withName(anotherNodeName)
+ .withSave(true)
.done()
.build();
@@ -159,8 +166,7 @@ context('Content', () => {
});
// Refresh to update the tree
- cy.get('li .umb-tree-root:contains("Content")').should("be.visible").rightclick();
- cy.umbracoContextMenuAction("action-refreshNode").click();
+ refreshContentTree();
// Move node
cy.umbracoTreeItem("content", [nodeName, childNodeName]).rightclick({ force: true });
@@ -209,8 +215,8 @@ context('Content', () => {
.withContentTypeAlias(generatedRootDocType["alias"])
.withAction("saveNew")
.addVariant()
- .withName(nodeName)
- .withSave(true)
+ .withName(nodeName)
+ .withSave(true)
.done()
.build();
@@ -223,8 +229,8 @@ context('Content', () => {
.withAction("saveNew")
.withParent(parentId)
.addVariant()
- .withName(firstChildNodeName)
- .withSave(true)
+ .withName(firstChildNodeName)
+ .withSave(true)
.done()
.build();
@@ -236,8 +242,8 @@ context('Content', () => {
.withAction("saveNew")
.withParent(parentId)
.addVariant()
- .withName(secondChildNodeName)
- .withSave(true)
+ .withName(secondChildNodeName)
+ .withSave(true)
.done()
.build();
@@ -247,8 +253,7 @@ context('Content', () => {
});
// Refresh to update the tree
- cy.get('li .umb-tree-root:contains("Content")').should("be.visible").rightclick();
- cy.umbracoContextMenuAction("action-refreshNode").click();
+ refreshContentTree();
// Sort nodes
cy.umbracoTreeItem("content", [nodeName]).rightclick({ force: true });
@@ -288,8 +293,8 @@ context('Content', () => {
const rootContentNode = new ContentBuilder()
.withContentTypeAlias(generatedRootDocType["alias"])
.addVariant()
- .withName(initialNodeName)
- .withSave(true)
+ .withName(initialNodeName)
+ .withSave(true)
.done()
.build();
@@ -297,8 +302,7 @@ context('Content', () => {
});
// Refresh to update the tree
- cy.get('li .umb-tree-root:contains("Content")').should("be.visible").rightclick();
- cy.umbracoContextMenuAction("action-refreshNode").click();
+ refreshContentTree();
// Access node
cy.umbracoTreeItem("content", [initialNodeName]).click();
@@ -309,15 +313,16 @@ context('Content', () => {
// Save and publish
cy.get('.btn-success').first().click();
+ cy.umbracoSuccessNotification().should('be.visible');
// Rollback
cy.get('.umb-box-header :button').click();
- cy.get('.umb-box-content > .ng-scope > .input-block-level')
+ cy.get('.umb-box-content > div > .input-block-level')
.find('option[label*=' + new Date().getDate() + ']')
.then(elements => {
const option = elements[[elements.length - 1]].getAttribute('value');
- cy.get('.umb-box-content > .ng-scope > .input-block-level')
+ cy.get('.umb-box-content > div > .input-block-level')
.select(option);
});
@@ -326,8 +331,8 @@ context('Content', () => {
cy.reload();
// Assert
- cy.get('.history').find('.umb-badge').eq(0).should('contain.text', "Save");
- cy.get('.history').find('.umb-badge').eq(1).should('contain.text', "Rollback");
+ cy.get('.history').find('.umb-badge').contains('Save').should('be.visible');
+ cy.get('.history').find('.umb-badge').contains('Rollback').should('be.visible');
cy.get('#headerName').should('have.value', initialNodeName);
// Clean up (content is automatically deleted when document types are gone)
@@ -343,9 +348,9 @@ context('Content', () => {
.withName(rootDocTypeName)
.withAllowAsRoot(true)
.addGroup()
- .addTextBoxProperty()
- .withLabel(labelName)
- .done()
+ .addTextBoxProperty()
+ .withLabel(labelName)
+ .done()
.done()
.build();
@@ -356,8 +361,8 @@ context('Content', () => {
const rootContentNode = new ContentBuilder()
.withContentTypeAlias(generatedRootDocType["alias"])
.addVariant()
- .withName(nodeName)
- .withSave(true)
+ .withName(nodeName)
+ .withSave(true)
.done()
.build();
@@ -365,8 +370,7 @@ context('Content', () => {
});
// Refresh to update the tree
- cy.get('li .umb-tree-root:contains("Content")').should("be.visible").rightclick();
- cy.umbracoContextMenuAction("action-refreshNode").click();
+ refreshContentTree();
// Access node
cy.umbracoTreeItem("content", [nodeName]).click();
@@ -398,8 +402,8 @@ context('Content', () => {
.withContentTypeAlias(generatedRootDocType["alias"])
.withAction("saveNew")
.addVariant()
- .withName(nodeName)
- .withSave(true)
+ .withName(nodeName)
+ .withSave(true)
.done()
.build();
@@ -407,8 +411,7 @@ context('Content', () => {
});
// Refresh to update the tree
- cy.get('li .umb-tree-root:contains("Content")').should("be.visible").rightclick();
- cy.umbracoContextMenuAction("action-refreshNode").click();
+ refreshContentTree();
// Access node
cy.umbracoTreeItem("content", [nodeName]).click();
@@ -437,8 +440,8 @@ context('Content', () => {
.withContentTypeAlias(generatedRootDocType["alias"])
.withAction("saveNew")
.addVariant()
- .withName(nodeName)
- .withSave(true)
+ .withName(nodeName)
+ .withSave(true)
.done()
.build();
@@ -446,8 +449,7 @@ context('Content', () => {
});
// Refresh to update the tree
- cy.get('li .umb-tree-root:contains("Content")').should("be.visible").rightclick();
- cy.umbracoContextMenuAction("action-refreshNode").click();
+ refreshContentTree();
// Access node
cy.umbracoTreeItem("content", [nodeName]).click();
@@ -478,8 +480,8 @@ context('Content', () => {
const rootContentNode = new ContentBuilder()
.withContentTypeAlias(generatedRootDocType["alias"])
.addVariant()
- .withName(nodeName)
- .withSave(true)
+ .withName(nodeName)
+ .withSave(true)
.done()
.build();
@@ -487,8 +489,7 @@ context('Content', () => {
});
// Refresh to update the tree
- cy.get('li .umb-tree-root:contains("Content")').should("be.visible").rightclick();
- cy.umbracoContextMenuAction("action-refreshNode").click();
+ refreshContentTree();
// Access node
cy.umbracoTreeItem("content", [nodeName]).click();
@@ -499,4 +500,100 @@ context('Content', () => {
// Clean up (content is automatically deleted when document types are gone)
cy.umbracoEnsureDocumentTypeNameNotExists(rootDocTypeName);
});
+
+ it('Content with contentpicker', () => {
+ const pickerDocTypeName = 'Content picker doc type';
+ const pickerDocTypeAlias = AliasHelper.toAlias(pickerDocTypeName);
+ const pickedDocTypeName = 'Picked content document type';
+
+ cy.deleteAllContent();
+ cy.umbracoEnsureDocumentTypeNameNotExists(pickerDocTypeName);
+ cy.umbracoEnsureTemplateNameNotExists(pickerDocTypeName);
+ cy.umbracoEnsureDocumentTypeNameNotExists(pickedDocTypeName);
+
+ // Create the content type and content we'll be picking from.
+ const pickedDocType = new DocumentTypeBuilder()
+ .withName(pickedDocTypeName)
+ .withAllowAsRoot(true)
+ .addGroup()
+ .addTextBoxProperty()
+ .withAlias('text')
+ .done()
+ .done()
+ .build();
+
+ cy.saveDocumentType(pickedDocType).then((generatedType) => {
+ const pickedContentNode = new ContentBuilder()
+ .withContentTypeAlias(generatedType["alias"])
+ .withAction("publishNew")
+ .addVariant()
+ .withName('Content to pick')
+ .withSave(true)
+ .withPublish(true)
+ .addProperty()
+ .withAlias('text')
+ .withValue('Acceptance test')
+ .done()
+ .withSave(true)
+ .withPublish(true)
+ .done()
+ .build();
+ cy.saveContent(pickedContentNode);
+ });
+
+ // Create the doctype with a the picker
+ const pickerDocType = new DocumentTypeBuilder()
+ .withName(pickerDocTypeName)
+ .withAlias(pickerDocTypeAlias)
+ .withAllowAsRoot(true)
+ .withDefaultTemplate(pickerDocTypeAlias)
+ .addGroup()
+ .withName('ContentPickerGroup')
+ .addContentPickerProperty()
+ .withAlias('picker')
+ .done()
+ .done()
+ .build();
+
+ cy.saveDocumentType(pickerDocType);
+
+ // Edit it the template to allow us to verify the rendered view.
+ cy.editTemplate(pickerDocTypeName, `@inherits Umbraco.Web.Mvc.UmbracoViewPage
+ @using ContentModels = Umbraco.Web.PublishedModels;
+ @{
+ Layout = null;
+ }
+
+ @{
+ IPublishedContent typedContentPicker = Model.Value("picker");
+ if (typedContentPicker != null)
+ {
+ @typedContentPicker.Value("text")
+ }
+ }`);
+
+ // Create content with content picker
+ cy.get('.umb-tree-root-link').rightclick();
+ cy.get('.-opens-dialog > .umb-action-link').click();
+ cy.get('[data-element="action-create-' + pickerDocTypeAlias + '"] > .umb-action-link').click();
+ // Fill out content
+ cy.umbracoEditorHeaderName('ContentPickerContent');
+ cy.get('.umb-node-preview-add').click();
+ // Should really try and find a better way to do this, but umbracoTreeItem tries to click the content pane in the background
+ cy.get('[ng-if="vm.treeReady"] > .umb-tree > [ng-if="!tree.root.containsGroups"] > .umb-animated > .umb-tree-item__inner').click();
+ // We have to wait for the picked content to show up or it wont be added.
+ cy.get('.umb-node-preview__description').should('be.visible');
+ //save and publish
+ cy.umbracoButtonByLabelKey('buttons_saveAndPublish').click();
+ cy.umbracoSuccessNotification().should('be.visible');
+
+ // Assert
+ cy.log('Checking that content is rendered correctly.')
+ const expectedContent = 'Acceptance test
'
+ cy.umbracoVerifyRenderedViewContent('contentpickercontent', expectedContent, true).should('be.true');
+ // clean
+ cy.umbracoEnsureDocumentTypeNameNotExists(pickerDocTypeName);
+ cy.umbracoEnsureTemplateNameNotExists(pickerDocTypeName);
+ cy.umbracoEnsureDocumentTypeNameNotExists(pickedDocTypeName);
+ });
});
diff --git a/src/Umbraco.Tests.AcceptanceTest/cypress/integration/Members/members.js b/src/Umbraco.Tests.AcceptanceTest/cypress/integration/Members/members.js
index aeb4576ccd..4d468a0cf0 100644
--- a/src/Umbraco.Tests.AcceptanceTest/cypress/integration/Members/members.js
+++ b/src/Umbraco.Tests.AcceptanceTest/cypress/integration/Members/members.js
@@ -8,7 +8,8 @@ context('Members', () => {
it('Create member', () => {
const name = "Alice Bobson";
const email = "alice-bobson@acceptancetest.umbraco";
- const password = "$AUlkoF*St0kgPiyyVEk5iU5JWdN*F7&@OSl5Y4pOofnidfifkBj5Ns2ONv%FzsTl36V1E924Gw97zcuSeT7UwK&qb5l&O9h!d!w";
+ const password = "$AUlkoF*St0kgPiyyVEk5iU5JWdN*F7&";
+ const passwordTimeout = 20000
cy.umbracoEnsureMemberEmailNotExists(email);
cy.umbracoSection('member');
@@ -24,8 +25,8 @@ context('Members', () => {
cy.get('input#_umb_login').clear().type(email);
cy.get('input#_umb_email').clear().type(email);
- cy.get('input#password').clear().type(password);
- cy.get('input#confirmPassword').clear().type(password);
+ cy.get('input#password').clear().type(password, { timeout: passwordTimeout });
+ cy.get('input#confirmPassword').clear().type(password, { timeout: passwordTimeout });
// Save
cy.get('.btn-success').click();
diff --git a/src/Umbraco.Tests.AcceptanceTest/cypress/integration/Settings/scripts.ts b/src/Umbraco.Tests.AcceptanceTest/cypress/integration/Settings/scripts.ts
index cce8a45da6..430f8aa108 100644
--- a/src/Umbraco.Tests.AcceptanceTest/cypress/integration/Settings/scripts.ts
+++ b/src/Umbraco.Tests.AcceptanceTest/cypress/integration/Settings/scripts.ts
@@ -1,18 +1,24 @@
///
+import { ScriptBuilder } from "umbraco-cypress-testhelpers";
+
context('Scripts', () => {
beforeEach(() => {
cy.umbracoLogin(Cypress.env('username'), Cypress.env('password'));
});
+ function navigateToSettings() {
+ cy.umbracoSection('settings');
+ cy.get('li .umb-tree-root:contains("Settings")').should("be.visible");
+ }
+
it('Create new JavaScript file', () => {
const name = "TestScript";
const fileName = name + ".js";
- cy.umbracoEnsureScriptNameNotExists(fileName);
+ cy.umbracoEnsureScriptNameNotExists(fileName);
- cy.umbracoSection('settings');
- cy.get('li .umb-tree-root:contains("Settings")').should("be.visible");
+ navigateToSettings()
cy.umbracoTreeItem("settings", ["Scripts"]).rightclick();
@@ -27,9 +33,89 @@ context('Scripts', () => {
//Assert
cy.umbracoSuccessNotification().should('be.visible');
+ cy.umbracoScriptExists(fileName).should('be.true');
+
//Clean up
cy.umbracoEnsureScriptNameNotExists(fileName);
- });
+ });
+ it('Delete a JavaScript file', () => {
+ const name = "TestDeleteScriptFile";
+ const fileName = name + ".js";
+
+ cy.umbracoEnsureScriptNameNotExists(fileName);
+
+ const script = new ScriptBuilder()
+ .withName(name)
+ .withContent('alert("this is content");')
+ .build();
+
+ cy.saveScript(script);
+
+ navigateToSettings()
+
+ cy.umbracoTreeItem("settings", ["Scripts", fileName]).rightclick();
+ cy.umbracoContextMenuAction("action-delete").click();
+ cy.umbracoButtonByLabelKey("general_ok").click();
+
+ cy.contains(fileName).should('not.exist');
+ cy.umbracoScriptExists(name).should('be.false');
+
+ cy.umbracoEnsureScriptNameNotExists(fileName);
+ });
+
+ it('Update JavaScript file', () => {
+ const name = "TestEditJavaScriptFile";
+ const nameEdit = "Edited";
+ let fileName = name + ".js";
+
+ const originalContent = 'console.log("A script);\n';
+ const edit = 'alert("content");';
+ const expected = originalContent + edit;
+
+ cy.umbracoEnsureScriptNameNotExists(fileName);
+
+ const script = new ScriptBuilder()
+ .withName(name)
+ .withContent(originalContent)
+ .build();
+ cy.saveScript(script);
+
+ navigateToSettings();
+ cy.umbracoTreeItem("settings", ["Scripts", fileName]).click();
+
+ cy.get('.ace_text-input').type(edit, { force: true });
+
+ // Since scripts has no alias it should be safe to not use umbracoEditorHeaderName
+ // umbracoEditorHeaderName does not like {backspace}
+ cy.get('#headerName').type("{backspace}{backspace}{backspace}" + nameEdit).should('have.value', name+nameEdit);
+ fileName = name + nameEdit + ".js";
+ cy.get('.btn-success').click();
+
+ cy.umbracoSuccessNotification().should('be.visible');
+ cy.umbracoVerifyScriptContent(fileName, expected).should('be.true');
+
+ cy.umbracoEnsureScriptNameNotExists(fileName);
+ });
+
+ it('Can Delete folder', () => {
+ const folderName = "TestFolder";
+
+ // The way scripts and folders are fetched and deleted are identical
+ cy.umbracoEnsureScriptNameNotExists(folderName);
+ cy.saveFolder('scripts', folderName);
+
+ navigateToSettings()
+
+ cy.umbracoTreeItem("settings", ["Scripts", folderName]).rightclick();
+ cy.umbracoContextMenuAction("action-delete").click();
+ cy.umbracoButtonByLabelKey("general_ok").click();
+
+ cy.contains(folderName).should('not.exist');
+ cy.umbracoScriptExists(folderName).should('be.false')
+
+ // A script an a folder is the same thing in this case
+ cy.umbracoEnsureScriptNameNotExists(folderName);
+ });
});
diff --git a/src/Umbraco.Tests.AcceptanceTest/cypress/integration/Settings/templates.ts b/src/Umbraco.Tests.AcceptanceTest/cypress/integration/Settings/templates.ts
index aff1c38093..da1adedaeb 100644
--- a/src/Umbraco.Tests.AcceptanceTest/cypress/integration/Settings/templates.ts
+++ b/src/Umbraco.Tests.AcceptanceTest/cypress/integration/Settings/templates.ts
@@ -18,25 +18,30 @@ context('Templates', () => {
cy.umbracoContextMenuAction("action-create").click();
}
-
-
it('Create template', () => {
- const name = "Test template test";
+ const name = "Create template test";
cy.umbracoEnsureTemplateNameNotExists(name);
createTemplate();
//Type name
cy.umbracoEditorHeaderName(name);
- /* Make an edit, if you don't the file will be create twice,
- only happens in testing though, probably because the test is too fast
- Certifiably mega wonk regardless */
- cy.get('.ace_text-input').type("var num = 5;", {force:true} );
-
- //Save
+ // Save
+ // We must drop focus for the auto save event to occur.
+ cy.get('.btn-success').focus();
+ // And then wait for the auto save event to finish by finding the page in the tree view.
+ // This is a bit of a roundabout way to find items in a treev view since we dont use umbracoTreeItem
+ // but we must be able to wait for the save evnent to finish, and we can't do that with umbracoTreeItem
+ cy.get('[data-element="tree-item-templates"] > :nth-child(2) > .umb-animated > .umb-tree-item__inner > .umb-tree-item__label')
+ .contains(name).should('be.visible', { timeout: 10000 });
+ // Now that the auto save event has finished we can save
+ // and there wont be any duplicates or file in use errors.
cy.get('.btn-success').click();
//Assert
cy.umbracoSuccessNotification().should('be.visible');
+ // For some reason cy.umbracoErrorNotification tries to click the element which is not possible
+ // if it doesn't actually exist, making should('not.be.visible') impossible.
+ cy.get('.umb-notifications__notifications > .alert-error').should('not.be.visible');
//Clean up
cy.umbracoEnsureTemplateNameNotExists(name);
diff --git a/src/Umbraco.Tests.AcceptanceTest/package.json b/src/Umbraco.Tests.AcceptanceTest/package.json
index e845681f18..996a0cd2f8 100644
--- a/src/Umbraco.Tests.AcceptanceTest/package.json
+++ b/src/Umbraco.Tests.AcceptanceTest/package.json
@@ -9,7 +9,7 @@
"cross-env": "^7.0.2",
"cypress": "^5.1.0",
"ncp": "^2.0.0",
- "umbraco-cypress-testhelpers": "^1.0.0-beta-50",
+ "umbraco-cypress-testhelpers": "^1.0.0-beta-51",
"prompt": "^1.0.0"
},
"dependencies": {
diff --git a/src/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityServiceTests.cs b/src/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityServiceTests.cs
index da933bb9d7..84b9af3951 100644
--- a/src/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityServiceTests.cs
+++ b/src/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityServiceTests.cs
@@ -15,6 +15,7 @@ using Umbraco.Tests.Testing;
namespace Umbraco.Tests.Integration.Umbraco.Infrastructure.Services
{
+
///
/// Tests covering the EntityService
///
diff --git a/src/Umbraco.Tests/Services/ExternalLoginServiceTests.cs b/src/Umbraco.Tests/Services/ExternalLoginServiceTests.cs
new file mode 100644
index 0000000000..c9ad924099
--- /dev/null
+++ b/src/Umbraco.Tests/Services/ExternalLoginServiceTests.cs
@@ -0,0 +1,253 @@
+using System;
+using System.Linq;
+using System.Threading;
+using Microsoft.AspNet.Identity;
+using NUnit.Framework;
+using Umbraco.Core.Configuration;
+using Umbraco.Core.Configuration.Legacy;
+using Umbraco.Core.Models.Identity;
+using Umbraco.Core.Models.Membership;
+using Umbraco.Core.Persistence.Dtos;
+using Umbraco.Tests.TestHelpers;
+using Umbraco.Tests.Testing;
+
+namespace Umbraco.Tests.Services
+{
+ [TestFixture]
+ [Apartment(ApartmentState.STA)]
+ [UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerFixture)]
+ public class ExternalLoginServiceTests : TestWithDatabaseBase
+ {
+ private IGlobalSettings GlobalSettings => SettingsForTests.DefaultGlobalSettings;
+
+ [Test]
+ public void Removes_Existing_Duplicates_On_Save()
+ {
+ var user = new User(GlobalSettings, "Test", "test@test.com", "test", "helloworldtest");
+ ServiceContext.UserService.Save(user);
+
+ var providerKey = Guid.NewGuid().ToString("N");
+ var latest = DateTime.Now.AddDays(-1);
+ var oldest = DateTime.Now.AddDays(-10);
+
+ using (var scope = ScopeProvider.CreateScope())
+ {
+ // insert duplicates manuall
+ scope.Database.Insert(new ExternalLoginDto
+ {
+ UserId = user.Id,
+ LoginProvider = "test1",
+ ProviderKey = providerKey,
+ CreateDate = latest
+ });
+ scope.Database.Insert(new ExternalLoginDto
+ {
+ UserId = user.Id,
+ LoginProvider = "test1",
+ ProviderKey = providerKey,
+ CreateDate = oldest
+ });
+ }
+
+ // try to save 2 other duplicates
+ var externalLogins = new[]
+ {
+ new ExternalLogin("test2", providerKey),
+ new ExternalLogin("test2", providerKey),
+ new ExternalLogin("test1", providerKey)
+ };
+
+ ServiceContext.ExternalLoginService.Save(user.Id, externalLogins);
+
+ var logins = ServiceContext.ExternalLoginService.GetAll(user.Id).ToList();
+
+ // duplicates will be removed, keeping the latest entries
+ Assert.AreEqual(2, logins.Count);
+
+ var test1 = logins.Single(x => x.LoginProvider == "test1");
+ Assert.Greater(test1.CreateDate, latest);
+ }
+
+ [Test]
+ public void Does_Not_Persist_Duplicates()
+ {
+ var user = new User(GlobalSettings, "Test", "test@test.com", "test", "helloworldtest");
+ ServiceContext.UserService.Save(user);
+
+ var providerKey = Guid.NewGuid().ToString("N");
+ var externalLogins = new[]
+ {
+ new ExternalLogin("test1", providerKey),
+ new ExternalLogin("test1", providerKey)
+ };
+
+ ServiceContext.ExternalLoginService.Save(user.Id, externalLogins);
+
+ var logins = ServiceContext.ExternalLoginService.GetAll(user.Id).ToList();
+ Assert.AreEqual(1, logins.Count);
+ }
+
+ [Test]
+ public void Single_Create()
+ {
+ var user = new User(GlobalSettings, "Test", "test@test.com", "test", "helloworldtest");
+ ServiceContext.UserService.Save(user);
+
+ var extLogin = new IdentityUserLogin("test1", Guid.NewGuid().ToString("N"), user.Id)
+ {
+ UserData = "hello"
+ };
+ ServiceContext.ExternalLoginService.Save(extLogin);
+
+ var found = ServiceContext.ExternalLoginService.GetAll(user.Id);
+
+ Assert.AreEqual(1, found.Count());
+ Assert.IsTrue(extLogin.HasIdentity);
+ Assert.IsTrue(extLogin.Id > 0);
+ }
+
+ [Test]
+ public void Single_Update()
+ {
+ var user = new User(GlobalSettings, "Test", "test@test.com", "test", "helloworldtest");
+ ServiceContext.UserService.Save(user);
+
+ var extLogin = new IdentityUserLogin("test1", Guid.NewGuid().ToString("N"), user.Id)
+ {
+ UserData = "hello"
+ };
+ ServiceContext.ExternalLoginService.Save(extLogin);
+
+ extLogin.UserData = "world";
+ ServiceContext.ExternalLoginService.Save(extLogin);
+
+ var found = ServiceContext.ExternalLoginService.GetAll(user.Id).Cast().ToList();
+ Assert.AreEqual(1, found.Count);
+ Assert.AreEqual("world", found[0].UserData);
+ }
+
+ [Test]
+ public void Multiple_Update()
+ {
+ var user = new User(GlobalSettings, "Test", "test@test.com", "test", "helloworldtest");
+ ServiceContext.UserService.Save(user);
+
+ var providerKey1 = Guid.NewGuid().ToString("N");
+ var providerKey2 = Guid.NewGuid().ToString("N");
+ var extLogins = new[]
+ {
+ new ExternalLogin("test1", providerKey1, "hello"),
+ new ExternalLogin("test2", providerKey2, "world")
+ };
+ ServiceContext.ExternalLoginService.Save(user.Id, extLogins);
+
+ extLogins = new[]
+ {
+ new ExternalLogin("test1", providerKey1, "123456"),
+ new ExternalLogin("test2", providerKey2, "987654")
+ };
+ ServiceContext.ExternalLoginService.Save(user.Id, extLogins);
+
+ var found = ServiceContext.ExternalLoginService.GetAll(user.Id).Cast().OrderBy(x => x.LoginProvider).ToList();
+ Assert.AreEqual(2, found.Count);
+ Assert.AreEqual("123456", found[0].UserData);
+ Assert.AreEqual("987654", found[1].UserData);
+ }
+
+ [Test]
+ public void Can_Find_As_Extended_Type()
+ {
+ var user = new User(GlobalSettings, "Test", "test@test.com", "test", "helloworldtest");
+ ServiceContext.UserService.Save(user);
+
+ var providerKey1 = Guid.NewGuid().ToString("N");
+ var providerKey2 = Guid.NewGuid().ToString("N");
+ var extLogins = new[]
+ {
+ new ExternalLogin("test1", providerKey1, "hello"),
+ new ExternalLogin("test2", providerKey2, "world")
+ };
+ ServiceContext.ExternalLoginService.Save(user.Id, extLogins);
+
+ var found = ServiceContext.ExternalLoginService.Find("test2", providerKey2).ToList();
+ Assert.AreEqual(1, found.Count);
+ var asExtended = found.Cast().ToList();
+ Assert.AreEqual(1, found.Count);
+
+ }
+
+ [Test]
+ public void Add_Logins()
+ {
+ var user = new User(GlobalSettings, "Test", "test@test.com", "test", "helloworldtest");
+ ServiceContext.UserService.Save(user);
+
+ var externalLogins = new[]
+ {
+ new ExternalLogin("test1", Guid.NewGuid().ToString("N")),
+ new ExternalLogin("test2", Guid.NewGuid().ToString("N"))
+ };
+
+ ServiceContext.ExternalLoginService.Save(user.Id, externalLogins);
+
+ var logins = ServiceContext.ExternalLoginService.GetAll(user.Id).OrderBy(x => x.LoginProvider).ToList();
+ Assert.AreEqual(2, logins.Count);
+ for (int i = 0; i < logins.Count; i++)
+ {
+ Assert.AreEqual(logins[i].ProviderKey, externalLogins[i].ProviderKey);
+ Assert.AreEqual(logins[i].LoginProvider, externalLogins[i].LoginProvider);
+ }
+ }
+
+ [Test]
+ public void Add_Update_Delete_Logins()
+ {
+ var user = new User(GlobalSettings, "Test", "test@test.com", "test", "helloworldtest");
+ ServiceContext.UserService.Save(user);
+
+ var externalLogins = new[]
+ {
+ new ExternalLogin("test1", Guid.NewGuid().ToString("N")),
+ new ExternalLogin("test2", Guid.NewGuid().ToString("N")),
+ new ExternalLogin("test3", Guid.NewGuid().ToString("N")),
+ new ExternalLogin("test4", Guid.NewGuid().ToString("N"))
+ };
+
+ ServiceContext.ExternalLoginService.Save(user.Id, externalLogins);
+
+ var logins = ServiceContext.ExternalLoginService.GetAll(user.Id).OrderBy(x => x.LoginProvider).ToList();
+
+ logins.RemoveAt(0); // remove the first one
+ logins.Add(new IdentityUserLogin("test5", Guid.NewGuid().ToString("N"), user.Id)); // add a new one
+
+ // save new list
+ ServiceContext.ExternalLoginService.Save(user.Id, logins.Select(x => new ExternalLogin(x.LoginProvider, x.ProviderKey)));
+
+ var updatedLogins = ServiceContext.ExternalLoginService.GetAll(user.Id).OrderBy(x => x.LoginProvider).ToList();
+ Assert.AreEqual(4, updatedLogins.Count);
+ for (int i = 0; i < updatedLogins.Count; i++)
+ {
+ Assert.AreEqual(logins[i].LoginProvider, updatedLogins[i].LoginProvider);
+ }
+ }
+
+ [Test]
+ public void Add_Retrieve_User_Data()
+ {
+ var user = new User(GlobalSettings, "Test", "test@test.com", "test", "helloworldtest");
+ ServiceContext.UserService.Save(user);
+
+ var externalLogins = new[]
+ {
+ new ExternalLogin("test1", Guid.NewGuid().ToString("N"), "hello world")
+ };
+
+ ServiceContext.ExternalLoginService.Save(user.Id, externalLogins);
+
+ var logins = ServiceContext.ExternalLoginService.GetAll(user.Id).Cast().ToList();
+
+ Assert.AreEqual("hello world", logins[0].UserData);
+
+ }
+ }
+}
diff --git a/src/Umbraco.Tests/Umbraco.Tests.csproj b/src/Umbraco.Tests/Umbraco.Tests.csproj
index 24abe3f774..59b390c37d 100644
--- a/src/Umbraco.Tests/Umbraco.Tests.csproj
+++ b/src/Umbraco.Tests/Umbraco.Tests.csproj
@@ -167,6 +167,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Umbraco.Web.BackOffice/Controllers/BackOfficeServerVariables.cs b/src/Umbraco.Web.BackOffice/Controllers/BackOfficeServerVariables.cs
index 874cb6fe1d..23c4dd68d8 100644
--- a/src/Umbraco.Web.BackOffice/Controllers/BackOfficeServerVariables.cs
+++ b/src/Umbraco.Web.BackOffice/Controllers/BackOfficeServerVariables.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.Serialization;
@@ -22,6 +22,7 @@ using Umbraco.Web.Common.Attributes;
using Umbraco.Web.Editors;
using Umbraco.Web.Features;
using Umbraco.Web.Models.ContentEditing;
+using Umbraco.Web.Security;
using Umbraco.Web.Trees;
namespace Umbraco.Web.BackOffice.Controllers
diff --git a/src/Umbraco.Web.BackOffice/Controllers/DenyLocalLoginAuthorizationAttribute.cs b/src/Umbraco.Web.BackOffice/Controllers/DenyLocalLoginAuthorizationAttribute.cs
new file mode 100644
index 0000000000..89a67d8f78
--- /dev/null
+++ b/src/Umbraco.Web.BackOffice/Controllers/DenyLocalLoginAuthorizationAttribute.cs
@@ -0,0 +1,18 @@
+using System.Web.Http;
+using System.Web.Http.Controllers;
+using Umbraco.Web.WebApi;
+using Umbraco.Web.Security;
+
+namespace Umbraco.Web.Editors.Filters
+{
+ internal class DenyLocalLoginAuthorizationAttribute : AuthorizeAttribute
+ {
+ protected override bool IsAuthorized(HttpActionContext actionContext)
+ {
+ var owinContext = actionContext.Request.TryGetOwinContext().Result;
+
+ // no authorization if any external logins deny local login
+ return !owinContext.Authentication.HasDenyLocalLogin();
+ }
+ }
+}
diff --git a/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs b/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs
index 25c6d712c5..41b6c0a889 100644
--- a/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs
+++ b/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs
@@ -428,11 +428,6 @@ namespace Umbraco.Web.BackOffice.Controllers
throw new HttpResponseException(HttpStatusCode.BadRequest, ModelState);
}
- if (EmailSender.CanSendRequiredEmail(_globalSettings) == false)
- {
- throw HttpResponseException.CreateNotificationValidationErrorResponse("No Email server is configured");
- }
-
IUser user;
if (_securitySettings.UsernameIsEmail)
{
@@ -442,9 +437,17 @@ namespace Umbraco.Web.BackOffice.Controllers
else
{
//first validate the username if we're showing it
- user = CheckUniqueUsername(userSave.Username, u => u.LastLoginDate != default(DateTime) || u.EmailConfirmedDate.HasValue);
+ user = CheckUniqueUsername(userSave.Username, u => u.LastLoginDate != default || u.EmailConfirmedDate.HasValue);
+ }
+ user = CheckUniqueEmail(userSave.Email, u => u.LastLoginDate != default || u.EmailConfirmedDate.HasValue);
+
+ var userMgr = TryGetOwinContext().Result.GetBackOfficeUserManager();
+
+ if (!EmailSender.CanSendRequiredEmail(GlobalSettings) && !userMgr.HasSendingUserInviteEventHandler)
+ {
+ throw new HttpResponseException(
+ Request.CreateNotificationValidationErrorResponse("No Email server is configured"));
}
- user = CheckUniqueEmail(userSave.Email, u => u.LastLoginDate != default(DateTime) || u.EmailConfirmedDate.HasValue);
//Perform authorization here to see if the current user can actually save this user with the info being requested
var authHelper = new UserEditorAuthorizationHelper(_contentService,_mediaService, _userService, _entityService);
@@ -477,16 +480,50 @@ namespace Umbraco.Web.BackOffice.Controllers
//ensure the invited date is set
user.InvitedDate = DateTime.Now;
- //Save the updated user
+ //Save the updated user (which will process the user groups too)
_userService.Save(user);
var display = _umbracoMapper.Map(user);
- //send the email
+ var inviteArgs = new UserInviteEventArgs(
+ Request.TryGetHttpContext().Result.GetCurrentRequestIpAddress(),
+ performingUser: Security.GetUserId().Result,
+ userSave,
+ user);
- await SendUserInviteEmailAsync(display, _backofficeSecurityAccessor.BackOfficeSecurity.CurrentUser.Name, _backofficeSecurityAccessor.BackOfficeSecurity.CurrentUser.Email, user, userSave.Message);
+ try
+ {
+ userMgr.RaiseSendingUserInvite(inviteArgs);
+ }
+ catch (Exception ex)
+ {
+ Logger.Error(ex, "An error occured in a custom event handler while inviting the user");
+ throw new HttpResponseException(
+ Request.CreateNotificationValidationErrorResponse($"An error occured inviting the user (check logs for more info): {ex.Message}"));
+ }
+
+ // If the event is handled then no need to send the email
+ if (inviteArgs.InviteHandled)
+ {
+ // if no user result was created then map the minimum args manually for the UI
+ if (!inviteArgs.ShowUserResult)
+ {
+ display = new UserDisplay
+ {
+ Name = userSave.Name,
+ Email = userSave.Email,
+ Username = userSave.Username
+ };
+ }
+ }
+ else
+ {
+ //send the email
+
+ await SendUserInviteEmailAsync(display, _backofficeSecurityAccessor.BackOfficeSecurity.CurrentUser.Name, _backofficeSecurityAccessor.BackOfficeSecurity.CurrentUser.Email, user, userSave.Message);
+
+ }
display.AddSuccessNotification(_localizedTextService.Localize("speechBubbles/resendInviteHeader"), _localizedTextService.Localize("speechBubbles/resendInviteSuccess", new[] { user.Name }));
-
return display;
}
@@ -560,7 +597,7 @@ namespace Umbraco.Web.BackOffice.Controllers
///
///
[TypeFilter(typeof(OutgoingEditorModelEventAttribute))]
- public async Task PostSaveUser(UserSave userSave)
+ public UserDisplay PostSaveUser(UserSave userSave)
{
if (userSave == null) throw new ArgumentNullException(nameof(userSave));
@@ -587,6 +624,14 @@ namespace Umbraco.Web.BackOffice.Controllers
var hasErrors = false;
+ // we need to check if there's any Deny Local login providers present, if so we need to ensure that the user's email address cannot be changed
+ var owinContext = Request.TryGetOwinContext().Result;
+ var hasDenyLocalLogin = owinContext.Authentication.HasDenyLocalLogin();
+ if (hasDenyLocalLogin)
+ {
+ userSave.Email = found.Email; // it cannot change, this would only happen if people are mucking around with the request
+ }
+
var existing = _userService.GetByEmail(userSave.Email);
if (existing != null && existing.Id != userSave.Id)
{
diff --git a/src/Umbraco.Web.BackOffice/Extensions/HtmlHelperBackOfficeExtensions.cs b/src/Umbraco.Web.BackOffice/Extensions/HtmlHelperBackOfficeExtensions.cs
index 68025fc7ab..153e309992 100644
--- a/src/Umbraco.Web.BackOffice/Extensions/HtmlHelperBackOfficeExtensions.cs
+++ b/src/Umbraco.Web.BackOffice/Extensions/HtmlHelperBackOfficeExtensions.cs
@@ -18,6 +18,7 @@ using Umbraco.Web.Features;
using Umbraco.Web.Models;
using Umbraco.Web.WebApi;
using Umbraco.Web.WebAssets;
+using Umbraco.Core;
namespace Umbraco.Extensions
{
@@ -63,6 +64,7 @@ namespace Umbraco.Extensions
///
///
public static async Task AngularValueExternalLoginInfoScriptAsync(this IHtmlHelper html,
+ BackOfficeExternalLoginProviderErrors externalLoginErrors,
BackOfficeSignInManager signInManager,
IEnumerable externalLoginErrors)
{
@@ -89,13 +91,15 @@ namespace Umbraco.Extensions
if (externalLoginErrors != null)
{
- foreach (var error in externalLoginErrors)
+ foreach (var error in externalLoginErrors.Errors)
{
- sb.AppendFormat(@"errors.push(""{0}"");", error).AppendLine();
+ sb.AppendFormat(@"errors.push(""{0}"");", error.ToSingleLine()).AppendLine();
}
}
sb.AppendLine(@"app.value(""externalLoginInfo"", {");
+ if (externalLoginErrors?.AuthenticationType != null)
+ sb.AppendLine($@"errorProvider: '{externalLoginErrors.AuthenticationType}',");
sb.AppendLine(@"errors: errors,");
sb.Append(@"providers: ");
sb.AppendLine(JsonConvert.SerializeObject(loginProviders));
@@ -104,6 +108,12 @@ namespace Umbraco.Extensions
return html.Raw(sb.ToString());
}
+ [Obsolete("Use the other overload instead")]
+ public static IHtmlString AngularValueExternalLoginInfoScript(this HtmlHelper html, IEnumerable externalLoginErrors)
+ {
+ return html.AngularValueExternalLoginInfoScript(new BackOfficeExternalLoginProviderErrors(string.Empty, externalLoginErrors));
+ }
+
///
/// Used to render the script that will pass in the angular "resetPasswordCodeInfo" service/value on page load
///
diff --git a/src/Umbraco.Web.BackOffice/Services/IconService.cs b/src/Umbraco.Web.BackOffice/Services/IconService.cs
index 07a52b4446..a29f1a3210 100644
--- a/src/Umbraco.Web.BackOffice/Services/IconService.cs
+++ b/src/Umbraco.Web.BackOffice/Services/IconService.cs
@@ -22,13 +22,11 @@ namespace Umbraco.Web.BackOffice.Services
_hostingEnvironment = hostingEnvironment;
}
-
///
public IList GetAllIcons()
{
var icons = new List();
- var directory = new DirectoryInfo(_hostingEnvironment.MapPathWebRoot($"{_globalSettings.Value.IconsPath}/"));
- var iconNames = directory.GetFiles("*.svg");
+ var iconNames = GetAllIconNames();
iconNames.OrderBy(f => f.Name).ToList().ForEach(iconInfo =>
{
@@ -55,8 +53,8 @@ namespace Umbraco.Web.BackOffice.Services
/// Gets an IconModel using values from a FileInfo model
///
///
- ///
- private IconModel GetIcon(FileInfo fileInfo)
+ ///
+ private IconModel GetIcon(FileSystemInfo fileInfo)
{
return fileInfo == null || string.IsNullOrWhiteSpace(fileInfo.Name)
? null
@@ -67,17 +65,37 @@ namespace Umbraco.Web.BackOffice.Services
/// Gets an IconModel containing the icon name and SvgString
///
///
- ///
- ///
- private IconModel CreateIconModel(string iconName, string iconPath)
+ ///
+ private IconModel CreateIconModel(string iconName)
{
- var sanitizer = new HtmlSanitizer();
- sanitizer.AllowedAttributes.UnionWith(Constants.SvgSanitizer.Attributes);
- sanitizer.AllowedCssProperties.UnionWith(Constants.SvgSanitizer.Attributes);
- sanitizer.AllowedTags.UnionWith(Constants.SvgSanitizer.Tags);
+ if (string.IsNullOrWhiteSpace(iconName))
+ return null;
+
+ var iconNames = GetAllIconNames();
+ var iconPath = iconNames.FirstOrDefault(x => x.Name.InvariantEquals($"{iconName}.svg"))?.FullName;
+ return iconPath == null
+ ? null
+ : CreateIconModel(iconName, iconPath);
+ }
+
+ ///
+ /// Gets an IconModel containing the icon name and SvgString
+ ///
+ ///
+ ///
+ ///
+ private static IconModel CreateIconModel(string iconName, string iconPath)
+ {
+ if (string.IsNullOrWhiteSpace(iconPath))
+ return null;
try
{
+ var sanitizer = new HtmlSanitizer();
+ sanitizer.AllowedAttributes.UnionWith(Constants.SvgSanitizer.Attributes);
+ sanitizer.AllowedCssProperties.UnionWith(Constants.SvgSanitizer.Attributes);
+ sanitizer.AllowedTags.UnionWith(Constants.SvgSanitizer.Tags);
+
var svgContent = System.IO.File.ReadAllText(iconPath);
var sanitizedString = sanitizer.Sanitize(svgContent);
@@ -94,5 +112,26 @@ namespace Umbraco.Web.BackOffice.Services
return null;
}
}
+
+ private IEnumerable GetAllIconNames()
+ {
+ // add icons from plugins
+ var appPlugins = new DirectoryInfo(_hostingEnvironment.MapPath(Constants.SystemDirectories.AppPlugins));
+ var pluginIcons = appPlugins.Exists == false
+ ? new List()
+ : appPlugins.GetDirectories()
+ // Find all directories in App_Plugins that are named "Icons" and get a list of SVGs from them
+ .SelectMany(x => x.GetDirectories("Icons", SearchOption.AllDirectories))
+ .SelectMany(x => x.GetFiles("*.svg", SearchOption.TopDirectoryOnly));
+
+ // add icons from IconsPath if not already added from plugins
+ var directory = new DirectoryInfo(_hostingEnvironment.MapPath($"{_globalSettings.IconsPath}/"));
+ var iconNames = directory.GetFiles("*.svg")
+ .Where(x => pluginIcons.Any(i => i.Name == x.Name) == false);
+
+ iconNames = iconNames.Concat(pluginIcons).ToList();
+
+ return iconNames;
+ }
}
}
diff --git a/src/Umbraco.Web.Common/Security/ExternalSignInAutoLinkOptions.cs b/src/Umbraco.Web.Common/Security/ExternalSignInAutoLinkOptions.cs
index 93ecf95a3e..d39d54ba3d 100644
--- a/src/Umbraco.Web.Common/Security/ExternalSignInAutoLinkOptions.cs
+++ b/src/Umbraco.Web.Common/Security/ExternalSignInAutoLinkOptions.cs
@@ -6,6 +6,7 @@ using SecurityConstants = Umbraco.Core.Constants.Security;
namespace Umbraco.Web.Common.Security
{
+
///
/// Options used to configure auto-linking external OAuth providers
///
@@ -27,15 +28,23 @@ namespace Umbraco.Web.Common.Security
_defaultCulture = defaultCulture;
}
+ ///
+ /// By default this is true which allows the user to manually link and unlink the external provider, if set to false the back office user
+ /// will not see and cannot perform manual linking or unlinking of the external provider.
+ ///
+ public bool AllowManualLinking { get; set; } = true;
+
///
/// A callback executed during account auto-linking and before the user is persisted
///
+ [IgnoreDataMember]
public Action OnAutoLinking { get; set; }
///
/// A callback executed during every time a user authenticates using an external login.
/// returns a boolean indicating if sign in should continue or not.
///
+ [IgnoreDataMember]
public Func OnExternalLogin { get; set; }
///
diff --git a/src/Umbraco.Web.UI.Client/src/assets/icons/icon-adressbook.svg b/src/Umbraco.Web.UI.Client/src/assets/icons/icon-addressbook.svg
similarity index 100%
rename from src/Umbraco.Web.UI.Client/src/assets/icons/icon-adressbook.svg
rename to src/Umbraco.Web.UI.Client/src/assets/icons/icon-addressbook.svg
diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umblogin.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umblogin.directive.js
index 3e287a6d6c..675378985c 100644
--- a/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umblogin.directive.js
+++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/application/umblogin.directive.js
@@ -13,7 +13,10 @@
}
});
- function UmbLoginController($scope, $location, currentUserResource, formHelper, mediaHelper, umbRequestHelper, Upload, localizationService, userService, externalLoginInfo, resetPasswordCodeInfo, $timeout, authResource, $q, $route) {
+ function UmbLoginController($scope, $location, currentUserResource, formHelper,
+ mediaHelper, umbRequestHelper, Upload, localizationService,
+ userService, externalLoginInfo, externalLoginInfoService,
+ resetPasswordCodeInfo, $timeout, authResource, $q, $route) {
const vm = this;
@@ -43,7 +46,15 @@
vm.allowPasswordReset = Umbraco.Sys.ServerVariables.umbracoSettings.canSendRequiredEmail && Umbraco.Sys.ServerVariables.umbracoSettings.allowPasswordReset;
vm.errorMsg = "";
vm.externalLoginFormAction = Umbraco.Sys.ServerVariables.umbracoUrls.externalLoginsUrl;
- vm.externalLoginProviders = externalLoginInfo.providers;
+ vm.externalLoginProviders = externalLoginInfoService.getLoginProviders();
+ vm.externalLoginProviders.forEach(x => {
+ x.customView = externalLoginInfoService.getLoginProviderView(x);
+ // if there are errors set for this specific provider than assign them directly to the model
+ if (externalLoginInfo.errorProvider === x.authType) {
+ x.errors = externalLoginInfo.errors;
+ }
+ });
+ vm.denyLocalLogin = externalLoginInfoService.hasDenyLocalLogin();
vm.externalLoginInfo = externalLoginInfo;
vm.resetPasswordCodeInfo = resetPasswordCodeInfo;
vm.backgroundImage = Umbraco.Sys.ServerVariables.umbracoSettings.loginBackgroundImage;
@@ -62,7 +73,7 @@
vm.setPasswordSubmit = setPasswordSubmit;
vm.labels = {};
localizationService.localizeMany([
- vm.usernameIsEmail ? "general_email" : "general_username",
+ vm.usernameIsEmail ? "general_email" : "general_username",
vm.usernameIsEmail ? "placeholders_email" : "placeholders_usernameHint",
vm.usernameIsEmail ? "placeholders_emptyEmail" : "placeholders_emptyUsername",
"placeholders_emptyPassword"]
@@ -72,9 +83,11 @@
vm.labels.usernameError = data[2];
vm.labels.passwordError = data[3];
});
-
+
vm.twoFactor = {};
+ vm.loginSuccess = loginSuccess;
+
function onInit() {
// Check if it is a new user
@@ -98,11 +111,11 @@
//localize the text
localizationService.localize("errorHandling_errorInPasswordFormat", [
- vm.invitedUserPasswordModel.passwordPolicies.minPasswordLength,
- vm.invitedUserPasswordModel.passwordPolicies.minNonAlphaNumericChars
- ]).then(function (data) {
- vm.invitedUserPasswordModel.passwordPolicyText = data;
- });
+ vm.invitedUserPasswordModel.passwordPolicies.minPasswordLength,
+ vm.invitedUserPasswordModel.passwordPolicies.minNonAlphaNumericChars
+ ]).then(function (data) {
+ vm.invitedUserPasswordModel.passwordPolicyText = data;
+ });
})
]).then(function () {
vm.inviteStep = Number(inviteVal);
@@ -144,12 +157,12 @@
function getStarted() {
$location.search('invite', null);
- if(vm.onLogin) {
+ if (vm.onLogin) {
vm.onLogin();
}
}
- function inviteSavePassword () {
+ function inviteSavePassword() {
if (formHelper.submitForm({ scope: $scope })) {
@@ -197,37 +210,41 @@
SetTitle();
}
+ function loginSuccess() {
+ vm.loginStates.submitButton = "success";
+ userService._retryRequestQueue(true);
+ if (vm.onLogin) {
+ vm.onLogin();
+ }
+ }
+
function loginSubmit() {
-
- if (formHelper.submitForm({ scope: $scope })) {
+
+ if (formHelper.submitForm({ scope: $scope, formCtrl: vm.loginForm })) {
//if the login and password are not empty we need to automatically
// validate them - this is because if there are validation errors on the server
// then the user has to change both username & password to resubmit which isn't ideal,
// so if they're not empty, we'll just make sure to set them to valid.
- if (vm.login && vm.password && vm.login.length > 0 && vm.password.length > 0) {
+ if (vm.login && vm.password && vm.login.length > 0 && vm.password.length > 0) {
vm.loginForm.username.$setValidity('auth', true);
vm.loginForm.password.$setValidity('auth', true);
}
-
+
if (vm.loginForm.$invalid) {
SetTitle();
return;
}
-
+
// make sure that we are returning to the login view.
vm.view = "login";
vm.loginStates.submitButton = "busy";
userService.authenticate(vm.login, vm.password)
- .then(function(data) {
- vm.loginStates.submitButton = "success";
- userService._retryRequestQueue(true);
- if (vm.onLogin) {
- vm.onLogin();
- }
- },
- function(reason) {
+ .then(function (data) {
+ loginSuccess();
+ },
+ function (reason) {
//is Two Factor required?
if (reason.status === 402) {
@@ -249,13 +266,13 @@
//setup a watch for both of the model values changing, if they change
// while the form is invalid, then revalidate them so that the form can
// be submitted again.
- vm.loginForm.username.$viewChangeListeners.push(function() {
+ vm.loginForm.username.$viewChangeListeners.push(function () {
if (vm.loginForm.$invalid) {
vm.loginForm.username.$setValidity('auth', true);
vm.loginForm.password.$setValidity('auth', true);
}
});
- vm.loginForm.password.$viewChangeListeners.push(function() {
+ vm.loginForm.password.$viewChangeListeners.push(function () {
if (vm.loginForm.$invalid) {
vm.loginForm.username.$setValidity('auth', true);
vm.loginForm.password.$setValidity('auth', true);
@@ -460,7 +477,7 @@
case "2fa-login":
title = "Two Factor Authentication";
break;
- }
+ }
$scope.$emit("$changeTitle", title);
}
diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/buttons/umbbutton.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/buttons/umbbutton.directive.js
index 5e007a7ff4..a236e7f5ac 100644
--- a/src/Umbraco.Web.UI.Client/src/common/directives/components/buttons/umbbutton.directive.js
+++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/buttons/umbbutton.directive.js
@@ -86,6 +86,7 @@ Use this directive to render an umbraco button. The directive can be used to gen
bindings: {
action: "&?",
href: "@?",
+ hrefTarget: "@?",
type: "@",
buttonStyle: "@?",
state: "",
@@ -123,6 +124,10 @@ Use this directive to render an umbraco button. The directive can be used to gen
vm.innerState = "init";
vm.generalActions = vm.labelKey === "general_actions";
+ if (!vm.type) {
+ vm.type = "button"; // set the default
+ }
+
vm.buttonLabel = vm.label;
// is this a primary button style (i.e. anything but an 'info' button)?
vm.isPrimaryButtonStyle = vm.buttonStyle && vm.buttonStyle !== 'info';
@@ -166,7 +171,7 @@ Use this directive to render an umbraco button. The directive can be used to gen
vm.innerState = changes.state.currentValue;
}
if (changes.state.currentValue === 'success' || changes.state.currentValue === 'error') {
- // set the state back to 'init' after a success or error
+ // set the state back to 'init' after a success or error
$timeout(function () {
vm.innerState = 'init';
}, 2000);
@@ -191,6 +196,13 @@ Use this directive to render an umbraco button. The directive can be used to gen
setButtonLabel();
}
+ // watch for type changes
+ if(changes.type) {
+ if (!vm.type) {
+ vm.type = "button";// set the default
+ }
+ }
+
}
function clickButton(event) {
diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/umbconfirmaction.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/umbconfirmaction.directive.js
index 2f682205ad..1dcccda481 100644
--- a/src/Umbraco.Web.UI.Client/src/common/directives/components/umbconfirmaction.directive.js
+++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/umbconfirmaction.directive.js
@@ -14,10 +14,10 @@ The prompt can be opened in four direction up, down, left or right.
+
@@ -71,23 +71,17 @@ The prompt can be opened in four direction up, down, left or right.
function link(scope, el, attr, ctrl) {
- scope.clickButton = function (event) {
- if(scope.onDelete) {
- scope.onDelete({$event: event});
- }
- }
+ scope.clickConfirm = function() {
+ if(scope.onConfirm) {
+ scope.onConfirm();
+ }
+ };
- scope.clickConfirm = function() {
- if(scope.onConfirm) {
- scope.onConfirm();
- }
- };
-
- scope.clickCancel = function() {
- if(scope.onCancel) {
- scope.onCancel();
- }
- };
+ scope.clickCancel = function() {
+ if(scope.onCancel) {
+ scope.onCancel();
+ }
+ };
}
@@ -97,8 +91,6 @@ The prompt can be opened in four direction up, down, left or right.
templateUrl: 'views/components/umb-confirm-action.html',
scope: {
direction: "@",
- show: "<",
- onDelete: "&?",
onConfirm: "&",
onCancel: "&"
},
diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/umbicon.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/umbicon.directive.js
index 517776388b..87d976f6d9 100644
--- a/src/Umbraco.Web.UI.Client/src/common/directives/components/umbicon.directive.js
+++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/umbicon.directive.js
@@ -15,16 +15,7 @@ Simple icon
Icon with additional attribute. It can be treated like any other dom element
-
-
-
-Manual svg string
-This format is only used in the iconpicker.html
-
-
-
+
@example
**/
@@ -43,13 +34,19 @@ This format is only used in the iconpicker.html
svgString: "=?"
},
- link: function (scope) {
-
+ link: function (scope, element) {
if (scope.svgString === undefined && scope.svgString !== null && scope.icon !== undefined && scope.icon !== null) {
- var icon = scope.icon.split(" ")[0]; // Ensure that only the first part of the icon is used as sometimes the color is added too, e.g. see umbeditorheader.directive scope.openIconPicker
+ const observer = new IntersectionObserver(_lazyRequestIcon, {rootMargin: "100px"});
+ const iconEl = element[0];
- _requestIcon(icon);
+ observer.observe(iconEl);
+
+ // make sure to disconnect the observer when the scope is destroyed
+ scope.$on('$destroy', function () {
+ observer.disconnect();
+ });
}
+
scope.$watch("icon", function (newValue, oldValue) {
if (newValue && oldValue) {
var newicon = newValue.split(" ")[0];
@@ -61,6 +58,17 @@ This format is only used in the iconpicker.html
}
});
+ function _lazyRequestIcon(entries, observer) {
+ entries.forEach(entry => {
+ if (entry.isIntersecting === true) {
+ observer.disconnect();
+
+ var icon = scope.icon.split(" ")[0]; // Ensure that only the first part of the icon is used as sometimes the color is added too, e.g. see umbeditorheader.directive scope.openIconPicker
+ _requestIcon(icon);
+ }
+ });
+ }
+
function _requestIcon(icon) {
// Reset svg string before requesting new icon.
scope.svgString = null;
diff --git a/src/Umbraco.Web.UI.Client/src/common/interceptors/_module.js b/src/Umbraco.Web.UI.Client/src/common/interceptors/_module.js
index 7e1971352a..b5244a9874 100644
--- a/src/Umbraco.Web.UI.Client/src/common/interceptors/_module.js
+++ b/src/Umbraco.Web.UI.Client/src/common/interceptors/_module.js
@@ -6,6 +6,7 @@ angular.module('umbraco.interceptors', [])
$httpProvider.interceptors.push('securityInterceptor');
$httpProvider.interceptors.push('requiredHeadersInterceptor');
+ $httpProvider.interceptors.push('requiredHeadersInterceptor');
$httpProvider.interceptors.push('doNotPostDollarVariablesOnPostRequestInterceptor');
$httpProvider.interceptors.push('cultureRequestInterceptor');
diff --git a/src/Umbraco.Web.UI.Client/src/common/services/blockeditor.service.js b/src/Umbraco.Web.UI.Client/src/common/services/blockeditor.service.js
index dfa0eae297..12e1144acc 100644
--- a/src/Umbraco.Web.UI.Client/src/common/services/blockeditor.service.js
+++ b/src/Umbraco.Web.UI.Client/src/common/services/blockeditor.service.js
@@ -51,7 +51,7 @@
} else {
// lets crawl through all properties of layout to make sure get captured all `contentUdi` and `settingsUdi` properties.
var propType = typeof obj[k];
- if(propType === "object" || propType === "array") {
+ if(propType != null && (propType === "object" || propType === "array")) {
replaceUdisOfObject(obj[k], propValue)
}
}
@@ -65,7 +65,7 @@
function replaceRawBlockListUDIsResolver(value, propClearingMethod) {
- if (typeof value === "object") {
+ if (value != null && typeof value === "object") {
// we got an object, and it has these three props then we are most likely dealing with a Block Editor.
if ((value.layout !== undefined && value.contentData !== undefined && value.settingsData !== undefined)) {
diff --git a/src/Umbraco.Web.UI.Client/src/common/services/blockeditormodelobject.service.js b/src/Umbraco.Web.UI.Client/src/common/services/blockeditormodelobject.service.js
index 868b8baba7..77ed357c35 100644
--- a/src/Umbraco.Web.UI.Client/src/common/services/blockeditormodelobject.service.js
+++ b/src/Umbraco.Web.UI.Client/src/common/services/blockeditormodelobject.service.js
@@ -425,7 +425,11 @@
if (self.scaffolds) {
self.scaffolds.push(formatScaffoldData(scaffold));
}
- }));
+ }).catch(
+ () => {
+ // Do nothing if we get an error.
+ }
+ ));
});
return $q.all(tasks);
@@ -439,7 +443,14 @@
* @return {Array} array of strings representing alias.
*/
getAvailableAliasesForBlockContent: function () {
- return this.blockConfigurations.map(blockConfiguration => this.getScaffoldFromKey(blockConfiguration.contentElementTypeKey).contentTypeAlias);
+ return this.blockConfigurations.map(
+ (blockConfiguration) => {
+ var scaffold = this.getScaffoldFromKey(blockConfiguration.contentElementTypeKey);
+ if (scaffold) {
+ return scaffold.contentTypeAlias;
+ }
+ }
+ );
},
/**
@@ -519,7 +530,7 @@
var dataModel = getDataByUdi(contentUdi, this.value.contentData);
if (dataModel === null) {
- console.error("Couldn't find content model of " + contentUdi)
+ console.error("Couldn't find content data of " + contentUdi)
return null;
}
@@ -591,7 +602,7 @@
var settingsData = getDataByUdi(settingsUdi, this.value.settingsData);
if (settingsData === null) {
- console.error("Couldnt find content settings data of " + settingsUdi)
+ console.error("Couldnt find settings data of " + settingsUdi)
return null;
}
diff --git a/src/Umbraco.Web.UI.Client/src/common/services/externallogininfo.service.js b/src/Umbraco.Web.UI.Client/src/common/services/externallogininfo.service.js
new file mode 100644
index 0000000000..1d2048b2f5
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/common/services/externallogininfo.service.js
@@ -0,0 +1,67 @@
+/**
+ * @ngdoc service
+ * @name umbraco.services.externalLoginInfoService
+ * @description A service for working with external login providers
+ **/
+function externalLoginInfoService(externalLoginInfo, umbRequestHelper) {
+
+ function getLoginProvider(provider) {
+ if (provider) {
+ var found = _.find(externalLoginInfo.providers, x => x.authType == provider);
+ return found;
+ }
+ return null;
+ }
+
+ function getLoginProviderView(provider) {
+ if (provider && provider.properties.UmbracoBackOfficeExternalLoginOptions && provider.properties.UmbracoBackOfficeExternalLoginOptions.CustomBackOfficeView) {
+ return umbRequestHelper.convertVirtualToAbsolutePath(provider.properties.UmbracoBackOfficeExternalLoginOptions.CustomBackOfficeView);
+ }
+ return null;
+ }
+
+ /**
+ * Returns true if any provider denies local login if `provider` is null, else whether the passed
+ * @param {any} provider
+ */
+ function hasDenyLocalLogin(provider) {
+ if (!provider) {
+ return _.some(externalLoginInfo.providers, x => x.properties.UmbracoBackOfficeExternalLoginOptions.DenyLocalLogin === true);
+ }
+ else {
+ return provider.properties.UmbracoBackOfficeExternalLoginOptions.DenyLocalLogin;
+ }
+ }
+
+ /**
+ * Returns all login providers
+ */
+ function getLoginProviders() {
+ return externalLoginInfo.providers;
+ }
+
+ /** Returns all logins providers that have options that the user can interact with */
+ function getLoginProvidersWithOptions() {
+ // only include providers that allow manual linking or ones that provide a custom view
+ var providers = _.filter(externalLoginInfo.providers, x => {
+ // transform the data and also include the custom view as a nicer property
+ x.customView = getLoginProviderView(x);
+ if (x.customView) {
+ return true;
+ }
+ else {
+ return x.properties.ExternalSignInAutoLinkOptions.AllowManualLinking;
+ }
+ });
+ return providers;
+ }
+
+ return {
+ hasDenyLocalLogin: hasDenyLocalLogin,
+ getLoginProvider: getLoginProvider,
+ getLoginProviders: getLoginProviders,
+ getLoginProvidersWithOptions: getLoginProvidersWithOptions,
+ getLoginProviderView: getLoginProviderView
+ };
+}
+angular.module('umbraco.services').factory('externalLoginInfoService', externalLoginInfoService);
diff --git a/src/Umbraco.Web.UI.Client/src/common/services/focuslock.service.js b/src/Umbraco.Web.UI.Client/src/common/services/focuslock.service.js
index a3dd91194e..9ce2f41691 100644
--- a/src/Umbraco.Web.UI.Client/src/common/services/focuslock.service.js
+++ b/src/Umbraco.Web.UI.Client/src/common/services/focuslock.service.js
@@ -5,11 +5,15 @@
var elementToInert = document.querySelector('#mainwrapper');
function addInertAttribute() {
- elementToInert.setAttribute('inert', true);
+ if (elementToInert) {
+ elementToInert.setAttribute('inert', true);
+ }
}
function removeInertAttribute() {
- elementToInert.removeAttribute('inert');
+ if (elementToInert) {
+ elementToInert.removeAttribute('inert');
+ }
}
var service = {
diff --git a/src/Umbraco.Web.UI.Client/src/common/services/servervalidationmgr.service.js b/src/Umbraco.Web.UI.Client/src/common/services/servervalidationmgr.service.js
index 731cc5ed36..8df5a9ce8c 100644
--- a/src/Umbraco.Web.UI.Client/src/common/services/servervalidationmgr.service.js
+++ b/src/Umbraco.Web.UI.Client/src/common/services/servervalidationmgr.service.js
@@ -464,7 +464,7 @@ function serverValidationManager($timeout) {
* @ngdoc function
* @name addErrorsForModelState
* @methodOf umbraco.services.serverValidationManager
- * @param {any} modelState
+ * @param {any} modelState the modelState object
* @param {any} parentValidationPath optional parameter specifying a nested element's UDI for which this property belongs (for complex editors)
* @description
* This wires up all of the server validation model state so that valServer and valServerField directives work
diff --git a/src/Umbraco.Web.UI.Client/src/common/services/user.service.js b/src/Umbraco.Web.UI.Client/src/common/services/user.service.js
index de6fbaf782..00871caab1 100644
--- a/src/Umbraco.Web.UI.Client/src/common/services/user.service.js
+++ b/src/Umbraco.Web.UI.Client/src/common/services/user.service.js
@@ -1,5 +1,5 @@
angular.module('umbraco.services')
- .factory('userService', function ($rootScope, eventsService, $q, $location, requestRetryQueue, authResource, emailMarketingResource, $timeout, angularHelper) {
+ .factory('userService', function ($rootScope, eventsService, $q, $location, $window, requestRetryQueue, authResource, emailMarketingResource, $timeout, angularHelper) {
var currentUser = null;
var lastUserId = null;
@@ -166,7 +166,7 @@ angular.module('umbraco.services')
},
/** Internal method to retry all request after sucessfull login */
- _retryRequestQueue: function(success) {
+ _retryRequestQueue: function (success) {
retryRequestQueue(success)
},
@@ -185,18 +185,22 @@ angular.module('umbraco.services')
authenticate: function (login, password) {
return authResource.performLogin(login, password)
- .then(function(data) {
+ .then(function (data) {
// Check if user has a start node set.
- if(data.startContentIds.length === 0 && data.startMediaIds.length === 0){
+ if (data.startContentIds.length === 0 && data.startMediaIds.length === 0) {
var errorMsg = "User has no start-nodes";
var result = { errorMsg: errorMsg, user: data, authenticated: false, lastUserId: lastUserId, loginType: "credentials" };
eventsService.emit("app.notAuthenticated", result);
+ // TODO: How does this make sense? How can you throw from a promise? Does this get caught by the rejection?
+ // If so then return $q.reject should be used.
throw result;
}
-
+
return data;
-
+
+ }, function (err) {
+ return $q.reject(err);
}).then(this.setAuthenticationSuccessful);
},
setAuthenticationSuccessful: function (data) {
@@ -218,8 +222,14 @@ angular.module('umbraco.services')
return authResource.performLogout()
.then(function (data) {
userAuthExpired();
- //done!
- return null;
+
+ if (data && data.signOutRedirectUrl) {
+ $window.location.replace(data.signOutRedirectUrl);
+ }
+ else {
+ //done!
+ return null;
+ }
});
},
@@ -235,9 +245,9 @@ angular.module('umbraco.services')
setCurrentUser(data);
deferred.resolve(currentUser);
- }, function () {
+ }, function (err) {
//it failed, so they are not logged in
- deferred.reject();
+ deferred.reject(err);
});
return deferred.promise;
@@ -245,7 +255,7 @@ angular.module('umbraco.services')
/** Returns the current user object in a promise */
getCurrentUser: function (args) {
-
+
if (!currentUser) {
return authResource.getCurrentUser()
.then(function (data) {
@@ -260,9 +270,9 @@ angular.module('umbraco.services')
setCurrentUser(data);
return $q.when(currentUser);
- }, function () {
+ }, function (err) {
//it failed, so they are not logged in
- return $q.reject(currentUser);
+ return $q.reject(err);
});
}
diff --git a/src/Umbraco.Web.UI.Client/src/less/components/card.less b/src/Umbraco.Web.UI.Client/src/less/components/card.less
index 017468fa0c..a1a4b4bc5e 100644
--- a/src/Umbraco.Web.UI.Client/src/less/components/card.less
+++ b/src/Umbraco.Web.UI.Client/src/less/components/card.less
@@ -159,6 +159,7 @@
justify-content: center;
flex-direction: column;
background-color: transparent;
+ word-break: break-word;
}
}
diff --git a/src/Umbraco.Web.UI.Client/src/less/components/tree/umb-tree.less b/src/Umbraco.Web.UI.Client/src/less/components/tree/umb-tree.less
index bf8e7fdb70..f967994c0f 100644
--- a/src/Umbraco.Web.UI.Client/src/less/components/tree/umb-tree.less
+++ b/src/Umbraco.Web.UI.Client/src/less/components/tree/umb-tree.less
@@ -168,7 +168,9 @@ body.touch .umb-tree {
.umb-tree .umb-tree-node-checked > .umb-tree-item__inner > i[class^="icon-"],
.umb-tree .umb-tree-node-checked > .umb-tree-item__inner > i[class*=" icon-"],
.umb-tree .umb-tree-node-checked .umb-search-group-item-name > i[class^="icon-"],
-.umb-tree .umb-tree-node-checked .umb-search-group-item-name > i[class*=" icon-"] {
+.umb-tree .umb-tree-node-checked .umb-search-group-item-name > i[class*=" icon-"],
+.umb-tree .umb-tree-node-checked > i[class^="icon-"],
+.umb-tree .umb-tree-node-checked > i[class*="icon-"] {
font-family: 'icomoon' !important;
color: @green !important;
diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-confirm-action.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-confirm-action.less
index a6548123ac..112194f012 100644
--- a/src/Umbraco.Web.UI.Client/src/less/components/umb-confirm-action.less
+++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-confirm-action.less
@@ -1,8 +1,3 @@
-//WRAPPER
-.umb_confirm-action {
- display: inline-block;
-}
-
// OVERLAY
.umb_confirm-action__overlay {
position: absolute;
diff --git a/src/Umbraco.Web.UI.Client/src/less/components/umb-form-check.less b/src/Umbraco.Web.UI.Client/src/less/components/umb-form-check.less
index cff9980483..a96c59de84 100644
--- a/src/Umbraco.Web.UI.Client/src/less/components/umb-form-check.less
+++ b/src/Umbraco.Web.UI.Client/src/less/components/umb-form-check.less
@@ -97,9 +97,11 @@
height: 20px;
width: 20px;
position: absolute;
- top: -1px;
+ top: 0;
+
}
+
&__check {
display: flex;
position: relative;
diff --git a/src/Umbraco.Web.UI.Client/src/less/main.less b/src/Umbraco.Web.UI.Client/src/less/main.less
index 3bf00fb25c..e1e368f2e2 100644
--- a/src/Umbraco.Web.UI.Client/src/less/main.less
+++ b/src/Umbraco.Web.UI.Client/src/less/main.less
@@ -208,6 +208,13 @@ umb-property:last-of-type .umb-control-group {
.control-description {
display: block;
clear: both;
+ overflow-wrap: break-word;
+ }
+
+ &::after {
+ content: '';
+ display: block;
+ clear: both;
}
}
diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/blockpicker/blockpicker.html b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/blockpicker/blockpicker.html
index 6dea4debb6..4b08d4e5fc 100644
--- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/blockpicker/blockpicker.html
+++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/blockpicker/blockpicker.html
@@ -16,7 +16,7 @@