Compare commits

..

15 Commits

Author SHA1 Message Date
Bram Kragten 1454f4d081 Bumped version to 20260729.3 2026-07-31 16:44:13 +02:00
Paul Bottein 1a47c326b2 Fix tile name truncated too early (#53416) 2026-07-31 16:43:50 +02:00
Aidan Timson d8e827e08b Fix profile theme link colour (#53415) 2026-07-31 16:43:49 +02:00
John G. 301c907fa9 Fix typo in Google Home Matter pairing instructions (#53413)
Corrected a typo in the instructions for pairing a Matter device from Google Home with a pairing code.
2026-07-31 16:43:48 +02:00
karwosts 1ef3bc94d0 Add a few missing logbook csv fields (#53412) 2026-07-31 16:43:47 +02:00
Bram Kragten ba0367be2f use defaults provided by core for ports in strings, add url to new lo… (#53407)
* use defaults provided by core for ports in strings, add url to new location after restart

* Apply suggestions from code review

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>

---------

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2026-07-31 16:43:46 +02:00
Bram Kragten c186a31056 Prevent http confirm dialog from getting closed (#53406) 2026-07-31 16:43:45 +02:00
Paul Bottein bf8c92c95e Use backend slugify for the entity ID format preview (#53394)
* Use backend slugify for the entity ID format preview

* Update src/panels/config/core/ha-config-entity-id-format.ts

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

* Use top-level slugify websocket command

* Fix formatting

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-07-31 16:43:44 +02:00
Bram Kragten 9743117abf Bumped version to 20260729.2 2026-07-30 23:27:41 +02:00
Petar Petrov 5e012973b2 Reject non-http URLs from integrations before using them as links (#53379) 2026-07-30 23:27:10 +02:00
Petar Petrov ea659f1b33 Show which energy power statistic is missing (#53404) 2026-07-30 23:25:16 +02:00
Marcel van der Veldt 539803cb5b Search a media player's own library from the media browser (#53402)
* Search a media player's own library from the media browser

The media browser search only ever asked the media sources, so searching
inside a media player's own library (Music Assistant, Sonos, Squeezebox,
Jellyfin) failed. Ask the entity instead when the current item belongs to
it, the same way browsing already does.

* Update src/data/media_source.ts

* Remove blank line in media_source.ts

Removed unnecessary blank line at the top of media_source.ts

---------

Co-authored-by: Bram Kragten <mail@bramkragten.nl>
Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2026-07-30 23:25:15 +02:00
karwosts 944d3332d1 Fix schedule editor dirty tracking (#53401) 2026-07-30 23:25:14 +02:00
Petar Petrov 4a267d160f Fix numeric input feature editor showing the wrong default style (#53398) 2026-07-30 23:25:13 +02:00
Aidan Timson 7edb9f8164 Launch screen tweaks (#53380)
* Polish the launch screen

- Use the system sans-serif font so the launch screen no longer blocks on
  loading Roboto
- Reduce the gap between the logo and the loading text
- Dim the loading text to 66% opacity
- Match the OHF logo variant to the applied theme instead of the system
  color scheme
- Make the launch screen text and buttons unselectable

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Drop unit test

* Use classMap

* Drop view transition for css transition to avoid flash

---------

Co-authored-by: marcinbauer85 <marcinbauer85@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:25:12 +02:00
57 changed files with 1065 additions and 281 deletions
+4 -10
View File
@@ -34,16 +34,8 @@
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
<meta name="theme-color" content="#03a9f4" />
<link rel="preload" href="/static/fonts/roboto/Roboto-Regular.woff2" as="font" type="font/woff2" crossorigin>
<%= renderTemplate("_social_meta.html.template") %>
<style>
@font-face {
font-family: "Roboto Launch Screen";
font-display: block;
src: url("/static/fonts/roboto/Roboto-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
html {
background-color: var(--primary-background-color, #fafafa);
color: var(--primary-text-color, #212121);
@@ -64,12 +56,14 @@
padding: 0;
}
#ha-launch-screen {
font-family: "Roboto Launch Screen", sans-serif;
font-family: ui-sans-serif, system-ui, sans-serif;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
user-select: none;
-webkit-user-select: none;
transition: opacity var(--ha-animation-duration-normal, 250ms) ease-out;
}
#ha-launch-screen.removing {
@@ -104,7 +98,7 @@
}
#ha-launch-screen .ha-launch-screen-spacer-bottom {
flex: 1;
padding-top: 48px;
padding-top: 16px;
}
.ohf-logo {
margin: max(var(--safe-area-inset-bottom, 0px), 48px) 0;
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260729.1"
version = "20260729.3"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
@@ -1,15 +0,0 @@
import type { EntityIdPart } from "../../data/entity_id_format";
import { slugify } from "../string/slugify";
export const computeEntityIdFormatExample = (
format: EntityIdPart[],
examples: Record<EntityIdPart, string>
): string => {
const parts = format
.map((item) => examples[item])
.filter(Boolean)
.map((part) => slugify(part, "_"))
.filter(Boolean);
return parts.join("_") || "unknown";
};
+52
View File
@@ -0,0 +1,52 @@
import { sanitizeNavigationPath } from "./sanitize-navigation-path";
const HOME_ASSISTANT_SCHEME = "homeassistant://";
/**
* Returns the URL if it is safe to use as a link target, `undefined` otherwise.
* Only absolute `http:` and `https:` URLs pass, the same check
* `ha-attribute-value` already applies to attribute links.
*
* Use for every URL that reaches the frontend as data — from an integration
* manifest, an add-on, a config flow or an entity attribute — so that a
* `javascript:` URI can never become a clickable link.
*/
export const sanitizeHttpUrl = (
url: string | null | undefined
): string | undefined => {
if (!url) {
return undefined;
}
try {
const { protocol } = new URL(url);
return protocol === "http:" || protocol === "https:" ? url : undefined;
} catch (_err) {
return undefined;
}
};
/** Whether the URL is a `homeassistant://` deep link into the frontend. */
export const isHomeAssistantUrl = (url: string | null | undefined): boolean =>
!!url?.startsWith(HOME_ASSISTANT_SCHEME);
/**
* Turns a `homeassistant://` deep link into an in-app path, or returns
* `undefined` when it does not point inside the frontend. Rewriting the scheme
* on its own is not enough: `homeassistant:///example.com` would become
* `//example.com`, which resolves to another origin.
*/
export const homeAssistantUrlToPath = (
url: string | null | undefined
): string | undefined =>
isHomeAssistantUrl(url)
? sanitizeNavigationPath(`/${url!.slice(HOME_ASSISTANT_SCHEME.length)}`)
: undefined;
/**
* Sanitizes a URL that may be either an external link or a `homeassistant://`
* deep link, returning something safe to bind to an `href`.
*/
export const sanitizeLinkUrl = (
url: string | null | undefined
): string | undefined =>
isHomeAssistantUrl(url) ? homeAssistantUrlToPath(url) : sanitizeHttpUrl(url);
@@ -85,6 +85,10 @@ export class HaStatisticPicker extends LitElement {
@property() public helper?: string;
@property({ attribute: "error-message" }) public errorMessage?: string;
@property({ type: Boolean }) public invalid = false;
@property() public placeholder?: string;
@property({ attribute: "statistic-types" })
@@ -526,6 +530,9 @@ export class HaStatisticPicker extends LitElement {
.autofocus=${this.autofocus}
.allowCustomValue=${this.allowCustomEntity}
.disabled=${this.disabled}
.required=${this.required}
.invalid=${this.invalid}
.errorMessage=${this.errorMessage}
.label=${this.label}
use-top-label
.placeholder=${placeholder}
+17 -3
View File
@@ -304,14 +304,21 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
private _renderHelper() {
const showError = this.invalid && this.errorMessage;
const showHelper = !showError && this.helper;
if (!showError && !showHelper) {
if (!showError && !this.helper) {
return nothing;
}
return html`<ha-input-helper-text .disabled=${this.disabled}>
${showError ? this.errorMessage : this.helper}
${
showError
? html`<span class="error">${this.errorMessage}</span> ${
this.helper
? html`<span class="helper">${this.helper}</span>`
: nothing
}`
: this.helper
}
</ha-input-helper-text>`;
}
@@ -448,6 +455,13 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
:host([invalid]) ha-input-helper-text {
color: var(--mdc-theme-error, var(--error-color, #b00020));
}
ha-input-helper-text .error,
ha-input-helper-text .helper {
display: block;
}
ha-input-helper-text .helper {
color: var(--secondary-text-color);
}
wa-popover {
--wa-space-l: 0;
+7 -9
View File
@@ -488,6 +488,11 @@ export class HaServiceControl extends LitElement {
)) ||
serviceData?.description;
const documentationLink =
this._manifest?.is_built_in && this._value?.action
? documentationUrl(this.hass, `/actions/${this._value.action}`)
: this._manifest?.documentation;
const targetSelector =
serviceData && "target" in serviceData
? this._targetSelector(
@@ -514,16 +519,9 @@ export class HaServiceControl extends LitElement {
<div class="description">
${description ? html`<p>${description}</p>` : ""}
${
this._manifest
documentationLink
? html` <a
href=${
this._manifest.is_built_in && this._value?.action
? documentationUrl(
this.hass,
`/actions/${this._value.action}`
)
: this._manifest.documentation
}
href=${documentationLink}
title=${this.hass.localize(
"ui.components.service-control.integration_doc"
)}
+3
View File
@@ -228,6 +228,9 @@ export class HaThemeSettings extends LitElement {
}
static styles = css`
a {
color: var(--primary-color);
}
.inputs {
display: flex;
flex-wrap: wrap;
@@ -34,10 +34,12 @@ import {
browseMediaPlayer,
BROWSER_PLAYER,
MediaClassBrowserSettings,
searchMediaPlayer,
} from "../../data/media-player";
import {
browseLocalMediaPlayer,
isManualMediaSourceContentId,
isMediaSourceContentId,
MANUAL_MEDIA_SOURCE_PREFIX,
searchMedia,
} from "../../data/media_source";
@@ -869,13 +871,31 @@ export class HaMediaPlayerBrowse extends LitElement {
const mediaFilterClasses = this._mediaClassFilter.length
? this._mediaClassFilter
: undefined;
// A player's tree can embed media sources, which resolve their own searches;
// everything else in it uses integration specific ids only the entity knows.
const searchEntityId =
this.entityId &&
this.entityId !== BROWSER_PLAYER &&
!isMediaSourceContentId(navigateId.media_content_id ?? "")
? this.entityId
: undefined;
try {
const { result } = await searchMedia(
this.hass,
navigateId.media_content_id,
searchQuery,
mediaFilterClasses
);
const { result } = searchEntityId
? await searchMediaPlayer(
this.hass,
searchEntityId,
searchQuery,
navigateId.media_content_id,
navigateId.media_content_type,
mediaFilterClasses
)
: await searchMedia(
this.hass,
navigateId.media_content_id,
searchQuery,
mediaFilterClasses
);
// Ignore the response if a newer search started or we navigated away
if (requestId !== this._searchRequestId) {
return;
+6 -13
View File
@@ -15,13 +15,14 @@ import { customElement, property } from "lit/decorators";
*
* @property {boolean} secondaryLoading - Whether the secondary text is loading. Shows a skeleton placeholder.
*
* @csspart primary - The primary text. Style it to opt into another truncation, such as a multi line clamp.
*
* @cssprop --ha-tile-info-gap - The vertical gap between the primary and secondary text. defaults to `0`.
* @cssprop --ha-tile-info-min-height - Minimum height of the primary/secondary block. Set this to reserve space for a missing secondary so it doesn't shift surrounding content. defaults to `auto`.
* @cssprop --ha-tile-info-primary-min-height - Minimum height of the primary text block, independent of `--ha-tile-info-primary-line-clamp`. Lets tiles that never wrap still match the height of tiles that do. defaults to `auto` (sizes to the actual rendered lines).
* @cssprop --ha-tile-info-primary-min-height - Minimum height of the primary text block, independent of the number of rendered lines. Lets tiles that never wrap still match the height of tiles that do. defaults to `auto` (sizes to the actual rendered lines).
* @cssprop --ha-tile-info-primary-font-size - The font size of the primary text. defaults to `var(--ha-font-size-m)`.
* @cssprop --ha-tile-info-primary-font-weight - The font weight of the primary text. defaults to `var(--ha-font-weight-medium)`.
* @cssprop --ha-tile-info-primary-line-height - The line height of the primary text. defaults to `var(--ha-line-height-normal)`.
* @cssprop --ha-tile-info-primary-line-clamp - The maximum number of lines for the primary text before truncating with an ellipsis. defaults to `1`.
* @cssprop --ha-tile-info-primary-letter-spacing - The letter spacing of the primary text. defaults to `0.1px`.
* @cssprop --ha-tile-info-primary-color - The color of the primary text. defaults to `var(--primary-text-color)`.
* @cssprop --ha-tile-info-secondary-font-size - The font size of the secondary text. defaults to `var(--ha-font-size-s)`.
@@ -43,7 +44,7 @@ export class HaTileInfo extends LitElement {
return html`
<div class="info">
<slot name="primary" class="primary">
<span>${this.primary}</span>
<span part="primary">${this.primary}</span>
</slot>
${
this.secondaryLoading
@@ -77,7 +78,6 @@ export class HaTileInfo extends LitElement {
--ha-tile-info-primary-line-height,
var(--ha-line-height-normal)
);
--tile-info-primary-line-clamp: var(--ha-tile-info-primary-line-clamp, 1);
--tile-info-primary-min-height: var(
--ha-tile-info-primary-min-height,
auto
@@ -120,6 +120,8 @@ export class HaTileInfo extends LitElement {
gap: var(--tile-info-gap);
min-height: var(--tile-info-min-height);
}
.primary span,
::slotted([slot="primary"]),
.secondary span,
::slotted([slot="secondary"]) {
text-overflow: ellipsis;
@@ -127,15 +129,6 @@ export class HaTileInfo extends LitElement {
white-space: nowrap;
width: 100%;
}
.primary span,
::slotted([slot="primary"]) {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: var(--tile-info-primary-line-clamp);
overflow: hidden;
overflow-wrap: anywhere;
width: 100%;
}
.primary {
display: flex;
align-items: center;
+27 -5
View File
@@ -1,6 +1,7 @@
import type { Connection } from "home-assistant-js-websocket";
import { createCollection } from "home-assistant-js-websocket";
import type { LocalizeFunc } from "../common/translations/localize";
import { sanitizeHttpUrl } from "../common/url/sanitize-http-url";
import { debounce } from "../common/util/debounce";
import type { HomeAssistant } from "../types";
@@ -28,7 +29,7 @@ export interface IntegrationManifest {
domain: string;
name: string;
config_flow: boolean;
documentation: string;
documentation?: string;
issue_tracker?: string;
dependencies?: string[];
after_dependencies?: string[];
@@ -78,11 +79,27 @@ export enum LogSeverity {
export type IntegrationLogPersistance = "none" | "once" | "permanent";
/**
* A custom integration supplies its own manifest, so its URLs are untrusted
* input. Strip them here, where manifests enter the frontend, so no consumer can
* turn one into a link that runs script.
*/
const sanitizeManifest = <T extends IntegrationManifest | undefined>(
manifest: T
): T =>
manifest
? ({
...manifest,
documentation: sanitizeHttpUrl(manifest.documentation),
issue_tracker: sanitizeHttpUrl(manifest.issue_tracker),
} as T)
: manifest;
export const integrationIssuesUrl = (
domain: string,
manifest: IntegrationManifest
) =>
manifest.issue_tracker ||
sanitizeHttpUrl(manifest.issue_tracker) ||
`https://github.com/home-assistant/core/issues?q=is%3Aissue+is%3Aopen+label%3A%22integration%3A+${domain}%22`;
export const domainToName = (
@@ -101,7 +118,9 @@ export const fetchIntegrationManifests = (
if (integrations) {
params.integrations = integrations;
}
return hass.callWS<IntegrationManifest[]>(params);
return hass
.callWS<IntegrationManifest[]>(params)
.then((manifests) => manifests.map(sanitizeManifest));
};
export const fetchIntegrationManifestsCollection = async (
@@ -113,7 +132,7 @@ export const fetchIntegrationManifestsCollection = async (
});
const manifests: DomainManifestLookup = {};
for (const manifest of fetched) {
manifests[manifest.domain] = manifest;
manifests[manifest.domain] = sanitizeManifest(manifest);
}
setValue(manifests);
// One-time fetch — nothing to unsubscribe from
@@ -125,7 +144,10 @@ export const fetchIntegrationManifestsCollection = async (
export const fetchIntegrationManifest = (
hass: HomeAssistant,
integration: string
) => hass.callWS<IntegrationManifest>({ type: "manifest/get", integration });
) =>
hass
.callWS<IntegrationManifest>({ type: "manifest/get", integration })
.then(sanitizeManifest);
export const fetchIntegrationSetups = (hass: HomeAssistant) =>
hass.callWS<IntegrationSetup[]>({ type: "integration/setup_info" });
+23
View File
@@ -208,6 +208,29 @@ export const browseMediaPlayer = (
media_content_type: mediaContentType,
});
export interface SearchMediaResult {
result: MediaPlayerItem[];
}
export const searchMediaPlayer = (
hass: HomeAssistant,
entityId: string,
searchQuery: string,
mediaContentId?: string,
mediaContentType?: string,
mediaFilterClasses?: string[]
): Promise<SearchMediaResult> =>
hass.callWS<SearchMediaResult>({
type: "media_player/search_media",
entity_id: entityId,
search_query: searchQuery,
// the backend requires these two to be passed together, and JSON
// serialization drops them both when the current item is the root
media_content_id: mediaContentId,
media_content_type: mediaContentType,
media_filter_classes: mediaFilterClasses,
});
export const getCurrentProgress = (stateObj: MediaPlayerEntity): number => {
let progress = stateObj.attributes.media_position!;
+1 -5
View File
@@ -1,5 +1,5 @@
import type { HomeAssistant } from "../types";
import type { MediaPlayerItem } from "./media-player";
import type { MediaPlayerItem, SearchMediaResult } from "./media-player";
export interface ResolvedMediaSource {
url: string;
@@ -24,10 +24,6 @@ export const browseLocalMediaPlayer = (
media_content_id: mediaContentId,
});
export interface SearchMediaResult {
result: MediaPlayerItem[];
}
export const searchMedia = (
hass: HomeAssistant,
mediaContentId: string | undefined,
+10
View File
@@ -0,0 +1,10 @@
import type { HomeAssistantApi } from "../types";
export const fetchSlug = (
api: HomeAssistantApi,
text: string
): Promise<{ slug: string }> =>
api.callWS<{ slug: string }>({
type: "slugify",
text,
});
@@ -7,6 +7,7 @@ import { createRef, ref } from "lit/directives/ref";
import memoizeOne from "memoize-one";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { sanitizeHttpUrl } from "../../common/url/sanitize-http-url";
import "../../components/ha-button";
import "../../components/ha-dialog";
import "../../components/ha-dialog-footer";
@@ -337,6 +338,13 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
this._params.manifest?.is_built_in) ||
!!this._params.manifest?.documentation;
const documentationLink = this._params.manifest?.is_built_in
? documentationUrl(
this.hass,
`/integrations/${this._params.manifest.domain}`
)
: this._params.manifest?.documentation;
const dialogTitle = this._getDialogTitle();
const dialogSubtitle = this._getDialogSubtitle();
@@ -368,19 +376,15 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
: nothing
}
${
showDocumentationLink && !this._loading && this._step
showDocumentationLink &&
documentationLink &&
!this._loading &&
this._step
? html`
<a
slot="headerActionItems"
class="help"
href=${
this._params.manifest!.is_built_in
? documentationUrl(
this.hass,
`/integrations/${this._params.manifest!.domain}`
)
: this._params.manifest!.documentation
}
href=${documentationLink}
target="_blank"
rel="noreferrer noopener"
>
@@ -542,21 +546,29 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
</ha-button>
</ha-dialog-footer>
`;
case "external":
case "external": {
const externalUrl = sanitizeHttpUrl(this._step.url);
return html`
<ha-dialog-footer slot="footer">
<ha-button
slot="primaryAction"
href=${this._step.url}
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.integrations.config_flow.external_step.open_site"
)}
</ha-button>
${
externalUrl
? html`
<ha-button
slot="primaryAction"
href=${externalUrl}
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.integrations.config_flow.external_step.open_site"
)}
</ha-button>
`
: nothing
}
</ha-dialog-footer>
`;
}
case "create_entry": {
const devices = this._devices(
this._params!.flowConfig.showDevices,
@@ -1,6 +1,7 @@
import type { CSSResultGroup, TemplateResult, PropertyValues } from "lit";
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { sanitizeHttpUrl } from "../../common/url/sanitize-http-url";
import type { DataEntryFlowStepExternal } from "../../data/data_entry_flow";
import type { HomeAssistant } from "../../types";
import type { FlowConfig } from "./show-dialog-data-entry-flow";
@@ -24,7 +25,11 @@ class StepFlowExternal extends LitElement {
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
window.open(this.step.url);
// Opened without user interaction, so only ever follow an http(s) URL
const url = sanitizeHttpUrl(this.step.url);
if (url) {
window.open(url);
}
}
static get styles(): CSSResultGroup {
@@ -43,12 +43,19 @@ export class DialogHttpPendingConfig
private _interval?: number;
// This dialog must only be dismissed through its own footer buttons
// (confirm / revert / close). This flag is flipped right before such a
// button closes the dialog, so `closeDialog()` can refuse every other
// close request (navigation, back button, `closeAllDialogs`, …).
private _resolved = false;
public showDialog(params: HttpPendingConfigDialogParams): void {
this._params = params;
this._open = true;
this._busy = undefined;
this._error = undefined;
this._reverted = false;
this._resolved = false;
this._startCountdown();
// The field labels live in the config panel fragment, which is not loaded
// yet when this dialog pops up on startup. Load it so the changed-field
@@ -57,6 +64,12 @@ export class DialogHttpPendingConfig
}
public closeDialog(): boolean {
// Refuse programmatic close requests (navigation, back button,
// `closeAllDialogs`) so a pending HTTP config is never left silently
// unresolved. The dialog only closes once the user picks a footer action.
if (!this._resolved) {
return false;
}
this._open = false;
this._stopCountdown();
return true;
@@ -337,6 +350,9 @@ export class DialogHttpPendingConfig
}
private _notifyResolved(): void {
// Mark the dialog as user-resolved so `closeDialog()` is allowed to close
// it; every footer action calls this before setting `_open = false`.
this._resolved = true;
this._params?.onResolved?.();
// The form on Settings > System > Network may be mounted and showing
// stale state; let it know to refetch.
@@ -9,6 +9,7 @@ import { consumeLocalize } from "../../../common/decorators/consume-context-entr
import { transform } from "../../../common/decorators/transform";
import { supportsFeature } from "../../../common/entity/supports-feature";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { sanitizeHttpUrl } from "../../../common/url/sanitize-http-url";
import "../../../components/buttons/ha-progress-button";
import "../../../components/ha-alert";
import "../../../components/ha-button";
@@ -232,6 +233,7 @@ class MoreInfoUpdate extends LitElement {
}
const createBackupTexts = this._computeCreateBackupTexts();
const releaseUrl = sanitizeHttpUrl(this.stateObj.attributes.release_url);
return html`
<div class="content">
@@ -283,14 +285,10 @@ class MoreInfoUpdate extends LitElement {
</div>
${
this.stateObj.attributes.release_url
releaseUrl
? html`<div class="row">
<div class="key">
<a
href=${this.stateObj.attributes.release_url}
target="_blank"
rel="noreferrer"
>
<a href=${releaseUrl} target="_blank" rel="noreferrer">
${this._localize(
"ui.dialogs.more_info_control.update.release_announcement"
)}
+4 -26
View File
@@ -18,38 +18,15 @@
<meta name="referrer" content="same-origin" />
<meta name="theme-color" content="{{ theme_color }}" />
<meta name="color-scheme" content="dark light" />
<link rel="preload" href="/static/fonts/roboto/Roboto-Regular.woff2" as="font" type="font/woff2" crossorigin>
<%= renderTemplate("_style_base.html.template") %>
<style>
@font-face {
font-family: "Roboto Launch Screen";
font-display: block;
src: url("/static/fonts/roboto/Roboto-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
::view-transition-group(launch-screen) {
animation-duration: var(--ha-animation-duration-normal, 250ms);
animation-timing-function: ease-out;
}
::view-transition-old(launch-screen) {
animation: fade-out var(--ha-animation-duration-normal, 250ms) ease-out;
}
html {
background-color: var(--primary-background-color, #fafafa);
color: var(--primary-text-color, #212121);
height: 100vh;
}
#ha-launch-screen {
font-family: "Roboto Launch Screen", sans-serif;
font-family: ui-sans-serif, system-ui, sans-serif;
position: fixed;
top: 0;
left: 0;
@@ -61,7 +38,8 @@
flex-direction: column;
justify-content: center;
align-items: center;
view-transition-name: launch-screen;
user-select: none;
-webkit-user-select: none;
background-color: var(--primary-background-color, #fafafa);
z-index: 100;
transition: opacity var(--ha-animation-duration-normal, 250ms) ease-out;
@@ -98,7 +76,7 @@
}
#ha-launch-screen .ha-launch-screen-spacer-bottom {
flex: 1;
padding-top: 48px;
padding-top: 16px;
}
.ohf-logo {
margin: max(var(--safe-area-inset-bottom, 0px), 48px) 0;
+6 -2
View File
@@ -1,5 +1,6 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-button";
@@ -59,7 +60,7 @@ export class HaInitPage extends LitElement {
: nothing
}
`
: html`<p>
: html`<p class=${classMap({ "loading-text": !this.migration })}>
${
this.migration
? html`<span class="migration-text"
@@ -68,7 +69,7 @@ export class HaInitPage extends LitElement {
"Database upgrade is in progress, Home Assistant will not start until the upgrade is completed.\n\nThe upgrade may need a long time to complete, please be patient."
}</span
>`
: this.localize?.("ui.init.loading") || "Loading data"
: this.localize?.("ui.init.loading") || "Loading..."
}
</p>`;
}
@@ -120,6 +121,9 @@ export class HaInitPage extends LitElement {
.migration-text {
white-space: pre-line;
}
.loading-text {
opacity: 0.66;
}
`;
}
@@ -107,6 +107,9 @@ export class DialogAddApplicationCredential extends DirtyStateProviderMixin<Cred
const selectedDomainName = this._params.selectedDomain
? domainToName(this.hass.localize, this._domain!)
: "";
const documentationLink = this._manifest?.is_built_in
? documentationUrl(this.hass, `/integrations/${this._domain}`)
: this._manifest?.documentation;
return html`
<ha-dialog
.open=${this._open}
@@ -139,17 +142,9 @@ export class DialogAddApplicationCredential extends DirtyStateProviderMixin<Cred
}
)}
${
this._manifest?.is_built_in ||
this._manifest?.documentation
documentationLink
? html`<a
href=${
this._manifest.is_built_in
? documentationUrl(
this.hass,
`/integrations/${this._domain}`
)
: this._manifest.documentation
}
href=${documentationLink}
target="_blank"
rel="noreferrer"
>
@@ -42,6 +42,7 @@ import { computeDomain } from "../../../../../common/entity/compute_domain";
import { navigate } from "../../../../../common/navigate";
import { capitalizeFirstLetter } from "../../../../../common/string/capitalize-first-letter";
import type { LocalizeKeys } from "../../../../../common/translations/localize";
import { sanitizeHttpUrl } from "../../../../../common/url/sanitize-http-url";
import "../../../../../components/buttons/ha-progress-button";
import "../../../../../components/chips/ha-assist-chip";
import "../../../../../components/chips/ha-chip-set";
@@ -549,7 +550,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
"ui.panel.config.apps.dashboard.visit_app_page",
{
name: html`<a
href=${this._currentAddon.url!}
href=${ifDefined(sanitizeHttpUrl(this._currentAddon.url))}
target="_blank"
rel="noreferrer"
>${getAppDisplayName(
@@ -1107,7 +1108,11 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
private get _pathWebui(): string | null {
const addon = this._currentAddon as HassioAddonDetails;
return addon.webui!.replace("[HOST]", document.location.hostname);
return (
sanitizeHttpUrl(
addon.webui!.replace("[HOST]", document.location.hostname)
) ?? null
);
}
private get _computeShowWebUI(): boolean | "" | null {
@@ -185,20 +185,17 @@ export class HaPlatformCondition extends LitElement {
)
);
const documentationLink = this._manifest?.is_built_in
? documentationUrl(this.hass, `/conditions/${this.condition.condition}`)
: this._manifest?.documentation;
return html`
<div class="description">
${description ? html`<p>${description}</p>` : nothing}
${
this._manifest
documentationLink
? html`<a
href=${
this._manifest.is_built_in
? documentationUrl(
this.hass,
`/conditions/${this.condition.condition}`
)
: this._manifest.documentation
}
href=${documentationLink}
title=${this.hass.localize(
"ui.components.service-control.integration_doc"
)}
@@ -180,20 +180,17 @@ export class HaPlatformTrigger extends LitElement {
)
);
const documentationLink = this._manifest?.is_built_in
? documentationUrl(this.hass, `/triggers/${this.trigger.trigger}`)
: this._manifest?.documentation;
return html`
<div class="description">
${description ? html`<p>${description}</p>` : nothing}
${
this._manifest
documentationLink
? html`<a
href=${
this._manifest.is_built_in
? documentationUrl(
this.hass,
`/triggers/${this.trigger.trigger}`
)
: this._manifest.documentation
}
href=${documentationLink}
title=${this.hass.localize(
"ui.components.service-control.integration_doc"
)}
@@ -3,9 +3,10 @@ import { mdiRestore } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import { computeEntityIdFormatExample } from "../../../common/entity/compute_entity_id_format_example";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { debounce } from "../../../common/util/debounce";
import type { HaProgressButton } from "../../../components/buttons/ha-progress-button";
import "../../../components/buttons/ha-progress-button";
import "../../../components/ha-alert";
@@ -23,11 +24,13 @@ import {
type EntityIdFormat,
type EntityIdPart,
} from "../../../data/entity_id_format";
import { fetchSlug } from "../../../data/ws-slugify";
import { haStyle } from "../../../resources/styles";
import { documentationUrl } from "../../../util/documentation-url";
import "./ha-entity-id-format-editor";
const EXAMPLE_DOMAIN = "sensor";
const PREVIEW_DEBOUNCE_MS = 200;
@customElement("ha-config-entity-id-format")
export class HaConfigEntityIdFormat extends LitElement {
@@ -47,6 +50,10 @@ export class HaConfigEntityIdFormat extends LitElement {
@state() private _error?: string;
@state() private _preview?: string;
@state() private _previewError = false;
protected async firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
try {
@@ -57,12 +64,47 @@ export class HaConfigEntityIdFormat extends LitElement {
}
}
private _examples: Record<EntityIdPart, string> = {
area: "Living room",
device: "Thermostat",
entity: "Temperature",
floor: "Ground floor",
};
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (
(changedProps.has("_format") || changedProps.has("_localize")) &&
this._format
) {
if (changedProps.get("_format") === undefined) {
this._updatePreview();
} else {
this._debouncedUpdatePreview();
}
}
}
private _examples = memoizeOne(
(localize: LocalizeFunc): Record<EntityIdPart, string> => ({
area: localize("ui.panel.config.entity_id_format.card.examples.area"),
device: localize("ui.panel.config.entity_id_format.card.examples.device"),
entity: localize("ui.panel.config.entity_id_format.card.examples.entity"),
floor: localize("ui.panel.config.entity_id_format.card.examples.floor"),
})
);
private _debouncedUpdatePreview = debounce(
() => this._updatePreview(),
PREVIEW_DEBOUNCE_MS
);
private async _updatePreview() {
const examples = this._examples(this._localize);
const fullName = this._format!.map((part) => examples[part])
.filter(Boolean)
.join(" ");
try {
const { slug } = await fetchSlug(this._api, fullName);
this._preview = slug;
this._previewError = false;
} catch (_err: any) {
this._previewError = true;
}
}
protected render() {
return html`
@@ -131,13 +173,20 @@ export class HaConfigEntityIdFormat extends LitElement {
}
private _renderPreview() {
const example = computeEntityIdFormatExample(this._format!, this._examples);
return html`
<div class="preview">
<span class="preview-label">
${this._localize("ui.panel.config.entity_id_format.card.preview")}
</span>
<code>${EXAMPLE_DOMAIN}.${example}</code>
${
this._previewError
? html`<ha-alert alert-type="error">
${this._localize(
"ui.panel.config.entity_id_format.card.preview_error"
)}
</ha-alert>`
: html`<code>${EXAMPLE_DOMAIN}.${this._preview ?? "…"}</code>`
}
</div>
`;
}
@@ -96,6 +96,10 @@ import "../../../layouts/hass-subpage";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { isHelperDomain } from "../helpers/const";
import {
isHomeAssistantUrl,
sanitizeLinkUrl,
} from "../../../common/url/sanitize-http-url";
import { createSearchParam } from "../../../common/url/search-params";
import { brandsUrl } from "../../../util/brands-url";
import { fileDownload } from "../../../util/file_download";
@@ -1263,12 +1267,11 @@ export class HaConfigDevicePage extends LitElement {
const deviceActions: DeviceAction[] = [];
const configurationUrlIsHomeAssistant =
device.configuration_url?.startsWith("homeassistant://") || false;
const configurationUrlIsHomeAssistant = isHomeAssistantUrl(
device.configuration_url
);
const configurationUrl = configurationUrlIsHomeAssistant
? device.configuration_url?.replace("homeassistant://", "/")
: device.configuration_url;
const configurationUrl = sanitizeLinkUrl(device.configuration_url);
if (configurationUrl) {
deviceActions.push({
@@ -61,6 +61,10 @@ export class HaEnergyPowerConfig extends LitElement {
}
}
private get _requiredError(): string {
return this.hass.localize("ui.common.error_required");
}
protected render(): TemplateResult {
return html`
<p class="power-section-label">
@@ -119,6 +123,9 @@ export class HaEnergyPowerConfig extends LitElement {
`${this.localizeBaseKey}.power_helper` as LocalizeKeys,
{ unit: this._powerUnits?.join(", ") || "" }
)}
required
.invalid=${!this.powerConfig.stat_rate}
.errorMessage=${this._requiredError}
></ha-statistic-picker>
`
: nothing
@@ -138,11 +145,16 @@ export class HaEnergyPowerConfig extends LitElement {
.helper=${this.hass.localize(
`${this.localizeBaseKey}.type_inverted_description` as LocalizeKeys
)}
required
.invalid=${!this.powerConfig.stat_rate_inverted}
.errorMessage=${this._requiredError}
></ha-statistic-picker>
`
: nothing
}
${
// These two exclude each other, so they keep their clear button
// (and therefore no required marker) to stay swappable.
this.powerType === "two_sensors"
? html`
<ha-statistic-picker
@@ -157,6 +169,8 @@ export class HaEnergyPowerConfig extends LitElement {
this.powerConfig.stat_rate_to,
].filter((id): id is string => Boolean(id))}
@value-changed=${this._fromPowerChanged}
.invalid=${!this.powerConfig.stat_rate_from}
.errorMessage=${this._requiredError}
></ha-statistic-picker>
<ha-statistic-picker
.hass=${this.hass}
@@ -170,6 +184,8 @@ export class HaEnergyPowerConfig extends LitElement {
this.powerConfig.stat_rate_from,
].filter((id): id is string => Boolean(id))}
@value-changed=${this._toPowerChanged}
.invalid=${!this.powerConfig.stat_rate_to}
.errorMessage=${this._requiredError}
></ha-statistic-picker>
`
: nothing
@@ -8,6 +8,7 @@ import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { round } from "../../../common/number/round";
import { blankBeforePercent } from "../../../common/translations/blank_before_percent";
import { sanitizeHttpUrl } from "../../../common/url/sanitize-http-url";
import "../../../components/chart/ha-chart-base";
import "../../../components/ha-alert";
import "../../../components/ha-button";
@@ -228,7 +229,7 @@ class HaConfigHardwareOverview extends SubscribeMixin(LitElement) {
) as ConfigEntry[];
boardId = boardData.board!.hassio_board_id;
boardName = boardData.name;
documentationURL = boardData.url;
documentationURL = sanitizeHttpUrl(boardData.url);
imageURL = hardwareBrandsUrl(
{
category: "boards",
@@ -300,6 +300,7 @@ class HaScheduleForm extends LitElement {
const newValue = { ...this._item };
const endFormatted = formatTime24h(end, this.hass.locale, this.hass.config);
newValue[day] = [...newValue[day]];
newValue[day][index] = {
...newValue[day][index],
from: value.from,
@@ -337,6 +338,7 @@ class HaScheduleForm extends LitElement {
};
if (newDay === day) {
newValue[day] = [...newValue[day]];
newValue[day][index] = event;
} else {
newValue[day].splice(index, 1);
@@ -10,6 +10,10 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { fireEvent } from "../../../common/dom/fire_event";
import {
isHomeAssistantUrl,
sanitizeLinkUrl,
} from "../../../common/url/sanitize-http-url";
import "../../../components/ha-button";
import "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
@@ -46,6 +50,15 @@ export class HaConfigFlowCard extends LitElement {
protected render(): TemplateResult {
const attention = ATTENTION_SOURCES.includes(this.flow.context.source);
const configurationUrlIsHomeAssistant = isHomeAssistantUrl(
this.flow.context.configuration_url
);
const configurationUrl = sanitizeLinkUrl(
this.flow.context.configuration_url
);
const documentationLink = this.manifest?.is_built_in
? documentationUrl(this.hass, `/integrations/${this.manifest.domain}`)
: this.manifest?.documentation;
return html`
<ha-integration-action-card
class=${classMap({
@@ -78,7 +91,7 @@ export class HaConfigFlowCard extends LitElement {
)}
</ha-button>
${
this.flow.context.configuration_url || this.manifest || attention
configurationUrl || documentationLink || attention
? html`<ha-dropdown
slot="header-button"
placement="bottom-end"
@@ -90,19 +103,12 @@ export class HaConfigFlowCard extends LitElement {
.path=${mdiDotsVertical}
></ha-icon-button>
${
this.flow.context.configuration_url
configurationUrl
? html`<a
href=${this.flow.context.configuration_url.replace(
/^homeassistant:\/\//,
"/"
)}
href=${configurationUrl}
rel="noreferrer"
target=${
this.flow.context.configuration_url.startsWith(
"homeassistant://"
)
? "_self"
: "_blank"
configurationUrlIsHomeAssistant ? "_self" : "_blank"
}
>
<ha-dropdown-item>
@@ -122,16 +128,9 @@ export class HaConfigFlowCard extends LitElement {
: nothing
}
${
this.manifest
documentationLink
? html`<a
href=${
this.manifest.is_built_in
? documentationUrl(
this.hass,
`/integrations/${this.manifest.domain}`
)
: this.manifest.documentation
}
href=${documentationLink}
rel="noreferrer"
target="_blank"
>
@@ -368,21 +368,18 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
this.domain
);
const documentationLink = this._manifest?.is_built_in
? documentationUrl(this.hass, `/integrations/${this._manifest.domain}`)
: this._manifest?.documentation;
return html`
<hass-subpage .hass=${this.hass} .narrow=${this.narrow}>
${
this._manifest
documentationLink
? html`
<a
slot="toolbar-icon"
href=${
this._manifest.is_built_in
? documentationUrl(
this.hass,
`/integrations/${this._manifest.domain}`
)
: this._manifest.documentation
}
href=${documentationLink}
rel="noreferrer"
target="_blank"
>
@@ -11,6 +11,7 @@ import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { computeDeviceNameDisplay } from "../../../../../common/entity/compute_device_name";
import { sanitizeHttpUrl } from "../../../../../common/url/sanitize-http-url";
import { groupBy } from "../../../../../common/util/group-by";
import "../../../../../components/buttons/ha-progress-button";
import type { HaProgressButton } from "../../../../../components/buttons/ha-progress-button";
@@ -154,8 +155,9 @@ class ZWaveJSNodeConfig extends LitElement {
device_database: html`<a
rel="noreferrer noopener"
href=${
this._nodeMetadata?.device_database_url ||
"https://devices.zwave-js.io"
sanitizeHttpUrl(
this._nodeMetadata?.device_database_url
) || "https://devices.zwave-js.io"
}
target="_blank"
>${this.hass.localize(
+8 -4
View File
@@ -4,6 +4,7 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { sanitizeHttpUrl } from "../../../common/url/sanitize-http-url";
import { extractSearchParam } from "../../../common/url/search-params";
import "../../../components/ha-alert";
import "../../../components/ha-button";
@@ -199,6 +200,9 @@ class HaConfigLabs extends SubscribeMixin(LitElement) {
const isHighlighted = this._highlightedPreviewFeature === previewFeatureId;
const feedbackUrl = sanitizeHttpUrl(preview_feature.feedback_url);
const reportIssueUrl = sanitizeHttpUrl(preview_feature.report_issue_url);
// Build description with learn more link if available
const descriptionWithLink = preview_feature.learn_more_url
? `${description}\n\n[${this.hass.localize("ui.panel.config.labs.learn_more")}](${preview_feature.learn_more_url})`
@@ -237,11 +241,11 @@ class HaConfigLabs extends SubscribeMixin(LitElement) {
<div class="card-actions">
<div>
${
preview_feature.feedback_url
feedbackUrl
? html`
<ha-button
appearance="plain"
href=${preview_feature.feedback_url}
href=${feedbackUrl}
target="_blank"
rel="noopener noreferrer"
>
@@ -253,11 +257,11 @@ class HaConfigLabs extends SubscribeMixin(LitElement) {
: nothing
}
${
preview_feature.report_issue_url
reportIssueUrl
? html`
<ha-button
appearance="plain"
href=${preview_feature.report_issue_url}
href=${reportIssueUrl}
target="_blank"
rel="noopener noreferrer"
>
@@ -25,6 +25,15 @@ import { showToast } from "../../../util/toast";
import type { SystemLogDetailDialogParams } from "./show-dialog-system-log-detail";
import { formatSystemLogTime } from "./util";
/** Compares the host, so a URL that merely contains ours does not pass. */
const isOfficialDocumentationUrl = (url: string): boolean => {
try {
return new URL(url).hostname === "www.home-assistant.io";
} catch (_err) {
return false;
}
};
@customElement("dialog-system-log-detail")
class DialogSystemLogDetail extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -76,7 +85,12 @@ class DialogSystemLogDetail extends LitElement {
this._manifest &&
(this._manifest.is_built_in ||
// Custom components with our official docs should not link to our docs
!this._manifest.documentation.includes("://www.home-assistant.io"));
(!!this._manifest.documentation &&
!isOfficialDocumentationUrl(this._manifest.documentation)));
const documentationLink = this._manifest?.is_built_in
? documentationUrl(this.hass, `/integrations/${this._manifest.domain}`)
: this._manifest?.documentation;
const title = this.hass.localize("ui.panel.config.logs.details", {
level: html`<span class=${item.level}
@@ -124,18 +138,12 @@ class DialogSystemLogDetail extends LitElement {
${
!this._manifest ||
// Can happen with custom integrations
!showDocumentation
!showDocumentation ||
!documentationLink
? ""
: html`
(<a
href=${
this._manifest.is_built_in
? documentationUrl(
this.hass,
`/integrations/${this._manifest.domain}`
)
: this._manifest.documentation
}
href=${documentationLink}
target="_blank"
rel="noreferrer"
>${this.hass.localize(
@@ -3,6 +3,7 @@ import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { mainWindow } from "../../../common/dom/get_main_window";
import {
IP_ADDRESS_OR_NETWORK_PATTERN,
IP_ADDRESS_PATTERN,
@@ -24,7 +25,10 @@ import type {
HttpConfig,
HttpConfigWithMeta,
} from "../../../data/http";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import {
showAlertDialog,
showConfirmationDialog,
} from "../../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
@@ -167,6 +171,10 @@ class HaConfigHttpForm extends LitElement {
@state() private _activeConfigType?: ActiveConfigType;
// The built-in default config as reported by core; used to show the default
// port in the helper text instead of a hard-coded value.
@state() private _default?: HttpConfigWithMeta;
// A pending config that was reverted/failed and kept only for display.
@state() private _revertedPending?: HttpConfigWithMeta;
@@ -201,6 +209,8 @@ class HaConfigHttpForm extends LitElement {
const portChanged =
!!this._stable && this._config?.server_port !== this._stable.server_port;
const hasListenAddresses = !!this._config?.server_host?.some(Boolean);
return html`
<ha-card
outlined
@@ -255,6 +265,17 @@ class HaConfigHttpForm extends LitElement {
`
: nothing
}
${
hasListenAddresses
? html`
<ha-alert alert-type="warning">
${this.hass.localize(
"ui.panel.config.network.http.server_host_warning"
)}
</ha-alert>
`
: nothing
}
${
this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
@@ -309,12 +330,16 @@ class HaConfigHttpForm extends LitElement {
private async _fetchConfig(): Promise<void> {
try {
const { stable, pending, active_config_type } = await fetchHttpConfig(
this.hass
);
const {
stable,
pending,
active_config_type,
default: defaultConfig,
} = await fetchHttpConfig(this.hass);
this._stable = stable;
this._config = { ...stable };
this._activeConfigType = active_config_type;
this._default = defaultConfig;
// An active trial pending (no error) is handled by the global
// confirm/revert dialog. A pending carrying an error was reverted or
// failed to apply and is kept only so we can surface it here.
@@ -351,6 +376,12 @@ class HaConfigHttpForm extends LitElement {
if ("type" in schema && schema.type === "expandable") {
return "";
}
if (schema.name === "server_port") {
return this.hass.localize(
"ui.panel.config.network.http.helpers.server_port",
{ port: this._default?.server_port ?? 8123 }
);
}
return (
this.hass.localize(
`ui.panel.config.network.http.helpers.${schema.name}` as any
@@ -365,6 +396,63 @@ class HaConfigHttpForm extends LitElement {
this._showNoChanges = false;
}
// Build a link to the new address for an address-changing restart, so the
// user (still on the old address) can jump to it once Home Assistant is back.
// Best-effort: skip Home Assistant Cloud remote UI (Nabu Casa), and skip when
// the current page is not on the old port — that usually means a reverse
// proxy, where swapping the port would point at the wrong place. Even when
// shown, the new address may not be reachable (e.g. a firewall).
private _newAddressUrl(): string | undefined {
if (!this._stable || !this._config) {
return undefined;
}
const loc = mainWindow.location;
if (loc.hostname.endsWith("nabu.casa")) {
return undefined;
}
const oldHttps = !!this._stable.ssl_certificate;
const newHttps = !!this._config.ssl_certificate;
const oldPort = this._stable.server_port ?? (oldHttps ? 443 : 80);
const newPort = this._config.server_port ?? (newHttps ? 443 : 80);
// The reachable address only changes when the scheme or the port changes.
if (oldHttps === newHttps && oldPort === newPort) {
return undefined;
}
const currentPort = loc.port
? Number(loc.port)
: loc.protocol === "https:"
? 443
: 80;
if (currentPort !== oldPort) {
return undefined;
}
const url = new URL(loc.origin);
url.protocol = newHttps ? "https:" : "http:";
url.port = String(newPort);
return url.toString();
}
private _showNewAddress(url: string): void {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.network.http.restart_address.title"
),
text: html`
<p>
${this.hass.localize(
"ui.panel.config.network.http.restart_address.text"
)}
</p>
<a href=${url} rel="noreferrer noopener">${url}</a>
<p class="dialog-note">
${this.hass.localize(
"ui.panel.config.network.http.restart_address.note"
)}
</p>
`,
});
}
private async _save(): Promise<void> {
if (!this._config || !this._stable) {
return;
@@ -393,6 +481,9 @@ class HaConfigHttpForm extends LitElement {
return;
}
// Capture the new address before the restart drops the connection.
const newAddressUrl = this._newAddressUrl();
this._saving = true;
this._error = undefined;
this._fieldErrors = {};
@@ -413,16 +504,21 @@ class HaConfigHttpForm extends LitElement {
const result = await saveHttpConfig(this.hass, config);
if (!result.restart) {
this._showNoChanges = true;
} else if (newAddressUrl) {
// restart === true: a restart is in flight. The reply usually races
// with the connection drop; if we do reach this branch, offer the new
// address so the user can follow along.
this._showNewAddress(newAddressUrl);
}
// restart === true: a restart is in flight. The reply usually races with
// the connection drop; if we do reach this branch, the disconnected
// overlay will appear in moments. Leave the form as is.
} catch (err: any) {
// The restart kills the WS connection before the ack — that's expected.
if (
err?.error?.code === ERR_CONNECTION_LOST ||
err === ERR_CONNECTION_LOST
) {
if (newAddressUrl) {
this._showNewAddress(newAddressUrl);
}
return;
}
this._handleSaveError(err);
@@ -15,6 +15,8 @@ import type { HaInputCopy } from "../../../components/input/ha-input-copy";
import type { CloudStatus } from "../../../data/cloud";
import { fetchCloudStatus } from "../../../data/cloud";
import { saveCoreConfig } from "../../../data/core";
import type { HttpConfigWithMeta } from "../../../data/http";
import { fetchHttpConfig } from "../../../data/http";
import { getNetworkUrls, type NetworkUrls } from "../../../data/network";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../types";
@@ -34,6 +36,12 @@ class ConfigUrlForm extends SubscribeMixin(LitElement) {
@state() private _internal_url = "";
// Stable HTTP config from core; used to build a realistic internal URL
// placeholder (scheme from SSL, port from the configured/default port).
@state() private _httpConfig?: HttpConfigWithMeta;
@state() private _httpDefault?: HttpConfigWithMeta;
@state() private _cloudStatus?: CloudStatus | null;
@state() private _showCustomExternalUrl = false;
@@ -97,6 +105,28 @@ class ConfigUrlForm extends SubscribeMixin(LitElement) {
}
}
// Build realistic URL placeholders: use https when an SSL certificate is
// configured, and only show the port when it differs from the scheme
// default (80 for http, 443 for https).
const useHttps = this._httpConfig?.ssl_certificate ? true : httpUseHttps;
const placeholderPort =
this._httpConfig?.server_port ?? this._httpDefault?.server_port;
const placeholderParts = {
protocol: useHttps ? "https" : "http",
port:
placeholderPort && placeholderPort !== (useHttps ? 443 : 80)
? `:${placeholderPort}`
: "",
};
const internalUrlPlaceholder = this.hass.localize(
"ui.panel.config.url.internal_url_placeholder",
placeholderParts
);
const externalUrlPlaceholder = this.hass.localize(
"ui.panel.config.url.external_url_placeholder",
placeholderParts
);
return html`
<ha-card
outlined
@@ -157,7 +187,7 @@ class ConfigUrlForm extends SubscribeMixin(LitElement) {
data-name="external_url"
type="url"
.maskedToggle=${!(this._showCustomExternalUrl && canEdit)}
placeholder="https://example.duckdns.org:8123"
.placeholder=${externalUrlPlaceholder}
.value=${externalUrl}
.maskedValue=${
this._showCustomExternalUrl && canEdit
@@ -260,9 +290,7 @@ class ConfigUrlForm extends SubscribeMixin(LitElement) {
data-name="internal_url"
.maskedToggle=${!(this._showCustomInternalUrl && canEdit)}
type="url"
placeholder=${this.hass.localize(
"ui.panel.config.url.internal_url_placeholder"
)}
.placeholder=${internalUrlPlaceholder}
.value=${internalUrl}
.maskedValue=${
this._showCustomInternalUrl && canEdit
@@ -325,6 +353,14 @@ class ConfigUrlForm extends SubscribeMixin(LitElement) {
this._cloudStatus = null;
}
this._fetchUrls();
// Best-effort: the placeholder still works without it, just without a port.
fetchHttpConfig(this.hass).then(
({ stable, default: defaultConfig }) => {
this._httpConfig = stable;
this._httpDefault = defaultConfig;
},
() => undefined
);
}
private _toggleCloud(ev: Event) {
@@ -4,6 +4,10 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import { isNavigationClick } from "../../../common/dom/is-navigation-click";
import {
isHomeAssistantUrl,
sanitizeLinkUrl,
} from "../../../common/url/sanitize-http-url";
import "../../../components/ha-alert";
import "../../../components/ha-dialog";
import "../../../components/ha-button";
@@ -52,8 +56,10 @@ class DialogRepairsIssue extends LitElement {
return nothing;
}
const learnMoreUrlIsHomeAssistant =
this._issue.learn_more_url?.startsWith("homeassistant://") || false;
const learnMoreUrlIsHomeAssistant = isHomeAssistantUrl(
this._issue.learn_more_url
);
const learnMoreUrl = sanitizeLinkUrl(this._issue.learn_more_url);
const dialogTitle =
this.hass.localize(
@@ -127,20 +133,13 @@ class DialogRepairsIssue extends LitElement {
}
</ha-button>
${
this._issue.learn_more_url
learnMoreUrl
? html`
<ha-button
slot="primaryAction"
appearance="filled"
rel="noopener noreferrer"
href=${
learnMoreUrlIsHomeAssistant
? this._issue.learn_more_url.replace(
"homeassistant://",
"/"
)
: this._issue.learn_more_url
}
href=${learnMoreUrl}
.target=${learnMoreUrlIsHomeAssistant ? "" : "_blank"}
@click=${
learnMoreUrlIsHomeAssistant ? this.closeDialog : undefined
@@ -5,6 +5,8 @@ import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { formatDateTime } from "../../../common/datetime/format_date_time";
import { fireEvent } from "../../../common/dom/fire_event";
import { sanitizeHttpUrl } from "../../../common/url/sanitize-http-url";
import { sanitizeNavigationPath } from "../../../common/url/sanitize-navigation-path";
import { copyToClipboard } from "../../../common/util/copy-clipboard";
import { subscribePollingCollection } from "../../../common/util/subscribe-polling";
import "../../../components/ha-alert";
@@ -335,14 +337,15 @@ class DialogSystemInformation extends LitElement {
if (info.type === "pending") {
value = html` <ha-spinner size="small"></ha-spinner> `;
} else if (info.type === "failed") {
const moreInfoUrl = sanitizeHttpUrl(info.more_info);
value = html`
<span class="error">${info.error}</span>${
!info.more_info
!moreInfoUrl
? ""
: html`
<a
href=${info.more_info}
href=${moreInfoUrl}
target="_blank"
rel="noreferrer noopener"
>
@@ -378,18 +381,22 @@ class DialogSystemInformation extends LitElement {
`);
}
if (domain !== "homeassistant") {
// No target, so an in-app path is also a valid destination here
const manageUrl =
sanitizeHttpUrl(domainInfo.manage_url) ??
sanitizeNavigationPath(domainInfo.manage_url);
sections.push(html`
<div class="card-header">
<h3>${domainToName(this.hass.localize, domain)}</h3>
${
!domainInfo.manage_url
!manageUrl
? ""
: html`
<ha-button
appearance="plain"
size="s"
class="manage"
href=${domainInfo.manage_url}
href=${manageUrl}
>
${this.hass.localize(
"ui.panel.config.info.system_health.manage"
@@ -43,11 +43,15 @@ class IntegrationsStartupTime extends LitElement {
<ha-md-list>
${this._setups?.map((setup) => {
const manifest = this._manifests && this._manifests[setup.domain];
const docLink = manifest
? manifest.is_built_in
? documentationUrl(this.hass, `/integrations/${manifest.domain}`)
: manifest.documentation
: "";
const docLink =
(manifest
? manifest.is_built_in
? documentationUrl(
this.hass,
`/integrations/${manifest.domain}`
)
: manifest.documentation
: "") || "";
const setupSeconds = setup.seconds?.toFixed(2);
return html`
@@ -5,6 +5,7 @@ import { dump, JSON_SCHEMA, load } from "js-yaml";
import type { CSSResultGroup, TemplateResult, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import { until } from "lit/directives/until";
import memoizeOne from "memoize-one";
@@ -17,6 +18,8 @@ import {
isTemplate,
} from "../../../../common/string/has-template";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import { sanitizeHttpUrl } from "../../../../common/url/sanitize-http-url";
import { sanitizeNavigationPath } from "../../../../common/url/sanitize-navigation-path";
import { extractSearchParam } from "../../../../common/url/search-params";
import { copyToClipboard } from "../../../../common/util/copy-clipboard";
import type { HaProgressButton } from "../../../../components/buttons/ha-progress-button";
@@ -562,7 +565,10 @@ class HaPanelDevAction extends MatchMinHeightMixin(LitElement) {
`
: html`
<a
href=${resolved.url}
href=${ifDefined(
sanitizeHttpUrl(resolved.url) ??
sanitizeNavigationPath(resolved.url)
)}
target="_blank"
rel="noreferrer"
><ha-button>
+14
View File
@@ -1,3 +1,4 @@
import { sanitizeUrl } from "@braintree/sanitize-url";
import { html, css, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
@@ -15,6 +16,19 @@ class HaPanelIframe extends LitElement {
@property({ attribute: false }) panel!: PanelInfo<{ url: string }>;
render() {
// The sandbox keeps allow-same-origin, so a javascript: URL would run in
// the frontend's own origin
if (sanitizeUrl(this.panel.config.url) === "about:blank") {
return html`
<hass-error-screen
.hass=${this.hass}
.narrow=${this.narrow}
error="Unable to load iframes with this URL."
rootnav
></hass-error-screen>
`;
}
if (
location.protocol === "https:" &&
new URL(this.panel.config.url, location.toString()).protocol !== "https:"
+6
View File
@@ -321,6 +321,9 @@ export class HaPanelLogbook extends LitElement {
"entity_id",
"state",
"event_type",
"name",
"message",
"source",
"context_id",
"context_user_id",
"context_event_type",
@@ -339,6 +342,9 @@ export class HaPanelLogbook extends LitElement {
d.entity_id || "",
csvSafeString(d.state),
csvSafeString(d.attributes?.event_type),
csvSafeString(d.name),
csvSafeString(d.message),
csvSafeString(d.source),
d.context_id || "",
d.context_user_id || "",
csvSafeString(d.context_event_type),
+6 -3
View File
@@ -825,9 +825,12 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
justify-content: center;
color: white;
}
ha-tile-info.twoline {
--ha-tile-info-primary-line-clamp: 2;
--ha-tile-info-primary-line-height: var(--ha-space-4);
ha-tile-info.twoline::part(primary) {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
white-space: normal;
overflow-wrap: anywhere;
}
`,
];
@@ -1,3 +1,4 @@
import { sanitizeUrl } from "@braintree/sanitize-url";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
@@ -52,6 +53,12 @@ export class HuiIframeCard extends LitElement implements LovelaceCard {
throw new Error("URL required");
}
// The sandbox keeps allow-same-origin, so a javascript: URL would run in
// the frontend's own origin
if (sanitizeUrl(config.url) === "about:blank") {
throw new Error("Invalid URL");
}
this._config = config;
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { sanitizeUrl } from "@braintree/sanitize-url";
import { fireEvent } from "../../../common/dom/fire_event";
import { navigate } from "../../../common/navigate";
import { forwardHaptic } from "../../../data/haptics";
@@ -133,7 +134,7 @@ export const handleAction = async (
break;
case "url": {
if (actionConfig.url_path) {
window.open(actionConfig.url_path);
window.open(sanitizeUrl(actionConfig.url_path));
} else {
showToast(node, {
message: hass.localize("ui.panel.lovelace.cards.actions.no_url"),
@@ -54,7 +54,7 @@ export class HuiNumericInputCardFeatureEditor
}
const data: NumericInputCardFeatureConfig = {
style: "buttons",
style: "slider",
...this._config,
};
@@ -1,3 +1,4 @@
import { sanitizeHttpUrl } from "../../../common/url/sanitize-http-url";
import {
getCustomBadgeEntry,
getCustomCardEntry,
@@ -38,7 +39,9 @@ export const getCardDocumentationURL = (
type: string
): string | undefined => {
if (isCustomType(type)) {
return getCustomCardEntry(stripCustomPrefix(type))?.documentationURL;
return sanitizeHttpUrl(
getCustomCardEntry(stripCustomPrefix(type))?.documentationURL
);
}
return `${documentationUrl(hass, "/dashboards/")}${NON_STANDARD_CARD_URLS[type] || type}`;
@@ -49,7 +52,9 @@ export const getBadgeDocumentationURL = (
type: string
): string | undefined => {
if (isCustomType(type)) {
return getCustomBadgeEntry(stripCustomPrefix(type))?.documentationURL;
return sanitizeHttpUrl(
getCustomBadgeEntry(stripCustomPrefix(type))?.documentationURL
);
}
return `${documentationUrl(hass, "/dashboards/")}${NON_STANDARD_BADGE_URLS[type] || "badges"}`;
@@ -1,3 +1,4 @@
import { sanitizeUrl } from "@braintree/sanitize-url";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
@@ -16,6 +17,11 @@ class HuiWeblinkRow extends LitElement implements LovelaceRow {
throw new Error("URL required");
}
// Reject schemes that would run script in the frontend's origin
if (sanitizeUrl(config.url) === "about:blank") {
throw new Error("Invalid URL");
}
this._config = {
icon: "mdi:link",
name: config.url,
+1 -7
View File
@@ -1,5 +1,5 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import "../../components/ha-button";
@@ -148,12 +148,6 @@ export class HaPickThemeRow extends SubscribeMixin(LitElement) {
this._migrating = false;
}
}
static styles = css`
a {
color: var(--primary-color);
}
`;
}
declare global {
+3
View File
@@ -8,6 +8,7 @@ import type { HASSDomEvent } from "../common/dom/fire_event";
import { subscribeThemePreferences, saveThemePreferences } from "../data/theme";
import { subscribeThemes } from "../data/ws-themes";
import type { Constructor, HomeAssistant } from "../types";
import { updateLaunchScreenLogo } from "../util/launch-screen";
import { storeState } from "../util/ha-pref-storage";
import type { HassBaseEl } from "./hass-base-mixin";
@@ -145,6 +146,8 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
true
);
updateLaunchScreenLogo(darkMode);
if (darkMode !== this.hass.themes.darkMode) {
this._updateHass({
themes: { ...this.hass.themes!, darkMode },
+18 -4
View File
@@ -75,7 +75,7 @@
},
"ui": {
"init": {
"loading": "Loading data",
"loading": "Loading...",
"migration": "Database upgrade is in progress, Home Assistant will not start until the upgrade is completed.\n\nThe upgrade may need a long time to complete, please be patient.",
"project_from": "A project from the",
"error": {
@@ -2396,7 +2396,7 @@
"header": "Copy pairing code",
"step_1": "[%key:ui::dialogs::matter-add-device::google_home::step_1%]",
"step_2": "[%key:ui::dialogs::matter-add-device::google_home::step_2%]",
"step_3": "Tap {link_apps_services} and choose {use_pairing_code} form the list",
"step_3": "Tap {link_apps_services} and choose {use_pairing_code} from the list",
"linked_matter_apps_services": "[%key:ui::dialogs::matter-add-device::google_home::linked_matter_apps_services%]",
"link_apps_services": "[%key:ui::dialogs::matter-add-device::google_home::link_apps_services%]",
"use_pairing_code": "Use Pairing Code",
@@ -4479,7 +4479,8 @@
"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 certificate.",
"internal_url_automatic_description": "Use the configured network settings",
"internal_url_placeholder": "http://<some IP address>:8123",
"internal_url_placeholder": "{protocol}://192.168.1.100{port}",
"external_url_placeholder": "{protocol}://example.duckdns.org{port}",
"invalid_url": "Invalid URL"
},
"hardware": {
@@ -8651,7 +8652,14 @@
"description": "Entity IDs are used to reference entities in automations, scripts, and dashboards. This format only applies when a new entity is created. Using it is optional, and you can still rename each entity ID afterwards in its settings",
"learn_more": "Learn more",
"preview": "Preview",
"preview_error": "Failed to generate preview",
"reset": "Reset to default",
"examples": {
"area": "Living room",
"device": "Thermostat",
"entity": "Temperature",
"floor": "Ground floor"
},
"editor": {
"label": "Format",
"add": "Add",
@@ -8751,6 +8759,7 @@
"save_no_changes": "Nothing changed — no restart needed.",
"save_error": "Could not save the HTTP configuration.",
"port_warning": "Clients such as the Home Assistant mobile apps will lose their connection until you update the URL in their settings. If Home Assistant is not confirmed reachable on the new port, the change is rolled back automatically after 5 minutes.",
"server_host_warning": "Leave this empty to listen on all interfaces. If you set specific addresses, Home Assistant is only reachable through those — binding it only to an address that other devices can't reach (for example localhost) will make it unreachable from those devices.",
"invalid_host": "Enter a valid IP address.",
"invalid_network": "Enter a valid IP address or network.",
"running_default": "Your saved HTTP configuration could not be applied, so Home Assistant is running on the built-in default configuration.",
@@ -8762,6 +8771,11 @@
"text": "Saving will restart Home Assistant to apply the new HTTP settings.",
"confirm": "Save and restart"
},
"restart_address": {
"title": "Home Assistant is restarting",
"text": "The address changed. Once Home Assistant has restarted, you can reach it at:",
"note": "Depending on your network setup, this address may not be reachable from this device."
},
"ssl_profile_modern": "Modern",
"ssl_profile_intermediate": "Intermediate",
"sections": {
@@ -8785,7 +8799,7 @@
"login_attempts_threshold": "Login attempts before ban"
},
"helpers": {
"server_port": "The port Home Assistant listens on. Default is 8123.",
"server_port": "The port Home Assistant listens on. Default is {port}.",
"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).",
+24 -18
View File
@@ -1,12 +1,11 @@
import type { TemplateResult } from "lit";
import { render } from "lit";
import { parseAnimationDuration } from "../common/util/parse-animation-duration";
import { withViewTransition } from "../common/util/view-transition";
let removalInitiated = false;
/**
* Removes the launch screen with a fade-out view transition.
* Removes the launch screen with a CSS fade-out transition.
*
* @param instant - Removes the launch screen without animation. Used when the
* external app covers the frontend with its own splash screen until the
@@ -26,23 +25,16 @@ export const removeLaunchScreen = (instant = false): boolean => {
return true;
}
withViewTransition((viewTransitionAvailable) => {
if (viewTransitionAvailable) {
launchScreenElement.classList.add("removing");
const durationFromCss = getComputedStyle(document.documentElement)
.getPropertyValue("--ha-animation-duration-normal")
.trim();
setTimeout(
() => {
launchScreenElement.parentElement?.removeChild(launchScreenElement);
return;
}
launchScreenElement.classList.add("removing");
const durationFromCss = getComputedStyle(document.documentElement)
.getPropertyValue("--ha-animation-duration-normal")
.trim();
setTimeout(
() => {
launchScreenElement.parentElement?.removeChild(launchScreenElement);
},
parseAnimationDuration(durationFromCss || "250ms")
);
});
},
parseAnimationDuration(durationFromCss || "250ms")
);
return true;
};
@@ -57,6 +49,20 @@ export const renderLaunchScreenContent = (
updateLaunchScreenAttribution(attribution);
};
/**
* Switches the launch screen OHF logo to the variant matching the applied
* theme. The `<picture>` element initially picks a variant based on the system
* color scheme, which can differ from the theme the frontend ends up applying.
*/
export const updateLaunchScreenLogo = (darkMode: boolean) => {
const logoSourceElement = document.querySelector<HTMLSourceElement>(
"#ha-launch-screen .ohf-logo source"
);
if (logoSourceElement) {
logoSourceElement.media = darkMode ? "all" : "not all";
}
};
export const updateLaunchScreenAttribution = (attribution: string) => {
const attributionElement = document.getElementById(
"ha-launch-screen-attribution"
+94
View File
@@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import {
homeAssistantUrlToPath,
isHomeAssistantUrl,
sanitizeLinkUrl,
sanitizeHttpUrl,
} from "../../../src/common/url/sanitize-http-url";
describe("sanitizeHttpUrl", () => {
it("keeps http and https URLs", () => {
expect(
sanitizeHttpUrl("https://www.home-assistant.io/integrations/hue")
).toEqual("https://www.home-assistant.io/integrations/hue");
expect(sanitizeHttpUrl("http://192.168.1.5:8080/setup")).toEqual(
"http://192.168.1.5:8080/setup"
);
});
/* eslint-disable no-script-url */
it("rejects URIs that can execute script", () => {
expect(sanitizeHttpUrl("javascript:alert(1)")).toBeUndefined();
expect(sanitizeHttpUrl("JavaScript:alert(1)")).toBeUndefined();
expect(sanitizeHttpUrl("java\tscript:alert(1)")).toBeUndefined();
expect(sanitizeHttpUrl(" javascript:alert(1)")).toBeUndefined();
expect(
sanitizeHttpUrl("data:text/html,<script>alert(1)</script>")
).toBeUndefined();
expect(sanitizeHttpUrl("vbscript:msgbox(1)")).toBeUndefined();
});
/* eslint-enable no-script-url */
it("rejects other schemes and unparseable values", () => {
expect(sanitizeHttpUrl("homeassistant://config/system")).toBeUndefined();
expect(sanitizeHttpUrl("file:///etc/passwd")).toBeUndefined();
expect(sanitizeHttpUrl("about:blank")).toBeUndefined();
expect(sanitizeHttpUrl("/config/system")).toBeUndefined();
expect(sanitizeHttpUrl("not a url")).toBeUndefined();
});
it("rejects missing values", () => {
expect(sanitizeHttpUrl(undefined)).toBeUndefined();
expect(sanitizeHttpUrl(null)).toBeUndefined();
expect(sanitizeHttpUrl("")).toBeUndefined();
});
});
describe("isHomeAssistantUrl", () => {
it("detects the Home Assistant scheme", () => {
expect(isHomeAssistantUrl("homeassistant://config/system")).toBe(true);
expect(isHomeAssistantUrl("https://www.home-assistant.io/")).toBe(false);
expect(isHomeAssistantUrl(undefined)).toBe(false);
});
});
describe("homeAssistantUrlToPath", () => {
it("rewrites a deep link to an in-app path", () => {
expect(homeAssistantUrlToPath("homeassistant://config/system")).toEqual(
"/config/system"
);
expect(homeAssistantUrlToPath("homeassistant://config/network")).toEqual(
"/config/network"
);
});
it("rejects a deep link that leaves the frontend", () => {
// A plain scheme rewrite would turn this into "//example.com".
expect(
homeAssistantUrlToPath("homeassistant:///example.com")
).toBeUndefined();
expect(
homeAssistantUrlToPath("homeassistant:///\\example.com")
).toBeUndefined();
});
it("rejects anything that is not a deep link", () => {
expect(homeAssistantUrlToPath("https://example.com/")).toBeUndefined();
expect(homeAssistantUrlToPath(undefined)).toBeUndefined();
});
});
describe("sanitizeLinkUrl", () => {
it("handles both external links and deep links", () => {
expect(sanitizeLinkUrl("https://example.com/docs")).toEqual(
"https://example.com/docs"
);
expect(sanitizeLinkUrl("homeassistant://config/system")).toEqual(
"/config/system"
);
// eslint-disable-next-line no-script-url
expect(sanitizeLinkUrl("javascript:alert(1)")).toBeUndefined();
expect(sanitizeLinkUrl("homeassistant:///example.com")).toBeUndefined();
});
});
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, it, vi } from "vitest";
import type { IntegrationManifest } from "../../src/data/integration";
import {
fetchIntegrationManifest,
fetchIntegrationManifests,
integrationIssuesUrl,
} from "../../src/data/integration";
import type { HomeAssistant } from "../../src/types";
const manifest = (
overrides: Partial<IntegrationManifest> = {}
): IntegrationManifest =>
({
domain: "evil",
name: "Evil",
is_built_in: false,
config_flow: false,
iot_class: "local_polling",
documentation: "https://example.com/docs",
...overrides,
}) as IntegrationManifest;
const hassWith = (result: unknown) =>
({ callWS: vi.fn().mockResolvedValue(result) }) as unknown as HomeAssistant;
// A custom integration ships its own manifest, so these URLs are untrusted.
/* eslint-disable no-script-url */
const UNSAFE_URL = "javascript:alert(1)";
describe("integration manifests", () => {
it("strips unsafe URLs from a fetched list", async () => {
const hass = hassWith([
manifest({ documentation: UNSAFE_URL, issue_tracker: UNSAFE_URL }),
]);
const [fetched] = await fetchIntegrationManifests(hass);
expect(fetched.documentation).toBeUndefined();
expect(fetched.issue_tracker).toBeUndefined();
});
it("strips unsafe URLs from a single fetched manifest", async () => {
const hass = hassWith(manifest({ documentation: UNSAFE_URL }));
expect((await fetchIntegrationManifest(hass, "evil"))!.documentation).toBe(
undefined
);
});
it("keeps http and https URLs", async () => {
const hass = hassWith([
manifest({
documentation: "https://example.com/docs",
issue_tracker: "http://example.com/issues",
}),
]);
const [fetched] = await fetchIntegrationManifests(hass);
expect(fetched.documentation).toEqual("https://example.com/docs");
expect(fetched.issue_tracker).toEqual("http://example.com/issues");
});
it("does not mutate the received manifest", async () => {
const received = manifest({ documentation: UNSAFE_URL });
const hass = hassWith([received]);
await fetchIntegrationManifests(hass);
expect(received.documentation).toEqual(UNSAFE_URL);
});
it("falls back to the core issue tracker for an unsafe issue_tracker", () => {
expect(
integrationIssuesUrl("evil", manifest({ issue_tracker: UNSAFE_URL }))
).toContain("https://github.com/home-assistant/core/issues");
});
});
/* eslint-enable no-script-url */
@@ -1,5 +1,12 @@
import { render } from "lit";
import { assert, describe, it } from "vitest";
import { getPowerHelperEntityId } from "../../../../src/panels/config/energy/dialogs/power-config";
import "../../../../src/panels/config/energy/dialogs/ha-energy-power-config";
import type { HaEnergyPowerConfig } from "../../../../src/panels/config/energy/dialogs/ha-energy-power-config";
import type { PowerConfig } from "../../../../src/data/energy";
import {
getPowerHelperEntityId,
type PowerType,
} from "../../../../src/panels/config/energy/dialogs/power-config";
describe("getPowerHelperEntityId", () => {
it("returns the helper for an inverted config", () => {
@@ -69,3 +76,100 @@ describe("getPowerHelperEntityId", () => {
);
});
});
// Renders the template directly so the async unit lookup in willUpdate is
// skipped. localize echoes the key back.
const renderPickers = (powerType: PowerType, powerConfig: PowerConfig) => {
const el = document.createElement(
"ha-energy-power-config"
) as HaEnergyPowerConfig;
el.hass = { localize: (key: string) => key } as any;
el.powerType = powerType;
el.powerConfig = powerConfig;
const container = document.createElement("div");
render((el as any).render(), container);
return [...container.querySelectorAll("ha-statistic-picker")].map(
(picker: any) => ({
required: picker.required,
invalid: picker.invalid,
errorMessage: picker.errorMessage,
})
);
};
describe("ha-energy-power-config required power statistic", () => {
it("renders no picker when no power sensor is configured", () => {
assert.lengthOf(renderPickers("none", {}), 0);
});
it("marks an empty standard statistic as required and invalid", () => {
assert.deepEqual(renderPickers("standard", {}), [
{
required: true,
invalid: true,
errorMessage: "ui.common.error_required",
},
]);
});
it("keeps the statistic required but valid once it is set", () => {
const [picker] = renderPickers("standard", { stat_rate: "sensor.power" });
assert.isTrue(picker.required);
assert.isFalse(picker.invalid);
});
it("marks an empty inverted statistic as required and invalid", () => {
const [picker] = renderPickers("inverted", {});
assert.isTrue(picker.required);
assert.isTrue(picker.invalid);
});
it("does not flag the inverted statistic when it is set", () => {
const [picker] = renderPickers("inverted", {
stat_rate_inverted: "sensor.power",
});
assert.isFalse(picker.invalid);
});
it("flags both two sensor statistics while they are empty", () => {
const pickers = renderPickers("two_sensors", {});
assert.lengthOf(pickers, 2);
assert.deepEqual(
pickers.map((p) => p.invalid),
[true, true]
);
});
// The two sensor statistics exclude each other, so they keep their clear
// button — and therefore no required marker — to stay swappable.
it("does not mark the two sensor statistics as required", () => {
const pickers = renderPickers("two_sensors", {});
assert.deepEqual(
pickers.map((p) => p.required),
[false, false]
);
});
it("flags only the statistic that is still missing", () => {
const pickers = renderPickers("two_sensors", {
stat_rate_from: "sensor.power_from",
});
assert.deepEqual(
pickers.map((p) => p.invalid),
[false, true]
);
});
it("clears both flags once the two sensor pair is complete", () => {
const pickers = renderPickers("two_sensors", {
stat_rate_from: "sensor.power_from",
stat_rate_to: "sensor.power_to",
});
assert.deepEqual(
pickers.map((p) => p.invalid),
[false, false]
);
});
});
@@ -0,0 +1,43 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { handleAction } from "../../../src/panels/lovelace/common/handle-action";
import type { HomeAssistant } from "../../../src/types";
const hass = { localize: (key: string) => key } as unknown as HomeAssistant;
const openUrl = (url: string) => {
const open = vi.spyOn(window, "open").mockImplementation(() => null);
open.mockClear();
handleAction(
document.createElement("div"),
hass,
{ tap_action: { action: "url", url_path: url } },
"tap"
);
return open.mock.calls[0]?.[0];
};
afterEach(() => {
vi.restoreAllMocks();
});
describe("handleAction url", () => {
it("opens a configured URL", () => {
expect(openUrl("https://example.com/page")).toEqual(
"https://example.com/page"
);
expect(openUrl("mailto:someone@example.com")).toEqual(
"mailto:someone@example.com"
);
});
/* eslint-disable no-script-url */
it("does not open a URL that runs script", () => {
expect(openUrl("javascript:alert(1)")).toEqual("about:blank");
expect(openUrl("JaVaScRiPt:alert(1)")).toEqual("about:blank");
expect(openUrl("data:text/html,<script>alert(1)</script>")).toEqual(
"about:blank"
);
});
/* eslint-enable no-script-url */
});
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import "../../../src/panels/lovelace/cards/hui-iframe-card";
import "../../../src/panels/lovelace/special-rows/hui-weblink-row";
/* eslint-disable no-script-url */
const UNSAFE_URLS = [
"javascript:alert(1)",
"JaVaScRiPt:alert(1)",
"java\tscript:alert(1)",
"data:text/html,<script>alert(1)</script>",
"vbscript:msgbox(1)",
];
/* eslint-enable no-script-url */
const SAFE_URLS = [
"https://example.com/page",
"http://192.168.1.5:8080/",
"mailto:someone@example.com",
"/local/page.html",
// Forms that sanitizing rewrites, so they must not be compared to the input
"http://example.com",
"https://example.com/foo bar",
"https://Example.com/Path",
];
describe("hui-weblink-row config", () => {
const row = () => document.createElement("hui-weblink-row") as any;
it.each(SAFE_URLS)("accepts %s", (url) => {
expect(() => row().setConfig({ url })).not.toThrow();
});
it.each(UNSAFE_URLS)("rejects %s", (url) => {
expect(() => row().setConfig({ url })).toThrow("Invalid URL");
});
});
describe("hui-iframe-card config", () => {
const card = () => document.createElement("hui-iframe-card") as any;
it.each(SAFE_URLS)("accepts %s", (url) => {
expect(() => card().setConfig({ type: "iframe", url })).not.toThrow();
});
it.each(UNSAFE_URLS)("rejects %s", (url) => {
expect(() => card().setConfig({ type: "iframe", url })).toThrow(
"Invalid URL"
);
});
});