Convert onboarding to Lit (#2894)

* Convert onboarding to Lit

* Apply suggestions from code review

Co-Authored-By: balloob <paulus@home-assistant.io>

* Add confirm password field
This commit is contained in:
Paulus Schoutsen 2019-03-08 13:51:37 -08:00 committed by GitHub
parent f809bf0550
commit ee948302ed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 319 additions and 208 deletions

24
src/data/onboarding.ts Normal file
View File

@ -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<UserStepResponse>(
fetch("/api/onboarding/users", {
method: "POST",
credentials: "same-origin",
body: JSON.stringify(params),
})
);

View File

@ -1,3 +0,0 @@
import "../components/ha-iconset-svg";
import "../resources/roboto";
import "../onboarding/ha-onboarding";

View File

@ -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<Response>;
}
}

View File

@ -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`
<style>
.error {
color: red;
}
.action {
margin: 32px 0;
text-align: center;
}
</style>
<p>
[[localize('ui.panel.page-onboarding.intro')]]
</p>
<p>
[[localize('ui.panel.page-onboarding.user.intro')]]
</p>
<template is='dom-if' if='[[_errorMsg]]'>
<p class='error'>[[_computeErrorMsg(localize, _errorMsg)]]</p>
</template>
<form>
<paper-input
autofocus
label="[[localize('ui.panel.page-onboarding.user.data.name')]]"
value='{{_name}}'
required
auto-validate
autocapitalize='on'
error-message="[[localize('ui.panel.page-onboarding.user.required_field')]]"
on-blur='_maybePopulateUsername'
></paper-input>
<paper-input
label="[[localize('ui.panel.page-onboarding.user.data.username')]]"
value='{{_username}}'
required
auto-validate
autocapitalize='none'
error-message="[[localize('ui.panel.page-onboarding.user.required_field')]]"
></paper-input>
<paper-input
label="[[localize('ui.panel.page-onboarding.user.data.password')]]"
value='{{_password}}'
required
type='password'
auto-validate
error-message="[[localize('ui.panel.page-onboarding.user.required_field')]]"
></paper-input>
<template is='dom-if' if='[[!_loading]]'>
<p class='action'>
<mwc-button raised on-click='_submitForm'>
[[localize('ui.panel.page-onboarding.user.create_account')]]
</mwc-button>
</p>
</template>
</div>
</form>
`;
}
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);

View File

@ -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`
<p>
${this.localize("ui.panel.page-onboarding.intro")}
</p>
<p>
${this.localize("ui.panel.page-onboarding.user.intro")}
</p>
${
this._errorMsg
? html`
<p class="error">
${this.localize(
`ui.panel.page-onboarding.user.error.${this._errorMsg}`
) || this._errorMsg}
</p>
`
: ""
}
<form>
<paper-input
autofocus
name="name"
label="${this.localize("ui.panel.page-onboarding.user.data.name")}"
.value=${this._name}
@value-changed=${this._handleValueChanged}
required
auto-validate
autocapitalize='on'
.errorMessage="${this.localize(
"ui.panel.page-onboarding.user.required_field"
)}"
@blur=${this._maybePopulateUsername}
></paper-input>
<paper-input
name="username"
label="${this.localize("ui.panel.page-onboarding.user.data.username")}"
value=${this._username}
@value-changed=${this._handleValueChanged}
required
auto-validate
autocapitalize='none'
.errorMessage="${this.localize(
"ui.panel.page-onboarding.user.required_field"
)}"
></paper-input>
<paper-input
name="password"
label="${this.localize("ui.panel.page-onboarding.user.data.password")}"
value=${this._password}
@value-changed=${this._handleValueChanged}
required
type='password'
auto-validate
.errorMessage="${this.localize(
"ui.panel.page-onboarding.user.required_field"
)}"
></paper-input>
<paper-input
name="passwordConfirm"
label="${this.localize(
"ui.panel.page-onboarding.user.data.password_confirm"
)}"
value=${this._passwordConfirm}
@value-changed=${this._handleValueChanged}
required
type='password'
.invalid=${this._password !== "" &&
this._passwordConfirm !== "" &&
this._passwordConfirm !== this._password}
.errorMessage="${this.localize(
"ui.panel.page-onboarding.user.error.password_not_match"
)}"
></paper-input>
<p class="action">
<mwc-button
raised
@click=${this._submitForm}
.disabled=${this._loading}
>
${this.localize("ui.panel.page-onboarding.user.create_account")}
</mwc-button>
</p>
</div>
</form>
`;
}
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<string>): 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<void> {
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;
}
}

View File

@ -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"
}
}
}

View File

@ -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);
};

View File

@ -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);
};

View File

@ -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 <T>(
fetchPromise: Promise<Response>
): Promise<T> => {
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<T>(
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<T>(fetchWithAuth(auth, url, init));
}

View File

@ -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",