mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
fix(errors): show the catalogue sentence, not the server's text
About forty refusal messages still printed the error string the server sent: English in every locale, and for a dropped connection the browser's own TypeError text. Mood tags, targets and thresholds, family history, medication side effects, phases, API tokens and dose imports, the Coach model picker, the AI provider, fallback chain, timeout and Codex cards, the notification channel cards, the avatar, passkeys, security keys, the authenticator card, the re-proof dialog and the admin pages now resolve the refusal's code through localizedApiError and fall back to the surface's own sentence. Two refusals carried something a person needs and had no code: a private AI base URL (the AI_PRIVATE_ORIGINS hint) and removing the last passkey. Both now send a code with a sentence of their own. A throttled re-proof says so in the reader's language. Raw fetch sites throw the same ApiError as the verb helpers through the exported throwApiError, which replaces the admin's getApiErrorMessage; the authenticator card's copy of describeStepUp is gone, and describeStepUp no longer passes a non-step-up refusal's prose through (its test pinned that). The admin password reset keeps the server's verdict on a 422: the server words the strength and breach verdict in the account's language.
This commit is contained in:
1 parent
9227844f42
commit
0b7afd8460
76 files changed
+584
-521
No files matched your search
+12
-3
@@ -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",
|
||||
|
||||
+12
-3
@@ -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",
|
||||
|
||||
+12
-3
@@ -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",
|
||||
|
||||
+12
-3
@@ -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",
|
||||
|
||||
+12
-3
@@ -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",
|
||||
|
||||
+12
-3
@@ -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": "관리자",
|
||||
|
||||
+12
-3
@@ -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",
|
||||
|
||||
+12
-3
@@ -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",
|
||||
|
||||
+12
-3
@@ -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",
|
||||
|
||||
+12
-3
@@ -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": "Администрирование",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string> {
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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")),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
{updateUser.isError && (
|
||||
<p className="text-destructive text-sm">
|
||||
{(updateUser.error as Error).message}
|
||||
{localizedApiError(
|
||||
updateUser.error,
|
||||
t,
|
||||
"admin.settingsSaveError",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<SettingsCardActions>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<typeof import("@/lib/api/api-fetch")>()),
|
||||
apiFetchRaw: mocks.apiFetchRaw,
|
||||
apiGet: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -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<typeof import("@/lib/api/api-fetch")>()),
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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") });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<HTMLFormElement>) {
|
||||
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 (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<void> {
|
||||
// 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<T extends EnabledSettings>(
|
||||
@@ -75,8 +88,7 @@ export function useChannelEnabledToggle<T extends EnabledSettings>(
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, Error, boolean, T | undefined>({
|
||||
mutationFn: (enabled) =>
|
||||
persistChannelEnabled(endpoint, enabled, t("settings.savingError")),
|
||||
mutationFn: (enabled) => persistChannelEnabled(endpoint, enabled),
|
||||
onMutate: async (enabled) => {
|
||||
await queryClient.cancelQueries({ queryKey });
|
||||
return optimisticallySetChannelEnabled<T>(queryClient, queryKey, enabled);
|
||||
@@ -86,7 +98,7 @@ export function useChannelEnabledToggle<T extends EnabledSettings>(
|
||||
},
|
||||
onError: (err, _enabled, previous) => {
|
||||
rollbackChannelEnabled(queryClient, queryKey, previous);
|
||||
showErrorToast(err.message, { cause: err });
|
||||
showErrorToast(channelSaveError(err, t), { cause: err });
|
||||
},
|
||||
onSettled: () => {
|
||||
void queryClient.invalidateQueries({ queryKey });
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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("<TotpCard> — 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");
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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"),
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -83,7 +83,7 @@ export function createRecentProofFlow(deps: {
|
||||
/** Posts the proof to `/api/auth/reproof`. */
|
||||
submit: (proof: ExistingFactorProof) => Promise<unknown>;
|
||||
/** 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 {
|
||||
|
||||
@@ -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"),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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"),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<Record<ThresholdMetric, { min: number; max: number }>>,
|
||||
) => {
|
||||
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({
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<T, M = Record<string, unknown>> {
|
||||
meta?: M;
|
||||
}
|
||||
|
||||
async function throwApiError(res: Response): Promise<never> {
|
||||
/** Throw the `ApiError` for a refused raw `Response` (see `apiFetchRaw`). */
|
||||
export async function throwApiError(res: Response): Promise<never> {
|
||||
let meta: Record<string, unknown> | undefined;
|
||||
let issuePaths: string[] = [];
|
||||
try {
|
||||
|
||||
@@ -58,7 +58,11 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
"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<Record<string, readonly string[]>> =
|
||||
"auth.already_authenticated",
|
||||
"auth.mfa.code_invalid",
|
||||
"auth.missing",
|
||||
"auth.passkey.last_method",
|
||||
"auth.refresh.invalid",
|
||||
"auth.refresh.reuse",
|
||||
"auth.refresh.revoked",
|
||||
|
||||
Reference in new issue
Block a user