diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/da-dk.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/da-dk.ts index 0b05cfbb8d..224cf316de 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/da-dk.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/da-dk.ts @@ -901,6 +901,7 @@ export default { avatar: 'Avatar til', header: 'Overskrift', systemField: 'system felt', + readOnly: 'Skrivebeskyttet', restore: 'Genskab', generic: 'Generic', media: 'Media', diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/en-us.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/en-us.ts index 1c296f2a86..6f53119e14 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/en-us.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/en-us.ts @@ -936,6 +936,7 @@ export default { skipToMenu: 'Skip to menu', skipToContent: 'Skip to content', restore: 'Restore', + readOnly: 'Read-only', newVersionAvailable: 'New version available', }, colors: { diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts index 1830171106..27fb01708b 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts @@ -47,6 +47,7 @@ export default { notify: 'Notifications', protect: 'Public Access', publish: 'Publish', + readOnly: 'Read-only', refreshNode: 'Reload', remove: 'Remove', rename: 'Rename', diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-split-view/workspace-split-view-variant-selector.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-split-view/workspace-split-view-variant-selector.element.ts index d7be226d99..f32f98bc68 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-split-view/workspace-split-view-variant-selector.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-split-view/workspace-split-view-variant-selector.element.ts @@ -307,7 +307,9 @@ export class UmbWorkspaceSplitViewVariantSelectorElement extends UmbLitElement { #renderReadOnlyTag(culture?: string | null) { if (!culture) return nothing; - return this.#isReadOnly(culture) ? html`Read-only` : nothing; + return this.#isReadOnly(culture) + ? html`${this.localize.term('general_readOnly')}` + : nothing; } #renderSplitViewButton(variant: UmbDocumentVariantOptionModel) { diff --git a/src/Umbraco.Web.UI.Client/src/packages/language/app-language-select/app-language-select.element.ts b/src/Umbraco.Web.UI.Client/src/packages/language/app-language-select/app-language-select.element.ts index 940bd928a5..4b76521814 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/language/app-language-select/app-language-select.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/language/app-language-select/app-language-select.element.ts @@ -2,8 +2,18 @@ import { UmbLanguageCollectionRepository } from '../collection/index.js'; import type { UmbLanguageDetailModel } from '../types.js'; import { type UmbAppLanguageContext, UMB_APP_LANGUAGE_CONTEXT } from '../global-contexts/index.js'; import type { UUIMenuItemEvent, UUIPopoverContainerElement } from '@umbraco-cms/backoffice/external/uui'; -import { css, html, customElement, state, repeat, ifDefined, query } from '@umbraco-cms/backoffice/external/lit'; +import { + css, + html, + customElement, + state, + repeat, + ifDefined, + query, + nothing, +} from '@umbraco-cms/backoffice/external/lit'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { UMB_CURRENT_USER_CONTEXT } from '@umbraco-cms/backoffice/current-user'; @customElement('umb-app-language-select') export class UmbAppLanguageSelectElement extends UmbLitElement { @@ -16,6 +26,9 @@ export class UmbAppLanguageSelectElement extends UmbLitElement { @state() private _appLanguage?: UmbLanguageDetailModel; + @state() + private _appLanguageIsReadOnly = false; + @state() private _isOpen = false; @@ -23,6 +36,12 @@ export class UmbAppLanguageSelectElement extends UmbLitElement { #appLanguageContext?: UmbAppLanguageContext; #languagesObserver?: any; + #currentUserAllowedLanguages?: Array; + #currentUserHasAccessToAllLanguages?: boolean; + + @state() + _disallowedLanguages: Array = []; + constructor() { super(); @@ -30,6 +49,29 @@ export class UmbAppLanguageSelectElement extends UmbLitElement { this.#appLanguageContext = instance; this.#observeAppLanguage(); }); + + this.consumeContext(UMB_CURRENT_USER_CONTEXT, (context) => { + this.observe(context.languages, (languages) => { + this.#currentUserAllowedLanguages = languages; + this.#checkForLanguageAccess(); + }); + + this.observe(context.hasAccessToAllLanguages, (hasAccessToAllLanguages) => { + this.#currentUserHasAccessToAllLanguages = hasAccessToAllLanguages; + this.#checkForLanguageAccess(); + }); + }); + } + + #checkForLanguageAccess() { + // find all disallowed languages + this._disallowedLanguages = this._languages?.filter((language) => { + if (this.#currentUserHasAccessToAllLanguages) { + return false; + } + + return !this.#currentUserAllowedLanguages?.includes(language.unique); + }); } async #observeAppLanguage() { @@ -38,6 +80,10 @@ export class UmbAppLanguageSelectElement extends UmbLitElement { this.observe(this.#appLanguageContext.appLanguage, (language) => { this._appLanguage = language; }); + + this.observe(this.#appLanguageContext.appLanguageReadOnlyState.isReadOnly, (isReadOnly) => { + this._appLanguageIsReadOnly = isReadOnly; + }); } async #observeLanguages() { @@ -46,6 +92,7 @@ export class UmbAppLanguageSelectElement extends UmbLitElement { // TODO: listen to changes if (data) { this._languages = data.items; + this.#checkForLanguageAccess(); } } @@ -83,7 +130,11 @@ export class UmbAppLanguageSelectElement extends UmbLitElement { #renderTrigger() { return html``; } @@ -98,13 +149,25 @@ export class UmbAppLanguageSelectElement extends UmbLitElement { label=${ifDefined(language.name)} @click-label=${this.#onLabelClick} data-unique=${ifDefined(language.unique)} - ?active=${language.unique === this._appLanguage?.unique}> + ?active=${language.unique === this._appLanguage?.unique}> + ${this.#isLanguageReadOnly(language.unique) ? this.#renderReadOnlyTag(language.unique) : nothing} + `, )} `; } + #isLanguageReadOnly(culture?: string) { + if (!culture) return false; + return this._disallowedLanguages.find((language) => language.unique === culture) ? true : false; + } + + #renderReadOnlyTag(culture?: string) { + if (!culture) return nothing; + return html`${this.localize.term('general_readOnly')}`; + } + static override styles = [ css` :host { diff --git a/src/Umbraco.Web.UI.Client/src/packages/language/global-contexts/app-language.context.ts b/src/Umbraco.Web.UI.Client/src/packages/language/global-contexts/app-language.context.ts index 6a0d4b7d8b..bb6da03b19 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/language/global-contexts/app-language.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/language/global-contexts/app-language.context.ts @@ -6,30 +6,35 @@ import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; import type { UmbApi } from '@umbraco-cms/backoffice/extension-api'; import { UMB_AUTH_CONTEXT } from '@umbraco-cms/backoffice/auth'; +import { UmbReadOnlyStateManager } from '@umbraco-cms/backoffice/utils'; +import { UMB_CURRENT_USER_CONTEXT } from '@umbraco-cms/backoffice/current-user'; // TODO: Make a store for the App Languages. // TODO: Implement default language end-point, in progress at backend team, so we can avoid getting all languages. export class UmbAppLanguageContext extends UmbContextBase implements UmbApi { - #languageCollectionRepository: UmbLanguageCollectionRepository; #languages = new UmbArrayState([], (x) => x.unique); - moreThanOneLanguage = this.#languages.asObservablePart((x) => x.length > 1); #appLanguage = new UmbObjectState(undefined); - appLanguage = this.#appLanguage.asObservable(); + public readonly appLanguage = this.#appLanguage.asObservable(); + public readonly appLanguageCulture = this.#appLanguage.asObservablePart((x) => x?.unique); - appLanguageCulture = this.#appLanguage.asObservablePart((x) => x?.unique); + public readonly appLanguageReadOnlyState = new UmbReadOnlyStateManager(this); - appDefaultLanguage = createObservablePart(this.#languages.asObservable(), (languages) => + public readonly appDefaultLanguage = createObservablePart(this.#languages.asObservable(), (languages) => languages.find((language) => language.isDefault), ); - getAppCulture() { - return this.#appLanguage.getValue()?.unique; - } + public readonly moreThanOneLanguage = this.#languages.asObservablePart((x) => x.length > 1); + + #languageCollectionRepository = new UmbLanguageCollectionRepository(this); + #currentUserAllowedLanguages: Array = []; + #currentUserHasAccessToAllLanguages = false; + + #readOnlyStateIdentifier = 'UMB_LANGUAGE_PERMISSION_'; + #localStorageKey = 'umb:appLanguage'; constructor(host: UmbControllerHost) { super(host, UMB_APP_LANGUAGE_CONTEXT); - this.#languageCollectionRepository = new UmbLanguageCollectionRepository(this); // TODO: We need to ensure this request is called every time the user logs in, but this should be done somewhere across the app and not here [JOV] this.consumeContext(UMB_AUTH_CONTEXT, (authContext) => { @@ -38,12 +43,46 @@ export class UmbAppLanguageContext extends UmbContextBase this.#observeLanguages(); }); }); + + this.consumeContext(UMB_CURRENT_USER_CONTEXT, (context) => { + this.observe(context.languages, (languages) => { + this.#currentUserAllowedLanguages = languages || []; + this.#setIsReadOnly(); + }); + + this.observe(context.hasAccessToAllLanguages, (hasAccessToAllLanguages) => { + this.#currentUserHasAccessToAllLanguages = hasAccessToAllLanguages || false; + this.#setIsReadOnly(); + }); + }); + } + + getAppCulture() { + return this.#appLanguage.getValue()?.unique; } setLanguage(unique: string) { - const languages = this.#languages.getValue(); - const language = languages.find((x) => x.unique === unique); + // clear the previous read-only state + const appLanguage = this.#appLanguage.getValue(); + if (appLanguage?.unique) { + this.appLanguageReadOnlyState.removeState(this.#readOnlyStateIdentifier + appLanguage.unique); + } + + // find the language + const language = this.#findLanguage(unique); + + if (!language) { + throw new Error(`Language with unique ${unique} not found`); + } + + // set the new language this.#appLanguage.update(language); + + // store the new language in local storage + localStorage.setItem(this.#localStorageKey, language?.unique); + + // set the new read-only state + this.#setIsReadOnly(); } async #observeLanguages() { @@ -61,6 +100,17 @@ export class UmbAppLanguageContext extends UmbContextBase } #initAppLanguage() { + // get the selected language from local storage + const uniqueFromLocalStorage = localStorage.getItem(this.#localStorageKey); + + if (uniqueFromLocalStorage) { + const language = this.#findLanguage(uniqueFromLocalStorage); + if (language) { + this.setLanguage(language.unique); + return; + } + } + const defaultLanguage = this.#languages.getValue().find((x) => x.isDefault); // TODO: do we always have a default language? // do we always get the default language on the first request, or could it be on page 2? @@ -68,6 +118,37 @@ export class UmbAppLanguageContext extends UmbContextBase if (!defaultLanguage?.unique) return; this.setLanguage(defaultLanguage.unique); } + + #findLanguage(unique: string) { + return this.#languages.getValue().find((x) => x.unique === unique); + } + + #setIsReadOnly() { + const appLanguage = this.#appLanguage.getValue(); + + if (!appLanguage) { + this.appLanguageReadOnlyState.clear(); + return; + } + + const unique = this.#readOnlyStateIdentifier + appLanguage.unique; + this.appLanguageReadOnlyState.removeState(unique); + + if (this.#currentUserHasAccessToAllLanguages) { + return; + } + + const isReadOnly = !this.#currentUserAllowedLanguages.includes(appLanguage.unique); + + if (isReadOnly) { + const readOnlyState = { + unique, + message: 'You do not have permission to edit to this culture', + }; + + this.appLanguageReadOnlyState.addState(readOnlyState); + } + } } // Default export to enable this as a globalContext extension js: