diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 355535f32..087d71406 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -717,21 +717,6 @@ export const E2E_SETUP_LOCALE = { role: "USER", } as const; -/** - * v1.38.19 — the shared-provider offer's own account. - * - * Its journey seeds INSTANCE-wide state (the operator key's presence and the - * provider-health ledger the tri-state folds) and mints a consent receipt on - * the account, so it cannot share one with a journey that asserts a module - * map or a tile order. - */ -export const E2E_SETUP_AI_OFFER = { - email: "e2e-setup-ai-offer@healthlog.test", - username: "e2e-setup-ai-offer", - password: "Ao6!Zd3fB9qMv2Rk", - role: "USER", -} as const; - export const SETUP_BP_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateSetupBp.json", @@ -760,10 +745,6 @@ export const SETUP_LOCALE_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateSetupLocale.json", ); -export const SETUP_AI_OFFER_STORAGE_STATE_PATH = resolve( - process.cwd(), - "e2e/setup/storageStateSetupAiOffer.json", -); const SETUP_FLOW_ACCOUNTS = [ E2E_SETUP_BP, @@ -772,7 +753,6 @@ const SETUP_FLOW_ACCOUNTS = [ E2E_SETUP_CHILD, E2E_SETUP_A11Y, E2E_SETUP_LOCALE, - E2E_SETUP_AI_OFFER, E2E_SETUP_CONNECT, ] as const; @@ -1882,7 +1862,6 @@ export default async function globalSetup(config: FullConfig): Promise { await capture(E2E_SETUP_A11Y, SETUP_A11Y_STORAGE_STATE_PATH); await capture(E2E_SETUP_LOCALE, SETUP_LOCALE_STORAGE_STATE_PATH); await capture(E2E_SETUP_CONNECT, SETUP_CONNECT_STORAGE_STATE_PATH); - await capture(E2E_SETUP_AI_OFFER, SETUP_AI_OFFER_STORAGE_STATE_PATH); await capture(E2E_SETUP_CHILD, SETUP_CHILD_STORAGE_STATE_PATH); await pool.query( `UPDATE users SET totp_confirmed_at = $2 WHERE username = $1`, diff --git a/messages/de.json b/messages/de.json index c3db429d5..13bcb27aa 100644 --- a/messages/de.json +++ b/messages/de.json @@ -144,7 +144,10 @@ "required": "Bestätige dein aktuelles Passwort, um das zu ändern.", "failed": "Die Bestätigung ist fehlgeschlagen. Es wurde nichts geändert." }, - "missing": "Deine Sitzung ist abgelaufen, daher wurde nichts geändert. Melde dich erneut an, um weiterzumachen." + "missing": "Deine Sitzung ist abgelaufen, daher wurde nichts geändert. Melde dich erneut an, um weiterzumachen.", + "passkey": { + "last_method": "Dieser Passkey ist deine einzige Anmeldemöglichkeit. Lege zuerst ein Passwort fest oder füge einen weiteren Passkey hinzu." + } }, "biomarkers": { "unit": { @@ -185,6 +188,40 @@ "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." + }, + "encounter": { + "invalid": "Prüfe die Angaben zum Termin und versuche es erneut.", + "practitioner-not-found": "Diese Praxis bzw. dieser Arzt existiert nicht mehr. Wähle einen anderen Eintrag oder keinen.", + "reminder-not-found": "Diese Vorsorge existiert nicht mehr. Wähle eine andere oder keine.", + "reminder-conflict": "Ein geplanter Termin kann keine Vorsorge abschließen, denn er hat noch nicht stattgefunden." + }, + "vaccination": { + "invalid": "Prüfe die Angaben zur Impfung und versuche es erneut.", + "practitioner-not-found": "Diese Praxis bzw. dieser Arzt existiert nicht mehr. Wähle einen anderen Eintrag oder keinen.", + "encounter-not-found": "Dieser Termin existiert nicht mehr. Wähle einen anderen oder keinen.", + "custom-vaccine-not-found": "Dieser Impfstoff existiert nicht mehr. Wähle ihn erneut aus der Liste.", + "not-found": "Diese Impfung ist nicht mehr vorhanden.", + "identity-required": "Eine Impfung braucht einen Namen: Wähle einen Impfstoff aus der Liste oder gib ein, was im Impfpass steht.", + "booster-invalid": "Prüfe die Angaben zur Auffrischung und versuche es erneut.", + "booster-no-antigen": "Diese Impfung nennt keinen Impfstoff aus dem Katalog, daher lässt sich keine Auffrischung planen.", + "booster-out-of-scope": "Deine Freigabe für diese Akte reicht nicht, um eine Auffrischung zu planen." + }, + "allergy": { + "invalid": "Prüfe die Angaben zur Allergie und versuche es erneut." + }, + "practitioner": { + "invalid": "Prüfe die Angaben zur Praxis bzw. zum Arzt und versuche es erneut." + }, + "workout": { + "batch": { + "invalid": "Prüfe die Angaben zum Training und versuche es erneut.", + "too_large": "Zu viele Trainings auf einmal. Schicke weniger auf einmal.", + "payload_too_large": "Die Trainingsdaten sind zu groß, um sie auf einmal zu speichern.", + "source_not_permitted": "Diese Verbindung darf keine Trainings unter dieser Quelle speichern." + } } }, "doctorReport": { @@ -1539,7 +1576,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 +7794,9 @@ "hrv": "HRV", "weight": "Wiegungen", "cycle": "Menstruationszyklus" - } + }, + "passkeyRenameFailed": "Der Passkey konnte nicht umbenannt werden.", + "passkeyRemoveFailed": "Der Passkey konnte nicht entfernt werden." }, "admin": { "title": "Administration", diff --git a/messages/en.json b/messages/en.json index d897c04e6..6ba260ae3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -144,7 +144,10 @@ "required": "Confirm your current password to change this.", "failed": "That did not verify. Nothing was changed." }, - "missing": "Your session has ended, so nothing was changed. Sign in again to continue." + "missing": "Your session has ended, so nothing was changed. Sign in again to continue.", + "passkey": { + "last_method": "This passkey is your only way to sign in. Set a password or add another passkey first." + } }, "biomarkers": { "unit": { @@ -185,6 +188,40 @@ "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." + }, + "encounter": { + "invalid": "Check the visit details and try again.", + "practitioner-not-found": "This practice or doctor no longer exists. Choose another or none.", + "reminder-not-found": "This checkup no longer exists. Choose another or none.", + "reminder-conflict": "A planned visit cannot close a checkup, because it has not happened yet." + }, + "vaccination": { + "invalid": "Check the vaccination details and try again.", + "practitioner-not-found": "This practice or doctor no longer exists. Choose another or none.", + "encounter-not-found": "This visit no longer exists. Choose another or none.", + "custom-vaccine-not-found": "This vaccine no longer exists. Choose it again from the list.", + "not-found": "This vaccination is no longer available.", + "identity-required": "A dose needs a name: pick a vaccine from the list or type what the record says.", + "booster-invalid": "Check the booster details and try again.", + "booster-no-antigen": "This dose names no vaccine from the catalogue, so no booster can be planned for it.", + "booster-out-of-scope": "Your access to this record does not cover planning a booster." + }, + "allergy": { + "invalid": "Check the allergy details and try again." + }, + "practitioner": { + "invalid": "Check the practice or doctor details and try again." + }, + "workout": { + "batch": { + "invalid": "Check the workout details and try again.", + "too_large": "Too many workouts at once. Send fewer at a time.", + "payload_too_large": "The workout data is too large to save in one go.", + "source_not_permitted": "This connection may not save workouts under that source." + } } }, "doctorReport": { @@ -1539,7 +1576,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 +7794,9 @@ "hrv": "HRV", "weight": "weigh-ins", "cycle": "menstrual cycle" - } + }, + "passkeyRenameFailed": "Could not rename the passkey.", + "passkeyRemoveFailed": "Could not remove the passkey." }, "admin": { "title": "Administration", diff --git a/messages/es.json b/messages/es.json index c6c9875ea..62128c707 100644 --- a/messages/es.json +++ b/messages/es.json @@ -144,7 +144,10 @@ "required": "Confirma tu contraseña actual para cambiar esto.", "failed": "La verificación ha fallado. No se ha cambiado nada." }, - "missing": "Tu sesión ha caducado, así que no se ha cambiado nada. Inicia sesión de nuevo para continuar." + "missing": "Tu sesión ha caducado, así que no se ha cambiado nada. Inicia sesión de nuevo para continuar.", + "passkey": { + "last_method": "Esta clave de acceso es tu única forma de iniciar sesión. Primero establece una contraseña o añade otra clave de acceso." + } }, "biomarkers": { "unit": { @@ -185,6 +188,40 @@ "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." + }, + "encounter": { + "invalid": "Comprueba los datos de la cita e inténtalo de nuevo.", + "practitioner-not-found": "Esta consulta o este médico ya no existe. Elige otro o ninguno.", + "reminder-not-found": "Esta revisión ya no existe. Elige otra o ninguna.", + "reminder-conflict": "Una cita prevista no puede cerrar una revisión, porque todavía no ha tenido lugar." + }, + "vaccination": { + "invalid": "Comprueba los datos de la vacuna e inténtalo de nuevo.", + "practitioner-not-found": "Esta consulta o este médico ya no existe. Elige otro o ninguno.", + "encounter-not-found": "Esta cita ya no existe. Elige otra o ninguna.", + "custom-vaccine-not-found": "Esta vacuna ya no existe. Elígela de nuevo en la lista.", + "not-found": "Esta vacuna ya no está disponible.", + "identity-required": "Una dosis necesita un nombre: elige una vacuna de la lista o escribe lo que pone el registro.", + "booster-invalid": "Comprueba los datos del refuerzo e inténtalo de nuevo.", + "booster-no-antigen": "Esta dosis no indica ninguna vacuna del catálogo, así que no se puede planificar un refuerzo.", + "booster-out-of-scope": "Tu acceso a este registro no permite planificar un refuerzo." + }, + "allergy": { + "invalid": "Comprueba los datos de la alergia e inténtalo de nuevo." + }, + "practitioner": { + "invalid": "Comprueba los datos de la consulta o del médico e inténtalo de nuevo." + }, + "workout": { + "batch": { + "invalid": "Comprueba los datos del entrenamiento e inténtalo de nuevo.", + "too_large": "Demasiados entrenamientos a la vez. Envía menos cada vez.", + "payload_too_large": "Los datos del entrenamiento son demasiado grandes para guardarlos de una vez.", + "source_not_permitted": "Esta conexión no puede guardar entrenamientos con esa fuente." + } } }, "doctorReport": { @@ -1539,7 +1576,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 +7794,9 @@ "hrv": "VFC", "weight": "pesajes", "cycle": "ciclo menstrual" - } + }, + "passkeyRenameFailed": "No se pudo cambiar el nombre de la clave de acceso.", + "passkeyRemoveFailed": "No se pudo quitar la clave de acceso." }, "admin": { "title": "Administration", diff --git a/messages/fr.json b/messages/fr.json index 95779eaa4..f9885e3c5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -144,7 +144,10 @@ "required": "Confirmez votre mot de passe actuel pour modifier ceci.", "failed": "La vérification a échoué. Rien n'a été modifié." }, - "missing": "Votre session a expiré, rien n'a donc été modifié. Reconnectez-vous pour continuer." + "missing": "Votre session a expiré, rien n'a donc été modifié. Reconnectez-vous pour continuer.", + "passkey": { + "last_method": "Cette clé d’accès est votre seul moyen de connexion. Définissez d’abord un mot de passe ou ajoutez une autre clé d’accès." + } }, "biomarkers": { "unit": { @@ -185,6 +188,40 @@ "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." + }, + "encounter": { + "invalid": "Vérifiez les informations du rendez-vous et réessayez.", + "practitioner-not-found": "Ce cabinet ou ce médecin n'existe plus. Choisissez-en un autre ou aucun.", + "reminder-not-found": "Ce dépistage n'existe plus. Choisissez-en un autre ou aucun.", + "reminder-conflict": "Un rendez-vous prévu ne peut pas clore un dépistage, car il n'a pas encore eu lieu." + }, + "vaccination": { + "invalid": "Vérifiez les informations de la vaccination et réessayez.", + "practitioner-not-found": "Ce cabinet ou ce médecin n'existe plus. Choisissez-en un autre ou aucun.", + "encounter-not-found": "Ce rendez-vous n'existe plus. Choisissez-en un autre ou aucun.", + "custom-vaccine-not-found": "Ce vaccin n'existe plus. Choisissez-le à nouveau dans la liste.", + "not-found": "Cette vaccination n'est plus disponible.", + "identity-required": "Une dose a besoin d'un nom : choisissez un vaccin dans la liste ou saisissez ce qu'indique le carnet.", + "booster-invalid": "Vérifiez les informations du rappel et réessayez.", + "booster-no-antigen": "Cette dose ne cite aucun vaccin du catalogue, aucun rappel ne peut donc être prévu.", + "booster-out-of-scope": "Votre accès à ce dossier ne permet pas de prévoir un rappel." + }, + "allergy": { + "invalid": "Vérifiez les informations de l'allergie et réessayez." + }, + "practitioner": { + "invalid": "Vérifiez les informations du cabinet ou du médecin et réessayez." + }, + "workout": { + "batch": { + "invalid": "Vérifiez les informations de l'entraînement et réessayez.", + "too_large": "Trop d'entraînements à la fois. Envoyez-en moins à la fois.", + "payload_too_large": "Les données d'entraînement sont trop volumineuses pour être enregistrées en une fois.", + "source_not_permitted": "Cette connexion n'a pas le droit d'enregistrer des entraînements sous cette source." + } } }, "doctorReport": { @@ -1539,7 +1576,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 +7794,9 @@ "hrv": "VFC", "weight": "pesées", "cycle": "cycle menstruel" - } + }, + "passkeyRenameFailed": "Impossible de renommer la clé d’accès.", + "passkeyRemoveFailed": "Impossible de supprimer la clé d’accès." }, "admin": { "title": "Administration", diff --git a/messages/it.json b/messages/it.json index 1bc25ec4c..f78033c05 100644 --- a/messages/it.json +++ b/messages/it.json @@ -144,7 +144,10 @@ "required": "Conferma la password attuale per modificarlo.", "failed": "La verifica non è riuscita. Non è stato modificato nulla." }, - "missing": "La tua sessione è scaduta, quindi non è stato modificato nulla. Accedi di nuovo per continuare." + "missing": "La tua sessione è scaduta, quindi non è stato modificato nulla. Accedi di nuovo per continuare.", + "passkey": { + "last_method": "Questa passkey è il tuo unico modo per accedere. Imposta prima una password o aggiungi un’altra passkey." + } }, "biomarkers": { "unit": { @@ -185,6 +188,40 @@ "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." + }, + "encounter": { + "invalid": "Controlla i dati dell'appuntamento e riprova.", + "practitioner-not-found": "Questo studio o medico non esiste più. Scegline un altro o nessuno.", + "reminder-not-found": "Questo controllo non esiste più. Scegline un altro o nessuno.", + "reminder-conflict": "Un appuntamento previsto non può chiudere un controllo, perché non è ancora avvenuto." + }, + "vaccination": { + "invalid": "Controlla i dati della vaccinazione e riprova.", + "practitioner-not-found": "Questo studio o medico non esiste più. Scegline un altro o nessuno.", + "encounter-not-found": "Questo appuntamento non esiste più. Scegline un altro o nessuno.", + "custom-vaccine-not-found": "Questo vaccino non esiste più. Sceglilo di nuovo dall'elenco.", + "not-found": "Questa vaccinazione non è più disponibile.", + "identity-required": "Una dose ha bisogno di un nome: scegli un vaccino dall'elenco o scrivi quello che riporta il libretto.", + "booster-invalid": "Controlla i dati del richiamo e riprova.", + "booster-no-antigen": "Questa dose non indica alcun vaccino del catalogo, quindi non si può pianificare un richiamo.", + "booster-out-of-scope": "Il tuo accesso a questa cartella non consente di pianificare un richiamo." + }, + "allergy": { + "invalid": "Controlla i dati dell'allergia e riprova." + }, + "practitioner": { + "invalid": "Controlla i dati dello studio o del medico e riprova." + }, + "workout": { + "batch": { + "invalid": "Controlla i dati dell'allenamento e riprova.", + "too_large": "Troppi allenamenti in una volta. Inviane meno alla volta.", + "payload_too_large": "I dati dell'allenamento sono troppo grandi per salvarli in una volta.", + "source_not_permitted": "Questa connessione non può salvare allenamenti con questa fonte." + } } }, "doctorReport": { @@ -1539,7 +1576,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 +7794,9 @@ "hrv": "HRV", "weight": "pesate", "cycle": "ciclo mestruale" - } + }, + "passkeyRenameFailed": "Impossibile rinominare la passkey.", + "passkeyRemoveFailed": "Impossibile rimuovere la passkey." }, "admin": { "title": "Administration", diff --git a/messages/ko.json b/messages/ko.json index 5aeef7e5d..c62750df2 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -144,7 +144,10 @@ "required": "변경하려면 현재 비밀번호를 확인하세요.", "failed": "확인에 실패했습니다. 아무것도 변경되지 않았습니다." }, - "missing": "세션이 만료되어 아무것도 변경되지 않았어요. 계속하려면 다시 로그인해 주세요." + "missing": "세션이 만료되어 아무것도 변경되지 않았어요. 계속하려면 다시 로그인해 주세요.", + "passkey": { + "last_method": "이 패스키가 유일한 로그인 수단이에요. 먼저 비밀번호를 설정하거나 패스키를 하나 더 추가하세요." + } }, "biomarkers": { "unit": { @@ -185,6 +188,40 @@ "rate_limited": "지금은 요청이 너무 많아 아무것도 변경되지 않았어요. 잠시 후 다시 시도해 주세요.", "forbidden": "이 작업을 할 권한이 없어 아무것도 변경되지 않았어요.", "invalid": "일부 입력값이 받아들여지지 않아 아무것도 변경되지 않았어요. 확인한 후 다시 시도해 주세요." + }, + "ai_provider": { + "private_host": "이 주소는 사설 네트워크에 있어요. 운영자가 먼저 AI_PRIVATE_ORIGINS에 정확한 출처를 허용해야 해요." + }, + "encounter": { + "invalid": "진료 정보를 확인하고 다시 시도해 주세요.", + "practitioner-not-found": "이 병원 또는 의사가 더 이상 없어요. 다른 항목을 고르거나 선택하지 마세요.", + "reminder-not-found": "이 건강검진이 더 이상 없어요. 다른 검진을 고르거나 선택하지 마세요.", + "reminder-conflict": "예정된 진료는 아직 이루어지지 않았기 때문에 건강검진을 완료할 수 없어요." + }, + "vaccination": { + "invalid": "예방접종 정보를 확인하고 다시 시도해 주세요.", + "practitioner-not-found": "이 병원 또는 의사가 더 이상 없어요. 다른 항목을 고르거나 선택하지 마세요.", + "encounter-not-found": "이 진료가 더 이상 없어요. 다른 진료를 고르거나 선택하지 마세요.", + "custom-vaccine-not-found": "이 백신이 더 이상 없어요. 목록에서 다시 골라 주세요.", + "not-found": "이 예방접종 기록은 더 이상 없어요.", + "identity-required": "접종에는 이름이 필요해요. 목록에서 백신을 고르거나 기록에 적힌 이름을 입력하세요.", + "booster-invalid": "추가 접종 정보를 확인하고 다시 시도해 주세요.", + "booster-no-antigen": "이 접종에는 목록의 백신이 지정되지 않아 추가 접종을 계획할 수 없어요.", + "booster-out-of-scope": "이 기록에 대한 권한으로는 추가 접종을 계획할 수 없어요." + }, + "allergy": { + "invalid": "알레르기 정보를 확인하고 다시 시도해 주세요." + }, + "practitioner": { + "invalid": "병원 또는 의사 정보를 확인하고 다시 시도해 주세요." + }, + "workout": { + "batch": { + "invalid": "운동 정보를 확인하고 다시 시도해 주세요.", + "too_large": "한 번에 운동이 너무 많아요. 조금씩 나눠서 보내 주세요.", + "payload_too_large": "운동 데이터가 너무 커서 한 번에 저장할 수 없어요.", + "source_not_permitted": "이 연결로는 해당 출처의 운동을 저장할 수 없어요." + } } }, "doctorReport": { @@ -1539,7 +1576,8 @@ "gallbladderDiscomfort": "담낭 불편감" }, "deleteConfirmTitle": "이 기록을 삭제할까요?", - "deleteConfirmBody": "이 부작용 기록이 영구히 삭제돼요." + "deleteConfirmBody": "이 부작용 기록이 영구히 삭제돼요.", + "saveError": "기록을 저장하지 못했어요" }, "scheduling": { "naturalLanguage": { @@ -7756,7 +7794,9 @@ "hrv": "HRV", "weight": "체중 측정", "cycle": "생리 주기" - } + }, + "passkeyRenameFailed": "패스키 이름을 변경하지 못했어요.", + "passkeyRemoveFailed": "패스키를 삭제하지 못했어요." }, "admin": { "title": "관리자", diff --git a/messages/nl.json b/messages/nl.json index e0a3f8535..da1aa2e27 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -144,7 +144,10 @@ "required": "Bevestig je huidige wachtwoord om dit te wijzigen.", "failed": "De verificatie is mislukt. Er is niets gewijzigd." }, - "missing": "Je sessie is verlopen, dus er is niets gewijzigd. Meld je opnieuw aan om verder te gaan." + "missing": "Je sessie is verlopen, dus er is niets gewijzigd. Meld je opnieuw aan om verder te gaan.", + "passkey": { + "last_method": "Deze passkey is je enige manier om in te loggen. Stel eerst een wachtwoord in of voeg nog een passkey toe." + } }, "biomarkers": { "unit": { @@ -185,6 +188,40 @@ "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." + }, + "encounter": { + "invalid": "Controleer de gegevens van het bezoek en probeer het opnieuw.", + "practitioner-not-found": "Deze praktijk of arts bestaat niet meer. Kies een andere of geen.", + "reminder-not-found": "Deze controle bestaat niet meer. Kies een andere of geen.", + "reminder-conflict": "Een gepland bezoek kan geen controle afsluiten, want het heeft nog niet plaatsgevonden." + }, + "vaccination": { + "invalid": "Controleer de gegevens van de vaccinatie en probeer het opnieuw.", + "practitioner-not-found": "Deze praktijk of arts bestaat niet meer. Kies een andere of geen.", + "encounter-not-found": "Dit bezoek bestaat niet meer. Kies een ander of geen.", + "custom-vaccine-not-found": "Dit vaccin bestaat niet meer. Kies het opnieuw uit de lijst.", + "not-found": "Deze vaccinatie is niet meer beschikbaar.", + "identity-required": "Een dosis heeft een naam nodig: kies een vaccin uit de lijst of typ wat er in het boekje staat.", + "booster-invalid": "Controleer de gegevens van de booster en probeer het opnieuw.", + "booster-no-antigen": "Deze dosis noemt geen vaccin uit de catalogus, dus er kan geen booster worden gepland.", + "booster-out-of-scope": "Je toegang tot dit dossier dekt het plannen van een booster niet." + }, + "allergy": { + "invalid": "Controleer de gegevens van de allergie en probeer het opnieuw." + }, + "practitioner": { + "invalid": "Controleer de gegevens van de praktijk of arts en probeer het opnieuw." + }, + "workout": { + "batch": { + "invalid": "Controleer de gegevens van de training en probeer het opnieuw.", + "too_large": "Te veel trainingen tegelijk. Stuur er minder per keer.", + "payload_too_large": "De trainingsgegevens zijn te groot om in één keer op te slaan.", + "source_not_permitted": "Deze koppeling mag geen trainingen onder die bron opslaan." + } } }, "doctorReport": { @@ -1539,7 +1576,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 +7794,9 @@ "hrv": "HRV", "weight": "wegingen", "cycle": "menstruatiecyclus" - } + }, + "passkeyRenameFailed": "De naam van de passkey kon niet worden gewijzigd.", + "passkeyRemoveFailed": "De passkey kon niet worden verwijderd." }, "admin": { "title": "Beheer", diff --git a/messages/pl.json b/messages/pl.json index d35c7b385..854b541fe 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -144,7 +144,10 @@ "required": "Potwierdź obecne hasło, aby to zmienić.", "failed": "Weryfikacja nie powiodła się. Nic nie zostało zmienione." }, - "missing": "Twoja sesja wygasła, więc nic nie zostało zmienione. Zaloguj się ponownie, aby kontynuować." + "missing": "Twoja sesja wygasła, więc nic nie zostało zmienione. Zaloguj się ponownie, aby kontynuować.", + "passkey": { + "last_method": "Ten klucz dostępu to Twój jedyny sposób logowania. Najpierw ustaw hasło albo dodaj kolejny klucz dostępu." + } }, "biomarkers": { "unit": { @@ -185,6 +188,40 @@ "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." + }, + "encounter": { + "invalid": "Sprawdź dane wizyty i spróbuj ponownie.", + "practitioner-not-found": "Ten gabinet lub lekarz już nie istnieje. Wybierz inny albo żaden.", + "reminder-not-found": "To badanie już nie istnieje. Wybierz inne albo żadne.", + "reminder-conflict": "Zaplanowana wizyta nie może zamknąć badania, bo jeszcze się nie odbyła." + }, + "vaccination": { + "invalid": "Sprawdź dane szczepienia i spróbuj ponownie.", + "practitioner-not-found": "Ten gabinet lub lekarz już nie istnieje. Wybierz inny albo żaden.", + "encounter-not-found": "Ta wizyta już nie istnieje. Wybierz inną albo żadną.", + "custom-vaccine-not-found": "Ta szczepionka już nie istnieje. Wybierz ją ponownie z listy.", + "not-found": "To szczepienie nie jest już dostępne.", + "identity-required": "Dawka potrzebuje nazwy: wybierz szczepionkę z listy albo wpisz to, co widnieje w dokumentacji.", + "booster-invalid": "Sprawdź dane dawki przypominającej i spróbuj ponownie.", + "booster-no-antigen": "Ta dawka nie wskazuje szczepionki z katalogu, więc nie da się zaplanować dawki przypominającej.", + "booster-out-of-scope": "Twój dostęp do tej kartoteki nie obejmuje planowania dawki przypominającej." + }, + "allergy": { + "invalid": "Sprawdź dane alergii i spróbuj ponownie." + }, + "practitioner": { + "invalid": "Sprawdź dane gabinetu lub lekarza i spróbuj ponownie." + }, + "workout": { + "batch": { + "invalid": "Sprawdź dane treningu i spróbuj ponownie.", + "too_large": "Za dużo treningów naraz. Wysyłaj mniej za jednym razem.", + "payload_too_large": "Dane treningu są za duże, by zapisać je za jednym razem.", + "source_not_permitted": "To połączenie nie może zapisywać treningów pod tym źródłem." + } } }, "doctorReport": { @@ -1539,7 +1576,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 +7794,9 @@ "hrv": "HRV", "weight": "pomiary wagi", "cycle": "cykl menstruacyjny" - } + }, + "passkeyRenameFailed": "Nie udało się zmienić nazwy klucza dostępu.", + "passkeyRemoveFailed": "Nie udało się usunąć klucza dostępu." }, "admin": { "title": "Administracja", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 05e875766..bf5265f6e 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -144,7 +144,10 @@ "required": "Confirme sua senha atual para alterar isto.", "failed": "A verificação não foi concluída. Nada foi alterado." }, - "missing": "Sua sessão terminou, então nada foi alterado. Entre novamente para continuar." + "missing": "Sua sessão terminou, então nada foi alterado. Entre novamente para continuar.", + "passkey": { + "last_method": "Esta chave de acesso é sua única forma de entrar. Defina uma senha ou adicione outra chave de acesso primeiro." + } }, "biomarkers": { "unit": { @@ -185,6 +188,40 @@ "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." + }, + "encounter": { + "invalid": "Verifique os dados da consulta e tente novamente.", + "practitioner-not-found": "Esta clínica ou médico não existe mais. Escolha outro ou nenhum.", + "reminder-not-found": "Este check não existe mais. Escolha outro ou nenhum.", + "reminder-conflict": "Uma consulta planejada não pode fechar um check, porque ainda não aconteceu." + }, + "vaccination": { + "invalid": "Verifique os dados da vacina e tente novamente.", + "practitioner-not-found": "Esta clínica ou médico não existe mais. Escolha outro ou nenhum.", + "encounter-not-found": "Esta consulta não existe mais. Escolha outra ou nenhuma.", + "custom-vaccine-not-found": "Esta vacina não existe mais. Escolha-a novamente na lista.", + "not-found": "Esta vacina não está mais disponível.", + "identity-required": "Uma dose precisa de um nome: escolha uma vacina da lista ou digite o que consta no registro.", + "booster-invalid": "Verifique os dados do reforço e tente novamente.", + "booster-no-antigen": "Esta dose não indica nenhuma vacina do catálogo, então não é possível planejar um reforço.", + "booster-out-of-scope": "Seu acesso a este registro não permite planejar um reforço." + }, + "allergy": { + "invalid": "Verifique os dados da alergia e tente novamente." + }, + "practitioner": { + "invalid": "Verifique os dados da clínica ou do médico e tente novamente." + }, + "workout": { + "batch": { + "invalid": "Verifique os dados do treino e tente novamente.", + "too_large": "Treinos demais de uma vez. Envie menos por vez.", + "payload_too_large": "Os dados do treino são grandes demais para salvar de uma vez.", + "source_not_permitted": "Esta conexão não pode salvar treinos com essa origem." + } } }, "doctorReport": { @@ -1539,7 +1576,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 +7794,9 @@ "hrv": "VFC", "weight": "pesagens", "cycle": "ciclo menstrual" - } + }, + "passkeyRenameFailed": "Não foi possível renomear a chave de acesso.", + "passkeyRemoveFailed": "Não foi possível remover a chave de acesso." }, "admin": { "title": "Administração", diff --git a/messages/ru.json b/messages/ru.json index c96b8a0b2..d207f54f6 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -144,7 +144,10 @@ "required": "Подтвердите текущий пароль, чтобы изменить это.", "failed": "Не подтверждено. Ничего не изменено." }, - "missing": "Сеанс завершён, поэтому ничего не изменено. Войдите снова, чтобы продолжить." + "missing": "Сеанс завершён, поэтому ничего не изменено. Войдите снова, чтобы продолжить.", + "passkey": { + "last_method": "Этот ключ доступа остаётся вашим единственным способом входа. Сначала задайте пароль или добавьте ещё один ключ доступа." + } }, "biomarkers": { "unit": { @@ -185,6 +188,40 @@ "rate_limited": "Сейчас слишком много запросов, поэтому ничего не изменено. Подождите немного и попробуйте снова.", "forbidden": "У вас нет прав на это действие, поэтому ничего не изменено.", "invalid": "Некоторые данные не приняты, поэтому ничего не изменено. Проверьте их и попробуйте снова." + }, + "ai_provider": { + "private_host": "Этот адрес находится в частной сети. Сначала оператор должен разрешить его точный источник в AI_PRIVATE_ORIGINS." + }, + "encounter": { + "invalid": "Проверьте данные визита и попробуйте снова.", + "practitioner-not-found": "Этой клиники или врача больше нет. Выберите другую запись или никакую.", + "reminder-not-found": "Этого осмотра больше нет. Выберите другой или никакой.", + "reminder-conflict": "Запланированный визит не может закрыть осмотр, потому что он ещё не состоялся." + }, + "vaccination": { + "invalid": "Проверьте данные вакцинации и попробуйте снова.", + "practitioner-not-found": "Этой клиники или врача больше нет. Выберите другую запись или никакую.", + "encounter-not-found": "Этого визита больше нет. Выберите другой или никакой.", + "custom-vaccine-not-found": "Этой вакцины больше нет. Выберите её в списке ещё раз.", + "not-found": "Этой вакцинации больше нет.", + "identity-required": "У дозы должно быть название: выберите вакцину из списка или введите то, что указано в записи.", + "booster-invalid": "Проверьте данные ревакцинации и попробуйте снова.", + "booster-no-antigen": "В этой дозе не указана вакцина из каталога, поэтому ревакцинацию запланировать нельзя.", + "booster-out-of-scope": "Ваш доступ к этой карте не позволяет планировать ревакцинацию." + }, + "allergy": { + "invalid": "Проверьте данные аллергии и попробуйте снова." + }, + "practitioner": { + "invalid": "Проверьте данные клиники или врача и попробуйте снова." + }, + "workout": { + "batch": { + "invalid": "Проверьте данные тренировки и попробуйте снова.", + "too_large": "Слишком много тренировок за раз. Отправляйте меньше за один раз.", + "payload_too_large": "Данные тренировки слишком велики, чтобы сохранить их за один раз.", + "source_not_permitted": "Это подключение не может сохранять тренировки с таким источником." + } } }, "doctorReport": { @@ -1539,7 +1576,8 @@ "gallbladderDiscomfort": "Дискомфорт в желчном пузыре" }, "deleteConfirmTitle": "Удалить эту запись?", - "deleteConfirmBody": "Эта запись о побочном эффекте будет удалена навсегда." + "deleteConfirmBody": "Эта запись о побочном эффекте будет удалена навсегда.", + "saveError": "Не удалось сохранить запись" }, "scheduling": { "naturalLanguage": { @@ -7756,7 +7794,9 @@ "hrv": "ВСР", "weight": "взвешивания", "cycle": "менструальный цикл" - } + }, + "passkeyRenameFailed": "Не удалось переименовать ключ доступа.", + "passkeyRemoveFailed": "Не удалось убрать ключ доступа." }, "admin": { "title": "Администрирование", diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index a6c30e3f9..7b03fc37e 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 339, - "total": 110080, + "count": 337, + "total": 109677, "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, @@ -99,7 +99,6 @@ "components/documents/sources/document-source-picker.tsx :: DocumentSourcePicker": 563, "components/illness/log-day-sheet.tsx :: LogDaySheet": 265, "components/illness/new-episode-sheet.tsx :: NewEpisodeSheet": 264, - "components/insights/coach-panel/attachment-picker.tsx :: AttachmentPicker": 208, "components/insights/coach-panel/chat-bubble.tsx :: ChatBubbleImpl": 419, "components/insights/coach-panel/coach-conversation.tsx :: CoachConversation": 1002, "components/insights/coach-panel/coach-input.tsx :: CoachInput": 259, @@ -164,11 +163,10 @@ "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, - "components/mental-health/mental-wellbeing.tsx :: MentalWellbeing": 201, "components/mood/manage/tag-groups-card.tsx :: TagGroupsCard": 256, "components/mood/manage/tag-manager-card.tsx :: TagManagerCard": 270, "components/mood/mood-form.tsx :: MoodForm": 514, @@ -190,7 +188,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 +209,7 @@ "components/settings/mood-reminder-card.tsx :: MoodReminderCard": 234, "components/settings/report-selection/selection-panel.tsx :: HealthRecordExportPanel": 370, "components/settings/score-section.tsx :: ScoreSection": 383, - "components/settings/security-section/passkey-list-section.tsx :: PasskeyListSection": 390, + "components/settings/security-section/passkey-list-section.tsx :: PasskeyListSection": 395, "components/settings/security-section/security-keys-card.tsx :: SecurityKeysCard": 288, "components/settings/security-section/totp-card.tsx :: TotpCard": 533, "components/settings/security-sessions-card.tsx :: SecuritySessionsCard": 245, diff --git a/src/__tests__/client-fetch-limit-guard.test.ts b/src/__tests__/client-fetch-limit-guard.test.ts index 514275f8b..13eae31a4 100644 --- a/src/__tests__/client-fetch-limit-guard.test.ts +++ b/src/__tests__/client-fetch-limit-guard.test.ts @@ -77,7 +77,7 @@ const ALLOWLIST: readonly Allowed[] = [ file: "components/insights/coach-panel/attachment-picker.tsx", limit: 50, reason: - "Search-backed picker: the query narrows the whole document vault server-side.", + "Search-backed picker: the query narrows the whole document vault server-side, and a cut list says so and points to the search.", }, { file: "components/documents/document-picker-sheet.tsx", diff --git a/src/app/api/auth/passkeys/[id]/route.ts b/src/app/api/auth/passkeys/[id]/route.ts index 206d166cd..5d3f7b14f 100644 --- a/src/app/api/auth/passkeys/[id]/route.ts +++ b/src/app/api/auth/passkeys/[id]/route.ts @@ -126,6 +126,7 @@ export const DELETE = apiHandler( return apiError( "Cannot delete. At least one authentication method must remain", 400, + { errorCode: "auth.passkey.last_method" }, ); } diff --git a/src/app/api/user/ai-provider/route.ts b/src/app/api/user/ai-provider/route.ts index a7f53faf6..09a99b994 100644 --- a/src/app/api/user/ai-provider/route.ts +++ b/src/app/api/user/ai-provider/route.ts @@ -265,6 +265,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => { return apiError( "Base URL points to an internal/private host. The operator must allow its exact origin on this instance via AI_PRIVATE_ORIGINS (e.g. http://ollama.lan:11434) for a self-hosted Ollama / LM Studio.", 422, + { errorCode: "ai_provider.private_host" }, ); } updates.aiBaseUrl = trimmed; @@ -289,6 +290,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => { return apiError( "Base URL points to an internal/private host. The operator must allow its exact origin on this instance via AI_PRIVATE_ORIGINS (e.g. http://litellm.lan:4000) for a self-hosted gateway.", 422, + { errorCode: "ai_provider.private_host" }, ); } updates.aiCompatBaseUrl = trimmed; diff --git a/src/components/admin/_shared.tsx b/src/components/admin/_shared.tsx index 6ea08b5a3..dd65bca48 100644 --- a/src/components/admin/_shared.tsx +++ b/src/components/admin/_shared.tsx @@ -12,6 +12,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { PasswordInput as SharedPasswordInput } from "@/components/ui/password-input"; import { apiGet, apiPut } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; /** * Re-export the canonical password input so admin sections have access to @@ -323,29 +324,13 @@ export function useUpdateSettings() { toast.success(t("common.saved")); }, onError: (err) => { - showErrorToast( - err instanceof Error && err.message - ? err.message - : t("admin.settingsSaveError"), - { cause: err }, - ); + showErrorToast(localizedApiError(err, t, "admin.settingsSaveError"), { + cause: err, + }); }, }); } -export async function getApiErrorMessage(response: Response): Promise { - const fallback = `HTTP ${response.status}`; - try { - const json = (await response.json()) as { error?: string }; - if (typeof json?.error === "string" && json.error.trim().length > 0) { - return json.error; - } - } catch { - return fallback; - } - return fallback; -} - /** * Shared `auth.*` audit-action label map. Two surfaces consume it * today (`/admin/login-overview` and the dashboard recent-activity diff --git a/src/components/admin/ai-server-key-section.tsx b/src/components/admin/ai-server-key-section.tsx index ee90687e8..a76e2cd5e 100644 --- a/src/components/admin/ai-server-key-section.tsx +++ b/src/components/admin/ai-server-key-section.tsx @@ -21,6 +21,7 @@ import { queryKeys, } from "@/lib/query-keys"; import { apiGet, apiPut } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { PasswordInput } from "./_shared"; /** @@ -90,10 +91,9 @@ export function AiServerKeySection() { toast.success(t("admin.aiServerKey.saved")); }, onError: (error: unknown) => { - showErrorToast( - error instanceof Error ? error.message : t("admin.aiServerKey.error"), - { cause: error }, - ); + showErrorToast(localizedApiError(error, t, "admin.aiServerKey.error"), { + cause: error, + }); }, }); diff --git a/src/components/admin/ai-usage-section.tsx b/src/components/admin/ai-usage-section.tsx index 94cdd4bcf..6feee7cc2 100644 --- a/src/components/admin/ai-usage-section.tsx +++ b/src/components/admin/ai-usage-section.tsx @@ -35,6 +35,7 @@ import { type AiUsageLevel, } from "@/lib/ai/usage-level"; import { apiGet, apiPut } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { useTranslations } from "@/lib/i18n/context"; import { aiInputDependentKeys, @@ -122,12 +123,9 @@ function AiUsageForm({ bounds }: { bounds: AiUsageBounds }) { toast.success(t("common.saved")); }, onError: (err) => { - showErrorToast( - err instanceof Error && err.message - ? err.message - : t("admin.settingsSaveError"), - { cause: err }, - ); + showErrorToast(localizedApiError(err, t, "admin.settingsSaveError"), { + cause: err, + }); }, }); diff --git a/src/components/admin/assistant-section.tsx b/src/components/admin/assistant-section.tsx index f42880bf3..d3cfde645 100644 --- a/src/components/admin/assistant-section.tsx +++ b/src/components/admin/assistant-section.tsx @@ -15,6 +15,7 @@ import { } from "@/lib/query-keys"; import { SettingsToggle } from "./_shared"; import { apiGet, apiPut } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { Label } from "@/components/ui/label"; import { NativeSelect } from "@/components/ui/native-select"; import { @@ -105,12 +106,9 @@ function useUpdateAssistantFlags() { toast.success(t("common.saved")); }, onError: (err) => { - showErrorToast( - err instanceof Error && err.message - ? err.message - : t("admin.settingsSaveError"), - { cause: err }, - ); + showErrorToast(localizedApiError(err, t, "admin.settingsSaveError"), { + cause: err, + }); client.invalidateQueries({ queryKey: queryKeys.adminAssistantFlags(), }); diff --git a/src/components/admin/backups/use-backup-actions.ts b/src/components/admin/backups/use-backup-actions.ts index 033ea391b..525b3bfb6 100644 --- a/src/components/admin/backups/use-backup-actions.ts +++ b/src/components/admin/backups/use-backup-actions.ts @@ -7,14 +7,13 @@ import { ReproofCancelledError, throwIfReproofRequired, } from "@/components/settings/security-section/use-recent-proof"; -import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch"; +import { apiFetchRaw, apiPost, throwApiError } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { showErrorToast } from "@/lib/errors/error-toast"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import type { BackupRow } from "@/types/backups"; -import { getApiErrorMessage } from "../_shared"; - /** "Run backup now": enqueues the pg-boss `data-backup` job. */ export function useRunBackup() { const { t } = useTranslations(); @@ -34,9 +33,7 @@ export function useRunBackup() { }, onError: (err) => { showErrorToast( - err instanceof Error && err.message - ? err.message - : t("admin.section.backups.runFailed"), + localizedApiError(err, t, "admin.section.backups.runFailed"), { cause: err }, ); }, @@ -62,9 +59,7 @@ export function useBackupDownload( await apiFetchRaw(`/api/admin/backups/${row.id}/download`), ), ); - if (!res.ok) { - throw new Error(await getApiErrorMessage(res)); - } + if (!res.ok) await throwApiError(res); // Use the server-provided Content-Disposition filename if present, // otherwise fall back to a deterministic client-side name. The // server filename is more accurate (uses the actual createdAt). @@ -86,9 +81,7 @@ export function useBackupDownload( } catch (err) { if (err instanceof ReproofCancelledError) return; showErrorToast( - err instanceof Error && err.message - ? err.message - : t("admin.section.backups.downloadFailed"), + localizedApiError(err, t, "admin.section.backups.downloadFailed"), { cause: err }, ); } finally { diff --git a/src/components/admin/backups/use-backup-restores.ts b/src/components/admin/backups/use-backup-restores.ts index 7f1854ca5..523843c32 100644 --- a/src/components/admin/backups/use-backup-restores.ts +++ b/src/components/admin/backups/use-backup-restores.ts @@ -5,6 +5,7 @@ import { toast } from "sonner"; import type { useRecentProof } from "@/components/settings/security-section/use-recent-proof"; import { ReproofCancelledError } from "@/components/settings/security-section/use-recent-proof"; import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { showErrorToast } from "@/lib/errors/error-toast"; import type { MissingBackupSection, @@ -188,9 +189,7 @@ export function useBackupRestores( } setRefusedSections([]); showErrorToast( - err instanceof Error && err.message - ? err.message - : t("admin.section.backups.restoreFailed"), + localizedApiError(err, t, "admin.section.backups.restoreFailed"), { cause: err }, ); }, diff --git a/src/components/admin/backups/use-backup-upload.ts b/src/components/admin/backups/use-backup-upload.ts index c38e29f8f..f4f237819 100644 --- a/src/components/admin/backups/use-backup-upload.ts +++ b/src/components/admin/backups/use-backup-upload.ts @@ -5,6 +5,7 @@ import { toast } from "sonner"; import type { useRecentProof } from "@/components/settings/security-section/use-recent-proof"; import { ReproofCancelledError } from "@/components/settings/security-section/use-recent-proof"; import { ApiError, apiFetch, apiGet } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { showErrorToast } from "@/lib/errors/error-toast"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -167,9 +168,7 @@ export function useBackupUpload( return; } showErrorToast( - err instanceof Error && err.message - ? err.message - : t("admin.section.backups.uploadFailed"), + localizedApiError(err, t, "admin.section.backups.uploadFailed"), { cause: err }, ); }, diff --git a/src/components/admin/central-codex-section.tsx b/src/components/admin/central-codex-section.tsx index ff918faff..05cf106f5 100644 --- a/src/components/admin/central-codex-section.tsx +++ b/src/components/admin/central-codex-section.tsx @@ -28,7 +28,8 @@ import { Button } from "@/components/ui/button"; import { ConfirmButton } from "@/components/ui/confirm-button"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; -import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; +import { apiDelete, apiGet, apiPost } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { formatDateTime } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; import { @@ -66,21 +67,20 @@ export function CentralCodexSection() { async function handleConnect() { setDevicePolling(true); try { - const res = await apiFetchRaw("/api/admin/central-codex/device-start", { - method: "POST", - }); - const json = await res.json(); - if (!res.ok) throw new Error(json.error || t("admin.centralCodex.error")); + const data = await apiPost<{ + userCode: string; + verificationUrl: string; + intervalSeconds: number; + }>("/api/admin/central-codex/device-start"); setDeviceCode({ - userCode: json.data.userCode, - verificationUrl: json.data.verificationUrl, - intervalSeconds: json.data.intervalSeconds, + userCode: data.userCode, + verificationUrl: data.verificationUrl, + intervalSeconds: data.intervalSeconds, }); } catch (err) { - showErrorToast( - err instanceof Error ? err.message : t("admin.centralCodex.error"), - { cause: err }, - ); + showErrorToast(localizedApiError(err, t, "admin.centralCodex.error"), { + cause: err, + }); setDevicePolling(false); } } @@ -92,15 +92,11 @@ export function CentralCodexSection() { async function tick() { try { - const res = await apiFetchRaw("/api/admin/central-codex/device-poll", { - method: "POST", - }); - const json = await res.json(); + const data = await apiPost<{ status?: string } | null>( + "/api/admin/central-codex/device-poll", + ); if (cancelled) return; - if (!res.ok) { - throw new Error(json.error || t("admin.centralCodex.error")); - } - if (json.data?.status === "connected") { + if (data?.status === "connected") { setDeviceCode(null); setDevicePolling(false); toast.success(t("admin.centralCodex.connected")); @@ -113,10 +109,9 @@ export function CentralCodexSection() { if (!cancelled) setTimeout(tick, intervalMs); } catch (err) { if (cancelled) return; - showErrorToast( - err instanceof Error ? err.message : t("admin.centralCodex.error"), - { cause: err }, - ); + showErrorToast(localizedApiError(err, t, "admin.centralCodex.error"), { + cause: err, + }); setDeviceCode(null); setDevicePolling(false); } @@ -133,23 +128,16 @@ export function CentralCodexSection() { async function handleDisconnect() { setDisconnecting(true); try { - const res = await apiFetchRaw("/api/admin/central-codex", { - method: "DELETE", - }); - if (!res.ok) { - const json = await res.json(); - throw new Error(json.error || t("admin.centralCodex.error")); - } + await apiDelete("/api/admin/central-codex"); toast.success(t("admin.centralCodex.disconnected")); queryClient.invalidateQueries({ queryKey: queryKeys.adminCentralCodex(), }); void invalidateKeys(queryClient, aiInputDependentKeys); } catch (err) { - showErrorToast( - err instanceof Error ? err.message : t("admin.centralCodex.error"), - { cause: err }, - ); + showErrorToast(localizedApiError(err, t, "admin.centralCodex.error"), { + cause: err, + }); } finally { setDisconnecting(false); } diff --git a/src/components/admin/danger-zone-section.tsx b/src/components/admin/danger-zone-section.tsx index b14ca7c4b..dcd5b9102 100644 --- a/src/components/admin/danger-zone-section.tsx +++ b/src/components/admin/danger-zone-section.tsx @@ -22,6 +22,7 @@ import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { apiDelete } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { randomId } from "@/lib/random-id"; import { recentProofErrorMessage, @@ -81,8 +82,10 @@ export function DangerZoneSection() { }), ); }, - onError: (err: Error) => { - setWipeMsg(recentProofErrorMessage(err, err.message)); + onError: (err) => { + setWipeMsg( + recentProofErrorMessage(err, localizedApiError(err, t, "common.error")), + ); }, }); diff --git a/src/components/admin/delete-user-control.tsx b/src/components/admin/delete-user-control.tsx index dc25e1591..ef44ee3af 100644 --- a/src/components/admin/delete-user-control.tsx +++ b/src/components/admin/delete-user-control.tsx @@ -7,6 +7,7 @@ import { ConfirmButton } from "@/components/ui/confirm-button"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; import { showErrorToast } from "@/lib/errors/error-toast"; import { apiDelete } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { formatDate } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -90,9 +91,7 @@ function DeleteUserButton({ user }: { user: AdminUser }) { onError: (err) => { const message = recentProofErrorMessage( err, - err instanceof Error && err.message - ? err.message - : t("admin.section.users.deleteUserFailed"), + localizedApiError(err, t, "admin.section.users.deleteUserFailed"), ); if (message) showErrorToast(message, { cause: err }); }, diff --git a/src/components/admin/encryption-section.tsx b/src/components/admin/encryption-section.tsx index 79e56c541..24e4b1982 100644 --- a/src/components/admin/encryption-section.tsx +++ b/src/components/admin/encryption-section.tsx @@ -43,8 +43,13 @@ import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; -import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; -import { getApiErrorMessage } from "./_shared"; +import { + ApiError, + apiFetchRaw, + apiGet, + throwApiError, +} from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { recentProofErrorMessage, throwIfReproofRequired, @@ -133,12 +138,7 @@ export function EncryptionSection() { }), ), ); - if (!res.ok) { - if (res.status === 401) { - throw new Error(t("admin.section.encryption.stepUpNeeded")); - } - throw new Error(await getApiErrorMessage(res)); - } + if (!res.ok) await throwApiError(res); return res.json(); }, onSuccess: () => { @@ -150,7 +150,9 @@ export function EncryptionSection() { onError: (err: Error) => { const message = recentProofErrorMessage( err, - err.message || t("admin.section.encryption.rotateFailed"), + err instanceof ApiError && err.status === 401 + ? t("admin.section.encryption.stepUpNeeded") + : localizedApiError(err, t, "admin.section.encryption.rotateFailed"), ); if (message === null) return; showErrorToast(message, { cause: err }); diff --git a/src/components/admin/glitchtip-section.tsx b/src/components/admin/glitchtip-section.tsx index 853f14d29..b878905b5 100644 --- a/src/components/admin/glitchtip-section.tsx +++ b/src/components/admin/glitchtip-section.tsx @@ -20,6 +20,7 @@ import { ConfiguredBadge, } from "./_shared"; import { apiPost } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { queryKeys } from "@/lib/query-keys"; export function GlitchtipSection() { @@ -113,9 +114,7 @@ export function GlitchtipSection() { }, onError: (error) => { showErrorToast( - error instanceof Error - ? error.message - : t("admin.monitoringTestFailed"), + localizedApiError(error, t, "admin.monitoringTestFailed"), { cause: error }, ); void queryClient.invalidateQueries({ diff --git a/src/components/admin/measurement-maintenance-card.tsx b/src/components/admin/measurement-maintenance-card.tsx index 3569c0e64..bab3ea441 100644 --- a/src/components/admin/measurement-maintenance-card.tsx +++ b/src/components/admin/measurement-maintenance-card.tsx @@ -33,12 +33,12 @@ import { } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { QueryErrorCard } from "@/components/ui/query-error-card"; -import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; +import { apiFetchRaw, apiGet, throwApiError } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { formatBytes } from "@/lib/format/bytes"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import type { MeasurementMaintenanceStatus } from "@/lib/jobs/measurement-maintenance-status"; import { queryKeys } from "@/lib/query-keys"; -import { getApiErrorMessage } from "./_shared"; const ENDPOINT = "/api/admin/maintenance/measurements"; @@ -70,7 +70,7 @@ export function MeasurementMaintenanceCard() { headers: { "content-type": "application/json" }, body: JSON.stringify({ vacuum: true, reindex: true }), }); - if (!res.ok) throw new Error(await getApiErrorMessage(res)); + if (!res.ok) await throwApiError(res); return ((await res.json()) as { data: { enqueued: boolean } }).data; }, onSuccess: ({ enqueued }) => { @@ -80,9 +80,9 @@ export function MeasurementMaintenanceCard() { queryKey: queryKeys.adminMeasurementMaintenance(), }); }, - onError: (err: Error) => { + onError: (err) => { showErrorToast( - err.message || t("admin.section.maintenance.startFailed"), + localizedApiError(err, t, "admin.section.maintenance.startFailed"), { cause: err }, ); }, diff --git a/src/components/admin/module-availability-section.tsx b/src/components/admin/module-availability-section.tsx index f57122c64..3554c0475 100644 --- a/src/components/admin/module-availability-section.tsx +++ b/src/components/admin/module-availability-section.tsx @@ -23,6 +23,7 @@ import { import type { ModuleKey } from "@/lib/modules/registry"; import { SettingsToggle } from "./_shared"; import { apiGet, apiPatch } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { InlineAction } from "@/components/ui/inline-action"; /** @@ -76,12 +77,9 @@ function useUpdateModuleAvailability() { toast.success(t("common.saved")); }, onError: (err) => { - showErrorToast( - err instanceof Error && err.message - ? err.message - : t("admin.modules.saveError"), - { cause: err }, - ); + showErrorToast(localizedApiError(err, t, "admin.modules.saveError"), { + cause: err, + }); client.invalidateQueries({ queryKey: queryKeys.adminModuleAvailability(), }); diff --git a/src/components/admin/pending-deletion-controls.tsx b/src/components/admin/pending-deletion-controls.tsx index e45ba9fcc..f5cc0adf7 100644 --- a/src/components/admin/pending-deletion-controls.tsx +++ b/src/components/admin/pending-deletion-controls.tsx @@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge"; import { ConfirmButton } from "@/components/ui/confirm-button"; import { showErrorToast } from "@/lib/errors/error-toast"; import { apiPost } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { formatDate } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -41,9 +42,7 @@ export function PendingDeletionControls({ user }: { user: AdminUser }) { }, onError: (err) => { showErrorToast( - err instanceof Error && err.message - ? err.message - : t("admin.section.users.undoDeletionFailed"), + localizedApiError(err, t, "admin.section.users.undoDeletionFailed"), { cause: err }, ); }, diff --git a/src/components/admin/pulse-day-mean-card.tsx b/src/components/admin/pulse-day-mean-card.tsx index 705922bac..2e1db2d91 100644 --- a/src/components/admin/pulse-day-mean-card.tsx +++ b/src/components/admin/pulse-day-mean-card.tsx @@ -32,7 +32,13 @@ import { import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { QueryErrorCard } from "@/components/ui/query-error-card"; -import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; +import { + ApiError, + apiFetchRaw, + apiGet, + throwApiError, +} from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { showErrorToast } from "@/lib/errors/error-toast"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -77,23 +83,7 @@ export function PulseDayMeanCard() { headers: { "content-type": "application/json" }, body: JSON.stringify({ action }), }); - if (!res.ok) { - let code: string | undefined; - try { - const body = (await res.json()) as { meta?: { errorCode?: string } }; - code = body.meta?.errorCode; - } catch { - // No envelope: the generic message below. - } - // The route's two refusals each have their own message. - throw new Error( - code === "maintenance.pulseDayMean.busy" - ? t("admin.section.pulseDayMean.error.busy") - : code === "maintenance.pulseDayMean.dryRunRequired" - ? t("admin.section.pulseDayMean.error.dryRunRequired") - : t("admin.section.pulseDayMean.error.generic"), - ); - } + if (!res.ok) await throwApiError(res); const { enqueued } = ( (await res.json()) as { data: { enqueued: boolean } } ).data; @@ -117,8 +107,21 @@ export function PulseDayMeanCard() { queryKey: queryKeys.adminPulseDayMean(), }); }, - onError: (err: Error) => { - showErrorToast(err.message, { cause: err }); + // The route's two refusals each have their own message. + onError: (err) => { + const code = err instanceof ApiError ? err.meta?.errorCode : undefined; + showErrorToast( + code === "maintenance.pulseDayMean.busy" + ? t("admin.section.pulseDayMean.error.busy") + : code === "maintenance.pulseDayMean.dryRunRequired" + ? t("admin.section.pulseDayMean.error.dryRunRequired") + : localizedApiError( + err, + t, + "admin.section.pulseDayMean.error.generic", + ), + { cause: err }, + ); }, }); diff --git a/src/components/admin/recovery-kit-card.tsx b/src/components/admin/recovery-kit-card.tsx index 915754093..7f8c7e8b5 100644 --- a/src/components/admin/recovery-kit-card.tsx +++ b/src/components/admin/recovery-kit-card.tsx @@ -32,6 +32,7 @@ import { NativeSelect } from "@/components/ui/native-select"; import { QueryErrorRow } from "@/components/ui/query-error-row"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { ApiError, apiFetch, apiPost } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { showErrorToast } from "@/lib/errors/error-toast"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -264,11 +265,14 @@ function CheckMyCopy({ kit }: { kit: RecoveryKit }) { setCheckResult(data.matches); setCandidate(""); }, - onError: (err: Error) => { + onError: (err) => { setCheckResult(null); - showErrorToast(err.message || t("admin.recoveryKit.checkFailed"), { - cause: err, - }); + showErrorToast( + localizedApiError(err, t, "admin.recoveryKit.checkFailed"), + { + cause: err, + }, + ); }, }); @@ -395,9 +399,10 @@ export function RecoveryKitCard() { await invalidate(); return; } - showErrorToast(err.message || t("admin.recoveryKit.confirmFailed"), { - cause: err, - }); + showErrorToast( + localizedApiError(err, t, "admin.recoveryKit.confirmFailed"), + { cause: err }, + ); }, }); diff --git a/src/components/admin/reminders-section.tsx b/src/components/admin/reminders-section.tsx index 9856f2e4c..47082fdf5 100644 --- a/src/components/admin/reminders-section.tsx +++ b/src/components/admin/reminders-section.tsx @@ -23,6 +23,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { useAdminSettings, useUpdateSettings } from "./_shared"; import { apiGet, apiPost } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { describeChannelTestFailures, type ChannelTestResult, @@ -163,9 +164,7 @@ export function RemindersSection() { }, onError: (error) => { showErrorToast( - error instanceof Error - ? error.message - : t("admin.notificationTestFailed"), + localizedApiError(error, t, "admin.notificationTestFailed"), { cause: error }, ); }, @@ -201,10 +200,9 @@ export function RemindersSection() { toast.success(data?.message ?? t("admin.reminderCheckSuccess")); }, onError: (error) => { - showErrorToast( - error instanceof Error ? error.message : t("admin.reminderCheckFailed"), - { cause: error }, - ); + showErrorToast(localizedApiError(error, t, "admin.reminderCheckFailed"), { + cause: error, + }); }, }); diff --git a/src/components/admin/umami-section.tsx b/src/components/admin/umami-section.tsx index 7be6ec909..961df303b 100644 --- a/src/components/admin/umami-section.tsx +++ b/src/components/admin/umami-section.tsx @@ -19,6 +19,7 @@ import { ConfiguredBadge, } from "./_shared"; import { apiPost } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; export function UmamiSection() { const { t } = useTranslations(); @@ -52,9 +53,7 @@ export function UmamiSection() { }, onError: (error) => { showErrorToast( - error instanceof Error - ? error.message - : t("admin.monitoringTestFailed"), + localizedApiError(error, t, "admin.monitoringTestFailed"), { cause: error }, ); }, diff --git a/src/components/admin/user-management-section.tsx b/src/components/admin/user-management-section.tsx index 0424b1e6e..f5266f74b 100644 --- a/src/components/admin/user-management-section.tsx +++ b/src/components/admin/user-management-section.tsx @@ -41,7 +41,8 @@ import { queryKeys } from "@/lib/query-keys"; import { type AdminUser, PasswordInput } from "./_shared"; import { PendingDeletionControls } from "./pending-deletion-controls"; import { DeleteUserControl } from "./delete-user-control"; -import { apiGet, apiPost, apiPut } from "@/lib/api/api-fetch"; +import { ApiError, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { recentProofErrorMessage, useRecentProof, @@ -109,12 +110,9 @@ export function UserManagementSection() { toast.success(t("common.saved")); }, onError: (err) => { - showErrorToast( - err instanceof Error && err.message - ? err.message - : t("admin.settingsSaveError"), - { cause: err }, - ); + showErrorToast(localizedApiError(err, t, "admin.settingsSaveError"), { + cause: err, + }); }, }); @@ -132,8 +130,17 @@ export function UserManagementSection() { setResetMsg(t("admin.passwordReset")); setResetPassword(""); }, - onError: (err: Error) => { - setResetMsg(recentProofErrorMessage(err, err.message)); + // The server words its password verdict (too weak, found in a breach) in + // the account's language; any other refusal reads through the catalogue. + onError: (err) => { + setResetMsg( + recentProofErrorMessage( + err, + err instanceof ApiError && err.status === 422 + ? err.message + : localizedApiError(err, t, "common.error"), + ), + ); }, }); @@ -156,9 +163,7 @@ export function UserManagementSection() { }, onError: (err) => { showErrorToast( - err instanceof Error && err.message - ? err.message - : t("admin.section.users.forceLogoutFailed"), + localizedApiError(err, t, "admin.section.users.forceLogoutFailed"), { cause: err }, ); }, @@ -560,7 +565,11 @@ export function UserManagementSection() { {updateUser.isError && (

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

)} diff --git a/src/components/charts/__tests__/use-bmi-divisor.test.ts b/src/components/charts/__tests__/use-bmi-divisor.test.ts new file mode 100644 index 000000000..1216d08eb --- /dev/null +++ b/src/components/charts/__tests__/use-bmi-divisor.test.ts @@ -0,0 +1,59 @@ +/** + * The BMI chart divides the weights on screen by the height of the record on + * screen. It used to know only the viewer's height, so inside another + * person's record it drew nothing at all. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const h = vi.hoisted(() => ({ + inSharedRecord: false, + recordHeightCm: 120 as number | null, + enabled: [] as boolean[], +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ isAuthenticated: true, user: { heightCm: 180 } }), +})); +vi.mock("@/hooks/use-record-capabilities", () => ({ + useRecordCapabilities: () => ({ inSharedRecord: h.inSharedRecord }), +})); +vi.mock("@tanstack/react-query", () => ({ + useQuery: (options: { enabled: boolean }) => { + h.enabled.push(options.enabled); + return options.enabled + ? { data: { profile: { heightCm: h.recordHeightCm } } } + : { data: undefined }; + }, +})); + +import { useBmiDivisor } from "../health-chart/use-bmi-divisor"; + +describe("useBmiDivisor", () => { + beforeEach(() => { + h.inSharedRecord = false; + h.recordHeightCm = 120; + h.enabled.length = 0; + }); + + it("uses the viewer's own height in their own record", () => { + expect(useBmiDivisor("bmi")).toBeCloseTo(3.24); + expect(h.enabled).toEqual([false]); + }); + + it("uses the record's height inside somebody else's record", () => { + h.inSharedRecord = true; + expect(useBmiDivisor("bmi")).toBeCloseTo(1.44); + }); + + it("draws no BMI when the record has no height", () => { + h.inSharedRecord = true; + h.recordHeightCm = null; + expect(useBmiDivisor("bmi")).toBeNull(); + }); + + it("asks for nothing outside the BMI view", () => { + h.inSharedRecord = true; + expect(useBmiDivisor("raw")).toBeNull(); + expect(h.enabled).toEqual([false]); + }); +}); diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 4761e40c6..49909cebf 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -19,7 +19,6 @@ import { import { Skeleton } from "@/components/ui/skeleton"; import { useAuth } from "@/hooks/use-auth"; import { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs"; -import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { useViewportWidth } from "@/hooks/use-viewport-width"; import { axisTickUnitSuffix } from "@/lib/charts/axis-unit"; import { makeBucketLabelFormatters } from "@/lib/charts/bucket-label"; @@ -61,6 +60,7 @@ import { useHealthChartLabels, useHealthChartOverlays, } from "./health-chart/use-health-chart-overlays"; +import { useBmiDivisor } from "./health-chart/use-bmi-divisor"; import { useHealthChartSeries } from "./health-chart/use-health-chart-series"; import { useHealthChartView } from "./health-chart/use-health-chart-view"; import { resolveMiniRangePoints } from "./mini-window"; @@ -157,15 +157,7 @@ export function HealthChart({ // Viewport-aware tick density: 4 to 10 labels, whatever the point count. const viewportWidth = useViewportWidth(); - // `useAuth()` is the person at the keyboard, and under a switch that is the - // delegate: dividing the owner's weights by the delegate's height squared - // gives a plausible BMI that is somebody else's. Inside a shared record the - // chart draws no BMI rather than a wrong one. - const { inSharedRecord } = useRecordCapabilities(); - const bmiDivisor = - valueMode === "bmi" && !inSharedRecord && user?.heightCm - ? (user.heightCm / 100) ** 2 - : null; + const bmiDivisor = useBmiDivisor(valueMode); const { data, isLoading, isError, error, isPlaceholderData, refetch } = useHealthChartSeries({ diff --git a/src/components/charts/health-chart/use-bmi-divisor.ts b/src/components/charts/health-chart/use-bmi-divisor.ts new file mode 100644 index 000000000..520e7e21c --- /dev/null +++ b/src/components/charts/health-chart/use-bmi-divisor.ts @@ -0,0 +1,36 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import { useAuth } from "@/hooks/use-auth"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; +import { apiGet } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; + +/** + * The BMI view's divisor: the height of the record on screen, in metres, + * squared. `useAuth()` is the person at the keyboard, and under a switch that + * is the delegate, so inside somebody else's record the height comes from + * `/api/insights/targets`, the route that knows which record is on screen + * (the BMI page and the reference panel read the same cell). No height, no + * BMI. + */ +export function useBmiDivisor( + valueMode: "raw" | "bmi" | undefined, +): number | null { + const { isAuthenticated, user } = useAuth(); + const { inSharedRecord } = useRecordCapabilities(); + const { data: targets } = useQuery({ + queryKey: queryKeys.insightsTargets(), + queryFn: () => + apiGet<{ profile?: { heightCm: number | null } }>( + "/api/insights/targets", + ), + enabled: isAuthenticated && valueMode === "bmi" && inSharedRecord, + }); + if (valueMode !== "bmi") return null; + const heightCm = inSharedRecord + ? (targets?.profile?.heightCm ?? null) + : (user?.heightCm ?? null); + return heightCm ? (heightCm / 100) ** 2 : null; +} diff --git a/src/components/custom-metrics/custom-metric-form.tsx b/src/components/custom-metrics/custom-metric-form.tsx index ee599f7b1..e0cb0a053 100644 --- a/src/components/custom-metrics/custom-metric-form.tsx +++ b/src/components/custom-metrics/custom-metric-form.tsx @@ -10,7 +10,8 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; -import { ApiError, apiPatch, apiPost } from "@/lib/api/api-fetch"; +import { ApiError, apiPatch } from "@/lib/api/api-fetch"; +import { useIdempotentPost } from "@/lib/api/use-idempotent-post"; import { localizedApiError } from "@/lib/api/localized-error"; import { useTranslations } from "@/lib/i18n/context"; @@ -34,6 +35,18 @@ function parseInteger(raw: string): number | null { return Number.isInteger(n) && n >= 0 ? n : null; } +/** + * Update the metric, or define a new one under one Idempotency-Key until the + * create lands, so a retry after a timeout is not a second metric. + */ +function useSaveCustomMetric(existing: CustomMetricDto | undefined) { + const post = useIdempotentPost(); + return (body: unknown) => + existing + ? apiPatch(`/api/custom-metrics/${existing.id}`, body) + : post("/api/custom-metrics", body); +} + interface CustomMetricFormProps { /** When set the form edits this metric; otherwise it defines a new one. */ existing?: CustomMetricDto; @@ -80,6 +93,7 @@ export function CustomMetricForm({ ); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); + const save = useSaveCustomMetric(existing); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); @@ -110,12 +124,7 @@ export function CustomMetricForm({ setSubmitting(true); try { - const saved = existing - ? await apiPatch( - `/api/custom-metrics/${existing.id}`, - body, - ) - : await apiPost("/api/custom-metrics", body); + const saved = await save(body); toast.success( existing ? t("customMetrics.form.updatedToast") diff --git a/src/components/illness/__tests__/use-illness-save-settles.test.ts b/src/components/illness/__tests__/use-illness-save-settles.test.ts new file mode 100644 index 000000000..59a4b91c7 --- /dev/null +++ b/src/components/illness/__tests__/use-illness-save-settles.test.ts @@ -0,0 +1,87 @@ +/** + * An episode save settles once the server stored it, and stores it once. + * + * Every episode hook's `onSuccess` used to return the refresh of the illness + * reads and the dashboard. A mutation waits for what `onSuccess` returns, so + * the episode sheet kept its spinner until each of those reads had refetched. + * And a create retried after a timeout went out under a new key, so the + * server stored the episode twice. + */ +import { describe, expect, it, vi } from "vitest"; + +const client = vi.hoisted(() => ({ + // A refetch that never answers, as on a slow connection. + invalidateQueries: vi.fn(() => new Promise(() => {})), + refetchQueries: vi.fn(() => new Promise(() => {})), + getQueryCache: () => ({ findAll: () => [] }), +})); + +vi.mock("react", async (importOriginal) => ({ + ...(await importOriginal()), + useRef: (initial: T) => ({ current: initial }), +})); + +vi.mock("@tanstack/react-query", () => ({ + // The hook's options are what is under test; return them as they are. + useQuery: (options: unknown) => options, + useMutation: (options: unknown) => options, + useQueryClient: () => client, +})); + +vi.mock("@/lib/api/api-fetch", () => ({ + apiGet: vi.fn(), + apiGetList: vi.fn(), + apiPost: vi.fn(), + apiPatch: vi.fn(), + apiDelete: vi.fn(), +})); + +import { apiPost } from "@/lib/api/api-fetch"; +import { + useCreateEpisode, + useDeleteEpisode, + useResolveEpisode, + useRestoreEpisode, + useUpdateEpisode, + useUpsertDayLog, +} from "../use-illness"; + +describe("episode saves", () => { + it.each([ + ["create", () => useCreateEpisode()], + ["update", () => useUpdateEpisode()], + ["resolve", () => useResolveEpisode()], + ["delete", () => useDeleteEpisode()], + ["restore", () => useRestoreEpisode()], + ["day log", () => useUpsertDayLog("e1")], + ])("%s starts the refresh and does not wait for it", (_, hook) => { + client.invalidateQueries.mockClear(); + const options = hook() as unknown as { onSuccess: () => unknown }; + + expect(options.onSuccess()).toBeUndefined(); + expect(client.invalidateQueries).toHaveBeenCalled(); + }); +}); + +describe("creating an episode", () => { + it("retries under the same Idempotency-Key and starts the next one fresh", async () => { + const create = useCreateEpisode() as unknown as { + mutationFn: (input: unknown) => Promise; + }; + const post = vi.mocked(apiPost); + post.mockRejectedValueOnce(new TypeError("timed out")); + post.mockResolvedValue({ id: "e1" }); + const keyOf = (call: number) => + new Headers((post.mock.calls[call]![2] as RequestInit).headers).get( + "Idempotency-Key", + ); + + await expect(create.mutationFn({ name: "Flu" })).rejects.toThrow(); + await create.mutationFn({ name: "Flu" }); + await create.mutationFn({ name: "Cold" }); + + expect(keyOf(0)).toBeTruthy(); + expect(keyOf(1)).toBe(keyOf(0)); + expect(keyOf(2)).not.toBe(keyOf(0)); + }); +}); diff --git a/src/components/illness/use-illness.ts b/src/components/illness/use-illness.ts index 562ba032b..c0ed5d8d7 100644 --- a/src/components/illness/use-illness.ts +++ b/src/components/illness/use-illness.ts @@ -17,6 +17,7 @@ import { apiPatch, apiPost, } from "@/lib/api/api-fetch"; +import { useIdempotentPost } from "@/lib/api/use-idempotent-post"; import { illnessDependentKeys, invalidateKeys, @@ -152,18 +153,24 @@ function useInvalidateIllness() { // the Today digest — both unmounted from here, so the bundle invalidation // is paired with the forced inactive refetch. The write routes hard-evict // the server analytics bucket, so the refetch returns post-write context. - return async () => { - await invalidateKeys(qc, illnessDependentKeys); - await refetchInactiveDailyReads(qc); + // Not returned: a mutation waits for what `onSuccess` returns, and the + // sheet would stay open until every read had refetched. + return () => { + void invalidateKeys(qc, illnessDependentKeys).then(() => + refetchInactiveDailyReads(qc), + ); }; } /** Create an episode. */ export function useCreateEpisode() { const invalidate = useInvalidateIllness(); + // One key until a save lands, so a retry after a timeout is not a second + // episode. + const post = useIdempotentPost(); return useMutation({ mutationFn: (input: IllnessEpisodeCreateInput) => - apiPost("/api/illness/episodes", input), + post("/api/illness/episodes", input), onSuccess: invalidate, }); } diff --git a/src/components/insights/coach-panel/__tests__/attachment-picker-cap.test.tsx b/src/components/insights/coach-panel/__tests__/attachment-picker-cap.test.tsx new file mode 100644 index 000000000..a4d117b05 --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/attachment-picker-cap.test.tsx @@ -0,0 +1,71 @@ +/** + * The Coach's "Choose from documents" picker reads one page of the vault, the + * newest fifty. With a longer vault it listed those and said nothing, so an + * older report seemed not to exist unless one already knew to search for it. + * + * ResponsiveSheet portals its body, which static markup does not render, so it + * is mocked down to a wrapper that always shows its children. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +const page = vi.hoisted(() => ({ nextCursor: null as string | null })); + +vi.mock("@/components/ui/responsive-sheet", () => ({ + ResponsiveSheet: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ + data: { + // A cut page is a full one. + documents: Array.from({ length: page.nextCursor ? 50 : 3 }, (_, i) => ({ + id: `d${i}`, + kind: "LAB_RESULT", + title: `Blood panel ${i}`, + filename: null, + hasContentIndex: true, + })), + nextCursor: page.nextCursor, + }, + isPending: false, + isError: false, + }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { AttachmentPicker } from "../attachment-picker"; + +function render() { + return renderToStaticMarkup( + + undefined} + remainingSlots={3} + excludeIds={[]} + onConfirm={() => undefined} + /> + , + ); +} + +describe("AttachmentPicker", () => { + it("says the list is cut, and how to reach the rest", () => { + page.nextCursor = "d49"; + const html = render(); + expect(html).toContain('data-slot="coach-attachment-picker-capped"'); + expect(html).toContain("Showing the newest 50"); + }); + + it("says nothing when every document is listed", () => { + page.nextCursor = null; + const html = render(); + expect(html).not.toContain('data-slot="coach-attachment-picker-capped"'); + expect( + html.match(/data-slot="coach-attachment-picker-item"/g), + ).toHaveLength(3); + }); +}); diff --git a/src/components/insights/coach-panel/__tests__/chat-bubble-initials.test.tsx b/src/components/insights/coach-panel/__tests__/chat-bubble-initials.test.tsx new file mode 100644 index 000000000..badd7f82c --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/chat-bubble-initials.test.tsx @@ -0,0 +1,52 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +/** + * The person's bubble falls back to initials of the name the app shows, like + * the top bar, never the login: a managed record's login is a generated + * handle, and "MA" for `managed-…` is nobody's initials. + */ +const auth = vi.hoisted(() => ({ + user: { + id: "u1", + username: "managed-1f2e3d", + displayName: "Alex Example" as string | null, + role: "USER", + avatarUrl: null, + }, +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ user: auth.user, isAuthenticated: true, isLoading: false }), +})); +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: undefined }), + useMutation: () => ({ mutate: vi.fn(), isPending: false, isError: false }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), +})); + +import { ChatBubble } from "../chat-bubble"; + +function userBubble(): string { + return renderToStaticMarkup( + + + , + ); +} + +describe("coach bubble initials", () => { + it("spells the display name, not the login", () => { + const html = userBubble(); + expect(html).toContain(">AE<"); + expect(html).not.toContain(">MA<"); + }); + + it("falls back to the login when there is no display name", () => { + auth.user.displayName = null; + auth.user.username = "sam"; + expect(userBubble()).toContain(">S<"); + }); +}); diff --git a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx index f26660ff9..9c28c40b1 100644 --- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx +++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx @@ -551,10 +551,11 @@ describe("", () => { }); it("falls back to initials when no avatar URL is present", () => { - // Default mock returns avatarUrl: null → initials path. + // Default mock returns avatarUrl: null → initials path, spelled like the + // top bar's: one word, one letter. const html = render(); expect(html).toContain('data-slot="coach-bubble-user-avatar"'); - expect(html).toMatch(/data-slot="coach-bubble-user-avatar"[^>]*>TE]*>T void; } +/** One document in the picker: a toggle, disabled until it is indexed. */ +function PickerRow({ + doc, + isSelected, + disabled, + onToggle, +}: { + doc: InboundDocumentDto; + isSelected: boolean; + disabled: boolean; + onToggle: () => void; +}) { + const { t } = useTranslations(); + const title = doc.title ?? doc.filename ?? t("documents.card.untitled"); + const Icon = DOCUMENT_KIND_ICONS[doc.kind]; + return ( +
  • + +
  • + ); +} + export function AttachmentPicker({ open, onOpenChange, @@ -107,6 +159,8 @@ export function AttachmentPicker({ ); const capReached = selected.length >= remainingSlots; + // More match than one page: say so, and that the search reaches the rest. + const cut = Boolean(list.data?.nextCursor); function toggle(id: string) { setSelected((prev) => { @@ -200,55 +254,28 @@ export function AttachmentPicker({
      {documents.map((doc) => { const isSelected = selected.includes(doc.id); - const selectable = doc.hasContentIndex; - const disabled = !selectable || (!isSelected && capReached); - const title = - doc.title ?? doc.filename ?? t("documents.card.untitled"); - const Icon = DOCUMENT_KIND_ICONS[doc.kind]; return ( -
    • - -
    • + toggle(doc.id)} + /> ); })}
    )} + {cut && !list.isError ? ( +

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

    + ) : null} ); diff --git a/src/components/insights/coach-panel/chat-bubble.tsx b/src/components/insights/coach-panel/chat-bubble.tsx index 21f341c8f..a150b6abc 100644 --- a/src/components/insights/coach-panel/chat-bubble.tsx +++ b/src/components/insights/coach-panel/chat-bubble.tsx @@ -9,6 +9,7 @@ import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { useTranslations } from "@/lib/i18n/context"; import { useAuth } from "@/hooks/use-auth"; +import { accountName, nameInitials } from "@/lib/account-name"; import { parseChartTokens, tokenToMetric, @@ -388,12 +389,10 @@ function ChatBubbleImpl({ if (role === "user") { // v1.5.5 — pull the user's self-hosted avatar so the user // bubble matches the Coach avatar in size and visual weight. - // Falls back to initials when the user has not uploaded an - // avatar. Replaces the Gravatar leak (v1.4.22 B3). + // Falls back to the display name's initials, as the top bar does, when + // the user has not uploaded an avatar. const avatarUrl = user?.avatarUrl ?? null; - const initials = user?.username - ? user.username.slice(0, 2).toUpperCase() - : null; + const initials = user ? nameInitials(accountName(user)) || null : null; return (
    - setError( - e instanceof Error && e.message - ? e.message - : t("insights.coach.frame.saveFailed"), - ); + setError(localizedApiError(e, t, "insights.coach.frame.saveFailed")); const switchProvider = useMutation({ mutationFn: async (provider: ProviderType) => { diff --git a/src/components/measurements/list/__tests__/use-measurement-list-deletes-settle.test.ts b/src/components/measurements/list/__tests__/use-measurement-list-deletes-settle.test.ts new file mode 100644 index 000000000..2b62ccaba --- /dev/null +++ b/src/components/measurements/list/__tests__/use-measurement-list-deletes-settle.test.ts @@ -0,0 +1,98 @@ +/** + * The list's bulk delete and its Undo answer once the server has done it. + * + * Both used to wait for every read a measurement touches (charts, insights, + * the dashboard) before the selection bar cleared or the toast showed, so on + * a slow connection a delete looked like it had not happened. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const h = vi.hoisted(() => ({ + client: { + // A refetch that never answers, as on a slow connection. + invalidateQueries: vi.fn(() => new Promise(() => {})), + getQueryCache: () => ({ findAll: () => [] }), + }, + toastSuccess: vi.fn(), + apiPost: vi.fn(async () => ({ deleted: 2 })), +})); + +vi.mock("react", async (importOriginal) => ({ + ...(await importOriginal()), + useCallback: (fn: T) => fn, + useEffect: () => {}, +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: undefined, isLoading: false, isError: false }), + // The hook's options are what is under test; return them as they are. + useMutation: (options: unknown) => options, + useQueryClient: () => h.client, +})); + +vi.mock("@/lib/api/api-fetch", () => ({ + apiGet: vi.fn(), + apiDelete: vi.fn(), + apiPost: h.apiPost, +})); + +vi.mock("@/lib/i18n/context", () => ({ + useTranslations: () => ({ t: (key: string) => key }), +})); + +vi.mock("sonner", () => ({ toast: { success: h.toastSuccess } })); + +import type { MeasurementListFilters } from "../use-measurement-list-filters"; +import { useMeasurementListData } from "../use-measurement-list-data"; + +function useBulkDelete(clearSelection = vi.fn()) { + const filters = { + typeFilter: "ALL", + selectedIds: new Set(), + setSelectedIds: vi.fn(), + clearSelection, + } as unknown as MeasurementListFilters; + const data = useMeasurementListData({ + filters, + enabled: true, + lockedType: undefined, + }); + return data.bulkDeleteMutation as unknown as { + onSuccess: (deleted: number, ids: string[]) => unknown; + }; +} + +describe("measurement list deletes", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("clears the selection and says so without waiting for the refetch", () => { + const clearSelection = vi.fn(); + const bulk = useBulkDelete(clearSelection); + + expect(bulk.onSuccess(2, ["a", "b"])).toBeUndefined(); + expect(clearSelection).toHaveBeenCalled(); + expect(h.toastSuccess).toHaveBeenCalledWith( + "measurements.bulkDeleteSuccess", + expect.anything(), + ); + }); + + it("confirms the Undo once the rows are back, before the refetch", async () => { + useBulkDelete().onSuccess(2, ["a", "b"]); + const undo = h.toastSuccess.mock.calls[0]![1] as { + action: { onClick: () => void }; + }; + + undo.action.onClick(); + await vi.waitFor(() => + expect(h.toastSuccess).toHaveBeenLastCalledWith( + "measurements.restoredToast", + ), + ); + expect(h.apiPost).toHaveBeenLastCalledWith("/api/measurements/restore", { + ids: ["a", "b"], + }); + }); +}); diff --git a/src/components/measurements/list/use-measurement-list-data.ts b/src/components/measurements/list/use-measurement-list-data.ts index 81c2db1e9..472bdf88b 100644 --- a/src/components/measurements/list/use-measurement-list-data.ts +++ b/src/components/measurements/list/use-measurement-list-data.ts @@ -114,9 +114,9 @@ export function useMeasurementListData({ async (ids: string[]) => { try { await apiPost("/api/measurements/restore", { ids }); - await invalidateKeys(queryClient, measurementDependentKeys); - await refetchInactiveDailyReads(queryClient); toast.success(t("measurements.restoredToast")); + void invalidateKeys(queryClient, measurementDependentKeys); + void refetchInactiveDailyReads(queryClient); } catch (failure) { showErrorToast(t("measurements.restoreError"), { cause: failure }); } @@ -154,9 +154,11 @@ export function useMeasurementListData({ ); return data.deleted; }, - onSuccess: async (deleted, ids) => { - await invalidateKeys(queryClient, measurementDependentKeys); - await refetchInactiveDailyReads(queryClient); + // The refresh is started, not returned: the selection bar and the toast + // answer as soon as the rows are gone. + onSuccess: (deleted, ids) => { + void invalidateKeys(queryClient, measurementDependentKeys); + void refetchInactiveDailyReads(queryClient); clearSelection(); toast.success( deleted === 1 diff --git a/src/components/medications/__tests__/use-medication-intake.test.ts b/src/components/medications/__tests__/use-medication-intake.test.ts index 7d8d500d3..0e323f312 100644 --- a/src/components/medications/__tests__/use-medication-intake.test.ts +++ b/src/components/medications/__tests__/use-medication-intake.test.ts @@ -366,7 +366,41 @@ describe("runLogIntake — manual backdated intake from the Add choice", () => { takenAt: "2026-06-01T08:30:00.000Z", }); expect(body).not.toHaveProperty("scheduledFor"); - expect(queryClient.invalidateQueries).toHaveBeenCalled(); + // Started, not awaited (the case below). + await vi.waitFor(() => + expect(queryClient.invalidateQueries).toHaveBeenCalled(), + ); + expect(toast.success).toHaveBeenCalledWith( + "medications.intakeToastTaken:Mounjaro", + ); + }); + + it("answers once the dose is stored, without waiting for the refetches", async () => { + // The dialog closes on this answer; on a slow connection the reads it + // refreshes can take seconds. + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers(), + json: vi.fn(), + text: vi.fn().mockResolvedValue(""), + }), + ); + const queryClient = { + invalidateQueries: vi.fn(() => new Promise(() => {})), + getQueryCache: () => ({ findAll: () => [] }), + } as unknown as QueryClient; + + await expect( + runLogIntake({ + medication, + skipped: false, + takenAt: "2026-06-01T08:30:00.000Z", + t, + queryClient, + }), + ).resolves.toBe(true); expect(toast.success).toHaveBeenCalledWith( "medications.intakeToastTaken:Mounjaro", ); diff --git a/src/components/medications/dose-history-add-dialog.tsx b/src/components/medications/dose-history-add-dialog.tsx index 04789ed39..2f935152f 100644 --- a/src/components/medications/dose-history-add-dialog.tsx +++ b/src/components/medications/dose-history-add-dialog.tsx @@ -193,16 +193,14 @@ export function LedgerAddDialog({ } else { toast.success(message); } - await invalidateKeys(queryClient, [ - ...medicationDependentKeys, - ledgerKey, - ]); - // A ledger add can land a today-dated dose, so clear the unmounted - // Today hero + dashboard too (v1.32.19 — same class as the ledger's - // own log/edit/delete sites). - await refetchInactiveDailyReads(queryClient); setNudgeOpen(false); onOpenChange(false); + // Started, not awaited: the dialog closes once the dose is stored. A + // ledger add can land a today-dated dose, so clear the unmounted Today + // hero + dashboard too (same class as the ledger's own log/edit/delete + // sites). + void invalidateKeys(queryClient, [...medicationDependentKeys, ledgerKey]); + void refetchInactiveDailyReads(queryClient); } catch (failure) { showErrorToast( t("medications.intakeToastFailed", { name: medicationName }), diff --git a/src/components/medications/intake-edit-dialog.tsx b/src/components/medications/intake-edit-dialog.tsx index 48da1b6f4..7c55e6309 100644 --- a/src/components/medications/intake-edit-dialog.tsx +++ b/src/components/medications/intake-edit-dialog.tsx @@ -130,12 +130,14 @@ function IntakeEditDialogBody({ body.takenAt = null; } await apiPut(`/api/medications/${medicationId}/intake/${event.id}`, body); - // Editing today's dose (un-skip, retime) changes its due/taken state — - // route through the blessed helper so the Today hero + dashboard clear - // at once even when they are unmounted behind the detail page. - await invalidateMedicationReads(queryClient); toast.success(t("medications.detail.intake.edit.savedToast")); onClose(); + // Editing today's dose (un-skip, retime) changes its due/taken state — + // route through the blessed helper so the Today hero + dashboard clear + // at once even when they are unmounted behind the detail page. Started, + // not awaited: the edit names one row, so nothing here can be sent twice + // while the reads catch up. + void invalidateMedicationReads(queryClient); } catch (failure) { showErrorToast(t("medications.detail.intake.edit.failed"), { cause: failure, diff --git a/src/components/medications/intake-import-dialog.tsx b/src/components/medications/intake-import-dialog.tsx index b9f2c9c57..c34c1290f 100644 --- a/src/components/medications/intake-import-dialog.tsx +++ b/src/components/medications/intake-import-dialog.tsx @@ -38,11 +38,9 @@ import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { useTranslations } from "@/lib/i18n/context"; import { invalidateMedicationReads } from "@/lib/query-keys"; -import { ApiError, apiPost } from "@/lib/api/api-fetch"; -import { - MedicationIntakeImportError, - waitForMedicationIntakeImport, -} from "@/lib/medications/intake-import-poll"; +import { apiPost } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; +import { waitForMedicationIntakeImport } from "@/lib/medications/intake-import-poll"; export interface IntakeImportDialogProps { medicationId: string | null; @@ -132,19 +130,12 @@ export function IntakeImportDialog({ // import dialog on the detail page). void invalidateMedicationReads(queryClient); } catch (err) { - if (err instanceof MedicationIntakeImportError) { - setResult({ - kind: "notice", - tone: "error", - text: t("medications.importFailed"), - }); - return; - } - if (!(err instanceof ApiError)) throw err; + // A refusal reads through the API catalogue; a failed job's reason is + // the worker's English, so it reads as the plain failure. setResult({ kind: "notice", tone: "error", - text: err.message || t("medications.importFailed"), + text: localizedApiError(err, t, "medications.importFailed"), }); } } catch (err) { diff --git a/src/components/medications/scheduling/natural-language-extractor.tsx b/src/components/medications/scheduling/natural-language-extractor.tsx index fdf204b9e..4f0ddac29 100644 --- a/src/components/medications/scheduling/natural-language-extractor.tsx +++ b/src/components/medications/scheduling/natural-language-extractor.tsx @@ -95,10 +95,9 @@ type DialogError = | { kind: "budget" } | { kind: "consent" } | { kind: "unavailable" } - | { kind: "network"; message?: string }; + | { kind: "network" }; interface ExtractFailureBody { - error?: string | null; meta?: { errorCode?: unknown } | null; } @@ -127,7 +126,7 @@ export function classifyExtractFailure( } if (status === 429) return { kind: "rateLimit" }; if (status === 503) return { kind: "noProvider" }; - return { kind: "network", message: body?.error ?? undefined }; + return { kind: "network" }; } const MAX_TEXT_LENGTH = 2000; @@ -200,7 +199,7 @@ export function NaturalLanguageExtractor({ error: string | null; }; if (body.error || !body.data) { - setError({ kind: "network", message: body.error ?? undefined }); + setError({ kind: "network" }); return; } onPrefill(body.data); @@ -214,10 +213,7 @@ export function NaturalLanguageExtractor({ // Caller dismissed the dialog mid-flight; do not surface. return; } - setError({ - kind: "network", - message: err instanceof Error ? err.message : undefined, - }); + setError({ kind: "network" }); } finally { setBusy(false); abortRef.current = null; diff --git a/src/components/medications/sections/api-tokens-row.tsx b/src/components/medications/sections/api-tokens-row.tsx index 9f50d2e7b..e47b53b3b 100644 --- a/src/components/medications/sections/api-tokens-row.tsx +++ b/src/components/medications/sections/api-tokens-row.tsx @@ -37,7 +37,8 @@ import { medicationDependentKeys, queryKeys, } from "@/lib/query-keys"; -import { ApiError, apiGet, apiPut } from "@/lib/api/api-fetch"; +import { apiGet, apiPut } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; export interface ApiTokensRowProps { medicationId: string; @@ -182,9 +183,7 @@ export function ApiTokensRow({ }); } catch (err) { showErrorToast( - err instanceof ApiError && err.message - ? err.message - : t("medications.detail.api.mintFailed"), + localizedApiError(err, t, "medications.detail.api.mintFailed"), { cause: err }, ); } finally { diff --git a/src/components/medications/sections/data-portability-row.tsx b/src/components/medications/sections/data-portability-row.tsx index 2ad68d5f5..a6b8311cc 100644 --- a/src/components/medications/sections/data-portability-row.tsx +++ b/src/components/medications/sections/data-portability-row.tsx @@ -72,8 +72,8 @@ export function DataPortabilityRow({ `/api/export/medications?${query}`, `healthlog-medication-${stamp}.csv`, ); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); + } catch { + setError(t("settings.sections.export.downloadFailed")); } finally { setBusy(false); } diff --git a/src/components/medications/sections/phase-config-sheet.tsx b/src/components/medications/sections/phase-config-sheet.tsx index 5c00ea61f..ea19de4a5 100644 --- a/src/components/medications/sections/phase-config-sheet.tsx +++ b/src/components/medications/sections/phase-config-sheet.tsx @@ -48,7 +48,8 @@ import { medicationDependentKeys, queryKeys, } from "@/lib/query-keys"; -import { ApiError, apiGet, apiPut } from "@/lib/api/api-fetch"; +import { apiGet, apiPut } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { QueryErrorRow } from "@/components/ui/query-error-row"; type PhaseMode = "MINUTES" | "PERCENT"; @@ -229,9 +230,7 @@ function PhaseConfigForm({ onClose(); } catch (err) { showErrorToast( - err instanceof ApiError && err.message - ? err.message - : t("medications.detail.phases.saveFailed"), + localizedApiError(err, t, "medications.detail.phases.saveFailed"), { cause: err }, ); } finally { diff --git a/src/components/medications/side-effects-section.tsx b/src/components/medications/side-effects-section.tsx index 365de1d29..82492bd5d 100644 --- a/src/components/medications/side-effects-section.tsx +++ b/src/components/medications/side-effects-section.tsx @@ -49,6 +49,7 @@ import { } from "@/lib/medications/side-effects/taxonomy"; import { MedicationDetailSection } from "@/components/medications/medication-detail-section"; import { apiDelete, apiGetList, apiPost } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { queryKeys } from "@/lib/query-keys"; import { EmptyState } from "@/components/ui/empty-state"; @@ -291,7 +292,9 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) { setOpen(false); }, onError: (err) => { - setFormError(err instanceof Error ? err.message : String(err)); + setFormError( + localizedApiError(err, t, "medications.sideEffects.saveError"), + ); }, }, ); diff --git a/src/components/medications/use-medication-intake.ts b/src/components/medications/use-medication-intake.ts index 1bb3c72be..33f5d8446 100644 --- a/src/components/medications/use-medication-intake.ts +++ b/src/components/medications/use-medication-intake.ts @@ -281,7 +281,10 @@ export async function runLogIntake(deps: { body, ); const eventId = created?.id; - await invalidateMedicationReads(queryClient); + // Started, not awaited: the dialog closes on this answer. A scheduled + // dose lands on its slot, so a stale card tapped meanwhile only repeats + // it. + void invalidateMedicationReads(queryClient); const message = t( skipped ? "medications.intakeToastSkipped" diff --git a/src/components/mental-health/mental-wellbeing.tsx b/src/components/mental-health/mental-wellbeing.tsx index 0cdebd557..88a14a5b6 100644 --- a/src/components/mental-health/mental-wellbeing.tsx +++ b/src/components/mental-health/mental-wellbeing.tsx @@ -31,7 +31,8 @@ import { useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useTranslations } from "@/lib/i18n/context"; -import { apiGetList, apiPost } from "@/lib/api/api-fetch"; +import { apiGetList } from "@/lib/api/api-fetch"; +import { useIdempotentPost } from "@/lib/api/use-idempotent-post"; import { invalidateKeys, queryKeys, @@ -57,9 +58,35 @@ import type { } from "./types"; import { ListCapNotice } from "@/components/ui/list-cap-notice"; +/** + * The check-in's save, under one Idempotency-Key until it lands, so a second + * tap after a timeout replays the stored screening instead of adding one. + */ +function useSubmitAssessment(onSaved: (data: CreateResponse) => void) { + const queryClient = useQueryClient(); + const post = useIdempotentPost(); + return useMutation({ + mutationFn: (body: unknown) => + post("/api/mental-health/assessments", body), + onSuccess: (data) => { + onSaved(data); + // A screening is listed on its day, and it can satisfy a due checkup + // reminder, so the checkups list and the Today rail must repaint. + // Honest constraint: the server satisfy runs async via pg-boss, so + // this invalidation narrows the stale window but cannot close it — a + // refetch racing the job may still see the pre-satisfy row. + void invalidateKeys(queryClient, [ + queryKeys.mentalHealthAssessments(), + queryKeys.measurementReminders(), + queryKeys.dayRoot(), + ]); + void refetchInactiveDailyReads(queryClient); + }, + }); +} + export function MentalWellbeing() { const { t, locale } = useTranslations(); - const queryClient = useQueryClient(); const [phase, setPhase] = useState("choose"); const [instrument, setInstrument] = useState("PHQ9"); @@ -94,24 +121,9 @@ export function MentalWellbeing() { }); const history = historyPage?.data; - const mutation = useMutation({ - mutationFn: (body: unknown) => - apiPost("/api/mental-health/assessments", body), - onSuccess: (data) => { - setResult(data); - setPhase("result"); - // A screening is listed on its day, and it can satisfy a due checkup - // reminder, so the checkups list and the Today rail must repaint. - // Honest constraint: the server satisfy runs async via pg-boss, so - // this invalidation narrows the stale window but cannot close it — a - // refetch racing the job may still see the pre-satisfy row. - void invalidateKeys(queryClient, [ - queryKeys.mentalHealthAssessments(), - queryKeys.measurementReminders(), - queryKeys.dayRoot(), - ]); - void refetchInactiveDailyReads(queryClient); - }, + const mutation = useSubmitAssessment((data) => { + setResult(data); + setPhase("result"); }); // Most-recent assessment per instrument for the landing's "last result" line. diff --git a/src/components/mood/manage/archived-tags-card.tsx b/src/components/mood/manage/archived-tags-card.tsx index f88ffd2ea..0c3d2b77d 100644 --- a/src/components/mood/manage/archived-tags-card.tsx +++ b/src/components/mood/manage/archived-tags-card.tsx @@ -19,6 +19,7 @@ import { import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { ApiError, apiDelete, apiPatch } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { useTranslations } from "@/lib/i18n/context"; import { moodTagIcon } from "../mood-tag-icons"; import { @@ -74,10 +75,9 @@ export function ArchivedTagsCard({ catalog }: ArchivedTagsCardProps) { if (err instanceof ApiError && err.status === 422) { showErrorToast(t("mood.manage.tagLimitReached"), { cause: err }); } else { - showErrorToast( - err instanceof ApiError ? err.message : t("common.error"), - { cause: err }, - ); + showErrorToast(localizedApiError(err, t, "common.error"), { + cause: err, + }); } } finally { void invalidateMoodTagCaches(queryClient); @@ -94,10 +94,7 @@ export function ArchivedTagsCard({ catalog }: ArchivedTagsCardProps) { toast.success(t("mood.manage.purged")); } catch (err) { rollback(); - showErrorToast( - err instanceof ApiError ? err.message : t("common.error"), - { cause: err }, - ); + showErrorToast(localizedApiError(err, t, "common.error"), { cause: err }); } finally { void invalidateMoodTagCaches(queryClient); } diff --git a/src/components/mood/manage/tag-editor-sheet.tsx b/src/components/mood/manage/tag-editor-sheet.tsx index c2014b3db..53a801a6c 100644 --- a/src/components/mood/manage/tag-editor-sheet.tsx +++ b/src/components/mood/manage/tag-editor-sheet.tsx @@ -12,6 +12,7 @@ import { Label } from "@/components/ui/label"; import { NativeSelect } from "@/components/ui/native-select"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { ApiError, apiPatch, apiPost } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { MoodTagIconPicker } from "../mood-tag-icon-picker"; @@ -144,10 +145,9 @@ export function TagEditorSheet({ if (err instanceof ApiError && err.status === 422) { showErrorToast(t("mood.manage.tagLimitReached"), { cause: err }); } else { - showErrorToast( - err instanceof ApiError ? err.message : t("common.error"), - { cause: err }, - ); + showErrorToast(localizedApiError(err, t, "common.error"), { + cause: err, + }); } } finally { setSaving(false); diff --git a/src/components/mood/manage/tag-groups-card.tsx b/src/components/mood/manage/tag-groups-card.tsx index 8874a7f59..a2e1e66c9 100644 --- a/src/components/mood/manage/tag-groups-card.tsx +++ b/src/components/mood/manage/tag-groups-card.tsx @@ -60,6 +60,7 @@ import { apiPost, apiPut, } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { readUpdatedAtToken, withBaseToken, @@ -175,10 +176,7 @@ export function TagGroupsCard({ catalog }: TagGroupsCardProps) { toast.message(t("common.conflictReloaded")); return false; } - showErrorToast( - err instanceof ApiError ? err.message : t("common.error"), - { cause: err }, - ); + showErrorToast(localizedApiError(err, t, "common.error"), { cause: err }); return false; } finally { void invalidateMoodTagCaches(queryClient); @@ -359,10 +357,9 @@ export function TagGroupsCard({ catalog }: TagGroupsCardProps) { ); toast.success(t("mood.manage.groupDeleted")); } catch (err) { - showErrorToast( - err instanceof ApiError ? err.message : t("common.error"), - { cause: err }, - ); + showErrorToast(localizedApiError(err, t, "common.error"), { + cause: err, + }); } finally { void invalidateMoodTagCaches(queryClient); } @@ -562,10 +559,9 @@ function GroupEditorSheet({ if (err instanceof ApiError && err.status === 422) { showErrorToast(t("mood.manage.groupLimitReached"), { cause: err }); } else { - showErrorToast( - err instanceof ApiError ? err.message : t("common.error"), - { cause: err }, - ); + showErrorToast(localizedApiError(err, t, "common.error"), { + cause: err, + }); } } finally { setSaving(false); diff --git a/src/components/mood/manage/tag-manager-card.tsx b/src/components/mood/manage/tag-manager-card.tsx index c5246873a..b8e32b506 100644 --- a/src/components/mood/manage/tag-manager-card.tsx +++ b/src/components/mood/manage/tag-manager-card.tsx @@ -47,7 +47,8 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { ApiError, apiPatch, apiPut } from "@/lib/api/api-fetch"; +import { apiPatch, apiPut } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { readUpdatedAtToken, withBaseToken, @@ -168,10 +169,7 @@ export function TagManagerCard({ catalog }: TagManagerCardProps) { toast.message(t("common.conflictReloaded")); return false; } - showErrorToast( - err instanceof ApiError ? err.message : t("common.error"), - { cause: err }, - ); + showErrorToast(localizedApiError(err, t, "common.error"), { cause: err }); return false; } finally { void invalidateMoodTagCaches(queryClient); @@ -243,10 +241,7 @@ export function TagManagerCard({ catalog }: TagManagerCardProps) { } } catch (err) { rollback(); - showErrorToast( - err instanceof ApiError ? err.message : t("common.error"), - { cause: err }, - ); + showErrorToast(localizedApiError(err, t, "common.error"), { cause: err }); } finally { void invalidateMoodTagCaches(queryClient); } @@ -283,10 +278,7 @@ export function TagManagerCard({ catalog }: TagManagerCardProps) { toast.message(t("common.conflictReloaded")); return; } - showErrorToast( - err instanceof ApiError ? err.message : t("common.error"), - { cause: err }, - ); + showErrorToast(localizedApiError(err, t, "common.error"), { cause: err }); } finally { void invalidateMoodTagCaches(queryClient); } diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index c9cc771d0..c811157e7 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -383,9 +383,9 @@ export function MoodList({ onAddFirst, onEmptyChange }: MoodListProps = {}) { async (ids: string[]) => { try { await apiPost("/api/mood-entries/restore", { ids }); - await invalidateKeys(queryClient, moodDependentKeys); - await refetchInactiveDailyReads(queryClient); toast.success(t("mood.restoredToast")); + void invalidateKeys(queryClient, moodDependentKeys); + void refetchInactiveDailyReads(queryClient); } catch (failure) { showErrorToast(t("mood.restoreError"), { cause: failure }); } @@ -425,9 +425,9 @@ export function MoodList({ onAddFirst, onEmptyChange }: MoodListProps = {}) { ); return data.deleted; }, - onSuccess: async (deleted, ids) => { - await invalidateKeys(queryClient, moodDependentKeys); - await refetchInactiveDailyReads(queryClient); + onSuccess: (deleted, ids) => { + void invalidateKeys(queryClient, moodDependentKeys); + void refetchInactiveDailyReads(queryClient); clearSelection(); toast.success( deleted === 1 @@ -506,12 +506,12 @@ export function MoodList({ onAddFirst, onEmptyChange }: MoodListProps = {}) { moodLoggedAt, }); }, - onSuccess: async (saved) => { + onSuccess: (saved) => { // A tag picked in the dialog may have been archived or deleted // elsewhere since it opened; the server names what it did not store. if (droppedAnyLinkKeys(saved)) toast.warning(t("mood.tagsNotSaved")); - await invalidateKeys(queryClient, moodDependentKeys); - await refetchInactiveDailyReads(queryClient); + void invalidateKeys(queryClient, moodDependentKeys); + void refetchInactiveDailyReads(queryClient); setEditSeed({ mood: editMood, tagsInput: editTagsInput, diff --git a/src/components/onboarding/tour-launcher.tsx b/src/components/onboarding/tour-launcher.tsx index 71786dbde..77205fd6d 100644 --- a/src/components/onboarding/tour-launcher.tsx +++ b/src/components/onboarding/tour-launcher.tsx @@ -244,8 +244,7 @@ export function TourLauncher({ ready = true }: TourLauncherProps) { // v1.4.47 — clock reading for the 24 h auto-launch gate. The // useState lazy-initialiser runs once at mount; subsequent re-renders // reuse the same value. This keeps the render path pure (React's - // purity linter rejects a bare `Date.now()` reference in render) and - // matches the established pattern in `personal-record-badge.tsx`: + // purity linter rejects a bare `Date.now()` reference in render): // capture the clock at a stable boundary, then derive freshness from // it. The launcher only consults this value once per (user, flag) // tuple via the `decidedFor` guard, so a mount that takes ten diff --git a/src/components/records/family-history-form.tsx b/src/components/records/family-history-form.tsx index 96cc17545..849e10f70 100644 --- a/src/components/records/family-history-form.tsx +++ b/src/components/records/family-history-form.tsx @@ -16,7 +16,8 @@ import { SelectValue, } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; -import { ApiError, apiPost, apiPut } from "@/lib/api/api-fetch"; +import { apiPost, apiPut } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { familyRelationshipEnum } from "@/lib/validations/family-history"; import { useTranslations } from "@/lib/i18n/context"; @@ -99,10 +100,11 @@ export function FamilyHistoryForm({ ); onSuccess?.(saved); } catch (err) { - const message = - err instanceof ApiError - ? err.message - : t("records.family.form.saveError"); + const message = localizedApiError( + err, + t, + "records.family.form.saveError", + ); setError(message); } finally { setSubmitting(false); diff --git a/src/components/settings/__tests__/coach-entry-follows-capability.test.tsx b/src/components/settings/__tests__/coach-entry-follows-capability.test.tsx new file mode 100644 index 000000000..c30b49e8c --- /dev/null +++ b/src/components/settings/__tests__/coach-entry-follows-capability.test.tsx @@ -0,0 +1,89 @@ +/** + * Settings lists the Coach entry only while the Coach can answer, like the + * sidebar and the bottom bar, and Settings → AI then keeps the stored Coach + * memory in reach. + * + * Both used to read the raw module map, which answers the operator's switch + * and Hide Coach but not a missing provider or missing consent: the settings + * nav kept a Coach entry the sidebar had already dropped. The two now read + * the same map (`useNavModules`), so the memory's own entry and the fallback + * under Settings → AI cannot both go. + * + * The shell's module filter runs after hydration only, so `useMounted` is + * mocked to the hydrated answer. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +const h = vi.hoisted(() => ({ coachAvailable: true })); + +vi.mock("next/navigation", () => ({ + usePathname: () => "/settings/ai", + useRouter: () => ({ replace: () => {}, push: () => {} }), +})); +vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true })); +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1", role: "USER", modules: { coach: true } }, + isAuthenticated: true, + isLoading: false, + refetch: vi.fn(), + }), +})); +vi.mock("@/hooks/use-ai-capability", () => ({ + useAiCapability: () => ({ + available: h.coachAvailable, + reason: h.coachAvailable ? null : "no_provider", + onDeviceAllowed: true, + }), +})); +// Settings → AI's cards have their own suites; only the memory slot is +// under test here. +vi.mock("@/components/ai/ai-consent-request", () => ({ + AiConsentRequest: () => null, +})); +vi.mock("@/components/settings/ai/ai-insights-card", () => ({ + AiInsightsCard: () => null, +})); +vi.mock("@/components/settings/ai/operator-off-notice", () => ({ + OperatorOffNotice: () => null, +})); +vi.mock("@/components/insights/ai-setup-hint", () => ({ + AiSetupHint: () => null, +})); +vi.mock("@/components/settings/coach-memory-section", () => ({ + StoredCoachMemory: () =>
    , +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { AiSection } from "../ai-section"; +import { SettingsShell } from "../settings-shell"; + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +describe("the Coach entry in Settings", () => { + beforeEach(() => { + h.coachAvailable = true; + }); + + it("is listed while the Coach can answer, and the memory lives there", () => { + expect(render(body)).toContain( + 'href="/settings/coach"', + ); + expect(render()).not.toContain( + 'data-slot="stored-coach-memory"', + ); + }); + + it("goes when the Coach cannot answer, and Settings → AI keeps the memory", () => { + h.coachAvailable = false; + expect( + render(body), + ).not.toContain('href="/settings/coach"'); + expect(render()).toContain('data-slot="stored-coach-memory"'); + }); +}); diff --git a/src/components/settings/__tests__/notification-channel-cards.test.tsx b/src/components/settings/__tests__/notification-channel-cards.test.tsx index 0e09da9cf..e3a1df045 100644 --- a/src/components/settings/__tests__/notification-channel-cards.test.tsx +++ b/src/components/settings/__tests__/notification-channel-cards.test.tsx @@ -65,7 +65,8 @@ vi.mock("@tanstack/react-query", () => ({ useQueryClient: () => mocks.queryClient, })); -vi.mock("@/lib/api/api-fetch", () => ({ +vi.mock("@/lib/api/api-fetch", async (importOriginal) => ({ + ...(await importOriginal()), apiFetchRaw: mocks.apiFetchRaw, apiGet: vi.fn(), })); diff --git a/src/components/settings/__tests__/notification-channel-toggle.test.ts b/src/components/settings/__tests__/notification-channel-toggle.test.ts index d0361bff7..bf4e582f7 100644 --- a/src/components/settings/__tests__/notification-channel-toggle.test.ts +++ b/src/components/settings/__tests__/notification-channel-toggle.test.ts @@ -1,12 +1,16 @@ import { QueryClient } from "@tanstack/react-query"; import { beforeEach, describe, expect, it, vi } from "vitest"; -vi.mock("@/lib/api/api-fetch", () => ({ +vi.mock("@/lib/api/api-fetch", async (importOriginal) => ({ + ...(await importOriginal()), apiFetchRaw: vi.fn(), })); -import { apiFetchRaw } from "@/lib/api/api-fetch"; +import { ApiError, apiFetchRaw } from "@/lib/api/api-fetch"; +import en from "@/../messages/en.json"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; import { + channelSaveError, optimisticallySetChannelEnabled, persistChannelEnabled, rollbackChannelEnabled, @@ -27,7 +31,7 @@ describe("notification channel enable-only mutation", () => { new Response(JSON.stringify({ data: { saved: true } }), { status: 200 }), ); - await persistChannelEnabled(endpoint, true, "save failed"); + await persistChannelEnabled(endpoint, true); expect(apiFetchRaw).toHaveBeenCalledWith(endpoint, { method: "PUT", @@ -87,7 +91,33 @@ describe("notification channel enable-only mutation", () => { ); await expect( - persistChannelEnabled("/api/settings/ntfy", true, "save failed"), + persistChannelEnabled("/api/settings/ntfy", true), ).rejects.toThrow("Stored configuration is missing"); }); }); + +describe("the sentence a refused channel save leaves", () => { + const { t } = getServerTranslator("en"); + + it("names the private-origin policy like the test button does", () => { + const refusal = new ApiError("Private network target", 422, { + errorCode: "private_origin_not_approved", + }); + expect(channelSaveError(refusal, t)).toBe( + en.settings.testConnection.errors.private_origin_not_approved, + ); + }); + + it("reads any other refusal through the catalogue, never as the server's prose", () => { + const refusal = new ApiError("Stored configuration is missing", 422, { + errorCode: "http.invalid", + }); + expect(channelSaveError(refusal, t)).toBe(en.apiErrors.http.invalid); + }); + + it("says could not save when the request never reached the server", () => { + expect(channelSaveError(new TypeError("Failed to fetch"), t)).toBe( + en.settings.savingError, + ); + }); +}); diff --git a/src/components/settings/access/__tests__/managed-profile-step-up-flow.test.ts b/src/components/settings/access/__tests__/managed-profile-step-up-flow.test.ts index 35424713c..eaf7f9984 100644 --- a/src/components/settings/access/__tests__/managed-profile-step-up-flow.test.ts +++ b/src/components/settings/access/__tests__/managed-profile-step-up-flow.test.ts @@ -146,7 +146,11 @@ function dialog(): Dialog { }, // The same call `useRecentProof` makes. submit: (proof) => apiPost("/api/auth/reproof", proof), - messages: () => ({ signInAgain: "sign in again", failed: "failed" }), + messages: () => ({ + signInAgain: "sign in again", + failed: "failed", + rateLimited: "rate limited", + }), }); return d; } diff --git a/src/components/settings/account-section/avatar-section.tsx b/src/components/settings/account-section/avatar-section.tsx index 6297cdb61..21a8ec62a 100644 --- a/src/components/settings/account-section/avatar-section.tsx +++ b/src/components/settings/account-section/avatar-section.tsx @@ -11,7 +11,8 @@ import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; -import { apiFetchRaw } from "@/lib/api/api-fetch"; +import { ApiError, apiFetchRaw, throwApiError } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { statusText, type StatusMessage } from "./account-section-utils"; import { accountName, nameInitials } from "@/lib/account-name"; @@ -37,41 +38,38 @@ export function AvatarSection() { method: "POST", body, }); - const json = await res.json().catch(() => ({})); - if (!res.ok) { - if (res.status === 415) - throw new Error(t("settings.avatar.invalidType")); - if (res.status === 413) throw new Error(t("settings.avatar.tooLarge")); - throw new Error(json.error || t("settings.avatar.error")); - } + if (!res.ok) await throwApiError(res); }, onSuccess: async () => { setMsgType("success"); setMsg({ key: "settings.avatar.uploaded" }); await refetch(); }, - onError: (err: Error) => { + onError: (err) => { setMsgType("error"); - setMsg({ text: err.message }); + setMsg( + err instanceof ApiError && err.status === 415 + ? { key: "settings.avatar.invalidType" } + : err instanceof ApiError && err.status === 413 + ? { key: "settings.avatar.tooLarge" } + : { text: localizedApiError(err, t, "settings.avatar.error") }, + ); }, }); const remove = useMutation({ mutationFn: async () => { const res = await apiFetchRaw("/api/user/avatar", { method: "DELETE" }); - if (!res.ok) { - const json = await res.json().catch(() => ({})); - throw new Error(json.error || t("settings.avatar.error")); - } + if (!res.ok) await throwApiError(res); }, onSuccess: async () => { setMsgType("success"); setMsg({ key: "settings.avatar.removed" }); await refetch(); }, - onError: (err: Error) => { + onError: (err) => { setMsgType("error"); - setMsg({ text: err.message }); + setMsg({ text: localizedApiError(err, t, "settings.avatar.error") }); }, }); diff --git a/src/components/settings/ai-section.tsx b/src/components/settings/ai-section.tsx index 57ae0dd16..30de702d7 100644 --- a/src/components/settings/ai-section.tsx +++ b/src/components/settings/ai-section.tsx @@ -25,10 +25,12 @@ import { StoredCoachMemory } from "@/components/settings/coach-memory-section"; import { AiSetupHint } from "@/components/insights/ai-setup-hint"; import { useAuth } from "@/hooks/use-auth"; import { useMounted } from "@/hooks/use-mounted"; +import { useNavModules } from "@/hooks/use-nav-modules"; import { isSurfaceVisible } from "@/lib/modules/surface"; export function AiSection() { - const { isAuthenticated, user } = useAuth(); + const { isAuthenticated } = useAuth(); + const navModules = useNavModules(); // v1.16.6 — the auth query can resolve before this boundary // hydrates; every `disabled={!isAuthenticated}` binding in the child // cards would then disagree with the SSR HTML (React #418). Gate the @@ -46,11 +48,9 @@ export function AiSection() { // nothing below reads as broken), and the one calm setup hint while no // provider is configured, or the consent question while the operator's // provider waits on it. Below it: what the Coach stored, while Settings → - // Coach is not reachable, so it can always be read and deleted. - const coachSettingsReachable = isSurfaceVisible( - "settings:coach", - user?.modules, - ); + // Coach is not listed (the settings nav reads the same map), so it can + // always be read and deleted. + const coachSettingsReachable = isSurfaceVisible("settings:coach", navModules); return (
    diff --git a/src/components/settings/ai/anthropic-provider-form.tsx b/src/components/settings/ai/anthropic-provider-form.tsx index 7aa31d06b..477cced52 100644 --- a/src/components/settings/ai/anthropic-provider-form.tsx +++ b/src/components/settings/ai/anthropic-provider-form.tsx @@ -15,6 +15,7 @@ import { Label } from "@/components/ui/label"; import { NativeSelect } from "@/components/ui/native-select"; import { PasswordInput } from "@/components/ui/password-input"; import { apiPatch } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { useTranslations } from "@/lib/i18n/context"; import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys"; @@ -86,7 +87,7 @@ export function AnthropicProviderForm({ }, onError: (e) => { setOk(false); - setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric")); + setMsg(localizedApiError(e, t, "settings.ai.errorGeneric")); }, onSettled: () => { submitInFlightRef.current = false; diff --git a/src/components/settings/ai/codex-provider-form.tsx b/src/components/settings/ai/codex-provider-form.tsx index 2d7446f70..ddb8820d4 100644 --- a/src/components/settings/ai/codex-provider-form.tsx +++ b/src/components/settings/ai/codex-provider-form.tsx @@ -12,7 +12,8 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { SettingsCardActions } from "@/components/settings/_card-actions"; import { ConfirmButton } from "@/components/ui/confirm-button"; -import { apiFetchRaw } from "@/lib/api/api-fetch"; +import { apiDelete, apiPost } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { formatDateTime } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys"; @@ -77,18 +78,18 @@ export function CodexProviderForm({ setMsg(null); setDevicePolling(true); try { - const res = await apiFetchRaw("/api/auth/codex/device-start", { - method: "POST", - }); - const json = await res.json(); - if (!res.ok) throw new Error(json.error || t("settings.savingError")); + const data = await apiPost<{ + userCode: string; + verificationUrl: string; + intervalSeconds: number; + }>("/api/auth/codex/device-start"); setDeviceCode({ - userCode: json.data.userCode, - verificationUrl: json.data.verificationUrl, - intervalSeconds: json.data.intervalSeconds, + userCode: data.userCode, + verificationUrl: data.verificationUrl, + intervalSeconds: data.intervalSeconds, }); } catch (err) { - setMsg(err instanceof Error ? err.message : t("settings.savingError")); + setMsg(localizedApiError(err, t, "settings.savingError")); setMsgType("error"); setDevicePolling(false); } @@ -101,15 +102,11 @@ export function CodexProviderForm({ async function tick() { try { - const res = await apiFetchRaw("/api/auth/codex/device-poll", { - method: "POST", - }); - const json = await res.json(); + const data = await apiPost<{ status?: string } | null>( + "/api/auth/codex/device-poll", + ); if (cancelled) return; - if (!res.ok) { - throw new Error(json.error || t("settings.savingError")); - } - if (json.data?.status === "connected") { + if (data?.status === "connected") { setDeviceCode(null); setDevicePolling(false); setMsg(t("settings.codexConnected")); @@ -120,7 +117,7 @@ export function CodexProviderForm({ if (!cancelled) setTimeout(tick, intervalMs); } catch (err) { if (cancelled) return; - setMsg(err instanceof Error ? err.message : t("settings.savingError")); + setMsg(localizedApiError(err, t, "settings.savingError")); setMsgType("error"); setDeviceCode(null); setDevicePolling(false); @@ -144,18 +141,12 @@ export function CodexProviderForm({ setDisconnecting(true); setMsg(null); try { - const res = await apiFetchRaw("/api/auth/codex/disconnect", { - method: "DELETE", - }); - if (!res.ok) { - const json = await res.json(); - throw new Error(json.error); - } + await apiDelete("/api/auth/codex/disconnect"); setMsg(t("settings.codexDisconnected")); setMsgType("success"); void invalidateKeys(queryClient, aiInputDependentKeys); } catch (err) { - setMsg(err instanceof Error ? err.message : t("settings.savingError")); + setMsg(localizedApiError(err, t, "settings.savingError")); setMsgType("error"); } finally { setDisconnecting(false); diff --git a/src/components/settings/ai/compat-provider-form.tsx b/src/components/settings/ai/compat-provider-form.tsx index 52adbccf8..00efa3736 100644 --- a/src/components/settings/ai/compat-provider-form.tsx +++ b/src/components/settings/ai/compat-provider-form.tsx @@ -17,6 +17,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { PasswordInput } from "@/components/ui/password-input"; import { apiPatch } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { useTranslations } from "@/lib/i18n/context"; import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys"; @@ -78,7 +79,7 @@ export function CompatProviderForm({ }, onError: (e) => { setOk(false); - setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric")); + setMsg(localizedApiError(e, t, "settings.ai.errorGeneric")); }, onSettled: () => { submitInFlightRef.current = false; diff --git a/src/components/settings/ai/fallback-chain-card.tsx b/src/components/settings/ai/fallback-chain-card.tsx index 1c6631ec0..c9df354fb 100644 --- a/src/components/settings/ai/fallback-chain-card.tsx +++ b/src/components/settings/ai/fallback-chain-card.tsx @@ -26,6 +26,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header"; import { NativeSelect } from "@/components/ui/native-select"; import { Switch } from "@/components/ui/switch"; import { apiPut } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { useTranslations } from "@/lib/i18n/context"; import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys"; @@ -97,11 +98,7 @@ export function FallbackChainCard({ }, onError: (e) => { setOk(false); - setMsg( - e instanceof Error - ? e.message - : t("settings.ai.providerChain.saveFailed"), - ); + setMsg(localizedApiError(e, t, "settings.ai.providerChain.saveFailed")); }, }); diff --git a/src/components/settings/ai/local-provider-form.tsx b/src/components/settings/ai/local-provider-form.tsx index b0225e2f3..73c457ca6 100644 --- a/src/components/settings/ai/local-provider-form.tsx +++ b/src/components/settings/ai/local-provider-form.tsx @@ -15,6 +15,7 @@ import { Label } from "@/components/ui/label"; import { NativeSelect } from "@/components/ui/native-select"; import { PasswordInput } from "@/components/ui/password-input"; import { apiPatch } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { useTranslations } from "@/lib/i18n/context"; import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys"; @@ -92,7 +93,7 @@ export function LocalProviderForm({ }, onError: (e) => { setOk(false); - setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric")); + setMsg(localizedApiError(e, t, "settings.ai.errorGeneric")); }, onSettled: () => { submitInFlightRef.current = false; diff --git a/src/components/settings/ai/openai-provider-form.tsx b/src/components/settings/ai/openai-provider-form.tsx index 9e3bb9c2b..756c85483 100644 --- a/src/components/settings/ai/openai-provider-form.tsx +++ b/src/components/settings/ai/openai-provider-form.tsx @@ -18,6 +18,7 @@ import { Label } from "@/components/ui/label"; import { NativeSelect } from "@/components/ui/native-select"; import { PasswordInput } from "@/components/ui/password-input"; import { apiPatch } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { useTranslations } from "@/lib/i18n/context"; import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys"; @@ -94,7 +95,7 @@ export function OpenAIProviderForm({ }, onError: (e) => { setOk(false); - setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric")); + setMsg(localizedApiError(e, t, "settings.ai.errorGeneric")); }, onSettled: () => { submitInFlightRef.current = false; diff --git a/src/components/settings/ai/response-timeout-card.tsx b/src/components/settings/ai/response-timeout-card.tsx index ec032b9a1..00cf2f61a 100644 --- a/src/components/settings/ai/response-timeout-card.tsx +++ b/src/components/settings/ai/response-timeout-card.tsx @@ -24,6 +24,7 @@ import { Label } from "@/components/ui/label"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; import { PROVIDER_DEFAULT_TIMEOUT_MS } from "@/lib/ai/effective-timeout"; import { apiPatch } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { useTranslations } from "@/lib/i18n/context"; import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys"; @@ -68,17 +69,7 @@ export function ResponseTimeoutCard({ } const saveMutation = useMutation({ - mutationFn: async () => { - const trimmed = value.trim(); - const responseTimeoutSeconds = trimmed === "" ? null : Number(trimmed); - if ( - responseTimeoutSeconds !== null && - (!Number.isInteger(responseTimeoutSeconds) || - responseTimeoutSeconds < 10 || - responseTimeoutSeconds > 600) - ) { - throw new Error(t("settings.ai.responseTimeoutInvalid")); - } + mutationFn: async (responseTimeoutSeconds: number | null) => { await apiPatch("/api/user/ai-provider", { responseTimeoutSeconds }); }, onSuccess: () => { @@ -88,7 +79,7 @@ export function ResponseTimeoutCard({ }, onError: (e) => { setOk(false); - setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric")); + setMsg(localizedApiError(e, t, "settings.ai.errorGeneric")); }, onSettled: () => { submitInFlightRef.current = false; @@ -98,8 +89,20 @@ export function ResponseTimeoutCard({ function submit(event: FormEvent) { event.preventDefault(); if (submitInFlightRef.current || saveMutation.isPending) return; + const trimmed = value.trim(); + const responseTimeoutSeconds = trimmed === "" ? null : Number(trimmed); + if ( + responseTimeoutSeconds !== null && + (!Number.isInteger(responseTimeoutSeconds) || + responseTimeoutSeconds < 10 || + responseTimeoutSeconds > 600) + ) { + setOk(false); + setMsg(t("settings.ai.responseTimeoutInvalid")); + return; + } submitInFlightRef.current = true; - saveMutation.mutate(); + saveMutation.mutate(responseTimeoutSeconds); } return ( diff --git a/src/components/settings/ai/runtime-actions-row.tsx b/src/components/settings/ai/runtime-actions-row.tsx index 79a2b24f4..0e9a98321 100644 --- a/src/components/settings/ai/runtime-actions-row.tsx +++ b/src/components/settings/ai/runtime-actions-row.tsx @@ -17,6 +17,7 @@ import { apiFetchRaw, apiPut } from "@/lib/api/api-fetch"; import { formatDateTime } from "@/lib/format"; import { useAiCapability } from "@/hooks/use-ai-capability"; import { useTranslations } from "@/lib/i18n/context"; +import { translateCodeOrNull } from "@/lib/i18n/translate-code"; import { localiseTestReason, @@ -83,7 +84,6 @@ export function RuntimeActionsRow({ reason?: string; httpStatus?: number | null; } | null; - error?: string | null; }; try { json = await res.json(); @@ -102,7 +102,12 @@ export function RuntimeActionsRow({ typeof code === "string" && code.startsWith("assistant.disabled.") ? t("settings.ai.operatorOff.testRefused") : t("settings.ai.testFailedShort", { - message: json.error ?? `HTTP ${res.status}`, + message: + translateCodeOrNull( + t, + "apiErrors", + typeof code === "string" ? code : null, + ) ?? `HTTP ${res.status}`, }), ); return; @@ -131,12 +136,10 @@ export function RuntimeActionsRow({ model: json.data?.model ?? "", }), ); - } catch (e) { + } catch { setTestOk(false); setTestMsg( - t("settings.ai.testFailedShort", { - message: e instanceof Error ? e.message : "fetch error", - }), + t("settings.ai.testFailedShort", { message: t("common.networkError") }), ); } finally { setTesting(false); diff --git a/src/components/settings/email-card.tsx b/src/components/settings/email-card.tsx index e797a230c..e897ee2c3 100644 --- a/src/components/settings/email-card.tsx +++ b/src/components/settings/email-card.tsx @@ -14,10 +14,13 @@ import { SettingsCardHeader } from "@/components/settings/_card-header"; import { TestConnectionButton } from "@/components/settings/test-connection-button"; import { isEmailFormDirty } from "@/components/settings/channel-form-dirty"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; -import { useChannelEnabledToggle } from "@/components/settings/notification-channel-toggle"; +import { + channelSaveError, + useChannelEnabledToggle, +} from "@/components/settings/notification-channel-toggle"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; -import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; +import { apiFetchRaw, apiGet, throwApiError } from "@/lib/api/api-fetch"; interface EmailSettings { enabled: boolean; @@ -64,10 +67,7 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) { enabled: settings?.enabled ?? false, }), }); - if (!res.ok) { - const json = await res.json(); - throw new Error(json.error || t("common.error")); - } + if (!res.ok) await throwApiError(res); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.settingsEmail() }); @@ -75,8 +75,8 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) { toastWrittenOutcome("success", t("settings.saved")); }, // A form shows its refusal inline, next to the field it is about. - onError: (err: Error) => { - setSaveError(err.message); + onError: (err) => { + setSaveError(channelSaveError(err, t)); }, }); diff --git a/src/components/settings/import-panel/dose-history-import-card.tsx b/src/components/settings/import-panel/dose-history-import-card.tsx index 87d9baf21..1b31532e4 100644 --- a/src/components/settings/import-panel/dose-history-import-card.tsx +++ b/src/components/settings/import-panel/dose-history-import-card.tsx @@ -31,10 +31,7 @@ import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { apiFetchRaw } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; -import { - MedicationIntakeImportError, - waitForMedicationIntakeImport, -} from "@/lib/medications/intake-import-poll"; +import { waitForMedicationIntakeImport } from "@/lib/medications/intake-import-poll"; import { invalidateMedicationReads } from "@/lib/query-keys"; import { DoseHistoryColumnRulings } from "./dose-history-columns"; @@ -194,15 +191,12 @@ export function DoseHistoryImportCard() { skippedDetailsOmitted: result.skippedDetailsOmitted, }); void invalidateMedicationReads(queryClient); - } catch (err) { - // A failed job carries a sanitised reason; anything else is a transport - // failure with nothing to add. Either way the run did not write, and the - // card says so rather than leaving the last verdict on screen. - setError( - err instanceof MedicationIntakeImportError && err.message.length > 0 - ? err.message - : t("settings.sections.export.import.doseHistory.failed"), - ); + } catch { + // A failed job's reason is the worker's English and adds nothing; + // anything else is a transport failure. Either way the run did not + // write, and the card says so rather than leaving the last verdict on + // screen. + setError(t("settings.sections.export.import.doseHistory.failed")); setVerdict(null); } finally { setBusy(false); diff --git a/src/components/settings/notification-channel-toggle.ts b/src/components/settings/notification-channel-toggle.ts index 0675a921d..0ae9c3147 100644 --- a/src/components/settings/notification-channel-toggle.ts +++ b/src/components/settings/notification-channel-toggle.ts @@ -15,33 +15,46 @@ import { } from "@tanstack/react-query"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; -import { apiFetchRaw } from "@/lib/api/api-fetch"; +import { ApiError, apiFetchRaw, throwApiError } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { showErrorToast } from "@/lib/errors/error-toast"; import { useTranslations } from "@/lib/i18n/context"; +import { translateCodeOrNull } from "@/lib/i18n/translate-code"; interface EnabledSettings { enabled: boolean; } +/** + * A refused channel save in the reader's language. The private-origin policy + * reads like the test button's verdict; anything else goes through the API + * catalogue, and a request that never reached the server (offline, a dropped + * connection) reads as the plain "could not save". Never the server's prose. + */ +export function channelSaveError( + err: unknown, + t: (key: string) => string, +): string { + const code = err instanceof ApiError ? err.meta?.errorCode : undefined; + return ( + translateCodeOrNull( + t, + "settings.testConnection.errors", + typeof code === "string" ? code : null, + ) ?? localizedApiError(err, t, "settings.savingError") + ); +} + export async function persistChannelEnabled( endpoint: string, enabled: boolean, - fallbackError: string, ): Promise { - // A request that never reaches the server (offline, a dropped connection) - // is a refused change like any other. const response = await apiFetchRaw(endpoint, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ enabled }), - }).catch(() => null); - - if (!response?.ok) { - const json = (await response?.json().catch(() => null)) as { - error?: string; - } | null; - throw new Error(json?.error || fallbackError); - } + }); + if (!response.ok) await throwApiError(response); } export function optimisticallySetChannelEnabled( @@ -75,8 +88,7 @@ export function useChannelEnabledToggle( const { t } = useTranslations(); const queryClient = useQueryClient(); return useMutation({ - mutationFn: (enabled) => - persistChannelEnabled(endpoint, enabled, t("settings.savingError")), + mutationFn: (enabled) => persistChannelEnabled(endpoint, enabled), onMutate: async (enabled) => { await queryClient.cancelQueries({ queryKey }); return optimisticallySetChannelEnabled(queryClient, queryKey, enabled); @@ -86,7 +98,7 @@ export function useChannelEnabledToggle( }, onError: (err, _enabled, previous) => { rollbackChannelEnabled(queryClient, queryKey, previous); - showErrorToast(err.message, { cause: err }); + showErrorToast(channelSaveError(err, t), { cause: err }); }, onSettled: () => { void queryClient.invalidateQueries({ queryKey }); diff --git a/src/components/settings/ntfy-card.tsx b/src/components/settings/ntfy-card.tsx index f97975c33..391933173 100644 --- a/src/components/settings/ntfy-card.tsx +++ b/src/components/settings/ntfy-card.tsx @@ -15,11 +15,13 @@ import { SettingsCardHeader } from "@/components/settings/_card-header"; import { TestConnectionButton } from "@/components/settings/test-connection-button"; import { isNtfyFormDirty } from "@/components/settings/channel-form-dirty"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; -import { useChannelEnabledToggle } from "@/components/settings/notification-channel-toggle"; +import { + channelSaveError, + useChannelEnabledToggle, +} from "@/components/settings/notification-channel-toggle"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; -import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; -import { translateCodeOrNull } from "@/lib/i18n/translate-code"; +import { apiFetchRaw, apiGet, throwApiError } from "@/lib/api/api-fetch"; interface NtfySettings { enabled: boolean; @@ -73,22 +75,7 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) { enabled: settings?.enabled ?? false, }), }); - if (!res.ok) { - const json = (await res.json()) as { - error?: string; - meta?: { errorCode?: string }; - }; - // A coded refusal (the private-origin policy) translates like the - // test button's; anything else shows the server sentence. - throw new Error( - translateCodeOrNull( - t, - "settings.testConnection.errors", - json.meta?.errorCode, - ) ?? - (json.error || t("common.error")), - ); - } + if (!res.ok) await throwApiError(res); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.settingsNtfy() }); @@ -97,8 +84,8 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) { setAuthToken(""); }, // A form shows its refusal inline, next to the fields it is about. - onError: (err: Error) => { - setSaveError(err.message); + onError: (err) => { + setSaveError(channelSaveError(err, t)); }, }); diff --git a/src/components/settings/report-selection/selection-panel.tsx b/src/components/settings/report-selection/selection-panel.tsx index 24fba5f4b..b8da8cb1c 100644 --- a/src/components/settings/report-selection/selection-panel.tsx +++ b/src/components/settings/report-selection/selection-panel.tsx @@ -49,6 +49,7 @@ import { ReportVisitOffer } from "./report-visit-offer"; import { Switch } from "@/components/ui/switch"; import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group"; import { apiFetchRaw } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { recentProofErrorMessage, throwIfReproofRequired, @@ -177,7 +178,7 @@ export function HealthRecordExportPanel() { } const message = recentProofErrorMessage( err, - err instanceof Error ? err.message : String(err), + localizedApiError(err, t, "settings.healthRecord.errorGeneric"), ); if (message) setError(message); } finally { diff --git a/src/components/settings/security-section/__tests__/oidc-reproof-choice.test.ts b/src/components/settings/security-section/__tests__/oidc-reproof-choice.test.ts index 9131bb3ba..90e16ea60 100644 --- a/src/components/settings/security-section/__tests__/oidc-reproof-choice.test.ts +++ b/src/components/settings/security-section/__tests__/oidc-reproof-choice.test.ts @@ -62,7 +62,11 @@ describe("the re-proof dialog flow", () => { const flow = createRecentProofFlow({ view: { setMethods, setPending: vi.fn(), setError: vi.fn() }, submit: vi.fn(), - messages: () => ({ signInAgain: "sign in again", failed: "failed" }), + messages: () => ({ + signInAgain: "sign in again", + failed: "failed", + rateLimited: "rate limited", + }), }); void flow .run(async () => { @@ -75,4 +79,23 @@ describe("the re-proof dialog flow", () => { await vi.waitFor(() => expect(setMethods).toHaveBeenCalledWith(["oidc"])); flow.cancel(); }); + + it("says a throttled proof in the caller's words, not the server's", async () => { + const setError = vi.fn(); + const flow = createRecentProofFlow({ + view: { setMethods: vi.fn(), setPending: vi.fn(), setError }, + submit: vi.fn(async () => { + throw new ApiError("Too many attempts. Please wait 15 minutes.", 429); + }), + messages: () => ({ + signInAgain: "sign in again", + failed: "failed", + rateLimited: "rate limited", + }), + }); + + await flow.prove({ method: "password", password: "pw" }); + + expect(setError).toHaveBeenLastCalledWith("rate limited"); + }); }); diff --git a/src/components/settings/security-section/__tests__/passkey-removal-step-up.test.tsx b/src/components/settings/security-section/__tests__/passkey-removal-step-up.test.tsx index 039bd96d7..4d502ebea 100644 --- a/src/components/settings/security-section/__tests__/passkey-removal-step-up.test.tsx +++ b/src/components/settings/security-section/__tests__/passkey-removal-step-up.test.tsx @@ -12,8 +12,9 @@ * 1. a step-up refusal renders the card's sentence, not the server's prose, * 2. the mfa_not_enrolled arm maps too — both codes mean "the gate stopped * you", and handling only the first falls through to raw English, - * 3. an ordinary failure still shows its own message rather than being - * swallowed by the step-up sentence, + * 3. any other refusal gets the caller's sentence (its catalogue sentence + * through `localizedApiError`), not the step-up one and not the server's + * prose, * 4. the sentence resolves in more than one locale, * 5. the sentence names a recovery that always exists. A refusal that names * its proofs (`meta.methods`) opens the re-proof dialog instead and never @@ -65,14 +66,15 @@ describe("passkey removal — the step-up refusal a person sees", () => { ).toBe(STEP_UP_SENTENCE); }); - it("leaves an ordinary refusal saying its own thing", () => { + it("leaves any other refusal to the caller's sentence, never the server's", () => { const lastMethod = new ApiError( - "Cannot delete — at least one authentication method must remain", + "Cannot delete. At least one authentication method must remain", 400, + { errorCode: "auth.passkey.last_method" }, ); - expect(describeStepUp(lastMethod, "fallback", STEP_UP_SENTENCE)).toContain( - "at least one authentication method", + expect(describeStepUp(lastMethod, "fallback", STEP_UP_SENTENCE)).toBe( + "fallback", ); }); diff --git a/src/components/settings/security-section/__tests__/totp-card.test.tsx b/src/components/settings/security-section/__tests__/totp-card.test.tsx index 638caa5df..caed502a4 100644 --- a/src/components/settings/security-section/__tests__/totp-card.test.tsx +++ b/src/components/settings/security-section/__tests__/totp-card.test.tsx @@ -27,7 +27,7 @@ * * Mutation check: drop either `onSettled` and the wiring case goes red; drop * an `open=` binding and the controlled case goes red; return `err.message` - * from `describeError`'s step-up arm and the sentence case goes red. + * from `describeStepUp`'s step-up arm and the sentence case goes red. */ import { describe, expect, it } from "vitest"; import { readFileSync } from "node:fs"; @@ -38,7 +38,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ApiError } from "@/lib/api/api-fetch"; import { I18nProvider } from "@/lib/i18n/context"; import en from "@/../messages/en.json"; -import { describeError, TotpCard } from "../totp-card"; +import { describeStepUp } from "../security-keys-card"; +import { TotpCard } from "../totp-card"; const STEP_UP_SENTENCE = en.settings.security.stepUpRequired; @@ -81,7 +82,7 @@ describe(" — a refused mutation", () => { { errorCode: "auth.stepup.required" }, ); - const shown = describeError(refusal, "fallback", STEP_UP_SENTENCE); + const shown = describeStepUp(refusal, "fallback", STEP_UP_SENTENCE); expect(shown).toBe(STEP_UP_SENTENCE); expect(shown).not.toContain("second-factor verification required"); diff --git a/src/components/settings/security-section/existing-factor-reauth-dialog.tsx b/src/components/settings/security-section/existing-factor-reauth-dialog.tsx index 799c30f33..a81e51da2 100644 --- a/src/components/settings/security-section/existing-factor-reauth-dialog.tsx +++ b/src/components/settings/security-section/existing-factor-reauth-dialog.tsx @@ -17,6 +17,7 @@ import { Segmented } from "@/components/ui/segmented"; import { Input } from "@/components/ui/input"; import { useTranslations } from "@/lib/i18n/context"; import { ApiError, apiPost } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { describePasskeyError } from "@/lib/passkey-errors"; export type ReauthMethod = @@ -171,7 +172,7 @@ export function ExistingFactorReauthDialog({ onProof({ method, challengeId: begun.challengeId, credential }); } catch (err) { if (err instanceof ApiError) { - setAssertionError(err.message); + setAssertionError(localizedApiError(err, t, "settings.reproof.failed")); } else { const { key, params } = describePasskeyError(err); setAssertionError(t(key, params)); diff --git a/src/components/settings/security-section/passkey-list-section.tsx b/src/components/settings/security-section/passkey-list-section.tsx index 24b860bc5..35b332127 100644 --- a/src/components/settings/security-section/passkey-list-section.tsx +++ b/src/components/settings/security-section/passkey-list-section.tsx @@ -43,6 +43,7 @@ import { apiPatch, apiPost, } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { describePasskeyError } from "@/lib/passkey-errors"; import { describeStepUp } from "./security-keys-card"; import { @@ -206,7 +207,7 @@ export function PasskeyListSection({ }, onError: (err) => { if (err instanceof ApiError) { - setMsg(err.message || t("settings.passkeyRegistrationFailed")); + setMsg(localizedApiError(err, t, "settings.passkeyRegistrationFailed")); } else { const { key, params } = describePasskeyError(err); setMsg(t(key, params)); @@ -223,7 +224,8 @@ export function PasskeyListSection({ setMsg(null); queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() }); }, - onError: (err: Error) => setMsg(err.message), + onError: (err) => + setMsg(localizedApiError(err, t, "settings.passkeyRenameFailed")), }); // Removal is step-up gated: a second factor, or on a passkey-only account a @@ -248,7 +250,11 @@ export function PasskeyListSection({ onError: (err: Error) => { if (err instanceof ReproofCancelledError) return; setMsg( - describeStepUp(err, err.message, t("settings.passkeyStepUpRequired")), + describeStepUp( + err, + localizedApiError(err, t, "settings.passkeyRemoveFailed"), + t("settings.passkeyStepUpRequired"), + ), ); }, }); diff --git a/src/components/settings/security-section/recent-proof-flow.ts b/src/components/settings/security-section/recent-proof-flow.ts index f2c6c804c..ab598de12 100644 --- a/src/components/settings/security-section/recent-proof-flow.ts +++ b/src/components/settings/security-section/recent-proof-flow.ts @@ -83,7 +83,7 @@ export function createRecentProofFlow(deps: { /** Posts the proof to `/api/auth/reproof`. */ submit: (proof: ExistingFactorProof) => Promise; /** Read at the moment they are needed, so a locale switch is honoured. */ - messages: () => { signInAgain: string; failed: string }; + messages: () => { signInAgain: string; failed: string; rateLimited: string }; }): RecentProofFlow { let waiter: { resolve: () => void; reject: () => void } | null = null; @@ -127,7 +127,7 @@ export function createRecentProofFlow(deps: { } catch (err) { deps.view.setError( err instanceof ApiError && err.status === 429 - ? err.message + ? deps.messages().rateLimited : deps.messages().failed, ); } finally { diff --git a/src/components/settings/security-section/security-keys-card.tsx b/src/components/settings/security-section/security-keys-card.tsx index 8bb5c0a8a..c8b03a630 100644 --- a/src/components/settings/security-section/security-keys-card.tsx +++ b/src/components/settings/security-section/security-keys-card.tsx @@ -23,6 +23,7 @@ import { SettingsCardActions } from "@/components/settings/_card-actions"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { ApiError, apiDelete, apiPatch, apiPost } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { describePasskeyError } from "@/lib/passkey-errors"; import { formatDate } from "@/lib/format"; import { @@ -50,6 +51,8 @@ export interface WebauthnKeyInfo { * "Recent second-factor verification required", which is English-only and, for a * passkey-only account, not even accurate about which credential to re-prove — * so the caller supplies its own sentence and this only decides when to use it. + * Any other refusal reads as `fallback`, which callers resolve through + * `localizedApiError`; the server's prose is never shown. * * Matched on `meta.errorCode`, and on the whole `auth.stepup` prefix rather than * one code: `auth.stepup.required` and `auth.stepup.mfa_not_enrolled` both mean @@ -70,7 +73,6 @@ export function describeStepUp( ) { return stepUpMsg; } - return err.message || fallback; } return fallback; } @@ -141,7 +143,7 @@ export function SecurityKeysCard({ } let message: string; if (err instanceof ApiError) { - message = err.message || t("settings.security.keys.addFailed"); + message = localizedApiError(err, t, "settings.security.keys.addFailed"); } else { const { key, params } = describePasskeyError(err); message = t(key, params); @@ -165,9 +167,7 @@ export function SecurityKeysCard({ }, onError: (err) => setError( - err instanceof ApiError - ? err.message - : t("settings.security.keys.renameFailed"), + localizedApiError(err, t, "settings.security.keys.renameFailed"), ), }); @@ -191,7 +191,7 @@ export function SecurityKeysCard({ setError( describeStepUp( err, - t("settings.security.keys.removeFailed"), + localizedApiError(err, t, "settings.security.keys.removeFailed"), t("settings.security.stepUpRequired"), ), ); diff --git a/src/components/settings/security-section/totp-card.tsx b/src/components/settings/security-section/totp-card.tsx index 2acac8456..a4c10190f 100644 --- a/src/components/settings/security-section/totp-card.tsx +++ b/src/components/settings/security-section/totp-card.tsx @@ -31,8 +31,10 @@ import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; -import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch"; +import { apiGet, apiPost } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { RecoveryCodesPanel } from "./recovery-codes-panel"; +import { describeStepUp } from "./security-keys-card"; import { ReproofCancelledError, recentProofErrorMessage, @@ -59,31 +61,6 @@ interface ConfirmResult { recoveryCodesRemaining: number; } -/** - * Map a step-up 401 to a clear re-login message; otherwise the raw message. - * - * Exported for the unit suite: this is the sentence a refused mutation leaves - * on the card, and the card is where a person reads it. - */ -export function describeError( - err: unknown, - fallback: string, - stepUpMsg: string, -): string { - if (err instanceof ApiError) { - const code = err.meta?.errorCode; - if ( - err.status === 401 && - typeof code === "string" && - code.startsWith("auth.stepup") - ) { - return stepUpMsg; - } - return err.message || fallback; - } - return fallback; -} - export function TotpCard({ enabled, recoveryCodesRemaining, @@ -201,9 +178,9 @@ export function TotpCard({ setReauthOpen(true); return; } - const message = describeError( + const message = describeStepUp( err, - t("settings.security.totp.setupFailed"), + localizedApiError(err, t, "settings.security.totp.setupFailed"), t("settings.security.stepUpRequired"), ); if (proof) setReauthError(message); @@ -223,9 +200,9 @@ export function TotpCard({ }, onError: (err) => setError( - describeError( + describeStepUp( err, - t("settings.security.totp.invalidCode"), + localizedApiError(err, t, "settings.security.totp.invalidCode"), t("settings.security.stepUpRequired"), ), ), @@ -248,9 +225,9 @@ export function TotpCard({ onError: (err) => { if (err instanceof ReproofCancelledError) return; setError( - describeError( + describeStepUp( err, - t("settings.security.totp.disableFailed"), + localizedApiError(err, t, "settings.security.totp.disableFailed"), t("settings.security.stepUpRequired"), ), ); @@ -274,9 +251,9 @@ export function TotpCard({ onError: (err) => { if (err instanceof ReproofCancelledError) return; setError( - describeError( + describeStepUp( err, - t("settings.security.recovery.regenFailed"), + localizedApiError(err, t, "settings.security.recovery.regenFailed"), t("settings.security.stepUpRequired"), ), ); diff --git a/src/components/settings/security-section/use-recent-proof.tsx b/src/components/settings/security-section/use-recent-proof.tsx index be06029e4..b52195c7d 100644 --- a/src/components/settings/security-section/use-recent-proof.tsx +++ b/src/components/settings/security-section/use-recent-proof.tsx @@ -105,6 +105,7 @@ export function useRecentProof( messages: () => ({ signInAgain: t("settings.reproof.signInAgain"), failed: t("settings.reproof.failed"), + rateLimited: t("apiErrors.http.rate_limited"), }), }), [t], diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index 1ddee6b15..a2bcb5185 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -47,6 +47,7 @@ import { cn } from "@/lib/utils"; import { PageHeader } from "@/components/ui/page-header"; import { SUB_SHELL_GRID_FLOOR } from "@/components/layout/shell-metrics"; import { useAuth } from "@/hooks/use-auth"; +import { useNavModules } from "@/hooks/use-nav-modules"; import { useMounted } from "@/hooks/use-mounted"; import { useTranslations } from "@/lib/i18n/context"; import { isSurfaceVisible } from "@/lib/modules/surface"; @@ -366,8 +367,8 @@ export function SettingsShell({ const activeSlug = deriveActiveSlug(pathname, active); // v1.18.0 — per-submodule entries are listed only when their module - // is enabled. Read from the resolved `useAuth().user.modules` map (the - // same map the Module hub, nav, and Insights pills gate off). Fail OPEN + // is enabled, from `useNavModules()` as in the sidebar (Coach follows its + // AI capability; Settings → AI then lists the stored memory). Fail OPEN // (`!== false`): a missing key, or a not-yet-resolved `/me` payload, // reads as enabled so an entry never silently disappears. A section's // owner comes from the surface map (`settings:`); sections with no @@ -390,7 +391,7 @@ export function SettingsShell({ // effect (which the lint rule flags as a cascading render), and no risk of // the two passes disagreeing. const hydrated = useMounted(); - const modules = user?.modules; + const modules = useNavModules(); const activeRecord = user?.accountAccess?.active ?? null; const allVisibleSections = SETTINGS_SECTIONS.filter( (section) => diff --git a/src/components/settings/thresholds-editor-section.tsx b/src/components/settings/thresholds-editor-section.tsx index 5266aab2c..b242eb077 100644 --- a/src/components/settings/thresholds-editor-section.tsx +++ b/src/components/settings/thresholds-editor-section.tsx @@ -22,7 +22,8 @@ import { type ThresholdMetric, type EffectiveRange, } from "@/lib/analytics/effective-range"; -import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; +import { apiFetchRaw, apiGet, apiPut } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { useUnitDisplay } from "@/hooks/use-unit-display"; import { resolveTargetUnitAdapter } from "@/lib/targets/target-unit-display"; import { SettingsCard } from "@/components/settings/settings-card"; @@ -81,15 +82,7 @@ export function ThresholdsEditorSection({ id }: { id: string }) { mutationFn: async ( payload: Partial>, ) => { - const res = await apiFetchRaw("/api/user/thresholds", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - if (!res.ok) { - const err = await res.json().catch(() => null); - throw new Error(err?.error ?? "save failed"); - } + await apiPut("/api/user/thresholds", payload); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.userThresholds() }); @@ -102,10 +95,9 @@ export function ThresholdsEditorSection({ id }: { id: string }) { toast.success(t("thresholds.saveSuccess")); }, onError: (err) => - showErrorToast( - err instanceof Error ? err.message : t("thresholds.saveError"), - { cause: err }, - ), + showErrorToast(localizedApiError(err, t, "thresholds.saveError"), { + cause: err, + }), }); const resetMutation = useMutation({ diff --git a/src/components/settings/webhook-card.tsx b/src/components/settings/webhook-card.tsx index 644be17e5..916c46456 100644 --- a/src/components/settings/webhook-card.tsx +++ b/src/components/settings/webhook-card.tsx @@ -16,12 +16,14 @@ import { SettingsCardHeader } from "@/components/settings/_card-header"; import { TestConnectionButton } from "@/components/settings/test-connection-button"; import { isWebhookFormDirty } from "@/components/settings/channel-form-dirty"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; -import { useChannelEnabledToggle } from "@/components/settings/notification-channel-toggle"; +import { + channelSaveError, + useChannelEnabledToggle, +} from "@/components/settings/notification-channel-toggle"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; -import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; +import { apiFetchRaw, apiGet, throwApiError } from "@/lib/api/api-fetch"; import type { WebhookPayloadFormat } from "@/lib/notifications/types"; -import { translateCodeOrNull } from "@/lib/i18n/translate-code"; interface WebhookSettings { enabled: boolean; @@ -82,22 +84,7 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) { enabled: settings?.enabled ?? false, }), }); - if (!res.ok) { - const json = (await res.json()) as { - error?: string; - meta?: { errorCode?: string }; - }; - // A coded refusal (the private-origin policy) translates like the - // test button's; anything else shows the server sentence. - throw new Error( - translateCodeOrNull( - t, - "settings.testConnection.errors", - json.meta?.errorCode, - ) ?? - (json.error || t("common.error")), - ); - } + if (!res.ok) await throwApiError(res); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.settingsWebhook() }); @@ -106,8 +93,8 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) { setHeaderValue(""); }, // A form shows its refusal inline, next to the fields it is about. - onError: (err: Error) => { - setSaveError(err.message); + onError: (err) => { + setSaveError(channelSaveError(err, t)); }, }); diff --git a/src/components/symptoms/use-symptoms.ts b/src/components/symptoms/use-symptoms.ts index 286cdaed1..a5f3094d7 100644 --- a/src/components/symptoms/use-symptoms.ts +++ b/src/components/symptoms/use-symptoms.ts @@ -16,7 +16,7 @@ import { type QueryClient, } from "@tanstack/react-query"; -import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch"; +import { apiDelete, apiGet, apiPatch } from "@/lib/api/api-fetch"; import { useIdempotentPost } from "@/lib/api/use-idempotent-post"; import { invalidateKeys, queryKeys } from "@/lib/query-keys"; import type { @@ -147,9 +147,12 @@ function useInvalidateSymptoms() { export function useCreateSymptomDefinition() { const invalidate = useInvalidateSymptoms(); + // One key until a save lands, so a retry after a timeout is not a second + // symptom. + const post = useIdempotentPost(); return useMutation({ mutationFn: (input: { label: string; icon?: string | null }) => - apiPost("/api/symptoms/definitions", input), + post("/api/symptoms/definitions", input), onSuccess: invalidate, }); } diff --git a/src/components/targets/target-edit-sheet.tsx b/src/components/targets/target-edit-sheet.tsx index c51f643ee..890f4b344 100644 --- a/src/components/targets/target-edit-sheet.tsx +++ b/src/components/targets/target-edit-sheet.tsx @@ -25,6 +25,7 @@ import { import { resolveGlucoseUnit } from "@/lib/glucose"; import { useUnitDisplay } from "@/hooks/use-unit-display"; import { apiDelete, apiGet, apiPut } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; /** * Per-metric target editor, mounted inline on the Insights metric @@ -258,10 +259,9 @@ function TargetEditSheetBody({ onOpenChange(false); }, onError: (err) => - showErrorToast( - err instanceof Error ? err.message : t("targets.edit.saveError"), - { cause: err }, - ), + showErrorToast(localizedApiError(err, t, "targets.edit.saveError"), { + cause: err, + }), }); const resetMutation = useMutation({ diff --git a/src/components/timeline/__tests__/use-life-event-mutations.test.ts b/src/components/timeline/__tests__/use-life-event-mutations.test.ts new file mode 100644 index 000000000..8a973de6c --- /dev/null +++ b/src/components/timeline/__tests__/use-life-event-mutations.test.ts @@ -0,0 +1,42 @@ +/** + * A life event save settles once the server stored it. + * + * The hooks returned the timeline refresh from `onSuccess`, which a mutation + * waits for, so the sheet kept its spinner until the timeline had refetched. + */ +import { describe, expect, it, vi } from "vitest"; + +const client = vi.hoisted(() => ({ + // A refetch that never answers, as on a slow connection. + invalidateQueries: vi.fn(() => new Promise(() => {})), + getQueryCache: () => ({ findAll: () => [] }), +})); + +vi.mock("react", async (importOriginal) => ({ + ...(await importOriginal()), + useRef: (initial: T) => ({ current: initial }), +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: (options: unknown) => options, + // The hook's options are what is under test; return them as they are. + useMutation: (options: unknown) => options, + useQueryClient: () => client, +})); + +import { useLifeEventMutations } from "../use-timeline"; + +describe("life event saves", () => { + it.each(["create", "update", "remove"] as const)( + "%s starts the refresh and does not wait for it", + (name) => { + client.invalidateQueries.mockClear(); + const options = useLifeEventMutations()[name] as unknown as { + onSuccess: () => unknown; + }; + + expect(options.onSuccess()).toBeUndefined(); + expect(client.invalidateQueries).toHaveBeenCalled(); + }, + ); +}); diff --git a/src/components/timeline/use-timeline.ts b/src/components/timeline/use-timeline.ts index e3a49e242..0db31301f 100644 --- a/src/components/timeline/use-timeline.ts +++ b/src/components/timeline/use-timeline.ts @@ -10,7 +10,8 @@ */ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch"; +import { apiDelete, apiGet, apiPatch } from "@/lib/api/api-fetch"; +import { useIdempotentPost } from "@/lib/api/use-idempotent-post"; import type { LifeEventCreateInput, LifeEventDTO, @@ -72,12 +73,17 @@ export function useLifeEvents(enabled = true) { export function useLifeEventMutations() { const qc = useQueryClient(); + // Not returned, so the sheet closes without waiting for the timeline to + // refetch. const invalidate = () => - invalidateKeys(qc, [queryKeys.timelineRoot(), queryKeys.dayRoot()]); + void invalidateKeys(qc, [queryKeys.timelineRoot(), queryKeys.dayRoot()]); + // One key until a save lands, so a retry after a timeout is not a second + // event. + const post = useIdempotentPost(); const create = useMutation({ mutationFn: (body: LifeEventCreateInput) => - apiPost("/api/life-events", body), + post("/api/life-events", body), onSuccess: invalidate, }); const update = useMutation({ diff --git a/src/hooks/use-practitioners.ts b/src/hooks/use-practitioners.ts index 3fdd4145e..3e9bedd26 100644 --- a/src/hooks/use-practitioners.ts +++ b/src/hooks/use-practitioners.ts @@ -11,6 +11,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { apiDelete, apiGetList, apiPatch, apiPost } from "@/lib/api/api-fetch"; +import { useIdempotentPost } from "@/lib/api/use-idempotent-post"; import { encounterDependentKeys, invalidateKeys, @@ -70,10 +71,11 @@ export function usePractitionerMutations() { refetchInactiveDailyReads(qc), ]); + // One key for as long as the sheet that mounts this is open. + const post = useIdempotentPost(); const create = useMutation({ mutationKey: queryKeys.practitionerCreate(), - mutationFn: (body: PractitionerWriteBody) => - apiPost(BASE, body), + mutationFn: (body: PractitionerWriteBody) => post(BASE, body), onSuccess: invalidate, }); diff --git a/src/lib/api/__tests__/localized-error.test.ts b/src/lib/api/__tests__/localized-error.test.ts index b828bf842..33bc278f0 100644 --- a/src/lib/api/__tests__/localized-error.test.ts +++ b/src/lib/api/__tests__/localized-error.test.ts @@ -7,7 +7,11 @@ import en from "../../../../messages/en.json"; import es from "../../../../messages/es.json"; import fr from "../../../../messages/fr.json"; import itCatalog from "../../../../messages/it.json"; +import ko from "../../../../messages/ko.json"; +import nl from "../../../../messages/nl.json"; import pl from "../../../../messages/pl.json"; +import ptBR from "../../../../messages/pt-BR.json"; +import ru from "../../../../messages/ru.json"; type Catalog = Record; @@ -160,4 +164,54 @@ describe("localizedApiError", () => { } }); }); + + describe("the refusals of the visit, vaccination, allergy, address-book and workout saves", () => { + // Each of these is a code the route sends to a form that reads it through + // `localizedApiError`; without a sentence the form says only "could not + // save", which hides a fix the person can make. + const codes = [ + "encounter.invalid", + "encounter.practitioner-not-found", + "encounter.reminder-not-found", + "encounter.reminder-conflict", + "vaccination.invalid", + "vaccination.not-found", + "vaccination.identity-required", + "vaccination.practitioner-not-found", + "vaccination.encounter-not-found", + "vaccination.custom-vaccine-not-found", + "vaccination.booster-invalid", + "vaccination.booster-no-antigen", + "vaccination.booster-out-of-scope", + "allergy.invalid", + "practitioner.invalid", + "workout.batch.invalid", + "workout.batch.too_large", + "workout.batch.payload_too_large", + "workout.batch.source_not_permitted", + ]; + + it.each([ + ["de", de], + ["en", en], + ["es", es], + ["fr", fr], + ["it", itCatalog], + ["ko", ko], + ["nl", nl], + ["pl", pl], + ["pt-BR", ptBR], + ["ru", ru], + ])("has a sentence of its own for each in %s", (_, locale) => { + const localT = translator(locale as Catalog); + for (const code of codes) { + const error = new ApiError("raw English server prose", 422, { + errorCode: code, + }); + expect(localizedApiError(error, localT, "common.error"), code).not.toBe( + localT("common.error"), + ); + } + }); + }); }); diff --git a/src/lib/api/api-fetch.ts b/src/lib/api/api-fetch.ts index 15f2e28a2..7d180aaea 100644 --- a/src/lib/api/api-fetch.ts +++ b/src/lib/api/api-fetch.ts @@ -47,7 +47,10 @@ * sites that need the `Response` itself — blob downloads, SSE * streams, manual status branching. No `.ok` check, no unwrap, and * deliberately NO default timeout: the Coach chat stream and other - * long-lived responses ride this path with their own signals. + * long-lived responses ride this path with their own signals. A + * caller that finds the raw `Response` refused passes it to + * `throwApiError`, so the refusal reaches its handler as the same + * `ApiError` the verb helpers throw. */ import { randomId } from "@/lib/random-id"; @@ -103,7 +106,8 @@ interface Envelope> { meta?: M; } -async function throwApiError(res: Response): Promise { +/** Throw the `ApiError` for a refused raw `Response` (see `apiFetchRaw`). */ +export async function throwApiError(res: Response): Promise { let meta: Record | undefined; let issuePaths: string[] = []; try { diff --git a/src/lib/daily/daily-briefing-push.ts b/src/lib/daily/daily-briefing-push.ts index 1fa56dab9..16047602a 100644 --- a/src/lib/daily/daily-briefing-push.ts +++ b/src/lib/daily/daily-briefing-push.ts @@ -22,9 +22,9 @@ * signal's headline without a figure in it, "your score is ready", or the * honest all-clear — never a reading, a dose or the score itself, since the * push lands on a locked phone). It is dispatched NON-URGENT, so it never escalates to a - * Focus-bypassing / time-sensitive delivery — a concerning reading always stays - * on `MEASUREMENT_ANOMALY`, never here. It fires ONLY inside a local morning - * window, which is the quiet-hours guarantee for this nudge. + * Focus-bypassing / time-sensitive delivery, and a concerning reading is never + * pushed through it. It fires ONLY inside a local morning window, which is the + * quiet-hours guarantee for this nudge. */ import { SESSION_USER_SELECT, type SessionUser } from "@/lib/auth/session-user"; import type { PrismaClient } from "@/generated/prisma/client"; diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 548057cf0..fa30f1bf2 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -58,7 +58,11 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "ai.usage.levelTooLow", "ai.usage.rateLimited", ], - ai_provider: ["ai_provider.invalid", "ai_provider.no_fields"], + ai_provider: [ + "ai_provider.invalid", + "ai_provider.no_fields", + "ai_provider.private_host", + ], aiRuns: [ "aiRuns.failed", "aiRuns.notFound", @@ -84,6 +88,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "auth.already_authenticated", "auth.mfa.code_invalid", "auth.missing", + "auth.passkey.last_method", "auth.refresh.invalid", "auth.refresh.reuse", "auth.refresh.revoked", diff --git a/src/lib/personal-records/pr-detection-worker.ts b/src/lib/personal-records/pr-detection-worker.ts index e2839662a..77074822d 100644 --- a/src/lib/personal-records/pr-detection-worker.ts +++ b/src/lib/personal-records/pr-detection-worker.ts @@ -79,8 +79,8 @@ export interface DetectPersonalRecordsOptions { * Set when a batch ingest crosses the historical-backfill threshold * — a multi-year Apple Health import shouldn't fire hundreds of * pushes during initial sync. The flag rides through to the - * dispatcher; the row is still written, the badge still renders, the - * doctor report still picks it up. + * dispatcher; the row is still written and the doctor report still + * picks it up. */ silent?: boolean; /**