mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
fix(settings): confirm before ending a grant from the sessions card
After sign-out-everywhere the sessions card lists the access grants it left standing, each with an end button that revoked on the tap. It is the same act the access card confirms, permanent for the other account, so it now asks the same question with the same disclosure, audit window included, and the row names the account the way the access card does.
This commit is contained in:
1 parent
2e653b7aa0
commit
6a9543b4f8
3 files changed
+102
-24
No files matched your search
@@ -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"],
|
||||
},
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<KeptGrant[]>([]);
|
||||
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({
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm">
|
||||
{grant.account.displayName ?? grant.account.username}
|
||||
{accountLabel(grant.account)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t(ACCESS_LABEL_KEY[grant.access])}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 shrink-0 sm:min-h-9"
|
||||
disabled={revokeGrant.isPending}
|
||||
onClick={() =>
|
||||
revokeGrant.mutate(grant.id, {
|
||||
onSuccess: () =>
|
||||
setKeptGrants((current) =>
|
||||
current.filter((g) => g.id !== grant.id),
|
||||
),
|
||||
onError: () =>
|
||||
setStatus(
|
||||
t("recordSharing.actionError.revokeFailed"),
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("recordSharing.given.revoke")}
|
||||
</Button>
|
||||
{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>
|
||||
|
||||
Reference in new issue
Block a user