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: "
+ @@ -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 @@
-