/// namespace AmeContentPermissionsUi { const $ = jQuery; const _ = wsAmeLodash; declare const wsAmeCpeScriptData: ScriptData; interface ScriptData { translations: Record>; } const translations = wsAmeCpeScriptData.translations || {}; interface EditorData { policy: PolicyData | null; applicableActions: SerializedAction[]; requiredCapabilities: Record; enforcementDisabled: boolean; adminLikeRoles: string[]; groupLabels: Record; } const possibleReadActionNames = new Set([ 'read', 'view_in_lists', 'read_associated_objects', 'view_associated_objects_in_lists' ]); type SelectedActorObservable = ReturnType; class ContentPermissionsEditor { public serializedPolicy: KnockoutObservable; public readonly tabs: { id: string, title: string }[] = []; public readonly activeTab: KnockoutObservable; private readonly actorSelector: AmeActorSelector; public readonly visibleActors: KnockoutObservable; public readonly advancedTabActors: KnockoutObservable; public readonly selectedActor: SelectedActorObservable; private readonly policy: Policy; public readonly actionSettings: ActionPermissionSetting[] = []; public readonly actionSettingsGroups: ActionSettingsGroup[] = []; private readonly actions: Action[] = []; private readonly readActions: Action[] = []; private readonly otherActions: Action[] = []; public readonly gridsByActorId: Record = {}; public readonly basicViewState: KnockoutComputed; public readonly basicActorSettings: BasicActorSetting[] = []; public readonly everyoneHasDefaultPermissions: KnockoutComputed; public readonly enforcementDisabled: boolean = false; constructor(editorData: EditorData) { this.tabs.push( { id: 'basic', title: translations.tabTitles.basic || 'Basic' }, { id: 'advanced', title: translations.tabTitles.advanced || 'Advanced' }, { id: 'protection', title: translations.tabTitles.protection || 'Protection' }, { id: 'about', title: translations.tabTitles.about || 'About' } ); this.actorSelector = new AmeActorSelector(AmeActors, false, false, 1); this.selectedActor = this.actorSelector.createActorObservable(ko); const genericLoggedInUser = AmeActors.getGenericLoggedInUser(); const anonymousUser = AmeActors.getAnonymousUser(); this.actorSelector.addSpecialActor(genericLoggedInUser); this.actorSelector.addSpecialActor(anonymousUser); { const allSelectableActors = ko.observable(this.actorSelector.getVisibleActors()); this.actorSelector.onVisibleActorsChanged(allSelectableActors); this.visibleActors = ko.computed(() => { return allSelectableActors().filter(actor => !actor.isUser()); }); } this.advancedTabActors = this.visibleActors; //Select the first actor in the list. const tempActors = this.advancedTabActors(); if (tempActors.length > 0) { this.selectedActor(tempActors[0]); } this.actions = (editorData.applicableActions || []).map(Action.fromJSON); for (const action of this.actions) { if (possibleReadActionNames.has(action.name)) { this.readActions.push(action); } else { this.otherActions.push(action); } } this.policy = new Policy( editorData.policy || {}, this.advancedTabActors(), this.actions, genericLoggedInUser, editorData.requiredCapabilities ); this.serializedPolicy = ko.observable(this.getSerializedPolicy()); //Update the serialized policy string when the policy changes. It would be nicer //to only do this when the post is saved, but I'm not sure we can catch that event //in all the different post editors. ko.computed(() => { //Establish a dependency on the number of statements in the policy. //This way the serialized string will be updated when statements are added or //removed, not just when existing statements are modified. this.policy.statementCount(); return this.getSerializedPolicy(); }).extend({ //Rate-limited to avoid excessive updates. rateLimit: { timeout: 1000, method: 'notifyWhenChangesStop' } }).subscribe(this.serializedPolicy); //Select the "Advanced" tab if the policy has the "preferAdvancedMode" flag set, //or the "Basic" tab otherwise. const advancedTab = this.tabs.find(tab => tab.id === 'advanced'); this.activeTab = ko.observable( (this.policy.preferAdvancedMode() && advancedTab) ? advancedTab : this.tabs[0] ); //Initialize action settings and preview grids. //This needs to happen *after* the policy and actions have been loaded because the selected //option for each setting and the color of each grid cell depend on the policy. //Prepare groups for action settings. const defaultGroup = new ActionSettingsGroup(); const groupsByName: Record = {}; for (const [groupKey, groupLabel] of Object.entries(editorData.groupLabels)) { groupsByName[groupKey] = new ActionSettingsGroup(groupLabel); this.actionSettingsGroups.push(groupsByName[groupKey]); } //When the user changes a setting in the "Advanced" tab, make "Advanced" the preferred tab. const settingChangeListenerForAdvancedTab = () => { if (this.activeTab().id === 'advanced') { this.policy.preferAdvancedMode(true); } } for (const action of this.actions) { const setting = new ActionPermissionSetting( action, this.policy, this.selectedActor, settingChangeListenerForAdvancedTab ); this.actionSettings.push(setting); if (action.group && groupsByName[action.group]) { groupsByName[action.group].settings.push(setting); } else { defaultGroup.settings.push(setting); } } //Show the default group only if it has any actions. if (defaultGroup.settings.length > 0) { this.actionSettingsGroups.unshift(defaultGroup); } for (const actor of this.advancedTabActors()) { this.gridsByActorId[actor.getId()] = this.actions .map(action => new MiniGridItem(actor, action, this.policy)); } //Initialize "Basic" tab settings. for (const actor of this.visibleActors()) { if (actor.getId().startsWith('role:')) { this.basicActorSettings.push(new BasicActorSetting( actor, this.policy, this.generateBasicPermissions(actor, true), this.generateBasicPermissions(actor, false), this.readActions )); } } this.everyoneHasDefaultPermissions = ko.pureComputed(() => { return this.visibleActors().every(actor => { for (const action of this.actions) { if (action.isVisibleFor(actor)) { const state = this.policy.getActorPermission(actor, action); if (state !== null) { return false; } } } return true; }) }); const presets = { //"Logged In Users" = read permissions on for logged-in users, off for logged-out users. loggedIn: this.generateBasicPermissions(genericLoggedInUser, true).concat( this.generateBasicPermissions(anonymousUser, false) ), //"Logged Out Users" = the opposite of the above, except read permissions are explicitly //enabled for admins (below) so that they can still see hidden posts. loggedOut: this.generateBasicPermissions(anonymousUser, true).concat( this.generateBasicPermissions(genericLoggedInUser, false) ) }; //Enable read permissions for admin-like roles when selecting "Logged Out Users". //We also need to remember which actors are in the "Logged Out Users" preset so that //we can determine if it's selected later. const loggedOutPresetActors = new Set([genericLoggedInUser, anonymousUser]); for (const roleId of editorData.adminLikeRoles) { const role = AmeActors.getActor('role:' + roleId); if (role) { loggedOutPresetActors.add(role); for (const action of this.readActions) { presets.loggedOut.push([role, action, true]); } } } this.basicViewState = ko.computed({ read: (): BasicViewState => { if (this.policy.preferAdvancedMode()) { return 'advanced'; } //"Everyone" = all actors have their default permissions. if (this.everyoneHasDefaultPermissions()) { return 'everyone'; } //"Logged Out Users" = the preset matches, and everyone else has default permissions. if (this.policy.matchesMultiplePermissions(presets.loggedOut)) { const othersHaveDefaults = this.visibleActors().every(actor => { return loggedOutPresetActors.has(actor) || !this.policy.actorHasAnyCustomPermissions(actor); }); if (othersHaveDefaults) { return 'loggedOut'; } } //"Logged In Users" = the preset matches, and all the roles are in one of their //predefined states or their default state. if (this.policy.matchesMultiplePermissions(presets.loggedIn)) { if (this.basicActorSettings.every( s => s.isPredefinedState() || s.isAllDefaultState()) ) { return 'loggedIn'; } } //Any other mix of settings is considered "Advanced". return 'advanced'; }, write: (value: BasicViewState): void => { if (value === 'advanced') { this.policy.preferAdvancedMode(true); //Manually selecting "Advanced" does not change any permissions. //The user should do that in the advanced tab. return; } this.resetAllPermissions(); this.policy.preferAdvancedMode(false); switch (value) { case 'everyone': //"Everyone" resets all settings to default, which we already did above. break; case 'loggedIn': this.policy.setMultiplePermissions(presets.loggedIn); break; case 'loggedOut': this.policy.setMultiplePermissions(presets.loggedOut); break; } } }); if (editorData.enforcementDisabled) { this.enforcementDisabled = true; } } /** * Apply dynamic CSS classes to options in the action permission dropdowns. * * This can't be done in the template because the options are generated using the "options" * binding which doesn't have a parameter for classes. */ addOptionClasses(optionElement: HTMLElement, option: ActionPermissionOption) { if (option && ko.isObservable(option.cssClass)) { //Sanity check. ko.applyBindingsToNode(optionElement, {class: option.cssClass}); } } setSelectedActorPermission(action: Action, effect: RawPermissionValue): void { const targetActor = this.selectedActor(); if (!targetActor) { return; } this.policy.setActorPermission(targetActor, action, effect); } getSerializedPolicy() { return JSON.stringify(this.policy.toJSON()); } updateSerializedPolicy() { this.serializedPolicy(this.getSerializedPolicy()); } getReplacementContent() { return this.policy.replacementContent(); } setReplacementContent(content: string) { this.policy.replacementContent(content); } checkAllBasicActors() { this.toggleAllBasicSettings(true); } checkNoneBasicActors() { this.toggleAllBasicSettings(false); } private resetAllPermissions() { for (const actor of this.visibleActors()) { for (const action of this.actions) { this.policy.setActorPermission(actor, action, null); } } } private generateBasicPermissions(actor: IAmeActor, isAllowed: boolean): PermissionsList { const permissions: PermissionsList = []; if (isAllowed) { //Allow reading. for (const action of this.readActions) { if (!action.isVisibleFor(actor)) { continue; } //Don't explicitly set the permission to allowed unless necessary. //Most roles can read content by default, so resetting to default //is usually enough. const predictedDefault = this.policy.getPredictedDefaultPermission(actor, action); const newSetting = (predictedDefault === isAllowed) ? null : isAllowed; permissions.push([actor, action, newSetting]); } //Reset other permissions. for (const action of this.otherActions) { if (action.isVisibleFor(actor)) { permissions.push([actor, action, null]); } } } else { //Deny all permissions. for (const action of this.actions) { if (action.isVisibleFor(actor)) { permissions.push([actor, action, false]); } } } return permissions; } private toggleAllBasicSettings(state: boolean) { if (this.basicViewState() !== 'loggedIn') { return; } for (const actorSetting of this.basicActorSettings) { actorSetting.isChecked(state); } } private permissionsBeforeReset: PermissionsList | null = null; public readonly undoResetActionVisible: KnockoutObservable = ko.observable(false); uiResetPermissionsToDefaults() { const currentPermissions: PermissionsList = []; let foundCustomPermissions = false; for (const actor of this.visibleActors()) { const permissions: PermissionsList = []; for (const action of this.actions) { const effect = this.policy.getActorPermission(actor, action); if (effect !== null) { permissions.push([actor, action, effect]); foundCustomPermissions = true; } } } if (!foundCustomPermissions) { alert(translations.general.noCustomPermissionsReset || 'No custom permissions to reset.'); return; //Nothing to reset. } this.permissionsBeforeReset = currentPermissions; this.resetAllPermissions(); this.undoResetActionVisible(true); } undoLastPermissionsReset() { if (!this.permissionsBeforeReset) { return; } this.resetAllPermissions(); this.policy.setMultiplePermissions(this.permissionsBeforeReset); this.permissionsBeforeReset = null; this.undoResetActionVisible(false); } } interface SerializedAction { name: string; label: string; group?: string | null; description?: string; } class Action { constructor( public readonly name: string, public readonly label: string, public readonly group: string | null = null, public readonly description: string = '' ) { } isVisibleFor(actor: IAmeActor | null): boolean { if (actor === null) { return false; } //The special logged-in and anonymous actors only have settings for reading permissions. if ((actor === AmeActors.getGenericLoggedInUser()) || (actor === AmeActors.getAnonymousUser())) { return possibleReadActionNames.has(this.name); } return true; } static fromJSON(data: SerializedAction): Action { const instance = new Action(data.name, data.label, data.group || null, data.description || ''); return instance; } } type RawPermissionValue = boolean | null; class VirtualStatement { public readonly actor: IAmeActor; public readonly action: Action; public readonly effect: KnockoutObservable; constructor(actor: IAmeActor, action: Action, effect: RawPermissionValue = null) { this.actor = actor; this.action = action; this.effect = ko.observable(effect); } } interface PolicyActorAccessData { [actorId: string]: { [actionName: string]: boolean; } } interface RedirectAccessProtectionData { tag: 'redirect'; targetUrl?: string; redirectCode?: number; shortcodesEnabled?: boolean; } interface ErrorMessageProtectionData { tag: 'errorMessage'; errorMessage?: string; } interface NotFoundProtectionData { tag: 'notFound'; } interface ContentReplacementProtectionData { tag: 'replace'; } type AccessProtectionData = RedirectAccessProtectionData | ErrorMessageProtectionData | NotFoundProtectionData | ContentReplacementProtectionData; interface AccessProtectionSettingsData { active?: AccessProtectionData['tag']; protections?: { [K in AccessProtectionData['tag']]?: Extract; } } abstract class AccessProtection implements AmeJsonSerializable { abstract readonly tag: AccessProtectionData['tag']; abstract toJSON(): AccessProtectionData; getLabel(): string { return _.get(translations, ['protectionLabels', this.tag], this.tag); } } class RedirectAccessProtection extends AccessProtection { public readonly tag = 'redirect'; public readonly targetUrl: KnockoutObservable; public readonly redirectCode: KnockoutObservable; public readonly shortcodesEnabled: KnockoutObservable; constructor(data: RedirectAccessProtectionData) { super(); this.targetUrl = ko.observable(data.targetUrl ?? ''); this.redirectCode = ko.observable(data.redirectCode ?? 307); this.shortcodesEnabled = ko.observable(data.shortcodesEnabled ?? false); } toJSON(): RedirectAccessProtectionData { return { tag: this.tag, targetUrl: this.targetUrl(), redirectCode: this.redirectCode(), shortcodesEnabled: this.shortcodesEnabled() }; } } class ErrorMessageProtection extends AccessProtection { public readonly tag = 'errorMessage'; public readonly errorMessage: KnockoutObservable; constructor(data: ErrorMessageProtectionData) { super(); this.errorMessage = ko.observable(data.errorMessage ?? ''); } toJSON(): ErrorMessageProtectionData { return { tag: 'errorMessage', errorMessage: this.errorMessage() }; } } class NotFoundProtection extends AccessProtection { public readonly tag = 'notFound'; constructor(_: NotFoundProtectionData) { super(); } toJSON(): NotFoundProtectionData { return {tag: 'notFound'}; } } class ContentReplacementProtection extends AccessProtection { public readonly tag = 'replace'; constructor(_: ContentReplacementProtectionData) { super(); } toJSON(): ContentReplacementProtectionData { return {tag: 'replace'}; } } function deserializeAccessProtection(data: AccessProtectionData): AccessProtection { switch (data.tag) { case 'redirect': return new RedirectAccessProtection(data); case 'errorMessage': return new ErrorMessageProtection(data); case 'notFound': return new NotFoundProtection(data); case 'replace': return new ContentReplacementProtection(data); } } function createDefaultAccessProtection(tag: AccessProtectionData['tag']): AccessProtection { switch (tag) { case 'redirect': return new RedirectAccessProtection({ tag: 'redirect', targetUrl: '', redirectCode: 307, shortcodesEnabled: false }); case 'errorMessage': return new ErrorMessageProtection({ tag: 'errorMessage', errorMessage: '' }); case 'notFound': return new NotFoundProtection({tag: 'notFound'}); case 'replace': return new ContentReplacementProtection({tag: 'replace'}); } } const allProtectionTags: AccessProtectionData['tag'][] = ['replace', 'notFound', 'errorMessage', 'redirect']; class AccessProtectionSettings implements AmeJsonSerializable { public readonly active: KnockoutObservable; public readonly protections: AccessProtection[] = []; constructor(data: AccessProtectionSettingsData) { let activeInstance: AccessProtection | null = null; for (const tag of allProtectionTags) { let instance: AccessProtection; const serializedProtection = this.getProtectionFromSettings(data, tag); if (serializedProtection) { instance = deserializeAccessProtection(serializedProtection); } else { instance = createDefaultAccessProtection(tag); } this.protections.push(instance); if (tag === data.active) { activeInstance = instance; } } this.active = ko.observable(activeInstance || this.protections[0]); } private getProtectionFromSettings( settings: AccessProtectionSettingsData, tag: AccessProtectionData['tag'] ): AccessProtectionData | null { if (settings.protections) { const temp = settings.protections[tag]; if (temp) { return temp; } } return null; } toJSON(): AccessProtectionSettingsData { return { active: this.active().tag, protections: this.protections.reduce((result, protection) => { const serialized = protection.toJSON(); result[serialized.tag] = serialized; return result; }, {} as Record) } satisfies AccessProtectionSettingsData; } } interface PolicyData { actorAccess?: PolicyActorAccessData; replacementContent?: string; accessProtection?: AccessProtectionSettingsData; preferAdvancedMode?: boolean; } type PermissionsList = Array<[IAmeActor, Action, RawPermissionValue]>; class Policy implements AmeJsonSerializable { public readonly replacementContent: KnockoutObservable; public readonly accessProtection: AccessProtectionSettings; private readonly statements: InvertedIndex; public readonly statementCount: KnockoutComputed; public readonly preferAdvancedMode: KnockoutObservable; constructor( properties: PolicyData, validActors: IAmeActor[], validActions: Action[], private readonly genericLoggedInUser: IAmeActor, private readonly requiredCapsByAction: Record = {} ) { this.replacementContent = ko.observable(properties.replacementContent || ''); this.accessProtection = new AccessProtectionSettings(properties.accessProtection || {}); this.preferAdvancedMode = ko.observable(properties.preferAdvancedMode || false); this.statements = new InvertedIndex( (statement: VirtualStatement) => `${statement.actor.getId()}/${statement.action.name}`, ['actor', 'action'] ); for (const actor of validActors) { for (const action of validActions) { const effect = _.get(properties.actorAccess, [actor.getId(), action.name], null); if (effect !== null) { this.statements.add(new VirtualStatement(actor, action, effect)); } } } this.statementCount = ko.pureComputed(() => this.statements.size()); } getActorPermission(actor: IAmeActor, action: Action): RawPermissionValue { return this.getOrCreateStatement(actor, action).effect(); } setActorPermission(actor: IAmeActor, action: Action, isAllowed: RawPermissionValue): void { const statement = this.getOrCreateStatement(actor, action); statement.effect(isAllowed); } /** * Like setActorPermission(), but if the predicted default matches the given value, * the permission reset to the default instead. */ setActorPermissonOrDefault(actor: IAmeActor, action: Action, isAllowed: RawPermissionValue): void { const predictedDefault = this.getPredictedDefaultPermission(actor, action); if (predictedDefault === isAllowed) { this.setActorPermission(actor, action, null); } else { this.setActorPermission(actor, action, isAllowed); } } getPredictedDefaultPermission(actor: IAmeActor, action: Action): RawPermissionValue { //Roles inherit the permissions of the "Logged In Users" actor if it has a custom setting. if (actor.getId().startsWith('role:') && action.isVisibleFor(this.genericLoggedInUser)) { const inheritedEffect = this.getActorPermission(this.genericLoggedInUser, action); if (inheritedEffect !== null) { return inheritedEffect; } } const caps = this.requiredCapsByAction[action.name]; if (caps && (caps.length > 0)) { //Check if the actor has all the required capabilities. //Note: We assume everyone has the "read" capability. This is not always true, but //it leads to more accurate predictions in most cases because, usually, everyone //can read content (even users who are not logged in). return caps.every(cap => (actor.hasOwnCap(cap) || (cap === 'read'))); } return null; } getPermissionState(actor: IAmeActor | null, action: Action): PermissionCheckResult { if (actor === null) { return new PermissionCheckResult(null, null); } return new PermissionCheckResult( this.getActorPermission(actor, action), this.getPredictedDefaultPermission(actor, action) ); } setMultiplePermissions(permissions: PermissionsList): void { for (const [actor, action, effect] of permissions) { this.setActorPermission(actor, action, effect); } } matchesMultiplePermissions(permissions: PermissionsList): boolean { for (const [actor, action, effect] of permissions) { if (this.getActorPermission(actor, action) !== effect) { return false; } } return true; } actorHasAnyCustomPermissions(actor: IAmeActor): boolean { if (this.statements.size() === 0) { return false; } const statement = this.statements.findFirst({actor}); if (statement) { return (statement.effect() !== null); } return false; } private getOrCreateStatement(actor: IAmeActor, action: Action): VirtualStatement { const foundStatement = this.statements.findFirst({actor, action}); if (foundStatement) { return foundStatement; } const statement = new VirtualStatement(actor, action, null); this.statements.add(statement); return statement; } toJSON(): PolicyData { const data: PolicyData = {}; const actorAcess: PolicyActorAccessData = {}; for (const statement of this.statements.getAll()) { const effect = statement.effect(); if (effect !== null) { _.set(actorAcess, [statement.actor.getId(), statement.action.name], effect); } } if (!_.isEmpty(actorAcess)) { data.actorAccess = actorAcess; } const replacementContent = this.replacementContent(); if (replacementContent !== '') { data.replacementContent = replacementContent; } const accessProtection = this.accessProtection.toJSON(); if (!_.isEmpty(accessProtection)) { data.accessProtection = accessProtection; } if (this.preferAdvancedMode()) { data.preferAdvancedMode = true; } return data; } } class PermissionCheckResult { constructor( public readonly setting: RawPermissionValue, public readonly predictedDefault: RawPermissionValue ) { } get effectivePermission(): RawPermissionValue { return this.setting ?? this.predictedDefault; } get isDefault(): boolean { return this.setting === null; } } interface ActionPermissionOption { label: KnockoutObservable; effect: RawPermissionValue; cssClass: KnockoutComputed; dashicon: string; } const allowOption: ActionPermissionOption = { label: ko.observable(translations.permissionOptions.allow || 'Allow'), effect: true, cssClass: ko.pureComputed(() => 'ame-cpe-option-allow'), dashicon: 'yes' } const denyOption: ActionPermissionOption = { label: ko.observable(translations.permissionOptions.deny || 'Deny'), effect: false, cssClass: ko.pureComputed(() => 'ame-cpe-option-deny'), dashicon: 'no' } class DefaultPermissionOption implements ActionPermissionOption { public readonly label: KnockoutObservable; public readonly effect: RawPermissionValue = null; public readonly cssClass: KnockoutComputed; public readonly dashicon: string = ''; private readonly predictedEffect: KnockoutComputed; constructor(action: Action, policy: Policy, selectedActorObservable: SelectedActorObservable) { this.predictedEffect = ko.pureComputed(() => { const actor = selectedActorObservable(); if (actor === null) { return null; } return policy.getPredictedDefaultPermission(actor, action); }); this.label = ko.pureComputed(() => { const effect = this.predictedEffect(); if (effect === null) { return translations.permissionOptions.default || '(Default)'; } return (effect ? (translations.permissionOptions.defaultAllow || '(Default: Allow)') : (translations.permissionOptions.defaultDeny || '(Default: Deny)')); }); this.cssClass = ko.pureComputed(() => { const classes = ['ame-cpe-option-default']; const effect = this.predictedEffect(); if (effect === true) { classes.push('ame-cpe-option-allow'); } else if (effect === false) { classes.push('ame-cpe-option-deny'); } return classes.join(' '); }); } } class ActionPermissionSetting { public readonly options: ActionPermissionOption[] = [allowOption, denyOption]; public readonly selectedOption: KnockoutComputed; public readonly isVisible: KnockoutObservable; public readonly cssClass: KnockoutComputed; constructor( public readonly action: Action, policy: Policy, selectedActorObservable: SelectedActorObservable, changeSubscription?: () => void ) { const defaultOption = new DefaultPermissionOption(action, policy, selectedActorObservable); this.options.unshift(defaultOption); this.selectedOption = ko.computed({ read: () => { const actor = selectedActorObservable(); if (!actor) { return defaultOption; } const isAllowed = policy.getActorPermission(actor, action); if (isAllowed === null) { return defaultOption; } return isAllowed ? allowOption : denyOption; }, write: (option: ActionPermissionOption): void => { const actor = selectedActorObservable(); if (!actor) { return; } const effect = option.effect; policy.setActorPermission(actor, action, effect); if (changeSubscription) { changeSubscription(); } } }); this.cssClass = ko.computed(() => { return this.selectedOption().cssClass(); }); this.isVisible = ko.pureComputed(() => action.isVisibleFor(selectedActorObservable())); } } class ActionSettingsGroup { public readonly label: string; public readonly settings: ActionPermissionSetting[] = []; public readonly isVisible: KnockoutObservable; constructor(label: string = '') { this.label = label; this.isVisible = ko.pureComputed(() => this.settings.some(s => s.isVisible())); } } class PermissionOptionsComponent { public readonly setting: ActionPermissionSetting; constructor(params: Record) { this.setting = ko.unwrap(params.setting) as ActionPermissionSetting; } } class PermissionOptionsDropdownComponent extends PermissionOptionsComponent { public readonly selectId: string = ''; constructor(params: Record) { super(params); const selectId = ko.unwrap(params.suggestedId); if (typeof selectId === 'string') { this.selectId = selectId; } } } class PermissionOptionsBarComponent extends PermissionOptionsComponent { constructor(params: Record) { super(params); } } type BasicViewState = 'everyone' | 'loggedIn' | 'loggedOut' | 'advanced'; class BasicActorSetting { public readonly isChecked: KnockoutObservable; public readonly isPredefinedState: KnockoutComputed; public readonly isAllDefaultState: KnockoutComputed; constructor( public readonly actor: IAmeActor, policy: Policy, checkedState: PermissionsList, uncheckedState: PermissionsList, readActions: Action[], ) { this.isChecked = ko.computed({ read: (): boolean => { return readActions.every( action => policy.getPermissionState(actor, action).effectivePermission ); }, write: (value: boolean): void => { if (value) { policy.setMultiplePermissions(checkedState); } else { policy.setMultiplePermissions(uncheckedState); } } }); //todo: Maybe an indeterminate state if all read actions have null permissions. this.isPredefinedState = ko.pureComputed(() => { const expectedState = this.isChecked() ? checkedState : uncheckedState; return policy.matchesMultiplePermissions(expectedState); }); this.isAllDefaultState = ko.pureComputed( () => !policy.actorHasAnyCustomPermissions(this.actor) ); } } class MiniGridItem { public readonly cssClass: KnockoutComputed; constructor(actor: IAmeActor, action: Action, policy: Policy) { this.cssClass = ko.computed(() => { const classes: string[] = []; if (!action.isVisibleFor(actor)) { classes.push('ame-cpe-cell-not-applicable'); return classes.join(' '); } let effect = policy.getActorPermission(actor, action); if (effect === null) { classes.push('ame-cpe-cell-default'); effect = policy.getPredictedDefaultPermission(actor, action); } if (effect === true) { classes.push('ame-cpe-cell-allow'); } else if (effect === false) { classes.push('ame-cpe-cell-deny'); } if (!action.isVisibleFor(actor)) { classes.push('ame-cpe-cell-not-applicable'); } return classes.join(' '); }); } } //region Inverted Index type PropertyKey = string | number | symbol; type PropertyValues = { [K in keyof T]: T[K] }; class InvertedIndex> { private records = new Map(); private indexes = new Map>>(); private keyFunction: (item: T) => string; private indexedProperties: Set; private lazyInitIndexes: boolean; public readonly size: KnockoutComputed; private readonly internalSize: KnockoutObservable = ko.observable(0); constructor( keyFunction: (item: T) => string, indexedProperties?: Array ) { this.keyFunction = keyFunction; this.indexedProperties = new Set(indexedProperties); if (this.indexedProperties.size > 0) { //We know which properties will be indexed, so we can initialize the indexes immediately. this.lazyInitIndexes = false; for (const prop of this.indexedProperties.values()) { this.indexes.set(prop, new Map>()); } } else { this.lazyInitIndexes = true; } this.size = ko.pureComputed(() => this.internalSize()); } private shouldIndex(prop: keyof T): boolean { //If no properties were specified for indexing, index everything. return (this.indexedProperties.size === 0) || this.indexedProperties.has(prop); } private initializeLazyIndexes(record: T) { //Initialize index maps only for properties we want to index. for (const prop in record) { if (this.shouldIndex(prop) && !this.indexes.has(prop)) { this.indexes.set(prop, new Map>()); } } } add(record: T): void { const key = this.keyFunction(record); //Remove the old record if it exists. if (this.records.has(key)) { this.remove(this.records.get(key)!); } //Initialize indexes as needed. if (this.lazyInitIndexes) { this.initializeLazyIndexes(record); } //Store the record. this.records.set(key, record); this.internalSize(this.records.size); //Add to property indexes (only for indexed properties). for (const [prop, value] of Object.entries(record)) { if (!this.shouldIndex(prop as keyof T)) { continue; } const propIndex = this.indexes.get(prop as keyof T)!; if (!propIndex.has(value)) { propIndex.set(value, new Set()); } propIndex.get(value)!.add(record); } } remove(record: T): void { const key = this.keyFunction(record); if (!this.records.has(key)) { return; } //Remove from property indexes. for (const [prop, value] of Object.entries(record)) { if (!this.shouldIndex(prop as keyof T)) { continue; } const propIndex = this.indexes.get(prop as keyof T); if (propIndex) { const valueSet = propIndex.get(value); if (valueSet) { valueSet.delete(record); if (valueSet.size === 0) { propIndex.delete(value); } } } } //Remove from records. this.records.delete(key); this.internalSize(this.records.size); } getByKey(key: string): T | undefined { return this.records.get(key); } find(criteria: Partial>): Set { let result: Set | null = null; for (const [prop, value] of Object.entries(criteria)) { const propKey = prop as keyof T; //If we're searching by a non-indexed property, we need to do a full scan. if (!this.shouldIndex(propKey)) { const matches = new Set( Array.from(this.records.values()).filter( record => record[propKey] === value ) ); if (result === null) { result = matches; } else { result = new Set([...result].filter((x: T) => matches.has(x))); } continue; } const propIndex = this.indexes.get(propKey); if (!propIndex) { //This can happen if indexes are lazy-initialized and no records with this //property have been added yet. continue; } const matches = propIndex.get(value) || new Set(); if (result === null) { result = new Set(matches); } else { //Intersect with previous results. result = new Set([...result].filter(x => matches.has(x))); } //Short circuit if we have no matches. if (result.size === 0) { break; } } return result || new Set(); } findFirst(criteria: Partial>): T | undefined { const result = this.find(criteria); return result.values().next().value; } getAll(): T[] { return Array.from(this.records.values()); } } //endregion jQuery(function () { const $metaBox = $('#ame-cpe-content-permissions'); const $tagEditor = $('.wrap #edittag'); const $editor = ($metaBox.length > 0) ? $metaBox.find('.inside #ame-cpe-permissions-editor-root').first() : $tagEditor.find('#ame-cpe-permissions-editor-root').first(); const editorData = $editor.data('cpe-editor-data'); if (($editor.length !== 1) || (!editorData)) { return; } ko.components.register( 'ame-cpe-permission-options-dropdown', { viewModel: PermissionOptionsDropdownComponent, template: {element: 'ame-cpe-permission-dropdown-component'} } ); ko.components.register( 'ame-cpe-permission-options-bar', { viewModel: PermissionOptionsBarComponent, template: {element: 'ame-cpe-permission-bar-component'} } ); const permissionsEditor = new ContentPermissionsEditor(editorData); ko.applyBindings(permissionsEditor, $editor[0]); //Clear the data attribute to free up memory. It contains a long JSON string which is //no longer needed after the editor is initialized. $editor.data('cpe-editor-data', ''); //Enable tooltips in the editor. $editor.tooltip({ items: '.ame-cpe-tooltip-trigger[title]', classes: { 'ui-tooltip': 'ws-ame-tooltip' }, position: { my: "left+10 center", at: "right center", collision: "flipfit" }, //No animation. show: false, hide: false }); //region Replacement Content Editor //One-off event handling for the replacement content editor. Probably not worth creating //a custom Knockout binding for one field. const replacementContentEditorId = 'ame-cpe-replacement-content-editor'; const $replacementContentTextarea = $('textarea#' + replacementContentEditorId); const initialContent = permissionsEditor.getReplacementContent(); function updateReplacementContentFromEditor() { //Prefer the WP API because it handles automatic paragraph conversion, //and also works in text mode. if (wp.editor.getContent) { const newContent = wp.editor.getContent(replacementContentEditorId); if (typeof newContent === 'string') { permissionsEditor.setReplacementContent(newContent); } } else if ($replacementContentTextarea.length > 0) { //Fallback: Read the textarea. permissionsEditor.setReplacementContent($replacementContentTextarea.val() || ''); } } //Throttle updates to the observable. Probably don't need cascading updates on every keypress. const throttledContentUpdate = _.throttle( updateReplacementContentFromEditor, 10000, {leading: true, trailing: true} ); //First, the plain textarea fallback. This is relevant when the post editor is in //text mode or when the user has disabled the visual editor. $replacementContentTextarea.val(initialContent); $replacementContentTextarea.on('change input', function () { throttledContentUpdate(); }); //Then the visual editor. if (tinymce) { function addMceChangeHandler(editor: Editor) { //Update the observable when the editor content changes. editor.on('change', () => throttledContentUpdate()); } const editor = tinymce.get('ame-cpe-replacement-content-editor'); if (editor) { //Set initial content. let content = initialContent; //Content is saved with line breaks, let's convert them to paragraphs. WP does this //automatically when initializing the editor, but now we're changing the content //after the editor already exists, so we need to do it explicitly. if (wp.editor.autop) { content = wp.editor.autop(content); } editor.setContent(content); addMceChangeHandler(editor); } else { //Wait for the editor to be initialized. const $document = $(document); $document.on('tinymce-editor-init', function mceInitListener(_, editor) { if (editor.id === replacementContentEditorId) { addMceChangeHandler(editor); $document.off('tinymce-editor-init', mceInitListener); } }); } } //endregion //Update the serialized policy field before the post is saved. This isn't guaranteed to //happen by default because some of the observables are rate-limited. setTimeout(() => { function updatePolicyNow() { //Push the latest replacement content to the observable. updateReplacementContentFromEditor(); //Update the serialized policy string. permissionsEditor.updateSerializedPolicy(); } //Classic editor. $editor.closest('form').on('submit', updatePolicyNow); //Block editor. There is a way to do this more cleanly with wp.data.subscribe(), but //that gets triggered very often and might not be good for performance. //See: https://wordpress.stackexchange.com/questions/319054/trigger-javascript-on-gutenberg-block-editor-save/331317#331317 const editorButtonSelectors = [ '#submitpost #publish', '#editor .editor-post-save-draft', '#editor .editor-header__settings button.is-primary' ]; $(editorButtonSelectors.join(', ')).on('click', updatePolicyNow); }, 500); }); }