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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 15:06:47 +02:00
1 parent b53434daa0
commit a264dee030
2 files changed
+184 -64

No files matched your search

@@ -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<typeof KeptGrantsList>[0]["grantList"]) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<KeptGrantsList
grants={GRANTS}
grantList={grantList}
revokeGrant={{ mutate: () => {}, isPending: false }}
onRevoked={() => {}}
onRevokeFailed={() => {}}
/>
</I18nProvider>,
);
}
function revokeButton(html: string): string {
const at = html.indexOf('data-slot="kept-grant-revoke"');
expect(at).toBeGreaterThan(-1);
return html.slice(html.lastIndexOf("<button", at), html.indexOf(">", 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=""/);
});
});
@@ -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({
</p>
)}
{keptGrants.length > 0 && (
<div
data-slot="sign-out-everywhere-grants-kept"
className="space-y-2 border-t pt-4"
>
<p className="text-sm font-medium">
{t("settings.security.grantsKeptTitle")}
</p>
<p className="text-muted-foreground text-sm">
{t("settings.security.grantsKeptBody")}
</p>
<ul className="divide-y">
{keptGrants.map((grant) => (
<li
key={grant.id}
className="flex items-center justify-between gap-3 py-2"
>
<div className="min-w-0">
<p className="truncate text-sm">
{accountLabel(grant.account)}
</p>
<p className="text-muted-foreground text-xs">
{t(ACCESS_LABEL_KEY[grant.access])}
</p>
</div>
{grantList ? (
<ConfirmButton
slot="kept-grant-revoke"
destructive
size="sm"
className="min-h-11 shrink-0 sm:min-h-9"
label={t("recordSharing.given.revoke")}
title={t("recordSharing.given.revokeTitle", {
name: accountLabel(grant.account),
})}
body={revokeBody({
t,
access: grant.access,
name: accountLabel(grant.account),
retentionDays: grantList.retentionDays,
})}
confirmLabel={t("recordSharing.given.revokeConfirm")}
pending={revokeGrant.isPending}
onConfirm={() =>
revokeGrant.mutate(grant.id, {
onSuccess: () =>
setKeptGrants((current) =>
current.filter((g) => g.id !== grant.id),
),
onError: () =>
setStatus(
t("recordSharing.actionError.revokeFailed"),
),
})
}
/>
) : null}
</li>
))}
</ul>
</div>
)}
<KeptGrantsList
grants={keptGrants}
grantList={grantListQuery}
revokeGrant={revokeGrant}
onRevoked={(id) =>
setKeptGrants((current) => current.filter((g) => g.id !== id))
}
onRevokeFailed={() =>
setStatus(t("recordSharing.actionError.revokeFailed"))
}
/>
</div>
</SettingsCard>
);
}
/**
* 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 (
<div
data-slot="sign-out-everywhere-grants-kept"
className="space-y-2 border-t pt-4"
>
<p className="text-sm font-medium">
{t("settings.security.grantsKeptTitle")}
</p>
<p className="text-muted-foreground text-sm">
{t("settings.security.grantsKeptBody")}
</p>
{grantList.isError && (
<QueryErrorRow
inset
error={grantList.error}
onRetry={() => void grantList.refetch()}
/>
)}
<ul className="divide-y">
{grants.map((grant) => (
<li
key={grant.id}
className="flex items-center justify-between gap-3 py-2"
>
<div className="min-w-0">
<p className="truncate text-sm">{accountLabel(grant.account)}</p>
<p className="text-muted-foreground text-xs">
{t(ACCESS_LABEL_KEY[grant.access])}
</p>
</div>
<ConfirmButton
slot="kept-grant-revoke"
destructive
size="sm"
className="min-h-11 shrink-0 sm:min-h-9"
label={t("recordSharing.given.revoke")}
title={t("recordSharing.given.revokeTitle", {
name: accountLabel(grant.account),
})}
body={
retentionDays === undefined
? ""
: revokeBody({
t,
access: grant.access,
name: accountLabel(grant.account),
retentionDays,
})
}
confirmLabel={t("recordSharing.given.revokeConfirm")}
disabled={retentionDays === undefined}
pending={revokeGrant.isPending}
onConfirm={() =>
revokeGrant.mutate(grant.id, {
onSuccess: () => onRevoked(grant.id),
onError: onRevokeFailed,
})
}
/>
</li>
))}
</ul>
</div>
);
}