Spaces:
Build error
Build error
| /** | |
| * Access Control plugin | |
| * | |
| * Manages document access permissions with three modes: | |
| * - role-based: only role restrictions (gold = reviewers only) | |
| * - owner-based: documents editable only by owner | |
| * - granular: database-backed per-document permissions | |
| */ | |
| /** | |
| * @import { PluginContext } from '../modules/plugin-context.js' | |
| * @import { ApplicationState } from '../state.js' | |
| * @import { StatusText } from '../modules/panels/widgets/status-text.js' | |
| * @import { StatusSwitch } from '../modules/panels/widgets/status-switch.js' | |
| * @import { UserData } from './authentication.js' | |
| * @import { AccessControlModeResponse } from '../modules/api-client-v1.js' | |
| */ | |
| import { Plugin } from '../modules/plugin-base.js' | |
| import { getFileDataById } from '../modules/file-data-utils.js' | |
| import { PanelUtils } from '../modules/panels/index.js' | |
| import { notify } from '../modules/sl-utils.js' | |
| import { | |
| userHasReviewerRole, | |
| userHasAnnotatorRole, | |
| isGoldFile, | |
| isVersionFile, | |
| canEditDocumentWithPermissions, | |
| canViewDocumentWithPermissions, | |
| canEditFile as canEditFileFromUtils, | |
| userIsAdmin | |
| } from '../modules/acl-utils.js' | |
| /** | |
| * Access control mode | |
| * @typedef {'role-based' | 'owner-based' | 'granular'} AccessControlMode | |
| */ | |
| /** | |
| * Document permissions object | |
| * @typedef {object} DocumentPermissions | |
| * @property {string} visibility - Document visibility ('collection' or 'owner') | |
| * @property {string} editability - Document editability ('collection' or 'owner') | |
| * @property {string|null} owner - Document owner username (null if no owner) | |
| * @property {boolean} can_modify - Whether current user can modify permissions | |
| */ | |
| /** | |
| * Access control API | |
| * @typedef {object} AccessControlAPI | |
| * @property {() => DocumentPermissions} getDocumentPermissions - Gets current document permissions | |
| * @property {(user: UserData|null) => boolean} canEditDocument - Checks if user can edit document | |
| * @property {(user: UserData|null) => boolean} canViewDocument - Checks if user can view document | |
| * @property {() => AccessControlMode} getMode - Gets current access control mode | |
| * @property {(fileId: string) => boolean} checkCanEditFile - Checks if user can edit file | |
| */ | |
| class AccessControlPlugin extends Plugin { | |
| /** @param {PluginContext} context */ | |
| constructor(context) { | |
| super(context, { name: 'access-control', deps: ['client', 'logger', 'xmleditor'] }) | |
| } | |
| /** @type {AccessControlModeResponse | null} */ | |
| #accessControlConfig = null | |
| /** @type {StatusText | null} */ | |
| #permissionInfoWidget = null | |
| /** @type {HTMLElement | null} */ | |
| #permissionsDropdown = null | |
| /** @type {StatusSwitch | null} */ | |
| #visibilitySwitch = null | |
| /** @type {StatusSwitch | null} */ | |
| #editabilitySwitch = null | |
| /** @type {DocumentPermissions} */ | |
| #currentPermissions = { | |
| visibility: 'collection', | |
| editability: 'owner', | |
| owner: null, | |
| can_modify: false | |
| } | |
| /** @type {string | null} */ | |
| #xmlCache = null | |
| /** | |
| * @param {ApplicationState} _state | |
| */ | |
| async install(_state) { | |
| await super.install(_state) | |
| const logger = this.getDependency('logger') | |
| logger.debug(`Installing plugin "access-control"`) | |
| this.#permissionInfoWidget = PanelUtils.createText({ | |
| text: '', | |
| variant: 'neutral' | |
| }) | |
| this.#visibilitySwitch = this.#createVisibilitySwitch() | |
| this.#editabilitySwitch = this.#createEditabilitySwitch() | |
| this.#permissionsDropdown = this.#createPermissionsDropdown() | |
| const xmlEditor = this.getDependency('xmleditor') | |
| xmlEditor.addStatusbarWidget(this.#permissionInfoWidget, 'left', 1) | |
| xmlEditor.addStatusbarWidget(this.#permissionsDropdown, 'left', 2) | |
| this.#hideAccessControlWidgets() | |
| } | |
| async start() { | |
| try { | |
| this.#accessControlConfig = await this.getDependency('client').apiClient.filesAccessControlMode() | |
| this.getDependency('logger').info(`Access control mode: ${this.#accessControlConfig.mode}`) | |
| } catch (error) { | |
| this.getDependency('logger').error(`Failed to fetch access control mode: ${error}`) | |
| this.#accessControlConfig = { | |
| mode: 'role-based', | |
| default_visibility: 'collection', | |
| default_editability: 'owner' | |
| } | |
| } | |
| } | |
| /** | |
| * @param {string[]} changedKeys | |
| */ | |
| async onStateUpdate(_changedKeys) { | |
| const state = this.state | |
| if (!state.xml) { | |
| this.#hideAccessControlWidgets() | |
| this.#xmlCache = null | |
| return | |
| } | |
| if (state.xml === this.#xmlCache) { | |
| // Update dropdown disabled state based on editorReadOnly | |
| if (this.#permissionsDropdown) { | |
| const triggerButton = this.#permissionsDropdown.querySelector('sl-icon-button') | |
| if (triggerButton) triggerButton.disabled = state.editorReadOnly | |
| } | |
| return | |
| } | |
| this.#xmlCache = state.xml | |
| this.getDependency('logger').debug(`Access control: Updating for document: ${state.xml}`) | |
| try { | |
| await this.#computeDocumentPermissions() | |
| this.#updateAccessControlUI() | |
| } catch (error) { | |
| this.getDependency('logger').error(`Access control error in onStateUpdate(): ${error}`) | |
| } | |
| const shouldBeReadOnly = !this.canEditDocument(state.user) | |
| if (shouldBeReadOnly && !state.editorReadOnly) { | |
| this.getDependency('logger').debug(`Setting editor read-only based on access control`) | |
| // scheduleStateChange() defers the dispatch until after the current propagation | |
| // cycle completes, which is required here because we're inside onStateUpdate and | |
| // the read-only decision depends on async permission data fetched above. | |
| this.scheduleStateChange({ editorReadOnly: true }).catch(err => | |
| this.getDependency('logger').error(`Failed to set editorReadOnly: ${err}`) | |
| ) | |
| } | |
| this.#updateReadOnlyContext(state.editorReadOnly, shouldBeReadOnly) | |
| } | |
| /** | |
| * Gets current document permissions | |
| * @returns {DocumentPermissions} | |
| */ | |
| getDocumentPermissions() { | |
| return this.#currentPermissions | |
| } | |
| /** | |
| * Checks if a user can edit the currently loaded document | |
| * @param {UserData|null} user | |
| * @returns {boolean} | |
| */ | |
| canEditDocument(user) { | |
| const mode = this.#accessControlConfig?.mode || 'role-based' | |
| const fileId = this.state?.xml || undefined | |
| if (userIsAdmin(user)) return true | |
| if (fileId) { | |
| if (isGoldFile(fileId) && !userHasReviewerRole(user)) return false | |
| if (isVersionFile(fileId) && !userHasAnnotatorRole(user) && !userHasReviewerRole(user)) return false | |
| } | |
| if (mode === 'role-based') return true | |
| if (mode === 'owner-based') return this.#currentPermissions.owner === user?.username | |
| if (mode === 'granular') return canEditDocumentWithPermissions(user, this.#currentPermissions, fileId) | |
| return true | |
| } | |
| /** | |
| * Checks if a user can view the currently loaded document | |
| * @param {UserData|null} user | |
| * @returns {boolean} | |
| */ | |
| canViewDocument(user) { | |
| return canViewDocumentWithPermissions(user, this.#currentPermissions) | |
| } | |
| /** | |
| * Gets the current access control mode | |
| * @returns {AccessControlMode} | |
| */ | |
| getMode() { | |
| return this.#accessControlConfig?.mode || 'role-based' | |
| } | |
| /** | |
| * Checks if the current user can edit the given file | |
| * @param {string} fileId | |
| * @returns {boolean} | |
| */ | |
| checkCanEditFile(fileId) { | |
| const currentUser = this.getDependency('authentication').getUser() | |
| return canEditFileFromUtils(currentUser, fileId) | |
| } | |
| /** @returns {HTMLElement} */ | |
| #createPermissionsDropdown() { | |
| const dropdown = document.createElement('sl-dropdown') | |
| dropdown.setAttribute('placement', 'top-start') | |
| dropdown.setAttribute('distance', '4') | |
| const triggerButton = document.createElement('sl-icon-button') | |
| triggerButton.setAttribute('slot', 'trigger') | |
| triggerButton.setAttribute('name', 'gear') | |
| triggerButton.setAttribute('label', 'Document Permissions') | |
| triggerButton.title = 'Document permission settings' | |
| triggerButton.style.fontSize = '1rem' | |
| const menu = document.createElement('sl-menu') | |
| menu.style.minWidth = '200px' | |
| menu.style.padding = '8px' | |
| const header = document.createElement('div') | |
| header.style.cssText = 'font-weight: 600; font-size: 0.75rem; color: var(--sl-color-neutral-500); padding: 4px 8px; text-transform: uppercase;' | |
| header.textContent = 'Permissions' | |
| const visibilityItem = document.createElement('sl-menu-item') | |
| visibilityItem.style.cssText = '--submenu-offset: 0;' | |
| visibilityItem.appendChild(this.#visibilitySwitch) | |
| const editabilityItem = document.createElement('sl-menu-item') | |
| editabilityItem.style.cssText = '--submenu-offset: 0;' | |
| editabilityItem.appendChild(this.#editabilitySwitch) | |
| menu.addEventListener('sl-select', (e) => { | |
| e.preventDefault() | |
| e.stopPropagation() | |
| }) | |
| menu.appendChild(header) | |
| menu.appendChild(visibilityItem) | |
| menu.appendChild(editabilityItem) | |
| dropdown.appendChild(triggerButton) | |
| dropdown.appendChild(menu) | |
| return dropdown | |
| } | |
| /** @returns {StatusSwitch} */ | |
| #createVisibilitySwitch() { | |
| const switchEl = PanelUtils.createSwitch({ | |
| name: 'visibilitySwitch', | |
| text: 'Visible', | |
| checked: true, | |
| size: 'small' | |
| }) | |
| switchEl.title = 'Checked = visible to all users with collection access, Unchecked = visible only to document owner' | |
| switchEl.addEventListener('widget-change', (e) => this.#handleVisibilityChange(e)) | |
| return switchEl | |
| } | |
| /** @returns {StatusSwitch} */ | |
| #createEditabilitySwitch() { | |
| const switchEl = PanelUtils.createSwitch({ | |
| name: 'editabilitySwitch', | |
| text: 'Editable', | |
| checked: false, | |
| size: 'small' | |
| }) | |
| switchEl.title = 'Checked = editable by all users with collection access, Unchecked = editable only by document owner' | |
| switchEl.addEventListener('widget-change', (e) => this.#handleEditabilityChange(e)) | |
| return switchEl | |
| } | |
| /** | |
| * @param {CustomEvent} event | |
| */ | |
| async #handleVisibilityChange(event) { | |
| const checked = event.detail.checked | |
| const newVisibility = checked ? 'collection' : 'owner' | |
| try { | |
| await this.#updateDocumentPermissions(newVisibility, this.#currentPermissions.editability) | |
| this.getDependency('logger').info(`Visibility updated to: ${newVisibility}`) | |
| } catch (error) { | |
| this.getDependency('logger').error(`Failed to update visibility: ${error}`) | |
| notify(`Failed to update visibility: ${String(error)}`, 'danger', 'exclamation-octagon') | |
| if (this.#visibilitySwitch) this.#visibilitySwitch.checked = this.#currentPermissions.visibility === 'collection' | |
| } | |
| } | |
| /** | |
| * @param {CustomEvent} event | |
| */ | |
| async #handleEditabilityChange(event) { | |
| const checked = event.detail.checked | |
| const newEditability = checked ? 'collection' : 'owner' | |
| try { | |
| await this.#updateDocumentPermissions(this.#currentPermissions.visibility, newEditability) | |
| this.getDependency('logger').info(`Editability updated to: ${newEditability}`) | |
| } catch (error) { | |
| this.getDependency('logger').error(`Failed to update editability: ${error}`) | |
| notify(`Failed to update editability: ${String(error)}`, 'danger', 'exclamation-octagon') | |
| if (this.#editabilitySwitch) this.#editabilitySwitch.checked = this.#currentPermissions.editability === 'collection' | |
| } | |
| } | |
| async #computeDocumentPermissions() { | |
| const mode = this.#accessControlConfig?.mode || 'role-based' | |
| const fileData = getFileDataById(this.state?.xml) | |
| const currentUser = this.getDependency('authentication').getUser() | |
| const owner = fileData?.item?.created_by || null | |
| if (mode === 'role-based') { | |
| this.#currentPermissions = { visibility: 'collection', editability: 'collection', owner, can_modify: false } | |
| } else if (mode === 'owner-based') { | |
| this.#currentPermissions = { visibility: 'collection', editability: 'owner', owner, can_modify: false } | |
| } else if (mode === 'granular') { | |
| if (!this.state?.xml) { | |
| this.#currentPermissions = { | |
| visibility: this.#accessControlConfig?.default_visibility || 'collection', | |
| editability: this.#accessControlConfig?.default_editability || 'owner', | |
| owner, | |
| can_modify: false | |
| } | |
| return | |
| } | |
| try { | |
| const perms = await this.getDependency('client').apiClient.filesPermissions(this.state.xml) | |
| const isOwner = perms.owner === currentUser?.username | |
| const isReviewer = userHasReviewerRole(currentUser) | |
| const isAdmin = userIsAdmin(currentUser) | |
| this.#currentPermissions = { | |
| visibility: perms.visibility, | |
| editability: perms.editability, | |
| owner: perms.owner, | |
| can_modify: isOwner || isReviewer || isAdmin | |
| } | |
| } catch (error) { | |
| this.getDependency('logger').debug(`Failed to fetch permissions (using defaults): ${error}`) | |
| this.#currentPermissions = { | |
| visibility: this.#accessControlConfig?.default_visibility || 'collection', | |
| editability: this.#accessControlConfig?.default_editability || 'owner', | |
| owner, | |
| can_modify: false | |
| } | |
| } | |
| } | |
| this.getDependency('logger').debug(`Document permissions: ${JSON.stringify(this.#currentPermissions)}`) | |
| } | |
| /** | |
| * @param {string} visibility | |
| * @param {string} editability | |
| */ | |
| async #updateDocumentPermissions(visibility, editability) { | |
| const mode = this.#accessControlConfig?.mode || 'role-based' | |
| if (mode !== 'granular') throw new Error('Permission modification only available in granular mode') | |
| if (!this.state?.xml) throw new Error('No document loaded') | |
| const fileData = getFileDataById(this.state?.xml) | |
| const response = await this.getDependency('client').apiClient.filesSetPermissions({ | |
| stable_id: this.state.xml, | |
| visibility, | |
| editability, | |
| owner: this.#currentPermissions.owner || fileData?.created_by || '' | |
| }) | |
| this.#currentPermissions.visibility = response.visibility | |
| this.#currentPermissions.editability = response.editability | |
| this.#currentPermissions.owner = response.owner | |
| this.#updateAccessControlUI() | |
| } | |
| #updateAccessControlUI() { | |
| const mode = this.#accessControlConfig?.mode || 'role-based' | |
| if (mode === 'granular' && this.#currentPermissions.can_modify) { | |
| this.#showPermissionSwitches() | |
| this.#updateSwitchStates() | |
| } else if (mode === 'granular' && !this.#currentPermissions.can_modify) { | |
| this.#showPermissionInfo() | |
| this.#updatePermissionInfoDisplay() | |
| } else if (mode === 'owner-based') { | |
| this.#hideAccessControlWidgets() | |
| this.#showOwnerBasedNotification() | |
| } else { | |
| this.#hideAccessControlWidgets() | |
| } | |
| } | |
| #showPermissionSwitches() { | |
| if (this.#permissionInfoWidget) this.#permissionInfoWidget.style.display = 'none' | |
| if (this.#permissionsDropdown) this.#permissionsDropdown.style.display = '' | |
| } | |
| #showPermissionInfo() { | |
| if (this.#permissionInfoWidget) this.#permissionInfoWidget.style.display = '' | |
| if (this.#permissionsDropdown) this.#permissionsDropdown.style.display = 'none' | |
| } | |
| #updateSwitchStates() { | |
| if (this.#visibilitySwitch) { | |
| this.#visibilitySwitch.checked = this.#currentPermissions.visibility === 'collection' | |
| this.#visibilitySwitch.textContent = this.#currentPermissions.visibility === 'collection' ? 'Visible to all' : 'Visible to owner' | |
| } | |
| if (this.#editabilitySwitch) { | |
| this.#editabilitySwitch.checked = this.#currentPermissions.editability === 'collection' | |
| this.#editabilitySwitch.textContent = this.#currentPermissions.editability === 'collection' ? 'Editable by all' : 'Editable by owner' | |
| } | |
| } | |
| #updatePermissionInfoDisplay() { | |
| if (!this.#permissionInfoWidget) return | |
| const { visibility, editability, owner } = this.#currentPermissions | |
| let infoText = '' | |
| let variant = 'neutral' | |
| if (visibility === 'owner') { | |
| infoText = owner ? `Owner-only (${owner})` : 'Owner-only' | |
| variant = 'warning' | |
| } else { | |
| infoText = 'Collection' | |
| } | |
| if (editability === 'owner') { | |
| infoText += ' • Owner edits' | |
| variant = 'warning' | |
| } | |
| this.#permissionInfoWidget.text = infoText | |
| this.#permissionInfoWidget.variant = variant | |
| } | |
| #showOwnerBasedNotification() { | |
| const currentUser = this.getDependency('authentication').getUser() | |
| const owner = this.#currentPermissions.owner | |
| const isOwner = owner && owner === currentUser?.username | |
| if (!isOwner) { | |
| if (owner) { | |
| notify( | |
| `This document is owned by ${owner}. Use "Save Revision → Save to a new personal copy" to work on your own copy.`, | |
| 'warning', 'exclamation-triangle' | |
| ) | |
| } else { | |
| notify( | |
| `This document has no owner and is read-only. Use "Save Revision → Save to a new personal copy" to create an editable copy.`, | |
| 'warning', 'exclamation-triangle' | |
| ) | |
| } | |
| } | |
| } | |
| #hideAccessControlWidgets() { | |
| if (this.#permissionInfoWidget) this.#permissionInfoWidget.style.display = 'none' | |
| if (this.#permissionsDropdown) this.#permissionsDropdown.style.display = 'none' | |
| } | |
| /** | |
| * @param {boolean} editorReadOnly | |
| * @param {boolean} accessControlReadOnly | |
| */ | |
| #updateReadOnlyContext(editorReadOnly, accessControlReadOnly) { | |
| if (!editorReadOnly || !accessControlReadOnly) return | |
| const mode = this.#accessControlConfig?.mode || 'role-based' | |
| const { owner } = this.#currentPermissions | |
| const currentUser = this.getDependency('authentication').getUser() | |
| let contextText = 'Read-only' | |
| if (this.state?.xml) { | |
| if (isGoldFile(this.state.xml) && !userHasReviewerRole(currentUser)) { | |
| contextText = 'Read-only (gold file - reviewer role required)' | |
| } else if (isVersionFile(this.state.xml) && !userHasAnnotatorRole(currentUser) && !userHasReviewerRole(currentUser)) { | |
| contextText = 'Read-only (version file - annotator role required)' | |
| } else if (mode === 'owner-based' && owner && owner !== currentUser?.username) { | |
| contextText = `Read-only (owned by ${owner}) — save to a personal copy to edit` | |
| } else if (mode === 'granular' && this.#currentPermissions.editability === 'owner' && owner !== currentUser?.username) { | |
| contextText = `Read-only (owned by ${owner})` | |
| } | |
| } | |
| this.getDependency('xmleditor').setReadOnlyContext(contextText) | |
| this.getDependency('logger').debug(`Updated read-only context: ${contextText}`) | |
| } | |
| } | |
| export default AccessControlPlugin | |
| export const plugin = AccessControlPlugin | |