From 670e7097a200e2980c540fd8782c9a250ab6a0dd Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 19 Jul 2026 12:46:04 +0200
Subject: [PATCH] fix(settings): destructive session and device controls ask
before firing
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Signing every other device out, revoking a single session, revoking a
trusted device and revoking them all each fired on a single tap. None is
undoable — the other devices simply have to sign in again, and a revoked
trusted device asks for its second factor next time — while every other
destructive surface in the app confirms first. On a shared screen a
mis-tap is a support call.
A sweep of the rest of the tree found two more hard deletes with neither
a confirmation nor an undo: a Coach reminder and a travel entry. Both now
confirm. Deleting a document deliberately does not: it soft-deletes and
offers an undo, which covers the mis-tap.
The four controls share one ConfirmButton rather than four copies of the
dialog, so the next destructive control gets the confirmation by using
it instead of by someone remembering. Copy is per control and states the
actual consequence — no claim of more finality than is true.
---
messages/de.json | 21 +++-
messages/en.json | 21 +++-
messages/es.json | 21 +++-
messages/fr.json | 21 +++-
messages/it.json | 21 +++-
messages/pl.json | 21 +++-
.../destructive-controls-confirm.test.tsx | 67 ++++++++++
.../settings/coach-reminders-section.tsx | 31 +++--
src/components/settings/confirm-button.tsx | 119 ++++++++++++++++++
.../settings/environment-section.tsx | 24 ++--
.../settings/security-sessions-card.tsx | 41 +++---
.../settings/trusted-devices-card.tsx | 41 +++---
12 files changed, 361 insertions(+), 88 deletions(-)
create mode 100644 src/components/settings/__tests__/destructive-controls-confirm.test.tsx
create mode 100644 src/components/settings/confirm-button.tsx
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}
- }
+ pending={removeTravel.isPending}
+ title={t(
+ "settings.sections.environment.travel.removeConfirmTitle",
+ )}
+ body={t(
+ "settings.sections.environment.travel.removeConfirmBody",
+ )}
+ confirmLabel={t(
"settings.sections.environment.travel.remove",
)}
- disabled={removeTravel.isPending}
- onClick={() => removeTravel.mutate(tr.id)}
- >
-
-
+ 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 && (