Merge branch 'main' into v17/dev
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -84,10 +84,6 @@ export class UmbRefRteBlockElement extends UmbLitElement {
|
||||
umb-icon,
|
||||
umb-ufm-render {
|
||||
z-index: 1;
|
||||
|
||||
&::selection {
|
||||
color: var(--uui-color-default-contrast);
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -94,7 +94,6 @@ export class UmbMemberValidationServerDataSource {
|
||||
path: { id: model.unique },
|
||||
body,
|
||||
}),
|
||||
{ disableNotifications: true },
|
||||
);
|
||||
|
||||
if (data && typeof data === 'string') {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user