diff --git a/src/components/ha-settings-row.ts b/src/components/ha-settings-row.ts index 722eae470502..e58bc1f41610 100644 --- a/src/components/ha-settings-row.ts +++ b/src/components/ha-settings-row.ts @@ -5,6 +5,8 @@ import { customElement, property } from "lit/decorators"; export class HaSettingsRow extends LitElement { @property({ type: Boolean, reflect: true }) public narrow = false; + @property({ type: Boolean, reflect: true }) public slim = false; // remove padding and min-height + @property({ type: Boolean, attribute: "three-line" }) public threeLine = false; @@ -112,6 +114,14 @@ export class HaSettingsRow extends LitElement { display: flex; align-items: center; } + :host([slim]), + :host([slim]) .content, + :host([slim]) ::slotted(ha-switch) { + padding: 0; + } + :host([slim]) .body { + min-height: 0; + } `; } } diff --git a/src/data/network.ts b/src/data/network.ts index 5bfb391c00b3..10482557ac1d 100644 --- a/src/data/network.ts +++ b/src/data/network.ts @@ -26,6 +26,12 @@ export interface NetworkConfig { configured_adapters: string[]; } +export interface NetworkUrls { + internal: string; + external: string; + cloud: string; +} + export const getNetworkConfig = (hass: HomeAssistant) => hass.callWS({ type: "network", @@ -41,3 +47,8 @@ export const setNetworkConfig = ( configured_adapters: configured_adapters, }, }); + +export const getNetworkUrls = (hass: HomeAssistant) => + hass.callWS({ + type: "network/url", + }); diff --git a/src/panels/config/cloud/account/cloud-remote-pref.ts b/src/panels/config/cloud/account/cloud-remote-pref.ts index 5bd00fadee55..5c036b4c5295 100644 --- a/src/panels/config/cloud/account/cloud-remote-pref.ts +++ b/src/panels/config/cloud/account/cloud-remote-pref.ts @@ -23,6 +23,7 @@ import { import type { HomeAssistant } from "../../../../types"; import { showToast } from "../../../../util/toast"; import { showCloudCertificateDialog } from "../dialog-cloud-certificate/show-dialog-cloud-certificate"; +import { obfuscateUrl } from "../../../../util/url"; @customElement("cloud-remote-pref") export class CloudRemotePref extends LitElement { @@ -142,7 +143,7 @@ export class CloudRemotePref extends LitElement { - ${this.hass.localize( - "ui.panel.config.cloud.account.remote.copy_link" - )} + ${this.hass.localize("ui.panel.config.common.copy_link")} diff --git a/src/panels/config/network/ha-config-url-form.ts b/src/panels/config/network/ha-config-url-form.ts index 8ec30540b182..934869b79995 100644 --- a/src/panels/config/network/ha-config-url-form.ts +++ b/src/panels/config/network/ha-config-url-form.ts @@ -8,6 +8,7 @@ import { nothing, } from "lit"; import { customElement, property, state } from "lit/decorators"; +import { mdiContentCopy, mdiEyeOff, mdiEye } from "@mdi/js"; import { isComponentLoaded } from "../../../common/config/is_component_loaded"; import { isIPAddress } from "../../../common/string/is_ip_address"; import "../../../components/ha-alert"; @@ -18,7 +19,12 @@ import "../../../components/ha-textfield"; import type { HaTextField } from "../../../components/ha-textfield"; import { CloudStatus, fetchCloudStatus } from "../../../data/cloud"; import { saveCoreConfig } from "../../../data/core"; +import { getNetworkUrls, type NetworkUrls } from "../../../data/network"; import type { ValueChangedEvent, HomeAssistant } from "../../../types"; +import { copyToClipboard } from "../../../common/util/copy-clipboard"; +import { showToast } from "../../../util/toast"; +import type { HaSwitch } from "../../../components/ha-switch"; +import { obfuscateUrl } from "../../../util/url"; @customElement("ha-config-url-form") class ConfigUrlForm extends LitElement { @@ -28,9 +34,11 @@ class ConfigUrlForm extends LitElement { @state() private _working = false; - @state() private _external_url?: string; + @state() private _urls?: NetworkUrls; - @state() private _internal_url?: string; + @state() private _external_url: string = ""; + + @state() private _internal_url: string = ""; @state() private _cloudStatus?: CloudStatus | null; @@ -38,18 +46,29 @@ class ConfigUrlForm extends LitElement { @state() private _showCustomInternalUrl = false; + @state() private _unmaskedExternalUrl = false; + + @state() private _unmaskedInternalUrl = false; + + @state() private _cloudChecked = false; + protected render() { const canEdit = ["storage", "default"].includes( this.hass.config.config_source ); const disabled = this._working || !canEdit; - if (!this.hass.userData?.showAdvanced || this._cloudStatus === undefined) { + if (this._cloudStatus === undefined || this._urls === undefined) { return nothing; } - const internalUrl = this._internalUrlValue; - const externalUrl = this._externalUrlValue; + const internalUrl = this._showCustomInternalUrl + ? this._internal_url + : this._urls?.internal || ""; + const externalUrl = this._showCustomExternalUrl + ? this._external_url + : (this._cloudChecked ? this._urls?.cloud : this._urls?.external) || ""; + let hasCloud: boolean; let remoteEnabled: boolean; let httpUseHttps: boolean; @@ -95,49 +114,61 @@ class ConfigUrlForm extends LitElement { ${hasCloud ? html` -
-
+

+ ${this.hass.localize( + "ui.panel.config.url.external_url_label" + )} +

+ + ${this.hass.localize( - "ui.panel.config.url.external_url_label" - )} -
- - - -
+ + + ` : ""} - ${!this._showCustomExternalUrl - ? "" - : html` -
-
- ${hasCloud - ? "" - : this.hass.localize( - "ui.panel.config.url.external_url_label" - )} -
- - -
- `} +
+
+
` + } + > + ${!this._showCustomExternalUrl || !canEdit + ? html` + + ` + : nothing} +
+ + + ${this.hass.localize("ui.panel.config.common.copy_link")} + + ${hasCloud || !isComponentLoaded(this.hass, "cloud") ? "" : html` @@ -180,40 +211,65 @@ class ConfigUrlForm extends LitElement { ` : ""} -
-
- ${this.hass.localize("ui.panel.config.url.internal_url_label")} -
- - + ${this.hass.localize("ui.panel.config.url.internal_url_label")} + + + + ${this.hass.localize( "ui.panel.config.url.internal_url_automatic" )} - > - - + + + ${this.hass.localize( + "ui.panel.config.url.internal_url_automatic_description" + )} + + + + +
+
+
` + } + > + ${!this._showCustomInternalUrl || !canEdit + ? html` + + ` + : nothing} +
+ + + ${this.hass.localize("ui.panel.config.common.copy_link")} +
- - ${!this._showCustomInternalUrl - ? "" - : html` -
-
- - -
- `} ${ // If the user has configured a cert, show an error if httpUseHttps && // there is no internal url configured @@ -253,46 +309,47 @@ class ConfigUrlForm extends LitElement { protected override firstUpdated(changedProps: PropertyValues) { super.firstUpdated(changedProps); - this._showCustomInternalUrl = this._internalUrlValue !== null; - if (isComponentLoaded(this.hass, "cloud")) { fetchCloudStatus(this.hass).then((cloudStatus) => { this._cloudStatus = cloudStatus; - if (cloudStatus.logged_in) { - this._showCustomExternalUrl = this._externalUrlValue !== null; - } else { - this._showCustomExternalUrl = true; - } + this._showCustomExternalUrl = !( + this._cloudStatus.logged_in && !this.hass.config.external_url + ); }); } else { this._cloudStatus = null; - this._showCustomExternalUrl = true; } + this._fetchUrls(); } - private get _internalUrlValue() { - return this._internal_url !== undefined - ? this._internal_url - : this.hass.config.internal_url; + private _toggleCloud(ev: Event) { + this._cloudChecked = (ev.currentTarget as HaSwitch).checked; + this._showCustomExternalUrl = !this._cloudChecked; } - private get _externalUrlValue() { - return this._external_url !== undefined - ? this._external_url - : this.hass.config.external_url; + private _toggleInternalAutomatic(ev: Event) { + this._showCustomInternalUrl = !(ev.currentTarget as HaSwitch).checked; } - private _toggleCloud(ev) { - this._showCustomExternalUrl = !ev.currentTarget.checked; + private _toggleUnmaskedInternalUrl() { + this._unmaskedInternalUrl = !this._unmaskedInternalUrl; } - private _toggleInternalAutomatic(ev) { - this._showCustomInternalUrl = !ev.currentTarget.checked; + private _toggleUnmaskedExternalUrl() { + this._unmaskedExternalUrl = !this._unmaskedExternalUrl; + } + + private async _copyURL(ev) { + const url = ev.currentTarget.url; + await copyToClipboard(url); + showToast(this, { + message: this.hass.localize("ui.common.copied_clipboard"), + }); } private _handleChange(ev: ValueChangedEvent) { const target = ev.currentTarget as HaTextField; - this[`_${target.name}`] = target.value || null; + this[`_${target.name}`] = target.value || ""; } private async _save() { @@ -307,6 +364,7 @@ class ConfigUrlForm extends LitElement { ? this._internal_url || null : null, }); + await this._fetchUrls(); } catch (err: any) { this._error = err.message || err; } finally { @@ -314,6 +372,19 @@ class ConfigUrlForm extends LitElement { } } + private async _fetchUrls() { + this._urls = await getNetworkUrls(this.hass); + this._cloudChecked = + this._urls?.cloud === this._urls?.external && + !this.hass.config.external_url; + this._showCustomInternalUrl = !!this.hass.config.internal_url; + this._showCustomExternalUrl = !( + this._cloudStatus?.logged_in && !this.hass.config.external_url + ); + this._internal_url = this._urls?.internal ?? ""; + this._external_url = this._urls?.external ?? ""; + } + static get styles(): CSSResultGroup { return css` .description { @@ -351,6 +422,31 @@ class ConfigUrlForm extends LitElement { color: var(--primary-color); text-decoration: none; } + + .url-container { + display: flex; + align-items: center; + gap: 8px; + margin-top: 8px; + } + .textfield-container { + position: relative; + flex: 1; + } + .textfield-container ha-textfield { + display: block; + } + .toggle-unmasked-url { + position: absolute; + top: 8px; + right: 8px; + inset-inline-start: initial; + inset-inline-end: 8px; + --mdc-icon-button-size: 40px; + --mdc-icon-size: 20px; + color: var(--secondary-text-color); + direction: var(--direction); + } `; } } diff --git a/src/translations/en.json b/src/translations/en.json index 98d82bd602c9..ea07a3b19397 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -1930,7 +1930,10 @@ "multiselect": { "failed": "Failed to update {number} items." }, - "learn_more": "Learn more" + "learn_more": "Learn more", + "show_url": "Show full URL", + "hide_url": "Hide URL", + "copy_link": "Copy link" }, "updates": { "caption": "Updates", @@ -2398,7 +2401,9 @@ "enable_remote": "[%key:ui::common::enable%]", "internal_url_automatic": "Automatic", "internal_url_https_error_title": "Invalid local network URL", - "internal_url_https_error_description": "You have configured an HTTPS certificate in Home Assistant. This means that your internal URL needs to be set to a domain covered by the certficate." + "internal_url_https_error_description": "You have configured an HTTPS certificate in Home Assistant. This means that your internal URL needs to be set to a domain covered by the certficate.", + "internal_url_automatic_description": "Use the configured network settings", + "internal_url_placeholder": "http://:8123" }, "hardware": { "caption": "Hardware", @@ -3915,9 +3920,6 @@ "info": "Home Assistant Cloud provides a secure remote access to your instance while away from home. For more information on remote access and these settings visit our security documentation.", "info_instance_will_be_available": "Your instance will be available at your Nabu Casa URL.", "link_learn_how_it_works": "Learn how it works", - "show_url": "Show full URL", - "hide_url": "Hide URL", - "copy_link": "Copy link", "security_options": "Security options", "external_activation": "Allow external activation of remote access", "external_activation_secondary": "If you disable remote access on this page, having this setting enabled allows you to reactivate it remotely via your Nabu Casa account.", diff --git a/src/util/url.ts b/src/util/url.ts new file mode 100644 index 000000000000..ed3da5bb5926 --- /dev/null +++ b/src/util/url.ts @@ -0,0 +1,9 @@ +export function obfuscateUrl(url: string) { + if (url.endsWith(".ui.nabu.casa")) { + return "https://•••••••••••••••••.ui.nabu.casa"; + } + // hide any words that look like they might be a hostname or IP address + return url.replace(/(?<=:\/\/)[\w-]+|(?<=\.)[\w-]+/g, (match) => + "•".repeat(match.length) + ); +}