From 6a9543b4f8c6e57a9bf04f28fc285207323bf571 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 12:02:21 +0200 Subject: [PATCH] fix(settings): confirm before ending a grant from the sessions card After sign-out-everywhere the sessions card lists the access grants it left standing, each with an end button that revoked on the tap. It is the same act the access card confirms, permanent for the other account, so it now asks the same question with the same disclosure, audit window included, and the row names the account the way the access card does. --- .../destructive-control-guard.test.ts | 3 +- .../kept-grant-revoke-confirm.test.ts | 57 ++++++++++++++++ .../settings/security-sessions-card.tsx | 66 ++++++++++++------- 3 files changed, 102 insertions(+), 24 deletions(-) create mode 100644 src/components/settings/__tests__/kept-grant-revoke-confirm.test.ts diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index 0d25ccca4..7cd537ce7 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -114,7 +114,8 @@ const REGISTRY: DestructiveEntry[] = [ }, { file: "components/settings/security-sessions-card.tsx", - destroys: "one login session, or every session other than this one", + destroys: + "one login session, or every session other than this one; and, from the list sign-out-everywhere leaves behind, another account's access grant (the same revoke the access card confirms)", recovery: "permanent", confirm: ["ConfirmButton"], }, diff --git a/src/components/settings/__tests__/kept-grant-revoke-confirm.test.ts b/src/components/settings/__tests__/kept-grant-revoke-confirm.test.ts new file mode 100644 index 000000000..70cc9ee9c --- /dev/null +++ b/src/components/settings/__tests__/kept-grant-revoke-confirm.test.ts @@ -0,0 +1,57 @@ +/** + * Ending a grant from the sessions card asks first. + * + * "Sign out everywhere" leaves accepted grants standing and lists them with a + * one-click end. That end is the same act as the access card's revoke — it + * takes another account's standing access to the record, permanently — but it + * fired on the tap, while the access card asks with a dialog naming who loses + * what. The row is state set after a mutation, out of reach of a static + * render, so the wiring is read from the source: the revoke call may only sit + * inside a `ConfirmButton`'s `onConfirm`. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import ts from "typescript"; +import { describe, expect, it } from "vitest"; + +const FILE = "src/components/settings/security-sessions-card.tsx"; + +function revokeCallSites(): { attr: string | null; element: string | null }[] { + const text = readFileSync(join(process.cwd(), FILE), "utf8"); + const sf = ts.createSourceFile(FILE, text, ts.ScriptTarget.Latest, true); + const sites: { attr: string | null; element: string | null }[] = []; + const visit = (node: ts.Node) => { + if ( + ts.isCallExpression(node) && + node.expression.getText(sf) === "revokeGrant.mutate" + ) { + let attr: string | null = null; + let element: string | null = null; + for (let p: ts.Node | undefined = node.parent; p; p = p.parent) { + if (ts.isJsxAttribute(p) && attr === null) attr = p.name.getText(sf); + if ( + (ts.isJsxSelfClosingElement(p) || ts.isJsxOpeningElement(p)) && + attr !== null + ) { + element = p.tagName.getText(sf); + break; + } + } + sites.push({ attr, element }); + } + ts.forEachChild(node, visit); + }; + visit(sf); + return sites; +} + +describe("kept-grant revoke on the sessions card", () => { + it("fires only from a ConfirmButton's onConfirm", () => { + const sites = revokeCallSites(); + expect(sites.length).toBeGreaterThan(0); + for (const site of sites) { + expect(site).toEqual({ attr: "onConfirm", element: "ConfirmButton" }); + } + }); +}); diff --git a/src/components/settings/security-sessions-card.tsx b/src/components/settings/security-sessions-card.tsx index 83f5ca161..b2bf30eb6 100644 --- a/src/components/settings/security-sessions-card.tsx +++ b/src/components/settings/security-sessions-card.tsx @@ -16,7 +16,6 @@ import { ChevronDown, Loader2, MonitorSmartphone } from "lucide-react"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardActions } from "@/components/settings/_card-actions"; import { SettingsCardHeader } from "@/components/settings/_card-header"; -import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { ConfirmButton } from "@/components/ui/confirm-button"; import { EmptyState } from "@/components/ui/empty-state"; @@ -24,7 +23,12 @@ import { QueryErrorRow } from "@/components/ui/query-error-row"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiGet, apiDelete } from "@/lib/api/api-fetch"; -import { useRevokeGrant } from "@/lib/queries/use-account-grants"; +import { + useAccountGrants, + useRevokeGrant, +} from "@/lib/queries/use-account-grants"; +import { revokeBody } from "@/components/settings/access/grants-given-card"; +import { accountLabel } from "@/lib/sharing/account-access-view"; import { cn } from "@/lib/utils"; /** An accepted grant sign-out-everywhere left standing (`grantsKept`). */ @@ -75,6 +79,10 @@ export function SecuritySessionsCard({ // a one-click end instead. Held only until the card unmounts. const [keptGrants, setKeptGrants] = useState([]); const revokeGrant = useRevokeGrant(); + // Ending one of them is the same act as on the access card, so it asks the + // same question with the same disclosure — and that names the audit window, + // which rides the grant list. Read only once there is a row to end. + const { data: grantList } = useAccountGrants(keptGrants.length > 0); const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: queryKeys.sessions(), @@ -295,32 +303,44 @@ export function SecuritySessionsCard({ >

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

{t(ACCESS_LABEL_KEY[grant.access])}

- + {grantList ? ( + + revokeGrant.mutate(grant.id, { + onSuccess: () => + setKeptGrants((current) => + current.filter((g) => g.id !== grant.id), + ), + onError: () => + setStatus( + t("recordSharing.actionError.revokeFailed"), + ), + }) + } + /> + ) : null} ))}