diff --git a/src/common/auth/fetch_token.js b/src/common/auth/fetch_token.js deleted file mode 100644 index e96153127b..0000000000 --- a/src/common/auth/fetch_token.js +++ /dev/null @@ -1,17 +0,0 @@ -export default function fetchToken(clientId, code) { - const data = new FormData(); - data.append('client_id', clientId); - data.append('grant_type', 'authorization_code'); - data.append('code', code); - return fetch('/auth/token', { - credentials: 'same-origin', - method: 'POST', - body: data, - }).then((resp) => { - if (!resp.ok) throw new Error('Unable to fetch tokens'); - return resp.json().then((tokens) => { - tokens.expires = (tokens.expires_in * 1000) + Date.now(); - return tokens; - }); - }); -} diff --git a/src/common/auth/refresh_token.js b/src/common/auth/refresh_token.js deleted file mode 100644 index 2a11aa1e64..0000000000 --- a/src/common/auth/refresh_token.js +++ /dev/null @@ -1,17 +0,0 @@ -export default function refreshAccessToken(clientId, refreshToken) { - const data = new FormData(); - data.append('client_id', clientId); - data.append('grant_type', 'refresh_token'); - data.append('refresh_token', refreshToken); - return fetch('/auth/token', { - credentials: 'same-origin', - method: 'POST', - body: data, - }).then((resp) => { - if (!resp.ok) throw new Error('Unable to fetch tokens'); - return resp.json().then((tokens) => { - tokens.expires = (tokens.expires_in * 1000) + Date.now(); - return tokens; - }); - }); -} diff --git a/src/common/auth/token.js b/src/common/auth/token.js new file mode 100644 index 0000000000..66ade7ae49 --- /dev/null +++ b/src/common/auth/token.js @@ -0,0 +1,76 @@ +import { storeTokens, loadTokens } from './token_storage.js'; + +function genClientId() { + return `${location.protocol}//${location.host}/`; +} + + +export function redirectLogin() { + document.location.href = `/auth/authorize?response_type=code&client_id=${encodeURIComponent(genClientId())}&redirect_uri=${encodeURIComponent(location.toString())}`; + return new Promise((() => {})); +} + + +function fetchTokenRequest(code) { + const data = new FormData(); + data.append('client_id', genClientId()); + data.append('grant_type', 'authorization_code'); + data.append('code', code); + return fetch('/auth/token', { + credentials: 'same-origin', + method: 'POST', + body: data, + }).then((resp) => { + if (!resp.ok) throw new Error('Unable to fetch tokens'); + return resp.json().then((tokens) => { + tokens.expires = (tokens.expires_in * 1000) + Date.now(); + return tokens; + }); + }); +} + +function refreshTokenRequest(tokens) { + const data = new FormData(); + data.append('client_id', genClientId()); + data.append('grant_type', 'refresh_token'); + data.append('refresh_token', tokens.refresh_token); + return fetch('/auth/token', { + credentials: 'same-origin', + method: 'POST', + body: data, + }).then((resp) => { + if (!resp.ok) throw new Error('Unable to fetch tokens'); + return resp.json().then((newTokens) => { + newTokens.expires = (newTokens.expires_in * 1000) + Date.now(); + return newTokens; + }); + }); +} + +export function resolveCode(code) { + return fetchTokenRequest(code).then((tokens) => { + storeTokens(tokens); + history.replaceState(null, null, location.pathname); + return tokens; + }, (err) => { + // eslint-disable-next-line + console.error('Resolve token failed', err); + alert('Unable to fetch tokens'); + redirectLogin(); + }); +} + + +export function refreshToken() { + const tokens = loadTokens(); + + if (tokens === null) { + return redirectLogin(); + } + + return refreshTokenRequest(tokens).then((accessTokenResp) => { + const newTokens = Object.assign({}, tokens, accessTokenResp); + storeTokens(newTokens); + return newTokens; + }, () => redirectLogin()); +} diff --git a/src/common/auth/token_storage.js b/src/common/auth/token_storage.js new file mode 100644 index 0000000000..e894f55bb8 --- /dev/null +++ b/src/common/auth/token_storage.js @@ -0,0 +1,45 @@ +const storage = window.localStorage || {}; + +// So that core.js and main app hit same shared object. +let tokenCache = window.__tokenCache; +if (!tokenCache) { + tokenCache = window.__tokenCache = { + tokens: undefined, + writeEnabled: undefined, + }; +} + +export function askWrite() { + return tokenCache.writeEnabled === undefined; +} + +export function storeTokens(tokens) { + tokenCache.tokens = tokens; + if (tokenCache.writeEnabled) { + try { + storage.tokens = JSON.stringify(tokens); + } catch (err) {} // eslint-disable-line + } +} + +export function enableWrite() { + tokenCache.writeEnabled = true; + storeTokens(tokenCache.tokens); +} + +export function loadTokens() { + if (tokenCache.tokens === undefined) { + try { + const tokens = storage.tokens; + if (tokens) { + tokenCache.tokens = JSON.parse(tokens); + tokenCache.writeEnabled = true; + } else { + tokenCache.tokens = null; + } + } catch (err) { + tokenCache.tokens = null; + } + } + return tokenCache.tokens; +} diff --git a/src/dialogs/ha-store-auth-card.js b/src/dialogs/ha-store-auth-card.js new file mode 100644 index 0000000000..a497b74d23 --- /dev/null +++ b/src/dialogs/ha-store-auth-card.js @@ -0,0 +1,69 @@ +import '@polymer/paper-card/paper-card.js'; +import { html } from '@polymer/polymer/lib/utils/html-tag.js'; +import { PolymerElement } from '@polymer/polymer/polymer-element.js'; + +import { enableWrite } from '../common/auth/token_storage.js'; +import LocalizeMixin from '../mixins/localize-mixin.js'; + +import '../resources/ha-style.js'; + +class HaStoreAuth extends LocalizeMixin(PolymerElement) { + static get template() { + return html` + + +
+ [[localize('ui.auth_store.ask')]] +
+
+ [[localize('ui.auth_store.decline')]] + [[localize('ui.auth_store.confirm')]] +
+
+ `; + } + + static get properties() { + return { + hass: Object, + }; + } + + ready() { + super.ready(); + this.classList.toggle('small', window.innerWidth < 600); + } + + _save() { + enableWrite(); + this._done(); + } + + _done() { + const card = this.shadowRoot.querySelector('paper-card'); + card.style.transition = 'bottom .25s'; + card.style.bottom = `-${card.offsetHeight + 8}px`; + setTimeout(() => this.parentNode.removeChild(this), 300); + } +} + +customElements.define('ha-store-auth-card', HaStoreAuth); diff --git a/src/entrypoints/core.js b/src/entrypoints/core.js index b0739a3ddc..3361d98d12 100644 --- a/src/entrypoints/core.js +++ b/src/entrypoints/core.js @@ -5,9 +5,10 @@ import { subscribeEntities, } from 'home-assistant-js-websocket'; -import fetchToken from '../common/auth/fetch_token.js'; -import refreshToken_ from '../common/auth/refresh_token.js'; +import { redirectLogin, resolveCode, refreshToken } from '../common/auth/token.js'; +// import refreshToken_ from '../common/auth/refresh_token.js'; import parseQuery from '../common/util/parse_query.js'; +import { loadTokens } from '../common/auth/token_storage.js'; const init = window.createHassConnection = function (password, accessToken) { const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'; @@ -29,68 +30,11 @@ const init = window.createHassConnection = function (password, accessToken) { }); }; -function clientId() { - return `${location.protocol}//${location.host}/`; -} - -function redirectLogin() { - document.location.href = `/auth/authorize?response_type=code&client_id=${encodeURIComponent(clientId())}&redirect_uri=${encodeURIComponent(location.toString())}`; - return new Promise(); -} - -let tokenCache; - -function storeTokens(tokens) { - tokenCache = tokens; - try { - localStorage.tokens = JSON.stringify(tokens); - } catch (err) {} // eslint-disable-line -} - -function loadTokens() { - if (tokenCache === undefined) { - try { - const tokens = localStorage.tokens; - tokenCache = tokens ? JSON.parse(tokens) : null; - } catch (err) { - tokenCache = null; - } - } - return tokenCache; -} - -window.refreshToken = () => { - const tokens = loadTokens(); - - if (tokens === null) { - return redirectLogin(); - } - - return refreshToken_(clientId(), tokens.refresh_token).then((accessTokenResp) => { - const newTokens = Object.assign({}, tokens, accessTokenResp); - storeTokens(newTokens); - return newTokens; - }, () => redirectLogin()); -}; - -function resolveCode(code) { - fetchToken(clientId(), code).then((tokens) => { - storeTokens(tokens); - // Refresh the page and have tokens in place. - document.location.href = location.pathname; - }, (err) => { - // eslint-disable-next-line - console.error('Resolve token failed', err); - alert('Unable to fetch tokens'); - redirectLogin(); - }); -} - function main() { if (location.search) { const query = parseQuery(location.search.substr(1)); if (query.code) { - resolveCode(query.code); + window.hassConnection = resolveCode(query.code).then(newTokens => init(null, newTokens)); return; } } @@ -103,14 +47,14 @@ function main() { if (Date.now() + 30000 > tokens.expires) { // refresh access token if it will expire in 30 seconds to avoid invalid auth event - window.hassConnection = window.refreshToken().then(newTokens => init(null, newTokens)); + window.hassConnection = refreshToken().then(newTokens => init(null, newTokens)); return; } window.hassConnection = init(null, tokens).catch((err) => { if (err !== ERR_INVALID_AUTH) throw err; - return window.refreshToken().then(newTokens => init(null, newTokens)); + return refreshToken().then(newTokens => init(null, newTokens)); }); } diff --git a/src/layouts/app/auth-mixin.js b/src/layouts/app/auth-mixin.js index eafa3e35ea..a4de9e7f8c 100644 --- a/src/layouts/app/auth-mixin.js +++ b/src/layouts/app/auth-mixin.js @@ -1,9 +1,20 @@ +import { afterNextRender } from '@polymer/polymer/lib/utils/render-status.js'; import { clearState } from '../../util/ha-pref-storage.js'; +import { askWrite } from '../../common/auth/token_storage.js'; export default superClass => class extends superClass { ready() { super.ready(); this.addEventListener('hass-logout', () => this._handleLogout()); + + afterNextRender(null, () => { + if (askWrite()) { + const el = document.createElement('ha-store-auth-card'); + this.shadowRoot.appendChild(el); + this.provideHass(el); + import(/* webpackChunkName: "ha-store-auth-card" */ '../../dialogs/ha-store-auth-card.js'); + } + }); } hassConnected() { diff --git a/src/layouts/app/connection-mixin.js b/src/layouts/app/connection-mixin.js index 69dba37d6e..40851476a5 100644 --- a/src/layouts/app/connection-mixin.js +++ b/src/layouts/app/connection-mixin.js @@ -9,6 +9,7 @@ import translationMetadata from '../../../build-translations/translationMetadata import LocalizeMixin from '../../mixins/localize-mixin.js'; import EventsMixin from '../../mixins/events-mixin.js'; +import { refreshToken } from '../../common/auth/token.js'; import { getState } from '../../util/ha-pref-storage.js'; import { getActiveTranslation } from '../../util/hass-translation.js'; import hassCallApi from '../../util/hass-call-api.js'; @@ -103,7 +104,7 @@ export default superClass => try { // Refresh token if it will expire in 30 seconds if (auth.accessToken && Date.now() + 30000 > auth.expires) { - const accessToken = await window.refreshToken(); + const accessToken = await refreshToken(); conn.options.accessToken = accessToken.access_token; conn.options.expires = accessToken.expires; } @@ -112,7 +113,7 @@ export default superClass => if (!err || err.status_code !== 401 || !auth.accessToken) throw err; // If we connect with access token and get 401, refresh token and try again - const accessToken = await window.refreshToken(); + const accessToken = await refreshToken(); conn.options.accessToken = accessToken.access_token; conn.options.expires = accessToken.expires; return await hassCallApi(host, auth, method, path, parameters); @@ -159,7 +160,7 @@ export default superClass => while (this.unsubFuncs.length) { this.unsubFuncs.pop()(); } - const accessToken = await window.refreshToken(); + const accessToken = await refreshToken(); this._handleNewConnProm(window.createHassConnection(null, accessToken)); }; diff --git a/src/layouts/app/hass-base-mixin.js b/src/layouts/app/hass-base-mixin.js index 7572491b57..421d9c659a 100644 --- a/src/layouts/app/hass-base-mixin.js +++ b/src/layouts/app/hass-base-mixin.js @@ -18,6 +18,7 @@ export default superClass => class extends superClass { provideHass(el) { this.__provideHass.push(el); + el.hass = this.hass; } async _updateHass(obj) { diff --git a/src/resources/ha-style.js b/src/resources/ha-style.js index 3666d6960e..868e6b8981 100644 --- a/src/resources/ha-style.js +++ b/src/resources/ha-style.js @@ -191,6 +191,12 @@ documentContainer.innerHTML = ` .card-actions ha-call-service-button.warning:not([disabled]) { color: var(--google-red-500); } + + .card-actions paper-button[primary] { + background-color: var(--primary-color); + color: var(--text-primary-color); + } + diff --git a/src/translations/en.json b/src/translations/en.json index 3d856d21ed..47749c4d28 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -326,6 +326,11 @@ } }, "ui": { + "auth_store": { + "ask": "Do you want to save this login?", + "decline": "No thanks", + "confirm": "Save login" + }, "card": { "alarm_control_panel": { "code": "Code",