1
0
mirror of https://github.com/home-assistant/frontend.git synced 2026-02-15 07:25:54 +00:00

Migrate profile dialogs to wa, refactor LL access token dialog (#29440)

* Migrate profile dialog(s) to wa

* Make sure code is entered before submit is allowed

* Refactor dialog

* Remove unused params

* Pass existing names and validate name is not already used

* Reduce cleanup on show

* Make QR image larger

* max width

* Fix

* Remove extra event fire

* Make params required

* cleanup

* Cleanup

* Fix

* Fix
This commit is contained in:
Aidan Timson
2026-02-09 11:54:02 +00:00
committed by GitHub
parent 3d04046bcc
commit 2baa044db5
5 changed files with 362 additions and 151 deletions

View File

@@ -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`
<ha-dialog
open
.heading=${this._computeStepTitle()}
@closed=${this.closeDialog}
<ha-wa-dialog
.hass=${this.hass}
.open=${this._open}
prevent-scrim-close
header-title=${this._computeStepTitle()}
@closed=${this._dialogClosed}
>
<div>
${this._errorMessage
@@ -115,6 +125,7 @@ class HaMfaModuleSetupFlow extends LitElement {
)}
></ha-markdown>
<ha-form
autofocus
.hass=${this.hass}
.data=${this._stepData}
.schema=${autocompleteLoginFields(
@@ -127,31 +138,33 @@ class HaMfaModuleSetupFlow extends LitElement {
></ha-form>`
: ""}`}
</div>
<ha-button
slot="primaryAction"
@click=${this.closeDialog}
appearance=${["abort", "create_entry"].includes(
this._step?.type || ""
)
? "accent"
: "plain"}
>${this.hass.localize(
["abort", "create_entry"].includes(this._step?.type || "")
? "ui.panel.profile.mfa_setup.close"
: "ui.common.cancel"
)}</ha-button
>
${this._step?.type === "form"
? html`<ha-button
slot="primaryAction"
.disabled=${this._loading}
@click=${this._submitStep}
>${this.hass.localize(
"ui.panel.profile.mfa_setup.submit"
)}</ha-button
>`
: nothing}
</ha-dialog>
<ha-dialog-footer slot="footer">
<ha-button
slot=${this._step?.type === "form"
? "secondaryAction"
: "primaryAction"}
appearance=${ifDefined(
this._step?.type === "form" ? "plain" : undefined
)}
@click=${this.closeDialog}
>${this.hass.localize(
["abort", "create_entry"].includes(this._step?.type || "")
? "ui.panel.profile.mfa_setup.close"
: "ui.common.cancel"
)}</ha-button
>
${this._step?.type === "form"
? html`<ha-button
slot="primaryAction"
.disabled=${this._isSubmitDisabled()}
@click=${this._submitStep}
>${this.hass.localize(
"ui.panel.profile.mfa_setup.submit"
)}</ha-button
>`
: nothing}
</ha-dialog-footer>
</ha-wa-dialog>
`;
}
@@ -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<string, unknown>).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() {

View File

@@ -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<string>();
@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`
<ha-dialog
open
hideActions
.heading=${createCloseHeading(this.hass, this._params.name)}
@closed=${this.closeDialog}
>
<div>
<ha-textfield
dialogInitialFocus
.value=${this._params.token}
.label=${this.hass.localize(
"ui.panel.profile.long_lived_access_tokens.prompt_copy_token"
<ha-wa-dialog
.hass=${this.hass}
.open=${this._open}
header-title=${this._token
? this.hass.localize(
"ui.panel.profile.long_lived_access_tokens.created_title",
{ name: this._name }
)
: this.hass.localize(
"ui.panel.profile.long_lived_access_tokens.create"
)}
type="text"
iconTrailing
readOnly
>
<ha-icon-button
@click=${this._copyToken}
slot="trailingIcon"
.path=${mdiContentCopy}
></ha-icon-button>
</ha-textfield>
<div id="qr">
${this._qrCode
? this._qrCode
: html`
<ha-button
appearance="plain"
size="small"
@click=${this._generateQR}
>
${this.hass.localize(
"ui.panel.profile.long_lived_access_tokens.generate_qr_code"
)}
prevent-scrim-close
@closed=${this._dialogClosed}
>
<div class="content">
${this._errorMessage
? html`<ha-alert alert-type="error"
>${this._errorMessage}</ha-alert
>`
: nothing}
${this._token
? html`
<p>
${this.hass.localize(
"ui.panel.profile.long_lived_access_tokens.prompt_copy_token"
)}
</p>
<div class="token-row">
<ha-textfield
autofocus
.value=${this._token}
type="text"
readOnly
></ha-textfield>
<ha-button appearance="plain" @click=${this._copyToken}>
<ha-svg-icon
slot="start"
.path=${mdiContentCopy}
></ha-svg-icon>
${this.hass.localize("ui.common.copy")}
</ha-button>
`}
</div>
</div>
<div id="qr">
${this._qrCode
? this._qrCode
: html`
<ha-button
appearance="plain"
@click=${this._generateQR}
>
<ha-svg-icon
slot="start"
.path=${mdiQrcode}
></ha-svg-icon>
${this.hass.localize(
"ui.panel.profile.long_lived_access_tokens.generate_qr_code"
)}
</ha-button>
`}
</div>
`
: html`
<ha-textfield
autofocus
.value=${this._name}
.label=${this.hass.localize(
"ui.panel.profile.long_lived_access_tokens.name"
)}
.invalid=${this._hasDuplicateName()}
.errorMessage=${this.hass.localize(
"ui.panel.profile.long_lived_access_tokens.name_exists"
)}
required
@input=${this._nameChanged}
></ha-textfield>
`}
</div>
</ha-dialog>
<ha-dialog-footer slot="footer">
${this._token
? nothing
: html`<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this.closeDialog}
>
${this.hass.localize("ui.common.cancel")}
</ha-button>`}
${!this._token
? html`<ha-button
slot="primaryAction"
.disabled=${this._isCreateDisabled()}
@click=${this._createToken}
>
${this.hass.localize(
"ui.panel.profile.long_lived_access_tokens.create"
)}
</ha-button>`
: html`<ha-button slot="primaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.common.close")}
</ha-button>`}
</ha-dialog-footer>
</ha-wa-dialog>
`;
}
private async _copyToken(ev): Promise<void> {
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<void> {
if (this._isCreateDisabled()) {
return;
}
const name = this._name.trim();
this._loading = true;
this._errorMessage = undefined;
try {
this._token = await this.hass.callWS<string>({
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<void> {
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`<img
alt=${this.hass.localize(
"ui.panel.profile.long_lived_access_tokens.qr_code_image",
{ name: this._params!.name }
)}
src=${canvas.toDataURL()}
></img>`;
await withViewTransition(() => {
this._qrCode = html`<img
alt=${this.hass.localize(
"ui.panel.profile.long_lived_access_tokens.qr_code_image",
{ name: this._name }
)}
src=${canvas.toDataURL()}
></img>`;
});
}
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);
}
`,
];

View File

@@ -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`
<ha-card
@@ -55,13 +54,13 @@ class HaLongLivedTokens extends LitElement {
"ui.panel.profile.long_lived_access_tokens.learn_auth_requests"
)}
</a>
${!accessTokens?.length
${!accessTokens.length
? html`<p>
${this.hass.localize(
"ui.panel.profile.long_lived_access_tokens.empty_state"
)}
</p>`
: accessTokens!.map(
: accessTokens.map(
(token) =>
html`<ha-settings-row two-line>
<span slot="heading">${token.client_name}</span>
@@ -98,38 +97,15 @@ class HaLongLivedTokens extends LitElement {
`;
}
private async _createToken(): Promise<void> {
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<string>({
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<void> {

View File

@@ -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 = (

View File

@@ -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.",