Merge branch 'fix/v1473-rest' into release/v1.47.3

This commit is contained in:
Marc-André Bombeck committed 2026-11-11 21:57:54 +01:00
commit f6d11e7e37
108 files changed
+1723 -688

No files matched your search

-21
View File
@@ -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<void> {
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`,
+43 -3
View File
@@ -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",
+43 -3
View File
@@ -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",
+43 -3
View File
@@ -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",
+43 -3
View File
@@ -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",
+43 -3
View File
@@ -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",
+43 -3
View File
@@ -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": "관리자",
+43 -3
View File
@@ -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",
+43 -3
View File
@@ -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",
+43 -3
View File
@@ -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",
+43 -3
View File
@@ -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": "Администрирование",
@@ -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,
@@ -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",
+1
View File
@@ -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" },
);
}
+2
View File
@@ -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;
+4 -19
View File
@@ -12,6 +12,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { PasswordInput as SharedPasswordInput } from "@/components/ui/password-input";
import { apiGet, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
/**
* Re-export the canonical password input so admin sections have access to
@@ -323,29 +324,13 @@ export function useUpdateSettings() {
toast.success(t("common.saved"));
},
onError: (err) => {
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.settingsSaveError"),
{ cause: err },
);
showErrorToast(localizedApiError(err, t, "admin.settingsSaveError"), {
cause: err,
});
},
});
}
export async function getApiErrorMessage(response: Response): Promise<string> {
const fallback = `HTTP ${response.status}`;
try {
const json = (await response.json()) as { error?: string };
if (typeof json?.error === "string" && json.error.trim().length > 0) {
return json.error;
}
} catch {
return fallback;
}
return fallback;
}
/**
* Shared `auth.*` audit-action label map. Two surfaces consume it
* today (`/admin/login-overview` and the dashboard recent-activity
@@ -21,6 +21,7 @@ import {
queryKeys,
} from "@/lib/query-keys";
import { apiGet, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { PasswordInput } from "./_shared";
/**
@@ -90,10 +91,9 @@ export function AiServerKeySection() {
toast.success(t("admin.aiServerKey.saved"));
},
onError: (error: unknown) => {
showErrorToast(
error instanceof Error ? error.message : t("admin.aiServerKey.error"),
{ cause: error },
);
showErrorToast(localizedApiError(error, t, "admin.aiServerKey.error"), {
cause: error,
});
},
});
+4 -6
View File
@@ -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,
});
},
});
+4 -6
View File
@@ -15,6 +15,7 @@ import {
} from "@/lib/query-keys";
import { SettingsToggle } from "./_shared";
import { apiGet, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import {
@@ -105,12 +106,9 @@ function useUpdateAssistantFlags() {
toast.success(t("common.saved"));
},
onError: (err) => {
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.settingsSaveError"),
{ cause: err },
);
showErrorToast(localizedApiError(err, t, "admin.settingsSaveError"), {
cause: err,
});
client.invalidateQueries({
queryKey: queryKeys.adminAssistantFlags(),
});
@@ -7,14 +7,13 @@ import {
ReproofCancelledError,
throwIfReproofRequired,
} from "@/components/settings/security-section/use-recent-proof";
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
import { apiFetchRaw, apiPost, throwApiError } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { BackupRow } from "@/types/backups";
import { getApiErrorMessage } from "../_shared";
/** "Run backup now": enqueues the pg-boss `data-backup` job. */
export function useRunBackup() {
const { t } = useTranslations();
@@ -34,9 +33,7 @@ export function useRunBackup() {
},
onError: (err) => {
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.section.backups.runFailed"),
localizedApiError(err, t, "admin.section.backups.runFailed"),
{ cause: err },
);
},
@@ -62,9 +59,7 @@ export function useBackupDownload(
await apiFetchRaw(`/api/admin/backups/${row.id}/download`),
),
);
if (!res.ok) {
throw new Error(await getApiErrorMessage(res));
}
if (!res.ok) await throwApiError(res);
// Use the server-provided Content-Disposition filename if present,
// otherwise fall back to a deterministic client-side name. The
// server filename is more accurate (uses the actual createdAt).
@@ -86,9 +81,7 @@ export function useBackupDownload(
} catch (err) {
if (err instanceof ReproofCancelledError) return;
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.section.backups.downloadFailed"),
localizedApiError(err, t, "admin.section.backups.downloadFailed"),
{ cause: err },
);
} finally {
@@ -5,6 +5,7 @@ import { toast } from "sonner";
import type { useRecentProof } from "@/components/settings/security-section/use-recent-proof";
import { ReproofCancelledError } from "@/components/settings/security-section/use-recent-proof";
import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { showErrorToast } from "@/lib/errors/error-toast";
import type {
MissingBackupSection,
@@ -188,9 +189,7 @@ export function useBackupRestores(
}
setRefusedSections([]);
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.section.backups.restoreFailed"),
localizedApiError(err, t, "admin.section.backups.restoreFailed"),
{ cause: err },
);
},
@@ -5,6 +5,7 @@ import { toast } from "sonner";
import type { useRecentProof } from "@/components/settings/security-section/use-recent-proof";
import { ReproofCancelledError } from "@/components/settings/security-section/use-recent-proof";
import { ApiError, apiFetch, apiGet } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -167,9 +168,7 @@ export function useBackupUpload(
return;
}
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.section.backups.uploadFailed"),
localizedApiError(err, t, "admin.section.backups.uploadFailed"),
{ cause: err },
);
},
+24 -36
View File
@@ -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);
}
+5 -2
View File
@@ -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")),
);
},
});
+2 -3
View File
@@ -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 });
},
+11 -9
View File
@@ -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 });
+2 -3
View File
@@ -20,6 +20,7 @@ import {
ConfiguredBadge,
} from "./_shared";
import { apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { queryKeys } from "@/lib/query-keys";
export function GlitchtipSection() {
@@ -113,9 +114,7 @@ export function GlitchtipSection() {
},
onError: (error) => {
showErrorToast(
error instanceof Error
? error.message
: t("admin.monitoringTestFailed"),
localizedApiError(error, t, "admin.monitoringTestFailed"),
{ cause: error },
);
void queryClient.invalidateQueries({
@@ -33,12 +33,12 @@ import {
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { apiFetchRaw, apiGet, throwApiError } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { formatBytes } from "@/lib/format/bytes";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { MeasurementMaintenanceStatus } from "@/lib/jobs/measurement-maintenance-status";
import { queryKeys } from "@/lib/query-keys";
import { getApiErrorMessage } from "./_shared";
const ENDPOINT = "/api/admin/maintenance/measurements";
@@ -70,7 +70,7 @@ export function MeasurementMaintenanceCard() {
headers: { "content-type": "application/json" },
body: JSON.stringify({ vacuum: true, reindex: true }),
});
if (!res.ok) throw new Error(await getApiErrorMessage(res));
if (!res.ok) await throwApiError(res);
return ((await res.json()) as { data: { enqueued: boolean } }).data;
},
onSuccess: ({ enqueued }) => {
@@ -80,9 +80,9 @@ export function MeasurementMaintenanceCard() {
queryKey: queryKeys.adminMeasurementMaintenance(),
});
},
onError: (err: Error) => {
onError: (err) => {
showErrorToast(
err.message || t("admin.section.maintenance.startFailed"),
localizedApiError(err, t, "admin.section.maintenance.startFailed"),
{ cause: err },
);
},
@@ -23,6 +23,7 @@ import {
import type { ModuleKey } from "@/lib/modules/registry";
import { SettingsToggle } from "./_shared";
import { apiGet, apiPatch } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { InlineAction } from "@/components/ui/inline-action";
/**
@@ -76,12 +77,9 @@ function useUpdateModuleAvailability() {
toast.success(t("common.saved"));
},
onError: (err) => {
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.modules.saveError"),
{ cause: err },
);
showErrorToast(localizedApiError(err, t, "admin.modules.saveError"), {
cause: err,
});
client.invalidateQueries({
queryKey: queryKeys.adminModuleAvailability(),
});
@@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { showErrorToast } from "@/lib/errors/error-toast";
import { apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { formatDate } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -41,9 +42,7 @@ export function PendingDeletionControls({ user }: { user: AdminUser }) {
},
onError: (err) => {
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.section.users.undoDeletionFailed"),
localizedApiError(err, t, "admin.section.users.undoDeletionFailed"),
{ cause: err },
);
},
+23 -20
View File
@@ -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 },
);
},
});
+12 -7
View File
@@ -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 },
);
},
});
+5 -7
View File
@@ -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,
});
},
});
+2 -3
View File
@@ -19,6 +19,7 @@ import {
ConfiguredBadge,
} from "./_shared";
import { apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
export function UmamiSection() {
const { t } = useTranslations();
@@ -52,9 +53,7 @@ export function UmamiSection() {
},
onError: (error) => {
showErrorToast(
error instanceof Error
? error.message
: t("admin.monitoringTestFailed"),
localizedApiError(error, t, "admin.monitoringTestFailed"),
{ cause: error },
);
},
@@ -41,7 +41,8 @@ import { queryKeys } from "@/lib/query-keys";
import { type AdminUser, PasswordInput } from "./_shared";
import { PendingDeletionControls } from "./pending-deletion-controls";
import { DeleteUserControl } from "./delete-user-control";
import { apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
import { ApiError, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import {
recentProofErrorMessage,
useRecentProof,
@@ -109,12 +110,9 @@ export function UserManagementSection() {
toast.success(t("common.saved"));
},
onError: (err) => {
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.settingsSaveError"),
{ cause: err },
);
showErrorToast(localizedApiError(err, t, "admin.settingsSaveError"), {
cause: err,
});
},
});
@@ -132,8 +130,17 @@ export function UserManagementSection() {
setResetMsg(t("admin.passwordReset"));
setResetPassword("");
},
onError: (err: Error) => {
setResetMsg(recentProofErrorMessage(err, err.message));
// The server words its password verdict (too weak, found in a breach) in
// the account's language; any other refusal reads through the catalogue.
onError: (err) => {
setResetMsg(
recentProofErrorMessage(
err,
err instanceof ApiError && err.status === 422
? err.message
: localizedApiError(err, t, "common.error"),
),
);
},
});
@@ -156,9 +163,7 @@ export function UserManagementSection() {
},
onError: (err) => {
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.section.users.forceLogoutFailed"),
localizedApiError(err, t, "admin.section.users.forceLogoutFailed"),
{ cause: err },
);
},
@@ -560,7 +565,11 @@ export function UserManagementSection() {
</div>
{updateUser.isError && (
<p className="text-destructive text-sm">
{(updateUser.error as Error).message}
{localizedApiError(
updateUser.error,
t,
"admin.settingsSaveError",
)}
</p>
)}
<SettingsCardActions>
@@ -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]);
});
});
+2 -10
View File
@@ -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({
@@ -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;
}
@@ -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<CustomMetricDto>(`/api/custom-metrics/${existing.id}`, body)
: post<CustomMetricDto>("/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<string | null>(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<CustomMetricDto>(
`/api/custom-metrics/${existing.id}`,
body,
)
: await apiPost<CustomMetricDto>("/api/custom-metrics", body);
const saved = await save(body);
toast.success(
existing
? t("customMetrics.form.updatedToast")
@@ -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<typeof import("react")>()),
useRef: <T>(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<unknown>;
};
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));
});
});
+11 -4
View File
@@ -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<IllnessEpisodeDTO>("/api/illness/episodes", input),
post<IllnessEpisodeDTO>("/api/illness/episodes", input),
onSuccess: invalidate,
});
}
@@ -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 }) => (
<div data-slot="mock-sheet">{children}</div>
),
}));
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(
<I18nProvider initialLocale="en">
<AttachmentPicker
open
onOpenChange={() => undefined}
remainingSlots={3}
excludeIds={[]}
onConfirm={() => undefined}
/>
</I18nProvider>,
);
}
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);
});
});
@@ -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(
<I18nProvider initialLocale="en">
<ChatBubble role="user" content="How did I sleep?" />
</I18nProvider>,
);
}
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<");
});
});
@@ -551,10 +551,11 @@ describe("<MessageThread>", () => {
});
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(<MessageThread conversation={baseConversation} />);
expect(html).toContain('data-slot="coach-bubble-user-avatar"');
expect(html).toMatch(/data-slot="coach-bubble-user-avatar"[^>]*>TE</);
expect(html).toMatch(/data-slot="coach-bubble-user-avatar"[^>]*>T</);
});
// v1.4.25 — optimistic user bubble appears before the "Thinking…"
@@ -45,6 +45,58 @@ export interface AttachmentPickerProps {
onConfirm: (ids: string[]) => void;
}
/** One document in the picker: a toggle, disabled until it is indexed. */
function PickerRow({
doc,
isSelected,
disabled,
onToggle,
}: {
doc: InboundDocumentDto;
isSelected: boolean;
disabled: boolean;
onToggle: () => void;
}) {
const { t } = useTranslations();
const title = doc.title ?? doc.filename ?? t("documents.card.untitled");
const Icon = DOCUMENT_KIND_ICONS[doc.kind];
return (
<li>
<button
type="button"
aria-pressed={isSelected}
disabled={disabled}
onClick={() => doc.hasContentIndex && onToggle()}
data-slot="coach-attachment-picker-item"
data-selected={isSelected ? "true" : undefined}
className={cn(
"border-border hover:bg-muted/50 flex w-full items-center gap-3 rounded-lg border p-3 text-left",
"focus-visible:ring-input-focus focus-visible:ring-[3px] focus-visible:outline-none",
isSelected && "border-primary/40 bg-primary/5",
disabled && "cursor-not-allowed opacity-60 hover:bg-transparent",
)}
>
<Icon className="text-foreground size-5 shrink-0" aria-hidden="true" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{title}</span>
{!doc.hasContentIndex ? (
<span className="text-muted-foreground block text-xs">
{t("insights.coach.attach.pickerNotIndexed")}
</span>
) : null}
</span>
<Check
className={cn(
"text-primary size-4 shrink-0",
isSelected ? "opacity-100" : "opacity-0",
)}
aria-hidden="true"
/>
</button>
</li>
);
}
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({
<ul className="max-h-[50vh] space-y-1.5 overflow-y-auto overscroll-contain">
{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 (
<li key={doc.id}>
<button
type="button"
aria-pressed={isSelected}
disabled={disabled}
onClick={() => selectable && toggle(doc.id)}
data-slot="coach-attachment-picker-item"
data-selected={isSelected ? "true" : undefined}
className={cn(
"border-border hover:bg-muted/50 flex w-full items-center gap-3 rounded-lg border p-3 text-left",
"focus-visible:ring-input-focus focus-visible:ring-[3px] focus-visible:outline-none",
isSelected && "border-primary/40 bg-primary/5",
disabled &&
"cursor-not-allowed opacity-60 hover:bg-transparent",
)}
>
<Icon
className="text-foreground size-5 shrink-0"
aria-hidden="true"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{title}
</span>
{!selectable ? (
<span className="text-muted-foreground block text-xs">
{t("insights.coach.attach.pickerNotIndexed")}
</span>
) : null}
</span>
<Check
className={cn(
"text-primary size-4 shrink-0",
isSelected ? "opacity-100" : "opacity-0",
)}
aria-hidden="true"
/>
</button>
</li>
<PickerRow
key={doc.id}
doc={doc}
isSelected={isSelected}
disabled={!doc.hasContentIndex || (!isSelected && capReached)}
onToggle={() => toggle(doc.id)}
/>
);
})}
</ul>
)}
{cut && !list.isError ? (
<p
data-slot="coach-attachment-picker-capped"
className="text-muted-foreground text-xs"
>
{t("documents.picker.capped", {
count: String(list.data?.documents.length ?? 0),
})}
</p>
) : null}
</div>
</ResponsiveSheet>
);
@@ -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 (
<div
data-slot="coach-bubble-user"
@@ -20,6 +20,7 @@ import {
type UserAIProvider,
} from "@/components/settings/ai/shared";
import { apiGet, apiPatch, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { useTranslations } from "@/lib/i18n/context";
import {
aiInputDependentKeys,
@@ -155,11 +156,7 @@ export function CoachModelPicker() {
void invalidateKeys(queryClient, aiInputDependentKeys);
};
const onFailed = (e: unknown) =>
setError(
e instanceof Error && e.message
? e.message
: t("insights.coach.frame.saveFailed"),
);
setError(localizedApiError(e, t, "insights.coach.frame.saveFailed"));
const switchProvider = useMutation({
mutationFn: async (provider: ProviderType) => {
@@ -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<typeof import("react")>()),
useCallback: <T>(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<string>(),
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"],
});
});
});
@@ -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
@@ -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",
);
@@ -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 }),
@@ -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,
@@ -38,11 +38,9 @@ import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateMedicationReads } from "@/lib/query-keys";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
import {
MedicationIntakeImportError,
waitForMedicationIntakeImport,
} from "@/lib/medications/intake-import-poll";
import { apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { waitForMedicationIntakeImport } from "@/lib/medications/intake-import-poll";
export interface IntakeImportDialogProps {
medicationId: string | null;
@@ -132,19 +130,12 @@ export function IntakeImportDialog({
// import dialog on the detail page).
void invalidateMedicationReads(queryClient);
} catch (err) {
if (err instanceof MedicationIntakeImportError) {
setResult({
kind: "notice",
tone: "error",
text: t("medications.importFailed"),
});
return;
}
if (!(err instanceof ApiError)) throw err;
// A refusal reads through the API catalogue; a failed job's reason is
// the worker's English, so it reads as the plain failure.
setResult({
kind: "notice",
tone: "error",
text: err.message || t("medications.importFailed"),
text: localizedApiError(err, t, "medications.importFailed"),
});
}
} catch (err) {
@@ -95,10 +95,9 @@ type DialogError =
| { kind: "budget" }
| { kind: "consent" }
| { kind: "unavailable" }
| { kind: "network"; message?: string };
| { kind: "network" };
interface ExtractFailureBody {
error?: string | null;
meta?: { errorCode?: unknown } | null;
}
@@ -127,7 +126,7 @@ export function classifyExtractFailure(
}
if (status === 429) return { kind: "rateLimit" };
if (status === 503) return { kind: "noProvider" };
return { kind: "network", message: body?.error ?? undefined };
return { kind: "network" };
}
const MAX_TEXT_LENGTH = 2000;
@@ -200,7 +199,7 @@ export function NaturalLanguageExtractor({
error: string | null;
};
if (body.error || !body.data) {
setError({ kind: "network", message: body.error ?? undefined });
setError({ kind: "network" });
return;
}
onPrefill(body.data);
@@ -214,10 +213,7 @@ export function NaturalLanguageExtractor({
// Caller dismissed the dialog mid-flight; do not surface.
return;
}
setError({
kind: "network",
message: err instanceof Error ? err.message : undefined,
});
setError({ kind: "network" });
} finally {
setBusy(false);
abortRef.current = null;
@@ -37,7 +37,8 @@ import {
medicationDependentKeys,
queryKeys,
} from "@/lib/query-keys";
import { ApiError, apiGet, apiPut } from "@/lib/api/api-fetch";
import { apiGet, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
export interface ApiTokensRowProps {
medicationId: string;
@@ -182,9 +183,7 @@ export function ApiTokensRow({
});
} catch (err) {
showErrorToast(
err instanceof ApiError && err.message
? err.message
: t("medications.detail.api.mintFailed"),
localizedApiError(err, t, "medications.detail.api.mintFailed"),
{ cause: err },
);
} finally {
@@ -72,8 +72,8 @@ export function DataPortabilityRow({
`/api/export/medications?${query}`,
`healthlog-medication-${stamp}.csv`,
);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} catch {
setError(t("settings.sections.export.downloadFailed"));
} finally {
setBusy(false);
}
@@ -48,7 +48,8 @@ import {
medicationDependentKeys,
queryKeys,
} from "@/lib/query-keys";
import { ApiError, apiGet, apiPut } from "@/lib/api/api-fetch";
import { apiGet, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { QueryErrorRow } from "@/components/ui/query-error-row";
type PhaseMode = "MINUTES" | "PERCENT";
@@ -229,9 +230,7 @@ function PhaseConfigForm({
onClose();
} catch (err) {
showErrorToast(
err instanceof ApiError && err.message
? err.message
: t("medications.detail.phases.saveFailed"),
localizedApiError(err, t, "medications.detail.phases.saveFailed"),
{ cause: err },
);
} finally {
@@ -49,6 +49,7 @@ import {
} from "@/lib/medications/side-effects/taxonomy";
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
import { apiDelete, apiGetList, apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { queryKeys } from "@/lib/query-keys";
import { EmptyState } from "@/components/ui/empty-state";
@@ -291,7 +292,9 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
setOpen(false);
},
onError: (err) => {
setFormError(err instanceof Error ? err.message : String(err));
setFormError(
localizedApiError(err, t, "medications.sideEffects.saveError"),
);
},
},
);
@@ -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"
@@ -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<CreateResponse>("/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<Phase>("choose");
const [instrument, setInstrument] = useState<InstrumentId>("PHQ9");
@@ -94,24 +121,9 @@ export function MentalWellbeing() {
});
const history = historyPage?.data;
const mutation = useMutation({
mutationFn: (body: unknown) =>
apiPost<CreateResponse>("/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.
@@ -19,6 +19,7 @@ import {
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ApiError, apiDelete, apiPatch } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { useTranslations } from "@/lib/i18n/context";
import { moodTagIcon } from "../mood-tag-icons";
import {
@@ -74,10 +75,9 @@ export function ArchivedTagsCard({ catalog }: ArchivedTagsCardProps) {
if (err instanceof ApiError && err.status === 422) {
showErrorToast(t("mood.manage.tagLimitReached"), { cause: err });
} else {
showErrorToast(
err instanceof ApiError ? err.message : t("common.error"),
{ cause: err },
);
showErrorToast(localizedApiError(err, t, "common.error"), {
cause: err,
});
}
} finally {
void invalidateMoodTagCaches(queryClient);
@@ -94,10 +94,7 @@ export function ArchivedTagsCard({ catalog }: ArchivedTagsCardProps) {
toast.success(t("mood.manage.purged"));
} catch (err) {
rollback();
showErrorToast(
err instanceof ApiError ? err.message : t("common.error"),
{ cause: err },
);
showErrorToast(localizedApiError(err, t, "common.error"), { cause: err });
} finally {
void invalidateMoodTagCaches(queryClient);
}
@@ -12,6 +12,7 @@ import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { ApiError, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { MoodTagIconPicker } from "../mood-tag-icon-picker";
@@ -144,10 +145,9 @@ export function TagEditorSheet({
if (err instanceof ApiError && err.status === 422) {
showErrorToast(t("mood.manage.tagLimitReached"), { cause: err });
} else {
showErrorToast(
err instanceof ApiError ? err.message : t("common.error"),
{ cause: err },
);
showErrorToast(localizedApiError(err, t, "common.error"), {
cause: err,
});
}
} finally {
setSaving(false);
+8 -12
View File
@@ -60,6 +60,7 @@ import {
apiPost,
apiPut,
} from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import {
readUpdatedAtToken,
withBaseToken,
@@ -175,10 +176,7 @@ export function TagGroupsCard({ catalog }: TagGroupsCardProps) {
toast.message(t("common.conflictReloaded"));
return false;
}
showErrorToast(
err instanceof ApiError ? err.message : t("common.error"),
{ cause: err },
);
showErrorToast(localizedApiError(err, t, "common.error"), { cause: err });
return false;
} finally {
void invalidateMoodTagCaches(queryClient);
@@ -359,10 +357,9 @@ export function TagGroupsCard({ catalog }: TagGroupsCardProps) {
);
toast.success(t("mood.manage.groupDeleted"));
} catch (err) {
showErrorToast(
err instanceof ApiError ? err.message : t("common.error"),
{ cause: err },
);
showErrorToast(localizedApiError(err, t, "common.error"), {
cause: err,
});
} finally {
void invalidateMoodTagCaches(queryClient);
}
@@ -562,10 +559,9 @@ function GroupEditorSheet({
if (err instanceof ApiError && err.status === 422) {
showErrorToast(t("mood.manage.groupLimitReached"), { cause: err });
} else {
showErrorToast(
err instanceof ApiError ? err.message : t("common.error"),
{ cause: err },
);
showErrorToast(localizedApiError(err, t, "common.error"), {
cause: err,
});
}
} finally {
setSaving(false);
@@ -47,7 +47,8 @@ import {
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ApiError, apiPatch, apiPut } from "@/lib/api/api-fetch";
import { apiPatch, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import {
readUpdatedAtToken,
withBaseToken,
@@ -168,10 +169,7 @@ export function TagManagerCard({ catalog }: TagManagerCardProps) {
toast.message(t("common.conflictReloaded"));
return false;
}
showErrorToast(
err instanceof ApiError ? err.message : t("common.error"),
{ cause: err },
);
showErrorToast(localizedApiError(err, t, "common.error"), { cause: err });
return false;
} finally {
void invalidateMoodTagCaches(queryClient);
@@ -243,10 +241,7 @@ export function TagManagerCard({ catalog }: TagManagerCardProps) {
}
} catch (err) {
rollback();
showErrorToast(
err instanceof ApiError ? err.message : t("common.error"),
{ cause: err },
);
showErrorToast(localizedApiError(err, t, "common.error"), { cause: err });
} finally {
void invalidateMoodTagCaches(queryClient);
}
@@ -283,10 +278,7 @@ export function TagManagerCard({ catalog }: TagManagerCardProps) {
toast.message(t("common.conflictReloaded"));
return;
}
showErrorToast(
err instanceof ApiError ? err.message : t("common.error"),
{ cause: err },
);
showErrorToast(localizedApiError(err, t, "common.error"), { cause: err });
} finally {
void invalidateMoodTagCaches(queryClient);
}
+8 -8
View File
@@ -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,
+1 -2
View File
@@ -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
@@ -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);
@@ -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: () => <div data-slot="stored-coach-memory" />,
}));
import { I18nProvider } from "@/lib/i18n/context";
import { AiSection } from "../ai-section";
import { SettingsShell } from "../settings-shell";
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
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(<SettingsShell active="ai">body</SettingsShell>)).toContain(
'href="/settings/coach"',
);
expect(render(<AiSection />)).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(<SettingsShell active="ai">body</SettingsShell>),
).not.toContain('href="/settings/coach"');
expect(render(<AiSection />)).toContain('data-slot="stored-coach-memory"');
});
});
@@ -65,7 +65,8 @@ vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => mocks.queryClient,
}));
vi.mock("@/lib/api/api-fetch", () => ({
vi.mock("@/lib/api/api-fetch", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/api/api-fetch")>()),
apiFetchRaw: mocks.apiFetchRaw,
apiGet: vi.fn(),
}));
@@ -1,12 +1,16 @@
import { QueryClient } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/api/api-fetch", () => ({
vi.mock("@/lib/api/api-fetch", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/api/api-fetch")>()),
apiFetchRaw: vi.fn(),
}));
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { ApiError, apiFetchRaw } from "@/lib/api/api-fetch";
import en from "@/../messages/en.json";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import {
channelSaveError,
optimisticallySetChannelEnabled,
persistChannelEnabled,
rollbackChannelEnabled,
@@ -27,7 +31,7 @@ describe("notification channel enable-only mutation", () => {
new Response(JSON.stringify({ data: { saved: true } }), { status: 200 }),
);
await persistChannelEnabled(endpoint, true, "save failed");
await persistChannelEnabled(endpoint, true);
expect(apiFetchRaw).toHaveBeenCalledWith(endpoint, {
method: "PUT",
@@ -87,7 +91,33 @@ describe("notification channel enable-only mutation", () => {
);
await expect(
persistChannelEnabled("/api/settings/ntfy", true, "save failed"),
persistChannelEnabled("/api/settings/ntfy", true),
).rejects.toThrow("Stored configuration is missing");
});
});
describe("the sentence a refused channel save leaves", () => {
const { t } = getServerTranslator("en");
it("names the private-origin policy like the test button does", () => {
const refusal = new ApiError("Private network target", 422, {
errorCode: "private_origin_not_approved",
});
expect(channelSaveError(refusal, t)).toBe(
en.settings.testConnection.errors.private_origin_not_approved,
);
});
it("reads any other refusal through the catalogue, never as the server's prose", () => {
const refusal = new ApiError("Stored configuration is missing", 422, {
errorCode: "http.invalid",
});
expect(channelSaveError(refusal, t)).toBe(en.apiErrors.http.invalid);
});
it("says could not save when the request never reached the server", () => {
expect(channelSaveError(new TypeError("Failed to fetch"), t)).toBe(
en.settings.savingError,
);
});
});
@@ -146,7 +146,11 @@ function dialog(): Dialog {
},
// The same call `useRecentProof` makes.
submit: (proof) => apiPost("/api/auth/reproof", proof),
messages: () => ({ signInAgain: "sign in again", failed: "failed" }),
messages: () => ({
signInAgain: "sign in again",
failed: "failed",
rateLimited: "rate limited",
}),
});
return d;
}
@@ -11,7 +11,8 @@ import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { ApiError, apiFetchRaw, throwApiError } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { statusText, type StatusMessage } from "./account-section-utils";
import { accountName, nameInitials } from "@/lib/account-name";
@@ -37,41 +38,38 @@ export function AvatarSection() {
method: "POST",
body,
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
if (res.status === 415)
throw new Error(t("settings.avatar.invalidType"));
if (res.status === 413) throw new Error(t("settings.avatar.tooLarge"));
throw new Error(json.error || t("settings.avatar.error"));
}
if (!res.ok) await throwApiError(res);
},
onSuccess: async () => {
setMsgType("success");
setMsg({ key: "settings.avatar.uploaded" });
await refetch();
},
onError: (err: Error) => {
onError: (err) => {
setMsgType("error");
setMsg({ text: err.message });
setMsg(
err instanceof ApiError && err.status === 415
? { key: "settings.avatar.invalidType" }
: err instanceof ApiError && err.status === 413
? { key: "settings.avatar.tooLarge" }
: { text: localizedApiError(err, t, "settings.avatar.error") },
);
},
});
const remove = useMutation({
mutationFn: async () => {
const res = await apiFetchRaw("/api/user/avatar", { method: "DELETE" });
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error(json.error || t("settings.avatar.error"));
}
if (!res.ok) await throwApiError(res);
},
onSuccess: async () => {
setMsgType("success");
setMsg({ key: "settings.avatar.removed" });
await refetch();
},
onError: (err: Error) => {
onError: (err) => {
setMsgType("error");
setMsg({ text: err.message });
setMsg({ text: localizedApiError(err, t, "settings.avatar.error") });
},
});
+6 -6
View File
@@ -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 (
<div className="space-y-6">
<OperatorOffNotice />
@@ -15,6 +15,7 @@ import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { PasswordInput } from "@/components/ui/password-input";
import { apiPatch } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { useTranslations } from "@/lib/i18n/context";
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
@@ -86,7 +87,7 @@ export function AnthropicProviderForm({
},
onError: (e) => {
setOk(false);
setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric"));
setMsg(localizedApiError(e, t, "settings.ai.errorGeneric"));
},
onSettled: () => {
submitInFlightRef.current = false;
@@ -12,7 +12,8 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { apiDelete, apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
@@ -77,18 +78,18 @@ export function CodexProviderForm({
setMsg(null);
setDevicePolling(true);
try {
const res = await apiFetchRaw("/api/auth/codex/device-start", {
method: "POST",
});
const json = await res.json();
if (!res.ok) throw new Error(json.error || t("settings.savingError"));
const data = await apiPost<{
userCode: string;
verificationUrl: string;
intervalSeconds: number;
}>("/api/auth/codex/device-start");
setDeviceCode({
userCode: json.data.userCode,
verificationUrl: json.data.verificationUrl,
intervalSeconds: json.data.intervalSeconds,
userCode: data.userCode,
verificationUrl: data.verificationUrl,
intervalSeconds: data.intervalSeconds,
});
} catch (err) {
setMsg(err instanceof Error ? err.message : t("settings.savingError"));
setMsg(localizedApiError(err, t, "settings.savingError"));
setMsgType("error");
setDevicePolling(false);
}
@@ -101,15 +102,11 @@ export function CodexProviderForm({
async function tick() {
try {
const res = await apiFetchRaw("/api/auth/codex/device-poll", {
method: "POST",
});
const json = await res.json();
const data = await apiPost<{ status?: string } | null>(
"/api/auth/codex/device-poll",
);
if (cancelled) return;
if (!res.ok) {
throw new Error(json.error || t("settings.savingError"));
}
if (json.data?.status === "connected") {
if (data?.status === "connected") {
setDeviceCode(null);
setDevicePolling(false);
setMsg(t("settings.codexConnected"));
@@ -120,7 +117,7 @@ export function CodexProviderForm({
if (!cancelled) setTimeout(tick, intervalMs);
} catch (err) {
if (cancelled) return;
setMsg(err instanceof Error ? err.message : t("settings.savingError"));
setMsg(localizedApiError(err, t, "settings.savingError"));
setMsgType("error");
setDeviceCode(null);
setDevicePolling(false);
@@ -144,18 +141,12 @@ export function CodexProviderForm({
setDisconnecting(true);
setMsg(null);
try {
const res = await apiFetchRaw("/api/auth/codex/disconnect", {
method: "DELETE",
});
if (!res.ok) {
const json = await res.json();
throw new Error(json.error);
}
await apiDelete("/api/auth/codex/disconnect");
setMsg(t("settings.codexDisconnected"));
setMsgType("success");
void invalidateKeys(queryClient, aiInputDependentKeys);
} catch (err) {
setMsg(err instanceof Error ? err.message : t("settings.savingError"));
setMsg(localizedApiError(err, t, "settings.savingError"));
setMsgType("error");
} finally {
setDisconnecting(false);
@@ -17,6 +17,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
import { apiPatch } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { useTranslations } from "@/lib/i18n/context";
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
@@ -78,7 +79,7 @@ export function CompatProviderForm({
},
onError: (e) => {
setOk(false);
setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric"));
setMsg(localizedApiError(e, t, "settings.ai.errorGeneric"));
},
onSettled: () => {
submitInFlightRef.current = false;
@@ -26,6 +26,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
import { NativeSelect } from "@/components/ui/native-select";
import { Switch } from "@/components/ui/switch";
import { apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { useTranslations } from "@/lib/i18n/context";
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
@@ -97,11 +98,7 @@ export function FallbackChainCard({
},
onError: (e) => {
setOk(false);
setMsg(
e instanceof Error
? e.message
: t("settings.ai.providerChain.saveFailed"),
);
setMsg(localizedApiError(e, t, "settings.ai.providerChain.saveFailed"));
},
});
@@ -15,6 +15,7 @@ import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { PasswordInput } from "@/components/ui/password-input";
import { apiPatch } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { useTranslations } from "@/lib/i18n/context";
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
@@ -92,7 +93,7 @@ export function LocalProviderForm({
},
onError: (e) => {
setOk(false);
setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric"));
setMsg(localizedApiError(e, t, "settings.ai.errorGeneric"));
},
onSettled: () => {
submitInFlightRef.current = false;
@@ -18,6 +18,7 @@ import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { PasswordInput } from "@/components/ui/password-input";
import { apiPatch } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { useTranslations } from "@/lib/i18n/context";
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
@@ -94,7 +95,7 @@ export function OpenAIProviderForm({
},
onError: (e) => {
setOk(false);
setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric"));
setMsg(localizedApiError(e, t, "settings.ai.errorGeneric"));
},
onSettled: () => {
submitInFlightRef.current = false;
@@ -24,6 +24,7 @@ import { Label } from "@/components/ui/label";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { PROVIDER_DEFAULT_TIMEOUT_MS } from "@/lib/ai/effective-timeout";
import { apiPatch } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { useTranslations } from "@/lib/i18n/context";
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
@@ -68,17 +69,7 @@ export function ResponseTimeoutCard({
}
const saveMutation = useMutation({
mutationFn: async () => {
const trimmed = value.trim();
const responseTimeoutSeconds = trimmed === "" ? null : Number(trimmed);
if (
responseTimeoutSeconds !== null &&
(!Number.isInteger(responseTimeoutSeconds) ||
responseTimeoutSeconds < 10 ||
responseTimeoutSeconds > 600)
) {
throw new Error(t("settings.ai.responseTimeoutInvalid"));
}
mutationFn: async (responseTimeoutSeconds: number | null) => {
await apiPatch("/api/user/ai-provider", { responseTimeoutSeconds });
},
onSuccess: () => {
@@ -88,7 +79,7 @@ export function ResponseTimeoutCard({
},
onError: (e) => {
setOk(false);
setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric"));
setMsg(localizedApiError(e, t, "settings.ai.errorGeneric"));
},
onSettled: () => {
submitInFlightRef.current = false;
@@ -98,8 +89,20 @@ export function ResponseTimeoutCard({
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (submitInFlightRef.current || saveMutation.isPending) return;
const trimmed = value.trim();
const responseTimeoutSeconds = trimmed === "" ? null : Number(trimmed);
if (
responseTimeoutSeconds !== null &&
(!Number.isInteger(responseTimeoutSeconds) ||
responseTimeoutSeconds < 10 ||
responseTimeoutSeconds > 600)
) {
setOk(false);
setMsg(t("settings.ai.responseTimeoutInvalid"));
return;
}
submitInFlightRef.current = true;
saveMutation.mutate();
saveMutation.mutate(responseTimeoutSeconds);
}
return (
@@ -17,6 +17,7 @@ import { apiFetchRaw, apiPut } from "@/lib/api/api-fetch";
import { formatDateTime } from "@/lib/format";
import { useAiCapability } from "@/hooks/use-ai-capability";
import { useTranslations } from "@/lib/i18n/context";
import { translateCodeOrNull } from "@/lib/i18n/translate-code";
import {
localiseTestReason,
@@ -83,7 +84,6 @@ export function RuntimeActionsRow({
reason?: string;
httpStatus?: number | null;
} | null;
error?: string | null;
};
try {
json = await res.json();
@@ -102,7 +102,12 @@ export function RuntimeActionsRow({
typeof code === "string" && code.startsWith("assistant.disabled.")
? t("settings.ai.operatorOff.testRefused")
: t("settings.ai.testFailedShort", {
message: json.error ?? `HTTP ${res.status}`,
message:
translateCodeOrNull(
t,
"apiErrors",
typeof code === "string" ? code : null,
) ?? `HTTP ${res.status}`,
}),
);
return;
@@ -131,12 +136,10 @@ export function RuntimeActionsRow({
model: json.data?.model ?? "",
}),
);
} catch (e) {
} catch {
setTestOk(false);
setTestMsg(
t("settings.ai.testFailedShort", {
message: e instanceof Error ? e.message : "fetch error",
}),
t("settings.ai.testFailedShort", { message: t("common.networkError") }),
);
} finally {
setTesting(false);
+8 -8
View File
@@ -14,10 +14,13 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { isEmailFormDirty } from "@/components/settings/channel-form-dirty";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { useChannelEnabledToggle } from "@/components/settings/notification-channel-toggle";
import {
channelSaveError,
useChannelEnabledToggle,
} from "@/components/settings/notification-channel-toggle";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { apiFetchRaw, apiGet, throwApiError } from "@/lib/api/api-fetch";
interface EmailSettings {
enabled: boolean;
@@ -64,10 +67,7 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
enabled: settings?.enabled ?? false,
}),
});
if (!res.ok) {
const json = await res.json();
throw new Error(json.error || t("common.error"));
}
if (!res.ok) await throwApiError(res);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.settingsEmail() });
@@ -75,8 +75,8 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
toastWrittenOutcome("success", t("settings.saved"));
},
// A form shows its refusal inline, next to the field it is about.
onError: (err: Error) => {
setSaveError(err.message);
onError: (err) => {
setSaveError(channelSaveError(err, t));
},
});
@@ -31,10 +31,7 @@ import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import {
MedicationIntakeImportError,
waitForMedicationIntakeImport,
} from "@/lib/medications/intake-import-poll";
import { waitForMedicationIntakeImport } from "@/lib/medications/intake-import-poll";
import { invalidateMedicationReads } from "@/lib/query-keys";
import { DoseHistoryColumnRulings } from "./dose-history-columns";
@@ -194,15 +191,12 @@ export function DoseHistoryImportCard() {
skippedDetailsOmitted: result.skippedDetailsOmitted,
});
void invalidateMedicationReads(queryClient);
} catch (err) {
// A failed job carries a sanitised reason; anything else is a transport
// failure with nothing to add. Either way the run did not write, and the
// card says so rather than leaving the last verdict on screen.
setError(
err instanceof MedicationIntakeImportError && err.message.length > 0
? err.message
: t("settings.sections.export.import.doseHistory.failed"),
);
} catch {
// A failed job's reason is the worker's English and adds nothing;
// anything else is a transport failure. Either way the run did not
// write, and the card says so rather than leaving the last verdict on
// screen.
setError(t("settings.sections.export.import.doseHistory.failed"));
setVerdict(null);
} finally {
setBusy(false);
@@ -15,33 +15,46 @@ import {
} from "@tanstack/react-query";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { ApiError, apiFetchRaw, throwApiError } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { translateCodeOrNull } from "@/lib/i18n/translate-code";
interface EnabledSettings {
enabled: boolean;
}
/**
* A refused channel save in the reader's language. The private-origin policy
* reads like the test button's verdict; anything else goes through the API
* catalogue, and a request that never reached the server (offline, a dropped
* connection) reads as the plain "could not save". Never the server's prose.
*/
export function channelSaveError(
err: unknown,
t: (key: string) => string,
): string {
const code = err instanceof ApiError ? err.meta?.errorCode : undefined;
return (
translateCodeOrNull(
t,
"settings.testConnection.errors",
typeof code === "string" ? code : null,
) ?? localizedApiError(err, t, "settings.savingError")
);
}
export async function persistChannelEnabled(
endpoint: string,
enabled: boolean,
fallbackError: string,
): Promise<void> {
// A request that never reaches the server (offline, a dropped connection)
// is a refused change like any other.
const response = await apiFetchRaw(endpoint, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
}).catch(() => null);
if (!response?.ok) {
const json = (await response?.json().catch(() => null)) as {
error?: string;
} | null;
throw new Error(json?.error || fallbackError);
}
});
if (!response.ok) await throwApiError(response);
}
export function optimisticallySetChannelEnabled<T extends EnabledSettings>(
@@ -75,8 +88,7 @@ export function useChannelEnabledToggle<T extends EnabledSettings>(
const { t } = useTranslations();
const queryClient = useQueryClient();
return useMutation<void, Error, boolean, T | undefined>({
mutationFn: (enabled) =>
persistChannelEnabled(endpoint, enabled, t("settings.savingError")),
mutationFn: (enabled) => persistChannelEnabled(endpoint, enabled),
onMutate: async (enabled) => {
await queryClient.cancelQueries({ queryKey });
return optimisticallySetChannelEnabled<T>(queryClient, queryKey, enabled);
@@ -86,7 +98,7 @@ export function useChannelEnabledToggle<T extends EnabledSettings>(
},
onError: (err, _enabled, previous) => {
rollbackChannelEnabled(queryClient, queryKey, previous);
showErrorToast(err.message, { cause: err });
showErrorToast(channelSaveError(err, t), { cause: err });
},
onSettled: () => {
void queryClient.invalidateQueries({ queryKey });
+8 -21
View File
@@ -15,11 +15,13 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { isNtfyFormDirty } from "@/components/settings/channel-form-dirty";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { useChannelEnabledToggle } from "@/components/settings/notification-channel-toggle";
import {
channelSaveError,
useChannelEnabledToggle,
} from "@/components/settings/notification-channel-toggle";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { translateCodeOrNull } from "@/lib/i18n/translate-code";
import { apiFetchRaw, apiGet, throwApiError } from "@/lib/api/api-fetch";
interface NtfySettings {
enabled: boolean;
@@ -73,22 +75,7 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
enabled: settings?.enabled ?? false,
}),
});
if (!res.ok) {
const json = (await res.json()) as {
error?: string;
meta?: { errorCode?: string };
};
// A coded refusal (the private-origin policy) translates like the
// test button's; anything else shows the server sentence.
throw new Error(
translateCodeOrNull(
t,
"settings.testConnection.errors",
json.meta?.errorCode,
) ??
(json.error || t("common.error")),
);
}
if (!res.ok) await throwApiError(res);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.settingsNtfy() });
@@ -97,8 +84,8 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
setAuthToken("");
},
// A form shows its refusal inline, next to the fields it is about.
onError: (err: Error) => {
setSaveError(err.message);
onError: (err) => {
setSaveError(channelSaveError(err, t));
},
});
@@ -49,6 +49,7 @@ import { ReportVisitOffer } from "./report-visit-offer";
import { Switch } from "@/components/ui/switch";
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import {
recentProofErrorMessage,
throwIfReproofRequired,
@@ -177,7 +178,7 @@ export function HealthRecordExportPanel() {
}
const message = recentProofErrorMessage(
err,
err instanceof Error ? err.message : String(err),
localizedApiError(err, t, "settings.healthRecord.errorGeneric"),
);
if (message) setError(message);
} finally {
@@ -62,7 +62,11 @@ describe("the re-proof dialog flow", () => {
const flow = createRecentProofFlow({
view: { setMethods, setPending: vi.fn(), setError: vi.fn() },
submit: vi.fn(),
messages: () => ({ signInAgain: "sign in again", failed: "failed" }),
messages: () => ({
signInAgain: "sign in again",
failed: "failed",
rateLimited: "rate limited",
}),
});
void flow
.run(async () => {
@@ -75,4 +79,23 @@ describe("the re-proof dialog flow", () => {
await vi.waitFor(() => expect(setMethods).toHaveBeenCalledWith(["oidc"]));
flow.cancel();
});
it("says a throttled proof in the caller's words, not the server's", async () => {
const setError = vi.fn();
const flow = createRecentProofFlow({
view: { setMethods: vi.fn(), setPending: vi.fn(), setError },
submit: vi.fn(async () => {
throw new ApiError("Too many attempts. Please wait 15 minutes.", 429);
}),
messages: () => ({
signInAgain: "sign in again",
failed: "failed",
rateLimited: "rate limited",
}),
});
await flow.prove({ method: "password", password: "pw" });
expect(setError).toHaveBeenLastCalledWith("rate limited");
});
});
@@ -12,8 +12,9 @@
* 1. a step-up refusal renders the card's sentence, not the server's prose,
* 2. the mfa_not_enrolled arm maps too — both codes mean "the gate stopped
* you", and handling only the first falls through to raw English,
* 3. an ordinary failure still shows its own message rather than being
* swallowed by the step-up sentence,
* 3. any other refusal gets the caller's sentence (its catalogue sentence
* through `localizedApiError`), not the step-up one and not the server's
* prose,
* 4. the sentence resolves in more than one locale,
* 5. the sentence names a recovery that always exists. A refusal that names
* its proofs (`meta.methods`) opens the re-proof dialog instead and never
@@ -65,14 +66,15 @@ describe("passkey removal — the step-up refusal a person sees", () => {
).toBe(STEP_UP_SENTENCE);
});
it("leaves an ordinary refusal saying its own thing", () => {
it("leaves any other refusal to the caller's sentence, never the server's", () => {
const lastMethod = new ApiError(
"Cannot delete — at least one authentication method must remain",
"Cannot delete. At least one authentication method must remain",
400,
{ errorCode: "auth.passkey.last_method" },
);
expect(describeStepUp(lastMethod, "fallback", STEP_UP_SENTENCE)).toContain(
"at least one authentication method",
expect(describeStepUp(lastMethod, "fallback", STEP_UP_SENTENCE)).toBe(
"fallback",
);
});
@@ -27,7 +27,7 @@
*
* Mutation check: drop either `onSettled` and the wiring case goes red; drop
* an `open=` binding and the controlled case goes red; return `err.message`
* from `describeError`'s step-up arm and the sentence case goes red.
* from `describeStepUp`'s step-up arm and the sentence case goes red.
*/
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
@@ -38,7 +38,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ApiError } from "@/lib/api/api-fetch";
import { I18nProvider } from "@/lib/i18n/context";
import en from "@/../messages/en.json";
import { describeError, TotpCard } from "../totp-card";
import { describeStepUp } from "../security-keys-card";
import { TotpCard } from "../totp-card";
const STEP_UP_SENTENCE = en.settings.security.stepUpRequired;
@@ -81,7 +82,7 @@ describe("<TotpCard> — a refused mutation", () => {
{ errorCode: "auth.stepup.required" },
);
const shown = describeError(refusal, "fallback", STEP_UP_SENTENCE);
const shown = describeStepUp(refusal, "fallback", STEP_UP_SENTENCE);
expect(shown).toBe(STEP_UP_SENTENCE);
expect(shown).not.toContain("second-factor verification required");
@@ -17,6 +17,7 @@ import { Segmented } from "@/components/ui/segmented";
import { Input } from "@/components/ui/input";
import { useTranslations } from "@/lib/i18n/context";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { describePasskeyError } from "@/lib/passkey-errors";
export type ReauthMethod =
@@ -171,7 +172,7 @@ export function ExistingFactorReauthDialog({
onProof({ method, challengeId: begun.challengeId, credential });
} catch (err) {
if (err instanceof ApiError) {
setAssertionError(err.message);
setAssertionError(localizedApiError(err, t, "settings.reproof.failed"));
} else {
const { key, params } = describePasskeyError(err);
setAssertionError(t(key, params));
@@ -43,6 +43,7 @@ import {
apiPatch,
apiPost,
} from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { describePasskeyError } from "@/lib/passkey-errors";
import { describeStepUp } from "./security-keys-card";
import {
@@ -206,7 +207,7 @@ export function PasskeyListSection({
},
onError: (err) => {
if (err instanceof ApiError) {
setMsg(err.message || t("settings.passkeyRegistrationFailed"));
setMsg(localizedApiError(err, t, "settings.passkeyRegistrationFailed"));
} else {
const { key, params } = describePasskeyError(err);
setMsg(t(key, params));
@@ -223,7 +224,8 @@ export function PasskeyListSection({
setMsg(null);
queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() });
},
onError: (err: Error) => setMsg(err.message),
onError: (err) =>
setMsg(localizedApiError(err, t, "settings.passkeyRenameFailed")),
});
// Removal is step-up gated: a second factor, or on a passkey-only account a
@@ -248,7 +250,11 @@ export function PasskeyListSection({
onError: (err: Error) => {
if (err instanceof ReproofCancelledError) return;
setMsg(
describeStepUp(err, err.message, t("settings.passkeyStepUpRequired")),
describeStepUp(
err,
localizedApiError(err, t, "settings.passkeyRemoveFailed"),
t("settings.passkeyStepUpRequired"),
),
);
},
});
@@ -83,7 +83,7 @@ export function createRecentProofFlow(deps: {
/** Posts the proof to `/api/auth/reproof`. */
submit: (proof: ExistingFactorProof) => Promise<unknown>;
/** Read at the moment they are needed, so a locale switch is honoured. */
messages: () => { signInAgain: string; failed: string };
messages: () => { signInAgain: string; failed: string; rateLimited: string };
}): RecentProofFlow {
let waiter: { resolve: () => void; reject: () => void } | null = null;
@@ -127,7 +127,7 @@ export function createRecentProofFlow(deps: {
} catch (err) {
deps.view.setError(
err instanceof ApiError && err.status === 429
? err.message
? deps.messages().rateLimited
: deps.messages().failed,
);
} finally {
@@ -23,6 +23,7 @@ import { SettingsCardActions } from "@/components/settings/_card-actions";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { ApiError, apiDelete, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { describePasskeyError } from "@/lib/passkey-errors";
import { formatDate } from "@/lib/format";
import {
@@ -50,6 +51,8 @@ export interface WebauthnKeyInfo {
* "Recent second-factor verification required", which is English-only and, for a
* passkey-only account, not even accurate about which credential to re-prove —
* so the caller supplies its own sentence and this only decides when to use it.
* Any other refusal reads as `fallback`, which callers resolve through
* `localizedApiError`; the server's prose is never shown.
*
* Matched on `meta.errorCode`, and on the whole `auth.stepup` prefix rather than
* one code: `auth.stepup.required` and `auth.stepup.mfa_not_enrolled` both mean
@@ -70,7 +73,6 @@ export function describeStepUp(
) {
return stepUpMsg;
}
return err.message || fallback;
}
return fallback;
}
@@ -141,7 +143,7 @@ export function SecurityKeysCard({
}
let message: string;
if (err instanceof ApiError) {
message = err.message || t("settings.security.keys.addFailed");
message = localizedApiError(err, t, "settings.security.keys.addFailed");
} else {
const { key, params } = describePasskeyError(err);
message = t(key, params);
@@ -165,9 +167,7 @@ export function SecurityKeysCard({
},
onError: (err) =>
setError(
err instanceof ApiError
? err.message
: t("settings.security.keys.renameFailed"),
localizedApiError(err, t, "settings.security.keys.renameFailed"),
),
});
@@ -191,7 +191,7 @@ export function SecurityKeysCard({
setError(
describeStepUp(
err,
t("settings.security.keys.removeFailed"),
localizedApiError(err, t, "settings.security.keys.removeFailed"),
t("settings.security.stepUpRequired"),
),
);
@@ -31,8 +31,10 @@ import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { RecoveryCodesPanel } from "./recovery-codes-panel";
import { describeStepUp } from "./security-keys-card";
import {
ReproofCancelledError,
recentProofErrorMessage,
@@ -59,31 +61,6 @@ interface ConfirmResult {
recoveryCodesRemaining: number;
}
/**
* Map a step-up 401 to a clear re-login message; otherwise the raw message.
*
* Exported for the unit suite: this is the sentence a refused mutation leaves
* on the card, and the card is where a person reads it.
*/
export function describeError(
err: unknown,
fallback: string,
stepUpMsg: string,
): string {
if (err instanceof ApiError) {
const code = err.meta?.errorCode;
if (
err.status === 401 &&
typeof code === "string" &&
code.startsWith("auth.stepup")
) {
return stepUpMsg;
}
return err.message || fallback;
}
return fallback;
}
export function TotpCard({
enabled,
recoveryCodesRemaining,
@@ -201,9 +178,9 @@ export function TotpCard({
setReauthOpen(true);
return;
}
const message = describeError(
const message = describeStepUp(
err,
t("settings.security.totp.setupFailed"),
localizedApiError(err, t, "settings.security.totp.setupFailed"),
t("settings.security.stepUpRequired"),
);
if (proof) setReauthError(message);
@@ -223,9 +200,9 @@ export function TotpCard({
},
onError: (err) =>
setError(
describeError(
describeStepUp(
err,
t("settings.security.totp.invalidCode"),
localizedApiError(err, t, "settings.security.totp.invalidCode"),
t("settings.security.stepUpRequired"),
),
),
@@ -248,9 +225,9 @@ export function TotpCard({
onError: (err) => {
if (err instanceof ReproofCancelledError) return;
setError(
describeError(
describeStepUp(
err,
t("settings.security.totp.disableFailed"),
localizedApiError(err, t, "settings.security.totp.disableFailed"),
t("settings.security.stepUpRequired"),
),
);
@@ -274,9 +251,9 @@ export function TotpCard({
onError: (err) => {
if (err instanceof ReproofCancelledError) return;
setError(
describeError(
describeStepUp(
err,
t("settings.security.recovery.regenFailed"),
localizedApiError(err, t, "settings.security.recovery.regenFailed"),
t("settings.security.stepUpRequired"),
),
);
@@ -105,6 +105,7 @@ export function useRecentProof(
messages: () => ({
signInAgain: t("settings.reproof.signInAgain"),
failed: t("settings.reproof.failed"),
rateLimited: t("apiErrors.http.rate_limited"),
}),
}),
[t],
+4 -3
View File
@@ -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:<slug>`); 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) =>
@@ -22,7 +22,8 @@ import {
type ThresholdMetric,
type EffectiveRange,
} from "@/lib/analytics/effective-range";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { apiFetchRaw, apiGet, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { resolveTargetUnitAdapter } from "@/lib/targets/target-unit-display";
import { SettingsCard } from "@/components/settings/settings-card";
@@ -81,15 +82,7 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
mutationFn: async (
payload: Partial<Record<ThresholdMetric, { min: number; max: number }>>,
) => {
const res = await apiFetchRaw("/api/user/thresholds", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(err?.error ?? "save failed");
}
await apiPut("/api/user/thresholds", payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.userThresholds() });
@@ -102,10 +95,9 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
toast.success(t("thresholds.saveSuccess"));
},
onError: (err) =>
showErrorToast(
err instanceof Error ? err.message : t("thresholds.saveError"),
{ cause: err },
),
showErrorToast(localizedApiError(err, t, "thresholds.saveError"), {
cause: err,
}),
});
const resetMutation = useMutation({
+8 -21
View File
@@ -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));
},
});
+5 -2
View File
@@ -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<SymptomDefinitionDTO>("/api/symptoms/definitions", input),
post<SymptomDefinitionDTO>("/api/symptoms/definitions", input),
onSuccess: invalidate,
});
}
+4 -4
View File
@@ -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({
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff. Show more