diff --git a/src/Umbraco.Infrastructure/Services/MemberEditingService.cs b/src/Umbraco.Infrastructure/Services/MemberEditingService.cs index 974a70254f..6f062f1d43 100644 --- a/src/Umbraco.Infrastructure/Services/MemberEditingService.cs +++ b/src/Umbraco.Infrastructure/Services/MemberEditingService.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Models.ContentEditing.Validation; using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services.OperationStatus; @@ -48,15 +49,21 @@ internal sealed class MemberEditingService : IMemberEditingService public Task GetAsync(Guid key) => Task.FromResult(_memberService.GetById(key)); - public async Task> ValidateCreateAsync(MemberCreateModel createModel) - => await _memberContentEditingService.ValidateAsync(createModel, createModel.ContentTypeKey); + public async Task> ValidateCreateAsync( + MemberCreateModel createModel) => + await ValidateMember(createModel, null, createModel.Password, createModel.ContentTypeKey); + + public async Task> ValidateUpdateAsync(Guid key, MemberUpdateModel updateModel) { IMember? member = _memberService.GetById(key); - return member is not null - ? await _memberContentEditingService.ValidateAsync(updateModel, member.ContentType.Key) - : Attempt.FailWithStatus(ContentEditingOperationStatus.NotFound, new ContentValidationResult()); + if (member is null) + { + return Attempt.FailWithStatus(ContentEditingOperationStatus.NotFound, new ContentValidationResult()); + } + + return await ValidateMember(updateModel, key, updateModel.NewPassword, member.ContentType.Key); } public async Task> CreateAsync(MemberCreateModel createModel, IUser user) @@ -221,6 +228,73 @@ internal sealed class MemberEditingService : IMemberEditingService contentDeleteResult.Result); } + private async Task> ValidateMember(MemberEditingModelBase model, Guid? memberKey, string? password, Guid memberTypeKey) + { + var validationErrors = new List(); + MemberEditingOperationStatus validationStatus = await ValidateMemberDataAsync(model, memberKey, password); + if (validationStatus is not MemberEditingOperationStatus.Success) + { + validationErrors.Add(MapStatusToPropertyValidationError(validationStatus)); + } + Attempt propertyValidation = await _memberContentEditingService.ValidateAsync(model, memberTypeKey); + + if (propertyValidation.Success is false) + { + if (propertyValidation.Status is ContentEditingOperationStatus.ContentTypeNotFound) + { + return Attempt.FailWithStatus(ContentEditingOperationStatus.ContentTypeNotFound, new ContentValidationResult()); + } + else + { + validationErrors.AddRange(propertyValidation.Result.ValidationErrors); + } + } + + var result = new ContentValidationResult { ValidationErrors = validationErrors }; + return result.ValidationErrors.Any() is false + ? Attempt.SucceedWithStatus(ContentEditingOperationStatus.Success, result) + : Attempt.FailWithStatus(ContentEditingOperationStatus.PropertyValidationError, result); + } + + private PropertyValidationError MapStatusToPropertyValidationError(MemberEditingOperationStatus memberEditingOperationStatus) + { + string alias; + string[] errorMessages; + switch (memberEditingOperationStatus) + { + case MemberEditingOperationStatus.InvalidName: + alias = "name"; + errorMessages = ["Invalid or empty name"]; + break; + case MemberEditingOperationStatus.InvalidPassword: + alias = "password"; + errorMessages = ["Invalid password"]; + break; + case MemberEditingOperationStatus.InvalidUsername: + alias = "username"; + errorMessages = ["Invalid username"]; + break; + case MemberEditingOperationStatus.InvalidEmail: + alias = "email"; + errorMessages = ["Invalid email"]; + break; + case MemberEditingOperationStatus.DuplicateUsername: + alias = "username"; + errorMessages = ["Duplicate username"]; + break; + case MemberEditingOperationStatus.DuplicateEmail: + alias = "email"; + errorMessages = ["Duplicate email"]; + break; + default: + alias = string.Empty; + errorMessages = []; + break; + } + + return new PropertyValidationError { Alias = alias, Culture = null, Segment = null, ErrorMessages = errorMessages, JsonPath = string.Empty }; + } + private async Task ValidateMemberDataAsync(MemberEditingModelBase model, Guid? memberKey, string? password) { if (model.Variants.FirstOrDefault(v => v.Culture is null && v.Segment is null)?.Name.IsNullOrWhiteSpace() is not false) diff --git a/src/Umbraco.Web.UI.Client/src/apps/backoffice/components/backoffice-header-sections.element.ts b/src/Umbraco.Web.UI.Client/src/apps/backoffice/components/backoffice-header-sections.element.ts index b83133f761..89c0ffbb51 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/backoffice/components/backoffice-header-sections.element.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/backoffice/components/backoffice-header-sections.element.ts @@ -16,6 +16,8 @@ export class UmbBackofficeHeaderSectionsElement extends UmbLitElement { private _backofficeContext?: UmbBackofficeContext; + #sectionPathMap = new Map(); + constructor() { super(); @@ -52,6 +54,49 @@ export class UmbBackofficeHeaderSectionsElement extends UmbLitElement { ); } + #getSectionPath(manifest: ManifestSection | undefined) { + return `section/${manifest?.meta.pathname}`; + } + + #onSectionClick(event: PointerEvent, manifest: ManifestSection | undefined) { + // Let the browser handle the click if the Ctrl or Meta key is pressed + if (event.ctrlKey || event.metaKey) { + return; + } + + event.stopPropagation(); + event.preventDefault(); + + // Store the current path for the section so we can redirect to it next time the section is visited + if (this._currentSectionAlias) { + const currentPath = window.location.pathname; + this.#sectionPathMap.set(this._currentSectionAlias, currentPath); + } + + if (!manifest) { + throw new Error('Section manifest is missing'); + } + + const clickedSectionAlias = manifest.alias; + + // If the clicked section is the same as the current section, we just load the original section path to load the section root + if (this._currentSectionAlias === clickedSectionAlias) { + const sectionPath = this.#getSectionPath(manifest); + history.pushState(null, '', sectionPath); + return; + } + + // Check if we have a stored path for the clicked section + if (this.#sectionPathMap.has(clickedSectionAlias)) { + const storedPath = this.#sectionPathMap.get(clickedSectionAlias); + history.pushState(null, '', storedPath); + } else { + // Nothing stored, so we navigate to the regular section path + const sectionPath = this.#getSectionPath(manifest); + history.pushState(null, '', sectionPath); + } + } + override render() { return html` @@ -61,7 +106,8 @@ export class UmbBackofficeHeaderSectionsElement extends UmbLitElement { (section) => html` this.#onSectionClick(event, section.manifest)} + href="${this.#getSectionPath(section.manifest)}" label="${ifDefined( section.manifest?.meta.label ? this.localize.string(section.manifest?.meta.label) diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/ref-rte-block/ref-rte-block.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/ref-rte-block/ref-rte-block.element.ts index 3f0dc522fc..20adfe8be5 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/ref-rte-block/ref-rte-block.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/ref-rte-block/ref-rte-block.element.ts @@ -84,10 +84,6 @@ export class UmbRefRteBlockElement extends UmbLitElement { umb-icon, umb-ufm-render { z-index: 1; - - &::selection { - color: var(--uui-color-default-contrast); - } } `, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/views/edit/content-editor.element.ts b/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/views/edit/content-editor.element.ts index f5b9941d44..45c8d3b817 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/views/edit/content-editor.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/views/edit/content-editor.element.ts @@ -201,7 +201,7 @@ export class UmbContentWorkspaceViewEditElement extends UmbLitElement implements fullPath === this._activePath || (!this._hasRootGroups && index === 0 && this._routerPath + '/' === this._activePath); return html` { + // If the user has chosen to stay logged in, we ignore the logout command and instead request a new token + if (this.#keepUserLoggedIn) { + console.log( + '[Auth Context] User chose to stay logged in, attempting to validate token instead of logging out.', + ); + await this.#tryValidateToken(); + return; + } + if (event.data?.command === 'logout') { // If the worker signals a logout, we clear the token storage and set the user as unauthorized host.timeOut(); @@ -60,6 +72,16 @@ export class UmbAuthSessionTimeoutController extends UmbControllerBase { }, '_authFlowTimeoutSignal', ); + + this.observe( + host.isAuthorized, + (isAuthorized) => { + if (isAuthorized) { + this.#observeKeepUserLoggedIn(); + } + }, + '_authFlowIsAuthorizedSignal', + ); } override destroy(): void { @@ -68,6 +90,20 @@ export class UmbAuthSessionTimeoutController extends UmbControllerBase { this.#tokenCheckWorker = undefined; } + /** + * Observe the user's preference for staying logged in + * and update the internal state accordingly. + * This method fetches the current user configuration from the server to find the value. + * // TODO: We cannot observe the config store directly here yet, as it would create a circular dependency, so maybe we need to move the config option somewhere else? + */ + async #observeKeepUserLoggedIn() { + if (this.#hasCheckedKeepUserLoggedIn) return; + this.#hasCheckedKeepUserLoggedIn = true; + // eslint-disable-next-line local-rules/no-direct-api-import + const { data } = await UserService.getUserCurrentConfiguration(); + this.#keepUserLoggedIn = data?.keepUserLoggedIn ?? false; + } + async #closeTimeoutModal() { const contextToken = (await import('@umbraco-cms/backoffice/modal')).UMB_MODAL_MANAGER_CONTEXT; const modalManager = await this.getContext(contextToken); diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member/repository/validation/member-validation.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member/repository/validation/member-validation.server.data-source.ts index 544069add2..b6889b741f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member/repository/validation/member-validation.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member/repository/validation/member-validation.server.data-source.ts @@ -94,7 +94,6 @@ export class UmbMemberValidationServerDataSource { path: { id: model.unique }, body, }), - { disableNotifications: true }, ); if (data && typeof data === 'string') { diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/heading/heading.tiptap-api.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/heading/heading.tiptap-api.ts index d733e83234..240f330e70 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/heading/heading.tiptap-api.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/heading/heading.tiptap-api.ts @@ -13,7 +13,11 @@ export default class UmbTiptapHeadingExtensionApi extends UmbTiptapExtensionApiB h5, h6 { margin-top: 0; - margin-bottom: 0.5em; + margin-bottom: 1rem; + + &:first-child { + margin-top: 0.25rem; + } } `; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/tiptap-rte/property-editor-ui-tiptap.element.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/tiptap-rte/property-editor-ui-tiptap.element.ts index c979385c79..2ac8f55a88 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/tiptap-rte/property-editor-ui-tiptap.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/tiptap-rte/property-editor-ui-tiptap.element.ts @@ -20,13 +20,6 @@ export class UmbPropertyEditorUiTiptapElement extends UmbPropertyEditorUiRteElem const tipTapElement = event.target; const markup = tipTapElement.value; - // If we don't get any markup clear the property editor value. - if (tipTapElement.isEmpty()) { - this.value = undefined; - this._fireChangeEvent(); - return; - } - // Remove unused Blocks of Blocks Layout. Leaving only the Blocks that are present in Markup. const usedContentKeys: string[] = [];