diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6e0fbcab1..00ed782e5 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/messages/de.json b/messages/de.json index 9024e9196..d62745355 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 11e239fa1..e302c4dff 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 9e91ad9fb..a26e7bd46 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 3bf6139bf..6d96b26fd 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 99e0ee1bd..6ebc40c7d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/ko.json b/messages/ko.json index 369fe19d7..2e991a5f3 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -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": "관리자", diff --git a/messages/nl.json b/messages/nl.json index a01d249ed..d01313ca4 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 10c3305fa..fd41253e7 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/__tests__/confirm-destructive-style-guard.test.ts b/src/__tests__/confirm-destructive-style-guard.test.ts new file mode 100644 index 000000000..44ae1df62 --- /dev/null +++ b/src/__tests__/confirm-destructive-style-guard.test.ts @@ -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(" { + 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([]); + }); +}); diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index 0d25ccca4..6fe80d772 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -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", diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 09cebfdc0..ef1167630 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -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, diff --git a/src/app/api/notifications/preferences/route.ts b/src/app/api/notifications/preferences/route.ts index 0fbe2fa4d..ad39a9675 100644 --- a/src/app/api/notifications/preferences/route.ts +++ b/src/app/api/notifications/preferences/route.ts @@ -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), }); }); diff --git a/src/app/coach/plans/page.tsx b/src/app/coach/plans/page.tsx index f60a3cfc4..cd646d1e5 100644 --- a/src/app/coach/plans/page.tsx +++ b/src/app/coach/plans/page.tsx @@ -191,6 +191,7 @@ function CoachPlansBody({ readOnly }: { readOnly: boolean }) { {(readOnly || group === "past") && ( { 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"/, + ); + }); }); diff --git a/src/app/notifications/page.tsx b/src/app/notifications/page.tsx index 4c14bd9e3..ca5390281 100644 --- a/src/app/notifications/page.tsx +++ b/src/app/notifications/page.tsx @@ -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; } 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 + diff --git a/src/components/admin/ai-server-key-section.tsx b/src/components/admin/ai-server-key-section.tsx index c28f88035..ee90687e8 100644 --- a/src/components/admin/ai-server-key-section.tsx +++ b/src/components/admin/ai-server-key-section.tsx @@ -187,6 +187,7 @@ export function AiServerKeySection() { {configured && ( } @@ -1471,6 +1472,7 @@ export function SymptomRow({ 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")} /> } diff --git a/src/components/measurement-reminders/vorsorge-settings.tsx b/src/components/measurement-reminders/vorsorge-settings.tsx index 3fe69268b..a4df6ea47 100644 --- a/src/components/measurement-reminders/vorsorge-settings.tsx +++ b/src/components/measurement-reminders/vorsorge-settings.tsx @@ -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() { - 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 diff --git a/src/components/records/__tests__/emergency-profile-save.test.tsx b/src/components/records/__tests__/emergency-profile-save.test.tsx index 6640644ce..845175c56 100644 --- a/src/components/records/__tests__/emergency-profile-save.test.tsx +++ b/src/components/records/__tests__/emergency-profile-save.test.tsx @@ -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(" Save", () => { it("stays disabled until a field differs from the stored profile", () => { @@ -43,3 +53,71 @@ describe(" Save", () => { expect(row).toMatch(/]*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( + /\s*\{t\("records\.emergency\.notRecorded"\)\}/, + ); + expect(source).toMatch(/next === NOT_RECORDED \? "" : next/); + }); +}); diff --git a/src/components/records/about-me-note-manager.tsx b/src/components/records/about-me-note-manager.tsx index ebbc8232c..422fd6001 100644 --- a/src/components/records/about-me-note-manager.tsx +++ b/src/components/records/about-me-note-manager.tsx @@ -194,6 +194,7 @@ export function AboutMeNoteManager() { {hasNote && ( } variant="outline" diff --git a/src/components/records/audit-c-panel.tsx b/src/components/records/audit-c-panel.tsx index 6f040d9b4..c986cff7d 100644 --- a/src/components/records/audit-c-panel.tsx +++ b/src/components/records/audit-c-panel.tsx @@ -176,6 +176,7 @@ export function AuditCPanel({ className="text-destructive size-11 shrink-0 sm:size-9" icon={} slot={`audit-c-remove-${entry.id}`} + destructive /> ))} @@ -214,6 +215,7 @@ export function AuditCPanel({ className="text-destructive size-11 shrink-0 sm:size-9" icon={} slot="audit-c-legacy-remove" + destructive /> ) : null} diff --git a/src/components/records/emergency-profile-manager.tsx b/src/components/records/emergency-profile-manager.tsx index 920fe4b40..4210900d3 100644 --- a/src/components/records/emergency-profile-manager.tsx +++ b/src/components/records/emergency-profile-manager.tsx @@ -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 { + const body: Record = {}; + 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("/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( + "/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}`)} ` tag, or null. */ +function displayNameInput(html: string): string | null { + return /]*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/); + }); +}); diff --git a/src/components/settings/account-section/avatar-section.tsx b/src/components/settings/account-section/avatar-section.tsx index 583fb2ff9..bff3eb9e5 100644 --- a/src/components/settings/account-section/avatar-section.tsx +++ b/src/components/settings/account-section/avatar-section.tsx @@ -154,6 +154,7 @@ export function AvatarSection() { {avatarUrl && ( } diff --git a/src/components/settings/account-section/index.tsx b/src/components/settings/account-section/index.tsx index 860586e40..cacf26e4c 100644 --- a/src/components/settings/account-section/index.tsx +++ b/src/components/settings/account-section/index.tsx @@ -95,12 +95,16 @@ export function AccountSection() { const [dateOfBirth, setDateOfBirth] = useState(""); const [gender, setGender] = useState(""); const [timezone, setTimezone] = useState(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" /> + {/* The name the greeting and the reports use. The setup flow + asks for it once; this is where it changes afterwards. */}
- + { - 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" + } + /> +

+ {t("onboarding.baseline.displayNameHint")} +

+ - - {emailChanged && !emailNeedsFactor && ( -
- - { - setEmailPassword(e.target.value); - clearFieldError("email"); - }} - autoComplete="current-password" - maxLength={512} - /> -
- )}
+
+ + { + 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} + /> + + {emailChanged && !emailNeedsFactor && ( +
+ + { + setEmailPassword(e.target.value); + clearFieldError("email"); + }} + autoComplete="current-password" + maxLength={512} + /> +
+ )} +
+
diff --git a/src/components/settings/ai/__tests__/remove-saved-key.test.tsx b/src/components/settings/ai/__tests__/remove-saved-key.test.tsx new file mode 100644 index 000000000..bda1376bb --- /dev/null +++ b/src/components/settings/ai/__tests__/remove-saved-key.test.tsx @@ -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 { + 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; +}[] = [ + { + 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( + + +
+ + , + ); +} + +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 }); + }); + }); + } +}); diff --git a/src/components/settings/ai/anthropic-provider-form.tsx b/src/components/settings/ai/anthropic-provider-form.tsx index 33ded234f..7aa31d06b 100644 --- a/src/components/settings/ai/anthropic-provider-form.tsx +++ b/src/components/settings/ai/anthropic-provider-form.tsx @@ -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({ )} + {userProvider?.hasAnthropicKey && ( + + )} @@ -718,7 +718,7 @@ function ApiTokensCard() { variant="destructive" onClick={() => handleRevoke(tok.id)} > - {t("settings.tokenRevoked")} + {t("settings.tokenRevokeAction")} diff --git a/src/components/settings/coach-memory-section.tsx b/src/components/settings/coach-memory-section.tsx index c5e34a9e2..c5fa86302 100644 --- a/src/components/settings/coach-memory-section.tsx +++ b/src/components/settings/coach-memory-section.tsx @@ -228,6 +228,7 @@ function FactRow({ accessible name and the confirmation carry the words. */} 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) { diff --git a/src/components/settings/coach-reminders-section.tsx b/src/components/settings/coach-reminders-section.tsx index 3c040e3ba..1da7a16e4 100644 --- a/src/components/settings/coach-reminders-section.tsx +++ b/src/components/settings/coach-reminders-section.tsx @@ -147,6 +147,7 @@ export function CoachRemindersSection({ )} + showErrorToast(t("settings.savingError"), { cause: failure }), }); function toggle(site: InjectionSiteKey, checked: boolean) { diff --git a/src/components/settings/integrations/oauth-integration-card.tsx b/src/components/settings/integrations/oauth-integration-card.tsx index d3f6eb1af..ae4636334 100644 --- a/src/components/settings/integrations/oauth-integration-card.tsx +++ b/src/components/settings/integrations/oauth-integration-card.tsx @@ -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({ diff --git a/src/components/settings/low-stock-card.tsx b/src/components/settings/low-stock-card.tsx index caa967fb7..7619c6dea 100644 --- a/src/components/settings/low-stock-card.tsx +++ b/src/components/settings/low-stock-card.tsx @@ -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) { diff --git a/src/components/settings/mcp-section.tsx b/src/components/settings/mcp-section.tsx index bc21fcb4f..6b448a5d6 100644 --- a/src/components/settings/mcp-section.tsx +++ b/src/components/settings/mcp-section.tsx @@ -516,7 +516,7 @@ function McpTokensCard() { className="min-h-11 w-full" > - {t("settings.tokenRevoke")} + {t("settings.tokenRevokeAction")} @@ -536,7 +536,7 @@ function McpTokensCard() { variant="destructive" onClick={() => handleRevoke(tok.id)} > - {t("settings.tokenRevoked")} + {t("settings.tokenRevokeAction")} diff --git a/src/components/settings/mood-reminder-card.tsx b/src/components/settings/mood-reminder-card.tsx index 4b1f65528..04a9844fd 100644 --- a/src/components/settings/mood-reminder-card.tsx +++ b/src/components/settings/mood-reminder-card.tsx @@ -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 ( diff --git a/src/components/settings/notification-content-card.tsx b/src/components/settings/notification-content-card.tsx index 91dfc2f2b..f64b06256 100644 --- a/src/components/settings/notification-content-card.tsx +++ b/src/components/settings/notification-content-card.tsx @@ -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 ( diff --git a/src/components/settings/security-section/passkey-list-section.tsx b/src/components/settings/security-section/passkey-list-section.tsx index 5fd27cef5..a9e9281a9 100644 --- a/src/components/settings/security-section/passkey-list-section.tsx +++ b/src/components/settings/security-section/passkey-list-section.tsx @@ -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 diff --git a/src/components/settings/security-section/security-keys-card.tsx b/src/components/settings/security-section/security-keys-card.tsx index 9a520fe82..ce4b99415 100644 --- a/src/components/settings/security-section/security-keys-card.tsx +++ b/src/components/settings/security-section/security-keys-card.tsx @@ -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; diff --git a/src/components/settings/security-sessions-card.tsx b/src/components/settings/security-sessions-card.tsx index 69373a1f5..b2bf30eb6 100644 --- a/src/components/settings/security-sessions-card.tsx +++ b/src/components/settings/security-sessions-card.tsx @@ -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([]); 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 && (

- {grant.account.displayName ?? grant.account.username} + {accountLabel(grant.account)}

{t(ACCESS_LABEL_KEY[grant.access])}

- + {grantList ? ( + + revokeGrant.mutate(grant.id, { + onSuccess: () => + setKeptGrants((current) => + current.filter((g) => g.id !== grant.id), + ), + onError: () => + setStatus( + t("recordSharing.actionError.revokeFailed"), + ), + }) + } + /> + ) : null} ))} diff --git a/src/components/settings/sharing-section.tsx b/src/components/settings/sharing-section.tsx index 7b3574d06..1c7b70340 100644 --- a/src/components/settings/sharing-section.tsx +++ b/src/components/settings/sharing-section.tsx @@ -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( diff --git a/src/components/settings/telegram-card.tsx b/src/components/settings/telegram-card.tsx index a0870d6ff..3c685ce6b 100644 --- a/src/components/settings/telegram-card.tsx +++ b/src/components/settings/telegram-card.tsx @@ -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 ( diff --git a/src/components/settings/trusted-devices-card.tsx b/src/components/settings/trusted-devices-card.tsx index 20fa7b45f..70c1d074c 100644 --- a/src/components/settings/trusted-devices-card.tsx +++ b/src/components/settings/trusted-devices-card.tsx @@ -137,6 +137,7 @@ export function TrustedDevicesCard({