Add cloud signup to voice flow (#22941)

This commit is contained in:
Bram Kragten 2024-11-22 08:55:37 +01:00 committed by GitHub
parent 3e02d95e01
commit f8fb5d7bf2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 590 additions and 154 deletions

View File

@ -0,0 +1,174 @@
import { mdiEarth, mdiMicrophoneMessage, mdiOpenInNew } from "@mdi/js";
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-button";
import "../../../components/ha-svg-icon";
import type { HomeAssistant } from "../../../types";
import { brandsUrl } from "../../../util/brands-url";
import { AssistantSetupStyles } from "../styles";
@customElement("cloud-step-intro")
export class CloudStepIntro extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
render() {
return html`<div class="content">
<img
src=${`/static/images/logo_nabu_casa${this.hass.themes?.darkMode ? "_dark" : ""}.png`}
alt="Nabu Casa logo"
/>
<h1>The power of Home Assistant Cloud</h1>
<div class="features">
<div class="feature speech">
<div class="logos">
<div class="round-icon">
<ha-svg-icon .path=${mdiMicrophoneMessage}></ha-svg-icon>
</div>
</div>
<h2>
${this.hass.localize(
"ui.panel.config.voice_assistants.assistants.cloud.features.speech.title"
)}
<span class="no-wrap"></span>
</h2>
<p>
${this.hass.localize(
"ui.panel.config.voice_assistants.assistants.cloud.features.speech.text"
)}
</p>
</div>
<div class="feature access">
<div class="logos">
<div class="round-icon">
<ha-svg-icon .path=${mdiEarth}></ha-svg-icon>
</div>
</div>
<h2>
Remote access
<span class="no-wrap"></span>
</h2>
<p>
Secure remote access to your system while supporting the
development of Home Assistant.
</p>
</div>
<div class="feature">
<div class="logos">
<img
alt="Google Assistant"
src=${brandsUrl({
domain: "google_assistant",
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
})}
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
<img
alt="Amazon Alexa"
src=${brandsUrl({
domain: "alexa",
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
})}
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
</div>
<h2>
${this.hass.localize(
"ui.panel.config.voice_assistants.assistants.cloud.features.assistants.title"
)}
</h2>
<p>
${this.hass.localize(
"ui.panel.config.voice_assistants.assistants.cloud.features.assistants.text"
)}
</p>
</div>
</div>
</div>
<div class="footer side-by-side">
<a
href="https://www.nabucasa.com"
target="_blank"
rel="noreferrer noopenner"
>
<ha-button>
<ha-svg-icon .path=${mdiOpenInNew} slot="icon"></ha-svg-icon>
nabucasa.com
</ha-button>
</a>
<ha-button unelevated @click=${this._signUp}
>Try 1 month for free</ha-button
>
</div>`;
}
private _signUp() {
fireEvent(this, "cloud-step", { step: "SIGNUP" });
}
static styles = [
AssistantSetupStyles,
css`
:host {
display: flex;
}
.features {
display: flex;
flex-direction: column;
grid-gap: 16px;
padding: 16px;
}
.feature {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
margin-bottom: 16px;
}
.feature .logos {
margin-bottom: 16px;
}
.feature .logos > * {
width: 40px;
height: 40px;
margin: 0 4px;
}
.round-icon {
border-radius: 50%;
color: #6e41ab;
background-color: #e8dcf7;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
.access .round-icon {
color: #00aef8;
background-color: #cceffe;
}
.feature h2 {
font-weight: 500;
font-size: 16px;
line-height: 24px;
margin-top: 0;
margin-bottom: 8px;
}
.feature p {
font-weight: 400;
font-size: 14px;
line-height: 20px;
margin: 0;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"cloud-step-intro": CloudStepIntro;
}
}

View File

@ -0,0 +1,167 @@
import { LitElement, css, html } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import { navigate } from "../../../common/navigate";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-password-field";
import type { HaPasswordField } from "../../../components/ha-password-field";
import "../../../components/ha-svg-icon";
import "../../../components/ha-textfield";
import type { HaTextField } from "../../../components/ha-textfield";
import { cloudLogin } from "../../../data/cloud";
import type { HomeAssistant } from "../../../types";
import { showAlertDialog } from "../../generic/show-dialog-box";
import { AssistantSetupStyles } from "../styles";
@customElement("cloud-step-signin")
export class CloudStepSignin extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _requestInProgress = false;
@state() private _error?: string;
@query("#email", true) private _emailField!: HaTextField;
@query("#password", true) private _passwordField!: HaPasswordField;
render() {
return html`<div class="content">
<img
src=${`/static/images/logo_nabu_casa${this.hass.themes?.darkMode ? "_dark" : ""}.png`}
alt="Nabu Casa logo"
/>
<h1>Sign in</h1>
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: ""}
<ha-textfield
autofocus
id="email"
name="email"
.label=${this.hass.localize(
"ui.panel.config.cloud.register.email_address"
)}
.disabled=${this._requestInProgress}
type="email"
autocomplete="email"
required
@keydown=${this._keyDown}
validationMessage=${this.hass.localize(
"ui.panel.config.cloud.register.email_error_msg"
)}
></ha-textfield>
<ha-password-field
id="password"
name="password"
.label=${this.hass.localize(
"ui.panel.config.cloud.register.password"
)}
.disabled=${this._requestInProgress}
autocomplete="new-password"
minlength="8"
required
@keydown=${this._keyDown}
validationMessage=${this.hass.localize(
"ui.panel.config.cloud.register.password_error_msg"
)}
></ha-password-field>
</div>
<div class="footer">
<ha-button
unelevated
@click=${this._handleLogin}
.disabled=${this._requestInProgress}
>Sign in</ha-button
>
</div>`;
}
private _keyDown(ev: KeyboardEvent) {
if (ev.key === "Enter") {
this._handleLogin();
}
}
private async _handleLogin() {
const emailField = this._emailField;
const passwordField = this._passwordField;
const email = emailField.value;
const password = passwordField.value;
if (!emailField.reportValidity()) {
passwordField.reportValidity();
emailField.focus();
return;
}
if (!passwordField.reportValidity()) {
passwordField.focus();
return;
}
this._requestInProgress = true;
const doLogin = async (username: string) => {
try {
await cloudLogin(this.hass, username, password);
} catch (err: any) {
const errCode = err && err.body && err.body.code;
if (errCode === "usernotfound" && username !== username.toLowerCase()) {
await doLogin(username.toLowerCase());
return;
}
if (errCode === "PasswordChangeRequired") {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.cloud.login.alert_password_change_required"
),
});
navigate("/config/cloud/forgot-password");
fireEvent(this, "closed");
return;
}
this._requestInProgress = false;
if (errCode === "UserNotConfirmed") {
this._error = this.hass.localize(
"ui.panel.config.cloud.login.alert_email_confirm_necessary"
);
} else {
this._error =
err && err.body && err.body.message
? err.body.message
: "Unknown error";
}
emailField.focus();
}
};
await doLogin(email);
}
static styles = [
AssistantSetupStyles,
css`
:host {
display: block;
}
ha-textfield,
ha-password-field {
display: block;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"cloud-step-signin": CloudStepSignin;
}
}

View File

@ -0,0 +1,212 @@
import { LitElement, css, html } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-password-field";
import type { HaPasswordField } from "../../../components/ha-password-field";
import "../../../components/ha-svg-icon";
import "../../../components/ha-textfield";
import type { HaTextField } from "../../../components/ha-textfield";
import {
cloudLogin,
cloudRegister,
cloudResendVerification,
} from "../../../data/cloud";
import type { HomeAssistant } from "../../../types";
import { AssistantSetupStyles } from "../styles";
@customElement("cloud-step-signup")
export class CloudStepSignup extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _requestInProgress = false;
@state() private _email?: string;
@state() private _password?: string;
@state() private _error?: string;
@state() private _state?: "VERIFY";
@query("#email", true) private _emailField!: HaTextField;
@query("#password", true) private _passwordField!: HaPasswordField;
render() {
return html`<div class="content">
<img
src=${`/static/images/logo_nabu_casa${this.hass.themes?.darkMode ? "_dark" : ""}.png`}
alt="Nabu Casa logo"
/>
<h1>Sign up</h1>
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: ""}
${this._state === "VERIFY"
? html`<p>
Check the email we just sent to ${this._email} and click the
confirmation link to continue.
</p>`
: html`<ha-textfield
autofocus
id="email"
name="email"
.label=${this.hass.localize(
"ui.panel.config.cloud.register.email_address"
)}
.disabled=${this._requestInProgress}
type="email"
autocomplete="email"
required
@keydown=${this._keyDown}
validationMessage=${this.hass.localize(
"ui.panel.config.cloud.register.email_error_msg"
)}
></ha-textfield>
<ha-password-field
id="password"
name="password"
.label=${this.hass.localize(
"ui.panel.config.cloud.register.password"
)}
.disabled=${this._requestInProgress}
autocomplete="new-password"
minlength="8"
required
@keydown=${this._keyDown}
validationMessage=${this.hass.localize(
"ui.panel.config.cloud.register.password_error_msg"
)}
></ha-password-field>`}
</div>
<div class="footer side-by-side">
${this._state === "VERIFY"
? html`<ha-button
@click=${this._handleResendVerifyEmail}
.disabled=${this._requestInProgress}
>Send the email again</ha-button
><ha-button
unelevated
@click=${this._login}
.disabled=${this._requestInProgress}
>I clicked the link</ha-button
>`
: html`<ha-button
@click=${this._signIn}
.disabled=${this._requestInProgress}
>Sign in</ha-button
>
<ha-button
unelevated
@click=${this._handleRegister}
.disabled=${this._requestInProgress}
>Next</ha-button
>`}
</div>`;
}
private _signIn() {
fireEvent(this, "cloud-step", { step: "SIGNIN" });
}
private _keyDown(ev: KeyboardEvent) {
if (ev.key === "Enter") {
this._handleRegister();
}
}
private async _handleRegister() {
const emailField = this._emailField;
const passwordField = this._passwordField;
if (!emailField.reportValidity()) {
passwordField.reportValidity();
emailField.focus();
return;
}
if (!passwordField.reportValidity()) {
passwordField.focus();
return;
}
const email = emailField.value.toLowerCase();
const password = passwordField.value;
this._requestInProgress = true;
try {
await cloudRegister(this.hass, email, password);
this._email = email;
this._password = password;
this._verificationEmailSent();
} catch (err: any) {
this._password = "";
this._error =
err && err.body && err.body.message
? err.body.message
: "Unknown error";
} finally {
this._requestInProgress = false;
}
}
private async _handleResendVerifyEmail() {
if (!this._email) {
return;
}
try {
await cloudResendVerification(this.hass, this._email);
this._verificationEmailSent();
} catch (err: any) {
this._error =
err && err.body && err.body.message
? err.body.message
: "Unknown error";
}
}
private _verificationEmailSent() {
this._state = "VERIFY";
setTimeout(() => this._login(), 5000);
}
private async _login() {
if (!this._email || !this._password) {
return;
}
try {
await cloudLogin(this.hass, this._email, this._password);
fireEvent(this, "cloud-step", { step: "DONE" });
} catch (e: any) {
if (e?.body?.code === "usernotconfirmed") {
this._verificationEmailSent();
} else {
this._error = "Something went wrong. Please try again.";
}
}
}
static styles = [
AssistantSetupStyles,
css`
.content {
width: 100%;
}
ha-textfield,
ha-password-field {
display: block;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"cloud-step-signup": CloudStepSignup;
}
}

View File

@ -1,171 +1,54 @@
import { mdiEarth, mdiMicrophoneMessage, mdiOpenInNew } from "@mdi/js";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-button";
import "../../components/ha-svg-icon";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import { AssistantSetupStyles } from "./styles";
import "./cloud/cloud-step-intro";
import "./cloud/cloud-step-signin";
import "./cloud/cloud-step-signup";
import { fireEvent } from "../../common/dom/fire_event";
import { STEP } from "./voice-assistant-setup-dialog";
@customElement("ha-voice-assistant-setup-step-cloud")
export class HaVoiceAssistantSetupStepCloud extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _state: "SIGNUP" | "SIGNIN" | "INTRO" = "INTRO";
protected override render() {
return html`<div class="content">
<img
src=${`/static/images/logo_nabu_casa${this.hass.themes?.darkMode ? "_dark" : ""}.png`}
alt="Nabu Casa logo"
/>
<h1>The power of Home Assistant Cloud</h1>
<div class="features">
<div class="feature speech">
<div class="logos">
<div class="round-icon">
<ha-svg-icon .path=${mdiMicrophoneMessage}></ha-svg-icon>
</div>
</div>
<h2>
${this.hass.localize(
"ui.panel.config.voice_assistants.assistants.cloud.features.speech.title"
)}
<span class="no-wrap"></span>
</h2>
<p>
${this.hass.localize(
"ui.panel.config.voice_assistants.assistants.cloud.features.speech.text"
)}
</p>
</div>
<div class="feature access">
<div class="logos">
<div class="round-icon">
<ha-svg-icon .path=${mdiEarth}></ha-svg-icon>
</div>
</div>
<h2>
Remote access
<span class="no-wrap"></span>
</h2>
<p>
Secure remote access to your system while supporting the
development of Home Assistant.
</p>
</div>
<div class="feature">
<div class="logos">
<img
alt="Google Assistant"
src=${brandsUrl({
domain: "google_assistant",
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
})}
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
<img
alt="Amazon Alexa"
src=${brandsUrl({
domain: "alexa",
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
})}
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
</div>
<h2>
${this.hass.localize(
"ui.panel.config.voice_assistants.assistants.cloud.features.assistants.title"
)}
</h2>
<p>
${this.hass.localize(
"ui.panel.config.voice_assistants.assistants.cloud.features.assistants.text"
)}
</p>
</div>
</div>
</div>
<div class="footer side-by-side">
<a
href="https://www.nabucasa.com"
target="_blank"
rel="noreferrer noopenner"
>
<ha-button>
<ha-svg-icon .path=${mdiOpenInNew} slot="icon"></ha-svg-icon>
nabucasa.com
</ha-button>
</a>
<a href="/config/cloud/register" @click=${this._close}
><ha-button unelevated>Try 1 month for free</ha-button></a
>
</div>`;
if (this._state === "SIGNUP") {
return html`<cloud-step-signup
.hass=${this.hass}
@cloud-step=${this._cloudStep}
></cloud-step-signup>`;
}
if (this._state === "SIGNIN") {
return html`<cloud-step-signin
.hass=${this.hass}
@cloud-step=${this._cloudStep}
></cloud-step-signin>`;
}
return html`<cloud-step-intro
.hass=${this.hass}
@cloud-step=${this._cloudStep}
></cloud-step-intro>`;
}
private _close() {
fireEvent(this, "closed");
private _cloudStep(ev) {
if (ev.detail.step === "DONE") {
fireEvent(this, "next-step", {
step: STEP.PIPELINE,
noPrevious: true,
});
return;
}
static styles = [
AssistantSetupStyles,
css`
.features {
display: flex;
flex-direction: column;
grid-gap: 16px;
padding: 16px;
this._state = ev.detail.step;
}
.feature {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
margin-bottom: 16px;
}
.feature .logos {
margin-bottom: 16px;
}
.feature .logos > * {
width: 40px;
height: 40px;
margin: 0 4px;
}
.round-icon {
border-radius: 50%;
color: #6e41ab;
background-color: #e8dcf7;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
.access .round-icon {
color: #00aef8;
background-color: #cceffe;
}
.feature h2 {
font-weight: 500;
font-size: 16px;
line-height: 24px;
margin-top: 0;
margin-bottom: 8px;
}
.feature p {
font-weight: 400;
font-size: 14px;
line-height: 20px;
margin: 0;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-voice-assistant-setup-step-cloud": HaVoiceAssistantSetupStepCloud;
}
interface HASSDomEvents {
"cloud-step": { step: "SIGNUP" | "SIGNIN" | "DONE" };
}
}