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 01/10] 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", From 85240ab8582fa28cfa94dcffa13eb83ba02121c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 21:32:23 +0100 Subject: [PATCH 02/10] fix(records): give the visit, vaccination and workout refusals a sentence The visit, vaccination, allergy, address-book and workout forms read a refusal's code through the catalogue, but nineteen of the codes their routes send had no sentence: a stale doctor or check-up, a planned visit that would close a check-up, a dose without a name, a booster that cannot be planned, an invalid body, a workout batch that is too large. Each of them read as the form's generic "could not be saved", which hides what the person can change. They now have their own sentence in all ten languages. --- messages/de.json | 31 +++++++++++ messages/en.json | 31 +++++++++++ messages/es.json | 31 +++++++++++ messages/fr.json | 31 +++++++++++ messages/it.json | 31 +++++++++++ messages/ko.json | 31 +++++++++++ messages/nl.json | 31 +++++++++++ messages/pl.json | 31 +++++++++++ messages/pt-BR.json | 31 +++++++++++ messages/ru.json | 31 +++++++++++ src/lib/api/__tests__/localized-error.test.ts | 54 +++++++++++++++++++ 11 files changed, 364 insertions(+) diff --git a/messages/de.json b/messages/de.json index 8ee193b89..13bcb27aa 100644 --- a/messages/de.json +++ b/messages/de.json @@ -191,6 +191,37 @@ }, "ai_provider": { "private_host": "Diese Adresse liegt in einem privaten Netz. Der Betreiber muss ihren genauen Ursprung erst in AI_PRIVATE_ORIGINS freigeben." + }, + "encounter": { + "invalid": "Prüfe die Angaben zum Termin und versuche es erneut.", + "practitioner-not-found": "Diese Praxis bzw. dieser Arzt existiert nicht mehr. Wähle einen anderen Eintrag oder keinen.", + "reminder-not-found": "Diese Vorsorge existiert nicht mehr. Wähle eine andere oder keine.", + "reminder-conflict": "Ein geplanter Termin kann keine Vorsorge abschließen, denn er hat noch nicht stattgefunden." + }, + "vaccination": { + "invalid": "Prüfe die Angaben zur Impfung und versuche es erneut.", + "practitioner-not-found": "Diese Praxis bzw. dieser Arzt existiert nicht mehr. Wähle einen anderen Eintrag oder keinen.", + "encounter-not-found": "Dieser Termin existiert nicht mehr. Wähle einen anderen oder keinen.", + "custom-vaccine-not-found": "Dieser Impfstoff existiert nicht mehr. Wähle ihn erneut aus der Liste.", + "not-found": "Diese Impfung ist nicht mehr vorhanden.", + "identity-required": "Eine Impfung braucht einen Namen: Wähle einen Impfstoff aus der Liste oder gib ein, was im Impfpass steht.", + "booster-invalid": "Prüfe die Angaben zur Auffrischung und versuche es erneut.", + "booster-no-antigen": "Diese Impfung nennt keinen Impfstoff aus dem Katalog, daher lässt sich keine Auffrischung planen.", + "booster-out-of-scope": "Deine Freigabe für diese Akte reicht nicht, um eine Auffrischung zu planen." + }, + "allergy": { + "invalid": "Prüfe die Angaben zur Allergie und versuche es erneut." + }, + "practitioner": { + "invalid": "Prüfe die Angaben zur Praxis bzw. zum Arzt und versuche es erneut." + }, + "workout": { + "batch": { + "invalid": "Prüfe die Angaben zum Training und versuche es erneut.", + "too_large": "Zu viele Trainings auf einmal. Schicke weniger auf einmal.", + "payload_too_large": "Die Trainingsdaten sind zu groß, um sie auf einmal zu speichern.", + "source_not_permitted": "Diese Verbindung darf keine Trainings unter dieser Quelle speichern." + } } }, "doctorReport": { diff --git a/messages/en.json b/messages/en.json index 5a5554e39..6ba260ae3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -191,6 +191,37 @@ }, "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." + }, + "encounter": { + "invalid": "Check the visit details and try again.", + "practitioner-not-found": "This practice or doctor no longer exists. Choose another or none.", + "reminder-not-found": "This checkup no longer exists. Choose another or none.", + "reminder-conflict": "A planned visit cannot close a checkup, because it has not happened yet." + }, + "vaccination": { + "invalid": "Check the vaccination details and try again.", + "practitioner-not-found": "This practice or doctor no longer exists. Choose another or none.", + "encounter-not-found": "This visit no longer exists. Choose another or none.", + "custom-vaccine-not-found": "This vaccine no longer exists. Choose it again from the list.", + "not-found": "This vaccination is no longer available.", + "identity-required": "A dose needs a name: pick a vaccine from the list or type what the record says.", + "booster-invalid": "Check the booster details and try again.", + "booster-no-antigen": "This dose names no vaccine from the catalogue, so no booster can be planned for it.", + "booster-out-of-scope": "Your access to this record does not cover planning a booster." + }, + "allergy": { + "invalid": "Check the allergy details and try again." + }, + "practitioner": { + "invalid": "Check the practice or doctor details and try again." + }, + "workout": { + "batch": { + "invalid": "Check the workout details and try again.", + "too_large": "Too many workouts at once. Send fewer at a time.", + "payload_too_large": "The workout data is too large to save in one go.", + "source_not_permitted": "This connection may not save workouts under that source." + } } }, "doctorReport": { diff --git a/messages/es.json b/messages/es.json index 283ba8e2f..62128c707 100644 --- a/messages/es.json +++ b/messages/es.json @@ -191,6 +191,37 @@ }, "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." + }, + "encounter": { + "invalid": "Comprueba los datos de la cita e inténtalo de nuevo.", + "practitioner-not-found": "Esta consulta o este médico ya no existe. Elige otro o ninguno.", + "reminder-not-found": "Esta revisión ya no existe. Elige otra o ninguna.", + "reminder-conflict": "Una cita prevista no puede cerrar una revisión, porque todavía no ha tenido lugar." + }, + "vaccination": { + "invalid": "Comprueba los datos de la vacuna e inténtalo de nuevo.", + "practitioner-not-found": "Esta consulta o este médico ya no existe. Elige otro o ninguno.", + "encounter-not-found": "Esta cita ya no existe. Elige otra o ninguna.", + "custom-vaccine-not-found": "Esta vacuna ya no existe. Elígela de nuevo en la lista.", + "not-found": "Esta vacuna ya no está disponible.", + "identity-required": "Una dosis necesita un nombre: elige una vacuna de la lista o escribe lo que pone el registro.", + "booster-invalid": "Comprueba los datos del refuerzo e inténtalo de nuevo.", + "booster-no-antigen": "Esta dosis no indica ninguna vacuna del catálogo, así que no se puede planificar un refuerzo.", + "booster-out-of-scope": "Tu acceso a este registro no permite planificar un refuerzo." + }, + "allergy": { + "invalid": "Comprueba los datos de la alergia e inténtalo de nuevo." + }, + "practitioner": { + "invalid": "Comprueba los datos de la consulta o del médico e inténtalo de nuevo." + }, + "workout": { + "batch": { + "invalid": "Comprueba los datos del entrenamiento e inténtalo de nuevo.", + "too_large": "Demasiados entrenamientos a la vez. Envía menos cada vez.", + "payload_too_large": "Los datos del entrenamiento son demasiado grandes para guardarlos de una vez.", + "source_not_permitted": "Esta conexión no puede guardar entrenamientos con esa fuente." + } } }, "doctorReport": { diff --git a/messages/fr.json b/messages/fr.json index a04c2fd05..f9885e3c5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -191,6 +191,37 @@ }, "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." + }, + "encounter": { + "invalid": "Vérifiez les informations du rendez-vous et réessayez.", + "practitioner-not-found": "Ce cabinet ou ce médecin n'existe plus. Choisissez-en un autre ou aucun.", + "reminder-not-found": "Ce dépistage n'existe plus. Choisissez-en un autre ou aucun.", + "reminder-conflict": "Un rendez-vous prévu ne peut pas clore un dépistage, car il n'a pas encore eu lieu." + }, + "vaccination": { + "invalid": "Vérifiez les informations de la vaccination et réessayez.", + "practitioner-not-found": "Ce cabinet ou ce médecin n'existe plus. Choisissez-en un autre ou aucun.", + "encounter-not-found": "Ce rendez-vous n'existe plus. Choisissez-en un autre ou aucun.", + "custom-vaccine-not-found": "Ce vaccin n'existe plus. Choisissez-le à nouveau dans la liste.", + "not-found": "Cette vaccination n'est plus disponible.", + "identity-required": "Une dose a besoin d'un nom : choisissez un vaccin dans la liste ou saisissez ce qu'indique le carnet.", + "booster-invalid": "Vérifiez les informations du rappel et réessayez.", + "booster-no-antigen": "Cette dose ne cite aucun vaccin du catalogue, aucun rappel ne peut donc être prévu.", + "booster-out-of-scope": "Votre accès à ce dossier ne permet pas de prévoir un rappel." + }, + "allergy": { + "invalid": "Vérifiez les informations de l'allergie et réessayez." + }, + "practitioner": { + "invalid": "Vérifiez les informations du cabinet ou du médecin et réessayez." + }, + "workout": { + "batch": { + "invalid": "Vérifiez les informations de l'entraînement et réessayez.", + "too_large": "Trop d'entraînements à la fois. Envoyez-en moins à la fois.", + "payload_too_large": "Les données d'entraînement sont trop volumineuses pour être enregistrées en une fois.", + "source_not_permitted": "Cette connexion n'a pas le droit d'enregistrer des entraînements sous cette source." + } } }, "doctorReport": { diff --git a/messages/it.json b/messages/it.json index 2be4d5cf8..f78033c05 100644 --- a/messages/it.json +++ b/messages/it.json @@ -191,6 +191,37 @@ }, "ai_provider": { "private_host": "Questo indirizzo si trova in una rete privata. L’operatore deve prima consentirne l’origine esatta in AI_PRIVATE_ORIGINS." + }, + "encounter": { + "invalid": "Controlla i dati dell'appuntamento e riprova.", + "practitioner-not-found": "Questo studio o medico non esiste più. Scegline un altro o nessuno.", + "reminder-not-found": "Questo controllo non esiste più. Scegline un altro o nessuno.", + "reminder-conflict": "Un appuntamento previsto non può chiudere un controllo, perché non è ancora avvenuto." + }, + "vaccination": { + "invalid": "Controlla i dati della vaccinazione e riprova.", + "practitioner-not-found": "Questo studio o medico non esiste più. Scegline un altro o nessuno.", + "encounter-not-found": "Questo appuntamento non esiste più. Scegline un altro o nessuno.", + "custom-vaccine-not-found": "Questo vaccino non esiste più. Sceglilo di nuovo dall'elenco.", + "not-found": "Questa vaccinazione non è più disponibile.", + "identity-required": "Una dose ha bisogno di un nome: scegli un vaccino dall'elenco o scrivi quello che riporta il libretto.", + "booster-invalid": "Controlla i dati del richiamo e riprova.", + "booster-no-antigen": "Questa dose non indica alcun vaccino del catalogo, quindi non si può pianificare un richiamo.", + "booster-out-of-scope": "Il tuo accesso a questa cartella non consente di pianificare un richiamo." + }, + "allergy": { + "invalid": "Controlla i dati dell'allergia e riprova." + }, + "practitioner": { + "invalid": "Controlla i dati dello studio o del medico e riprova." + }, + "workout": { + "batch": { + "invalid": "Controlla i dati dell'allenamento e riprova.", + "too_large": "Troppi allenamenti in una volta. Inviane meno alla volta.", + "payload_too_large": "I dati dell'allenamento sono troppo grandi per salvarli in una volta.", + "source_not_permitted": "Questa connessione non può salvare allenamenti con questa fonte." + } } }, "doctorReport": { diff --git a/messages/ko.json b/messages/ko.json index 8bee83435..c62750df2 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -191,6 +191,37 @@ }, "ai_provider": { "private_host": "이 주소는 사설 네트워크에 있어요. 운영자가 먼저 AI_PRIVATE_ORIGINS에 정확한 출처를 허용해야 해요." + }, + "encounter": { + "invalid": "진료 정보를 확인하고 다시 시도해 주세요.", + "practitioner-not-found": "이 병원 또는 의사가 더 이상 없어요. 다른 항목을 고르거나 선택하지 마세요.", + "reminder-not-found": "이 건강검진이 더 이상 없어요. 다른 검진을 고르거나 선택하지 마세요.", + "reminder-conflict": "예정된 진료는 아직 이루어지지 않았기 때문에 건강검진을 완료할 수 없어요." + }, + "vaccination": { + "invalid": "예방접종 정보를 확인하고 다시 시도해 주세요.", + "practitioner-not-found": "이 병원 또는 의사가 더 이상 없어요. 다른 항목을 고르거나 선택하지 마세요.", + "encounter-not-found": "이 진료가 더 이상 없어요. 다른 진료를 고르거나 선택하지 마세요.", + "custom-vaccine-not-found": "이 백신이 더 이상 없어요. 목록에서 다시 골라 주세요.", + "not-found": "이 예방접종 기록은 더 이상 없어요.", + "identity-required": "접종에는 이름이 필요해요. 목록에서 백신을 고르거나 기록에 적힌 이름을 입력하세요.", + "booster-invalid": "추가 접종 정보를 확인하고 다시 시도해 주세요.", + "booster-no-antigen": "이 접종에는 목록의 백신이 지정되지 않아 추가 접종을 계획할 수 없어요.", + "booster-out-of-scope": "이 기록에 대한 권한으로는 추가 접종을 계획할 수 없어요." + }, + "allergy": { + "invalid": "알레르기 정보를 확인하고 다시 시도해 주세요." + }, + "practitioner": { + "invalid": "병원 또는 의사 정보를 확인하고 다시 시도해 주세요." + }, + "workout": { + "batch": { + "invalid": "운동 정보를 확인하고 다시 시도해 주세요.", + "too_large": "한 번에 운동이 너무 많아요. 조금씩 나눠서 보내 주세요.", + "payload_too_large": "운동 데이터가 너무 커서 한 번에 저장할 수 없어요.", + "source_not_permitted": "이 연결로는 해당 출처의 운동을 저장할 수 없어요." + } } }, "doctorReport": { diff --git a/messages/nl.json b/messages/nl.json index 680cb970c..da1aa2e27 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -191,6 +191,37 @@ }, "ai_provider": { "private_host": "Dit adres ligt in een privénetwerk. De beheerder moet de exacte origin eerst toestaan in AI_PRIVATE_ORIGINS." + }, + "encounter": { + "invalid": "Controleer de gegevens van het bezoek en probeer het opnieuw.", + "practitioner-not-found": "Deze praktijk of arts bestaat niet meer. Kies een andere of geen.", + "reminder-not-found": "Deze controle bestaat niet meer. Kies een andere of geen.", + "reminder-conflict": "Een gepland bezoek kan geen controle afsluiten, want het heeft nog niet plaatsgevonden." + }, + "vaccination": { + "invalid": "Controleer de gegevens van de vaccinatie en probeer het opnieuw.", + "practitioner-not-found": "Deze praktijk of arts bestaat niet meer. Kies een andere of geen.", + "encounter-not-found": "Dit bezoek bestaat niet meer. Kies een ander of geen.", + "custom-vaccine-not-found": "Dit vaccin bestaat niet meer. Kies het opnieuw uit de lijst.", + "not-found": "Deze vaccinatie is niet meer beschikbaar.", + "identity-required": "Een dosis heeft een naam nodig: kies een vaccin uit de lijst of typ wat er in het boekje staat.", + "booster-invalid": "Controleer de gegevens van de booster en probeer het opnieuw.", + "booster-no-antigen": "Deze dosis noemt geen vaccin uit de catalogus, dus er kan geen booster worden gepland.", + "booster-out-of-scope": "Je toegang tot dit dossier dekt het plannen van een booster niet." + }, + "allergy": { + "invalid": "Controleer de gegevens van de allergie en probeer het opnieuw." + }, + "practitioner": { + "invalid": "Controleer de gegevens van de praktijk of arts en probeer het opnieuw." + }, + "workout": { + "batch": { + "invalid": "Controleer de gegevens van de training en probeer het opnieuw.", + "too_large": "Te veel trainingen tegelijk. Stuur er minder per keer.", + "payload_too_large": "De trainingsgegevens zijn te groot om in één keer op te slaan.", + "source_not_permitted": "Deze koppeling mag geen trainingen onder die bron opslaan." + } } }, "doctorReport": { diff --git a/messages/pl.json b/messages/pl.json index f688e8ad1..854b541fe 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -191,6 +191,37 @@ }, "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." + }, + "encounter": { + "invalid": "Sprawdź dane wizyty i spróbuj ponownie.", + "practitioner-not-found": "Ten gabinet lub lekarz już nie istnieje. Wybierz inny albo żaden.", + "reminder-not-found": "To badanie już nie istnieje. Wybierz inne albo żadne.", + "reminder-conflict": "Zaplanowana wizyta nie może zamknąć badania, bo jeszcze się nie odbyła." + }, + "vaccination": { + "invalid": "Sprawdź dane szczepienia i spróbuj ponownie.", + "practitioner-not-found": "Ten gabinet lub lekarz już nie istnieje. Wybierz inny albo żaden.", + "encounter-not-found": "Ta wizyta już nie istnieje. Wybierz inną albo żadną.", + "custom-vaccine-not-found": "Ta szczepionka już nie istnieje. Wybierz ją ponownie z listy.", + "not-found": "To szczepienie nie jest już dostępne.", + "identity-required": "Dawka potrzebuje nazwy: wybierz szczepionkę z listy albo wpisz to, co widnieje w dokumentacji.", + "booster-invalid": "Sprawdź dane dawki przypominającej i spróbuj ponownie.", + "booster-no-antigen": "Ta dawka nie wskazuje szczepionki z katalogu, więc nie da się zaplanować dawki przypominającej.", + "booster-out-of-scope": "Twój dostęp do tej kartoteki nie obejmuje planowania dawki przypominającej." + }, + "allergy": { + "invalid": "Sprawdź dane alergii i spróbuj ponownie." + }, + "practitioner": { + "invalid": "Sprawdź dane gabinetu lub lekarza i spróbuj ponownie." + }, + "workout": { + "batch": { + "invalid": "Sprawdź dane treningu i spróbuj ponownie.", + "too_large": "Za dużo treningów naraz. Wysyłaj mniej za jednym razem.", + "payload_too_large": "Dane treningu są za duże, by zapisać je za jednym razem.", + "source_not_permitted": "To połączenie nie może zapisywać treningów pod tym źródłem." + } } }, "doctorReport": { diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 17035a7a1..bf5265f6e 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -191,6 +191,37 @@ }, "ai_provider": { "private_host": "Este endereço está em uma rede privada. O operador precisa primeiro permitir a origem exata em AI_PRIVATE_ORIGINS." + }, + "encounter": { + "invalid": "Verifique os dados da consulta e tente novamente.", + "practitioner-not-found": "Esta clínica ou médico não existe mais. Escolha outro ou nenhum.", + "reminder-not-found": "Este check não existe mais. Escolha outro ou nenhum.", + "reminder-conflict": "Uma consulta planejada não pode fechar um check, porque ainda não aconteceu." + }, + "vaccination": { + "invalid": "Verifique os dados da vacina e tente novamente.", + "practitioner-not-found": "Esta clínica ou médico não existe mais. Escolha outro ou nenhum.", + "encounter-not-found": "Esta consulta não existe mais. Escolha outra ou nenhuma.", + "custom-vaccine-not-found": "Esta vacina não existe mais. Escolha-a novamente na lista.", + "not-found": "Esta vacina não está mais disponível.", + "identity-required": "Uma dose precisa de um nome: escolha uma vacina da lista ou digite o que consta no registro.", + "booster-invalid": "Verifique os dados do reforço e tente novamente.", + "booster-no-antigen": "Esta dose não indica nenhuma vacina do catálogo, então não é possível planejar um reforço.", + "booster-out-of-scope": "Seu acesso a este registro não permite planejar um reforço." + }, + "allergy": { + "invalid": "Verifique os dados da alergia e tente novamente." + }, + "practitioner": { + "invalid": "Verifique os dados da clínica ou do médico e tente novamente." + }, + "workout": { + "batch": { + "invalid": "Verifique os dados do treino e tente novamente.", + "too_large": "Treinos demais de uma vez. Envie menos por vez.", + "payload_too_large": "Os dados do treino são grandes demais para salvar de uma vez.", + "source_not_permitted": "Esta conexão não pode salvar treinos com essa origem." + } } }, "doctorReport": { diff --git a/messages/ru.json b/messages/ru.json index 05d5c1372..d207f54f6 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -191,6 +191,37 @@ }, "ai_provider": { "private_host": "Этот адрес находится в частной сети. Сначала оператор должен разрешить его точный источник в AI_PRIVATE_ORIGINS." + }, + "encounter": { + "invalid": "Проверьте данные визита и попробуйте снова.", + "practitioner-not-found": "Этой клиники или врача больше нет. Выберите другую запись или никакую.", + "reminder-not-found": "Этого осмотра больше нет. Выберите другой или никакой.", + "reminder-conflict": "Запланированный визит не может закрыть осмотр, потому что он ещё не состоялся." + }, + "vaccination": { + "invalid": "Проверьте данные вакцинации и попробуйте снова.", + "practitioner-not-found": "Этой клиники или врача больше нет. Выберите другую запись или никакую.", + "encounter-not-found": "Этого визита больше нет. Выберите другой или никакой.", + "custom-vaccine-not-found": "Этой вакцины больше нет. Выберите её в списке ещё раз.", + "not-found": "Этой вакцинации больше нет.", + "identity-required": "У дозы должно быть название: выберите вакцину из списка или введите то, что указано в записи.", + "booster-invalid": "Проверьте данные ревакцинации и попробуйте снова.", + "booster-no-antigen": "В этой дозе не указана вакцина из каталога, поэтому ревакцинацию запланировать нельзя.", + "booster-out-of-scope": "Ваш доступ к этой карте не позволяет планировать ревакцинацию." + }, + "allergy": { + "invalid": "Проверьте данные аллергии и попробуйте снова." + }, + "practitioner": { + "invalid": "Проверьте данные клиники или врача и попробуйте снова." + }, + "workout": { + "batch": { + "invalid": "Проверьте данные тренировки и попробуйте снова.", + "too_large": "Слишком много тренировок за раз. Отправляйте меньше за один раз.", + "payload_too_large": "Данные тренировки слишком велики, чтобы сохранить их за один раз.", + "source_not_permitted": "Это подключение не может сохранять тренировки с таким источником." + } } }, "doctorReport": { diff --git a/src/lib/api/__tests__/localized-error.test.ts b/src/lib/api/__tests__/localized-error.test.ts index b828bf842..33bc278f0 100644 --- a/src/lib/api/__tests__/localized-error.test.ts +++ b/src/lib/api/__tests__/localized-error.test.ts @@ -7,7 +7,11 @@ import en from "../../../../messages/en.json"; import es from "../../../../messages/es.json"; import fr from "../../../../messages/fr.json"; import itCatalog from "../../../../messages/it.json"; +import ko from "../../../../messages/ko.json"; +import nl from "../../../../messages/nl.json"; import pl from "../../../../messages/pl.json"; +import ptBR from "../../../../messages/pt-BR.json"; +import ru from "../../../../messages/ru.json"; type Catalog = Record; @@ -160,4 +164,54 @@ describe("localizedApiError", () => { } }); }); + + describe("the refusals of the visit, vaccination, allergy, address-book and workout saves", () => { + // Each of these is a code the route sends to a form that reads it through + // `localizedApiError`; without a sentence the form says only "could not + // save", which hides a fix the person can make. + const codes = [ + "encounter.invalid", + "encounter.practitioner-not-found", + "encounter.reminder-not-found", + "encounter.reminder-conflict", + "vaccination.invalid", + "vaccination.not-found", + "vaccination.identity-required", + "vaccination.practitioner-not-found", + "vaccination.encounter-not-found", + "vaccination.custom-vaccine-not-found", + "vaccination.booster-invalid", + "vaccination.booster-no-antigen", + "vaccination.booster-out-of-scope", + "allergy.invalid", + "practitioner.invalid", + "workout.batch.invalid", + "workout.batch.too_large", + "workout.batch.payload_too_large", + "workout.batch.source_not_permitted", + ]; + + it.each([ + ["de", de], + ["en", en], + ["es", es], + ["fr", fr], + ["it", itCatalog], + ["ko", ko], + ["nl", nl], + ["pl", pl], + ["pt-BR", ptBR], + ["ru", ru], + ])("has a sentence of its own for each in %s", (_, locale) => { + const localT = translator(locale as Catalog); + for (const code of codes) { + const error = new ApiError("raw English server prose", 422, { + errorCode: code, + }); + expect(localizedApiError(error, localT, "common.error"), code).not.toBe( + localT("common.error"), + ); + } + }); + }); }); From 4485dfb3a9d47c5f2e41f39b615d43f59180a732 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 21:36:37 +0100 Subject: [PATCH 03/10] fix(forms): close episode, mood and dose dialogs before the refetch The illness episode hooks returned the refresh of the illness reads and the dashboard from onSuccess, so every episode sheet kept spinning until those had refetched. The mood edit dialog, the mood and measurement bulk deletes and their Undo toasts did the same with an await, and so did the dose edit dialog, the log-intake dialog and the ledger's add dialog. They now answer and close as soon as the write lands and start the refresh without waiting for it. A scheduled dose lands on its slot, so a card that is still stale for a moment only repeats it. The take and skip buttons on a medication card keep waiting: nothing marks the dose in the cache meanwhile, so releasing them early would offer a Take for a dose already recorded. --- .../use-illness-save-settles.test.ts | 56 +++++++++++ src/components/illness/use-illness.ts | 9 +- ...se-measurement-list-deletes-settle.test.ts | 98 +++++++++++++++++++ .../list/use-measurement-list-data.ts | 12 ++- .../__tests__/use-medication-intake.test.ts | 36 ++++++- .../medications/dose-history-add-dialog.tsx | 14 ++- .../medications/intake-edit-dialog.tsx | 10 +- .../medications/use-medication-intake.ts | 5 +- src/components/mood/mood-list.tsx | 16 +-- 9 files changed, 226 insertions(+), 30 deletions(-) create mode 100644 src/components/illness/__tests__/use-illness-save-settles.test.ts create mode 100644 src/components/measurements/list/__tests__/use-measurement-list-deletes-settle.test.ts diff --git a/src/components/illness/__tests__/use-illness-save-settles.test.ts b/src/components/illness/__tests__/use-illness-save-settles.test.ts new file mode 100644 index 000000000..d5212fe30 --- /dev/null +++ b/src/components/illness/__tests__/use-illness-save-settles.test.ts @@ -0,0 +1,56 @@ +/** + * An episode save settles once the server stored it. + * + * Every episode hook's `onSuccess` used to return the refresh of the illness + * reads and the dashboard. A mutation waits for what `onSuccess` returns, so + * the episode sheet kept its spinner until each of those reads had refetched. + */ +import { describe, expect, it, vi } from "vitest"; + +const client = vi.hoisted(() => ({ + // A refetch that never answers, as on a slow connection. + invalidateQueries: vi.fn(() => new Promise(() => {})), + refetchQueries: vi.fn(() => new Promise(() => {})), + getQueryCache: () => ({ findAll: () => [] }), +})); + +vi.mock("@tanstack/react-query", () => ({ + // The hook's options are what is under test; return them as they are. + useQuery: (options: unknown) => options, + useMutation: (options: unknown) => options, + useQueryClient: () => client, +})); + +vi.mock("@/lib/api/api-fetch", () => ({ + apiGet: vi.fn(), + apiGetList: vi.fn(), + apiPost: vi.fn(), + apiPatch: vi.fn(), + apiDelete: vi.fn(), +})); + +import { + useCreateEpisode, + useDeleteEpisode, + useResolveEpisode, + useRestoreEpisode, + useUpdateEpisode, + useUpsertDayLog, +} from "../use-illness"; + +describe("episode saves", () => { + it.each([ + ["create", () => useCreateEpisode()], + ["update", () => useUpdateEpisode()], + ["resolve", () => useResolveEpisode()], + ["delete", () => useDeleteEpisode()], + ["restore", () => useRestoreEpisode()], + ["day log", () => useUpsertDayLog("e1")], + ])("%s starts the refresh and does not wait for it", (_, hook) => { + client.invalidateQueries.mockClear(); + const options = hook() as unknown as { onSuccess: () => unknown }; + + expect(options.onSuccess()).toBeUndefined(); + expect(client.invalidateQueries).toHaveBeenCalled(); + }); +}); diff --git a/src/components/illness/use-illness.ts b/src/components/illness/use-illness.ts index 562ba032b..f333c04ea 100644 --- a/src/components/illness/use-illness.ts +++ b/src/components/illness/use-illness.ts @@ -152,9 +152,12 @@ function useInvalidateIllness() { // the Today digest — both unmounted from here, so the bundle invalidation // is paired with the forced inactive refetch. The write routes hard-evict // the server analytics bucket, so the refetch returns post-write context. - return async () => { - await invalidateKeys(qc, illnessDependentKeys); - await refetchInactiveDailyReads(qc); + // Not returned: a mutation waits for what `onSuccess` returns, and the + // sheet would stay open until every read had refetched. + return () => { + void invalidateKeys(qc, illnessDependentKeys).then(() => + refetchInactiveDailyReads(qc), + ); }; } diff --git a/src/components/measurements/list/__tests__/use-measurement-list-deletes-settle.test.ts b/src/components/measurements/list/__tests__/use-measurement-list-deletes-settle.test.ts new file mode 100644 index 000000000..2b62ccaba --- /dev/null +++ b/src/components/measurements/list/__tests__/use-measurement-list-deletes-settle.test.ts @@ -0,0 +1,98 @@ +/** + * The list's bulk delete and its Undo answer once the server has done it. + * + * Both used to wait for every read a measurement touches (charts, insights, + * the dashboard) before the selection bar cleared or the toast showed, so on + * a slow connection a delete looked like it had not happened. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const h = vi.hoisted(() => ({ + client: { + // A refetch that never answers, as on a slow connection. + invalidateQueries: vi.fn(() => new Promise(() => {})), + getQueryCache: () => ({ findAll: () => [] }), + }, + toastSuccess: vi.fn(), + apiPost: vi.fn(async () => ({ deleted: 2 })), +})); + +vi.mock("react", async (importOriginal) => ({ + ...(await importOriginal()), + useCallback: (fn: T) => fn, + useEffect: () => {}, +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: undefined, isLoading: false, isError: false }), + // The hook's options are what is under test; return them as they are. + useMutation: (options: unknown) => options, + useQueryClient: () => h.client, +})); + +vi.mock("@/lib/api/api-fetch", () => ({ + apiGet: vi.fn(), + apiDelete: vi.fn(), + apiPost: h.apiPost, +})); + +vi.mock("@/lib/i18n/context", () => ({ + useTranslations: () => ({ t: (key: string) => key }), +})); + +vi.mock("sonner", () => ({ toast: { success: h.toastSuccess } })); + +import type { MeasurementListFilters } from "../use-measurement-list-filters"; +import { useMeasurementListData } from "../use-measurement-list-data"; + +function useBulkDelete(clearSelection = vi.fn()) { + const filters = { + typeFilter: "ALL", + selectedIds: new Set(), + setSelectedIds: vi.fn(), + clearSelection, + } as unknown as MeasurementListFilters; + const data = useMeasurementListData({ + filters, + enabled: true, + lockedType: undefined, + }); + return data.bulkDeleteMutation as unknown as { + onSuccess: (deleted: number, ids: string[]) => unknown; + }; +} + +describe("measurement list deletes", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("clears the selection and says so without waiting for the refetch", () => { + const clearSelection = vi.fn(); + const bulk = useBulkDelete(clearSelection); + + expect(bulk.onSuccess(2, ["a", "b"])).toBeUndefined(); + expect(clearSelection).toHaveBeenCalled(); + expect(h.toastSuccess).toHaveBeenCalledWith( + "measurements.bulkDeleteSuccess", + expect.anything(), + ); + }); + + it("confirms the Undo once the rows are back, before the refetch", async () => { + useBulkDelete().onSuccess(2, ["a", "b"]); + const undo = h.toastSuccess.mock.calls[0]![1] as { + action: { onClick: () => void }; + }; + + undo.action.onClick(); + await vi.waitFor(() => + expect(h.toastSuccess).toHaveBeenLastCalledWith( + "measurements.restoredToast", + ), + ); + expect(h.apiPost).toHaveBeenLastCalledWith("/api/measurements/restore", { + ids: ["a", "b"], + }); + }); +}); diff --git a/src/components/measurements/list/use-measurement-list-data.ts b/src/components/measurements/list/use-measurement-list-data.ts index 81c2db1e9..472bdf88b 100644 --- a/src/components/measurements/list/use-measurement-list-data.ts +++ b/src/components/measurements/list/use-measurement-list-data.ts @@ -114,9 +114,9 @@ export function useMeasurementListData({ async (ids: string[]) => { try { await apiPost("/api/measurements/restore", { ids }); - await invalidateKeys(queryClient, measurementDependentKeys); - await refetchInactiveDailyReads(queryClient); toast.success(t("measurements.restoredToast")); + void invalidateKeys(queryClient, measurementDependentKeys); + void refetchInactiveDailyReads(queryClient); } catch (failure) { showErrorToast(t("measurements.restoreError"), { cause: failure }); } @@ -154,9 +154,11 @@ export function useMeasurementListData({ ); return data.deleted; }, - onSuccess: async (deleted, ids) => { - await invalidateKeys(queryClient, measurementDependentKeys); - await refetchInactiveDailyReads(queryClient); + // The refresh is started, not returned: the selection bar and the toast + // answer as soon as the rows are gone. + onSuccess: (deleted, ids) => { + void invalidateKeys(queryClient, measurementDependentKeys); + void refetchInactiveDailyReads(queryClient); clearSelection(); toast.success( deleted === 1 diff --git a/src/components/medications/__tests__/use-medication-intake.test.ts b/src/components/medications/__tests__/use-medication-intake.test.ts index 7d8d500d3..0e323f312 100644 --- a/src/components/medications/__tests__/use-medication-intake.test.ts +++ b/src/components/medications/__tests__/use-medication-intake.test.ts @@ -366,7 +366,41 @@ describe("runLogIntake — manual backdated intake from the Add choice", () => { takenAt: "2026-06-01T08:30:00.000Z", }); expect(body).not.toHaveProperty("scheduledFor"); - expect(queryClient.invalidateQueries).toHaveBeenCalled(); + // Started, not awaited (the case below). + await vi.waitFor(() => + expect(queryClient.invalidateQueries).toHaveBeenCalled(), + ); + expect(toast.success).toHaveBeenCalledWith( + "medications.intakeToastTaken:Mounjaro", + ); + }); + + it("answers once the dose is stored, without waiting for the refetches", async () => { + // The dialog closes on this answer; on a slow connection the reads it + // refreshes can take seconds. + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers(), + json: vi.fn(), + text: vi.fn().mockResolvedValue(""), + }), + ); + const queryClient = { + invalidateQueries: vi.fn(() => new Promise(() => {})), + getQueryCache: () => ({ findAll: () => [] }), + } as unknown as QueryClient; + + await expect( + runLogIntake({ + medication, + skipped: false, + takenAt: "2026-06-01T08:30:00.000Z", + t, + queryClient, + }), + ).resolves.toBe(true); expect(toast.success).toHaveBeenCalledWith( "medications.intakeToastTaken:Mounjaro", ); diff --git a/src/components/medications/dose-history-add-dialog.tsx b/src/components/medications/dose-history-add-dialog.tsx index 04789ed39..2f935152f 100644 --- a/src/components/medications/dose-history-add-dialog.tsx +++ b/src/components/medications/dose-history-add-dialog.tsx @@ -193,16 +193,14 @@ export function LedgerAddDialog({ } else { toast.success(message); } - await invalidateKeys(queryClient, [ - ...medicationDependentKeys, - ledgerKey, - ]); - // A ledger add can land a today-dated dose, so clear the unmounted - // Today hero + dashboard too (v1.32.19 — same class as the ledger's - // own log/edit/delete sites). - await refetchInactiveDailyReads(queryClient); setNudgeOpen(false); onOpenChange(false); + // Started, not awaited: the dialog closes once the dose is stored. A + // ledger add can land a today-dated dose, so clear the unmounted Today + // hero + dashboard too (same class as the ledger's own log/edit/delete + // sites). + void invalidateKeys(queryClient, [...medicationDependentKeys, ledgerKey]); + void refetchInactiveDailyReads(queryClient); } catch (failure) { showErrorToast( t("medications.intakeToastFailed", { name: medicationName }), diff --git a/src/components/medications/intake-edit-dialog.tsx b/src/components/medications/intake-edit-dialog.tsx index 48da1b6f4..7c55e6309 100644 --- a/src/components/medications/intake-edit-dialog.tsx +++ b/src/components/medications/intake-edit-dialog.tsx @@ -130,12 +130,14 @@ function IntakeEditDialogBody({ body.takenAt = null; } await apiPut(`/api/medications/${medicationId}/intake/${event.id}`, body); - // Editing today's dose (un-skip, retime) changes its due/taken state — - // route through the blessed helper so the Today hero + dashboard clear - // at once even when they are unmounted behind the detail page. - await invalidateMedicationReads(queryClient); toast.success(t("medications.detail.intake.edit.savedToast")); onClose(); + // Editing today's dose (un-skip, retime) changes its due/taken state — + // route through the blessed helper so the Today hero + dashboard clear + // at once even when they are unmounted behind the detail page. Started, + // not awaited: the edit names one row, so nothing here can be sent twice + // while the reads catch up. + void invalidateMedicationReads(queryClient); } catch (failure) { showErrorToast(t("medications.detail.intake.edit.failed"), { cause: failure, diff --git a/src/components/medications/use-medication-intake.ts b/src/components/medications/use-medication-intake.ts index 1bb3c72be..33f5d8446 100644 --- a/src/components/medications/use-medication-intake.ts +++ b/src/components/medications/use-medication-intake.ts @@ -281,7 +281,10 @@ export async function runLogIntake(deps: { body, ); const eventId = created?.id; - await invalidateMedicationReads(queryClient); + // Started, not awaited: the dialog closes on this answer. A scheduled + // dose lands on its slot, so a stale card tapped meanwhile only repeats + // it. + void invalidateMedicationReads(queryClient); const message = t( skipped ? "medications.intakeToastSkipped" diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index c9cc771d0..c811157e7 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -383,9 +383,9 @@ export function MoodList({ onAddFirst, onEmptyChange }: MoodListProps = {}) { async (ids: string[]) => { try { await apiPost("/api/mood-entries/restore", { ids }); - await invalidateKeys(queryClient, moodDependentKeys); - await refetchInactiveDailyReads(queryClient); toast.success(t("mood.restoredToast")); + void invalidateKeys(queryClient, moodDependentKeys); + void refetchInactiveDailyReads(queryClient); } catch (failure) { showErrorToast(t("mood.restoreError"), { cause: failure }); } @@ -425,9 +425,9 @@ export function MoodList({ onAddFirst, onEmptyChange }: MoodListProps = {}) { ); return data.deleted; }, - onSuccess: async (deleted, ids) => { - await invalidateKeys(queryClient, moodDependentKeys); - await refetchInactiveDailyReads(queryClient); + onSuccess: (deleted, ids) => { + void invalidateKeys(queryClient, moodDependentKeys); + void refetchInactiveDailyReads(queryClient); clearSelection(); toast.success( deleted === 1 @@ -506,12 +506,12 @@ export function MoodList({ onAddFirst, onEmptyChange }: MoodListProps = {}) { moodLoggedAt, }); }, - onSuccess: async (saved) => { + onSuccess: (saved) => { // A tag picked in the dialog may have been archived or deleted // elsewhere since it opened; the server names what it did not store. if (droppedAnyLinkKeys(saved)) toast.warning(t("mood.tagsNotSaved")); - await invalidateKeys(queryClient, moodDependentKeys); - await refetchInactiveDailyReads(queryClient); + void invalidateKeys(queryClient, moodDependentKeys); + void refetchInactiveDailyReads(queryClient); setEditSeed({ mood: editMood, tagsInput: editTagsInput, From e68041a3aff111c573646bf8414ed57616f37ef9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 21:39:19 +0100 Subject: [PATCH 04/10] fix(forms): keep one Idempotency-Key for the remaining create forms A screening, an illness episode, a life event, a symptom, a custom metric or an address-book entry created on a weak signal could be stored twice: the POST committed, the answer missed the timeout, and the second tap went out under a new key. Their routes already honour the key; the forms now post through useIdempotentPost like the other create forms, so the retry replays the first answer. The screening's save moves into a small hook beside MentalWellbeing, which sat at the function-size cap and now drops below it; the custom metric form's create-or-update call moves into one as well. --- .../baselines/source-function-lines.json | 5 +- .../custom-metrics/custom-metric-form.tsx | 23 +++++--- .../use-illness-save-settles.test.ts | 33 +++++++++++- src/components/illness/use-illness.ts | 6 ++- .../mental-health/mental-wellbeing.tsx | 52 ++++++++++++------- src/components/symptoms/use-symptoms.ts | 7 ++- src/components/timeline/use-timeline.ts | 8 ++- src/hooks/use-practitioners.ts | 6 ++- 8 files changed, 102 insertions(+), 38 deletions(-) diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 7ac0176fa..ec9a32f35 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": 110086, + "count": 338, + "total": 109885, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275, @@ -168,7 +168,6 @@ "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, - "components/mental-health/mental-wellbeing.tsx :: MentalWellbeing": 201, "components/mood/manage/tag-groups-card.tsx :: TagGroupsCard": 256, "components/mood/manage/tag-manager-card.tsx :: TagManagerCard": 270, "components/mood/mood-form.tsx :: MoodForm": 514, diff --git a/src/components/custom-metrics/custom-metric-form.tsx b/src/components/custom-metrics/custom-metric-form.tsx index ee599f7b1..e0cb0a053 100644 --- a/src/components/custom-metrics/custom-metric-form.tsx +++ b/src/components/custom-metrics/custom-metric-form.tsx @@ -10,7 +10,8 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; -import { ApiError, apiPatch, apiPost } from "@/lib/api/api-fetch"; +import { ApiError, apiPatch } from "@/lib/api/api-fetch"; +import { useIdempotentPost } from "@/lib/api/use-idempotent-post"; import { localizedApiError } from "@/lib/api/localized-error"; import { useTranslations } from "@/lib/i18n/context"; @@ -34,6 +35,18 @@ function parseInteger(raw: string): number | null { return Number.isInteger(n) && n >= 0 ? n : null; } +/** + * Update the metric, or define a new one under one Idempotency-Key until the + * create lands, so a retry after a timeout is not a second metric. + */ +function useSaveCustomMetric(existing: CustomMetricDto | undefined) { + const post = useIdempotentPost(); + return (body: unknown) => + existing + ? apiPatch(`/api/custom-metrics/${existing.id}`, body) + : post("/api/custom-metrics", body); +} + interface CustomMetricFormProps { /** When set the form edits this metric; otherwise it defines a new one. */ existing?: CustomMetricDto; @@ -80,6 +93,7 @@ export function CustomMetricForm({ ); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); + const save = useSaveCustomMetric(existing); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); @@ -110,12 +124,7 @@ export function CustomMetricForm({ setSubmitting(true); try { - const saved = existing - ? await apiPatch( - `/api/custom-metrics/${existing.id}`, - body, - ) - : await apiPost("/api/custom-metrics", body); + const saved = await save(body); toast.success( existing ? t("customMetrics.form.updatedToast") diff --git a/src/components/illness/__tests__/use-illness-save-settles.test.ts b/src/components/illness/__tests__/use-illness-save-settles.test.ts index d5212fe30..59a4b91c7 100644 --- a/src/components/illness/__tests__/use-illness-save-settles.test.ts +++ b/src/components/illness/__tests__/use-illness-save-settles.test.ts @@ -1,9 +1,11 @@ /** - * An episode save settles once the server stored it. + * An episode save settles once the server stored it, and stores it once. * * Every episode hook's `onSuccess` used to return the refresh of the illness * reads and the dashboard. A mutation waits for what `onSuccess` returns, so * the episode sheet kept its spinner until each of those reads had refetched. + * And a create retried after a timeout went out under a new key, so the + * server stored the episode twice. */ import { describe, expect, it, vi } from "vitest"; @@ -14,6 +16,11 @@ const client = vi.hoisted(() => ({ getQueryCache: () => ({ findAll: () => [] }), })); +vi.mock("react", async (importOriginal) => ({ + ...(await importOriginal()), + useRef: (initial: T) => ({ current: initial }), +})); + vi.mock("@tanstack/react-query", () => ({ // The hook's options are what is under test; return them as they are. useQuery: (options: unknown) => options, @@ -29,6 +36,7 @@ vi.mock("@/lib/api/api-fetch", () => ({ apiDelete: vi.fn(), })); +import { apiPost } from "@/lib/api/api-fetch"; import { useCreateEpisode, useDeleteEpisode, @@ -54,3 +62,26 @@ describe("episode saves", () => { expect(client.invalidateQueries).toHaveBeenCalled(); }); }); + +describe("creating an episode", () => { + it("retries under the same Idempotency-Key and starts the next one fresh", async () => { + const create = useCreateEpisode() as unknown as { + mutationFn: (input: unknown) => Promise; + }; + const post = vi.mocked(apiPost); + post.mockRejectedValueOnce(new TypeError("timed out")); + post.mockResolvedValue({ id: "e1" }); + const keyOf = (call: number) => + new Headers((post.mock.calls[call]![2] as RequestInit).headers).get( + "Idempotency-Key", + ); + + await expect(create.mutationFn({ name: "Flu" })).rejects.toThrow(); + await create.mutationFn({ name: "Flu" }); + await create.mutationFn({ name: "Cold" }); + + expect(keyOf(0)).toBeTruthy(); + expect(keyOf(1)).toBe(keyOf(0)); + expect(keyOf(2)).not.toBe(keyOf(0)); + }); +}); diff --git a/src/components/illness/use-illness.ts b/src/components/illness/use-illness.ts index f333c04ea..c0ed5d8d7 100644 --- a/src/components/illness/use-illness.ts +++ b/src/components/illness/use-illness.ts @@ -17,6 +17,7 @@ import { apiPatch, apiPost, } from "@/lib/api/api-fetch"; +import { useIdempotentPost } from "@/lib/api/use-idempotent-post"; import { illnessDependentKeys, invalidateKeys, @@ -164,9 +165,12 @@ function useInvalidateIllness() { /** Create an episode. */ export function useCreateEpisode() { const invalidate = useInvalidateIllness(); + // One key until a save lands, so a retry after a timeout is not a second + // episode. + const post = useIdempotentPost(); return useMutation({ mutationFn: (input: IllnessEpisodeCreateInput) => - apiPost("/api/illness/episodes", input), + post("/api/illness/episodes", input), onSuccess: invalidate, }); } diff --git a/src/components/mental-health/mental-wellbeing.tsx b/src/components/mental-health/mental-wellbeing.tsx index 0cdebd557..88a14a5b6 100644 --- a/src/components/mental-health/mental-wellbeing.tsx +++ b/src/components/mental-health/mental-wellbeing.tsx @@ -31,7 +31,8 @@ import { useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useTranslations } from "@/lib/i18n/context"; -import { apiGetList, apiPost } from "@/lib/api/api-fetch"; +import { apiGetList } from "@/lib/api/api-fetch"; +import { useIdempotentPost } from "@/lib/api/use-idempotent-post"; import { invalidateKeys, queryKeys, @@ -57,9 +58,35 @@ import type { } from "./types"; import { ListCapNotice } from "@/components/ui/list-cap-notice"; +/** + * The check-in's save, under one Idempotency-Key until it lands, so a second + * tap after a timeout replays the stored screening instead of adding one. + */ +function useSubmitAssessment(onSaved: (data: CreateResponse) => void) { + const queryClient = useQueryClient(); + const post = useIdempotentPost(); + return useMutation({ + mutationFn: (body: unknown) => + post("/api/mental-health/assessments", body), + onSuccess: (data) => { + onSaved(data); + // A screening is listed on its day, and it can satisfy a due checkup + // reminder, so the checkups list and the Today rail must repaint. + // Honest constraint: the server satisfy runs async via pg-boss, so + // this invalidation narrows the stale window but cannot close it — a + // refetch racing the job may still see the pre-satisfy row. + void invalidateKeys(queryClient, [ + queryKeys.mentalHealthAssessments(), + queryKeys.measurementReminders(), + queryKeys.dayRoot(), + ]); + void refetchInactiveDailyReads(queryClient); + }, + }); +} + export function MentalWellbeing() { const { t, locale } = useTranslations(); - const queryClient = useQueryClient(); const [phase, setPhase] = useState("choose"); const [instrument, setInstrument] = useState("PHQ9"); @@ -94,24 +121,9 @@ export function MentalWellbeing() { }); const history = historyPage?.data; - const mutation = useMutation({ - mutationFn: (body: unknown) => - apiPost("/api/mental-health/assessments", body), - onSuccess: (data) => { - setResult(data); - setPhase("result"); - // A screening is listed on its day, and it can satisfy a due checkup - // reminder, so the checkups list and the Today rail must repaint. - // Honest constraint: the server satisfy runs async via pg-boss, so - // this invalidation narrows the stale window but cannot close it — a - // refetch racing the job may still see the pre-satisfy row. - void invalidateKeys(queryClient, [ - queryKeys.mentalHealthAssessments(), - queryKeys.measurementReminders(), - queryKeys.dayRoot(), - ]); - void refetchInactiveDailyReads(queryClient); - }, + const mutation = useSubmitAssessment((data) => { + setResult(data); + setPhase("result"); }); // Most-recent assessment per instrument for the landing's "last result" line. diff --git a/src/components/symptoms/use-symptoms.ts b/src/components/symptoms/use-symptoms.ts index 286cdaed1..a5f3094d7 100644 --- a/src/components/symptoms/use-symptoms.ts +++ b/src/components/symptoms/use-symptoms.ts @@ -16,7 +16,7 @@ import { type QueryClient, } from "@tanstack/react-query"; -import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch"; +import { apiDelete, apiGet, apiPatch } from "@/lib/api/api-fetch"; import { useIdempotentPost } from "@/lib/api/use-idempotent-post"; import { invalidateKeys, queryKeys } from "@/lib/query-keys"; import type { @@ -147,9 +147,12 @@ function useInvalidateSymptoms() { export function useCreateSymptomDefinition() { const invalidate = useInvalidateSymptoms(); + // One key until a save lands, so a retry after a timeout is not a second + // symptom. + const post = useIdempotentPost(); return useMutation({ mutationFn: (input: { label: string; icon?: string | null }) => - apiPost("/api/symptoms/definitions", input), + post("/api/symptoms/definitions", input), onSuccess: invalidate, }); } diff --git a/src/components/timeline/use-timeline.ts b/src/components/timeline/use-timeline.ts index e3a49e242..ab0aaae45 100644 --- a/src/components/timeline/use-timeline.ts +++ b/src/components/timeline/use-timeline.ts @@ -10,7 +10,8 @@ */ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch"; +import { apiDelete, apiGet, apiPatch } from "@/lib/api/api-fetch"; +import { useIdempotentPost } from "@/lib/api/use-idempotent-post"; import type { LifeEventCreateInput, LifeEventDTO, @@ -75,9 +76,12 @@ export function useLifeEventMutations() { const invalidate = () => invalidateKeys(qc, [queryKeys.timelineRoot(), queryKeys.dayRoot()]); + // One key until a save lands, so a retry after a timeout is not a second + // event. + const post = useIdempotentPost(); const create = useMutation({ mutationFn: (body: LifeEventCreateInput) => - apiPost("/api/life-events", body), + post("/api/life-events", body), onSuccess: invalidate, }); const update = useMutation({ diff --git a/src/hooks/use-practitioners.ts b/src/hooks/use-practitioners.ts index 3fdd4145e..3e9bedd26 100644 --- a/src/hooks/use-practitioners.ts +++ b/src/hooks/use-practitioners.ts @@ -11,6 +11,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { apiDelete, apiGetList, apiPatch, apiPost } from "@/lib/api/api-fetch"; +import { useIdempotentPost } from "@/lib/api/use-idempotent-post"; import { encounterDependentKeys, invalidateKeys, @@ -70,10 +71,11 @@ export function usePractitionerMutations() { refetchInactiveDailyReads(qc), ]); + // One key for as long as the sheet that mounts this is open. + const post = useIdempotentPost(); const create = useMutation({ mutationKey: queryKeys.practitionerCreate(), - mutationFn: (body: PractitionerWriteBody) => - apiPost(BASE, body), + mutationFn: (body: PractitionerWriteBody) => post(BASE, body), onSuccess: invalidate, }); From b5c97062b85c6ba0a1e85709acd87996d2f877c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 21:40:02 +0100 Subject: [PATCH 05/10] fix(timeline): close the life event sheet before the timeline refetch The life event hooks returned the timeline and day refresh from onSuccess, which a mutation waits for, so saving or removing an event kept the sheet spinning until the whole timeline had reloaded. The refresh is now started and not returned, like the other record sheets. --- .../use-life-event-mutations.test.ts | 42 +++++++++++++++++++ src/components/timeline/use-timeline.ts | 4 +- 2 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 src/components/timeline/__tests__/use-life-event-mutations.test.ts diff --git a/src/components/timeline/__tests__/use-life-event-mutations.test.ts b/src/components/timeline/__tests__/use-life-event-mutations.test.ts new file mode 100644 index 000000000..8a973de6c --- /dev/null +++ b/src/components/timeline/__tests__/use-life-event-mutations.test.ts @@ -0,0 +1,42 @@ +/** + * A life event save settles once the server stored it. + * + * The hooks returned the timeline refresh from `onSuccess`, which a mutation + * waits for, so the sheet kept its spinner until the timeline had refetched. + */ +import { describe, expect, it, vi } from "vitest"; + +const client = vi.hoisted(() => ({ + // A refetch that never answers, as on a slow connection. + invalidateQueries: vi.fn(() => new Promise(() => {})), + getQueryCache: () => ({ findAll: () => [] }), +})); + +vi.mock("react", async (importOriginal) => ({ + ...(await importOriginal()), + useRef: (initial: T) => ({ current: initial }), +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: (options: unknown) => options, + // The hook's options are what is under test; return them as they are. + useMutation: (options: unknown) => options, + useQueryClient: () => client, +})); + +import { useLifeEventMutations } from "../use-timeline"; + +describe("life event saves", () => { + it.each(["create", "update", "remove"] as const)( + "%s starts the refresh and does not wait for it", + (name) => { + client.invalidateQueries.mockClear(); + const options = useLifeEventMutations()[name] as unknown as { + onSuccess: () => unknown; + }; + + expect(options.onSuccess()).toBeUndefined(); + expect(client.invalidateQueries).toHaveBeenCalled(); + }, + ); +}); diff --git a/src/components/timeline/use-timeline.ts b/src/components/timeline/use-timeline.ts index ab0aaae45..0db31301f 100644 --- a/src/components/timeline/use-timeline.ts +++ b/src/components/timeline/use-timeline.ts @@ -73,8 +73,10 @@ export function useLifeEvents(enabled = true) { export function useLifeEventMutations() { const qc = useQueryClient(); + // Not returned, so the sheet closes without waiting for the timeline to + // refetch. const invalidate = () => - invalidateKeys(qc, [queryKeys.timelineRoot(), queryKeys.dayRoot()]); + void invalidateKeys(qc, [queryKeys.timelineRoot(), queryKeys.dayRoot()]); // One key until a save lands, so a retry after a timeout is not a second // event. From b85989f3dfd97d05d6804be0723830c06fb8b31d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 21:41:39 +0100 Subject: [PATCH 06/10] fix(coach): spell the bubble's initials from the display name The person's chat bubble took the first two letters of the login, so a managed record read "MA" for its generated handle and everybody else saw other letters than in the top bar. It now uses nameInitials(accountName) like the top bar, the sidebar and the settings avatar. The thread test that pinned the login's two letters now expects the shared spelling. --- .../__tests__/chat-bubble-initials.test.tsx | 52 +++++++++++++++++++ .../__tests__/message-thread.test.tsx | 5 +- .../insights/coach-panel/chat-bubble.tsx | 9 ++-- 3 files changed, 59 insertions(+), 7 deletions(-) create mode 100644 src/components/insights/coach-panel/__tests__/chat-bubble-initials.test.tsx diff --git a/src/components/insights/coach-panel/__tests__/chat-bubble-initials.test.tsx b/src/components/insights/coach-panel/__tests__/chat-bubble-initials.test.tsx new file mode 100644 index 000000000..badd7f82c --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/chat-bubble-initials.test.tsx @@ -0,0 +1,52 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +/** + * The person's bubble falls back to initials of the name the app shows, like + * the top bar, never the login: a managed record's login is a generated + * handle, and "MA" for `managed-…` is nobody's initials. + */ +const auth = vi.hoisted(() => ({ + user: { + id: "u1", + username: "managed-1f2e3d", + displayName: "Alex Example" as string | null, + role: "USER", + avatarUrl: null, + }, +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ user: auth.user, isAuthenticated: true, isLoading: false }), +})); +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: undefined }), + useMutation: () => ({ mutate: vi.fn(), isPending: false, isError: false }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), +})); + +import { ChatBubble } from "../chat-bubble"; + +function userBubble(): string { + return renderToStaticMarkup( + + + , + ); +} + +describe("coach bubble initials", () => { + it("spells the display name, not the login", () => { + const html = userBubble(); + expect(html).toContain(">AE<"); + expect(html).not.toContain(">MA<"); + }); + + it("falls back to the login when there is no display name", () => { + auth.user.displayName = null; + auth.user.username = "sam"; + expect(userBubble()).toContain(">S<"); + }); +}); diff --git a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx index f26660ff9..9c28c40b1 100644 --- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx +++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx @@ -551,10 +551,11 @@ describe("", () => { }); it("falls back to initials when no avatar URL is present", () => { - // Default mock returns avatarUrl: null → initials path. + // Default mock returns avatarUrl: null → initials path, spelled like the + // top bar's: one word, one letter. const html = render(); expect(html).toContain('data-slot="coach-bubble-user-avatar"'); - expect(html).toMatch(/data-slot="coach-bubble-user-avatar"[^>]*>TE]*>T Date: Wed, 11 Nov 2026 21:43:07 +0100 Subject: [PATCH 07/10] fix(charts): draw the BMI of the record on screen Inside a shared or managed record the BMI chart drew nothing: it only knew the viewer's height, and dividing someone else's weights by it would have given a plausible wrong figure, so it divided by nothing. The chart now takes the record's height from /api/insights/targets, the cell the BMI page and the reference panel already read, through a small useBmiDivisor hook; in the viewer's own record it keeps using the account's height. --- .../charts/__tests__/use-bmi-divisor.test.ts | 59 +++++++++++++++++++ src/components/charts/health-chart.tsx | 12 +--- .../charts/health-chart/use-bmi-divisor.ts | 36 +++++++++++ 3 files changed, 97 insertions(+), 10 deletions(-) create mode 100644 src/components/charts/__tests__/use-bmi-divisor.test.ts create mode 100644 src/components/charts/health-chart/use-bmi-divisor.ts diff --git a/src/components/charts/__tests__/use-bmi-divisor.test.ts b/src/components/charts/__tests__/use-bmi-divisor.test.ts new file mode 100644 index 000000000..1216d08eb --- /dev/null +++ b/src/components/charts/__tests__/use-bmi-divisor.test.ts @@ -0,0 +1,59 @@ +/** + * The BMI chart divides the weights on screen by the height of the record on + * screen. It used to know only the viewer's height, so inside another + * person's record it drew nothing at all. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const h = vi.hoisted(() => ({ + inSharedRecord: false, + recordHeightCm: 120 as number | null, + enabled: [] as boolean[], +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ isAuthenticated: true, user: { heightCm: 180 } }), +})); +vi.mock("@/hooks/use-record-capabilities", () => ({ + useRecordCapabilities: () => ({ inSharedRecord: h.inSharedRecord }), +})); +vi.mock("@tanstack/react-query", () => ({ + useQuery: (options: { enabled: boolean }) => { + h.enabled.push(options.enabled); + return options.enabled + ? { data: { profile: { heightCm: h.recordHeightCm } } } + : { data: undefined }; + }, +})); + +import { useBmiDivisor } from "../health-chart/use-bmi-divisor"; + +describe("useBmiDivisor", () => { + beforeEach(() => { + h.inSharedRecord = false; + h.recordHeightCm = 120; + h.enabled.length = 0; + }); + + it("uses the viewer's own height in their own record", () => { + expect(useBmiDivisor("bmi")).toBeCloseTo(3.24); + expect(h.enabled).toEqual([false]); + }); + + it("uses the record's height inside somebody else's record", () => { + h.inSharedRecord = true; + expect(useBmiDivisor("bmi")).toBeCloseTo(1.44); + }); + + it("draws no BMI when the record has no height", () => { + h.inSharedRecord = true; + h.recordHeightCm = null; + expect(useBmiDivisor("bmi")).toBeNull(); + }); + + it("asks for nothing outside the BMI view", () => { + h.inSharedRecord = true; + expect(useBmiDivisor("raw")).toBeNull(); + expect(h.enabled).toEqual([false]); + }); +}); diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 4761e40c6..49909cebf 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -19,7 +19,6 @@ import { import { Skeleton } from "@/components/ui/skeleton"; import { useAuth } from "@/hooks/use-auth"; import { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs"; -import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { useViewportWidth } from "@/hooks/use-viewport-width"; import { axisTickUnitSuffix } from "@/lib/charts/axis-unit"; import { makeBucketLabelFormatters } from "@/lib/charts/bucket-label"; @@ -61,6 +60,7 @@ import { useHealthChartLabels, useHealthChartOverlays, } from "./health-chart/use-health-chart-overlays"; +import { useBmiDivisor } from "./health-chart/use-bmi-divisor"; import { useHealthChartSeries } from "./health-chart/use-health-chart-series"; import { useHealthChartView } from "./health-chart/use-health-chart-view"; import { resolveMiniRangePoints } from "./mini-window"; @@ -157,15 +157,7 @@ export function HealthChart({ // Viewport-aware tick density: 4 to 10 labels, whatever the point count. const viewportWidth = useViewportWidth(); - // `useAuth()` is the person at the keyboard, and under a switch that is the - // delegate: dividing the owner's weights by the delegate's height squared - // gives a plausible BMI that is somebody else's. Inside a shared record the - // chart draws no BMI rather than a wrong one. - const { inSharedRecord } = useRecordCapabilities(); - const bmiDivisor = - valueMode === "bmi" && !inSharedRecord && user?.heightCm - ? (user.heightCm / 100) ** 2 - : null; + const bmiDivisor = useBmiDivisor(valueMode); const { data, isLoading, isError, error, isPlaceholderData, refetch } = useHealthChartSeries({ diff --git a/src/components/charts/health-chart/use-bmi-divisor.ts b/src/components/charts/health-chart/use-bmi-divisor.ts new file mode 100644 index 000000000..520e7e21c --- /dev/null +++ b/src/components/charts/health-chart/use-bmi-divisor.ts @@ -0,0 +1,36 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import { useAuth } from "@/hooks/use-auth"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; +import { apiGet } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; + +/** + * The BMI view's divisor: the height of the record on screen, in metres, + * squared. `useAuth()` is the person at the keyboard, and under a switch that + * is the delegate, so inside somebody else's record the height comes from + * `/api/insights/targets`, the route that knows which record is on screen + * (the BMI page and the reference panel read the same cell). No height, no + * BMI. + */ +export function useBmiDivisor( + valueMode: "raw" | "bmi" | undefined, +): number | null { + const { isAuthenticated, user } = useAuth(); + const { inSharedRecord } = useRecordCapabilities(); + const { data: targets } = useQuery({ + queryKey: queryKeys.insightsTargets(), + queryFn: () => + apiGet<{ profile?: { heightCm: number | null } }>( + "/api/insights/targets", + ), + enabled: isAuthenticated && valueMode === "bmi" && inSharedRecord, + }); + if (valueMode !== "bmi") return null; + const heightCm = inSharedRecord + ? (targets?.profile?.heightCm ?? null) + : (user?.heightCm ?? null); + return heightCm ? (heightCm / 100) ** 2 : null; +} From aacf06b7b3194fa56c05e7aaa28b1b718b878df1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 21:44:42 +0100 Subject: [PATCH 08/10] fix(coach): say when the attachment picker list is cut The Coach's "Choose from documents" picker reads the newest fifty documents and dropped the route's nextCursor, so with a longer vault an older report seemed not to exist unless one already knew to search for it. A cut list now ends with the same line the document picker shows: how many are shown and that the search reaches the rest. The document row moves into its own component, which takes AttachmentPicker under the function-size cap. --- .../baselines/source-function-lines.json | 5 +- .../client-fetch-limit-guard.test.ts | 2 +- .../__tests__/attachment-picker-cap.test.tsx | 71 +++++++++++ .../coach-panel/attachment-picker.tsx | 115 +++++++++++------- 4 files changed, 145 insertions(+), 48 deletions(-) create mode 100644 src/components/insights/coach-panel/__tests__/attachment-picker-cap.test.tsx diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index ec9a32f35..7b03fc37e 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": 338, - "total": 109885, + "count": 337, + "total": 109677, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275, @@ -99,7 +99,6 @@ "components/documents/sources/document-source-picker.tsx :: DocumentSourcePicker": 563, "components/illness/log-day-sheet.tsx :: LogDaySheet": 265, "components/illness/new-episode-sheet.tsx :: NewEpisodeSheet": 264, - "components/insights/coach-panel/attachment-picker.tsx :: AttachmentPicker": 208, "components/insights/coach-panel/chat-bubble.tsx :: ChatBubbleImpl": 419, "components/insights/coach-panel/coach-conversation.tsx :: CoachConversation": 1002, "components/insights/coach-panel/coach-input.tsx :: CoachInput": 259, diff --git a/src/__tests__/client-fetch-limit-guard.test.ts b/src/__tests__/client-fetch-limit-guard.test.ts index 514275f8b..13eae31a4 100644 --- a/src/__tests__/client-fetch-limit-guard.test.ts +++ b/src/__tests__/client-fetch-limit-guard.test.ts @@ -77,7 +77,7 @@ const ALLOWLIST: readonly Allowed[] = [ file: "components/insights/coach-panel/attachment-picker.tsx", limit: 50, reason: - "Search-backed picker: the query narrows the whole document vault server-side.", + "Search-backed picker: the query narrows the whole document vault server-side, and a cut list says so and points to the search.", }, { file: "components/documents/document-picker-sheet.tsx", diff --git a/src/components/insights/coach-panel/__tests__/attachment-picker-cap.test.tsx b/src/components/insights/coach-panel/__tests__/attachment-picker-cap.test.tsx new file mode 100644 index 000000000..a4d117b05 --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/attachment-picker-cap.test.tsx @@ -0,0 +1,71 @@ +/** + * The Coach's "Choose from documents" picker reads one page of the vault, the + * newest fifty. With a longer vault it listed those and said nothing, so an + * older report seemed not to exist unless one already knew to search for it. + * + * ResponsiveSheet portals its body, which static markup does not render, so it + * is mocked down to a wrapper that always shows its children. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +const page = vi.hoisted(() => ({ nextCursor: null as string | null })); + +vi.mock("@/components/ui/responsive-sheet", () => ({ + ResponsiveSheet: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ + data: { + // A cut page is a full one. + documents: Array.from({ length: page.nextCursor ? 50 : 3 }, (_, i) => ({ + id: `d${i}`, + kind: "LAB_RESULT", + title: `Blood panel ${i}`, + filename: null, + hasContentIndex: true, + })), + nextCursor: page.nextCursor, + }, + isPending: false, + isError: false, + }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { AttachmentPicker } from "../attachment-picker"; + +function render() { + return renderToStaticMarkup( + + undefined} + remainingSlots={3} + excludeIds={[]} + onConfirm={() => undefined} + /> + , + ); +} + +describe("AttachmentPicker", () => { + it("says the list is cut, and how to reach the rest", () => { + page.nextCursor = "d49"; + const html = render(); + expect(html).toContain('data-slot="coach-attachment-picker-capped"'); + expect(html).toContain("Showing the newest 50"); + }); + + it("says nothing when every document is listed", () => { + page.nextCursor = null; + const html = render(); + expect(html).not.toContain('data-slot="coach-attachment-picker-capped"'); + expect( + html.match(/data-slot="coach-attachment-picker-item"/g), + ).toHaveLength(3); + }); +}); diff --git a/src/components/insights/coach-panel/attachment-picker.tsx b/src/components/insights/coach-panel/attachment-picker.tsx index e733d825f..b86fd9fa1 100644 --- a/src/components/insights/coach-panel/attachment-picker.tsx +++ b/src/components/insights/coach-panel/attachment-picker.tsx @@ -45,6 +45,58 @@ export interface AttachmentPickerProps { onConfirm: (ids: string[]) => void; } +/** One document in the picker: a toggle, disabled until it is indexed. */ +function PickerRow({ + doc, + isSelected, + disabled, + onToggle, +}: { + doc: InboundDocumentDto; + isSelected: boolean; + disabled: boolean; + onToggle: () => void; +}) { + const { t } = useTranslations(); + const title = doc.title ?? doc.filename ?? t("documents.card.untitled"); + const Icon = DOCUMENT_KIND_ICONS[doc.kind]; + return ( +
  • + +
  • + ); +} + export function AttachmentPicker({ open, onOpenChange, @@ -107,6 +159,8 @@ export function AttachmentPicker({ ); const capReached = selected.length >= remainingSlots; + // More match than one page: say so, and that the search reaches the rest. + const cut = Boolean(list.data?.nextCursor); function toggle(id: string) { setSelected((prev) => { @@ -200,55 +254,28 @@ export function AttachmentPicker({
      {documents.map((doc) => { const isSelected = selected.includes(doc.id); - const selectable = doc.hasContentIndex; - const disabled = !selectable || (!isSelected && capReached); - const title = - doc.title ?? doc.filename ?? t("documents.card.untitled"); - const Icon = DOCUMENT_KIND_ICONS[doc.kind]; return ( -
    • - -
    • + toggle(doc.id)} + /> ); })}
    )} + {cut && !list.isError ? ( +

    + {t("documents.picker.capped", { + count: String(list.data?.documents.length ?? 0), + })} +

    + ) : null} ); From ce377ef56d26b13167460292cb1acf0ec6c8e678 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 21:47:54 +0100 Subject: [PATCH 09/10] chore: remove leftovers of the deleted onboarding AI offer and badge The end-to-end setup still seeded the shared-provider offer's account and captured its session, although the journey that used it is gone. Three comments still pointed at what was removed: the tour launcher cited the deleted personal-record badge as its model, the personal record worker said the badge still renders, and the morning briefing push said a concerning reading goes out as an anomaly notification, which nothing sends. --- e2e/setup/global-setup.ts | 21 ------------------- src/components/onboarding/tour-launcher.tsx | 3 +-- src/lib/daily/daily-briefing-push.ts | 6 +++--- .../personal-records/pr-detection-worker.ts | 4 ++-- 4 files changed, 6 insertions(+), 28 deletions(-) diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 355535f32..087d71406 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -717,21 +717,6 @@ export const E2E_SETUP_LOCALE = { role: "USER", } as const; -/** - * v1.38.19 — the shared-provider offer's own account. - * - * Its journey seeds INSTANCE-wide state (the operator key's presence and the - * provider-health ledger the tri-state folds) and mints a consent receipt on - * the account, so it cannot share one with a journey that asserts a module - * map or a tile order. - */ -export const E2E_SETUP_AI_OFFER = { - email: "e2e-setup-ai-offer@healthlog.test", - username: "e2e-setup-ai-offer", - password: "Ao6!Zd3fB9qMv2Rk", - role: "USER", -} as const; - export const SETUP_BP_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateSetupBp.json", @@ -760,10 +745,6 @@ export const SETUP_LOCALE_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateSetupLocale.json", ); -export const SETUP_AI_OFFER_STORAGE_STATE_PATH = resolve( - process.cwd(), - "e2e/setup/storageStateSetupAiOffer.json", -); const SETUP_FLOW_ACCOUNTS = [ E2E_SETUP_BP, @@ -772,7 +753,6 @@ const SETUP_FLOW_ACCOUNTS = [ E2E_SETUP_CHILD, E2E_SETUP_A11Y, E2E_SETUP_LOCALE, - E2E_SETUP_AI_OFFER, E2E_SETUP_CONNECT, ] as const; @@ -1882,7 +1862,6 @@ export default async function globalSetup(config: FullConfig): Promise { await capture(E2E_SETUP_A11Y, SETUP_A11Y_STORAGE_STATE_PATH); await capture(E2E_SETUP_LOCALE, SETUP_LOCALE_STORAGE_STATE_PATH); await capture(E2E_SETUP_CONNECT, SETUP_CONNECT_STORAGE_STATE_PATH); - await capture(E2E_SETUP_AI_OFFER, SETUP_AI_OFFER_STORAGE_STATE_PATH); await capture(E2E_SETUP_CHILD, SETUP_CHILD_STORAGE_STATE_PATH); await pool.query( `UPDATE users SET totp_confirmed_at = $2 WHERE username = $1`, diff --git a/src/components/onboarding/tour-launcher.tsx b/src/components/onboarding/tour-launcher.tsx index 71786dbde..77205fd6d 100644 --- a/src/components/onboarding/tour-launcher.tsx +++ b/src/components/onboarding/tour-launcher.tsx @@ -244,8 +244,7 @@ export function TourLauncher({ ready = true }: TourLauncherProps) { // v1.4.47 — clock reading for the 24 h auto-launch gate. The // useState lazy-initialiser runs once at mount; subsequent re-renders // reuse the same value. This keeps the render path pure (React's - // purity linter rejects a bare `Date.now()` reference in render) and - // matches the established pattern in `personal-record-badge.tsx`: + // purity linter rejects a bare `Date.now()` reference in render): // capture the clock at a stable boundary, then derive freshness from // it. The launcher only consults this value once per (user, flag) // tuple via the `decidedFor` guard, so a mount that takes ten diff --git a/src/lib/daily/daily-briefing-push.ts b/src/lib/daily/daily-briefing-push.ts index 1fa56dab9..16047602a 100644 --- a/src/lib/daily/daily-briefing-push.ts +++ b/src/lib/daily/daily-briefing-push.ts @@ -22,9 +22,9 @@ * signal's headline without a figure in it, "your score is ready", or the * honest all-clear — never a reading, a dose or the score itself, since the * push lands on a locked phone). It is dispatched NON-URGENT, so it never escalates to a - * Focus-bypassing / time-sensitive delivery — a concerning reading always stays - * on `MEASUREMENT_ANOMALY`, never here. It fires ONLY inside a local morning - * window, which is the quiet-hours guarantee for this nudge. + * Focus-bypassing / time-sensitive delivery, and a concerning reading is never + * pushed through it. It fires ONLY inside a local morning window, which is the + * quiet-hours guarantee for this nudge. */ import { SESSION_USER_SELECT, type SessionUser } from "@/lib/auth/session-user"; import type { PrismaClient } from "@/generated/prisma/client"; diff --git a/src/lib/personal-records/pr-detection-worker.ts b/src/lib/personal-records/pr-detection-worker.ts index e2839662a..77074822d 100644 --- a/src/lib/personal-records/pr-detection-worker.ts +++ b/src/lib/personal-records/pr-detection-worker.ts @@ -79,8 +79,8 @@ export interface DetectPersonalRecordsOptions { * Set when a batch ingest crosses the historical-backfill threshold * — a multi-year Apple Health import shouldn't fire hundreds of * pushes during initial sync. The flag rides through to the - * dispatcher; the row is still written, the badge still renders, the - * doctor report still picks it up. + * dispatcher; the row is still written and the doctor report still + * picks it up. */ silent?: boolean; /** From 0d41abcda4e243cb987ba43cb4daa21579e359d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 21:50:28 +0100 Subject: [PATCH 10/10] fix(settings): list the Coach entry only while the Coach can answer The settings nav filtered its Coach entry by the raw module map, which follows the operator's switch and Hide Coach but not a missing provider or missing consent, so it kept an entry the sidebar and the bottom bar had already dropped. It now reads useNavModules like them. Settings, AI decides from the same map whether to list the stored Coach memory, so the memory always has one place in Settings where it can be read and deleted. --- .../coach-entry-follows-capability.test.tsx | 89 +++++++++++++++++++ src/components/settings/ai-section.tsx | 12 +-- src/components/settings/settings-shell.tsx | 7 +- 3 files changed, 99 insertions(+), 9 deletions(-) create mode 100644 src/components/settings/__tests__/coach-entry-follows-capability.test.tsx diff --git a/src/components/settings/__tests__/coach-entry-follows-capability.test.tsx b/src/components/settings/__tests__/coach-entry-follows-capability.test.tsx new file mode 100644 index 000000000..c30b49e8c --- /dev/null +++ b/src/components/settings/__tests__/coach-entry-follows-capability.test.tsx @@ -0,0 +1,89 @@ +/** + * Settings lists the Coach entry only while the Coach can answer, like the + * sidebar and the bottom bar, and Settings → AI then keeps the stored Coach + * memory in reach. + * + * Both used to read the raw module map, which answers the operator's switch + * and Hide Coach but not a missing provider or missing consent: the settings + * nav kept a Coach entry the sidebar had already dropped. The two now read + * the same map (`useNavModules`), so the memory's own entry and the fallback + * under Settings → AI cannot both go. + * + * The shell's module filter runs after hydration only, so `useMounted` is + * mocked to the hydrated answer. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +const h = vi.hoisted(() => ({ coachAvailable: true })); + +vi.mock("next/navigation", () => ({ + usePathname: () => "/settings/ai", + useRouter: () => ({ replace: () => {}, push: () => {} }), +})); +vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true })); +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1", role: "USER", modules: { coach: true } }, + isAuthenticated: true, + isLoading: false, + refetch: vi.fn(), + }), +})); +vi.mock("@/hooks/use-ai-capability", () => ({ + useAiCapability: () => ({ + available: h.coachAvailable, + reason: h.coachAvailable ? null : "no_provider", + onDeviceAllowed: true, + }), +})); +// Settings → AI's cards have their own suites; only the memory slot is +// under test here. +vi.mock("@/components/ai/ai-consent-request", () => ({ + AiConsentRequest: () => null, +})); +vi.mock("@/components/settings/ai/ai-insights-card", () => ({ + AiInsightsCard: () => null, +})); +vi.mock("@/components/settings/ai/operator-off-notice", () => ({ + OperatorOffNotice: () => null, +})); +vi.mock("@/components/insights/ai-setup-hint", () => ({ + AiSetupHint: () => null, +})); +vi.mock("@/components/settings/coach-memory-section", () => ({ + StoredCoachMemory: () =>
    , +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { AiSection } from "../ai-section"; +import { SettingsShell } from "../settings-shell"; + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +describe("the Coach entry in Settings", () => { + beforeEach(() => { + h.coachAvailable = true; + }); + + it("is listed while the Coach can answer, and the memory lives there", () => { + expect(render(body)).toContain( + 'href="/settings/coach"', + ); + expect(render()).not.toContain( + 'data-slot="stored-coach-memory"', + ); + }); + + it("goes when the Coach cannot answer, and Settings → AI keeps the memory", () => { + h.coachAvailable = false; + expect( + render(body), + ).not.toContain('href="/settings/coach"'); + expect(render()).toContain('data-slot="stored-coach-memory"'); + }); +}); diff --git a/src/components/settings/ai-section.tsx b/src/components/settings/ai-section.tsx index 57ae0dd16..30de702d7 100644 --- a/src/components/settings/ai-section.tsx +++ b/src/components/settings/ai-section.tsx @@ -25,10 +25,12 @@ import { StoredCoachMemory } from "@/components/settings/coach-memory-section"; import { AiSetupHint } from "@/components/insights/ai-setup-hint"; import { useAuth } from "@/hooks/use-auth"; import { useMounted } from "@/hooks/use-mounted"; +import { useNavModules } from "@/hooks/use-nav-modules"; import { isSurfaceVisible } from "@/lib/modules/surface"; export function AiSection() { - const { isAuthenticated, user } = useAuth(); + const { isAuthenticated } = useAuth(); + const navModules = useNavModules(); // v1.16.6 — the auth query can resolve before this boundary // hydrates; every `disabled={!isAuthenticated}` binding in the child // cards would then disagree with the SSR HTML (React #418). Gate the @@ -46,11 +48,9 @@ export function AiSection() { // nothing below reads as broken), and the one calm setup hint while no // provider is configured, or the consent question while the operator's // provider waits on it. Below it: what the Coach stored, while Settings → - // Coach is not reachable, so it can always be read and deleted. - const coachSettingsReachable = isSurfaceVisible( - "settings:coach", - user?.modules, - ); + // Coach is not listed (the settings nav reads the same map), so it can + // always be read and deleted. + const coachSettingsReachable = isSurfaceVisible("settings:coach", navModules); return (
    diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index 1ddee6b15..a2bcb5185 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -47,6 +47,7 @@ import { cn } from "@/lib/utils"; import { PageHeader } from "@/components/ui/page-header"; import { SUB_SHELL_GRID_FLOOR } from "@/components/layout/shell-metrics"; import { useAuth } from "@/hooks/use-auth"; +import { useNavModules } from "@/hooks/use-nav-modules"; import { useMounted } from "@/hooks/use-mounted"; import { useTranslations } from "@/lib/i18n/context"; import { isSurfaceVisible } from "@/lib/modules/surface"; @@ -366,8 +367,8 @@ export function SettingsShell({ const activeSlug = deriveActiveSlug(pathname, active); // v1.18.0 — per-submodule entries are listed only when their module - // is enabled. Read from the resolved `useAuth().user.modules` map (the - // same map the Module hub, nav, and Insights pills gate off). Fail OPEN + // is enabled, from `useNavModules()` as in the sidebar (Coach follows its + // AI capability; Settings → AI then lists the stored memory). Fail OPEN // (`!== false`): a missing key, or a not-yet-resolved `/me` payload, // reads as enabled so an entry never silently disappears. A section's // owner comes from the surface map (`settings:`); sections with no @@ -390,7 +391,7 @@ export function SettingsShell({ // effect (which the lint rule flags as a cascading render), and no risk of // the two passes disagreeing. const hydrated = useMounted(); - const modules = user?.modules; + const modules = useNavModules(); const activeRecord = user?.accountAccess?.active ?? null; const allVisibleSections = SETTINGS_SECTIONS.filter( (section) =>