michielbdejong/solid-ui

View on GitHub
src/acl/access-groups.ts

Summary

Maintainability
C
1 day
Test Coverage
import { IndexedFormula, NamedNode, sym } from 'rdflib'
import { ACLbyCombination, readACL } from './acl'
import widgets from '../widgets'
import ns from '../ns'
import { AccessController } from './access-controller'
import { AgentMapMap, ComboList, PartialAgentTriple } from './types'
import { AddAgentButtons } from './add-agent-buttons'

const ACL = ns.acl

const COLLOQUIAL = {
  13: 'Owners',
  9: 'Owners (write locked)',
  5: 'Editors',
  3: 'Posters',
  2: 'Submitters',
  1: 'Viewers'
}

const RECOMMENDED = {
  13: true,
  5: true,
  3: true,
  2: true,
  1: true
}

const EXPLANATION = {
  13: 'can read, write, and control sharing.',
  9: 'can read and control sharing, currently write-locked.',
  5: 'can read and change information',
  3: 'can add new information, and read but not change existing information',
  2: 'can add new information but not read any',
  1: 'can read but not change information'
}

interface AccessGroupsOptions {
  defaults?: boolean
}

export class AccessGroups {
  private readonly defaults: boolean
  public byCombo: ComboList
  public aclMap: AgentMapMap
  private readonly addAgentButton: AddAgentButtons
  private readonly rootElement: HTMLElement
  private _store: IndexedFormula

  constructor (
    private doc: NamedNode,
    private aclDoc: NamedNode,
    public controller: AccessController,
    store: IndexedFormula,
    private options: AccessGroupsOptions = {}
  ) {
    this.defaults = options.defaults || false
    this._store = store
    this.aclMap = readACL(doc, aclDoc, store, this.defaults)
    this.byCombo = ACLbyCombination(this.aclMap)
    this.addAgentButton = new AddAgentButtons(this)
    this.rootElement = this.controller.dom.createElement('div')
    this.rootElement.classList.add(this.controller.classes.accessGroupList)
  }

  public get store () {
    return this._store
  }

  public set store (store) {
    this._store = store
    this.aclMap = readACL(this.doc, this.aclDoc, store, this.defaults)
    this.byCombo = ACLbyCombination(this.aclMap)
  }

  public render (): HTMLElement {
    this.rootElement.innerHTML = ''
    this.renderGroups().forEach(group => this.rootElement.appendChild(group))
    if (this.controller.isEditable) {
      this.rootElement.appendChild(this.addAgentButton.render())
    }
    return this.rootElement
  }

  private renderGroups (): HTMLElement[] {
    const groupElements: HTMLElement[] = []
    for (let comboIndex = 15; comboIndex > 0; comboIndex--) {
      const combo = kToCombo(comboIndex)
      if ((this.controller.isEditable && RECOMMENDED[comboIndex]) || this.byCombo[combo]) {
        groupElements.push(this.renderGroup(comboIndex, combo))
      }
    }
    return groupElements
  }

  private renderGroup (comboIndex: number, combo: string): HTMLElement {
    const groupRow = this.controller.dom.createElement('div')
    groupRow.classList.add(this.controller.classes.accessGroupListItem)
    widgets.makeDropTarget(groupRow, (uris) => this.handleDroppedUris(uris, combo)
      .then(() => this.controller.render())
      .catch(error => this.controller.renderStatus(error)))
    const groupColumns = this.renderGroupElements(comboIndex, combo)
    groupColumns.forEach(column => groupRow.appendChild(column))
    return groupRow
  }

  private renderGroupElements (comboIndex, combo): HTMLElement[] {
    const groupNameColumn = this.controller.dom.createElement('div')
    groupNameColumn.classList.add(this.controller.classes.group)
    groupNameColumn.classList.toggle(this.controller.classes[`group-${comboIndex}`], this.controller.isEditable)
    groupNameColumn.innerText = COLLOQUIAL[comboIndex] || ktToList(comboIndex)

    const groupAgentsColumn = this.controller.dom.createElement('div')
    groupAgentsColumn.classList.add(this.controller.classes.group)
    groupAgentsColumn.classList.toggle(this.controller.classes[`group-${comboIndex}`], this.controller.isEditable)
    const groupAgentsTable = groupAgentsColumn.appendChild(this.controller.dom.createElement('table'))
    const combos = this.byCombo[combo] || []
    combos
      .map(([pred, obj]) => this.renderAgent(groupAgentsTable, combo, pred, obj))
      .forEach(agentElement => groupAgentsTable.appendChild(agentElement))

    const groupDescriptionElement = this.controller.dom.createElement('div')
    groupDescriptionElement.classList.add(this.controller.classes.group)
    groupDescriptionElement.classList.toggle(this.controller.classes[`group-${comboIndex}`], this.controller.isEditable)
    groupDescriptionElement.innerText = EXPLANATION[comboIndex] || 'Unusual combination'

    return [groupNameColumn, groupAgentsColumn, groupDescriptionElement]
  }

  private renderAgent (groupAgentsTable, combo, pred, obj): HTMLElement {
    const personRow = widgets.personTR(this.controller.dom, ACL(pred), sym(obj), this.controller.isEditable ? {
      deleteFunction: () => this.deleteAgent(combo, pred, obj)
        .then(() => groupAgentsTable.removeChild(personRow))
        .catch(error => this.controller.renderStatus(error))
    } : {})
    return personRow
  }

  private async deleteAgent (combo, pred, obj): Promise<void> {
    const combos = this.byCombo[combo] || []
    const comboToRemove = combos.find(([comboPred, comboObj]) => comboPred === pred && comboObj === obj)
    if (comboToRemove) {
      combos.splice(combos.indexOf(comboToRemove), 1)
    }
    await this.controller.save()
  }

  public async addNewURI (uri: string): Promise<void> {
    await this.handleDroppedUri(uri, kToCombo(1))
    await this.controller.save()
  }

  private async handleDroppedUris (uris: string[], combo: string): Promise<void> {
    try {
      await Promise.all(uris.map(uri => this.handleDroppedUri(uri, combo)))
      await this.controller.save()
    } catch (error) {
      return Promise.reject(error)
    }
  }

  private async handleDroppedUri (uri: string, combo: string, secondAttempt: boolean = false): Promise<void> {
    const agent = findAgent(uri, this.store) // eg 'agent', 'origin', agentClass'
    const thing = sym(uri)
    if (!agent && !secondAttempt) {
      console.log(`   Not obvious: looking up dropped thing ${thing}`)
      try {
        await (this.store as any).fetcher.load(thing.doc())
      } catch (error) {
        const message = `Ignore error looking up dropped thing: ${error}`
        console.error(message)
        return Promise.reject(new Error(message))
      }
      return this.handleDroppedUri(uri, combo, true)
    } else if (!agent) {
      const error = `   Error: Drop fails to drop appropriate thing! ${uri}`
      console.error(error)
      return Promise.reject(new Error(error))
    }
    this.setACLCombo(combo, uri, agent, this.controller.subject)
  }

  private setACLCombo (combo: string, uri: string, res: PartialAgentTriple, subject: NamedNode): void {
    if (!(combo in this.byCombo)) {
      this.byCombo[combo] = []
    }
    this.removeAgentFromCombos(uri) // Combos are mutually distinct
    this.byCombo[combo].push([res.pred, res.obj.uri])
    console.log(`ACL: setting access to ${subject} by ${res.pred}: ${res.obj}`)
  }

  private removeAgentFromCombos (uri: string): void {
    for (let k = 0; k < 16; k++) {
      const combos = this.byCombo[kToCombo(k)]
      if (combos) {
        for (let i = 0; i < combos.length; i++) {
          while (i < combos.length && combos[i][1] === uri) {
            combos.splice(i, 1)
          }
        }
      }
    }
  }
}

function kToCombo (k: number): string {
  const y = ['Read', 'Append', 'Write', 'Control']
  const combo: string[] = []
  for (let i = 0; i < 4; i++) {
    if (k & (1 << i)) {
      combo.push('http://www.w3.org/ns/auth/acl#' + y[i])
    }
  }
  combo.sort()
  return combo.join('\n')
}

function ktToList (k: number): string {
  let list = ''
  const y = ['Read', 'Append', 'Write', 'Control']
  for (let i = 0; i < 4; i++) {
    if (k & (1 << i)) {
      list += y[i]
    }
  }
  return list
}

function findAgent (uri, kb): PartialAgentTriple | null {
  const obj = sym(uri)
  const types = kb.findTypeURIs(obj)
  for (const ty in types) {
    console.log('    drop object type includes: ' + ty)
  }
  // An Origin URI is one like https://fred.github.io eith no trailing slash
  if (uri.startsWith('http') && uri.split('/').length === 3) {
    // there is no third slash
    return { pred: 'origin', obj: obj } // The only way to know an origin alas
  }
  // @@ This is an almighty kludge needed because drag and drop adds extra slashes to origins
  if (
    uri.startsWith('http') &&
    uri.split('/').length === 4 &&
    uri.endsWith('/')
  ) {
    // there  IS third slash
    console.log('Assuming final slash on dragged origin URI was unintended!')
    return { pred: 'origin', obj: sym(uri.slice(0, -1)) } // Fix a URI where the drag and drop system has added a spurious slash
  }

  if (ns.vcard('WebID').uri in types) return { pred: 'agent', obj: obj }

  if (ns.vcard('Group').uri in types) {
    return { pred: 'agentGroup', obj: obj } // @@ note vcard membership not RDFs
  }
  if (
    obj.sameTerm(ns.foaf('Agent')) ||
    obj.sameTerm(ns.acl('AuthenticatedAgent')) || // AuthenticatedAgent
    obj.sameTerm(ns.rdf('Resource')) ||
    obj.sameTerm(ns.owl('Thing'))
  ) {
    return { pred: 'agentClass', obj: obj }
  }
  if (
    ns.vcard('Individual').uri in types ||
    ns.foaf('Person').uri in types ||
    ns.foaf('Agent').uri in types
  ) {
    const pref = kb.any(obj, ns.foaf('preferredURI'))
    if (pref) return { pred: 'agent', obj: sym(pref) }
    return { pred: 'agent', obj: obj }
  }
  if (ns.solid('AppProvider').uri in types) {
    return { pred: 'origin', obj: obj }
  }
  if (ns.solid('AppProviderClass').uri in types) {
    return { pred: 'originClass', obj: obj }
  }
  console.log('    Triage fails for ' + uri)
  return null
}