mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
feat: describe managed sharing activity
Every action a manage-level route files now renders as its own sentence in the owner's activity view, in all six locales. A write delegate can only add, so the generic "made a change" was at least the right shape of fact; a manager changes and removes entries the owner made themselves, and the generic line withholds the only part that matters — whether a reading was corrected or deleted, whether a dose history was purged. The inventory reads the routes and calls the map rather than matching `case` in its source, so an action that falls through to the fallback fails, and it follows each resolved key into all six bundles. Every sentence interpolates the actor's name and nothing else: the audit table is not encrypted and a value in a verb line would be a health figure in a second store. The revoke dialog's retention disclosure no longer depends on which of two requests answers first. `retentionDays` rides the grant list, so a client holding a row to revoke holds the window it must state, and the branch that omitted the sentence is gone.
This commit is contained in:
1 parent
25b1b0eab6
commit
c92a80ee4c
15 files changed
+1131
-61
No files matched your search
@@ -19,7 +19,19 @@ import type {
|
||||
RecordActivity,
|
||||
} from "@/lib/queries/use-account-grants";
|
||||
|
||||
const grantsRef: { value: GrantList } = { value: { given: [], received: [] } };
|
||||
const grantsRef: { value: GrantList } = { value: grantList() };
|
||||
/**
|
||||
* A grant payload, with the audit window every real one carries.
|
||||
*
|
||||
* v1.37.0 — `retentionDays` rides `GET /api/account/grants` so the revoke
|
||||
* dialog can state how long attribution survives without waiting on a second
|
||||
* request. A fixture that omitted it would be describing a payload the server
|
||||
* cannot send.
|
||||
*/
|
||||
function grantList(partial: Partial<GrantList> = {}): GrantList {
|
||||
return { given: [], received: [], retentionDays: 365, ...partial };
|
||||
}
|
||||
|
||||
const activityRef: { value: RecordActivity | undefined } = { value: undefined };
|
||||
|
||||
vi.mock("@/lib/queries/use-account-grants", async (importOriginal) => {
|
||||
@@ -75,7 +87,7 @@ const BASE: GrantRow = {
|
||||
|
||||
describe("who can open my record", () => {
|
||||
it("says so honestly when nobody can", () => {
|
||||
grantsRef.value = { given: [], received: [] };
|
||||
grantsRef.value = grantList();
|
||||
const html = render(<GrantsGivenCard />);
|
||||
expect(html).toContain("Nobody else can open your record");
|
||||
expect(html).not.toContain('data-slot="grants-given-list"');
|
||||
@@ -86,7 +98,7 @@ describe("who can open my record", () => {
|
||||
// produced. A panel that compared `expiresAt` to the clock would be a
|
||||
// second opinion about somebody's access to a health record, and the two
|
||||
// would disagree first in the dangerous direction.
|
||||
grantsRef.value = {
|
||||
grantsRef.value = grantList({
|
||||
given: [
|
||||
{ ...BASE, id: "a", state: "ACTIVE" },
|
||||
{ ...BASE, id: "p", state: "PENDING", acceptedAt: null },
|
||||
@@ -98,7 +110,7 @@ describe("who can open my record", () => {
|
||||
},
|
||||
],
|
||||
received: [],
|
||||
};
|
||||
});
|
||||
const html = render(<GrantsGivenCard />);
|
||||
expect(html).toContain('data-grant-state="ACTIVE"');
|
||||
expect(html).toContain('data-grant-state="PENDING"');
|
||||
@@ -109,7 +121,7 @@ describe("who can open my record", () => {
|
||||
// The row IS the consent record. "Who had access, from when to when, and
|
||||
// who ended it" is the question the panel exists to answer, and a list
|
||||
// that dropped the ended rows could not answer it.
|
||||
grantsRef.value = {
|
||||
grantsRef.value = grantList({
|
||||
given: [
|
||||
{
|
||||
...BASE,
|
||||
@@ -119,14 +131,14 @@ describe("who can open my record", () => {
|
||||
},
|
||||
],
|
||||
received: [],
|
||||
};
|
||||
});
|
||||
const html = render(<GrantsGivenCard />);
|
||||
expect(html).toContain('data-grant-state="REVOKED"');
|
||||
expect(html).toContain("You ended it");
|
||||
});
|
||||
|
||||
it("distinguishes a withdrawal from a handover", () => {
|
||||
grantsRef.value = {
|
||||
grantsRef.value = grantList({
|
||||
given: [
|
||||
{
|
||||
...BASE,
|
||||
@@ -136,7 +148,7 @@ describe("who can open my record", () => {
|
||||
},
|
||||
],
|
||||
received: [],
|
||||
};
|
||||
});
|
||||
const html = render(<GrantsGivenCard />);
|
||||
// Two facts, one column. A trail that conflated them would misread an
|
||||
// ordinary handover as a falling-out.
|
||||
@@ -145,20 +157,20 @@ describe("who can open my record", () => {
|
||||
});
|
||||
|
||||
it("offers no way to end a grant that has already ended", () => {
|
||||
grantsRef.value = {
|
||||
grantsRef.value = grantList({
|
||||
given: [{ ...BASE, state: "REVOKED", revokedAt: "2026-02-01T09:00:00Z" }],
|
||||
received: [],
|
||||
};
|
||||
});
|
||||
const ended = render(<GrantsGivenCard />);
|
||||
expect(ended).not.toContain('data-slot="grant-revoke"');
|
||||
|
||||
grantsRef.value = { given: [BASE], received: [] };
|
||||
grantsRef.value = grantList({ given: [BASE] });
|
||||
const live = render(<GrantsGivenCard />);
|
||||
expect(live).toContain('data-slot="grant-revoke"');
|
||||
});
|
||||
|
||||
it("says a live grant has never been used rather than leaving a blank", () => {
|
||||
grantsRef.value = { given: [{ ...BASE, lastUsedAt: null }], received: [] };
|
||||
grantsRef.value = grantList({ given: [{ ...BASE, lastUsedAt: null }] });
|
||||
const html = render(<GrantsGivenCard />);
|
||||
expect(html).toContain("never");
|
||||
});
|
||||
@@ -166,10 +178,10 @@ describe("who can open my record", () => {
|
||||
|
||||
describe("records I can open", () => {
|
||||
it("offers acceptance on an invitation and nothing else", () => {
|
||||
grantsRef.value = {
|
||||
grantsRef.value = grantList({
|
||||
given: [],
|
||||
received: [{ ...BASE, state: "PENDING", acceptedAt: null }],
|
||||
};
|
||||
});
|
||||
const html = render(<GrantsReceivedCard />);
|
||||
expect(html).toContain('data-slot="grant-accept"');
|
||||
// Nothing to open and nothing to hand back: an invitation confers nothing.
|
||||
@@ -178,7 +190,7 @@ describe("records I can open", () => {
|
||||
});
|
||||
|
||||
it("offers opening and handing back once it is active", () => {
|
||||
grantsRef.value = { given: [], received: [BASE] };
|
||||
grantsRef.value = grantList({ received: [BASE] });
|
||||
const html = render(<GrantsReceivedCard />);
|
||||
expect(html).toContain('data-slot="grant-open"');
|
||||
expect(html).toContain('data-account-id="acct-1"');
|
||||
|
||||
@@ -21,7 +21,18 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { GrantList, GrantRow } from "@/lib/queries/use-account-grants";
|
||||
|
||||
const grantsRef: { value: GrantList } = { value: { given: [], received: [] } };
|
||||
const grantsRef: { value: GrantList } = { value: grantList() };
|
||||
/**
|
||||
* A grant payload, with the audit window every real one carries.
|
||||
*
|
||||
* v1.37.0 — `retentionDays` rides `GET /api/account/grants` so the revoke
|
||||
* dialog can state how long attribution survives without waiting on a second
|
||||
* request. A fixture that omitted it would be describing a payload the server
|
||||
* cannot send.
|
||||
*/
|
||||
function grantList(partial: Partial<GrantList> = {}): GrantList {
|
||||
return { given: [], received: [], retentionDays: 365, ...partial };
|
||||
}
|
||||
|
||||
vi.mock("@/lib/queries/use-account-grants", async (importOriginal) => {
|
||||
const actual =
|
||||
@@ -154,10 +165,10 @@ describe("offering a level", () => {
|
||||
|
||||
describe("accepting a level", () => {
|
||||
it("states the level and what accepting it means before the button", () => {
|
||||
grantsRef.value = {
|
||||
grantsRef.value = grantList({
|
||||
given: [],
|
||||
received: [{ ...BASE, access: "WRITE" }],
|
||||
};
|
||||
});
|
||||
const html = render(<GrantsReceivedCard />);
|
||||
|
||||
expect(html).toContain('data-grant-access="WRITE"');
|
||||
@@ -170,7 +181,7 @@ describe("accepting a level", () => {
|
||||
});
|
||||
|
||||
it("asks a manage invitation for its own, heavier consent", () => {
|
||||
grantsRef.value = { given: [], received: [{ ...BASE, access: "MANAGE" }] };
|
||||
grantsRef.value = grantList({ received: [{ ...BASE, access: "MANAGE" }] });
|
||||
const html = render(<GrantsReceivedCard />);
|
||||
|
||||
expect(html).toContain('data-grant-access="MANAGE"');
|
||||
@@ -188,7 +199,7 @@ describe("accepting a level", () => {
|
||||
});
|
||||
|
||||
it("does not ask a read invitation for a write consent", () => {
|
||||
grantsRef.value = { given: [], received: [{ ...BASE, access: "READ" }] };
|
||||
grantsRef.value = grantList({ received: [{ ...BASE, access: "READ" }] });
|
||||
const html = render(<GrantsReceivedCard />);
|
||||
expect(html).toContain('data-grant-access="READ"');
|
||||
expect(html).not.toContain('data-slot="grant-write-consent"');
|
||||
@@ -196,7 +207,7 @@ describe("accepting a level", () => {
|
||||
|
||||
it("stops saying it once the invitation has been answered", () => {
|
||||
// A sentence about a decision already taken is nagging, not consent.
|
||||
grantsRef.value = {
|
||||
grantsRef.value = grantList({
|
||||
given: [],
|
||||
received: [
|
||||
{
|
||||
@@ -206,7 +217,7 @@ describe("accepting a level", () => {
|
||||
acceptedAt: "2026-01-02T11:00:00.000Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
const html = render(<GrantsReceivedCard />);
|
||||
expect(html).not.toContain('data-slot="grant-write-consent"');
|
||||
});
|
||||
@@ -254,11 +265,24 @@ describe("ending an access, and what it says about what was entered", () => {
|
||||
expect(body).toContain('revokeAttribution({"days":90})');
|
||||
});
|
||||
|
||||
it("omits the window rather than inventing one", () => {
|
||||
// The number is the operator's setting. A compiled-in 365 here would be
|
||||
// exactly the invented value the sentence exists to avoid.
|
||||
const body = revokeBody({ t, access: "WRITE", name: "Jo" });
|
||||
it("states the operator's window rather than a compiled-in one", () => {
|
||||
// The number is the operator's setting, so an instance running 90 days
|
||||
// says 90 and this file would fail on a hardcoded 365.
|
||||
//
|
||||
// v1.37.0 replaced the case that used to live here. It asserted that the
|
||||
// sentence was OMITTED when the number had not arrived, which was true and
|
||||
// was the defect: the number came off a second query, so whether an owner
|
||||
// was told how long attribution survives depended on which of two requests
|
||||
// landed first. The window rides the grant list now — a caller with a row
|
||||
// to revoke has it — so there is no absent case left to assert.
|
||||
const body = revokeBody({
|
||||
t,
|
||||
access: "WRITE",
|
||||
name: "Jo",
|
||||
retentionDays: 7,
|
||||
});
|
||||
expect(body).toContain("recordSharing.given.revokeEntriesStay");
|
||||
expect(body).not.toContain("revokeAttribution");
|
||||
expect(body).toContain('revokeAttribution({"days":7})');
|
||||
expect(body).not.toContain("365");
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,6 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { accountLabel } from "@/lib/sharing/account-access-view";
|
||||
import {
|
||||
useAccountGrants,
|
||||
useRecordActivity,
|
||||
useRevokeGrant,
|
||||
type GrantRow,
|
||||
} from "@/lib/queries/use-account-grants";
|
||||
@@ -39,12 +38,6 @@ export function GrantsGivenCard() {
|
||||
const { t } = useTranslations();
|
||||
const { data, isLoading, isError, refetch } = useAccountGrants();
|
||||
const revoke = useRevokeGrant();
|
||||
// Read for one number: how long the activity view can still say who entered
|
||||
// what. The activity card on this same page holds the query, so this is the
|
||||
// cached answer rather than a second request — and when the server has not
|
||||
// answered yet, the sentence that would need the number is left out instead
|
||||
// of being written around a guessed 365.
|
||||
const activity = useRecordActivity();
|
||||
|
||||
const given = data?.given ?? [];
|
||||
|
||||
@@ -95,6 +88,10 @@ export function GrantsGivenCard() {
|
||||
// Only a live grant can be ended. An already-ended row keeps its history
|
||||
// and loses its button, rather than offering an act that would 409.
|
||||
if (grant.state !== "ACTIVE" && grant.state !== "PENDING") return null;
|
||||
// The row and the window arrive together. A grant that can be revoked came
|
||||
// out of a resolved payload, so there is no state in which this control
|
||||
// exists and the number behind its disclosure does not.
|
||||
if (!data) return null;
|
||||
const name = accountLabel(grant.account);
|
||||
return (
|
||||
<ConfirmButton
|
||||
@@ -110,7 +107,7 @@ export function GrantsGivenCard() {
|
||||
t,
|
||||
access: grant.access,
|
||||
name,
|
||||
retentionDays: activity.data?.retentionDays,
|
||||
retentionDays: data.retentionDays,
|
||||
})}
|
||||
confirmLabel={t("recordSharing.given.revokeConfirm")}
|
||||
pending={revoke.isPending}
|
||||
@@ -134,10 +131,17 @@ export function GrantsGivenCard() {
|
||||
* condition is "not READ" rather than "is WRITE", so the level that can also
|
||||
* change and remove entries carries both sentences too — for a manage grant
|
||||
* the question "what happens to what they did" is the sharper one, not the
|
||||
* softer. The last sentence is the honest bound on attribution — the activity
|
||||
* softer. The last sentence is the honest bound on attribution: the activity
|
||||
* view answers "who entered what" for as long as the audit window reaches, and
|
||||
* that window is the operator's setting. It is said with the number the server
|
||||
* resolved, or not said at all.
|
||||
* that window is the operator's setting.
|
||||
*
|
||||
* v1.37.0 — `retentionDays` is required, and that is the whole fix. It used to
|
||||
* be optional and came from a second query, so the disclosure appeared only
|
||||
* when that query happened to have answered: two owners performing the same
|
||||
* act read different paragraphs, and the one who read less had no way to tell.
|
||||
* The number rides the grant list now, so a caller with a grant to revoke
|
||||
* cannot be without it and this function has no branch that omits the
|
||||
* sentence.
|
||||
*/
|
||||
export function revokeBody({
|
||||
t,
|
||||
@@ -148,17 +152,15 @@ export function revokeBody({
|
||||
t: (key: string, params?: Record<string, string | number>) => string;
|
||||
access: GrantRow["access"];
|
||||
name: string;
|
||||
/** Undefined until the server has said what the window is. */
|
||||
retentionDays?: number;
|
||||
/** The audit window, off the same payload the row came from. */
|
||||
retentionDays: number;
|
||||
}): string {
|
||||
const lines = [t("recordSharing.given.revokeBody", { name })];
|
||||
if (access !== "READ") {
|
||||
lines.push(t("recordSharing.given.revokeEntriesStay"));
|
||||
if (retentionDays !== undefined) {
|
||||
lines.push(
|
||||
t("recordSharing.given.revokeAttribution", { days: retentionDays }),
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
t("recordSharing.given.revokeAttribution", { days: retentionDays }),
|
||||
);
|
||||
}
|
||||
return lines.join(" ");
|
||||
}
|
||||
Reference in new issue
Block a user