diff --git a/src/data/cloud.ts b/src/data/cloud.ts new file mode 100644 index 0000000000..150e92f731 --- /dev/null +++ b/src/data/cloud.ts @@ -0,0 +1,19 @@ +import { HomeAssistant } from "../types"; + +export interface CloudWebhook { + webhook_id: string; + cloudhook_id: string; + cloudhook_url: string; +} + +export const createCloudhook = (hass: HomeAssistant, webhookId: string) => + hass.callWS({ + type: "cloud/cloudhook/create", + webhook_id: webhookId, + }); + +export const deleteCloudhook = (hass: HomeAssistant, webhookId: string) => + hass.callWS({ + type: "cloud/cloudhook/delete", + webhook_id: webhookId, + }); diff --git a/src/data/webhook.ts b/src/data/webhook.ts new file mode 100644 index 0000000000..98ae07495a --- /dev/null +++ b/src/data/webhook.ts @@ -0,0 +1,12 @@ +import { HomeAssistant } from "../types"; + +export interface Webhook { + webhook_id: string; + domain: string; + name: string; +} + +export const fetchWebhooks = (hass: HomeAssistant): Promise => + hass.callWS({ + type: "webhook/list", + }); diff --git a/src/panels/config/cloud/cloud-alexa-pref.ts b/src/panels/config/cloud/cloud-alexa-pref.ts index e92ac32296..149084acc8 100644 --- a/src/panels/config/cloud/cloud-alexa-pref.ts +++ b/src/panels/config/cloud/cloud-alexa-pref.ts @@ -24,6 +24,10 @@ export class CloudAlexaPref extends LitElement { } protected render(): TemplateResult { + if (!this.cloudStatus) { + return html``; + } + const enabled = this.cloudStatus!.prefs.alexa_enabled; return html` diff --git a/src/panels/config/cloud/cloud-google-pref.ts b/src/panels/config/cloud/cloud-google-pref.ts index 5d39501927..015c5155c2 100644 --- a/src/panels/config/cloud/cloud-google-pref.ts +++ b/src/panels/config/cloud/cloud-google-pref.ts @@ -25,7 +25,11 @@ export class CloudGooglePref extends LitElement { } protected render(): TemplateResult { - const { google_enabled, google_allow_unlock } = this.cloudStatus!.prefs; + if (!this.cloudStatus) { + return html``; + } + + const { google_enabled, google_allow_unlock } = this.cloudStatus.prefs; return html` ${this.renderStyle()} diff --git a/src/panels/config/cloud/cloud-webhook-manage-dialog.ts b/src/panels/config/cloud/cloud-webhook-manage-dialog.ts new file mode 100644 index 0000000000..610f916f24 --- /dev/null +++ b/src/panels/config/cloud/cloud-webhook-manage-dialog.ts @@ -0,0 +1,142 @@ +import { html, LitElement, PropertyDeclarations } from "@polymer/lit-element"; + +import "@polymer/paper-button/paper-button"; +import "@polymer/paper-input/paper-input"; +import "@polymer/paper-dialog-scrollable/paper-dialog-scrollable"; +import "@polymer/paper-dialog/paper-dialog"; +// This is not a duplicate import, one is for types, one is for element. +// tslint:disable-next-line +import { PaperDialogElement } from "@polymer/paper-dialog/paper-dialog"; +// tslint:disable-next-line +import { PaperInputElement } from "@polymer/paper-input/paper-input"; + +import { buttonLink } from "../../../resources/ha-style"; + +import { HomeAssistant } from "../../../types"; +import { WebhookDialogParams } from "./types"; + +const inputLabel = "Public URL – Click to copy to clipboard"; + +export class CloudWebhookManageDialog extends LitElement { + protected hass?: HomeAssistant; + private _params?: WebhookDialogParams; + + static get properties(): PropertyDeclarations { + return { + _params: {}, + }; + } + + public async showDialog(params: WebhookDialogParams) { + this._params = params; + // Wait till dialog is rendered. + await this.updateComplete; + this._dialog.open(); + } + + protected render() { + if (!this._params) { + return html``; + } + const { webhook, cloudhook } = this._params; + const docsUrl = + webhook.domain === "automation" + ? "https://www.home-assistant.io/docs/automation/trigger/#webhook-trigger" + : `https://www.home-assistant.io/components/${webhook.domain}/`; + return html` + ${this._renderStyle()} + +

Webhook for ${webhook.name}

+
+

The webhook is available at the following url:

+ +

+ If you no longer want to use this webhook, you can + . +

+
+ +
+ VIEW DOCUMENTATION + CLOSE +
+
+ `; + } + + private get _dialog(): PaperDialogElement { + return this.shadowRoot!.querySelector("paper-dialog")!; + } + + private get _paperInput(): PaperInputElement { + return this.shadowRoot!.querySelector("paper-input")!; + } + + private _closeDialog() { + this._dialog.close(); + } + + private async _disableWebhook() { + if (!confirm("Are you sure you want to disable this webhook?")) { + return; + } + + this._params!.disableHook(); + this._closeDialog(); + } + + private _copyClipboard(ev: FocusEvent) { + // paper-input -> iron-input -> input + const paperInput = ev.currentTarget as PaperInputElement; + const input = (paperInput.inputElement as any) + .inputElement as HTMLInputElement; + input.setSelectionRange(0, input.value.length); + try { + document.execCommand("copy"); + paperInput.label = "COPIED TO CLIPBOARD"; + } catch (err) { + // Copying failed. Oh no + } + } + + private _restoreLabel() { + this._paperInput.label = inputLabel; + } + + private _renderStyle() { + return html` + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "cloud-webhook-manage-dialog": CloudWebhookManageDialog; + } +} + +customElements.define("cloud-webhook-manage-dialog", CloudWebhookManageDialog); diff --git a/src/panels/config/cloud/cloud-webhooks.ts b/src/panels/config/cloud/cloud-webhooks.ts new file mode 100644 index 0000000000..bec6948ab3 --- /dev/null +++ b/src/panels/config/cloud/cloud-webhooks.ts @@ -0,0 +1,234 @@ +import { + html, + LitElement, + PropertyDeclarations, + PropertyValues, +} from "@polymer/lit-element"; +import "@polymer/paper-toggle-button/paper-toggle-button"; +import "@polymer/paper-item/paper-item"; +import "@polymer/paper-item/paper-item-body"; +import "@polymer/paper-spinner/paper-spinner"; +import "../../../components/ha-card"; + +import { fireEvent } from "../../../common/dom/fire_event"; + +import { HomeAssistant, WebhookError } from "../../../types"; +import { WebhookDialogParams, CloudStatusLoggedIn } from "./types"; +import { Webhook, fetchWebhooks } from "../../../data/webhook"; +import { + createCloudhook, + deleteCloudhook, + CloudWebhook, +} from "../../../data/cloud"; + +declare global { + // for fire event + interface HASSDomEvents { + "manage-cloud-webhook": WebhookDialogParams; + } +} + +export class CloudWebhooks extends LitElement { + public hass?: HomeAssistant; + public cloudStatus?: CloudStatusLoggedIn; + private _cloudHooks?: { [webhookId: string]: CloudWebhook }; + private _localHooks?: Webhook[]; + private _progress: string[]; + + static get properties(): PropertyDeclarations { + return { + hass: {}, + cloudStatus: {}, + _cloudHooks: {}, + _localHooks: {}, + _progress: {}, + }; + } + + constructor() { + super(); + this._progress = []; + } + + public connectedCallback() { + super.connectedCallback(); + this._fetchData(); + } + + protected render() { + return html` + ${this.renderStyle()} + +
+ Anything that is configured to be triggered by a webhook can be given + a publicly accessible URL to allow you to send data back to Home + Assistant from anywhere, without exposing your instance to the + internet. +
+ + ${this._renderBody()} + + +
+ `; + } + + protected updated(changedProps: PropertyValues) { + if (changedProps.has("cloudStatus") && this.cloudStatus) { + this._cloudHooks = this.cloudStatus.prefs.cloudhooks || {}; + } + } + + private _renderBody() { + if (!this.cloudStatus || !this._localHooks || !this._cloudHooks) { + return html` +
Loading…
+ `; + } + + return this._localHooks.map( + (entry) => html` +
+ +
+ ${entry.name} + ${ + entry.domain === entry.name.toLowerCase() + ? "" + : ` (${entry.domain})` + } +
+
${entry.webhook_id}
+
+ ${ + this._progress.includes(entry.webhook_id) + ? html` +
+ +
+ ` + : this._cloudHooks![entry.webhook_id] + ? html` + Manage + ` + : html` + + ` + } +
+ ` + ); + } + + private _showDialog(webhookId: string) { + const webhook = this._localHooks!.find( + (ent) => ent.webhook_id === webhookId + ); + const cloudhook = this._cloudHooks![webhookId]; + const params: WebhookDialogParams = { + webhook: webhook!, + cloudhook, + disableHook: () => this._disableWebhook(webhookId), + }; + fireEvent(this, "manage-cloud-webhook", params); + } + + private _handleManageButton(ev: MouseEvent) { + const entry = (ev.currentTarget as any).parentElement.entry as Webhook; + this._showDialog(entry.webhook_id); + } + + private async _enableWebhook(ev: MouseEvent) { + const entry = (ev.currentTarget as any).parentElement.entry; + this._progress = [...this._progress, entry.webhook_id]; + let updatedWebhook; + + try { + updatedWebhook = await createCloudhook(this.hass!, entry.webhook_id); + } catch (err) { + alert((err as WebhookError).message); + return; + } finally { + this._progress = this._progress.filter((wid) => wid !== entry.webhook_id); + } + + this._cloudHooks = { + ...this._cloudHooks, + [entry.webhook_id]: updatedWebhook, + }; + + // Only open dialog if we're not also enabling others, otherwise it's confusing + if (this._progress.length === 0) { + this._showDialog(entry.webhook_id); + } + } + + private async _disableWebhook(webhookId: string) { + this._progress = [...this._progress, webhookId]; + try { + await deleteCloudhook(this.hass!, webhookId!); + } catch (err) { + alert(`Failed to disable webhook: ${(err as WebhookError).message}`); + return; + } finally { + this._progress = this._progress.filter((wid) => wid !== webhookId); + } + + // Remove cloud related parts from entry. + const { [webhookId]: disabledHook, ...newHooks } = this._cloudHooks!; + this._cloudHooks = newHooks; + } + + private async _fetchData() { + this._localHooks = await fetchWebhooks(this.hass!); + } + + private renderStyle() { + return html` + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "cloud-webhooks": CloudWebhooks; + } +} + +customElements.define("cloud-webhooks", CloudWebhooks); diff --git a/src/panels/config/cloud/ha-config-cloud-account.js b/src/panels/config/cloud/ha-config-cloud-account.js index 516bc0f3d1..b5305af2c7 100644 --- a/src/panels/config/cloud/ha-config-cloud-account.js +++ b/src/panels/config/cloud/ha-config-cloud-account.js @@ -10,15 +10,19 @@ import "../../../layouts/hass-subpage"; import "../../../resources/ha-style"; import "../ha-config-section"; +import "./cloud-webhooks"; import formatDateTime from "../../../common/datetime/format_date_time"; import EventsMixin from "../../../mixins/events-mixin"; import LocalizeMixin from "../../../mixins/localize-mixin"; +import { fireEvent } from "../../../common/dom/fire_event"; import { fetchSubscriptionInfo } from "./data"; import "./cloud-alexa-pref"; import "./cloud-google-pref"; +let registeredWebhookDialog = false; + /* * @appliesMixin EventsMixin * @appliesMixin LocalizeMixin @@ -129,6 +133,11 @@ class HaConfigCloudAccount extends EventsMixin(LocalizeMixin(PolymerElement)) { hass="[[hass]]" cloud-status="[[cloudStatus]]" > + + @@ -152,9 +161,26 @@ class HaConfigCloudAccount extends EventsMixin(LocalizeMixin(PolymerElement)) { this._fetchSubscriptionInfo(); } + connectedCallback() { + super.connectedCallback(); + + if (!registeredWebhookDialog) { + registeredWebhookDialog = true; + fireEvent(this, "register-dialog", { + dialogShowEvent: "manage-cloud-webhook", + dialogTag: "cloud-webhook-manage-dialog", + dialogImport: () => import("./cloud-webhook-manage-dialog"), + }); + } + } + async _fetchSubscriptionInfo() { this._subscription = await fetchSubscriptionInfo(this.hass); - if (this._subscription.provider && this.cloudStatus.cloud !== "connected") { + if ( + this._subscription.provider && + this.cloudStatus && + this.cloudStatus.cloud !== "connected" + ) { this.fire("ha-refresh-cloud-status"); } } diff --git a/src/panels/config/cloud/types.ts b/src/panels/config/cloud/types.ts index a42ab724b0..80affe7d3f 100644 --- a/src/panels/config/cloud/types.ts +++ b/src/panels/config/cloud/types.ts @@ -1,3 +1,6 @@ +import { CloudWebhook } from "../../../data/cloud"; +import { Webhook } from "../../../data/webhook"; + export interface EntityFilter { include_domains: string[]; include_entities: string[]; @@ -19,6 +22,7 @@ export type CloudStatusLoggedIn = CloudStatusBase & { google_enabled: boolean; alexa_enabled: boolean; google_allow_unlock: boolean; + cloudhooks: { [webhookId: string]: CloudWebhook }; }; }; @@ -27,3 +31,9 @@ export type CloudStatus = CloudStatusBase | CloudStatusLoggedIn; export interface SubscriptionInfo { human_description: string; } + +export interface WebhookDialogParams { + webhook: Webhook; + cloudhook: CloudWebhook; + disableHook: () => void; +} diff --git a/src/resources/ha-style.js b/src/resources/ha-style.js index 57c1e51a8d..b645210773 100644 --- a/src/resources/ha-style.js +++ b/src/resources/ha-style.js @@ -1,6 +1,19 @@ import "@polymer/paper-styles/paper-styles"; import "@polymer/polymer/polymer-legacy"; +export const buttonLink = ` + button.link { + background: none; + color: inherit; + border: none; + padding: 0; + font: inherit; + text-align: left; + text-decoration: underline; + cursor: pointer; + } +`; + const documentContainer = document.createElement("template"); documentContainer.setAttribute("style", "display: none;"); @@ -162,16 +175,7 @@ documentContainer.innerHTML = ` @apply --paper-font-title; } - button.link { - background: none; - color: inherit; - border: none; - padding: 0; - font: inherit; - text-align: left; - text-decoration: underline; - cursor: pointer; - } + ${buttonLink} .card-actions a { text-decoration: none; diff --git a/src/types.ts b/src/types.ts index dad3f5ffd4..9fc4b9db09 100644 --- a/src/types.ts +++ b/src/types.ts @@ -15,6 +15,11 @@ declare global { var __VERSION__: string; } +export interface WebhookError { + code: number; + message: string; +} + export interface Credential { auth_provider_type: string; auth_provider_id: string;