Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/components/input/ha-input-multi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ class HaInputMulti extends LitElement {
<div class="items">
${repeat(
this._items,
(item, index) => `${item}-${index}`,
(_item, index) => index,
(item, index) => {
const indexSuffix = `${this.itemIndex ? ` ${index + 1}` : ""}`;
return html`
Expand Down Expand Up @@ -126,7 +126,7 @@ class HaInputMulti extends LitElement {
)}
</div>
</ha-sortable>
<div class="layout horizontal">
<div class="layout horizontal add-row">
<ha-button
size="small"
appearance="filled"
Expand Down Expand Up @@ -218,6 +218,9 @@ class HaInputMulti extends LitElement {
margin-bottom: 8px;
--ha-input-padding-bottom: 0;
}
.add-row:has(+ ha-input-helper-text) {
margin-bottom: var(--ha-space-1);
}
ha-icon-button {
display: block;
}
Expand Down
25 changes: 25 additions & 0 deletions src/data/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
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";
}

export const fetchHttpConfig = (hass: HomeAssistant) =>
hass.callWS<HttpConfig>({ type: "http/config" });

export const saveHttpConfig = (hass: HomeAssistant, config: HttpConfig) =>
hass.callWS<undefined>({
type: "http/config/configure",
config,
});
316 changes: 316 additions & 0 deletions src/panels/config/network/ha-config-http-form.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {};

@state() private _saving = false;

@state() private _saved = false;

protected override firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this._fetchConfig();
}

protected render() {
if (!this._config && !this._error) {
return nothing;
}
Comment thread
MindFreeze marked this conversation as resolved.

const schema = SCHEMA(this.hass.localize);

return html`
<ha-card
outlined
.header=${this.hass.localize("ui.panel.config.network.http.caption")}
>
<div class="card-content">
<p class="description">
${this.hass.localize("ui.panel.config.network.http.description")}
</p>

${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
${this._saved
? html`
<ha-alert
alert-type="info"
.title=${this.hass.localize(
"ui.panel.config.network.http.restart_required_title"
)}
>
${this.hass.localize(
"ui.panel.config.network.http.restart_required_description"
)}
<ha-button slot="action" @click=${this._restart}>
${this.hass.localize(
"ui.panel.config.network.http.restart"
)}
</ha-button>
</ha-alert>
`
: nothing}
${this._config
? html`
<ha-form
.hass=${this.hass}
.data=${this._config}
.schema=${schema}
.error=${this._fieldErrors}
.disabled=${this._saving}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
@value-changed=${this._valueChanged}
></ha-form>
`
: nothing}
</div>
${this._config
? html`
<div class="card-actions">
<ha-button @click=${this._save} .disabled=${this._saving}>
${this.hass.localize("ui.panel.config.network.http.save")}
</ha-button>
</div>
`
: nothing}
</ha-card>
`;
}

private async _fetchConfig(): Promise<void> {
try {
this._config = await fetchHttpConfig(this.hass);
} catch (err: any) {
this._error = err.message;
}
}

private _computeLabel = (
schema: SchemaUnion<ReturnType<typeof SCHEMA>>
): 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<ReturnType<typeof SCHEMA>>
): 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<void> {
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 "<message> @ data['<field>']".
// 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<HTMLElement>("ha-alert") ??
haForm?.shadowRoot?.querySelector<HTMLElement>("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;
}
}
3 changes: 3 additions & 0 deletions src/panels/config/network/ha-config-section-network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -40,6 +41,7 @@ class HaConfigSectionNetwork extends LitElement {
<supervisor-network .hass=${this.hass}></supervisor-network>`
: ""}
<ha-config-url-form .hass=${this.hass}></ha-config-url-form>
<ha-config-http-form .hass=${this.hass}></ha-config-http-form>
<ha-config-network .hass=${this.hass}></ha-config-network>
${NETWORK_BROWSERS.some((component) =>
isComponentLoaded(this.hass.config, component)
Expand Down Expand Up @@ -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;
Expand Down
Loading