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} ))}