diff --git a/.github/workflows/pr-first-response.yml b/.github/workflows/pr-first-response.yml index f54c8b91ba..b2161c0d79 100644 --- a/.github/workflows/pr-first-response.yml +++ b/.github/workflows/pr-first-response.yml @@ -8,19 +8,43 @@ jobs: send-response: runs-on: ubuntu-latest steps: - - name: Fetch random comment 🗣️ - uses: JamesIves/fetch-api-data-action@v2.1.0 - with: - ENDPOINT: https://collaboratorsv2.euwest01.umbraco.io/umbraco/api/comments/PostComment - CONFIGURATION: '{ "method": "POST", "headers": {"Authorization": "Bearer ${{ secrets.OUR_BOT_API_TOKEN }}", "Content-Type": "application/json" }, "body": { "repo": "${{ github.repository }}", "number": "${{ github.event.number }}", "actor": "${{ github.actor }}", "commentType": "opened-pr-first-comment"} }' - - name: Add PR comment - if: "${{ env.fetch-api-data != '' }}" - uses: actions/github-script@v5 + - name: Install dependencies + run: | + npm install node-fetch@2 + - name: Fetch random comment 🗣️ and add it to the PR + uses: actions/github-script@v6 with: script: | - github.rest.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - body: `${{ env.fetch-api-data }}` - }) \ No newline at end of file + const fetch = require('node-fetch') + + const response = await fetch('https://collaboratorsv2.euwest01.umbraco.io/umbraco/api/comments/PostComment', { + method: 'post', + body: JSON.stringify({ + repo: '${{ github.repository }}', + number: '${{ github.event.number }}', + actor: '${{ github.actor }}', + commentType: 'opened-pr-first-comment' + }), + headers: { + 'Authorization': 'Bearer ${{ secrets.OUR_BOT_API_TOKEN }}', + 'Content-Type': 'application/json' + } + }); + + try { + const data = await response.text(); + + if(response.status === 200 && data !== '') { + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: data + }); + } else { + console.log("Status code did not indicate success:", response.status); + console.log("Returned data:", data); + } + } catch(error) { + console.log(error); + } diff --git a/src/JsonSchema/JsonSchema.csproj b/src/JsonSchema/JsonSchema.csproj index 3cd8def105..16814669d7 100644 --- a/src/JsonSchema/JsonSchema.csproj +++ b/src/JsonSchema/JsonSchema.csproj @@ -8,9 +8,9 @@ - + - + diff --git a/src/Umbraco.Cms.Persistence.Sqlite/Umbraco.Cms.Persistence.Sqlite.csproj b/src/Umbraco.Cms.Persistence.Sqlite/Umbraco.Cms.Persistence.Sqlite.csproj index 3fce21af07..8471b6db92 100644 --- a/src/Umbraco.Cms.Persistence.Sqlite/Umbraco.Cms.Persistence.Sqlite.csproj +++ b/src/Umbraco.Cms.Persistence.Sqlite/Umbraco.Cms.Persistence.Sqlite.csproj @@ -15,7 +15,7 @@ - + diff --git a/src/Umbraco.Core/Constants-Conventions.cs b/src/Umbraco.Core/Constants-Conventions.cs index 16c63b4c02..cb34901e6c 100644 --- a/src/Umbraco.Core/Constants-Conventions.cs +++ b/src/Umbraco.Core/Constants-Conventions.cs @@ -1,3 +1,5 @@ +using System; + namespace Umbraco.Cms.Core { public static partial class Constants @@ -187,43 +189,49 @@ namespace Umbraco.Cms.Core /// /// Property alias for the Approved boolean of a Member /// + [Obsolete("IsApproved is no longer property data, access the property directly on IMember instead, scheduled for removal in V11")] public const string IsApproved = "umbracoMemberApproved"; - + [Obsolete("Use the stateApproved translation in the user area instead, scheduled for removal in V11")] public const string IsApprovedLabel = "Is Approved"; /// /// Property alias for the Locked out boolean of a Member /// + [Obsolete("IsLockedOut is no longer property data, access the property directly on IMember instead, scheduled for removal in V11")] public const string IsLockedOut = "umbracoMemberLockedOut"; - + [Obsolete("Use the stateLockedOut translation in the user area instead, scheduled for removal in V11")] public const string IsLockedOutLabel = "Is Locked Out"; /// /// Property alias for the last date the Member logged in /// + [Obsolete("LastLoginDate is no longer property data, access the property directly on IMember instead, scheduled for removal in V11")] public const string LastLoginDate = "umbracoMemberLastLogin"; - + [Obsolete("Use the lastLogin translation in the user area instead, scheduled for removal in V11")] public const string LastLoginDateLabel = "Last Login Date"; /// /// Property alias for the last date a Member changed its password /// + [Obsolete("LastPasswordChangeDate is no longer property data, access the property directly on IMember instead, scheduled for removal in V11")] public const string LastPasswordChangeDate = "umbracoMemberLastPasswordChangeDate"; - + [Obsolete("Use the lastPasswordChangeDate translation in the user area instead, scheduled for removal in V11")] public const string LastPasswordChangeDateLabel = "Last Password Change Date"; /// /// Property alias for the last date a Member was locked out /// + [Obsolete("LastLockoutDate is no longer property data, access the property directly on IMember instead, scheduled for removal in V11")] public const string LastLockoutDate = "umbracoMemberLastLockoutDate"; - + [Obsolete("Use the lastLockoutDate translation in the user area instead, scheduled for removal in V11")] public const string LastLockoutDateLabel = "Last Lockout Date"; /// /// Property alias for the number of failed login attempts /// + [Obsolete("FailedPasswordAttempts is no longer property data, access the property directly on IMember instead, scheduled for removal in V11")] public const string FailedPasswordAttempts = "umbracoMemberFailedPasswordAttempts"; - + [Obsolete("Use the failedPasswordAttempts translation in the user area instead, scheduled for removal in V11")] public const string FailedPasswordAttemptsLabel = "Failed Password Attempts"; /// diff --git a/src/Umbraco.Core/ConventionsHelper.cs b/src/Umbraco.Core/ConventionsHelper.cs index f5e0b168b9..2f9203ef92 100644 --- a/src/Umbraco.Core/ConventionsHelper.cs +++ b/src/Umbraco.Core/ConventionsHelper.cs @@ -11,64 +11,16 @@ namespace Umbraco.Cms.Core { { Constants.Conventions.Member.Comments, - new PropertyType(shortStringHelper, Constants.PropertyEditors.Aliases.TextArea, ValueStorageType.Ntext, true, + new PropertyType( + shortStringHelper, + Constants.PropertyEditors.Aliases.TextArea, + ValueStorageType.Ntext, + true, Constants.Conventions.Member.Comments) { - Name = Constants.Conventions.Member.CommentsLabel + Name = Constants.Conventions.Member.CommentsLabel, } }, - { - Constants.Conventions.Member.FailedPasswordAttempts, - new PropertyType(shortStringHelper, Constants.PropertyEditors.Aliases.Label, ValueStorageType.Integer, true, - Constants.Conventions.Member.FailedPasswordAttempts) - { - Name = Constants.Conventions.Member.FailedPasswordAttemptsLabel, - DataTypeId = Constants.DataTypes.LabelInt - } - }, - { - Constants.Conventions.Member.IsApproved, - new PropertyType(shortStringHelper, Constants.PropertyEditors.Aliases.Boolean, ValueStorageType.Integer, true, - Constants.Conventions.Member.IsApproved) - { - Name = Constants.Conventions.Member.IsApprovedLabel - } - }, - { - Constants.Conventions.Member.IsLockedOut, - new PropertyType(shortStringHelper, Constants.PropertyEditors.Aliases.Boolean, ValueStorageType.Integer, true, - Constants.Conventions.Member.IsLockedOut) - { - Name = Constants.Conventions.Member.IsLockedOutLabel - } - }, - { - Constants.Conventions.Member.LastLockoutDate, - new PropertyType(shortStringHelper, Constants.PropertyEditors.Aliases.Label, ValueStorageType.Date, true, - Constants.Conventions.Member.LastLockoutDate) - { - Name = Constants.Conventions.Member.LastLockoutDateLabel, - DataTypeId = Constants.DataTypes.LabelDateTime - } - }, - { - Constants.Conventions.Member.LastLoginDate, - new PropertyType(shortStringHelper, Constants.PropertyEditors.Aliases.Label, ValueStorageType.Date, true, - Constants.Conventions.Member.LastLoginDate) - { - Name = Constants.Conventions.Member.LastLoginDateLabel, - DataTypeId = Constants.DataTypes.LabelDateTime - } - }, - { - Constants.Conventions.Member.LastPasswordChangeDate, - new PropertyType(shortStringHelper, Constants.PropertyEditors.Aliases.Label, ValueStorageType.Date, true, - Constants.Conventions.Member.LastPasswordChangeDate) - { - Name = Constants.Conventions.Member.LastPasswordChangeDateLabel, - DataTypeId = Constants.DataTypes.LabelDateTime - } - } }; } } diff --git a/src/Umbraco.Core/Models/ContentEditing/MemberDisplay.cs b/src/Umbraco.Core/Models/ContentEditing/MemberDisplay.cs index 1b2aadd69f..5448c40b1e 100644 --- a/src/Umbraco.Core/Models/ContentEditing/MemberDisplay.cs +++ b/src/Umbraco.Core/Models/ContentEditing/MemberDisplay.cs @@ -24,6 +24,12 @@ namespace Umbraco.Cms.Core.Models.ContentEditing [DataMember(Name = "email")] public string? Email { get; set; } + [DataMember(Name = "isLockedOut")] + public bool IsLockedOut { get; set; } + + [DataMember(Name = "isApproved")] + public bool IsApproved { get; set; } + //[DataMember(Name = "membershipScenario")] //public MembershipScenario MembershipScenario { get; set; } diff --git a/src/Umbraco.Core/Models/ContentEditing/MemberSave.cs b/src/Umbraco.Core/Models/ContentEditing/MemberSave.cs index 519266fb15..903c87341a 100644 --- a/src/Umbraco.Core/Models/ContentEditing/MemberSave.cs +++ b/src/Umbraco.Core/Models/ContentEditing/MemberSave.cs @@ -31,15 +31,11 @@ namespace Umbraco.Cms.Core.Models.ContentEditing /// public string? Comments => GetPropertyValue(Constants.Conventions.Member.Comments); - /// - /// Returns the value from the IsLockedOut property - /// - public bool IsLockedOut => GetPropertyValue(Constants.Conventions.Member.IsLockedOut); + [DataMember(Name = "isLockedOut")] + public bool IsLockedOut { get; set; } - /// - /// Returns the value from the IsApproved property - /// - public bool IsApproved => GetPropertyValue(Constants.Conventions.Member.IsApproved); + [DataMember(Name = "isApproved")] + public bool IsApproved { get; set; } private T? GetPropertyValue(string alias) { diff --git a/src/Umbraco.Core/Models/ContentEditing/UserDisplay.cs b/src/Umbraco.Core/Models/ContentEditing/UserDisplay.cs index 9ae3d25cb0..20e517cefc 100644 --- a/src/Umbraco.Core/Models/ContentEditing/UserDisplay.cs +++ b/src/Umbraco.Core/Models/ContentEditing/UserDisplay.cs @@ -64,11 +64,11 @@ namespace Umbraco.Cms.Core.Models.ContentEditing [DataMember(Name = "lastLockoutDate")] [ReadOnly(true)] - public DateTime LastLockoutDate { get; set; } + public DateTime? LastLockoutDate { get; set; } [DataMember(Name = "lastPasswordChangeDate")] [ReadOnly(true)] - public DateTime LastPasswordChangeDate { get; set; } + public DateTime? LastPasswordChangeDate { get; set; } [DataMember(Name = "createDate")] [ReadOnly(true)] diff --git a/src/Umbraco.Core/Models/Mapping/MemberTabsAndPropertiesMapper.cs b/src/Umbraco.Core/Models/Mapping/MemberTabsAndPropertiesMapper.cs index 9bb53cfc9e..78506e6ba6 100644 --- a/src/Umbraco.Core/Models/Mapping/MemberTabsAndPropertiesMapper.cs +++ b/src/Umbraco.Core/Models/Mapping/MemberTabsAndPropertiesMapper.cs @@ -70,13 +70,6 @@ namespace Umbraco.Cms.Core.Models.Mapping var resolved = base.Map(source, context); - // IMember.IsLockedOut can't be set to true, so make it readonly when that's the case (you can only unlock) - var isLockedOutProperty = resolved.Where(x => x.Properties is not null).SelectMany(x => x.Properties!).FirstOrDefault(x => x.Alias == Constants.Conventions.Member.IsLockedOut); - if (isLockedOutProperty?.Value != null && isLockedOutProperty.Value.ToString() != "1") - { - isLockedOutProperty.Readonly = true; - } - return resolved; } @@ -194,7 +187,7 @@ namespace Umbraco.Cms.Core.Models.Mapping var properties = new List { GetLoginProperty(member, _localizedTextService), - new ContentPropertyDisplay + new() { Alias = $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}email", Label = _localizedTextService.Localize("general","email"), @@ -202,7 +195,7 @@ namespace Umbraco.Cms.Core.Models.Mapping View = "email", Validation = { Mandatory = true } }, - new ContentPropertyDisplay + new() { Alias = $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}password", Label = _localizedTextService.Localize(null,"password"), @@ -214,7 +207,7 @@ namespace Umbraco.Cms.Core.Models.Mapping View = "changepassword", Config = GetPasswordConfig(member) // Initialize the dictionary with the configuration from the default membership provider }, - new ContentPropertyDisplay + new() { Alias = $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}membergroup", Label = _localizedTextService.Localize("content","membergroup"), @@ -223,9 +216,80 @@ namespace Umbraco.Cms.Core.Models.Mapping Config = new Dictionary { { "IsRequired", true } + }, + }, + + // These properties used to live on the member as property data, defaulting to sensitive, so we set them to sensitive here too + new() + { + Alias = $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}failedPasswordAttempts", + Label = _localizedTextService.Localize("user", "failedPasswordAttempts"), + Value = member.FailedPasswordAttempts, + View = "readonlyvalue", + IsSensitive = true, + }, + + new() + { + Alias = $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}approved", + Label = _localizedTextService.Localize("user", "stateApproved"), + Value = member.IsApproved, + View = "boolean", + IsSensitive = true, + Readonly = false, + }, + + new() + { + Alias = $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}lockedOut", + Label = _localizedTextService.Localize("user", "stateLockedOut"), + Value = member.IsLockedOut, + View = "boolean", + IsSensitive = true, + Readonly = !member.IsLockedOut, // IMember.IsLockedOut can't be set to true, so make it readonly when that's the case (you can only unlock) + }, + + new() + { + Alias = $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}lastLockoutDate", + Label = _localizedTextService.Localize("user", "lastLockoutDate"), + Value = member.LastLockoutDate?.ToString(), + View = "readonlyvalue", + IsSensitive = true, + }, + + new() + { + Alias = $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}lastLoginDate", + Label = _localizedTextService.Localize("user", "lastLogin"), + Value = member.LastLoginDate?.ToString(), + View = "readonlyvalue", + IsSensitive = true, + }, + + new() + { + Alias = $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}lastPasswordChangeDate", + Label = _localizedTextService.Localize("user", "lastPasswordChangeDate"), + Value = member.LastPasswordChangeDate?.ToString(), + View = "readonlyvalue", + IsSensitive = true, + }, + }; + + if (_backofficeSecurityAccessor.BackOfficeSecurity.CurrentUser.HasAccessToSensitiveData() is false) + { + // Current user doesn't have access to sensitive data so explicitly set the views and remove the value from sensitive data + foreach (var property in properties) + { + if (property.IsSensitive) + { + property.Value = null; + property.View = "sensitivevalue"; + property.Readonly = true; } } - }; + } return properties; } diff --git a/src/Umbraco.Core/Models/Mapping/UserMapDefinition.cs b/src/Umbraco.Core/Models/Mapping/UserMapDefinition.cs index 4f7b66d48d..a2c3fa7f28 100644 --- a/src/Umbraco.Core/Models/Mapping/UserMapDefinition.cs +++ b/src/Umbraco.Core/Models/Mapping/UserMapDefinition.cs @@ -304,7 +304,7 @@ namespace Umbraco.Cms.Core.Models.Mapping target.Id = source.Id; target.Key = source.Key; target.LastLockoutDate = source.LastLockoutDate; - target.LastLoginDate = source.LastLoginDate == default ? null : (DateTime?)source.LastLoginDate; + target.LastLoginDate = source.LastLoginDate == default(DateTime) ? null : (DateTime?)source.LastLoginDate; target.LastPasswordChangeDate = source.LastPasswordChangeDate; target.Name = source.Name; target.Navigation = CreateUserEditorNavigation(); diff --git a/src/Umbraco.Core/Models/Member.cs b/src/Umbraco.Core/Models/Member.cs index 301e22b458..4244e1ba44 100644 --- a/src/Umbraco.Core/Models/Member.cs +++ b/src/Umbraco.Core/Models/Member.cs @@ -21,6 +21,12 @@ namespace Umbraco.Cms.Core.Models private string? _passwordConfig; private DateTime? _emailConfirmedDate; private string? _securityStamp; + private int _failedPasswordAttempts; + private bool _isApproved; + private bool _isLockedOut; + private DateTime? _lastLockoutDate; + private DateTime? _lastLoginDate; + private DateTime? _lastPasswordChangeDate; /// /// Initializes a new instance of the class. @@ -281,41 +287,13 @@ namespace Umbraco.Cms.Core.Models } /// - /// Gets or sets a boolean indicating whether the Member is approved + /// Gets or sets a value indicating whether the Member is approved /// - /// - /// Alias: umbracoMemberApproved - /// Part of the standard properties collection. - /// [DataMember] public bool IsApproved { - get - { - var a = WarnIfPropertyTypeNotFoundOnGet(Constants.Conventions.Member.IsApproved, nameof(IsApproved), - //This is the default value if the prop is not found - true); - if (a.Success == false) - return a.Result; - if (Properties[Constants.Conventions.Member.IsApproved]?.GetValue() == null) - return true; - var tryConvert = Properties[Constants.Conventions.Member.IsApproved]?.GetValue().TryConvertTo(); - if (tryConvert?.Success ?? false) - { - return tryConvert?.Result ?? false; - } - //if the property exists but it cannot be converted, we will assume true - return true; - } - set - { - if (WarnIfPropertyTypeNotFoundOnSet( - Constants.Conventions.Member.IsApproved, - nameof(IsApproved)) == false) - return; - - Properties[Constants.Conventions.Member.IsApproved]?.SetValue(value); - } + get => _isApproved; + set => SetPropertyValueAndDetectChanges(value, ref _isApproved, nameof(IsApproved)); } /// @@ -328,30 +306,8 @@ namespace Umbraco.Cms.Core.Models [DataMember] public bool IsLockedOut { - get - { - var a = WarnIfPropertyTypeNotFoundOnGet(Constants.Conventions.Member.IsLockedOut, nameof(IsLockedOut), false); - if (a.Success == false) - return a.Result; - if (Properties[Constants.Conventions.Member.IsLockedOut]?.GetValue() == null) - return false; - var tryConvert = Properties[Constants.Conventions.Member.IsLockedOut]?.GetValue().TryConvertTo(); - if (tryConvert?.Success ?? false) - { - return tryConvert?.Result ?? false; - } - return false; - // TODO: Use TryConvertTo instead - } - set - { - if (WarnIfPropertyTypeNotFoundOnSet( - Constants.Conventions.Member.IsLockedOut, - nameof(IsLockedOut)) == false) - return; - - Properties[Constants.Conventions.Member.IsLockedOut]?.SetValue(value); - } + get => _isLockedOut; + set => SetPropertyValueAndDetectChanges(value, ref _isLockedOut, nameof(IsLockedOut)); } /// @@ -362,32 +318,10 @@ namespace Umbraco.Cms.Core.Models /// Part of the standard properties collection. /// [DataMember] - public DateTime LastLoginDate + public DateTime? LastLoginDate { - get - { - var a = WarnIfPropertyTypeNotFoundOnGet(Constants.Conventions.Member.LastLoginDate, nameof(LastLoginDate), default(DateTime)); - if (a.Success == false) - return a.Result; - if (Properties[Constants.Conventions.Member.LastLoginDate]?.GetValue() == null) - return default(DateTime); - var tryConvert = Properties[Constants.Conventions.Member.LastLoginDate]?.GetValue().TryConvertTo(); - if (tryConvert?.Success ?? false) - { - return tryConvert?.Result ?? default(DateTime); - } - return default(DateTime); - // TODO: Use TryConvertTo instead - } - set - { - if (WarnIfPropertyTypeNotFoundOnSet( - Constants.Conventions.Member.LastLoginDate, - nameof(LastLoginDate)) == false) - return; - - Properties[Constants.Conventions.Member.LastLoginDate]?.SetValue(value); - } + get => _lastLoginDate; + set => SetPropertyValueAndDetectChanges(value, ref _lastLoginDate, nameof(LastLoginDate)); } /// @@ -398,32 +332,10 @@ namespace Umbraco.Cms.Core.Models /// Part of the standard properties collection. /// [DataMember] - public DateTime LastPasswordChangeDate + public DateTime? LastPasswordChangeDate { - get - { - var a = WarnIfPropertyTypeNotFoundOnGet(Constants.Conventions.Member.LastPasswordChangeDate, nameof(LastPasswordChangeDate), default(DateTime)); - if (a.Success == false) - return a.Result; - if (Properties[Constants.Conventions.Member.LastPasswordChangeDate]?.GetValue() == null) - return default(DateTime); - var tryConvert = Properties[Constants.Conventions.Member.LastPasswordChangeDate]?.GetValue().TryConvertTo(); - if (tryConvert?.Success ?? false) - { - return tryConvert?.Result ?? default(DateTime); - } - return default(DateTime); - // TODO: Use TryConvertTo instead - } - set - { - if (WarnIfPropertyTypeNotFoundOnSet( - Constants.Conventions.Member.LastPasswordChangeDate, - nameof(LastPasswordChangeDate)) == false) - return; - - Properties[Constants.Conventions.Member.LastPasswordChangeDate]?.SetValue(value); - } + get => _lastPasswordChangeDate; + set => SetPropertyValueAndDetectChanges(value, ref _lastPasswordChangeDate, nameof(LastPasswordChangeDate)); } /// @@ -434,32 +346,10 @@ namespace Umbraco.Cms.Core.Models /// Part of the standard properties collection. /// [DataMember] - public DateTime LastLockoutDate + public DateTime? LastLockoutDate { - get - { - var a = WarnIfPropertyTypeNotFoundOnGet(Constants.Conventions.Member.LastLockoutDate, nameof(LastLockoutDate), default(DateTime)); - if (a.Success == false) - return a.Result; - if (Properties[Constants.Conventions.Member.LastLockoutDate]?.GetValue() == null) - return default(DateTime); - var tryConvert = Properties[Constants.Conventions.Member.LastLockoutDate]?.GetValue().TryConvertTo(); - if (tryConvert?.Success ?? false) - { - return tryConvert?.Result ?? default(DateTime); - } - return default(DateTime); - // TODO: Use TryConvertTo instead - } - set - { - if (WarnIfPropertyTypeNotFoundOnSet( - Constants.Conventions.Member.LastLockoutDate, - nameof(LastLockoutDate)) == false) - return; - - Properties[Constants.Conventions.Member.LastLockoutDate]?.SetValue(value); - } + get => _lastLockoutDate; + set => SetPropertyValueAndDetectChanges(value, ref _lastLockoutDate, nameof(LastLockoutDate)); } /// @@ -473,30 +363,8 @@ namespace Umbraco.Cms.Core.Models [DataMember] public int FailedPasswordAttempts { - get - { - var a = WarnIfPropertyTypeNotFoundOnGet(Constants.Conventions.Member.FailedPasswordAttempts, nameof(FailedPasswordAttempts), 0); - if (a.Success == false) - return a.Result; - if (Properties[Constants.Conventions.Member.FailedPasswordAttempts]?.GetValue() == null) - return default(int); - var tryConvert = Properties[Constants.Conventions.Member.FailedPasswordAttempts]?.GetValue().TryConvertTo(); - if (tryConvert?.Success ?? false) - { - return tryConvert?.Result ?? default(int); - } - return default(int); - // TODO: Use TryConvertTo instead - } - set - { - if (WarnIfPropertyTypeNotFoundOnSet( - Constants.Conventions.Member.FailedPasswordAttempts, - nameof(FailedPasswordAttempts)) == false) - return; - - Properties[Constants.Conventions.Member.FailedPasswordAttempts]?.SetValue(value); - } + get => _failedPasswordAttempts; + set => SetPropertyValueAndDetectChanges(value, ref _failedPasswordAttempts, nameof(FailedPasswordAttempts)); } /// diff --git a/src/Umbraco.Core/Models/MemberType.cs b/src/Umbraco.Core/Models/MemberType.cs index ab420b5a8b..4db8388b94 100644 --- a/src/Umbraco.Core/Models/MemberType.cs +++ b/src/Umbraco.Core/Models/MemberType.cs @@ -161,7 +161,7 @@ namespace Umbraco.Cms.Core.Models } else { - var tuple = new MemberTypePropertyProfileAccess(false, false, true); + var tuple = new MemberTypePropertyProfileAccess(false, false, value); _memberTypePropertyTypes.Add(propertyTypeAlias, tuple); } } diff --git a/src/Umbraco.Core/Models/Membership/IMembershipUser.cs b/src/Umbraco.Core/Models/Membership/IMembershipUser.cs index b4a38479f6..f8efe55885 100644 --- a/src/Umbraco.Core/Models/Membership/IMembershipUser.cs +++ b/src/Umbraco.Core/Models/Membership/IMembershipUser.cs @@ -25,9 +25,9 @@ namespace Umbraco.Cms.Core.Models.Membership string? Comments { get; set; } bool IsApproved { get; set; } bool IsLockedOut { get; set; } - DateTime LastLoginDate { get; set; } - DateTime LastPasswordChangeDate { get; set; } - DateTime LastLockoutDate { get; set; } + DateTime? LastLoginDate { get; set; } + DateTime? LastPasswordChangeDate { get; set; } + DateTime? LastLockoutDate { get; set; } /// /// Gets or sets the number of failed password attempts. diff --git a/src/Umbraco.Core/Models/Membership/User.cs b/src/Umbraco.Core/Models/Membership/User.cs index c12ab288bf..463b44c73e 100644 --- a/src/Umbraco.Core/Models/Membership/User.cs +++ b/src/Umbraco.Core/Models/Membership/User.cs @@ -119,9 +119,9 @@ namespace Umbraco.Cms.Core.Models.Membership private bool _isApproved; private bool _isLockedOut; private string? _language; - private DateTime _lastPasswordChangedDate; - private DateTime _lastLoginDate; - private DateTime _lastLockoutDate; + private DateTime? _lastPasswordChangedDate; + private DateTime? _lastLoginDate; + private DateTime? _lastLockoutDate; //Custom comparer for enumerable private static readonly DelegateEqualityComparer> IntegerEnumerableComparer = @@ -187,21 +187,21 @@ namespace Umbraco.Cms.Core.Models.Membership } [IgnoreDataMember] - public DateTime LastLoginDate + public DateTime? LastLoginDate { get => _lastLoginDate; set => SetPropertyValueAndDetectChanges(value, ref _lastLoginDate, nameof(LastLoginDate)); } [IgnoreDataMember] - public DateTime LastPasswordChangeDate + public DateTime? LastPasswordChangeDate { get => _lastPasswordChangedDate; set => SetPropertyValueAndDetectChanges(value, ref _lastPasswordChangedDate, nameof(LastPasswordChangeDate)); } [IgnoreDataMember] - public DateTime LastLockoutDate + public DateTime? LastLockoutDate { get => _lastLockoutDate; set => SetPropertyValueAndDetectChanges(value, ref _lastLockoutDate, nameof(LastLockoutDate)); diff --git a/src/Umbraco.Core/Persistence/Repositories/IMemberRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IMemberRepository.cs index a868eefe9f..28a89ff43a 100644 --- a/src/Umbraco.Core/Persistence/Repositories/IMemberRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/IMemberRepository.cs @@ -51,6 +51,7 @@ namespace Umbraco.Cms.Core.Persistence.Repositories /// updating their login date. This operation must be fast and cannot use database locks which is fine if we are only executing a single query /// for this data since there won't be any other data contention issues. /// + [Obsolete("This is now a NoOp since last login date is no longer an umbraco property, set the date on the IMember directly and Save it instead, scheduled for removal in V11.")] void SetLastLogin(string username, DateTime date); } } diff --git a/src/Umbraco.Core/Services/MemberService.cs b/src/Umbraco.Core/Services/MemberService.cs index 77c6476ec9..95443c549d 100644 --- a/src/Umbraco.Core/Services/MemberService.cs +++ b/src/Umbraco.Core/Services/MemberService.cs @@ -67,10 +67,10 @@ namespace Umbraco.Cms.Core.Services query = Query(); break; case MemberCountType.LockedOut: - query = Query()?.Where(x => x.PropertyTypeAlias == Constants.Conventions.Member.IsLockedOut && ((Member) x).BoolPropertyValue); + query = Query()?.Where(x => x.IsLockedOut == true); break; case MemberCountType.Approved: - query = Query()?.Where(x => x.PropertyTypeAlias == Constants.Conventions.Member.IsApproved && ((Member) x).BoolPropertyValue); + query = Query()?.Where(x => x.IsApproved == true); break; default: throw new ArgumentOutOfRangeException(nameof(countType)); @@ -748,13 +748,9 @@ namespace Umbraco.Cms.Core.Services #region Save /// + [Obsolete("This is now a NoOp since last login date is no longer an umbraco property, set the date on the IMember directly and Save it instead, scheduled for removal in V11.")] public void SetLastLogin(string username, DateTime date) { - using (var scope = ScopeProvider.CreateScope()) - { - _memberRepository.SetLastLogin(username, date); - scope.Complete(); - } } /// diff --git a/src/Umbraco.Core/Umbraco.Core.csproj b/src/Umbraco.Core/Umbraco.Core.csproj index 26fce6f2bb..448c2b1c6f 100644 --- a/src/Umbraco.Core/Umbraco.Core.csproj +++ b/src/Umbraco.Core/Umbraco.Core.csproj @@ -17,7 +17,7 @@ - + diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs index c0bb0ca42e..d31a81b402 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs @@ -76,31 +76,13 @@ namespace Umbraco.Cms.Infrastructure.DependencyInjection private static LocalizedTextServiceFileSources SourcesFactory(IServiceProvider container) { var hostingEnvironment = container.GetRequiredService(); - var globalSettings = container.GetRequiredService>().Value; var mainLangFolder = new DirectoryInfo(hostingEnvironment.MapPathContentRoot(WebPath.Combine(Constants.SystemDirectories.Umbraco, "config", "lang"))); - var appPlugins = new DirectoryInfo(hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.AppPlugins)); - var configLangFolder = new DirectoryInfo(hostingEnvironment.MapPathContentRoot(WebPath.Combine(Constants.SystemDirectories.Config, "lang"))); - - var pluginLangFolders = appPlugins.Exists == false - ? Enumerable.Empty() - : appPlugins.GetDirectories() - // Check for both Lang & lang to support case sensitive file systems. - .SelectMany(x => x.GetDirectories("?ang", SearchOption.AllDirectories).Where(x => x.Name.InvariantEquals("lang"))) - .SelectMany(x => x.GetFiles("*.xml", SearchOption.TopDirectoryOnly)) - .Select(x => new LocalizedTextServiceSupplementaryFileSource(x, false)); - - // user defined langs that overwrite the default, these should not be used by plugin creators - var userLangFolders = configLangFolder.Exists == false - ? Enumerable.Empty() - : configLangFolder - .GetFiles("*.user.xml", SearchOption.TopDirectoryOnly) - .Select(x => new LocalizedTextServiceSupplementaryFileSource(x, true)); return new LocalizedTextServiceFileSources( container.GetRequiredService>(), container.GetRequiredService(), mainLangFolder, - pluginLangFolders.Concat(userLangFolders)); + container.GetServices()); } } } diff --git a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseDataCreator.cs b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseDataCreator.cs index c95bb87cc6..c00a745d8e 100644 --- a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseDataCreator.cs +++ b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseDataCreator.cs @@ -638,13 +638,15 @@ namespace Umbraco.Cms.Infrastructure.Migrations.Install // Membership property types. if (_database.Exists(11)) { - _database.Insert(Cms.Core.Constants.DatabaseSchema.Tables.PropertyType, "id", false, new PropertyTypeDto { Id = 28, UniqueId = 28.ToGuid(), DataTypeId = Cms.Core.Constants.DataTypes.Textarea, ContentTypeId = 1044, PropertyTypeGroupId = 11, Alias = Cms.Core.Constants.Conventions.Member.Comments, Name = Cms.Core.Constants.Conventions.Member.CommentsLabel, SortOrder = 0, Mandatory = false, ValidationRegExp = null, Description = null, Variations = (byte)ContentVariation.Nothing }); - _database.Insert(Cms.Core.Constants.DatabaseSchema.Tables.PropertyType, "id", false, new PropertyTypeDto { Id = 29, UniqueId = 29.ToGuid(), DataTypeId = Cms.Core.Constants.DataTypes.LabelInt, ContentTypeId = 1044, PropertyTypeGroupId = 11, Alias = Cms.Core.Constants.Conventions.Member.FailedPasswordAttempts, Name = Cms.Core.Constants.Conventions.Member.FailedPasswordAttemptsLabel, SortOrder = 1, Mandatory = false, ValidationRegExp = null, Description = null, Variations = (byte)ContentVariation.Nothing }); - _database.Insert(Cms.Core.Constants.DatabaseSchema.Tables.PropertyType, "id", false, new PropertyTypeDto { Id = 30, UniqueId = 30.ToGuid(), DataTypeId = Cms.Core.Constants.DataTypes.Boolean, ContentTypeId = 1044, PropertyTypeGroupId = 11, Alias = Cms.Core.Constants.Conventions.Member.IsApproved, Name = Cms.Core.Constants.Conventions.Member.IsApprovedLabel, SortOrder = 2, Mandatory = false, ValidationRegExp = null, Description = null, Variations = (byte)ContentVariation.Nothing }); - _database.Insert(Cms.Core.Constants.DatabaseSchema.Tables.PropertyType, "id", false, new PropertyTypeDto { Id = 31, UniqueId = 31.ToGuid(), DataTypeId = Cms.Core.Constants.DataTypes.Boolean, ContentTypeId = 1044, PropertyTypeGroupId = 11, Alias = Cms.Core.Constants.Conventions.Member.IsLockedOut, Name = Cms.Core.Constants.Conventions.Member.IsLockedOutLabel, SortOrder = 3, Mandatory = false, ValidationRegExp = null, Description = null, Variations = (byte)ContentVariation.Nothing }); - _database.Insert(Cms.Core.Constants.DatabaseSchema.Tables.PropertyType, "id", false, new PropertyTypeDto { Id = 32, UniqueId = 32.ToGuid(), DataTypeId = Cms.Core.Constants.DataTypes.LabelDateTime, ContentTypeId = 1044, PropertyTypeGroupId = 11, Alias = Cms.Core.Constants.Conventions.Member.LastLockoutDate, Name = Cms.Core.Constants.Conventions.Member.LastLockoutDateLabel, SortOrder = 4, Mandatory = false, ValidationRegExp = null, Description = null, Variations = (byte)ContentVariation.Nothing }); - _database.Insert(Cms.Core.Constants.DatabaseSchema.Tables.PropertyType, "id", false, new PropertyTypeDto { Id = 33, UniqueId = 33.ToGuid(), DataTypeId = Cms.Core.Constants.DataTypes.LabelDateTime, ContentTypeId = 1044, PropertyTypeGroupId = 11, Alias = Cms.Core.Constants.Conventions.Member.LastLoginDate, Name = Cms.Core.Constants.Conventions.Member.LastLoginDateLabel, SortOrder = 5, Mandatory = false, ValidationRegExp = null, Description = null, Variations = (byte)ContentVariation.Nothing }); - _database.Insert(Cms.Core.Constants.DatabaseSchema.Tables.PropertyType, "id", false, new PropertyTypeDto { Id = 34, UniqueId = 34.ToGuid(), DataTypeId = Cms.Core.Constants.DataTypes.LabelDateTime, ContentTypeId = 1044, PropertyTypeGroupId = 11, Alias = Cms.Core.Constants.Conventions.Member.LastPasswordChangeDate, Name = Cms.Core.Constants.Conventions.Member.LastPasswordChangeDateLabel, SortOrder = 6, Mandatory = false, ValidationRegExp = null, Description = null, Variations = (byte)ContentVariation.Nothing }); + _database.Insert(Cms.Core.Constants.DatabaseSchema.Tables.PropertyType, "id", false, + new PropertyTypeDto + { + Id = 28, UniqueId = 28.ToGuid(), DataTypeId = Cms.Core.Constants.DataTypes.Textarea, + ContentTypeId = 1044, PropertyTypeGroupId = 11, + Alias = Cms.Core.Constants.Conventions.Member.Comments, + Name = Cms.Core.Constants.Conventions.Member.CommentsLabel, SortOrder = 0, Mandatory = false, + ValidationRegExp = null, Description = null, Variations = (byte)ContentVariation.Nothing + }); } } diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs index 34077943c8..37c2ab6c0e 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs @@ -4,6 +4,7 @@ using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration; using Umbraco.Cms.Core.Semver; using Umbraco.Cms.Infrastructure.Migrations.Upgrade.Common; +using Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_10_0_0; using Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_8_0_0; using Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_8_0_1; using Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_8_1_0; @@ -285,6 +286,9 @@ namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade // TO 9.4.0 To("{DBBA1EA0-25A1-4863-90FB-5D306FB6F1E1}"); To("{DED98755-4059-41BB-ADBD-3FEAB12D1D7B}"); + + // TO 10.0.0 + To("{B7E0D53C-2B0E-418B-AB07-2DDE486E225F}"); } } } diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_10_0_0/AddMemberPropertiesAsColumns.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_10_0_0/AddMemberPropertiesAsColumns.cs new file mode 100644 index 0000000000..5bc58c9b25 --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_10_0_0/AddMemberPropertiesAsColumns.cs @@ -0,0 +1,242 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; +using NPoco; +using Umbraco.Cms.Core; +using Umbraco.Cms.Infrastructure.Persistence; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_10_0_0; + +public class AddMemberPropertiesAsColumns : MigrationBase +{ + public AddMemberPropertiesAsColumns(IMigrationContext context) + : base(context) + { + } + + protected override void Migrate() + { + var columns = SqlSyntax.GetColumnsInSchema(Context.Database).ToList(); + + AddColumnIfNotExists(columns, "failedPasswordAttempts"); + AddColumnIfNotExists(columns, "isLockedOut"); + AddColumnIfNotExists(columns, "isApproved"); + AddColumnIfNotExists(columns, "lastLoginDate"); + AddColumnIfNotExists(columns, "lastLockoutDate"); + AddColumnIfNotExists(columns, "lastPasswordChangeDate"); + + Sql newestContentVersionQuery = Database.SqlContext.Sql() + .Select($"MAX({GetQuotedSelector("cv", "id")}) as {SqlSyntax.GetQuotedColumnName("id")}", GetQuotedSelector("cv", "nodeId")) + .From("cv") + .GroupBy(GetQuotedSelector("cv", "nodeId")); + + Sql passwordAttemptsQuery = Database.SqlContext.Sql() + .Select(GetSubQueryColumns()) + .From("pt") + .Where($"{GetQuotedSelector("pt", "Alias")} = 'umbracoMemberFailedPasswordAttempts'"); + + Sql memberApprovedQuery = Database.SqlContext.Sql() + .Select(GetSubQueryColumns()) + .From("pt") + .Where($"{GetQuotedSelector("pt", "Alias")} = 'umbracoMemberApproved'"); + + Sql memberLockedOutQuery = Database.SqlContext.Sql() + .Select(GetSubQueryColumns()) + .From("pt") + .Where($"{GetQuotedSelector("pt", "Alias")} = 'umbracoMemberLockedOut'"); + + Sql memberLastLockoutDateQuery = Database.SqlContext.Sql() + .Select(GetSubQueryColumns()) + .From("pt") + .Where($"{GetQuotedSelector("pt", "Alias")} = 'umbracoMemberLastLockoutDate'"); + + Sql memberLastLoginDateQuery = Database.SqlContext.Sql() + .Select(GetSubQueryColumns()) + .From("pt") + .Where($"{GetQuotedSelector("pt", "Alias")} = 'umbracoMemberLastLogin'"); + + Sql memberLastPasswordChangeDateQuery = Database.SqlContext.Sql() + .Select(GetSubQueryColumns()) + .From("pt") + .Where($"{GetQuotedSelector("pt", "Alias")} = 'umbracoMemberLastPasswordChangeDate'"); + + StringBuilder queryBuilder = new StringBuilder(); + queryBuilder.AppendLine($"UPDATE {Constants.DatabaseSchema.Tables.Member}"); + queryBuilder.AppendLine("SET"); + queryBuilder.AppendLine($"\t{Database.SqlContext.SqlSyntax.GetFieldNameForUpdate(x => x.FailedPasswordAttempts)} = {GetQuotedSelector("umbracoPropertyData", "intValue")},"); + queryBuilder.AppendLine($"\t{Database.SqlContext.SqlSyntax.GetFieldNameForUpdate(x => x.IsApproved)} = {GetQuotedSelector("pdmp", "intValue")},"); + queryBuilder.AppendLine($"\t{Database.SqlContext.SqlSyntax.GetFieldNameForUpdate(x => x.IsLockedOut)} = {GetQuotedSelector("pdlo", "intValue")},"); + queryBuilder.AppendLine($"\t{Database.SqlContext.SqlSyntax.GetFieldNameForUpdate(x => x.LastLockoutDate)} = {GetQuotedSelector("pdlout", "dateValue")},"); + queryBuilder.AppendLine($"\t{Database.SqlContext.SqlSyntax.GetFieldNameForUpdate(x => x.LastLoginDate)} = {GetQuotedSelector("pdlin", "dateValue")},"); + queryBuilder.Append($"\t{Database.SqlContext.SqlSyntax.GetFieldNameForUpdate(x => x.LastPasswordChangeDate)} = {GetQuotedSelector("pdlpc", "dateValue")}"); + + Sql updateMemberColumnsQuery = Database.SqlContext.Sql(queryBuilder.ToString()) + .From() + .InnerJoin() + .On((left, right) => left.NodeId == right.NodeId) + .InnerJoin() + .On((left, right) => left.ContentTypeId == right.NodeId) + .InnerJoin(newestContentVersionQuery, "umbracoContentVersion") + .On((left, right) => left.NodeId == right.NodeId) + .InnerJoin("m") + .On((left, right) => left.NodeId == right.NodeId, null, "m") + .LeftJoin(passwordAttemptsQuery, "failedAttemptsType") + .On((left, right) => left.ContentTypeId == right.ContentTypeId) + .LeftJoin() + .On((left, right) => left.DataTypeId == right.NodeId) + .LeftJoin() + .On((left, middle, right) => left.PropertyTypeId == middle.Id && left.VersionId == right.Id) + .LeftJoin(memberApprovedQuery, "memberApprovedType") + .On((left, right) => left.ContentTypeId == right.ContentTypeId) + .LeftJoin("dtmp") + .On((left, right) => left.DataTypeId == right.NodeId, null, "dtmp") + .LeftJoin("pdmp") + .On((left, middle, right) => left.PropertyTypeId == middle.Id && left.VersionId == right.Id, "pdmp") + .LeftJoin(memberLockedOutQuery, "memberLockedOutType") + .On((left, right) => left.ContentTypeId == right.ContentTypeId) + .LeftJoin("dtlo") + .On((left, right) => left.DataTypeId == right.NodeId, null, "dtlo") + .LeftJoin("pdlo") + .On((left, middle, right) => left.PropertyTypeId == middle.Id && left.VersionId == right.Id, "pdlo") + .LeftJoin(memberLastLockoutDateQuery, "lastLockOutDateType") + .On((left, right) => left.ContentTypeId == right.ContentTypeId) + .LeftJoin("dtlout") + .On((left, right) => left.DataTypeId == right.NodeId, null, "dtlout") + .LeftJoin("pdlout") + .On((left, middle, right) => left.PropertyTypeId == middle.Id && left.VersionId == right.Id, "pdlout") + .LeftJoin(memberLastLoginDateQuery, "lastLoginDateType") + .On((left, right) => left.ContentTypeId == right.ContentTypeId) + .LeftJoin("dtlin") + .On((left, right) => left.DataTypeId == right.NodeId, null, "dtlin") + .LeftJoin("pdlin") + .On((left, middle, right) => left.PropertyTypeId == middle.Id && left.VersionId == right.Id, "pdlin") + .LeftJoin(memberLastPasswordChangeDateQuery, "lastPasswordChangeType") + .On((left, right) => left.ContentTypeId == right.ContentTypeId) + .LeftJoin("dtlpc") + .On((left, right) => left.DataTypeId == right.NodeId, null, "dtlpc") + .LeftJoin("pdlpc") + .On((left, middle, right) => left.PropertyTypeId == middle.Id && left.VersionId == right.Id, "pdlpc") + .Where(x => x.NodeObjectType == Constants.ObjectTypes.Member); + + Database.Execute(updateMemberColumnsQuery); + + // Removing old property types and values, since these are no longer needed + // Hard coding the aliases, since we want to be able to delete the constants... + string[] propertyTypesToDelete = + { + "umbracoMemberFailedPasswordAttempts", + "umbracoMemberApproved", + "umbracoMemberLockedOut", + "umbracoMemberLastLockoutDate", + "umbracoMemberLastLogin", + "umbracoMemberLastPasswordChangeDate" + }; + + Sql idQuery = Database.SqlContext.Sql().Select(x => x.Id) + .From() + .WhereIn(x => x.Alias, propertyTypesToDelete); + List idsToDelete = Database.Fetch(idQuery); + + // Firstly deleting the property data due to FK constraints + Sql propertyDataDelete = Database.SqlContext.Sql() + .Delete() + .WhereIn(x => x.PropertyTypeId, idsToDelete); + Database.Execute(propertyDataDelete); + + // Then we can remove the property + Sql propertyTypeDelete = Database.SqlContext.Sql() + .Delete() + .WhereIn(x => x.Id, idsToDelete); + Database.Execute(propertyTypeDelete); + } + + private string GetQuotedSelector(string tableName, string columnName) + => $"{SqlSyntax.GetQuotedTableName(tableName)}.{SqlSyntax.GetQuotedColumnName(columnName)}"; + + private object[] GetSubQueryColumns() => new object[] + { + SqlSyntax.GetQuotedColumnName("contentTypeId"), + SqlSyntax.GetQuotedColumnName("dataTypeId"), + SqlSyntax.GetQuotedColumnName("id"), + }; + + [TableName("failedAttemptsType")] + private class FailedAttempts + { + [Column("contentTypeId")] + public int ContentTypeId { get; set; } + + [Column("dataTypeId")] + public int DataTypeId { get; set; } + + [Column("id")] + public int Id { get; set; } + } + + [TableName("memberApprovedType")] + private class MemberApproved + { + [Column("contentTypeId")] + public int ContentTypeId { get; set; } + + [Column("dataTypeId")] + public int DataTypeId { get; set; } + + [Column("id")] + public int Id { get; set; } + } + + [TableName("memberLockedOutType")] + private class MemberLockedOut + { + [Column("contentTypeId")] + public int ContentTypeId { get; set; } + + [Column("dataTypeId")] + public int DataTypeId { get; set; } + + [Column("id")] + public int Id { get; set; } + } + + [TableName("lastLockOutDateType")] + private class LastLockoutDate + { + [Column("contentTypeId")] + public int ContentTypeId { get; set; } + + [Column("dataTypeId")] + public int DataTypeId { get; set; } + + [Column("id")] + public int Id { get; set; } + } + + [TableName("lastLoginDateType")] + private class LastLoginDate + { + [Column("contentTypeId")] + public int ContentTypeId { get; set; } + + [Column("dataTypeId")] + public int DataTypeId { get; set; } + + [Column("id")] + public int Id { get; set; } + } + + [TableName("lastPasswordChangeType")] + private class LastPasswordChange + { + [Column("contentTypeId")] + public int ContentTypeId { get; set; } + + [Column("dataTypeId")] + public int DataTypeId { get; set; } + + [Column("id")] + public int Id { get; set; } + } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/MemberDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/MemberDto.cs index 538510c417..6c24bad7c4 100644 --- a/src/Umbraco.Infrastructure/Persistence/Dtos/MemberDto.cs +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/MemberDto.cs @@ -49,7 +49,30 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Dtos [NullSetting(NullSetting = NullSettings.Null)] public DateTime? EmailConfirmedDate { get; set; } - // TODO: It would be SOOOOO much better to store all core member data here instead of hiding it in Umbraco properties + [Column("failedPasswordAttempts")] + [NullSetting(NullSetting = NullSettings.Null)] + public int? FailedPasswordAttempts { get; set; } + + [Column("isLockedOut")] + [Constraint(Default = 0)] + [NullSetting(NullSetting = NullSettings.Null)] + public bool IsLockedOut { get; set; } + + [Column("isApproved")] + [Constraint(Default = 1)] + public bool IsApproved { get; set; } + + [Column("lastLoginDate")] + [NullSetting(NullSetting = NullSettings.Null)] + public DateTime? LastLoginDate { get; set; } + + [Column("lastLockoutDate")] + [NullSetting(NullSetting = NullSettings.Null)] + public DateTime? LastLockoutDate { get; set; } + + [Column("lastPasswordChangeDate")] + [NullSetting(NullSetting = NullSettings.Null)] + public DateTime? LastPasswordChangeDate { get; set; } [ResultColumn] [Reference(ReferenceType.OneToOne, ReferenceMemberName = "NodeId")] diff --git a/src/Umbraco.Infrastructure/Persistence/Factories/ContentBaseFactory.cs b/src/Umbraco.Infrastructure/Persistence/Factories/ContentBaseFactory.cs index ea2c01ea92..5048474ee7 100644 --- a/src/Umbraco.Infrastructure/Persistence/Factories/ContentBaseFactory.cs +++ b/src/Umbraco.Infrastructure/Persistence/Factories/ContentBaseFactory.cs @@ -143,6 +143,12 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Factories content.WriterId = contentVersionDto.UserId ?? Cms.Core.Constants.Security.UnknownUserId; content.CreateDate = nodeDto.CreateDate; content.UpdateDate = contentVersionDto.VersionDate; + content.FailedPasswordAttempts = dto.FailedPasswordAttempts ?? default; + content.IsLockedOut = dto.IsLockedOut; + content.IsApproved = dto.IsApproved; + content.LastLoginDate = dto.LastLoginDate; + content.LastLockoutDate = dto.LastLockoutDate; + content.LastPasswordChangeDate = dto.LastPasswordChangeDate; // reset dirty initial properties (U4-1946) content.ResetDirtyProperties(false); @@ -219,7 +225,13 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Factories EmailConfirmedDate = entity.EmailConfirmedDate, ContentDto = contentDto, ContentVersionDto = BuildContentVersionDto(entity, contentDto), - PasswordConfig = entity.PasswordConfiguration + PasswordConfig = entity.PasswordConfiguration, + FailedPasswordAttempts = entity.FailedPasswordAttempts, + IsApproved = entity.IsApproved, + IsLockedOut = entity.IsLockedOut, + LastLockoutDate = entity.LastLockoutDate, + LastLoginDate = entity.LastLoginDate, + LastPasswordChangeDate = entity.LastPasswordChangeDate, }; return dto; } diff --git a/src/Umbraco.Infrastructure/Persistence/Factories/UserFactory.cs b/src/Umbraco.Infrastructure/Persistence/Factories/UserFactory.cs index f49224c460..8ee2bcfec0 100644 --- a/src/Umbraco.Infrastructure/Persistence/Factories/UserFactory.cs +++ b/src/Umbraco.Infrastructure/Persistence/Factories/UserFactory.cs @@ -28,9 +28,9 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Factories user.Language = dto.UserLanguage; user.SecurityStamp = dto.SecurityStampToken; user.FailedPasswordAttempts = dto.FailedLoginAttempts ?? 0; - user.LastLockoutDate = dto.LastLockoutDate ?? DateTime.MinValue; - user.LastLoginDate = dto.LastLoginDate ?? DateTime.MinValue; - user.LastPasswordChangeDate = dto.LastPasswordChangeDate ?? DateTime.MinValue; + user.LastLockoutDate = dto.LastLockoutDate; + user.LastLoginDate = dto.LastLoginDate; + user.LastPasswordChangeDate = dto.LastPasswordChangeDate; user.CreateDate = dto.CreateDate; user.UpdateDate = dto.UpdateDate; user.Avatar = dto.Avatar; diff --git a/src/Umbraco.Infrastructure/Persistence/Mappers/MemberMapper.cs b/src/Umbraco.Infrastructure/Persistence/Mappers/MemberMapper.cs index 066f1584cd..c9fce21a73 100644 --- a/src/Umbraco.Infrastructure/Persistence/Mappers/MemberMapper.cs +++ b/src/Umbraco.Infrastructure/Persistence/Mappers/MemberMapper.cs @@ -35,14 +35,14 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Mappers DefineMap(nameof(Member.Email), nameof(MemberDto.Email)); DefineMap(nameof(Member.Username), nameof(MemberDto.LoginName)); DefineMap(nameof(Member.RawPasswordValue), nameof(MemberDto.Password)); + DefineMap(nameof(Member.IsApproved), nameof(MemberDto.IsApproved)); + DefineMap(nameof(Member.IsLockedOut), nameof(MemberDto.IsLockedOut)); + DefineMap(nameof(Member.FailedPasswordAttempts), nameof(MemberDto.FailedPasswordAttempts)); + DefineMap(nameof(Member.LastLockoutDate), nameof(MemberDto.LastLockoutDate)); + DefineMap(nameof(Member.LastLoginDate), nameof(MemberDto.LastLoginDate)); + DefineMap(nameof(Member.LastPasswordChangeDate), nameof(MemberDto.LastPasswordChangeDate)); - DefineMap(nameof(Member.IsApproved), nameof(PropertyDataDto.IntegerValue)); - DefineMap(nameof(Member.IsLockedOut), nameof(PropertyDataDto.IntegerValue)); DefineMap(nameof(Member.Comments), nameof(PropertyDataDto.TextValue)); - DefineMap(nameof(Member.FailedPasswordAttempts), nameof(PropertyDataDto.IntegerValue)); - DefineMap(nameof(Member.LastLockoutDate), nameof(PropertyDataDto.DateValue)); - DefineMap(nameof(Member.LastLoginDate), nameof(PropertyDataDto.DateValue)); - DefineMap(nameof(Member.LastPasswordChangeDate), nameof(PropertyDataDto.DateValue)); /* Internal experiment */ DefineMap(nameof(Member.DateTimePropertyValue), nameof(PropertyDataDto.DateValue)); diff --git a/src/Umbraco.Infrastructure/Persistence/NPocoSqlExtensions.cs b/src/Umbraco.Infrastructure/Persistence/NPocoSqlExtensions.cs index b9bb5e508f..ea77708c8c 100644 --- a/src/Umbraco.Infrastructure/Persistence/NPocoSqlExtensions.cs +++ b/src/Umbraco.Infrastructure/Persistence/NPocoSqlExtensions.cs @@ -405,6 +405,24 @@ namespace Umbraco.Extensions return sql.InnerJoin(join); } + /// + /// Appends an INNER JOIN clause using a nested query. + /// + /// The SQL statement. + /// The nested sql query. + /// An optional alias for the joined table. + /// A SqlJoin statement. + public static Sql.SqlJoinClause InnerJoin(this Sql sql, Sql nestedSelect, string alias = null) + { + var join = $"({nestedSelect.SQL})"; + if (alias is not null) + { + join += " " + sql.SqlContext.SqlSyntax.GetQuotedTableName(alias); + } + + return sql.InnerJoin(join); + } + /// /// Appends a LEFT JOIN clause to the Sql statement. /// @@ -437,6 +455,24 @@ namespace Umbraco.Extensions string? alias = null) => sql.SqlContext.SqlSyntax.LeftJoinWithNestedJoin(sql, nestedJoin, alias); + /// + /// Appends an LEFT JOIN clause using a nested query. + /// + /// The SQL statement. + /// The nested sql query. + /// An optional alias for the joined table. + /// A SqlJoin statement. + public static Sql.SqlJoinClause LeftJoin(this Sql sql, Sql nestedSelect, string alias = null) + { + var join = $"({nestedSelect.SQL})"; + if (alias is not null) + { + join += " " + sql.SqlContext.SqlSyntax.GetQuotedTableName(alias); + } + + return sql.LeftJoin(join); + } + /// /// Appends a RIGHT JOIN clause to the Sql statement. /// diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/MemberRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/MemberRepository.cs index 17f7c0a67d..9c41482436 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/MemberRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/MemberRepository.cs @@ -211,58 +211,11 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement } /// + [Obsolete( + "This is now a NoOp since last login date is no longer an umbraco property, set the date on the IMember directly and Save it instead, scheduled for removal in V11.")] public void SetLastLogin(string username, DateTime date) { - // Important - these queries are designed to execute without an exclusive WriteLock taken in our distributed lock - // table. However due to the data that we are updating which relies on version data we cannot update this data - // without taking some locks, otherwise we'll end up with strange situations because when a member is updated, that operation - // deletes and re-inserts all property data. So if there are concurrent transactions, one deleting and re-inserting and another trying - // to update there can be problems. This is only an issue for cmsPropertyData, not umbracoContentVersion because that table just - // maintains a single row and it isn't deleted/re-inserted. - // So the important part here is the ForUpdate() call on the select to fetch the property data to update. - // Update the cms property value for the member - - SqlTemplate sqlSelectTemplateProperty = SqlContext.Templates.Get( - "Umbraco.Core.MemberRepository.SetLastLogin1", s => s - .Select(x => x.Id) - .From() - .InnerJoin() - .On((l, r) => l.Id == r.PropertyTypeId) - .InnerJoin() - .On((l, r) => l.Id == r.VersionId) - .InnerJoin().On((l, r) => l.NodeId == r.NodeId) - .InnerJoin().On((l, r) => l.NodeId == r.NodeId) - .Where(x => x.NodeObjectType == SqlTemplate.Arg("nodeObjectType")) - .Where(x => x.Alias == SqlTemplate.Arg("propertyTypeAlias")) - .Where(x => x.LoginName == SqlTemplate.Arg("username")) - .ForUpdate()); - Sql sqlSelectProperty = sqlSelectTemplateProperty.Sql(Constants.ObjectTypes.Member, - Constants.Conventions.Member.LastLoginDate, username); - - Sql update = Sql() - .Update(u => u - .Set(x => x.DateValue, date)) - .WhereIn(x => x.Id, sqlSelectProperty); - - Database.Execute(update); - - // Update the umbracoContentVersion value for the member - - SqlTemplate sqlSelectTemplateVersion = SqlContext.Templates.Get( - "Umbraco.Core.MemberRepository.SetLastLogin2", s => s - .Select(x => x.Id) - .From() - .InnerJoin().On((l, r) => l.NodeId == r.NodeId) - .InnerJoin().On((l, r) => l.NodeId == r.NodeId) - .Where(x => x.NodeObjectType == SqlTemplate.Arg("nodeObjectType")) - .Where(x => x.LoginName == SqlTemplate.Arg("username"))); - Sql sqlSelectVersion = sqlSelectTemplateVersion.Sql(Constants.ObjectTypes.Member, username); - - Database.Execute(Sql() - .Update(u => u - .Set(x => x.VersionDate, date)) - .WhereIn(x => x.Id, sqlSelectVersion)); } /// @@ -779,12 +732,43 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement changedCols.Add("LoginName"); } + if (entity.IsPropertyDirty(nameof(entity.FailedPasswordAttempts))) + { + changedCols.Add(nameof(entity.FailedPasswordAttempts)); + } + + if (entity.IsPropertyDirty(nameof(entity.IsApproved))) + { + changedCols.Add(nameof(entity.IsApproved)); + } + + if (entity.IsPropertyDirty(nameof(entity.IsLockedOut))) + { + changedCols.Add(nameof(entity.IsLockedOut)); + } + + if (entity.IsPropertyDirty(nameof(entity.LastLockoutDate))) + { + changedCols.Add(nameof(entity.LastLockoutDate)); + } + + if (entity.IsPropertyDirty(nameof(entity.LastLoginDate))) + { + changedCols.Add(nameof(entity.LastLoginDate)); + } + + if (entity.IsPropertyDirty(nameof(entity.LastPasswordChangeDate))) + { + changedCols.Add(nameof(entity.LastPasswordChangeDate)); + } + // this can occur from an upgrade if (memberDto.PasswordConfig.IsNullOrWhiteSpace()) { memberDto.PasswordConfig = DefaultPasswordConfigJson; changedCols.Add("passwordConfig"); - }else if (memberDto.PasswordConfig == Constants.Security.UnknownPasswordConfigJson) + } + else if (memberDto.PasswordConfig == Constants.Security.UnknownPasswordConfigJson) { changedCols.Add("passwordConfig"); } diff --git a/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs b/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs index 83fca576dd..f48a796867 100644 --- a/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs +++ b/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs @@ -499,12 +499,12 @@ namespace Umbraco.Cms.Core.Security // don't assign anything if nothing has changed as this will trigger the track changes of the model if (identityUser.IsPropertyDirty(nameof(BackOfficeIdentityUser.LastLoginDateUtc)) || (user.LastLoginDate != default && identityUser.LastLoginDateUtc.HasValue == false) - || (identityUser.LastLoginDateUtc.HasValue && user.LastLoginDate.ToUniversalTime() != identityUser.LastLoginDateUtc.Value)) + || (identityUser.LastLoginDateUtc.HasValue && user.LastLoginDate?.ToUniversalTime() != identityUser.LastLoginDateUtc.Value)) { anythingChanged = true; // if the LastLoginDate is being set to MinValue, don't convert it ToLocalTime - DateTime dt = identityUser.LastLoginDateUtc == DateTime.MinValue ? DateTime.MinValue : identityUser.LastLoginDateUtc?.ToLocalTime() ?? DateTime.MinValue; + DateTime? dt = identityUser.LastLoginDateUtc?.ToLocalTime(); user.LastLoginDate = dt; } @@ -516,8 +516,8 @@ namespace Umbraco.Cms.Core.Security } if (identityUser.IsPropertyDirty(nameof(BackOfficeIdentityUser.LastPasswordChangeDateUtc)) - || (user.LastPasswordChangeDate != default && identityUser.LastPasswordChangeDateUtc.HasValue == false) - || (identityUser.LastPasswordChangeDateUtc.HasValue && user.LastPasswordChangeDate.ToUniversalTime() != identityUser.LastPasswordChangeDateUtc.Value)) + || (user.LastPasswordChangeDate.HasValue && user.LastPasswordChangeDate.Value != default && identityUser.LastPasswordChangeDateUtc.HasValue == false) + || (identityUser.LastPasswordChangeDateUtc.HasValue && user.LastPasswordChangeDate?.ToUniversalTime() != identityUser.LastPasswordChangeDateUtc.Value)) { anythingChanged = true; user.LastPasswordChangeDate = identityUser.LastPasswordChangeDateUtc?.ToLocalTime() ?? DateTime.Now; diff --git a/src/Umbraco.Infrastructure/Security/IdentityMapDefinition.cs b/src/Umbraco.Infrastructure/Security/IdentityMapDefinition.cs index 7e1a2dcf9a..b0f25f895a 100644 --- a/src/Umbraco.Infrastructure/Security/IdentityMapDefinition.cs +++ b/src/Umbraco.Infrastructure/Security/IdentityMapDefinition.cs @@ -72,8 +72,8 @@ namespace Umbraco.Cms.Core.Security target.CalculatedContentStartNodeIds = source.CalculateContentStartNodeIds(_entityService, _appCaches); target.Email = source.Email; target.UserName = source.Username; - target.LastPasswordChangeDateUtc = source.LastPasswordChangeDate.ToUniversalTime(); - target.LastLoginDateUtc = source.LastLoginDate.ToUniversalTime(); + target.LastPasswordChangeDateUtc = source.LastPasswordChangeDate?.ToUniversalTime(); + target.LastLoginDateUtc = source.LastLoginDate?.ToUniversalTime(); target.InviteDateUtc = source.InvitedDate?.ToUniversalTime(); target.EmailConfirmed = source.EmailConfirmedDate.HasValue; target.Name = source.Name; @@ -93,8 +93,8 @@ namespace Umbraco.Cms.Core.Security { target.Email = source.Email; target.UserName = source.Username; - target.LastPasswordChangeDateUtc = source.LastPasswordChangeDate.ToUniversalTime(); - target.LastLoginDateUtc = source.LastLoginDate.ToUniversalTime(); + target.LastPasswordChangeDateUtc = source.LastPasswordChangeDate?.ToUniversalTime(); + target.LastLoginDateUtc = source.LastLoginDate?.ToUniversalTime(); target.EmailConfirmed = source.EmailConfirmedDate.HasValue; target.Name = source.Name; target.AccessFailedCount = source.FailedPasswordAttempts; @@ -104,7 +104,7 @@ namespace Umbraco.Cms.Core.Security target.SecurityStamp = source.SecurityStamp; target.LockoutEnd = source.IsLockedOut ? DateTime.MaxValue.ToUniversalTime() : (DateTime?)null; target.Comments = source.Comments; - target.LastLockoutDateUtc = source.LastLockoutDate == DateTime.MinValue ? null : source.LastLockoutDate.ToUniversalTime(); + target.LastLockoutDateUtc = source.LastLockoutDate == DateTime.MinValue ? null : source.LastLockoutDate?.ToUniversalTime(); target.CreatedDateUtc = source.CreateDate.ToUniversalTime(); target.Key = source.Key; target.MemberTypeAlias = source.ContentTypeAlias; diff --git a/src/Umbraco.Infrastructure/Security/MemberUserStore.cs b/src/Umbraco.Infrastructure/Security/MemberUserStore.cs index c87249ba84..1211a2c05f 100644 --- a/src/Umbraco.Infrastructure/Security/MemberUserStore.cs +++ b/src/Umbraco.Infrastructure/Security/MemberUserStore.cs @@ -174,6 +174,7 @@ namespace Umbraco.Cms.Core.Security //TODO: should this be thrown, or an identity result? throw new InvalidOperationException("The user id must be an integer to work with the Umbraco"); } + using IScope scope = _scopeProvider.CreateScope(autoComplete: true); IMember? found = _memberService.GetById(asInt); @@ -183,17 +184,10 @@ namespace Umbraco.Cms.Core.Security var isLoginsPropertyDirty = user.IsPropertyDirty(nameof(MemberIdentityUser.Logins)); var isTokensPropertyDirty = user.IsPropertyDirty(nameof(MemberIdentityUser.LoginTokens)); - MemberDataChangeType memberChangeType = UpdateMemberProperties(found, user); - if (memberChangeType == MemberDataChangeType.FullSave) + if (UpdateMemberProperties(found, user)) { _memberService.Save(found); } - else if (memberChangeType == MemberDataChangeType.LoginOnly) - { - // If the member is only logging in, just issue that command without - // any write locks so we are creating a bottleneck. - _memberService.SetLastLogin(found.Username, DateTime.Now); - } if (isLoginsPropertyDirty) { @@ -598,19 +592,16 @@ namespace Umbraco.Cms.Core.Security return user; } - private MemberDataChangeType UpdateMemberProperties(IMember member, MemberIdentityUser identityUser) + private bool UpdateMemberProperties(IMember member, MemberIdentityUser identityUser) { - MemberDataChangeType changeType = MemberDataChangeType.None; + var anythingChanged = false; // don't assign anything if nothing has changed as this will trigger the track changes of the model if (identityUser.IsPropertyDirty(nameof(MemberIdentityUser.LastLoginDateUtc)) || (member.LastLoginDate != default && identityUser.LastLoginDateUtc.HasValue == false) - || (identityUser.LastLoginDateUtc.HasValue && member.LastLoginDate.ToUniversalTime() != identityUser.LastLoginDateUtc.Value)) + || (identityUser.LastLoginDateUtc.HasValue && member.LastLoginDate?.ToUniversalTime() != identityUser.LastLoginDateUtc.Value)) { - // If the LastLoginDate is default on the member we have to do a full save. - // This is because the umbraco property data for the member doesn't exist yet in this case - // meaning we can't just update that property data, but have to do a full save to create it - changeType = member.LastLoginDate == default ? MemberDataChangeType.FullSave : MemberDataChangeType.LoginOnly; + anythingChanged = true; // if the LastLoginDate is being set to MinValue, don't convert it ToLocalTime DateTime dt = identityUser.LastLoginDateUtc == DateTime.MinValue ? DateTime.MinValue : identityUser.LastLoginDateUtc?.ToLocalTime() ?? DateTime.MinValue; @@ -619,16 +610,16 @@ namespace Umbraco.Cms.Core.Security if (identityUser.IsPropertyDirty(nameof(MemberIdentityUser.LastPasswordChangeDateUtc)) || (member.LastPasswordChangeDate != default && identityUser.LastPasswordChangeDateUtc.HasValue == false) - || (identityUser.LastPasswordChangeDateUtc.HasValue && member.LastPasswordChangeDate.ToUniversalTime() != identityUser.LastPasswordChangeDateUtc.Value)) + || (identityUser.LastPasswordChangeDateUtc.HasValue && member.LastPasswordChangeDate?.ToUniversalTime() != identityUser.LastPasswordChangeDateUtc.Value)) { - changeType = MemberDataChangeType.FullSave; + anythingChanged = true; member.LastPasswordChangeDate = identityUser.LastPasswordChangeDateUtc?.ToLocalTime() ?? DateTime.Now; } if (identityUser.IsPropertyDirty(nameof(MemberIdentityUser.Comments)) && member.Comments != identityUser.Comments && identityUser.Comments.IsNullOrWhiteSpace() == false) { - changeType = MemberDataChangeType.FullSave; + anythingChanged = true; member.Comments = identityUser.Comments; } @@ -636,34 +627,34 @@ namespace Umbraco.Cms.Core.Security || (member.EmailConfirmedDate.HasValue && member.EmailConfirmedDate.Value != default && identityUser.EmailConfirmed == false) || ((member.EmailConfirmedDate.HasValue == false || member.EmailConfirmedDate.Value == default) && identityUser.EmailConfirmed)) { - changeType = MemberDataChangeType.FullSave; + anythingChanged = true; member.EmailConfirmedDate = identityUser.EmailConfirmed ? (DateTime?)DateTime.Now : null; } if (identityUser.IsPropertyDirty(nameof(MemberIdentityUser.Name)) && member.Name != identityUser.Name && identityUser.Name.IsNullOrWhiteSpace() == false) { - changeType = MemberDataChangeType.FullSave; + anythingChanged = true; member.Name = identityUser.Name ?? string.Empty; } if (identityUser.IsPropertyDirty(nameof(MemberIdentityUser.Email)) && member.Email != identityUser.Email && identityUser.Email.IsNullOrWhiteSpace() == false) { - changeType = MemberDataChangeType.FullSave; + anythingChanged = true; member.Email = identityUser.Email; } if (identityUser.IsPropertyDirty(nameof(MemberIdentityUser.AccessFailedCount)) && member.FailedPasswordAttempts != identityUser.AccessFailedCount) { - changeType = MemberDataChangeType.FullSave; + anythingChanged = true; member.FailedPasswordAttempts = identityUser.AccessFailedCount; } if (member.IsLockedOut != identityUser.IsLockedOut) { - changeType = MemberDataChangeType.FullSave; + anythingChanged = true; member.IsLockedOut = identityUser.IsLockedOut; if (member.IsLockedOut) @@ -675,40 +666,40 @@ namespace Umbraco.Cms.Core.Security if (member.IsApproved != identityUser.IsApproved) { - changeType = MemberDataChangeType.FullSave; + anythingChanged = true; member.IsApproved = identityUser.IsApproved; } if (identityUser.IsPropertyDirty(nameof(MemberIdentityUser.UserName)) && member.Username != identityUser.UserName && identityUser.UserName.IsNullOrWhiteSpace() == false) { - changeType = MemberDataChangeType.FullSave; + anythingChanged = true; member.Username = identityUser.UserName; } if (identityUser.IsPropertyDirty(nameof(MemberIdentityUser.PasswordHash)) && member.RawPasswordValue != identityUser.PasswordHash && identityUser.PasswordHash.IsNullOrWhiteSpace() == false) { - changeType = MemberDataChangeType.FullSave; + anythingChanged = true; member.RawPasswordValue = identityUser.PasswordHash; member.PasswordConfiguration = identityUser.PasswordConfig; } if (member.PasswordConfiguration != identityUser.PasswordConfig) { - changeType = MemberDataChangeType.FullSave; + anythingChanged = true; member.PasswordConfiguration = identityUser.PasswordConfig; } if (member.SecurityStamp != identityUser.SecurityStamp) { - changeType = MemberDataChangeType.FullSave; + anythingChanged = true; member.SecurityStamp = identityUser.SecurityStamp; } if (identityUser.IsPropertyDirty(nameof(MemberIdentityUser.Roles))) { - changeType = MemberDataChangeType.FullSave; + anythingChanged = true; var identityUserRoles = identityUser.Roles.Select(x => x.RoleId).ToArray(); _memberService.ReplaceRoles(new[] { member.Id }, identityUserRoles); @@ -717,7 +708,7 @@ namespace Umbraco.Cms.Core.Security // reset all changes identityUser.ResetDirtyProperties(false); - return changeType; + return anythingChanged; } public IPublishedContent? GetPublishedMember(MemberIdentityUser user) @@ -735,13 +726,6 @@ namespace Umbraco.Cms.Core.Security return publishedSnapshot.Members?.Get(member); } - private enum MemberDataChangeType - { - None, - LoginOnly, - FullSave - } - /// /// Overridden to support Umbraco's own data storage requirements /// diff --git a/src/Umbraco.Infrastructure/Umbraco.Infrastructure.csproj b/src/Umbraco.Infrastructure/Umbraco.Infrastructure.csproj index 876d0831b6..006e651706 100644 --- a/src/Umbraco.Infrastructure/Umbraco.Infrastructure.csproj +++ b/src/Umbraco.Infrastructure/Umbraco.Infrastructure.csproj @@ -19,9 +19,9 @@ - - - + + + @@ -29,7 +29,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/Umbraco.PublishedCache.NuCache/PublishedMember.cs b/src/Umbraco.PublishedCache.NuCache/PublishedMember.cs index 5670877499..ab6c7b88b7 100644 --- a/src/Umbraco.PublishedCache.NuCache/PublishedMember.cs +++ b/src/Umbraco.PublishedCache.NuCache/PublishedMember.cs @@ -109,13 +109,13 @@ namespace Umbraco.Cms.Infrastructure.PublishedCache public bool IsLockedOut => Member.IsLockedOut; - public DateTime LastLockoutDate => Member.LastLockoutDate; + public DateTime? LastLockoutDate => Member.LastLockoutDate; public DateTime CreationDate => Member.CreateDate; - public DateTime LastLoginDate => Member.LastLoginDate; + public DateTime? LastLoginDate => Member.LastLoginDate; - public DateTime LastPasswordChangedDate => Member.LastPasswordChangeDate; + public DateTime? LastPasswordChangedDate => Member.LastPasswordChangeDate; #endregion } diff --git a/src/Umbraco.PublishedCache.NuCache/Umbraco.PublishedCache.NuCache.csproj b/src/Umbraco.PublishedCache.NuCache/Umbraco.PublishedCache.NuCache.csproj index 3d4926b44d..328221ed3f 100644 --- a/src/Umbraco.PublishedCache.NuCache/Umbraco.PublishedCache.NuCache.csproj +++ b/src/Umbraco.PublishedCache.NuCache/Umbraco.PublishedCache.NuCache.csproj @@ -20,7 +20,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + all diff --git a/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs b/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs index ae07b7f4d0..22a0a47fc5 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs @@ -862,7 +862,7 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers // Check user hasn't logged in. If they have they may have made content changes which will mean // the Id is associated with audit trails, versions etc. and can't be removed. - if (user.LastLoginDate != default(DateTime)) + if (user.LastLoginDate is not null && user.LastLoginDate != default(DateTime)) { return BadRequest(); } diff --git a/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.LocalizedText.cs b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.LocalizedText.cs new file mode 100644 index 0000000000..e5f30ded32 --- /dev/null +++ b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.LocalizedText.cs @@ -0,0 +1,90 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; + +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; + +using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.Routing; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Extensions +{ + /// + /// Extension methods for for the Umbraco back office + /// + public static partial class UmbracoBuilderExtensions + { + /// + /// Add the SupplementaryLocalizedTextFilesSources + /// + /// + /// + private static IUmbracoBuilder AddSupplemenataryLocalizedTextFileSources(this IUmbracoBuilder builder) + { + builder.Services.AddTransient(sp => + { + return GetSupplementaryFileSources( + sp.GetRequiredService()); + }); + + return builder; + } + + + /// + /// Loads the suplimentary localization files from plugins and user config + /// + private static IEnumerable GetSupplementaryFileSources( + IWebHostEnvironment webHostEnvironment) + { + var webFileProvider = webHostEnvironment.WebRootFileProvider; + var contentFileProvider = webHostEnvironment.ContentRootFileProvider; + + // plugins in /app_plugins + var pluginLangFolders = GetPluginLanguageFileSources(contentFileProvider, Cms.Core.Constants.SystemDirectories.AppPlugins, false); + + // files in /wwwroot/app_plugins (or any virtual location that maps to this) + var razorPluginFolders = GetPluginLanguageFileSources(webFileProvider, Cms.Core.Constants.SystemDirectories.AppPlugins, false); + + // user defined langs that overwrite the default, these should not be used by plugin creators + var userConfigLangFolder = Cms.Core.Constants.SystemDirectories.Config + .TrimStart(Cms.Core.Constants.CharArrays.Tilde); + + var userLangFolders = contentFileProvider.GetDirectoryContents(userConfigLangFolder) + .Where(x => x.IsDirectory && x.Name.InvariantEquals("lang")) + .Select(x => new DirectoryInfo(x.PhysicalPath)) + .SelectMany(x => x.GetFiles("*.user.xml", SearchOption.TopDirectoryOnly)) + .Select(x => new LocalizedTextServiceSupplementaryFileSource(x, true)); + + return pluginLangFolders + .Concat(razorPluginFolders) + .Concat(userLangFolders); + } + + + /// + /// Loads the suplimentary localaization files via the file provider. + /// + /// + /// locates all *.xml files in the lang folder of any sub folder of the one provided. + /// e.g /app_plugins/plugin-name/lang/*.xml + /// + private static IEnumerable GetPluginLanguageFileSources( + IFileProvider fileProvider, string folder, bool overwriteCoreKeys) + { + // locate all the *.xml files inside Lang folders inside folders of the main folder + // e.g. /app_plugins/plugin-name/lang/*.xml + + return fileProvider.GetDirectoryContents(folder) + .Where(x => x.IsDirectory) + .SelectMany(x => fileProvider.GetDirectoryContents(WebPath.Combine(folder, x.Name))) + .Where(x => x.IsDirectory && x.Name.InvariantEquals("lang")) + .Select(x => new DirectoryInfo(x.PhysicalPath)) + .SelectMany(x => x.GetFiles("*.xml", SearchOption.TopDirectoryOnly)) + .Select(x => new LocalizedTextServiceSupplementaryFileSource(x, overwriteCoreKeys)); + } + } +} diff --git a/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilderExtensions.cs index 640c0847b9..bc0a9f399d 100644 --- a/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilderExtensions.cs @@ -56,7 +56,8 @@ namespace Umbraco.Extensions .AddLogViewer() .AddExamine() .AddExamineIndexes() - .AddControllersWithAmbiguousConstructors(); + .AddControllersWithAmbiguousConstructors() + .AddSupplemenataryLocalizedTextFileSources(); public static IUmbracoBuilder AddUnattendedInstallInstallCreateUser(this IUmbracoBuilder builder) { diff --git a/src/Umbraco.Web.BackOffice/Mapping/MemberMapDefinition.cs b/src/Umbraco.Web.BackOffice/Mapping/MemberMapDefinition.cs index 73b324fbb3..c8a0348417 100644 --- a/src/Umbraco.Web.BackOffice/Mapping/MemberMapDefinition.cs +++ b/src/Umbraco.Web.BackOffice/Mapping/MemberMapDefinition.cs @@ -65,6 +65,8 @@ namespace Umbraco.Cms.Web.BackOffice.Mapping //Membership target.Username = source.Username; target.Email = source.Email; + target.IsLockedOut = source.IsLockedOut; + target.IsApproved = source.IsApproved; target.MembershipProperties = _tabsAndPropertiesMapper.MapMembershipProperties(source, context); } diff --git a/src/Umbraco.Web.BackOffice/Umbraco.Web.BackOffice.csproj b/src/Umbraco.Web.BackOffice/Umbraco.Web.BackOffice.csproj index 89126d0440..916cbfb1ea 100644 --- a/src/Umbraco.Web.BackOffice/Umbraco.Web.BackOffice.csproj +++ b/src/Umbraco.Web.BackOffice/Umbraco.Web.BackOffice.csproj @@ -25,7 +25,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + all diff --git a/src/Umbraco.Web.Common/Umbraco.Web.Common.csproj b/src/Umbraco.Web.Common/Umbraco.Web.Common.csproj index a2f7bfdc32..54a693b60b 100644 --- a/src/Umbraco.Web.Common/Umbraco.Web.Common.csproj +++ b/src/Umbraco.Web.Common/Umbraco.Web.Common.csproj @@ -26,18 +26,18 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - - + + all diff --git a/src/Umbraco.Web.UI.Client/src/common/services/umbdataformatter.service.js b/src/Umbraco.Web.UI.Client/src/common/services/umbdataformatter.service.js index 38b56b7661..805e222920 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/umbdataformatter.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/umbdataformatter.service.js @@ -290,6 +290,12 @@ case '_umb_membergroup': saveModel.memberGroups = _.keys(_.pick(prop.value, value => value === true)); break; + case '_umb_approved': + saveModel.isApproved = prop.value; + break; + case '_umb_lockedOut': + saveModel.isLockedOut = prop.value; + break; } }); diff --git a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj index 536b993aa3..e4a681ccea 100644 --- a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj +++ b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj @@ -42,7 +42,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/da.xml b/src/Umbraco.Web.UI/umbraco/config/lang/da.xml index 9916973405..676c502e15 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/da.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/da.xml @@ -1916,6 +1916,7 @@ Mange hilsner fra Umbraco robotten Aktiv Deaktiveret Låst ude + Godkendt Inviteret Inaktiv Navn (A-Å) diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/en.xml b/src/Umbraco.Web.UI/umbraco/config/lang/en.xml index 6670f692b8..2e7711bc88 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/en.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/en.xml @@ -1520,7 +1520,7 @@ To manage your website, simply open the Umbraco backoffice and start adding cont Insufficient user permissions, could not complete the operation Cancelled Operation was cancelled by a 3rd party add-in - This file is being uploaded as part of a folder, but creating a new folder is not allowed here + This file is being uploaded as part of a folder, but creating a new folder is not allowed here Creating a new folder is not allowed here Publishing was cancelled by a 3rd party add-in Property type already exists @@ -2217,6 +2217,7 @@ To manage your website, simply open the Umbraco backoffice and start adding cont Active Disabled Locked out + Approved Invited Inactive Name (A-Z) diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/en_us.xml b/src/Umbraco.Web.UI/umbraco/config/lang/en_us.xml index bc8f38d408..5850e54b6e 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/en_us.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/en_us.xml @@ -2293,6 +2293,7 @@ To manage your website, simply open the Umbraco backoffice and start adding cont Active Disabled Locked out + Approved Invited Inactive Name (A-Z) diff --git a/tests/Umbraco.TestData/Umbraco.TestData.csproj b/tests/Umbraco.TestData/Umbraco.TestData.csproj index 6343a92def..b5b2c5c99f 100644 --- a/tests/Umbraco.TestData/Umbraco.TestData.csproj +++ b/tests/Umbraco.TestData/Umbraco.TestData.csproj @@ -7,7 +7,7 @@ - + diff --git a/tests/Umbraco.Tests.Benchmarks/Umbraco.Tests.Benchmarks.csproj b/tests/Umbraco.Tests.Benchmarks/Umbraco.Tests.Benchmarks.csproj index 4440943322..db34088068 100644 --- a/tests/Umbraco.Tests.Benchmarks/Umbraco.Tests.Benchmarks.csproj +++ b/tests/Umbraco.Tests.Benchmarks/Umbraco.Tests.Benchmarks.csproj @@ -29,7 +29,7 @@ 6.0.0 - 4.16.1 + 4.17.2 diff --git a/tests/Umbraco.Tests.Common/Builders/MemberTypeBuilder.cs b/tests/Umbraco.Tests.Common/Builders/MemberTypeBuilder.cs index fd8e687c34..9cf092d6d4 100644 --- a/tests/Umbraco.Tests.Common/Builders/MemberTypeBuilder.cs +++ b/tests/Umbraco.Tests.Common/Builders/MemberTypeBuilder.cs @@ -35,47 +35,11 @@ namespace Umbraco.Cms.Tests.Common.Builders .WithId(99) .WithName(Constants.Conventions.Member.StandardPropertiesGroupName) .AddPropertyType() - .WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.TextArea) - .WithValueStorageType(ValueStorageType.Ntext) - .WithAlias(Constants.Conventions.Member.Comments) - .WithName(Constants.Conventions.Member.CommentsLabel) - .Done() - .AddPropertyType() - .WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.Boolean) - .WithValueStorageType(ValueStorageType.Integer) - .WithAlias(Constants.Conventions.Member.IsApproved) - .WithName(Constants.Conventions.Member.IsApprovedLabel) - .Done() - .AddPropertyType() - .WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.Boolean) - .WithValueStorageType(ValueStorageType.Integer) - .WithAlias(Constants.Conventions.Member.IsLockedOut) - .WithName(Constants.Conventions.Member.IsLockedOutLabel) - .Done() - .AddPropertyType() - .WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.Label) - .WithValueStorageType(ValueStorageType.Date) - .WithAlias(Constants.Conventions.Member.LastLoginDate) - .WithName(Constants.Conventions.Member.LastLoginDateLabel) - .Done() - .AddPropertyType() - .WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.Label) - .WithValueStorageType(ValueStorageType.Date) - .WithAlias(Constants.Conventions.Member.LastPasswordChangeDate) - .WithName(Constants.Conventions.Member.LastPasswordChangeDateLabel) - .Done() - .AddPropertyType() - .WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.Label) - .WithValueStorageType(ValueStorageType.Date) - .WithAlias(Constants.Conventions.Member.LastLockoutDate) - .WithName(Constants.Conventions.Member.LastLockoutDateLabel) - .Done() - .AddPropertyType() - .WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.Label) - .WithValueStorageType(ValueStorageType.Integer) - .WithAlias(Constants.Conventions.Member.FailedPasswordAttempts) - .WithName(Constants.Conventions.Member.FailedPasswordAttemptsLabel) - .Done(); + .WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.TextArea) + .WithValueStorageType(ValueStorageType.Ntext) + .WithAlias(Constants.Conventions.Member.Comments) + .WithName(Constants.Conventions.Member.CommentsLabel) + .Done(); _propertyGroupBuilders.Add(builder); return this; } diff --git a/tests/Umbraco.Tests.Common/Umbraco.Tests.Common.csproj b/tests/Umbraco.Tests.Common/Umbraco.Tests.Common.csproj index fbd69e7812..a83ec4d1ab 100644 --- a/tests/Umbraco.Tests.Common/Umbraco.Tests.Common.csproj +++ b/tests/Umbraco.Tests.Common/Umbraco.Tests.Common.csproj @@ -16,10 +16,10 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + - + diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/MemberServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/MemberServiceTests.cs index 2fe543c1e2..c2316b7752 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/MemberServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/MemberServiceTests.cs @@ -83,12 +83,14 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services IMember member = new Member("xname", "xemail", "xusername", "xrawpassword", memberType, true) { LastLoginDate = now, - UpdateDate = now + UpdateDate = now, }; MemberService.Save(member); DateTime newDate = now.AddDays(10); - MemberService.SetLastLogin(member.Username, newDate); + member.LastLoginDate = newDate; + member.UpdateDate = newDate; + MemberService.Save(member); // re-get member = MemberService.GetById(member.Id); @@ -97,6 +99,121 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services Assert.That(member.UpdateDate, Is.EqualTo(newDate).Within(1).Seconds); } + // These might seem like some somewhat pointless tests, but there's some funky things going on in MemberRepository when saving. + [Test] + public void Can_Set_Failed_Password_Attempts() + { + IMemberType memberType = MemberTypeService.Get("member"); + IMember member = new Member("xname", "xemail", "xusername", "xrawpassword", memberType, true) + { + FailedPasswordAttempts = 1, + }; + MemberService.Save(member); + + member.FailedPasswordAttempts = 2; + MemberService.Save(member); + + member = MemberService.GetById(member.Id); + + Assert.AreEqual(2, member.FailedPasswordAttempts); + } + + [Test] + public void Can_Set_Is_Approved() + { + IMemberType memberType = MemberTypeService.Get("member"); + IMember member = new Member("xname", "xemail", "xusername", "xrawpassword", memberType, true); + MemberService.Save(member); + + member.IsApproved = false; + MemberService.Save(member); + + member = MemberService.GetById(member.Id); + + Assert.IsFalse(member.IsApproved); + } + + [Test] + public void Can_Set_Is_Locked_Out() + { + IMemberType memberType = MemberTypeService.Get("member"); + IMember member = new Member("xname", "xemail", "xusername", "xrawpassword", memberType, true) + { + IsLockedOut = false, + }; + MemberService.Save(member); + + member.IsLockedOut = true; + MemberService.Save(member); + + member = MemberService.GetById(member.Id); + + Assert.IsTrue(member.IsLockedOut); + } + + [Test] + public void Can_Set_Last_Lockout_Date() + { + DateTime now = DateTime.Now; + IMemberType memberType = MemberTypeService.Get("member"); + IMember member = new Member("xname", "xemail", "xusername", "xrawpassword", memberType, true) + { + LastLockoutDate = now, + }; + MemberService.Save(member); + + DateTime newDate = now.AddDays(10); + member.LastLockoutDate = newDate; + MemberService.Save(member); + + // re-get + member = MemberService.GetById(member.Id); + + Assert.That(member.LastLockoutDate, Is.EqualTo(newDate).Within(1).Seconds); + } + + [Test] + public void Can_set_Last_Login_Date() + { + DateTime now = DateTime.Now; + IMemberType memberType = MemberTypeService.Get("member"); + IMember member = new Member("xname", "xemail", "xusername", "xrawpassword", memberType, true) + { + LastLoginDate = now, + }; + MemberService.Save(member); + + DateTime newDate = now.AddDays(10); + member.LastLoginDate = newDate; + MemberService.Save(member); + + // re-get + member = MemberService.GetById(member.Id); + + Assert.That(member.LastLoginDate, Is.EqualTo(newDate).Within(1).Seconds); + } + + [Test] + public void Can_Set_Last_Password_Change_Date() + { + DateTime now = DateTime.Now; + IMemberType memberType = MemberTypeService.Get("member"); + IMember member = new Member("xname", "xemail", "xusername", "xrawpassword", memberType, true) + { + LastPasswordChangeDate = now, + }; + MemberService.Save(member); + + DateTime newDate = now.AddDays(10); + member.LastPasswordChangeDate = newDate; + MemberService.Save(member); + + // re-get + member = MemberService.GetById(member.Id); + + Assert.That(member.LastPasswordChangeDate, Is.EqualTo(newDate).Within(1).Seconds); + } + [Test] public void Can_Create_Member_With_Properties() { @@ -116,17 +233,9 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services // contains the umbracoMember... properties created when installing, on the member type // contains the other properties, that PublishedContentType adds (BuiltinMemberProperties) - // - // TODO: see TODO in PublishedContentType, this list contains duplicates string[] aliases = new[] { Constants.Conventions.Member.Comments, - Constants.Conventions.Member.FailedPasswordAttempts, - Constants.Conventions.Member.IsApproved, - Constants.Conventions.Member.IsLockedOut, - Constants.Conventions.Member.LastLockoutDate, - Constants.Conventions.Member.LastLoginDate, - Constants.Conventions.Member.LastPasswordChangeDate, nameof(IMember.Email), nameof(IMember.Username), nameof(IMember.Comments), @@ -589,14 +698,25 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services // just a c# property of the Member object resolved.Email = "changed@test.com"; + // NOTE: This will not trigger a property isDirty for the same reason above, but this is a new change, so leave this to make sure. + resolved.FailedPasswordAttempts = 1234; + // NOTE: this WILL trigger a property isDirty because setting this c# property actually sets a value of // the underlying 'Property' - resolved.FailedPasswordAttempts = 1234; + resolved.Comments = "This will make it dirty"; var dirtyMember = (ICanBeDirty)resolved; var dirtyProperties = resolved.Properties.Where(x => x.IsDirty()).ToList(); Assert.IsTrue(dirtyMember.IsDirty()); Assert.AreEqual(1, dirtyProperties.Count); + + // Assert that email and failed password attempts is still set as dirty on the member it self + Assert.IsTrue(dirtyMember.IsPropertyDirty(nameof(resolved.Email))); + Assert.IsTrue(dirtyMember.IsPropertyDirty(nameof(resolved.FailedPasswordAttempts))); + + // Comment will also be marked as dirty on the member object because content base merges dirty properties. + Assert.IsTrue(dirtyMember.IsPropertyDirty(Constants.Conventions.Member.Comments)); + Assert.AreEqual(3, dirtyMember.GetDirtyProperties().Count()); } [Test] @@ -1205,7 +1325,7 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services MemberService.Save(members); Member customMember = MemberBuilder.CreateSimpleMember(memberType, "hello", "hello@test.com", "hello", "hello"); - customMember.SetValue(Constants.Conventions.Member.IsLockedOut, true); + customMember.IsLockedOut = true; MemberService.Save(customMember); int found = MemberService.GetCount(MemberCountType.LockedOut); @@ -1222,7 +1342,7 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services MemberService.Save(members); Member customMember = MemberBuilder.CreateSimpleMember(memberType, "hello", "hello@test.com", "hello", "hello"); - customMember.SetValue(Constants.Conventions.Member.IsApproved, false); + customMember.IsApproved = false; MemberService.Save(customMember); int found = MemberService.GetCount(MemberCountType.Approved); @@ -1250,48 +1370,6 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services Assert.IsTrue(found.Comments.IsNullOrWhiteSpace()); } - /// - /// Because we are forcing some of the built-ins to be Labels which have an underlying db type as nvarchar but we need - /// to ensure that the dates/int get saved to the correct column anyways. - /// - [Test] - public void Setting_DateTime_Property_On_Built_In_Member_Property_Saves_To_Correct_Column() - { - IMemberType memberType = MemberTypeBuilder.CreateSimpleMemberType(); - MemberTypeService.Save(memberType); - Member member = MemberBuilder.CreateSimpleMember(memberType, "test", "test@test.com", "test", "test"); - DateTime date = DateTime.Now; - member.LastLoginDate = DateTime.Now; - MemberService.Save(member); - - IMember result = MemberService.GetById(member.Id); - Assert.AreEqual( - date.TruncateTo(DateTimeExtensions.DateTruncate.Second), - result.LastLoginDate.TruncateTo(DateTimeExtensions.DateTruncate.Second)); - - // now ensure the col is correct - ISqlContext sqlContext = GetRequiredService(); - Sql sql = sqlContext.Sql().Select() - .From() - .InnerJoin().On(dto => dto.PropertyTypeId, dto => dto.Id) - .InnerJoin().On((left, right) => left.VersionId == right.Id) - .Where(dto => dto.NodeId == member.Id) - .Where(dto => dto.Alias == Constants.Conventions.Member.LastLoginDate); - - List colResult; - using (IScope scope = ScopeProvider.CreateScope()) - { - colResult = ScopeAccessor.AmbientScope.Database.Fetch(sql); - scope.Complete(); - } - - Assert.AreEqual(1, colResult.Count); - Assert.IsTrue(colResult.First().DateValue.HasValue); - Assert.IsFalse(colResult.First().IntegerValue.HasValue); - Assert.IsNull(colResult.First().TextValue); - Assert.IsNull(colResult.First().VarcharValue); - } - [Test] public void New_Member_Approved_By_Default() { diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj index 103dbc3feb..bd44eeca06 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj +++ b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj @@ -85,19 +85,19 @@ - - - + + + - - - - + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Models/ContentTypeTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Models/ContentTypeTests.cs index b939eb15a5..84d44133d2 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Models/ContentTypeTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Models/ContentTypeTests.cs @@ -310,6 +310,33 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Models Debug.Print(json); } + [Test] + [TestCase(false, false, false)] + [TestCase(true, false, false)] + [TestCase(true, true, false)] + [TestCase(true, true, true)] + public void Can_Set_Is_Member_Specific_Property_Type_Options(bool isSensitive, bool canView, bool canEdit) + { + var propertyTypeAlias = "testType"; + MemberType memberType = BuildMemberType(); + var propertyType = new PropertyTypeBuilder() + .WithAlias("testType") + .Build(); + + memberType.AddPropertyType(propertyType); + + memberType.SetIsSensitiveProperty(propertyTypeAlias, isSensitive); + memberType.SetMemberCanViewProperty(propertyTypeAlias, canView); + memberType.SetMemberCanEditProperty(propertyTypeAlias, canEdit); + + Assert.Multiple(() => + { + Assert.AreEqual(isSensitive, memberType.IsSensitiveProperty(propertyTypeAlias)); + Assert.AreEqual(canView, memberType.MemberCanViewProperty(propertyTypeAlias)); + Assert.AreEqual(canEdit, memberType.MemberCanEditProperty(propertyTypeAlias)); + }); + } + private static MemberType BuildMemberType() { var builder = new MemberTypeBuilder(); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.Common/Builders/MemberBuilderTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.Common/Builders/MemberBuilderTests.cs index f7392f9e38..6cddecc473 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.Common/Builders/MemberBuilderTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.Common/Builders/MemberBuilderTests.cs @@ -137,14 +137,14 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Tests.Common.Builders Assert.AreEqual(testLastLoginDate, member.LastLoginDate); Assert.AreEqual(testLastPasswordChangeDate, member.LastPasswordChangeDate); Assert.AreEqual(testGroups, member.Groups.ToArray()); - Assert.AreEqual(10, member.Properties.Count); // 7 from membership properties group, 3 custom + Assert.AreEqual(4, member.Properties.Count); // 1 from membership properties group, 3 custom Assert.AreEqual(testPropertyData1.Value, member.GetValue(testPropertyData1.Key)); Assert.AreEqual(testPropertyData2.Value, member.GetValue(testPropertyData2.Key)); Assert.AreEqual(testPropertyData3.Value, member.GetValue(testPropertyData3.Key)); IOrderedEnumerable propertyIds = member.Properties.Select(x => x.Id).OrderBy(x => x); Assert.AreEqual(testPropertyIdsIncrementingFrom + 1, propertyIds.Min()); - Assert.AreEqual(testPropertyIdsIncrementingFrom + 10, propertyIds.Max()); + Assert.AreEqual(testPropertyIdsIncrementingFrom + 4, propertyIds.Max()); Assert.AreEqual(2, member.AdditionalData.Count); Assert.AreEqual(testAdditionalData1.Value, member.AdditionalData[testAdditionalData1.Key]); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.Common/Builders/MemberTypeBuilderTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.Common/Builders/MemberTypeBuilderTests.cs index 925b9386a6..239674aaa8 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.Common/Builders/MemberTypeBuilderTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.Common/Builders/MemberTypeBuilderTests.cs @@ -100,11 +100,11 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Tests.Common.Builders Assert.AreEqual(testThumbnail, memberType.Thumbnail); Assert.AreEqual(testTrashed, memberType.Trashed); Assert.IsFalse(memberType.IsContainer); - Assert.AreEqual(9, memberType.PropertyTypes.Count()); // 7 from membership properties group, 2 custom + Assert.AreEqual(3, memberType.PropertyTypes.Count()); // 1 from membership properties group, 2 custom IOrderedEnumerable propertyTypeIds = memberType.PropertyTypes.Select(x => x.Id).OrderBy(x => x); Assert.AreEqual(testPropertyTypeIdsIncrementingFrom + 1, propertyTypeIds.Min()); - Assert.AreEqual(testPropertyTypeIdsIncrementingFrom + 9, propertyTypeIds.Max()); + Assert.AreEqual(testPropertyTypeIdsIncrementingFrom + 3, propertyTypeIds.Max()); Assert.IsTrue(memberType.MemberCanEditProperty(testPropertyType1.Alias)); Assert.IsFalse(memberType.MemberCanViewProperty(testPropertyType1.Alias)); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj b/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj index 64ae376541..cce774aa04 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj @@ -22,9 +22,9 @@ - - - + + + diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.BackOffice/Controllers/MemberControllerUnitTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.BackOffice/Controllers/MemberControllerUnitTests.cs index aaf3445908..41e0434ebc 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.BackOffice/Controllers/MemberControllerUnitTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.BackOffice/Controllers/MemberControllerUnitTests.cs @@ -594,7 +594,7 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.BackOffice.Controllers Path = member.Path }; - memberDisplay = new MemberDisplay() + memberDisplay = new MemberDisplay { Id = memberId, SortOrder = member.SortOrder, @@ -609,33 +609,57 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.BackOffice.Controllers ContentTypeName = member.ContentType.Name, Icon = fakeMemberData.Icon, Path = member.Path, - Tabs = new List>() + Tabs = new List> { - new Tab() + new() { Alias = "test", Id = 77, - Properties = new List() + Properties = new List { - new ContentPropertyDisplay() + new() { - Alias = "_umb_login" + Alias = $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}login", }, - new ContentPropertyDisplay() + new() { - Alias= "_umb_email" + Alias = $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}email", }, - new ContentPropertyDisplay() + new() { - Alias = "_umb_password" + Alias = $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}password", }, - new ContentPropertyDisplay() + new() { - Alias = "_umb_membergroup" - } - } - } - } + Alias = $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}membergroup", + }, + new() + { + Alias = $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}failedPasswordAttempts", + }, + new() + { + Alias = $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}approved", + }, + new() + { + Alias = $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}lockedOut", + }, + new() + { + Alias = $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}lastLockoutDate", + }, + new() + { + Alias = $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}lastLoginDate", + }, + new() + { + Alias = $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}lastPasswordChangeDate", + }, + }, + }, + }, }; return member;