mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-01 03:45:51 +00:00
Compare commits
15 Commits
20260729.1
..
rc
| Author | SHA1 | Date | |
|---|---|---|---|
| 1454f4d081 | |||
| 1a47c326b2 | |||
| d8e827e08b | |||
| 301c907fa9 | |||
| 1ef3bc94d0 | |||
| ba0367be2f | |||
| c186a31056 | |||
| bf8c92c95e | |||
| 9743117abf | |||
| 5e012973b2 | |||
| ea659f1b33 | |||
| 539803cb5b | |||
| 944d3332d1 | |||
| 4a267d160f | |||
| 7edb9f8164 |
@@ -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
@@ -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";
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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" });
|
||||
|
||||
@@ -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 +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,
|
||||
|
||||
@@ -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"
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:"
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 */
|
||||
});
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user