Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Work in progress] Separation of concerns using React - TrustedApplications #120

Open
wants to merge 2 commits into
base: soc-react
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ register(require('./internalPane.js'))
// The home pane is a 2016 experiment. Always there.

register(require('./profile/profilePane').default) // edit your public profile
register(require('./trustedApplications/trustedApplicationsPane').default) // manage your trusted applications
register(require('./trustedApplications/index').default) // manage your trusted applications
register(require('./home/homePane').default)

// ENDS
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ Array [
Statement {
"object": NamedNode {
"termType": "NamedNode",
"value": "http://www.w3.org/ns/auth/acl#Read",
"value": "http://www.w3.org/ns/auth/acl#read",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpicking, but this should not be the case - Read (and the other modes) are classes, so should start with capital letters

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's exactly the kind of nitpicking I need, otherwise I'd never learn. Is a "class" an RDF concept, and if so, how do I know when something is a class?

(This is also probably wrong at many other places.)

},
"predicate": NamedNode {
"termType": "NamedNode",
Expand All @@ -62,7 +62,7 @@ Array [
Statement {
"object": NamedNode {
"termType": "NamedNode",
"value": "http://www.w3.org/ns/auth/acl#Write",
"value": "http://www.w3.org/ns/auth/acl#write",
},
"predicate": NamedNode {
"termType": "NamedNode",
Expand Down
81 changes: 81 additions & 0 deletions trustedApplications/container.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import * as React from 'react'
import $rdf from 'rdflib'
import vocab from 'solid-namespace'
import { View } from './view'
import { ContainerProps } from '../types'
import { TrustedApplication, Mode } from './model'
import { getStatementsToAdd, getStatementsToDelete, fetchTrustedApps } from './service'

const ns = vocab($rdf)

export const Container: React.FC<ContainerProps> = (props) => {
if (!props.session) {
return <div>You are not logged in</div>
}

const isEditable: boolean = (props.store as any).updater.editable(props.subject.doc().uri, props.store)
if (!isEditable) {
return <div>Your profile {props.subject.doc().uri} is not editable, so we cannot do much here.</div>
}

const fetchedTrustedApps: TrustedApplication[] = fetchTrustedApps(props.store, props.subject, ns)

const [trustedApps, setTrustedApps] = React.useState(fetchedTrustedApps)

const addOrEditApp = (origin: string, modes: Mode[]) => {
const result = new Promise<void>((resolve) => {
const deletions = getStatementsToDelete($rdf.sym(origin), props.subject, props.store, ns)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not a big fan of this level of scoping =/ (This is where I like to rely on classes instead, which of course introduces some challenges of its own.) Just wanted to mention it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Me neither, didn't think too long about this. Ideally, the updater would return a Promise itself. (Well, there's actually code that does, but it's not quite clear to me yet when - might be an avenue to pursue. If we do go ahead with this, I'll see if I can improve this.)

const additions = getStatementsToAdd($rdf.sym(origin), generateRandomString(), modes, props.subject, ns)
props.store.updater!.update(deletions, additions, () => {
const newApp: TrustedApplication = { subject: props.subject.value, origin, modes }
setTrustedApps(insertTrustedApp(newApp, trustedApps))
resolve()
})
})

return result
}

const deleteApp = (origin: string) => {
const result = new Promise<void>((resolve) => {
const deletions = getStatementsToDelete($rdf.sym(origin), props.subject, props.store, ns)
props.store.updater!.update(deletions, [], () => {
setTrustedApps(removeTrustedApp(origin, trustedApps))
resolve()
})
})

return result
}

return (
<section>
<View
apps={trustedApps}
onSaveApp={addOrEditApp}
onDeleteApp={deleteApp}
/>
</section>
)
}

function insertTrustedApp (app: TrustedApplication, into: TrustedApplication[]): TrustedApplication[] {
const index = into.findIndex(found => found.origin === app.origin)
if (index === -1) {
return into.concat(app)
}

return into.slice(0, index)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't it be better to use Array.prototype.splice?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I usually go for methods that do not modify their input parameters, but sure, splice would work too.

.concat(app)
.concat(into.slice(index + 1))
}
function removeTrustedApp (origin: string, from: TrustedApplication[]): TrustedApplication[] {
const index = from.findIndex(found => found.origin === origin)
return (index === -1)
? from
: from.slice(0, index).concat(from.slice(index + 1))
}

function generateRandomString (): string {
return Math.random().toString(36).substring(7)
}
64 changes: 64 additions & 0 deletions trustedApplications/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/* Profile Editing Pane
**
** Unlike most panes, this is available any place whatever the real subject,
** and allows the user to edit their own profile.
**
** Usage: paneRegistry.register('profile/profilePane')
** or standalone script adding onto existing mashlib.
*/

import * as React from 'react'
import * as ReactDOM from 'react-dom'
import solidUi, { SolidUi } from 'solid-ui'
import { IndexedFormula } from 'rdflib'
import paneRegistry from 'pane-registry'

import { PaneDefinition } from '../types'
import { Container } from './container'

const nodeMode = (typeof module !== 'undefined')

let panes
let UI: SolidUi

if (nodeMode) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Know that we have to do this wrt testing, but also would like a better approach at some point.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's not there for testing - it's primarily there because it already was there; see here.

I think it may have to do with having been a Firefox extension or something? Perhaps Tim knows more about it.

UI = solidUi
panes = paneRegistry
} else { // Add to existing mashlib
panes = (window as any).panes
UI = panes.UI
}

const kb: IndexedFormula = UI.store

const thisPane: PaneDefinition = {
icon: UI.icons.iconBase + 'noun_15177.svg', // Looks like an A - could say it's for Applications?

name: 'trustedApplications',

label: function (subject) {
var types = kb.findTypeURIs(subject)
if (types[UI.ns.foaf('Person').uri] || types[UI.ns.vcard('Individual').uri]) {
return 'Manage your trusted applications'
}
return null
},

render: function (subject, _dom) {
const container = document.createElement('div')
UI.authn.solidAuthClient.currentSession().then((session: any) => {
ReactDOM.render(
<Container store={UI.store} subject={subject} session={session}/>,
container
)
})

return container
}
}

export default thisPane
if (!nodeMode) {
console.log('*** patching in live pane: ' + thisPane.name)
panes.register(thisPane)
}
7 changes: 7 additions & 0 deletions trustedApplications/model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export type Mode = 'read' | 'append' | 'write' | 'control'

export interface TrustedApplication {
origin: string
subject: string
modes: Mode[]
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* eslint-env jest */
const $rdf = require('rdflib')
const ns = require('solid-namespace')($rdf)
const { getStatementsToDelete, getStatementsToAdd } = require('./trustedApplicationsUtils')
const { getStatementsToDelete, getStatementsToAdd, deserialiseMode } = require('./service')

describe('getStatementsToDelete', () => {
it('should return an empty array when there are no statements', () => {
Expand Down Expand Up @@ -43,10 +43,19 @@ describe('getStatementsToAdd', () => {
it('should return all required statements to add the given permissions for a given origin', () => {
const mockOrigin = $rdf.sym('https://fake.origin')
const mockProfile = $rdf.sym('https://fake.profile#me')
const modes = [ns.acl('Read'), ns.acl('Write')]
const modes = ['read', 'write']

const statementsToAdd = getStatementsToAdd(mockOrigin, 'mock_app_id', modes, mockProfile, ns)
expect(statementsToAdd.length).toBe(4)
expect(statementsToAdd).toMatchSnapshot()
})
})

describe('deserialiseMode', () => {
it('should convert a full namespaced ACL to a plaintext string', () => {
expect(deserialiseMode($rdf.sym(ns.acl('read')), ns)).toBe('read')
expect(deserialiseMode($rdf.sym(ns.acl('append')), ns)).toBe('append')
expect(deserialiseMode($rdf.sym(ns.acl('write')), ns)).toBe('write')
expect(deserialiseMode($rdf.sym(ns.acl('control')), ns)).toBe('control')
})
})
76 changes: 76 additions & 0 deletions trustedApplications/service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import $rdf, { NamedNode, IndexedFormula, Statement } from 'rdflib'
import { Namespaces } from 'solid-namespace'
import { Mode, TrustedApplication } from './model'

export function getStatementsToDelete (
origin: NamedNode,
person: NamedNode,
kb: IndexedFormula,
ns: Namespaces
) {
const applicationStatements = kb.statementsMatching(null, ns.acl('origin'), origin, null)
const statementsToDelete = applicationStatements.reduce(
(memo, st) => {
return memo
.concat(kb.statementsMatching(person, ns.acl('trustedApp'), st.subject, null, false))
.concat(kb.statementsMatching(st.subject, null, null, null, false))
},
[] as Statement[]
)
return statementsToDelete
}

export function getStatementsToAdd (
origin: NamedNode,
nodeName: string,
modes: Mode[],
person: NamedNode,
ns: Namespaces
) {
var application = new $rdf.BlankNode(`bn_${nodeName}`)
return [
$rdf.st(person, ns.acl('trustedApp'), application, person.doc()),
$rdf.st(application, ns.acl('origin'), origin, person.doc()),
...modes
.map(mode => {
return ns.acl(mode)
})
.map(mode => $rdf.st(application, ns.acl('mode'), mode, person.doc()))
]
}

/* istanbul ignore next [This executes the actual HTTP requests, which is too much effort to test.] */
export function fetchTrustedApps (
store: $rdf.IndexedFormula,
subject: $rdf.NamedNode,
ns: Namespaces
): TrustedApplication[] {
return (store.each(subject, ns.acl('trustedApp'), undefined, undefined) as any)
.flatMap((app: $rdf.NamedNode) => {
return store.each(app, ns.acl('origin'), undefined, undefined)
.map((origin) => {
const modes = store.each(app, ns.acl('mode'), undefined, undefined)
const trustedApp: TrustedApplication = {
origin: origin.value,
subject: subject.value,
modes: modes.map((mode) => deserialiseMode(mode as $rdf.NamedNode, ns))
}
return trustedApp
})
})
.sort((appA: TrustedApplication, appB: TrustedApplication) => (appA.origin > appB.origin) ? 1 : -1)
}

/**
* @param serialisedMode The full IRI of a mode
* @returns A plain text string representing that mode, i.e. 'read', 'append', 'write' or 'control'
*/
export function deserialiseMode (serialisedMode: $rdf.NamedNode, ns: Namespaces): Mode {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@megoth Do you know if there's a built-in way to remove the namespace from an IRI?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You mean something like https://www.w3.org/ns/auth/acl#Read - namespace = Read?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, exactly.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not that I'm aware of, no, but shouldn't be to difficult to create

const deserialisedMode = serialisedMode.value
.replace(ns.acl('read').value, 'read')
.replace(ns.acl('append').value, 'append')
.replace(ns.acl('write').value, 'write')
.replace(ns.acl('control').value, 'control')

return deserialisedMode as Mode
}
Loading