mirror of
https://github.com/home-assistant/frontend.git
synced 2025-04-26 22:37:21 +00:00
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:
parent
f809bf0550
commit
ee948302ed
24
src/data/onboarding.ts
Normal file
24
src/data/onboarding.ts
Normal 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),
|
||||||
|
})
|
||||||
|
);
|
@ -1,3 +0,0 @@
|
|||||||
import "../components/ha-iconset-svg";
|
|
||||||
import "../resources/roboto";
|
|
||||||
import "../onboarding/ha-onboarding";
|
|
10
src/entrypoints/onboarding.ts
Normal file
10
src/entrypoints/onboarding.ts
Normal 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>;
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
|
230
src/onboarding/ha-onboarding.ts
Normal file
230
src/onboarding/ha-onboarding.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -1135,11 +1135,13 @@
|
|||||||
"data": {
|
"data": {
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"username": "Username",
|
"username": "Username",
|
||||||
"password": "Password"
|
"password": "Password",
|
||||||
|
"password_confirm": "Confirm Password"
|
||||||
},
|
},
|
||||||
"create_account": "Create Account",
|
"create_account": "Create Account",
|
||||||
"error": {
|
"error": {
|
||||||
"required_fields": "Fill in all required fields"
|
"required_fields": "Fill in all required fields",
|
||||||
|
"password_not_match": "Passwords don't match"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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);
|
|
||||||
};
|
|
21
src/util/fetch-with-auth.ts
Normal file
21
src/util/fetch-with-auth.ts
Normal 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);
|
||||||
|
};
|
@ -1,24 +1,13 @@
|
|||||||
import { fetchWithAuth } from "./fetch-with-auth";
|
import { fetchWithAuth } from "./fetch-with-auth";
|
||||||
|
import { Auth } from "home-assistant-js-websocket";
|
||||||
|
|
||||||
/* eslint-disable no-throw-literal */
|
export const handleFetchPromise = async <T>(
|
||||||
|
fetchPromise: Promise<Response>
|
||||||
export default async function hassCallApi(auth, method, path, parameters) {
|
): Promise<T> => {
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
let response;
|
let response;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
response = await fetchWithAuth(auth, url, init);
|
response = await fetchPromise;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw {
|
throw {
|
||||||
error: "Request error",
|
error: "Request error",
|
||||||
@ -49,9 +38,31 @@ export default async function hassCallApi(auth, method, path, parameters) {
|
|||||||
throw {
|
throw {
|
||||||
error: `Response error: ${response.status}`,
|
error: `Response error: ${response.status}`,
|
||||||
status_code: 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));
|
||||||
}
|
}
|
@ -41,7 +41,7 @@ function createConfig(isProdBuild, latestBuild) {
|
|||||||
const entry = {
|
const entry = {
|
||||||
app: "./src/entrypoints/app.js",
|
app: "./src/entrypoints/app.js",
|
||||||
authorize: "./src/entrypoints/authorize.js",
|
authorize: "./src/entrypoints/authorize.js",
|
||||||
onboarding: "./src/entrypoints/onboarding.js",
|
onboarding: "./src/entrypoints/onboarding.ts",
|
||||||
core: "./src/entrypoints/core.ts",
|
core: "./src/entrypoints/core.ts",
|
||||||
compatibility: "./src/entrypoints/compatibility.js",
|
compatibility: "./src/entrypoints/compatibility.js",
|
||||||
"custom-panel": "./src/entrypoints/custom-panel.js",
|
"custom-panel": "./src/entrypoints/custom-panel.js",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user