From 1a1d73a08b34325be57dce4f292e69cfec467257 Mon Sep 17 00:00:00 2001
From: Xiao Gui <xgui3783@gmail.com>
Date: Thu, 9 Apr 2020 09:42:59 +0200
Subject: [PATCH] feat: saneUrl

---
 deploy/app.js                                 |   9 +
 deploy/package.json                           |   6 +-
 deploy/saneUrl/index.js                       |  99 ++++++
 deploy/saneUrl/index.spec.js                  | 199 +++++++++++
 deploy/saneUrl/store.js                       |  85 +++++
 deploy/saneUrl/store.spec.js                  |  57 ++++
 deploy/server.js                              |   2 +
 src/auth/auth.directive.ts                    |  18 +
 src/auth/auth.module.ts                       |  26 ++
 src/{services => auth}/auth.service.ts        |   2 +-
 src/auth/index.ts                             |   2 +
 .../signinModal/signinModal.component.ts      |   2 +-
 .../signinModal/signinModal.style.css         |   0
 .../signinModal/signinModal.template.html     |   0
 src/main.module.ts                            |   2 +-
 src/share/saneUrl/saneUrl.component.spec.ts   | 315 ++++++++++++++++++
 src/share/saneUrl/saneUrl.component.ts        | 189 +++++++++++
 src/share/saneUrl/saneUrl.template.html       |  46 +++
 src/share/share.module.ts                     |  16 +-
 src/share/shareSaneLink.directive.ts          |   0
 src/state/index.ts                            |   1 +
 src/state/state.module.ts                     |  18 +
 src/state/stateAggregator.directive.ts        |  38 +++
 .../statusCard/statusCard.component.ts        |   8 +-
 .../statusCard/statusCard.template.html       |  71 +++-
 .../signinBanner/signinBanner.components.ts   |   4 +-
 src/ui/ui.module.ts                           |  10 +-
 src/util/constants.ts                         |   3 +-
 28 files changed, 1211 insertions(+), 17 deletions(-)
 create mode 100644 deploy/saneUrl/index.js
 create mode 100644 deploy/saneUrl/index.spec.js
 create mode 100644 deploy/saneUrl/store.js
 create mode 100644 deploy/saneUrl/store.spec.js
 create mode 100644 src/auth/auth.directive.ts
 create mode 100644 src/auth/auth.module.ts
 rename src/{services => auth}/auth.service.ts (95%)
 create mode 100644 src/auth/index.ts
 rename src/{ui => auth}/signinModal/signinModal.component.ts (88%)
 rename src/{ui => auth}/signinModal/signinModal.style.css (100%)
 rename src/{ui => auth}/signinModal/signinModal.template.html (100%)
 create mode 100644 src/share/saneUrl/saneUrl.component.spec.ts
 create mode 100644 src/share/saneUrl/saneUrl.component.ts
 create mode 100644 src/share/saneUrl/saneUrl.template.html
 delete mode 100644 src/share/shareSaneLink.directive.ts
 create mode 100644 src/state/index.ts
 create mode 100644 src/state/state.module.ts
 create mode 100644 src/state/stateAggregator.directive.ts

diff --git a/deploy/app.js b/deploy/app.js
index 67c350ee2..4ad2980db 100644
--- a/deploy/app.js
+++ b/deploy/app.js
@@ -106,11 +106,20 @@ const { compressionMiddleware } = require('nomiseco')
 
 app.use(compressionMiddleware, express.static(PUBLIC_PATH))
 
+/**
+ * saneUrl end points
+ */
+const saneUrlRouter = require('./saneUrl')
+app.use('/saneUrl', saneUrlRouter)
+
 const jsonMiddleware = (req, res, next) => {
   if (!res.get('Content-Type')) res.set('Content-Type', 'application/json')
   next()
 }
 
+/**
+ * resources endpoints
+ */
 const templateRouter = require('./templates')
 const nehubaConfigRouter = require('./nehubaConfig')
 const datasetRouter = require('./datasets')
diff --git a/deploy/package.json b/deploy/package.json
index 61322a472..168ec3c3b 100644
--- a/deploy/package.json
+++ b/deploy/package.json
@@ -18,6 +18,7 @@
     "archiver": "^3.0.0",
     "body-parser": "^1.19.0",
     "express": "^4.16.4",
+    "express-rate-limit": "^5.1.1",
     "express-session": "^1.15.6",
     "hbp-seafile": "0.0.6",
     "helmet-csp": "^2.8.0",
@@ -26,7 +27,9 @@
     "nomiseco": "0.0.2",
     "openid-client": "^2.4.5",
     "passport": "^0.4.0",
-    "request": "^2.88.0"
+    "rate-limit-redis": "^1.7.0",
+    "request": "^2.88.0",
+    "soswrap": "0.0.1"
   },
   "devDependencies": {
     "chai": "^4.2.0",
@@ -36,6 +39,7 @@
     "google-spreadsheet": "^3.0.8",
     "got": "^10.5.5",
     "mocha": "^6.1.4",
+    "nock": "^12.0.3",
     "sinon": "^8.0.2"
   }
 }
diff --git a/deploy/saneUrl/index.js b/deploy/saneUrl/index.js
new file mode 100644
index 000000000..ec870d3dc
--- /dev/null
+++ b/deploy/saneUrl/index.js
@@ -0,0 +1,99 @@
+const router = require('express').Router()
+const RateLimit = require('express-rate-limit')
+const RedisStore = require('rate-limit-redis')
+const { Store, NotFoundError } = require('./store')
+const bodyParser = require('body-parser')
+const { readUserData, saveUserData } = require('../user/store')
+
+const store = new Store()
+
+const { 
+  REDIS_PROTO,
+  REDIS_ADDR,
+  REDIS_PORT,
+
+  REDIS_RATE_LIMITING_DB_EPHEMERAL_PORT_6379_TCP_PROTO,
+  REDIS_RATE_LIMITING_DB_EPHEMERAL_PORT_6379_TCP_ADDR,
+  REDIS_RATE_LIMITING_DB_EPHEMERAL_PORT_6379_TCP_PORT,
+
+  HOSTNAME,
+} = process.env
+
+const redisProto = REDIS_PROTO || REDIS_RATE_LIMITING_DB_EPHEMERAL_PORT_6379_TCP_PROTO || 'redis'
+const redisAddr = REDIS_ADDR || REDIS_RATE_LIMITING_DB_EPHEMERAL_PORT_6379_TCP_ADDR || null
+const redisPort = REDIS_PORT || REDIS_RATE_LIMITING_DB_EPHEMERAL_PORT_6379_TCP_PORT || 6379
+
+const redisURL = redisAddr && `${redisProto}://${redisAddr}:${redisPort}`
+
+const limiter = new RateLimit({
+  windowMs: 1e3 * 5,
+  max: 5,
+  ...( redisURL ? { store: new RedisStore({ redisURL }) } : {} )
+})
+
+const passthrough = (_, __, next) => next()
+
+const acceptHtmlProg = /text\/html/i
+
+router.get('/:name', DISABLE_LIMITER ? passthrough : limiter, async (req, res) => {
+  const { name } = req.params
+  const { headers } = req
+  
+  const redirectFlag = acceptHtmlProg.test(headers['accept'])
+    
+  try {
+    const value = await store.get(name)
+    const json = JSON.parse(value)
+    const { expiry, queryString } = json
+    if ( expiry && ((Date.now() - expiry) > 0) ) {
+      return res.status(404).end()
+    }
+
+    if (redirectFlag) res.redirect(`${HOSTNAME}/?${queryString}`)
+    else res.status(200).send(value)
+
+  } catch (e) {
+    if (e instanceof NotFoundError) return res.status(404).end()
+    res.status(500).send(e.toString())
+  }
+})
+
+router.post('/:name', DISABLE_LIMITER ? passthrough : limiter, bodyParser.json(), async (req, res) => {
+  const { name } = req.params
+  const { body, user } = req
+  
+  try {
+    const payload = {
+      ...body,
+      userId: user && user.id,
+      expiry: !user && (Date.now() + 1e3 * 60 * 60 * 72)
+    }
+
+    await store.set(name, JSON.stringify(payload))
+    res.status(200).end()
+
+    try {
+      if (!user) return
+      const { savedCustomLinks = [], ...rest } = await readUserData(user)
+      await saveUserData(user, {
+        ...rest,
+        savedCustomLinks: [
+          ...savedCustomLinks,
+          name
+        ]
+      })
+    } catch (e) {
+      console.error(`reading/writing user data error ${user && user.id}, ${name}`, e)
+    }
+  } catch (e) {
+    console.error(`saneUrl /POST error`, e)
+    const { statusCode, statusMessage } = e
+    res.status(statusCode || 500).send(statusMessage || 'Error encountered.')
+  }
+})
+
+router.use((_, res) => {
+  res.status(405).send('Not implemneted')
+})
+
+module.exports = router
diff --git a/deploy/saneUrl/index.spec.js b/deploy/saneUrl/index.spec.js
new file mode 100644
index 000000000..39162a25c
--- /dev/null
+++ b/deploy/saneUrl/index.spec.js
@@ -0,0 +1,199 @@
+const sinon = require('sinon')
+const { Store } = require('./store')
+
+sinon
+  .stub(Store.prototype, 'getToken')
+  .returns(Promise.resolve(`--fake-token--`))
+
+const userStore = require('../user/store')
+
+const savedUserDataPayload = {
+  otherData: 'not relevant data',
+  savedCustomLinks: [
+    '111222',
+    '333444'
+  ]
+}
+
+const readUserDataStub = sinon
+  .stub(userStore, 'readUserData')
+  .returns(Promise.resolve(savedUserDataPayload))
+
+const saveUserDataStub = sinon
+  .stub(userStore, 'saveUserData')
+  .returns(Promise.resolve())
+
+const express = require('express')
+const router = require('./index')
+const got = require('got')
+const { expect } = require('chai')
+
+const app = express()
+let user
+app.use('', (req, res, next) => {
+  req.user = user
+  next()
+}, router)
+
+const name = `nameme`
+
+const payload = {
+  ver: '0.0.1',
+  queryString: 'test_test'
+}
+
+describe('> saneUrl/index.js', () => {
+
+  describe('> router', () => {
+
+    let server, setStub
+    before(() => {
+
+      setStub = sinon
+        .stub(Store.prototype, 'set')
+        .returns(Promise.resolve())
+      server = app.listen(50000)
+    })
+
+    afterEach(() => {
+      setStub.resetHistory()
+    })
+
+    after(() => {
+      server.close()
+    })
+    
+    it('> works', async () => {
+      const body = {
+        ...payload
+      }
+      const getStub = sinon
+        .stub(Store.prototype, 'get')
+        .returns(Promise.resolve(JSON.stringify(body)))
+      const { body: respBody } = await got(`http://localhost:50000/${name}`)
+
+      expect(getStub.calledWith(name)).to.be.true
+      expect(respBody).to.equal(JSON.stringify(body))
+      getStub.restore()
+    })
+
+    it('> get on expired returns 404', async () => {
+      const body = {
+        ...payload,
+        expiry: Date.now() - 1e3 * 60
+      }
+      const getStub = sinon
+        .stub(Store.prototype, 'get')
+        .returns(Promise.resolve(JSON.stringify(body)))
+        
+      const { statusCode } = await got(`http://localhost:50000/${name}`, {
+        throwHttpErrors: false
+      })
+      expect(statusCode).to.equal(404)
+      expect(getStub.calledWith(name)).to.be.true
+      getStub.restore()
+    })
+
+    it('> set works', async () => {
+
+      await got(`http://localhost:50000/${name}`, {
+        method: 'POST',
+        headers: {
+          'Content-type': 'application/json'
+        },
+        body: JSON.stringify(payload)
+      })
+
+      const [ storedName, _ ] = setStub.args[0]
+
+      expect(storedName).to.equal(name)
+      expect(setStub.called).to.be.true
+    })
+
+    describe('> set with unauthenticated user', () => {
+
+      it('> set with anonymous user has user undefined and expiry as defined', async () => {
+
+        await got(`http://localhost:50000/${name}`, {
+          method: 'POST',
+          headers: {
+            'Content-type': 'application/json'
+          },
+          body: JSON.stringify(payload)
+        })
+  
+        expect(setStub.called).to.be.true
+        const [ _, storedContent] = setStub.args[0]
+        const { userId, expiry } = JSON.parse(storedContent)
+        expect(!!userId).to.be.false
+        expect(!!expiry).to.be.true
+  
+        // there will be some discrepencies, but the server lag should not exceed 5 seconds
+        expect( 1e3 * 60 * 60 * 72 - expiry + Date.now() ).to.be.lessThan(1e3 * 5)
+      })  
+    })
+
+    describe('> set with authenticated user', () => {
+      
+      before(() => {
+        user = {
+          id: 'test/1',
+          name: 'hello world'
+        }
+      })
+
+      afterEach(() => {
+        readUserDataStub.resetHistory()
+        saveUserDataStub.resetHistory()
+      })
+
+      after(() => {
+        user = null
+        readUserDataStub.restore()
+        saveUserDataStub.restore()
+      })
+
+      it('> userId set, expiry unset', async () => {
+
+        await got(`http://localhost:50000/${name}`, {
+          method: 'POST',
+          headers: {
+            'Content-type': 'application/json'
+          },
+          body: JSON.stringify(payload)
+        })
+  
+        expect(setStub.called).to.be.true
+        const [ _, storedContent] = setStub.args[0]
+        const { userId, expiry } = JSON.parse(storedContent)
+        expect(!!userId).to.be.true
+        expect(!!expiry).to.be.false
+  
+        expect( userId ).to.equal('test/1')
+      })
+
+      it('> readUserDataset saveUserDataset data stubs called', async () => {
+
+        await got(`http://localhost:50000/${name}`, {
+          method: 'POST',
+          headers: {
+            'Content-type': 'application/json'
+          },
+          body: JSON.stringify(payload)
+        })
+        
+        expect(readUserDataStub.called).to.be.true
+        expect(readUserDataStub.calledWith(user)).to.be.true
+        
+        expect(saveUserDataStub.called).to.be.true
+        expect(saveUserDataStub.calledWith(user, {
+          ...savedUserDataPayload,
+          savedCustomLinks: [
+            ...savedUserDataPayload.savedCustomLinks,
+            name
+          ]
+        })).to.be.true
+      })
+    })
+  })
+})
diff --git a/deploy/saneUrl/store.js b/deploy/saneUrl/store.js
new file mode 100644
index 000000000..1cb6cb8db
--- /dev/null
+++ b/deploy/saneUrl/store.js
@@ -0,0 +1,85 @@
+const { SamlOpenstackWrapper } = require('soswrap')
+const request = require('request')
+
+const {
+  OBJ_STORAGE_AUTH_URL,
+  OBJ_STORAGE_IDP_NAME,
+  OBJ_STORAGE_IDP_PROTO,
+  OBJ_STORAGE_IDP_URL,
+  OBJ_STORAGE_USERNAME,
+  OBJ_STORAGE_PASSWORD,
+  OBJ_STORAGE_PROJECT_ID,
+  OBJ_STORAGE_ROOT_URL,
+} = process.env
+
+class NotFoundError extends Error{}
+
+class Store {
+  constructor({
+    authUrl,
+    idPName,
+    idPProto,
+    idPUrl,
+    username,
+    password,
+
+    objStorateRootUrl,
+  } = {}){
+
+    this.wrapper = new SamlOpenstackWrapper({
+      authUrl:   authUrl  || OBJ_STORAGE_AUTH_URL,
+      idPName:   idPName  || OBJ_STORAGE_IDP_NAME,
+      idPProto:  idPProto || OBJ_STORAGE_IDP_PROTO,
+      idPUrl:    idPUrl   || OBJ_STORAGE_IDP_URL,
+    })
+
+    this.objStorateRootUrl = objStorateRootUrl || OBJ_STORAGE_ROOT_URL
+
+    this.wrapper.username = username || OBJ_STORAGE_USERNAME
+    this.wrapper.password = password || OBJ_STORAGE_PASSWORD
+
+    this.getToken()
+  }
+
+  async getToken() {
+    this.token = await this.wrapper.getScopedToken({ projectId: OBJ_STORAGE_PROJECT_ID })
+  }
+
+  get(id) {
+    return new Promise((rs, rj) => {
+      request.get(`${this.objStorateRootUrl}/${id}`, {
+        headers: {
+          'X-Auth-Token': this.token
+        }
+      }, (err, resp, body) => {
+        if (err) return rj(err)
+        if (resp.statusCode === 404) return rj(new NotFoundError())
+        if (resp.statusCode >= 400) return rj(resp)
+        return rs(body)
+      })
+    })
+  }
+
+  set(id, value) {
+    return new Promise((rs, rj) => {
+      request.put(`${this.objStorateRootUrl}/${id}`, {
+        headers: {
+          'X-Auth-Token': this.token
+        },
+        body: value
+      }, (err, resp, body) => {
+        if (err) return rj(err)
+        if (resp.statusCode >= 400) return rj(resp)
+        return rs(body)
+      })
+    })
+  }
+
+  async healthCheck(){
+
+  }
+}
+
+
+exports.Store = Store
+exports.NotFoundError = NotFoundError
diff --git a/deploy/saneUrl/store.spec.js b/deploy/saneUrl/store.spec.js
new file mode 100644
index 000000000..32ce7c043
--- /dev/null
+++ b/deploy/saneUrl/store.spec.js
@@ -0,0 +1,57 @@
+const { NotFoundError, Store } = require('./store')
+const sinon = require('sinon')
+const { expect } = require("chai")
+const nock = require('nock')
+
+const fakeToken = `token-123-token`
+const objStorateRootUrl = `http://fake.obj`
+const objName = `objname`
+const objContent = `objContent`
+
+describe('> store.js', () => {
+  
+  describe('> Store', () => {
+    const getTokenSpy = sinon
+      .stub(Store.prototype, 'getToken')
+      .returns(Promise.resolve(fakeToken))
+    
+    const store = new Store({ objStorateRootUrl })
+
+    afterEach(() => {
+      getTokenSpy.resetHistory()
+    })
+
+    it('> spy works', async () => {
+      expect(getTokenSpy.called).to.be.true
+
+      const token = await store.getToken()
+      expect(token).to.equal(fakeToken)
+    })
+
+    it('> get works', async () => {
+      const scope = nock(objStorateRootUrl)
+        .get(`/${objName}`)
+        .reply(200, objContent)
+
+      const content = await store.get(objName)
+      expect(content).to.equal(objContent)
+      expect(scope.isDone()).to.be.true
+
+    })
+
+    it('> set works', async () => {
+
+      const scope = nock(objStorateRootUrl)
+        .put(`/${objName}`)
+        .reply(200)
+
+      scope.on('request', (req, int, body) => {
+        expect(body).to.equal(objContent)
+      })
+
+      await store.set(objName, objContent)
+      
+      expect(scope.isDone()).to.be.true
+    })
+  })
+})
diff --git a/deploy/server.js b/deploy/server.js
index ce483fb83..e2a2d8024 100644
--- a/deploy/server.js
+++ b/deploy/server.js
@@ -71,6 +71,8 @@ if(HOST_PATHNAME !== '') {
   if (HOST_PATHNAME.slice(-1) === '/') throw new Error(`HOST_PATHNAME, if defined and non-emtpy, should NOT end with a slash. HOST_PATHNAME: ${HOST_PATHNAME}`)
 }
 
+server.set('trust proxy', 1)
+
 server.disable('x-powered-by')
 
 server.use(HOST_PATHNAME, app)
diff --git a/src/auth/auth.directive.ts b/src/auth/auth.directive.ts
new file mode 100644
index 000000000..982a57b3d
--- /dev/null
+++ b/src/auth/auth.directive.ts
@@ -0,0 +1,18 @@
+import { Directive } from "@angular/core";
+import { Observable } from "rxjs";
+import { IUser, AuthService } from './auth.service'
+
+@Directive({
+  selector: '[iav-auth-authState]',
+  exportAs: 'iavAuthAuthState'
+})
+
+export class AuthStateDdirective{
+  public user$: Observable<IUser>
+
+  constructor(
+    private authService: AuthService,
+  ){
+    this.user$ = this.authService.user$
+  }
+}
diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts
new file mode 100644
index 000000000..bbd75a841
--- /dev/null
+++ b/src/auth/auth.module.ts
@@ -0,0 +1,26 @@
+import { NgModule } from "@angular/core";
+import { SigninModal } from "./signinModal/signinModal.component";
+import { CommonModule } from "@angular/common";
+import { AngularMaterialModule } from "src/ui/sharedModules/angularMaterial.module";
+import { AuthService } from "./auth.service";
+import { AuthStateDdirective } from "./auth.directive";
+
+@NgModule({
+  imports: [
+    CommonModule,
+    AngularMaterialModule,
+  ],
+  declarations: [
+    SigninModal,
+    AuthStateDdirective,
+  ],
+  exports: [
+    SigninModal,
+    AuthStateDdirective,
+  ],
+  providers: [
+    AuthService,
+  ]
+})
+
+export class AuthModule{}
diff --git a/src/services/auth.service.ts b/src/auth/auth.service.ts
similarity index 95%
rename from src/services/auth.service.ts
rename to src/auth/auth.service.ts
index fdec1e6ef..12720ca92 100644
--- a/src/services/auth.service.ts
+++ b/src/auth/auth.service.ts
@@ -1,7 +1,7 @@
 import { HttpClient } from "@angular/common/http";
 import { Injectable, OnDestroy } from "@angular/core";
 import { Observable, of, Subscription } from "rxjs";
-import { catchError, shareReplay } from "rxjs/operators";
+import { catchError, shareReplay, mapTo } from "rxjs/operators";
 
 const IV_REDIRECT_TOKEN = `IV_REDIRECT_TOKEN`
 
diff --git a/src/auth/index.ts b/src/auth/index.ts
new file mode 100644
index 000000000..25b4f9e47
--- /dev/null
+++ b/src/auth/index.ts
@@ -0,0 +1,2 @@
+export { AuthModule } from './auth.module'
+export { AuthService } from './auth.service'
\ No newline at end of file
diff --git a/src/ui/signinModal/signinModal.component.ts b/src/auth/signinModal/signinModal.component.ts
similarity index 88%
rename from src/ui/signinModal/signinModal.component.ts
rename to src/auth/signinModal/signinModal.component.ts
index c05a1e81e..5cb7ba742 100644
--- a/src/ui/signinModal/signinModal.component.ts
+++ b/src/auth/signinModal/signinModal.component.ts
@@ -1,5 +1,5 @@
 import { Component } from "@angular/core";
-import { AuthService, IAuthMethod, IUser } from "src/services/auth.service";
+import { AuthService, IAuthMethod, IUser } from "../auth.service";
 
 @Component({
   selector: 'signin-modal',
diff --git a/src/ui/signinModal/signinModal.style.css b/src/auth/signinModal/signinModal.style.css
similarity index 100%
rename from src/ui/signinModal/signinModal.style.css
rename to src/auth/signinModal/signinModal.style.css
diff --git a/src/ui/signinModal/signinModal.template.html b/src/auth/signinModal/signinModal.template.html
similarity index 100%
rename from src/ui/signinModal/signinModal.template.html
rename to src/auth/signinModal/signinModal.template.html
diff --git a/src/main.module.ts b/src/main.module.ts
index 074035bba..fa397086a 100644
--- a/src/main.module.ts
+++ b/src/main.module.ts
@@ -20,7 +20,6 @@ import { ModalUnit } from "./atlasViewer/modalUnit/modalUnit.component";
 import { TransformOnhoverSegmentPipe } from "./atlasViewer/onhoverSegment.pipe";
 import { ConfirmDialogComponent } from "./components/confirmDialog/confirmDialog.component";
 import { DialogComponent } from "./components/dialog/dialog.component";
-import { AuthService } from "./services/auth.service";
 import { DialogService } from "./services/dialogService.service";
 import { UseEffects } from "./services/effect/effect";
 import { LocalFileService } from "./services/localFile.service";
@@ -52,6 +51,7 @@ import 'src/res/css/extra_styles.css'
 import 'src/res/css/version.css'
 import 'src/theme.scss'
 import { ShareModule } from './share';
+import { AuthService } from './auth'
 
 @NgModule({
   imports : [
diff --git a/src/share/saneUrl/saneUrl.component.spec.ts b/src/share/saneUrl/saneUrl.component.spec.ts
new file mode 100644
index 000000000..4b830bb1a
--- /dev/null
+++ b/src/share/saneUrl/saneUrl.component.spec.ts
@@ -0,0 +1,315 @@
+import { TestBed, async, fakeAsync, tick, flush } from '@angular/core/testing'
+import { ShareModule } from '../share.module'
+import { SaneUrl } from './saneUrl.component'
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'
+import { By } from '@angular/platform-browser'
+import { BACKENDURL } from 'src/util/constants'
+import { NoopAnimationsModule } from '@angular/platform-browser/animations'
+
+const inputCss = `input[aria-label="Custom link"]`
+const submitCss = `button[aria-label="Create custom link"]`
+const copyBtnCss = `button[aria-label="Copy created custom URL to clipboard"]`
+
+describe('> saneUrl.component.ts', () => {
+  describe('> SaneUrl', () => {
+    beforeEach(async(() => {
+      TestBed.configureTestingModule({
+        imports: [
+          ShareModule,
+          HttpClientTestingModule,
+          NoopAnimationsModule,
+        ]
+      }).compileComponents()
+    }))
+
+    afterEach(() => {
+      const ctrl = TestBed.inject(HttpTestingController)
+      ctrl.verify()
+    })
+
+    it('> can be created', () => {
+      const fixture = TestBed.createComponent(SaneUrl)
+      const el = fixture.debugElement.componentInstance
+      expect(el).toBeTruthy()
+    })
+
+    it('> all elements exist', () => {
+      const fixture = TestBed.createComponent(SaneUrl)
+
+      const input = fixture.debugElement.query( By.css( inputCss ) )
+      expect(input).toBeTruthy()
+
+      const submit = fixture.debugElement.query( By.css( submitCss ) )
+      expect(submit).toBeTruthy()
+
+      const cpyBtn = fixture.debugElement.query( By.css( copyBtnCss ) )
+      expect(cpyBtn).toBeFalsy()
+    })
+
+    it('> catches invalid input syncly', fakeAsync(() => {
+
+      const failValue = `test-1`
+
+      // Necessary to detectChanges, or formControl will not initialise properly
+      // See https://stackoverflow.com/a/56600762/6059235
+      const fixture = TestBed.createComponent(SaneUrl)
+      fixture.detectChanges()
+
+      // Set fail value
+      fixture.componentInstance.customUrl.setValue(failValue)
+
+      // Expect validator to fail catch it
+      expect(fixture.componentInstance.customUrl.invalid).toEqual(true)
+
+      // on change detection, UI should catch it
+      fixture.detectChanges()
+
+      const input = fixture.debugElement.query( By.css( inputCss ) )
+      const invalid = input.attributes['aria-invalid']
+      expect(invalid.toString()).toEqual('true')
+
+    }))
+
+    it('> when user inputs valid input, does not not invalidate', () => {
+
+      const successValue = `test_1`
+
+      // Necessary to detectChanges, or formControl will not initialise properly
+      // See https://stackoverflow.com/a/56600762/6059235
+      const fixture = TestBed.createComponent(SaneUrl)
+      fixture.detectChanges()
+
+      // Set fail value
+      fixture.componentInstance.customUrl.setValue(successValue)
+
+      // Expect validator to fail catch it
+      expect(fixture.componentInstance.customUrl.invalid).toEqual(false)
+
+      // on change detection, UI should catch it
+      fixture.detectChanges()
+
+      const input = fixture.debugElement.query( By.css( inputCss ) )
+      const invalid = input.attributes['aria-invalid']
+      expect(invalid.toString()).toEqual('false')
+    })
+
+    it('> on entering string in input, makes debounced GET request', fakeAsync(() => {
+
+      const value = 'test_1'
+
+      const httpTestingController = TestBed.inject(HttpTestingController)
+
+      // Necessary to detectChanges, or formControl will not initialise properly
+      // See https://stackoverflow.com/a/56600762/6059235
+      const fixture = TestBed.createComponent(SaneUrl)
+      fixture.detectChanges()
+
+      // Set value
+      fixture.componentInstance.customUrl.setValue(value)
+
+      tick(500)
+
+      const req = httpTestingController.expectOne(`${BACKENDURL}saneUrl/${value}`)
+      req.flush(200)
+    }))
+
+    it('> on 200 response, show error', fakeAsync(() => {
+      
+      const value = 'test_1'
+
+      const httpTestingController = TestBed.inject(HttpTestingController)
+
+      // Necessary to detectChanges, or formControl will not initialise properly
+      // See https://stackoverflow.com/a/56600762/6059235
+      const fixture = TestBed.createComponent(SaneUrl)
+      fixture.detectChanges()
+
+      // Set value
+      fixture.componentInstance.customUrl.setValue(value)
+
+      tick(500)
+
+      const req = httpTestingController.expectOne(`${BACKENDURL}saneUrl/${value}`)
+      req.flush('OK')
+
+      // Expect validator to fail catch it
+      expect(fixture.componentInstance.customUrl.invalid).toEqual(true)
+
+      // on change detection, UI should catch it
+      fixture.detectChanges()
+
+      const input = fixture.debugElement.query( By.css( inputCss ) )
+      const invalid = input.attributes['aria-invalid']
+      expect(invalid.toString()).toEqual('true')
+
+      const submit = fixture.debugElement.query( By.css( submitCss ) )
+      const disabled = !!submit.attributes['disabled']
+      expect(disabled.toString()).toEqual('true')
+    }))
+
+    it('> on 404 response, show available', fakeAsync(() => {
+
+      const value = 'test_1'
+
+      const httpTestingController = TestBed.inject(HttpTestingController)
+
+      // Necessary to detectChanges, or formControl will not initialise properly
+      // See https://stackoverflow.com/a/56600762/6059235
+      const fixture = TestBed.createComponent(SaneUrl)
+      fixture.detectChanges()
+
+      // Set value
+      fixture.componentInstance.customUrl.setValue(value)
+
+      tick(500)
+
+      const req = httpTestingController.expectOne(`${BACKENDURL}saneUrl/${value}`)
+      req.flush('some reason', { status: 404, statusText: 'Not Found.' })
+
+      // Expect validator to fail catch it
+      expect(fixture.componentInstance.customUrl.invalid).toEqual(false)
+
+      // on change detection, UI should catch it
+      fixture.detectChanges()
+
+      const input = fixture.debugElement.query( By.css( inputCss ) )
+      const invalid = input.attributes['aria-invalid']
+      expect(invalid.toString()).toEqual('false')
+
+      const submit = fixture.debugElement.query( By.css( submitCss ) )
+      const disabled = !!submit.attributes['disabled']
+      expect(disabled.toString()).toEqual('false')
+    }))
+
+    it('> on other error codes, show invalid', fakeAsync(() => {
+
+      const value = 'test_1'
+
+      const httpTestingController = TestBed.inject(HttpTestingController)
+
+      // Necessary to detectChanges, or formControl will not initialise properly
+      // See https://stackoverflow.com/a/56600762/6059235
+      const fixture = TestBed.createComponent(SaneUrl)
+      fixture.detectChanges()
+
+      // Set value
+      fixture.componentInstance.customUrl.setValue(value)
+
+      tick(500)
+
+      const req = httpTestingController.expectOne(`${BACKENDURL}saneUrl/${value}`)
+      req.flush('some reason', { status: 401, statusText: 'Unauthorised.' })
+
+      // Expect validator to fail catch it
+      expect(fixture.componentInstance.customUrl.invalid).toEqual(true)
+
+      // on change detection, UI should catch it
+      fixture.detectChanges()
+
+      const input = fixture.debugElement.query( By.css( inputCss ) )
+      const invalid = input.attributes['aria-invalid']
+      expect(invalid.toString()).toEqual('true')
+
+      const submit = fixture.debugElement.query( By.css( submitCss ) )
+      const disabled = !!submit.attributes['disabled']
+      expect(disabled.toString()).toEqual('true')
+    }))
+
+    it('> on click create link btn calls correct API', fakeAsync(() => {
+
+      const value = 'test_1'
+
+      const httpTestingController = TestBed.inject(HttpTestingController)
+
+      // Necessary to detectChanges, or formControl will not initialise properly
+      // See https://stackoverflow.com/a/56600762/6059235
+      const fixture = TestBed.createComponent(SaneUrl)
+      fixture.detectChanges()
+
+      // Set value
+      fixture.componentInstance.customUrl.setValue(value)
+
+      tick(500)
+
+      const req = httpTestingController.expectOne(`${BACKENDURL}saneUrl/${value}`)
+      req.flush('some reason', { status: 404, statusText: 'Not Found.' })
+
+      fixture.detectChanges()
+      flush()
+
+      const submit = fixture.debugElement.query( By.css( submitCss ) )
+      const disabled = !!submit.attributes['disabled']
+      expect(disabled.toString()).toEqual('false')
+
+      submit.triggerEventHandler('click', {})
+
+      fixture.detectChanges()
+
+      const disabledInProgress = !!submit.attributes['disabled']
+      expect(disabledInProgress.toString()).toEqual('true')
+
+      const req2 = httpTestingController.expectOne({
+        method: 'POST',
+        url: `${BACKENDURL}saneUrl/${value}`
+      })
+      
+      req2.flush({})
+
+      fixture.detectChanges()
+
+      const disabledAfterComplete = !!submit.attributes['disabled']
+      expect(disabledAfterComplete.toString()).toEqual('true')
+
+      const cpyBtn = fixture.debugElement.query( By.css( copyBtnCss ) )
+      expect(cpyBtn).toBeTruthy()
+    }))
+
+    it('> on click create link btn fails show result', fakeAsync(() => {
+
+      const value = 'test_1'
+
+      const httpTestingController = TestBed.inject(HttpTestingController)
+
+      // Necessary to detectChanges, or formControl will not initialise properly
+      // See https://stackoverflow.com/a/56600762/6059235
+      const fixture = TestBed.createComponent(SaneUrl)
+      fixture.detectChanges()
+
+      // Set value
+      fixture.componentInstance.customUrl.setValue(value)
+
+      tick(500)
+
+      const req = httpTestingController.expectOne(`${BACKENDURL}saneUrl/${value}`)
+      req.flush('some reason', { status: 404, statusText: 'Not Found.' })
+
+      fixture.detectChanges()
+      flush()
+
+      const submit = fixture.debugElement.query( By.css( submitCss ) )
+      const disabled = !!submit.attributes['disabled']
+      expect(disabled.toString()).toEqual('false')
+
+      submit.triggerEventHandler('click', {})
+
+      fixture.detectChanges()
+
+      const disabledInProgress = !!submit.attributes['disabled']
+      expect(disabledInProgress.toString()).toEqual('true')
+
+      const req2 = httpTestingController.expectOne({
+        method: 'POST',
+        url: `${BACKENDURL}saneUrl/${value}`
+      })
+      
+      req2.flush('Something went wrong', { statusText: 'Wrong status text', status: 500 })
+
+      fixture.detectChanges()
+
+      const input = fixture.debugElement.query( By.css( inputCss ) )
+      const invalid = input.attributes['aria-invalid']
+      expect(invalid.toString()).toEqual('true')
+
+    }))
+  })
+})
diff --git a/src/share/saneUrl/saneUrl.component.ts b/src/share/saneUrl/saneUrl.component.ts
new file mode 100644
index 000000000..c397478f5
--- /dev/null
+++ b/src/share/saneUrl/saneUrl.component.ts
@@ -0,0 +1,189 @@
+import { Component, OnDestroy, Input } from "@angular/core";
+import { HttpClient } from '@angular/common/http'
+import { BACKENDURL } from 'src/util/constants'
+import { Observable, merge, of, Subscription, BehaviorSubject, combineLatest } from "rxjs";
+import { startWith, mapTo, map, debounceTime, switchMap, catchError, shareReplay, filter, tap, takeUntil, distinctUntilChanged } from "rxjs/operators";
+import { FormControl } from "@angular/forms";
+import { ErrorStateMatcher } from "@angular/material/core";
+import { Clipboard } from "@angular/cdk/clipboard";
+import { MatSnackBar } from "@angular/material/snack-bar";
+
+export class SaneUrlErrorStateMatcher implements ErrorStateMatcher{
+  isErrorState(ctrl: FormControl | null){
+    return !!(ctrl && ctrl.invalid)
+  }
+}
+
+enum ESavingProgress {
+  INIT,
+  INPROGRESS,
+  DONE,
+  DEFAULT,
+  ERROR,
+}
+
+enum EBtnTxt {
+  AVAILABLE = 'Available',
+  VERIFYING = 'Verifying ...',
+  CREATING = 'Creating ...',
+  CREATED = 'Created!',
+  DEFAULT = '...',
+}
+
+enum ESavingStatus {
+  PENDING,
+  FREE,
+  NOTFREE,
+}
+
+@Component({
+  selector: 'iav-sane-url',
+  templateUrl: './saneUrl.template.html'
+})
+
+export class SaneUrl implements OnDestroy{
+
+  @Input() stateTobeSaved: any
+
+  private subscriptions: Subscription[] = []
+
+  private validator = (val: string) => /^[a-zA-Z0-9_]+$/.test(val)
+  public customUrl = new FormControl('')
+
+  public matcher = new SaneUrlErrorStateMatcher()
+
+  public createBtnDisabled$: Observable<boolean>
+  public iconClass$: Observable<string>
+
+  public savingStatus$: Observable<ESavingStatus>
+  public btnHintTxt$: Observable<EBtnTxt>
+
+  public savingProgress$: BehaviorSubject<ESavingProgress> = new BehaviorSubject(ESavingProgress.INIT)
+  public saved$: Observable<boolean>
+
+  constructor(
+    private http: HttpClient,
+    private clipboard: Clipboard,
+    private snackbar: MatSnackBar,
+  ){
+
+    const validatedValueInput$ = this.customUrl.valueChanges.pipe(
+      tap(val => {
+        if (!this.validator(val)) {
+          this.customUrl.setErrors({
+            message: 'Shortname must only use the following characters: a-zA-Z0-9_'
+          })
+        }
+      }),
+      filter(this.validator),
+      distinctUntilChanged(),
+      shareReplay(1),
+    )
+
+    const checkAvailable$ = validatedValueInput$.pipe(
+      debounceTime(500),
+      switchMap(val => val === ''
+        ? of(false)
+        : this.http.get(`${this.saneUrlRoot}${val}`).pipe(
+          mapTo(false),
+          catchError((err, obs) => {
+            const { status } = err
+            if (status === 404) return of(true)
+            return of(false)
+          })
+        )
+      ),
+      shareReplay(1)
+    )
+
+    this.savingStatus$ = merge(
+      this.customUrl.valueChanges.pipe(
+        mapTo(ESavingStatus.PENDING)
+      ),
+      checkAvailable$.pipe(
+        map(available => available ? ESavingStatus.FREE : ESavingStatus.NOTFREE)
+      )
+    )
+
+    this.btnHintTxt$ = combineLatest(
+      this.savingStatus$,
+      this.savingProgress$,
+    ).pipe(
+      map(([savingStatus, savingProgress]) => {
+        if (savingProgress === ESavingProgress.DONE) return EBtnTxt.CREATED
+        if (savingProgress === ESavingProgress.INPROGRESS) return EBtnTxt.CREATING
+        if (savingStatus === ESavingStatus.FREE) return EBtnTxt.AVAILABLE
+        if (savingStatus === ESavingStatus.PENDING) return EBtnTxt.VERIFYING
+        return EBtnTxt.DEFAULT
+      })
+    )
+    
+    this.createBtnDisabled$ = this.savingStatus$.pipe(
+      map(val => val !== ESavingStatus.FREE),
+      startWith(true)
+    )
+
+    this.iconClass$ = combineLatest(
+      this.savingStatus$,
+      this.savingProgress$,
+    ).pipe(
+      map(([savingStatus, savingProgress]) => {
+        if (savingProgress === ESavingProgress.DONE) return `fas fa-check`
+        if (savingProgress === ESavingProgress.INPROGRESS) return `fas fa-spinner fa-spin`
+        if (savingStatus === ESavingStatus.FREE) return `fas fa-link`
+        if (savingStatus === ESavingStatus.PENDING) return `fas fa-spinner fa-spin`
+        if (savingStatus === ESavingStatus.NOTFREE) return `fas fa-ban`
+        return EBtnTxt.DEFAULT
+      }),
+      startWith('fas fa-ban'),
+    )
+
+    this.subscriptions.push(
+      checkAvailable$.subscribe(flag => {
+        if (!flag) this.customUrl.setErrors({ message: 'Shortname not available' })
+      })
+    )
+
+    this.saved$ = this.savingProgress$.pipe(
+      map(v => v === ESavingProgress.DONE),
+      startWith(false),
+    )
+  }
+
+  ngOnDestroy(){
+    while(this.subscriptions.length > 0){
+      this.subscriptions.pop().unsubscribe()
+    }
+  }
+
+  saveLink(){
+    this.savingProgress$.next(ESavingProgress.INPROGRESS)
+    this.customUrl.disable()
+    this.http.post(
+      `${this.saneUrlRoot}${this.customUrl.value}`,
+      this.stateTobeSaved
+    ).subscribe(
+      resp => {
+        this.savingProgress$.next(ESavingProgress.DONE)
+      },
+      err => {
+        this.customUrl.enable()
+
+        const { status, error, statusText } = err
+        this.customUrl.setErrors({ message: `${status}: ${error || statusText}` })
+        this.savingProgress$.next(ESavingProgress.INIT)
+      },
+    )
+  }
+
+  copyLinkToClipboard(){
+    const success = this.clipboard.copy(`${this.saneUrlRoot}${this.customUrl.value}`)
+    this.snackbar.open(
+      success ? `Copied URL to clipboard!` : `Failed to copy URL to clipboard!`,
+      null,
+      { duration: 1000 }
+    )
+  }
+
+  public saneUrlRoot = `${BACKENDURL}saneUrl/`
+}
diff --git a/src/share/saneUrl/saneUrl.template.html b/src/share/saneUrl/saneUrl.template.html
new file mode 100644
index 000000000..a673dcef7
--- /dev/null
+++ b/src/share/saneUrl/saneUrl.template.html
@@ -0,0 +1,46 @@
+<mat-form-field class="mr-2">
+  <span matPrefix class="text-muted">
+    {{ saneUrlRoot }}
+  </span>
+
+  <input type="text"
+    autocomplete="off"
+    placeholder="my_custom_url"
+    matInput
+    aria-label="Custom link"
+    (keyup.enter)="submitBtn.disabled ? null : saveLink()"
+    [formControl]="customUrl"
+    [errorStateMatcher]="matcher">
+
+  <button mat-icon-button
+    matSuffix
+    aria-label="Copy created custom URL to clipboard"
+    matTooltip="Copy created custom URL to clipboard."
+    (click)="copyLinkToClipboard()"
+    *ngIf="saved$ | async"
+    color="primary">
+    <i class="fas fa-copy"></i>
+  </button>
+
+  <mat-error *ngIf="customUrl.invalid">
+    Error: {{ customUrl.errors.message }}
+  </mat-error>
+
+  <mat-hint>
+    {{ btnHintTxt$ | async }} 
+  </mat-hint>
+
+</mat-form-field>
+
+<button mat-flat-button
+  (click)="saveLink()"
+  color="primary"
+  aria-label="Create custom link"
+  [disabled]="createBtnDisabled$ | async"
+  #submitBtn="matButton">
+
+  <i [class]="iconClass$ | async"></i>
+  <span>
+    {{ (saved$ | async) ? 'Created!' : 'Create' }}
+  </span>
+</button>
diff --git a/src/share/share.module.ts b/src/share/share.module.ts
index 8ef45901e..385e347b9 100644
--- a/src/share/share.module.ts
+++ b/src/share/share.module.ts
@@ -1,16 +1,26 @@
 import { NgModule } from "@angular/core";
 import { AngularMaterialModule } from "src/ui/sharedModules/angularMaterial.module";
 import { ClipboardCopy } from "./clipboardCopy.directive";
+import { HttpClientModule } from "@angular/common/http";
+import { SaneUrl } from "./saneUrl/saneUrl.component";
+import { CommonModule } from "@angular/common";
+import { ReactiveFormsModule, FormsModule } from "@angular/forms";
 
 @NgModule({
   imports: [
-    AngularMaterialModule
+    AngularMaterialModule,
+    HttpClientModule,
+    CommonModule,
+    FormsModule,
+    ReactiveFormsModule,
   ],
   declarations: [
-    ClipboardCopy
+    ClipboardCopy,
+    SaneUrl,
   ],
   exports: [
-    ClipboardCopy
+    ClipboardCopy,
+    SaneUrl,
   ]
 })
 
diff --git a/src/share/shareSaneLink.directive.ts b/src/share/shareSaneLink.directive.ts
deleted file mode 100644
index e69de29bb..000000000
diff --git a/src/state/index.ts b/src/state/index.ts
new file mode 100644
index 000000000..d5efade12
--- /dev/null
+++ b/src/state/index.ts
@@ -0,0 +1 @@
+export { StateModule } from './state.module'
diff --git a/src/state/state.module.ts b/src/state/state.module.ts
new file mode 100644
index 000000000..2695945ac
--- /dev/null
+++ b/src/state/state.module.ts
@@ -0,0 +1,18 @@
+import { NgModule } from "@angular/core";
+import { StateAggregator } from "./stateAggregator.directive";
+
+// TODO
+// perhaps this should be called StateUtilModule?
+// or alternatively, slowly move all state related components to this module?
+// urlutil should also be at least in this module folder
+
+@NgModule({
+  declarations: [
+    StateAggregator
+  ],
+  exports: [
+    StateAggregator
+  ]
+})
+
+export class StateModule{}
diff --git a/src/state/stateAggregator.directive.ts b/src/state/stateAggregator.directive.ts
new file mode 100644
index 000000000..c9fd436c3
--- /dev/null
+++ b/src/state/stateAggregator.directive.ts
@@ -0,0 +1,38 @@
+import { Directive } from "@angular/core";
+import { Store } from "@ngrx/store";
+import { Observable } from "rxjs";
+import { map, debounceTime, shareReplay } from "rxjs/operators";
+import { IavRootStoreInterface } from "src/services/stateStore.service";
+import { cvtStateToSearchParam } from "src/atlasViewer/atlasViewer.urlUtil";
+
+const jsonVersion = '0.0.1'
+
+interface IJsonifiedState {
+  ver: string
+  queryString: any
+}
+
+@Directive({
+  selector: '[iav-state-aggregator]',
+  exportAs: 'iavStateAggregator'
+})
+
+export class StateAggregator{
+
+  public jsonifiedSstate$: Observable<IJsonifiedState>
+  constructor(
+    private store$: Store<IavRootStoreInterface>
+  ){
+    this.jsonifiedSstate$ = this.store$.pipe(
+      debounceTime(100),
+      map(json => {
+        const queryString = cvtStateToSearchParam(json)
+        return {
+          ver: jsonVersion,
+          queryString: queryString.toString()
+        }
+      }),
+      shareReplay(1)
+    )
+  }
+}
diff --git a/src/ui/nehubaContainer/statusCard/statusCard.component.ts b/src/ui/nehubaContainer/statusCard/statusCard.component.ts
index 0adce7701..f64f9c3ee 100644
--- a/src/ui/nehubaContainer/statusCard/statusCard.component.ts
+++ b/src/ui/nehubaContainer/statusCard/statusCard.component.ts
@@ -6,6 +6,7 @@ import { NehubaViewerUnit } from "../nehubaViewer/nehubaViewer.component";
 import { Observable, Subscription, of, combineLatest, BehaviorSubject } from "rxjs";
 import { distinctUntilChanged, shareReplay, map, filter, startWith } from "rxjs/operators";
 import { MatBottomSheet } from "@angular/material/bottom-sheet";
+import { MatDialog } from "@angular/material/dialog";
 
 @Component({
   selector : 'ui-status-card',
@@ -29,7 +30,8 @@ export class StatusCardComponent implements OnInit, OnChanges{
     private store: Store<ViewerStateInterface>,
     private log: LoggingService,
     private store$: Store<IavRootStoreInterface>,
-    private bottomSheet: MatBottomSheet
+    private bottomSheet: MatBottomSheet,
+    private dialog: MatDialog,
   ) {
     const viewerState$ = this.store$.pipe(
       select('viewerState'),
@@ -154,4 +156,8 @@ export class StatusCardComponent implements OnInit, OnChanges{
       },
     })
   }
+
+  openDialog(tmpl: TemplateRef<any>) {
+    this.dialog.open(tmpl)
+  }
 }
diff --git a/src/ui/nehubaContainer/statusCard/statusCard.template.html b/src/ui/nehubaContainer/statusCard/statusCard.template.html
index 3af8b0b15..fbb52fae9 100644
--- a/src/ui/nehubaContainer/statusCard/statusCard.template.html
+++ b/src/ui/nehubaContainer/statusCard/statusCard.template.html
@@ -78,7 +78,7 @@
     <mat-form-field *ngIf="!isMobile"
       class="w-100">
       <mat-label>
-        Cursor Pos
+        Cursor Position
       </mat-label>
       <input type="text"
         matInput
@@ -99,11 +99,76 @@
       <mat-icon
         class="mr-4"
         fontSet="fas"
-        fontIcon="fa-link">
+        fontIcon="fa-copy">
       </mat-icon>
       <span>
         Copy link to this view
       </span>
     </mat-list-item>
+    <mat-list-item (click)="openDialog(shareSaneUrl)">
+      <mat-icon 
+        class="mr-4"
+        fontSet="fas"
+        fontIcon="fa-link">
+      </mat-icon>
+      
+      <span>
+        Create custom URL
+      </span>
+
+    </mat-list-item>
   </mat-nav-list>
-</ng-template>
\ No newline at end of file
+</ng-template>
+
+<ng-template #shareSaneUrl>
+  <h2 mat-dialog-title>
+    Create custom URL
+  </h2>
+
+  <div mat-dialog-content>
+    <div iav-auth-authState
+      #authState="iavAuthAuthState">
+
+      <!-- Logged in. Explain that links will not expire, offer to logout -->
+      <ng-container *ngIf="authState.user$ | async as user; else otherTmpl">
+        <span>
+          Logged in as {{ user.name }}
+        </span>
+        <button mat-button
+          color="warn"
+          tabindex="-1">
+          <i class="fas fa-sign-in-alt"></i>
+          <span>
+            Logout
+          </span>
+        </button>
+      </ng-container>
+
+      <!-- Not logged in. Offer to login -->
+      <ng-template #otherTmpl>
+        <span>
+          Not logged in
+        </span>
+        <signin-modal></signin-modal>
+      </ng-template>
+    </div>
+
+    <!-- explain links expiration -->
+    <div class="text-muted mat-small">
+      {{ (authState.user$ | async) ? 'Links you generate will not expire' : 'Links you generate will expire after 72 hours' }}
+    </div>
+
+    <iav-sane-url iav-state-aggregator
+      [stateTobeSaved]="stateAggregator.jsonifiedSstate$ | async"
+      #stateAggregator="iavStateAggregator">
+    </iav-sane-url>
+  </div>
+
+  <div mat-dialog-actions
+    class="d-flex justify-content-center">
+    <button mat-button
+      mat-dialog-close>
+      close
+    </button>
+  </div>
+</ng-template>
diff --git a/src/ui/signinBanner/signinBanner.components.ts b/src/ui/signinBanner/signinBanner.components.ts
index 0e9c9cc97..c5036ae52 100644
--- a/src/ui/signinBanner/signinBanner.components.ts
+++ b/src/ui/signinBanner/signinBanner.components.ts
@@ -10,7 +10,7 @@ import {
 import { select, Store } from "@ngrx/store";
 import { Observable } from "rxjs";
 import { map } from "rxjs/operators";
-import { AuthService, IUser } from "src/services/auth.service";
+import { AuthService } from "src/auth";
 import { IavRootStoreInterface, IDataEntry } from "src/services/stateStore.service";
 import {MatDialog, MatDialogRef} from "@angular/material/dialog";
 import {MatBottomSheet} from "@angular/material/bottom-sheet";
@@ -32,7 +32,7 @@ export class SigninBanner {
 
   @ViewChild('takeScreenshotElement', {read: ElementRef}) takeScreenshotElement: ElementRef
 
-  public user$: Observable<IUser>
+  public user$: Observable<any>
   public userBtnTooltip$: Observable<string>
   public favDataEntries$: Observable<IDataEntry[]>
 
diff --git a/src/ui/ui.module.ts b/src/ui/ui.module.ts
index 066031db5..3b57eea7d 100644
--- a/src/ui/ui.module.ts
+++ b/src/ui/ui.module.ts
@@ -47,7 +47,7 @@ import { MobileOverlay } from "./nehubaContainer/mobileOverlay/mobileOverlay.com
 import { MobileControlNubStylePipe } from "./nehubaContainer/pipes/mobileControlNubStyle.pipe";
 import { StatusCardComponent } from "./nehubaContainer/statusCard/statusCard.component";
 import { SigninBanner } from "./signinBanner/signinBanner.components";
-import { SigninModal } from "./signinModal/signinModal.component";
+
 import { TemplateParcellationCitationsContainer } from "./templateParcellationCitations/templateParcellationCitations.component";
 import { FilterNameBySearch } from "./viewerStateController/regionHierachy/filterNameBySearch.pipe";
 
@@ -82,6 +82,8 @@ import { LandmarkUIComponent } from "./landmarkUI/landmarkUI.component";
 import { NehubaModule } from "./nehubaContainer/nehuba.module";
 import { LayerDetailComponent } from "./layerbrowser/layerDetail/layerDetail.component";
 import { ShareModule } from "src/share";
+import { StateModule } from "src/state";
+import { AuthModule } from "src/auth";
 
 @NgModule({
   imports : [
@@ -96,6 +98,8 @@ import { ShareModule } from "src/share";
     AngularMaterialModule,
     NehubaModule,
     ShareModule,
+    StateModule,
+    AuthModule,
   ],
   declarations : [
     NehubaContainer,
@@ -114,7 +118,7 @@ import { ShareModule } from "src/share";
     HelpComponent,
     ConfigComponent,
     SigninBanner,
-    SigninModal,
+    
     StatusCardComponent,
     CookieAgreement,
     KGToS,
@@ -192,7 +196,7 @@ import { ShareModule } from "src/share";
     HelpComponent,
     ConfigComponent,
     SigninBanner,
-    SigninModal,
+    
     CookieAgreement,
     KGToS,
     StatusCardComponent,
diff --git a/src/util/constants.ts b/src/util/constants.ts
index b6f7198ec..a5776463a 100644
--- a/src/util/constants.ts
+++ b/src/util/constants.ts
@@ -11,4 +11,5 @@ export const LOCAL_STORAGE_CONST = {
 
 export const COOKIE_VERSION = '0.3.0'
 export const KG_TOS_VERSION = '0.3.0'
-export const DS_PREVIEW_URL = DATASET_PREVIEW_URL
\ No newline at end of file
+export const DS_PREVIEW_URL = DATASET_PREVIEW_URL
+export const BACKENDURL = BACKEND_URL || 'http://localhost:3000/'
-- 
GitLab