diff --git a/.babelrc b/.babelrc index 9fed85d05..c2ac03f6c 100644 --- a/.babelrc +++ b/.babelrc @@ -4,6 +4,7 @@ "@babel/preset-typescript" ], "plugins": [ + "@babel/plugin-proposal-class-properties", "@babel/plugin-transform-async-to-generator", "@babel/plugin-transform-runtime" ] diff --git a/package-lock.json b/package-lock.json index c96d62ab0..450221556 100644 --- a/package-lock.json +++ b/package-lock.json @@ -344,6 +344,16 @@ "@babel/plugin-syntax-async-generators": "^7.7.4" } }, + "@babel/plugin-proposal-class-properties": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.7.4.tgz", + "integrity": "sha512-EcuXeV4Hv1X3+Q1TsuOmyyxeTRiSqurGJ26+I/FW1WbymmRRapVORm6x1Zl3iDIHyRxEs+VXWp6qnlcfcJSbbw==", + "dev": true, + "requires": { + "@babel/helper-create-class-features-plugin": "^7.7.4", + "@babel/helper-plugin-utils": "^7.0.0" + } + }, "@babel/plugin-proposal-dynamic-import": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.7.4.tgz", @@ -1847,6 +1857,20 @@ "which": "^1.2.9" } }, + "css-vendor": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/css-vendor/-/css-vendor-2.0.7.tgz", + "integrity": "sha512-VS9Rjt79+p7M0WkPqcAza4Yq1ZHrsHrwf7hPL/bjQB+c1lwmAI+1FXxYTYt818D/50fFVflw0XKleiBN5RITkg==", + "requires": { + "@babel/runtime": "^7.6.2", + "is-in-browser": "^1.0.2" + } + }, + "csstype": { + "version": "2.6.8", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.8.tgz", + "integrity": "sha512-msVS9qTuMT5zwAGCVm4mxfrZ18BNc6Csd0oJAtiFMZ1FAx1CCvy2+5MDmYoix63LM/6NDbNtodCiGYGmFgO0dA==" + }, "dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", @@ -3588,6 +3612,11 @@ } } }, + "hyphenate-style-name": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.3.tgz", + "integrity": "sha512-EcuixamT82oplpoJ2XU4pDtKGWQ7b00CD9f1ug9IaQ3p1bkHMiKCZ9ut9QDI6qsa6cpUuB+A/I+zLtdNK4n2DQ==" + }, "iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -3803,6 +3832,11 @@ "is-extglob": "^2.1.1" } }, + "is-in-browser": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz", + "integrity": "sha1-Vv9NtoOgeMYILrldrX3GLh0E+DU=" + }, "is-number": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", @@ -4054,6 +4088,153 @@ "verror": "1.10.0" } }, + "jss": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/jss/-/jss-10.0.3.tgz", + "integrity": "sha512-AcDvFdOk16If9qvC9KN3oFXsrkHWM9+TaPMpVB9orm3z+nq1Xw3ofHyflRe/mkSucRZnaQtlhZs1hdP3DR9uRw==", + "requires": { + "@babel/runtime": "^7.3.1", + "csstype": "^2.6.5", + "is-in-browser": "^1.1.3", + "tiny-warning": "^1.0.2" + } + }, + "jss-plugin-camel-case": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/jss-plugin-camel-case/-/jss-plugin-camel-case-10.0.3.tgz", + "integrity": "sha512-rild/oFKFkmRP7AoiX9D6bdDAUfmJv8c7sEBvFoi+JP31dn2W8nw4txMKGnV1LJKlFkYprdZt1X99Uvztl1hug==", + "requires": { + "@babel/runtime": "^7.3.1", + "hyphenate-style-name": "^1.0.3", + "jss": "^10.0.3" + } + }, + "jss-plugin-compose": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/jss-plugin-compose/-/jss-plugin-compose-10.0.3.tgz", + "integrity": "sha512-S3mwXjWGlW8EynxGvEqEa/O3S4FOv1rcSs5/z6HjbgR85iBx2xZ0NlJBjyfqyR3uqsv2eCXkiwyE6LvC4+3YEg==", + "requires": { + "@babel/runtime": "^7.3.1", + "jss": "^10.0.3", + "tiny-warning": "^1.0.2" + } + }, + "jss-plugin-default-unit": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/jss-plugin-default-unit/-/jss-plugin-default-unit-10.0.3.tgz", + "integrity": "sha512-n+XfVLPF9Qh7IOTdQ8M4oRpjpg6egjr/r0NNytubbCafMgCILJYIVrMTGgOTydH+uvak8onQY3f/F9hasPUx6g==", + "requires": { + "@babel/runtime": "^7.3.1", + "jss": "^10.0.3" + } + }, + "jss-plugin-expand": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/jss-plugin-expand/-/jss-plugin-expand-10.0.3.tgz", + "integrity": "sha512-FpkgQs+jOgBthRRDUIyCJoFM7XQei4l+Nuf9DZbSgZkYju8lA0tPBKLGfO1G8qY3jQM3SwbnL1QFl9QMI9rvog==", + "requires": { + "@babel/runtime": "^7.3.1", + "jss": "^10.0.3" + } + }, + "jss-plugin-extend": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/jss-plugin-extend/-/jss-plugin-extend-10.0.3.tgz", + "integrity": "sha512-pLVyOutTGpDDq865ICFUvWpXeMqqva78RjwM3ecFMRcrpt51EsgHs8+2mJtPHfx66GRxXBImqIWQ/VyDgJcyFA==", + "requires": { + "@babel/runtime": "^7.3.1", + "jss": "^10.0.3", + "tiny-warning": "^1.0.2" + } + }, + "jss-plugin-global": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/jss-plugin-global/-/jss-plugin-global-10.0.3.tgz", + "integrity": "sha512-kNotkAciJIXpIGYnmueaIifBne9rdq31O8Xq1nF7KMfKlskNRANTcEX5rVnsGKl2yubTMYfjKBFCeDgcQn6+gA==", + "requires": { + "@babel/runtime": "^7.3.1", + "jss": "^10.0.3" + } + }, + "jss-plugin-nested": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/jss-plugin-nested/-/jss-plugin-nested-10.0.3.tgz", + "integrity": "sha512-OMucRs9YLvWlZ3Ew+VhdgNVMwSS2zZy/2vy+s/etvopnPUzDHgCnJwdY2Wx/SlhLGERJeKKufyih2seH+ui0iw==", + "requires": { + "@babel/runtime": "^7.3.1", + "jss": "^10.0.3", + "tiny-warning": "^1.0.2" + } + }, + "jss-plugin-props-sort": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/jss-plugin-props-sort/-/jss-plugin-props-sort-10.0.3.tgz", + "integrity": "sha512-ufhvdCMnRcDa0tNHoZ12OcVNQQyE10yLMohxo/UIMarLV245rM6n9D19A12epjldRgyiS13SoSyLFCJEobprYg==", + "requires": { + "@babel/runtime": "^7.3.1", + "jss": "^10.0.3" + } + }, + "jss-plugin-rule-value-function": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/jss-plugin-rule-value-function/-/jss-plugin-rule-value-function-10.0.3.tgz", + "integrity": "sha512-RWwIT2UBAIwf3f6DQtt5gyjxHMRJoeO9TQku+ueR8dBMakqSSe8vFwQNfjXEoe0W+Tez5HZCTkZKNMulv3Z+9A==", + "requires": { + "@babel/runtime": "^7.3.1", + "jss": "^10.0.3" + } + }, + "jss-plugin-rule-value-observable": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/jss-plugin-rule-value-observable/-/jss-plugin-rule-value-observable-10.0.3.tgz", + "integrity": "sha512-xtB7+HMfCP8QqeSu/hcaGD1oAj6lb6d5tYw4GUp2D3z+Nwy2me1FHBTJTqt9kPQStoOzSflxKD7aqyYZ8VTKtQ==", + "requires": { + "@babel/runtime": "^7.3.1", + "jss": "^10.0.3", + "symbol-observable": "^1.2.0" + } + }, + "jss-plugin-template": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/jss-plugin-template/-/jss-plugin-template-10.0.3.tgz", + "integrity": "sha512-O+u+mO0jlcGqIknKZ6TJadQFH6yWrB9eoif6et0tuzo7zUlqRI2M3ANKrytqV1iWDHSk5K0NmbhaiPv0NWXFNQ==", + "requires": { + "@babel/runtime": "^7.3.1", + "jss": "^10.0.3", + "tiny-warning": "^1.0.2" + } + }, + "jss-plugin-vendor-prefixer": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/jss-plugin-vendor-prefixer/-/jss-plugin-vendor-prefixer-10.0.3.tgz", + "integrity": "sha512-zVs6e5z4tFRK/fJ5kuTLzXlTFQbLeFTVwk7lTZiYNufmZwKT0kSmnOJDUukcSe7JLGSRztjWhnHB/6voP174gw==", + "requires": { + "@babel/runtime": "^7.3.1", + "css-vendor": "^2.0.7", + "jss": "^10.0.3" + } + }, + "jss-preset-default": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/jss-preset-default/-/jss-preset-default-10.0.3.tgz", + "integrity": "sha512-xGTs5r1jstNNzj+gPkgnwxQWTQTrTKiLKPgXYkAtHrzn/oiQSKbZNq8l8k5obNscZa3vOnDAJQn8sXiaUolCCw==", + "requires": { + "@babel/runtime": "^7.3.1", + "jss": "^10.0.3", + "jss-plugin-camel-case": "^10.0.3", + "jss-plugin-compose": "^10.0.3", + "jss-plugin-default-unit": "^10.0.3", + "jss-plugin-expand": "^10.0.3", + "jss-plugin-extend": "^10.0.3", + "jss-plugin-global": "^10.0.3", + "jss-plugin-nested": "^10.0.3", + "jss-plugin-props-sort": "^10.0.3", + "jss-plugin-rule-value-function": "^10.0.3", + "jss-plugin-rule-value-observable": "^10.0.3", + "jss-plugin-template": "^10.0.3", + "jss-plugin-vendor-prefixer": "^10.0.3" + } + }, "jsx-ast-utils": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-2.2.3.tgz", @@ -4743,7 +4924,7 @@ }, "mkdirp": { "version": "0.5.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "resolved": "http://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", "dev": true, "requires": { @@ -5158,7 +5339,7 @@ }, "path-is-absolute": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "resolved": "http://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", "dev": true }, @@ -5787,7 +5968,7 @@ }, "safe-regex": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", + "resolved": "http://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", "integrity": "sha1-QKNmnzsHfR6UPURinhV91IAjvy4=", "dev": true, "optional": true, @@ -6534,8 +6715,7 @@ "symbol-observable": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz", - "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==", - "dev": true + "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==" }, "table": { "version": "5.4.6", @@ -6576,7 +6756,7 @@ }, "text-encoding": { "version": "0.6.4", - "resolved": "https://registry.npmjs.org/text-encoding/-/text-encoding-0.6.4.tgz", + "resolved": "http://registry.npmjs.org/text-encoding/-/text-encoding-0.6.4.tgz", "integrity": "sha1-45mpgiV6J22uQou5KEXLcb3CbRk=" }, "text-table": { @@ -6587,10 +6767,15 @@ }, "through": { "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "resolved": "http://registry.npmjs.org/through/-/through-2.3.8.tgz", "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", "dev": true }, + "tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" + }, "tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", diff --git a/package.json b/package.json index 51298f3cf..a84207534 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,8 @@ "dependencies": { "@babel/runtime": "^7.6.2", "escape-html": "^1.0.3", + "jss": "^10.0.3", + "jss-preset-default": "^10.0.3", "mime-types": "^2.1.24", "node-uuid": "^1.4.7", "pane-registry": "^2.0.0", @@ -59,6 +61,7 @@ "devDependencies": { "@babel/cli": "^7.6.2", "@babel/core": "^7.6.2", + "@babel/plugin-proposal-class-properties": "^7.7.4", "@babel/plugin-transform-async-to-generator": "^7.5.0", "@babel/plugin-transform-runtime": "^7.6.2", "@babel/preset-env": "^7.6.2", diff --git a/src/acl/access-controller.ts b/src/acl/access-controller.ts new file mode 100644 index 000000000..745ee8614 --- /dev/null +++ b/src/acl/access-controller.ts @@ -0,0 +1,242 @@ +import { adoptACLDefault, getProspectiveHolder, makeACLGraphbyCombo, sameACL } from './acl' +import { graph, NamedNode, UpdateManager } from 'rdflib' +import { AccessGroups } from './access-groups' +import { DataBrowserContext } from 'pane-registry' +import { shortNameForFolder } from './acl-control' +import utils from '../utils.js' + +export class AccessController { + public mainCombo: AccessGroups + public defaultsCombo: AccessGroups | null + private readonly isContainer: boolean + private defaultsDiffer: boolean + private readonly rootElement: HTMLElement + private isUsingDefaults: boolean + + constructor ( + public subject: NamedNode, + public noun: string, + public context: DataBrowserContext, + private statusElement: HTMLElement, + public classes: Record, + public targetIsProtected: boolean, + private targetDoc: NamedNode, + private targetACLDoc: NamedNode, + private defaultHolder: NamedNode | null, + private defaultACLDoc: NamedNode | null, + private prospectiveDefaultHolder: NamedNode | undefined, + public store, + public dom + ) { + this.rootElement = dom.createElement('div') + this.rootElement.classList.add(classes.aclGroupContent) + this.isContainer = targetDoc.uri.slice(-1) === '/' // Give default for all directories + if (defaultHolder && defaultACLDoc) { + this.isUsingDefaults = true + const aclDefaultStore = adoptACLDefault(this.targetDoc, targetACLDoc, defaultHolder, defaultACLDoc) + this.mainCombo = new AccessGroups(targetDoc, targetACLDoc, this, aclDefaultStore, { defaults: true }) + this.defaultsCombo = null + this.defaultsDiffer = false + } else { + this.isUsingDefaults = false + this.mainCombo = new AccessGroups(targetDoc, targetACLDoc, this, store) + this.defaultsCombo = new AccessGroups(targetDoc, targetACLDoc, this, store, { defaults: true }) + this.defaultsDiffer = !sameACL(this.mainCombo.aclMap, this.defaultsCombo.aclMap) + } + } + + public get isEditable (): boolean { + return !this.isUsingDefaults + } + + public render (): HTMLElement { + this.rootElement.innerHTML = '' + if (this.isUsingDefaults) { + this.renderStatus(`The sharing for this ${this.noun} is the default for folder `) + if (this.defaultHolder) { + const defaultHolderLink = this.statusElement.appendChild(this.dom.createElement('a')) + defaultHolderLink.href = this.defaultHolder.uri + defaultHolderLink.innerText = shortNameForFolder(this.defaultHolder) + } + } else if (!this.defaultsDiffer) { + this.renderStatus('This is also the default for things in this folder.') + } else { + this.renderStatus('') + } + this.rootElement.appendChild(this.mainCombo.render()) + if (this.defaultsCombo && this.defaultsDiffer) { + this.rootElement.appendChild(this.renderRemoveDefaultsController()) + this.rootElement.appendChild(this.defaultsCombo.render()) + } else if (this.isEditable) { + this.rootElement.appendChild(this.renderAddDefaultsController()) + } + if (!this.targetIsProtected && this.isUsingDefaults) { + this.rootElement.appendChild(this.renderAddAclsController()) + } else if (!this.targetIsProtected) { + this.rootElement.appendChild(this.renderRemoveAclsController()) + } + return this.rootElement + } + + private renderRemoveAclsController (): HTMLElement { + const useDefaultButton = this.dom.createElement('button') + useDefaultButton.innerText = `Remove custom sharing settings for this ${this.noun} -- just use default${this.prospectiveDefaultHolder ? ` for ${utils.label(this.prospectiveDefaultHolder)}` : ''}` + useDefaultButton.classList.add(this.classes.bigButton) + useDefaultButton.addEventListener('click', () => this.removeAcls() + .then(() => this.render()) + .catch(error => this.renderStatus(error))) + return useDefaultButton + } + + private renderAddAclsController (): HTMLElement { + const addAclButton = this.dom.createElement('button') + addAclButton.innerText = `Set specific sharing for this ${this.noun}` + addAclButton.classList.add(this.classes.bigButton) + addAclButton.addEventListener('click', () => this.addAcls() + .then(() => this.render()) + .catch(error => this.renderStatus(error))) + return addAclButton + } + + private renderAddDefaultsController (): HTMLElement { + const containerElement = this.dom.createElement('div') + containerElement.classList.add(this.classes.defaultsController) + + const noticeElement = containerElement.appendChild(this.dom.createElement('div')) + noticeElement.innerText = 'Sharing for things within the folder currently tracks sharing for the folder.' + noticeElement.classList.add(this.classes.defaultsControllerNotice) + + const button = containerElement.appendChild(this.dom.createElement('button')) + button.innerText = 'Set the sharing of folder contents separately from the sharing for the folder' + button.classList.add(this.classes.bigButton) + button.addEventListener('click', () => this.addDefaults() + .then(() => this.render())) + return containerElement + } + + private renderRemoveDefaultsController (): HTMLElement { + const containerElement = this.dom.createElement('div') + containerElement.classList.add(this.classes.defaultsController) + + const noticeElement = containerElement.appendChild(this.dom.createElement('div')) + noticeElement.innerText = 'Access to things within this folder:' + noticeElement.classList.add(this.classes.defaultsControllerNotice) + + const button = containerElement.appendChild(this.dom.createElement('button')) + button.innerText = 'Set default for folder contents to just track the sharing for the folder' + button.classList.add(this.classes.bigButton) + button.addEventListener('click', () => this.removeDefaults() + .then(() => this.render()) + .catch(error => this.renderStatus(error))) + return containerElement + } + + public renderTemporaryStatus (message: string): void { + // @@ TODO Introduce better system for error notification to user https://github.com/solid/mashlib/issues/87 + this.statusElement.classList.add(this.classes.aclControlBoxStatusRevealed) + this.statusElement.innerText = message + this.statusElement.classList.add(this.classes.temporaryStatusInit) + setTimeout(() => { + this.statusElement.classList.add(this.classes.temporaryStatusEnd) + }) + setTimeout(() => { + this.statusElement.innerText = '' + }, 5000) + } + + public renderStatus (message: string): void { + // @@ TODO Introduce better system for error notification to user https://github.com/solid/mashlib/issues/87 + this.statusElement.classList.toggle(this.classes.aclControlBoxStatusRevealed, !!message) + this.statusElement.innerText = message + } + + private async addAcls (): Promise { + if (!this.defaultHolder || !this.defaultACLDoc) { + const message = 'Unable to find defaults to copy' + console.error(message) + return Promise.reject(message) + } + const aclGraph = adoptACLDefault(this.targetDoc, this.targetACLDoc, this.defaultHolder, this.defaultACLDoc) + aclGraph.statements.forEach(st => this.store.add(st.subject, st.predicate, st.object, this.targetACLDoc)) + try { + await this.store.fetcher.putBack(this.targetACLDoc) + this.isUsingDefaults = false + return Promise.resolve() + } catch (error) { + const message = ` Error writing back access control file! ${error}` + console.error(message) + return Promise.reject(message) + } + } + + private async addDefaults (): Promise { + this.defaultsCombo = new AccessGroups(this.targetDoc, this.targetACLDoc, this, this.store, { defaults: true }) + this.defaultsDiffer = true + } + + private async removeAcls (): Promise { + try { + await this.store.fetcher.delete(this.targetACLDoc.uri, {}) + this.isUsingDefaults = true + try { + this.prospectiveDefaultHolder = await getProspectiveHolder(this.targetDoc.uri) + } catch (error) { + // No need to show this error in status, but good to warn about it in console + console.warn(error) + } + } catch (error) { + const message = `Error deleting access control file: ${this.targetACLDoc}: ${error}` + console.error(message) + return Promise.reject(message) + } + } + + private async removeDefaults (): Promise { + const fallbackCombo = this.defaultsCombo + try { + this.defaultsCombo = null + this.defaultsDiffer = false + await this.save() + } catch (error) { + this.defaultsCombo = fallbackCombo + this.defaultsDiffer = true + console.error(error) + return Promise.reject(error) + } + } + + public save (): Promise { + const newAClGraph = graph() + if (!this.isContainer) { + makeACLGraphbyCombo(newAClGraph, this.targetDoc, this.mainCombo.byCombo, this.targetACLDoc, true) + } else if (this.defaultsCombo && this.defaultsDiffer) { + // Pair of controls + makeACLGraphbyCombo(newAClGraph, this.targetDoc, this.mainCombo.byCombo, this.targetACLDoc, true) + makeACLGraphbyCombo(newAClGraph, this.targetDoc, this.defaultsCombo.byCombo, this.targetACLDoc, false, true) + } else { + // Linked controls + makeACLGraphbyCombo(newAClGraph, this.targetDoc, this.mainCombo.byCombo, this.targetACLDoc, true, true) + } + const updater = newAClGraph.updater || new UpdateManager(newAClGraph) + return new Promise((resolve, reject) => updater.put( + this.targetACLDoc, + newAClGraph.statementsMatching(undefined, undefined, undefined, this.targetACLDoc), + 'text/turtle', + (uri, ok, message) => { + if (!ok) { + return reject(new Error(`ACL file save failed: ${message}`)) + } + this.store.fetcher.unload(this.targetACLDoc) + this.store.add(newAClGraph.statements) + this.store.fetcher.requested[this.targetACLDoc.uri] = 'done' // missing: save headers + this.mainCombo.store = this.store + if (this.defaultsCombo) { + this.defaultsCombo.store = this.store + } + this.defaultsDiffer = !!this.defaultsCombo && !sameACL(this.mainCombo.aclMap, this.defaultsCombo.aclMap) + console.log('ACL modification: success!') + resolve() + } + )) + } +} diff --git a/src/acl/access-groups.ts b/src/acl/access-groups.ts new file mode 100644 index 000000000..0931905af --- /dev/null +++ b/src/acl/access-groups.ts @@ -0,0 +1,280 @@ +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 { + 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 { + await this.handleDroppedUri(uri, kToCombo(1)) + await this.controller.save() + } + + private async handleDroppedUris (uris: string[], combo: string): Promise { + 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 { + 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 +} diff --git a/src/acl/acl-control.ts b/src/acl/acl-control.ts index a6c10a62b..c81369ebc 100644 --- a/src/acl/acl-control.ts +++ b/src/acl/acl-control.ts @@ -6,12 +6,12 @@ import ns from '../ns' import utils from '../utils.js' -import { ACLbyCombination, adoptACLDefault, getACLorDefault, makeACLGraphbyCombo, readACL, sameACL } from './acl' -import widgets from '../widgets' -import icons from '../iconBase.js' -import { graph, IndexedFormula, NamedNode, sym, UpdateManager } from 'rdflib' +import { getACLorDefault, getProspectiveHolder } from './acl' +import { IndexedFormula, NamedNode } from 'rdflib' import { DataBrowserContext } from 'pane-registry' -import { logInLoadProfile } from '../authn/authn' +import { AccessController } from './access-controller' +import { getClasses } from '../jss' +import { styles } from './styles' // In apps which may use drag and drop, this utility takes care of the fact // by default in a browser, an uncuaght user drop into a browser window @@ -70,886 +70,86 @@ export function ACLControlBox5 ( kb: IndexedFormula ): HTMLElement { const dom = context.dom - const ACL = ns.acl const doc = subject.doc() // The ACL is actually to the doc describing the thing + const classes = getClasses(dom.head, styles).classes - const table = dom.createElement('table') - table.setAttribute('style', 'margin: 1em; border: 0.1em #ccc ;') - const headerRow = table.appendChild(dom.createElement('tr')) - headerRow.textContent = `Sharing for ${noun} ${utils.label(subject)}` - headerRow.setAttribute( - 'style', - 'min-width: 20em; padding: 1em; font-size: 120%; border-bottom: 0.1em solid red; margin-bottom: 2em;' - ) + const container = dom.createElement('div') + container.classList.add(classes.aclControlBoxContainer) - const statusRow = table.appendChild(dom.createElement('tr')) + const header = container.appendChild(dom.createElement('h1')) + header.textContent = `Sharing for ${noun} ${utils.label(subject)}` + header.classList.add(classes.aclControlBoxHeader) - const statusCell = statusRow.appendChild(dom.createElement('td')) - const statusBlock = statusCell.appendChild(dom.createElement('div')) - statusBlock.setAttribute('style', 'padding: 2em;') - const MainRow = table.appendChild(dom.createElement('tr')) - const box: any = MainRow.appendChild(dom.createElement('table')) - const bottomRow = table.appendChild(dom.createElement('tr')) + const status = container.appendChild(dom.createElement('div')) + status.classList.add(classes.aclControlBoxStatus) - // A world button can be dragged to gve public access. - // later, allow it to be pressed to make pubicly viewable? - const bottomLeftCell = bottomRow.appendChild(dom.createElement('td')) - // const bottomMiddleCell = bottomRow.appendChild(dom.createElement('td')) - const bottomRightCell = bottomRow.appendChild(dom.createElement('td')) - - // const publicAccessButton = bottomLeftCell.appendChild(widgets.button(dom, icons.iconBase + 'noun_98053.svg', 'Public')) - - const bigButtonStyle = 'border-radius: 0.3em; background-color: white; border: 0.1em solid #888;' - - // This is the main function which produces an editable access control. - // There are two of these in all iff the defaults are separate - // - function ACLControlEditable ( - box, - doc, - aclDoc, - kb, - options: { - doingDefaults?: boolean, - modify?: boolean - } = {}) { - const ac = readACL(doc, aclDoc, kb, options.doingDefaults) // Note kb might not be normal one - const byCombo = ACLbyCombination(ac) - - function kToCombo (k) { - 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') - } - - 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' - } - - const kToColor = { - 13: 'purple', - 9: 'blue', - 5: 'red', - 3: 'orange', - 2: '#cc0', - 1: 'green' - } - - function ktToList (k) { - 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 removeAgentFromCombos (uri) { - for (let k = 0; k < 16; k++) { - const a = byCombo[kToCombo(k)] - if (a) { - for (let i = 0; i < a.length; i++) { - while (i < a.length && a[i][1] === uri) { - a.splice(i, 1) - } - } - } - } - } - - function agentTriage (uri) { - 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) - } - - box.saveBack = function (callback) { - const kb2 = graph() - if (!box.isContainer) { - makeACLGraphbyCombo(kb2, doc, box.mainByCombo, aclDoc, true) - } else if (box.defaultsDiffer) { - // Pair of controls - makeACLGraphbyCombo(kb2, doc, box.mainByCombo, aclDoc, true) - makeACLGraphbyCombo( - kb2, - doc, - box.defByCombo, - aclDoc, - false, - true - ) - } else { - // Linked controls - makeACLGraphbyCombo( - kb2, - doc, - box.mainByCombo, - aclDoc, - true, - true - ) - } - const updater = kb2.updater || new UpdateManager(kb2) - updater.put( - aclDoc, - kb2.statementsMatching(undefined, undefined, undefined, aclDoc), - 'text/turtle', - function (uri, ok, message) { - let error = '' - if (!ok) { - error = 'ACL file save failed: ' + message - console.log(error) - } else { - kb.fetcher.unload(aclDoc) - kb.add(kb2.statements) - kb.fetcher.requested[aclDoc.uri] = 'done' // missing: save headers - console.log('ACL modification: success!') - } - callback(ok, error) - } - ) - } - - function renderCombo (byCombo, combo) { - const row = box.appendChild(dom.createElement('tr')) - row.combo = combo - row.setAttribute( - 'style', - 'color: ' + (options.modify ? kToColor[k] || 'black' : '#888') + ';' - ) - - const left = row.appendChild(dom.createElement('td')) - - left.textContent = colloquial[k] || ktToList[k] - left.setAttribute('style', 'padding-bottom: 2em;') - - const middle = row.appendChild(dom.createElement('td')) - const middleTable = middle.appendChild(dom.createElement('table')) - middleTable.style.width = '100%' - - const right = row.appendChild(dom.createElement('td')) - right.textContent = explanation[k] || 'Unusual combination' - right.setAttribute('style', 'max-width: 30%;') - - function addAgent (pred, obj) { - if (middleTable.NoneTR) { - middleTable.removeChild(middleTable.NoneTR) - delete middleTable.NoneTR - } - const opt: any = {} - if (options.modify) { - opt.deleteFunction = function deletePerson () { - const arr = byCombo[combo] - for (let b = 0; b < arr.length; b++) { - if (arr[b][0] === pred && arr[b][1] === obj) { - arr.splice(b, 1) // remove from ACL - break - } - } - box.saveBack(function (ok, error) { - if (ok) { - middleTable.removeChild(tr) - } else { - alert(error) - } - }) - } - } - const tr = middleTable.appendChild( - widgets.personTR(dom, ACL(pred), sym(obj), opt) - ) - tr.predObj = [pred.uri, obj.uri] - } - - function syncCombo (combo) { - let i - const arr = byCombo[combo] - if (arr && arr.length) { - const already = middleTable.children - arr.sort() - for (let j = 0; j < already.length; j++) { - already[j].trashme = true - } - for (let a = 0; a < arr.length; a++) { - let found = false - for (i = 0; i < already.length; i++) { - if ( - already[i].predObj && // skip NoneTR - already[i].predObj[0] === arr[a][0] && - already[i].predObj[1] === arr[a][1] - ) { - found = true - delete already[i].trashme - break - } - } - if (!found) { - addAgent(arr[a][0], arr[a][1]) - } - } - for (i = already.length - 1; i >= 0; i--) { - if (already[i].trashme) { - middleTable.removeChild(already[i]) - } - } - } else { - widgets.clearElement(middleTable) - const tr = middleTable.appendChild(dom.createElement('tr')) - tr.textContent = 'None' - tr.setAttribute('style', 'padding: 1em;') - middleTable.NoneTR = tr - } - } - - syncCombo(combo) - row.refresh = function () { - syncCombo(combo) - } - - function saveAndRestoreUI () { - box.saveBack(function (ok, error) { - if (ok) { - row.style.backgroundColor = 'white' // restore look to before drag - syncPanel() - } else { - alert(error) - } - }) - } - - function handleManyDroppedURIs (uris) { - Promise.all( - uris.map(function (u) { - return handleOneDroppedURI(u) // can add to meetingDoc but must be sync - }) - ).then(function () { - saveAndRestoreUI() - }) - } - - async function handleOneDroppedURI (u) { - function setACLCombo () { - if (!(combo in byCombo)) { - byCombo[combo] = [] - } - removeAgentFromCombos(u) // Combos are mutually distinct - // @@ TODO Remove the need for bang (!) syntax - byCombo[combo].push([res!.pred, res!.obj.uri]) - console.log(`ACL: setting access to ${subject} by ${res!.pred}: ${res!.obj}`) - } - - let res = agentTriage(u) // eg 'agent', 'origin', agentClass' - const thing = sym(u) - if (!res) { - console.log(' Not obvious: looking up dropped thing ' + thing) - try { - await kb.fetcher.load(thing.doc()) - } catch (err) { - console.log('Ignore error looking up dropped thing: ' + err) - } - res = agentTriage(u) - if (!res) { - console.log(' Error: Drop fails to drop appropriate thing! ' + u) - } else { - setACLCombo() - } - } else { - setACLCombo() - } - } // handleOneDroppedURI - - async function addNewUIRI (uri) { - await handleOneDroppedURI(uri) - saveAndRestoreUI() - } + try { + loadController(doc, kb, subject, noun, context, classes, dom, status) + .then(controller => container.appendChild(controller.render())) + } catch (error) { + status.innerText = error + } - if (options.modify) { - row.addNewURI = addNewUIRI - widgets.makeDropTarget(row, handleManyDroppedURIs) - } - return row - } // renderCombo + return container +} - function syncPanel () { - const kids = box.children - for (let i = 0; i < kids.length; i++) { - if (kids[i].refresh) { - kids[i].refresh() - } - } // @@ later -- need to addd combos not in the box? +async function loadController ( + doc: NamedNode, + kb: IndexedFormula, + subject: NamedNode, + noun: string, + context: DataBrowserContext, + classes: Record, + dom: HTMLDocument, + status: HTMLElement +): Promise { + return new Promise((resolve, reject) => getACLorDefault(doc, async ( + ok, + isDirectACL, + targetDoc, + targetACLDoc, + defaultHolder, + defaultACLDoc + ) => { + if (!ok) { + return reject(new Error(`Error reading ${isDirectACL ? '' : ' default '}ACL. status ${targetDoc}: ${targetACLDoc}`)) } - - function renderAdditionTool (ele, lastRow) { - function removeOthers (button) { - button.keep = true - button.parentNode.keep = true - const removeThese: Array = [] - for (const ele of bar.children) { - if (!ele.keep) removeThese.push(ele) - } - removeThese.forEach(e => bar.removeChild(e)) - } - - function removeBar () { - ele.removeChild(ele.bar) - ele.bar = null + const targetDirectory = getDirectory(targetDoc as NamedNode) + const targetIsProtected = isStorage(targetDoc as NamedNode, targetACLDoc as NamedNode, kb) || hasProtectedAcl(targetDoc as NamedNode) + if (!targetIsProtected && targetDirectory) { + try { + const prospectiveDefaultHolder = await getProspectiveHolder(targetDirectory) + return resolve(getController(prospectiveDefaultHolder)) + } catch (error) { + // No need to show this error in status, but good to warn about it in console + console.warn(error) } - - if (ele.bar) { - // toggle - return removeBar() - } - const bar = ele.appendChild(dom.createElement('div')) - ele.bar = bar - - /** Buttons to add different types of theings to have access - */ - - // Person - bar.appendChild( - widgets.button( - dom, - icons.iconBase + widgets.iconForClass['vcard:Individual'], - 'Add Person', - async event => { - removeOthers(event.target) - const name = await widgets.askName( - dom, - kb, - bar, - ns.vcard('URI'), - ns.vcard('Individual'), - 'person' - ) - if (!name) return removeBar() // user cancelled - const domainNameRegexp = /^https?:/i - if (!name.match(domainNameRegexp)) { - // @@ enforce in user input live like a form element - return alert('Not a http URI') - } - // @@ check it actually is a person and has an owner who agrees they own it - console.log('Adding to ACL person: ' + name) - await lastRow.addNewURI(name) - removeBar() - } - ) - ) - - // Group - bar.appendChild( - widgets.button( - dom, - icons.iconBase + widgets.iconForClass['vcard:Group'], - 'Add Group', - async event => { - removeOthers(event.target) - const name = await widgets.askName( - dom, - kb, - bar, - ns.vcard('URI'), - ns.vcard('Group'), - 'group' - ) - if (!name) return removeBar() // user cancelled - const domainNameRegexp = /^https?:/i - if (!name.match(domainNameRegexp)) { - // @@ enforce in user input live like a form element - return alert('Not a http URI') - } - // @@ check it actually is a group and has an owner who agrees they own it - console.log('Adding to ACL group: ' + name) - await lastRow.addNewURI(name) - removeBar() - } - ) - ) - - // General public - bar.appendChild( - widgets.button( - dom, - icons.iconBase + widgets.iconForClass['foaf:Agent'], - 'Add Everyone', - async _event => { - statusBlock.textContent = - 'Adding the general public to those who can read. Drag the globe to a different level to give them more access.' - await lastRow.addNewURI(ns.foaf('Agent').uri) - removeBar() - } - ) - ) - - // AuthenticatedAgent - bar.appendChild( - widgets.button( - dom, - icons.iconBase + 'noun_99101.svg', - 'Anyone logged In', - async _event => { - statusBlock.textContent = - 'Adding the anyone logged in to those who can read. Drag the ID icon to a different level to give them more access.' - await lastRow.addNewURI(ns.acl('AuthenticatedAgent').uri) - removeBar() - } - ) - ) - - // Bots - bar.appendChild( - widgets.button( - dom, - icons.iconBase + 'noun_Robot_849764.svg', - 'A Software Agent (bot)', - async event => { - removeOthers(event.target) - const name = await widgets.askName( - dom, - kb, - bar, - ns.vcard('URI'), - ns.schema('Application'), - 'bot' - ) - if (!name) return removeBar() // user cancelled - const domainNameRegexp = /^https?:/i - if (!name.match(domainNameRegexp)) { - // @@ enforce in user input live like a form element - return alert('Not a http URI') - } - // @@ check it actually is a bot and has an owner who agrees they own it - console.log('Adding to ACL bot: ' + name) - await lastRow.addNewURI(name) - removeBar() - } - ) - ) - - // Web Apps - bar.appendChild( - widgets.button( - dom, - icons.iconBase + 'noun_15177.svg', - 'A Web App (origin)', - async event => { - removeOthers(event.target) - const eventContext: any = { div: bar, dom } - await logInLoadProfile(eventContext) - const trustedApps = kb.each(eventContext.me, ns.acl('trustedApp')) - const trustedOrigins = trustedApps.flatMap(app => - kb.each(app, ns.acl('origin')) - ) - - bar.appendChild(dom.createElement('p')).textContent = `You have ${ - trustedOrigins.length - } selected web apps.` - const table = bar.appendChild(dom.createElement('table')) - trustedApps.forEach(app => { - const origin = kb.any(app, ns.acl('origin')) - const thingTR = widgets.personTR( - dom, - ns.acl('origin'), - origin, - {} - ) - const innerTable = dom.createElement('table') - const innerRow = innerTable.appendChild(dom.createElement('tr')) - const innerLeft = innerRow.appendChild(dom.createElement('td')) - const innerMiddle = innerRow.appendChild(dom.createElement('td')) - const innerRight = innerRow.appendChild(dom.createElement('td')) - innerLeft.appendChild(thingTR) - innerMiddle.textContent = - 'Give access to ' + noun + ' ' + utils.label(subject) + '?' - innerRight.appendChild( - widgets.continueButton(dom, async _event => { - await lastRow.addNewURI(origin.uri) - }) - ) - table.appendChild(innerTable) - }) - table.style.backgroundColor = '#eee' - - // Add the Trusted App pane for managing you set of apps - const trustedApplications = context.session.paneRegistry.byName( - 'trustedApplications' - ) - const trustedAppControl = trustedApplications.render( - eventContext.me, - context - ) - trustedAppControl.style.borderColor = 'orange' - trustedAppControl.style.borderWidth = '0.1em' - trustedAppControl.style.borderRadius = '1em' - bar.appendChild(trustedAppControl) - const cancel = widgets.cancelButton(dom, () => - bar.removeChild(trustedAppControl) - ) - trustedAppControl.insertBefore(cancel, trustedAppControl.firstChild) - cancel.style.float = 'right' - - const name = await widgets.askName( - dom, - kb, - bar, - null, - ns.schema('WebApplication'), - 'webapp domain' - ) // @@ hack - if (!name) return removeBar() // user cancelled - const domainNameRegexp = /^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/i - // https://www.oreilly.com/library/view/regular-expressions-cookbook/9781449327453/ch08s15.html - if (!name.match(domainNameRegexp)) { - // @@ enforce in user input live like a form element - return alert('Not a domain name') - } - const origin = 'https://' + name - console.log('Adding to ACL origin: ' + origin) - await lastRow.addNewURI(origin) - removeBar() - } - ) - ) - } - - function renderAddToolBar (box, lastRow) { - // const toolRow = box.appendChild(dom.createElement('tr')) - bottomLeftCell.appendChild( - widgets.button( - dom, - icons.iconBase + 'noun_34653_green.svg', - 'Add ...', - _event => { - renderAdditionTool(bottomLeftCell, lastRow) - } - ) - ) } + return resolve(getController()) - let k, combo, lastRow - for (k = 15; k > 0; k--) { - combo = kToCombo(k) - if ((options.modify && recommended[k]) || byCombo[combo]) { - lastRow = renderCombo(byCombo, combo) - } // if - } // for - - if (options.modify) { - renderAddToolBar(box, lastRow) + function getController (prospectiveDefaultHolder?: NamedNode) { + return new AccessController(subject, noun, context, status, classes, targetIsProtected, targetDoc as NamedNode, targetACLDoc as NamedNode, defaultHolder as NamedNode, + defaultACLDoc as NamedNode, prospectiveDefaultHolder, kb, dom) } + })) +} - return byCombo - } // ACLControlEditable - - function renderBox () { - box.innerHTML = '' - getACLorDefault(doc, function ( - ok, - p2, - targetDoc, - targetACLDoc, - defaultHolder, - defaultACLDoc - ) { - const defa = !p2 - // @@ Could also set from classes ldp:Container etc etc - if (!ok) { - statusBlock.textContent += `Error reading ${defa ? ' default ' : ''}ACL. status ${targetDoc}: ${targetACLDoc}` - } else { - box.isContainer = (targetDoc as NamedNode).uri.slice(-1) === '/' // Give default for all directories - if (defa) { - const defaults = kb - .each(undefined, ACL('default'), defaultHolder, defaultACLDoc) - .concat( - kb.each( - undefined, - ACL('defaultForNew'), - defaultHolder, - defaultACLDoc - ) - ) - if (!defaults.length) { - statusBlock.textContent += ' (No defaults given.)' - } else { - statusBlock.innerHTML = '' - statusBlock.textContent = - 'The sharing for this ' + noun + ' is the default for folder ' - const a = statusBlock.appendChild(dom.createElement('a')) - const defaultHolder2 = defaultHolder as NamedNode - const defaultACLDoc2 = defaultACLDoc as NamedNode - a.setAttribute('href', defaultHolder2.uri) - a.textContent = shortNameForFolder(defaultHolder2) - const kb2 = adoptACLDefault( - doc, - targetACLDoc as NamedNode, - defaultHolder2, - defaultACLDoc2 - ) - ACLControlEditable(box, doc, targetACLDoc, kb2, { modify: false }) // Add btton to save them as actual - box.style.cssText = 'color: #777;' - - const editPlease = bottomRightCell.appendChild( - dom.createElement('button') - ) - editPlease.textContent = 'Set specific sharing\nfor this ' + noun - editPlease.style.cssText = bigButtonStyle - editPlease.addEventListener('click', async function (_event) { - kb2.statements.forEach(st => { - kb.add(st.subject, st.predicate as NamedNode, st.object, targetACLDoc as NamedNode) - }) - try { - // @@ TODO Remove casting of kb - (kb as any).fetcher.putBack(targetACLDoc).then(function () { - statusBlock.textContent = - ' (Now editing specific access for this ' + noun + ')' - bottomRightCell.removeChild(editPlease) - renderBox() - }) - } catch (e) { - const msg = ' Error writing back access control file! ' + e - console.error(msg) - statusBlock.textContent += msg - } - // kb.fetcher.requested[targetACLDoc.uri] = 'done' // cheat - say cache is now in sync - }) - } // defaults.length - } else { - // Not using defaults - let useDefault - const addDefaultButton = function (prospectiveDefaultHolder?) { - useDefault = bottomRightCell.appendChild( - dom.createElement('button') - ) - useDefault.textContent = - 'Stop specific sharing for this ' + noun + ' -- just use default' // + utils.label(thisDefaultHolder) - if (prospectiveDefaultHolder) { - useDefault.textContent += - ' for ' + utils.label(prospectiveDefaultHolder) - } - useDefault.style.cssText = bigButtonStyle - useDefault.addEventListener('click', function (_event) { - // @@ TODO Remove casting of kb - (kb as any).fetcher - .delete((targetACLDoc as NamedNode).uri) - .then(function () { - statusBlock.textContent = - ' The sharing for this ' + noun + ' is now the default.' - bottomRightCell.removeChild(useDefault) - box.style.cssText = 'color: #777;' - bottomLeftCell.innerHTML = '' - renderBox() - }) - .catch(function (e) { - statusBlock.textContent += - ' (Error deleting access control file: ' + - targetACLDoc + - ': ' + - e + - ')' - }) - }) - } - let prospectiveDefaultHolder - - const str = (targetDoc as NamedNode).uri.split('#')[0] - const p = str.slice(0, -1).lastIndexOf('/') - const q = str.indexOf('//') - const targetDocDir = - (q >= 0 && p < q + 2) || p < 0 ? null : str.slice(0, p + 1) - - // @@ TODO: The methods used for targetIsStorage are HACKs - it should not be relied upon, and work is - // @@ underway to standardize a behavior that does not rely upon this hack - // @@ hopefully fixed as part of https://github.com/solid/data-interoperability-panel/issues/10 - const targetIsStorage = kb.holds( - targetDoc as NamedNode, - ns.rdf('type'), - ns.space('Storage'), - targetACLDoc as NamedNode - ) - const targetAclIsProtected = hasProtectedAcl(targetDoc as NamedNode) - const targetIsProtected = targetIsStorage || targetAclIsProtected - - if (!targetIsProtected && targetDocDir) { - getACLorDefault(sym(targetDocDir), function ( - ok2, - p22, - targetDoc2, - targetACLDoc2, - defaultHolder2, - _defaultACLDoc2 - ) { - if (ok2) { - prospectiveDefaultHolder = p22 ? targetDoc2 : defaultHolder2 - } - addDefaultButton(prospectiveDefaultHolder) - }) - } else if (!targetIsProtected) { - addDefaultButton() - } - - box.addControlForDefaults = function () { - box.notice.textContent = 'Access to things within this folder:' - box.notice.style.cssText = 'font-size: 120%; color: black;' - const mergeButton = widgets - .clearElement(box.offer) - .appendChild(dom.createElement('button')) - mergeButton.innerHTML = - '

Set default for folder contents to
just track the sharing for the folder

' - mergeButton.style.cssText = bigButtonStyle - mergeButton.addEventListener( - 'click', - function (_event) { - delete box.defaultsDiffer - delete box.defByCombo - box.saveBack(function (ok, error) { - if (ok) { - box.removeControlForDefaults() - } else { - alert(error) - } - }) - }, - false - ) - box.defaultsDiffer = true - box.defByCombo = ACLControlEditable( - box, - targetDoc, - targetACLDoc, - kb, - { modify: true, doingDefaults: true } - ) - } - box.removeControlForDefaults = function () { - statusBlock.textContent = - 'This is also the default for things in this folder.' - box.notice.textContent = - 'Sharing for things within the folder currently tracks sharing for the folder.' - box.notice.style.cssText = 'font-size: 80%; color: #888;' - const splitButton = widgets - .clearElement(box.offer) - .appendChild(dom.createElement('button')) - splitButton.innerHTML = - '

Set the sharing of folder contents
separately from the sharing for the folder

' - splitButton.style.cssText = bigButtonStyle - splitButton.addEventListener('click', function (_event) { - box.addControlForDefaults() - statusBlock.textContent = '' - }) - while (box.divider.nextSibling) { - box.removeChild(box.divider.nextSibling) - } - statusBlock.textContent = - 'This is now also the default for things in this folder.' - } - - box.mainByCombo = ACLControlEditable( - box, - targetDoc, - targetACLDoc, - kb, - { modify: true } - ) // yes can edit - box.divider = box.appendChild(dom.createElement('tr')) - box.notice = box.divider.appendChild(dom.createElement('td')) - box.notice.style.cssText = 'font-size: 80%; color: #888;' - box.offer = box.divider.appendChild(dom.createElement('td')) - box.notice.setAttribute('colspan', '2') - - if (box.isContainer) { - const ac = readACL(targetDoc as NamedNode, targetACLDoc as NamedNode, kb) - const acd = readACL(targetDoc as NamedNode, targetACLDoc as NamedNode, kb, true) - box.defaultsDiffer = !sameACL(ac, acd) - console.log('Defaults differ ACL: ' + box.defaultsDiffer) - if (box.defaultsDiffer) { - box.addControlForDefaults() - } else { - box.removeControlForDefaults() - } - } - } // Not using defaults - } - }) - } +function getDirectory (doc: NamedNode): string | null { + const str = doc.uri.split('#')[0] + const p = str.slice(0, -1).lastIndexOf('/') + const q = str.indexOf('//') + return (q >= 0 && p < q + 2) || p < 0 ? null : str.slice(0, p + 1) +} - renderBox() - return table -} // ACLControlBox +function isStorage (doc: NamedNode, aclDoc: NamedNode, store: IndexedFormula): boolean { + // @@ TODO: The methods used for targetIsStorage are HACKs - it should not be relied upon, and work is + // @@ underway to standardize a behavior that does not rely upon this hack + // @@ hopefully fixed as part of https://github.com/solid/data-interoperability-panel/issues/10 + return store.holds(doc, ns.rdf('type'), ns.space('Storage'), aclDoc) +} function hasProtectedAcl (targetDoc: NamedNode): boolean { // @@ TODO: This is hacky way of knowing whether or not a certain ACL file can be removed // Hopefully we'll find a better, standardized solution to this - https://github.com/solid/specification/issues/37 return targetDoc.uri === targetDoc.site().uri } - -// ends diff --git a/src/acl/acl.ts b/src/acl/acl.ts index 35e4eef01..2f5682c8f 100644 --- a/src/acl/acl.ts +++ b/src/acl/acl.ts @@ -186,7 +186,7 @@ export function loadUnionACL (subjectList: Array<$rdf.NamedNode>, callbackFuncti // Combos are like full control, read append, read only etc. // export function ACLbyCombination (ac: AgentMapMap): ComboList { - const byCombo = [] + const byCombo = {} ;['agent', 'agentClass', 'agentGroup', 'origin', 'originClass'].map(function (pred) { for (const agent in ac[pred]) { const combo: string[] = [] @@ -591,4 +591,17 @@ export function getACL ( }) } -// ///////////////////////////////////////// End of ACL stuff +export async function getProspectiveHolder (targetDirectory: string): Promise<$rdf.NamedNode | undefined> { + return new Promise((resolve, reject) => getACLorDefault($rdf.sym(targetDirectory), ( + ok, + isDirectACL, + targetDoc, + targetACLDoc, + defaultHolder + ) => { + if (ok) { + return resolve((isDirectACL ? targetDoc : defaultHolder) as $rdf.NamedNode) + } + return reject(new Error(`Error loading ${targetDirectory}`)) + })) +} diff --git a/src/acl/add-agent-buttons.ts b/src/acl/add-agent-buttons.ts new file mode 100644 index 000000000..ddf951135 --- /dev/null +++ b/src/acl/add-agent-buttons.ts @@ -0,0 +1,275 @@ +import { AccessGroups } from './access-groups' +import icons from '../iconBase' +import widgets from '../widgets' +import ns from '../ns' +import { logInLoadProfile } from '../authn/authn' +import utils from '../utils' +import { NamedNode } from 'rdflib' +import { AuthenticationContext } from '../authn/types' + +export class AddAgentButtons { + private readonly rootElement: HTMLElement + private readonly barElement: HTMLElement + private isExpanded: boolean = false + + constructor (private groupList: AccessGroups) { + this.rootElement = groupList.controller.dom.createElement('div') + this.barElement = groupList.controller.dom.createElement('div') + } + + public render (): HTMLElement { + this.rootElement.innerHTML = '' + this.rootElement.appendChild(this.renderAddButton()) + this.rootElement.appendChild(this.barElement) + return this.rootElement + } + + private renderAddButton (): HTMLElement { + return widgets.button( + this.groupList.controller.dom, + `${icons.iconBase}noun_34653_green.svg`, + 'Add ...', + () => { + this.toggleBar() + this.renderBar() + } + ) + } + + private renderBar (): void { + this.barElement.innerHTML = '' + if (!this.isExpanded) { + return + } + this.barElement.appendChild(this.renderPersonButton()) + this.barElement.appendChild(this.renderGroupButton()) + this.barElement.appendChild(this.renderPublicButton()) + this.barElement.appendChild(this.renderAuthenticatedAgentButton()) + this.barElement.appendChild(this.renderBotButton()) + this.barElement.appendChild(this.renderAppsButton()) + } + + private renderSimplifiedBar (button: EventTarget | null) { + Array.from(this.barElement.children) + .filter(element => element !== button) + .forEach(element => this.barElement.removeChild(element)) + } + + private renderPersonButton (): HTMLElement { + return widgets.button( + this.groupList.controller.dom, + icons.iconBase + widgets.iconForClass['vcard:Individual'], + 'Add Person', + event => { + this.renderSimplifiedBar(event.target) + this.renderNameForm(ns.vcard('Individual'), 'person') + .then(name => this.addPerson(name)) + .then(() => this.renderCleanup()) + .catch(error => this.groupList.controller.renderStatus(error)) + } + ) + } + + private renderGroupButton (): HTMLElement { + return widgets.button( + this.groupList.controller.dom, + icons.iconBase + widgets.iconForClass['vcard:Group'], + 'Add Group', + event => { + this.renderSimplifiedBar(event.target) + this.renderNameForm(ns.vcard('Group'), 'group') + .then(name => this.addGroup(name)) + .then(() => this.renderCleanup()) + .catch(error => this.groupList.controller.renderStatus(error)) + } + ) + } + + private renderNameForm (type: NamedNode, noun: string): Promise { + return widgets.askName( + this.groupList.controller.dom, + this.groupList.store, + this.barElement, + ns.vcard('URI'), + type, + noun + ) + } + + private renderPublicButton (): HTMLElement { + return widgets.button( + this.groupList.controller.dom, + icons.iconBase + widgets.iconForClass['foaf:Agent'], + 'Add Everyone', + () => this.addAgent(ns.foaf('Agent').uri) + .then(() => this.groupList.controller.renderTemporaryStatus('Adding the general public to those who can read. Drag the globe to a different level to give them more access.')) + .then(() => this.renderCleanup())) + } + + private renderAuthenticatedAgentButton (): HTMLElement { + return widgets.button( + this.groupList.controller.dom, + `${icons.iconBase}noun_99101.svg`, + 'Anyone logged In', + () => this.addAgent(ns.acl('AuthenticatedAgent').uri) + .then(() => this.groupList.controller.renderTemporaryStatus('Adding anyone logged in to those who can read. Drag the ID icon to a different level to give them more access.')) + .then(() => this.renderCleanup())) + } + + private renderBotButton (): HTMLElement { + return widgets.button( + this.groupList.controller.dom, + icons.iconBase + 'noun_Robot_849764.svg', + 'A Software Agent (bot)', + event => { + this.renderSimplifiedBar(event.target) + this.renderNameForm(ns.schema('Application'), 'bot') + .then(name => this.addBot(name)) + .then(() => this.renderCleanup()) + }) + } + + private renderAppsButton (): HTMLElement { + return widgets.button( + this.groupList.controller.dom, + `${icons.iconBase}noun_15177.svg`, + 'A Web App (origin)', + event => { + this.renderSimplifiedBar(event.target) + const eventContext = { + div: this.barElement, + dom: this.groupList.controller.dom + } + const existingApps = this.renderAppsTable(eventContext) + .catch(error => this.groupList.controller.renderStatus(error)) + this.renderAppsView() + const newApp = this.renderNameForm(ns.schema('WebApplication'), 'webapp domain') + .then(name => this.getOriginFromName(name)) + Promise.race([ + existingApps, + newApp + ]) + .then(origin => { + if (origin) { + this.groupList.addNewURI(origin) + } + }) + .then(() => this.renderCleanup()) + } + ) + } + + private renderAppsView (): void { + const trustedApplications = this.groupList.controller.context.session.paneRegistry.byName('trustedApplications') + if (trustedApplications) { + const trustedApplicationsElement = trustedApplications.render(null, this.groupList.controller.context) + trustedApplicationsElement.classList.add(this.groupList.controller.classes.trustedAppController) + + const cancelButton = widgets.cancelButton(this.groupList.controller.dom, () => this.renderCleanup()) + cancelButton.classList.add(this.groupList.controller.classes.trustedAppCancelButton) + trustedApplicationsElement.insertBefore(cancelButton, trustedApplicationsElement.firstChild) + + this.barElement.appendChild(trustedApplicationsElement) + } + } + + private async renderAppsTable (eventContext: AuthenticationContext): Promise { + await logInLoadProfile(eventContext) + const trustedApps = this.groupList.store.each(eventContext.me, ns.acl('trustedApp')) + const trustedOrigins = trustedApps.flatMap(app => this.groupList.store.each(app, ns.acl('origin'))) + + this.barElement.appendChild(this.groupList.controller.dom.createElement('p')).textContent = `You have ${trustedOrigins.length} selected web apps.` + return new Promise((resolve, reject) => { + const appsTable = this.barElement.appendChild(this.groupList.controller.dom.createElement('table')) + appsTable.classList.add(this.groupList.controller.classes.trustedAppAddApplicationsTable) + trustedApps.forEach(app => { + const origin = this.groupList.store.any(app, ns.acl('origin')) + if (!origin) { + reject(new Error(`Unable to pick app: ${app.value}`)) + } + const thingTR = widgets.personTR(this.groupList.controller.dom, ns.acl('origin'), origin, {}) + const innerTable = this.groupList.controller.dom.createElement('table') + const innerRow = innerTable.appendChild(this.groupList.controller.dom.createElement('tr')) + + const innerLeftColumn = innerRow.appendChild(this.groupList.controller.dom.createElement('td')) + innerLeftColumn.appendChild(thingTR) + + const innerMiddleColumn = innerRow.appendChild(this.groupList.controller.dom.createElement('td')) + innerMiddleColumn.textContent = `Give access to ${this.groupList.controller.noun} ${utils.label(this.groupList.controller.subject)}?` + + const innerRightColumn = innerRow.appendChild(this.groupList.controller.dom.createElement('td')) + innerRightColumn.appendChild(widgets.continueButton(this.groupList.controller.dom, () => resolve(origin!.value))) + + appsTable.appendChild(innerTable) + }) + }) + } + + private renderCleanup (): void { + this.renderBar() + this.groupList.render() + } + + private async addPerson (name?: string): Promise { + if (!name) return this.toggleBar() // user cancelled + const domainNameRegexp = /^https?:/i + if (!name.match(domainNameRegexp)) { + // @@ enforce in user input live like a form element + return Promise.reject(new Error('Not a http URI')) + } + // @@ check it actually is a person and has an owner who agrees they own it + console.log(`Adding to ACL person: ${name}`) + await this.groupList.addNewURI(name) + this.toggleBar() + } + + private async addGroup (name?: string): Promise { + if (!name) return this.toggleBar() // user cancelled + + const domainNameRegexp = /^https?:/i + if (!name.match(domainNameRegexp)) { + // @@ enforce in user input live like a form element + return Promise.reject(new Error('Not a http URI')) + } + // @@ check it actually is a group and has an owner who agrees they own it + console.log('Adding to ACL group: ' + name) + await this.groupList.addNewURI(name) + this.toggleBar() + } + + private async addAgent (agentUri: string): Promise { + await this.groupList.addNewURI(agentUri) + this.toggleBar() + } + + private async addBot (name?: string): Promise { + if (!name) return this.toggleBar() // user cancelled + const domainNameRegexp = /^https?:/i + if (!name.match(domainNameRegexp)) { + // @@ enforce in user input live like a form element + return Promise.reject(new Error('Not a http URI')) + } + // @@ check it actually is a bot and has an owner who agrees they own it + console.log('Adding to ACL bot: ' + name) + await this.groupList.addNewURI(name) + this.toggleBar() + } + + private async getOriginFromName (name?: string): Promise { + if (!name) return Promise.resolve() // user cancelled + const domainNameRegexp = /^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/i + // https://www.oreilly.com/library/view/regular-expressions-cookbook/9781449327453/ch08s15.html + if (!name.match(domainNameRegexp)) { + // @@ enforce in user input live like a form element + return Promise.reject(new Error('Not a domain name')) + } + const origin = 'https://' + name + console.log('Adding to ACL origin: ' + origin) + this.toggleBar() + return origin + } + + private toggleBar (): void { + this.isExpanded = !this.isExpanded + } +} diff --git a/src/acl/styles.ts b/src/acl/styles.ts new file mode 100644 index 000000000..f46c779d1 --- /dev/null +++ b/src/acl/styles.ts @@ -0,0 +1,84 @@ +export const styles = { + aclControlBoxContainer: { + margin: '1em' + }, + aclControlBoxHeader: { + fontSize: '120%', + margin: '0 0 1rem' + }, + aclControlBoxStatus: { + display: 'none', + margin: '1rem 0' + }, + aclControlBoxStatusRevealed: { + display: 'block' + }, + aclGroupContent: { + maxWidth: 650 + }, + accessGroupList: { + display: 'grid', + gridTemplateColumns: '1fr', + margin: '1em', + width: '100%' + }, + accessGroupListItem: { + display: 'grid', + gridTemplateColumns: '100px auto 30%' + }, + defaultsController: { + display: 'flex' + }, + defaultsControllerNotice: { + color: '#888', + flexGrow: 1, + fontSize: '80%' + }, + bigButton: { + backgroundColor: 'white', + border: '0.1em solid #888', + borderRadius: '0.3em', + maxWidth: '50%', + paddingBottom: '1em', + paddingTop: '1em' + }, + group: { + color: '#888' + }, + 'group-1': { + color: 'green' + }, + 'group-2': { + color: '#cc0' + }, + 'group-3': { + color: 'orange' + }, + 'group-5': { + color: 'red' + }, + 'group-9': { + color: 'blue' + }, + 'group-13': { + color: 'purple' + }, + trustedAppAddApplicationsTable: { + backgroundColor: '#eee' + }, + trustedAppCancelButton: { + float: 'right' as 'right' // @@ a little hack - https://stackoverflow.com/questions/52781251/using-typescript-jss-with-react-throws-type-is-unassignable-for-some-css-prop + }, + trustedAppControllerI: { + borderColor: 'orange', + borderRadius: '1em', + borderWidth: '0.1em' + }, + temporaryStatusInit: { + background: 'green' + }, + temporaryStatusEnd: { + background: 'transparent', + transition: 'background 5s linear' + } +} diff --git a/src/acl/types.ts b/src/acl/types.ts index 62ea11ade..bba8b4862 100644 --- a/src/acl/types.ts +++ b/src/acl/types.ts @@ -14,4 +14,9 @@ export type AgentMap = { } } -export type ComboList = Array +export type ComboList = { [key: string]: Array } + +export type PartialAgentTriple = { + pred: string, + obj: NamedNode +} diff --git a/src/jss/index.ts b/src/jss/index.ts new file mode 100644 index 000000000..a94ba9f3c --- /dev/null +++ b/src/jss/index.ts @@ -0,0 +1,22 @@ +import { create, Jss, Styles, StyleSheet } from 'jss' +import preset from 'jss-preset-default' + +const stylesheetsMap = new Map() + +export function getClasses (insertionPoint: HTMLElement, styles: Partial): StyleSheet { + const stylesheet = getStylesheet(insertionPoint) + return stylesheet.createStyleSheet(styles).attach() +} + +export function getStylesheet (insertionPoint: HTMLElement): Jss { + const cachedStylesheet = stylesheetsMap.get(insertionPoint) + if (cachedStylesheet) { + return cachedStylesheet + } + const stylesheet = create({ + insertionPoint, + plugins: preset().plugins + }) + stylesheetsMap.set(insertionPoint, stylesheet) + return stylesheet +} diff --git a/src/widgets/buttons.js b/src/widgets/buttons.js index 99d793e4b..304a2c44d 100644 --- a/src/widgets/buttons.js +++ b/src/widgets/buttons.js @@ -49,6 +49,7 @@ function complain (context, err) { if (ele) ele.appendChild(error.errorMessageBlock(context.dom, err)) else alert(err) } + buttons.complain = complain // var UI.ns = require('./ns.js') @@ -127,8 +128,7 @@ buttons.formatDateTime = function (date, format) { var width = { Milliseconds: 3, FullYear: 4 } var d = { Month: 1 } return s - ? ('000' + (date['get' + k]() + (d[k] || 0))).slice(-(width[k] || 2)) + - s.split('}')[1] + ? ('000' + (date['get' + k]() + (d[k] || 0))).slice(-(width[k] || 2)) + s.split('}')[1] : '' }) .join('') @@ -222,8 +222,9 @@ var tempSite = function (x) { } } -/** Find an image for this thing as a classs -*/ +/** + * Find an image for this thing as a class + */ buttons.findImageFromURI = function findImageFromURI (x) { const iconDir = UI.icons.iconBase @@ -276,11 +277,12 @@ buttons.findImage = thing => { return image ? image.uri : null } -/** Do the best you can with the data available -** -** @return {Boolean} Are we happy with this icon? -** Sets src AND STYLE of the image. -*/ +/** + * Do the best you can with the data available + * + * @return {Boolean} Are we happy with this icon? + * Sets src AND STYLE of the image. + */ buttons._trySetImage = function _trySetImage (element, thing, iconForClassMap) { const kb = UI.store @@ -482,6 +484,7 @@ buttons.askName = function (dom, kb, container, predicate, klass, noun) { namefield.select() // focus next user input form.appendChild(namefield) container.appendChild(form) + // namefield.focus() function gotName () { @@ -489,36 +492,24 @@ buttons.askName = function (dom, kb, container, predicate, klass, noun) { resolve(namefield.value.trim()) } - namefield.addEventListener( - 'keyup', - function (e) { - if (e.keyCode === 13) { - gotName() - } - }, - false - ) + namefield.addEventListener('keyup', function (e) { + if (e.keyCode === 13) { + gotName() + } + }, false) form.appendChild(dom.createElement('br')) const cancel = form.appendChild(buttons.cancelButton(dom)) - cancel.addEventListener( - 'click', - function (_event) { - form.parentNode.removeChild(form) - resolve(null) - }, - false - ) + cancel.addEventListener('click', function (_event) { + form.parentNode.removeChild(form) + resolve(null) + }, false) const continueButton = form.appendChild(buttons.continueButton(dom)) - continueButton.addEventListener( - 'click', - function (_event) { - gotName() - }, - false - ) + continueButton.addEventListener('click', function (_event) { + gotName() + }, false) namefield.focus() }) // Promise } @@ -559,25 +550,14 @@ buttons.personTR = function (dom, pred, obj, options) { // var image = td1.appendChild(dom.createElement('img')) var image = faviconOrDefault(dom, obj) - td1.setAttribute( - 'style', - 'vertical-align: middle; width:2.5em; padding:0.5em; height: 2.5em;' - ) + td1.setAttribute('style', 'vertical-align: middle; width:2.5em; padding:0.5em; height: 2.5em;') td2.setAttribute('style', 'vertical-align: middle; text-align:left;') - td3.setAttribute( - 'style', - 'vertical-align: middle; width:2em; padding:0.5em; height: 4em;' - ) + td3.setAttribute('style', 'vertical-align: middle; width:2em; padding:0.5em; height: 4em;') td1.appendChild(image) buttons.setName(td2, obj) if (options.deleteFunction) { - buttons.deleteButtonWithCheck( - dom, - td3, - options.noun || 'one', - options.deleteFunction - ) + buttons.deleteButtonWithCheck(dom, td3, options.noun || 'one', options.deleteFunction) } if (obj.uri) { // blank nodes need not apply @@ -703,20 +683,14 @@ buttons.openHrefInOutlineMode = function (e) { const dom = window.document if (dom.outlineManager) { // @@ TODO Remove the use of document as a global object - dom.outlineManager.GotoSubject( - UI.store.sym(uri), - true, - undefined, - true, - undefined - ) + dom.outlineManager.GotoSubject(UI.store.sym(uri), true, undefined, true, undefined) } else if (window && window.panes && window.panes.getOutliner) { // @@ TODO Remove the use of window as a global object window.panes .getOutliner() .GotoSubject(UI.store.sym(uri), true, undefined, true, undefined) } else { - console.log("ERROR: Can't access outline manager in this config") + console.log('ERROR: Can\'t access outline manager in this config') } // dom.outlineManager.GotoSubject(UI.store.sym(uri), true, undefined, true, undefined) } @@ -762,16 +736,17 @@ buttons.allClassURIs = function () { return set } -/** Figuring which properties we know about -* -* When the user inputs an RDF property, like for a form field -* or when specifying the relationship between two arbitrary things, -* then er can prompt them with properties the session knows about -* -* TODO: Look again by catching this somewhere. (On the kb?) -* TODO: move to diff module? Not really a button. -* @param {Store} kb The quadstore to be searched. -*/ +/** + * Figuring which properties we know about + * + * When the user inputs an RDF property, like for a form field + * or when specifying the relationship between two arbitrary things, + * then er can prompt them with properties the session knows about + * + * TODO: Look again by catching this somewhere. (On the kb?) + * TODO: move to diff module? Not really a button. + * @param {Store} kb The quadstore to be searched. + */ buttons.propertyTriage = function (kb) { var possibleProperties = {} @@ -805,20 +780,12 @@ buttons.propertyTriage = function (kb) { } possibleProperties.op = op possibleProperties.dp = dp - UI.log.info( - 'propertyTriage: ' + - no + - ' non-lit, ' + - nd + - ' literal. ' + - nu + - ' unknown.' - ) + UI.log.info(`propertyTriage: ${no} non-lit, ${nd} literal. ${nu} unknown.`) return possibleProperties } -/* General purpose widgets - ** +/** + * General purpose widgets */ // A button for jumping @@ -827,14 +794,10 @@ buttons.linkButton = function (dom, object) { var b = dom.createElement('button') b.setAttribute('type', 'button') b.textContent = 'Goto ' + utils.label(object) - b.addEventListener( - 'click', - function (_event) { - // b.parentNode.removeChild(b) - dom.outlineManager.GotoSubject(object, true, undefined, true, undefined) - }, - true - ) + b.addEventListener('click', function (_event) { + // b.parentNode.removeChild(b) + dom.outlineManager.GotoSubject(object, true, undefined, true, undefined) + }, true) return b } @@ -842,13 +805,9 @@ buttons.removeButton = function (dom, element) { var b = dom.createElement('button') b.setAttribute('type', 'button') b.textContent = '✕' // MULTIPLICATION X - b.addEventListener( - 'click', - function (_event) { - element.parentNode.removeChild(element) - }, - true - ) + b.addEventListener('click', function (_event) { + element.parentNode.removeChild(element) + }, true) return b } @@ -970,34 +929,26 @@ buttons.selectorPanelRefresh = function ( iconDiv.appendChild(image) box.appendChild(iconDiv) - item.addEventListener( - 'click', - function (event) { - if (selected === item) { - // deselect - item.setAttribute('style', style0) - selected = null - } else { - if (selected) selected.setAttribute('style', style0) - item.setAttribute( - 'style', - style0 + 'background-color: #ccc; color:black;' - ) - selected = item - } - callbackFunction(x, event, selected === item) - setStyle() - }, - false - ) + item.addEventListener('click', function (event) { + if (selected === item) { + // deselect + item.setAttribute('style', style0) + selected = null + } else { + if (selected) selected.setAttribute('style', style0) + item.setAttribute( + 'style', + style0 + 'background-color: #ccc; color:black;' + ) + selected = item + } + callbackFunction(x, event, selected === item) + setStyle() + }, false) - image.addEventListener( - 'click', - function (event) { - linkCallback(x, event, inverse, setStyle) - }, - false - ) + image.addEventListener('click', function (event) { + linkCallback(x, event, inverse, setStyle) + }, false) box.appendChild(item) return box @@ -1051,21 +1002,19 @@ buttons.index.twoLine['http://www.w3.org/2000/10/swap/pim/qif#Transaction'] = fu return y ? utils.escapeForXML(y.value) : '?' // @@@@ } var box = dom.createElement('table') - box.innerHTML = - '' + - enc('payee') + - '\n' + - enc('date').slice(0, 10) + - '' + - enc('amount') + - '' + box.innerHTML = ` + + ${enc('payee')} + + + ${enc('date').slice(0, 10)} + ${enc('amount')} + ` if (failed) { - box.innerHTML = - '' + - utils.escapeForXML(failed) + - '' + box.innerHTML = ` + + ${utils.escapeForXML(failed)} + ` } return box } @@ -1079,14 +1028,14 @@ buttons.index.twoLine['http://www.w3.org/ns/pim/trip#Trip'] = function ( return y ? utils.escapeForXML(y.value) : '?' } var box = dom.createElement('table') - box.innerHTML = - '' + - enc(UI.ns.dc('title')) + - '\n' + - enc(UI.ns.cal('dtstart')) + - '' + - enc(UI.ns.cal('dtend')) + - '' + box.innerHTML = ` + + ${enc(UI.ns.dc('title'))} + + + ${enc(UI.ns.cal('dtstart'))} + ${enc(UI.ns.cal('dtend'))} + ` return box } @@ -1132,8 +1081,8 @@ buttons.isImage = function (file, kind) { return false } -/** File upload button - ** +/** + * File upload button * @param dom The DOM aka document * @param display:none - Same handler function as drop, takes array of file objects * @returns {Element} - a div with a button and a inout in it