From 40fa9545428c86ac2de4df68cbfe9ae060239be0 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Mon, 11 May 2026 14:06:27 +0300 Subject: [PATCH 1/8] Fix focus loss in ha-input-multi when items change --- src/components/input/ha-input-multi.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/input/ha-input-multi.ts b/src/components/input/ha-input-multi.ts index cd1aca3850b1..886274a15555 100644 --- a/src/components/input/ha-input-multi.ts +++ b/src/components/input/ha-input-multi.ts @@ -78,7 +78,7 @@ class HaInputMulti extends LitElement {
${repeat( this._items, - (item, index) => `${item}-${index}`, + (_item, index) => index, (item, index) => { const indexSuffix = `${this.itemIndex ? ` ${index + 1}` : ""}`; return html` From 75a2331c87d3b8ca78134b3c62a6e438c70b3b46 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Mon, 11 May 2026 14:06:52 +0300 Subject: [PATCH 2/8] Add HTTP server settings to the network panel --- src/data/http.ts | 29 ++ .../config/network/ha-config-http-form.ts | 261 ++++++++++++++++++ .../network/ha-config-section-network.ts | 3 + src/translations/en.json | 38 +++ 4 files changed, 331 insertions(+) create mode 100644 src/data/http.ts create mode 100644 src/panels/config/network/ha-config-http-form.ts diff --git a/src/data/http.ts b/src/data/http.ts new file mode 100644 index 000000000000..390f099c4d2f --- /dev/null +++ b/src/data/http.ts @@ -0,0 +1,29 @@ +import type { HomeAssistant } from "../types"; + +export interface HttpConfig { + server_host?: string[]; + server_port?: number; + ssl_certificate?: string; + ssl_peer_certificate?: string; + ssl_key?: string; + cors_allowed_origins?: string[]; + use_x_forwarded_for?: boolean; + trusted_proxies?: string[]; + use_x_frame_options?: boolean; + ip_ban_enabled?: boolean; + login_attempts_threshold?: number; + ssl_profile?: "modern" | "intermediate"; +} + +interface HttpConfigResponse { + config: HttpConfig; +} + +export const fetchHttpConfig = (hass: HomeAssistant) => + hass.callWS({ type: "http/config/get" }); + +export const saveHttpConfig = (hass: HomeAssistant, config: HttpConfig) => + hass.callWS({ + type: "http/config/update", + config, + }); diff --git a/src/panels/config/network/ha-config-http-form.ts b/src/panels/config/network/ha-config-http-form.ts new file mode 100644 index 000000000000..7d73ff1b814c --- /dev/null +++ b/src/panels/config/network/ha-config-http-form.ts @@ -0,0 +1,261 @@ +import type { CSSResultGroup, PropertyValues } from "lit"; +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import type { LocalizeFunc } from "../../../common/translations/localize"; +import "../../../components/ha-alert"; +import "../../../components/ha-button"; +import "../../../components/ha-card"; +import "../../../components/ha-form/ha-form"; +import type { SchemaUnion } from "../../../components/ha-form/types"; +import { fetchHttpConfig, saveHttpConfig } from "../../../data/http"; +import type { HttpConfig } from "../../../data/http"; +import { showRestartDialog } from "../../../dialogs/restart/show-dialog-restart"; +import { haStyle } from "../../../resources/styles"; +import type { HomeAssistant } from "../../../types"; + +const SCHEMA = memoizeOne( + (localize: LocalizeFunc) => + [ + { + name: "server_port", + required: true, + selector: { number: { min: 1, max: 65535, mode: "box" } }, + }, + { + name: "server_host", + selector: { text: { multiple: true } }, + }, + { + name: "ssl_certificate", + selector: { text: {} }, + }, + { + name: "ssl_key", + selector: { text: {} }, + }, + { + name: "ssl_peer_certificate", + selector: { text: {} }, + }, + { + name: "ssl_profile", + selector: { + select: { + options: [ + { + value: "modern", + label: localize( + "ui.panel.config.network.http.ssl_profile_modern" + ), + }, + { + value: "intermediate", + label: localize( + "ui.panel.config.network.http.ssl_profile_intermediate" + ), + }, + ], + }, + }, + }, + { + name: "cors_allowed_origins", + selector: { text: { multiple: true } }, + }, + { + name: "use_x_forwarded_for", + selector: { boolean: {} }, + }, + { + name: "trusted_proxies", + selector: { text: { multiple: true } }, + }, + { + name: "use_x_frame_options", + selector: { boolean: {} }, + }, + { + name: "ip_ban_enabled", + selector: { boolean: {} }, + }, + { + name: "login_attempts_threshold", + required: true, + selector: { number: { min: -1, max: 1000, mode: "box" } }, + }, + ] as const +); + +@customElement("ha-config-http-form") +class HaConfigHttpForm extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _config?: HttpConfig; + + @state() private _error?: string; + + @state() private _fieldErrors: Record = {}; + + @state() private _saving = false; + + @state() private _saved = false; + + protected override firstUpdated(changedProps: PropertyValues) { + super.firstUpdated(changedProps); + this._fetchConfig(); + } + + protected render() { + if (!this._config) { + return nothing; + } + + const schema = SCHEMA(this.hass.localize); + + return html` + +
+

+ ${this.hass.localize("ui.panel.config.network.http.description")} +

+ + ${this._error + ? html`${this._error}` + : nothing} + ${this._saved + ? html` + + ${this.hass.localize( + "ui.panel.config.network.http.restart_required_description" + )} + + ${this.hass.localize( + "ui.panel.config.network.http.restart" + )} + + + ` + : nothing} + + +
+
+ + ${this.hass.localize("ui.panel.config.network.http.save")} + +
+
+ `; + } + + private async _fetchConfig(): Promise { + try { + const result = await fetchHttpConfig(this.hass); + this._config = result.config; + } catch (err: any) { + this._error = err.message; + } + } + + private _computeLabel = ( + schema: SchemaUnion> + ): string => + this.hass.localize(`ui.panel.config.network.http.fields.${schema.name}`); + + private _computeHelper = ( + schema: SchemaUnion> + ): string => + this.hass.localize(`ui.panel.config.network.http.helpers.${schema.name}`) || + ""; + + private _valueChanged(ev: CustomEvent): void { + this._config = ev.detail.value; + this._saved = false; + this._error = undefined; + this._fieldErrors = {}; + } + + private async _save(): Promise { + if (!this._config) { + return; + } + this._saving = true; + this._error = undefined; + this._fieldErrors = {}; + try { + const result = await saveHttpConfig(this.hass, this._config); + this._config = result.config; + this._saved = true; + } catch (err: any) { + // voluptuous formats errors as " @ data['']". + // If a field is identified, mark it inline; otherwise show a card-level + // alert. + const fieldMatch = err.message?.match(/\bdata\['([^']+)'\]/); + if (fieldMatch) { + this._fieldErrors = { [fieldMatch[1]]: err.message }; + } else { + this._error = err.message; + } + } finally { + this._saving = false; + } + await this.updateComplete; + const haForm = this.renderRoot.querySelector("ha-form"); + await haForm?.updateComplete; + // Inline field errors render inside ha-form's shadow root, so fall back to + // it when no top-level alert is present. + const target = + this.renderRoot.querySelector("ha-alert") ?? + haForm?.shadowRoot?.querySelector("ha-alert"); + target?.scrollIntoView({ behavior: "smooth", block: "center" }); + } + + private _restart(): void { + showRestartDialog(this); + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + css` + .description { + margin-top: 0; + color: var(--secondary-text-color); + } + ha-alert { + display: block; + margin-bottom: var(--ha-space-4); + } + .card-actions { + display: flex; + gap: var(--ha-space-2); + justify-content: flex-end; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-config-http-form": HaConfigHttpForm; + } +} diff --git a/src/panels/config/network/ha-config-section-network.ts b/src/panels/config/network/ha-config-section-network.ts index 33a99a588799..b58b8b83ca02 100644 --- a/src/panels/config/network/ha-config-section-network.ts +++ b/src/panels/config/network/ha-config-section-network.ts @@ -8,6 +8,7 @@ import "../../../components/ha-md-list"; import "../../../components/ha-md-list-item"; import "../../../components/ha-icon-next"; import type { HomeAssistant, Route } from "../../../types"; +import "./ha-config-http-form"; import "./ha-config-network"; import "./ha-config-url-form"; import "./supervisor-hostname"; @@ -40,6 +41,7 @@ class HaConfigSectionNetwork extends LitElement { ` : ""} + ${NETWORK_BROWSERS.some((component) => isComponentLoaded(this.hass.config, component) @@ -88,6 +90,7 @@ class HaConfigSectionNetwork extends LitElement { supervisor-hostname, supervisor-network, ha-config-url-form, + ha-config-http-form, ha-config-network, .discovery-card { display: block; diff --git a/src/translations/en.json b/src/translations/en.json index 1ac67071e3b6..cfad666a6aaf 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -8251,6 +8251,44 @@ "zeroconf": "Zeroconf browser", "zeroconf_info": "Show services discovered using mDNS. Does not include services unknown to Home Assistant." }, + "http": { + "caption": "HTTP server", + "description": "Configure how Home Assistant serves its web interface. Changes take effect after a restart.", + "save": "Save", + "restart": "Restart", + "restart_required_title": "Restart required", + "restart_required_description": "Restart Home Assistant to apply the new HTTP settings.", + "ssl_profile_modern": "Modern", + "ssl_profile_intermediate": "Intermediate", + "fields": { + "server_port": "Server port", + "server_host": "Listen addresses", + "ssl_certificate": "SSL certificate path", + "ssl_key": "SSL key path", + "ssl_peer_certificate": "SSL peer certificate path", + "ssl_profile": "SSL profile", + "cors_allowed_origins": "CORS allowed origins", + "use_x_forwarded_for": "Trust X-Forwarded-For", + "trusted_proxies": "Trusted proxies", + "use_x_frame_options": "Send X-Frame-Options", + "ip_ban_enabled": "Enable IP banning", + "login_attempts_threshold": "Login attempts before ban" + }, + "helpers": { + "server_port": "The port Home Assistant listens on. Default is 8123.", + "server_host": "IP addresses to bind to. Leave empty to listen on all interfaces.", + "ssl_certificate": "Absolute path to your TLS certificate (for example, /ssl/fullchain.pem).", + "ssl_key": "Absolute path to your TLS private key (for example, /ssl/privkey.pem).", + "ssl_peer_certificate": "Absolute path to a client certificate Home Assistant should require for secure connections.", + "ssl_profile": "Mozilla SSL profile. Use 'Intermediate' only if integrations have SSL handshake issues.", + "cors_allowed_origins": "Origins that may make cross-origin requests. Include the scheme, for example https://example.com.", + "use_x_forwarded_for": "Trust the X-Forwarded-For header behind a reverse proxy. Requires the trusted proxies list below.", + "trusted_proxies": "Reverse-proxy IP addresses or CIDR networks allowed to set X-Forwarded-For. Use a network address, not a host.", + "use_x_frame_options": "Send the X-Frame-Options header to help prevent clickjacking.", + "ip_ban_enabled": "Automatically ban IP addresses after repeated failed logins.", + "login_attempts_threshold": "Failed login attempts before an IP is banned. Set to -1 to disable automatic bans." + } + }, "network_adapter": "Network adapter", "network_adapter_info": "Configure which network adapters integrations will use. A restart is required for these settings to apply.", "ip_information": "IP information", From 18268c67dcef69b9f16b41b519b9f76c66235094 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Mon, 11 May 2026 14:50:20 +0300 Subject: [PATCH 3/8] Surface fetch errors and validate before saving in HTTP config form --- .../config/network/ha-config-http-form.ts | 45 ++++++++++++------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/src/panels/config/network/ha-config-http-form.ts b/src/panels/config/network/ha-config-http-form.ts index 7d73ff1b814c..bd840e069af3 100644 --- a/src/panels/config/network/ha-config-http-form.ts +++ b/src/panels/config/network/ha-config-http-form.ts @@ -107,7 +107,7 @@ class HaConfigHttpForm extends LitElement { } protected render() { - if (!this._config) { + if (!this._config && !this._error) { return nothing; } @@ -145,23 +145,30 @@ class HaConfigHttpForm extends LitElement { ` : nothing} - - -
-
- - ${this.hass.localize("ui.panel.config.network.http.save")} - + ${this._config + ? html` + + ` + : nothing}
+ ${this._config + ? html` +
+ + ${this.hass.localize("ui.panel.config.network.http.save")} + +
+ ` + : nothing} `; } @@ -197,6 +204,10 @@ class HaConfigHttpForm extends LitElement { if (!this._config) { return; } + const form = this.renderRoot.querySelector("ha-form"); + if (form && !form.reportValidity()) { + return; + } this._saving = true; this._error = undefined; this._fieldErrors = {}; From 94bc9d8567b2cc06da2e4eb80769090725da0cbf Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Tue, 12 May 2026 08:39:01 +0300 Subject: [PATCH 4/8] Update src/panels/config/network/ha-config-http-form.ts Co-authored-by: Paul Bottein --- src/panels/config/network/ha-config-http-form.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/panels/config/network/ha-config-http-form.ts b/src/panels/config/network/ha-config-http-form.ts index bd840e069af3..4c40d64e745e 100644 --- a/src/panels/config/network/ha-config-http-form.ts +++ b/src/panels/config/network/ha-config-http-form.ts @@ -129,7 +129,7 @@ class HaConfigHttpForm extends LitElement { ${this._saved ? html` Date: Thu, 14 May 2026 16:47:58 +0300 Subject: [PATCH 5/8] Group HTTP form fields into collapsible sections --- .../config/network/ha-config-http-form.ts | 152 ++++++++++++------ src/translations/en.json | 6 + 2 files changed, 105 insertions(+), 53 deletions(-) diff --git a/src/panels/config/network/ha-config-http-form.ts b/src/panels/config/network/ha-config-http-form.ts index 4c40d64e745e..edd7a9843143 100644 --- a/src/panels/config/network/ha-config-http-form.ts +++ b/src/panels/config/network/ha-config-http-form.ts @@ -27,62 +27,94 @@ const SCHEMA = memoizeOne( selector: { text: { multiple: true } }, }, { - name: "ssl_certificate", - selector: { text: {} }, - }, - { - name: "ssl_key", - selector: { text: {} }, - }, - { - name: "ssl_peer_certificate", - selector: { text: {} }, - }, - { - name: "ssl_profile", - selector: { - select: { - options: [ - { - value: "modern", - label: localize( - "ui.panel.config.network.http.ssl_profile_modern" - ), - }, - { - value: "intermediate", - label: localize( - "ui.panel.config.network.http.ssl_profile_intermediate" - ), + name: "ssl", + type: "expandable", + flatten: true, + title: localize("ui.panel.config.network.http.sections.ssl"), + schema: [ + { + name: "ssl_certificate", + selector: { text: {} }, + }, + { + name: "ssl_key", + selector: { text: {} }, + }, + { + name: "ssl_peer_certificate", + selector: { text: {} }, + }, + { + name: "ssl_profile", + selector: { + select: { + options: [ + { + value: "modern", + label: localize( + "ui.panel.config.network.http.ssl_profile_modern" + ), + }, + { + value: "intermediate", + label: localize( + "ui.panel.config.network.http.ssl_profile_intermediate" + ), + }, + ], }, - ], + }, }, - }, + ], }, { - name: "cors_allowed_origins", - selector: { text: { multiple: true } }, - }, - { - name: "use_x_forwarded_for", - selector: { boolean: {} }, - }, - { - name: "trusted_proxies", - selector: { text: { multiple: true } }, - }, - { - name: "use_x_frame_options", - selector: { boolean: {} }, + name: "reverse_proxy", + type: "expandable", + flatten: true, + title: localize("ui.panel.config.network.http.sections.reverse_proxy"), + schema: [ + { + name: "use_x_forwarded_for", + selector: { boolean: {} }, + }, + { + name: "trusted_proxies", + selector: { text: { multiple: true } }, + }, + ], }, { - name: "ip_ban_enabled", - selector: { boolean: {} }, + name: "ip_banning", + type: "expandable", + flatten: true, + title: localize("ui.panel.config.network.http.sections.ip_banning"), + schema: [ + { + name: "ip_ban_enabled", + selector: { boolean: {} }, + }, + { + name: "login_attempts_threshold", + required: true, + selector: { number: { min: -1, max: 1000, mode: "box" } }, + }, + ], }, { - name: "login_attempts_threshold", - required: true, - selector: { number: { min: -1, max: 1000, mode: "box" } }, + name: "advanced", + type: "expandable", + flatten: true, + title: localize("ui.panel.config.network.http.sections.advanced"), + schema: [ + { + name: "cors_allowed_origins", + selector: { text: { multiple: true } }, + }, + { + name: "use_x_frame_options", + selector: { boolean: {} }, + }, + ], }, ] as const ); @@ -184,14 +216,28 @@ class HaConfigHttpForm extends LitElement { private _computeLabel = ( schema: SchemaUnion> - ): string => - this.hass.localize(`ui.panel.config.network.http.fields.${schema.name}`); + ): string => { + if ("type" in schema && schema.type === "expandable") { + // Expandable sections render their own title; never label them. + return ""; + } + return this.hass.localize( + `ui.panel.config.network.http.fields.${schema.name}` as any + ); + }; private _computeHelper = ( schema: SchemaUnion> - ): string => - this.hass.localize(`ui.panel.config.network.http.helpers.${schema.name}`) || - ""; + ): string => { + if ("type" in schema && schema.type === "expandable") { + return ""; + } + return ( + this.hass.localize( + `ui.panel.config.network.http.helpers.${schema.name}` as any + ) || "" + ); + }; private _valueChanged(ev: CustomEvent): void { this._config = ev.detail.value; diff --git a/src/translations/en.json b/src/translations/en.json index cfad666a6aaf..be9ac1090092 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -8260,6 +8260,12 @@ "restart_required_description": "Restart Home Assistant to apply the new HTTP settings.", "ssl_profile_modern": "Modern", "ssl_profile_intermediate": "Intermediate", + "sections": { + "ssl": "SSL/TLS", + "reverse_proxy": "Reverse proxy", + "ip_banning": "IP banning", + "advanced": "Advanced" + }, "fields": { "server_port": "Server port", "server_host": "Listen addresses", From 770254b9207f9661bda69c72d7cbd320e9d8849b Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Thu, 14 May 2026 16:53:41 +0300 Subject: [PATCH 6/8] Add bottom margin to ha-input-multi add button --- src/components/input/ha-input-multi.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/input/ha-input-multi.ts b/src/components/input/ha-input-multi.ts index 886274a15555..f007b11d4655 100644 --- a/src/components/input/ha-input-multi.ts +++ b/src/components/input/ha-input-multi.ts @@ -126,7 +126,7 @@ class HaInputMulti extends LitElement { )} -
+
Date: Thu, 14 May 2026 16:54:27 +0300 Subject: [PATCH 7/8] Only apply add-button margin when helper text is present --- src/components/input/ha-input-multi.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/input/ha-input-multi.ts b/src/components/input/ha-input-multi.ts index f007b11d4655..b1f25c05f7c7 100644 --- a/src/components/input/ha-input-multi.ts +++ b/src/components/input/ha-input-multi.ts @@ -218,7 +218,7 @@ class HaInputMulti extends LitElement { margin-bottom: 8px; --ha-input-padding-bottom: 0; } - .add-row { + .add-row:has(+ ha-input-helper-text) { margin-bottom: var(--ha-space-1); } ha-icon-button { From 490893616bd10d9b0cebf98918dfa16c75c60b9b Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Tue, 19 May 2026 14:57:18 +0300 Subject: [PATCH 8/8] Update HTTP config form to new WebSocket API --- src/data/http.ts | 10 +++------- src/panels/config/network/ha-config-http-form.ts | 6 ++---- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/src/data/http.ts b/src/data/http.ts index 390f099c4d2f..e63cfed9548b 100644 --- a/src/data/http.ts +++ b/src/data/http.ts @@ -15,15 +15,11 @@ export interface HttpConfig { ssl_profile?: "modern" | "intermediate"; } -interface HttpConfigResponse { - config: HttpConfig; -} - export const fetchHttpConfig = (hass: HomeAssistant) => - hass.callWS({ type: "http/config/get" }); + hass.callWS({ type: "http/config" }); export const saveHttpConfig = (hass: HomeAssistant, config: HttpConfig) => - hass.callWS({ - type: "http/config/update", + hass.callWS({ + type: "http/config/configure", config, }); diff --git a/src/panels/config/network/ha-config-http-form.ts b/src/panels/config/network/ha-config-http-form.ts index edd7a9843143..e7d32e47611d 100644 --- a/src/panels/config/network/ha-config-http-form.ts +++ b/src/panels/config/network/ha-config-http-form.ts @@ -207,8 +207,7 @@ class HaConfigHttpForm extends LitElement { private async _fetchConfig(): Promise { try { - const result = await fetchHttpConfig(this.hass); - this._config = result.config; + this._config = await fetchHttpConfig(this.hass); } catch (err: any) { this._error = err.message; } @@ -258,8 +257,7 @@ class HaConfigHttpForm extends LitElement { this._error = undefined; this._fieldErrors = {}; try { - const result = await saveHttpConfig(this.hass, this._config); - this._config = result.config; + await saveHttpConfig(this.hass, this._config); this._saved = true; } catch (err: any) { // voluptuous formats errors as " @ data['']".