diff --git a/src/components/input/ha-input-multi.ts b/src/components/input/ha-input-multi.ts index cd1aca3850b1..b1f25c05f7c7 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` @@ -126,7 +126,7 @@ class HaInputMulti extends LitElement { )}
-
+
+ hass.callWS({ type: "http/config" }); + +export const saveHttpConfig = (hass: HomeAssistant, config: HttpConfig) => + 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 new file mode 100644 index 000000000000..e7d32e47611d --- /dev/null +++ b/src/panels/config/network/ha-config-http-form.ts @@ -0,0 +1,316 @@ +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", + 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: "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_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: "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 +); + +@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 && !this._error) { + 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._config + ? html` + + ` + : nothing} +
+ ${this._config + ? html` +
+ + ${this.hass.localize("ui.panel.config.network.http.save")} + +
+ ` + : nothing} +
+ `; + } + + private async _fetchConfig(): Promise { + try { + this._config = await fetchHttpConfig(this.hass); + } catch (err: any) { + this._error = err.message; + } + } + + private _computeLabel = ( + schema: SchemaUnion> + ): 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 => { + 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; + this._saved = false; + this._error = undefined; + this._fieldErrors = {}; + } + + private async _save(): Promise { + if (!this._config) { + return; + } + const form = this.renderRoot.querySelector("ha-form"); + if (form && !form.reportValidity()) { + return; + } + this._saving = true; + this._error = undefined; + this._fieldErrors = {}; + try { + await saveHttpConfig(this.hass, this._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..be9ac1090092 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -8251,6 +8251,50 @@ "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", + "sections": { + "ssl": "SSL/TLS", + "reverse_proxy": "Reverse proxy", + "ip_banning": "IP banning", + "advanced": "Advanced" + }, + "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",