diff --git a/src/panels/config/backup/components/ha-backup-config-data.ts b/src/panels/config/backup/components/ha-backup-config-data.ts index 66834351c141..99ec42c05478 100644 --- a/src/panels/config/backup/components/ha-backup-config-data.ts +++ b/src/panels/config/backup/components/ha-backup-config-data.ts @@ -84,7 +84,7 @@ class HaBackupConfigData extends LitElement { ]; } - private getData = memoizeOne((value?: BackupConfigData): FormData => { + private _getData = memoizeOne((value?: BackupConfigData): FormData => { if (!value) { return INITIAL_FORM_DATA; } @@ -111,7 +111,7 @@ class HaBackupConfigData extends LitElement { }; }); - private setData(data: FormData) { + private _setData(data: FormData) { const hasSelfCreatedAddons = data.addons.includes(SELF_CREATED_ADDONS_NAME); const include_folders = [ @@ -140,7 +140,7 @@ class HaBackupConfigData extends LitElement { } protected render() { - const data = this.getData(this.value); + const data = this._getData(this.value); const isHassio = isComponentLoaded(this.hass, "hassio"); @@ -263,8 +263,8 @@ class HaBackupConfigData extends LitElement { private _switchChanged(ev: Event) { const target = ev.currentTarget as HaSwitch; - const data = this.getData(this.value); - this.setData({ + const data = this._getData(this.value); + this._setData({ ...data, [target.id]: target.checked, }); @@ -273,8 +273,8 @@ class HaBackupConfigData extends LitElement { private _selectChanged(ev: Event) { const target = ev.currentTarget as HaMdSelect; - const data = this.getData(this.value); - this.setData({ + const data = this._getData(this.value); + this._setData({ ...data, [target.id]: target.value, }); @@ -284,8 +284,8 @@ class HaBackupConfigData extends LitElement { private _addonsChanged(ev: CustomEvent) { ev.stopPropagation(); const addons = ev.detail.value; - const data = this.getData(this.value); - this.setData({ + const data = this._getData(this.value); + this._setData({ ...data, addons, }); diff --git a/src/panels/config/backup/dialogs/dialog-backup-onboarding.ts b/src/panels/config/backup/dialogs/dialog-backup-onboarding.ts new file mode 100644 index 000000000000..b3b6a23e31fc --- /dev/null +++ b/src/panels/config/backup/dialogs/dialog-backup-onboarding.ts @@ -0,0 +1,448 @@ +import { mdiClose, mdiDownload, mdiKey } from "@mdi/js"; +import type { CSSResultGroup } from "lit"; +import { LitElement, css, html, nothing } from "lit"; +import { customElement, property, query, state } from "lit/decorators"; +import { isComponentLoaded } from "../../../../common/config/is_component_loaded"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import "../../../../components/ha-button"; +import "../../../../components/ha-dialog-header"; +import "../../../../components/ha-icon-button"; +import "../../../../components/ha-icon-button-prev"; +import "../../../../components/ha-md-dialog"; +import type { HaMdDialog } from "../../../../components/ha-md-dialog"; +import "../../../../components/ha-md-list"; +import "../../../../components/ha-md-list-item"; +import "../../../../components/ha-password-field"; +import "../../../../components/ha-svg-icon"; +import type { + BackupConfig, + BackupMutableConfig, +} from "../../../../data/backup"; +import { + BackupScheduleState, + generateEncryptionKey, + updateBackupConfig, +} from "../../../../data/backup"; +import type { HassDialog } from "../../../../dialogs/make-dialog-manager"; +import { haStyle, haStyleDialog } from "../../../../resources/styles"; +import type { HomeAssistant } from "../../../../types"; +import { fileDownload } from "../../../../util/file_download"; +import { showToast } from "../../../../util/toast"; +import "../components/ha-backup-config-agents"; +import "../components/ha-backup-config-data"; +import type { BackupConfigData } from "../components/ha-backup-config-data"; +import "../components/ha-backup-config-schedule"; +import type { BackupConfigSchedule } from "../components/ha-backup-config-schedule"; +import type { SetBackupEncryptionKeyDialogParams } from "./show-dialog-set-backup-encryption-key"; + +const STEPS = ["new_key", "save_key", "schedule", "data", "locations"] as const; + +type Step = (typeof STEPS)[number]; + +const INITIAL_CONFIG: BackupConfig = { + create_backup: { + agent_ids: [], + include_addons: null, + include_all_addons: false, + include_database: true, + include_folders: null, + name: null, + password: null, + }, + retention: { + copies: 3, + days: null, + }, + schedule: { + state: BackupScheduleState.DAILY, + }, + last_automatic_backup: null, +}; + +@customElement("ha-dialog-backup-onboarding") +class DialogSetBackupEncryptionKey extends LitElement implements HassDialog { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _opened = false; + + @state() private _step?: Step; + + @state() private _params?: SetBackupEncryptionKeyDialogParams; + + @query("ha-md-dialog") private _dialog!: HaMdDialog; + + @state() private _config?: BackupConfig; + + private _suggestedEncryptionKey?: string; + + public showDialog(params: SetBackupEncryptionKeyDialogParams): void { + this._params = params; + this._step = STEPS[0]; + this._config = INITIAL_CONFIG; + this._opened = true; + this._suggestedEncryptionKey = generateEncryptionKey(); + } + + public closeDialog(): void { + if (this._params!.cancel) { + this._params!.cancel(); + } + if (this._opened) { + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + this._opened = false; + this._step = undefined; + this._config = undefined; + this._params = undefined; + this._suggestedEncryptionKey = undefined; + } + + private async _done() { + if (!this._config) { + return; + } + + const params: BackupMutableConfig = { + create_backup: { + password: this._config.create_backup.password, + include_database: this._config.create_backup.include_database, + agent_ids: this._config.create_backup.agent_ids, + }, + schedule: this._config.schedule.state, + retention: this._config.retention, + }; + + if (isComponentLoaded(this.hass, "hassio")) { + params.create_backup!.include_folders = + this._config.create_backup.include_folders || []; + params.create_backup!.include_all_addons = + this._config.create_backup.include_all_addons; + params.create_backup!.include_addons = + this._config.create_backup.include_addons || []; + } + + try { + await updateBackupConfig(this.hass, params); + + this._params?.submit!(true); + this._dialog.close(); + } catch (err) { + // eslint-disable-next-line no-console + console.error(err); + showToast(this, { message: "Failed to save backup configuration" }); + } + } + + private _previousStep() { + const index = STEPS.indexOf(this._step!); + if (index === 0) { + return; + } + this._step = STEPS[index - 1]; + } + + private _nextStep() { + const index = STEPS.indexOf(this._step!); + if (index === STEPS.length - 1) { + return; + } + this._step = STEPS[index + 1]; + } + + protected render() { + if (!this._opened || !this._params) { + return nothing; + } + + const isLastStep = this._step === STEPS[STEPS.length - 1]; + const isFirstStep = this._step === STEPS[0]; + + return html` + + + ${isFirstStep + ? html` + + ` + : html` + + `} + + ${this._stepTitle} + +
${this._renderStepContent()}
+
+ ${isLastStep + ? html` + + Save + + ` + : html` + + Next + + `} +
+
+ `; + } + + private get _stepTitle(): string { + switch (this._step) { + case "new_key": + return "Encryption key"; + case "save_key": + return "Save encryption key"; + case "schedule": + return "Automatic backups"; + case "data": + return "Backup data"; + case "locations": + return "Locations"; + default: + return ""; + } + } + + private _isStepValid(): boolean { + switch (this._step) { + case "new_key": + return !!this._config?.create_backup.password; + case "save_key": + return true; + case "schedule": + return !!this._config?.schedule; + case "data": + return !!this._config?.schedule; + case "locations": + return !!this._config?.create_backup.agent_ids.length; + default: + return true; + } + } + + private _renderStepContent() { + if (!this._config) { + return nothing; + } + + switch (this._step) { + case "new_key": + return html` +

+ All your backups are encrypted to keep your data private and secure. + You need this encryption key to restore any backup. +

+ + + + + Use suggested encryption key + + ${this._suggestedEncryptionKey} + + + Enter + + + + `; + case "save_key": + return html` +

+ It’s important that you don’t lose this encryption key. We recommend + to save this key somewhere secure. As you can only restore your data + with the backup encryption key. +

+ + + Download emergency kit + + We recommend to save this encryption key somewhere secure. + + + + Download + + + + `; + case "schedule": + return html` +

+ Let Home Assistant take care of your backups by creating a scheduled + backup that also removes older copies. +

+ + `; + case "data": + return html` +

+ Choose what data to include in your backups. You can always change + this later. +

+ + `; + case "locations": + return html` +

+ Home Assistant will upload to these locations when this backup + strategy is used. You can use all locations for custom backups. +

+ + `; + } + return nothing; + } + + private _downloadKey() { + const key = this._config?.create_backup.password; + if (!key) { + return; + } + fileDownload( + "data:text/plain;charset=utf-8," + encodeURIComponent(key), + "emergency_kit.txt" + ); + } + + private _encryptionKeyChanged(ev) { + const value = ev.target.value; + this._setEncryptionKey(value); + } + + private _useSuggestedEncryptionKey() { + this._setEncryptionKey(this._suggestedEncryptionKey!); + } + + private _setEncryptionKey(value: string) { + this._config = { + ...this._config!, + create_backup: { + ...this._config!.create_backup, + password: value, + }, + }; + } + + private _dataConfig(config: BackupConfig): BackupConfigData { + const { + include_addons, + include_all_addons, + include_database, + include_folders, + } = config.create_backup; + + return { + include_homeassistant: true, + include_database, + include_folders: include_folders || undefined, + include_all_addons, + include_addons: include_addons || undefined, + }; + } + + private _dataChanged(ev) { + const data = ev.detail.value as BackupConfigData; + this._config = { + ...this._config!, + create_backup: { + ...this._config!.create_backup, + include_database: data.include_database, + include_folders: data.include_folders || null, + include_all_addons: data.include_all_addons, + include_addons: data.include_addons || null, + }, + }; + } + + private _scheduleChanged(ev) { + const value = ev.detail.value as BackupConfigSchedule; + this._config = { + ...this._config!, + schedule: value.schedule, + retention: value.retention, + }; + } + + private _agentsConfigChanged(ev) { + const agents = ev.detail.value as string[]; + this._config = { + ...this._config!, + create_backup: { + ...this._config!.create_backup, + agent_ids: agents, + }, + }; + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + haStyleDialog, + css` + ha-md-dialog { + width: 90vw; + max-width: 500px; + } + div[slot="content"] { + margin-top: -16px; + } + ha-md-list { + background: none; + --md-list-item-leading-space: 0; + --md-list-item-trailing-space: 0; + } + @media all and (max-width: 450px), all and (max-height: 500px) { + ha-md-dialog { + max-width: none; + } + div[slot="content"] { + margin-top: 0; + } + } + p { + margin-top: 0; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-dialog-backup-onboarding": DialogSetBackupEncryptionKey; + } +} diff --git a/src/panels/config/backup/dialogs/show-dialog-backup_onboarding.ts b/src/panels/config/backup/dialogs/show-dialog-backup_onboarding.ts new file mode 100644 index 000000000000..e8d88a4f4a61 --- /dev/null +++ b/src/panels/config/backup/dialogs/show-dialog-backup_onboarding.ts @@ -0,0 +1,36 @@ +import { fireEvent } from "../../../../common/dom/fire_event"; + +export interface BackupOnboardingDialogParams { + submit?: (value: boolean) => void; + cancel?: () => void; +} + +const loadDialog = () => import("./dialog-backup-onboarding"); + +export const showBackupOnboardingDialog = ( + element: HTMLElement, + params?: BackupOnboardingDialogParams +) => + new Promise((resolve) => { + const origCancel = params?.cancel; + const origSubmit = params?.submit; + fireEvent(element, "show-dialog", { + dialogTag: "ha-dialog-backup-onboarding", + dialogImport: loadDialog, + dialogParams: { + ...params, + cancel: () => { + resolve(false); + if (origCancel) { + origCancel(); + } + }, + submit: (value) => { + resolve(value); + if (origSubmit) { + origSubmit(value); + } + }, + }, + }); + }); diff --git a/src/panels/config/backup/ha-config-backup-dashboard.ts b/src/panels/config/backup/ha-config-backup-dashboard.ts index c6cfea082cf2..90b80fe5a931 100644 --- a/src/panels/config/backup/ha-config-backup-dashboard.ts +++ b/src/panels/config/backup/ha-config-backup-dashboard.ts @@ -13,6 +13,7 @@ import memoizeOne from "memoize-one"; import { isComponentLoaded } from "../../../common/config/is_component_loaded"; import { relativeTime } from "../../../common/datetime/relative_time"; import type { HASSDomEvent } from "../../../common/dom/fire_event"; +import { shouldHandleRequestSelectedEvent } from "../../../common/mwc/handle-request-selected-event"; import { navigate } from "../../../common/navigate"; import type { LocalizeFunc } from "../../../common/translations/localize"; import type { @@ -33,7 +34,6 @@ import { getSignedPath } from "../../../data/auth"; import type { BackupConfig, BackupContent, - BackupMutableConfig, GenerateBackupParams, } from "../../../data/backup"; import { @@ -44,7 +44,6 @@ import { getBackupDownloadUrl, getPreferredAgentForDownload, subscribeBackupEvents, - updateBackupConfig, } from "../../../data/backup"; import { extractApiErrorMessage } from "../../../data/hassio/common"; import { @@ -60,11 +59,10 @@ import { brandsUrl } from "../../../util/brands-url"; import { bytesToString } from "../../../util/bytes-to-string"; import { fileDownload } from "../../../util/file_download"; import "./components/ha-backup-summary-card"; +import { showBackupOnboardingDialog } from "./dialogs/show-dialog-backup_onboarding"; import { showGenerateBackupDialog } from "./dialogs/show-dialog-generate-backup"; import { showNewBackupDialog } from "./dialogs/show-dialog-new-backup"; -import { shouldHandleRequestSelectedEvent } from "../../../common/mwc/handle-request-selected-event"; import { showUploadBackupDialog } from "./dialogs/show-dialog-upload-backup"; -import { showSetBackupEncryptionKeyDialog } from "./dialogs/show-dialog-set-backup-encryption-key"; @customElement("ha-config-backup-dashboard") class HaConfigBackupDashboard extends SubscribeMixin(LitElement) { @@ -344,11 +342,6 @@ class HaConfigBackupDashboard extends SubscribeMixin(LitElement) { this._config = config; } - private async _updateBackupConfig(config: BackupMutableConfig) { - await updateBackupConfig(this.hass, config); - await this._fetchBackupConfig(); - } - private get _needsOnboarding() { return this._config && !this._config.create_backup.password; } @@ -363,19 +356,14 @@ class HaConfigBackupDashboard extends SubscribeMixin(LitElement) { private async _newBackup(): Promise { if (this._needsOnboarding) { - const success = await showSetBackupEncryptionKeyDialog(this, { - saveKey: (key) => { - this._updateBackupConfig({ - create_backup: { - password: key, - }, - }); - }, - }); + const success = await showBackupOnboardingDialog(this, {}); if (!success) { return; } } + + await this._fetchBackupConfig(); + const config = this._config!; const type = await showNewBackupDialog(this, { config }); @@ -480,15 +468,7 @@ class HaConfigBackupDashboard extends SubscribeMixin(LitElement) { } private async _onboardDefaultBackup() { - const success = await showSetBackupEncryptionKeyDialog(this, { - saveKey: (key) => { - this._updateBackupConfig({ - create_backup: { - password: key, - }, - }); - }, - }); + const success = await showBackupOnboardingDialog(this, {}); if (!success) { return; }