Convert cloud account config to Lit (#10350)

This commit is contained in:
Bram Kragten 2021-10-21 18:49:55 +02:00 committed by GitHub
parent 7c2e0aea92
commit 95c6adc739
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 1063 additions and 966 deletions

View File

@ -78,7 +78,6 @@
"@polymer/paper-input": "^3.2.1",
"@polymer/paper-item": "^3.0.1",
"@polymer/paper-listbox": "^3.0.1",
"@polymer/paper-ripple": "^3.0.2",
"@polymer/paper-slider": "^3.0.1",
"@polymer/paper-styles": "^3.0.1",
"@polymer/paper-tabs": "^3.1.0",

View File

@ -62,6 +62,8 @@ export type CloudStatus = CloudStatusNotLoggedIn | CloudStatusLoggedIn;
export interface SubscriptionInfo {
human_description: string;
provider: string;
plan_renewal_date?: number;
}
export interface CloudWebhook {
@ -76,6 +78,39 @@ export interface ThingTalkConversion {
placeholders: PlaceholderContainer;
}
export const cloudLogin = (
hass: HomeAssistant,
email: string,
password: string
) =>
hass.callApi("POST", "cloud/login", {
email,
password,
});
export const cloudLogout = (hass: HomeAssistant) =>
hass.callApi("POST", "cloud/logout");
export const cloudForgotPassword = (hass: HomeAssistant, email: string) =>
hass.callApi("POST", "cloud/forgot_password", {
email,
});
export const cloudRegister = (
hass: HomeAssistant,
email: string,
password: string
) =>
hass.callApi("POST", "cloud/register", {
email,
password,
});
export const cloudResendVerification = (hass: HomeAssistant, email: string) =>
hass.callApi("POST", "cloud/resend_confirm", {
email,
});
export const fetchCloudStatus = (hass: HomeAssistant) =>
hass.callWS<CloudStatus>({ type: "cloud/status" });

View File

@ -1,246 +0,0 @@
import "@material/mwc-button";
import "@polymer/paper-item/paper-item-body";
import { html } from "@polymer/polymer/lib/utils/html-tag";
/* eslint-plugin-disable lit */
import { PolymerElement } from "@polymer/polymer/polymer-element";
import { formatDateTime } from "../../../../common/datetime/format_date_time";
import { computeRTLDirection } from "../../../../common/util/compute_rtl";
import "../../../../components/buttons/ha-call-api-button";
import "../../../../components/ha-card";
import { fetchCloudSubscriptionInfo } from "../../../../data/cloud";
import "../../../../layouts/hass-subpage";
import { EventsMixin } from "../../../../mixins/events-mixin";
import LocalizeMixin from "../../../../mixins/localize-mixin";
import "../../../../styles/polymer-ha-style";
import "../../ha-config-section";
import "./cloud-alexa-pref";
import "./cloud-google-pref";
import "./cloud-remote-pref";
import "./cloud-tts-pref";
import "./cloud-webhooks";
/*
* @appliesMixin EventsMixin
* @appliesMixin LocalizeMixin
*/
class CloudAccount extends EventsMixin(LocalizeMixin(PolymerElement)) {
static get template() {
return html`
<style include="iron-flex ha-style">
[slot="introduction"] {
margin: -1em 0;
}
[slot="introduction"] a {
color: var(--primary-color);
}
.content {
padding-bottom: 24px;
}
.account-row {
display: flex;
padding: 0 16px;
}
mwc-button {
align-self: center;
}
.soon {
font-style: italic;
margin-top: 24px;
text-align: center;
}
.nowrap {
white-space: nowrap;
}
.wrap {
white-space: normal;
}
.status {
text-transform: capitalize;
padding: 16px;
}
a {
color: var(--primary-color);
}
</style>
<hass-subpage
hass="[[hass]]"
narrow="[[narrow]]"
header="Home Assistant Cloud"
>
<div class="content">
<ha-config-section is-wide="[[isWide]]">
<span slot="header">Home Assistant Cloud</span>
<div slot="introduction">
<p>
[[localize('ui.panel.config.cloud.account.thank_you_note')]]
</p>
</div>
<ha-card
header="[[localize('ui.panel.config.cloud.account.nabu_casa_account')]]"
>
<div class="account-row">
<paper-item-body two-line="">
[[cloudStatus.email]]
<div secondary class="wrap">
[[_formatSubscription(_subscription)]]
</div>
</paper-item-body>
</div>
<div class="account-row">
<paper-item-body
>[[localize('ui.panel.config.cloud.account.connection_status')]]</paper-item-body
>
<div class="status">
[[_computeConnectionStatus(cloudStatus.cloud)]]
</div>
</div>
<div class="card-actions">
<a
href="https://account.nabucasa.com"
target="_blank"
rel="noreferrer"
>
<mwc-button
>[[localize('ui.panel.config.cloud.account.manage_account')]]</mwc-button
>
</a>
<mwc-button style="float: right" on-click="handleLogout"
>[[localize('ui.panel.config.cloud.account.sign_out')]]</mwc-button
>
</div>
</ha-card>
</ha-config-section>
<ha-config-section is-wide="[[isWide]]">
<span slot="header"
>[[localize('ui.panel.config.cloud.account.integrations')]]</span
>
<div slot="introduction">
<p>
[[localize('ui.panel.config.cloud.account.integrations_introduction')]]
</p>
<p>
[[localize('ui.panel.config.cloud.account.integrations_introduction2')]]
<a
href="https://www.nabucasa.com"
target="_blank"
rel="noreferrer"
>
[[localize('ui.panel.config.cloud.account.integrations_link_all_features')]]</a
>.
</p>
</div>
<cloud-remote-pref
hass="[[hass]]"
cloud-status="[[cloudStatus]]"
dir="[[_rtlDirection]]"
></cloud-remote-pref>
<cloud-tts-pref
hass="[[hass]]"
cloud-status="[[cloudStatus]]"
dir="[[_rtlDirection]]"
></cloud-tts-pref>
<cloud-alexa-pref
hass="[[hass]]"
cloud-status="[[cloudStatus]]"
dir="[[_rtlDirection]]"
></cloud-alexa-pref>
<cloud-google-pref
hass="[[hass]]"
cloud-status="[[cloudStatus]]"
dir="[[_rtlDirection]]"
></cloud-google-pref>
<cloud-webhooks
hass="[[hass]]"
cloud-status="[[cloudStatus]]"
dir="[[_rtlDirection]]"
></cloud-webhooks>
</ha-config-section>
</div>
</hass-subpage>
`;
}
static get properties() {
return {
hass: Object,
isWide: Boolean,
narrow: Boolean,
cloudStatus: Object,
_subscription: {
type: Object,
value: null,
},
_rtlDirection: {
type: Boolean,
computed: "_computeRTLDirection(hass)",
},
};
}
ready() {
super.ready();
this._fetchSubscriptionInfo();
}
_computeConnectionStatus(status) {
return status === "connected"
? this.hass.localize("ui.panel.config.cloud.account.connected")
: status === "disconnected"
? this.hass.localize("ui.panel.config.cloud.account.not_connected")
: this.hass.localize("ui.panel.config.cloud.account.connecting");
}
async _fetchSubscriptionInfo() {
this._subscription = await fetchCloudSubscriptionInfo(this.hass);
if (
this._subscription.provider &&
this.cloudStatus &&
this.cloudStatus.cloud !== "connected"
) {
this.fire("ha-refresh-cloud-status");
}
}
handleLogout() {
this.hass
.callApi("post", "cloud/logout")
.then(() => this.fire("ha-refresh-cloud-status"));
}
_formatSubscription(subInfo) {
if (subInfo === null) {
return this.hass.localize(
"ui.panel.config.cloud.account.fetching_subscription"
);
}
let description = subInfo.human_description;
if (subInfo.plan_renewal_date) {
description = description.replace(
"{periodEnd}",
formatDateTime(
new Date(subInfo.plan_renewal_date * 1000),
this.hass.locale
)
);
}
return description;
}
_computeRTLDirection(hass) {
return computeRTLDirection(hass);
}
}
customElements.define("cloud-account", CloudAccount);

View File

@ -0,0 +1,268 @@
import "@material/mwc-button";
import "@polymer/paper-item/paper-item-body";
import { LitElement, css, html, PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { formatDateTime } from "../../../../common/datetime/format_date_time";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeRTLDirection } from "../../../../common/util/compute_rtl";
import "../../../../components/buttons/ha-call-api-button";
import "../../../../components/ha-card";
import {
cloudLogout,
CloudStatusLoggedIn,
fetchCloudSubscriptionInfo,
SubscriptionInfo,
} from "../../../../data/cloud";
import "../../../../layouts/hass-subpage";
import { HomeAssistant } from "../../../../types";
import "../../ha-config-section";
import "./cloud-alexa-pref";
import "./cloud-google-pref";
import "./cloud-remote-pref";
import "./cloud-tts-pref";
import "./cloud-webhooks";
@customElement("cloud-account")
export class CloudAccount extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public isWide = false;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public cloudStatus!: CloudStatusLoggedIn;
@state() private _subscription?: SubscriptionInfo;
@state() private _rtlDirection: "rtl" | "ltr" = "rtl";
protected render() {
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
header="Home Assistant Cloud"
>
<div class="content">
<ha-config-section .isWide=${this.isWide}>
<span slot="header">Home Assistant Cloud</span>
<div slot="introduction">
<p>
${this.hass.localize(
"ui.panel.config.cloud.account.thank_you_note"
)}
</p>
</div>
<ha-card
.header=${this.hass.localize(
"ui.panel.config.cloud.account.nabu_casa_account"
)}
>
<div class="account-row">
<paper-item-body two-line>
${this.cloudStatus.email}
<div secondary class="wrap">
${this._subscription
? this._subscription.human_description.replace(
"{periodEnd}",
this._subscription.plan_renewal_date
? formatDateTime(
new Date(
this._subscription.plan_renewal_date * 1000
),
this.hass.locale
)
: ""
)
: this.hass.localize(
"ui.panel.config.cloud.account.fetching_subscription"
)}
</div>
</paper-item-body>
</div>
<div class="account-row">
<paper-item-body>
${this.hass.localize(
"ui.panel.config.cloud.account.connection_status"
)}
</paper-item-body>
<div class="status">
${this.cloudStatus.cloud === "connected"
? this.hass.localize(
"ui.panel.config.cloud.account.connected"
)
: this.cloudStatus.cloud === "disconnected"
? this.hass.localize(
"ui.panel.config.cloud.account.not_connected"
)
: this.hass.localize(
"ui.panel.config.cloud.account.connecting"
)}
</div>
</div>
<div class="card-actions">
<a
href="https://account.nabucasa.com"
target="_blank"
rel="noreferrer"
>
<mwc-button>
${this.hass.localize(
"ui.panel.config.cloud.account.manage_account"
)}
</mwc-button>
</a>
<mwc-button @click=${this._handleLogout}
>${this.hass.localize(
"ui.panel.config.cloud.account.sign_out"
)}</mwc-button
>
</div>
</ha-card>
</ha-config-section>
<ha-config-section .isWide=${this.isWide}>
<span slot="header"
>${this.hass.localize(
"ui.panel.config.cloud.account.integrations"
)}</span
>
<div slot="introduction">
<p>
${this.hass.localize(
"ui.panel.config.cloud.account.integrations_introduction"
)}
</p>
<p>
${this.hass.localize(
"ui.panel.config.cloud.account.integrations_introduction2"
)}
<a
href="https://www.nabucasa.com"
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.cloud.account.integrations_link_all_features"
)}</a
>.
</p>
</div>
<cloud-remote-pref
.hass=${this.hass}
.cloudStatus=${this.cloudStatus}
dir=${this._rtlDirection}
></cloud-remote-pref>
<cloud-tts-pref
.hass=${this.hass}
.cloudStatus=${this.cloudStatus}
dir=${this._rtlDirection}
></cloud-tts-pref>
<cloud-alexa-pref
.hass=${this.hass}
.cloudStatus=${this.cloudStatus}
dir=${this._rtlDirection}
></cloud-alexa-pref>
<cloud-google-pref
.hass=${this.hass}
.cloudStatus=${this.cloudStatus}
dir=${this._rtlDirection}
></cloud-google-pref>
<cloud-webhooks
.hass=${this.hass}
.cloudStatus=${this.cloudStatus}
dir=${this._rtlDirection}
></cloud-webhooks>
</ha-config-section>
</div>
</hass-subpage>
`;
}
firstUpdated() {
this._fetchSubscriptionInfo();
}
protected updated(changedProps: PropertyValues) {
if (changedProps.has("hass")) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (!oldHass || oldHass.locale !== this.hass.locale) {
this._rtlDirection = computeRTLDirection(this.hass);
}
}
}
private async _fetchSubscriptionInfo() {
this._subscription = await fetchCloudSubscriptionInfo(this.hass);
if (
this._subscription.provider &&
this.cloudStatus &&
this.cloudStatus.cloud !== "connected"
) {
fireEvent(this, "ha-refresh-cloud-status");
}
}
private async _handleLogout() {
await cloudLogout(this.hass);
fireEvent(this, "ha-refresh-cloud-status");
}
_computeRTLDirection(hass) {
return computeRTLDirection(hass);
}
static get styles() {
return css`
[slot="introduction"] {
margin: -1em 0;
}
[slot="introduction"] a {
color: var(--primary-color);
}
.content {
padding-bottom: 24px;
}
.account-row {
display: flex;
padding: 0 16px;
}
.card-actions {
display: flex;
justify-content: space-between;
}
.card-actions a {
text-decoration: none;
}
mwc-button {
align-self: center;
}
.wrap {
white-space: normal;
}
.status {
text-transform: capitalize;
padding: 16px;
}
a {
color: var(--primary-color);
}
`;
}
}
customElements.define("cloud-account", CloudAccount);
declare global {
interface HTMLElementTagNameMap {
"cloud-account": CloudAccount;
}
}

View File

@ -1,152 +0,0 @@
import "@polymer/paper-input/paper-input";
import { html } from "@polymer/polymer/lib/utils/html-tag";
/* eslint-plugin-disable lit */
import { PolymerElement } from "@polymer/polymer/polymer-element";
import "../../../../components/buttons/ha-progress-button";
import "../../../../components/ha-card";
import "../../../../layouts/hass-subpage";
import { EventsMixin } from "../../../../mixins/events-mixin";
import LocalizeMixin from "../../../../mixins/localize-mixin";
import "../../../../styles/polymer-ha-style";
/*
* @appliesMixin EventsMixin
* @appliesMixin LocalizeMixin
*/
class CloudForgotPassword extends LocalizeMixin(EventsMixin(PolymerElement)) {
static get template() {
return html`
<style include="iron-flex ha-style">
.content {
padding-bottom: 24px;
}
ha-card {
max-width: 600px;
margin: 0 auto;
margin-top: 24px;
}
h1 {
@apply --paper-font-headline;
margin: 0;
}
.error {
color: var(--error-color);
}
.card-actions {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-actions a {
color: var(--primary-text-color);
}
[hidden] {
display: none;
}
</style>
<hass-subpage
hass="[[hass]]"
narrow="[[narrow]]"
header="[[localize('ui.panel.config.cloud.forgot_password.title')]]"
>
<div class="content">
<ha-card
header="[[localize('ui.panel.config.cloud.forgot_password.subtitle')]]"
>
<div class="card-content">
<p>
[[localize('ui.panel.config.cloud.forgot_password.instructions')]]
</p>
<div class="error" hidden$="[[!_error]]">[[_error]]</div>
<paper-input
autofocus=""
id="email"
label="[[localize('ui.panel.config.cloud.forgot_password.email')]]"
value="{{email}}"
type="email"
on-keydown="_keyDown"
error-message="[[localize('ui.panel.config.cloud.forgot_password.email_error_msg')]]"
></paper-input>
</div>
<div class="card-actions">
<ha-progress-button
on-click="_handleEmailPasswordReset"
progress="[[_requestInProgress]]"
>[[localize('ui.panel.config.cloud.forgot_password.send_reset_email')]]</ha-progress-button
>
</div>
</ha-card>
</div>
</hass-subpage>
`;
}
static get properties() {
return {
hass: Object,
narrow: Boolean,
email: {
type: String,
notify: true,
observer: "_emailChanged",
},
_requestInProgress: {
type: Boolean,
value: false,
},
_error: {
type: String,
value: "",
},
};
}
_emailChanged() {
this._error = "";
this.$.email.invalid = false;
}
_keyDown(ev) {
// validate on enter
if (ev.keyCode === 13) {
this._handleEmailPasswordReset();
ev.preventDefault();
}
}
_handleEmailPasswordReset() {
if (!this.email || !this.email.includes("@")) {
this.$.email.invalid = true;
}
if (this.$.email.invalid) return;
this._requestInProgress = true;
this.hass
.callApi("post", "cloud/forgot_password", {
email: this.email,
})
.then(
() => {
this._requestInProgress = false;
this.fire("cloud-done", {
flashMessage: this.hass.localize(
"ui.panel.config.cloud.forgot_password.check_your_email"
),
});
},
(err) =>
this.setProperties({
_requestInProgress: false,
_error:
err && err.body && err.body.message
? err.body.message
: "Unknown error",
})
);
}
}
customElements.define("cloud-forgot-password", CloudForgotPassword);

View File

@ -0,0 +1,156 @@
import "@material/mwc-textfield/mwc-textfield";
import type { TextField } from "@material/mwc-textfield/mwc-textfield";
import { css, html, LitElement, TemplateResult } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/buttons/ha-progress-button";
import "../../../../components/ha-alert";
import "../../../../components/ha-card";
import { cloudForgotPassword } from "../../../../data/cloud";
import "../../../../layouts/hass-subpage";
import { haStyle } from "../../../../resources/styles";
import { HomeAssistant } from "../../../../types";
@customElement("cloud-forgot-password")
export class CloudForgotPassword extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property() public email?: string;
@state() public _requestInProgress = false;
@state() private _error?: string;
@query("#email", true) private _emailField!: TextField;
protected render(): TemplateResult {
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize(
"ui.panel.config.cloud.forgot_password.title"
)}
>
<div class="content">
<ha-card
.header=${this.hass.localize(
"ui.panel.config.cloud.forgot_password.subtitle"
)}
>
<div class="card-content">
<p>
${this.hass.localize(
"ui.panel.config.cloud.forgot_password.instructions"
)}
</p>
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: ""}
<mwc-textfield
autofocus
id="email"
label=${this.hass.localize(
"ui.panel.config.cloud.forgot_password.email"
)}
.value=${this.email}
type="email"
required
@keydown=${this._keyDown}
.validationMessage=${this.hass.localize(
"ui.panel.config.cloud.forgot_password.email_error_msg"
)}
></mwc-textfield>
</div>
<div class="card-actions">
<ha-progress-button
@click=${this._handleEmailPasswordReset}
.progress=${this._requestInProgress}
>
${this.hass.localize(
"ui.panel.config.cloud.forgot_password.send_reset_email"
)}
</ha-progress-button>
</div>
</ha-card>
</div>
</hass-subpage>
`;
}
private _keyDown(ev: KeyboardEvent) {
if (ev.key === "Enter") {
this._handleEmailPasswordReset();
}
}
private async _handleEmailPasswordReset() {
const emailField = this._emailField;
const email = emailField.value;
if (!emailField.reportValidity()) {
emailField.focus();
return;
}
this._requestInProgress = true;
try {
await cloudForgotPassword(this.hass, email);
// @ts-ignore
fireEvent(this, "email-changed", { value: email });
this._requestInProgress = false;
// @ts-ignore
fireEvent(this, "cloud-done", {
flashMessage: this.hass.localize(
"ui.panel.config.cloud.forgot_password.check_your_email"
),
});
} catch (err: any) {
this._requestInProgress = false;
this._error =
err && err.body && err.body.message
? err.body.message
: "Unknown error";
}
}
static get styles() {
return [
haStyle,
css`
.content {
padding-bottom: 24px;
}
ha-card {
max-width: 600px;
margin: 0 auto;
margin-top: 24px;
}
h1 {
margin: 0;
}
mwc-textfield {
width: 100%;
}
.card-actions {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-actions a {
color: var(--primary-text-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"cloud-forgot-password": CloudForgotPassword;
}
}

View File

@ -1,336 +0,0 @@
import "@material/mwc-button";
import "@polymer/paper-input/paper-input";
import "@polymer/paper-item/paper-item";
import "@polymer/paper-item/paper-item-body";
import "@polymer/paper-ripple/paper-ripple";
import { html } from "@polymer/polymer/lib/utils/html-tag";
/* eslint-plugin-disable lit */
import { PolymerElement } from "@polymer/polymer/polymer-element";
import { computeRTL } from "../../../../common/util/compute_rtl";
import "../../../../components/buttons/ha-progress-button";
import "../../../../components/ha-card";
import "../../../../components/ha-icon";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-icon-next";
import "../../../../layouts/hass-subpage";
import { EventsMixin } from "../../../../mixins/events-mixin";
import LocalizeMixin from "../../../../mixins/localize-mixin";
import NavigateMixin from "../../../../mixins/navigate-mixin";
import "../../../../styles/polymer-ha-style";
import "../../ha-config-section";
/*
* @appliesMixin NavigateMixin
* @appliesMixin EventsMixin
* @appliesMixin LocalizeMixin
*/
class CloudLogin extends LocalizeMixin(
NavigateMixin(EventsMixin(PolymerElement))
) {
static get template() {
return html`
<style include="iron-flex ha-style">
.content {
padding-bottom: 24px;
}
[slot="introduction"] {
margin: -1em 0;
}
[slot="introduction"] a {
color: var(--primary-color);
}
paper-item {
cursor: pointer;
}
ha-card {
overflow: hidden;
}
ha-card .card-header {
margin-bottom: -8px;
}
h1 {
@apply --paper-font-headline;
margin: 0;
}
.error {
color: var(--error-color);
}
.card-actions {
display: flex;
justify-content: space-between;
align-items: center;
}
[hidden] {
display: none;
}
.flash-msg {
padding-right: 44px;
}
.flash-msg ha-icon-button {
position: absolute;
top: 4px;
right: 8px;
color: var(--secondary-text-color);
}
:host([rtl]) .flash-msg ha-icon-button {
right: auto;
left: 8px;
}
.login-form {
display: flex;
flex-direction: column;
}
.pwd-forgot-link {
color: var(--secondary-text-color) !important;
text-align: right !important;
align-self: flex-end;
}
</style>
<hass-subpage
hass="[[hass]]"
narrow="[[narrow]]"
header="Home Assistant Cloud"
>
<div class="content">
<ha-config-section is-wide="[[isWide]]">
<span slot="header">Home Assistant Cloud</span>
<div slot="introduction">
<p>[[localize('ui.panel.config.cloud.login.introduction')]]</p>
<p>
[[localize('ui.panel.config.cloud.login.introduction2')]]
<a
href="https://www.nabucasa.com"
target="_blank"
rel="noreferrer"
>
Nabu&nbsp;Casa,&nbsp;Inc</a
>[[localize('ui.panel.config.cloud.login.introduction2a')]]
</p>
<p>[[localize('ui.panel.config.cloud.login.introduction3')]]</p>
<p>
<a
href="https://www.nabucasa.com"
target="_blank"
rel="noreferrer"
>
[[localize('ui.panel.config.cloud.login.learn_more_link')]]
</a>
</p>
</div>
<ha-card hidden$="[[!flashMessage]]">
<div class="card-content flash-msg">
[[flashMessage]]
<ha-icon-button
label="[[localize('ui.panel.config.cloud.login.dismiss')]]"
on-click="_dismissFlash"
>
<ha-icon icon="hass:close"></ha-icon>
</ha-icon-button>
<paper-ripple id="flashRipple" noink=""></paper-ripple>
</div>
</ha-card>
<ha-card
header="[[localize('ui.panel.config.cloud.login.sign_in')]]"
>
<div class="card-content login-form">
<div class="error" hidden$="[[!_error]]">[[_error]]</div>
<paper-input
label="[[localize('ui.panel.config.cloud.login.email')]]"
id="email"
type="email"
value="{{email}}"
on-keydown="_keyDown"
error-message="[[localize('ui.panel.config.cloud.login.email_error_msg')]]"
></paper-input>
<paper-input
id="password"
label="[[localize('ui.panel.config.cloud.login.password')]]"
value="{{_password}}"
type="password"
on-keydown="_keyDown"
error-message="[[localize('ui.panel.config.cloud.login.password_error_msg')]]"
></paper-input>
<button
class="link pwd-forgot-link"
hidden="[[_requestInProgress]]"
on-click="_handleForgotPassword"
>
[[localize('ui.panel.config.cloud.login.forgot_password')]]
</button>
</div>
<div class="card-actions">
<ha-progress-button
on-click="_handleLogin"
progress="[[_requestInProgress]]"
>[[localize('ui.panel.config.cloud.login.sign_in')]]</ha-progress-button
>
</div>
</ha-card>
<ha-card>
<paper-item on-click="_handleRegister">
<paper-item-body two-line="">
[[localize('ui.panel.config.cloud.login.start_trial')]]
<div secondary="">
[[localize('ui.panel.config.cloud.login.trial_info')]]
</div>
</paper-item-body>
<ha-icon-next></ha-icon-next>
</paper-item>
</ha-card>
</ha-config-section>
</div>
</hass-subpage>
`;
}
static get properties() {
return {
hass: Object,
isWide: Boolean,
narrow: Boolean,
email: {
type: String,
notify: true,
},
_password: {
type: String,
value: "",
},
_requestInProgress: {
type: Boolean,
value: false,
},
flashMessage: {
type: String,
notify: true,
},
rtl: {
type: Boolean,
reflectToAttribute: true,
computed: "_computeRTL(hass)",
},
_error: String,
};
}
static get observers() {
return ["_inputChanged(email, _password)"];
}
connectedCallback() {
super.connectedCallback();
if (this.flashMessage) {
// Wait for DOM to be drawn
requestAnimationFrame(() =>
requestAnimationFrame(() => this.$.flashRipple.simulatedRipple())
);
}
}
_inputChanged() {
this.$.email.invalid = false;
this.$.password.invalid = false;
this._error = false;
}
_keyDown(ev) {
// validate on enter
if (ev.keyCode === 13) {
this._handleLogin();
ev.preventDefault();
}
}
_handleLogin() {
let invalid = false;
if (!this.email || !this.email.includes("@")) {
this.$.email.invalid = true;
this.$.email.focus();
invalid = true;
}
if (this._password.length < 8) {
this.$.password.invalid = true;
if (!invalid) {
invalid = true;
this.$.password.focus();
}
}
if (invalid) return;
this._requestInProgress = true;
this.hass
.callApi("post", "cloud/login", {
email: this.email,
password: this._password,
})
.then(
() => {
this.fire("ha-refresh-cloud-status");
this.setProperties({
email: "",
_password: "",
});
},
(err) => {
// Do this before setProperties because changing it clears errors.
this._password = "";
const errCode = err && err.body && err.body.code;
if (errCode === "PasswordChangeRequired") {
alert(
"[[localize('ui.panel.config.cloud.login.alert_password_change_required')]]"
);
this.navigate("/config/cloud/forgot-password");
return;
}
const props = {
_requestInProgress: false,
_error:
err && err.body && err.body.message
? err.body.message
: "Unknown error",
};
if (errCode === "UserNotConfirmed") {
props._error =
"[[localize('ui.panel.config.cloud.login.alert_email_confirm_necessary')]]";
}
this.setProperties(props);
this.$.email.focus();
}
);
}
_handleRegister() {
this.flashMessage = "";
this.navigate("/config/cloud/register");
}
_handleForgotPassword() {
this.flashMessage = "";
this.navigate("/config/cloud/forgot-password");
}
_dismissFlash() {
// give some time to let the ripple finish.
setTimeout(() => {
this.flashMessage = "";
}, 200);
}
_computeRTL(hass) {
return computeRTL(hass);
}
}
customElements.define("cloud-login", CloudLogin);

View File

@ -0,0 +1,310 @@
import "@material/mwc-button";
import "@material/mwc-textfield/mwc-textfield";
import type { TextField } from "@material/mwc-textfield/mwc-textfield";
import "@polymer/paper-input/paper-input";
import "@polymer/paper-item/paper-item";
import "@polymer/paper-item/paper-item-body";
import { css, html, LitElement, TemplateResult } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { navigate } from "../../../../common/navigate";
import "../../../../components/buttons/ha-progress-button";
import "../../../../components/ha-alert";
import "../../../../components/ha-card";
import "../../../../components/ha-icon-next";
import { cloudLogin } from "../../../../data/cloud";
import { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box";
import "../../../../layouts/hass-subpage";
import { haStyle } from "../../../../resources/styles";
import "../../../../styles/polymer-ha-style";
import { HomeAssistant } from "../../../../types";
import "../../ha-config-section";
@customElement("cloud-login")
export class CloudLogin extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public isWide = false;
@property({ type: Boolean }) public narrow = false;
@property() public email?: string;
@property() public flashMessage?: string;
@state() private _password?: string;
@state() private _requestInProgress = false;
@state() private _error?: string;
@query("#email", true) private _emailField!: TextField;
@query("#password", true) private _passwordField!: TextField;
protected render(): TemplateResult {
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
header="Home Assistant Cloud"
>
<div class="content">
<ha-config-section isWide=${this.isWide}>
<span slot="header">Home Assistant Cloud</span>
<div slot="introduction">
<p>
${this.hass.localize(
"ui.panel.config.cloud.login.introduction"
)}
</p>
<p>
${this.hass.localize(
"ui.panel.config.cloud.login.introduction2"
)}
<a
href="https://www.nabucasa.com"
target="_blank"
rel="noreferrer"
>
Nabu&nbsp;Casa,&nbsp;Inc</a
>${this.hass.localize(
"ui.panel.config.cloud.login.introduction2a"
)}
</p>
<p>
${this.hass.localize(
"ui.panel.config.cloud.login.introduction3"
)}
</p>
<p>
<a
href="https://www.nabucasa.com"
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.cloud.login.learn_more_link"
)}
</a>
</p>
</div>
${this.flashMessage
? html`<ha-alert
dismissable
@alert-dismissed-clicked=${this._dismissFlash}
>
${this.flashMessage}
</ha-alert>`
: ""}
<ha-card
.header=${this.hass.localize(
"ui.panel.config.cloud.login.sign_in"
)}
>
<div class="card-content login-form">
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: ""}
<mwc-textfield
.label=${this.hass.localize(
"ui.panel.config.cloud.login.email"
)}
id="email"
type="email"
required
.value=${this.email}
@keydown=${this._keyDown}
.disabled=${this._requestInProgress}
.validationMessage=${this.hass.localize(
"ui.panel.config.cloud.login.email_error_msg"
)}
></mwc-textfield>
<mwc-textfield
id="password"
.label=${this.hass.localize(
"ui.panel.config.cloud.login.password"
)}
.value=${this._password || ""}
type="password"
required
minlength="8"
@keydown=${this._keyDown}
.disabled=${this._requestInProgress}
.validationMessage=${this.hass.localize(
"ui.panel.config.cloud.login.password_error_msg"
)}
></mwc-textfield>
<button
class="link pwd-forgot-link"
.disabled=${this._requestInProgress}
@click=${this._handleForgotPassword}
>
${this.hass.localize(
"ui.panel.config.cloud.login.forgot_password"
)}
</button>
</div>
<div class="card-actions">
<ha-progress-button
@click=${this._handleLogin}
.progress=${this._requestInProgress}
>${this.hass.localize(
"ui.panel.config.cloud.login.sign_in"
)}</ha-progress-button
>
</div>
</ha-card>
<ha-card>
<paper-item @click=${this._handleRegister}>
<paper-item-body two-line>
${this.hass.localize(
"ui.panel.config.cloud.login.start_trial"
)}
<div secondary>
${this.hass.localize(
"ui.panel.config.cloud.login.trial_info"
)}
</div>
</paper-item-body>
<ha-icon-next></ha-icon-next>
</paper-item>
</ha-card>
</ha-config-section>
</div>
</hass-subpage>
`;
}
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;
try {
await cloudLogin(this.hass, email, password);
fireEvent(this, "ha-refresh-cloud-status");
this.email = "";
this._password = "";
} catch (err: any) {
const errCode = err && err.body && err.body.code;
if (errCode === "PasswordChangeRequired") {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.cloud.login.alert_password_change_required"
),
});
navigate("/config/cloud/forgot-password");
return;
}
this._password = "";
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();
}
}
private _handleRegister() {
this._dismissFlash();
// @ts-ignore
fireEvent(this, "email-changed", { value: this._emailField.value });
navigate("/config/cloud/register");
}
private _handleForgotPassword() {
this._dismissFlash();
// @ts-ignore
fireEvent(this, "email-changed", { value: this._emailField.value });
navigate("/config/cloud/forgot-password");
}
private _dismissFlash() {
// @ts-ignore
fireEvent(this, "flash-message-changed", { value: "" });
}
static get styles() {
return [
haStyle,
css`
.content {
padding-bottom: 24px;
}
[slot="introduction"] {
margin: -1em 0;
}
[slot="introduction"] a {
color: var(--primary-color);
}
paper-item {
cursor: pointer;
}
ha-card {
overflow: hidden;
}
ha-card .card-header {
margin-bottom: -8px;
}
h1 {
margin: 0;
}
.card-actions {
display: flex;
justify-content: space-between;
align-items: center;
}
.login-form {
display: flex;
flex-direction: column;
}
.pwd-forgot-link {
color: var(--secondary-text-color) !important;
text-align: right !important;
align-self: flex-end;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"cloud-login": CloudLogin;
}
}

View File

@ -1,229 +0,0 @@
import "@polymer/paper-input/paper-input";
import { html } from "@polymer/polymer/lib/utils/html-tag";
/* eslint-plugin-disable lit */
import { PolymerElement } from "@polymer/polymer/polymer-element";
import "../../../../components/buttons/ha-progress-button";
import "../../../../components/ha-card";
import "../../../../layouts/hass-subpage";
import { EventsMixin } from "../../../../mixins/events-mixin";
import LocalizeMixin from "../../../../mixins/localize-mixin";
import "../../../../styles/polymer-ha-style";
import { documentationUrl } from "../../../../util/documentation-url";
import "../../ha-config-section";
/*
* @appliesMixin EventsMixin
* @appliesMixin LocalizeMixin
*/
class CloudRegister extends LocalizeMixin(EventsMixin(PolymerElement)) {
static get template() {
return html`
<style include="iron-flex ha-style">
[slot=introduction] {
margin: -1em 0;
}
[slot=introduction] a {
color: var(--primary-color);
}
a {
color: var(--primary-color);
}
paper-item {
cursor: pointer;
}
h1 {
@apply --paper-font-headline;
margin: 0;
}
.error {
color: var(--error-color);
}
.card-actions {
display: flex;
justify-content: space-between;
align-items: center;
}
[hidden] {
display: none;
}
</style>
<hass-subpage hass="[[hass]]" narrow="[[narrow]]" header="[[localize('ui.panel.config.cloud.register.title')]]">
<div class="content">
<ha-config-section is-wide="[[isWide]]">
<span slot="header">[[localize('ui.panel.config.cloud.register.headline')]]</span>
<div slot="introduction">
<p>
[[localize('ui.panel.config.cloud.register.information')]]
</p>
<p>
[[localize('ui.panel.config.cloud.register.information2')]]
</p>
<ul>
<li>[[localize('ui.panel.config.cloud.register.feature_remote_control')]]</li>
<li>[[localize('ui.panel.config.cloud.register.feature_google_home')]]</li>
<li>[[localize('ui.panel.config.cloud.register.feature_amazon_alexa')]]</li>
<li>[[localize('ui.panel.config.cloud.register.feature_webhook_apps')]]</li>
</ul>
<p>
[[localize('ui.panel.config.cloud.register.information3')]] <a href='https://www.nabucasa.com' target='_blank'>Nabu&nbsp;Casa,&nbsp;Inc</a>[[localize('ui.panel.config.cloud.register.information3a')]]
</p>
<p>
[[localize('ui.panel.config.cloud.register.information4')]]
</p><ul>
<li><a href="[[_computeDocumentationUrlTos(hass)]]" target="_blank" rel="noreferrer">[[localize('ui.panel.config.cloud.register.link_terms_conditions')]]</a></li>
<li><a href="[[_computeDocumentationUrlPrivacy(hass)]]" target="_blank" rel="noreferrer">[[localize('ui.panel.config.cloud.register.link_privacy_policy')]]</a></li>
</ul>
</p>
</div>
<ha-card header="[[localize('ui.panel.config.cloud.register.create_account')]]">
<div class="card-content">
<div class="header">
<div class="error" hidden$="[[!_error]]">[[_error]]</div>
</div>
<paper-input autofocus="" id="email" label="[[localize('ui.panel.config.cloud.register.email_address')]]" type="email" value="{{email}}" on-keydown="_keyDown" error-message="[[localize('ui.panel.config.cloud.register.email_error_msg')]]"></paper-input>
<paper-input id="password" label="Password" value="{{_password}}" type="password" on-keydown="_keyDown" error-message="[[localize('ui.panel.config.cloud.register.password_error_msg')]]"></paper-input>
</div>
<div class="card-actions">
<ha-progress-button on-click="_handleRegister" progress="[[_requestInProgress]]">[[localize('ui.panel.config.cloud.register.start_trial')]]</ha-progress-button>
<button class="link" hidden="[[_requestInProgress]]" on-click="_handleResendVerifyEmail">[[localize('ui.panel.config.cloud.register.resend_confirmation_email')]]</button>
</div>
</ha-card>
</ha-config-section>
</div>
</hass-subpage>
`;
}
static get properties() {
return {
hass: Object,
isWide: Boolean,
narrow: Boolean,
email: {
type: String,
notify: true,
},
_requestInProgress: {
type: Boolean,
value: false,
},
_password: {
type: String,
value: "",
},
_error: {
type: String,
value: "",
},
};
}
static get observers() {
return ["_inputChanged(email, _password)"];
}
_inputChanged() {
this._error = "";
this.$.email.invalid = false;
this.$.password.invalid = false;
}
_keyDown(ev) {
// validate on enter
if (ev.keyCode === 13) {
this._handleRegister();
ev.preventDefault();
}
}
_computeDocumentationUrlTos(hass) {
return documentationUrl(hass, "/tos/");
}
_computeDocumentationUrlPrivacy(hass) {
return documentationUrl(hass, "/privacy/");
}
_handleRegister() {
let invalid = false;
if (!this.email || !this.email.includes("@")) {
this.$.email.invalid = true;
this.$.email.focus();
invalid = true;
}
if (this._password.length < 8) {
this.$.password.invalid = true;
if (!invalid) {
invalid = true;
this.$.password.focus();
}
}
if (invalid) return;
this._requestInProgress = true;
this.hass
.callApi("post", "cloud/register", {
email: this.email,
password: this._password,
})
.then(
() => this._verificationEmailSent(),
(err) => {
// Do this before setProperties because changing it clears errors.
this._password = "";
this.setProperties({
_requestInProgress: false,
_error:
err && err.body && err.body.message
? err.body.message
: "Unknown error",
});
}
);
}
_handleResendVerifyEmail() {
if (!this.email) {
this.$.email.invalid = true;
return;
}
this.hass
.callApi("post", "cloud/resend_confirm", {
email: this.email,
})
.then(
() => this._verificationEmailSent(),
(err) =>
this.setProperties({
_error:
err && err.body && err.body.message
? err.body.message
: "Unknown error",
})
);
}
_verificationEmailSent() {
this.setProperties({
_requestInProgress: false,
_password: "",
});
this.fire("cloud-done", {
flashMessage: this.hass.localize(
"ui.panel.config.cloud.register.account_created"
),
});
}
}
customElements.define("cloud-register", CloudRegister);

View File

@ -0,0 +1,293 @@
import "@material/mwc-textfield/mwc-textfield";
import type { TextField } from "@material/mwc-textfield/mwc-textfield";
import { css, html, LitElement, TemplateResult } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/buttons/ha-progress-button";
import "../../../../components/ha-alert";
import "../../../../components/ha-card";
import { cloudRegister, cloudResendVerification } from "../../../../data/cloud";
import "../../../../layouts/hass-subpage";
import { haStyle } from "../../../../resources/styles";
import { HomeAssistant } from "../../../../types";
import "../../ha-config-section";
@customElement("cloud-register")
export class CloudRegister extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public isWide = false;
@property({ type: Boolean }) public narrow = false;
@property() public email?: string;
@state() private _requestInProgress = false;
@state() private _password = "";
@state() private _error?: string;
@query("#email", true) private _emailField!: TextField;
@query("#password", true) private _passwordField!: TextField;
protected render(): TemplateResult {
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.cloud.register.title")}
>
<div class="content">
<ha-config-section .isWide=${this.isWide}>
<span slot="header"
>${this.hass.localize(
"ui.panel.config.cloud.register.headline"
)}</span
>
<div slot="introduction">
<p>
${this.hass.localize(
"ui.panel.config.cloud.register.information"
)}
</p>
<p>
${this.hass.localize(
"ui.panel.config.cloud.register.information2"
)}
</p>
<ul>
<li>
${this.hass.localize(
"ui.panel.config.cloud.register.feature_remote_control"
)}
</li>
<li>
${this.hass.localize(
"ui.panel.config.cloud.register.feature_google_home"
)}
</li>
<li>
${this.hass.localize(
"ui.panel.config.cloud.register.feature_amazon_alexa"
)}
</li>
<li>
${this.hass.localize(
"ui.panel.config.cloud.register.feature_webhook_apps"
)}
</li>
</ul>
<p>
${this.hass.localize(
"ui.panel.config.cloud.register.information3"
)}
<a href="https://www.nabucasa.com" target="_blank"
>Nabu&nbsp;Casa,&nbsp;Inc</a
>
${this.hass.localize(
"ui.panel.config.cloud.register.information3a"
)}
</p>
<p>
${this.hass.localize(
"ui.panel.config.cloud.register.information4"
)}
</p>
<ul>
<li>
<a
href="https://www.nabucasa.com/tos/"
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.cloud.register.link_terms_conditions"
)}
</a>
</li>
<li>
<a
href="https://www.nabucasa.com/privacy_policy/"
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.cloud.register.link_privacy_policy"
)}
</a>
</li>
</ul>
</div>
<ha-card
.header=${this.hass.localize(
"ui.panel.config.cloud.register.create_account"
)}
><div class="card-content register-form">
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: ""}
<mwc-textfield
autofocus
id="email"
.label=${this.hass.localize(
"ui.panel.config.cloud.register.email_address"
)}
type="email"
required
.value=${this.email}
@keydown=${this._keyDown}
validationMessage=${this.hass.localize(
"ui.panel.config.cloud.register.email_error_msg"
)}
></mwc-textfield>
<mwc-textfield
id="password"
label="Password"
.value=${this._password}
type="password"
minlength="8"
required
@keydown=${this._keyDown}
validationMessage=${this.hass.localize(
"ui.panel.config.cloud.register.password_error_msg"
)}
></mwc-textfield>
</div>
<div class="card-actions">
<ha-progress-button
@click=${this._handleRegister}
.progress=${this._requestInProgress}
>${this.hass.localize(
"ui.panel.config.cloud.register.start_trial"
)}</ha-progress-button
>
<button
class="link"
.disabled=${this._requestInProgress}
@click=${this._handleResendVerifyEmail}
>
${this.hass.localize(
"ui.panel.config.cloud.register.resend_confirm_email"
)}
</button>
</div>
</ha-card>
</ha-config-section>
</div>
</hass-subpage>
`;
}
private _keyDown(ev: KeyboardEvent) {
if (ev.key === "Enter") {
this._handleRegister();
}
}
private async _handleRegister() {
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;
try {
await cloudRegister(this.hass, email, password);
this._verificationEmailSent(email);
} catch (err: any) {
this._password = "";
this._requestInProgress = false;
this._error =
err && err.body && err.body.message
? err.body.message
: "Unknown error";
}
}
private async _handleResendVerifyEmail() {
const emailField = this._emailField;
const email = emailField.value;
if (!emailField.reportValidity()) {
emailField.focus();
return;
}
try {
await cloudResendVerification(this.hass, email);
this._verificationEmailSent(email);
} catch (err: any) {
this._error =
err && err.body && err.body.message
? err.body.message
: "Unknown error";
}
}
private _verificationEmailSent(email: string) {
this._requestInProgress = false;
this._password = "";
// @ts-ignore
fireEvent(this, "email-changed", { value: email });
// @ts-ignore
fireEvent(this, "cloud-done", {
flashMessage: this.hass.localize(
"ui.panel.config.cloud.register.account_created"
),
});
}
static get styles() {
return [
haStyle,
css`
[slot="introduction"] {
margin: -1em 0;
}
[slot="introduction"] a {
color: var(--primary-color);
}
a {
color: var(--primary-color);
}
paper-item {
cursor: pointer;
}
h1 {
margin: 0;
}
.register-form {
display: flex;
flex-direction: column;
}
.card-actions {
display: flex;
justify-content: space-between;
align-items: center;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"cloud-register": CloudRegister;
}
}

View File

@ -3343,7 +3343,7 @@ __metadata:
languageName: node
linkType: hard
"@polymer/paper-ripple@npm:^3.0.0-pre.26, @polymer/paper-ripple@npm:^3.0.2":
"@polymer/paper-ripple@npm:^3.0.0-pre.26":
version: 3.0.2
resolution: "@polymer/paper-ripple@npm:3.0.2"
dependencies:
@ -9038,7 +9038,6 @@ fsevents@^1.2.7:
"@polymer/paper-input": ^3.2.1
"@polymer/paper-item": ^3.0.1
"@polymer/paper-listbox": ^3.0.1
"@polymer/paper-ripple": ^3.0.2
"@polymer/paper-slider": ^3.0.1
"@polymer/paper-styles": ^3.0.1
"@polymer/paper-tabs": ^3.1.0