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(