mirror of
https://github.com/home-assistant/frontend.git
synced 2026-04-02 00:27:49 +01:00
* migrate ha-select to ha-dropdown * remove ha-menu * review * Fix eslint error --------- Co-authored-by: Aidan Timson <aidan@timmo.dev>
550 lines
16 KiB
TypeScript
550 lines
16 KiB
TypeScript
import "@material/mwc-linear-progress/mwc-linear-progress";
|
|
import type { Auth } from "home-assistant-js-websocket";
|
|
import {
|
|
createConnection,
|
|
genClientId,
|
|
getAuth,
|
|
subscribeConfig,
|
|
} from "home-assistant-js-websocket";
|
|
import type { PropertyValues } from "lit";
|
|
import { css, html, nothing } from "lit";
|
|
import { customElement, property, state } from "lit/decorators";
|
|
import {
|
|
enableWrite,
|
|
loadTokens,
|
|
saveTokens,
|
|
} from "../common/auth/token_storage";
|
|
import { applyThemesOnElement } from "../common/dom/apply_themes_on_element";
|
|
import type { HASSDomEvent } from "../common/dom/fire_event";
|
|
import {
|
|
addSearchParam,
|
|
extractSearchParam,
|
|
extractSearchParamsObject,
|
|
} from "../common/url/search-params";
|
|
import { subscribeOne } from "../common/util/subscribe-one";
|
|
import "../components/ha-card";
|
|
import type { AuthUrlSearchParams } from "../data/auth";
|
|
import { hassUrl } from "../data/auth";
|
|
import { saveFrontendSystemData } from "../data/frontend";
|
|
import type { OnboardingResponses, OnboardingStep } from "../data/onboarding";
|
|
import {
|
|
fetchInstallationType,
|
|
fetchOnboardingOverview,
|
|
onboardIntegrationStep,
|
|
} from "../data/onboarding";
|
|
import { subscribeUser } from "../data/ws-user";
|
|
import { litLocalizeLiteMixin } from "../mixins/lit-localize-lite-mixin";
|
|
import { HassElement } from "../state/hass-element";
|
|
import type { HomeAssistant } from "../types";
|
|
import { storeState } from "../util/ha-pref-storage";
|
|
import { registerServiceWorker } from "../util/register-service-worker";
|
|
import "./onboarding-analytics";
|
|
import "./onboarding-create-user";
|
|
import "./onboarding-loading";
|
|
import "./onboarding-welcome";
|
|
import "./onboarding-welcome-links";
|
|
import { makeDialogManager } from "../dialogs/make-dialog-manager";
|
|
import { navigate } from "../common/navigate";
|
|
import { mainWindow } from "../common/dom/get_main_window";
|
|
|
|
type OnboardingEvent =
|
|
| {
|
|
type: "init";
|
|
result?: { restore: "upload" | "cloud" };
|
|
}
|
|
| {
|
|
type: "user";
|
|
result: OnboardingResponses["user"];
|
|
}
|
|
| {
|
|
type: "core_config";
|
|
result: OnboardingResponses["core_config"];
|
|
}
|
|
| {
|
|
type: "integration";
|
|
}
|
|
| {
|
|
type: "analytics";
|
|
};
|
|
|
|
interface OnboardingProgressEvent {
|
|
increase?: number;
|
|
decrease?: number;
|
|
progress?: number;
|
|
}
|
|
|
|
declare global {
|
|
interface HASSDomEvents {
|
|
"onboarding-step": OnboardingEvent;
|
|
"onboarding-progress": OnboardingProgressEvent;
|
|
}
|
|
|
|
interface GlobalEventHandlersEventMap {
|
|
"onboarding-step": HASSDomEvent<OnboardingEvent>;
|
|
"onboarding-progress": HASSDomEvent<OnboardingProgressEvent>;
|
|
}
|
|
}
|
|
|
|
@customElement("ha-onboarding")
|
|
class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
|
|
@property({ attribute: false }) public hass?: HomeAssistant;
|
|
|
|
@property({ attribute: false }) public translationFragment =
|
|
"page-onboarding";
|
|
|
|
@state() private _progress = 0;
|
|
|
|
@state() private _loading = false;
|
|
|
|
@state() private _init = false;
|
|
|
|
@state() private _restoring?: "upload" | "cloud";
|
|
|
|
@state() private _supervisor?: boolean;
|
|
|
|
@state() private _steps?: OnboardingStep[];
|
|
|
|
@state() private _page = extractSearchParam("page");
|
|
|
|
private _mobileApp =
|
|
extractSearchParam("redirect_uri") === "homeassistant://auth-callback";
|
|
|
|
connectedCallback() {
|
|
super.connectedCallback();
|
|
mainWindow.addEventListener("location-changed", this._updatePage);
|
|
mainWindow.addEventListener("popstate", this._updatePage);
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
super.disconnectedCallback();
|
|
mainWindow.removeEventListener("location-changed", this._updatePage);
|
|
mainWindow.removeEventListener("popstate", this._updatePage);
|
|
}
|
|
|
|
private _updatePage = () => {
|
|
this._page = extractSearchParam("page");
|
|
};
|
|
|
|
protected render() {
|
|
return html`<mwc-linear-progress
|
|
.progress=${this._progress}
|
|
></mwc-linear-progress>
|
|
<ha-card>
|
|
<div class="card-content">${this._renderStep()}</div>
|
|
</ha-card>
|
|
${this._init && !this._restoring
|
|
? html`<onboarding-welcome-links
|
|
.localize=${this.localize}
|
|
.mobileApp=${this._mobileApp}
|
|
></onboarding-welcome-links>`
|
|
: nothing}
|
|
<div class="footer">
|
|
<ha-language-picker
|
|
.value=${this.language}
|
|
.label=${""}
|
|
native-name
|
|
@value-changed=${this._languageChanged}
|
|
></ha-language-picker>
|
|
<a
|
|
href="https://www.home-assistant.io/getting-started/onboarding/"
|
|
target="_blank"
|
|
rel="noreferrer noopener"
|
|
>${this.localize("ui.panel.page-onboarding.help")}</a
|
|
>
|
|
</div>`;
|
|
}
|
|
|
|
private _renderStep() {
|
|
if (this._restoring) {
|
|
return html`<onboarding-restore-backup
|
|
.localize=${this.localize}
|
|
.supervisor=${this._supervisor ?? false}
|
|
.mode=${this._restoring}
|
|
>
|
|
</onboarding-restore-backup>`;
|
|
}
|
|
|
|
if (this._init) {
|
|
return html`<onboarding-welcome
|
|
.localize=${this.localize}
|
|
></onboarding-welcome>`;
|
|
}
|
|
|
|
const step = this._curStep()!;
|
|
|
|
if (this._loading || !step) {
|
|
return html`<onboarding-loading></onboarding-loading>`;
|
|
}
|
|
if (step.step === "user") {
|
|
return html`<onboarding-create-user
|
|
.localize=${this.localize}
|
|
.language=${this.language}
|
|
>
|
|
</onboarding-create-user>`;
|
|
}
|
|
if (step.step === "core_config") {
|
|
return html`
|
|
<onboarding-core-config
|
|
.hass=${this.hass}
|
|
.onboardingLocalize=${this.localize}
|
|
></onboarding-core-config>
|
|
`;
|
|
}
|
|
if (step.step === "analytics") {
|
|
return html`
|
|
<onboarding-analytics
|
|
.hass=${this.hass}
|
|
.localize=${this.localize}
|
|
></onboarding-analytics>
|
|
`;
|
|
}
|
|
if (step.step === "integration") {
|
|
return html`
|
|
<onboarding-integrations
|
|
.hass=${this.hass}
|
|
.onboardingLocalize=${this.localize}
|
|
></onboarding-integrations>
|
|
`;
|
|
}
|
|
return nothing;
|
|
}
|
|
|
|
protected firstUpdated(changedProps: PropertyValues) {
|
|
super.firstUpdated(changedProps);
|
|
this._fetchOnboardingSteps();
|
|
import("./onboarding-integrations");
|
|
import("./onboarding-core-config");
|
|
import("./onboarding-restore-backup");
|
|
registerServiceWorker(this, false);
|
|
this.addEventListener("onboarding-step", (ev) => this._handleStepDone(ev));
|
|
this.addEventListener("onboarding-progress", (ev) =>
|
|
this._handleProgress(ev)
|
|
);
|
|
if (
|
|
window.innerWidth > 450 &&
|
|
!matchMedia("(prefers-reduced-motion)").matches
|
|
) {
|
|
import("../resources/particles");
|
|
}
|
|
makeDialogManager(this, this.shadowRoot!);
|
|
import("../components/ha-language-picker");
|
|
}
|
|
|
|
protected updated(changedProps: PropertyValues) {
|
|
super.updated(changedProps);
|
|
if (changedProps.has("_page")) {
|
|
this._restoring =
|
|
this._page === "restore_backup"
|
|
? "upload"
|
|
: this._page === "restore_backup_cloud"
|
|
? "cloud"
|
|
: undefined;
|
|
if (this._page === null && this._steps && !this._steps[0].done) {
|
|
this._init = true;
|
|
}
|
|
}
|
|
if (changedProps.has("language")) {
|
|
document.querySelector("html")!.setAttribute("lang", this.language);
|
|
}
|
|
if (changedProps.has("hass")) {
|
|
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
|
|
this.hassChanged(this.hass!, oldHass);
|
|
if (oldHass?.themes !== this.hass!.themes) {
|
|
if (matchMedia("(prefers-color-scheme: dark)").matches) {
|
|
applyThemesOnElement(
|
|
document.documentElement,
|
|
{
|
|
default_theme: "default",
|
|
default_dark_theme: null,
|
|
themes: {},
|
|
darkMode: true,
|
|
theme: "default",
|
|
},
|
|
undefined,
|
|
undefined,
|
|
true
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private _curStep() {
|
|
return this._steps ? this._steps.find((stp) => !stp.done) : undefined;
|
|
}
|
|
|
|
private async _fetchInstallationType(): Promise<void> {
|
|
try {
|
|
const response = await fetchInstallationType();
|
|
this._supervisor = [
|
|
"Home Assistant OS",
|
|
"Home Assistant Supervised",
|
|
].includes(response.installation_type);
|
|
} catch (err: any) {
|
|
// eslint-disable-next-line no-console
|
|
console.error(
|
|
"Something went wrong loading onboarding-restore-backup",
|
|
err
|
|
);
|
|
}
|
|
}
|
|
|
|
private async _fetchOnboardingSteps() {
|
|
try {
|
|
const response = await (window.stepsPromise || fetchOnboardingOverview());
|
|
|
|
if (response.status === 401 || response.status === 404) {
|
|
// We don't load the component when onboarding is done
|
|
document.location.assign("/");
|
|
return;
|
|
}
|
|
|
|
const steps: OnboardingStep[] = await response.json();
|
|
|
|
if (steps.every((step) => step.done)) {
|
|
// Onboarding is done!
|
|
document.location.assign("/");
|
|
return;
|
|
}
|
|
|
|
if (steps[0].done) {
|
|
// First step is already done, so we need to get auth somewhere else.
|
|
const auth = await getAuth({
|
|
hassUrl,
|
|
limitHassInstance: true,
|
|
saveTokens,
|
|
loadTokens: () => Promise.resolve(loadTokens()),
|
|
});
|
|
history.replaceState(null, "", location.pathname);
|
|
await this._connectHass(auth);
|
|
const currentStep = steps.findIndex((stp) => !stp.done);
|
|
const singelStepProgress = 1 / steps.length;
|
|
this._progress = currentStep * singelStepProgress + singelStepProgress;
|
|
} else {
|
|
this._init = true;
|
|
// Init screen needs to know the installation type.
|
|
this._fetchInstallationType();
|
|
}
|
|
|
|
this._steps = steps;
|
|
} catch (_err: any) {
|
|
alert("Something went wrong loading onboarding, try refreshing");
|
|
}
|
|
}
|
|
|
|
private _handleProgress(ev: HASSDomEvent<OnboardingProgressEvent>) {
|
|
const stepSize = 1 / this._steps!.length;
|
|
if (ev.detail.increase) {
|
|
this._progress += ev.detail.increase * stepSize;
|
|
}
|
|
if (ev.detail.decrease) {
|
|
this._progress -= ev.detail.decrease * stepSize;
|
|
}
|
|
if (ev.detail.progress) {
|
|
this._progress = ev.detail.progress;
|
|
}
|
|
}
|
|
|
|
private async _handleStepDone(ev: HASSDomEvent<OnboardingEvent>) {
|
|
const stepResult = ev.detail;
|
|
this._steps = this._steps!.map((step) =>
|
|
step.step === stepResult.type ? { ...step, done: true } : step
|
|
);
|
|
|
|
if (stepResult.type === "init") {
|
|
this._init = false;
|
|
this._restoring = stepResult.result?.restore;
|
|
if (!this._restoring) {
|
|
this._progress = 0.25;
|
|
} else {
|
|
navigate(
|
|
`${location.pathname}?${addSearchParam({ page: `restore_backup${this._restoring === "cloud" ? "_cloud" : ""}` })}`
|
|
);
|
|
}
|
|
} else if (stepResult.type === "user") {
|
|
const result = stepResult.result as OnboardingResponses["user"];
|
|
this._loading = true;
|
|
this._progress = 0.5;
|
|
enableWrite();
|
|
try {
|
|
const auth = await getAuth({
|
|
hassUrl,
|
|
limitHassInstance: true,
|
|
authCode: result.auth_code,
|
|
saveTokens,
|
|
});
|
|
await this._connectHass(auth);
|
|
} catch (_err: any) {
|
|
alert("Ah snap, something went wrong!");
|
|
location.reload();
|
|
} finally {
|
|
this._loading = false;
|
|
}
|
|
} else if (stepResult.type === "core_config") {
|
|
this._progress = 0.75;
|
|
// We do nothing
|
|
} else if (stepResult.type === "analytics") {
|
|
this._progress = 1;
|
|
// We do nothing
|
|
} else if (stepResult.type === "integration") {
|
|
this._loading = true;
|
|
|
|
// Determine if oauth redirect has been provided
|
|
const externalAuthParams =
|
|
extractSearchParamsObject() as AuthUrlSearchParams;
|
|
const authParams =
|
|
externalAuthParams.client_id && externalAuthParams.redirect_uri
|
|
? externalAuthParams
|
|
: {
|
|
client_id: genClientId(),
|
|
redirect_uri: `${location.protocol}//${location.host}/?auth_callback=1`,
|
|
state: btoa(
|
|
JSON.stringify({
|
|
hassUrl: `${location.protocol}//${location.host}`,
|
|
clientId: genClientId(),
|
|
})
|
|
),
|
|
};
|
|
|
|
await saveFrontendSystemData(this.hass!.connection, "core", {
|
|
onboarded_version: this.hass!.config.version,
|
|
onboarded_date: new Date().toISOString(),
|
|
});
|
|
|
|
let result: OnboardingResponses["integration"];
|
|
|
|
try {
|
|
result = await onboardIntegrationStep(this.hass!, {
|
|
client_id: authParams.client_id!,
|
|
redirect_uri: authParams.redirect_uri!,
|
|
});
|
|
} catch (err: any) {
|
|
this.hass!.connection.close();
|
|
await this.hass!.auth.revoke();
|
|
|
|
alert(`Unable to finish onboarding: ${err.message}`);
|
|
|
|
document.location.assign("/?");
|
|
return;
|
|
}
|
|
|
|
// If we don't close the connection manually, the connection will be
|
|
// closed when we navigate away from the page. Firefox allows JS to
|
|
// continue to execute, and so HAWS will automatically reconnect once
|
|
// the connection is closed. However, since we revoke our token below,
|
|
// HAWS will reload the page, since that will trigger the auth flow.
|
|
// In Firefox, triggering a reload will overrule the navigation that
|
|
// was in progress.
|
|
this.hass!.connection.close();
|
|
|
|
// Revoke current auth token.
|
|
await this.hass!.auth.revoke();
|
|
|
|
// Build up the url to redirect to
|
|
let redirectUrl = authParams.redirect_uri!;
|
|
redirectUrl +=
|
|
(redirectUrl.includes("?") ? "&" : "?") +
|
|
`code=${encodeURIComponent(result.auth_code)}&storeToken=true`;
|
|
|
|
if (authParams.state) {
|
|
redirectUrl += `&state=${encodeURIComponent(authParams.state)}`;
|
|
}
|
|
|
|
document.location.assign(redirectUrl);
|
|
}
|
|
}
|
|
|
|
private async _connectHass(auth: Auth) {
|
|
const conn = await createConnection({ auth });
|
|
// Make sure config and user info is loaded before we initialize.
|
|
// It is needed for the core config step.
|
|
await Promise.all([
|
|
subscribeOne(conn, subscribeConfig),
|
|
subscribeOne(conn, subscribeUser),
|
|
]);
|
|
this.initializeHass(auth, conn);
|
|
if (this.language !== this.hass!.language) {
|
|
this._updateHass({
|
|
locale: { ...this.hass!.locale, language: this.language },
|
|
language: this.language,
|
|
selectedLanguage: this.language,
|
|
});
|
|
storeState(this.hass!);
|
|
}
|
|
// Load config strings for integrations
|
|
(this as any)._loadFragmentTranslations(this.hass!.language, "config");
|
|
// Make sure hass is initialized + the config/user callbacks have called.
|
|
await new Promise((resolve) => {
|
|
setTimeout(resolve, 0);
|
|
});
|
|
}
|
|
|
|
private _languageChanged(ev: CustomEvent) {
|
|
const language = ev.detail.value;
|
|
this.language = language;
|
|
if (this.hass) {
|
|
this._updateHass({
|
|
locale: { ...this.hass!.locale, language },
|
|
language,
|
|
selectedLanguage: language,
|
|
});
|
|
storeState(this.hass!);
|
|
} else {
|
|
try {
|
|
window.localStorage.setItem(
|
|
"selectedLanguage",
|
|
JSON.stringify(language)
|
|
);
|
|
} catch (_err: any) {
|
|
// Ignore
|
|
}
|
|
}
|
|
}
|
|
|
|
static styles = css`
|
|
.card-content {
|
|
padding: 32px;
|
|
}
|
|
mwc-linear-progress {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
z-index: 10;
|
|
}
|
|
.footer {
|
|
padding-top: 8px;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
ha-language-picker {
|
|
display: block;
|
|
width: 200px;
|
|
border-radius: var(--ha-border-radius-sm);
|
|
overflow: hidden;
|
|
--ha-select-height: 40px;
|
|
--mdc-select-fill-color: none;
|
|
--mdc-select-label-ink-color: var(--primary-text-color, #212121);
|
|
--mdc-select-ink-color: var(--primary-text-color, #212121);
|
|
--mdc-select-idle-line-color: transparent;
|
|
--mdc-select-hover-line-color: transparent;
|
|
--mdc-select-dropdown-icon-color: var(--primary-text-color, #212121);
|
|
--mdc-shape-small: 0;
|
|
}
|
|
a {
|
|
text-decoration: none;
|
|
color: var(--primary-text-color);
|
|
margin-right: 16px;
|
|
margin-inline-end: 16px;
|
|
margin-inline-start: initial;
|
|
}
|
|
`;
|
|
}
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
"ha-onboarding": HaOnboarding;
|
|
}
|
|
}
|