Reverse conditions in back office extensions

With the removal of SendingContentNotification there are some challenges migrating existing projects, which relied on this to customize content model based on different logic.

Part of if can be solved, but I think hiding tabs/groups still in latest v17 is missing.

There are some packages like AdminOnly or Advanced ​Permissions for ​Umbraco for hiding tabs, but it seems it mainly it based on specific content type or user group and from what I know hiding tabs are currently some DOM manipulation or injected CSS, which is no guaranteed to work in future versions.

What we were able to with SendingContentNotification was to make a tab always visible to admin user group and some editor user group, but further logic based on e.g. start node ids.

I am looking at solving this via a custom condition hasAccess with an API check with similar logic as in the old SendingContentNotification to hide tabs and make some properties read-only. The hasAccess condition mean the properties should work as normal, but when no access the properties should be read-only and because the condition wasn’t met, the property write guard didn’t initialize.

export const manifests: Array<UmbExtensionManifest> = [
	{
		type: 'workspaceContext',
		name: 'Course Property Permission Workspace Context',
		alias: 'Mcb.WorkspaceContext.Course.PropertyPermission',
		api: () => import('./course-property-write-guard.workspace-context.js'),
		conditions: [
			{
				alias: MCB_FKB_ACCESS_CONDITION_ALIAS,
				match: 'course',
			},
			{
				alias: UMB_WORKSPACE_CONDITION_ALIAS,
				match: 'Umb.Workspace.Document',
			},
			{
				alias: UMB_WORKSPACE_CONTENT_TYPE_UNIQUE_CONDITION,
				match: '9667c6c8-af6d-4e3e-ab57-7c9e004dcd4f',
			}
		],
	},
];

I can reverse either the API check (or the logic in the custom condition), but I wonder if it makes sense to be able to negate condition, e.g. for built-in conditions? I negate property on condition may be useful (a bit similar to IIS redirects) for example to match any content types besides a specific content type (or multiple if oneOf is used).

An extension for tabs/groups would be useful, as we could use same condition to hide tabs/groups.

I’d say negate would be useful for conditions.. but then why stop at just that.. matchAll vs matchAny for instance on the array of conditions the same as IIS:UrlRewrite? :thinking:

On the startNode you mentioned (multiple site roots for me).. I just moved that into the property guard, so it wasn’t constrained by the Condition basis, below if of use (yes it’s hardcoded aliases and guids… but serves the purpose, and also never quite sure if any benefit to using the core repositories/contexts over creating a custom api endpoint)

import { UMB_WORKSPACE_CONDITION_ALIAS } from "@umbraco-cms/backoffice/workspace";
import { UMB_DOCUMENT_WORKSPACE_ALIAS } from "@umbraco-cms/backoffice/document";

const manifest: UmbExtensionManifest = {
    type: "workspaceContext",
    name: "SM Property Permission Workspace Context",
    alias: "SM.WorkspaceContext.DocumentPropertyPermission",
    js: () => import("./visibility-guards.element.js"),
    conditions: [
        {
            alias: UMB_WORKSPACE_CONDITION_ALIAS,
            match: UMB_DOCUMENT_WORKSPACE_ALIAS,
        },
    ],
};

export const manifests = [manifest];
import { UmbControllerBase } from "@umbraco-cms/backoffice/class-api";
import type { UmbControllerHost } from "@umbraco-cms/backoffice/controller-api";
import { UMB_DOCUMENT_WORKSPACE_CONTEXT, type UmbDocumentWorkspaceContext, UmbDocumentItemRepository } from "@umbraco-cms/backoffice/document";
import { UMB_ANCESTORS_ENTITY_CONTEXT, type UmbAncestorsEntityContext } from "@umbraco-cms/backoffice/entity";

export class MyDocumentPropertyPermissionWorkspaceContext extends UmbControllerBase {

    #workspace?: UmbDocumentWorkspaceContext;
    #ancestors?: UmbAncestorsEntityContext;
    readonly #docItemRepo: UmbDocumentItemRepository;

    // Toggle via console: localStorage.setItem('VISIBILTY_GUARDS_DEBUG', 'true')
    readonly #isDebug = localStorage.getItem('VISIBILTY_GUARDS_DEBUG') === 'true';

    constructor(host: UmbControllerHost) {
        super(host);

        this.#log("DEBUG: Permission Controller Initialized", null, 'info');

        // In v17, we instantiate the repository directly.
        // It uses the host (this) to access the central API clients.
        this.#docItemRepo = new UmbDocumentItemRepository(this);

        // 1. Setup flat context consumers
        this.consumeContext(UMB_DOCUMENT_WORKSPACE_CONTEXT, (context) => {
            this.#log("DEBUG: Workspace Context Captured");
            this.#workspace = context;
            this.#checkInitialization();
        });

        this.consumeContext(UMB_ANCESTORS_ENTITY_CONTEXT, (context) => {
            this.#log("DEBUG: Ancestors Context Captured");
            this.#ancestors = context;
            this.#checkInitialization();
        });
    }

    /**
     * Centralized Debug Logger
     */
    #log(message: string, data?: any, type: 'info' | 'log' | 'warn' | 'error' | 'dir' = 'log') {
        if (!this.#isDebug) return;

        if (type === 'dir' && data) {
            console.groupCollapsed(`[PermissionGuard] ${message}`);
            console.dir(data);
            console.groupEnd();
        } else {
            const logFn = console[type] || console.log;
            logFn(`[PermissionGuard] ${message}`, data ?? '');
        }
    }

    #checkInitialization() {
        if (this.#workspace && this.#ancestors && this.#docItemRepo) {
            this.#log("DEBUG: All contexts ready. Setting up rules...", null, 'info');
            this.#setupRules();
        } else {
            this.#log(`DEBUG: Initialization Status - Workspace: ${!!this.#workspace}, Ancestors: ${!!this.#ancestors}`);
        }
    }

    #setupRules() {
        // List of property aliases you want to guard
        const restrictedAliases = ['promotionalMessage', 'developmentIntroduction', 'housetypeIntroduction'];
        // Observe the lineage
        this.observe(this.#ancestors!.ancestors, async (ancestors) => {
            this.#log("DEBUG: Ancestors Array Updated:", ancestors);

            const currentUnique = this.#workspace!.getUnique();

            // Logic: If ancestors is empty [], currentUnique is our Root.
            const rootUnique = (ancestors && ancestors.length > 0)
                ? ancestors[0].unique
                : currentUnique;

            this.#log("DEBUG: Current Node Unique:", currentUnique);
            this.#log("DEBUG: Evaluated Root Unique:", rootUnique);

            if (!rootUnique) {
                this.#log("DEBUG: Root Unique is null (Document likely not yet saved).");
                return;
            }

            const { data: documentItems } = await this.#docItemRepo.requestItems([rootUnique]);
            this.#log(`DEBUG: documentItems for ${rootUnique}`, documentItems);
            this.#log(`DEBUG: documentItems for ${rootUnique}`, { documentItems }, 'dir');

            const llpHomePage = "06d46507-6e05-448e-bea9-b1a0467e284f";
            const isAllowed = documentItems?.[0]?.documentType?.unique.toLowerCase() === llpHomePage.toLowerCase();

            this.#log("DEBUG: Has a matching llpHomePage DocumentType:", isAllowed, 'info');

            // Loop through each alias to apply the rules
            for (const alias of restrictedAliases) {
                // Fetch property structure
                const propertyStructure = await this.#workspace!.structure.propertyStructureByAlias(alias);

                // If the property is missing, the observable itself might be null, 
                // or it might emit null inside the observe.
                if (!propertyStructure) {
                    this.#log("DEBUG: propertyStructure observable is null. The alias 'promotionalMessage' is likely incorrect or the property isn't on this DocType.", null, 'warn');

                    // Look at the underlying data in the workspace structure manager
                    this.#log("DEBUG: Inspecting workspace structure owner for available aliases:", (this.#workspace!.structure as any)._owner, 'dir');
                }

                // Create a unique observer key for each property to avoid collisions
                const observerKey = `guard_observer_${alias}`;

                this.observe(propertyStructure, (property) => {
                    if (property) {
                        this.#log(`DEBUG: Applying Rule for ${alias}. Permitted:`, isAllowed);

                        this.#workspace!.propertyViewGuard.addRule({
                            unique: `${alias}_permission`,
                            permitted: isAllowed,
                            message: "This property is restricted to the designated site section.",
                            propertyType: {
                                unique: property.unique
                            }
                        });
                    } else {
                        this.#log("DEBUG: Property 'promotionalMessage' structure not found.", null, 'warn');
                    }
                }, observerKey);
            }
        }, 'ancestor_observer');
    }
}

export { MyDocumentPropertyPermissionWorkspaceContext as api };