From a264dee030826221bf8ec22bcebb017ddd748f23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 15:06:47 +0200 Subject: [PATCH] fix(settings): keep the end-access button while the grant list loads The button for a grant that sign-out-everywhere left standing rendered only once the grant list (which carries the audit window its confirmation names) had loaded, so while it loaded, and for good when the read failed, the row explained the access but offered no way to end it. The button now shows disabled until the list arrives, and a failed read draws the standard error row with a retry. --- .../__tests__/kept-grants-list.test.tsx | 70 +++++++ .../settings/security-sessions-card.tsx | 178 +++++++++++------- 2 files changed, 184 insertions(+), 64 deletions(-) create mode 100644 src/components/settings/__tests__/kept-grants-list.test.tsx diff --git a/src/components/settings/__tests__/kept-grants-list.test.tsx b/src/components/settings/__tests__/kept-grants-list.test.tsx new file mode 100644 index 000000000..92adedb26 --- /dev/null +++ b/src/components/settings/__tests__/kept-grants-list.test.tsx @@ -0,0 +1,70 @@ +/** + * The "end access" button of a grant sign-out-everywhere left standing. + * + * Its confirmation names the audit window, which rides the grant list. The + * row used to render the button only once that list had loaded, so while it + * loaded, and for good when the read failed, the row offered no way to end + * the access it had just explained. Now the button is there, disabled, until + * the list arrives, and a failed read draws the standard error row. + */ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { KeptGrantsList, type KeptGrant } from "../security-sessions-card"; + +const GRANTS: KeptGrant[] = [ + { + id: "g1", + account: { id: "a1", username: "carer", displayName: null }, + access: "WRITE", + }, +]; + +function render(grantList: Parameters[0]["grantList"]) { + return renderToStaticMarkup( + + {}, isPending: false }} + onRevoked={() => {}} + onRevokeFailed={() => {}} + /> + , + ); +} + +function revokeButton(html: string): string { + const at = html.indexOf('data-slot="kept-grant-revoke"'); + expect(at).toBeGreaterThan(-1); + return html.slice(html.lastIndexOf("", at)); +} + +describe("KeptGrantsList", () => { + it("shows the button disabled while the grant list loads", () => { + const html = render({ isError: false, error: null, refetch: () => {} }); + expect(revokeButton(html)).toMatch(/\sdisabled=""/); + expect(html).not.toContain('data-slot="query-error-row"'); + }); + + it("keeps the button and draws the error row when the read fails", () => { + const html = render({ + isError: true, + error: new Error("boom"), + refetch: () => {}, + }); + expect(revokeButton(html)).toMatch(/\sdisabled=""/); + expect(html).toContain("query-error-row"); + }); + + it("enables the button once the audit window is known", () => { + const html = render({ + data: { retentionDays: 90 }, + isError: false, + error: null, + refetch: () => {}, + }); + expect(revokeButton(html)).not.toMatch(/\sdisabled=""/); + }); +}); diff --git a/src/components/settings/security-sessions-card.tsx b/src/components/settings/security-sessions-card.tsx index b2bf30eb6..4084b4882 100644 --- a/src/components/settings/security-sessions-card.tsx +++ b/src/components/settings/security-sessions-card.tsx @@ -32,7 +32,7 @@ import { accountLabel } from "@/lib/sharing/account-access-view"; import { cn } from "@/lib/utils"; /** An accepted grant sign-out-everywhere left standing (`grantsKept`). */ -interface KeptGrant { +export interface KeptGrant { id: string; account: { id: string; username: string; displayName: string | null }; access: "READ" | "WRITE" | "MANAGE"; @@ -82,7 +82,7 @@ export function SecuritySessionsCard({ // 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 grantListQuery = useAccountGrants(keptGrants.length > 0); const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: queryKeys.sessions(), @@ -284,69 +284,119 @@ export function SecuritySessionsCard({

)} - {keptGrants.length > 0 && ( -
-

- {t("settings.security.grantsKeptTitle")} -

-

- {t("settings.security.grantsKeptBody")} -

-
    - {keptGrants.map((grant) => ( -
  • -
    -

    - {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} -
  • - ))} -
-
- )} + + setKeptGrants((current) => current.filter((g) => g.id !== id)) + } + onRevokeFailed={() => + setStatus(t("recordSharing.actionError.revokeFailed")) + } + /> ); } + +/** + * The grants "sign out everywhere" left standing, each with its "end access" + * button. The button asks the access card's question, whose disclosure names + * the audit window off the grant list; until that list has loaded the button + * is shown disabled rather than left out, and a failed read says so with a + * retry instead of leaving the row without its action. + */ +export function KeptGrantsList({ + grants, + grantList, + revokeGrant, + onRevoked, + onRevokeFailed, +}: { + grants: readonly KeptGrant[]; + grantList: { + data?: { retentionDays: number }; + isError: boolean; + error: unknown; + refetch: () => unknown; + }; + /** The revoke mutation; called only from the confirmation's action. */ + revokeGrant: { + mutate: ( + grantId: string, + callbacks: { onSuccess: () => void; onError: () => void }, + ) => void; + isPending: boolean; + }; + onRevoked: (grantId: string) => void; + onRevokeFailed: () => void; +}) { + const { t } = useTranslations(); + if (grants.length === 0) return null; + const retentionDays = grantList.data?.retentionDays; + return ( +
+

+ {t("settings.security.grantsKeptTitle")} +

+

+ {t("settings.security.grantsKeptBody")} +

+ {grantList.isError && ( + void grantList.refetch()} + /> + )} +
    + {grants.map((grant) => ( +
  • +
    +

    {accountLabel(grant.account)}

    +

    + {t(ACCESS_LABEL_KEY[grant.access])} +

    +
    + + revokeGrant.mutate(grant.id, { + onSuccess: () => onRevoked(grant.id), + onError: onRevokeFailed, + }) + } + /> +
  • + ))} +
+
+ ); +}