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",