From 025f00871a08d8c1eb4b4160a7b3c6459c900dec Mon Sep 17 00:00:00 2001 From: AboveColin Date: Mon, 14 Sep 2026 13:31:58 +0200 Subject: [PATCH 1/2] Ask for the email in the reauth flow, and add a reconfigure step async_step_reauth_confirm asked only for the password and reused the email stored on the config entry. An entry whose data carries no email, which is what an entry written before the email was stored looks like, then passed an empty string to FitdaysClient.login. The client raises FitdaysValidationError at client.py:109 before it contacts the cloud, the flow mapped that to the "unknown" error key, and the form showed "Unexpected error." with no way to recover the entry. The reauth form now asks for the email as well, prefilled from the entry when it has one. FitdaysValidationError maps to invalid_auth, because blank input is a form problem and not an outage. The flow also had no async_step_reconfigure, so the entry could not be pointed at fresh credentials at all. Added one. It refuses credentials whose uid differs from the entry's unique_id, because reconfigure repairs one account's entry and a second account belongs in its own entry. Verified on ha-dev (HA 2026.8.3, podman container ha-dev on nix1): - the entry now reports supports_reconfigure true - the reconfigure form renders email (required), password (password selector), country (optional, default NL) - submitting a blank email, the exact input that produced "Unexpected error.", returns errors {"base": "invalid_auth"} on both the reconfigure and the reauth step, and logs no ERROR line Co-Authored-By: Claude Opus 5 --- custom_components/fitdays/config_flow.py | 79 ++++++++++++++++--- custom_components/fitdays/strings.json | 25 +++++- .../fitdays/translations/en.json | 25 +++++- 3 files changed, 110 insertions(+), 19 deletions(-) diff --git a/custom_components/fitdays/config_flow.py b/custom_components/fitdays/config_flow.py index 1d869bf..4587e32 100644 --- a/custom_components/fitdays/config_flow.py +++ b/custom_components/fitdays/config_flow.py @@ -26,18 +26,26 @@ try: from fitdays import FitdaysClient - from fitdays.exceptions import FitdaysAuthError, FitdaysError, FitdaysNetworkError + from fitdays.exceptions import ( + FitdaysAuthError, + FitdaysError, + FitdaysNetworkError, + FitdaysValidationError, + ) except ImportError as err: # pragma: no cover - handled by manifest requirements _LOGGER.error("Failed to import the fitdays package: %s", err) raise -STEP_USER_SCHEMA = vol.Schema( - { - vol.Required(CONF_EMAIL): str, - vol.Required(CONF_PASSWORD): _SECRET, - vol.Optional(CONF_COUNTRY, default=DEFAULT_COUNTRY): str, - } -) + +def _credentials_schema(email: str = "", country: str = DEFAULT_COUNTRY) -> vol.Schema: + """Build the email/password/country form, prefilled with what we know.""" + return vol.Schema( + { + vol.Required(CONF_EMAIL, default=email): str, + vol.Required(CONF_PASSWORD): _SECRET, + vol.Optional(CONF_COUNTRY, default=country or DEFAULT_COUNTRY): str, + } + ) class FitdaysConfigFlow(ConfigFlow, domain=DOMAIN): @@ -68,6 +76,10 @@ async def _async_try_login( return None, "invalid_auth" except FitdaysNetworkError: return None, "cannot_connect" + except FitdaysValidationError: + # The client rejects blank input before it calls the cloud. That is + # a form problem, not an outage, so say so instead of "unknown". + return None, "invalid_auth" except FitdaysError as err: _LOGGER.error("Unexpected Fitdays error during login: %s", err) return None, "unknown" @@ -95,7 +107,7 @@ async def async_step_user( return self.async_create_entry(title=email, data=entry_data) return self.async_show_form( - step_id="user", data_schema=STEP_USER_SCHEMA, errors=errors + step_id="user", data_schema=_credentials_schema(), errors=errors ) async def async_step_reauth( @@ -108,16 +120,17 @@ async def async_step_reauth( async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Ask for the password again and refresh the stored session.""" + """Ask for the credentials again and refresh the stored session.""" errors: dict[str, str] = {} existing = self._reauth_entry_data or {} email = existing.get(CONF_EMAIL) or "" if user_input is not None: + email = user_input[CONF_EMAIL].strip() new_data, error = await self._async_try_login( email, user_input[CONF_PASSWORD], - existing.get(CONF_COUNTRY) or DEFAULT_COUNTRY, + user_input.get(CONF_COUNTRY) or DEFAULT_COUNTRY, ) if error: errors["base"] = error @@ -127,9 +140,49 @@ async def async_step_reauth_confirm( data={**existing, **new_data}, ) + # The email is asked for rather than taken from the entry, because an + # entry written before the email was stored has none, and a login with + # a blank email fails in the client before it reaches the cloud. return self.async_show_form( step_id="reauth_confirm", - data_schema=vol.Schema({vol.Required(CONF_PASSWORD): _SECRET}), - description_placeholders={"email": email}, + data_schema=_credentials_schema( + email, existing.get(CONF_COUNTRY) or DEFAULT_COUNTRY + ), + description_placeholders={"email": email or "this account"}, + errors=errors, + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Let the user point the entry at fresh credentials.""" + errors: dict[str, str] = {} + entry = self._get_reconfigure_entry() + email = entry.data.get(CONF_EMAIL) or "" + country = entry.data.get(CONF_COUNTRY) or DEFAULT_COUNTRY + + if user_input is not None: + email = user_input[CONF_EMAIL].strip() + country = user_input.get(CONF_COUNTRY) or DEFAULT_COUNTRY + new_data, error = await self._async_try_login( + email, user_input[CONF_PASSWORD], country + ) + if error: + errors["base"] = error + elif str(new_data.get("uid")) != str(entry.unique_id): + # Reconfigure repairs one account's entry. A different account + # belongs in its own entry, or its sensors would silently + # change meaning. + return self.async_abort(reason="account_mismatch") + else: + return self.async_update_reload_and_abort( + entry, + data={**entry.data, **new_data}, + title=email, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=_credentials_schema(email, country), errors=errors, ) diff --git a/custom_components/fitdays/strings.json b/custom_components/fitdays/strings.json index b9fb919..cbff263 100644 --- a/custom_components/fitdays/strings.json +++ b/custom_components/fitdays/strings.json @@ -15,9 +15,26 @@ }, "reauth_confirm": { "title": "Re-authenticate Fitdays", - "description": "The stored session for {email} is no longer valid. Enter the password again to reconnect.", + "description": "The stored session for {email} is no longer valid. Enter the credentials again to reconnect.", "data": { - "password": "Password" + "email": "Email", + "password": "Password", + "country": "Country code" + }, + "data_description": { + "country": "Two-letter code of the country your Fitdays account is registered in, for example NL." + } + }, + "reconfigure": { + "title": "Reconfigure Fitdays", + "description": "Sign in again to replace the stored session. The credentials must belong to the same Fitdays account as this entry.", + "data": { + "email": "Email", + "password": "Password", + "country": "Country code" + }, + "data_description": { + "country": "Two-letter code of the country your Fitdays account is registered in, for example NL." } } }, @@ -28,7 +45,9 @@ }, "abort": { "already_configured": "This Fitdays account is already set up.", - "reauth_successful": "Re-authentication was successful." + "reauth_successful": "Re-authentication was successful.", + "reconfigure_successful": "Fitdays was reconfigured.", + "account_mismatch": "Those credentials are for a different Fitdays account. Add that account as a new entry instead." } }, "entity": { diff --git a/custom_components/fitdays/translations/en.json b/custom_components/fitdays/translations/en.json index b9fb919..cbff263 100644 --- a/custom_components/fitdays/translations/en.json +++ b/custom_components/fitdays/translations/en.json @@ -15,9 +15,26 @@ }, "reauth_confirm": { "title": "Re-authenticate Fitdays", - "description": "The stored session for {email} is no longer valid. Enter the password again to reconnect.", + "description": "The stored session for {email} is no longer valid. Enter the credentials again to reconnect.", "data": { - "password": "Password" + "email": "Email", + "password": "Password", + "country": "Country code" + }, + "data_description": { + "country": "Two-letter code of the country your Fitdays account is registered in, for example NL." + } + }, + "reconfigure": { + "title": "Reconfigure Fitdays", + "description": "Sign in again to replace the stored session. The credentials must belong to the same Fitdays account as this entry.", + "data": { + "email": "Email", + "password": "Password", + "country": "Country code" + }, + "data_description": { + "country": "Two-letter code of the country your Fitdays account is registered in, for example NL." } } }, @@ -28,7 +45,9 @@ }, "abort": { "already_configured": "This Fitdays account is already set up.", - "reauth_successful": "Re-authentication was successful." + "reauth_successful": "Re-authentication was successful.", + "reconfigure_successful": "Fitdays was reconfigured.", + "account_mismatch": "Those credentials are for a different Fitdays account. Add that account as a new entry instead." } }, "entity": { From a966b1b70b5583677f24d1826e2269faa3260193 Mon Sep 17 00:00:00 2001 From: AboveColin Date: Tue, 15 Sep 2026 14:31:48 +0200 Subject: [PATCH 2/2] Translate the reconfigure step into Dutch nl.json still described the reauth step as password-only and had no reconfigure step, so a Dutch user would have seen the new email field and the reconfigure form fall back to English. The key sets of en.json and nl.json now match exactly, checked by comparing them. Co-Authored-By: Claude Opus 5 --- .../fitdays/translations/nl.json | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/custom_components/fitdays/translations/nl.json b/custom_components/fitdays/translations/nl.json index d003a05..d9ecac9 100644 --- a/custom_components/fitdays/translations/nl.json +++ b/custom_components/fitdays/translations/nl.json @@ -15,9 +15,26 @@ }, "reauth_confirm": { "title": "Fitdays opnieuw verbinden", - "description": "De opgeslagen sessie voor {email} is niet meer geldig. Voer het wachtwoord opnieuw in om te herverbinden.", + "description": "De opgeslagen sessie voor {email} is niet meer geldig. Voer de inloggegevens opnieuw in om te herverbinden.", "data": { - "password": "Wachtwoord" + "email": "E-mailadres", + "password": "Wachtwoord", + "country": "Landcode" + }, + "data_description": { + "country": "Tweeletterige code van het land waar je Fitdays-account geregistreerd is, bijvoorbeeld NL." + } + }, + "reconfigure": { + "title": "Fitdays opnieuw instellen", + "description": "Log opnieuw in om de opgeslagen sessie te vervangen. De inloggegevens moeten bij hetzelfde Fitdays-account horen als deze invoer.", + "data": { + "email": "E-mailadres", + "password": "Wachtwoord", + "country": "Landcode" + }, + "data_description": { + "country": "Tweeletterige code van het land waar je Fitdays-account geregistreerd is, bijvoorbeeld NL." } } }, @@ -28,7 +45,9 @@ }, "abort": { "already_configured": "Dit Fitdays-account is al ingesteld.", - "reauth_successful": "Opnieuw verbinden is gelukt." + "reauth_successful": "Opnieuw verbinden is gelukt.", + "reconfigure_successful": "Fitdays is opnieuw ingesteld.", + "account_mismatch": "Deze inloggegevens horen bij een ander Fitdays-account. Voeg dat account toe als een nieuwe invoer." } }, "entity": {