pdf-tei-editor / app /src /plugins /access-control.js
cmboulanger's picture
disaster-recovery deploy of pdf-tei-editor
6a49f21 verified
Raw
History Blame Contribute Delete
18.6 kB
/**
* 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