diff --git a/src/panels/profile/dialog-ha-mfa-module-setup-flow.ts b/src/panels/profile/dialog-ha-mfa-module-setup-flow.ts index 960d2cce79..d2e52a8b4f 100644 --- a/src/panels/profile/dialog-ha-mfa-module-setup-flow.ts +++ b/src/panels/profile/dialog-ha-mfa-module-setup-flow.ts @@ -1,9 +1,12 @@ import type { CSSResultGroup } from "lit"; import { css, html, LitElement, nothing } from "lit"; +import { ifDefined } from "lit/directives/if-defined"; import { customElement, property, state } from "lit/decorators"; import "../../components/ha-button"; -import "../../components/ha-dialog"; +import "../../components/ha-dialog-footer"; +import "../../components/ha-wa-dialog"; import "../../components/ha-form/ha-form"; +import type { HaFormSchema } from "../../components/ha-form/types"; import "../../components/ha-markdown"; import "../../components/ha-spinner"; import { autocompleteLoginFields } from "../../data/auth"; @@ -28,7 +31,7 @@ class HaMfaModuleSetupFlow extends LitElement { @state() private _loading = false; - @state() private _opened = false; + @state() private _open = false; @state() private _stepData: any = {}; @@ -39,7 +42,7 @@ class HaMfaModuleSetupFlow extends LitElement { public showDialog({ continueFlowId, mfaModuleId, dialogClosedCallback }) { this._instance = instance++; this._dialogClosedCallback = dialogClosedCallback; - this._opened = true; + this._open = true; const fetchStep = continueFlowId ? this.hass.callWS({ @@ -61,22 +64,29 @@ class HaMfaModuleSetupFlow extends LitElement { } public closeDialog() { - // Closed dialog by clicking on the overlay + this._open = false; + } + + private _dialogClosed() { if (this._step) { this._flowDone(); + return; } - this._opened = false; + + this._resetDialogState(); } protected render() { - if (!this._opened) { + if (this._instance === undefined) { return nothing; } return html` -
${this._errorMessage @@ -115,6 +125,7 @@ class HaMfaModuleSetupFlow extends LitElement { )} > ` : ""}`}
- ${this.hass.localize( - ["abort", "create_entry"].includes(this._step?.type || "") - ? "ui.panel.profile.mfa_setup.close" - : "ui.common.cancel" - )} - ${this._step?.type === "form" - ? html`${this.hass.localize( - "ui.panel.profile.mfa_setup.submit" - )}` - : nothing} -
+ + ${this.hass.localize( + ["abort", "create_entry"].includes(this._step?.type || "") + ? "ui.panel.profile.mfa_setup.close" + : "ui.common.cancel" + )} + ${this._step?.type === "form" + ? html`${this.hass.localize( + "ui.panel.profile.mfa_setup.submit" + )}` + : nothing} + + `; } @@ -162,9 +175,6 @@ class HaMfaModuleSetupFlow extends LitElement { .error { color: red; } - ha-dialog { - max-width: 500px; - } ha-markdown { --markdown-svg-background-color: white; --markdown-svg-color: black; @@ -177,9 +187,17 @@ class HaMfaModuleSetupFlow extends LitElement { ha-markdown-element p { text-align: center; } + ha-markdown-element svg { + display: block; + margin: 0 auto; + } ha-markdown-element code { background-color: transparent; } + ha-form { + display: block; + margin-top: var(--ha-space-4); + } ha-markdown-element > *:last-child { margin-bottom: revert; } @@ -206,6 +224,10 @@ class HaMfaModuleSetupFlow extends LitElement { } private _submitStep() { + if (this._isSubmitDisabled()) { + return; + } + this._loading = true; this._errorMessage = undefined; @@ -234,6 +256,62 @@ class HaMfaModuleSetupFlow extends LitElement { ); } + private _isSubmitDisabled() { + return this._loading || this._hasMissingRequiredFields(); + } + + private _hasMissingRequiredFields( + schema: readonly HaFormSchema[] = this._step?.type === "form" + ? this._step.data_schema + : [] + ): boolean { + for (const field of schema) { + if ("schema" in field) { + if (this._hasMissingRequiredFields(field.schema)) { + return true; + } + continue; + } + + if (!field.required) { + continue; + } + + if ( + field.default !== undefined || + field.description?.suggested_value !== undefined + ) { + continue; + } + + if (this._isEmptyValue(this._stepData[field.name])) { + return true; + } + } + + return false; + } + + private _isEmptyValue(value: unknown): boolean { + if (value === undefined || value === null) { + return true; + } + + if (typeof value === "string") { + return value.trim() === ""; + } + + if (Array.isArray(value)) { + return value.length === 0; + } + + if (typeof value === "object") { + return Object.keys(value as Record).length === 0; + } + + return false; + } + private _processStep(step) { if (!step.errors) step.errors = {}; this._step = step; @@ -251,12 +329,15 @@ class HaMfaModuleSetupFlow extends LitElement { this._dialogClosedCallback!({ flowFinished, }); + this._resetDialogState(); + } + private _resetDialogState() { this._errorMessage = undefined; this._step = undefined; this._stepData = {}; this._dialogClosedCallback = undefined; - this.closeDialog(); + this._instance = undefined; } private _computeStepTitle() { diff --git a/src/panels/profile/ha-long-lived-access-token-dialog.ts b/src/panels/profile/ha-long-lived-access-token-dialog.ts index 69db923096..652530a83b 100644 --- a/src/panels/profile/ha-long-lived-access-token-dialog.ts +++ b/src/panels/profile/ha-long-lived-access-token-dialog.ts @@ -1,17 +1,18 @@ -import { mdiContentCopy } from "@mdi/js"; +import { mdiContentCopy, mdiQrcode } from "@mdi/js"; import type { CSSResultGroup, TemplateResult } from "lit"; import { css, html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import { fireEvent } from "../../common/dom/fire_event"; -import { createCloseHeading } from "../../components/ha-dialog"; +import { copyToClipboard } from "../../common/util/copy-clipboard"; +import { withViewTransition } from "../../common/util/view-transition"; +import "../../components/ha-alert"; import "../../components/ha-textfield"; import "../../components/ha-button"; -import "../../components/ha-icon-button"; -import { haStyleDialog } from "../../resources/styles"; +import "../../components/ha-dialog-footer"; +import "../../components/ha-svg-icon"; +import "../../components/ha-wa-dialog"; import type { HomeAssistant } from "../../types"; import type { LongLivedAccessTokenDialogParams } from "./show-long-lived-access-token-dialog"; -import type { HaTextField } from "../../components/ha-textfield"; -import { copyToClipboard } from "../../common/util/copy-clipboard"; import { showToast } from "../../util/toast"; const QR_LOGO_URL = "/static/icons/favicon-192x192.png"; @@ -20,81 +21,221 @@ const QR_LOGO_URL = "/static/icons/favicon-192x192.png"; export class HaLongLivedAccessTokenDialog extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; - @state() private _params?: LongLivedAccessTokenDialogParams; - @state() private _qrCode?: TemplateResult; + @state() private _open = false; + + @state() private _renderDialog = false; + + @state() private _name = ""; + + @state() private _token?: string; + + private _createdCallback!: () => void; + + private _existingNames = new Set(); + + @state() private _loading = false; + + @state() private _errorMessage?: string; + public showDialog(params: LongLivedAccessTokenDialogParams): void { - this._params = params; + this._createdCallback = params.createdCallback; + this._existingNames = new Set( + params.existingNames.map((name) => this._normalizeName(name)) + ); + this._renderDialog = true; + this._open = true; } public closeDialog() { - this._params = undefined; + this._open = false; + } + + private _dialogClosed() { + this._open = false; + this._renderDialog = false; + this._name = ""; + this._token = undefined; + this._existingNames = new Set(); + this._errorMessage = undefined; + this._loading = false; this._qrCode = undefined; fireEvent(this, "dialog-closed", { dialog: this.localName }); } protected render() { - if (!this._params || !this._params.token) { + if (!this._renderDialog) { return nothing; } return html` - -
- - - -
- ${this._qrCode - ? this._qrCode - : html` - - ${this.hass.localize( - "ui.panel.profile.long_lived_access_tokens.generate_qr_code" - )} + prevent-scrim-close + @closed=${this._dialogClosed} + > +
+ ${this._errorMessage + ? html`${this._errorMessage}` + : nothing} + ${this._token + ? html` +

+ ${this.hass.localize( + "ui.panel.profile.long_lived_access_tokens.prompt_copy_token" + )} +

+
+ + + + ${this.hass.localize("ui.common.copy")} - `} -
+
+
+ ${this._qrCode + ? this._qrCode + : html` + + + ${this.hass.localize( + "ui.panel.profile.long_lived_access_tokens.generate_qr_code" + )} + + `} +
+ ` + : html` + + `}
- + + ${this._token + ? nothing + : html` + ${this.hass.localize("ui.common.cancel")} + `} + ${!this._token + ? html` + ${this.hass.localize( + "ui.panel.profile.long_lived_access_tokens.create" + )} + ` + : html` + ${this.hass.localize("ui.common.close")} + `} + + `; } - private async _copyToken(ev): Promise { - const textField = ev.target.parentElement as HaTextField; - await copyToClipboard(textField.value); + private _nameChanged(ev: Event) { + this._name = (ev.currentTarget as HTMLInputElement).value; + this._errorMessage = undefined; + } + + private _isCreateDisabled() { + return this._loading || !this._name.trim() || this._hasDuplicateName(); + } + + private async _createToken(): Promise { + if (this._isCreateDisabled()) { + return; + } + + const name = this._name.trim(); + + this._loading = true; + this._errorMessage = undefined; + + try { + this._token = await this.hass.callWS({ + type: "auth/long_lived_access_token", + lifespan: 3650, + client_name: name, + }); + this._name = name; + this._createdCallback(); + } catch (err: unknown) { + this._errorMessage = err instanceof Error ? err.message : String(err); + } finally { + this._loading = false; + } + } + + private async _copyToken(): Promise { + if (!this._token) { + return; + } + + await copyToClipboard(this._token); showToast(this, { message: this.hass.localize("ui.common.copied_clipboard"), }); } + private _normalizeName(name: string): string { + return name.trim().toLowerCase(); + } + + private _hasDuplicateName(): boolean { + return this._existingNames.has(this._normalizeName(this._name)); + } + private async _generateQR() { + if (!this._token) { + return; + } + const qrcode = await import("qrcode"); - const canvas = await qrcode.toCanvas(this._params!.token, { - width: 180, + const canvas = await qrcode.toCanvas(this._token, { + width: 512, errorCorrectionLevel: "Q", }); const context = canvas.getContext("2d"); @@ -112,35 +253,46 @@ export class HaLongLivedAccessTokenDialog extends LitElement { canvas.height / 3 ); - this._qrCode = html`${this.hass.localize(`; + await withViewTransition(() => { + this._qrCode = html`${this.hass.localize(`; + }); } static get styles(): CSSResultGroup { return [ - haStyleDialog, css` #qr { text-align: center; } + #qr img { + max-width: 90%; + height: auto; + display: block; + margin: 0 auto; + } + .content { + display: grid; + gap: var(--ha-space-4); + } + .token-row { + display: flex; + gap: var(--ha-space-2); + align-items: center; + } + .token-row ha-textfield { + flex: 1; + } + p { + margin: 0; + } ha-textfield { display: block; - --textfield-icon-trailing-padding: 0; - } - ha-textfield > ha-icon-button { - position: relative; - right: -8px; - --mdc-icon-button-size: 36px; - --mdc-icon-size: 20px; - color: var(--secondary-text-color); - inset-inline-start: initial; - inset-inline-end: -8px; - direction: var(--direction); } `, ]; diff --git a/src/panels/profile/ha-long-lived-access-tokens-card.ts b/src/panels/profile/ha-long-lived-access-tokens-card.ts index f6941feb04..cfe95e3543 100644 --- a/src/panels/profile/ha-long-lived-access-tokens-card.ts +++ b/src/panels/profile/ha-long-lived-access-tokens-card.ts @@ -13,7 +13,6 @@ import type { RefreshToken } from "../../data/refresh_token"; import { showAlertDialog, showConfirmationDialog, - showPromptDialog, } from "../../dialogs/generic/show-dialog-box"; import { haStyle } from "../../resources/styles"; import type { HomeAssistant } from "../../types"; @@ -26,14 +25,14 @@ class HaLongLivedTokens extends LitElement { @property({ attribute: false }) public refreshTokens?: RefreshToken[]; private _accessTokens = memoizeOne( - (refreshTokens: RefreshToken[]): RefreshToken[] => - refreshTokens - ?.filter((token) => token.type === "long_lived_access_token") + (refreshTokens?: RefreshToken[]): RefreshToken[] => + (refreshTokens ?? []) + .filter((token) => token.type === "long_lived_access_token") .reverse() ); protected render(): TemplateResult { - const accessTokens = this._accessTokens(this.refreshTokens!); + const accessTokens = this._accessTokens(this.refreshTokens); return html` - ${!accessTokens?.length + ${!accessTokens.length ? html`

${this.hass.localize( "ui.panel.profile.long_lived_access_tokens.empty_state" )}

` - : accessTokens!.map( + : accessTokens.map( (token) => html` ${token.client_name} @@ -98,38 +97,15 @@ class HaLongLivedTokens extends LitElement { `; } - private async _createToken(): Promise { - const name = await showPromptDialog(this, { - text: this.hass.localize( - "ui.panel.profile.long_lived_access_tokens.prompt_name" - ), - inputLabel: this.hass.localize( - "ui.panel.profile.long_lived_access_tokens.name" - ), + private _createToken(): void { + const accessTokens = this._accessTokens(this.refreshTokens); + + showLongLivedAccessTokenDialog(this, { + createdCallback: () => fireEvent(this, "hass-refresh-tokens"), + existingNames: accessTokens + .map((token) => token.client_name) + .filter((name): name is string => Boolean(name)), }); - - if (!name) { - return; - } - - try { - const token = await this.hass.callWS({ - type: "auth/long_lived_access_token", - lifespan: 3650, - client_name: name, - }); - - showLongLivedAccessTokenDialog(this, { token, name }); - - fireEvent(this, "hass-refresh-tokens"); - } catch (err: any) { - showAlertDialog(this, { - title: this.hass.localize( - "ui.panel.profile.long_lived_access_tokens.create_failed" - ), - text: err.message, - }); - } } private async _deleteToken(ev: Event): Promise { diff --git a/src/panels/profile/show-long-lived-access-token-dialog.ts b/src/panels/profile/show-long-lived-access-token-dialog.ts index 00ac5a9a53..fd47a877f6 100644 --- a/src/panels/profile/show-long-lived-access-token-dialog.ts +++ b/src/panels/profile/show-long-lived-access-token-dialog.ts @@ -1,8 +1,8 @@ import { fireEvent } from "../../common/dom/fire_event"; export interface LongLivedAccessTokenDialogParams { - token: string; - name: string; + createdCallback: () => void; + existingNames: string[]; } export const showLongLivedAccessTokenDialog = ( diff --git a/src/translations/en.json b/src/translations/en.json index ca65e05b23..506820e64f 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -9727,8 +9727,10 @@ "confirm_delete_text": "Are you sure you want to delete the long-lived access token for {name}?", "delete_failed": "Failed to delete the access token.", "create": "Create token", + "created_title": "Token created: {name}", "create_failed": "Failed to create the access token.", "name": "Name", + "name_exists": "A token with this name already exists.", "prompt_name": "Give the token a name", "prompt_copy_token": "Copy your access token. It will not be shown again.", "empty_state": "You have no long-lived access tokens yet.",