Merge branch 'release/17.0'
# Conflicts: # tests/Umbraco.Tests.Integration/CompatibilitySuppressions.xml
This commit is contained in:
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc.Controllers;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
using Umbraco.Cms.Core.Routing;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Web.Common.Controllers;
|
||||
@@ -58,21 +59,48 @@ public sealed class ConfigureMemberCookieOptions : IConfigureNamedOptions<Cookie
|
||||
|
||||
await securityStampValidator.ValidateAsync(ctx);
|
||||
},
|
||||
OnRedirectToAccessDenied = ctx =>
|
||||
// retain the login redirect behavior in .NET 10
|
||||
// - see https://learn.microsoft.com/en-us/dotnet/core/compatibility/aspnet-core/10/cookie-authentication-api-endpoints
|
||||
OnRedirectToLogin = context =>
|
||||
{
|
||||
// When the controller is an UmbracoAPIController, we want to return a StatusCode instead of a redirect.
|
||||
// All other cases should use the default Redirect of the CookieAuthenticationEvent.
|
||||
var controllerDescriptor = ctx.HttpContext.GetEndpoint()?.Metadata
|
||||
.OfType<ControllerActionDescriptor>()
|
||||
.FirstOrDefault();
|
||||
|
||||
if (!controllerDescriptor?.ControllerTypeInfo.IsSubclassOf(typeof(UmbracoApiController)) ?? false)
|
||||
if (IsXhr(context.Request))
|
||||
{
|
||||
new CookieAuthenticationEvents().OnRedirectToAccessDenied(ctx);
|
||||
context.Response.Headers.Location = context.RedirectUri;
|
||||
context.Response.StatusCode = 401;
|
||||
}
|
||||
else
|
||||
{
|
||||
context.Response.Redirect(context.RedirectUri);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
OnRedirectToAccessDenied = context =>
|
||||
{
|
||||
// TODO: rewrite this to match OnRedirectToLogin (with a 403 status code) when UmbracoApiController is removed
|
||||
// When the controller is an UmbracoAPIController, or if the request is an XHR, we want to return a
|
||||
// StatusCode instead of a redirect.
|
||||
// All other cases should use the default Redirect of the CookieAuthenticationEvent.
|
||||
if (IsXhr(context.Request) is false && IsUmbracoApiControllerRequest(context.HttpContext) is false)
|
||||
{
|
||||
new CookieAuthenticationEvents().OnRedirectToAccessDenied(context);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
};
|
||||
return;
|
||||
|
||||
bool IsUmbracoApiControllerRequest(HttpContext context)
|
||||
=> context.GetEndpoint()
|
||||
?.Metadata
|
||||
.OfType<ControllerActionDescriptor>()
|
||||
.FirstOrDefault()
|
||||
?.ControllerTypeInfo
|
||||
.IsSubclassOf(typeof(UmbracoApiController)) is true;
|
||||
|
||||
bool IsXhr(HttpRequest request) =>
|
||||
string.Equals(request.Query[HeaderNames.XRequestedWith], "XMLHttpRequest", StringComparison.Ordinal) ||
|
||||
string.Equals(request.Headers.XRequestedWith, "XMLHttpRequest", StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +92,6 @@ export abstract class UmbContentTypeWorkspaceContextBase<
|
||||
let { data } = await request;
|
||||
|
||||
if (data) {
|
||||
data = await this._processIncomingData(data);
|
||||
data = await this._scaffoldProcessData(data);
|
||||
|
||||
if (this.modalContext) {
|
||||
|
||||
@@ -368,11 +368,6 @@ export abstract class UmbContentDetailWorkspaceContextBase<
|
||||
(varies) => {
|
||||
this._data.setVariesBySegment(varies);
|
||||
this.#variesBySegment = varies;
|
||||
if (varies) {
|
||||
this.loadSegments();
|
||||
} else {
|
||||
this._segments.setValue([]);
|
||||
}
|
||||
},
|
||||
null,
|
||||
);
|
||||
@@ -393,20 +388,28 @@ export abstract class UmbContentDetailWorkspaceContextBase<
|
||||
this.#languages.setValue(data?.items ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Call `_loadSegmentsFor` instead. `loadSegments` will be removed in v.18.
|
||||
* (note this was introduced in v.17, and deprecated in v.17.0.1)
|
||||
*/
|
||||
protected async loadSegments() {
|
||||
console.warn('Stop using loadSegments, call _loadSegmentsFor instead. loadSegments will be removed in v.18.');
|
||||
const unique = await firstValueFrom(this.unique);
|
||||
if (!unique) {
|
||||
this._segments.setValue([]);
|
||||
return;
|
||||
}
|
||||
this._loadSegmentsFor(unique);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
protected async _loadSegmentsFor(unique: string): Promise<void> {
|
||||
console.warn(
|
||||
`UmbContentDetailWorkspaceContextBase: Segments are not implemented in the workspace context for "${this.getEntityType()}" types.`,
|
||||
);
|
||||
this._segments.setValue([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Call `_processIncomingData` instead. `_scaffoldProcessData` will be removed in v.18.
|
||||
*/
|
||||
protected override _scaffoldProcessData(data: DetailModelType): Promise<DetailModelType> {
|
||||
return this._processIncomingData(data);
|
||||
}
|
||||
|
||||
protected override async _processIncomingData(data: DetailModelType): Promise<DetailModelType> {
|
||||
const contentTypeUnique: string | undefined = (data as any)[this.#contentTypePropertyName].unique;
|
||||
if (!contentTypeUnique) {
|
||||
@@ -415,24 +418,28 @@ export abstract class UmbContentDetailWorkspaceContextBase<
|
||||
// Load the content type structure, usually this comes from the data, but in this case we are making the data, and we need this to be able to complete the data. [NL]
|
||||
await this.structure.loadType(contentTypeUnique);
|
||||
|
||||
// Load segments if varying by segment, or reset to empty array:
|
||||
if (this.#variesBySegment) {
|
||||
await this._loadSegmentsFor(data.unique);
|
||||
} else {
|
||||
this._segments.setValue([]);
|
||||
}
|
||||
|
||||
// Set culture and segment for all values:
|
||||
const cultures = this.#languages.getValue().map((x) => x.unique);
|
||||
|
||||
if (this.structure.variesBySegment) {
|
||||
// TODO: v.17 Engage please note we have not implemented support for segments yet. [NL]
|
||||
console.warn('Segments are not yet implemented for preset');
|
||||
let segments: Array<string> | undefined;
|
||||
if (this.#variesBySegment) {
|
||||
segments = this._segments.getValue().map((s) => s.alias);
|
||||
}
|
||||
// TODO: Add Segments for Presets:
|
||||
const segments: Array<string> | undefined = this.structure.variesBySegment ? [] : undefined;
|
||||
|
||||
const repo = new UmbDataTypeDetailRepository(this);
|
||||
|
||||
const propertyTypes = await this.structure.getContentTypeProperties();
|
||||
const contentTypeVariesByCulture = this.structure.getVariesByCulture();
|
||||
const contentTypeVariesBySegment = this.structure.getVariesByCulture();
|
||||
const contentTypeVariesBySegment = this.structure.getVariesBySegment();
|
||||
const valueDefinitions = await Promise.all(
|
||||
propertyTypes.map(async (property) => {
|
||||
// TODO: Implement caching for data-type requests. [NL]
|
||||
const dataType = (await repo.requestByUnique(property.dataType.unique)).data;
|
||||
// This means if its not loaded this will never resolve and the error below will never happen.
|
||||
if (!dataType) {
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
} from '@umbraco-cms/backoffice/entity-action';
|
||||
import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification';
|
||||
|
||||
type ResetReason = 'error' | 'empty' | 'fallback';
|
||||
|
||||
export class UmbTreeItemChildrenManager<
|
||||
TreeItemType extends UmbTreeItemModel = UmbTreeItemModel,
|
||||
TreeRootType extends UmbTreeRootModel = UmbTreeRootModel,
|
||||
@@ -218,7 +220,7 @@ export class UmbTreeItemChildrenManager<
|
||||
async #loadChildren(reload = false) {
|
||||
if (this.#loadChildrenRetries > this.#requestMaxRetries) {
|
||||
this.#loadChildrenRetries = 0;
|
||||
this.#resetChildren();
|
||||
this.#resetChildren('error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -302,7 +304,7 @@ export class UmbTreeItemChildrenManager<
|
||||
We cancel the base target and load using skip/take pagination instead.
|
||||
This can happen if deep linked to a non existing item or all retries have failed.
|
||||
*/
|
||||
this.#resetChildren();
|
||||
this.#resetChildren(this.#children.getValue().length === 0 ? 'empty' : 'fallback');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,7 +331,7 @@ export class UmbTreeItemChildrenManager<
|
||||
if (this.#loadPrevItemsRetries > this.#requestMaxRetries) {
|
||||
// If we have exceeded the maximum number of retries, we need to reset the base target and load from the top
|
||||
this.#loadPrevItemsRetries = 0;
|
||||
this.#resetChildren();
|
||||
this.#resetChildren('error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -378,7 +380,7 @@ export class UmbTreeItemChildrenManager<
|
||||
If we can't find a new end target we reload the children from the top.
|
||||
We cancel the base target and load using skip/take pagination instead.
|
||||
*/
|
||||
this.#resetChildren();
|
||||
this.#resetChildren(this.#children.getValue().length === 0 ? 'empty' : 'fallback');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,7 +411,7 @@ export class UmbTreeItemChildrenManager<
|
||||
if (this.#loadNextItemsRetries > this.#requestMaxRetries) {
|
||||
// If we have exceeded the maximum number of retries, we need to reset the base target and load from the top
|
||||
this.#loadNextItemsRetries = 0;
|
||||
this.#resetChildren();
|
||||
this.#resetChildren('error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -467,7 +469,7 @@ export class UmbTreeItemChildrenManager<
|
||||
If we can't find a new end target we reload the children from the top.
|
||||
We cancel the base target and load using skip/take pagination instead.
|
||||
*/
|
||||
this.#resetChildren();
|
||||
this.#resetChildren(this.#children.getValue().length === 0 ? 'empty' : 'fallback');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -520,12 +522,81 @@ export class UmbTreeItemChildrenManager<
|
||||
this.targetPagination.clear();
|
||||
}
|
||||
|
||||
async #resetChildren() {
|
||||
/**
|
||||
* Loads children using offset pagination only.
|
||||
* This is a "safe" fallback that does NOT:
|
||||
* - Use target pagination
|
||||
* - Retry with new targets
|
||||
* - Call #resetChildren (preventing recursion)
|
||||
* - Throw errors (fails gracefully)
|
||||
*/
|
||||
async #loadChildrenWithOffsetPagination(): Promise<void> {
|
||||
const repository = this.#treeContext?.getRepository();
|
||||
if (!repository) {
|
||||
// Terminal fallback - fail silently rather than throwing
|
||||
return;
|
||||
}
|
||||
|
||||
this.#isLoading.setValue(true);
|
||||
|
||||
const parent = this.getStartNode() || this.getTreeItem();
|
||||
const foldersOnly = this.getFoldersOnly();
|
||||
const additionalArgs = this.getAdditionalRequestArgs();
|
||||
|
||||
const offsetPaging: UmbOffsetPaginationRequestModel = {
|
||||
skip: 0, // Always from the start
|
||||
take: this.offsetPagination.getPageSize(),
|
||||
};
|
||||
|
||||
const { data } = parent?.unique
|
||||
? await repository.requestTreeItemsOf({
|
||||
parent: { unique: parent.unique, entityType: parent.entityType },
|
||||
skip: offsetPaging.skip,
|
||||
take: offsetPaging.take,
|
||||
paging: offsetPaging,
|
||||
foldersOnly,
|
||||
...additionalArgs,
|
||||
})
|
||||
: await repository.requestTreeRootItems({
|
||||
skip: offsetPaging.skip,
|
||||
take: offsetPaging.take,
|
||||
paging: offsetPaging,
|
||||
foldersOnly,
|
||||
...additionalArgs,
|
||||
});
|
||||
|
||||
if (data) {
|
||||
const items = data.items as Array<TreeItemType>;
|
||||
this.#children.setValue(items);
|
||||
this.setHasChildren(data.total > 0);
|
||||
this.offsetPagination.setTotalItems(data.total);
|
||||
}
|
||||
// Note: On error, we simply don't update state - UI shows stale data
|
||||
// This is the terminal fallback, no further recovery
|
||||
|
||||
this.#isLoading.setValue(false);
|
||||
}
|
||||
|
||||
async #resetChildren(reason: ResetReason = 'error'): Promise<void> {
|
||||
// Clear pagination state
|
||||
this.targetPagination.clear();
|
||||
this.offsetPagination.clear();
|
||||
this.loadChildren();
|
||||
const notificationManager = await this.getContext(UMB_NOTIFICATION_CONTEXT);
|
||||
notificationManager?.peek('danger', { data: { message: 'Menu loading failed. Showing the first items again.' } });
|
||||
|
||||
// Reset retry counters to prevent any lingering retry state
|
||||
this.#loadChildrenRetries = 0;
|
||||
this.#loadPrevItemsRetries = 0;
|
||||
this.#loadNextItemsRetries = 0;
|
||||
|
||||
// Load using offset pagination only - this is our terminal fallback
|
||||
await this.#loadChildrenWithOffsetPagination();
|
||||
|
||||
// Only show notification for actual errors
|
||||
if (reason === 'error') {
|
||||
const notificationManager = await this.getContext(UMB_NOTIFICATION_CONTEXT);
|
||||
notificationManager?.peek('danger', {
|
||||
data: { message: 'Menu loading failed. Showing the first items again.' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#onPageChange = () => this.loadNextChildren();
|
||||
|
||||
@@ -253,7 +253,7 @@ export abstract class UmbEntityDetailWorkspaceContextBase<
|
||||
}
|
||||
}
|
||||
} else if (data) {
|
||||
const processedData = await this._processIncomingData(data);
|
||||
const processedData = await this._scaffoldProcessData(data);
|
||||
|
||||
this._data.setPersisted(processedData);
|
||||
this._data.setCurrent(processedData);
|
||||
@@ -311,7 +311,6 @@ export abstract class UmbEntityDetailWorkspaceContextBase<
|
||||
let { data } = await request;
|
||||
|
||||
if (data) {
|
||||
data = await this._processIncomingData(data);
|
||||
data = await this._scaffoldProcessData(data);
|
||||
|
||||
if (this.modalContext) {
|
||||
@@ -336,7 +335,7 @@ export abstract class UmbEntityDetailWorkspaceContextBase<
|
||||
* @returns {Promise<DetailModelType>} The processed data.
|
||||
*/
|
||||
protected async _scaffoldProcessData(data: DetailModelType): Promise<DetailModelType> {
|
||||
return data;
|
||||
return await this._processIncomingData(data);
|
||||
}
|
||||
protected async _processIncomingData(data: DetailModelType): Promise<DetailModelType> {
|
||||
return data;
|
||||
|
||||
@@ -229,22 +229,16 @@ export class UmbDocumentWorkspaceContext
|
||||
this.#isTrashedContext.setIsTrashed(false);
|
||||
}
|
||||
|
||||
protected override async loadSegments(): Promise<void> {
|
||||
this.observe(
|
||||
this.unique,
|
||||
async (unique) => {
|
||||
if (!unique) {
|
||||
this._segments.setValue([]);
|
||||
return;
|
||||
}
|
||||
const { data } = await this.#documentSegmentRepository.getDocumentByIdSegmentOptions(unique, {
|
||||
skip: 0,
|
||||
take: 9999,
|
||||
});
|
||||
this._segments.setValue(data?.items ?? []);
|
||||
},
|
||||
'_loadSegmentsUnique',
|
||||
);
|
||||
protected override async _loadSegmentsFor(unique: string): Promise<void> {
|
||||
if (!unique) {
|
||||
this._segments.setValue([]);
|
||||
return;
|
||||
}
|
||||
const { data } = await this.#documentSegmentRepository.getDocumentByIdSegmentOptions(unique, {
|
||||
skip: 0,
|
||||
take: 9999,
|
||||
});
|
||||
this._segments.setValue(data?.items ?? []);
|
||||
}
|
||||
|
||||
async create(parent: UmbEntityModel, documentTypeUnique: string, blueprintUnique?: string) {
|
||||
|
||||
@@ -229,7 +229,10 @@ export class UmbManagementApiTreeDataRequestManager<
|
||||
return args.take !== undefined ? args.take : this.#defaultTakeSize;
|
||||
}
|
||||
|
||||
#getTargetResultHasValidParents(data: Array<TreeItemType>, parentUnique: string | null): boolean {
|
||||
#getTargetResultHasValidParents(data: Array<TreeItemType> | undefined, parentUnique: string | null): boolean {
|
||||
if (!data) {
|
||||
return false;
|
||||
}
|
||||
return data.every((item) => {
|
||||
if (item.parent) {
|
||||
return item.parent.id === parentUnique;
|
||||
|
||||
@@ -3,9 +3,7 @@ WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
builder.CreateUmbracoBuilder()
|
||||
.AddBackOffice()
|
||||
.AddWebsite()
|
||||
#if UseDeliveryApi
|
||||
.AddDeliveryApi()
|
||||
#endif
|
||||
.AddComposers()
|
||||
.Build();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user