From 2bb3783b14f88872b964ab04ab049ec874326558 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 12:23:13 +0200 Subject: [PATCH] fix(settings): report the outcome of quiet settings actions Adding or removing a passkey or a security key only redrew the list. Disconnecting an integration and revoking a share link ran from a dialog that had already closed, so a refused request left everything as it was without a word. The injection-site exclusions, the local OCR switch and the preventive-care reminder switches saved, or did not, in silence. Each now reports through the outcome toast and, where the card has no inline error line, the shared error toast with its error id. --- messages/de.json | 14 +- messages/en.json | 14 +- messages/es.json | 14 +- messages/fr.json | 14 +- messages/it.json | 14 +- messages/ko.json | 14 +- messages/nl.json | 14 +- messages/pl.json | 14 +- src/components/labs/labs-settings.tsx | 13 +- .../vorsorge-settings.tsx | 20 ++- .../__tests__/action-feedback.test.ts | 152 ++++++++++++++++++ .../settings/injection-sites-card.tsx | 5 + .../integrations/oauth-integration-card.tsx | 17 +- .../security-section/passkey-list-section.tsx | 3 + .../security-section/security-keys-card.tsx | 3 + src/components/settings/sharing-section.tsx | 7 + 16 files changed, 302 insertions(+), 30 deletions(-) create mode 100644 src/components/settings/__tests__/action-feedback.test.ts diff --git a/messages/de.json b/messages/de.json index 9a2c80392..e299f3ad6 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6909,7 +6909,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", @@ -7096,7 +7098,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", @@ -7439,7 +7443,11 @@ "homePlaceUnknown": "Diesen Ort kennt die Dawarich-Instanz nicht mehr.", "notConnected": "Dawarich ist nicht verbunden.", "generic": "Das hat nicht geklappt. Versuche es erneut." - } + }, + "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 8d6ab2cd4..ff603514e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6909,7 +6909,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", @@ -7096,7 +7098,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", @@ -7439,7 +7443,11 @@ "homePlaceUnknown": "The Dawarich instance no longer knows this place.", "notConnected": "Dawarich is not connected.", "generic": "That did not work. Try again." - } + }, + "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 f86b42a52..dfd004bcf 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6909,7 +6909,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", @@ -7096,7 +7098,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", @@ -7439,7 +7443,11 @@ "homePlaceUnknown": "La instancia de Dawarich ya no conoce este lugar.", "notConnected": "Dawarich no está conectado.", "generic": "No ha funcionado. Inténtalo de nuevo." - } + }, + "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 3b3a92f2f..5fb617982 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6909,7 +6909,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", @@ -7096,7 +7098,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", @@ -7439,7 +7443,11 @@ "homePlaceUnknown": "L’instance Dawarich ne connaît plus ce lieu.", "notConnected": "Dawarich n’est pas connecté.", "generic": "Ça n’a pas marché. Réessaie." - } + }, + "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 85e7d2cf8..eda8a4549 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6909,7 +6909,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", @@ -7096,7 +7098,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", @@ -7439,7 +7443,11 @@ "homePlaceUnknown": "L’istanza Dawarich non conosce più questo luogo.", "notConnected": "Dawarich non è collegato.", "generic": "Non ha funzionato. Riprova." - } + }, + "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 cc848901c..69b2f0bd1 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -6909,7 +6909,9 @@ "reselectReason": "이 링크는 선택한 적 없는 범위를 제공하고 있어서 닫았어요. 새로 만들고 담을 내용을 골라 주세요.", "reselectAction": "새 링크 만들기", "createDetail": "토큰은 만들 때 한 번만 보여줘요. 그때 저장해 두세요. 복구할 수 없어요.", - "activeDescription": "공유한 링크와 만료 시점, 열람 횟수를 보여줘요." + "activeDescription": "공유한 링크와 만료 시점, 열람 횟수를 보여줘요.", + "revokedToast": "링크를 해지했어요.", + "revokeFailed": "링크를 해지하지 못했어요." }, "genderOther": "기타", "nightscout": "Nightscout", @@ -7096,7 +7098,9 @@ "lastUsed": "마지막 사용 {date}", "neverUsed": "사용한 적 없음", "added": "{date} 추가됨", - "nameLabel": "보안 키 이름" + "nameLabel": "보안 키 이름", + "addedToast": "보안 키를 추가했어요.", + "removedToast": "보안 키를 삭제했어요." }, "passkeyNudge": { "title": "패스키를 추가하면 더 빠르게 로그인해요", @@ -7439,7 +7443,11 @@ "homePlaceUnknown": "Dawarich 인스턴스에 이 장소가 더 이상 없습니다.", "notConnected": "Dawarich가 연결되어 있지 않습니다.", "generic": "실패했습니다. 다시 시도하세요." - } + }, + "passkeyAddedToast": "패스키를 추가했어요.", + "passkeyRemovedToast": "패스키를 삭제했어요.", + "integrationDisconnected": "{provider} 연결을 해제했어요.", + "integrationDisconnectFailed": "{provider} 연결을 해제하지 못했어요." }, "admin": { "title": "관리자", diff --git a/messages/nl.json b/messages/nl.json index 745094620..09026ba66 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -6909,7 +6909,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", @@ -7096,7 +7098,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", @@ -7439,7 +7443,11 @@ "homePlaceUnknown": "De Dawarich-instantie kent deze plaats niet meer.", "notConnected": "Dawarich is niet gekoppeld.", "generic": "Dat is niet gelukt. Probeer het opnieuw." - } + }, + "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 d87da90db..a9213953d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6909,7 +6909,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", @@ -7096,7 +7098,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", @@ -7439,7 +7443,11 @@ "homePlaceUnknown": "Instancja Dawarich nie zna już tego miejsca.", "notConnected": "Dawarich nie jest połączony.", "generic": "Nie udało się. Spróbuj ponownie." - } + }, + "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/components/labs/labs-settings.tsx b/src/components/labs/labs-settings.tsx index de53488cd..8988fd20b 100644 --- a/src/components/labs/labs-settings.tsx +++ b/src/components/labs/labs-settings.tsx @@ -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() { 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/settings/__tests__/action-feedback.test.ts b/src/components/settings/__tests__/action-feedback.test.ts new file mode 100644 index 000000000..c350187dd --- /dev/null +++ b/src/components/settings/__tests__/action-feedback.test.ts @@ -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 = useMutation({…})`, or `.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("); + }); + } + }); + } +}); diff --git a/src/components/settings/injection-sites-card.tsx b/src/components/settings/injection-sites-card.tsx index b4207d198..21e82c34f 100644 --- a/src/components/settings/injection-sites-card.tsx +++ b/src/components/settings/injection-sites-card.tsx @@ -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) { diff --git a/src/components/settings/integrations/oauth-integration-card.tsx b/src/components/settings/integrations/oauth-integration-card.tsx index 9af68d69e..11be377f9 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"; @@ -298,7 +300,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/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/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(