From 0b7afd84600f87159daacbc7b7219d39d749adcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 21:29:05 +0100 Subject: [PATCH] fix(errors): show the catalogue sentence, not the server's text About forty refusal messages still printed the error string the server sent: English in every locale, and for a dropped connection the browser's own TypeError text. Mood tags, targets and thresholds, family history, medication side effects, phases, API tokens and dose imports, the Coach model picker, the AI provider, fallback chain, timeout and Codex cards, the notification channel cards, the avatar, passkeys, security keys, the authenticator card, the re-proof dialog and the admin pages now resolve the refusal's code through localizedApiError and fall back to the surface's own sentence. Two refusals carried something a person needs and had no code: a private AI base URL (the AI_PRIVATE_ORIGINS hint) and removing the last passkey. Both now send a code with a sentence of their own. A throttled re-proof says so in the reader's language. Raw fetch sites throw the same ApiError as the verb helpers through the exported throwApiError, which replaces the admin's getApiErrorMessage; the authenticator card's copy of describeStepUp is gone, and describeStepUp no longer passes a non-step-up refusal's prose through (its test pinned that). The admin password reset keeps the server's verdict on a 422: the server words the strength and breach verdict in the account's language. --- messages/de.json | 15 ++++- messages/en.json | 15 ++++- messages/es.json | 15 ++++- messages/fr.json | 15 ++++- messages/it.json | 15 ++++- messages/ko.json | 15 ++++- messages/nl.json | 15 ++++- messages/pl.json | 15 ++++- messages/pt-BR.json | 15 ++++- messages/ru.json | 15 ++++- .../baselines/source-function-lines.json | 14 ++--- src/app/api/auth/passkeys/[id]/route.ts | 1 + src/app/api/user/ai-provider/route.ts | 2 + src/components/admin/_shared.tsx | 23 ++----- .../admin/ai-server-key-section.tsx | 8 +-- src/components/admin/ai-usage-section.tsx | 10 ++-- src/components/admin/assistant-section.tsx | 10 ++-- .../admin/backups/use-backup-actions.ts | 17 ++---- .../admin/backups/use-backup-restores.ts | 5 +- .../admin/backups/use-backup-upload.ts | 5 +- .../admin/central-codex-section.tsx | 60 ++++++++----------- src/components/admin/danger-zone-section.tsx | 7 ++- src/components/admin/delete-user-control.tsx | 5 +- src/components/admin/encryption-section.tsx | 20 ++++--- src/components/admin/glitchtip-section.tsx | 5 +- .../admin/measurement-maintenance-card.tsx | 10 ++-- .../admin/module-availability-section.tsx | 10 ++-- .../admin/pending-deletion-controls.tsx | 5 +- src/components/admin/pulse-day-mean-card.tsx | 43 ++++++------- src/components/admin/recovery-kit-card.tsx | 19 +++--- src/components/admin/reminders-section.tsx | 12 ++-- src/components/admin/umami-section.tsx | 5 +- .../admin/user-management-section.tsx | 35 +++++++---- .../coach-panel/coach-model-picker.tsx | 7 +-- .../medications/intake-import-dialog.tsx | 21 ++----- .../scheduling/natural-language-extractor.tsx | 12 ++-- .../medications/sections/api-tokens-row.tsx | 7 +-- .../sections/data-portability-row.tsx | 4 +- .../sections/phase-config-sheet.tsx | 7 +-- .../medications/side-effects-section.tsx | 5 +- .../mood/manage/archived-tags-card.tsx | 13 ++-- .../mood/manage/tag-editor-sheet.tsx | 8 +-- .../mood/manage/tag-groups-card.tsx | 20 +++---- .../mood/manage/tag-manager-card.tsx | 18 ++---- .../records/family-history-form.tsx | 12 ++-- .../notification-channel-cards.test.tsx | 3 +- .../notification-channel-toggle.test.ts | 38 ++++++++++-- .../managed-profile-step-up-flow.test.ts | 6 +- .../account-section/avatar-section.tsx | 30 +++++----- .../settings/ai/anthropic-provider-form.tsx | 3 +- .../settings/ai/codex-provider-form.tsx | 45 ++++++-------- .../settings/ai/compat-provider-form.tsx | 3 +- .../settings/ai/fallback-chain-card.tsx | 7 +-- .../settings/ai/local-provider-form.tsx | 3 +- .../settings/ai/openai-provider-form.tsx | 3 +- .../settings/ai/response-timeout-card.tsx | 29 +++++---- .../settings/ai/runtime-actions-row.tsx | 15 +++-- src/components/settings/email-card.tsx | 16 ++--- .../import-panel/dose-history-import-card.tsx | 20 +++---- .../settings/notification-channel-toggle.ts | 42 ++++++++----- src/components/settings/ntfy-card.tsx | 29 +++------ .../report-selection/selection-panel.tsx | 3 +- .../__tests__/oidc-reproof-choice.test.ts | 25 +++++++- .../passkey-removal-step-up.test.tsx | 14 +++-- .../__tests__/totp-card.test.tsx | 7 ++- .../existing-factor-reauth-dialog.tsx | 3 +- .../security-section/passkey-list-section.tsx | 12 +++- .../security-section/recent-proof-flow.ts | 4 +- .../security-section/security-keys-card.tsx | 12 ++-- .../settings/security-section/totp-card.tsx | 45 ++++---------- .../security-section/use-recent-proof.tsx | 1 + .../settings/thresholds-editor-section.tsx | 20 ++----- src/components/settings/webhook-card.tsx | 29 +++------ src/components/targets/target-edit-sheet.tsx | 8 +-- src/lib/api/api-fetch.ts | 8 ++- src/lib/openapi/error-codes.ts | 7 ++- 76 files changed, 584 insertions(+), 521 deletions(-) diff --git a/messages/de.json b/messages/de.json index c3db429d5..8ee193b89 100644 --- a/messages/de.json +++ b/messages/de.json @@ -144,7 +144,10 @@ "required": "Bestätige dein aktuelles Passwort, um das zu ändern.", "failed": "Die Bestätigung ist fehlgeschlagen. Es wurde nichts geändert." }, - "missing": "Deine Sitzung ist abgelaufen, daher wurde nichts geändert. Melde dich erneut an, um weiterzumachen." + "missing": "Deine Sitzung ist abgelaufen, daher wurde nichts geändert. Melde dich erneut an, um weiterzumachen.", + "passkey": { + "last_method": "Dieser Passkey ist deine einzige Anmeldemöglichkeit. Lege zuerst ein Passwort fest oder füge einen weiteren Passkey hinzu." + } }, "biomarkers": { "unit": { @@ -185,6 +188,9 @@ "rate_limited": "Gerade gibt es zu viele Anfragen, daher wurde nichts geändert. Warte kurz und versuche es erneut.", "forbidden": "Dafür fehlt dir die Berechtigung, daher wurde nichts geändert.", "invalid": "Einige Angaben wurden nicht angenommen, daher wurde nichts geändert. Prüfe sie und versuche es erneut." + }, + "ai_provider": { + "private_host": "Diese Adresse liegt in einem privaten Netz. Der Betreiber muss ihren genauen Ursprung erst in AI_PRIVATE_ORIGINS freigeben." } }, "doctorReport": { @@ -1539,7 +1545,8 @@ "gallbladderDiscomfort": "Gallenblasen-Beschwerden" }, "deleteConfirmTitle": "Eintrag löschen?", - "deleteConfirmBody": "Dieser Nebenwirkungs-Eintrag wird dauerhaft entfernt." + "deleteConfirmBody": "Dieser Nebenwirkungs-Eintrag wird dauerhaft entfernt.", + "saveError": "Eintrag konnte nicht gespeichert werden" }, "scheduling": { "naturalLanguage": { @@ -7756,7 +7763,9 @@ "hrv": "HRV", "weight": "Wiegungen", "cycle": "Menstruationszyklus" - } + }, + "passkeyRenameFailed": "Der Passkey konnte nicht umbenannt werden.", + "passkeyRemoveFailed": "Der Passkey konnte nicht entfernt werden." }, "admin": { "title": "Administration", diff --git a/messages/en.json b/messages/en.json index d897c04e6..5a5554e39 100644 --- a/messages/en.json +++ b/messages/en.json @@ -144,7 +144,10 @@ "required": "Confirm your current password to change this.", "failed": "That did not verify. Nothing was changed." }, - "missing": "Your session has ended, so nothing was changed. Sign in again to continue." + "missing": "Your session has ended, so nothing was changed. Sign in again to continue.", + "passkey": { + "last_method": "This passkey is your only way to sign in. Set a password or add another passkey first." + } }, "biomarkers": { "unit": { @@ -185,6 +188,9 @@ "rate_limited": "There are too many requests just now, so nothing was changed. Wait a moment and try again.", "forbidden": "You don't have permission for this, so nothing was changed.", "invalid": "Some of the entries were not accepted, so nothing was changed. Check them and try again." + }, + "ai_provider": { + "private_host": "This address is on a private network. The operator has to allow its exact origin in AI_PRIVATE_ORIGINS before it can be used." } }, "doctorReport": { @@ -1539,7 +1545,8 @@ "gallbladderDiscomfort": "Gallbladder discomfort" }, "deleteConfirmTitle": "Delete this entry?", - "deleteConfirmBody": "This side-effect entry will be permanently removed." + "deleteConfirmBody": "This side-effect entry will be permanently removed.", + "saveError": "Could not save the entry" }, "scheduling": { "naturalLanguage": { @@ -7756,7 +7763,9 @@ "hrv": "HRV", "weight": "weigh-ins", "cycle": "menstrual cycle" - } + }, + "passkeyRenameFailed": "Could not rename the passkey.", + "passkeyRemoveFailed": "Could not remove the passkey." }, "admin": { "title": "Administration", diff --git a/messages/es.json b/messages/es.json index c6c9875ea..283ba8e2f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -144,7 +144,10 @@ "required": "Confirma tu contraseña actual para cambiar esto.", "failed": "La verificación ha fallado. No se ha cambiado nada." }, - "missing": "Tu sesión ha caducado, así que no se ha cambiado nada. Inicia sesión de nuevo para continuar." + "missing": "Tu sesión ha caducado, así que no se ha cambiado nada. Inicia sesión de nuevo para continuar.", + "passkey": { + "last_method": "Esta clave de acceso es tu única forma de iniciar sesión. Primero establece una contraseña o añade otra clave de acceso." + } }, "biomarkers": { "unit": { @@ -185,6 +188,9 @@ "rate_limited": "Hay demasiadas solicitudes ahora mismo, así que no se ha cambiado nada. Espera un momento y vuelve a intentarlo.", "forbidden": "No tienes permiso para esto, así que no se ha cambiado nada.", "invalid": "Algunos datos no se aceptaron, así que no se ha cambiado nada. Revísalos y vuelve a intentarlo." + }, + "ai_provider": { + "private_host": "Esta dirección está en una red privada. El operador tiene que permitir antes su origen exacto en AI_PRIVATE_ORIGINS." } }, "doctorReport": { @@ -1539,7 +1545,8 @@ "gallbladderDiscomfort": "Molestia en la vesícula biliar" }, "deleteConfirmTitle": "¿Eliminar esta entrada?", - "deleteConfirmBody": "Esta entrada de efecto secundario se eliminará de forma permanente." + "deleteConfirmBody": "Esta entrada de efecto secundario se eliminará de forma permanente.", + "saveError": "No se pudo guardar la entrada" }, "scheduling": { "naturalLanguage": { @@ -7756,7 +7763,9 @@ "hrv": "VFC", "weight": "pesajes", "cycle": "ciclo menstrual" - } + }, + "passkeyRenameFailed": "No se pudo cambiar el nombre de la clave de acceso.", + "passkeyRemoveFailed": "No se pudo quitar la clave de acceso." }, "admin": { "title": "Administration", diff --git a/messages/fr.json b/messages/fr.json index 95779eaa4..a04c2fd05 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -144,7 +144,10 @@ "required": "Confirmez votre mot de passe actuel pour modifier ceci.", "failed": "La vérification a échoué. Rien n'a été modifié." }, - "missing": "Votre session a expiré, rien n'a donc été modifié. Reconnectez-vous pour continuer." + "missing": "Votre session a expiré, rien n'a donc été modifié. Reconnectez-vous pour continuer.", + "passkey": { + "last_method": "Cette clé d’accès est votre seul moyen de connexion. Définissez d’abord un mot de passe ou ajoutez une autre clé d’accès." + } }, "biomarkers": { "unit": { @@ -185,6 +188,9 @@ "rate_limited": "Il y a trop de requêtes pour le moment, rien n'a donc été modifié. Patientez un instant puis réessayez.", "forbidden": "Vous n'avez pas l'autorisation pour cette action, rien n'a donc été modifié.", "invalid": "Certaines saisies n'ont pas été acceptées, rien n'a donc été modifié. Vérifiez-les puis réessayez." + }, + "ai_provider": { + "private_host": "Cette adresse se trouve sur un réseau privé. L’opérateur doit d’abord autoriser son origine exacte dans AI_PRIVATE_ORIGINS." } }, "doctorReport": { @@ -1539,7 +1545,8 @@ "gallbladderDiscomfort": "Inconfort de la vésicule biliaire" }, "deleteConfirmTitle": "Supprimer cette entrée ?", - "deleteConfirmBody": "Cette entrée d’effet secondaire sera définitivement supprimée." + "deleteConfirmBody": "Cette entrée d’effet secondaire sera définitivement supprimée.", + "saveError": "Impossible d'enregistrer l'entrée" }, "scheduling": { "naturalLanguage": { @@ -7756,7 +7763,9 @@ "hrv": "VFC", "weight": "pesées", "cycle": "cycle menstruel" - } + }, + "passkeyRenameFailed": "Impossible de renommer la clé d’accès.", + "passkeyRemoveFailed": "Impossible de supprimer la clé d’accès." }, "admin": { "title": "Administration", diff --git a/messages/it.json b/messages/it.json index 1bc25ec4c..2be4d5cf8 100644 --- a/messages/it.json +++ b/messages/it.json @@ -144,7 +144,10 @@ "required": "Conferma la password attuale per modificarlo.", "failed": "La verifica non è riuscita. Non è stato modificato nulla." }, - "missing": "La tua sessione è scaduta, quindi non è stato modificato nulla. Accedi di nuovo per continuare." + "missing": "La tua sessione è scaduta, quindi non è stato modificato nulla. Accedi di nuovo per continuare.", + "passkey": { + "last_method": "Questa passkey è il tuo unico modo per accedere. Imposta prima una password o aggiungi un’altra passkey." + } }, "biomarkers": { "unit": { @@ -185,6 +188,9 @@ "rate_limited": "Ci sono troppe richieste in questo momento, quindi non è stato modificato nulla. Attendi un attimo e riprova.", "forbidden": "Non hai l'autorizzazione per farlo, quindi non è stato modificato nulla.", "invalid": "Alcuni dati non sono stati accettati, quindi non è stato modificato nulla. Controllali e riprova." + }, + "ai_provider": { + "private_host": "Questo indirizzo si trova in una rete privata. L’operatore deve prima consentirne l’origine esatta in AI_PRIVATE_ORIGINS." } }, "doctorReport": { @@ -1539,7 +1545,8 @@ "gallbladderDiscomfort": "Fastidio alla cistifellea" }, "deleteConfirmTitle": "Eliminare questa voce?", - "deleteConfirmBody": "Questa voce di effetto collaterale verrà rimossa definitivamente." + "deleteConfirmBody": "Questa voce di effetto collaterale verrà rimossa definitivamente.", + "saveError": "Impossibile salvare la voce" }, "scheduling": { "naturalLanguage": { @@ -7756,7 +7763,9 @@ "hrv": "HRV", "weight": "pesate", "cycle": "ciclo mestruale" - } + }, + "passkeyRenameFailed": "Impossibile rinominare la passkey.", + "passkeyRemoveFailed": "Impossibile rimuovere la passkey." }, "admin": { "title": "Administration", diff --git a/messages/ko.json b/messages/ko.json index 5aeef7e5d..8bee83435 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -144,7 +144,10 @@ "required": "변경하려면 현재 비밀번호를 확인하세요.", "failed": "확인에 실패했습니다. 아무것도 변경되지 않았습니다." }, - "missing": "세션이 만료되어 아무것도 변경되지 않았어요. 계속하려면 다시 로그인해 주세요." + "missing": "세션이 만료되어 아무것도 변경되지 않았어요. 계속하려면 다시 로그인해 주세요.", + "passkey": { + "last_method": "이 패스키가 유일한 로그인 수단이에요. 먼저 비밀번호를 설정하거나 패스키를 하나 더 추가하세요." + } }, "biomarkers": { "unit": { @@ -185,6 +188,9 @@ "rate_limited": "지금은 요청이 너무 많아 아무것도 변경되지 않았어요. 잠시 후 다시 시도해 주세요.", "forbidden": "이 작업을 할 권한이 없어 아무것도 변경되지 않았어요.", "invalid": "일부 입력값이 받아들여지지 않아 아무것도 변경되지 않았어요. 확인한 후 다시 시도해 주세요." + }, + "ai_provider": { + "private_host": "이 주소는 사설 네트워크에 있어요. 운영자가 먼저 AI_PRIVATE_ORIGINS에 정확한 출처를 허용해야 해요." } }, "doctorReport": { @@ -1539,7 +1545,8 @@ "gallbladderDiscomfort": "담낭 불편감" }, "deleteConfirmTitle": "이 기록을 삭제할까요?", - "deleteConfirmBody": "이 부작용 기록이 영구히 삭제돼요." + "deleteConfirmBody": "이 부작용 기록이 영구히 삭제돼요.", + "saveError": "기록을 저장하지 못했어요" }, "scheduling": { "naturalLanguage": { @@ -7756,7 +7763,9 @@ "hrv": "HRV", "weight": "체중 측정", "cycle": "생리 주기" - } + }, + "passkeyRenameFailed": "패스키 이름을 변경하지 못했어요.", + "passkeyRemoveFailed": "패스키를 삭제하지 못했어요." }, "admin": { "title": "관리자", diff --git a/messages/nl.json b/messages/nl.json index e0a3f8535..680cb970c 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -144,7 +144,10 @@ "required": "Bevestig je huidige wachtwoord om dit te wijzigen.", "failed": "De verificatie is mislukt. Er is niets gewijzigd." }, - "missing": "Je sessie is verlopen, dus er is niets gewijzigd. Meld je opnieuw aan om verder te gaan." + "missing": "Je sessie is verlopen, dus er is niets gewijzigd. Meld je opnieuw aan om verder te gaan.", + "passkey": { + "last_method": "Deze passkey is je enige manier om in te loggen. Stel eerst een wachtwoord in of voeg nog een passkey toe." + } }, "biomarkers": { "unit": { @@ -185,6 +188,9 @@ "rate_limited": "Er zijn net te veel verzoeken, dus er is niets gewijzigd. Wacht even en probeer het opnieuw.", "forbidden": "Je hebt hier geen toestemming voor, dus er is niets gewijzigd.", "invalid": "Sommige gegevens zijn niet geaccepteerd, dus er is niets gewijzigd. Controleer ze en probeer het opnieuw." + }, + "ai_provider": { + "private_host": "Dit adres ligt in een privénetwerk. De beheerder moet de exacte origin eerst toestaan in AI_PRIVATE_ORIGINS." } }, "doctorReport": { @@ -1539,7 +1545,8 @@ "gallbladderDiscomfort": "Galblaasklachten" }, "deleteConfirmTitle": "Deze registratie verwijderen?", - "deleteConfirmBody": "Deze bijwerkingregistratie wordt definitief verwijderd." + "deleteConfirmBody": "Deze bijwerkingregistratie wordt definitief verwijderd.", + "saveError": "De registratie kon niet worden opgeslagen" }, "scheduling": { "naturalLanguage": { @@ -7756,7 +7763,9 @@ "hrv": "HRV", "weight": "wegingen", "cycle": "menstruatiecyclus" - } + }, + "passkeyRenameFailed": "De naam van de passkey kon niet worden gewijzigd.", + "passkeyRemoveFailed": "De passkey kon niet worden verwijderd." }, "admin": { "title": "Beheer", diff --git a/messages/pl.json b/messages/pl.json index d35c7b385..f688e8ad1 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -144,7 +144,10 @@ "required": "Potwierdź obecne hasło, aby to zmienić.", "failed": "Weryfikacja nie powiodła się. Nic nie zostało zmienione." }, - "missing": "Twoja sesja wygasła, więc nic nie zostało zmienione. Zaloguj się ponownie, aby kontynuować." + "missing": "Twoja sesja wygasła, więc nic nie zostało zmienione. Zaloguj się ponownie, aby kontynuować.", + "passkey": { + "last_method": "Ten klucz dostępu to Twój jedyny sposób logowania. Najpierw ustaw hasło albo dodaj kolejny klucz dostępu." + } }, "biomarkers": { "unit": { @@ -185,6 +188,9 @@ "rate_limited": "W tej chwili jest zbyt wiele zapytań, więc nic nie zostało zmienione. Zaczekaj chwilę i spróbuj ponownie.", "forbidden": "Nie masz do tego uprawnień, więc nic nie zostało zmienione.", "invalid": "Niektóre dane nie zostały przyjęte, więc nic nie zostało zmienione. Sprawdź je i spróbuj ponownie." + }, + "ai_provider": { + "private_host": "Ten adres jest w sieci prywatnej. Operator musi najpierw dopuścić jego dokładne źródło w AI_PRIVATE_ORIGINS." } }, "doctorReport": { @@ -1539,7 +1545,8 @@ "gallbladderDiscomfort": "Dolegliwości pęcherzyka żółciowego" }, "deleteConfirmTitle": "Usunąć ten wpis?", - "deleteConfirmBody": "Ten wpis o skutku ubocznym zostanie trwale usunięty." + "deleteConfirmBody": "Ten wpis o skutku ubocznym zostanie trwale usunięty.", + "saveError": "Nie udało się zapisać wpisu" }, "scheduling": { "naturalLanguage": { @@ -7756,7 +7763,9 @@ "hrv": "HRV", "weight": "pomiary wagi", "cycle": "cykl menstruacyjny" - } + }, + "passkeyRenameFailed": "Nie udało się zmienić nazwy klucza dostępu.", + "passkeyRemoveFailed": "Nie udało się usunąć klucza dostępu." }, "admin": { "title": "Administracja", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 05e875766..17035a7a1 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -144,7 +144,10 @@ "required": "Confirme sua senha atual para alterar isto.", "failed": "A verificação não foi concluída. Nada foi alterado." }, - "missing": "Sua sessão terminou, então nada foi alterado. Entre novamente para continuar." + "missing": "Sua sessão terminou, então nada foi alterado. Entre novamente para continuar.", + "passkey": { + "last_method": "Esta chave de acesso é sua única forma de entrar. Defina uma senha ou adicione outra chave de acesso primeiro." + } }, "biomarkers": { "unit": { @@ -185,6 +188,9 @@ "rate_limited": "Há solicitações demais neste momento, então nada foi alterado. Aguarde um pouco e tente de novo.", "forbidden": "Você não tem permissão para isso, então nada foi alterado.", "invalid": "Alguns dados não foram aceitos, então nada foi alterado. Confira e tente de novo." + }, + "ai_provider": { + "private_host": "Este endereço está em uma rede privada. O operador precisa primeiro permitir a origem exata em AI_PRIVATE_ORIGINS." } }, "doctorReport": { @@ -1539,7 +1545,8 @@ "gallbladderDiscomfort": "Desconforto na vesícula" }, "deleteConfirmTitle": "Excluir esta entrada?", - "deleteConfirmBody": "Esta entrada de efeito colateral será removida permanentemente." + "deleteConfirmBody": "Esta entrada de efeito colateral será removida permanentemente.", + "saveError": "Não foi possível salvar a entrada" }, "scheduling": { "naturalLanguage": { @@ -7756,7 +7763,9 @@ "hrv": "VFC", "weight": "pesagens", "cycle": "ciclo menstrual" - } + }, + "passkeyRenameFailed": "Não foi possível renomear a chave de acesso.", + "passkeyRemoveFailed": "Não foi possível remover a chave de acesso." }, "admin": { "title": "Administração", diff --git a/messages/ru.json b/messages/ru.json index c96b8a0b2..05d5c1372 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -144,7 +144,10 @@ "required": "Подтвердите текущий пароль, чтобы изменить это.", "failed": "Не подтверждено. Ничего не изменено." }, - "missing": "Сеанс завершён, поэтому ничего не изменено. Войдите снова, чтобы продолжить." + "missing": "Сеанс завершён, поэтому ничего не изменено. Войдите снова, чтобы продолжить.", + "passkey": { + "last_method": "Этот ключ доступа остаётся вашим единственным способом входа. Сначала задайте пароль или добавьте ещё один ключ доступа." + } }, "biomarkers": { "unit": { @@ -185,6 +188,9 @@ "rate_limited": "Сейчас слишком много запросов, поэтому ничего не изменено. Подождите немного и попробуйте снова.", "forbidden": "У вас нет прав на это действие, поэтому ничего не изменено.", "invalid": "Некоторые данные не приняты, поэтому ничего не изменено. Проверьте их и попробуйте снова." + }, + "ai_provider": { + "private_host": "Этот адрес находится в частной сети. Сначала оператор должен разрешить его точный источник в AI_PRIVATE_ORIGINS." } }, "doctorReport": { @@ -1539,7 +1545,8 @@ "gallbladderDiscomfort": "Дискомфорт в желчном пузыре" }, "deleteConfirmTitle": "Удалить эту запись?", - "deleteConfirmBody": "Эта запись о побочном эффекте будет удалена навсегда." + "deleteConfirmBody": "Эта запись о побочном эффекте будет удалена навсегда.", + "saveError": "Не удалось сохранить запись" }, "scheduling": { "naturalLanguage": { @@ -7756,7 +7763,9 @@ "hrv": "ВСР", "weight": "взвешивания", "cycle": "менструальный цикл" - } + }, + "passkeyRenameFailed": "Не удалось переименовать ключ доступа.", + "passkeyRemoveFailed": "Не удалось убрать ключ доступа." }, "admin": { "title": "Администрирование", diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index a6c30e3f9..7ac0176fa 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 339, - "total": 110080, + "total": 110086, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275, @@ -36,7 +36,7 @@ "app/api/nutrients/batch/route.ts :: postBatch": 304, "app/api/onboarding/complete/route.ts :: completeNeedsFlow": 218, "app/api/record-settings/[family]/route.ts :: apiHandler(arg) #2": 262, - "app/api/user/ai-provider/route.ts :: apiHandler(arg) #2": 248, + "app/api/user/ai-provider/route.ts :: apiHandler(arg) #2": 250, "app/api/withings/callback/route.ts :: apiHandler(arg)": 247, "app/auth/login/page.tsx :: LoginPage": 569, "app/auth/register/page.tsx :: RegisterPage": 285, @@ -52,7 +52,7 @@ "components/admin/admin-shell.tsx :: AdminShell": 254, "components/admin/api-token-overview-section.tsx :: ApiTokenOverviewSection": 212, "components/admin/app-log-preview-section.tsx :: AppLogPreviewSection": 341, - "components/admin/central-codex-section.tsx :: CentralCodexSection": 241, + "components/admin/central-codex-section.tsx :: CentralCodexSection": 228, "components/admin/encryption-section.tsx :: EncryptionSection": 264, "components/admin/general-settings-section.tsx :: GeneralSettingsSection": 220, "components/admin/glitchtip-section.tsx :: GlitchtipSection": 233, @@ -61,7 +61,7 @@ "components/admin/login-overview-section.tsx :: LoginOverviewSection": 503, "components/admin/pulse-day-mean-card.tsx :: PulseDayMeanCard": 305, "components/admin/reminders-section.tsx :: RemindersSection": 309, - "components/admin/user-management-section.tsx :: UserManagementSection": 622, + "components/admin/user-management-section.tsx :: UserManagementSection": 630, "components/admin/web-push-vapid-section.tsx :: WebPushVapidSection": 217, "components/auth/claim-profile.tsx :: ClaimProfile": 429, "components/auth/mfa-login-step.tsx :: MfaLoginStep": 217, @@ -164,7 +164,7 @@ "components/medications/sections/composition-section.tsx :: CompositionEditor": 362, "components/medications/sections/inventory-dialogs.tsx :: AddInventoryDialog": 238, "components/medications/sections/inventory-section.tsx :: InventorySection": 368, - "components/medications/side-effects-section.tsx :: SideEffectsSection": 516, + "components/medications/side-effects-section.tsx :: SideEffectsSection": 518, "components/medications/wizard/medication-wizard-dialog.tsx :: WizardDialogShell": 516, "components/medications/wizard/steps/step3-dose.tsx :: Step3Dose": 222, "components/medications/wizard/steps/step8-summary.tsx :: Step8Summary": 230, @@ -190,7 +190,7 @@ "components/settings/account-section/index.tsx :: AccountSection": 659, "components/settings/ai/codex-provider-form.tsx :: CodexProviderForm": 280, "components/settings/ai/fallback-chain-card.tsx :: FallbackChainCard": 228, - "components/settings/ai/runtime-actions-row.tsx :: RuntimeActionsRow": 282, + "components/settings/ai/runtime-actions-row.tsx :: RuntimeActionsRow": 284, "components/settings/api-section.tsx :: ApiTokensCard": 269, "components/settings/coach-nudge-card.tsx :: CoachNudgeCard": 251, "components/settings/coach-prefs-section.tsx :: CoachPrefsSection": 350, @@ -211,7 +211,7 @@ "components/settings/mood-reminder-card.tsx :: MoodReminderCard": 234, "components/settings/report-selection/selection-panel.tsx :: HealthRecordExportPanel": 370, "components/settings/score-section.tsx :: ScoreSection": 383, - "components/settings/security-section/passkey-list-section.tsx :: PasskeyListSection": 390, + "components/settings/security-section/passkey-list-section.tsx :: PasskeyListSection": 395, "components/settings/security-section/security-keys-card.tsx :: SecurityKeysCard": 288, "components/settings/security-section/totp-card.tsx :: TotpCard": 533, "components/settings/security-sessions-card.tsx :: SecuritySessionsCard": 245, diff --git a/src/app/api/auth/passkeys/[id]/route.ts b/src/app/api/auth/passkeys/[id]/route.ts index 206d166cd..5d3f7b14f 100644 --- a/src/app/api/auth/passkeys/[id]/route.ts +++ b/src/app/api/auth/passkeys/[id]/route.ts @@ -126,6 +126,7 @@ export const DELETE = apiHandler( return apiError( "Cannot delete. At least one authentication method must remain", 400, + { errorCode: "auth.passkey.last_method" }, ); } diff --git a/src/app/api/user/ai-provider/route.ts b/src/app/api/user/ai-provider/route.ts index a7f53faf6..09a99b994 100644 --- a/src/app/api/user/ai-provider/route.ts +++ b/src/app/api/user/ai-provider/route.ts @@ -265,6 +265,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => { return apiError( "Base URL points to an internal/private host. The operator must allow its exact origin on this instance via AI_PRIVATE_ORIGINS (e.g. http://ollama.lan:11434) for a self-hosted Ollama / LM Studio.", 422, + { errorCode: "ai_provider.private_host" }, ); } updates.aiBaseUrl = trimmed; @@ -289,6 +290,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => { return apiError( "Base URL points to an internal/private host. The operator must allow its exact origin on this instance via AI_PRIVATE_ORIGINS (e.g. http://litellm.lan:4000) for a self-hosted gateway.", 422, + { errorCode: "ai_provider.private_host" }, ); } updates.aiCompatBaseUrl = trimmed; diff --git a/src/components/admin/_shared.tsx b/src/components/admin/_shared.tsx index 6ea08b5a3..dd65bca48 100644 --- a/src/components/admin/_shared.tsx +++ b/src/components/admin/_shared.tsx @@ -12,6 +12,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { PasswordInput as SharedPasswordInput } from "@/components/ui/password-input"; import { apiGet, apiPut } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; /** * Re-export the canonical password input so admin sections have access to @@ -323,29 +324,13 @@ export function useUpdateSettings() { toast.success(t("common.saved")); }, onError: (err) => { - showErrorToast( - err instanceof Error && err.message - ? err.message - : t("admin.settingsSaveError"), - { cause: err }, - ); + showErrorToast(localizedApiError(err, t, "admin.settingsSaveError"), { + cause: err, + }); }, }); } -export async function getApiErrorMessage(response: Response): Promise { - const fallback = `HTTP ${response.status}`; - try { - const json = (await response.json()) as { error?: string }; - if (typeof json?.error === "string" && json.error.trim().length > 0) { - return json.error; - } - } catch { - return fallback; - } - return fallback; -} - /** * Shared `auth.*` audit-action label map. Two surfaces consume it * today (`/admin/login-overview` and the dashboard recent-activity diff --git a/src/components/admin/ai-server-key-section.tsx b/src/components/admin/ai-server-key-section.tsx index ee90687e8..a76e2cd5e 100644 --- a/src/components/admin/ai-server-key-section.tsx +++ b/src/components/admin/ai-server-key-section.tsx @@ -21,6 +21,7 @@ import { queryKeys, } from "@/lib/query-keys"; import { apiGet, apiPut } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { PasswordInput } from "./_shared"; /** @@ -90,10 +91,9 @@ export function AiServerKeySection() { toast.success(t("admin.aiServerKey.saved")); }, onError: (error: unknown) => { - showErrorToast( - error instanceof Error ? error.message : t("admin.aiServerKey.error"), - { cause: error }, - ); + showErrorToast(localizedApiError(error, t, "admin.aiServerKey.error"), { + cause: error, + }); }, }); diff --git a/src/components/admin/ai-usage-section.tsx b/src/components/admin/ai-usage-section.tsx index 94cdd4bcf..6feee7cc2 100644 --- a/src/components/admin/ai-usage-section.tsx +++ b/src/components/admin/ai-usage-section.tsx @@ -35,6 +35,7 @@ import { type AiUsageLevel, } from "@/lib/ai/usage-level"; import { apiGet, apiPut } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { useTranslations } from "@/lib/i18n/context"; import { aiInputDependentKeys, @@ -122,12 +123,9 @@ function AiUsageForm({ bounds }: { bounds: AiUsageBounds }) { toast.success(t("common.saved")); }, onError: (err) => { - showErrorToast( - err instanceof Error && err.message - ? err.message - : t("admin.settingsSaveError"), - { cause: err }, - ); + showErrorToast(localizedApiError(err, t, "admin.settingsSaveError"), { + cause: err, + }); }, }); diff --git a/src/components/admin/assistant-section.tsx b/src/components/admin/assistant-section.tsx index f42880bf3..d3cfde645 100644 --- a/src/components/admin/assistant-section.tsx +++ b/src/components/admin/assistant-section.tsx @@ -15,6 +15,7 @@ import { } from "@/lib/query-keys"; import { SettingsToggle } from "./_shared"; import { apiGet, apiPut } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { Label } from "@/components/ui/label"; import { NativeSelect } from "@/components/ui/native-select"; import { @@ -105,12 +106,9 @@ function useUpdateAssistantFlags() { toast.success(t("common.saved")); }, onError: (err) => { - showErrorToast( - err instanceof Error && err.message - ? err.message - : t("admin.settingsSaveError"), - { cause: err }, - ); + showErrorToast(localizedApiError(err, t, "admin.settingsSaveError"), { + cause: err, + }); client.invalidateQueries({ queryKey: queryKeys.adminAssistantFlags(), }); diff --git a/src/components/admin/backups/use-backup-actions.ts b/src/components/admin/backups/use-backup-actions.ts index 033ea391b..525b3bfb6 100644 --- a/src/components/admin/backups/use-backup-actions.ts +++ b/src/components/admin/backups/use-backup-actions.ts @@ -7,14 +7,13 @@ import { ReproofCancelledError, throwIfReproofRequired, } from "@/components/settings/security-section/use-recent-proof"; -import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch"; +import { apiFetchRaw, apiPost, throwApiError } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { showErrorToast } from "@/lib/errors/error-toast"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import type { BackupRow } from "@/types/backups"; -import { getApiErrorMessage } from "../_shared"; - /** "Run backup now": enqueues the pg-boss `data-backup` job. */ export function useRunBackup() { const { t } = useTranslations(); @@ -34,9 +33,7 @@ export function useRunBackup() { }, onError: (err) => { showErrorToast( - err instanceof Error && err.message - ? err.message - : t("admin.section.backups.runFailed"), + localizedApiError(err, t, "admin.section.backups.runFailed"), { cause: err }, ); }, @@ -62,9 +59,7 @@ export function useBackupDownload( await apiFetchRaw(`/api/admin/backups/${row.id}/download`), ), ); - if (!res.ok) { - throw new Error(await getApiErrorMessage(res)); - } + if (!res.ok) await throwApiError(res); // Use the server-provided Content-Disposition filename if present, // otherwise fall back to a deterministic client-side name. The // server filename is more accurate (uses the actual createdAt). @@ -86,9 +81,7 @@ export function useBackupDownload( } catch (err) { if (err instanceof ReproofCancelledError) return; showErrorToast( - err instanceof Error && err.message - ? err.message - : t("admin.section.backups.downloadFailed"), + localizedApiError(err, t, "admin.section.backups.downloadFailed"), { cause: err }, ); } finally { diff --git a/src/components/admin/backups/use-backup-restores.ts b/src/components/admin/backups/use-backup-restores.ts index 7f1854ca5..523843c32 100644 --- a/src/components/admin/backups/use-backup-restores.ts +++ b/src/components/admin/backups/use-backup-restores.ts @@ -5,6 +5,7 @@ import { toast } from "sonner"; import type { useRecentProof } from "@/components/settings/security-section/use-recent-proof"; import { ReproofCancelledError } from "@/components/settings/security-section/use-recent-proof"; import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { showErrorToast } from "@/lib/errors/error-toast"; import type { MissingBackupSection, @@ -188,9 +189,7 @@ export function useBackupRestores( } setRefusedSections([]); showErrorToast( - err instanceof Error && err.message - ? err.message - : t("admin.section.backups.restoreFailed"), + localizedApiError(err, t, "admin.section.backups.restoreFailed"), { cause: err }, ); }, diff --git a/src/components/admin/backups/use-backup-upload.ts b/src/components/admin/backups/use-backup-upload.ts index c38e29f8f..f4f237819 100644 --- a/src/components/admin/backups/use-backup-upload.ts +++ b/src/components/admin/backups/use-backup-upload.ts @@ -5,6 +5,7 @@ import { toast } from "sonner"; import type { useRecentProof } from "@/components/settings/security-section/use-recent-proof"; import { ReproofCancelledError } from "@/components/settings/security-section/use-recent-proof"; import { ApiError, apiFetch, apiGet } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { showErrorToast } from "@/lib/errors/error-toast"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -167,9 +168,7 @@ export function useBackupUpload( return; } showErrorToast( - err instanceof Error && err.message - ? err.message - : t("admin.section.backups.uploadFailed"), + localizedApiError(err, t, "admin.section.backups.uploadFailed"), { cause: err }, ); }, diff --git a/src/components/admin/central-codex-section.tsx b/src/components/admin/central-codex-section.tsx index ff918faff..05cf106f5 100644 --- a/src/components/admin/central-codex-section.tsx +++ b/src/components/admin/central-codex-section.tsx @@ -28,7 +28,8 @@ import { Button } from "@/components/ui/button"; import { ConfirmButton } from "@/components/ui/confirm-button"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; -import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; +import { apiDelete, apiGet, apiPost } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { formatDateTime } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; import { @@ -66,21 +67,20 @@ export function CentralCodexSection() { async function handleConnect() { setDevicePolling(true); try { - const res = await apiFetchRaw("/api/admin/central-codex/device-start", { - method: "POST", - }); - const json = await res.json(); - if (!res.ok) throw new Error(json.error || t("admin.centralCodex.error")); + const data = await apiPost<{ + userCode: string; + verificationUrl: string; + intervalSeconds: number; + }>("/api/admin/central-codex/device-start"); setDeviceCode({ - userCode: json.data.userCode, - verificationUrl: json.data.verificationUrl, - intervalSeconds: json.data.intervalSeconds, + userCode: data.userCode, + verificationUrl: data.verificationUrl, + intervalSeconds: data.intervalSeconds, }); } catch (err) { - showErrorToast( - err instanceof Error ? err.message : t("admin.centralCodex.error"), - { cause: err }, - ); + showErrorToast(localizedApiError(err, t, "admin.centralCodex.error"), { + cause: err, + }); setDevicePolling(false); } } @@ -92,15 +92,11 @@ export function CentralCodexSection() { async function tick() { try { - const res = await apiFetchRaw("/api/admin/central-codex/device-poll", { - method: "POST", - }); - const json = await res.json(); + const data = await apiPost<{ status?: string } | null>( + "/api/admin/central-codex/device-poll", + ); if (cancelled) return; - if (!res.ok) { - throw new Error(json.error || t("admin.centralCodex.error")); - } - if (json.data?.status === "connected") { + if (data?.status === "connected") { setDeviceCode(null); setDevicePolling(false); toast.success(t("admin.centralCodex.connected")); @@ -113,10 +109,9 @@ export function CentralCodexSection() { if (!cancelled) setTimeout(tick, intervalMs); } catch (err) { if (cancelled) return; - showErrorToast( - err instanceof Error ? err.message : t("admin.centralCodex.error"), - { cause: err }, - ); + showErrorToast(localizedApiError(err, t, "admin.centralCodex.error"), { + cause: err, + }); setDeviceCode(null); setDevicePolling(false); } @@ -133,23 +128,16 @@ export function CentralCodexSection() { async function handleDisconnect() { setDisconnecting(true); try { - const res = await apiFetchRaw("/api/admin/central-codex", { - method: "DELETE", - }); - if (!res.ok) { - const json = await res.json(); - throw new Error(json.error || t("admin.centralCodex.error")); - } + await apiDelete("/api/admin/central-codex"); toast.success(t("admin.centralCodex.disconnected")); queryClient.invalidateQueries({ queryKey: queryKeys.adminCentralCodex(), }); void invalidateKeys(queryClient, aiInputDependentKeys); } catch (err) { - showErrorToast( - err instanceof Error ? err.message : t("admin.centralCodex.error"), - { cause: err }, - ); + showErrorToast(localizedApiError(err, t, "admin.centralCodex.error"), { + cause: err, + }); } finally { setDisconnecting(false); } diff --git a/src/components/admin/danger-zone-section.tsx b/src/components/admin/danger-zone-section.tsx index b14ca7c4b..dcd5b9102 100644 --- a/src/components/admin/danger-zone-section.tsx +++ b/src/components/admin/danger-zone-section.tsx @@ -22,6 +22,7 @@ import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { apiDelete } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { randomId } from "@/lib/random-id"; import { recentProofErrorMessage, @@ -81,8 +82,10 @@ export function DangerZoneSection() { }), ); }, - onError: (err: Error) => { - setWipeMsg(recentProofErrorMessage(err, err.message)); + onError: (err) => { + setWipeMsg( + recentProofErrorMessage(err, localizedApiError(err, t, "common.error")), + ); }, }); diff --git a/src/components/admin/delete-user-control.tsx b/src/components/admin/delete-user-control.tsx index dc25e1591..ef44ee3af 100644 --- a/src/components/admin/delete-user-control.tsx +++ b/src/components/admin/delete-user-control.tsx @@ -7,6 +7,7 @@ import { ConfirmButton } from "@/components/ui/confirm-button"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; import { showErrorToast } from "@/lib/errors/error-toast"; import { apiDelete } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { formatDate } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -90,9 +91,7 @@ function DeleteUserButton({ user }: { user: AdminUser }) { onError: (err) => { const message = recentProofErrorMessage( err, - err instanceof Error && err.message - ? err.message - : t("admin.section.users.deleteUserFailed"), + localizedApiError(err, t, "admin.section.users.deleteUserFailed"), ); if (message) showErrorToast(message, { cause: err }); }, diff --git a/src/components/admin/encryption-section.tsx b/src/components/admin/encryption-section.tsx index 79e56c541..24e4b1982 100644 --- a/src/components/admin/encryption-section.tsx +++ b/src/components/admin/encryption-section.tsx @@ -43,8 +43,13 @@ import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; -import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; -import { getApiErrorMessage } from "./_shared"; +import { + ApiError, + apiFetchRaw, + apiGet, + throwApiError, +} from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { recentProofErrorMessage, throwIfReproofRequired, @@ -133,12 +138,7 @@ export function EncryptionSection() { }), ), ); - if (!res.ok) { - if (res.status === 401) { - throw new Error(t("admin.section.encryption.stepUpNeeded")); - } - throw new Error(await getApiErrorMessage(res)); - } + if (!res.ok) await throwApiError(res); return res.json(); }, onSuccess: () => { @@ -150,7 +150,9 @@ export function EncryptionSection() { onError: (err: Error) => { const message = recentProofErrorMessage( err, - err.message || t("admin.section.encryption.rotateFailed"), + err instanceof ApiError && err.status === 401 + ? t("admin.section.encryption.stepUpNeeded") + : localizedApiError(err, t, "admin.section.encryption.rotateFailed"), ); if (message === null) return; showErrorToast(message, { cause: err }); diff --git a/src/components/admin/glitchtip-section.tsx b/src/components/admin/glitchtip-section.tsx index 853f14d29..b878905b5 100644 --- a/src/components/admin/glitchtip-section.tsx +++ b/src/components/admin/glitchtip-section.tsx @@ -20,6 +20,7 @@ import { ConfiguredBadge, } from "./_shared"; import { apiPost } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { queryKeys } from "@/lib/query-keys"; export function GlitchtipSection() { @@ -113,9 +114,7 @@ export function GlitchtipSection() { }, onError: (error) => { showErrorToast( - error instanceof Error - ? error.message - : t("admin.monitoringTestFailed"), + localizedApiError(error, t, "admin.monitoringTestFailed"), { cause: error }, ); void queryClient.invalidateQueries({ diff --git a/src/components/admin/measurement-maintenance-card.tsx b/src/components/admin/measurement-maintenance-card.tsx index 3569c0e64..bab3ea441 100644 --- a/src/components/admin/measurement-maintenance-card.tsx +++ b/src/components/admin/measurement-maintenance-card.tsx @@ -33,12 +33,12 @@ import { } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { QueryErrorCard } from "@/components/ui/query-error-card"; -import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; +import { apiFetchRaw, apiGet, throwApiError } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { formatBytes } from "@/lib/format/bytes"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import type { MeasurementMaintenanceStatus } from "@/lib/jobs/measurement-maintenance-status"; import { queryKeys } from "@/lib/query-keys"; -import { getApiErrorMessage } from "./_shared"; const ENDPOINT = "/api/admin/maintenance/measurements"; @@ -70,7 +70,7 @@ export function MeasurementMaintenanceCard() { headers: { "content-type": "application/json" }, body: JSON.stringify({ vacuum: true, reindex: true }), }); - if (!res.ok) throw new Error(await getApiErrorMessage(res)); + if (!res.ok) await throwApiError(res); return ((await res.json()) as { data: { enqueued: boolean } }).data; }, onSuccess: ({ enqueued }) => { @@ -80,9 +80,9 @@ export function MeasurementMaintenanceCard() { queryKey: queryKeys.adminMeasurementMaintenance(), }); }, - onError: (err: Error) => { + onError: (err) => { showErrorToast( - err.message || t("admin.section.maintenance.startFailed"), + localizedApiError(err, t, "admin.section.maintenance.startFailed"), { cause: err }, ); }, diff --git a/src/components/admin/module-availability-section.tsx b/src/components/admin/module-availability-section.tsx index f57122c64..3554c0475 100644 --- a/src/components/admin/module-availability-section.tsx +++ b/src/components/admin/module-availability-section.tsx @@ -23,6 +23,7 @@ import { import type { ModuleKey } from "@/lib/modules/registry"; import { SettingsToggle } from "./_shared"; import { apiGet, apiPatch } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { InlineAction } from "@/components/ui/inline-action"; /** @@ -76,12 +77,9 @@ function useUpdateModuleAvailability() { toast.success(t("common.saved")); }, onError: (err) => { - showErrorToast( - err instanceof Error && err.message - ? err.message - : t("admin.modules.saveError"), - { cause: err }, - ); + showErrorToast(localizedApiError(err, t, "admin.modules.saveError"), { + cause: err, + }); client.invalidateQueries({ queryKey: queryKeys.adminModuleAvailability(), }); diff --git a/src/components/admin/pending-deletion-controls.tsx b/src/components/admin/pending-deletion-controls.tsx index e45ba9fcc..f5cc0adf7 100644 --- a/src/components/admin/pending-deletion-controls.tsx +++ b/src/components/admin/pending-deletion-controls.tsx @@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge"; import { ConfirmButton } from "@/components/ui/confirm-button"; import { showErrorToast } from "@/lib/errors/error-toast"; import { apiPost } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { formatDate } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -41,9 +42,7 @@ export function PendingDeletionControls({ user }: { user: AdminUser }) { }, onError: (err) => { showErrorToast( - err instanceof Error && err.message - ? err.message - : t("admin.section.users.undoDeletionFailed"), + localizedApiError(err, t, "admin.section.users.undoDeletionFailed"), { cause: err }, ); }, diff --git a/src/components/admin/pulse-day-mean-card.tsx b/src/components/admin/pulse-day-mean-card.tsx index 705922bac..2e1db2d91 100644 --- a/src/components/admin/pulse-day-mean-card.tsx +++ b/src/components/admin/pulse-day-mean-card.tsx @@ -32,7 +32,13 @@ import { import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { QueryErrorCard } from "@/components/ui/query-error-card"; -import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; +import { + ApiError, + apiFetchRaw, + apiGet, + throwApiError, +} from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { showErrorToast } from "@/lib/errors/error-toast"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -77,23 +83,7 @@ export function PulseDayMeanCard() { headers: { "content-type": "application/json" }, body: JSON.stringify({ action }), }); - if (!res.ok) { - let code: string | undefined; - try { - const body = (await res.json()) as { meta?: { errorCode?: string } }; - code = body.meta?.errorCode; - } catch { - // No envelope: the generic message below. - } - // The route's two refusals each have their own message. - throw new Error( - code === "maintenance.pulseDayMean.busy" - ? t("admin.section.pulseDayMean.error.busy") - : code === "maintenance.pulseDayMean.dryRunRequired" - ? t("admin.section.pulseDayMean.error.dryRunRequired") - : t("admin.section.pulseDayMean.error.generic"), - ); - } + if (!res.ok) await throwApiError(res); const { enqueued } = ( (await res.json()) as { data: { enqueued: boolean } } ).data; @@ -117,8 +107,21 @@ export function PulseDayMeanCard() { queryKey: queryKeys.adminPulseDayMean(), }); }, - onError: (err: Error) => { - showErrorToast(err.message, { cause: err }); + // The route's two refusals each have their own message. + onError: (err) => { + const code = err instanceof ApiError ? err.meta?.errorCode : undefined; + showErrorToast( + code === "maintenance.pulseDayMean.busy" + ? t("admin.section.pulseDayMean.error.busy") + : code === "maintenance.pulseDayMean.dryRunRequired" + ? t("admin.section.pulseDayMean.error.dryRunRequired") + : localizedApiError( + err, + t, + "admin.section.pulseDayMean.error.generic", + ), + { cause: err }, + ); }, }); diff --git a/src/components/admin/recovery-kit-card.tsx b/src/components/admin/recovery-kit-card.tsx index 915754093..7f8c7e8b5 100644 --- a/src/components/admin/recovery-kit-card.tsx +++ b/src/components/admin/recovery-kit-card.tsx @@ -32,6 +32,7 @@ import { NativeSelect } from "@/components/ui/native-select"; import { QueryErrorRow } from "@/components/ui/query-error-row"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { ApiError, apiFetch, apiPost } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { showErrorToast } from "@/lib/errors/error-toast"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -264,11 +265,14 @@ function CheckMyCopy({ kit }: { kit: RecoveryKit }) { setCheckResult(data.matches); setCandidate(""); }, - onError: (err: Error) => { + onError: (err) => { setCheckResult(null); - showErrorToast(err.message || t("admin.recoveryKit.checkFailed"), { - cause: err, - }); + showErrorToast( + localizedApiError(err, t, "admin.recoveryKit.checkFailed"), + { + cause: err, + }, + ); }, }); @@ -395,9 +399,10 @@ export function RecoveryKitCard() { await invalidate(); return; } - showErrorToast(err.message || t("admin.recoveryKit.confirmFailed"), { - cause: err, - }); + showErrorToast( + localizedApiError(err, t, "admin.recoveryKit.confirmFailed"), + { cause: err }, + ); }, }); diff --git a/src/components/admin/reminders-section.tsx b/src/components/admin/reminders-section.tsx index 9856f2e4c..47082fdf5 100644 --- a/src/components/admin/reminders-section.tsx +++ b/src/components/admin/reminders-section.tsx @@ -23,6 +23,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { useAdminSettings, useUpdateSettings } from "./_shared"; import { apiGet, apiPost } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { describeChannelTestFailures, type ChannelTestResult, @@ -163,9 +164,7 @@ export function RemindersSection() { }, onError: (error) => { showErrorToast( - error instanceof Error - ? error.message - : t("admin.notificationTestFailed"), + localizedApiError(error, t, "admin.notificationTestFailed"), { cause: error }, ); }, @@ -201,10 +200,9 @@ export function RemindersSection() { toast.success(data?.message ?? t("admin.reminderCheckSuccess")); }, onError: (error) => { - showErrorToast( - error instanceof Error ? error.message : t("admin.reminderCheckFailed"), - { cause: error }, - ); + showErrorToast(localizedApiError(error, t, "admin.reminderCheckFailed"), { + cause: error, + }); }, }); diff --git a/src/components/admin/umami-section.tsx b/src/components/admin/umami-section.tsx index 7be6ec909..961df303b 100644 --- a/src/components/admin/umami-section.tsx +++ b/src/components/admin/umami-section.tsx @@ -19,6 +19,7 @@ import { ConfiguredBadge, } from "./_shared"; import { apiPost } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; export function UmamiSection() { const { t } = useTranslations(); @@ -52,9 +53,7 @@ export function UmamiSection() { }, onError: (error) => { showErrorToast( - error instanceof Error - ? error.message - : t("admin.monitoringTestFailed"), + localizedApiError(error, t, "admin.monitoringTestFailed"), { cause: error }, ); }, diff --git a/src/components/admin/user-management-section.tsx b/src/components/admin/user-management-section.tsx index 0424b1e6e..f5266f74b 100644 --- a/src/components/admin/user-management-section.tsx +++ b/src/components/admin/user-management-section.tsx @@ -41,7 +41,8 @@ import { queryKeys } from "@/lib/query-keys"; import { type AdminUser, PasswordInput } from "./_shared"; import { PendingDeletionControls } from "./pending-deletion-controls"; import { DeleteUserControl } from "./delete-user-control"; -import { apiGet, apiPost, apiPut } from "@/lib/api/api-fetch"; +import { ApiError, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { recentProofErrorMessage, useRecentProof, @@ -109,12 +110,9 @@ export function UserManagementSection() { toast.success(t("common.saved")); }, onError: (err) => { - showErrorToast( - err instanceof Error && err.message - ? err.message - : t("admin.settingsSaveError"), - { cause: err }, - ); + showErrorToast(localizedApiError(err, t, "admin.settingsSaveError"), { + cause: err, + }); }, }); @@ -132,8 +130,17 @@ export function UserManagementSection() { setResetMsg(t("admin.passwordReset")); setResetPassword(""); }, - onError: (err: Error) => { - setResetMsg(recentProofErrorMessage(err, err.message)); + // The server words its password verdict (too weak, found in a breach) in + // the account's language; any other refusal reads through the catalogue. + onError: (err) => { + setResetMsg( + recentProofErrorMessage( + err, + err instanceof ApiError && err.status === 422 + ? err.message + : localizedApiError(err, t, "common.error"), + ), + ); }, }); @@ -156,9 +163,7 @@ export function UserManagementSection() { }, onError: (err) => { showErrorToast( - err instanceof Error && err.message - ? err.message - : t("admin.section.users.forceLogoutFailed"), + localizedApiError(err, t, "admin.section.users.forceLogoutFailed"), { cause: err }, ); }, @@ -560,7 +565,11 @@ export function UserManagementSection() { {updateUser.isError && (

- {(updateUser.error as Error).message} + {localizedApiError( + updateUser.error, + t, + "admin.settingsSaveError", + )}

)} diff --git a/src/components/insights/coach-panel/coach-model-picker.tsx b/src/components/insights/coach-panel/coach-model-picker.tsx index a6ba1bb1c..bcccd1b03 100644 --- a/src/components/insights/coach-panel/coach-model-picker.tsx +++ b/src/components/insights/coach-panel/coach-model-picker.tsx @@ -20,6 +20,7 @@ import { type UserAIProvider, } from "@/components/settings/ai/shared"; import { apiGet, apiPatch, apiPut } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { useTranslations } from "@/lib/i18n/context"; import { aiInputDependentKeys, @@ -155,11 +156,7 @@ export function CoachModelPicker() { void invalidateKeys(queryClient, aiInputDependentKeys); }; const onFailed = (e: unknown) => - setError( - e instanceof Error && e.message - ? e.message - : t("insights.coach.frame.saveFailed"), - ); + setError(localizedApiError(e, t, "insights.coach.frame.saveFailed")); const switchProvider = useMutation({ mutationFn: async (provider: ProviderType) => { diff --git a/src/components/medications/intake-import-dialog.tsx b/src/components/medications/intake-import-dialog.tsx index b9f2c9c57..c34c1290f 100644 --- a/src/components/medications/intake-import-dialog.tsx +++ b/src/components/medications/intake-import-dialog.tsx @@ -38,11 +38,9 @@ import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { useTranslations } from "@/lib/i18n/context"; import { invalidateMedicationReads } from "@/lib/query-keys"; -import { ApiError, apiPost } from "@/lib/api/api-fetch"; -import { - MedicationIntakeImportError, - waitForMedicationIntakeImport, -} from "@/lib/medications/intake-import-poll"; +import { apiPost } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; +import { waitForMedicationIntakeImport } from "@/lib/medications/intake-import-poll"; export interface IntakeImportDialogProps { medicationId: string | null; @@ -132,19 +130,12 @@ export function IntakeImportDialog({ // import dialog on the detail page). void invalidateMedicationReads(queryClient); } catch (err) { - if (err instanceof MedicationIntakeImportError) { - setResult({ - kind: "notice", - tone: "error", - text: t("medications.importFailed"), - }); - return; - } - if (!(err instanceof ApiError)) throw err; + // A refusal reads through the API catalogue; a failed job's reason is + // the worker's English, so it reads as the plain failure. setResult({ kind: "notice", tone: "error", - text: err.message || t("medications.importFailed"), + text: localizedApiError(err, t, "medications.importFailed"), }); } } catch (err) { diff --git a/src/components/medications/scheduling/natural-language-extractor.tsx b/src/components/medications/scheduling/natural-language-extractor.tsx index fdf204b9e..4f0ddac29 100644 --- a/src/components/medications/scheduling/natural-language-extractor.tsx +++ b/src/components/medications/scheduling/natural-language-extractor.tsx @@ -95,10 +95,9 @@ type DialogError = | { kind: "budget" } | { kind: "consent" } | { kind: "unavailable" } - | { kind: "network"; message?: string }; + | { kind: "network" }; interface ExtractFailureBody { - error?: string | null; meta?: { errorCode?: unknown } | null; } @@ -127,7 +126,7 @@ export function classifyExtractFailure( } if (status === 429) return { kind: "rateLimit" }; if (status === 503) return { kind: "noProvider" }; - return { kind: "network", message: body?.error ?? undefined }; + return { kind: "network" }; } const MAX_TEXT_LENGTH = 2000; @@ -200,7 +199,7 @@ export function NaturalLanguageExtractor({ error: string | null; }; if (body.error || !body.data) { - setError({ kind: "network", message: body.error ?? undefined }); + setError({ kind: "network" }); return; } onPrefill(body.data); @@ -214,10 +213,7 @@ export function NaturalLanguageExtractor({ // Caller dismissed the dialog mid-flight; do not surface. return; } - setError({ - kind: "network", - message: err instanceof Error ? err.message : undefined, - }); + setError({ kind: "network" }); } finally { setBusy(false); abortRef.current = null; diff --git a/src/components/medications/sections/api-tokens-row.tsx b/src/components/medications/sections/api-tokens-row.tsx index 9f50d2e7b..e47b53b3b 100644 --- a/src/components/medications/sections/api-tokens-row.tsx +++ b/src/components/medications/sections/api-tokens-row.tsx @@ -37,7 +37,8 @@ import { medicationDependentKeys, queryKeys, } from "@/lib/query-keys"; -import { ApiError, apiGet, apiPut } from "@/lib/api/api-fetch"; +import { apiGet, apiPut } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; export interface ApiTokensRowProps { medicationId: string; @@ -182,9 +183,7 @@ export function ApiTokensRow({ }); } catch (err) { showErrorToast( - err instanceof ApiError && err.message - ? err.message - : t("medications.detail.api.mintFailed"), + localizedApiError(err, t, "medications.detail.api.mintFailed"), { cause: err }, ); } finally { diff --git a/src/components/medications/sections/data-portability-row.tsx b/src/components/medications/sections/data-portability-row.tsx index 2ad68d5f5..a6b8311cc 100644 --- a/src/components/medications/sections/data-portability-row.tsx +++ b/src/components/medications/sections/data-portability-row.tsx @@ -72,8 +72,8 @@ export function DataPortabilityRow({ `/api/export/medications?${query}`, `healthlog-medication-${stamp}.csv`, ); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); + } catch { + setError(t("settings.sections.export.downloadFailed")); } finally { setBusy(false); } diff --git a/src/components/medications/sections/phase-config-sheet.tsx b/src/components/medications/sections/phase-config-sheet.tsx index 5c00ea61f..ea19de4a5 100644 --- a/src/components/medications/sections/phase-config-sheet.tsx +++ b/src/components/medications/sections/phase-config-sheet.tsx @@ -48,7 +48,8 @@ import { medicationDependentKeys, queryKeys, } from "@/lib/query-keys"; -import { ApiError, apiGet, apiPut } from "@/lib/api/api-fetch"; +import { apiGet, apiPut } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { QueryErrorRow } from "@/components/ui/query-error-row"; type PhaseMode = "MINUTES" | "PERCENT"; @@ -229,9 +230,7 @@ function PhaseConfigForm({ onClose(); } catch (err) { showErrorToast( - err instanceof ApiError && err.message - ? err.message - : t("medications.detail.phases.saveFailed"), + localizedApiError(err, t, "medications.detail.phases.saveFailed"), { cause: err }, ); } finally { diff --git a/src/components/medications/side-effects-section.tsx b/src/components/medications/side-effects-section.tsx index 365de1d29..82492bd5d 100644 --- a/src/components/medications/side-effects-section.tsx +++ b/src/components/medications/side-effects-section.tsx @@ -49,6 +49,7 @@ import { } from "@/lib/medications/side-effects/taxonomy"; import { MedicationDetailSection } from "@/components/medications/medication-detail-section"; import { apiDelete, apiGetList, apiPost } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { queryKeys } from "@/lib/query-keys"; import { EmptyState } from "@/components/ui/empty-state"; @@ -291,7 +292,9 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) { setOpen(false); }, onError: (err) => { - setFormError(err instanceof Error ? err.message : String(err)); + setFormError( + localizedApiError(err, t, "medications.sideEffects.saveError"), + ); }, }, ); diff --git a/src/components/mood/manage/archived-tags-card.tsx b/src/components/mood/manage/archived-tags-card.tsx index f88ffd2ea..0c3d2b77d 100644 --- a/src/components/mood/manage/archived-tags-card.tsx +++ b/src/components/mood/manage/archived-tags-card.tsx @@ -19,6 +19,7 @@ import { import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { ApiError, apiDelete, apiPatch } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { useTranslations } from "@/lib/i18n/context"; import { moodTagIcon } from "../mood-tag-icons"; import { @@ -74,10 +75,9 @@ export function ArchivedTagsCard({ catalog }: ArchivedTagsCardProps) { if (err instanceof ApiError && err.status === 422) { showErrorToast(t("mood.manage.tagLimitReached"), { cause: err }); } else { - showErrorToast( - err instanceof ApiError ? err.message : t("common.error"), - { cause: err }, - ); + showErrorToast(localizedApiError(err, t, "common.error"), { + cause: err, + }); } } finally { void invalidateMoodTagCaches(queryClient); @@ -94,10 +94,7 @@ export function ArchivedTagsCard({ catalog }: ArchivedTagsCardProps) { toast.success(t("mood.manage.purged")); } catch (err) { rollback(); - showErrorToast( - err instanceof ApiError ? err.message : t("common.error"), - { cause: err }, - ); + showErrorToast(localizedApiError(err, t, "common.error"), { cause: err }); } finally { void invalidateMoodTagCaches(queryClient); } diff --git a/src/components/mood/manage/tag-editor-sheet.tsx b/src/components/mood/manage/tag-editor-sheet.tsx index c2014b3db..53a801a6c 100644 --- a/src/components/mood/manage/tag-editor-sheet.tsx +++ b/src/components/mood/manage/tag-editor-sheet.tsx @@ -12,6 +12,7 @@ import { Label } from "@/components/ui/label"; import { NativeSelect } from "@/components/ui/native-select"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { ApiError, apiPatch, apiPost } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { MoodTagIconPicker } from "../mood-tag-icon-picker"; @@ -144,10 +145,9 @@ export function TagEditorSheet({ if (err instanceof ApiError && err.status === 422) { showErrorToast(t("mood.manage.tagLimitReached"), { cause: err }); } else { - showErrorToast( - err instanceof ApiError ? err.message : t("common.error"), - { cause: err }, - ); + showErrorToast(localizedApiError(err, t, "common.error"), { + cause: err, + }); } } finally { setSaving(false); diff --git a/src/components/mood/manage/tag-groups-card.tsx b/src/components/mood/manage/tag-groups-card.tsx index 8874a7f59..a2e1e66c9 100644 --- a/src/components/mood/manage/tag-groups-card.tsx +++ b/src/components/mood/manage/tag-groups-card.tsx @@ -60,6 +60,7 @@ import { apiPost, apiPut, } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { readUpdatedAtToken, withBaseToken, @@ -175,10 +176,7 @@ export function TagGroupsCard({ catalog }: TagGroupsCardProps) { toast.message(t("common.conflictReloaded")); return false; } - showErrorToast( - err instanceof ApiError ? err.message : t("common.error"), - { cause: err }, - ); + showErrorToast(localizedApiError(err, t, "common.error"), { cause: err }); return false; } finally { void invalidateMoodTagCaches(queryClient); @@ -359,10 +357,9 @@ export function TagGroupsCard({ catalog }: TagGroupsCardProps) { ); toast.success(t("mood.manage.groupDeleted")); } catch (err) { - showErrorToast( - err instanceof ApiError ? err.message : t("common.error"), - { cause: err }, - ); + showErrorToast(localizedApiError(err, t, "common.error"), { + cause: err, + }); } finally { void invalidateMoodTagCaches(queryClient); } @@ -562,10 +559,9 @@ function GroupEditorSheet({ if (err instanceof ApiError && err.status === 422) { showErrorToast(t("mood.manage.groupLimitReached"), { cause: err }); } else { - showErrorToast( - err instanceof ApiError ? err.message : t("common.error"), - { cause: err }, - ); + showErrorToast(localizedApiError(err, t, "common.error"), { + cause: err, + }); } } finally { setSaving(false); diff --git a/src/components/mood/manage/tag-manager-card.tsx b/src/components/mood/manage/tag-manager-card.tsx index c5246873a..b8e32b506 100644 --- a/src/components/mood/manage/tag-manager-card.tsx +++ b/src/components/mood/manage/tag-manager-card.tsx @@ -47,7 +47,8 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { ApiError, apiPatch, apiPut } from "@/lib/api/api-fetch"; +import { apiPatch, apiPut } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { readUpdatedAtToken, withBaseToken, @@ -168,10 +169,7 @@ export function TagManagerCard({ catalog }: TagManagerCardProps) { toast.message(t("common.conflictReloaded")); return false; } - showErrorToast( - err instanceof ApiError ? err.message : t("common.error"), - { cause: err }, - ); + showErrorToast(localizedApiError(err, t, "common.error"), { cause: err }); return false; } finally { void invalidateMoodTagCaches(queryClient); @@ -243,10 +241,7 @@ export function TagManagerCard({ catalog }: TagManagerCardProps) { } } catch (err) { rollback(); - showErrorToast( - err instanceof ApiError ? err.message : t("common.error"), - { cause: err }, - ); + showErrorToast(localizedApiError(err, t, "common.error"), { cause: err }); } finally { void invalidateMoodTagCaches(queryClient); } @@ -283,10 +278,7 @@ export function TagManagerCard({ catalog }: TagManagerCardProps) { toast.message(t("common.conflictReloaded")); return; } - showErrorToast( - err instanceof ApiError ? err.message : t("common.error"), - { cause: err }, - ); + showErrorToast(localizedApiError(err, t, "common.error"), { cause: err }); } finally { void invalidateMoodTagCaches(queryClient); } diff --git a/src/components/records/family-history-form.tsx b/src/components/records/family-history-form.tsx index 96cc17545..849e10f70 100644 --- a/src/components/records/family-history-form.tsx +++ b/src/components/records/family-history-form.tsx @@ -16,7 +16,8 @@ import { SelectValue, } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; -import { ApiError, apiPost, apiPut } from "@/lib/api/api-fetch"; +import { apiPost, apiPut } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { familyRelationshipEnum } from "@/lib/validations/family-history"; import { useTranslations } from "@/lib/i18n/context"; @@ -99,10 +100,11 @@ export function FamilyHistoryForm({ ); onSuccess?.(saved); } catch (err) { - const message = - err instanceof ApiError - ? err.message - : t("records.family.form.saveError"); + const message = localizedApiError( + err, + t, + "records.family.form.saveError", + ); setError(message); } finally { setSubmitting(false); diff --git a/src/components/settings/__tests__/notification-channel-cards.test.tsx b/src/components/settings/__tests__/notification-channel-cards.test.tsx index 0e09da9cf..e3a1df045 100644 --- a/src/components/settings/__tests__/notification-channel-cards.test.tsx +++ b/src/components/settings/__tests__/notification-channel-cards.test.tsx @@ -65,7 +65,8 @@ vi.mock("@tanstack/react-query", () => ({ useQueryClient: () => mocks.queryClient, })); -vi.mock("@/lib/api/api-fetch", () => ({ +vi.mock("@/lib/api/api-fetch", async (importOriginal) => ({ + ...(await importOriginal()), apiFetchRaw: mocks.apiFetchRaw, apiGet: vi.fn(), })); diff --git a/src/components/settings/__tests__/notification-channel-toggle.test.ts b/src/components/settings/__tests__/notification-channel-toggle.test.ts index d0361bff7..bf4e582f7 100644 --- a/src/components/settings/__tests__/notification-channel-toggle.test.ts +++ b/src/components/settings/__tests__/notification-channel-toggle.test.ts @@ -1,12 +1,16 @@ import { QueryClient } from "@tanstack/react-query"; import { beforeEach, describe, expect, it, vi } from "vitest"; -vi.mock("@/lib/api/api-fetch", () => ({ +vi.mock("@/lib/api/api-fetch", async (importOriginal) => ({ + ...(await importOriginal()), apiFetchRaw: vi.fn(), })); -import { apiFetchRaw } from "@/lib/api/api-fetch"; +import { ApiError, apiFetchRaw } from "@/lib/api/api-fetch"; +import en from "@/../messages/en.json"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; import { + channelSaveError, optimisticallySetChannelEnabled, persistChannelEnabled, rollbackChannelEnabled, @@ -27,7 +31,7 @@ describe("notification channel enable-only mutation", () => { new Response(JSON.stringify({ data: { saved: true } }), { status: 200 }), ); - await persistChannelEnabled(endpoint, true, "save failed"); + await persistChannelEnabled(endpoint, true); expect(apiFetchRaw).toHaveBeenCalledWith(endpoint, { method: "PUT", @@ -87,7 +91,33 @@ describe("notification channel enable-only mutation", () => { ); await expect( - persistChannelEnabled("/api/settings/ntfy", true, "save failed"), + persistChannelEnabled("/api/settings/ntfy", true), ).rejects.toThrow("Stored configuration is missing"); }); }); + +describe("the sentence a refused channel save leaves", () => { + const { t } = getServerTranslator("en"); + + it("names the private-origin policy like the test button does", () => { + const refusal = new ApiError("Private network target", 422, { + errorCode: "private_origin_not_approved", + }); + expect(channelSaveError(refusal, t)).toBe( + en.settings.testConnection.errors.private_origin_not_approved, + ); + }); + + it("reads any other refusal through the catalogue, never as the server's prose", () => { + const refusal = new ApiError("Stored configuration is missing", 422, { + errorCode: "http.invalid", + }); + expect(channelSaveError(refusal, t)).toBe(en.apiErrors.http.invalid); + }); + + it("says could not save when the request never reached the server", () => { + expect(channelSaveError(new TypeError("Failed to fetch"), t)).toBe( + en.settings.savingError, + ); + }); +}); diff --git a/src/components/settings/access/__tests__/managed-profile-step-up-flow.test.ts b/src/components/settings/access/__tests__/managed-profile-step-up-flow.test.ts index 35424713c..eaf7f9984 100644 --- a/src/components/settings/access/__tests__/managed-profile-step-up-flow.test.ts +++ b/src/components/settings/access/__tests__/managed-profile-step-up-flow.test.ts @@ -146,7 +146,11 @@ function dialog(): Dialog { }, // The same call `useRecentProof` makes. submit: (proof) => apiPost("/api/auth/reproof", proof), - messages: () => ({ signInAgain: "sign in again", failed: "failed" }), + messages: () => ({ + signInAgain: "sign in again", + failed: "failed", + rateLimited: "rate limited", + }), }); return d; } diff --git a/src/components/settings/account-section/avatar-section.tsx b/src/components/settings/account-section/avatar-section.tsx index 6297cdb61..21a8ec62a 100644 --- a/src/components/settings/account-section/avatar-section.tsx +++ b/src/components/settings/account-section/avatar-section.tsx @@ -11,7 +11,8 @@ import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; -import { apiFetchRaw } from "@/lib/api/api-fetch"; +import { ApiError, apiFetchRaw, throwApiError } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { statusText, type StatusMessage } from "./account-section-utils"; import { accountName, nameInitials } from "@/lib/account-name"; @@ -37,41 +38,38 @@ export function AvatarSection() { method: "POST", body, }); - const json = await res.json().catch(() => ({})); - if (!res.ok) { - if (res.status === 415) - throw new Error(t("settings.avatar.invalidType")); - if (res.status === 413) throw new Error(t("settings.avatar.tooLarge")); - throw new Error(json.error || t("settings.avatar.error")); - } + if (!res.ok) await throwApiError(res); }, onSuccess: async () => { setMsgType("success"); setMsg({ key: "settings.avatar.uploaded" }); await refetch(); }, - onError: (err: Error) => { + onError: (err) => { setMsgType("error"); - setMsg({ text: err.message }); + setMsg( + err instanceof ApiError && err.status === 415 + ? { key: "settings.avatar.invalidType" } + : err instanceof ApiError && err.status === 413 + ? { key: "settings.avatar.tooLarge" } + : { text: localizedApiError(err, t, "settings.avatar.error") }, + ); }, }); const remove = useMutation({ mutationFn: async () => { const res = await apiFetchRaw("/api/user/avatar", { method: "DELETE" }); - if (!res.ok) { - const json = await res.json().catch(() => ({})); - throw new Error(json.error || t("settings.avatar.error")); - } + if (!res.ok) await throwApiError(res); }, onSuccess: async () => { setMsgType("success"); setMsg({ key: "settings.avatar.removed" }); await refetch(); }, - onError: (err: Error) => { + onError: (err) => { setMsgType("error"); - setMsg({ text: err.message }); + setMsg({ text: localizedApiError(err, t, "settings.avatar.error") }); }, }); diff --git a/src/components/settings/ai/anthropic-provider-form.tsx b/src/components/settings/ai/anthropic-provider-form.tsx index 7aa31d06b..477cced52 100644 --- a/src/components/settings/ai/anthropic-provider-form.tsx +++ b/src/components/settings/ai/anthropic-provider-form.tsx @@ -15,6 +15,7 @@ import { Label } from "@/components/ui/label"; import { NativeSelect } from "@/components/ui/native-select"; import { PasswordInput } from "@/components/ui/password-input"; import { apiPatch } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { useTranslations } from "@/lib/i18n/context"; import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys"; @@ -86,7 +87,7 @@ export function AnthropicProviderForm({ }, onError: (e) => { setOk(false); - setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric")); + setMsg(localizedApiError(e, t, "settings.ai.errorGeneric")); }, onSettled: () => { submitInFlightRef.current = false; diff --git a/src/components/settings/ai/codex-provider-form.tsx b/src/components/settings/ai/codex-provider-form.tsx index 2d7446f70..ddb8820d4 100644 --- a/src/components/settings/ai/codex-provider-form.tsx +++ b/src/components/settings/ai/codex-provider-form.tsx @@ -12,7 +12,8 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { SettingsCardActions } from "@/components/settings/_card-actions"; import { ConfirmButton } from "@/components/ui/confirm-button"; -import { apiFetchRaw } from "@/lib/api/api-fetch"; +import { apiDelete, apiPost } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { formatDateTime } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys"; @@ -77,18 +78,18 @@ export function CodexProviderForm({ setMsg(null); setDevicePolling(true); try { - const res = await apiFetchRaw("/api/auth/codex/device-start", { - method: "POST", - }); - const json = await res.json(); - if (!res.ok) throw new Error(json.error || t("settings.savingError")); + const data = await apiPost<{ + userCode: string; + verificationUrl: string; + intervalSeconds: number; + }>("/api/auth/codex/device-start"); setDeviceCode({ - userCode: json.data.userCode, - verificationUrl: json.data.verificationUrl, - intervalSeconds: json.data.intervalSeconds, + userCode: data.userCode, + verificationUrl: data.verificationUrl, + intervalSeconds: data.intervalSeconds, }); } catch (err) { - setMsg(err instanceof Error ? err.message : t("settings.savingError")); + setMsg(localizedApiError(err, t, "settings.savingError")); setMsgType("error"); setDevicePolling(false); } @@ -101,15 +102,11 @@ export function CodexProviderForm({ async function tick() { try { - const res = await apiFetchRaw("/api/auth/codex/device-poll", { - method: "POST", - }); - const json = await res.json(); + const data = await apiPost<{ status?: string } | null>( + "/api/auth/codex/device-poll", + ); if (cancelled) return; - if (!res.ok) { - throw new Error(json.error || t("settings.savingError")); - } - if (json.data?.status === "connected") { + if (data?.status === "connected") { setDeviceCode(null); setDevicePolling(false); setMsg(t("settings.codexConnected")); @@ -120,7 +117,7 @@ export function CodexProviderForm({ if (!cancelled) setTimeout(tick, intervalMs); } catch (err) { if (cancelled) return; - setMsg(err instanceof Error ? err.message : t("settings.savingError")); + setMsg(localizedApiError(err, t, "settings.savingError")); setMsgType("error"); setDeviceCode(null); setDevicePolling(false); @@ -144,18 +141,12 @@ export function CodexProviderForm({ setDisconnecting(true); setMsg(null); try { - const res = await apiFetchRaw("/api/auth/codex/disconnect", { - method: "DELETE", - }); - if (!res.ok) { - const json = await res.json(); - throw new Error(json.error); - } + await apiDelete("/api/auth/codex/disconnect"); setMsg(t("settings.codexDisconnected")); setMsgType("success"); void invalidateKeys(queryClient, aiInputDependentKeys); } catch (err) { - setMsg(err instanceof Error ? err.message : t("settings.savingError")); + setMsg(localizedApiError(err, t, "settings.savingError")); setMsgType("error"); } finally { setDisconnecting(false); diff --git a/src/components/settings/ai/compat-provider-form.tsx b/src/components/settings/ai/compat-provider-form.tsx index 52adbccf8..00efa3736 100644 --- a/src/components/settings/ai/compat-provider-form.tsx +++ b/src/components/settings/ai/compat-provider-form.tsx @@ -17,6 +17,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { PasswordInput } from "@/components/ui/password-input"; import { apiPatch } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { useTranslations } from "@/lib/i18n/context"; import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys"; @@ -78,7 +79,7 @@ export function CompatProviderForm({ }, onError: (e) => { setOk(false); - setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric")); + setMsg(localizedApiError(e, t, "settings.ai.errorGeneric")); }, onSettled: () => { submitInFlightRef.current = false; diff --git a/src/components/settings/ai/fallback-chain-card.tsx b/src/components/settings/ai/fallback-chain-card.tsx index 1c6631ec0..c9df354fb 100644 --- a/src/components/settings/ai/fallback-chain-card.tsx +++ b/src/components/settings/ai/fallback-chain-card.tsx @@ -26,6 +26,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header"; import { NativeSelect } from "@/components/ui/native-select"; import { Switch } from "@/components/ui/switch"; import { apiPut } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { useTranslations } from "@/lib/i18n/context"; import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys"; @@ -97,11 +98,7 @@ export function FallbackChainCard({ }, onError: (e) => { setOk(false); - setMsg( - e instanceof Error - ? e.message - : t("settings.ai.providerChain.saveFailed"), - ); + setMsg(localizedApiError(e, t, "settings.ai.providerChain.saveFailed")); }, }); diff --git a/src/components/settings/ai/local-provider-form.tsx b/src/components/settings/ai/local-provider-form.tsx index b0225e2f3..73c457ca6 100644 --- a/src/components/settings/ai/local-provider-form.tsx +++ b/src/components/settings/ai/local-provider-form.tsx @@ -15,6 +15,7 @@ import { Label } from "@/components/ui/label"; import { NativeSelect } from "@/components/ui/native-select"; import { PasswordInput } from "@/components/ui/password-input"; import { apiPatch } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { useTranslations } from "@/lib/i18n/context"; import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys"; @@ -92,7 +93,7 @@ export function LocalProviderForm({ }, onError: (e) => { setOk(false); - setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric")); + setMsg(localizedApiError(e, t, "settings.ai.errorGeneric")); }, onSettled: () => { submitInFlightRef.current = false; diff --git a/src/components/settings/ai/openai-provider-form.tsx b/src/components/settings/ai/openai-provider-form.tsx index 9e3bb9c2b..756c85483 100644 --- a/src/components/settings/ai/openai-provider-form.tsx +++ b/src/components/settings/ai/openai-provider-form.tsx @@ -18,6 +18,7 @@ import { Label } from "@/components/ui/label"; import { NativeSelect } from "@/components/ui/native-select"; import { PasswordInput } from "@/components/ui/password-input"; import { apiPatch } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { useTranslations } from "@/lib/i18n/context"; import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys"; @@ -94,7 +95,7 @@ export function OpenAIProviderForm({ }, onError: (e) => { setOk(false); - setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric")); + setMsg(localizedApiError(e, t, "settings.ai.errorGeneric")); }, onSettled: () => { submitInFlightRef.current = false; diff --git a/src/components/settings/ai/response-timeout-card.tsx b/src/components/settings/ai/response-timeout-card.tsx index ec032b9a1..00cf2f61a 100644 --- a/src/components/settings/ai/response-timeout-card.tsx +++ b/src/components/settings/ai/response-timeout-card.tsx @@ -24,6 +24,7 @@ import { Label } from "@/components/ui/label"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; import { PROVIDER_DEFAULT_TIMEOUT_MS } from "@/lib/ai/effective-timeout"; import { apiPatch } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { useTranslations } from "@/lib/i18n/context"; import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys"; @@ -68,17 +69,7 @@ export function ResponseTimeoutCard({ } const saveMutation = useMutation({ - mutationFn: async () => { - const trimmed = value.trim(); - const responseTimeoutSeconds = trimmed === "" ? null : Number(trimmed); - if ( - responseTimeoutSeconds !== null && - (!Number.isInteger(responseTimeoutSeconds) || - responseTimeoutSeconds < 10 || - responseTimeoutSeconds > 600) - ) { - throw new Error(t("settings.ai.responseTimeoutInvalid")); - } + mutationFn: async (responseTimeoutSeconds: number | null) => { await apiPatch("/api/user/ai-provider", { responseTimeoutSeconds }); }, onSuccess: () => { @@ -88,7 +79,7 @@ export function ResponseTimeoutCard({ }, onError: (e) => { setOk(false); - setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric")); + setMsg(localizedApiError(e, t, "settings.ai.errorGeneric")); }, onSettled: () => { submitInFlightRef.current = false; @@ -98,8 +89,20 @@ export function ResponseTimeoutCard({ function submit(event: FormEvent) { event.preventDefault(); if (submitInFlightRef.current || saveMutation.isPending) return; + const trimmed = value.trim(); + const responseTimeoutSeconds = trimmed === "" ? null : Number(trimmed); + if ( + responseTimeoutSeconds !== null && + (!Number.isInteger(responseTimeoutSeconds) || + responseTimeoutSeconds < 10 || + responseTimeoutSeconds > 600) + ) { + setOk(false); + setMsg(t("settings.ai.responseTimeoutInvalid")); + return; + } submitInFlightRef.current = true; - saveMutation.mutate(); + saveMutation.mutate(responseTimeoutSeconds); } return ( diff --git a/src/components/settings/ai/runtime-actions-row.tsx b/src/components/settings/ai/runtime-actions-row.tsx index 79a2b24f4..0e9a98321 100644 --- a/src/components/settings/ai/runtime-actions-row.tsx +++ b/src/components/settings/ai/runtime-actions-row.tsx @@ -17,6 +17,7 @@ import { apiFetchRaw, apiPut } from "@/lib/api/api-fetch"; import { formatDateTime } from "@/lib/format"; import { useAiCapability } from "@/hooks/use-ai-capability"; import { useTranslations } from "@/lib/i18n/context"; +import { translateCodeOrNull } from "@/lib/i18n/translate-code"; import { localiseTestReason, @@ -83,7 +84,6 @@ export function RuntimeActionsRow({ reason?: string; httpStatus?: number | null; } | null; - error?: string | null; }; try { json = await res.json(); @@ -102,7 +102,12 @@ export function RuntimeActionsRow({ typeof code === "string" && code.startsWith("assistant.disabled.") ? t("settings.ai.operatorOff.testRefused") : t("settings.ai.testFailedShort", { - message: json.error ?? `HTTP ${res.status}`, + message: + translateCodeOrNull( + t, + "apiErrors", + typeof code === "string" ? code : null, + ) ?? `HTTP ${res.status}`, }), ); return; @@ -131,12 +136,10 @@ export function RuntimeActionsRow({ model: json.data?.model ?? "", }), ); - } catch (e) { + } catch { setTestOk(false); setTestMsg( - t("settings.ai.testFailedShort", { - message: e instanceof Error ? e.message : "fetch error", - }), + t("settings.ai.testFailedShort", { message: t("common.networkError") }), ); } finally { setTesting(false); diff --git a/src/components/settings/email-card.tsx b/src/components/settings/email-card.tsx index e797a230c..e897ee2c3 100644 --- a/src/components/settings/email-card.tsx +++ b/src/components/settings/email-card.tsx @@ -14,10 +14,13 @@ import { SettingsCardHeader } from "@/components/settings/_card-header"; import { TestConnectionButton } from "@/components/settings/test-connection-button"; import { isEmailFormDirty } from "@/components/settings/channel-form-dirty"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; -import { useChannelEnabledToggle } from "@/components/settings/notification-channel-toggle"; +import { + channelSaveError, + useChannelEnabledToggle, +} from "@/components/settings/notification-channel-toggle"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; -import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; +import { apiFetchRaw, apiGet, throwApiError } from "@/lib/api/api-fetch"; interface EmailSettings { enabled: boolean; @@ -64,10 +67,7 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) { enabled: settings?.enabled ?? false, }), }); - if (!res.ok) { - const json = await res.json(); - throw new Error(json.error || t("common.error")); - } + if (!res.ok) await throwApiError(res); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.settingsEmail() }); @@ -75,8 +75,8 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) { toastWrittenOutcome("success", t("settings.saved")); }, // A form shows its refusal inline, next to the field it is about. - onError: (err: Error) => { - setSaveError(err.message); + onError: (err) => { + setSaveError(channelSaveError(err, t)); }, }); diff --git a/src/components/settings/import-panel/dose-history-import-card.tsx b/src/components/settings/import-panel/dose-history-import-card.tsx index 87d9baf21..1b31532e4 100644 --- a/src/components/settings/import-panel/dose-history-import-card.tsx +++ b/src/components/settings/import-panel/dose-history-import-card.tsx @@ -31,10 +31,7 @@ import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { apiFetchRaw } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; -import { - MedicationIntakeImportError, - waitForMedicationIntakeImport, -} from "@/lib/medications/intake-import-poll"; +import { waitForMedicationIntakeImport } from "@/lib/medications/intake-import-poll"; import { invalidateMedicationReads } from "@/lib/query-keys"; import { DoseHistoryColumnRulings } from "./dose-history-columns"; @@ -194,15 +191,12 @@ export function DoseHistoryImportCard() { skippedDetailsOmitted: result.skippedDetailsOmitted, }); void invalidateMedicationReads(queryClient); - } catch (err) { - // A failed job carries a sanitised reason; anything else is a transport - // failure with nothing to add. Either way the run did not write, and the - // card says so rather than leaving the last verdict on screen. - setError( - err instanceof MedicationIntakeImportError && err.message.length > 0 - ? err.message - : t("settings.sections.export.import.doseHistory.failed"), - ); + } catch { + // A failed job's reason is the worker's English and adds nothing; + // anything else is a transport failure. Either way the run did not + // write, and the card says so rather than leaving the last verdict on + // screen. + setError(t("settings.sections.export.import.doseHistory.failed")); setVerdict(null); } finally { setBusy(false); diff --git a/src/components/settings/notification-channel-toggle.ts b/src/components/settings/notification-channel-toggle.ts index 0675a921d..0ae9c3147 100644 --- a/src/components/settings/notification-channel-toggle.ts +++ b/src/components/settings/notification-channel-toggle.ts @@ -15,33 +15,46 @@ import { } from "@tanstack/react-query"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; -import { apiFetchRaw } from "@/lib/api/api-fetch"; +import { ApiError, apiFetchRaw, throwApiError } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { showErrorToast } from "@/lib/errors/error-toast"; import { useTranslations } from "@/lib/i18n/context"; +import { translateCodeOrNull } from "@/lib/i18n/translate-code"; interface EnabledSettings { enabled: boolean; } +/** + * A refused channel save in the reader's language. The private-origin policy + * reads like the test button's verdict; anything else goes through the API + * catalogue, and a request that never reached the server (offline, a dropped + * connection) reads as the plain "could not save". Never the server's prose. + */ +export function channelSaveError( + err: unknown, + t: (key: string) => string, +): string { + const code = err instanceof ApiError ? err.meta?.errorCode : undefined; + return ( + translateCodeOrNull( + t, + "settings.testConnection.errors", + typeof code === "string" ? code : null, + ) ?? localizedApiError(err, t, "settings.savingError") + ); +} + export async function persistChannelEnabled( endpoint: string, enabled: boolean, - fallbackError: string, ): Promise { - // A request that never reaches the server (offline, a dropped connection) - // is a refused change like any other. const response = await apiFetchRaw(endpoint, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ enabled }), - }).catch(() => null); - - if (!response?.ok) { - const json = (await response?.json().catch(() => null)) as { - error?: string; - } | null; - throw new Error(json?.error || fallbackError); - } + }); + if (!response.ok) await throwApiError(response); } export function optimisticallySetChannelEnabled( @@ -75,8 +88,7 @@ export function useChannelEnabledToggle( const { t } = useTranslations(); const queryClient = useQueryClient(); return useMutation({ - mutationFn: (enabled) => - persistChannelEnabled(endpoint, enabled, t("settings.savingError")), + mutationFn: (enabled) => persistChannelEnabled(endpoint, enabled), onMutate: async (enabled) => { await queryClient.cancelQueries({ queryKey }); return optimisticallySetChannelEnabled(queryClient, queryKey, enabled); @@ -86,7 +98,7 @@ export function useChannelEnabledToggle( }, onError: (err, _enabled, previous) => { rollbackChannelEnabled(queryClient, queryKey, previous); - showErrorToast(err.message, { cause: err }); + showErrorToast(channelSaveError(err, t), { cause: err }); }, onSettled: () => { void queryClient.invalidateQueries({ queryKey }); diff --git a/src/components/settings/ntfy-card.tsx b/src/components/settings/ntfy-card.tsx index f97975c33..391933173 100644 --- a/src/components/settings/ntfy-card.tsx +++ b/src/components/settings/ntfy-card.tsx @@ -15,11 +15,13 @@ import { SettingsCardHeader } from "@/components/settings/_card-header"; import { TestConnectionButton } from "@/components/settings/test-connection-button"; import { isNtfyFormDirty } from "@/components/settings/channel-form-dirty"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; -import { useChannelEnabledToggle } from "@/components/settings/notification-channel-toggle"; +import { + channelSaveError, + useChannelEnabledToggle, +} from "@/components/settings/notification-channel-toggle"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; -import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; -import { translateCodeOrNull } from "@/lib/i18n/translate-code"; +import { apiFetchRaw, apiGet, throwApiError } from "@/lib/api/api-fetch"; interface NtfySettings { enabled: boolean; @@ -73,22 +75,7 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) { enabled: settings?.enabled ?? false, }), }); - if (!res.ok) { - const json = (await res.json()) as { - error?: string; - meta?: { errorCode?: string }; - }; - // A coded refusal (the private-origin policy) translates like the - // test button's; anything else shows the server sentence. - throw new Error( - translateCodeOrNull( - t, - "settings.testConnection.errors", - json.meta?.errorCode, - ) ?? - (json.error || t("common.error")), - ); - } + if (!res.ok) await throwApiError(res); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.settingsNtfy() }); @@ -97,8 +84,8 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) { setAuthToken(""); }, // A form shows its refusal inline, next to the fields it is about. - onError: (err: Error) => { - setSaveError(err.message); + onError: (err) => { + setSaveError(channelSaveError(err, t)); }, }); diff --git a/src/components/settings/report-selection/selection-panel.tsx b/src/components/settings/report-selection/selection-panel.tsx index 24fba5f4b..b8da8cb1c 100644 --- a/src/components/settings/report-selection/selection-panel.tsx +++ b/src/components/settings/report-selection/selection-panel.tsx @@ -49,6 +49,7 @@ import { ReportVisitOffer } from "./report-visit-offer"; import { Switch } from "@/components/ui/switch"; import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group"; import { apiFetchRaw } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { recentProofErrorMessage, throwIfReproofRequired, @@ -177,7 +178,7 @@ export function HealthRecordExportPanel() { } const message = recentProofErrorMessage( err, - err instanceof Error ? err.message : String(err), + localizedApiError(err, t, "settings.healthRecord.errorGeneric"), ); if (message) setError(message); } finally { diff --git a/src/components/settings/security-section/__tests__/oidc-reproof-choice.test.ts b/src/components/settings/security-section/__tests__/oidc-reproof-choice.test.ts index 9131bb3ba..90e16ea60 100644 --- a/src/components/settings/security-section/__tests__/oidc-reproof-choice.test.ts +++ b/src/components/settings/security-section/__tests__/oidc-reproof-choice.test.ts @@ -62,7 +62,11 @@ describe("the re-proof dialog flow", () => { const flow = createRecentProofFlow({ view: { setMethods, setPending: vi.fn(), setError: vi.fn() }, submit: vi.fn(), - messages: () => ({ signInAgain: "sign in again", failed: "failed" }), + messages: () => ({ + signInAgain: "sign in again", + failed: "failed", + rateLimited: "rate limited", + }), }); void flow .run(async () => { @@ -75,4 +79,23 @@ describe("the re-proof dialog flow", () => { await vi.waitFor(() => expect(setMethods).toHaveBeenCalledWith(["oidc"])); flow.cancel(); }); + + it("says a throttled proof in the caller's words, not the server's", async () => { + const setError = vi.fn(); + const flow = createRecentProofFlow({ + view: { setMethods: vi.fn(), setPending: vi.fn(), setError }, + submit: vi.fn(async () => { + throw new ApiError("Too many attempts. Please wait 15 minutes.", 429); + }), + messages: () => ({ + signInAgain: "sign in again", + failed: "failed", + rateLimited: "rate limited", + }), + }); + + await flow.prove({ method: "password", password: "pw" }); + + expect(setError).toHaveBeenLastCalledWith("rate limited"); + }); }); diff --git a/src/components/settings/security-section/__tests__/passkey-removal-step-up.test.tsx b/src/components/settings/security-section/__tests__/passkey-removal-step-up.test.tsx index 039bd96d7..4d502ebea 100644 --- a/src/components/settings/security-section/__tests__/passkey-removal-step-up.test.tsx +++ b/src/components/settings/security-section/__tests__/passkey-removal-step-up.test.tsx @@ -12,8 +12,9 @@ * 1. a step-up refusal renders the card's sentence, not the server's prose, * 2. the mfa_not_enrolled arm maps too — both codes mean "the gate stopped * you", and handling only the first falls through to raw English, - * 3. an ordinary failure still shows its own message rather than being - * swallowed by the step-up sentence, + * 3. any other refusal gets the caller's sentence (its catalogue sentence + * through `localizedApiError`), not the step-up one and not the server's + * prose, * 4. the sentence resolves in more than one locale, * 5. the sentence names a recovery that always exists. A refusal that names * its proofs (`meta.methods`) opens the re-proof dialog instead and never @@ -65,14 +66,15 @@ describe("passkey removal — the step-up refusal a person sees", () => { ).toBe(STEP_UP_SENTENCE); }); - it("leaves an ordinary refusal saying its own thing", () => { + it("leaves any other refusal to the caller's sentence, never the server's", () => { const lastMethod = new ApiError( - "Cannot delete — at least one authentication method must remain", + "Cannot delete. At least one authentication method must remain", 400, + { errorCode: "auth.passkey.last_method" }, ); - expect(describeStepUp(lastMethod, "fallback", STEP_UP_SENTENCE)).toContain( - "at least one authentication method", + expect(describeStepUp(lastMethod, "fallback", STEP_UP_SENTENCE)).toBe( + "fallback", ); }); diff --git a/src/components/settings/security-section/__tests__/totp-card.test.tsx b/src/components/settings/security-section/__tests__/totp-card.test.tsx index 638caa5df..caed502a4 100644 --- a/src/components/settings/security-section/__tests__/totp-card.test.tsx +++ b/src/components/settings/security-section/__tests__/totp-card.test.tsx @@ -27,7 +27,7 @@ * * Mutation check: drop either `onSettled` and the wiring case goes red; drop * an `open=` binding and the controlled case goes red; return `err.message` - * from `describeError`'s step-up arm and the sentence case goes red. + * from `describeStepUp`'s step-up arm and the sentence case goes red. */ import { describe, expect, it } from "vitest"; import { readFileSync } from "node:fs"; @@ -38,7 +38,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ApiError } from "@/lib/api/api-fetch"; import { I18nProvider } from "@/lib/i18n/context"; import en from "@/../messages/en.json"; -import { describeError, TotpCard } from "../totp-card"; +import { describeStepUp } from "../security-keys-card"; +import { TotpCard } from "../totp-card"; const STEP_UP_SENTENCE = en.settings.security.stepUpRequired; @@ -81,7 +82,7 @@ describe(" — a refused mutation", () => { { errorCode: "auth.stepup.required" }, ); - const shown = describeError(refusal, "fallback", STEP_UP_SENTENCE); + const shown = describeStepUp(refusal, "fallback", STEP_UP_SENTENCE); expect(shown).toBe(STEP_UP_SENTENCE); expect(shown).not.toContain("second-factor verification required"); diff --git a/src/components/settings/security-section/existing-factor-reauth-dialog.tsx b/src/components/settings/security-section/existing-factor-reauth-dialog.tsx index 799c30f33..a81e51da2 100644 --- a/src/components/settings/security-section/existing-factor-reauth-dialog.tsx +++ b/src/components/settings/security-section/existing-factor-reauth-dialog.tsx @@ -17,6 +17,7 @@ import { Segmented } from "@/components/ui/segmented"; import { Input } from "@/components/ui/input"; import { useTranslations } from "@/lib/i18n/context"; import { ApiError, apiPost } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { describePasskeyError } from "@/lib/passkey-errors"; export type ReauthMethod = @@ -171,7 +172,7 @@ export function ExistingFactorReauthDialog({ onProof({ method, challengeId: begun.challengeId, credential }); } catch (err) { if (err instanceof ApiError) { - setAssertionError(err.message); + setAssertionError(localizedApiError(err, t, "settings.reproof.failed")); } else { const { key, params } = describePasskeyError(err); setAssertionError(t(key, params)); diff --git a/src/components/settings/security-section/passkey-list-section.tsx b/src/components/settings/security-section/passkey-list-section.tsx index 24b860bc5..35b332127 100644 --- a/src/components/settings/security-section/passkey-list-section.tsx +++ b/src/components/settings/security-section/passkey-list-section.tsx @@ -43,6 +43,7 @@ import { apiPatch, apiPost, } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { describePasskeyError } from "@/lib/passkey-errors"; import { describeStepUp } from "./security-keys-card"; import { @@ -206,7 +207,7 @@ export function PasskeyListSection({ }, onError: (err) => { if (err instanceof ApiError) { - setMsg(err.message || t("settings.passkeyRegistrationFailed")); + setMsg(localizedApiError(err, t, "settings.passkeyRegistrationFailed")); } else { const { key, params } = describePasskeyError(err); setMsg(t(key, params)); @@ -223,7 +224,8 @@ export function PasskeyListSection({ setMsg(null); queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() }); }, - onError: (err: Error) => setMsg(err.message), + onError: (err) => + setMsg(localizedApiError(err, t, "settings.passkeyRenameFailed")), }); // Removal is step-up gated: a second factor, or on a passkey-only account a @@ -248,7 +250,11 @@ export function PasskeyListSection({ onError: (err: Error) => { if (err instanceof ReproofCancelledError) return; setMsg( - describeStepUp(err, err.message, t("settings.passkeyStepUpRequired")), + describeStepUp( + err, + localizedApiError(err, t, "settings.passkeyRemoveFailed"), + t("settings.passkeyStepUpRequired"), + ), ); }, }); diff --git a/src/components/settings/security-section/recent-proof-flow.ts b/src/components/settings/security-section/recent-proof-flow.ts index f2c6c804c..ab598de12 100644 --- a/src/components/settings/security-section/recent-proof-flow.ts +++ b/src/components/settings/security-section/recent-proof-flow.ts @@ -83,7 +83,7 @@ export function createRecentProofFlow(deps: { /** Posts the proof to `/api/auth/reproof`. */ submit: (proof: ExistingFactorProof) => Promise; /** Read at the moment they are needed, so a locale switch is honoured. */ - messages: () => { signInAgain: string; failed: string }; + messages: () => { signInAgain: string; failed: string; rateLimited: string }; }): RecentProofFlow { let waiter: { resolve: () => void; reject: () => void } | null = null; @@ -127,7 +127,7 @@ export function createRecentProofFlow(deps: { } catch (err) { deps.view.setError( err instanceof ApiError && err.status === 429 - ? err.message + ? deps.messages().rateLimited : deps.messages().failed, ); } finally { diff --git a/src/components/settings/security-section/security-keys-card.tsx b/src/components/settings/security-section/security-keys-card.tsx index 8bb5c0a8a..c8b03a630 100644 --- a/src/components/settings/security-section/security-keys-card.tsx +++ b/src/components/settings/security-section/security-keys-card.tsx @@ -23,6 +23,7 @@ import { SettingsCardActions } from "@/components/settings/_card-actions"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { ApiError, apiDelete, apiPatch, apiPost } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { describePasskeyError } from "@/lib/passkey-errors"; import { formatDate } from "@/lib/format"; import { @@ -50,6 +51,8 @@ export interface WebauthnKeyInfo { * "Recent second-factor verification required", which is English-only and, for a * passkey-only account, not even accurate about which credential to re-prove — * so the caller supplies its own sentence and this only decides when to use it. + * Any other refusal reads as `fallback`, which callers resolve through + * `localizedApiError`; the server's prose is never shown. * * Matched on `meta.errorCode`, and on the whole `auth.stepup` prefix rather than * one code: `auth.stepup.required` and `auth.stepup.mfa_not_enrolled` both mean @@ -70,7 +73,6 @@ export function describeStepUp( ) { return stepUpMsg; } - return err.message || fallback; } return fallback; } @@ -141,7 +143,7 @@ export function SecurityKeysCard({ } let message: string; if (err instanceof ApiError) { - message = err.message || t("settings.security.keys.addFailed"); + message = localizedApiError(err, t, "settings.security.keys.addFailed"); } else { const { key, params } = describePasskeyError(err); message = t(key, params); @@ -165,9 +167,7 @@ export function SecurityKeysCard({ }, onError: (err) => setError( - err instanceof ApiError - ? err.message - : t("settings.security.keys.renameFailed"), + localizedApiError(err, t, "settings.security.keys.renameFailed"), ), }); @@ -191,7 +191,7 @@ export function SecurityKeysCard({ setError( describeStepUp( err, - t("settings.security.keys.removeFailed"), + localizedApiError(err, t, "settings.security.keys.removeFailed"), t("settings.security.stepUpRequired"), ), ); diff --git a/src/components/settings/security-section/totp-card.tsx b/src/components/settings/security-section/totp-card.tsx index 2acac8456..a4c10190f 100644 --- a/src/components/settings/security-section/totp-card.tsx +++ b/src/components/settings/security-section/totp-card.tsx @@ -31,8 +31,10 @@ import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; -import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch"; +import { apiGet, apiPost } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { RecoveryCodesPanel } from "./recovery-codes-panel"; +import { describeStepUp } from "./security-keys-card"; import { ReproofCancelledError, recentProofErrorMessage, @@ -59,31 +61,6 @@ interface ConfirmResult { recoveryCodesRemaining: number; } -/** - * Map a step-up 401 to a clear re-login message; otherwise the raw message. - * - * Exported for the unit suite: this is the sentence a refused mutation leaves - * on the card, and the card is where a person reads it. - */ -export function describeError( - err: unknown, - fallback: string, - stepUpMsg: string, -): string { - if (err instanceof ApiError) { - const code = err.meta?.errorCode; - if ( - err.status === 401 && - typeof code === "string" && - code.startsWith("auth.stepup") - ) { - return stepUpMsg; - } - return err.message || fallback; - } - return fallback; -} - export function TotpCard({ enabled, recoveryCodesRemaining, @@ -201,9 +178,9 @@ export function TotpCard({ setReauthOpen(true); return; } - const message = describeError( + const message = describeStepUp( err, - t("settings.security.totp.setupFailed"), + localizedApiError(err, t, "settings.security.totp.setupFailed"), t("settings.security.stepUpRequired"), ); if (proof) setReauthError(message); @@ -223,9 +200,9 @@ export function TotpCard({ }, onError: (err) => setError( - describeError( + describeStepUp( err, - t("settings.security.totp.invalidCode"), + localizedApiError(err, t, "settings.security.totp.invalidCode"), t("settings.security.stepUpRequired"), ), ), @@ -248,9 +225,9 @@ export function TotpCard({ onError: (err) => { if (err instanceof ReproofCancelledError) return; setError( - describeError( + describeStepUp( err, - t("settings.security.totp.disableFailed"), + localizedApiError(err, t, "settings.security.totp.disableFailed"), t("settings.security.stepUpRequired"), ), ); @@ -274,9 +251,9 @@ export function TotpCard({ onError: (err) => { if (err instanceof ReproofCancelledError) return; setError( - describeError( + describeStepUp( err, - t("settings.security.recovery.regenFailed"), + localizedApiError(err, t, "settings.security.recovery.regenFailed"), t("settings.security.stepUpRequired"), ), ); diff --git a/src/components/settings/security-section/use-recent-proof.tsx b/src/components/settings/security-section/use-recent-proof.tsx index be06029e4..b52195c7d 100644 --- a/src/components/settings/security-section/use-recent-proof.tsx +++ b/src/components/settings/security-section/use-recent-proof.tsx @@ -105,6 +105,7 @@ export function useRecentProof( messages: () => ({ signInAgain: t("settings.reproof.signInAgain"), failed: t("settings.reproof.failed"), + rateLimited: t("apiErrors.http.rate_limited"), }), }), [t], diff --git a/src/components/settings/thresholds-editor-section.tsx b/src/components/settings/thresholds-editor-section.tsx index 5266aab2c..b242eb077 100644 --- a/src/components/settings/thresholds-editor-section.tsx +++ b/src/components/settings/thresholds-editor-section.tsx @@ -22,7 +22,8 @@ import { type ThresholdMetric, type EffectiveRange, } from "@/lib/analytics/effective-range"; -import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; +import { apiFetchRaw, apiGet, apiPut } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { useUnitDisplay } from "@/hooks/use-unit-display"; import { resolveTargetUnitAdapter } from "@/lib/targets/target-unit-display"; import { SettingsCard } from "@/components/settings/settings-card"; @@ -81,15 +82,7 @@ export function ThresholdsEditorSection({ id }: { id: string }) { mutationFn: async ( payload: Partial>, ) => { - const res = await apiFetchRaw("/api/user/thresholds", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - if (!res.ok) { - const err = await res.json().catch(() => null); - throw new Error(err?.error ?? "save failed"); - } + await apiPut("/api/user/thresholds", payload); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.userThresholds() }); @@ -102,10 +95,9 @@ export function ThresholdsEditorSection({ id }: { id: string }) { toast.success(t("thresholds.saveSuccess")); }, onError: (err) => - showErrorToast( - err instanceof Error ? err.message : t("thresholds.saveError"), - { cause: err }, - ), + showErrorToast(localizedApiError(err, t, "thresholds.saveError"), { + cause: err, + }), }); const resetMutation = useMutation({ diff --git a/src/components/settings/webhook-card.tsx b/src/components/settings/webhook-card.tsx index 644be17e5..916c46456 100644 --- a/src/components/settings/webhook-card.tsx +++ b/src/components/settings/webhook-card.tsx @@ -16,12 +16,14 @@ import { SettingsCardHeader } from "@/components/settings/_card-header"; import { TestConnectionButton } from "@/components/settings/test-connection-button"; import { isWebhookFormDirty } from "@/components/settings/channel-form-dirty"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; -import { useChannelEnabledToggle } from "@/components/settings/notification-channel-toggle"; +import { + channelSaveError, + useChannelEnabledToggle, +} from "@/components/settings/notification-channel-toggle"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; -import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; +import { apiFetchRaw, apiGet, throwApiError } from "@/lib/api/api-fetch"; import type { WebhookPayloadFormat } from "@/lib/notifications/types"; -import { translateCodeOrNull } from "@/lib/i18n/translate-code"; interface WebhookSettings { enabled: boolean; @@ -82,22 +84,7 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) { enabled: settings?.enabled ?? false, }), }); - if (!res.ok) { - const json = (await res.json()) as { - error?: string; - meta?: { errorCode?: string }; - }; - // A coded refusal (the private-origin policy) translates like the - // test button's; anything else shows the server sentence. - throw new Error( - translateCodeOrNull( - t, - "settings.testConnection.errors", - json.meta?.errorCode, - ) ?? - (json.error || t("common.error")), - ); - } + if (!res.ok) await throwApiError(res); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.settingsWebhook() }); @@ -106,8 +93,8 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) { setHeaderValue(""); }, // A form shows its refusal inline, next to the fields it is about. - onError: (err: Error) => { - setSaveError(err.message); + onError: (err) => { + setSaveError(channelSaveError(err, t)); }, }); diff --git a/src/components/targets/target-edit-sheet.tsx b/src/components/targets/target-edit-sheet.tsx index c51f643ee..890f4b344 100644 --- a/src/components/targets/target-edit-sheet.tsx +++ b/src/components/targets/target-edit-sheet.tsx @@ -25,6 +25,7 @@ import { import { resolveGlucoseUnit } from "@/lib/glucose"; import { useUnitDisplay } from "@/hooks/use-unit-display"; import { apiDelete, apiGet, apiPut } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; /** * Per-metric target editor, mounted inline on the Insights metric @@ -258,10 +259,9 @@ function TargetEditSheetBody({ onOpenChange(false); }, onError: (err) => - showErrorToast( - err instanceof Error ? err.message : t("targets.edit.saveError"), - { cause: err }, - ), + showErrorToast(localizedApiError(err, t, "targets.edit.saveError"), { + cause: err, + }), }); const resetMutation = useMutation({ diff --git a/src/lib/api/api-fetch.ts b/src/lib/api/api-fetch.ts index 15f2e28a2..7d180aaea 100644 --- a/src/lib/api/api-fetch.ts +++ b/src/lib/api/api-fetch.ts @@ -47,7 +47,10 @@ * sites that need the `Response` itself — blob downloads, SSE * streams, manual status branching. No `.ok` check, no unwrap, and * deliberately NO default timeout: the Coach chat stream and other - * long-lived responses ride this path with their own signals. + * long-lived responses ride this path with their own signals. A + * caller that finds the raw `Response` refused passes it to + * `throwApiError`, so the refusal reaches its handler as the same + * `ApiError` the verb helpers throw. */ import { randomId } from "@/lib/random-id"; @@ -103,7 +106,8 @@ interface Envelope> { meta?: M; } -async function throwApiError(res: Response): Promise { +/** Throw the `ApiError` for a refused raw `Response` (see `apiFetchRaw`). */ +export async function throwApiError(res: Response): Promise { let meta: Record | undefined; let issuePaths: string[] = []; try { diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 548057cf0..fa30f1bf2 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -58,7 +58,11 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "ai.usage.levelTooLow", "ai.usage.rateLimited", ], - ai_provider: ["ai_provider.invalid", "ai_provider.no_fields"], + ai_provider: [ + "ai_provider.invalid", + "ai_provider.no_fields", + "ai_provider.private_host", + ], aiRuns: [ "aiRuns.failed", "aiRuns.notFound", @@ -84,6 +88,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "auth.already_authenticated", "auth.mfa.code_invalid", "auth.missing", + "auth.passkey.last_method", "auth.refresh.invalid", "auth.refresh.reuse", "auth.refresh.revoked",