Auth system refactoring (#812)
This brings a new number of improvements regarding auth: - The SSE connections will be made using _event-source-polyfill_ which allows setting headers on the request, and setting authorization headers or tokens as required has been added. The main UI can now be used when the API is fully protected, including the user operations like controlling items ("Implicit user role for unauthenticated requests" disabled in Settings > API Security). This closes #699. We try to avoid the use of the polyfill if it's not needed, as the native implementation is deemed better if it can be used. To figure out whether that's the case, a `HTTP HEAD /rest/events` is performed when setting the access token to determine if that endpoint is secured - set the requireToken in case of a 401 response indicating it is. - The UI now detects when the first /rest/ API calls fails with a 401 Unauthorized error. This can happen when it's installed as a standalone PWA ("Add to Home Screen" or "Install" in Chrome). In that case, it will display a f7 login dialog and will also try to use the Credentials API to store the passed credentials in the browser, and retrieve them back to log in automatically the next time. It will use these as Basic authentication credentials. This prevents "empty screen" errors when using e.g. myopenhab.org and the cloud credentials couldn't be passed. The Basic credentials for proxy services can also be passed from mobile with the following Javascript functions in the `OHApp` object: * `string getBasicCredentialsUsername()` * `string getBasicCredentialsPassword()` Also in that case, image handling in various parts of the UI has to be modified to use blobs. This allows passing the credentials if they're needed and known. - Detects whether the API auth bundle is disabled (no /auth endpoint), hides the authorize button and unlocks the admin menus. Signed-off-by: Yannick Schaus <github@schaus.net>3.0.x
parent
7384ec9387
commit
6a7ad46fdf
File diff suppressed because it is too large
Load Diff
|
@ -61,6 +61,7 @@
|
|||
"dayjs": "^1.9.6",
|
||||
"dom7": "^2.1.5",
|
||||
"echarts": "^4.9.0",
|
||||
"event-source-polyfill": "^1.0.22",
|
||||
"expression-eval": "^2.1.0",
|
||||
"framework7": "^5.7.12",
|
||||
"framework7-icons": "^3.0.1",
|
||||
|
@ -119,6 +120,7 @@
|
|||
"eslint-loader": "^2.2.1",
|
||||
"eslint-plugin-vue": "^5.2.3",
|
||||
"file-loader": "^3.0.1",
|
||||
"global-prefix": "^3.0.0",
|
||||
"html-webpack-plugin": "^3.2.0",
|
||||
"jest": "^24.9.0",
|
||||
"jest-serializer-vue": "^2.0.2",
|
||||
|
@ -130,6 +132,7 @@
|
|||
"ora": "^3.4.0",
|
||||
"postcss-loader": "^3.0.0",
|
||||
"postcss-preset-env": "^6.7.0",
|
||||
"resolve-dir": "^1.0.1",
|
||||
"rimraf": "^2.7.1",
|
||||
"standard": "^12.0.1",
|
||||
"strip-ansi": "=3.0.1",
|
||||
|
@ -139,12 +142,11 @@
|
|||
"swagger-ui-dist": "^3.32.5",
|
||||
"uglifyjs-webpack-plugin": "^2.2.0",
|
||||
"url-loader": "^1.1.2",
|
||||
"vue-jest": "^3.0.6",
|
||||
"vue-loader": "^15.9.3",
|
||||
"vue-masonry-css": "^1.0.3",
|
||||
"vue-style-loader": "^4.1.2",
|
||||
"vue-template-compiler": "^2.6.12",
|
||||
"webpack": "^4.44.1",
|
||||
"webpack": "^4.46.0",
|
||||
"webpack-bundle-analyzer": "^3.8.0",
|
||||
"webpack-cli": "^3.3.12",
|
||||
"webpack-dev-server": "^3.11.0",
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<f7-app v-if="init" :style="{ visibility: !($store.getters.user || $store.getters.page('overview')) ? 'hidden' : '' }" :params="f7params" :class="{ 'theme-dark': this.themeOptions.dark === 'dark', 'theme-filled': this.themeOptions.bars === 'filled' }">
|
||||
<f7-app v-if="init" :style="{ visibility: (($store.getters.user || $store.getters.page('overview')) || loginScreenOpened) ? '' : 'hidden' }" :params="f7params" :class="{ 'theme-dark': this.themeOptions.dark === 'dark', 'theme-filled': this.themeOptions.bars === 'filled' }">
|
||||
|
||||
<!-- Left Panel -->
|
||||
<f7-panel v-show="ready" left :cover="showSidebar" class="sidebar" :visible-breakpoint="1024">
|
||||
|
@ -94,7 +94,7 @@
|
|||
<f7-icon slot="media" size="14" :f7="this.visibleBreakpointDisabled ? 'pin_slash' : 'pin'" color="gray"></f7-icon>
|
||||
</f7-link>
|
||||
|
||||
<div slot="fixed" class="account" v-if="ready">
|
||||
<div slot="fixed" class="account" v-if="ready && this.$store.getters.apiEndpoint('auth')">
|
||||
<div class="display-flex justify-content-center">
|
||||
<div class="hint-signin" v-if="!$store.getters.user && !$store.getters.pages.filter((p) => p.uid !== 'overview').length">
|
||||
<em>{{ $t('sidebar.tip.signIn') }}<br /><f7-icon f7="arrow_down" size="20"></f7-icon></em>
|
||||
|
@ -112,7 +112,7 @@
|
|||
</f7-panel>
|
||||
|
||||
<!-- Right Panel -->
|
||||
<f7-panel right reveal theme-dark>
|
||||
<f7-panel right reveal theme-dark v-if="ready">
|
||||
<panel-right />
|
||||
<!-- <f7-view url="/panel-right/"></f7-view> -->
|
||||
</f7-panel>
|
||||
|
@ -123,7 +123,7 @@
|
|||
|
||||
<f7-view main v-show="ready" class="safe-areas" url="/" :master-detail-breakpoint="960" :animate="themeOptions.pageTransitionAnimation !== 'disabled'"></f7-view>
|
||||
|
||||
<f7-login-screen id="my-login-screen" :opened="loginScreenOpened">
|
||||
<!-- <f7-login-screen id="my-login-screen" :opened="loginScreenOpened">
|
||||
<f7-view name="login" v-if="$device.cordova">
|
||||
<f7-page login-screen>
|
||||
<f7-login-screen-title><img width="200px" src="res/img/openhab-logo.png"><br>Login</f7-login-screen-title>
|
||||
|
@ -158,7 +158,7 @@
|
|||
</f7-list>
|
||||
</f7-page>
|
||||
</f7-view>
|
||||
</f7-login-screen>
|
||||
</f7-login-screen> -->
|
||||
</f7-app>
|
||||
</template>
|
||||
|
||||
|
@ -360,6 +360,7 @@ export default {
|
|||
},
|
||||
computed: {
|
||||
isAdmin () {
|
||||
if (!this.$store.getters.apiEndpoint('auth')) return true
|
||||
return this.ready && this.user && this.user.roles && this.user.roles.indexOf('administrator') >= 0
|
||||
},
|
||||
serverDisplayUrl () {
|
||||
|
@ -367,12 +368,48 @@ export default {
|
|||
}
|
||||
},
|
||||
methods: {
|
||||
loadData () {
|
||||
return this.$oh.api.get('/rest/')
|
||||
loadData (useCredentials) {
|
||||
const useCredentialsPromise = (useCredentials) ? this.setBasicCredentials() : Promise.resolve()
|
||||
return useCredentialsPromise
|
||||
.then(() => { return this.$oh.api.get('/rest/') })
|
||||
.catch((err) => {
|
||||
if (err === 'Unauthorized') {
|
||||
if (!useCredentials) {
|
||||
// try again with credentials
|
||||
this.loadData(true)
|
||||
return Promise.reject()
|
||||
}
|
||||
this.loginScreenOpened = true
|
||||
this.$nextTick(() => {
|
||||
this.$f7.dialog.login(
|
||||
window.location.host,
|
||||
'openHAB',
|
||||
(username, password) => {
|
||||
this.setBasicCredentials(username, password)
|
||||
this.$oh.api.get('/rest/').then((rootResponse) => {
|
||||
this.storeBasicCredentials()
|
||||
this.loadData()
|
||||
}).catch((err) => {
|
||||
if (err === 'Unauthorized') {
|
||||
this.clearBasicCredentials()
|
||||
this.loadData()
|
||||
return Promise.reject()
|
||||
}
|
||||
})
|
||||
},
|
||||
() => {
|
||||
return Promise.reject()
|
||||
}
|
||||
)
|
||||
})
|
||||
return Promise.reject()
|
||||
}
|
||||
})
|
||||
.then((rootResponse) => {
|
||||
// store the REST API services present on the system
|
||||
this.$store.commit('setRootResource', { rootResponse })
|
||||
this.updateLocale()
|
||||
if (!this.$store.getters.apiEndpoint('auth')) this.$store.commit('setNoAuth', true)
|
||||
return rootResponse
|
||||
}).then((rootResponse) => {
|
||||
let locale = this.$store.state.locale
|
||||
|
@ -409,6 +446,7 @@ export default {
|
|||
},
|
||||
pageIsVisible (page) {
|
||||
if (!page.config.visibleTo) return true
|
||||
if (this.$store.getters.noAuth) return true
|
||||
const user = this.$store.getters.user
|
||||
if (!user) return false
|
||||
if (user.roles && user.roles.some(r => page.config.visibleTo.indexOf('role:' + r) >= 0)) return true
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import { Utils } from 'framework7'
|
||||
|
||||
import { authorize, setBasicCredentials, clearBasicCredentials, storeBasicCredentials } from '@/js/openhab/auth'
|
||||
|
||||
export default {
|
||||
data () {
|
||||
return {
|
||||
|
@ -7,27 +9,10 @@ export default {
|
|||
}
|
||||
},
|
||||
methods: {
|
||||
authorize,
|
||||
getRefreshToken () {
|
||||
return localStorage.getItem('openhab.ui:refreshToken') || null
|
||||
},
|
||||
authorize (setup) {
|
||||
import('pkce-challenge').then((PkceChallenge) => {
|
||||
const pkceChallenge = PkceChallenge.default()
|
||||
const authState = (setup ? 'setup-' : '') + this.$f7.utils.id()
|
||||
|
||||
sessionStorage.setItem('openhab.ui:codeVerifier', pkceChallenge.code_verifier)
|
||||
sessionStorage.setItem('openhab.ui:authState', authState)
|
||||
|
||||
window.location = '/auth' +
|
||||
'?response_type=code' +
|
||||
'&client_id=' + encodeURIComponent(window.location.origin) +
|
||||
'&redirect_uri=' + encodeURIComponent(window.location.origin) +
|
||||
'&scope=admin' +
|
||||
'&code_challenge_method=S256' +
|
||||
'&code_challenge=' + encodeURIComponent(pkceChallenge.code_challenge) +
|
||||
'&state=' + authState
|
||||
})
|
||||
},
|
||||
tryExchangeAuthorizationCode () {
|
||||
return new Promise((resolve, reject) => {
|
||||
const queryParams = Utils.parseUrlQuery(window.location.href)
|
||||
|
@ -52,19 +37,20 @@ export default {
|
|||
'code_verifier': codeVerifier
|
||||
})
|
||||
|
||||
this.$oh.api.setAccessToken(null)
|
||||
this.$oh.auth.clearAccessToken()
|
||||
this.$oh.api.postPlain('/rest/auth/token?useCookie=true', payload, 'application/json', 'application/x-www-form-urlencoded').then((data) => {
|
||||
const resp = JSON.parse(data)
|
||||
localStorage.setItem('openhab.ui:refreshToken', resp.refresh_token)
|
||||
this.$oh.api.setAccessToken(resp.access_token)
|
||||
// schedule the next token refresh when 95% of this token's lifetime has elapsed, i.e. 3 minutes before a 1-hour token is due to expire
|
||||
setTimeout(this.refreshAccessToken, resp.expires_in * 950)
|
||||
this.$store.commit('setUser', { user: resp.user })
|
||||
return this.$oh.auth.setAccessToken(resp.access_token).then(() => {
|
||||
// schedule the next token refresh when 95% of this token's lifetime has elapsed, i.e. 3 minutes before a 1-hour token is due to expire
|
||||
setTimeout(this.refreshAccessToken, resp.expires_in * 950)
|
||||
this.$store.commit('setUser', { user: resp.user })
|
||||
|
||||
const nextRoute = authState.indexOf('setup') === 0 ? '/setup-wizard/' : '/'
|
||||
this.$f7.views.main.router.navigate(nextRoute, { animate: false, clearPreviousHistory: true })
|
||||
const nextRoute = authState.indexOf('setup') === 0 ? '/setup-wizard/' : '/'
|
||||
this.$f7.views.main.router.navigate(nextRoute, { animate: false, clearPreviousHistory: true })
|
||||
|
||||
resolve(resp.user)
|
||||
resolve(resp.user)
|
||||
})
|
||||
}).catch((err) => {
|
||||
console.log(err)
|
||||
reject(err)
|
||||
|
@ -84,17 +70,18 @@ export default {
|
|||
'refresh_token': refreshToken
|
||||
})
|
||||
|
||||
this.$oh.api.setAccessToken(null)
|
||||
this.$oh.auth.clearAccessToken()
|
||||
this.$oh.api.postPlain('/rest/auth/token', payload, 'application/json', 'application/x-www-form-urlencoded').then((data) => {
|
||||
const resp = JSON.parse(data)
|
||||
this.$oh.api.setAccessToken(resp.access_token)
|
||||
// schedule the next token refresh when 95% of this token's lifetime has elapsed, i.e. 3 minutes before a 1-hour token is due to expire
|
||||
setTimeout(this.refreshAccessToken, resp.expires_in * 950)
|
||||
// also make sure to check the token and renew it when the app becomes visible again
|
||||
this.currentTokenExpireTime = new Date().getTime() + resp.expires_in * 950
|
||||
document.addEventListener('visibilitychange', this.checkTokenAfterVisibilityChange)
|
||||
this.$store.commit('setUser', { user: resp.user })
|
||||
resolve(resp)
|
||||
return this.$oh.auth.setAccessToken(resp.access_token).then(() => {
|
||||
// schedule the next token refresh when 95% of this token's lifetime has elapsed, i.e. 3 minutes before a 1-hour token is due to expire
|
||||
setTimeout(this.refreshAccessToken, resp.expires_in * 950)
|
||||
// also make sure to check the token and renew it when the app becomes visible again
|
||||
this.currentTokenExpireTime = new Date().getTime() + resp.expires_in * 950
|
||||
document.addEventListener('visibilitychange', this.checkTokenAfterVisibilityChange)
|
||||
this.$store.commit('setUser', { user: resp.user })
|
||||
resolve(resp)
|
||||
})
|
||||
}).catch((err) => {
|
||||
console.log(err)
|
||||
reject(err)
|
||||
|
@ -116,16 +103,19 @@ export default {
|
|||
localStorage.removeItem('openhab.ui:refreshToken')
|
||||
this.$oh.api.postPlain('/rest/auth/logout', payload, 'application/json', 'application/x-www-form-urlencoded').then((data) => {
|
||||
console.log('Logged out')
|
||||
this.$oh.api.setAccessToken(null)
|
||||
this.$oh.auth.clearAccessToken()
|
||||
this.$store.commit('setUser', { user: null })
|
||||
resolve()
|
||||
}).catch((err) => {
|
||||
console.log(err)
|
||||
this.$oh.api.setAccessToken(null)
|
||||
this.$oh.auth.clearAccessToken()
|
||||
this.$store.commit('setUser', { user: null })
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
setBasicCredentials,
|
||||
clearBasicCredentials,
|
||||
storeBasicCredentials
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
<template>
|
||||
<f7-card expandable ref="card" class="model-card" :class="type + '-card'" :animate="$f7.data.themeOptions.expandableCardAnimation !== 'disabled'" card-tablet-fullscreen v-on:card:opened="cardOpening" v-on:card:closed="cardClosed">
|
||||
<f7-card-content :padding="false">
|
||||
<div :class="(!config.backgroundImage) ? `bg-color-${color}` : undefined" :style="{ height: `calc(var(--f7-safe-area-top) + ${headerHeight})` }">
|
||||
<div :class="(!backgroundImageUrl) ? `bg-color-${color}` : undefined" :style="{ height: `calc(var(--f7-safe-area-top) + ${headerHeight})` }">
|
||||
<f7-card-header :text-color="config.invertText ? 'black' : 'white'" class="display-block card-header" :style="{ height: `calc(var(--f7-safe-area-top) + ${headerHeight})` }">
|
||||
<img v-if="config.backgroundImage" class="card-background" :src="config.backgroundImage" :style="config.backgroundImageStyle" />
|
||||
<img v-if="config.backgroundImage" class="card-background lazy" :src="backgroundImageUrl" :style="config.backgroundImageStyle" />
|
||||
<slot name="header">
|
||||
<div v-if="context && context.component.slots && context.component.slots.header">
|
||||
<generic-widget-component :context="childContext(slotComponent)" v-for="(slotComponent, idx) in context.component.slots.header" :key="'header-' + idx" @command="onCommand" />
|
||||
|
@ -89,6 +89,15 @@ export default {
|
|||
mixins: [CardMixin],
|
||||
props: ['headerHeight'],
|
||||
methods: {
|
||||
},
|
||||
asyncComputed: {
|
||||
backgroundImageUrl () {
|
||||
if (this.config.backgroundImage) {
|
||||
return this.$oh.media.getImage(this.config.backgroundImage)
|
||||
} else {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -21,7 +21,7 @@
|
|||
@update:center="centerUpdate"
|
||||
@update:zoom="zoomUpdate">
|
||||
<l-image-overlay
|
||||
:url="config.imageUrl"
|
||||
:url="backgroundImageUrl"
|
||||
:bounds="bounds"
|
||||
/>
|
||||
<l-feature-group ref="featureGroup" v-if="context.component.slots && ready">
|
||||
|
@ -87,7 +87,7 @@ export default {
|
|||
minZoom: -2,
|
||||
zoom: -0.5,
|
||||
crs: CRS.Simple,
|
||||
showMap: true,
|
||||
showMap: false,
|
||||
mapKey: this.$f7.utils.id(),
|
||||
markers: []
|
||||
}
|
||||
|
@ -110,12 +110,18 @@ export default {
|
|||
} : {})
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.fitMapBounds()
|
||||
asyncComputed: {
|
||||
backgroundImageUrl () {
|
||||
return this.$oh.media.getImage(this.config.imageUrl)
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'config.noZoomOrDrag': function (val) {
|
||||
this.refreshMap()
|
||||
},
|
||||
backgroundImageUrl (val) {
|
||||
this.showMap = true
|
||||
this.refreshMap()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
@ -144,7 +150,7 @@ export default {
|
|||
onMarkerUpdate () {
|
||||
},
|
||||
fitMapBounds () {
|
||||
this.$refs.map.mapObject.fitBounds(this.bounds)
|
||||
if (this.$refs.map) this.$refs.map.mapObject.fitBounds(this.bounds)
|
||||
},
|
||||
refreshMap () {
|
||||
this.mapKey = this.$f7.utils.id()
|
||||
|
|
|
@ -23,7 +23,9 @@ export default {
|
|||
},
|
||||
watch: {
|
||||
url (val) {
|
||||
this.src = this.$oh.media.getImage(val)
|
||||
this.$oh.media.getImage(val).then((url) => {
|
||||
this.src = url
|
||||
})
|
||||
},
|
||||
src (val) {
|
||||
if (this.config.lazy) this.$nextTick(() => { this.$f7.lazy.loadImage(this.$refs.lazyImage) })
|
||||
|
@ -47,7 +49,9 @@ export default {
|
|||
if (this.config.item) {
|
||||
this.loadItemImage()
|
||||
} else {
|
||||
this.src = this.$oh.media.getImage(this.config.url)
|
||||
this.$oh.media.getImage(this.config.url).then((url) => {
|
||||
this.src = url
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import Framework7 from 'framework7/framework7-lite.esm.bundle.js'
|
||||
|
||||
let accessToken = null
|
||||
import { getAccessToken, getTokenInCustomHeader, getBasicCredentials } from './auth'
|
||||
|
||||
function wrapPromise (f7promise) {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
@ -13,20 +12,21 @@ function wrapPromise (f7promise) {
|
|||
Framework7.request.setup({
|
||||
xhrFields: { withCredentials: true },
|
||||
beforeSend (xhr) {
|
||||
if (accessToken) {
|
||||
if (document.cookie.indexOf('X-OPENHAB-AUTH-HEADER') >= 0) {
|
||||
xhr.setRequestHeader('X-OPENHAB-TOKEN', accessToken)
|
||||
if (getAccessToken()) {
|
||||
if (getTokenInCustomHeader()) {
|
||||
xhr.setRequestHeader('X-OPENHAB-TOKEN', getAccessToken())
|
||||
} else {
|
||||
xhr.setRequestHeader('Authorization', 'Bearer ' + accessToken)
|
||||
xhr.setRequestHeader('Authorization', 'Bearer ' + getAccessToken())
|
||||
}
|
||||
}
|
||||
if (getBasicCredentials()) {
|
||||
const creds = getBasicCredentials()
|
||||
xhr.setRequestHeader('Authorization', 'Basic ' + btoa(creds.id + ':' + creds.password))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export default {
|
||||
setAccessToken (token) {
|
||||
accessToken = token
|
||||
},
|
||||
get (uri, data) {
|
||||
return wrapPromise(Framework7.request.promise.json(uri, data))
|
||||
},
|
||||
|
|
|
@ -0,0 +1,113 @@
|
|||
import Framework7 from 'framework7/framework7-lite.esm.bundle.js'
|
||||
|
||||
/**
|
||||
* The current access token
|
||||
*/
|
||||
let accessToken = null
|
||||
|
||||
/**
|
||||
* The access token should be passed in the X-OPENHAB-TOKEN header instead of Authorization: Bearer
|
||||
*/
|
||||
let tokenInCustomHeader = false
|
||||
|
||||
/**
|
||||
* The PasswordCredential to authenticate to a reverse proxy service like openHAB Cloud
|
||||
*/
|
||||
let basicCredentials = null
|
||||
|
||||
/**
|
||||
* The token is required for all requests, including SSE
|
||||
*/
|
||||
let requireToken = false
|
||||
|
||||
export function getAccessToken () { return accessToken }
|
||||
export function getTokenInCustomHeader () { return tokenInCustomHeader }
|
||||
export function getBasicCredentials () { return basicCredentials }
|
||||
export function getRequireToken () { return requireToken }
|
||||
|
||||
if (document.cookie.indexOf('X-OPENHAB-AUTH-HEADER')) tokenInCustomHeader = true
|
||||
|
||||
export function authorize (setup) {
|
||||
import('pkce-challenge').then((PkceChallenge) => {
|
||||
const pkceChallenge = PkceChallenge.default()
|
||||
const authState = (setup ? 'setup-' : '') + Framework7.utils.id()
|
||||
|
||||
sessionStorage.setItem('openhab.ui:codeVerifier', pkceChallenge.code_verifier)
|
||||
sessionStorage.setItem('openhab.ui:authState', authState)
|
||||
|
||||
window.location = '/auth' +
|
||||
'?response_type=code' +
|
||||
'&client_id=' + encodeURIComponent(window.location.origin) +
|
||||
'&redirect_uri=' + encodeURIComponent(window.location.origin) +
|
||||
'&scope=admin' +
|
||||
'&code_challenge_method=S256' +
|
||||
'&code_challenge=' + encodeURIComponent(pkceChallenge.code_challenge) +
|
||||
'&state=' + authState
|
||||
})
|
||||
}
|
||||
|
||||
export function setBasicCredentials (username, password) {
|
||||
if (username && password) {
|
||||
console.log('Using passed credentials')
|
||||
basicCredentials = { id: username, password: password }
|
||||
tokenInCustomHeader = true
|
||||
return Promise.resolve()
|
||||
} else if (window.OHApp && window.OHApp.getBasicCredentialsUsername) {
|
||||
const usernameFromApp = window.OHApp.getBasicCredentialsUsername()
|
||||
const passwordFromApp = window.OHApp.getBasicCredentialsPassword()
|
||||
basicCredentials = { id: usernameFromApp, password: passwordFromApp }
|
||||
tokenInCustomHeader = true
|
||||
return Promise.resolve()
|
||||
} else if (navigator.credentials && navigator.credentials.preventSilentAccess && window.PasswordCredential) {
|
||||
return navigator.credentials.get({ password: true }).then((creds) => {
|
||||
if (creds) {
|
||||
console.log('Using stored Basic credentials to sign in to a reverse proxy service')
|
||||
basicCredentials = { id: creds.id, password: creds.password }
|
||||
tokenInCustomHeader = true
|
||||
}
|
||||
return Promise.resolve()
|
||||
})
|
||||
} else {
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
export function clearBasicCredentials () {
|
||||
basicCredentials = null
|
||||
tokenInCustomHeader = document.cookie.indexOf('X-OPENHAB-AUTH-HEADER')
|
||||
}
|
||||
|
||||
export function storeBasicCredentials () {
|
||||
if (basicCredentials && navigator.credentials && navigator.credentials.preventSilentAccess && window.PasswordCredential) {
|
||||
navigator.credentials.store(new window.PasswordCredential(basicCredentials))
|
||||
}
|
||||
}
|
||||
|
||||
export function setAccessToken (token) {
|
||||
accessToken = token
|
||||
|
||||
// determine whether the token is required for user operations
|
||||
let headers = {}
|
||||
if (getBasicCredentials()) {
|
||||
const creds = getBasicCredentials()
|
||||
headers.Authorization = 'Basic ' + btoa(creds.id + ':' + creds.password)
|
||||
}
|
||||
return fetch('rest/events', { method: 'HEAD', credentials: 'include', headers })
|
||||
.then((resp) => {
|
||||
if (resp.status === 401) {
|
||||
requireToken = true
|
||||
if (!token) authorize()
|
||||
}
|
||||
return Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
export function clearAccessToken () {
|
||||
accessToken = null
|
||||
}
|
||||
|
||||
export default {
|
||||
setAccessToken,
|
||||
clearAccessToken,
|
||||
setBasicCredentials
|
||||
}
|
|
@ -1,10 +1,12 @@
|
|||
import api from './api'
|
||||
import auth from '../auth'
|
||||
import sse from './sse'
|
||||
import media from './media'
|
||||
import speech from './speech'
|
||||
|
||||
export default {
|
||||
api,
|
||||
auth,
|
||||
sse,
|
||||
media,
|
||||
speech
|
||||
|
|
|
@ -34,6 +34,6 @@ export default {
|
|||
}
|
||||
},
|
||||
getImage (url) {
|
||||
return url
|
||||
return Promise.resolve(url)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
import api from './api'
|
||||
import auth from './auth'
|
||||
import sse from './sse'
|
||||
import media from './media'
|
||||
import speech from './speech'
|
||||
|
||||
export default {
|
||||
api,
|
||||
auth,
|
||||
sse,
|
||||
media,
|
||||
speech
|
||||
|
|
|
@ -1,33 +1,39 @@
|
|||
import { getBasicCredentials } from '@/js/openhab/auth'
|
||||
import Framework7 from 'framework7/framework7-lite.esm.bundle.js'
|
||||
|
||||
export default {
|
||||
getIcon: (icon, format, state) => {
|
||||
if (!format) format = 'svg'
|
||||
let url = `/icon/${icon}?format=${format}&anyFormat=true`
|
||||
if (state) url += `&state=${encodeURIComponent(state)}`
|
||||
|
||||
// TODO handle basic auth with blobs and data URIs if necessary
|
||||
// return new Promise((resolve, reject) => {
|
||||
// Framework7.request.promise({ url, xhrFields: { responseType: 'blob' } }).then((resp) => {
|
||||
// let reader = new FileReader()
|
||||
// reader.readAsDataURL(resp.data)
|
||||
// reader.onload = () => {
|
||||
// return resolve(reader.result)
|
||||
// }
|
||||
// })
|
||||
// })
|
||||
return Promise.resolve(url)
|
||||
if (getBasicCredentials()) {
|
||||
return new Promise((resolve, reject) => {
|
||||
Framework7.request.promise({ url, xhrFields: { responseType: 'blob' } }).then((resp) => {
|
||||
let reader = new FileReader()
|
||||
reader.readAsDataURL(resp.data)
|
||||
reader.onload = () => {
|
||||
return resolve(reader.result)
|
||||
}
|
||||
})
|
||||
})
|
||||
} else {
|
||||
return Promise.resolve(url)
|
||||
}
|
||||
},
|
||||
getImage: (url) => {
|
||||
return url
|
||||
|
||||
// TODO handle basic auth with blobs and data URIs if necessary
|
||||
// return new Promise((resolve, reject) => {
|
||||
// Framework7.request.promise({ url, xhrFields: { responseType: 'blob' } }).then((resp) => {
|
||||
// let reader = new FileReader()
|
||||
// reader.readAsDataURL(resp.data)
|
||||
// reader.onload = () => {
|
||||
// return resolve(reader.result)
|
||||
// }
|
||||
// })
|
||||
// })
|
||||
if (getBasicCredentials()) {
|
||||
return new Promise((resolve, reject) => {
|
||||
Framework7.request.promise({ url, xhrFields: { responseType: 'blob' } }).then((resp) => {
|
||||
let reader = new FileReader()
|
||||
reader.readAsDataURL(resp.data)
|
||||
reader.onload = () => {
|
||||
return resolve(reader.result)
|
||||
}
|
||||
})
|
||||
})
|
||||
} else {
|
||||
return Promise.resolve(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,27 @@
|
|||
import { EventSourcePolyfill, NativeEventSource } from 'event-source-polyfill'
|
||||
import { getAccessToken, getTokenInCustomHeader, getBasicCredentials, getRequireToken } from './auth'
|
||||
|
||||
let openSSEClients = []
|
||||
|
||||
function newSSEConnection (path, readyCallback, messageCallback, errorCallback) {
|
||||
let eventSource
|
||||
// TODO handle basic auth with polyfill if necessary
|
||||
eventSource = new EventSource(path)
|
||||
const headers = {}
|
||||
if (getAccessToken() && getRequireToken()) {
|
||||
if (getTokenInCustomHeader()) {
|
||||
headers['Authorization'] = 'Bearer ' + getAccessToken()
|
||||
} else {
|
||||
headers['X-OPENHAB-TOKEN'] = getAccessToken()
|
||||
}
|
||||
}
|
||||
if (getBasicCredentials()) {
|
||||
const creds = getBasicCredentials()
|
||||
headers['Authorization'] = 'Basic ' + btoa(creds.id + ':' + creds.password)
|
||||
}
|
||||
if (Object.keys(headers).length > 0) {
|
||||
eventSource = new EventSourcePolyfill(path, { headers })
|
||||
} else {
|
||||
eventSource = new NativeEventSource(path)
|
||||
}
|
||||
|
||||
eventSource.addEventListener('ready', (e) => {
|
||||
readyCallback(e.data)
|
||||
|
|
|
@ -1,15 +1,20 @@
|
|||
const state = {
|
||||
user: null
|
||||
user: null,
|
||||
noAuth: false
|
||||
}
|
||||
|
||||
const getters = {
|
||||
user: (state) => state.user,
|
||||
isAdmin: (state) => state.user && state.user.roles && state.user.roles.indexOf('administrator') >= 0
|
||||
noAuth: (state) => state.noAuth,
|
||||
isAdmin: (state) => state.noAuth || (state.user && state.user.roles && state.user.roles.indexOf('administrator') >= 0)
|
||||
}
|
||||
|
||||
const mutations = {
|
||||
setUser (state, { user }) {
|
||||
state.user = user
|
||||
},
|
||||
setNoAuth (state, value) {
|
||||
state.noAuth = value
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -145,6 +145,7 @@ export default {
|
|||
ready (val) {
|
||||
if (val) {
|
||||
this.loadModel()
|
||||
this.$store.dispatch('startTrackingStates')
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -153,8 +154,10 @@ export default {
|
|||
this.overviewPageKey = this.$utils.id()
|
||||
},
|
||||
onPageAfterIn () {
|
||||
this.$store.dispatch('startTrackingStates')
|
||||
if (this.ready) this.loadModel()
|
||||
if (this.ready) {
|
||||
this.loadModel()
|
||||
this.$store.dispatch('startTrackingStates')
|
||||
}
|
||||
},
|
||||
onPageBeforeOut () {
|
||||
this.$store.dispatch('stopTrackingStates')
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import cardGroups from './homecards-grouping'
|
||||
import { compareItems } from '@/components/widgets/widget-order'
|
||||
import { loadLocaleMessages } from '@/js/i18n'
|
||||
import { authorize } from '@/js/openhab/auth'
|
||||
|
||||
export default {
|
||||
i18n: {
|
||||
|
@ -50,76 +51,83 @@ export default {
|
|||
}
|
||||
},
|
||||
loadModel (page) {
|
||||
this.$oh.api.get('/rest/items?metadata=semantics,listWidget,widgetOrder').then((data) => {
|
||||
this.items = data
|
||||
// get the location items
|
||||
const locations = data.filter((item, index, items) => {
|
||||
return item.metadata && item.metadata.semantics &&
|
||||
item.metadata.semantics.value.indexOf('Location') === 0
|
||||
}).sort(this.compareObjects).map((l) => {
|
||||
return {
|
||||
item: l,
|
||||
properties: data.filter((item, index, items) => {
|
||||
return item.metadata && item.metadata.semantics &&
|
||||
item.metadata.semantics && item.metadata.semantics.config &&
|
||||
item.metadata.semantics.config.relatesTo &&
|
||||
item.metadata.semantics.config.hasLocation === l.name
|
||||
}).sort(this.compareObjects),
|
||||
equipment: data.filter((item, index, items) => {
|
||||
return item.metadata && item.metadata.semantics &&
|
||||
item.metadata.semantics && item.metadata.semantics.config &&
|
||||
item.metadata.semantics.value.indexOf('Equipment') === 0 &&
|
||||
item.metadata.semantics.config.hasLocation === l.name
|
||||
}).sort(this.compareObjects).map((item) => {
|
||||
return {
|
||||
item: item,
|
||||
points: data.filter((item2, index, items) => {
|
||||
return item2.metadata && item2.metadata.semantics &&
|
||||
item2.metadata.semantics && item2.metadata.semantics.config &&
|
||||
item2.metadata.semantics.config.isPointOf === item.name
|
||||
}).sort(this.compareObjects)
|
||||
}
|
||||
})
|
||||
this.$oh.api.get('/rest/items?metadata=semantics,listWidget,widgetOrder')
|
||||
.then((data) => {
|
||||
this.items = data
|
||||
// get the location items
|
||||
const locations = data.filter((item, index, items) => {
|
||||
return item.metadata && item.metadata.semantics &&
|
||||
item.metadata.semantics.value.indexOf('Location') === 0
|
||||
}).sort(this.compareObjects).map((l) => {
|
||||
return {
|
||||
item: l,
|
||||
properties: data.filter((item, index, items) => {
|
||||
return item.metadata && item.metadata.semantics &&
|
||||
item.metadata.semantics && item.metadata.semantics.config &&
|
||||
item.metadata.semantics.config.relatesTo &&
|
||||
item.metadata.semantics.config.hasLocation === l.name
|
||||
}).sort(this.compareObjects),
|
||||
equipment: data.filter((item, index, items) => {
|
||||
return item.metadata && item.metadata.semantics &&
|
||||
item.metadata.semantics && item.metadata.semantics.config &&
|
||||
item.metadata.semantics.value.indexOf('Equipment') === 0 &&
|
||||
item.metadata.semantics.config.hasLocation === l.name
|
||||
}).sort(this.compareObjects).map((item) => {
|
||||
return {
|
||||
item: item,
|
||||
points: data.filter((item2, index, items) => {
|
||||
return item2.metadata && item2.metadata.semantics &&
|
||||
item2.metadata.semantics && item2.metadata.semantics.config &&
|
||||
item2.metadata.semantics.config.isPointOf === item.name
|
||||
}).sort(this.compareObjects)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// get the equipment items
|
||||
const equipment = data.filter((item, index, items) => {
|
||||
return item.metadata && item.metadata.semantics &&
|
||||
item.metadata.semantics &&
|
||||
item.metadata.semantics.value.indexOf('Equipment') === 0
|
||||
}).sort(this.compareObjects).reduce((prev, item, i, properties) => {
|
||||
const equipmentType = item.metadata.semantics.value.substring(item.metadata.semantics.value.lastIndexOf('_')).replace('_', '')
|
||||
if (!prev[equipmentType]) prev[equipmentType] = []
|
||||
const equipmentWithPoints = {
|
||||
item: item,
|
||||
points: data.filter((item2, index, items) => {
|
||||
return item2.metadata && item2.metadata.semantics &&
|
||||
item2.metadata.semantics && item2.metadata.semantics.config &&
|
||||
item2.metadata.semantics.config.isPointOf === item.name
|
||||
}).sort(this.compareObjects)
|
||||
}
|
||||
prev[equipmentType].push(equipmentWithPoints)
|
||||
return prev
|
||||
}, {})
|
||||
|
||||
// get the property items
|
||||
const properties = data.filter((item, index, items) => {
|
||||
return item.metadata && item.metadata.semantics &&
|
||||
item.metadata.semantics && item.metadata.semantics.config &&
|
||||
item.metadata.semantics.config.relatesTo
|
||||
}).sort(this.compareObjects).reduce((prev, item, i, properties) => {
|
||||
const property = item.metadata.semantics.config.relatesTo.split('_')[1]
|
||||
if (!prev[property]) prev[property] = []
|
||||
prev[property].push(item)
|
||||
return prev
|
||||
}, {})
|
||||
|
||||
this.model.locations = locations.map(l => this.buildModelCard('location', l, l.item.name, page))
|
||||
this.model.equipment = Object.keys(equipment).sort((a, b) => this.$t(a).localeCompare(this.$t(b))).map(k => this.buildModelCard('equipment', equipment[k], k, page))
|
||||
this.model.properties = Object.keys(properties).sort((a, b) => this.$t(a).localeCompare(this.$t(b))).map(k => this.buildModelCard('property', properties[k], k, page))
|
||||
this.modelReady = true
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log('Error while loading model: ' + err)
|
||||
if (err === 'Unauthorized') {
|
||||
authorize()
|
||||
}
|
||||
})
|
||||
|
||||
// get the equipment items
|
||||
const equipment = data.filter((item, index, items) => {
|
||||
return item.metadata && item.metadata.semantics &&
|
||||
item.metadata.semantics &&
|
||||
item.metadata.semantics.value.indexOf('Equipment') === 0
|
||||
}).sort(this.compareObjects).reduce((prev, item, i, properties) => {
|
||||
const equipmentType = item.metadata.semantics.value.substring(item.metadata.semantics.value.lastIndexOf('_')).replace('_', '')
|
||||
if (!prev[equipmentType]) prev[equipmentType] = []
|
||||
const equipmentWithPoints = {
|
||||
item: item,
|
||||
points: data.filter((item2, index, items) => {
|
||||
return item2.metadata && item2.metadata.semantics &&
|
||||
item2.metadata.semantics && item2.metadata.semantics.config &&
|
||||
item2.metadata.semantics.config.isPointOf === item.name
|
||||
}).sort(this.compareObjects)
|
||||
}
|
||||
prev[equipmentType].push(equipmentWithPoints)
|
||||
return prev
|
||||
}, {})
|
||||
|
||||
// get the property items
|
||||
const properties = data.filter((item, index, items) => {
|
||||
return item.metadata && item.metadata.semantics &&
|
||||
item.metadata.semantics && item.metadata.semantics.config &&
|
||||
item.metadata.semantics.config.relatesTo
|
||||
}).sort(this.compareObjects).reduce((prev, item, i, properties) => {
|
||||
const property = item.metadata.semantics.config.relatesTo.split('_')[1]
|
||||
if (!prev[property]) prev[property] = []
|
||||
prev[property].push(item)
|
||||
return prev
|
||||
}, {})
|
||||
|
||||
this.model.locations = locations.map(l => this.buildModelCard('location', l, l.item.name, page))
|
||||
this.model.equipment = Object.keys(equipment).sort((a, b) => this.$t(a).localeCompare(this.$t(b))).map(k => this.buildModelCard('equipment', equipment[k], k, page))
|
||||
this.model.properties = Object.keys(properties).sort((a, b) => this.$t(a).localeCompare(this.$t(b))).map(k => this.buildModelCard('property', properties[k], k, page))
|
||||
this.modelReady = true
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue