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, + }) + } + /> +
  • + ))} +
+
+ ); +}