From 1c076d22a6e5fdc1f765e44d83191b83baf48db7 Mon Sep 17 00:00:00 2001 From: Krisjanis Lejejs Date: Tue, 17 Dec 2024 19:55:07 +0000 Subject: [PATCH] Add MFA login flow support for cloud component (#23188) * Add MFA login flow support for cloud component * Update MFA login in voice assistant setup flow * Sync errors with core * Add translations to the TOTP dialog --- src/data/cloud.ts | 27 +++++--- .../cloud/cloud-step-signin.ts | 69 +++++++++++++++---- .../cloud/cloud-step-signup.ts | 6 +- src/panels/config/cloud/login/cloud-login.ts | 64 ++++++++++++++--- src/translations/en.json | 6 ++ 5 files changed, 139 insertions(+), 33 deletions(-) diff --git a/src/data/cloud.ts b/src/data/cloud.ts index 5b1569f534f5..41eae5c3413a 100644 --- a/src/data/cloud.ts +++ b/src/data/cloud.ts @@ -70,18 +70,27 @@ export interface CloudWebhook { managed?: boolean; } -export const cloudLogin = ( - hass: HomeAssistant, - email: string, - password: string -) => +interface CloudLoginBase { + hass: HomeAssistant; + email: string; +} + +export interface CloudLoginPassword extends CloudLoginBase { + password: string; +} + +export interface CloudLoginMFA extends CloudLoginBase { + code: string; +} + +export const cloudLogin = ({ + hass, + ...rest +}: CloudLoginPassword | CloudLoginMFA) => hass.callApi<{ success: boolean; cloud_pipeline?: string }>( "POST", "cloud/login", - { - email, - password, - } + rest ); export const cloudLogout = (hass: HomeAssistant) => diff --git a/src/dialogs/voice-assistant-setup/cloud/cloud-step-signin.ts b/src/dialogs/voice-assistant-setup/cloud/cloud-step-signin.ts index d4c36636822f..2de5a88bb5ba 100644 --- a/src/dialogs/voice-assistant-setup/cloud/cloud-step-signin.ts +++ b/src/dialogs/voice-assistant-setup/cloud/cloud-step-signin.ts @@ -11,7 +11,10 @@ import "../../../components/ha-textfield"; import type { HaTextField } from "../../../components/ha-textfield"; import { cloudLogin } from "../../../data/cloud"; import type { HomeAssistant } from "../../../types"; -import { showAlertDialog } from "../../generic/show-dialog-box"; +import { + showAlertDialog, + showPromptDialog, +} from "../../generic/show-dialog-box"; import { AssistantSetupStyles } from "../styles"; @customElement("cloud-step-signin") @@ -106,12 +109,36 @@ export class CloudStepSignin extends LitElement { this._requestInProgress = true; - const doLogin = async (username: string) => { + const doLogin = async (username: string, code?: string) => { try { - await cloudLogin(this.hass, username, password); + await cloudLogin({ + hass: this.hass, + email: username, + ...(code ? { code } : { password }), + }); } catch (err: any) { const errCode = err && err.body && err.body.code; + if (errCode === "mfarequired") { + const totpCode = await showPromptDialog(this, { + title: this.hass.localize( + "ui.panel.config.cloud.login.totp_code_prompt_title" + ), + inputLabel: this.hass.localize( + "ui.panel.config.cloud.login.totp_code" + ), + inputType: "text", + defaultValue: "", + confirmText: this.hass.localize( + "ui.panel.config.cloud.login.submit" + ), + }); + if (totpCode !== null && totpCode !== "") { + await doLogin(username, totpCode); + return; + } + } + if (errCode === "usernotfound" && username !== username.toLowerCase()) { await doLogin(username.toLowerCase()); return; @@ -130,15 +157,33 @@ export class CloudStepSignin extends LitElement { this._requestInProgress = false; - if (errCode === "UserNotConfirmed") { - this._error = this.hass.localize( - "ui.panel.config.cloud.login.alert_email_confirm_necessary" - ); - } else { - this._error = - err && err.body && err.body.message - ? err.body.message - : "Unknown error"; + switch (errCode) { + case "UserNotConfirmed": + this._error = this.hass.localize( + "ui.panel.config.cloud.login.alert_email_confirm_necessary" + ); + break; + case "mfarequired": + this._error = this.hass.localize( + "ui.panel.config.cloud.login.alert_mfa_code_required" + ); + break; + case "mfaexpiredornotstarted": + this._error = this.hass.localize( + "ui.panel.config.cloud.login.alert_mfa_expired_or_not_started" + ); + break; + case "invalidtotpcode": + this._error = this.hass.localize( + "ui.panel.config.cloud.login.alert_totp_code_invalid" + ); + break; + default: + this._error = + err && err.body && err.body.message + ? err.body.message + : "Unknown error"; + break; } emailField.focus(); diff --git a/src/dialogs/voice-assistant-setup/cloud/cloud-step-signup.ts b/src/dialogs/voice-assistant-setup/cloud/cloud-step-signup.ts index 3e8437fe2f41..ce795f8e40b3 100644 --- a/src/dialogs/voice-assistant-setup/cloud/cloud-step-signup.ts +++ b/src/dialogs/voice-assistant-setup/cloud/cloud-step-signup.ts @@ -190,7 +190,11 @@ export class CloudStepSignup extends LitElement { } try { - await cloudLogin(this.hass, this._email, this._password); + await cloudLogin({ + hass: this.hass, + email: this._email, + password: this._password, + }); fireEvent(this, "cloud-step", { step: "DONE" }); } catch (e: any) { if (e?.body?.code === "usernotconfirmed") { diff --git a/src/panels/config/cloud/login/cloud-login.ts b/src/panels/config/cloud/login/cloud-login.ts index aed24cb08b16..1a05db180f15 100644 --- a/src/panels/config/cloud/login/cloud-login.ts +++ b/src/panels/config/cloud/login/cloud-login.ts @@ -21,6 +21,7 @@ import { cloudLogin, removeCloudData } from "../../../../data/cloud"; import { showAlertDialog, showConfirmationDialog, + showPromptDialog, } from "../../../../dialogs/generic/show-dialog-box"; import "../../../../layouts/hass-subpage"; import { haStyle } from "../../../../resources/styles"; @@ -230,9 +231,13 @@ export class CloudLogin extends LitElement { this._requestInProgress = true; - const doLogin = async (username: string) => { + const doLogin = async (username: string, code?: string) => { try { - const result = await cloudLogin(this.hass, username, password); + const result = await cloudLogin({ + hass: this.hass, + email: username, + ...(code ? { code } : { password }), + }); this.email = ""; this._password = ""; if (result.cloud_pipeline) { @@ -252,6 +257,25 @@ export class CloudLogin extends LitElement { fireEvent(this, "ha-refresh-cloud-status"); } catch (err: any) { const errCode = err && err.body && err.body.code; + if (errCode === "mfarequired") { + const totpCode = await showPromptDialog(this, { + title: this.hass.localize( + "ui.panel.config.cloud.login.totp_code_prompt_title" + ), + inputLabel: this.hass.localize( + "ui.panel.config.cloud.login.totp_code" + ), + inputType: "text", + defaultValue: "", + confirmText: this.hass.localize( + "ui.panel.config.cloud.login.submit" + ), + }); + if (totpCode !== null && totpCode !== "") { + await doLogin(username, totpCode); + return; + } + } if (errCode === "PasswordChangeRequired") { showAlertDialog(this, { title: this.hass.localize( @@ -269,15 +293,33 @@ export class CloudLogin extends LitElement { this._password = ""; this._requestInProgress = false; - if (errCode === "UserNotConfirmed") { - this._error = this.hass.localize( - "ui.panel.config.cloud.login.alert_email_confirm_necessary" - ); - } else { - this._error = - err && err.body && err.body.message - ? err.body.message - : "Unknown error"; + switch (errCode) { + case "UserNotConfirmed": + this._error = this.hass.localize( + "ui.panel.config.cloud.login.alert_email_confirm_necessary" + ); + break; + case "mfarequired": + this._error = this.hass.localize( + "ui.panel.config.cloud.login.alert_mfa_code_required" + ); + break; + case "mfaexpiredornotstarted": + this._error = this.hass.localize( + "ui.panel.config.cloud.login.alert_mfa_expired_or_not_started" + ); + break; + case "invalidtotpcode": + this._error = this.hass.localize( + "ui.panel.config.cloud.login.alert_totp_code_invalid" + ); + break; + default: + this._error = + err && err.body && err.body.message + ? err.body.message + : "Unknown error"; + break; } emailField.focus(); diff --git a/src/translations/en.json b/src/translations/en.json index 3e9b39bdc8bd..adbd83b5d6fd 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -4016,11 +4016,17 @@ "email_error_msg": "Invalid email", "password": "Password", "password_error_msg": "Passwords are at least 8 characters", + "totp_code_prompt_title": "Two-factor authentication", + "totp_code": "TOTP code", + "submit": "Submit", "forgot_password": "Forgot password?", "start_trial": "Start your free 1 month trial", "trial_info": "No payment information necessary", "alert_password_change_required": "You need to change your password before logging in.", "alert_email_confirm_necessary": "You need to confirm your email before logging in.", + "alert_mfa_code_required": "You need to enter your two-factor authentication code.", + "alert_mfa_expired_or_not_started": "Multi-factor authentication expired, or not started. Please try again.", + "alert_totp_code_invalid": "Invalid two-factor authentication code.", "cloud_pipeline_title": "Want to use Home Assistant Cloud for your voice assistant?", "cloud_pipeline_text": "We created a new assistant for you, using the superior text-to-speech and speech-to-text engines from Home Assistant Cloud. Would you like to set this assistant as the preferred assistant?" },