Merge branch 'fix/v14311-settings' into release/v1.43.11

# Conflicts:
#	messages/de.json
#	messages/en.json
#	messages/es.json
#	messages/fr.json
#	messages/it.json
#	messages/ko.json
#	messages/nl.json
#	messages/pl.json
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 14:13:51 +02:00
commit eabfeaef08
74 files changed
+2545 -436

No files matched your search

+50 -3
View File
@@ -82823,10 +82823,58 @@ components:
items:
type: string
description: Every event category the server knows, in declaration order — the list a client renders rows for.
eventDefaults:
type: object
propertyNames:
type: string
enum:
- MEDICATION_REMINDER
- MEASUREMENT_ANOMALY
- COMPLIANCE_LOW
- WITHINGS_SYNC_FAILED
- SYSTEM_ALERT
- PERSONAL_RECORD
- MOOD_REMINDER
- CYCLE_PERIOD_SOON
- CYCLE_PERIOD_CONFIRM
- CYCLE_FERTILE_SOON
- COACH_NUDGE
- MEDICATION_LOW_STOCK
- MEASUREMENT_REMINDER
- MEDICATION_INTAKE_SYNC
- SECURITY_ALERT
- DAILY_BRIEFING
- SHARED_RECORD_INTAKE
description: Notification category a per-channel preference can toggle.
additionalProperties:
type: boolean
required:
- MEDICATION_REMINDER
- MEASUREMENT_ANOMALY
- COMPLIANCE_LOW
- WITHINGS_SYNC_FAILED
- SYSTEM_ALERT
- PERSONAL_RECORD
- MOOD_REMINDER
- CYCLE_PERIOD_SOON
- CYCLE_PERIOD_CONFIRM
- CYCLE_FERTILE_SOON
- COACH_NUDGE
- MEDICATION_LOW_STOCK
- MEASUREMENT_REMINDER
- MEDICATION_INTAKE_SYNC
- SECURITY_ALERT
- DAILY_BRIEFING
- SHARED_RECORD_INTAKE
description: What each event does on a channel with no stored row, resolved for this account by the function delivery
uses. Most are constant per category; the mood reminder follows the account's mood-reminder setting
(`moodReminderEnabled` on `/api/user/profile`). Render a switch without a row from this map rather than from
a copy of the defaults. Since v1.43.11.
required:
- channels
- preferences
- eventTypes
- eventDefaults
additionalProperties: false
description: The caller's notification channels, their stored per-event overrides, and the full event vocabulary.
NotificationChannelSummary:
@@ -82896,9 +82944,8 @@ components:
- eventType
- enabled
additionalProperties: false
description: An explicit per-(channel, event) override. Only STORED rows appear — an event with no row here follows the
server-side default for that category, which is ON for most and OFF for the opt-in ones (personal records, mood
and cycle reminders). Absence is therefore not 'disabled'.
description: An explicit per-(channel, event) override. Only STORED rows appear — an event with no row here follows
`eventDefaults` on the same response. Absence is therefore not 'disabled'.
PutNotificationPreferenceResponse:
type: object
properties:
+21 -5
View File
@@ -6206,7 +6206,7 @@
"resetConfirmBody": "Die Kette wird auf Codex → OpenAI → Anthropic → Lokal → Admin-OpenAI zurückgesetzt. Gespeicherte Zugangsdaten bleiben erhalten."
},
"activeProviderHeading": "Aktiver Provider",
"activeProviderLabel": "Primärer Provider",
"activeProviderLabel": "Anbieter einrichten",
"providerSelect": {
"codex": "ChatGPT-Account (Codex)",
"openai": "OpenAI (eigener API-Key)",
@@ -6413,7 +6413,7 @@
},
"coachNote": "Im Coach gilt die Denktiefe aus den Coach-Einstellungen."
},
"providerCardDescription": "Der Anbieter, den HealthLog zuerst fragt, mit Schlüssel und Modell.",
"providerCardDescription": "Richte Schlüssel und Modell je Anbieter ein; die Reihenfolge darunter bestimmt, welchen HealthLog zuerst fragt.",
"runtimeTitle": "Prüfen und aktualisieren",
"usage": {
"title": "KI-Einsatz",
@@ -6440,6 +6440,14 @@
"followsDefault": "Du folgst dem Standard des Servers ({level}).",
"useDefault": "Standard des Servers verwenden ({level})",
"saveError": "Die Stufe konnte nicht gespeichert werden. Bitte versuche es erneut."
},
"removeKey": {
"label": "API-Schlüssel entfernen",
"title": "Gespeicherten API-Schlüssel entfernen?",
"body": "HealthLog löscht den gespeicherten Schlüssel und kann ihn nicht wiederherstellen. Gib ihn erneut ein, um ihn weiter zu nutzen.",
"confirm": "API-Schlüssel entfernen",
"done": "API-Schlüssel entfernt.",
"failed": "Der API-Schlüssel konnte nicht entfernt werden."
}
},
"dashboard": {
@@ -6910,7 +6918,9 @@
"reselectReason": "Dieser Link lieferte einen Umfang, den niemand gewählt hatte, und wurde deshalb geschlossen. Lege einen neuen an und wähle den Inhalt.",
"reselectAction": "Ersatz anlegen",
"createDetail": "Der Token wird nur einmal bei der Erstellung angezeigt. Sichere ihn dann; er lässt sich nicht wiederherstellen.",
"activeDescription": "Deine geteilten Links, wann sie ablaufen und wie oft sie geöffnet wurden."
"activeDescription": "Deine geteilten Links, wann sie ablaufen und wie oft sie geöffnet wurden.",
"revokedToast": "Link widerrufen.",
"revokeFailed": "Der Link konnte nicht widerrufen werden."
},
"genderOther": "Divers",
"nightscout": "Nightscout",
@@ -7097,7 +7107,9 @@
"lastUsed": "Zuletzt verwendet {date}",
"neverUsed": "Noch nie verwendet",
"added": "hinzugefügt {date}",
"nameLabel": "Name des Sicherheitsschlüssels"
"nameLabel": "Name des Sicherheitsschlüssels",
"addedToast": "Sicherheitsschlüssel hinzugefügt.",
"removedToast": "Sicherheitsschlüssel entfernt."
},
"passkeyNudge": {
"title": "Füge einen Passkey für schnellere Anmeldung hinzu",
@@ -7449,7 +7461,11 @@
"guide": "So verbindest du Google Health",
"badgeEnding": "Wird abgeschaltet",
"badgeEnded": "Abgeschaltet"
}
},
"passkeyAddedToast": "Passkey hinzugefügt.",
"passkeyRemovedToast": "Passkey entfernt.",
"integrationDisconnected": "{provider} getrennt.",
"integrationDisconnectFailed": "{provider} konnte nicht getrennt werden."
},
"admin": {
"title": "Administration",
+21 -5
View File
@@ -6206,7 +6206,7 @@
"resetConfirmBody": "The chain reverts to Codex → OpenAI → Anthropic → Local → Admin OpenAI. Your saved credentials are not touched."
},
"activeProviderHeading": "Active provider",
"activeProviderLabel": "Primary provider",
"activeProviderLabel": "Set up a provider",
"providerSelect": {
"codex": "ChatGPT account (Codex)",
"openai": "OpenAI (your API key)",
@@ -6413,7 +6413,7 @@
},
"coachNote": "In the Coach, the thinking depth from the Coach settings applies."
},
"providerCardDescription": "The provider HealthLog asks first, and its key and model.",
"providerCardDescription": "Set up each provider's key and model; the order below decides which one HealthLog asks first.",
"runtimeTitle": "Check and refresh",
"usage": {
"title": "AI usage",
@@ -6440,6 +6440,14 @@
"followsDefault": "You follow the server default ({level}).",
"useDefault": "Use the server default ({level})",
"saveError": "The level could not be saved. Please try again."
},
"removeKey": {
"label": "Remove API key",
"title": "Remove the saved API key?",
"body": "HealthLog deletes the stored key and cannot recover it. Enter it again to keep using it.",
"confirm": "Remove API key",
"done": "API key removed.",
"failed": "Could not remove the API key."
}
},
"dashboard": {
@@ -6910,7 +6918,9 @@
"reselectReason": "This link served a scope that was never chosen, so it was closed. Create a new one and pick what it should contain.",
"reselectAction": "Create a replacement",
"createDetail": "The token is shown once on creation. Store it then; it cannot be recovered.",
"activeDescription": "Links you have shared, when they expire and how often they were opened."
"activeDescription": "Links you have shared, when they expire and how often they were opened.",
"revokedToast": "Link revoked.",
"revokeFailed": "Could not revoke the link."
},
"genderOther": "Other",
"nightscout": "Nightscout",
@@ -7097,7 +7107,9 @@
"lastUsed": "Last used {date}",
"neverUsed": "Never used",
"added": "added {date}",
"nameLabel": "Security key name"
"nameLabel": "Security key name",
"addedToast": "Security key added.",
"removedToast": "Security key removed."
},
"passkeyNudge": {
"title": "Add a passkey for faster sign-in",
@@ -7449,7 +7461,11 @@
"guide": "How to connect Google Health",
"badgeEnding": "Shutting down",
"badgeEnded": "Retired"
}
},
"passkeyAddedToast": "Passkey added.",
"passkeyRemovedToast": "Passkey removed.",
"integrationDisconnected": "{provider} disconnected.",
"integrationDisconnectFailed": "Could not disconnect {provider}."
},
"admin": {
"title": "Administration",
+21 -5
View File
@@ -6206,7 +6206,7 @@
"resetConfirmBody": "La cadena vuelve a Codex → OpenAI → Anthropic → Local → OpenAI de administrador. Tus credenciales guardadas no se modifican."
},
"activeProviderHeading": "Proveedor activo",
"activeProviderLabel": "Proveedor principal",
"activeProviderLabel": "Configurar un proveedor",
"providerSelect": {
"codex": "Cuenta de ChatGPT (Codex)",
"openai": "OpenAI (tu clave de API)",
@@ -6413,7 +6413,7 @@
},
"coachNote": "En el Coach se aplica la profundidad de pensamiento de los ajustes del Coach."
},
"providerCardDescription": "El proveedor al que HealthLog pregunta primero, con su clave y su modelo.",
"providerCardDescription": "Configura la clave y el modelo de cada proveedor; el orden de abajo decide a cuál pregunta HealthLog primero.",
"runtimeTitle": "Comprobar y actualizar",
"usage": {
"title": "Uso de la IA",
@@ -6440,6 +6440,14 @@
"followsDefault": "Sigues el valor predeterminado del servidor ({level}).",
"useDefault": "Usar el valor predeterminado del servidor ({level})",
"saveError": "No se pudo guardar el nivel. Inténtalo de nuevo."
},
"removeKey": {
"label": "Quitar clave de API",
"title": "¿Quitar la clave de API guardada?",
"body": "HealthLog borra la clave guardada y no puede recuperarla. Vuelve a introducirla para seguir usándola.",
"confirm": "Quitar clave de API",
"done": "Clave de API quitada.",
"failed": "No se pudo quitar la clave de API."
}
},
"dashboard": {
@@ -6910,7 +6918,9 @@
"reselectReason": "Este enlace mostraba un alcance que nadie eligió, por eso se cerró. Crea uno nuevo y elige su contenido.",
"reselectAction": "Crear un reemplazo",
"createDetail": "El token se muestra una sola vez al crearlo. Guárdalo entonces; no se puede recuperar.",
"activeDescription": "Los enlaces que has compartido, cuándo caducan y cuántas veces se abrieron."
"activeDescription": "Los enlaces que has compartido, cuándo caducan y cuántas veces se abrieron.",
"revokedToast": "Enlace revocado.",
"revokeFailed": "No se pudo revocar el enlace."
},
"genderOther": "Otro",
"nightscout": "Nightscout",
@@ -7097,7 +7107,9 @@
"lastUsed": "Usada por última vez el {date}",
"neverUsed": "Nunca usada",
"added": "añadida el {date}",
"nameLabel": "Nombre de la llave de seguridad"
"nameLabel": "Nombre de la llave de seguridad",
"addedToast": "Llave de seguridad añadida.",
"removedToast": "Llave de seguridad eliminada."
},
"passkeyNudge": {
"title": "Añade una clave de acceso para iniciar sesión más rápido",
@@ -7449,7 +7461,11 @@
"guide": "Cómo conectar Google Health",
"badgeEnding": "Se desactiva pronto",
"badgeEnded": "Desactivado"
}
},
"passkeyAddedToast": "Passkey añadida.",
"passkeyRemovedToast": "Passkey eliminada.",
"integrationDisconnected": "{provider} desconectado.",
"integrationDisconnectFailed": "No se pudo desconectar {provider}."
},
"admin": {
"title": "Administration",
+21 -5
View File
@@ -6206,7 +6206,7 @@
"resetConfirmBody": "La chaîne revient à Codex → OpenAI → Anthropic → Local → OpenAI administrateur. Vos identifiants enregistrés ne sont pas affectés."
},
"activeProviderHeading": "Fournisseur actif",
"activeProviderLabel": "Fournisseur principal",
"activeProviderLabel": "Configurer un fournisseur",
"providerSelect": {
"codex": "Compte ChatGPT (Codex)",
"openai": "OpenAI (votre clé API)",
@@ -6413,7 +6413,7 @@
},
"coachNote": "Dans le Coach, c'est la profondeur de réflexion des réglages du Coach qui s'applique."
},
"providerCardDescription": "Le fournisseur que HealthLog sollicite en premier, avec sa clé et son modèle.",
"providerCardDescription": "Configurez la clé et le modèle de chaque fournisseur ; l’ordre ci-dessous décide lequel HealthLog sollicite en premier.",
"runtimeTitle": "Vérifier et actualiser",
"usage": {
"title": "Usage de l'IA",
@@ -6440,6 +6440,14 @@
"followsDefault": "Vous suivez le réglage par défaut du serveur ({level}).",
"useDefault": "Utiliser le réglage par défaut du serveur ({level})",
"saveError": "Le niveau n'a pas pu être enregistré. Réessaie."
},
"removeKey": {
"label": "Supprimer la clé API",
"title": "Supprimer la clé API enregistrée ?",
"body": "HealthLog efface la clé enregistrée et ne peut pas la récupérer. Saisissez-la de nouveau pour continuer à l’utiliser.",
"confirm": "Supprimer la clé API",
"done": "Clé API supprimée.",
"failed": "Impossible de supprimer la clé API."
}
},
"dashboard": {
@@ -6910,7 +6918,9 @@
"reselectReason": "Ce lien diffusait un périmètre que personne n'avait choisi ; il a donc été fermé. Créez-en un nouveau et choisissez son contenu.",
"reselectAction": "Créer un remplacement",
"createDetail": "Le jeton n'est affiché qu'une fois à la création. Conservez-le alors ; il ne peut pas être récupéré.",
"activeDescription": "Les liens que vous avez partagés, leur expiration et le nombre d'ouvertures."
"activeDescription": "Les liens que vous avez partagés, leur expiration et le nombre d'ouvertures.",
"revokedToast": "Lien révoqué.",
"revokeFailed": "Impossible de révoquer le lien."
},
"genderOther": "Autre",
"nightscout": "Nightscout",
@@ -7097,7 +7107,9 @@
"lastUsed": "Dernière utilisation le {date}",
"neverUsed": "Jamais utilisée",
"added": "ajoutée le {date}",
"nameLabel": "Nom de la clé de sécurité"
"nameLabel": "Nom de la clé de sécurité",
"addedToast": "Clé de sécurité ajoutée.",
"removedToast": "Clé de sécurité supprimée."
},
"passkeyNudge": {
"title": "Ajoutez une clé d'accès pour une connexion plus rapide",
@@ -7449,7 +7461,11 @@
"guide": "Comment connecter Google Health",
"badgeEnding": "Bientôt désactivé",
"badgeEnded": "Désactivé"
}
},
"passkeyAddedToast": "Passkey ajoutée.",
"passkeyRemovedToast": "Passkey supprimée.",
"integrationDisconnected": "{provider} déconnecté.",
"integrationDisconnectFailed": "Impossible de déconnecter {provider}."
},
"admin": {
"title": "Administration",
+21 -5
View File
@@ -6206,7 +6206,7 @@
"resetConfirmBody": "La catena torna a Codex → OpenAI → Anthropic → Locale → OpenAI amministratore. Le credenziali salvate non vengono toccate."
},
"activeProviderHeading": "Provider attivo",
"activeProviderLabel": "Provider principale",
"activeProviderLabel": "Configura un fornitore",
"providerSelect": {
"codex": "Account ChatGPT (Codex)",
"openai": "OpenAI (la tua chiave API)",
@@ -6413,7 +6413,7 @@
},
"coachNote": "Nel Coach vale la profondità di ragionamento delle impostazioni del Coach."
},
"providerCardDescription": "Il fornitore che HealthLog interpella per primo, con chiave e modello.",
"providerCardDescription": "Configura chiave e modello di ogni fornitore; l’ordine qui sotto decide quale HealthLog interpella per primo.",
"runtimeTitle": "Verifica e aggiorna",
"usage": {
"title": "Uso dell'IA",
@@ -6440,6 +6440,14 @@
"followsDefault": "Segui l'impostazione predefinita del server ({level}).",
"useDefault": "Usa l'impostazione predefinita del server ({level})",
"saveError": "Non è stato possibile salvare il livello. Riprova."
},
"removeKey": {
"label": "Rimuovi chiave API",
"title": "Rimuovere la chiave API salvata?",
"body": "HealthLog elimina la chiave salvata e non può recuperarla. Inseriscila di nuovo per continuare a usarla.",
"confirm": "Rimuovi chiave API",
"done": "Chiave API rimossa.",
"failed": "Impossibile rimuovere la chiave API."
}
},
"dashboard": {
@@ -6910,7 +6918,9 @@
"reselectReason": "Questo link mostrava un ambito che nessuno aveva scelto, perciò è stato chiuso. Creane uno nuovo e scegli cosa deve contenere.",
"reselectAction": "Crea un sostituto",
"createDetail": "Il token viene mostrato una sola volta alla creazione: salvalo subito, non può essere recuperato.",
"activeDescription": "I link che hai condiviso, quando scadono e quante volte sono stati aperti."
"activeDescription": "I link che hai condiviso, quando scadono e quante volte sono stati aperti.",
"revokedToast": "Link revocato.",
"revokeFailed": "Impossibile revocare il link."
},
"genderOther": "Altro",
"nightscout": "Nightscout",
@@ -7097,7 +7107,9 @@
"lastUsed": "Usata l'ultima volta il {date}",
"neverUsed": "Mai usata",
"added": "aggiunta il {date}",
"nameLabel": "Nome della chiave di sicurezza"
"nameLabel": "Nome della chiave di sicurezza",
"addedToast": "Chiave di sicurezza aggiunta.",
"removedToast": "Chiave di sicurezza rimossa."
},
"passkeyNudge": {
"title": "Aggiungi una passkey per un accesso più rapido",
@@ -7449,7 +7461,11 @@
"guide": "Come collegare Google Health",
"badgeEnding": "In dismissione",
"badgeEnded": "Dismesso"
}
},
"passkeyAddedToast": "Passkey aggiunta.",
"passkeyRemovedToast": "Passkey rimossa.",
"integrationDisconnected": "{provider} disconnesso.",
"integrationDisconnectFailed": "Impossibile disconnettere {provider}."
},
"admin": {
"title": "Administration",
+21 -5
View File
@@ -6206,7 +6206,7 @@
"resetConfirmBody": "순서가 Codex → OpenAI → Anthropic → 로컬 → 관리자 OpenAI로 돌아가요. 저장한 인증 정보는 그대로예요."
},
"activeProviderHeading": "사용 중인 제공자",
"activeProviderLabel": "기본 제공자",
"activeProviderLabel": "설정할 제공자",
"providerSelect": {
"codex": "ChatGPT 계정 (Codex)",
"openai": "OpenAI (내 API 키)",
@@ -6413,7 +6413,7 @@
},
"coachNote": "코치에서는 코치 설정의 사고 깊이가 적용됩니다."
},
"providerCardDescription": "HealthLog가 가장 먼저 쓰는 제공자와 그 키, 모델이에요.",
"providerCardDescription": "제공자마다 키와 모델을 설정하고, 아래 순서로 HealthLog가 먼저 쓸 제공자를 정해요.",
"runtimeTitle": "확인 및 새로고침",
"usage": {
"title": "AI 사용량",
@@ -6440,6 +6440,14 @@
"followsDefault": "서버 기본값({level})을 따르고 있어요.",
"useDefault": "서버 기본값 사용({level})",
"saveError": "단계를 저장하지 못했어요. 다시 시도해 주세요."
},
"removeKey": {
"label": "API 키 삭제",
"title": "저장된 API 키를 삭제할까요?",
"body": "HealthLog가 저장된 키를 삭제하며 되돌릴 수 없어요. 계속 쓰려면 키를 다시 입력하세요.",
"confirm": "API 키 삭제",
"done": "API 키를 삭제했어요.",
"failed": "API 키를 삭제하지 못했어요."
}
},
"dashboard": {
@@ -6910,7 +6918,9 @@
"reselectReason": "이 링크는 선택한 적 없는 범위를 제공하고 있어서 닫았어요. 새로 만들고 담을 내용을 골라 주세요.",
"reselectAction": "새 링크 만들기",
"createDetail": "토큰은 만들 때 한 번만 보여줘요. 그때 저장해 두세요. 복구할 수 없어요.",
"activeDescription": "공유한 링크와 만료 시점, 열람 횟수를 보여줘요."
"activeDescription": "공유한 링크와 만료 시점, 열람 횟수를 보여줘요.",
"revokedToast": "링크를 해지했어요.",
"revokeFailed": "링크를 해지하지 못했어요."
},
"genderOther": "기타",
"nightscout": "Nightscout",
@@ -7097,7 +7107,9 @@
"lastUsed": "마지막 사용 {date}",
"neverUsed": "사용한 적 없음",
"added": "{date} 추가됨",
"nameLabel": "보안 키 이름"
"nameLabel": "보안 키 이름",
"addedToast": "보안 키를 추가했어요.",
"removedToast": "보안 키를 삭제했어요."
},
"passkeyNudge": {
"title": "패스키를 추가하면 더 빠르게 로그인해요",
@@ -7449,7 +7461,11 @@
"guide": "Google Health 연결 방법",
"badgeEnding": "종료 예정",
"badgeEnded": "종료됨"
}
},
"passkeyAddedToast": "패스키를 추가했어요.",
"passkeyRemovedToast": "패스키를 삭제했어요.",
"integrationDisconnected": "{provider} 연결을 해제했어요.",
"integrationDisconnectFailed": "{provider} 연결을 해제하지 못했어요."
},
"admin": {
"title": "관리자",
+21 -5
View File
@@ -6206,7 +6206,7 @@
"resetConfirmBody": "De keten wordt teruggezet naar Codex → OpenAI → Anthropic → Lokaal → OpenAI van beheerder. Je opgeslagen inloggegevens blijven onaangeroerd."
},
"activeProviderHeading": "Actieve provider",
"activeProviderLabel": "Primaire provider",
"activeProviderLabel": "Provider instellen",
"providerSelect": {
"codex": "ChatGPT-account (Codex)",
"openai": "OpenAI (je eigen API-sleutel)",
@@ -6413,7 +6413,7 @@
},
"coachNote": "In de Coach geldt de denkdiepte uit de Coach-instellingen."
},
"providerCardDescription": "De provider die HealthLog als eerste raadpleegt, met de bijbehorende sleutel en het model.",
"providerCardDescription": "Stel per provider de sleutel en het model in; de volgorde hieronder bepaalt welke HealthLog als eerste raadpleegt.",
"runtimeTitle": "Controleren en vernieuwen",
"usage": {
"title": "AI-gebruik",
@@ -6440,6 +6440,14 @@
"followsDefault": "Je volgt de standaardinstelling van de server ({level}).",
"useDefault": "Standaardinstelling van de server gebruiken ({level})",
"saveError": "Het niveau kon niet worden opgeslagen. Probeer het opnieuw."
},
"removeKey": {
"label": "API-sleutel verwijderen",
"title": "Opgeslagen API-sleutel verwijderen?",
"body": "HealthLog wist de opgeslagen sleutel en kan hem niet herstellen. Voer hem opnieuw in om hem te blijven gebruiken.",
"confirm": "API-sleutel verwijderen",
"done": "API-sleutel verwijderd.",
"failed": "De API-sleutel kon niet worden verwijderd."
}
},
"dashboard": {
@@ -6910,7 +6918,9 @@
"reselectReason": "Deze link gaf toegang tot een selectie die nooit was gekozen, daarom is hij gesloten. Maak een nieuwe link aan en kies wat erin moet staan.",
"reselectAction": "Vervanging aanmaken",
"createDetail": "Het token wordt één keer getoond bij het aanmaken. Bewaar het dan; het kan niet worden hersteld.",
"activeDescription": "Links die je hebt gedeeld, wanneer ze verlopen en hoe vaak ze zijn geopend."
"activeDescription": "Links die je hebt gedeeld, wanneer ze verlopen en hoe vaak ze zijn geopend.",
"revokedToast": "Link ingetrokken.",
"revokeFailed": "De link kon niet worden ingetrokken."
},
"genderOther": "Anders",
"nightscout": "Nightscout",
@@ -7097,7 +7107,9 @@
"lastUsed": "Laatst gebruikt op {date}",
"neverUsed": "Nooit gebruikt",
"added": "toegevoegd op {date}",
"nameLabel": "Naam van de beveiligingssleutel"
"nameLabel": "Naam van de beveiligingssleutel",
"addedToast": "Beveiligingssleutel toegevoegd.",
"removedToast": "Beveiligingssleutel verwijderd."
},
"passkeyNudge": {
"title": "Voeg een passkey toe om sneller in te loggen",
@@ -7449,7 +7461,11 @@
"guide": "Zo koppel je Google Health",
"badgeEnding": "Wordt uitgeschakeld",
"badgeEnded": "Uitgeschakeld"
}
},
"passkeyAddedToast": "Passkey toegevoegd.",
"passkeyRemovedToast": "Passkey verwijderd.",
"integrationDisconnected": "{provider} ontkoppeld.",
"integrationDisconnectFailed": "{provider} kon niet worden ontkoppeld."
},
"admin": {
"title": "Beheer",
+21 -5
View File
@@ -6206,7 +6206,7 @@
"resetConfirmBody": "Łańcuch wraca do Codex → OpenAI → Anthropic → Lokalny → OpenAI administratora. Zapisane dane logowania pozostają nienaruszone."
},
"activeProviderHeading": "Aktywny dostawca",
"activeProviderLabel": "Główny dostawca",
"activeProviderLabel": "Skonfiguruj dostawcę",
"providerSelect": {
"codex": "Konto ChatGPT (Codex)",
"openai": "OpenAI (Twój klucz API)",
@@ -6413,7 +6413,7 @@
},
"coachNote": "W Coachu obowiązuje głębokość myślenia z ustawień Coacha."
},
"providerCardDescription": "Dostawca, którego HealthLog pyta najpierw, wraz z kluczem i modelem.",
"providerCardDescription": "Skonfiguruj klucz i model każdego dostawcy; kolejność poniżej decyduje, którego HealthLog pyta najpierw.",
"runtimeTitle": "Sprawdź i odśwież",
"usage": {
"title": "Użycie AI",
@@ -6440,6 +6440,14 @@
"followsDefault": "Korzystasz z ustawienia domyślnego serwera ({level}).",
"useDefault": "Użyj ustawienia domyślnego serwera ({level})",
"saveError": "Nie udało się zapisać poziomu. Spróbuj ponownie."
},
"removeKey": {
"label": "Usuń klucz API",
"title": "Usunąć zapisany klucz API?",
"body": "HealthLog usuwa zapisany klucz i nie może go odzyskać. Wpisz go ponownie, aby dalej z niego korzystać.",
"confirm": "Usuń klucz API",
"done": "Usunięto klucz API.",
"failed": "Nie udało się usunąć klucza API."
}
},
"dashboard": {
@@ -6910,7 +6918,9 @@
"reselectReason": "Ten link udostępniał zakres, którego nikt nie wybrał, więc został zamknięty. Utwórz nowy i wybierz jego zawartość.",
"reselectAction": "Utwórz zamiennik",
"createDetail": "Token jest pokazywany tylko raz przy tworzeniu. Zapisz go wtedy; nie można go odzyskać.",
"activeDescription": "Udostępnione linki, termin ich wygaśnięcia i liczba otwarć."
"activeDescription": "Udostępnione linki, termin ich wygaśnięcia i liczba otwarć.",
"revokedToast": "Unieważniono link.",
"revokeFailed": "Nie udało się unieważnić linku."
},
"genderOther": "Inna",
"nightscout": "Nightscout",
@@ -7097,7 +7107,9 @@
"lastUsed": "Ostatnio użyty {date}",
"neverUsed": "Nigdy nieużywany",
"added": "dodany {date}",
"nameLabel": "Nazwa klucza bezpieczeństwa"
"nameLabel": "Nazwa klucza bezpieczeństwa",
"addedToast": "Dodano klucz bezpieczeństwa.",
"removedToast": "Usunięto klucz bezpieczeństwa."
},
"passkeyNudge": {
"title": "Dodaj klucz dostępu, aby logować się szybciej",
@@ -7449,7 +7461,11 @@
"guide": "Jak połączyć Google Health",
"badgeEnding": "Wkrótce wyłączone",
"badgeEnded": "Wyłączone"
}
},
"passkeyAddedToast": "Dodano passkey.",
"passkeyRemovedToast": "Usunięto passkey.",
"integrationDisconnected": "Rozłączono {provider}.",
"integrationDisconnectFailed": "Nie udało się rozłączyć {provider}."
},
"admin": {
"title": "Administracja",
@@ -0,0 +1,104 @@
/**
* A confirmation whose act takes something away wears the destructive style.
*
* `ConfirmButton` accepted a `destructive` prop and dropped it on the way to
* its dialog, so every delete, revoke and remove built on it asked its
* question over a neutral primary button — while the documents vault, which
* uses `ConfirmDialog`, asked the same question in red. The primitive forwards
* it now (`confirm-button.test.tsx` pins the wiring); this file pins that the
* call sites ask for it.
*
* The judgement "does this act destroy" is read off the `slot` the call site
* already names: a slot that says delete, remove, revoke, renounce, forget,
* clear or disconnect is that act. A reset is deliberately not in the list:
* it puts back a default the person can change again, and stays neutral.
* A slot without one of those words is not checked, which is the honest
* limit of a name-based rule; the slot is the one label every call site
* carries, and it is what the destructive-control registry is keyed on too.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import ts from "typescript";
import { describe, expect, it } from "vitest";
import { walkSourceFiles } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
const DESTROYING = /(delete|remove|revoke|renounce|forget|clear|disconnect)/i;
const TAGS = new Set(["ConfirmButton", "ConfirmDialog"]);
interface Site {
file: string;
slot: string;
destructive: boolean;
}
function slotText(attr: ts.JsxAttribute): string | null {
const init = attr.initializer;
if (!init) return null;
if (ts.isStringLiteral(init)) return init.text;
if (ts.isJsxExpression(init) && init.expression) {
const expr = init.expression;
if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) {
return expr.text;
}
if (ts.isTemplateExpression(expr)) return expr.head.text;
}
return null;
}
function collect(): Site[] {
const sites: Site[] = [];
for (const rel of walkSourceFiles(SRC, {
floor: 1200,
extensions: [".tsx"],
})) {
if (rel.includes("__tests__/") || rel.startsWith("generated/")) continue;
const text = readFileSync(join(SRC, rel), "utf8");
if (!text.includes("<Confirm")) continue;
const sf = ts.createSourceFile(rel, text, ts.ScriptTarget.Latest, true);
const visit = (node: ts.Node) => {
const opening = ts.isJsxSelfClosingElement(node)
? node
: ts.isJsxElement(node)
? node.openingElement
: null;
if (opening && TAGS.has(opening.tagName.getText(sf))) {
let slot: string | null = null;
let destructive = false;
for (const prop of opening.attributes.properties) {
if (!ts.isJsxAttribute(prop)) continue;
const name = prop.name.getText(sf);
if (name === "slot") slot = slotText(prop);
if (name === "destructive") destructive = true;
}
if (slot !== null) sites.push({ file: rel, slot, destructive });
}
ts.forEachChild(node, visit);
};
visit(sf);
}
return sites;
}
describe("destroying confirmations wear the destructive style", () => {
const sites = collect();
it("finds the confirmations it is meant to check", () => {
// A matcher that found nothing would agree with any tree.
expect(sites.filter((s) => DESTROYING.test(s.slot)).length).toBeGreaterThan(
15,
);
});
it("every one whose slot names a destroying act passes `destructive`", () => {
const missing = sites
.filter((s) => DESTROYING.test(s.slot) && !s.destructive)
.map((s) => `${s.file}: ${s.slot}`);
expect(
missing,
"Pass `destructive` so the confirm button is red, or rename the slot if the act does not take anything away.",
).toEqual([]);
});
});
@@ -114,7 +114,8 @@ const REGISTRY: DestructiveEntry[] = [
},
{
file: "components/settings/security-sessions-card.tsx",
destroys: "one login session, or every session other than this one",
destroys:
"one login session, or every session other than this one; and, from the list sign-out-everywhere leaves behind, another account's access grant (the same revoke the access card confirms)",
recovery: "permanent",
confirm: ["ConfirmButton"],
},
@@ -136,6 +137,21 @@ const REGISTRY: DestructiveEntry[] = [
recovery: "permanent",
confirm: ["AlertDialog"],
},
{
file: "components/settings/ai/remove-saved-key-button.tsx",
destroys:
"one stored personal AI provider key (OpenAI, Anthropic, the local model or the gateway); the provider choice and model stay",
triggers: [
"components/settings/ai/openai-provider-form.tsx",
"components/settings/ai/anthropic-provider-form.tsx",
"components/settings/ai/local-provider-form.tsx",
"components/settings/ai/compat-provider-form.tsx",
],
recovery: "permanent",
confirm: ["ConfirmButton"],
undetected:
"No detector sees it. The body comes from `removeSavedKeyBody` with a computed key (`{ [field]: null }`), which the D3 literal pattern does not match, and `/api/user/ai-provider` names no act. Registered by hand.",
},
{
file: "components/settings/api-section.tsx",
destroys: "an API token — revoked, and the secret is never shown again",
+4
View File
@@ -391,6 +391,10 @@ export const GET = apiHandler(async () => {
// resolved `ai.capabilities.coach` and `modules.coach` below, which fold
// in the record's own opt-out, the operator's switches and the grant.
disableCoach: user.disableCoach ?? false,
// The name the app addresses the account by (dashboard greeting,
// reports). Set in the setup flow and edited on the profile card, which
// seeds its field from here.
displayName: user.displayName ?? null,
// v1.7.0 — health-record export identity fields. All optional.
fullName: user.fullName ?? null,
insurerName: user.insurerName ?? null,
@@ -12,6 +12,7 @@ import { notificationPreferenceSchema } from "@/lib/validations/notifications";
import { EVENT_TYPES, CHANNEL_TYPE_LABELS } from "@/lib/notifications/types";
import type { ChannelType } from "@/lib/notifications/types";
import { decrypt, encrypt } from "@/lib/crypto";
import { resolveEventDefaults } from "@/lib/notifications/event-defaults";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
@@ -92,6 +93,12 @@ export const GET = apiHandler(async () => {
channels: channelData,
preferences,
eventTypes: EVENT_TYPES as unknown as string[],
// What an event does on a channel with no stored row, resolved for this
// account by the same function the dispatcher asks. A client renders a
// switch without a row from this map, never from its own copy of the
// defaults: the mood reminder's follows the reminder card, which a
// constant table cannot know.
eventDefaults: await resolveEventDefaults(user.id),
});
});
+1
View File
@@ -191,6 +191,7 @@ function CoachPlansBody({ readOnly }: { readOnly: boolean }) {
{(readOnly || group === "past") && (
<ConfirmButton
slot="coach-plan-delete"
destructive
variant="ghost"
size="sm"
className="min-h-9"
@@ -126,4 +126,58 @@ describe("NotificationsPage", () => {
const html = render();
expect(html).toContain("won’t receive notifications");
});
it("a switch without a stored row shows the server's default for this account", () => {
// The mood reminder is delivered when the reminder card is on, with no
// per-channel row. The constant table says OFF for it, so a matrix that
// read the table showed the switch off while the reminder kept arriving.
// The page renders what the server resolved, row-less events included.
queryState.data = {
channels: [
{
id: "c1",
type: "TELEGRAM",
label: "Telegram",
enabled: true,
globallyEnabled: true,
},
],
preferences: [],
eventTypes: ["MOOD_REMINDER", "PERSONAL_RECORD"],
eventDefaults: { MOOD_REMINDER: true, PERSONAL_RECORD: false },
};
const html = render();
const state = (label: string) =>
new RegExp(
`aria-label="${label} - Telegram"[^>]*data-state="(checked|unchecked)"|data-state="(checked|unchecked)"[^>]*aria-label="${label} - Telegram"`,
).exec(html);
const mood = state("Mood reminder");
const record = state("Personal record");
expect(mood, "mood switch rendered").not.toBeNull();
expect(mood?.[1] ?? mood?.[2]).toBe("checked");
expect(record?.[1] ?? record?.[2]).toBe("unchecked");
});
it("a stored row still wins over the default", () => {
queryState.data = {
channels: [
{
id: "c1",
type: "TELEGRAM",
label: "Telegram",
enabled: true,
globallyEnabled: true,
},
],
preferences: [
{ channelId: "c1", eventType: "MOOD_REMINDER", enabled: false },
],
eventTypes: ["MOOD_REMINDER"],
eventDefaults: { MOOD_REMINDER: true },
};
const html = render();
expect(html).toMatch(
/aria-label="Mood reminder - Telegram"[^>]*data-state="unchecked"|data-state="unchecked"[^>]*aria-label="Mood reminder - Telegram"/,
);
});
});
+9 -8
View File
@@ -12,10 +12,6 @@ import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import Link from "next/link";
import { Bell, Settings, AlertCircle } from "lucide-react";
import {
EVENT_DEFAULT_ENABLED,
type EventType,
} from "@/lib/notifications/types";
import {
eventDescriptionTranslationKey,
eventTranslationKey,
@@ -48,6 +44,11 @@ interface PreferencesData {
channels: ChannelInfo[];
preferences: Preference[];
eventTypes: string[];
/**
* What each event does on a channel with no stored row, resolved by the
* server for this account with the function the dispatcher uses.
*/
eventDefaults: Record<string, boolean>;
}
export default function NotificationsPage() {
@@ -116,10 +117,10 @@ export default function NotificationsPage() {
(p) => p.channelId === channelId && p.eventType === eventType,
);
if (pref) return pref.enabled;
// v1.4.25 — per-event default. Most events stay opt-out
// (no row = ON); PERSONAL_RECORD flips to opt-in (no row = OFF)
// so a backfill doesn't fire hundreds of pushes on first sync.
return EVENT_DEFAULT_ENABLED[eventType as EventType] ?? true;
// No row: the server's resolved default for this account. Read, never
// recomputed here: the mood reminder's follows the reminder card, and a
// copy of the constant table showed it off while it was being delivered.
return data?.eventDefaults?.[eventType] === true;
}
// Cross-links to the other two notification surfaces (reminder types +
@@ -187,6 +187,7 @@ export function AiServerKeySection() {
{configured && (
<ConfirmButton
slot="admin-ai-server-key-remove"
destructive
variant="outline"
size="sm"
label={t("admin.aiServerKey.remove")}
@@ -196,6 +196,7 @@ export function CentralCodexSection() {
<SettingsCardActions>
<ConfirmButton
slot="admin-central-codex-disconnect"
destructive
variant="destructive"
size="sm"
className="min-h-11 shrink-0 sm:min-h-9"
+2
View File
@@ -880,6 +880,7 @@ export function LogDaySheet({
{currentRowId ? (
<ConfirmButton
slot="cycle-day-delete"
destructive
variant="outline"
className="text-destructive hover:text-destructive mr-auto"
icon={<Trash2 className="h-4 w-4" />}
@@ -1471,6 +1472,7 @@ export function SymptomRow({
</button>
<ConfirmDialog
slot="cycle-custom-symptom-remove"
destructive
open={confirmRemove}
onOpenChange={setConfirmRemove}
title={t("cycle.symptom.custom.removeTitle")}
+12 -1
View File
@@ -14,6 +14,7 @@
* View / sort / order persist client-side via `useModuleListPrefs("labs")`.
*/
import { useQuery } from "@tanstack/react-query";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import {
ArrowDownUp,
FlaskConical,
@@ -29,6 +30,7 @@ import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { apiGet } from "@/lib/api/api-fetch";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { ModuleViewToggle } from "@/components/module-list/module-view-toggle";
@@ -139,7 +141,16 @@ export function LabsSettings() {
<Switch
checked={localOcr.data?.labsLocalOcrEnabled ?? false}
disabled={localOcr.isLoading || updateLocalOcr.isPending}
onCheckedChange={(checked) => updateLocalOcr.mutate(checked)}
onCheckedChange={(checked) =>
updateLocalOcr.mutate(checked, {
onSuccess: () =>
toastWrittenOutcome("success", t("settings.saved")),
onError: (failure) =>
showErrorToast(t("settings.savingError"), {
cause: failure,
}),
})
}
aria-label={t("labs.localOcr.heading")}
/>
}
@@ -11,6 +11,7 @@
* reminder PATCH route.
*/
import { ArrowUpDown, LayoutGrid, ListChecks } from "lucide-react";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { Card, CardContent } from "@/components/ui/card";
import { Switch } from "@/components/ui/switch";
@@ -31,6 +32,7 @@ import {
} from "@/hooks/use-measurement-reminders";
import { translateOrNull } from "@/lib/i18n/translate-code";
import { EmptyState } from "@/components/ui/empty-state";
import { showErrorToast } from "@/lib/errors/error-toast";
function resolveLabel(
reminder: MeasurementReminder,
@@ -127,10 +129,20 @@ export function VorsorgeSettings() {
<Switch
checked={reminder.enabled}
onCheckedChange={(next) =>
update.mutate({
id: reminder.id,
body: { enabled: next },
})
update.mutate(
{ id: reminder.id, body: { enabled: next } },
{
onSuccess: () =>
toastWrittenOutcome(
"success",
t("settings.saved"),
),
onError: (failure) =>
showErrorToast(t("settings.savingError"), {
cause: failure,
}),
},
)
}
disabled={update.isPending}
// Label each row's Switch by its own name span so SR
@@ -1,3 +1,6 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
@@ -30,7 +33,14 @@ vi.mock("@/lib/api/api-fetch", () => ({ apiGet: vi.fn(), apiPatch: vi.fn() }));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
import { I18nProvider } from "@/lib/i18n/context";
import { EmergencyProfileManager } from "../emergency-profile-manager";
import {
emergencyProfileUpdateSchema,
type EmergencyProfileDto,
} from "@/lib/validations/emergency-profile";
import {
EmergencyProfileManager,
emergencyPatchBody,
} from "../emergency-profile-manager";
describe("<EmergencyProfileManager> Save", () => {
it("stays disabled until a field differs from the stored profile", () => {
@@ -43,3 +53,71 @@ describe("<EmergencyProfileManager> Save", () => {
expect(row).toMatch(/<button[^>]*disabled=""[^>]*>[^]*?Save<\/button>/);
});
});
describe("emergency selects can be taken back to not recorded", () => {
// A blood type, donor status or directive, once chosen, could be changed
// to another value but never cleared: the select offered no empty option,
// and the save left out an empty select, so even a cleared draft would not
// have reached the column.
const stored: EmergencyProfileDto = {
bloodType: "A_POS",
organDonor: "YES",
advanceDirective: null,
contacts: "A contact",
contactsUnreadable: false,
implants: null,
implantsUnreadable: false,
note: null,
noteUnreadable: false,
};
const draft = {
bloodType: "A_POS",
organDonor: "YES",
advanceDirective: "",
contacts: "A contact",
implants: "",
note: "",
};
it("sends a cleared select as null, which clears the column", () => {
expect(
emergencyPatchBody({ ...draft, bloodType: "" }, stored),
).toMatchObject({ bloodType: null });
});
it("leaves an untouched select out of the save", () => {
const body = emergencyPatchBody(draft, stored);
expect(body).not.toHaveProperty("bloodType");
expect(body).not.toHaveProperty("organDonor");
expect(body).not.toHaveProperty("advanceDirective");
});
it("sends a changed select's new value", () => {
expect(
emergencyPatchBody({ ...draft, advanceDirective: "YES" }, stored),
).toMatchObject({ advanceDirective: "YES" });
});
it("the server accepts what the card sends", () => {
const body = emergencyPatchBody(
{ ...draft, bloodType: "", organDonor: "NO" },
stored,
);
const parsed = emergencyProfileUpdateSchema.safeParse(body);
expect(parsed.success).toBe(true);
expect(parsed.data).toMatchObject({ bloodType: null, organDonor: "NO" });
});
it("offers a not-recorded option in every select", () => {
// The options live in the popover, which a static render never opens;
// read from the source that each select carries the option.
const source = readFileSync(
resolve(__dirname, "../emergency-profile-manager.tsx"),
"utf8",
);
expect(source).toMatch(
/<SelectItem value=\{NOT_RECORDED\}>\s*\{t\("records\.emergency\.notRecorded"\)\}/,
);
expect(source).toMatch(/next === NOT_RECORDED \? "" : next/);
});
});
@@ -194,6 +194,7 @@ export function AboutMeNoteManager() {
{hasNote && (
<ConfirmButton
slot="settings-about-me-clear"
destructive
label={t("settings.ai.aboutMe.clear")}
icon={<Trash2 className="size-4" aria-hidden />}
variant="outline"
+2
View File
@@ -176,6 +176,7 @@ export function AuditCPanel({
className="text-destructive size-11 shrink-0 sm:size-9"
icon={<Trash2 className="size-4" aria-hidden />}
slot={`audit-c-remove-${entry.id}`}
destructive
/>
</li>
))}
@@ -214,6 +215,7 @@ export function AuditCPanel({
className="text-destructive size-11 shrink-0 sm:size-9"
icon={<Trash2 className="size-4" aria-hidden />}
slot="audit-c-legacy-remove"
destructive
/>
</div>
) : null}
@@ -31,7 +31,7 @@ import {
type EmergencyProfileDto,
} from "@/lib/validations/emergency-profile";
interface Draft {
export interface Draft {
bloodType: string;
organDonor: string;
advanceDirective: string;
@@ -40,6 +40,45 @@ interface Draft {
note: string;
}
/**
* The select value that stands for "not recorded". Radix refuses an empty
* string as an item value, so the cleared state needs a sentinel of its own;
* the draft keeps "" and only the select sees this.
*/
const NOT_RECORDED = "__none__";
type EnumKey = "bloodType" | "organDonor" | "advanceDirective";
const ENUM_KEYS: readonly EnumKey[] = [
"bloodType",
"organDonor",
"advanceDirective",
];
/**
* The PATCH body for a save. A select rides only when it changed, so a save
* leaves a column it did not touch alone; a changed select that is now empty
* rides as `null`, which is what clears it. The free-text fields always ride,
* an emptied one clearing its column server-side.
*
* An empty select used to be omitted always, and the select had no empty
* option to begin with: a blood type, once chosen, could be changed to
* another one but never taken back to "not recorded".
*/
export function emergencyPatchBody(
draft: Draft,
stored: EmergencyProfileDto,
): Record<string, string | null> {
const body: Record<string, string | null> = {};
for (const key of ENUM_KEYS) {
const next = draft[key] || null;
if (next !== (stored[key] ?? null)) body[key] = next;
}
body.contacts = draft.contacts;
body.implants = draft.implants;
body.note = draft.note;
return body;
}
function draftFromDto(dto: EmergencyProfileDto): Draft {
return {
bloodType: dto.bloodType ?? "",
@@ -66,19 +105,10 @@ export function EmergencyProfileManager() {
const save = useMutation({
mutationKey: queryKeys.emergencyProfile(),
mutationFn: (input: Draft) =>
apiPatch<EmergencyProfileDto>("/api/anamnesis/emergency", {
// A select left at "" is "not recorded" and is omitted so a partial
// save leaves the column untouched; the free-text fields always ride,
// with an emptied field clearing its column server-side.
...(input.bloodType ? { bloodType: input.bloodType } : {}),
...(input.organDonor ? { organDonor: input.organDonor } : {}),
...(input.advanceDirective
? { advanceDirective: input.advanceDirective }
: {}),
contacts: input.contacts,
implants: input.implants,
note: input.note,
}),
apiPatch<EmergencyProfileDto>(
"/api/anamnesis/emergency",
emergencyPatchBody(input, query.data!),
),
onSuccess: (data) => {
setDraft(draftFromDto(data));
queryClient.setQueryData(queryKeys.emergencyProfile(), data);
@@ -110,7 +140,7 @@ export function EmergencyProfileManager() {
setDraft((prev) => ({ ...(prev ?? draftFromDto(query.data!)), ...patch }));
const enumField = (
key: "bloodType" | "organDonor" | "advanceDirective",
key: EnumKey,
values: readonly string[],
valuePrefix: string,
) => (
@@ -119,14 +149,21 @@ export function EmergencyProfileManager() {
{t(`records.emergency.${key}`)}
</Label>
<Select
value={current?.[key] ?? ""}
value={current?.[key] || NOT_RECORDED}
disabled={disabled}
onValueChange={(next) => set({ [key]: next } as Partial<Draft>)}
onValueChange={(next) =>
set({ [key]: next === NOT_RECORDED ? "" : next } as Partial<Draft>)
}
>
<SelectTrigger id={`emergency-${key}`} className="w-full">
<SelectValue placeholder={t("records.emergency.notRecorded")} />
</SelectTrigger>
<SelectContent>
{/* The way back to "not recorded", as every optional select in the
records offers one. */}
<SelectItem value={NOT_RECORDED}>
{t("records.emergency.notRecorded")}
</SelectItem>
{values.map((option) => (
<SelectItem key={option} value={option}>
{t(`records.emergency.${valuePrefix}.${option}`)}
@@ -0,0 +1,152 @@
/**
* Settings actions say how they went.
*
* Each action below finished in silence on at least one side. Adding or
* removing a passkey or a security key only redrew the list; disconnecting an
* integration and revoking a share link fired from a dialog that had already
* closed, so a refused request left everything as it was and said nothing;
* the injection-site exclusions, the local-OCR switch and the preventive-care
* reminder switches saved, or did not, without a word.
*
* They use the pair the rest of the app reports a write with —
* `toastWrittenOutcome("success", …)`, the outcome module's one door to the
* success toast, and `showErrorToast` (which carries the error id of a server
* failure). The passkey and security-key cards keep their inline error line,
* which also carries the step-up dialog's outcome, and gain the success half.
*
* Mutation callbacks only run after an interaction, so the wiring is read
* from the source: per action, the success callback raises the success
* outcome and, where the card has no inline error line, the error callback raises
* `showErrorToast`.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import ts from "typescript";
import { describe, expect, it } from "vitest";
interface Site {
file: string;
/** `const <name> = useMutation({…})`, or `<name>.mutate(…, {…})`. */
name: string;
shape: "definition" | "call";
needsErrorToast: boolean;
}
const SITES: Site[] = [
{
file: "src/components/settings/security-section/passkey-list-section.tsx",
name: "add",
shape: "definition",
needsErrorToast: false,
},
{
file: "src/components/settings/security-section/passkey-list-section.tsx",
name: "remove",
shape: "definition",
needsErrorToast: false,
},
{
file: "src/components/settings/security-section/security-keys-card.tsx",
name: "add",
shape: "definition",
needsErrorToast: false,
},
{
file: "src/components/settings/security-section/security-keys-card.tsx",
name: "remove",
shape: "definition",
needsErrorToast: false,
},
{
file: "src/components/settings/integrations/oauth-integration-card.tsx",
name: "disconnect",
shape: "definition",
needsErrorToast: true,
},
{
file: "src/components/settings/sharing-section.tsx",
name: "revokeMutation",
shape: "definition",
needsErrorToast: true,
},
{
file: "src/components/settings/injection-sites-card.tsx",
name: "mutation",
shape: "definition",
needsErrorToast: true,
},
{
file: "src/components/labs/labs-settings.tsx",
name: "updateLocalOcr",
shape: "call",
needsErrorToast: true,
},
{
file: "src/components/measurement-reminders/vorsorge-settings.tsx",
name: "update",
shape: "call",
needsErrorToast: true,
},
];
/** The `{ onSuccess, onError, … }` literal for a site, or null. */
function callbacks(site: Site): ts.ObjectLiteralExpression | null {
const text = readFileSync(join(process.cwd(), site.file), "utf8");
const sf = ts.createSourceFile(site.file, text, ts.ScriptTarget.Latest, true);
let found: ts.ObjectLiteralExpression | null = null;
const visit = (node: ts.Node) => {
if (found) return;
if (
site.shape === "definition" &&
ts.isVariableDeclaration(node) &&
node.name.getText(sf) === site.name &&
node.initializer &&
ts.isCallExpression(node.initializer) &&
node.initializer.expression.getText(sf) === "useMutation"
) {
const arg = node.initializer.arguments[0];
if (arg && ts.isObjectLiteralExpression(arg)) found = arg;
}
if (
site.shape === "call" &&
ts.isCallExpression(node) &&
node.expression.getText(sf) === `${site.name}.mutate`
) {
const arg = node.arguments[1];
if (arg && ts.isObjectLiteralExpression(arg)) found = arg;
}
ts.forEachChild(node, visit);
};
visit(sf);
return found;
}
function property(obj: ts.ObjectLiteralExpression, name: string): string {
const prop = obj.properties.find((p) => p.name && p.name.getText() === name);
return prop ? prop.getText() : "";
}
describe("settings actions report their outcome", () => {
for (const site of SITES) {
describe(`${site.file} → ${site.name}`, () => {
const obj = callbacks(site);
it("is found", () => {
expect(obj, "the mutation options were not found").not.toBeNull();
});
it("raises a success toast", () => {
expect(property(obj!, "onSuccess")).toMatch(
/toastWrittenOutcome\(\s*"success"/,
);
});
if (site.needsErrorToast) {
it("raises an error toast through showErrorToast", () => {
expect(property(obj!, "onError")).toContain("showErrorToast(");
});
}
});
}
});
@@ -216,12 +216,25 @@ describe("<AiSection> — dropdown-driven UX (B2)", () => {
expect(html).toContain("ChatGPT-Account (Codex)");
// v1.4.33 — the active-provider heading and the parent section
// both ship without the "KI"/"AI" prefix per the project-voice rule
// ("Aktiver KI-Provider" → "Aktiver Provider"). The heading and the
// chain summary now share the same phrase, so we assert against the
// explicit `activeProviderLabel` slot below it ("Primärer Provider")
// to keep the test pinned to the right surface.
// ("Aktiver KI-Provider" → "Aktiver Provider"). The picker below it is
// labelled for what it does: it chooses the form, not the order.
expect(html).toContain("Aktiver Provider");
expect(html).toContain("Primärer Provider");
expect(html).toContain("Anbieter einrichten");
});
it("labels the picker for what it does, not as the primary provider", () => {
// The select picks which provider's key and model the form below edits.
// It never wrote anything, yet read "Primary provider" under a card
// that said it held "the provider HealthLog asks first": a person who
// picked one expected it to answer first, and the chain order, which is
// what decides that, stayed as it was. The copy now points at the chain.
activeProviderQuery = "codex";
const html = render();
expect(html).not.toContain("Primary provider");
expect(html).toContain("Set up a provider");
expect(html).toContain(
"the order below decides which one HealthLog asks first",
);
});
// v1.18.0 — the Coach preference cards (disable toggle, preferences,
@@ -0,0 +1,57 @@
/**
* Ending a grant from the sessions card asks first.
*
* "Sign out everywhere" leaves accepted grants standing and lists them with a
* one-click end. That end is the same act as the access card's revoke — it
* takes another account's standing access to the record, permanently — but it
* fired on the tap, while the access card asks with a dialog naming who loses
* what. The row is state set after a mutation, out of reach of a static
* render, so the wiring is read from the source: the revoke call may only sit
* inside a `ConfirmButton`'s `onConfirm`.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import ts from "typescript";
import { describe, expect, it } from "vitest";
const FILE = "src/components/settings/security-sessions-card.tsx";
function revokeCallSites(): { attr: string | null; element: string | null }[] {
const text = readFileSync(join(process.cwd(), FILE), "utf8");
const sf = ts.createSourceFile(FILE, text, ts.ScriptTarget.Latest, true);
const sites: { attr: string | null; element: string | null }[] = [];
const visit = (node: ts.Node) => {
if (
ts.isCallExpression(node) &&
node.expression.getText(sf) === "revokeGrant.mutate"
) {
let attr: string | null = null;
let element: string | null = null;
for (let p: ts.Node | undefined = node.parent; p; p = p.parent) {
if (ts.isJsxAttribute(p) && attr === null) attr = p.name.getText(sf);
if (
(ts.isJsxSelfClosingElement(p) || ts.isJsxOpeningElement(p)) &&
attr !== null
) {
element = p.tagName.getText(sf);
break;
}
}
sites.push({ attr, element });
}
ts.forEachChild(node, visit);
};
visit(sf);
return sites;
}
describe("kept-grant revoke on the sessions card", () => {
it("fires only from a ConfirmButton's onConfirm", () => {
const sites = revokeCallSites();
expect(sites.length).toBeGreaterThan(0);
for (const site of sites) {
expect(site).toEqual({ attr: "onConfirm", element: "ConfirmButton" });
}
});
});
@@ -0,0 +1,163 @@
/**
* A settings save that raises its own pending flag lowers it in a `finally`.
*
* The cards that hand-roll a `saving` flag around a raw `fetch` set it, await
* the request, and cleared it on the line after. A request that never reached
* the server (offline, a dropped connection, a timeout) rejects instead of
* returning a response, the line after never ran, and the card stayed in its
* saving state for good: the Telegram form, the mood reminder, the low-stock
* threshold, the lock-screen detail switch, the Coach nudges, the profile
* form. Every one of them disabled its own control while "saving", so the
* only way out was a reload.
*
* `useMutation` owns its `isPending` and cannot get stuck this way; this
* guard covers the hand-rolled ones. For every async function under
* `src/components/settings` that sets one of the pending flags below to
* `true` and then awaits, it requires a `try` whose `finally` sets the same
* flag back to `false` — or, the older shape that is equally safe, every
* await inside a `try` that catches, so nothing can skip the reset after it.
* What it cannot see: a flag under another name, or a function that hands
* the await to a helper it does not await itself.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import ts from "typescript";
import { describe, expect, it } from "vitest";
import { walkSourceFiles } from "@/__tests__/helpers/source-files";
const ROOT = join(process.cwd(), "src/components/settings");
const PENDING_SETTER =
/^set(Saving|Pending|Busy|Loading|Submitting|ActionLoading)$/;
interface Finding {
where: string;
setter: string;
/** The flag is lowered in a `finally`, or no await can throw past the reset. */
safe: boolean;
}
/** Is this await inside the try block of a `try` that has a `catch`? */
function caught(node: ts.Node, fn: ts.Node): boolean {
for (let p = node.parent; p && p !== fn; p = p.parent) {
if (
ts.isBlock(p) &&
ts.isTryStatement(p.parent) &&
p.parent.tryBlock === p &&
p.parent.catchClause
) {
return true;
}
}
return false;
}
type FunctionLike =
| ts.FunctionDeclaration
| ts.FunctionExpression
| ts.ArrowFunction
| ts.MethodDeclaration;
function isFunctionLike(node: ts.Node): node is FunctionLike {
return (
ts.isFunctionDeclaration(node) ||
ts.isFunctionExpression(node) ||
ts.isArrowFunction(node) ||
ts.isMethodDeclaration(node)
);
}
/** Walk a function body without descending into nested functions. */
function ownNodes(body: ts.Node, visit: (node: ts.Node) => void): void {
const step = (node: ts.Node) => {
visit(node);
if (isFunctionLike(node)) return;
ts.forEachChild(node, step);
};
ts.forEachChild(body, step);
}
function setterCall(node: ts.Node, sf: ts.SourceFile, value: string) {
return (
ts.isCallExpression(node) &&
ts.isIdentifier(node.expression) &&
PENDING_SETTER.test(node.expression.text) &&
node.arguments.length === 1 &&
node.arguments[0].getText(sf) === value
);
}
function scan(): Finding[] {
const findings: Finding[] = [];
for (const rel of walkSourceFiles(ROOT, { floor: 150 })) {
if (rel.includes("__tests__/")) continue;
const text = readFileSync(join(ROOT, rel), "utf8");
const sf = ts.createSourceFile(rel, text, ts.ScriptTarget.Latest, true);
const visitFn = (node: ts.Node) => {
if (
isFunctionLike(node) &&
node.body &&
node.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword)
) {
const raised = new Set<string>();
const awaits: ts.Node[] = [];
ownNodes(node.body, (n) => {
if (setterCall(n, sf, "true")) {
raised.add(
((n as ts.CallExpression).expression as ts.Identifier).text,
);
}
if (ts.isAwaitExpression(n)) awaits.push(n);
});
const allCaught = awaits.every((a) => caught(a, node));
if (awaits.length > 0) {
for (const setter of raised) {
let resetInFinally = false;
ownNodes(node.body, (n) => {
if (!ts.isTryStatement(n) || !n.finallyBlock) return;
ownNodes(n.finallyBlock, (m) => {
if (
setterCall(m, sf, "false") &&
((m as ts.CallExpression).expression as ts.Identifier)
.text === setter
) {
resetInFinally = true;
}
});
});
const line =
sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
findings.push({
where: `${rel}:${line}`,
setter,
safe: resetInFinally || allCaught,
});
}
}
}
ts.forEachChild(node, visitFn);
};
visitFn(sf);
}
return findings;
}
describe("settings saves cannot stay pending after a failed request", () => {
const findings = scan();
it("finds the hand-rolled saves it is meant to check", () => {
// A matcher that found nothing would pass on any tree.
expect(findings.length).toBeGreaterThan(10);
});
it("every one lowers its flag in a finally", () => {
const stuck = findings
.filter((f) => !f.safe)
.map((f) => `${f.where} (${f.setter})`);
expect(
stuck,
"Move the reset into `finally`, so a request that rejects (offline, timeout) cannot leave the card saving.",
).toEqual([]);
});
});
@@ -0,0 +1,52 @@
/**
* The token revoke confirmation names the act.
*
* Both token lists (API tokens and MCP connections) labelled the dialog's
* confirm button with `settings.tokenRevoked` — "Revoked", the status word the
* admin overview shows for a token that is already gone — so the button that
* performs the revoke read as if it had happened. The phone layout's trigger
* carried the dialog title instead, question mark included.
*
* Read from the source because the dialog content only mounts while open, and
* an SSR render never sees it. What is pinned: every `AlertDialogAction` that
* calls `handleRevoke` is labelled with the action key, and no trigger or
* action on these two surfaces uses the status word.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const FILES = [
"src/components/settings/api-section.tsx",
"src/components/settings/mcp-section.tsx",
];
function read(rel: string): string {
return readFileSync(join(process.cwd(), rel), "utf8");
}
/** The body of every `<AlertDialogAction …>…</AlertDialogAction>` block. */
function actions(source: string): string[] {
return [
...source.matchAll(/<AlertDialogAction\b[\s\S]*?<\/AlertDialogAction>/g),
].map((m) => m[0]);
}
describe("token revoke labels", () => {
for (const rel of FILES) {
it(`${rel}: the revoke confirm says "Revoke token"`, () => {
const revokes = actions(read(rel)).filter((a) =>
a.includes("handleRevoke(tok.id)"),
);
expect(revokes.length).toBeGreaterThan(0);
for (const action of revokes) {
expect(action).toContain('t("settings.tokenRevokeAction")');
}
});
it(`${rel}: never labels a control with the status word`, () => {
expect(read(rel)).not.toContain('t("settings.tokenRevoked")');
});
}
});
@@ -127,6 +127,7 @@ export function GrantsGivenCard() {
return (
<ConfirmButton
slot="grant-revoke"
destructive
size="sm"
label={
grant.state === "PENDING"
@@ -229,6 +229,7 @@ export function GrantsReceivedCard() {
</Button>
<ConfirmButton
slot="grant-renounce"
destructive
size="sm"
label={t("recordSharing.received.renounce")}
title={t("recordSharing.received.renounceTitle", { name })}
@@ -188,6 +188,7 @@ function ManagedProfileRow({
{guardians.data && (
<ConfirmButton
slot="managed-profile-delete"
destructive
size="sm"
className="min-h-11 sm:min-h-9"
label={t("recordSharing.managed.delete")}
@@ -172,6 +172,7 @@ export function ManagedProfileGuardians({
<div className="flex shrink-0 items-center">
<ConfirmButton
slot="managed-profile-guardian-remove"
destructive
size="sm"
className="min-h-11 sm:min-h-9"
label={t("recordSharing.managed.guardianRemove")}
@@ -0,0 +1,100 @@
/**
* The display name can be changed after the setup flow.
*
* The setup flow asks for the name the greeting and the reports use, and the
* profile route has always accepted `displayName` — but no settings surface
* offered it, and `/api/auth/me` did not even publish it, so a name chosen on
* the first day was the name for good. The profile card now carries the
* field, seeded from the account payload, and sends it with the save.
*
* Both ends are pinned: the route publishes the field (read from its response
* literal, the way `account-payload-consumer-guard` reads it), and the card
* renders it seeded and posts it.
*/
import { readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
let displayName: string | null = "Sam";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/settings/account",
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: null, isLoading: false, refetch: vi.fn() }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
}));
vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true }));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
id: "u1",
username: "testuser",
displayName,
email: "user@example.com",
heightCm: null,
dateOfBirth: null,
gender: null,
role: "USER",
unitPreference: "metric",
},
isAuthenticated: true,
isLoading: false,
refetch: vi.fn(),
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { AccountSection } from "@/components/settings/account-section";
function render(): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<AccountSection />
</I18nProvider>,
);
}
/** The `<input id="display-name" …>` tag, or null. */
function displayNameInput(html: string): string | null {
return /<input[^>]*id="display-name"[^>]*>/.exec(html)?.[0] ?? null;
}
describe("display name on the profile card", () => {
it("renders the field seeded with the stored name", () => {
displayName = "Sam";
const input = displayNameInput(render());
expect(input).not.toBeNull();
expect(input).toContain('value="Sam"');
});
it("renders it empty for an account that never set one", () => {
displayName = null;
const input = displayNameInput(render());
expect(input).not.toBeNull();
expect(input).toContain('value=""');
});
it("sends the field with the profile save", () => {
const source = readFileSync(resolve(__dirname, "../index.tsx"), "utf8");
expect(source).toMatch(
/displayName:\s*savedProfile\.displayName\s*\|\|\s*null/,
);
});
it("is published on the account payload the card seeds from", () => {
const route = readFileSync(
join(process.cwd(), "src/app/api/auth/me/route.ts"),
"utf8",
);
expect(route).toMatch(/\bdisplayName:\s*user\.displayName\s*\?\?\s*null/);
});
});
@@ -154,6 +154,7 @@ export function AvatarSection() {
{avatarUrl && (
<ConfirmButton
slot="settings-avatar-remove"
destructive
variant="ghost"
className="text-destructive min-h-11 sm:min-h-9"
icon={<Trash2 className="h-4 w-4" />}
+205 -146
View File
@@ -95,12 +95,16 @@ export function AccountSection() {
const [dateOfBirth, setDateOfBirth] = useState("");
const [gender, setGender] = useState<string>("");
const [timezone, setTimezone] = useState<string>(DEFAULT_TIMEZONE);
// The name the app addresses the account by; set in the setup flow, and
// until now not editable anywhere after it.
const [displayName, setDisplayName] = useState("");
// v1.7.0 — optional patient-identity fields for the health-record export.
const [fullName, setFullName] = useState("");
const [insurerName, setInsurerName] = useState("");
const [insuranceNumber, setInsuranceNumber] = useState("");
const [profileSeed, setProfileSeed] = useState({
email: "",
displayName: "",
height: EMPTY_HEIGHT_DRAFT,
dateOfBirth: "",
gender: "",
@@ -149,6 +153,7 @@ export function AccountSection() {
if (user && user.id !== seededUserId) {
const seed = {
email: user.email ?? "",
displayName: user.displayName ?? "",
height: heightAdapter.toDraft(user.heightCm),
dateOfBirth: user.dateOfBirth
? dateOnlyKey(new Date(user.dateOfBirth))
@@ -161,6 +166,7 @@ export function AccountSection() {
};
setSeededUserId(user.id);
setEmail(seed.email);
setDisplayName(seed.displayName);
setHeight(seed.height);
setDateOfBirth(seed.dateOfBirth);
setGender(seed.gender);
@@ -192,6 +198,7 @@ export function AccountSection() {
seed: profileSeed,
value: {
email,
displayName,
height,
dateOfBirth,
gender,
@@ -253,132 +260,148 @@ export function AccountSection() {
setSaveMsg(null);
setSaveMsgType(null);
setFieldErrors({});
const savedProfile = {
email: email.trim(),
height,
dateOfBirth,
gender,
timezone,
fullName: fullName.trim(),
insurerName: insurerName.trim(),
insuranceNumber: insuranceNumber.trim(),
};
try {
const savedProfile = {
email: email.trim(),
displayName: displayName.trim(),
height,
dateOfBirth,
gender,
timezone,
fullName: fullName.trim(),
insurerName: insurerName.trim(),
insuranceNumber: insuranceNumber.trim(),
};
// The timezone is owned by a dedicated route (v1.4.25 W7) so the
// resolver cache can be invalidated without piping the flag
// through the bigger profile patch path. Run the two PUTs in
// parallel — they're independent.
const [profileRes, tzRes] = await Promise.all([
apiFetchRaw("/api/auth/profile", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: savedProfile.email || null,
...(emailChanged && emailPassword
? { currentPassword: emailPassword }
: {}),
heightCm: heightAdapter.toCanonicalCm(savedProfile.height),
dateOfBirth: savedProfile.dateOfBirth || null,
gender: savedProfile.gender || null,
fullName: savedProfile.fullName || null,
insurerName: savedProfile.insurerName || null,
insuranceNumber: savedProfile.insuranceNumber || null,
// The timezone is owned by a dedicated route (v1.4.25 W7) so the
// resolver cache can be invalidated without piping the flag
// through the bigger profile patch path. Run the two PUTs in
// parallel — they're independent.
const [profileRes, tzRes] = await Promise.all([
apiFetchRaw("/api/auth/profile", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: savedProfile.email || null,
// An emptied field clears the name; the greeting falls back to
// the username, as it does for an account that never set one.
displayName: savedProfile.displayName || null,
...(emailChanged && emailPassword
? { currentPassword: emailPassword }
: {}),
heightCm: heightAdapter.toCanonicalCm(savedProfile.height),
dateOfBirth: savedProfile.dateOfBirth || null,
gender: savedProfile.gender || null,
fullName: savedProfile.fullName || null,
insurerName: savedProfile.insurerName || null,
insuranceNumber: savedProfile.insuranceNumber || null,
}),
}),
}),
user && timezone && timezone !== user.timezone
? apiFetchRaw("/api/auth/me/timezone", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ timezone }),
})
: Promise.resolve({ ok: true } as Response),
]);
user && timezone && timezone !== user.timezone
? apiFetchRaw("/api/auth/me/timezone", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ timezone }),
})
: Promise.resolve({ ok: true } as Response),
]);
setEmailPassword("");
setEmailPassword("");
if (profileRes.ok && tzRes.ok) {
// Instant same-tab flip of every rendered timestamp (issue #490):
// mirror the accepted zone before the `/me` refetch confirms it —
// the same synchronous-update pattern the time/date-format selects
// use. `refetch()` → `fetchMe` re-writes the mirror authoritatively.
if (user && timezone && timezone !== user.timezone) {
storeTimezone(timezone);
}
const profileJson = (await profileRes.json().catch(() => null)) as {
data?: { rejectedFields?: RejectedProfileField[] };
} | null;
const rejected = profileJson?.data?.rejectedFields;
if (profileRes.ok && tzRes.ok) {
// Instant same-tab flip of every rendered timestamp (issue #490):
// mirror the accepted zone before the `/me` refetch confirms it —
// the same synchronous-update pattern the time/date-format selects
// use. `refetch()` → `fetchMe` re-writes the mirror authoritatively.
if (user && timezone && timezone !== user.timezone) {
storeTimezone(timezone);
}
const profileJson = (await profileRes.json().catch(() => null)) as {
data?: { rejectedFields?: RejectedProfileField[] };
} | null;
const rejected = profileJson?.data?.rejectedFields;
if (!afterFactor && needsSecondFactor(rejected, undefined)) {
if (await proveFactorThenSave()) return;
}
if (!afterFactor && needsSecondFactor(rejected, undefined)) {
if (await proveFactorThenSave()) return;
}
if (rejected && rejected.length > 0) {
// The write is field-independent: everything but the rejected
// field(s) landed. Name the field instead of implying full
// success, and reseed the form from what the server actually
// persisted — the client's optimistic value for the rejected
// field was never written, so it must not read as saved.
setSaveMsg({
key: "settings.profilePartiallySaved",
params: { field: describeRejectedProfileField(rejected, t) ?? "" },
});
setSaveMsgType("warning");
setFieldErrors(describeRejectedProfileFields(rejected, t));
await refetch();
setSeededUserId(null);
if (rejected && rejected.length > 0) {
// The write is field-independent: everything but the rejected
// field(s) landed. Name the field instead of implying full
// success, and reseed the form from what the server actually
// persisted — the client's optimistic value for the rejected
// field was never written, so it must not read as saved.
setSaveMsg({
key: "settings.profilePartiallySaved",
params: { field: describeRejectedProfileField(rejected, t) ?? "" },
});
setSaveMsgType("warning");
setFieldErrors(describeRejectedProfileFields(rejected, t));
await refetch();
setSeededUserId(null);
} else {
setSaveMsg({ key: "settings.profileSaved" });
setSaveMsgType("success");
setEmail(savedProfile.email);
setDisplayName(savedProfile.displayName);
setFullName(savedProfile.fullName);
setInsurerName(savedProfile.insurerName);
setInsuranceNumber(savedProfile.insuranceNumber);
setProfileSeed(savedProfile);
await refetch();
}
} else if (!tzRes.ok) {
// The dedicated tz endpoint owns the IANA validation error
// text. Surface its message verbatim so the user sees
// "Not a valid IANA timezone." instead of the generic save
// failure copy.
try {
const json = (await (tzRes as Response).json()) as { error?: string };
setSaveMsg(
json.error
? { text: json.error }
: { key: "settings.timezoneInvalid" },
);
} catch {
setSaveMsg({ key: "settings.timezoneInvalid" });
}
setSaveMsgType("error");
} else {
setSaveMsg({ key: "settings.profileSaved" });
setSaveMsgType("success");
setEmail(savedProfile.email);
setFullName(savedProfile.fullName);
setInsurerName(savedProfile.insurerName);
setInsuranceNumber(savedProfile.insuranceNumber);
setProfileSeed(savedProfile);
await refetch();
}
} else if (!tzRes.ok) {
// The dedicated tz endpoint owns the IANA validation error
// text. Surface its message verbatim so the user sees
// "Not a valid IANA timezone." instead of the generic save
// failure copy.
try {
const json = (await (tzRes as Response).json()) as { error?: string };
setSaveMsg(
json.error
? { text: json.error }
: { key: "settings.timezoneInvalid" },
);
} catch {
setSaveMsg({ key: "settings.timezoneInvalid" });
}
setSaveMsgType("error");
} else {
// The server's top-level `error` string is intentionally generic
// (never a raw validator message); resolve the specific, localized
// reason through `meta.errorCode` instead of echoing it verbatim.
const json = (await profileRes.json().catch(() => null)) as {
meta?: { errorCode?: string };
details?: { issues?: RejectedProfileField[] };
} | null;
const errorCode = json?.meta?.errorCode;
if (!afterFactor && needsSecondFactor(undefined, errorCode)) {
if (await proveFactorThenSave()) return;
}
const field = describeRejectedProfileField(json?.details?.issues, t);
setFieldErrors(describeRejectedProfileFields(json?.details?.issues, t));
if (errorCode === "profile.update.nothingSaved" && field) {
setSaveMsg({ key: "settings.profileNothingSaved", params: { field } });
} else if (translateCodeOrNull(t, "apiErrors", errorCode) !== null) {
// Only a code with a sentence of its own: every refusal carries a
// code now, and the generic `http.*` family has no translation.
setSaveMsg({ key: `apiErrors.${errorCode}` });
} else {
setSaveMsg({ key: "settings.savingError" });
// The server's top-level `error` string is intentionally generic
// (never a raw validator message); resolve the specific, localized
// reason through `meta.errorCode` instead of echoing it verbatim.
const json = (await profileRes.json().catch(() => null)) as {
meta?: { errorCode?: string };
details?: { issues?: RejectedProfileField[] };
} | null;
const errorCode = json?.meta?.errorCode;
if (!afterFactor && needsSecondFactor(undefined, errorCode)) {
if (await proveFactorThenSave()) return;
}
const field = describeRejectedProfileField(json?.details?.issues, t);
setFieldErrors(describeRejectedProfileFields(json?.details?.issues, t));
if (errorCode === "profile.update.nothingSaved" && field) {
setSaveMsg({
key: "settings.profileNothingSaved",
params: { field },
});
} else if (translateCodeOrNull(t, "apiErrors", errorCode) !== null) {
// Only a code with a sentence of its own: every refusal carries a
// code now, and the generic `http.*` family has no translation.
setSaveMsg({ key: `apiErrors.${errorCode}` });
} else {
setSaveMsg({ key: "settings.savingError" });
}
setSaveMsgType("error");
}
} catch {
// The request never reached the server (offline, a dropped
// connection): a failed save, said as one, never a card left saving.
setSaveMsg({ key: "settings.savingError" });
setSaveMsgType("error");
} finally {
setSaving(false);
}
setSaving(false);
}
if (!mounted || isLoading) {
@@ -438,45 +461,81 @@ export function AccountSection() {
autoComplete="username"
/>
</div>
{/* The name the greeting and the reports use. The setup flow
asks for it once; this is where it changes afterwards. */}
<div className="space-y-2">
<Label htmlFor="email">{t("auth.email")}</Label>
<Label htmlFor="display-name">
{t("onboarding.baseline.displayNameLabel")}
</Label>
<Input
id="email"
type="email"
value={email}
id="display-name"
value={displayName}
onChange={(e) => {
setEmail(e.target.value);
clearFieldError("email");
setDisplayName(e.target.value);
clearFieldError("displayName");
}}
placeholder={t("auth.emailPlaceholder")}
maxLength={320}
autoComplete="email"
placeholder={t("onboarding.baseline.displayNamePlaceholder")}
maxLength={80}
autoComplete="nickname"
enterKeyHint="next"
aria-invalid={fieldErrors.email ? true : undefined}
aria-describedby={fieldErrors.email ? "email-error" : undefined}
aria-invalid={fieldErrors.displayName ? true : undefined}
aria-describedby={
fieldErrors.displayName
? "display-name-hint display-name-error"
: "display-name-hint"
}
/>
<p
id="display-name-hint"
className="text-muted-foreground text-xs"
>
{t("onboarding.baseline.displayNameHint")}
</p>
<FieldError
id="display-name-error"
message={fieldErrors.displayName}
/>
<FieldError id="email-error" message={fieldErrors.email} />
{emailChanged && !emailNeedsFactor && (
<div className="space-y-2 pt-1">
<Label htmlFor="email-current-password">
{t("settings.emailChangePassword")}
</Label>
<Input
id="email-current-password"
type="password"
value={emailPassword}
onChange={(e) => {
setEmailPassword(e.target.value);
clearFieldError("email");
}}
autoComplete="current-password"
maxLength={512}
/>
</div>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="email">{t("auth.email")}</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => {
setEmail(e.target.value);
clearFieldError("email");
}}
placeholder={t("auth.emailPlaceholder")}
maxLength={320}
autoComplete="email"
enterKeyHint="next"
aria-invalid={fieldErrors.email ? true : undefined}
aria-describedby={fieldErrors.email ? "email-error" : undefined}
/>
<FieldError id="email-error" message={fieldErrors.email} />
{emailChanged && !emailNeedsFactor && (
<div className="space-y-2 pt-1">
<Label htmlFor="email-current-password">
{t("settings.emailChangePassword")}
</Label>
<Input
id="email-current-password"
type="password"
value={emailPassword}
onChange={(e) => {
setEmailPassword(e.target.value);
clearFieldError("email");
}}
autoComplete="current-password"
maxLength={512}
/>
</div>
)}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="gender">{t("settings.gender")}</Label>
@@ -0,0 +1,121 @@
/**
* A saved API key can be removed from every provider form that holds one.
*
* The forms showed "saved" beside the key field and nothing to take the key
* back: an empty field on Save keeps the stored key by design, so a key once
* entered stayed. The remove control renders on each of the four personal-key
* forms exactly when that form's key is stored, and the body it sends clears
* that one key and nothing beside it. The route's side (the column is cleared,
* the other keys and the provider choice survive) is pinned against Postgres
* in `tests/integration/ai-provider-key-removal.test.ts`.
*/
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ComponentType } from "react";
import { I18nProvider } from "@/lib/i18n/context";
import { aiProviderPatchSchema } from "@/lib/validations/ai-provider";
import { AnthropicProviderForm } from "../anthropic-provider-form";
import { CompatProviderForm } from "../compat-provider-form";
import { LocalProviderForm } from "../local-provider-form";
import { OpenAIProviderForm } from "../openai-provider-form";
import {
removeSavedKeyBody,
type SavedKeyField,
} from "../remove-saved-key-button";
import type { UserAIProvider } from "../shared";
function provider(overrides: Partial<UserAIProvider> = {}): UserAIProvider {
return {
provider: null,
model: null,
baseUrl: null,
hasAnthropicKey: false,
anthropicKeyPreview: null,
hasLocalKey: false,
hasOpenaiKey: false,
openaiKeyPreview: null,
compatBaseUrl: null,
compatModel: null,
hasCompatKey: false,
responseTimeoutSeconds: null,
...overrides,
};
}
type Form = ComponentType<{ userProvider: UserAIProvider | null | undefined }>;
const FORMS: {
name: string;
Form: Form;
field: SavedKeyField;
saved: Partial<UserAIProvider>;
}[] = [
{
name: "OpenAI",
Form: OpenAIProviderForm,
field: "openaiKey",
saved: { hasOpenaiKey: true, openaiKeyPreview: "...abcd" },
},
{
name: "Anthropic",
Form: AnthropicProviderForm,
field: "anthropicKey",
saved: { hasAnthropicKey: true, anthropicKeyPreview: "...abcd" },
},
{
name: "Local",
Form: LocalProviderForm,
field: "localKey",
saved: { hasLocalKey: true },
},
{
name: "OpenAI-compatible gateway",
Form: CompatProviderForm,
field: "compatKey",
saved: { hasCompatKey: true },
},
];
function render(Form: Form, userProvider: UserAIProvider) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<Form userProvider={userProvider} />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("remove a saved API key", () => {
for (const { name, Form, field, saved } of FORMS) {
describe(name, () => {
const slot = `data-slot="settings-ai-remove-key-${field}"`;
it("offers the remove control while a key is stored", () => {
const html = render(Form, provider(saved));
expect(html).toContain(slot);
expect(html).toContain("Remove API key");
});
it("offers nothing to remove when no key is stored", () => {
expect(render(Form, provider())).not.toContain(slot);
});
it("sends a body that clears that key and nothing else", () => {
const body = removeSavedKeyBody(field);
expect(body).toEqual({ [field]: null });
// The route reads the body through this schema; a field it did not
// know would be dropped and the request answered "No valid fields".
const parsed = aiProviderPatchSchema.safeParse(body);
expect(parsed.success).toBe(true);
expect(parsed.data).toEqual({ [field]: null });
});
});
}
});
@@ -18,6 +18,8 @@ import { apiPatch } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
import { RemoveSavedKeyButton } from "./remove-saved-key-button";
import {
ANTHROPIC_MODEL_PRESETS,
CUSTOM_MODEL_SENTINEL,
@@ -169,6 +171,9 @@ export function AnthropicProviderForm({
)}
<SettingsCardActions>
{userProvider?.hasAnthropicKey && (
<RemoveSavedKeyButton field="anthropicKey" />
)}
<Button
type="submit"
size="sm"
@@ -210,6 +210,7 @@ export function CodexProviderForm({
) : isConnected ? (
<ConfirmButton
slot="settings-codex-disconnect"
destructive
variant="outline"
size="sm"
className="text-destructive min-h-11 shrink-0 sm:min-h-9"
@@ -20,6 +20,8 @@ import { apiPatch } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
import { RemoveSavedKeyButton } from "./remove-saved-key-button";
import { ReasoningEffortField } from "./reasoning-effort-field";
import {
uiToLegacyProviderEnum,
@@ -152,6 +154,9 @@ export function CompatProviderForm({
)}
<SettingsCardActions>
{userProvider?.hasCompatKey && (
<RemoveSavedKeyButton field="compatKey" />
)}
<Button
type="submit"
size="sm"
@@ -18,6 +18,8 @@ import { apiPatch } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
import { RemoveSavedKeyButton } from "./remove-saved-key-button";
import { ReasoningEffortField } from "./reasoning-effort-field";
import {
CUSTOM_MODEL_SENTINEL,
@@ -188,6 +190,7 @@ export function LocalProviderForm({
)}
<SettingsCardActions>
{userProvider?.hasLocalKey && <RemoveSavedKeyButton field="localKey" />}
<Button
type="submit"
size="sm"
@@ -21,6 +21,8 @@ import { apiPatch } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
import { RemoveSavedKeyButton } from "./remove-saved-key-button";
import {
CUSTOM_MODEL_SENTINEL,
OPENAI_MODEL_PRESETS,
@@ -207,6 +209,9 @@ export function OpenAIProviderForm({
)}
<SettingsCardActions>
{userProvider?.hasOpenaiKey && (
<RemoveSavedKeyButton field="openaiKey" />
)}
<Button
type="submit"
size="sm"
@@ -0,0 +1,68 @@
"use client";
/**
* Removes the stored API key of one personal AI provider.
*
* Every provider form showed a saved key and offered no way to take it back:
* saving with the key field empty keeps the stored key, by design, so a key
* once entered stayed until the account was deleted. `PATCH
* /api/user/ai-provider` has always cleared a key sent as `null`; this is the
* control for it, shared by the four forms that hold a personal key so each
* asks the same question and reports the same way.
*/
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Trash2 } from "lucide-react";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { apiPatch } from "@/lib/api/api-fetch";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
/** The PATCH field that holds each provider's personal key. */
export type SavedKeyField =
"openaiKey" | "anthropicKey" | "localKey" | "compatKey";
/**
* The body that clears exactly one key and touches nothing else: not the
* provider choice, the model or the base URL, which stay as they were.
*/
export function removeSavedKeyBody(
field: SavedKeyField,
): Partial<Record<SavedKeyField, null>> {
return { [field]: null };
}
export function RemoveSavedKeyButton({ field }: { field: SavedKeyField }) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const remove = useMutation({
mutationFn: () =>
apiPatch("/api/user/ai-provider", removeSavedKeyBody(field)),
onSuccess: () => {
void invalidateKeys(queryClient, aiInputDependentKeys);
toastWrittenOutcome("success", t("settings.ai.removeKey.done"));
},
onError: (failure) =>
showErrorToast(t("settings.ai.removeKey.failed"), { cause: failure }),
});
return (
<ConfirmButton
slot={`settings-ai-remove-key-${field}`}
destructive
variant="destructive"
size="sm"
className="min-h-11 sm:min-h-9"
icon={<Trash2 className="size-4" aria-hidden />}
label={t("settings.ai.removeKey.label")}
title={t("settings.ai.removeKey.title")}
body={t("settings.ai.removeKey.body")}
confirmLabel={t("settings.ai.removeKey.confirm")}
pending={remove.isPending}
onConfirm={() => remove.mutate()}
/>
);
}
+3 -3
View File
@@ -619,7 +619,7 @@ function ApiTokensCard() {
variant="destructive"
onClick={() => handleRevoke(tok.id)}
>
{t("settings.tokenRevoked")}
{t("settings.tokenRevokeAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -698,7 +698,7 @@ function ApiTokensCard() {
className="min-h-11 w-full"
>
<Trash2 className="h-3.5 w-3.5" />
{t("settings.tokenRevoke")}
{t("settings.tokenRevokeAction")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
@@ -718,7 +718,7 @@ function ApiTokensCard() {
variant="destructive"
onClick={() => handleRevoke(tok.id)}
>
{t("settings.tokenRevoked")}
{t("settings.tokenRevokeAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -228,6 +228,7 @@ function FactRow({
accessible name and the confirmation carry the words. */}
<ConfirmButton
slot="settings-coach-memory-forget"
destructive
variant="ghost"
size="icon"
className="size-11 sm:size-9"
+40 -34
View File
@@ -145,45 +145,51 @@ export function CoachNudgeCard({
// v1.32.22 — echo the base token so a concurrent notification-prefs
// write (the iOS client, another web card) 409s instead of reverting a
// sibling category through the server-side deep-merge.
const res = await apiFetchRaw("/api/auth/me/notification-prefs", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
withBaseToken(
{ coach: partial },
readUpdatedAtToken(queryClient, queryKeys.authNotificationPrefs()),
try {
// A request that never reaches the server (offline, a dropped
// connection) is a failed save like any other, not a stuck one: it
// falls through to the error branch, and `finally` lowers the flag.
const res = await apiFetchRaw("/api/auth/me/notification-prefs", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
withBaseToken(
{ coach: partial },
readUpdatedAtToken(queryClient, queryKeys.authNotificationPrefs()),
),
),
),
});
}).catch(() => null);
if (res.ok) {
if (toast) {
setMsg(toast);
setMsgType("success");
if (res?.ok) {
if (toast) {
setMsg(toast);
setMsgType("success");
scheduleClear();
}
await queryClient.invalidateQueries({
queryKey: queryKeys.authNotificationPrefs(),
});
setOptimistic({});
} else if (res?.status === 409) {
// The prefs advanced since they were loaded. Revert the optimistic knob
// and refetch (the active query reseeds a fresh token), then nudge — the
// change did not persist, so ask the user to re-apply it.
setOptimistic({});
await queryClient.invalidateQueries({
queryKey: queryKeys.authNotificationPrefs(),
});
setMsg(t("common.conflictReloaded"));
setMsgType("error");
scheduleClear();
} else {
setOptimistic({});
setMsg(t("notifications.coachNudge.saveError"));
setMsgType("error");
scheduleClear();
}
await queryClient.invalidateQueries({
queryKey: queryKeys.authNotificationPrefs(),
});
setOptimistic({});
} else if (res.status === 409) {
// The prefs advanced since they were loaded. Revert the optimistic knob
// and refetch (the active query reseeds a fresh token), then nudge — the
// change did not persist, so ask the user to re-apply it.
setOptimistic({});
await queryClient.invalidateQueries({
queryKey: queryKeys.authNotificationPrefs(),
});
setMsg(t("common.conflictReloaded"));
setMsgType("error");
scheduleClear();
} else {
setOptimistic({});
setMsg(t("notifications.coachNudge.saveError"));
setMsgType("error");
scheduleClear();
} finally {
setSaving(false);
}
setSaving(false);
}
async function handleToggle(next: boolean) {
@@ -147,6 +147,7 @@ export function CoachRemindersSection({
)}
<ConfirmButton
slot="coach-reminder-delete"
destructive
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground min-h-9"
@@ -450,6 +450,7 @@ export function EnvironmentSection() {
</span>
<ConfirmButton
slot="travel-remove"
destructive
size="icon"
variant="ghost"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
@@ -1,6 +1,7 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { Syringe } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
@@ -14,6 +15,7 @@ import {
type InjectionSiteKey,
} from "@/lib/medications/injection-sites";
import { apiFetchRaw, apiPatch } from "@/lib/api/api-fetch";
import { showErrorToast } from "@/lib/errors/error-toast";
/**
* v1.8.5 — Settings → global injection-site exclusion.
@@ -56,7 +58,10 @@ export function InjectionSitesCard({
void queryClient.invalidateQueries({
queryKey: queryKeys.injectionSitePrefs(),
});
toastWrittenOutcome("success", t("settings.saved"));
},
onError: (failure) =>
showErrorToast(t("settings.savingError"), { cause: failure }),
});
function toggle(site: InjectionSiteKey, checked: boolean) {
@@ -15,6 +15,7 @@
import { useState, type ReactNode } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import {
Activity,
ArrowRight,
@@ -34,6 +35,7 @@ import {
import { Button } from "@/components/ui/button";
import { apiPost } from "@/lib/api/api-fetch";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
import { loadDocument } from "@/lib/navigation/load-document";
@@ -311,7 +313,20 @@ export function OAuthIntegrationCardView({
mutationFn: async () => {
await apiPost(`/api/${provider}/disconnect`);
},
onSuccess: invalidateStatus,
onSuccess: () => {
invalidateStatus();
toastWrittenOutcome(
"success",
t("settings.integrationDisconnected", { provider: providerName }),
);
},
// The confirmation dialog has closed by now; without this a refused
// disconnect left the card connected and said nothing.
onError: (failure) =>
showErrorToast(
t("settings.integrationDisconnectFailed", { provider: providerName }),
{ cause: failure },
),
});
const resume = useMutation({
+42 -34
View File
@@ -124,44 +124,52 @@ export function LowStockCard({
// v1.32.22 — echo the base token so a concurrent notification-prefs
// write (the iOS client, another web card) 409s instead of reverting a
// sibling category through the server-side deep-merge.
const res = await apiFetchRaw("/api/auth/me/notification-prefs", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
withBaseToken(
body,
readUpdatedAtToken(queryClient, queryKeys.authNotificationPrefs()),
let saved = false;
try {
// A request that never reaches the server (offline, a dropped
// connection) is a failed save like any other, not a stuck one: it
// falls through to the error branch, and `finally` lowers the flag.
const res = await apiFetchRaw("/api/auth/me/notification-prefs", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
withBaseToken(
body,
readUpdatedAtToken(queryClient, queryKeys.authNotificationPrefs()),
),
),
),
});
}).catch(() => null);
if (res.ok) {
setMsg(successMsg);
setMsgType("success");
await Promise.all([
queryClient.invalidateQueries({
saved = res?.ok === true;
if (res?.ok) {
setMsg(successMsg);
setMsgType("success");
await Promise.all([
queryClient.invalidateQueries({
queryKey: queryKeys.authNotificationPrefs(),
}),
queryClient.invalidateQueries({
queryKey: queryKeys.settingsReminderThresholds(),
}),
]);
} else if (res?.status === 409) {
// The prefs advanced since they were loaded. Refetch (the active query
// reseeds a fresh token) and nudge — the caller reverts its optimistic
// value, so the card snaps back to server truth for a re-apply.
await queryClient.invalidateQueries({
queryKey: queryKeys.authNotificationPrefs(),
}),
queryClient.invalidateQueries({
queryKey: queryKeys.settingsReminderThresholds(),
}),
]);
} else if (res.status === 409) {
// The prefs advanced since they were loaded. Refetch (the active query
// reseeds a fresh token) and nudge — the caller reverts its optimistic
// value, so the card snaps back to server truth for a re-apply.
await queryClient.invalidateQueries({
queryKey: queryKeys.authNotificationPrefs(),
});
setMsg(t("common.conflictReloaded"));
setMsgType("error");
} else {
setMsg(t("notifications.lowStock.saveError"));
setMsgType("error");
});
setMsg(t("common.conflictReloaded"));
setMsgType("error");
} else {
setMsg(t("notifications.lowStock.saveError"));
setMsgType("error");
}
scheduleClear();
} finally {
setSaving(false);
}
scheduleClear();
setSaving(false);
return res.ok;
return saved;
}
async function persist(next: number | null, successMsg: string) {
+2 -2
View File
@@ -516,7 +516,7 @@ function McpTokensCard() {
className="min-h-11 w-full"
>
<Trash2 className="h-3.5 w-3.5" />
{t("settings.tokenRevoke")}
{t("settings.tokenRevokeAction")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
@@ -536,7 +536,7 @@ function McpTokensCard() {
variant="destructive"
onClick={() => handleRevoke(tok.id)}
>
{t("settings.tokenRevoked")}
{t("settings.tokenRevokeAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
+76 -57
View File
@@ -99,31 +99,44 @@ export function MoodReminderCard({
clearTimerRef.current = null;
}
const res = await apiFetchRaw("/api/user/profile", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ moodReminderEnabled: next }),
});
try {
// A request that never reaches the server (offline, a dropped
// connection) is a failed save like any other, not a stuck one: it
// falls through to the error branch, and `finally` lowers the flag.
const res = await apiFetchRaw("/api/user/profile", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ moodReminderEnabled: next }),
}).catch(() => null);
if (res.ok) {
setMsg(
next
? t("notifications.moodReminder.enabledToast")
: t("notifications.moodReminder.disabledToast"),
);
setMsgType("success");
await queryClient.invalidateQueries({
queryKey: queryKeys.userProfile(),
});
setOptimistic(null);
scheduleClear();
} else {
setOptimistic(null);
setMsg(t("notifications.moodReminder.saveError"));
setMsgType("error");
scheduleClear();
if (res?.ok) {
setMsg(
next
? t("notifications.moodReminder.enabledToast")
: t("notifications.moodReminder.disabledToast"),
);
setMsgType("success");
await Promise.all([
queryClient.invalidateQueries({
queryKey: queryKeys.userProfile(),
}),
// The /notifications matrix shows the reminder's channel switches
// from the server's `eventDefaults`, which follow this card.
queryClient.invalidateQueries({
queryKey: queryKeys.notificationsPreferences(),
}),
]);
setOptimistic(null);
scheduleClear();
} else {
setOptimistic(null);
setMsg(t("notifications.moodReminder.saveError"));
setMsgType("error");
scheduleClear();
}
} finally {
setSaving(false);
}
setSaving(false);
}
async function handleHourChange(next: number) {
@@ -139,43 +152,49 @@ export function MoodReminderCard({
// v1.32.22 — echo the base token so a concurrent notification-prefs
// write (the iOS client's `clientManaged` flag, another web card) 409s
// instead of silently reverting a sibling category through the deep-merge.
const res = await apiFetchRaw("/api/auth/me/notification-prefs", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
withBaseToken(
{ mood: { reminderHour: next } },
readUpdatedAtToken(queryClient, queryKeys.authNotificationPrefs()),
try {
// A request that never reaches the server (offline, a dropped
// connection) is a failed save like any other, not a stuck one: it
// falls through to the error branch, and `finally` lowers the flag.
const res = await apiFetchRaw("/api/auth/me/notification-prefs", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
withBaseToken(
{ mood: { reminderHour: next } },
readUpdatedAtToken(queryClient, queryKeys.authNotificationPrefs()),
),
),
),
});
}).catch(() => null);
if (res.ok) {
setMsg(t("notifications.moodReminder.hourSavedToast"));
setMsgType("success");
await queryClient.invalidateQueries({
queryKey: queryKeys.authNotificationPrefs(),
});
setOptimisticHour(null);
scheduleClear();
} else if (res.status === 409) {
// The prefs advanced since they were loaded. Revert the optimistic hour
// and refetch (the active query reseeds a fresh token), then nudge — the
// change did not persist, so ask the user to re-apply it.
setOptimisticHour(null);
await queryClient.invalidateQueries({
queryKey: queryKeys.authNotificationPrefs(),
});
setMsg(t("common.conflictReloaded"));
setMsgType("error");
scheduleClear();
} else {
setOptimisticHour(null);
setMsg(t("notifications.moodReminder.saveError"));
setMsgType("error");
scheduleClear();
if (res?.ok) {
setMsg(t("notifications.moodReminder.hourSavedToast"));
setMsgType("success");
await queryClient.invalidateQueries({
queryKey: queryKeys.authNotificationPrefs(),
});
setOptimisticHour(null);
scheduleClear();
} else if (res?.status === 409) {
// The prefs advanced since they were loaded. Revert the optimistic hour
// and refetch (the active query reseeds a fresh token), then nudge — the
// change did not persist, so ask the user to re-apply it.
setOptimisticHour(null);
await queryClient.invalidateQueries({
queryKey: queryKeys.authNotificationPrefs(),
});
setMsg(t("common.conflictReloaded"));
setMsgType("error");
scheduleClear();
} else {
setOptimisticHour(null);
setMsg(t("notifications.moodReminder.saveError"));
setMsgType("error");
scheduleClear();
}
} finally {
setSaving(false);
}
setSaving(false);
}
return (
@@ -69,37 +69,43 @@ export function NotificationContentCard({
setMsg(null);
setMsgType(null);
const res = await apiFetchRaw("/api/auth/me/notification-prefs", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
withBaseToken(
{ content: { detail: next ? "detailed" : "generic" } },
readUpdatedAtToken(queryClient, queryKeys.authNotificationPrefs()),
try {
// A request that never reaches the server (offline, a dropped
// connection) is a failed save like any other, not a stuck one: it
// falls through to the error branch, and `finally` lowers the flag.
const res = await apiFetchRaw("/api/auth/me/notification-prefs", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
withBaseToken(
{ content: { detail: next ? "detailed" : "generic" } },
readUpdatedAtToken(queryClient, queryKeys.authNotificationPrefs()),
),
),
),
});
}).catch(() => null);
await queryClient.invalidateQueries({
queryKey: queryKeys.authNotificationPrefs(),
});
setOptimistic(null);
if (res.ok) {
setMsg(
next
? t("notifications.content.detailedToast")
: t("notifications.content.genericToast"),
);
setMsgType("success");
} else if (res.status === 409) {
setMsg(t("common.conflictReloaded"));
setMsgType("error");
} else {
setMsg(t("notifications.content.saveError"));
setMsgType("error");
await queryClient.invalidateQueries({
queryKey: queryKeys.authNotificationPrefs(),
});
setOptimistic(null);
if (res?.ok) {
setMsg(
next
? t("notifications.content.detailedToast")
: t("notifications.content.genericToast"),
);
setMsgType("success");
} else if (res?.status === 409) {
setMsg(t("common.conflictReloaded"));
setMsgType("error");
} else {
setMsg(t("notifications.content.saveError"));
setMsgType("error");
}
scheduleClear();
} finally {
setSaving(false);
}
scheduleClear();
setSaving(false);
}
return (
@@ -2,6 +2,7 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import {
AlertTriangle,
Check,
@@ -142,6 +143,7 @@ export function PasskeyListSection({
setReauthSecret("");
setReauthMethod("password");
queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() });
toastWrittenOutcome("success", t("settings.passkeyAddedToast"));
},
onError: (err) => {
if (err instanceof ApiError) {
@@ -178,6 +180,7 @@ export function PasskeyListSection({
onSuccess: () => {
setMsg(null);
queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() });
toastWrittenOutcome("success", t("settings.passkeyRemovedToast"));
},
// A refusal the dialog could not take up keeps its own sentence. The
// server's prose says "second-factor", which is wrong for the passkey-only
@@ -2,6 +2,7 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import {
AlertTriangle,
Check,
@@ -133,6 +134,7 @@ export function SecurityKeysCard({
setReauthOpen(false);
setReauthError(null);
queryClient.invalidateQueries({ queryKey: queryKeys.mfaStatus() });
toastWrittenOutcome("success", t("settings.security.keys.addedToast"));
},
onError: (err, proof) => {
if (isReproofRequired(err) && nothingToReprove(err)) {
@@ -192,6 +194,7 @@ export function SecurityKeysCard({
onSuccess: () => {
setError(null);
queryClient.invalidateQueries({ queryKey: queryKeys.mfaStatus() });
toastWrittenOutcome("success", t("settings.security.keys.removedToast"));
},
onError: (err) => {
if (err instanceof ReproofCancelledError) return;
@@ -16,7 +16,6 @@ import { ChevronDown, Loader2, MonitorSmartphone } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { EmptyState } from "@/components/ui/empty-state";
@@ -24,7 +23,12 @@ import { QueryErrorRow } from "@/components/ui/query-error-row";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet, apiDelete } from "@/lib/api/api-fetch";
import { useRevokeGrant } from "@/lib/queries/use-account-grants";
import {
useAccountGrants,
useRevokeGrant,
} from "@/lib/queries/use-account-grants";
import { revokeBody } from "@/components/settings/access/grants-given-card";
import { accountLabel } from "@/lib/sharing/account-access-view";
import { cn } from "@/lib/utils";
/** An accepted grant sign-out-everywhere left standing (`grantsKept`). */
@@ -75,6 +79,10 @@ export function SecuritySessionsCard({
// a one-click end instead. Held only until the card unmounts.
const [keptGrants, setKeptGrants] = useState<KeptGrant[]>([]);
const revokeGrant = useRevokeGrant();
// Ending one of them is the same act as on the access card, so it asks the
// same question with the same disclosure — and that names the audit window,
// which rides the grant list. Read only once there is a row to end.
const { data: grantList } = useAccountGrants(keptGrants.length > 0);
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: queryKeys.sessions(),
@@ -216,6 +224,7 @@ export function SecuritySessionsCard({
{!s.isCurrent && (
<ConfirmButton
slot="revoke-session"
destructive
size="sm"
className="min-h-9 shrink-0"
label={t("settings.security.revokeSession")}
@@ -237,6 +246,7 @@ export function SecuritySessionsCard({
<SettingsCardActions>
<ConfirmButton
slot="sign-out-everywhere"
destructive
className="min-h-11 sm:min-h-9"
label={t("settings.security.signOutEverywhere")}
title={t("settings.security.signOutEverywhereConfirmTitle")}
@@ -293,32 +303,44 @@ export function SecuritySessionsCard({
>
<div className="min-w-0">
<p className="truncate text-sm">
{grant.account.displayName ?? grant.account.username}
{accountLabel(grant.account)}
</p>
<p className="text-muted-foreground text-xs">
{t(ACCESS_LABEL_KEY[grant.access])}
</p>
</div>
<Button
variant="outline"
size="sm"
className="min-h-11 shrink-0 sm:min-h-9"
disabled={revokeGrant.isPending}
onClick={() =>
revokeGrant.mutate(grant.id, {
onSuccess: () =>
setKeptGrants((current) =>
current.filter((g) => g.id !== grant.id),
),
onError: () =>
setStatus(
t("recordSharing.actionError.revokeFailed"),
),
})
}
>
{t("recordSharing.given.revoke")}
</Button>
{grantList ? (
<ConfirmButton
slot="kept-grant-revoke"
destructive
size="sm"
className="min-h-11 shrink-0 sm:min-h-9"
label={t("recordSharing.given.revoke")}
title={t("recordSharing.given.revokeTitle", {
name: accountLabel(grant.account),
})}
body={revokeBody({
t,
access: grant.access,
name: accountLabel(grant.account),
retentionDays: grantList.retentionDays,
})}
confirmLabel={t("recordSharing.given.revokeConfirm")}
pending={revokeGrant.isPending}
onConfirm={() =>
revokeGrant.mutate(grant.id, {
onSuccess: () =>
setKeptGrants((current) =>
current.filter((g) => g.id !== grant.id),
),
onError: () =>
setStatus(
t("recordSharing.actionError.revokeFailed"),
),
})
}
/>
) : null}
</li>
))}
</ul>
@@ -23,6 +23,7 @@
*/
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { FileText, KeyRound, Link2, List, Trash2 } from "lucide-react";
import {
@@ -53,6 +54,7 @@ import { formatDate, formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
import { showErrorToast } from "@/lib/errors/error-toast";
export function SharingSection() {
// v1.18.7 — the visible heading + subtitle come from the shared settings
@@ -127,7 +129,12 @@ function ShareLinksCard() {
mutationFn: (id: string) => apiDelete(`/api/share-links/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.shareLinks() });
toastWrittenOutcome("success", t("settings.sharing.revokedToast"));
},
// A refused revoke leaves the link working; that has to be said, since
// the dialog that asked has already closed.
onError: (failure) =>
showErrorToast(t("settings.sharing.revokeFailed"), { cause: failure }),
});
const activeLinks = useMemo(
+22 -15
View File
@@ -116,23 +116,30 @@ export function TelegramCard({
if (botToken.trim()) body.botToken = botToken.trim();
if (chatId !== (settings?.chatId ?? "")) body.chatId = chatId;
const res = await apiFetchRaw("/api/settings/telegram", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
try {
// A request that never reaches the server (offline, a dropped
// connection) is a failed save like any other, not a stuck one.
const res = await apiFetchRaw("/api/settings/telegram", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}).catch(() => null);
if (res.ok) {
setMsg(t("settings.saved"));
setMsgType("success");
setBotToken("");
queryClient.invalidateQueries({ queryKey: queryKeys.telegram() });
} else {
const json = await res.json();
setMsg(json.error || t("settings.savingError"));
setMsgType("error");
if (res?.ok) {
setMsg(t("settings.saved"));
setMsgType("success");
setBotToken("");
queryClient.invalidateQueries({ queryKey: queryKeys.telegram() });
} else {
const json = (await res?.json().catch(() => null)) as {
error?: string;
} | null;
setMsg(json?.error || t("settings.savingError"));
setMsgType("error");
}
} finally {
setSaving(false);
}
setSaving(false);
}
return (
@@ -137,6 +137,7 @@ export function TrustedDevicesCard({
</div>
<ConfirmButton
slot="revoke-trusted-device"
destructive
size="sm"
className="min-h-9 shrink-0"
label={t("settings.security.trustedDevices.revoke")}
@@ -157,6 +158,7 @@ export function TrustedDevicesCard({
<SettingsCardActions>
<ConfirmButton
slot="revoke-all-trusted-devices"
destructive
className="min-h-11 sm:min-h-9"
label={t("settings.security.trustedDevices.revokeAll")}
title={t(
@@ -285,6 +285,7 @@ export function LifeEventForm({
className="mr-auto min-h-11 sm:min-h-9"
icon={<Trash2 className="size-4" aria-hidden="true" />}
slot="life-event-delete"
destructive
/>
)}
{onCancel && (
@@ -160,6 +160,59 @@ describe("ConfirmBody — the action is the only path to the callback", () => {
});
});
describe("the destructive style reaches the action from both shapes", () => {
// `ConfirmButton` accepted `destructive` through its props type and dropped
// it on the way to the dialog, so every delete confirmation built on it
// offered a neutral primary where `ConfirmDialog` offered a red one.
function actionVariant(element: ReactElement): unknown {
const body = walk(element).find((el) => nameOf(el) === "ConfirmBody")!;
const rendered = walk(
(body.type as (p: unknown) => ReactElement)(body.props),
);
const action = rendered.find((el) => nameOf(el) === "AlertDialogAction")!;
return (action.props as Record<string, unknown>).variant;
}
it("ConfirmButton forwards destructive to the dialog's action", () => {
expect(
actionVariant(
ConfirmButton({
label: "Delete",
onConfirm: vi.fn(),
destructive: true,
...COPY,
}) as ReactElement,
),
).toBe("destructive");
});
it("ConfirmButton stays neutral when the act does not destroy", () => {
expect(
actionVariant(
ConfirmButton({
label: "Reset",
onConfirm: vi.fn(),
...COPY,
}) as ReactElement,
),
).toBe("default");
});
it("ConfirmDialog forwards it the same way", () => {
expect(
actionVariant(
ConfirmDialog({
open: true,
onOpenChange: vi.fn(),
onConfirm: vi.fn(),
destructive: true,
...COPY,
}) as ReactElement,
),
).toBe("destructive");
});
});
describe("<ConfirmDialog> — the trigger-less half", () => {
const onConfirm = vi.fn();
const tree = walk(
+2
View File
@@ -114,6 +114,7 @@ export function ConfirmButton({
ariaLabel,
slot,
extra,
destructive = false,
}: ConfirmCopy & {
/** The control's own label, before the dialog opens. */
label: string;
@@ -156,6 +157,7 @@ export function ConfirmButton({
pending={pending}
slot={slot}
extra={extra}
destructive={destructive}
/>
</AlertDialog>
);
+6
View File
@@ -157,6 +157,12 @@ export interface AuthUser {
* Defaults to `false` when the field is absent (a stale /me payload).
*/
disableCoach: boolean;
/**
* The name the app addresses the account by (dashboard greeting, reports),
* null when none was given. Edited on the profile card. Optional in the
* type so a stale /me payload coerces to "no display name".
*/
displayName?: string | null;
/**
* v1.7.0 — optional patient-identity fields used by the health-record
* export (PDF cover + FHIR Patient). All optional; `insuranceNumber`
@@ -0,0 +1,112 @@
/**
* One answer to "what does an event do on a channel with no stored row".
*
* The dispatcher and the `/notifications` matrix used to answer it apart: the
* dispatcher derived the mood reminder from the reminder card, the matrix read
* the constant table, and an account receiving the reminder saw it switched
* off. Both now read `event-defaults.ts`; this file pins what it answers and
* that nothing on the client keeps its own copy of the table.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: vi.fn() } },
}));
import { prisma } from "@/lib/db";
import { walkSourceFiles } from "@/__tests__/helpers/source-files";
import { EVENT_DEFAULT_ENABLED, EVENT_TYPES } from "@/lib/notifications/types";
import {
eventDefaultFor,
eventDefaultsFor,
resolveEventDefault,
resolveEventDefaults,
} from "../event-defaults";
const findUnique = vi.mocked(prisma.user.findUnique);
beforeEach(() => {
findUnique.mockReset();
});
describe("eventDefaultsFor", () => {
it("follows the reminder card for the mood reminder", () => {
expect(eventDefaultsFor({ moodReminderEnabled: true }).MOOD_REMINDER).toBe(
true,
);
expect(eventDefaultsFor({ moodReminderEnabled: false }).MOOD_REMINDER).toBe(
false,
);
});
it("answers every other event from the constant table", () => {
const map = eventDefaultsFor({ moodReminderEnabled: true });
for (const eventType of EVENT_TYPES) {
if (eventType === "MOOD_REMINDER") continue;
expect(map[eventType], eventType).toBe(EVENT_DEFAULT_ENABLED[eventType]);
}
});
it("covers every event the server knows", () => {
expect(
Object.keys(eventDefaultsFor({ moodReminderEnabled: false })).sort(),
).toEqual([...EVENT_TYPES].sort());
});
it("keeps the opt-out default for an event it does not know", () => {
expect(
eventDefaultFor("NOT_AN_EVENT", { moodReminderEnabled: false }),
).toBe(true);
});
});
describe("the account reads", () => {
it("the map and the dispatcher's per-event answer agree", async () => {
findUnique.mockResolvedValue({ moodReminderEnabled: true } as never);
const map = await resolveEventDefaults("u1");
for (const eventType of EVENT_TYPES) {
expect(await resolveEventDefault("u1", eventType), eventType).toBe(
map[eventType],
);
}
});
it("reads the account only for an event whose default follows it", async () => {
await resolveEventDefault("u1", "MEDICATION_REMINDER");
expect(findUnique).not.toHaveBeenCalled();
findUnique.mockResolvedValue({ moodReminderEnabled: false } as never);
expect(await resolveEventDefault("u1", "MOOD_REMINDER")).toBe(false);
expect(findUnique).toHaveBeenCalledTimes(1);
});
});
describe("no client keeps a copy of the table", () => {
it("nothing under src/app or src/components imports EVENT_DEFAULT_ENABLED", () => {
const root = join(process.cwd(), "src");
const offenders: string[] = [];
for (const dir of ["app", "components"]) {
// The floor makes a walk that found nothing fail instead of passing.
for (const rel of walkSourceFiles(join(root, dir), { floor: 300 })) {
if (rel.includes("__tests__/")) continue;
const text = readFileSync(join(root, dir, rel), "utf8");
if (text.includes("EVENT_DEFAULT_ENABLED")) {
offenders.push(`${dir}/${rel}`);
}
}
}
// The same matcher has to find the server modules that do hold the
// table, or an empty offender list proves nothing about the matcher.
const serverHolders = walkSourceFiles(join(root, "lib"), { floor: 300 })
.filter((rel) => !rel.includes("__tests__/"))
.filter((rel) =>
readFileSync(join(root, "lib", rel), "utf8").includes(
"EVENT_DEFAULT_ENABLED",
),
);
expect(serverHolders.length).toBeGreaterThan(1);
expect(offenders).toEqual([]);
});
});
+10 -12
View File
@@ -8,9 +8,8 @@ import type {
WebhookChannelConfig,
EmailChannelConfig,
ChannelType,
EventType,
} from "@/lib/notifications/types";
import { EVENT_DEFAULT_ENABLED } from "@/lib/notifications/types";
import { resolveEventDefault } from "@/lib/notifications/event-defaults";
import { sendViaTelegram } from "@/lib/notifications/senders/telegram";
import { sendViaNtfy } from "@/lib/notifications/senders/ntfy";
import { sendViaWebPush } from "@/lib/notifications/senders/web-push";
@@ -222,9 +221,7 @@ export async function dispatchNotification(
// lock-screen on first launch. The user enables it explicitly
// from /settings/notifications once they've seen the badge a few
// times and decided they want push.
let defaultEnabled =
EVENT_DEFAULT_ENABLED[recipientPayload.eventType as EventType] ?? true;
//
// v1.7.0 — MOOD_REMINDER single source of truth = the visible card.
// The event used to default OFF, layered on top of the per-user
// `moodReminderEnabled` flag, so a user who flipped the card still
@@ -234,13 +231,14 @@ export async function dispatchNotification(
// opt-out (a `NotificationPreference` row with `enabled = false`) is
// still honoured below via `pref.enabled` — only the no-row default
// is derived from the card.
if (recipientPayload.eventType === "MOOD_REMINDER") {
const user = await prisma.user.findUnique({
where: { id: delivery.recipientUserId },
select: { moodReminderEnabled: true },
});
defaultEnabled = user?.moodReminderEnabled === true;
}
//
// The resolution lives in `event-defaults.ts` because the /notifications
// matrix has to show the same answer: it reads the map the preferences
// route publishes from that module, never the constant table.
const defaultEnabled = await resolveEventDefault(
delivery.recipientUserId,
recipientPayload.eventType,
);
// Operator kill switches for the three channels that carry one, read at
// most once per dispatch and only when such a channel is actually in the
// cascade — a cascade of APNs + webhook + email pays nothing for it.
+92
View File
@@ -0,0 +1,92 @@
/**
* What an event does on a channel that carries no stored preference row.
*
* Most events have a constant answer (`EVENT_DEFAULT_ENABLED`). One does not:
* the mood reminder has no switch of its own at the channel layer, it follows
* the reminder card (`User.moodReminderEnabled`). The dispatcher has resolved
* it that way since v1.7.0, while the `/notifications` matrix read the
* constant table and showed the reminder as off on every channel of an account
* that was receiving it.
*
* This module is the one answer both read. The dispatcher asks per event, the
* preferences read publishes the whole map as `eventDefaults`, and a client
* renders that map instead of keeping a copy of the table — so the switch a
* person reads and the delivery they get cannot disagree again.
*/
import { prisma } from "@/lib/db";
import {
EVENT_DEFAULT_ENABLED,
EVENT_TYPES,
type EventType,
} from "@/lib/notifications/types";
/** The account settings a default can follow. */
export interface EventDefaultInputs {
moodReminderEnabled: boolean;
}
/**
* The events whose default is read off the account rather than the constant
* table. Kept as a map so the account read below happens only for them.
*/
const ACCOUNT_DERIVED: Partial<
Record<EventType, (inputs: EventDefaultInputs) => boolean>
> = {
MOOD_REMINDER: (inputs) => inputs.moodReminderEnabled,
};
function isEventType(value: string): value is EventType {
return (EVENT_TYPES as readonly string[]).includes(value);
}
/** The no-row default for one event, given the account's settings. */
export function eventDefaultFor(
eventType: string,
inputs: EventDefaultInputs,
): boolean {
if (!isEventType(eventType)) return true;
const derived = ACCOUNT_DERIVED[eventType];
return derived ? derived(inputs) : EVENT_DEFAULT_ENABLED[eventType];
}
/** Every event's no-row default, given the account's settings. */
export function eventDefaultsFor(
inputs: EventDefaultInputs,
): Record<EventType, boolean> {
return Object.fromEntries(
EVENT_TYPES.map((eventType) => [
eventType,
eventDefaultFor(eventType, inputs),
]),
) as Record<EventType, boolean>;
}
async function readInputs(userId: string): Promise<EventDefaultInputs> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { moodReminderEnabled: true },
});
return { moodReminderEnabled: user?.moodReminderEnabled === true };
}
/**
* The dispatcher's question. Reads the account only for an event whose
* default follows it, so the common event types pay nothing.
*/
export async function resolveEventDefault(
userId: string,
eventType: string,
): Promise<boolean> {
if (!isEventType(eventType)) return true;
const derived = ACCOUNT_DERIVED[eventType];
return derived
? derived(await readInputs(userId))
: EVENT_DEFAULT_ENABLED[eventType];
}
/** The preferences read's answer: the whole map for one account. */
export async function resolveEventDefaults(
userId: string,
): Promise<Record<EventType, boolean>> {
return eventDefaultsFor(await readInputs(userId));
}
+6 -1
View File
@@ -66,7 +66,7 @@ const notificationPreference = z
.meta({
id: "NotificationPreference",
description:
"An explicit per-(channel, event) override. Only STORED rows appear — an event with no row here follows the server-side default for that category, which is ON for most and OFF for the opt-in ones (personal records, mood and cycle reminders). Absence is therefore not 'disabled'.",
"An explicit per-(channel, event) override. Only STORED rows appear — an event with no row here follows `eventDefaults` on the same response. Absence is therefore not 'disabled'.",
});
const notificationPreferencesResponse = z
@@ -78,6 +78,11 @@ const notificationPreferencesResponse = z
.describe(
"Every event category the server knows, in declaration order — the list a client renders rows for.",
),
eventDefaults: z
.record(eventTypeEnum, z.boolean())
.describe(
"What each event does on a channel with no stored row, resolved for this account by the function delivery uses. Most are constant per category; the mood reminder follows the account's mood-reminder setting (`moodReminderEnabled` on `/api/user/profile`). Render a switch without a row from this map rather than from a copy of the defaults. Since v1.43.11.",
),
})
.meta({
id: "NotificationPreferencesResponse",
+1
View File
@@ -37,6 +37,7 @@ export interface RejectedProfileField {
*/
export const PROFILE_FIELD_LABEL_KEYS: Record<string, string> = {
email: "auth.email",
displayName: "onboarding.baseline.displayNameLabel",
heightCm: "settings.height",
dateOfBirth: "settings.dateOfBirth",
gender: "settings.gender",
@@ -0,0 +1,145 @@
/**
* Removing one saved AI key, against a real account row.
*
* The settings forms send `{ <provider>Key: null }` to `PATCH
* /api/user/ai-provider` to take a stored key back. This pins the route's
* half of that against Postgres: the one ciphertext column is cleared, the
* other keys, the provider choice and the model are left as they were, and
* the read the form seeds from reports the key gone.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
async function seedAccountWithKeys(): Promise<string> {
const prisma = getPrismaClient();
const { encrypt } = await import("@/lib/crypto");
const user = await prisma.user.create({
data: {
username: "ai-key-removal",
email: "ai-key-removal@example.test",
aiProvider: "OPENAI",
aiModel: "gpt-4o-mini",
aiOpenaiKeyEncrypted: encrypt("sk-test-openai-0001"),
aiAnthropicKeyEncrypted: encrypt("sk-ant-test-0002"),
aiLocalKeyEncrypted: encrypt("local-test-0003"),
aiCompatKeyEncrypted: encrypt("compat-test-0004"),
},
});
const session = await prisma.session.create({
data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) },
});
cookieJar.set("healthlog_session", session.id);
return user.id;
}
async function patch(body: unknown): Promise<Response> {
const { PATCH } = await import("@/app/api/user/ai-provider/route");
return PATCH(
new NextRequest("http://localhost/api/user/ai-provider", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}),
);
}
async function readProvider(): Promise<Record<string, unknown>> {
const { GET } = await import("@/app/api/user/ai-provider/route");
const res = await GET();
expect(res.status).toBe(200);
return ((await res.json()) as { data: Record<string, unknown> }).data;
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
describe("PATCH /api/user/ai-provider — removing one saved key", () => {
it("clears that key's column and leaves every other setting", async () => {
const userId = await seedAccountWithKeys();
const res = await patch({ openaiKey: null });
expect(res.status).toBe(200);
const row = await getPrismaClient().user.findUniqueOrThrow({
where: { id: userId },
select: {
aiProvider: true,
aiModel: true,
aiOpenaiKeyEncrypted: true,
aiAnthropicKeyEncrypted: true,
aiLocalKeyEncrypted: true,
aiCompatKeyEncrypted: true,
},
});
expect(row.aiOpenaiKeyEncrypted).toBeNull();
expect(row.aiAnthropicKeyEncrypted).not.toBeNull();
expect(row.aiLocalKeyEncrypted).not.toBeNull();
expect(row.aiCompatKeyEncrypted).not.toBeNull();
expect(row.aiProvider).toBe("OPENAI");
expect(row.aiModel).toBe("gpt-4o-mini");
});
it("the read the form seeds from reports the key gone", async () => {
await seedAccountWithKeys();
expect(await readProvider()).toMatchObject({
hasOpenaiKey: true,
hasAnthropicKey: true,
});
await patch({ anthropicKey: null });
expect(await readProvider()).toMatchObject({
hasOpenaiKey: true,
hasAnthropicKey: false,
anthropicKeyPreview: null,
hasLocalKey: true,
hasCompatKey: true,
});
});
for (const field of ["localKey", "compatKey"] as const) {
it(`clears ${field} on its own`, async () => {
await seedAccountWithKeys();
await patch({ [field]: null });
const data = await readProvider();
expect(data[field === "localKey" ? "hasLocalKey" : "hasCompatKey"]).toBe(
false,
);
expect(data.hasOpenaiKey).toBe(true);
});
}
});
@@ -133,4 +133,29 @@ describe("emergency profile route — the write lands in the row", () => {
});
expect(row.emergencyContactsEncrypted).toBeNull();
});
it("a select sent as null goes back to not recorded and leaves the others", async () => {
// The card sends a cleared select as `null` (and leaves an untouched one
// out). Until it did, a recorded blood type could be changed but never
// removed.
await loginAs(USER_ID);
const { GET, PATCH } = await import("@/app/api/anamnesis/emergency/route");
await PATCH(patch({ bloodType: "A_POS", organDonor: "YES" }));
const cleared = await PATCH(patch({ bloodType: null }));
expect(cleared.status).toBe(200);
const row = await getPrismaClient().userHealthProfile.findUniqueOrThrow({
where: { userId: USER_ID },
select: { emergencyBloodType: true, organDonorStatus: true },
});
expect(row.emergencyBloodType).toBeNull();
expect(row.organDonorStatus).toBe("YES");
const body = (await (await GET()).json()) as {
data: { bloodType: string | null; organDonor: string | null };
};
expect(body.data.bloodType).toBeNull();
expect(body.data.organDonor).toBe("YES");
});
});
@@ -0,0 +1,137 @@
/**
* The `/notifications` matrix and delivery give one answer for an event with
* no stored preference row, against a real account row.
*
* The mood reminder is the event this exists for: it has no switch of its own
* at the channel layer and follows the reminder card. Delivery read the card,
* the matrix read a constant table, and an account receiving the reminder saw
* it switched off on every channel. The preferences read now publishes
* `eventDefaults` from the resolver delivery asks; this suite flips the real
* column and checks the published switch and the actual send move together.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const ntfySendMock = vi.fn();
vi.mock("@/lib/notifications/senders/ntfy", () => ({
sendViaNtfy: (...args: unknown[]) => ntfySendMock(...args),
}));
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
const USERNAME = "event-defaults";
async function seedAccount(moodReminderEnabled: boolean): Promise<string> {
const prisma = getPrismaClient();
const { encrypt } = await import("@/lib/crypto");
const user = await prisma.user.create({
data: {
username: USERNAME,
email: `${USERNAME}@example.test`,
moodReminderEnabled,
},
});
await prisma.notificationChannel.create({
data: {
userId: user.id,
type: "NTFY",
enabled: true,
config: encrypt(
JSON.stringify({ serverUrl: "https://ntfy.example", topic: "health" }),
),
},
});
const session = await prisma.session.create({
data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) },
});
cookieJar.set("healthlog_session", session.id);
return user.id;
}
async function readDefaults(): Promise<Record<string, boolean>> {
const { GET } = await import("@/app/api/notifications/preferences/route");
const res = await GET();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { preferences: unknown[]; eventDefaults: Record<string, boolean> };
};
// The case is about the no-row path; a stored row would decide instead.
expect(body.data.preferences).toEqual([]);
return body.data.eventDefaults;
}
async function dispatchMood(userId: string) {
const { dispatchNotification } =
await import("@/lib/notifications/dispatcher");
return dispatchNotification({
userId,
eventType: "MOOD_REMINDER",
title: "How are you today?",
message: "Log your mood",
});
}
beforeEach(async () => {
vi.clearAllMocks();
ntfySendMock.mockResolvedValue({ ok: true });
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
describe("event defaults: the matrix shows what delivery does", () => {
it("reminder card on: the matrix shows ON and the reminder is sent", async () => {
const userId = await seedAccount(true);
expect((await readDefaults()).MOOD_REMINDER).toBe(true);
await dispatchMood(userId);
expect(ntfySendMock).toHaveBeenCalledTimes(1);
});
it("reminder card off: the matrix shows OFF and nothing is sent", async () => {
const userId = await seedAccount(false);
expect((await readDefaults()).MOOD_REMINDER).toBe(false);
await dispatchMood(userId);
expect(ntfySendMock).not.toHaveBeenCalled();
});
it("publishes a default for every event the server knows", async () => {
await seedAccount(false);
const { EVENT_TYPES } = await import("@/lib/notifications/types");
expect(Object.keys(await readDefaults()).sort()).toEqual(
[...EVENT_TYPES].sort(),
);
});
});
@@ -0,0 +1,110 @@
/**
* The display name round-trips through the profile save and the account
* payload, against a real account row.
*
* The profile card seeds its display-name field from `GET /api/auth/me` and
* saves it through `PUT /api/auth/profile`. Until now `/me` did not publish
* the name at all, so the two ends could not meet; this pins the pipe: a save
* lands in the column and comes back on the payload, and an emptied field
* clears it.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
async function seed(displayName: string | null): Promise<string> {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: "display-name",
email: "display-name@example.test",
displayName,
},
});
const session = await prisma.session.create({
data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) },
});
cookieJar.set("healthlog_session", session.id);
return user.id;
}
async function save(body: Record<string, unknown>): Promise<Response> {
const { PUT } = await import("@/app/api/auth/profile/route");
return PUT(
new NextRequest("http://localhost/api/auth/profile", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}),
);
}
async function me(): Promise<{ displayName?: string | null }> {
const { GET } = await import("@/app/api/auth/me/route");
const res = await GET();
expect(res.status).toBe(200);
return ((await res.json()) as { data: { displayName?: string | null } }).data;
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
describe("display name: profile save and account payload", () => {
it("publishes the stored name on /api/auth/me", async () => {
await seed("Sam");
expect((await me()).displayName).toBe("Sam");
});
it("a save lands in the column and comes back on the payload", async () => {
const userId = await seed("Sam");
const res = await save({ displayName: " Alex " });
expect(res.status).toBe(200);
const row = await getPrismaClient().user.findUniqueOrThrow({
where: { id: userId },
select: { displayName: true },
});
expect(row.displayName).toBe("Alex");
expect((await me()).displayName).toBe("Alex");
});
it("an emptied field clears the name", async () => {
await seed("Sam");
expect((await save({ displayName: null })).status).toBe(200);
expect((await me()).displayName).toBeNull();
});
});