mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
commit
eabfeaef08
74 files changed
+2545
-436
No files matched your search
+50
-3
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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"/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user