diff --git a/src/data/onboarding.ts b/src/data/onboarding.ts new file mode 100644 index 0000000000..05a54be1a0 --- /dev/null +++ b/src/data/onboarding.ts @@ -0,0 +1,24 @@ +import { handleFetchPromise } from "../util/hass-call-api"; + +export interface OnboardingStep { + step: string; + done: boolean; +} + +interface UserStepResponse { + auth_code: string; +} + +export const onboardUserStep = async (params: { + client_id: string; + name: string; + username: string; + password: string; +}) => + handleFetchPromise( + fetch("/api/onboarding/users", { + method: "POST", + credentials: "same-origin", + body: JSON.stringify(params), + }) + ); diff --git a/src/entrypoints/onboarding.js b/src/entrypoints/onboarding.js deleted file mode 100644 index ab286de092..0000000000 --- a/src/entrypoints/onboarding.js +++ /dev/null @@ -1,3 +0,0 @@ -import "../components/ha-iconset-svg"; -import "../resources/roboto"; -import "../onboarding/ha-onboarding"; diff --git a/src/entrypoints/onboarding.ts b/src/entrypoints/onboarding.ts new file mode 100644 index 0000000000..577a58c1ac --- /dev/null +++ b/src/entrypoints/onboarding.ts @@ -0,0 +1,10 @@ +import "../components/ha-iconset-svg"; +import "../resources/ha-style"; +import "../resources/roboto"; +import "../onboarding/ha-onboarding"; + +declare global { + interface Window { + stepsPromise: Promise; + } +} diff --git a/src/onboarding/ha-onboarding.js b/src/onboarding/ha-onboarding.js deleted file mode 100644 index c2cd056a55..0000000000 --- a/src/onboarding/ha-onboarding.js +++ /dev/null @@ -1,175 +0,0 @@ -import "@polymer/polymer/lib/elements/dom-if"; -import "@polymer/polymer/lib/elements/dom-repeat"; -import "@polymer/paper-input/paper-input"; -import "@material/mwc-button"; -import { html } from "@polymer/polymer/lib/utils/html-tag"; -import { PolymerElement } from "@polymer/polymer/polymer-element"; -import { localizeLiteMixin } from "../mixins/localize-lite-mixin"; - -class HaOnboarding extends localizeLiteMixin(PolymerElement) { - static get template() { - return html` - - -

- [[localize('ui.panel.page-onboarding.intro')]] -

- -

- [[localize('ui.panel.page-onboarding.user.intro')]] -

- - - -
- - - - - - - - -
-`; - } - - static get properties() { - return { - _name: String, - _username: String, - _password: String, - _loading: { - type: Boolean, - value: false, - }, - translationFragment: { - type: String, - value: "page-onboarding", - }, - _errorMsg: String, - }; - } - - async ready() { - super.ready(); - this.addEventListener("keypress", (ev) => { - if (ev.keyCode === 13) { - this._submitForm(); - } - }); - - try { - const response = await window.stepsPromise; - - if (response.status === 404) { - // We don't load the component when onboarding is done - document.location = "/"; - return; - } - - const steps = await response.json(); - - if (steps.every((step) => step.done)) { - // Onboarding is done! - document.location = "/"; - } - } catch (err) { - alert("Something went wrong loading loading onboarding, try refreshing"); - } - } - - _maybePopulateUsername() { - if (this._username) return; - - const parts = this._name.split(" "); - - if (parts.length) { - this._username = parts[0].toLowerCase(); - } - } - - async _submitForm() { - if (!this._name || !this._username || !this._password) { - this._errorMsg = "required_fields"; - return; - } - - this._errorMsg = ""; - - try { - const response = await fetch("/api/onboarding/users", { - method: "POST", - credentials: "same-origin", - body: JSON.stringify({ - name: this._name, - username: this._username, - password: this._password, - }), - }); - - if (!response.ok) { - // eslint-disable-next-line - throw { - message: `Bad response from server: ${response.status}`, - }; - } - - document.location = "/"; - } catch (err) { - // eslint-disable-next-line - console.error(err); - this.setProperties({ - _loading: false, - _errorMsg: err.message, - }); - } - } - - _computeErrorMsg(localize, errorMsg) { - return ( - localize(`ui.panel.page-onboarding.user.error.${errorMsg}`) || errorMsg - ); - } -} -customElements.define("ha-onboarding", HaOnboarding); diff --git a/src/onboarding/ha-onboarding.ts b/src/onboarding/ha-onboarding.ts new file mode 100644 index 0000000000..19a7a91f15 --- /dev/null +++ b/src/onboarding/ha-onboarding.ts @@ -0,0 +1,230 @@ +import "@polymer/paper-input/paper-input"; +import "@material/mwc-button"; +import { + LitElement, + CSSResult, + css, + html, + PropertyValues, + property, + customElement, + TemplateResult, +} from "lit-element"; +import { genClientId } from "home-assistant-js-websocket"; +import { litLocalizeLiteMixin } from "../mixins/lit-localize-lite-mixin"; +import { OnboardingStep, onboardUserStep } from "../data/onboarding"; +import { PolymerChangedEvent } from "../polymer-types"; + +@customElement("ha-onboarding") +class HaOnboarding extends litLocalizeLiteMixin(LitElement) { + public translationFragment = "page-onboarding"; + + @property() private _name = ""; + @property() private _username = ""; + @property() private _password = ""; + @property() private _passwordConfirm = ""; + @property() private _loading = false; + @property() private _errorMsg?: string = undefined; + + protected render(): TemplateResult | void { + return html` +

+ ${this.localize("ui.panel.page-onboarding.intro")} +

+ +

+ ${this.localize("ui.panel.page-onboarding.user.intro")} +

+ + ${ + this._errorMsg + ? html` +

+ ${this.localize( + `ui.panel.page-onboarding.user.error.${this._errorMsg}` + ) || this._errorMsg} +

+ ` + : "" + } + + +
+ + + + + + + + +

+ + ${this.localize("ui.panel.page-onboarding.user.create_account")} + +

+ +
+`; + } + + protected async firstUpdated(changedProps: PropertyValues) { + super.firstUpdated(changedProps); + this.addEventListener("keypress", (ev) => { + if (ev.keyCode === 13) { + this._submitForm(); + } + }); + + try { + const response = await window.stepsPromise; + + if (response.status === 404) { + // We don't load the component when onboarding is done + document.location.href = "/"; + return; + } + + const steps: OnboardingStep[] = await response.json(); + + if (steps.every((step) => step.done)) { + // Onboarding is done! + document.location.href = "/"; + } + } catch (err) { + alert("Something went wrong loading loading onboarding, try refreshing"); + } + } + + private _handleValueChanged(ev: PolymerChangedEvent): void { + const name = (ev.target as any).name; + this[`_${name}`] = ev.detail.value; + } + + private _maybePopulateUsername(): void { + if (this._username) { + return; + } + + const parts = this._name.split(" "); + + if (parts.length) { + this._username = parts[0].toLowerCase(); + } + } + + private async _submitForm(): Promise { + if (!this._name || !this._username || !this._password) { + this._errorMsg = "required_fields"; + return; + } + + if (this._password !== this._passwordConfirm) { + this._errorMsg = "password_not_match"; + return; + } + + this._loading = true; + this._errorMsg = ""; + + try { + const clientId = genClientId(); + + const { auth_code } = await onboardUserStep({ + client_id: clientId, + name: this._name, + username: this._username, + password: this._password, + }); + + const state = btoa( + JSON.stringify({ + hassUrl: `${location.protocol}//${location.host}`, + clientId, + }) + ); + + document.location.href = `/?auth_callback=1&code=${encodeURIComponent( + auth_code + )}&state=${state}`; + } catch (err) { + // tslint:disable-next-line + console.error(err); + this._loading = false; + this._errorMsg = err.message; + } + } + + static get styles(): CSSResult { + return css` + .error { + color: red; + } + + .action { + margin: 32px 0; + text-align: center; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-onboarding": HaOnboarding; + } +} diff --git a/src/translations/en.json b/src/translations/en.json index ee9cda1ee9..3fe3ab60d6 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -1135,11 +1135,13 @@ "data": { "name": "Name", "username": "Username", - "password": "Password" + "password": "Password", + "password_confirm": "Confirm Password" }, "create_account": "Create Account", "error": { - "required_fields": "Fill in all required fields" + "required_fields": "Fill in all required fields", + "password_not_match": "Passwords don't match" } } } diff --git a/src/util/fetch-with-auth.js b/src/util/fetch-with-auth.js deleted file mode 100644 index 0861a0331b..0000000000 --- a/src/util/fetch-with-auth.js +++ /dev/null @@ -1,9 +0,0 @@ -export const fetchWithAuth = async (auth, input, init = {}) => { - if (auth.expired) await auth.refreshAccessToken(); - init.credentials = "same-origin"; - if (!init.headers) { - init.headers = {}; - } - init.headers.authorization = `Bearer ${auth.accessToken}`; - return await fetch(input, init); -}; diff --git a/src/util/fetch-with-auth.ts b/src/util/fetch-with-auth.ts new file mode 100644 index 0000000000..0bec82fb29 --- /dev/null +++ b/src/util/fetch-with-auth.ts @@ -0,0 +1,21 @@ +import { Auth } from "home-assistant-js-websocket"; + +export const fetchWithAuth = async ( + auth: Auth, + input: RequestInfo, + init: RequestInit = {} +) => { + if (auth.expired) { + await auth.refreshAccessToken(); + } + init.credentials = "same-origin"; + if (!init.headers) { + init.headers = {}; + } + if (!init.headers) { + init.headers = {}; + } + // @ts-ignore + init.headers.authorization = `Bearer ${auth.accessToken}`; + return fetch(input, init); +}; diff --git a/src/util/hass-call-api.js b/src/util/hass-call-api.ts similarity index 66% rename from src/util/hass-call-api.js rename to src/util/hass-call-api.ts index dbfde887a0..bcde8b745a 100644 --- a/src/util/hass-call-api.js +++ b/src/util/hass-call-api.ts @@ -1,24 +1,13 @@ import { fetchWithAuth } from "./fetch-with-auth"; +import { Auth } from "home-assistant-js-websocket"; -/* eslint-disable no-throw-literal */ - -export default async function hassCallApi(auth, method, path, parameters) { - const url = `${auth.data.hassUrl}/api/${path}`; - - const init = { - method: method, - headers: {}, - }; - - if (parameters) { - init.headers["Content-Type"] = "application/json;charset=UTF-8"; - init.body = JSON.stringify(parameters); - } - +export const handleFetchPromise = async ( + fetchPromise: Promise +): Promise => { let response; try { - response = await fetchWithAuth(auth, url, init); + response = await fetchPromise; } catch (err) { throw { error: "Request error", @@ -49,9 +38,31 @@ export default async function hassCallApi(auth, method, path, parameters) { throw { error: `Response error: ${response.status}`, status_code: response.status, - body: body, + body, }; } - return body; + return (body as unknown) as T; +}; + +export default async function hassCallApi( + auth: Auth, + method: string, + path: string, + parameters?: {} +) { + const url = `${auth.data.hassUrl}/api/${path}`; + + const init: RequestInit = { + method, + headers: {}, + }; + + if (parameters) { + // @ts-ignore + init.headers["Content-Type"] = "application/json;charset=UTF-8"; + init.body = JSON.stringify(parameters); + } + + return handleFetchPromise(fetchWithAuth(auth, url, init)); } diff --git a/webpack.config.js b/webpack.config.js index 1a13b77e3d..e723bd58d7 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -41,7 +41,7 @@ function createConfig(isProdBuild, latestBuild) { const entry = { app: "./src/entrypoints/app.js", authorize: "./src/entrypoints/authorize.js", - onboarding: "./src/entrypoints/onboarding.js", + onboarding: "./src/entrypoints/onboarding.ts", core: "./src/entrypoints/core.ts", compatibility: "./src/entrypoints/compatibility.js", "custom-panel": "./src/entrypoints/custom-panel.js",