From c59b1c72c5616cd58fcffd9e5cd944dc3e44bfc1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Feb 2021 09:55:06 -1000 Subject: [PATCH] Add reauth support for tesla (#46307) --- homeassistant/components/tesla/__init__.py | 43 ++++++-- homeassistant/components/tesla/config_flow.py | 101 ++++++++++-------- homeassistant/components/tesla/strings.json | 4 + .../components/tesla/translations/en.json | 4 + tests/components/tesla/test_config_flow.py | 39 ++++++- 5 files changed, 136 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/tesla/__init__.py b/homeassistant/components/tesla/__init__.py index 8981b269a56..b31f8ae6dd3 100644 --- a/homeassistant/components/tesla/__init__.py +++ b/homeassistant/components/tesla/__init__.py @@ -5,10 +5,11 @@ from datetime import timedelta import logging import async_timeout -from teslajsonpy import Controller as TeslaAPI, TeslaException +from teslajsonpy import Controller as TeslaAPI +from teslajsonpy.exceptions import IncompleteCredentials, TeslaException import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( ATTR_BATTERY_CHARGING, ATTR_BATTERY_LEVEL, @@ -17,8 +18,9 @@ from homeassistant.const import ( CONF_SCAN_INTERVAL, CONF_TOKEN, CONF_USERNAME, + HTTP_UNAUTHORIZED, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.update_coordinator import ( @@ -28,12 +30,7 @@ from homeassistant.helpers.update_coordinator import ( ) from homeassistant.util import slugify -from .config_flow import ( - CannotConnect, - InvalidAuth, - configured_instances, - validate_input, -) +from .config_flow import CannotConnect, InvalidAuth, validate_input from .const import ( CONF_WAKE_ON_START, DATA_LISTENER, @@ -75,6 +72,16 @@ def _async_save_tokens(hass, config_entry, access_token, refresh_token): ) +@callback +def _async_configured_emails(hass): + """Return a set of configured Tesla emails.""" + return { + entry.data[CONF_USERNAME] + for entry in hass.config_entries.async_entries(DOMAIN) + if CONF_USERNAME in entry.data + } + + async def async_setup(hass, base_config): """Set up of Tesla component.""" @@ -95,7 +102,7 @@ async def async_setup(hass, base_config): email = config[CONF_USERNAME] password = config[CONF_PASSWORD] scan_interval = config[CONF_SCAN_INTERVAL] - if email in configured_instances(hass): + if email in _async_configured_emails(hass): try: info = await validate_input(hass, config) except (CannotConnect, InvalidAuth): @@ -151,7 +158,12 @@ async def async_setup_entry(hass, config_entry): CONF_WAKE_ON_START, DEFAULT_WAKE_ON_START ) ) + except IncompleteCredentials: + _async_start_reauth(hass, config_entry) + return False except TeslaException as ex: + if ex.code == HTTP_UNAUTHORIZED: + _async_start_reauth(hass, config_entry) _LOGGER.error("Unable to communicate with Tesla API: %s", ex.message) return False _async_save_tokens(hass, config_entry, access_token, refresh_token) @@ -206,6 +218,17 @@ async def async_unload_entry(hass, config_entry) -> bool: return False +def _async_start_reauth(hass: HomeAssistant, entry: ConfigEntry): + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": "reauth"}, + data=entry.data, + ) + ) + _LOGGER.error("Credentials are no longer valid. Please reauthenticate") + + async def update_listener(hass, config_entry): """Update when config_entry options update.""" controller = hass.data[DOMAIN][config_entry.entry_id]["coordinator"].controller diff --git a/homeassistant/components/tesla/config_flow.py b/homeassistant/components/tesla/config_flow.py index 683ef314a06..194ea71a3b7 100644 --- a/homeassistant/components/tesla/config_flow.py +++ b/homeassistant/components/tesla/config_flow.py @@ -20,22 +20,12 @@ from .const import ( CONF_WAKE_ON_START, DEFAULT_SCAN_INTERVAL, DEFAULT_WAKE_ON_START, - DOMAIN, MIN_SCAN_INTERVAL, ) +from .const import DOMAIN # pylint:disable=unused-import _LOGGER = logging.getLogger(__name__) -DATA_SCHEMA = vol.Schema( - {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} -) - - -@callback -def configured_instances(hass): - """Return a set of configured Tesla instances.""" - return {entry.title for entry in hass.config_entries.async_entries(DOMAIN)} - class TeslaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Tesla.""" @@ -43,46 +33,56 @@ class TeslaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + def __init__(self): + """Initialize the tesla flow.""" + self.username = None + async def async_step_import(self, import_config): """Import a config entry from configuration.yaml.""" return await self.async_step_user(import_config) async def async_step_user(self, user_input=None): """Handle the start of the config flow.""" + errors = {} - if not user_input: - return self.async_show_form( - step_id="user", - data_schema=DATA_SCHEMA, - errors={}, - description_placeholders={}, - ) + if user_input is not None: + existing_entry = self._async_entry_for_username(user_input[CONF_USERNAME]) + if ( + existing_entry + and existing_entry.data[CONF_PASSWORD] == user_input[CONF_PASSWORD] + ): + return self.async_abort(reason="already_configured") - if user_input[CONF_USERNAME] in configured_instances(self.hass): - return self.async_show_form( - step_id="user", - data_schema=DATA_SCHEMA, - errors={CONF_USERNAME: "already_configured"}, - description_placeholders={}, - ) + try: + info = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" - try: - info = await validate_input(self.hass, user_input) - except CannotConnect: - return self.async_show_form( - step_id="user", - data_schema=DATA_SCHEMA, - errors={"base": "cannot_connect"}, - description_placeholders={}, - ) - except InvalidAuth: - return self.async_show_form( - step_id="user", - data_schema=DATA_SCHEMA, - errors={"base": "invalid_auth"}, - description_placeholders={}, - ) - return self.async_create_entry(title=user_input[CONF_USERNAME], data=info) + if not errors: + if existing_entry: + self.hass.config_entries.async_update_entry( + existing_entry, data=info + ) + await self.hass.config_entries.async_reload(existing_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_create_entry( + title=user_input[CONF_USERNAME], data=info + ) + + return self.async_show_form( + step_id="user", + data_schema=self._async_schema(), + errors=errors, + description_placeholders={}, + ) + + async def async_step_reauth(self, data): + """Handle configuration by re-auth.""" + self.username = data[CONF_USERNAME] + return await self.async_step_user() @staticmethod @callback @@ -90,6 +90,23 @@ class TeslaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) + @callback + def _async_schema(self): + """Fetch schema with defaults.""" + return vol.Schema( + { + vol.Required(CONF_USERNAME, default=self.username): str, + vol.Required(CONF_PASSWORD): str, + } + ) + + @callback + def _async_entry_for_username(self, username): + """Find an existing entry for a username.""" + for entry in self._async_current_entries(): + if entry.data.get(CONF_USERNAME) == username: + return entry + class OptionsFlowHandler(config_entries.OptionsFlow): """Handle a option flow for Tesla.""" diff --git a/homeassistant/components/tesla/strings.json b/homeassistant/components/tesla/strings.json index 503124eedd4..c75562528de 100644 --- a/homeassistant/components/tesla/strings.json +++ b/homeassistant/components/tesla/strings.json @@ -5,6 +5,10 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, + "abort": { + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/tesla/translations/en.json b/homeassistant/components/tesla/translations/en.json index f2b888552b9..53b213ac19b 100644 --- a/homeassistant/components/tesla/translations/en.json +++ b/homeassistant/components/tesla/translations/en.json @@ -1,5 +1,9 @@ { "config": { + "abort": { + "already_configured": "Account is already configured", + "reauth_successful": "Re-authentication was successful" + }, "error": { "already_configured": "Account is already configured", "cannot_connect": "Failed to connect", diff --git a/tests/components/tesla/test_config_flow.py b/tests/components/tesla/test_config_flow.py index 136633c9a5c..b35ab0039d0 100644 --- a/tests/components/tesla/test_config_flow.py +++ b/tests/components/tesla/test_config_flow.py @@ -97,7 +97,12 @@ async def test_form_cannot_connect(hass): async def test_form_repeat_identifier(hass): """Test we handle repeat identifiers.""" - entry = MockConfigEntry(domain=DOMAIN, title="test-username", data={}, options=None) + entry = MockConfigEntry( + domain=DOMAIN, + title="test-username", + data={"username": "test-username", "password": "test-password"}, + options=None, + ) entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( @@ -112,8 +117,36 @@ async def test_form_repeat_identifier(hass): {CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password"}, ) - assert result2["type"] == "form" - assert result2["errors"] == {CONF_USERNAME: "already_configured"} + assert result2["type"] == "abort" + assert result2["reason"] == "already_configured" + + +async def test_form_reauth(hass): + """Test we handle reauth.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="test-username", + data={"username": "test-username", "password": "same"}, + options=None, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_REAUTH}, + data={"username": "test-username"}, + ) + with patch( + "homeassistant.components.tesla.config_flow.TeslaAPI.connect", + return_value=("test-refresh-token", "test-access-token"), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "test-username", CONF_PASSWORD: "new-password"}, + ) + + assert result2["type"] == "abort" + assert result2["reason"] == "reauth_successful" async def test_import(hass):