Merge branch 'main' into v17/dev

This commit is contained in:
Andy Butland
2025-09-16 10:33:09 +02:00
8 changed files with 168 additions and 20 deletions

View File

@@ -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<IMember?> GetAsync(Guid key)
=> Task.FromResult(_memberService.GetById(key));
public async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateCreateAsync(MemberCreateModel createModel)
=> await _memberContentEditingService.ValidateAsync(createModel, createModel.ContentTypeKey);
public async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateCreateAsync(
MemberCreateModel createModel) =>
await ValidateMember(createModel, null, createModel.Password, createModel.ContentTypeKey);
public async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> 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<Attempt<MemberCreateResult, MemberEditingStatus>> CreateAsync(MemberCreateModel createModel, IUser user)
@@ -221,6 +228,73 @@ internal sealed class MemberEditingService : IMemberEditingService
contentDeleteResult.Result);
}
private async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateMember(MemberEditingModelBase model, Guid? memberKey, string? password, Guid memberTypeKey)
{
var validationErrors = new List<PropertyValidationError>();
MemberEditingOperationStatus validationStatus = await ValidateMemberDataAsync(model, memberKey, password);
if (validationStatus is not MemberEditingOperationStatus.Success)
{
validationErrors.Add(MapStatusToPropertyValidationError(validationStatus));
}
Attempt<ContentValidationResult, ContentEditingOperationStatus> 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<MemberEditingOperationStatus> 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)

View File

@@ -16,6 +16,8 @@ export class UmbBackofficeHeaderSectionsElement extends UmbLitElement {
private _backofficeContext?: UmbBackofficeContext;
#sectionPathMap = new Map<string, string>();
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`
<uui-tab-group id="tabs" data-mark="section-links">
@@ -61,7 +106,8 @@ export class UmbBackofficeHeaderSectionsElement extends UmbLitElement {
(section) => html`
<uui-tab
?active="${this._currentSectionAlias === section.alias}"
href="${`section/${section.manifest?.meta.pathname}`}"
@click=${(event: PointerEvent) => this.#onSectionClick(event, section.manifest)}
href="${this.#getSectionPath(section.manifest)}"
label="${ifDefined(
section.manifest?.meta.label
? this.localize.string(section.manifest?.meta.label)

View File

@@ -84,10 +84,6 @@ export class UmbRefRteBlockElement extends UmbLitElement {
umb-icon,
umb-ufm-render {
z-index: 1;
&::selection {
color: var(--uui-color-default-contrast);
}
}
`,
];

View File

@@ -201,7 +201,7 @@ export class UmbContentWorkspaceViewEditElement extends UmbLitElement implements
fullPath === this._activePath ||
(!this._hasRootGroups && index === 0 && this._routerPath + '/' === this._activePath);
return html`<uui-tab
.label=${this.localize.string(name ?? '#general_unnamed')}
label=${this.localize.string(name ?? '#general_unnamed')}
.active=${active}
href=${fullPath}
data-mark="content-tab:${path}"

View File

@@ -2,10 +2,13 @@ import type { UmbAuthFlow } from '../auth-flow.js';
import type { UmbAuthContext } from '../auth.context.js';
import { UMB_MODAL_AUTH_TIMEOUT } from '../modals/umb-auth-timeout-modal.token.js';
import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
import { UserService } from '@umbraco-cms/backoffice/external/backend-api';
export class UmbAuthSessionTimeoutController extends UmbControllerBase {
#tokenCheckWorker?: SharedWorker;
#host: UmbAuthContext;
#keepUserLoggedIn = false;
#hasCheckedKeepUserLoggedIn = false;
constructor(host: UmbAuthContext, authFlow: UmbAuthFlow) {
super(host, 'UmbAuthSessionTimeoutController');
@@ -22,6 +25,15 @@ export class UmbAuthSessionTimeoutController extends UmbControllerBase {
// Listen for messages from the token check worker
this.#tokenCheckWorker.port.onmessage = async (event) => {
// 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);

View File

@@ -94,7 +94,6 @@ export class UmbMemberValidationServerDataSource {
path: { id: model.unique },
body,
}),
{ disableNotifications: true },
);
if (data && typeof data === 'string') {

View File

@@ -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;
}
}
`;
}

View File

@@ -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[] = [];