diff --git a/deploy/app.js b/deploy/app.js index 95d712170150e7a629c19e03348df9e4aaffc022..1eeaf6a788a0076e67066abe0149b562c64d3c50 100644 --- a/deploy/app.js +++ b/deploy/app.js @@ -80,6 +80,11 @@ app.use((_req, res, next) => { */ app.use(require('./devBanner')) +/** + * User route, for user profile/management + */ +app.use('/user', require('./user')) + /** * only use compression for production * this allows locally built aot to be served without errors diff --git a/deploy/auth/hbp-oidc-v2.js b/deploy/auth/hbp-oidc-v2.js new file mode 100644 index 0000000000000000000000000000000000000000..a49018f85a4c34d38021356989302cf445ffc176 --- /dev/null +++ b/deploy/auth/hbp-oidc-v2.js @@ -0,0 +1,44 @@ +const passport = require('passport') +const { configureAuth } = require('./oidc') + +const HOSTNAME = process.env.HOSTNAME || 'http://localhost:3000' +const HOST_PATHNAME = process.env.HOST_PATHNAME || '' +const clientId = process.env.HBP_CLIENTID_V2 || 'no hbp id' +const clientSecret = process.env.HBP_CLIENTSECRET_V2 || 'no hbp client secret' +const discoveryUrl = 'https://iam.humanbrainproject.eu/auth/realms/hbp' +const redirectUri = `${HOSTNAME}${HOST_PATHNAME}/hbp-oidc-v2/cb` +const cb = (tokenset, {sub, given_name, family_name, ...rest}, done) => { + return done(null, { + id: `hbp-oidc-v2:${sub}`, + name: `${given_name} ${family_name}`, + type: `hbp-oidc-v2`, + tokenset, + rest + }) +} + +module.exports = async (app) => { + try { + const { oidcStrategy } = await configureAuth({ + clientId, + clientSecret, + discoveryUrl, + redirectUri, + cb, + scope: 'openid email offline_access profile collab.drive', + clientConfig: { + redirect_uris: [ redirectUri ], + response_types: [ 'code' ] + } + }) + + passport.use('hbp-oidc-v2', oidcStrategy) + app.get('/hbp-oidc-v2/auth', passport.authenticate('hbp-oidc-v2')) + app.get('/hbp-oidc-v2/cb', passport.authenticate('hbp-oidc-v2', { + successRedirect: `${HOST_PATHNAME}/`, + failureRedirect: `${HOST_PATHNAME}/` + })) + } catch (e) { + console.error(e) + } +} diff --git a/deploy/auth/index.js b/deploy/auth/index.js index 0ad0941dfe9804a442d131130410d6a4098acaea..a2e6c45640632b543fbd86190c4c54956a51c9b8 100644 --- a/deploy/auth/index.js +++ b/deploy/auth/index.js @@ -1,4 +1,5 @@ const hbpOidc = require('./hbp-oidc') +const hbpOidc2 = require('./hbp-oidc-v2') const passport = require('passport') const objStoreDb = new Map() const HOST_PATHNAME = process.env.HOST_PATHNAME || '' @@ -20,14 +21,7 @@ module.exports = async (app) => { }) await hbpOidc(app) - - app.get('/user', (req, res) => { - if (req.user) { - return res.status(200).send(JSON.stringify(req.user)) - } else { - return res.status(401).end() - } - }) + await hbpOidc2(app) app.get('/logout', (req, res) => { if (req.user && req.user.id) objStoreDb.delete(req.user.id) diff --git a/deploy/package.json b/deploy/package.json index 4bded39e142c1a46fde997ade7384073b5a35b1f..c8c7e0ecfad411ef5a7bc071a2d63ae962fcf362 100644 --- a/deploy/package.json +++ b/deploy/package.json @@ -18,6 +18,7 @@ "body-parser": "^1.19.0", "express": "^4.16.4", "express-session": "^1.15.6", + "hbp-seafile": "0.0.6", "helmet-csp": "^2.8.0", "jwt-decode": "^2.2.0", "memorystore": "^1.6.1", diff --git a/deploy/user/index.js b/deploy/user/index.js new file mode 100644 index 0000000000000000000000000000000000000000..9d1809c963bfa07e6f518991f567f0b4e5af4137 --- /dev/null +++ b/deploy/user/index.js @@ -0,0 +1,37 @@ +const router = require('express').Router() +const { readUserData, saveUserData } = require('./store') +const bodyParser = require('body-parser') + +const loggedInOnlyMiddleware = (req, res, next) => { + const { user } = req + if (!user) return res.status(401).end() + return next() +} + +router.get('', loggedInOnlyMiddleware, (req, res) => { + return res.status(200).send(JSON.stringify(req.user)) +}) + +router.get('/config', loggedInOnlyMiddleware, async (req, res) => { + const { user } = req + try{ + const data = await readUserData(user) + res.status(200).json(data) + } catch (e){ + console.error(e) + res.status(500).send(e.toString()) + } +}) + +router.post('/config', loggedInOnlyMiddleware, bodyParser.json(), async (req, res) => { + const { user, body } = req + try { + await saveUserData(user, body) + res.status(200).end() + } catch (e) { + console.error(e) + res.status(500).send(e.toString()) + } +}) + +module.exports = router \ No newline at end of file diff --git a/deploy/user/store.js b/deploy/user/store.js new file mode 100644 index 0000000000000000000000000000000000000000..93c9db2b1adce8baa58ecf4fca6bfecaecfc650c --- /dev/null +++ b/deploy/user/store.js @@ -0,0 +1,68 @@ +const { Seafile } = require('hbp-seafile') +const { Readable } = require('stream') + +const IAV_DIR_NAME = `interactive-atlas-viewer` +const IAV_DIRECTORY = `/${IAV_DIR_NAME}/` +const IAV_FILENAME = 'data.json' + +const getNewSeafilehandle = async ({ accessToken }) => { + const seafileHandle = new Seafile({ accessToken }) + await seafileHandle.init() + return seafileHandle +} + +const saveUserData = async (user, data) => { + const { access_token } = user && user.tokenset || {} + if (!access_token) throw new Error(`user or user.tokenset not set can only save logged in user data`) + + let handle = await getNewSeafilehandle({ accessToken: access_token }) + + const s = await handle.ls() + const found = s.find(({ type, name }) => type === 'dir' && name === IAV_DIR_NAME) + + // if dir exists, check permission. throw if no writable or readable permission + if (found && !/w/.test(found.permission) && !/r/.test(found.permission)){ + throw new Error(`Writing to file not permitted. Current permission: ${found.permission}`) + } + + // create new dir if does not exist. Should have rw permission + if (!found) { + await handle.mkdir({ dir: IAV_DIR_NAME }) + } + + const fileLs = await handle.ls({ dir: IAV_DIRECTORY }) + const fileFound = fileLs.find(({ type, name }) => type === 'file' && name === IAV_FILENAME ) + + const rStream = new Readable() + rStream.path = IAV_FILENAME + rStream.push(JSON.stringify(data)) + rStream.push(null) + + if(!fileFound) { + return handle.uploadFile({ readStream: rStream, filename: `${IAV_FILENAME}` }, { dir: IAV_DIRECTORY }) + } + + if (fileFound && !/w/.test(fileFound.permission)) { + return new Error('file permission cannot be written') + } + + return handle.updateFile({ dir: IAV_DIRECTORY, replaceFilepath: `${IAV_DIRECTORY}${IAV_FILENAME}` }, { readStream: rStream, filename: IAV_FILENAME }) +} + +const readUserData = async (user) => { + const { access_token } = user && user.tokenset || {} + if (!access_token) throw new Error(`user or user.tokenset not set can only save logged in user data`) + + let handle = await getNewSeafilehandle({ accessToken: access_token }) + try { + const r = await handle.readFile({ dir: `${IAV_DIRECTORY}${IAV_FILENAME}` }) + return JSON.parse(r) + }catch(e){ + return {} + } +} + +module.exports = { + saveUserData, + readUserData +} diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index 81add5950f138e5be60b233086dc4f0ddf9dd9b8..fdec1e6ef5ec008b4d4bb01f8a3897ff4c482934 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -19,7 +19,10 @@ export class AuthService implements OnDestroy { */ public loginMethods: IAuthMethod[] = [{ name: 'HBP OIDC', - href: 'hbp-oidc/auth', + href: 'hbp-oidc/auth' + }, { + name: 'HBP OIDC v2 (beta)', + href: 'hbp-oidc-v2/auth' }] constructor(private httpClient: HttpClient) { diff --git a/src/services/state/ngViewerState.store.ts b/src/services/state/ngViewerState.store.ts index 53fe04a19c044f2bf9a02a03db8edfd8a051fa1e..1e93b4ee8e44bba5aec691c20fde79f87165a301 100644 --- a/src/services/state/ngViewerState.store.ts +++ b/src/services/state/ngViewerState.store.ts @@ -1,11 +1,11 @@ import { Injectable, OnDestroy } from '@angular/core'; -import { Actions, Effect, ofType } from '@ngrx/effects'; -import { Action, select, Store } from '@ngrx/store' -import { combineLatest, fromEvent, Observable, Subscription } from 'rxjs'; -import { distinctUntilChanged, filter, map, mapTo, scan, shareReplay, withLatestFrom } from 'rxjs/operators'; +import { Observable, combineLatest, fromEvent, Subscription, from, of } from 'rxjs'; +import { Effect, Actions, ofType } from '@ngrx/effects'; +import { withLatestFrom, map, distinctUntilChanged, scan, shareReplay, filter, mapTo, debounceTime, catchError, skip, throttleTime } from 'rxjs/operators'; import { AtlasViewerConstantsServices } from 'src/atlasViewer/atlasViewer.constantService.service'; -import { getNgIds, IavRootStoreInterface } from '../stateStore.service'; import { SNACKBAR_MESSAGE } from './uiState.store'; +import { getNgIds, IavRootStoreInterface, GENERAL_ACTION_TYPES } from '../stateStore.service'; +import { Action, select, Store } from '@ngrx/store' export const FOUR_PANEL = 'FOUR_PANEL' export const V_ONE_THREE = 'V_ONE_THREE' @@ -132,14 +132,72 @@ export const getStateStore = ({ state = defaultState } = {}) => (prevState: Stat ...prevState, forceShowSegment : action.forceShowSegment, } - case NEHUBA_READY: { - const { nehubaReady } = action - return { - ...prevState, - nehubaReady, - } - } - default: return prevState + case ADD_NG_LAYER: + return { + ...prevState, + + /* this configration hides the layer if a non mixable layer already present */ + + /* this configuration does not the addition of multiple non mixable layers */ + // layers : action.layer.mixability === 'nonmixable' && prevState.layers.findIndex(l => l.mixability === 'nonmixable') >= 0 + // ? prevState.layers + // : prevState.layers.concat(action.layer) + + /* this configuration allows the addition of multiple non mixables */ + // layers : prevState.layers.map(l => mapLayer(l, action.layer)).concat(action.layer) + layers : mixNgLayers(prevState.layers, action.layer) + + // action.layer.constructor === Array + // ? prevState.layers.concat(action.layer) + // : prevState.layers.concat({ + // ...action.layer, + // ...( action.layer.mixability === 'nonmixable' && prevState.layers.findIndex(l => l.mixability === 'nonmixable') >= 0 + // ? {visible: false} + // : {}) + // }) + } + case REMOVE_NG_LAYERS: + const { layers } = action + const layerNameSet = new Set(layers.map(l => l.name)) + return { + ...prevState, + layers: prevState.layers.filter(l => !layerNameSet.has(l.name)) + } + case REMOVE_NG_LAYER: + return { + ...prevState, + layers : prevState.layers.filter(l => l.name !== action.layer.name) + } + case SHOW_NG_LAYER: + return { + ...prevState, + layers : prevState.layers.map(l => l.name === action.layer.name + ? { ...l, visible: true } + : l) + } + case HIDE_NG_LAYER: + return { + ...prevState, + + layers : prevState.layers.map(l => l.name === action.layer.name + ? { ...l, visible: false } + : l) + } + case FORCE_SHOW_SEGMENT: + return { + ...prevState, + forceShowSegment : action.forceShowSegment + } + case NEHUBA_READY: + const { nehubaReady } = action + return { + ...prevState, + nehubaReady + } + case GENERAL_ACTION_TYPES.APPLY_STATE: + const { ngViewerState } = (action as any).state + return ngViewerState + default: return prevState } } @@ -187,11 +245,54 @@ export class NgViewerUseEffect implements OnDestroy { private subscriptions: Subscription[] = [] + @Effect() + public applySavedUserConfig$: Observable<any> + constructor( private actions: Actions, private store$: Store<IavRootStoreInterface>, - private constantService: AtlasViewerConstantsServices, - ) { + private constantService: AtlasViewerConstantsServices + ){ + + // TODO either split backend user to be more granular, or combine the user config into a single subscription + this.subscriptions.push( + this.store$.pipe( + select('ngViewerState'), + distinctUntilChanged(), + debounceTime(200), + skip(1), + // Max frequency save once every second + throttleTime(1000) + ).subscribe(({panelMode, panelOrder}) => { + fetch(`${this.constantService.backendUrl}user/config`, { + method: 'POST', + headers: { + 'Content-type': 'application/json' + }, + body: JSON.stringify({ ngViewerState: { panelMode, panelOrder } }) + }) + }) + ) + + this.applySavedUserConfig$ = from(fetch(`${this.constantService.backendUrl}user/config`).then(r => r.json())).pipe( + catchError((err,caught) => of(null)), + filter(v => !!v), + withLatestFrom(this.store$), + map(([{ngViewerState: fetchedNgViewerState}, state]) => { + const { ngViewerState } = state + return { + type: GENERAL_ACTION_TYPES.APPLY_STATE, + state: { + ...state, + ngViewerState: { + ...ngViewerState, + ...fetchedNgViewerState + } + } + } + }) + ) + const toggleMaxmimise$ = this.actions.pipe( ofType(ACTION_TYPES.TOGGLE_MAXIMISE), shareReplay(1), diff --git a/src/ui/signinModal/signinModal.style.css b/src/ui/signinModal/signinModal.style.css index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..cbbec1ebb685a7f543a0e476c8eff9401befb834 100644 --- a/src/ui/signinModal/signinModal.style.css +++ b/src/ui/signinModal/signinModal.style.css @@ -0,0 +1,5 @@ +a +{ + display:inline-block; + margin-top: 0.25rem; +} \ No newline at end of file diff --git a/src/ui/signinModal/signinModal.template.html b/src/ui/signinModal/signinModal.template.html index e2ab8fe9f88393075d7597edaec26356b2d1b309..faac23115ca6575f9497a2ae2805d8ce7a061968 100644 --- a/src/ui/signinModal/signinModal.template.html +++ b/src/ui/signinModal/signinModal.template.html @@ -12,9 +12,6 @@ </div> <ng-template #notLoggedIn> - <span> - Login via - </span> <a *ngFor="let m of loginMethods" [href]="m.href"> <button