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 {
@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;
}
`;
}
}

View File

@ -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<NetworkConfig>({
type: "network",
@ -41,3 +47,8 @@ export const setNetworkConfig = (
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 { 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 {
<ha-textfield
.value=${this._unmaskedUrl
? `https://${remote_domain}`
: "https://•••••••••••••••••.ui.nabu.casa"}
: obfuscateUrl(`https://${remote_domain}`)}
readonly
.suffix=${
// reserve some space for the icon.
@ -153,7 +154,7 @@ export class CloudRemotePref extends LitElement {
class="toggle-unmasked-url"
toggles
.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}
.path=${this._unmaskedUrl ? mdiEyeOff : mdiEye}
@ -165,9 +166,7 @@ export class CloudRemotePref extends LitElement {
unelevated
>
<ha-svg-icon slot="icon" .path=${mdiContentCopy}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.cloud.account.remote.copy_link"
)}
${this.hass.localize("ui.panel.config.common.copy_link")}
</ha-button>
</div>

View File

@ -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`
<div class="row">
<div class="flex">
<h4>
${this.hass.localize(
"ui.panel.config.url.external_url_label"
)}
</h4>
<ha-settings-row slim>
<span slot="heading">
${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"
)}
>
<ha-switch
.disabled=${disabled}
.checked=${externalUrl === null}
@change=${this._toggleCloud}
></ha-switch>
</ha-formfield>
</div>
</span>
<ha-switch
.disabled=${disabled}
.checked=${this._cloudChecked}
@change=${this._toggleCloud}
></ha-switch>
</ha-settings-row>
`
: ""}
${!this._showCustomExternalUrl
? ""
: html`
<div class="row">
<div class="flex">
${hasCloud
? ""
: this.hass.localize(
"ui.panel.config.url.external_url_label"
)}
</div>
<ha-textfield
class="flex"
name="external_url"
type="url"
.disabled=${disabled}
.value=${externalUrl || ""}
@change=${this._handleChange}
placeholder="https://example.duckdns.org:8123"
>
</ha-textfield>
</div>
`}
<div class="url-container">
<div class="textfield-container">
<ha-textfield
name="external_url"
type="url"
placeholder="https://example.duckdns.org:8123"
.value=${this._unmaskedExternalUrl ||
(this._showCustomExternalUrl && canEdit)
? externalUrl
: obfuscateUrl(externalUrl)}
@change=${this._handleChange}
.disabled=${disabled || !this._showCustomExternalUrl}
.suffix=${
// reserve some space for the icon.
html`<div style="width: 24px"></div>`
}
></ha-textfield>
${!this._showCustomExternalUrl || !canEdit
? html`
<ha-icon-button
class="toggle-unmasked-url"
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")
? ""
: html`
@ -180,40 +211,65 @@ class ConfigUrlForm extends LitElement {
`
: ""}
<div class="row">
<div class="flex">
${this.hass.localize("ui.panel.config.url.internal_url_label")}
</div>
<ha-formfield
.label=${this.hass.localize(
<h4>
${this.hass.localize("ui.panel.config.url.internal_url_label")}
</h4>
<ha-settings-row slim>
<span slot="heading">
${this.hass.localize(
"ui.panel.config.url.internal_url_automatic"
)}
>
<ha-switch
.checked=${internalUrl === null}
@change=${this._toggleInternalAutomatic}
></ha-switch>
</ha-formfield>
</div>
</span>
<span slot="description">
${this.hass.localize(
"ui.panel.config.url.internal_url_automatic_description"
)}
</span>
<ha-switch
.disabled=${disabled}
.checked=${!this._showCustomInternalUrl}
@change=${this._toggleInternalAutomatic}
></ha-switch>
</ha-settings-row>
${!this._showCustomInternalUrl
? ""
: html`
<div class="row">
<div class="flex"></div>
<ha-textfield
class="flex"
name="internal_url"
type="url"
placeholder="http://<some IP address>:8123"
.disabled=${disabled}
.value=${internalUrl || ""}
@change=${this._handleChange}
>
</ha-textfield>
</div>
`}
<div class="url-container">
<div class="textfield-container">
<ha-textfield
name="internal_url"
type="url"
placeholder=${this.hass.localize(
"ui.panel.config.url.internal_url_placeholder"
)}
.value=${this._unmaskedInternalUrl ||
(this._showCustomInternalUrl && canEdit)
? internalUrl
: obfuscateUrl(internalUrl)}
@change=${this._handleChange}
.disabled=${disabled || !this._showCustomInternalUrl}
.suffix=${
// 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
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<string>) {
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);
}
`;
}
}

View File

@ -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://<some IP address>: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.",

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