src/authn/authn.ts
/**
* signin.js
*
* Signing in, signing up, profile and preferences reloading
* Type index management
*
* Many functions in this module take a context object, add to it, and return a promise of it.
*/
import SolidTls from 'solid-auth-tls'
import * as $rdf from 'rdflib'
import widgets from '../widgets'
import solidAuthClient from 'solid-auth-client'
import ns from '../ns.js'
import kb from '../store.js'
import utils from '../utils.js'
import log from '../log.js'
import { AppDetails, AuthenticationContext } from './types'
import { PaneDefinition } from 'pane-registry'
export { solidAuthClient }
// const userCheckSite = 'https://databox.me/'
// Look for and load the User who has control over it
export function findOriginOwner (doc: $rdf.NamedNode | string): string | boolean {
const uri = (typeof doc === 'string') ? doc : doc.uri
const i = uri.indexOf('://')
if (i < 0) return false
const j = uri.indexOf('/', i + 3)
if (j < 0) return false
const origin = uri.slice(0, j + 1) // @@ TBC
return origin
}
// Promises versions
//
// These pass a context object which holds various RDF symbols
// as they become available
//
// me RDF symbol for the user's WebID
// publicProfile The user's public profile, iff loaded
// preferencesFile The user's personal preference file, iff loaded
// index.public The user's public type index file
// index.private The user's private type index file
//
// not RDF symbols:
// noun A string in english for the type of thing -- like "address book"
// instance An array of nodes which are existing instances
// containers An array of nodes of containers of instances
// div A DOM element where UI can be displayed
// statusArea A DOM element (opt) progress stuff can be displayed, or error messages
/**
* @param webId
* @param context
*
* @returns Returns the WebID, after setting it
*/
export function saveUser (
webId: $rdf.NamedNode | string | null,
context?: AuthenticationContext
): $rdf.NamedNode | null {
// @@ TODO Remove the need for having context as output argument
let webIdUri: string
if (webId) {
webIdUri = (typeof webId === 'string') ? webId : webId.uri
const me = $rdf.namedNode(webIdUri)
if (context) {
context.me = me
}
return me
}
return null
}
/**
* @returns {NamedNode|null}
*/
export function defaultTestUser (): $rdf.NamedNode | null {
// Check for offline override
const offlineId = offlineTestID()
if (offlineId) {
return offlineId
}
return null
}
/** Checks synchronously whether user is logged in
*
* @returns Named Node or null
*/
export function currentUser (): $rdf.NamedNode | null {
const str = localStorage['solid-auth-client']
if (str) {
const da = JSON.parse(str)
if (da.session && da.session.webId) {
// @@ check has not expired
return $rdf.sym(da.session.webId)
}
}
return offlineTestID() // null unless testing
// JSON.parse(localStorage['solid-auth-client']).session.webId
}
/**
* Resolves with the logged in user's WebID
*
* @param context
*/
export function logIn (context: AuthenticationContext): Promise<AuthenticationContext> {
const me = defaultTestUser() // me is a NamedNode or null
if (me) {
context.me = me
return Promise.resolve(context)
}
return new Promise(resolve => {
checkUser().then(webId => {
// Already logged in?
if (webId) {
context.me = $rdf.sym(webId as string)
console.log(`logIn: Already logged in as ${context.me}`)
return resolve(context)
}
if (!context.div || !context.dom) {
return resolve(context)
}
const box = loginStatusBox(context.dom, webIdUri => {
saveUser(webIdUri, context)
resolve(context) // always pass growing context
})
context.div.appendChild(box)
})
})
}
/**
* Logs the user in and loads their WebID profile document into the store
*
* @param context
*
* @returns Resolves with the context after login / fetch
*/
export function logInLoadProfile (context: AuthenticationContext): Promise<AuthenticationContext> {
if (context.publicProfile) {
return Promise.resolve(context)
} // already done
const fetcher = kb.fetcher
let profileDocument
return new Promise(function (resolve, reject) {
return logIn(context)
.then(context => {
const webID = context.me
if (!webID) {
return reject(new Error('Could not log in'))
}
profileDocument = webID.doc()
// Load the profile into the knowledge base (fetcher.store)
// withCredentials: Web arch should let us just load by turning off creds helps CORS
// reload: Gets around a specific old Chrome bug caching/origin/cors
fetcher
.load(profileDocument, { withCredentials: false, cache: 'reload' })
.then(_response => {
context.publicProfile = profileDocument
resolve(context)
})
.catch(err => {
const message = `Logged in but cannot load profile ${profileDocument} : ${err}`
if (context.div && context.dom) {
context.div.appendChild(
widgets.errorMessageBlock(context.dom, message)
)
}
reject(message)
})
})
.catch(err => {
reject(new Error(`Can't log in: ${err}`))
})
})
}
/**
* Loads preference file
* Do this after having done log in and load profile
*
* @private
*
* @param context
*/
export function logInLoadPreferences (context: AuthenticationContext): Promise<AuthenticationContext> {
if (context.preferencesFile) return Promise.resolve(context) // already done
const statusArea = context.statusArea || context.div || null
let progressDisplay
return new Promise(function (resolve, reject) {
return logInLoadProfile(context)
.then(context => {
const preferencesFile = kb.any(context.me, ns.space('preferencesFile'))
function complain (message) {
message = `logInLoadPreferences: ${message}`
if (statusArea) {
// statusArea.innerHTML = ''
statusArea.appendChild(
widgets.errorMessageBlock(context.dom, message)
)
}
console.log(message)
reject(new Error(message))
}
/**
* Are we working cross-origin?
* Returns True if we are in a webapp at an origin, and the file origin is different
*/
function differentOrigin (): boolean {
return `${window.location.origin}/` !== preferencesFile.site().uri
}
if (!preferencesFile) {
return reject(new Error(`Can't find a preference file pointer in profile ${context.publicProfile}`))
}
// //// Load preference file
return kb.fetcher
.load(preferencesFile, { withCredentials: true })
.then(function () {
if (progressDisplay) {
progressDisplay.parentNode.removeChild(progressDisplay)
}
context.preferencesFile = preferencesFile
return resolve(context)
})
.catch(function (err) {
// Really important to look at why
const status = err.status
const message = err.message
console.log(
`HTTP status ${status} for preference file ${preferencesFile}`
)
let m2
if (status === 401) {
m2 = 'Strange - you are not authenticated (properly logged in) to read preference file.'
alert(m2)
} else if (status === 403) {
if (differentOrigin()) {
m2 = `Unauthorized: Assuming preference file blocked for origin ${window.location.origin}`
context.preferencesFileError = m2
return resolve(context)
}
m2 = 'You are not authorized to read your preference file. This may be because you are using an untrusted web app.'
console.warn(m2)
} else if (status === 404) {
if (
confirm(`You do not currently have a preference file. OK for me to create an empty one? ${preferencesFile}`)
) {
// @@@ code me ... weird to have a name of the file but no file
alert(`Sorry; I am not prepared to do this. Please create an empty file at ${preferencesFile}`)
return complain(
new Error('Sorry; no code yet to create a preference file at ')
)
} else {
reject(
new Error(`User declined to create a preference file at ${preferencesFile}`)
)
}
} else {
m2 = `Strange: Error ${status} trying to read your preference file.${message}`
alert(m2)
}
}) // load preference file then
})
.catch(err => {
// Fail initial login load preferences
reject(new Error(`(via loadPrefs) ${err}`))
})
})
}
/**
* Resolves with the same context, outputting
* output: index.public, index.private
*
* @see https://github.com/solid/solid/blob/master/proposals/data-discovery.md#discoverability
*/
export async function loadTypeIndexes (context: AuthenticationContext): Promise<AuthenticationContext> {
await loadPublicTypeIndex(context)
await loadPrivateTypeIndex(context)
return context
}
async function loadPublicTypeIndex (context: AuthenticationContext): Promise<AuthenticationContext> {
return loadIndex(context, ns.solid('publicTypeIndex'), true)
}
async function loadPrivateTypeIndex (context: AuthenticationContext): Promise<AuthenticationContext> {
return loadIndex(context, ns.solid('privateTypeIndex'), false)
}
async function loadOneTypeIndex (context: AuthenticationContext, isPublic: boolean): Promise<AuthenticationContext> {
const predicate = isPublic
? ns.solid('publicTypeIndex')
: ns.solid('privateTypeIndex')
return loadIndex(context, predicate, isPublic)
}
async function loadIndex (
context: AuthenticationContext,
predicate: $rdf.NamedNode,
isPublic: boolean
): Promise<AuthenticationContext> {
// Loading preferences is more than loading profile
try {
;(await isPublic)
? logInLoadProfile(context)
: logInLoadPreferences(context)
} catch (err) {
widgets.complain(context, `loadPubicIndex: login and load problem ${err}`)
}
const me = context.me
let ixs
context.index = context.index || {}
if (isPublic) {
ixs = kb.each(me, predicate, undefined, context.publicProfile)
context.index.public = ixs
} else {
if (!context.preferencesFileError) {
ixs = kb.each(
me,
ns.solid('privateTypeIndex'),
undefined,
context.preferencesFile
)
context.index.private = ixs
if (ixs.length === 0) {
widgets.complain(`Your preference file ${context.preferencesFile} does not point to a private type index.`)
return context
}
} else {
console.log(
'We know your preference file is not available, so we are not bothering with private type indexes.'
)
}
}
try {
await kb.fetcher.load(ixs)
} catch (err) {
widgets.complain(context, `loadPubicIndex: loading public type index ${err}`)
}
return context
}
/**
* Resolves with the same context, outputting
* @see https://github.com/solid/solid/blob/master/proposals/data-discovery.md#discoverability
*/
async function ensureTypeIndexes (context: AuthenticationContext): Promise<AuthenticationContext> {
await ensureOneTypeIndex(context, true)
await ensureOneTypeIndex(context, false)
return context
}
/* Load or create ONE type index
* Find one or make one or fail
* Many reasons for filing including script not having permission etc
*
*/
/**
* Adds it output to the context
* @see https://github.com/solid/solid/blob/master/proposals/data-discovery.md#discoverability
*/
async function ensureOneTypeIndex (context: AuthenticationContext, isPublic: boolean): Promise<AuthenticationContext | void> {
async function makeIndexIfNecessary (context, isPublic) {
const relevant = isPublic ? context.publicProfile : context.preferencesFile
const visibility = isPublic ? 'public' : 'private'
async function putIndex (newIndex) {
try {
await kb.fetcher.webOperation('PUT', newIndex.uri, {
data: `# ${new Date()} Blank initial Type index
`,
contentType: 'text/turtle'
})
return context
} catch (e) {
const msg = `Error creating new index ${e}`
widgets.complain(context, msg)
}
} // putIndex
context.index = context.index || {}
context.index[visibility] = context.index[visibility] || []
let newIndex
if (context.index[visibility].length === 0) {
newIndex = $rdf.sym(`${relevant.dir().uri + visibility}TypeIndex.ttl`)
console.log(`Linking to new fresh type index ${newIndex}`)
if (!confirm(`OK to create a new empty index file at ${newIndex}, overwriting anything that is now there?`)) {
throw new Error('cancelled by user')
}
console.log(`Linking to new fresh type index ${newIndex}`)
const addMe = [
$rdf.st(context.me, ns.solid(`${visibility}TypeIndex`), newIndex, relevant)
]
try {
await updatePromise(kb.updater, [], addMe)
} catch (err) {
const msg = `Error saving type index link saving back ${newIndex}: ${err}`
widgets.complain(context, msg)
return context
}
console.log(`Creating new fresh type index file${newIndex}`)
await putIndex(newIndex)
context.index[visibility].push(newIndex) // @@ wait
} else {
// officially exists
const ixs = context.index[visibility]
try {
await kb.fetcher.load(ixs)
} catch (err) {
widgets.complain(context, `ensureOneTypeIndex: loading indexes ${err}`)
}
}
} // makeIndexIfNecessary
try {
await loadOneTypeIndex(context, isPublic)
if (context.index) {
console.log(
`ensureOneTypeIndex: Type index exists already ${isPublic}`
? context.index.public[0]
: context.index.private[0]
)
}
return context
} catch (error) {
await makeIndexIfNecessary(context, isPublic)
// widgets.complain(context, 'calling loadOneTypeIndex:' + error)
}
}
/**
* Returns promise of context with arrays of symbols
*
* 2016-12-11 change to include forClass arc a la
* https://github.com/solid/solid/blob/master/proposals/data-discovery.md
*/
export async function findAppInstances (
context: AuthenticationContext,
klass: $rdf.NamedNode,
isPublic: boolean
): Promise<AuthenticationContext> {
const fetcher = kb.fetcher
if (isPublic === undefined) {
// Then both public and private
await findAppInstances(context, klass, true)
await findAppInstances(context, klass, false)
return context
}
const visibility = isPublic ? 'public' : 'private'
try {
await loadOneTypeIndex(context, isPublic)
} catch (err) {
}
const index = context.index as { [key: string]: Array<$rdf.NamedNode> }
const thisIndex = index[visibility]
const registrations = thisIndex
.map(ix => kb.each(undefined, ns.solid('forClass'), klass, ix))
.flat()
const instances = registrations
.map(reg => kb.each(reg, ns.solid('instance')))
.flat()
const containers = registrations
.map(reg => kb.each(reg, ns.solid('instanceContainer')))
.flat()
context.instances = context.instances || []
context.instances = context.instances.concat(instances)
context.containers = context.containers || []
context.containers = context.containers.concat(containers)
if (!containers.length) {
return context
}
// If the index gives containers, then look up all things within them
try {
await fetcher.load(containers)
} catch (err) {
const e = new Error(`[FAI] Unable to load containers${err}`)
console.log(e) // complain
widgets.complain(context, `Error looking for ${utils.label(klass)}: ${err}`)
// but then ignore it
// throw new Error(e)
}
for (let i = 0; i < containers.length; i++) {
const cont = containers[i]
context.instances = context.instances.concat(
kb.each(cont, ns.ldp('contains'))
)
}
return context
}
// @@@@ use the one in rdflib.js when it is available and delete this
function updatePromise (
updater: $rdf.UpdateManager,
del: Array<$rdf.Statement>,
ins: Array<$rdf.Statement> = []
): Promise<void> {
return new Promise(function (resolve, reject) {
updater.update(del, ins, function (uri, ok, errorBody) {
if (!ok) {
reject(new Error(errorBody))
} else {
resolve()
}
}) // callback
}) // promise
}
/* Register a new app in a type index
*/
export async function registerInTypeIndex (
context: AuthenticationContext,
instance: $rdf.NamedNode,
klass: $rdf.NamedNode,
isPublic: boolean
): Promise<AuthenticationContext> {
await ensureOneTypeIndex(context, isPublic)
if (!context.index) {
throw new Error('registerInTypeIndex: No type index found')
}
const indexes = isPublic ? context.index.public : context.index.private
if (!indexes.length) {
throw new Error('registerInTypeIndex: What no type index?')
}
const index = indexes[0]
const registration = widgets.newThing(index)
const ins = [
// See https://github.com/solid/solid/blob/master/proposals/data-discovery.md
$rdf.st(registration, ns.rdf('type'), ns.solid('TypeRegistration'), index),
$rdf.st(registration, ns.solid('forClass'), klass, index),
$rdf.st(registration, ns.solid('instance'), instance, index)
]
try {
await updatePromise(kb.updater, [], ins)
} catch (e) {
console.log(e)
alert(e)
}
return context
}
/**
* UI to control registration of instance
*/
export function registrationControl (
context: AuthenticationContext,
instance,
klass
): Promise<AuthenticationContext | void> {
const dom = context.dom
if (!dom || !context.div) {
return Promise.resolve()
}
const box = dom.createElement('div')
context.div.appendChild(box)
return ensureTypeIndexes(context)
.then(function () {
box.innerHTML = '<table><tbody><tr></tr><tr></tr></tbody></table>' // tbody will be inserted anyway
box.setAttribute('style', 'font-size: 120%; text-align: right; padding: 1em; border: solid gray 0.05em;')
const tbody = box.children[0].children[0]
const form = kb.bnode() // @@ say for now
const registrationStatements = function (index) {
const registrations = kb
.each(undefined, ns.solid('instance'), instance)
.filter(function (r) {
return kb.holds(r, ns.solid('forClass'), klass)
})
const reg = registrations.length
? registrations[0]
: widgets.newThing(index)
return [
$rdf.st(reg, ns.solid('instance'), instance, index),
$rdf.st(reg, ns.solid('forClass'), klass, index)
]
}
let index, statements
if (context.index && context.index.public && context.index.public.length > 0) {
index = context.index.public[0]
statements = registrationStatements(index)
tbody.children[0].appendChild(
widgets.buildCheckboxForm(
context.dom,
kb,
`Public link to this ${context.noun}`,
null,
statements,
form,
index
)
)
}
if (context.index && context.index.private && context.index.private.length > 0) {
index = context.index.private[0]
statements = registrationStatements(index)
tbody.children[1].appendChild(
widgets.buildCheckboxForm(
context.dom,
kb,
`Personal note of this ${context.noun}`,
null,
statements,
form,
index
)
)
}
return context
},
function (e) {
let msg
if (context.div && context.preferencesFileError) {
msg = '(Preferences not available)'
context.div.appendChild(dom.createElement('p')).textContent = msg
} else if (context.div) {
msg = `registrationControl: Type indexes not available: ${e}`
context.div.appendChild(widgets.errorMessageBlock(context.dom, e))
}
console.log(msg)
}
)
.catch(function (e) {
const msg = `registrationControl: Error making panel: ${e}`
if (context.div) {
context.div.appendChild(widgets.errorMessageBlock(context.dom, e))
}
console.log(msg)
})
}
/**
* UI to List at all registered things
*/
export function registrationList (context: AuthenticationContext, options: {
private?: boolean
public?: boolean
}): Promise<AuthenticationContext> {
const dom = context.dom as HTMLDocument
const div = context.div as HTMLElement
const box = dom.createElement('div')
div.appendChild(box)
return ensureTypeIndexes(context).then(_indexes => {
box.innerHTML = '<table><tbody></tbody></table>' // tbody will be inserted anyway
box.setAttribute('style', 'font-size: 120%; text-align: right; padding: 1em; border: solid #eee 0.5em;')
const table = box.firstChild as HTMLElement
let ix: Array<$rdf.NamedNode> = []
let sts = []
const vs = ['private', 'public']
vs.forEach(function (visibility) {
if (context.index && options[visibility]) {
ix = ix.concat(context.index[visibility][0])
sts = sts.concat(
kb.statementsMatching(
undefined,
ns.solid('instance'),
undefined,
context.index[visibility][0]
)
)
}
})
for (let i = 0; i < sts.length; i++) {
const statement: $rdf.Statement = sts[i]
// const cla = statement.subject
const inst = statement.object
// if (false) {
// const tr = table.appendChild(dom.createElement('tr'))
// const anchor = tr.appendChild(dom.createElement('a'))
// anchor.setAttribute('href', inst.uri)
// anchor.textContent = utils.label(inst)
// } else {
// }
table.appendChild(widgets.personTR(dom, ns.solid('instance'), inst, {
deleteFunction: function (_x) {
kb.updater.update([statement], [], function (uri, ok, errorBody) {
if (ok) {
console.log(`Removed from index: ${statement.subject}`)
} else {
console.log(`Error: Cannot delete ${statement}: ${errorBody}`)
}
})
}
}))
}
/*
//const containers = kb.each(klass, ns.solid('instanceContainer'));
if (containers.length) {
fetcher.load(containers).then(function(xhrs){
for (const i=0; i<containers.length; i++) {
const cont = containers[i];
instances = instances.concat(kb.each(cont, ns.ldp('contains')));
}
});
}
*/
return context
})
}
/**
* Simple Access Control
*
* This function sets up a simple default ACL for a resource, with
* RWC for the owner, and a specified access (default none) for the public.
* In all cases owner has read write control.
* Parameter lists modes allowed to public
*
* @param options
* @param options.public eg ['Read', 'Write']
*
* @returns Resolves with aclDoc uri on successful write
*/
export function setACLUserPublic (
docURI: $rdf.NamedNode,
me: $rdf.NamedNode,
options: {
defaultForNew?: boolean,
public?: []
}
): Promise<$rdf.NamedNode> {
const aclDoc = kb.any(
kb.sym(docURI),
kb.sym('http://www.iana.org/assignments/link-relations/acl')
)
return Promise.resolve()
.then(() => {
if (aclDoc) {
return aclDoc
}
return fetchACLRel(docURI).catch(err => {
throw new Error(`Error fetching rel=ACL header for ${docURI}: ${err}`)
})
})
.then(aclDoc => {
const aclText = genACLText(docURI, me, aclDoc.uri, options)
return kb.fetcher
.webOperation('PUT', aclDoc.uri, {
data: aclText,
contentType: 'text/turtle'
})
.then(result => {
if (!result.ok) {
throw new Error('Error writing ACL text: ' + result.error)
}
return aclDoc
})
})
}
/**
* @param docURI
* @returns
*/
function fetchACLRel (docURI: $rdf.NamedNode): Promise<$rdf.NamedNode> {
const fetcher = kb.fetcher
return fetcher.load(docURI).then(result => {
if (!result.ok) {
throw new Error('fetchACLRel: While loading:' + result.error)
}
const aclDoc = kb.any(
kb.sym(docURI),
kb.sym('http://www.iana.org/assignments/link-relations/acl')
)
if (!aclDoc) {
throw new Error('fetchACLRel: No Link rel=ACL header for ' + docURI)
}
return aclDoc
})
}
/**
* @param docURI
* @param me
* @param aclURI
* @param options
*
* @returns Serialized ACL
*/
function genACLText (
docURI: $rdf.NamedNode,
me: $rdf.NamedNode,
aclURI: $rdf.NamedNode,
options: {
defaultForNew?: boolean,
public?: []
} = {}
): string {
const optPublic = options.public || []
const g = $rdf.graph()
const auth = $rdf.Namespace('http://www.w3.org/ns/auth/acl#')
let a = g.sym(`${aclURI}#a1`)
const acl = g.sym(aclURI)
const doc = g.sym(docURI)
g.add(a, ns.rdf('type'), auth('Authorization'), acl)
g.add(a, auth('accessTo'), doc, acl)
if (options.defaultForNew) {
// TODO: Should this be auth('default') instead?
g.add(a, auth('defaultForNew'), doc, acl)
}
g.add(a, auth('agent'), me, acl)
g.add(a, auth('mode'), auth('Read'), acl)
g.add(a, auth('mode'), auth('Write'), acl)
g.add(a, auth('mode'), auth('Control'), acl)
if (optPublic.length) {
a = g.sym(`${aclURI}#a2`)
g.add(a, ns.rdf('type'), auth('Authorization'), acl)
g.add(a, auth('accessTo'), doc, acl)
g.add(a, auth('agentClass'), ns.foaf('Agent'), acl)
for (let p = 0; p < optPublic.length; p++) {
g.add(a, auth('mode'), auth(optPublic[p]), acl) // Like 'Read' etc
}
}
// @@ TODO Remove casting of $rdf
return ($rdf as any).serialize(acl, g, aclURI, 'text/turtle')
}
/**
* @returns {NamedNode|null}
*/
export function offlineTestID (): $rdf.NamedNode | null {
const { $SolidTestEnvironment }: any = window
if (
typeof $SolidTestEnvironment !== 'undefined' &&
$SolidTestEnvironment.username
) {
// Test setup
console.log('Assuming the user is ' + $SolidTestEnvironment.username)
return $rdf.sym($SolidTestEnvironment.username)
}
if (
typeof document !== 'undefined' &&
document.location &&
('' + document.location).slice(0, 16) === 'http://localhost'
) {
const div = document.getElementById('appTarget')
if (!div) return null
const id = div.getAttribute('testID')
if (!id) return null
/* me = kb.any(subject, ns.acl('owner')); // when testing on plane with no WebID
*/
console.log('Assuming user is ' + id)
return $rdf.sym(id)
}
return null
}
function getDefaultSignInButtonStyle (): string {
return 'padding: 1em; border-radius:0.5em; margin: 2em; font-size: 100%;'
}
/**
* Bootstrapping identity
* (Called by `loginStatusBox()`)
*
* @param dom
* @param setUserCallback
*
* @returns
*/
function signInOrSignUpBox (
dom: HTMLDocument,
setUserCallback: (user: string) => void,
options: {
buttonStyle?: string
} = {}
): HTMLElement {
options = options || {}
const signInButtonStyle = options.buttonStyle || getDefaultSignInButtonStyle()
// @@ TODO Remove the need to cast HTML element to any
const box: any = dom.createElement('div')
const magicClassName = 'SolidSignInOrSignUpBox'
console.log('widgets.signInOrSignUpBox')
box.setUserCallback = setUserCallback
box.setAttribute('class', magicClassName)
box.style = 'display:flex;'
// Sign in button with PopUP
const signInPopUpButton = dom.createElement('input') // multi
box.appendChild(signInPopUpButton)
signInPopUpButton.setAttribute('type', 'button')
signInPopUpButton.setAttribute('value', 'Log in')
signInPopUpButton.setAttribute('style', `${signInButtonStyle}background-color: #eef;`)
signInPopUpButton.addEventListener('click', () => {
const offline = offlineTestID()
if (offline) return setUserCallback(offline.uri)
return solidAuthClient.popupLogin().then(session => {
const webIdURI = session.webId
// setUserCallback(webIdURI)
const divs = dom.getElementsByClassName(magicClassName)
console.log(`Logged in, ${divs.length} panels to be serviced`)
// At the same time, satisfy all the other login boxes
for (let i = 0; i < divs.length; i++) {
const div: any = divs[i]
// @@ TODO Remove the need to manipulate HTML elements
if (div.setUserCallback) {
try {
div.setUserCallback(webIdURI)
const parent = div.parentNode
if (parent) {
parent.removeChild(div)
}
} catch (e) {
console.log(`## Error satisfying login box: ${e}`)
div.appendChild(widgets.errorMessageBlock(dom, e))
}
}
}
})
}, false)
// Sign up button
const signupButton = dom.createElement('input')
box.appendChild(signupButton)
signupButton.setAttribute('type', 'button')
signupButton.setAttribute('value', 'Sign Up for Solid')
signupButton.setAttribute('style', `${signInButtonStyle}background-color: #efe;`)
signupButton.addEventListener('click', function (_event) {
const signupMgr = new SolidTls.Signup()
signupMgr.signup().then(function (uri) {
console.log('signInOrSignUpBox signed up ' + uri)
setUserCallback(uri)
})
}, false)
return box
}
/**
* @returns {Promise<string|null>} Resolves with WebID URI or null
*/
function webIdFromSession (session?: { webId: string }): string | null {
const webId = session ? session.webId : null
if (webId) {
saveUser(webId)
}
return webId
}
/**
* @returns {Promise<string|null>} Resolves with WebID URI or null
*/
/*
function checkCurrentUser () {
return checkUser()
}
*/
/**
* @param [setUserCallback] Optional callback
*
* @returns Resolves with web id uri, if no callback provided
*/
export function checkUser<T> (
setUserCallback?: (me: $rdf.NamedNode | null) => T
): Promise<$rdf.NamedNode | T> {
// Check to see if already logged in / have the WebID
const me = defaultTestUser()
if (me) {
return Promise.resolve(setUserCallback ? setUserCallback(me) : me)
}
// doc = kb.any(doc, ns.link('userMirror')) || doc
return solidAuthClient
.currentSession()
.then(webIdFromSession)
.catch(err => {
console.log('Error fetching currentSession:', err)
})
.then(webId => {
// if (webId.startsWith('dns:')) { // legacy rww.io pseudo-users
// webId = null
// }
const me = saveUser(webId)
if (me) {
console.log(`(Logged in as ${me} by authentication)`)
}
return setUserCallback ? setUserCallback(me) : me
})
}
/**
* Login status box
*
* A big sign-up/sign in box or a logout box depending on the state
*
* @param dom
* @param listener
*
* @returns
*/
export function loginStatusBox (
dom: HTMLDocument,
listener: ((uri: string | null) => void) | null = null,
options: {
buttonStyle?: string
} = {}
): HTMLElement {
// 20190630
let me = defaultTestUser()
// @@ TODO Remove the need to cast HTML element to any
const box: any = dom.createElement('div')
function setIt (newidURI) {
if (!newidURI) {
return
}
const uri = newidURI.uri || newidURI
// UI.preferences.set('me', uri)
me = $rdf.sym(uri)
box.refresh()
if (listener) listener(me.uri)
}
function logoutButtonHandler (_event) {
// UI.preferences.set('me', '')
solidAuthClient.logout().then(
function () {
const message = `Your WebID was ${me}. It has been forgotten.`
me = null
try {
log.alert(message)
} catch (e) {
window.alert(message)
}
box.refresh()
if (listener) listener(null)
},
err => {
alert('Fail to log out:' + err)
}
)
}
function logoutButton (me, options) {
const signInButtonStyle = options.buttonStyle || getDefaultSignInButtonStyle()
let logoutLabel = 'WebID logout'
if (me) {
const nick =
kb.any(me, ns.foaf('nick')) ||
kb.any(me, ns.foaf('name'))
if (nick) {
logoutLabel = 'Logout ' + nick.value
}
}
const signOutButton = dom.createElement('input')
// signOutButton.className = 'WebIDCancelButton'
signOutButton.setAttribute('type', 'button')
signOutButton.setAttribute('value', logoutLabel)
signOutButton.setAttribute('style', `${signInButtonStyle}background-color: #eee;`)
signOutButton.addEventListener('click', logoutButtonHandler, false)
return signOutButton
}
box.refresh = function () {
solidAuthClient.currentSession().then(
session => {
if (session && session.webId) {
me = $rdf.sym(session.webId)
} else {
me = null
}
if ((me && box.me !== me.uri) || (!me && box.me)) {
widgets.clearElement(box)
if (me) {
box.appendChild(logoutButton(me, options))
} else {
box.appendChild(signInOrSignUpBox(dom, setIt, options))
}
}
box.me = me ? me.uri : null
},
err => {
alert(`loginStatusBox: ${err}`)
}
)
}
if (solidAuthClient.trackSession) {
solidAuthClient.trackSession(session => {
if (session && session.webId) {
me = $rdf.sym(session.webId)
} else {
me = null
}
box.refresh()
})
}
box.me = '99999' // Force refresh
box.refresh()
return box
}
/**
* Workspace selection etc
*/
/**
* Returns a UI object which, if it selects a workspace,
* will callback(workspace, newBase).
*
* If necessary, will get an account, preference file, etc. In sequence:
*
* - If not logged in, log in.
* - Load preference file
* - Prompt user for workspaces
* - Allows the user to just type in a URI by hand
*
* Calls back with the ws and the base URI
*
* @param dom
* @param appDetails
* @param callbackWS
*/
export function selectWorkspace (
dom: HTMLDocument,
appDetails: AppDetails,
callbackWS: (workspace: string | null, newBase: string) => void
): HTMLElement {
const noun = appDetails.noun
const appPathSegment = appDetails.appPathSegment
const me = defaultTestUser()
const box = dom.createElement('div')
const context: AuthenticationContext = { me: me, dom: dom, div: box }
function say (s) {
box.appendChild(widgets.errorMessageBlock(dom, s))
}
function figureOutBase (ws) {
let newBase = kb.any(ws, ns.space('uriPrefix'))
if (!newBase) {
newBase = ws.uri.split('#')[0]
} else {
newBase = newBase.value
}
if (newBase.slice(-1) !== '/') {
console.log(`${appPathSegment}: No / at end of uriPrefix ${newBase}`) // @@ paramater?
newBase = `${newBase}/`
}
const now = new Date()
newBase += `${appPathSegment}/id${now.getTime()}/` // unique id
return newBase
}
function displayOptions (context) {
// const status = ''
const id = context.me
const preferencesFile = context.preferencesFile
let newBase = null
// A workspace specifically defined in the private preference file:
let w = kb
.statementsMatching(
id,
ns.space('workspace'), // Only trust preference file here
undefined,
preferencesFile
)
.map(function (st) {
return st.object
})
// A workspace in a storage in the public profile:
const storages = kb.each(id, ns.space('storage')) // @@ No provenance requirement at the moment
storages.map(function (s) {
w = w.concat(kb.each(s, ns.ldp('contains')))
})
if (w.length === 1) {
say(`Workspace used: ${w[0].uri}`) // @@ allow user to see URI
newBase = figureOutBase(w[0])
// callbackWS(w[0], newBase)
} else if (w.length === 0) {
say(`You don't seem to have any workspaces. You have ${storages.length} storage spaces.`)
}
// Prompt for ws selection or creation
// say( w.length + " workspaces for " + id + "Choose one.");
const table = dom.createElement('table')
table.setAttribute('style', 'border-collapse:separate; border-spacing: 0.5em;')
// const popup = window.open(undefined, '_blank', { height: 300, width:400 }, false)
box.appendChild(table)
// Add a field for directly adding the URI yourself
// const hr = box.appendChild(dom.createElement('hr')) // @@
box.appendChild(dom.createElement('hr')) // @@
const p = box.appendChild(dom.createElement('p'))
p.textContent = `Where would you like to store the data for the ${noun}? Give the URL of the directory where you would like the data stored.`
// @@ TODO Remove the need to cast baseField to any
const baseField: any = box.appendChild(dom.createElement('input'))
baseField.setAttribute('type', 'text')
baseField.size = 80 // really a string
baseField.label = 'base URL'
baseField.autocomplete = 'on'
if (newBase) {
// set to default
baseField.value = newBase
}
context.baseField = baseField
box.appendChild(dom.createElement('br')) // @@
const button = box.appendChild(dom.createElement('button'))
button.textContent = `Start new ${noun} at this URI`
button.addEventListener('click', function (_event) {
let newBase = baseField.value
if (newBase.slice(-1) !== '/') {
newBase += '/'
}
callbackWS(null, newBase)
})
// Now go set up the table of spaces
// const row = 0
w = w.filter(function (x) {
return !kb.holds(
x,
ns.rdf('type'), // Ignore master workspaces
ns.space('MasterWorkspace')
)
})
let col1, col2, col3, tr, ws, style, comment
const cellStyle = 'height: 3em; margin: 1em; padding: 1em white; border-radius: 0.3em;'
const deselectedStyle = `${cellStyle}border: 0px;`
// const selectedStyle = cellStyle + 'border: 1px solid black;'
for (let i = 0; i < w.length; i++) {
ws = w[i]
tr = dom.createElement('tr')
if (i === 0) {
col1 = dom.createElement('td')
col1.setAttribute('rowspan', `${w.length}1`)
col1.textContent = 'Choose a workspace for this:'
col1.setAttribute('style', 'vertical-align:middle;')
tr.appendChild(col1)
}
col2 = dom.createElement('td')
style = kb.any(ws, ns.ui('style'))
if (style) {
style = style.value
} else {
// Otherwise make up arbitrary colour
const hash = function (x) {
return x.split('').reduce(function (a, b) {
a = (a << 5) - a + b.charCodeAt(0)
return a & a
}, 0)
}
const bgcolor = `#${((hash(ws.uri) & 0xffffff) | 0xc0c0c0).toString(16)}` // c0c0c0 forces pale
style = `color: black ; background-color: ${bgcolor};`
}
col2.setAttribute('style', deselectedStyle + style)
tr.target = ws.uri
let label = kb.any(ws, ns.rdfs('label'))
if (!label) {
label = ws.uri.split('/').slice(-1)[0] || ws.uri.split('/').slice(-2)[0]
}
col2.textContent = label || '???'
tr.appendChild(col2)
if (i === 0) {
col3 = dom.createElement('td')
col3.setAttribute('rowspan', `${w.length}1`)
// col3.textContent = '@@@@@ remove';
col3.setAttribute('style', 'width:50%;')
tr.appendChild(col3)
}
table.appendChild(tr)
comment = kb.any(ws, ns.rdfs('comment'))
comment = comment ? comment.value : 'Use this workspace'
col2.addEventListener('click', function (_event) {
col3.textContent = comment ? comment.value : ''
col3.setAttribute('style', deselectedStyle + style)
const button = dom.createElement('button')
button.textContent = 'Continue'
// button.setAttribute('style', style);
const newBase = figureOutBase(ws)
baseField.value = newBase // show user proposed URI
button.addEventListener('click', function (_event) {
button.disabled = true
callbackWS(ws, newBase)
button.textContent = '---->'
}, true) // capture vs bubble
col3.appendChild(button)
}, true) // capture vs bubble
}
// last line with "Make new workspace"
const trLast = dom.createElement('tr')
col2 = dom.createElement('td')
col2.setAttribute('style', cellStyle)
col2.textContent = '+ Make a new workspace'
// addMyListener(col2, 'Set up a new workspace', '') // @@ TBD
trLast.appendChild(col2)
table.appendChild(trLast)
} // displayOptions
logInLoadPreferences(context) // kick off async operation
.then(displayOptions)
.catch(err => {
box.appendChild(widgets.errorMessageBlock(err))
})
return box // return the box element, while login proceeds
} // selectWorkspace
/**
* Creates a new instance of an app.
*
* An instance of an app could be e.g. an issue tracker for a given project,
* or a chess game, or calendar, or a health/fitness record for a person.
*
* @param dom
* @param appDetails
* @param callback
*
* @returns A div with a button in it for making a new app instance
*/
export function newAppInstance (
dom: HTMLDocument,
appDetails: AppDetails,
callback: (workspace: string | null, newBase: string) => void
): HTMLElement {
const gotWS = function (ws, base) {
// $rdf.log.debug("newAppInstance: Selected workspace = " + (ws? ws.uri : 'none'))
callback(ws, base)
}
const div = dom.createElement('div')
const b = dom.createElement('button')
b.setAttribute('type', 'button')
div.appendChild(b)
b.innerHTML = `Make new ${appDetails.noun}`
b.addEventListener('click', _event => {
div.appendChild(selectWorkspace(dom, appDetails, gotWS))
}, false)
div.appendChild(b)
return div
}
export async function getUserRoles (): Promise<Array<$rdf.NamedNode>> {
try {
const {
me,
preferencesFile,
preferencesFileError
} = await logInLoadPreferences({})
if (!preferencesFile || preferencesFileError) {
throw new Error(preferencesFileError)
}
return kb.each(me, ns.rdf('type'), null, preferencesFile.doc())
} catch (error) {
console.warn('Unable to fetch your preferences - this was the error: ', error)
}
return []
}
export async function filterAvailablePanes (panes: Array<PaneDefinition>): Promise<Array<PaneDefinition>> {
const userRoles = await getUserRoles()
return panes.filter(pane => isMatchingAudience(pane, userRoles))
}
function isMatchingAudience (pane: PaneDefinition, userRoles: Array<$rdf.NamedNode>): boolean {
const audience = pane.audience || []
return audience.reduce(
(isMatch, audienceRole) => isMatch && !!userRoles.find(role => role.equals(audienceRole)),
true as boolean
)
}