Show local network URL used in Home Assistant URL settings (#22379)

* Show local network URL used in Home Assistant URL settings

* full width alert

* always display both urls and add copy buttons

* remove mask button when editing

* fix typo

* type fix

* update styling based on comments

* fix bad copy/paste

* Update src/components/ha-settings-row.ts

Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>

* Update src/panels/config/network/ha-config-url-form.ts

Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>

* Update src/panels/config/network/ha-config-url-form.ts

Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>

* PR comment

* remove advanced flag

* make the value handling logic clearer

* move obfuscateUrl to a util function

* PR comments

---------

Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
This commit is contained in:
Petar Petrov 2024-10-29 16:07:18 +02:00 committed by GitHub
parent 42f2341e06
commit 5f6396b187
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 232 additions and 105 deletions

View File

@ -5,6 +5,8 @@ import { customElement, property } from "lit/decorators";
export class HaSettingsRow extends LitElement { export class HaSettingsRow extends LitElement {
@property({ type: Boolean, reflect: true }) public narrow = false; @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" }) @property({ type: Boolean, attribute: "three-line" })
public threeLine = false; public threeLine = false;
@ -112,6 +114,14 @@ export class HaSettingsRow extends LitElement {
display: flex; display: flex;
align-items: center; align-items: center;
} }
:host([slim]),
:host([slim]) .content,
:host([slim]) ::slotted(ha-switch) {
padding: 0;
}
:host([slim]) .body {
min-height: 0;
}
`; `;
} }
} }

View File

@ -26,6 +26,12 @@ export interface NetworkConfig {
configured_adapters: string[]; configured_adapters: string[];
} }
export interface NetworkUrls {
internal: string;
external: string;
cloud: string;
}
export const getNetworkConfig = (hass: HomeAssistant) => export const getNetworkConfig = (hass: HomeAssistant) =>
hass.callWS<NetworkConfig>({ hass.callWS<NetworkConfig>({
type: "network", type: "network",
@ -41,3 +47,8 @@ export const setNetworkConfig = (
configured_adapters: configured_adapters, configured_adapters: configured_adapters,
}, },
}); });
export const getNetworkUrls = (hass: HomeAssistant) =>
hass.callWS<NetworkUrls>({
type: "network/url",
});

View File

@ -23,6 +23,7 @@ import {
import type { HomeAssistant } from "../../../../types"; import type { HomeAssistant } from "../../../../types";
import { showToast } from "../../../../util/toast"; import { showToast } from "../../../../util/toast";
import { showCloudCertificateDialog } from "../dialog-cloud-certificate/show-dialog-cloud-certificate"; import { showCloudCertificateDialog } from "../dialog-cloud-certificate/show-dialog-cloud-certificate";
import { obfuscateUrl } from "../../../../util/url";
@customElement("cloud-remote-pref") @customElement("cloud-remote-pref")
export class CloudRemotePref extends LitElement { export class CloudRemotePref extends LitElement {
@ -142,7 +143,7 @@ export class CloudRemotePref extends LitElement {
<ha-textfield <ha-textfield
.value=${this._unmaskedUrl .value=${this._unmaskedUrl
? `https://${remote_domain}` ? `https://${remote_domain}`
: "https://•••••••••••••••••.ui.nabu.casa"} : obfuscateUrl(`https://${remote_domain}`)}
readonly readonly
.suffix=${ .suffix=${
// reserve some space for the icon. // reserve some space for the icon.
@ -153,7 +154,7 @@ export class CloudRemotePref extends LitElement {
class="toggle-unmasked-url" class="toggle-unmasked-url"
toggles toggles
.label=${this.hass.localize( .label=${this.hass.localize(
`ui.panel.config.cloud.account.remote.${this._unmaskedUrl ? "hide" : "show"}_url` `ui.panel.config.common.${this._unmaskedUrl ? "hide" : "show"}_url`
)} )}
@click=${this._toggleUnmaskedUrl} @click=${this._toggleUnmaskedUrl}
.path=${this._unmaskedUrl ? mdiEyeOff : mdiEye} .path=${this._unmaskedUrl ? mdiEyeOff : mdiEye}
@ -165,9 +166,7 @@ export class CloudRemotePref extends LitElement {
unelevated unelevated
> >
<ha-svg-icon slot="icon" .path=${mdiContentCopy}></ha-svg-icon> <ha-svg-icon slot="icon" .path=${mdiContentCopy}></ha-svg-icon>
${this.hass.localize( ${this.hass.localize("ui.panel.config.common.copy_link")}
"ui.panel.config.cloud.account.remote.copy_link"
)}
</ha-button> </ha-button>
</div> </div>

View File

@ -8,6 +8,7 @@ import {
nothing, nothing,
} from "lit"; } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { mdiContentCopy, mdiEyeOff, mdiEye } from "@mdi/js";
import { isComponentLoaded } from "../../../common/config/is_component_loaded"; import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { isIPAddress } from "../../../common/string/is_ip_address"; import { isIPAddress } from "../../../common/string/is_ip_address";
import "../../../components/ha-alert"; import "../../../components/ha-alert";
@ -18,7 +19,12 @@ import "../../../components/ha-textfield";
import type { HaTextField } from "../../../components/ha-textfield"; import type { HaTextField } from "../../../components/ha-textfield";
import { CloudStatus, fetchCloudStatus } from "../../../data/cloud"; import { CloudStatus, fetchCloudStatus } from "../../../data/cloud";
import { saveCoreConfig } from "../../../data/core"; import { saveCoreConfig } from "../../../data/core";
import { getNetworkUrls, type NetworkUrls } from "../../../data/network";
import type { ValueChangedEvent, HomeAssistant } from "../../../types"; 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") @customElement("ha-config-url-form")
class ConfigUrlForm extends LitElement { class ConfigUrlForm extends LitElement {
@ -28,9 +34,11 @@ class ConfigUrlForm extends LitElement {
@state() private _working = false; @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; @state() private _cloudStatus?: CloudStatus | null;
@ -38,18 +46,29 @@ class ConfigUrlForm extends LitElement {
@state() private _showCustomInternalUrl = false; @state() private _showCustomInternalUrl = false;
@state() private _unmaskedExternalUrl = false;
@state() private _unmaskedInternalUrl = false;
@state() private _cloudChecked = false;
protected render() { protected render() {
const canEdit = ["storage", "default"].includes( const canEdit = ["storage", "default"].includes(
this.hass.config.config_source this.hass.config.config_source
); );
const disabled = this._working || !canEdit; const disabled = this._working || !canEdit;
if (!this.hass.userData?.showAdvanced || this._cloudStatus === undefined) { if (this._cloudStatus === undefined || this._urls === undefined) {
return nothing; return nothing;
} }
const internalUrl = this._internalUrlValue; const internalUrl = this._showCustomInternalUrl
const externalUrl = this._externalUrlValue; ? 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 hasCloud: boolean;
let remoteEnabled: boolean; let remoteEnabled: boolean;
let httpUseHttps: boolean; let httpUseHttps: boolean;
@ -95,49 +114,61 @@ class ConfigUrlForm extends LitElement {
${hasCloud ${hasCloud
? html` ? html`
<div class="row"> <h4>
<div class="flex"> ${this.hass.localize(
"ui.panel.config.url.external_url_label"
)}
</h4>
<ha-settings-row slim>
<span slot="heading">
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.url.external_url_label"
)}
</div>
<ha-formfield
.label=${this.hass.localize(
"ui.panel.config.url.external_use_ha_cloud" "ui.panel.config.url.external_use_ha_cloud"
)} )}
> </span>
<ha-switch <ha-switch
.disabled=${disabled} .disabled=${disabled}
.checked=${externalUrl === null} .checked=${this._cloudChecked}
@change=${this._toggleCloud} @change=${this._toggleCloud}
></ha-switch> ></ha-switch>
</ha-formfield> </ha-settings-row>
</div>
` `
: ""} : ""}
${!this._showCustomExternalUrl <div class="url-container">
? "" <div class="textfield-container">
: html` <ha-textfield
<div class="row"> name="external_url"
<div class="flex"> type="url"
${hasCloud placeholder="https://example.duckdns.org:8123"
? "" .value=${this._unmaskedExternalUrl ||
: this.hass.localize( (this._showCustomExternalUrl && canEdit)
"ui.panel.config.url.external_url_label" ? externalUrl
)} : obfuscateUrl(externalUrl)}
</div> @change=${this._handleChange}
<ha-textfield .disabled=${disabled || !this._showCustomExternalUrl}
class="flex" .suffix=${
name="external_url" // reserve some space for the icon.
type="url" html`<div style="width: 24px"></div>`
.disabled=${disabled} }
.value=${externalUrl || ""} ></ha-textfield>
@change=${this._handleChange} ${!this._showCustomExternalUrl || !canEdit
placeholder="https://example.duckdns.org:8123" ? html`
> <ha-icon-button
</ha-textfield> class="toggle-unmasked-url"
</div> toggles
`} .label=${this.hass.localize(
`ui.panel.config.common.${this._unmaskedExternalUrl ? "hide" : "show"}_url`
)}
@click=${this._toggleUnmaskedExternalUrl}
.path=${this._unmaskedExternalUrl ? mdiEyeOff : mdiEye}
></ha-icon-button>
`
: nothing}
</div>
<ha-button .url=${externalUrl} @click=${this._copyURL}>
<ha-svg-icon slot="icon" .path=${mdiContentCopy}></ha-svg-icon>
${this.hass.localize("ui.panel.config.common.copy_link")}
</ha-button>
</div>
${hasCloud || !isComponentLoaded(this.hass, "cloud") ${hasCloud || !isComponentLoaded(this.hass, "cloud")
? "" ? ""
: html` : html`
@ -180,40 +211,65 @@ class ConfigUrlForm extends LitElement {
` `
: ""} : ""}
<div class="row"> <h4>
<div class="flex"> ${this.hass.localize("ui.panel.config.url.internal_url_label")}
${this.hass.localize("ui.panel.config.url.internal_url_label")} </h4>
</div> <ha-settings-row slim>
<span slot="heading">
<ha-formfield ${this.hass.localize(
.label=${this.hass.localize(
"ui.panel.config.url.internal_url_automatic" "ui.panel.config.url.internal_url_automatic"
)} )}
> </span>
<ha-switch <span slot="description">
.checked=${internalUrl === null} ${this.hass.localize(
@change=${this._toggleInternalAutomatic} "ui.panel.config.url.internal_url_automatic_description"
></ha-switch> )}
</ha-formfield> </span>
</div> <ha-switch
.disabled=${disabled}
.checked=${!this._showCustomInternalUrl}
@change=${this._toggleInternalAutomatic}
></ha-switch>
</ha-settings-row>
${!this._showCustomInternalUrl <div class="url-container">
? "" <div class="textfield-container">
: html` <ha-textfield
<div class="row"> name="internal_url"
<div class="flex"></div> type="url"
<ha-textfield placeholder=${this.hass.localize(
class="flex" "ui.panel.config.url.internal_url_placeholder"
name="internal_url" )}
type="url" .value=${this._unmaskedInternalUrl ||
placeholder="http://<some IP address>:8123" (this._showCustomInternalUrl && canEdit)
.disabled=${disabled} ? internalUrl
.value=${internalUrl || ""} : obfuscateUrl(internalUrl)}
@change=${this._handleChange} @change=${this._handleChange}
> .disabled=${disabled || !this._showCustomInternalUrl}
</ha-textfield> .suffix=${
</div> // reserve some space for the icon.
`} html`<div style="width: 24px"></div>`
}
></ha-textfield>
${!this._showCustomInternalUrl || !canEdit
? html`
<ha-icon-button
class="toggle-unmasked-url"
toggles
.label=${this.hass.localize(
`ui.panel.config.common.${this._unmaskedInternalUrl ? "hide" : "show"}_url`
)}
@click=${this._toggleUnmaskedInternalUrl}
.path=${this._unmaskedInternalUrl ? mdiEyeOff : mdiEye}
></ha-icon-button>
`
: nothing}
</div>
<ha-button .url=${internalUrl} @click=${this._copyURL}>
<ha-svg-icon slot="icon" .path=${mdiContentCopy}></ha-svg-icon>
${this.hass.localize("ui.panel.config.common.copy_link")}
</ha-button>
</div>
${ ${
// If the user has configured a cert, show an error if // If the user has configured a cert, show an error if
httpUseHttps && // there is no internal url configured httpUseHttps && // there is no internal url configured
@ -253,46 +309,47 @@ class ConfigUrlForm extends LitElement {
protected override firstUpdated(changedProps: PropertyValues) { protected override firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps); super.firstUpdated(changedProps);
this._showCustomInternalUrl = this._internalUrlValue !== null;
if (isComponentLoaded(this.hass, "cloud")) { if (isComponentLoaded(this.hass, "cloud")) {
fetchCloudStatus(this.hass).then((cloudStatus) => { fetchCloudStatus(this.hass).then((cloudStatus) => {
this._cloudStatus = cloudStatus; this._cloudStatus = cloudStatus;
if (cloudStatus.logged_in) { this._showCustomExternalUrl = !(
this._showCustomExternalUrl = this._externalUrlValue !== null; this._cloudStatus.logged_in && !this.hass.config.external_url
} else { );
this._showCustomExternalUrl = true;
}
}); });
} else { } else {
this._cloudStatus = null; this._cloudStatus = null;
this._showCustomExternalUrl = true;
} }
this._fetchUrls();
} }
private get _internalUrlValue() { private _toggleCloud(ev: Event) {
return this._internal_url !== undefined this._cloudChecked = (ev.currentTarget as HaSwitch).checked;
? this._internal_url this._showCustomExternalUrl = !this._cloudChecked;
: this.hass.config.internal_url;
} }
private get _externalUrlValue() { private _toggleInternalAutomatic(ev: Event) {
return this._external_url !== undefined this._showCustomInternalUrl = !(ev.currentTarget as HaSwitch).checked;
? this._external_url
: this.hass.config.external_url;
} }
private _toggleCloud(ev) { private _toggleUnmaskedInternalUrl() {
this._showCustomExternalUrl = !ev.currentTarget.checked; this._unmaskedInternalUrl = !this._unmaskedInternalUrl;
} }
private _toggleInternalAutomatic(ev) { private _toggleUnmaskedExternalUrl() {
this._showCustomInternalUrl = !ev.currentTarget.checked; 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<string>) { private _handleChange(ev: ValueChangedEvent<string>) {
const target = ev.currentTarget as HaTextField; const target = ev.currentTarget as HaTextField;
this[`_${target.name}`] = target.value || null; this[`_${target.name}`] = target.value || "";
} }
private async _save() { private async _save() {
@ -307,6 +364,7 @@ class ConfigUrlForm extends LitElement {
? this._internal_url || null ? this._internal_url || null
: null, : null,
}); });
await this._fetchUrls();
} catch (err: any) { } catch (err: any) {
this._error = err.message || err; this._error = err.message || err;
} finally { } 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 { static get styles(): CSSResultGroup {
return css` return css`
.description { .description {
@ -351,6 +422,31 @@ class ConfigUrlForm extends LitElement {
color: var(--primary-color); color: var(--primary-color);
text-decoration: none; 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);
}
`; `;
} }
} }

View File

@ -1930,7 +1930,10 @@
"multiselect": { "multiselect": {
"failed": "Failed to update {number} items." "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": { "updates": {
"caption": "Updates", "caption": "Updates",
@ -2398,7 +2401,9 @@
"enable_remote": "[%key:ui::common::enable%]", "enable_remote": "[%key:ui::common::enable%]",
"internal_url_automatic": "Automatic", "internal_url_automatic": "Automatic",
"internal_url_https_error_title": "Invalid local network URL", "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://<some IP address>:8123"
}, },
"hardware": { "hardware": {
"caption": "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": "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.", "info_instance_will_be_available": "Your instance will be available at your Nabu Casa URL.",
"link_learn_how_it_works": "Learn how it works", "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", "security_options": "Security options",
"external_activation": "Allow external activation of remote access", "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.", "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.",

9
src/util/url.ts Normal file
View File

@ -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)
);
}