mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user