diff --git a/messages/de.json b/messages/de.json index ad9268086..808caf400 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4672,7 +4672,9 @@ "description": "Füge einen Ort und einen Zeitraum hinzu – eine Reise oder einen früheren Abschnitt deiner Verlaufsdaten. Der Zeitraum überschreibt für seine Tage deinen Wohnort, damit das Wetter zu deinem tatsächlichen Aufenthalt passt. So korrigierst du die Vergangenheit.", "none": "Noch keine Ortszeiträume.", "add": "{label} hinzufügen", - "remove": "Ortszeitraum entfernen" + "remove": "Ortszeitraum entfernen", + "removeConfirmTitle": "Diese Reise entfernen?", + "removeConfirmBody": "Messwerte aus diesem Zeitraum bleiben erhalten; nur der Reise-Eintrag verschwindet." }, "backfill": { "title": "Vergangene Tage nacherfassen", @@ -4953,7 +4955,10 @@ "surfaced": "Angezeigt", "done": "Erledigt", "dismissed": "Verworfen" - } + }, + "deleteConfirmTitle": "Diese Erinnerung löschen?", + "deleteConfirmBody": "Sie wird nicht mehr ausgelöst. An bereits erfassten Daten ändert sich nichts.", + "deleteConfirmAction": "Löschen" }, "aboutMe": { "title": "Über mich", @@ -5628,8 +5633,16 @@ "revokeAllDone": "{count} vertrauenswürdige(s) Gerät(e) entfernt.", "unnamed": "Unbekanntes Gerät", "current": "Dieses Gerät", - "expires": "Vertraut bis {date}" - } + "expires": "Vertraut bis {date}", + "revokeConfirmTitle": "Diesem Gerät nicht mehr vertrauen?", + "revokeConfirmBody": "Es fragt beim nächsten Anmelden wieder nach deinem zweiten Faktor.", + "revokeAllConfirmTitle": "Keinem Gerät mehr vertrauen?", + "revokeAllConfirmBody": "Jedes Gerät fragt beim nächsten Anmelden wieder nach deinem zweiten Faktor, auch dieses." + }, + "revokeSessionConfirmTitle": "Dieses Gerät abmelden?", + "revokeSessionConfirmBody": "{device} muss sich neu anmelden. An den dort erfassten Daten ändert sich nichts.", + "signOutEverywhereConfirmTitle": "Alle anderen Geräte abmelden?", + "signOutEverywhereConfirmBody": "Jedes Gerät außer diesem muss sich neu anmelden. Dieses Gerät bleibt angemeldet. An deinen erfassten Daten ändert sich nichts." }, "privacy": { "intro": { diff --git a/messages/en.json b/messages/en.json index c78f6c78c..98c2df8bf 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4672,7 +4672,9 @@ "description": "Add a place and a date range — a trip, or a stretch of earlier history. The period overrides your home for the days it covers, so weather matches where you actually were. This is how you correct the past.", "none": "No location periods yet.", "add": "Add {label}", - "remove": "Remove location period" + "remove": "Remove location period", + "removeConfirmTitle": "Remove this trip?", + "removeConfirmBody": "Readings taken during it stay in your record; only the trip entry goes." }, "backfill": { "title": "Backfill past days", @@ -4953,7 +4955,10 @@ "surfaced": "Surfaced", "done": "Done", "dismissed": "Dismissed" - } + }, + "deleteConfirmTitle": "Delete this reminder?", + "deleteConfirmBody": "It stops being raised. Anything you have already recorded is untouched.", + "deleteConfirmAction": "Delete" }, "aboutMe": { "title": "About me", @@ -5628,8 +5633,16 @@ "revokeAllDone": "Forgot {count} trusted device(s).", "unnamed": "Unknown device", "current": "This device", - "expires": "Trusted until {date}" - } + "expires": "Trusted until {date}", + "revokeConfirmTitle": "Stop trusting this device?", + "revokeConfirmBody": "It will ask for your second factor again the next time you sign in from it.", + "revokeAllConfirmTitle": "Stop trusting every device?", + "revokeAllConfirmBody": "Every device will ask for your second factor again the next time you sign in from it, including this one." + }, + "revokeSessionConfirmTitle": "Sign this device out?", + "revokeSessionConfirmBody": "{device} will need to sign in again. Nothing recorded on it is affected.", + "signOutEverywhereConfirmTitle": "Sign out every other device?", + "signOutEverywhereConfirmBody": "Every device except this one will need to sign in again. This device stays signed in. Nothing you have recorded is affected." }, "privacy": { "intro": { diff --git a/messages/es.json b/messages/es.json index 7e77e5ed2..895108146 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4672,7 +4672,9 @@ "description": "Añade un lugar y un rango de fechas: un viaje o un tramo de tu historial anterior. El periodo anula tu hogar en los días que cubre, para que el tiempo coincida con donde estuviste realmente. Así corriges el pasado.", "none": "Aún no hay periodos de ubicación.", "add": "Añadir {label}", - "remove": "Eliminar periodo de ubicación" + "remove": "Eliminar periodo de ubicación", + "removeConfirmTitle": "¿Quitar este viaje?", + "removeConfirmBody": "Las mediciones de ese periodo permanecen; solo desaparece la entrada del viaje." }, "backfill": { "title": "Rellenar días pasados", @@ -4953,7 +4955,10 @@ "surfaced": "Mostrado", "done": "Hecho", "dismissed": "Descartado" - } + }, + "deleteConfirmTitle": "¿Eliminar este recordatorio?", + "deleteConfirmBody": "Deja de aparecer. Lo que ya has registrado no cambia.", + "deleteConfirmAction": "Eliminar" }, "aboutMe": { "title": "Sobre mí", @@ -5628,8 +5633,16 @@ "revokeAllDone": "Se olvidaron {count} dispositivo(s) de confianza.", "unnamed": "Dispositivo desconocido", "current": "Este dispositivo", - "expires": "De confianza hasta el {date}" - } + "expires": "De confianza hasta el {date}", + "revokeConfirmTitle": "¿Dejar de confiar en este dispositivo?", + "revokeConfirmBody": "Volverá a pedir tu segundo factor la próxima vez que inicies sesión desde él.", + "revokeAllConfirmTitle": "¿Dejar de confiar en todos los dispositivos?", + "revokeAllConfirmBody": "Todos los dispositivos volverán a pedir tu segundo factor la próxima vez que inicies sesión, incluido este." + }, + "revokeSessionConfirmTitle": "¿Cerrar la sesión de este dispositivo?", + "revokeSessionConfirmBody": "{device} tendrá que iniciar sesión de nuevo. Lo registrado en él no se ve afectado.", + "signOutEverywhereConfirmTitle": "¿Cerrar la sesión en los demás dispositivos?", + "signOutEverywhereConfirmBody": "Todos los dispositivos salvo este tendrán que iniciar sesión de nuevo. Este seguirá con la sesión abierta. Nada de lo que has registrado se ve afectado." }, "privacy": { "intro": { diff --git a/messages/fr.json b/messages/fr.json index a390a4c5f..fdf293245 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4672,7 +4672,9 @@ "description": "Ajoutez un lieu et une plage de dates : un voyage ou une portion de votre historique passé. La période remplace votre domicile pour les jours qu'elle couvre, afin que la météo corresponde à l'endroit où vous étiez réellement. C'est ainsi que vous corrigez le passé.", "none": "Aucune période de localisation pour l'instant.", "add": "Ajouter {label}", - "remove": "Supprimer la période de localisation" + "remove": "Supprimer la période de localisation", + "removeConfirmTitle": "Retirer ce voyage ?", + "removeConfirmBody": "Les mesures de cette période restent dans ton dossier ; seule l'entrée du voyage disparaît." }, "backfill": { "title": "Récupérer les jours passés", @@ -4953,7 +4955,10 @@ "surfaced": "Affiché", "done": "Terminé", "dismissed": "Ignoré" - } + }, + "deleteConfirmTitle": "Supprimer ce rappel ?", + "deleteConfirmBody": "Il ne se déclenchera plus. Ce que tu as déjà enregistré reste intact.", + "deleteConfirmAction": "Supprimer" }, "aboutMe": { "title": "À propos de moi", @@ -5628,8 +5633,16 @@ "revokeAllDone": "{count} appareil(s) de confiance oublié(s).", "unnamed": "Appareil inconnu", "current": "Cet appareil", - "expires": "De confiance jusqu'au {date}" - } + "expires": "De confiance jusqu'au {date}", + "revokeConfirmTitle": "Ne plus faire confiance à cet appareil ?", + "revokeConfirmBody": "Il redemandera ton deuxième facteur à la prochaine connexion depuis cet appareil.", + "revokeAllConfirmTitle": "Ne plus faire confiance à aucun appareil ?", + "revokeAllConfirmBody": "Chaque appareil redemandera ton deuxième facteur à la prochaine connexion, celui-ci compris." + }, + "revokeSessionConfirmTitle": "Déconnecter cet appareil ?", + "revokeSessionConfirmBody": "{device} devra se reconnecter. Ce qui y est enregistré n'est pas affecté.", + "signOutEverywhereConfirmTitle": "Déconnecter tous les autres appareils ?", + "signOutEverywhereConfirmBody": "Chaque appareil sauf celui-ci devra se reconnecter. Celui-ci reste connecté. Rien de ce que tu as enregistré n'est affecté." }, "privacy": { "intro": { diff --git a/messages/it.json b/messages/it.json index 827c43c5f..51fe88723 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4672,7 +4672,9 @@ "description": "Aggiungi un luogo e un intervallo di date: un viaggio o un tratto della tua cronologia passata. Il periodo prevale sulla tua residenza nei giorni che copre, così il meteo corrisponde a dove ti trovavi davvero. È così che correggi il passato.", "none": "Nessun periodo di posizione.", "add": "Aggiungi {label}", - "remove": "Rimuovi periodo di posizione" + "remove": "Rimuovi periodo di posizione", + "removeConfirmTitle": "Rimuovere questo viaggio?", + "removeConfirmBody": "Le misurazioni di quel periodo restano nel tuo archivio; sparisce solo la voce del viaggio." }, "backfill": { "title": "Recupera i giorni passati", @@ -4953,7 +4955,10 @@ "surfaced": "Mostrato", "done": "Fatto", "dismissed": "Ignorato" - } + }, + "deleteConfirmTitle": "Eliminare questo promemoria?", + "deleteConfirmBody": "Non verrà più mostrato. Quanto hai già registrato resta invariato.", + "deleteConfirmAction": "Elimina" }, "aboutMe": { "title": "Su di me", @@ -5628,8 +5633,16 @@ "revokeAllDone": "Dimenticati {count} dispositivo/i attendibile/i.", "unnamed": "Dispositivo sconosciuto", "current": "Questo dispositivo", - "expires": "Attendibile fino al {date}" - } + "expires": "Attendibile fino al {date}", + "revokeConfirmTitle": "Non fidarsi più di questo dispositivo?", + "revokeConfirmBody": "Al prossimo accesso da questo dispositivo chiederà di nuovo il secondo fattore.", + "revokeAllConfirmTitle": "Non fidarsi più di alcun dispositivo?", + "revokeAllConfirmBody": "Ogni dispositivo chiederà di nuovo il secondo fattore al prossimo accesso, questo compreso." + }, + "revokeSessionConfirmTitle": "Disconnettere questo dispositivo?", + "revokeSessionConfirmBody": "{device} dovrà accedere di nuovo. Quanto registrato su di esso non viene toccato.", + "signOutEverywhereConfirmTitle": "Disconnettere tutti gli altri dispositivi?", + "signOutEverywhereConfirmBody": "Ogni dispositivo tranne questo dovrà accedere di nuovo. Questo resta connesso. Nulla di ciò che hai registrato viene toccato." }, "privacy": { "intro": { diff --git a/messages/pl.json b/messages/pl.json index a43e94b7f..ef361115b 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4672,7 +4672,9 @@ "description": "Dodaj miejsce i zakres dat: podróż lub fragment wcześniejszej historii. Okres zastępuje Twój dom w dniach, które obejmuje, aby pogoda odpowiadała miejscu, w którym naprawdę byłeś. W ten sposób korygujesz przeszłość.", "none": "Brak okresów lokalizacji.", "add": "Dodaj {label}", - "remove": "Usuń okres lokalizacji" + "remove": "Usuń okres lokalizacji", + "removeConfirmTitle": "Usunąć tę podróż?", + "removeConfirmBody": "Pomiary z tego okresu pozostają w historii; znika tylko wpis podróży." }, "backfill": { "title": "Uzupełnij minione dni", @@ -4953,7 +4955,10 @@ "surfaced": "Wyświetlone", "done": "Gotowe", "dismissed": "Odrzucone" - } + }, + "deleteConfirmTitle": "Usunąć to przypomnienie?", + "deleteConfirmBody": "Przestanie się pojawiać. Zapisane wcześniej dane pozostają bez zmian.", + "deleteConfirmAction": "Usuń" }, "aboutMe": { "title": "O mnie", @@ -5628,8 +5633,16 @@ "revokeAllDone": "Zapomniano {count} zaufane urządzenie/a.", "unnamed": "Nieznane urządzenie", "current": "To urządzenie", - "expires": "Zaufane do {date}" - } + "expires": "Zaufane do {date}", + "revokeConfirmTitle": "Przestać ufać temu urządzeniu?", + "revokeConfirmBody": "Przy następnym logowaniu z tego urządzenia ponownie poprosi o drugi składnik.", + "revokeAllConfirmTitle": "Przestać ufać wszystkim urządzeniom?", + "revokeAllConfirmBody": "Każde urządzenie przy następnym logowaniu ponownie poprosi o drugi składnik, łącznie z tym." + }, + "revokeSessionConfirmTitle": "Wylogować to urządzenie?", + "revokeSessionConfirmBody": "{device} będzie musiało zalogować się ponownie. Zapisane na nim dane pozostają bez zmian.", + "signOutEverywhereConfirmTitle": "Wylogować wszystkie pozostałe urządzenia?", + "signOutEverywhereConfirmBody": "Każde urządzenie poza tym będzie musiało zalogować się ponownie. To urządzenie pozostaje zalogowane. Zapisane dane pozostają bez zmian." }, "privacy": { "intro": { diff --git a/src/components/settings/__tests__/destructive-controls-confirm.test.tsx b/src/components/settings/__tests__/destructive-controls-confirm.test.tsx new file mode 100644 index 000000000..6d5b0d96c --- /dev/null +++ b/src/components/settings/__tests__/destructive-controls-confirm.test.tsx @@ -0,0 +1,67 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +/** + * Destructive settings controls ask first. + * + * Signing every other device out, and revoking a trusted device, used to fire + * on a single tap. Neither is undoable — the other devices simply have to log + * in again, and a revoked trusted device asks for a second factor next time — + * while every other destructive surface in the app confirms. On a shared + * screen a mis-tap is a support call. + * + * This reads the sources rather than rendering, because what it pins is + * structural: that these particular controls route through `ConfirmButton` + * and never call a revoke mutation straight from an `onClick`. A render test + * would prove the dialog exists in one arrangement; this proves no control on + * these cards can go back to firing directly. + */ + +const CARDS = [ + { + file: "security-sessions-card.tsx", + slots: ["revoke-session", "sign-out-everywhere"], + }, + { + file: "trusted-devices-card.tsx", + slots: ["revoke-trusted-device", "revoke-all-trusted-devices"], + }, +]; + +function source(file: string) { + return readFileSync( + join(process.cwd(), "src", "components", "settings", file), + "utf8", + ); +} + +describe("destructive session controls confirm before firing", () => { + for (const card of CARDS) { + describe(card.file, () => { + const src = source(card.file); + + it("routes every destructive control through ConfirmButton", () => { + expect(src).toContain("ConfirmButton"); + for (const slot of card.slots) { + expect(src).toContain(`slot="${slot}"`); + } + }); + + it("never fires a revoke straight from an onClick", () => { + // `onConfirm={() => revoke…}` is the confirmed path and is fine; + // `onClick={() => revoke…}` is the unguarded one this forbids. + const unguarded = src.match(/onClick=\{\(\) =>\s*revoke/g) ?? []; + expect(unguarded).toEqual([]); + }); + + it("gives each control its own confirmation body", () => { + // A shared generic body would let one control's dialog describe + // another's consequence — the reason the copy is per-control. + const bodies = src.match(/body=\{t\("([^"]+)"/g) ?? []; + expect(bodies.length).toBe(card.slots.length); + expect(new Set(bodies).size).toBe(card.slots.length); + }); + }); + } +}); diff --git a/src/components/settings/coach-reminders-section.tsx b/src/components/settings/coach-reminders-section.tsx index f00e2a5da..c8eab020b 100644 --- a/src/components/settings/coach-reminders-section.tsx +++ b/src/components/settings/coach-reminders-section.tsx @@ -18,13 +18,14 @@ * Coach surface like the rest of the memory controls. */ import { useMemo } from "react"; -import { BellRing, Check, Loader2, Trash2, X } from "lucide-react"; +import { BellRing, Check, Trash2, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; import { formatDateOrRelative } from "@/lib/format"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { ConfirmButton } from "@/components/settings/confirm-button"; import { useTranslations } from "@/lib/i18n/context"; import { useCoachReminders, @@ -129,25 +130,21 @@ export function CoachRemindersSection({ {t("settings.ai.coachReminders.dismiss")} )} - + disabled={!isAuthenticated} + pending={busy} + ariaLabel={t("settings.ai.coachReminders.deleteAria")} + label="" + icon={} + title={t("settings.ai.coachReminders.deleteConfirmTitle")} + body={t("settings.ai.coachReminders.deleteConfirmBody")} + confirmLabel={t("settings.ai.coachReminders.deleteConfirmAction")} + onConfirm={() => remove.mutate(r.id)} + /> ); diff --git a/src/components/settings/confirm-button.tsx b/src/components/settings/confirm-button.tsx new file mode 100644 index 000000000..a6673f8bd --- /dev/null +++ b/src/components/settings/confirm-button.tsx @@ -0,0 +1,119 @@ +"use client"; + +/** + * A destructive settings control that asks first. + * + * The security card's "sign out everywhere" and its per-row revoke, and the + * same pair on trusted devices, fired on a single tap with no confirmation — + * while every other destructive surface in the app confirms. Signing every + * other device out is not recoverable by an undo; the other devices simply + * have to log in again, and on a shared screen a mis-tap is a support call. + * + * Wrapping the pattern rather than inlining a fourth dialog is the point: the + * next destructive control gets the confirmation by using this, instead of by + * someone remembering to add one. + * + * The copy contract matches the rest of the app after the delete-confirmation + * sweep — say what happens, and claim no more finality than is true. Callers + * pass their own body text so each control can be specific about its own + * consequence. + */ + +import type { ReactNode } from "react"; +import { Loader2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { useTranslations } from "@/lib/i18n/context"; + +export function ConfirmButton({ + label, + title, + body, + confirmLabel, + onConfirm, + pending = false, + disabled = false, + size, + variant = "outline", + className, + icon, + ariaLabel, + slot, +}: { + /** The control's own label, before the dialog opens. */ + label: string; + title: string; + body: string; + /** The dialog's action label. Names the act, not "OK". */ + confirmLabel: string; + onConfirm: () => void; + pending?: boolean; + disabled?: boolean; + size?: "sm" | "icon"; + variant?: "outline" | "ghost"; + className?: string; + /** For an icon-only control, where `label` renders as an icon. */ + ariaLabel?: string; + icon?: ReactNode; + /** `data-slot` for tests and the design guards. */ + slot?: string; +}) { + const { t } = useTranslations(); + + return ( + + + + + + + {title} + {body} + + + {t("common.cancel")} + + {pending && ( + + )} + {confirmLabel} + + + + + ); +} diff --git a/src/components/settings/environment-section.tsx b/src/components/settings/environment-section.tsx index d129eee3b..b807a38d0 100644 --- a/src/components/settings/environment-section.tsx +++ b/src/components/settings/environment-section.tsx @@ -27,6 +27,7 @@ import { DateField } from "@/components/ui/date-field"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useAuth } from "@/hooks/use-auth"; +import { ConfirmButton } from "@/components/settings/confirm-button"; import { useTranslations } from "@/lib/i18n/context"; import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch"; import { queryKeys } from "@/lib/query-keys"; @@ -325,19 +326,26 @@ export function EnvironmentSection() { {tr.label} · {tr.startDate} – {tr.endDate} - + onConfirm={() => removeTravel.mutate(tr.id)} + /> ))} diff --git a/src/components/settings/security-sessions-card.tsx b/src/components/settings/security-sessions-card.tsx index da33aaa07..e17ef4d2d 100644 --- a/src/components/settings/security-sessions-card.tsx +++ b/src/components/settings/security-sessions-card.tsx @@ -13,8 +13,8 @@ import { useId, useState } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { ChevronDown, Loader2, MonitorSmartphone } from "lucide-react"; -import { Button } from "@/components/ui/button"; import { SettingsCard } from "@/components/settings/settings-card"; +import { ConfirmButton } from "@/components/settings/confirm-button"; import { EmptyState } from "@/components/ui/empty-state"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -168,16 +168,19 @@ export function SecuritySessionsCard({ )} {!s.isCurrent && ( - + label={t("settings.security.revokeSession")} + title={t("settings.security.revokeSessionConfirmTitle")} + body={t("settings.security.revokeSessionConfirmBody", { + device: s.device, + })} + confirmLabel={t("settings.security.revokeSession")} + onConfirm={() => revokeOne.mutate(s.id)} + pending={revokeOne.isPending} + /> )} ))} @@ -186,18 +189,16 @@ export function SecuritySessionsCard({ {hasOthers && (
- + label={t("settings.security.signOutEverywhere")} + title={t("settings.security.signOutEverywhereConfirmTitle")} + body={t("settings.security.signOutEverywhereConfirmBody")} + confirmLabel={t("settings.security.signOutEverywhere")} + onConfirm={() => revokeOthers.mutate()} + pending={revokeOthers.isPending} + />
)} diff --git a/src/components/settings/trusted-devices-card.tsx b/src/components/settings/trusted-devices-card.tsx index 79d2745a7..104176f91 100644 --- a/src/components/settings/trusted-devices-card.tsx +++ b/src/components/settings/trusted-devices-card.tsx @@ -12,8 +12,8 @@ import { useState } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { Loader2, ShieldCheck } from "lucide-react"; -import { Button } from "@/components/ui/button"; import { SettingsCard } from "@/components/settings/settings-card"; +import { ConfirmButton } from "@/components/settings/confirm-button"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -130,16 +130,19 @@ export function TrustedDevicesCard({ })}

- + label={t("settings.security.trustedDevices.revoke")} + title={t( + "settings.security.trustedDevices.revokeConfirmTitle", + )} + body={t("settings.security.trustedDevices.revokeConfirmBody")} + confirmLabel={t("settings.security.trustedDevices.revoke")} + onConfirm={() => revokeOne.mutate(d.id)} + pending={revokeOne.isPending} + /> ))} @@ -147,18 +150,18 @@ export function TrustedDevicesCard({ {devices.length > 0 && (
- + body={t("settings.security.trustedDevices.revokeAllConfirmBody")} + confirmLabel={t("settings.security.trustedDevices.revokeAll")} + onConfirm={() => revokeAll.mutate()} + pending={revokeAll.isPending} + />
)}