mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
Merge branch 'fix/v1473-rest' into release/v1.47.3
This commit is contained in:
commit
f6d11e7e37
108 files changed
+1723
-688
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -126,6 +126,7 @@ export const DELETE = apiHandler(
|
||||
return apiError(
|
||||
"Cannot delete. At least one authentication method must remain",
|
||||
400,
|
||||
{ errorCode: "auth.passkey.last_method" },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -265,6 +265,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
return apiError(
|
||||
"Base URL points to an internal/private host. The operator must allow its exact origin on this instance via AI_PRIVATE_ORIGINS (e.g. http://ollama.lan:11434) for a self-hosted Ollama / LM Studio.",
|
||||
422,
|
||||
{ errorCode: "ai_provider.private_host" },
|
||||
);
|
||||
}
|
||||
updates.aiBaseUrl = trimmed;
|
||||
@@ -289,6 +290,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
return apiError(
|
||||
"Base URL points to an internal/private host. The operator must allow its exact origin on this instance via AI_PRIVATE_ORIGINS (e.g. http://litellm.lan:4000) for a self-hosted gateway.",
|
||||
422,
|
||||
{ errorCode: "ai_provider.private_host" },
|
||||
);
|
||||
}
|
||||
updates.aiCompatBaseUrl = trimmed;
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { PasswordInput as SharedPasswordInput } from "@/components/ui/password-input";
|
||||
import { apiGet, apiPut } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
|
||||
/**
|
||||
* Re-export the canonical password input so admin sections have access to
|
||||
@@ -323,29 +324,13 @@ export function useUpdateSettings() {
|
||||
toast.success(t("common.saved"));
|
||||
},
|
||||
onError: (err) => {
|
||||
showErrorToast(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("admin.settingsSaveError"),
|
||||
{ cause: err },
|
||||
);
|
||||
showErrorToast(localizedApiError(err, t, "admin.settingsSaveError"), {
|
||||
cause: err,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getApiErrorMessage(response: Response): Promise<string> {
|
||||
const fallback = `HTTP ${response.status}`;
|
||||
try {
|
||||
const json = (await response.json()) as { error?: string };
|
||||
if (typeof json?.error === "string" && json.error.trim().length > 0) {
|
||||
return json.error;
|
||||
}
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared `auth.*` audit-action label map. Two surfaces consume it
|
||||
* today (`/admin/login-overview` and the dashboard recent-activity
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import { apiGet, apiPut } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { PasswordInput } from "./_shared";
|
||||
|
||||
/**
|
||||
@@ -90,10 +91,9 @@ export function AiServerKeySection() {
|
||||
toast.success(t("admin.aiServerKey.saved"));
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
showErrorToast(
|
||||
error instanceof Error ? error.message : t("admin.aiServerKey.error"),
|
||||
{ cause: error },
|
||||
);
|
||||
showErrorToast(localizedApiError(error, t, "admin.aiServerKey.error"), {
|
||||
cause: error,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
type AiUsageLevel,
|
||||
} from "@/lib/ai/usage-level";
|
||||
import { apiGet, apiPut } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
aiInputDependentKeys,
|
||||
@@ -122,12 +123,9 @@ function AiUsageForm({ bounds }: { bounds: AiUsageBounds }) {
|
||||
toast.success(t("common.saved"));
|
||||
},
|
||||
onError: (err) => {
|
||||
showErrorToast(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("admin.settingsSaveError"),
|
||||
{ cause: err },
|
||||
);
|
||||
showErrorToast(localizedApiError(err, t, "admin.settingsSaveError"), {
|
||||
cause: err,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "@/lib/query-keys";
|
||||
import { SettingsToggle } from "./_shared";
|
||||
import { apiGet, apiPut } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import {
|
||||
@@ -105,12 +106,9 @@ function useUpdateAssistantFlags() {
|
||||
toast.success(t("common.saved"));
|
||||
},
|
||||
onError: (err) => {
|
||||
showErrorToast(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("admin.settingsSaveError"),
|
||||
{ cause: err },
|
||||
);
|
||||
showErrorToast(localizedApiError(err, t, "admin.settingsSaveError"), {
|
||||
cause: err,
|
||||
});
|
||||
client.invalidateQueries({
|
||||
queryKey: queryKeys.adminAssistantFlags(),
|
||||
});
|
||||
|
||||
@@ -7,14 +7,13 @@ import {
|
||||
ReproofCancelledError,
|
||||
throwIfReproofRequired,
|
||||
} from "@/components/settings/security-section/use-recent-proof";
|
||||
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
|
||||
import { apiFetchRaw, apiPost, throwApiError } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { BackupRow } from "@/types/backups";
|
||||
|
||||
import { getApiErrorMessage } from "../_shared";
|
||||
|
||||
/** "Run backup now": enqueues the pg-boss `data-backup` job. */
|
||||
export function useRunBackup() {
|
||||
const { t } = useTranslations();
|
||||
@@ -34,9 +33,7 @@ export function useRunBackup() {
|
||||
},
|
||||
onError: (err) => {
|
||||
showErrorToast(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("admin.section.backups.runFailed"),
|
||||
localizedApiError(err, t, "admin.section.backups.runFailed"),
|
||||
{ cause: err },
|
||||
);
|
||||
},
|
||||
@@ -62,9 +59,7 @@ export function useBackupDownload(
|
||||
await apiFetchRaw(`/api/admin/backups/${row.id}/download`),
|
||||
),
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error(await getApiErrorMessage(res));
|
||||
}
|
||||
if (!res.ok) await throwApiError(res);
|
||||
// Use the server-provided Content-Disposition filename if present,
|
||||
// otherwise fall back to a deterministic client-side name. The
|
||||
// server filename is more accurate (uses the actual createdAt).
|
||||
@@ -86,9 +81,7 @@ export function useBackupDownload(
|
||||
} catch (err) {
|
||||
if (err instanceof ReproofCancelledError) return;
|
||||
showErrorToast(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("admin.section.backups.downloadFailed"),
|
||||
localizedApiError(err, t, "admin.section.backups.downloadFailed"),
|
||||
{ cause: err },
|
||||
);
|
||||
} finally {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { toast } from "sonner";
|
||||
import type { useRecentProof } from "@/components/settings/security-section/use-recent-proof";
|
||||
import { ReproofCancelledError } from "@/components/settings/security-section/use-recent-proof";
|
||||
import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import type {
|
||||
MissingBackupSection,
|
||||
@@ -188,9 +189,7 @@ export function useBackupRestores(
|
||||
}
|
||||
setRefusedSections([]);
|
||||
showErrorToast(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("admin.section.backups.restoreFailed"),
|
||||
localizedApiError(err, t, "admin.section.backups.restoreFailed"),
|
||||
{ cause: err },
|
||||
);
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import { toast } from "sonner";
|
||||
import type { useRecentProof } from "@/components/settings/security-section/use-recent-proof";
|
||||
import { ReproofCancelledError } from "@/components/settings/security-section/use-recent-proof";
|
||||
import { ApiError, apiFetch, apiGet } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -167,9 +168,7 @@ export function useBackupUpload(
|
||||
return;
|
||||
}
|
||||
showErrorToast(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("admin.section.backups.uploadFailed"),
|
||||
localizedApiError(err, t, "admin.section.backups.uploadFailed"),
|
||||
{ cause: err },
|
||||
);
|
||||
},
|
||||
|
||||
@@ -28,7 +28,8 @@ import { Button } from "@/components/ui/button";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import { apiDelete, apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
@@ -66,21 +67,20 @@ export function CentralCodexSection() {
|
||||
async function handleConnect() {
|
||||
setDevicePolling(true);
|
||||
try {
|
||||
const res = await apiFetchRaw("/api/admin/central-codex/device-start", {
|
||||
method: "POST",
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json.error || t("admin.centralCodex.error"));
|
||||
const data = await apiPost<{
|
||||
userCode: string;
|
||||
verificationUrl: string;
|
||||
intervalSeconds: number;
|
||||
}>("/api/admin/central-codex/device-start");
|
||||
setDeviceCode({
|
||||
userCode: json.data.userCode,
|
||||
verificationUrl: json.data.verificationUrl,
|
||||
intervalSeconds: json.data.intervalSeconds,
|
||||
userCode: data.userCode,
|
||||
verificationUrl: data.verificationUrl,
|
||||
intervalSeconds: data.intervalSeconds,
|
||||
});
|
||||
} catch (err) {
|
||||
showErrorToast(
|
||||
err instanceof Error ? err.message : t("admin.centralCodex.error"),
|
||||
{ cause: err },
|
||||
);
|
||||
showErrorToast(localizedApiError(err, t, "admin.centralCodex.error"), {
|
||||
cause: err,
|
||||
});
|
||||
setDevicePolling(false);
|
||||
}
|
||||
}
|
||||
@@ -92,15 +92,11 @@ export function CentralCodexSection() {
|
||||
|
||||
async function tick() {
|
||||
try {
|
||||
const res = await apiFetchRaw("/api/admin/central-codex/device-poll", {
|
||||
method: "POST",
|
||||
});
|
||||
const json = await res.json();
|
||||
const data = await apiPost<{ status?: string } | null>(
|
||||
"/api/admin/central-codex/device-poll",
|
||||
);
|
||||
if (cancelled) return;
|
||||
if (!res.ok) {
|
||||
throw new Error(json.error || t("admin.centralCodex.error"));
|
||||
}
|
||||
if (json.data?.status === "connected") {
|
||||
if (data?.status === "connected") {
|
||||
setDeviceCode(null);
|
||||
setDevicePolling(false);
|
||||
toast.success(t("admin.centralCodex.connected"));
|
||||
@@ -113,10 +109,9 @@ export function CentralCodexSection() {
|
||||
if (!cancelled) setTimeout(tick, intervalMs);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
showErrorToast(
|
||||
err instanceof Error ? err.message : t("admin.centralCodex.error"),
|
||||
{ cause: err },
|
||||
);
|
||||
showErrorToast(localizedApiError(err, t, "admin.centralCodex.error"), {
|
||||
cause: err,
|
||||
});
|
||||
setDeviceCode(null);
|
||||
setDevicePolling(false);
|
||||
}
|
||||
@@ -133,23 +128,16 @@ export function CentralCodexSection() {
|
||||
async function handleDisconnect() {
|
||||
setDisconnecting(true);
|
||||
try {
|
||||
const res = await apiFetchRaw("/api/admin/central-codex", {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const json = await res.json();
|
||||
throw new Error(json.error || t("admin.centralCodex.error"));
|
||||
}
|
||||
await apiDelete("/api/admin/central-codex");
|
||||
toast.success(t("admin.centralCodex.disconnected"));
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.adminCentralCodex(),
|
||||
});
|
||||
void invalidateKeys(queryClient, aiInputDependentKeys);
|
||||
} catch (err) {
|
||||
showErrorToast(
|
||||
err instanceof Error ? err.message : t("admin.centralCodex.error"),
|
||||
{ cause: err },
|
||||
);
|
||||
showErrorToast(localizedApiError(err, t, "admin.centralCodex.error"), {
|
||||
cause: err,
|
||||
});
|
||||
} finally {
|
||||
setDisconnecting(false);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiDelete } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { randomId } from "@/lib/random-id";
|
||||
import {
|
||||
recentProofErrorMessage,
|
||||
@@ -81,8 +82,10 @@ export function DangerZoneSection() {
|
||||
}),
|
||||
);
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setWipeMsg(recentProofErrorMessage(err, err.message));
|
||||
onError: (err) => {
|
||||
setWipeMsg(
|
||||
recentProofErrorMessage(err, localizedApiError(err, t, "common.error")),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { apiDelete } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -90,9 +91,7 @@ function DeleteUserButton({ user }: { user: AdminUser }) {
|
||||
onError: (err) => {
|
||||
const message = recentProofErrorMessage(
|
||||
err,
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("admin.section.users.deleteUserFailed"),
|
||||
localizedApiError(err, t, "admin.section.users.deleteUserFailed"),
|
||||
);
|
||||
if (message) showErrorToast(message, { cause: err });
|
||||
},
|
||||
|
||||
@@ -43,8 +43,13 @@ import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import { getApiErrorMessage } from "./_shared";
|
||||
import {
|
||||
ApiError,
|
||||
apiFetchRaw,
|
||||
apiGet,
|
||||
throwApiError,
|
||||
} from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import {
|
||||
recentProofErrorMessage,
|
||||
throwIfReproofRequired,
|
||||
@@ -133,12 +138,7 @@ export function EncryptionSection() {
|
||||
}),
|
||||
),
|
||||
);
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
throw new Error(t("admin.section.encryption.stepUpNeeded"));
|
||||
}
|
||||
throw new Error(await getApiErrorMessage(res));
|
||||
}
|
||||
if (!res.ok) await throwApiError(res);
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
@@ -150,7 +150,9 @@ export function EncryptionSection() {
|
||||
onError: (err: Error) => {
|
||||
const message = recentProofErrorMessage(
|
||||
err,
|
||||
err.message || t("admin.section.encryption.rotateFailed"),
|
||||
err instanceof ApiError && err.status === 401
|
||||
? t("admin.section.encryption.stepUpNeeded")
|
||||
: localizedApiError(err, t, "admin.section.encryption.rotateFailed"),
|
||||
);
|
||||
if (message === null) return;
|
||||
showErrorToast(message, { cause: err });
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
ConfiguredBadge,
|
||||
} from "./_shared";
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
export function GlitchtipSection() {
|
||||
@@ -113,9 +114,7 @@ export function GlitchtipSection() {
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorToast(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("admin.monitoringTestFailed"),
|
||||
localizedApiError(error, t, "admin.monitoringTestFailed"),
|
||||
{ cause: error },
|
||||
);
|
||||
void queryClient.invalidateQueries({
|
||||
|
||||
@@ -33,12 +33,12 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import { apiFetchRaw, apiGet, throwApiError } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { formatBytes } from "@/lib/format/bytes";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { MeasurementMaintenanceStatus } from "@/lib/jobs/measurement-maintenance-status";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { getApiErrorMessage } from "./_shared";
|
||||
|
||||
const ENDPOINT = "/api/admin/maintenance/measurements";
|
||||
|
||||
@@ -70,7 +70,7 @@ export function MeasurementMaintenanceCard() {
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ vacuum: true, reindex: true }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await getApiErrorMessage(res));
|
||||
if (!res.ok) await throwApiError(res);
|
||||
return ((await res.json()) as { data: { enqueued: boolean } }).data;
|
||||
},
|
||||
onSuccess: ({ enqueued }) => {
|
||||
@@ -80,9 +80,9 @@ export function MeasurementMaintenanceCard() {
|
||||
queryKey: queryKeys.adminMeasurementMaintenance(),
|
||||
});
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
onError: (err) => {
|
||||
showErrorToast(
|
||||
err.message || t("admin.section.maintenance.startFailed"),
|
||||
localizedApiError(err, t, "admin.section.maintenance.startFailed"),
|
||||
{ cause: err },
|
||||
);
|
||||
},
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import { SettingsToggle } from "./_shared";
|
||||
import { apiGet, apiPatch } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
|
||||
/**
|
||||
@@ -76,12 +77,9 @@ function useUpdateModuleAvailability() {
|
||||
toast.success(t("common.saved"));
|
||||
},
|
||||
onError: (err) => {
|
||||
showErrorToast(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("admin.modules.saveError"),
|
||||
{ cause: err },
|
||||
);
|
||||
showErrorToast(localizedApiError(err, t, "admin.modules.saveError"), {
|
||||
cause: err,
|
||||
});
|
||||
client.invalidateQueries({
|
||||
queryKey: queryKeys.adminModuleAvailability(),
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -41,9 +42,7 @@ export function PendingDeletionControls({ user }: { user: AdminUser }) {
|
||||
},
|
||||
onError: (err) => {
|
||||
showErrorToast(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("admin.section.users.undoDeletionFailed"),
|
||||
localizedApiError(err, t, "admin.section.users.undoDeletionFailed"),
|
||||
{ cause: err },
|
||||
);
|
||||
},
|
||||
|
||||
@@ -32,7 +32,13 @@ import {
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
ApiError,
|
||||
apiFetchRaw,
|
||||
apiGet,
|
||||
throwApiError,
|
||||
} from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -77,23 +83,7 @@ export function PulseDayMeanCard() {
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ action }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
let code: string | undefined;
|
||||
try {
|
||||
const body = (await res.json()) as { meta?: { errorCode?: string } };
|
||||
code = body.meta?.errorCode;
|
||||
} catch {
|
||||
// No envelope: the generic message below.
|
||||
}
|
||||
// The route's two refusals each have their own message.
|
||||
throw new Error(
|
||||
code === "maintenance.pulseDayMean.busy"
|
||||
? t("admin.section.pulseDayMean.error.busy")
|
||||
: code === "maintenance.pulseDayMean.dryRunRequired"
|
||||
? t("admin.section.pulseDayMean.error.dryRunRequired")
|
||||
: t("admin.section.pulseDayMean.error.generic"),
|
||||
);
|
||||
}
|
||||
if (!res.ok) await throwApiError(res);
|
||||
const { enqueued } = (
|
||||
(await res.json()) as { data: { enqueued: boolean } }
|
||||
).data;
|
||||
@@ -117,8 +107,21 @@ export function PulseDayMeanCard() {
|
||||
queryKey: queryKeys.adminPulseDayMean(),
|
||||
});
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
showErrorToast(err.message, { cause: err });
|
||||
// The route's two refusals each have their own message.
|
||||
onError: (err) => {
|
||||
const code = err instanceof ApiError ? err.meta?.errorCode : undefined;
|
||||
showErrorToast(
|
||||
code === "maintenance.pulseDayMean.busy"
|
||||
? t("admin.section.pulseDayMean.error.busy")
|
||||
: code === "maintenance.pulseDayMean.dryRunRequired"
|
||||
? t("admin.section.pulseDayMean.error.dryRunRequired")
|
||||
: localizedApiError(
|
||||
err,
|
||||
t,
|
||||
"admin.section.pulseDayMean.error.generic",
|
||||
),
|
||||
{ cause: err },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ApiError, apiFetch, apiPost } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -264,11 +265,14 @@ function CheckMyCopy({ kit }: { kit: RecoveryKit }) {
|
||||
setCheckResult(data.matches);
|
||||
setCandidate("");
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
onError: (err) => {
|
||||
setCheckResult(null);
|
||||
showErrorToast(err.message || t("admin.recoveryKit.checkFailed"), {
|
||||
cause: err,
|
||||
});
|
||||
showErrorToast(
|
||||
localizedApiError(err, t, "admin.recoveryKit.checkFailed"),
|
||||
{
|
||||
cause: err,
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -395,9 +399,10 @@ export function RecoveryKitCard() {
|
||||
await invalidate();
|
||||
return;
|
||||
}
|
||||
showErrorToast(err.message || t("admin.recoveryKit.confirmFailed"), {
|
||||
cause: err,
|
||||
});
|
||||
showErrorToast(
|
||||
localizedApiError(err, t, "admin.recoveryKit.confirmFailed"),
|
||||
{ cause: err },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { useAdminSettings, useUpdateSettings } from "./_shared";
|
||||
import { apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import {
|
||||
describeChannelTestFailures,
|
||||
type ChannelTestResult,
|
||||
@@ -163,9 +164,7 @@ export function RemindersSection() {
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorToast(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("admin.notificationTestFailed"),
|
||||
localizedApiError(error, t, "admin.notificationTestFailed"),
|
||||
{ cause: error },
|
||||
);
|
||||
},
|
||||
@@ -201,10 +200,9 @@ export function RemindersSection() {
|
||||
toast.success(data?.message ?? t("admin.reminderCheckSuccess"));
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorToast(
|
||||
error instanceof Error ? error.message : t("admin.reminderCheckFailed"),
|
||||
{ cause: error },
|
||||
);
|
||||
showErrorToast(localizedApiError(error, t, "admin.reminderCheckFailed"), {
|
||||
cause: error,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
ConfiguredBadge,
|
||||
} from "./_shared";
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
|
||||
export function UmamiSection() {
|
||||
const { t } = useTranslations();
|
||||
@@ -52,9 +53,7 @@ export function UmamiSection() {
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorToast(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("admin.monitoringTestFailed"),
|
||||
localizedApiError(error, t, "admin.monitoringTestFailed"),
|
||||
{ cause: error },
|
||||
);
|
||||
},
|
||||
|
||||
@@ -41,7 +41,8 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
import { type AdminUser, PasswordInput } from "./_shared";
|
||||
import { PendingDeletionControls } from "./pending-deletion-controls";
|
||||
import { DeleteUserControl } from "./delete-user-control";
|
||||
import { apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
|
||||
import { ApiError, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import {
|
||||
recentProofErrorMessage,
|
||||
useRecentProof,
|
||||
@@ -109,12 +110,9 @@ export function UserManagementSection() {
|
||||
toast.success(t("common.saved"));
|
||||
},
|
||||
onError: (err) => {
|
||||
showErrorToast(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("admin.settingsSaveError"),
|
||||
{ cause: err },
|
||||
);
|
||||
showErrorToast(localizedApiError(err, t, "admin.settingsSaveError"), {
|
||||
cause: err,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -132,8 +130,17 @@ export function UserManagementSection() {
|
||||
setResetMsg(t("admin.passwordReset"));
|
||||
setResetPassword("");
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setResetMsg(recentProofErrorMessage(err, err.message));
|
||||
// The server words its password verdict (too weak, found in a breach) in
|
||||
// the account's language; any other refusal reads through the catalogue.
|
||||
onError: (err) => {
|
||||
setResetMsg(
|
||||
recentProofErrorMessage(
|
||||
err,
|
||||
err instanceof ApiError && err.status === 422
|
||||
? err.message
|
||||
: localizedApiError(err, t, "common.error"),
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -156,9 +163,7 @@ export function UserManagementSection() {
|
||||
},
|
||||
onError: (err) => {
|
||||
showErrorToast(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("admin.section.users.forceLogoutFailed"),
|
||||
localizedApiError(err, t, "admin.section.users.forceLogoutFailed"),
|
||||
{ cause: err },
|
||||
);
|
||||
},
|
||||
@@ -560,7 +565,11 @@ export function UserManagementSection() {
|
||||
</div>
|
||||
{updateUser.isError && (
|
||||
<p className="text-destructive text-sm">
|
||||
{(updateUser.error as Error).message}
|
||||
{localizedApiError(
|
||||
updateUser.error,
|
||||
t,
|
||||
"admin.settingsSaveError",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<SettingsCardActions>
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
+98
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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") });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -14,10 +14,13 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { TestConnectionButton } from "@/components/settings/test-connection-button";
|
||||
import { isEmailFormDirty } from "@/components/settings/channel-form-dirty";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { useChannelEnabledToggle } from "@/components/settings/notification-channel-toggle";
|
||||
import {
|
||||
channelSaveError,
|
||||
useChannelEnabledToggle,
|
||||
} from "@/components/settings/notification-channel-toggle";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import { apiFetchRaw, apiGet, throwApiError } from "@/lib/api/api-fetch";
|
||||
|
||||
interface EmailSettings {
|
||||
enabled: boolean;
|
||||
@@ -64,10 +67,7 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
enabled: settings?.enabled ?? false,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const json = await res.json();
|
||||
throw new Error(json.error || t("common.error"));
|
||||
}
|
||||
if (!res.ok) await throwApiError(res);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.settingsEmail() });
|
||||
@@ -75,8 +75,8 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
toastWrittenOutcome("success", t("settings.saved"));
|
||||
},
|
||||
// A form shows its refusal inline, next to the field it is about.
|
||||
onError: (err: Error) => {
|
||||
setSaveError(err.message);
|
||||
onError: (err) => {
|
||||
setSaveError(channelSaveError(err, t));
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -31,10 +31,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
MedicationIntakeImportError,
|
||||
waitForMedicationIntakeImport,
|
||||
} from "@/lib/medications/intake-import-poll";
|
||||
import { waitForMedicationIntakeImport } from "@/lib/medications/intake-import-poll";
|
||||
import { invalidateMedicationReads } from "@/lib/query-keys";
|
||||
|
||||
import { DoseHistoryColumnRulings } from "./dose-history-columns";
|
||||
@@ -194,15 +191,12 @@ export function DoseHistoryImportCard() {
|
||||
skippedDetailsOmitted: result.skippedDetailsOmitted,
|
||||
});
|
||||
void invalidateMedicationReads(queryClient);
|
||||
} catch (err) {
|
||||
// A failed job carries a sanitised reason; anything else is a transport
|
||||
// failure with nothing to add. Either way the run did not write, and the
|
||||
// card says so rather than leaving the last verdict on screen.
|
||||
setError(
|
||||
err instanceof MedicationIntakeImportError && err.message.length > 0
|
||||
? err.message
|
||||
: t("settings.sections.export.import.doseHistory.failed"),
|
||||
);
|
||||
} catch {
|
||||
// A failed job's reason is the worker's English and adds nothing;
|
||||
// anything else is a transport failure. Either way the run did not
|
||||
// write, and the card says so rather than leaving the last verdict on
|
||||
// screen.
|
||||
setError(t("settings.sections.export.import.doseHistory.failed"));
|
||||
setVerdict(null);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
|
||||
@@ -15,33 +15,46 @@ import {
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import { ApiError, apiFetchRaw, throwApiError } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { translateCodeOrNull } from "@/lib/i18n/translate-code";
|
||||
|
||||
interface EnabledSettings {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A refused channel save in the reader's language. The private-origin policy
|
||||
* reads like the test button's verdict; anything else goes through the API
|
||||
* catalogue, and a request that never reached the server (offline, a dropped
|
||||
* connection) reads as the plain "could not save". Never the server's prose.
|
||||
*/
|
||||
export function channelSaveError(
|
||||
err: unknown,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
const code = err instanceof ApiError ? err.meta?.errorCode : undefined;
|
||||
return (
|
||||
translateCodeOrNull(
|
||||
t,
|
||||
"settings.testConnection.errors",
|
||||
typeof code === "string" ? code : null,
|
||||
) ?? localizedApiError(err, t, "settings.savingError")
|
||||
);
|
||||
}
|
||||
|
||||
export async function persistChannelEnabled(
|
||||
endpoint: string,
|
||||
enabled: boolean,
|
||||
fallbackError: string,
|
||||
): Promise<void> {
|
||||
// A request that never reaches the server (offline, a dropped connection)
|
||||
// is a refused change like any other.
|
||||
const response = await apiFetchRaw(endpoint, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled }),
|
||||
}).catch(() => null);
|
||||
|
||||
if (!response?.ok) {
|
||||
const json = (await response?.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
} | null;
|
||||
throw new Error(json?.error || fallbackError);
|
||||
}
|
||||
});
|
||||
if (!response.ok) await throwApiError(response);
|
||||
}
|
||||
|
||||
export function optimisticallySetChannelEnabled<T extends EnabledSettings>(
|
||||
@@ -75,8 +88,7 @@ export function useChannelEnabledToggle<T extends EnabledSettings>(
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, Error, boolean, T | undefined>({
|
||||
mutationFn: (enabled) =>
|
||||
persistChannelEnabled(endpoint, enabled, t("settings.savingError")),
|
||||
mutationFn: (enabled) => persistChannelEnabled(endpoint, enabled),
|
||||
onMutate: async (enabled) => {
|
||||
await queryClient.cancelQueries({ queryKey });
|
||||
return optimisticallySetChannelEnabled<T>(queryClient, queryKey, enabled);
|
||||
@@ -86,7 +98,7 @@ export function useChannelEnabledToggle<T extends EnabledSettings>(
|
||||
},
|
||||
onError: (err, _enabled, previous) => {
|
||||
rollbackChannelEnabled(queryClient, queryKey, previous);
|
||||
showErrorToast(err.message, { cause: err });
|
||||
showErrorToast(channelSaveError(err, t), { cause: err });
|
||||
},
|
||||
onSettled: () => {
|
||||
void queryClient.invalidateQueries({ queryKey });
|
||||
|
||||
@@ -15,11 +15,13 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { TestConnectionButton } from "@/components/settings/test-connection-button";
|
||||
import { isNtfyFormDirty } from "@/components/settings/channel-form-dirty";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { useChannelEnabledToggle } from "@/components/settings/notification-channel-toggle";
|
||||
import {
|
||||
channelSaveError,
|
||||
useChannelEnabledToggle,
|
||||
} from "@/components/settings/notification-channel-toggle";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import { translateCodeOrNull } from "@/lib/i18n/translate-code";
|
||||
import { apiFetchRaw, apiGet, throwApiError } from "@/lib/api/api-fetch";
|
||||
|
||||
interface NtfySettings {
|
||||
enabled: boolean;
|
||||
@@ -73,22 +75,7 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
enabled: settings?.enabled ?? false,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const json = (await res.json()) as {
|
||||
error?: string;
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
// A coded refusal (the private-origin policy) translates like the
|
||||
// test button's; anything else shows the server sentence.
|
||||
throw new Error(
|
||||
translateCodeOrNull(
|
||||
t,
|
||||
"settings.testConnection.errors",
|
||||
json.meta?.errorCode,
|
||||
) ??
|
||||
(json.error || t("common.error")),
|
||||
);
|
||||
}
|
||||
if (!res.ok) await throwApiError(res);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.settingsNtfy() });
|
||||
@@ -97,8 +84,8 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
setAuthToken("");
|
||||
},
|
||||
// A form shows its refusal inline, next to the fields it is about.
|
||||
onError: (err: Error) => {
|
||||
setSaveError(err.message);
|
||||
onError: (err) => {
|
||||
setSaveError(channelSaveError(err, t));
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ import { ReportVisitOffer } from "./report-visit-offer";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import {
|
||||
recentProofErrorMessage,
|
||||
throwIfReproofRequired,
|
||||
@@ -177,7 +178,7 @@ export function HealthRecordExportPanel() {
|
||||
}
|
||||
const message = recentProofErrorMessage(
|
||||
err,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
localizedApiError(err, t, "settings.healthRecord.errorGeneric"),
|
||||
);
|
||||
if (message) setError(message);
|
||||
} finally {
|
||||
|
||||
@@ -62,7 +62,11 @@ describe("the re-proof dialog flow", () => {
|
||||
const flow = createRecentProofFlow({
|
||||
view: { setMethods, setPending: vi.fn(), setError: vi.fn() },
|
||||
submit: vi.fn(),
|
||||
messages: () => ({ signInAgain: "sign in again", failed: "failed" }),
|
||||
messages: () => ({
|
||||
signInAgain: "sign in again",
|
||||
failed: "failed",
|
||||
rateLimited: "rate limited",
|
||||
}),
|
||||
});
|
||||
void flow
|
||||
.run(async () => {
|
||||
@@ -75,4 +79,23 @@ describe("the re-proof dialog flow", () => {
|
||||
await vi.waitFor(() => expect(setMethods).toHaveBeenCalledWith(["oidc"]));
|
||||
flow.cancel();
|
||||
});
|
||||
|
||||
it("says a throttled proof in the caller's words, not the server's", async () => {
|
||||
const setError = vi.fn();
|
||||
const flow = createRecentProofFlow({
|
||||
view: { setMethods: vi.fn(), setPending: vi.fn(), setError },
|
||||
submit: vi.fn(async () => {
|
||||
throw new ApiError("Too many attempts. Please wait 15 minutes.", 429);
|
||||
}),
|
||||
messages: () => ({
|
||||
signInAgain: "sign in again",
|
||||
failed: "failed",
|
||||
rateLimited: "rate limited",
|
||||
}),
|
||||
});
|
||||
|
||||
await flow.prove({ method: "password", password: "pw" });
|
||||
|
||||
expect(setError).toHaveBeenLastCalledWith("rate limited");
|
||||
});
|
||||
});
|
||||
@@ -12,8 +12,9 @@
|
||||
* 1. a step-up refusal renders the card's sentence, not the server's prose,
|
||||
* 2. the mfa_not_enrolled arm maps too — both codes mean "the gate stopped
|
||||
* you", and handling only the first falls through to raw English,
|
||||
* 3. an ordinary failure still shows its own message rather than being
|
||||
* swallowed by the step-up sentence,
|
||||
* 3. any other refusal gets the caller's sentence (its catalogue sentence
|
||||
* through `localizedApiError`), not the step-up one and not the server's
|
||||
* prose,
|
||||
* 4. the sentence resolves in more than one locale,
|
||||
* 5. the sentence names a recovery that always exists. A refusal that names
|
||||
* its proofs (`meta.methods`) opens the re-proof dialog instead and never
|
||||
@@ -65,14 +66,15 @@ describe("passkey removal — the step-up refusal a person sees", () => {
|
||||
).toBe(STEP_UP_SENTENCE);
|
||||
});
|
||||
|
||||
it("leaves an ordinary refusal saying its own thing", () => {
|
||||
it("leaves any other refusal to the caller's sentence, never the server's", () => {
|
||||
const lastMethod = new ApiError(
|
||||
"Cannot delete — at least one authentication method must remain",
|
||||
"Cannot delete. At least one authentication method must remain",
|
||||
400,
|
||||
{ errorCode: "auth.passkey.last_method" },
|
||||
);
|
||||
|
||||
expect(describeStepUp(lastMethod, "fallback", STEP_UP_SENTENCE)).toContain(
|
||||
"at least one authentication method",
|
||||
expect(describeStepUp(lastMethod, "fallback", STEP_UP_SENTENCE)).toBe(
|
||||
"fallback",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
*
|
||||
* Mutation check: drop either `onSettled` and the wiring case goes red; drop
|
||||
* an `open=` binding and the controlled case goes red; return `err.message`
|
||||
* from `describeError`'s step-up arm and the sentence case goes red.
|
||||
* from `describeStepUp`'s step-up arm and the sentence case goes red.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
@@ -38,7 +38,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import en from "@/../messages/en.json";
|
||||
import { describeError, TotpCard } from "../totp-card";
|
||||
import { describeStepUp } from "../security-keys-card";
|
||||
import { TotpCard } from "../totp-card";
|
||||
|
||||
const STEP_UP_SENTENCE = en.settings.security.stepUpRequired;
|
||||
|
||||
@@ -81,7 +82,7 @@ describe("<TotpCard> — a refused mutation", () => {
|
||||
{ errorCode: "auth.stepup.required" },
|
||||
);
|
||||
|
||||
const shown = describeError(refusal, "fallback", STEP_UP_SENTENCE);
|
||||
const shown = describeStepUp(refusal, "fallback", STEP_UP_SENTENCE);
|
||||
|
||||
expect(shown).toBe(STEP_UP_SENTENCE);
|
||||
expect(shown).not.toContain("second-factor verification required");
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Segmented } from "@/components/ui/segmented";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { ApiError, apiPost } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { describePasskeyError } from "@/lib/passkey-errors";
|
||||
|
||||
export type ReauthMethod =
|
||||
@@ -171,7 +172,7 @@ export function ExistingFactorReauthDialog({
|
||||
onProof({ method, challengeId: begun.challengeId, credential });
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setAssertionError(err.message);
|
||||
setAssertionError(localizedApiError(err, t, "settings.reproof.failed"));
|
||||
} else {
|
||||
const { key, params } = describePasskeyError(err);
|
||||
setAssertionError(t(key, params));
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
apiPatch,
|
||||
apiPost,
|
||||
} from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { describePasskeyError } from "@/lib/passkey-errors";
|
||||
import { describeStepUp } from "./security-keys-card";
|
||||
import {
|
||||
@@ -206,7 +207,7 @@ export function PasskeyListSection({
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err instanceof ApiError) {
|
||||
setMsg(err.message || t("settings.passkeyRegistrationFailed"));
|
||||
setMsg(localizedApiError(err, t, "settings.passkeyRegistrationFailed"));
|
||||
} else {
|
||||
const { key, params } = describePasskeyError(err);
|
||||
setMsg(t(key, params));
|
||||
@@ -223,7 +224,8 @@ export function PasskeyListSection({
|
||||
setMsg(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() });
|
||||
},
|
||||
onError: (err: Error) => setMsg(err.message),
|
||||
onError: (err) =>
|
||||
setMsg(localizedApiError(err, t, "settings.passkeyRenameFailed")),
|
||||
});
|
||||
|
||||
// Removal is step-up gated: a second factor, or on a passkey-only account a
|
||||
@@ -248,7 +250,11 @@ export function PasskeyListSection({
|
||||
onError: (err: Error) => {
|
||||
if (err instanceof ReproofCancelledError) return;
|
||||
setMsg(
|
||||
describeStepUp(err, err.message, t("settings.passkeyStepUpRequired")),
|
||||
describeStepUp(
|
||||
err,
|
||||
localizedApiError(err, t, "settings.passkeyRemoveFailed"),
|
||||
t("settings.passkeyStepUpRequired"),
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -83,7 +83,7 @@ export function createRecentProofFlow(deps: {
|
||||
/** Posts the proof to `/api/auth/reproof`. */
|
||||
submit: (proof: ExistingFactorProof) => Promise<unknown>;
|
||||
/** Read at the moment they are needed, so a locale switch is honoured. */
|
||||
messages: () => { signInAgain: string; failed: string };
|
||||
messages: () => { signInAgain: string; failed: string; rateLimited: string };
|
||||
}): RecentProofFlow {
|
||||
let waiter: { resolve: () => void; reject: () => void } | null = null;
|
||||
|
||||
@@ -127,7 +127,7 @@ export function createRecentProofFlow(deps: {
|
||||
} catch (err) {
|
||||
deps.view.setError(
|
||||
err instanceof ApiError && err.status === 429
|
||||
? err.message
|
||||
? deps.messages().rateLimited
|
||||
: deps.messages().failed,
|
||||
);
|
||||
} finally {
|
||||
|
||||
@@ -23,6 +23,7 @@ import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { ApiError, apiDelete, apiPatch, apiPost } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { describePasskeyError } from "@/lib/passkey-errors";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import {
|
||||
@@ -50,6 +51,8 @@ export interface WebauthnKeyInfo {
|
||||
* "Recent second-factor verification required", which is English-only and, for a
|
||||
* passkey-only account, not even accurate about which credential to re-prove —
|
||||
* so the caller supplies its own sentence and this only decides when to use it.
|
||||
* Any other refusal reads as `fallback`, which callers resolve through
|
||||
* `localizedApiError`; the server's prose is never shown.
|
||||
*
|
||||
* Matched on `meta.errorCode`, and on the whole `auth.stepup` prefix rather than
|
||||
* one code: `auth.stepup.required` and `auth.stepup.mfa_not_enrolled` both mean
|
||||
@@ -70,7 +73,6 @@ export function describeStepUp(
|
||||
) {
|
||||
return stepUpMsg;
|
||||
}
|
||||
return err.message || fallback;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
@@ -141,7 +143,7 @@ export function SecurityKeysCard({
|
||||
}
|
||||
let message: string;
|
||||
if (err instanceof ApiError) {
|
||||
message = err.message || t("settings.security.keys.addFailed");
|
||||
message = localizedApiError(err, t, "settings.security.keys.addFailed");
|
||||
} else {
|
||||
const { key, params } = describePasskeyError(err);
|
||||
message = t(key, params);
|
||||
@@ -165,9 +167,7 @@ export function SecurityKeysCard({
|
||||
},
|
||||
onError: (err) =>
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: t("settings.security.keys.renameFailed"),
|
||||
localizedApiError(err, t, "settings.security.keys.renameFailed"),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -191,7 +191,7 @@ export function SecurityKeysCard({
|
||||
setError(
|
||||
describeStepUp(
|
||||
err,
|
||||
t("settings.security.keys.removeFailed"),
|
||||
localizedApiError(err, t, "settings.security.keys.removeFailed"),
|
||||
t("settings.security.stepUpRequired"),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -31,8 +31,10 @@ import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { RecoveryCodesPanel } from "./recovery-codes-panel";
|
||||
import { describeStepUp } from "./security-keys-card";
|
||||
import {
|
||||
ReproofCancelledError,
|
||||
recentProofErrorMessage,
|
||||
@@ -59,31 +61,6 @@ interface ConfirmResult {
|
||||
recoveryCodesRemaining: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a step-up 401 to a clear re-login message; otherwise the raw message.
|
||||
*
|
||||
* Exported for the unit suite: this is the sentence a refused mutation leaves
|
||||
* on the card, and the card is where a person reads it.
|
||||
*/
|
||||
export function describeError(
|
||||
err: unknown,
|
||||
fallback: string,
|
||||
stepUpMsg: string,
|
||||
): string {
|
||||
if (err instanceof ApiError) {
|
||||
const code = err.meta?.errorCode;
|
||||
if (
|
||||
err.status === 401 &&
|
||||
typeof code === "string" &&
|
||||
code.startsWith("auth.stepup")
|
||||
) {
|
||||
return stepUpMsg;
|
||||
}
|
||||
return err.message || fallback;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function TotpCard({
|
||||
enabled,
|
||||
recoveryCodesRemaining,
|
||||
@@ -201,9 +178,9 @@ export function TotpCard({
|
||||
setReauthOpen(true);
|
||||
return;
|
||||
}
|
||||
const message = describeError(
|
||||
const message = describeStepUp(
|
||||
err,
|
||||
t("settings.security.totp.setupFailed"),
|
||||
localizedApiError(err, t, "settings.security.totp.setupFailed"),
|
||||
t("settings.security.stepUpRequired"),
|
||||
);
|
||||
if (proof) setReauthError(message);
|
||||
@@ -223,9 +200,9 @@ export function TotpCard({
|
||||
},
|
||||
onError: (err) =>
|
||||
setError(
|
||||
describeError(
|
||||
describeStepUp(
|
||||
err,
|
||||
t("settings.security.totp.invalidCode"),
|
||||
localizedApiError(err, t, "settings.security.totp.invalidCode"),
|
||||
t("settings.security.stepUpRequired"),
|
||||
),
|
||||
),
|
||||
@@ -248,9 +225,9 @@ export function TotpCard({
|
||||
onError: (err) => {
|
||||
if (err instanceof ReproofCancelledError) return;
|
||||
setError(
|
||||
describeError(
|
||||
describeStepUp(
|
||||
err,
|
||||
t("settings.security.totp.disableFailed"),
|
||||
localizedApiError(err, t, "settings.security.totp.disableFailed"),
|
||||
t("settings.security.stepUpRequired"),
|
||||
),
|
||||
);
|
||||
@@ -274,9 +251,9 @@ export function TotpCard({
|
||||
onError: (err) => {
|
||||
if (err instanceof ReproofCancelledError) return;
|
||||
setError(
|
||||
describeError(
|
||||
describeStepUp(
|
||||
err,
|
||||
t("settings.security.recovery.regenFailed"),
|
||||
localizedApiError(err, t, "settings.security.recovery.regenFailed"),
|
||||
t("settings.security.stepUpRequired"),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -105,6 +105,7 @@ export function useRecentProof(
|
||||
messages: () => ({
|
||||
signInAgain: t("settings.reproof.signInAgain"),
|
||||
failed: t("settings.reproof.failed"),
|
||||
rateLimited: t("apiErrors.http.rate_limited"),
|
||||
}),
|
||||
}),
|
||||
[t],
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
Reference in new issue
Block a user