mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge pull request #690 from MBombeck/work/sharing-client
Account sharing, the part people touch
This commit is contained in:
60 files changed
+5648
-174
No files matched your search
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* What the navigation offers inside somebody else's record.
|
||||
*
|
||||
* Paint, not enforcement — every route behind a hidden entry refuses on its
|
||||
* own, from a frozen allowlist this model cannot reach. What these pin is that
|
||||
* the line is drawn in ONE place: the mobile bar's fixed slots, the sidebar,
|
||||
* the More hub and the shell's deep-link guard all ask the same list, so they
|
||||
* cannot end up disagreeing about what a delegate is offered.
|
||||
*
|
||||
* The default is the safe one. A destination that says nothing about sharing
|
||||
* drops out, so a surface added later is hidden until somebody has thought
|
||||
* about it rather than exposed until somebody notices.
|
||||
*
|
||||
* Mutation check, run: flip the default in `isNavDestinationVisible` so an
|
||||
* unflagged destination survives a switch → "hides everything that has not
|
||||
* been classified" goes red naming Insights and the Coach.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
BOTTOM_NAV_PRIMARY_SLOT_HREFS,
|
||||
NAV_DESTINATIONS,
|
||||
isDestinationInSharedRecord,
|
||||
mobileMoreHubDestinations,
|
||||
visibleNavDestinations,
|
||||
visibleUtilityDestinations,
|
||||
} from "../nav-model";
|
||||
|
||||
/** Everything enabled, so the module gate never masks the sharing gate. */
|
||||
const ALL_MODULES = Object.fromEntries(
|
||||
NAV_DESTINATIONS.filter((d) => d.requiresModule).map((d) => [
|
||||
d.requiresModule as string,
|
||||
true,
|
||||
]),
|
||||
);
|
||||
|
||||
function hrefsInSharedRecord(): string[] {
|
||||
return visibleNavDestinations(ALL_MODULES, true, true).map((d) => d.href);
|
||||
}
|
||||
|
||||
describe("the destination list under a switch", () => {
|
||||
it("still offers the health record itself", () => {
|
||||
const hrefs = hrefsInSharedRecord();
|
||||
// The non-zero proof for every negative assertion below: a gate that hid
|
||||
// everything would satisfy all of them and leave a delegate with a blank
|
||||
// app.
|
||||
expect(hrefs.length).toBeGreaterThan(5);
|
||||
expect(hrefs).toContain("/");
|
||||
expect(hrefs).toContain("/measurements");
|
||||
expect(hrefs).toContain("/medications");
|
||||
expect(hrefs).toContain("/labs");
|
||||
});
|
||||
|
||||
it("hides everything that has not been classified", () => {
|
||||
const hrefs = hrefsInSharedRecord();
|
||||
const unflagged = NAV_DESTINATIONS.filter((d) => !d.sharedRecord).map(
|
||||
(d) => d.href,
|
||||
);
|
||||
// Non-zero: if every destination were flagged this loop would assert
|
||||
// nothing at all.
|
||||
expect(unflagged.length).toBeGreaterThan(0);
|
||||
for (const href of unflagged) {
|
||||
expect(
|
||||
hrefs,
|
||||
`${href} should not be offered inside a shared record`,
|
||||
).not.toContain(href);
|
||||
}
|
||||
});
|
||||
|
||||
it("hides the AI surfaces by name", () => {
|
||||
// Not incidental. Server-managed LLM egress of a person's health data
|
||||
// rides the consent THAT person gave for their own use, so a delegate
|
||||
// triggering it would create a consent-shaped act the owner never made.
|
||||
const hrefs = hrefsInSharedRecord();
|
||||
expect(hrefs).not.toContain("/insights");
|
||||
expect(hrefs).not.toContain("/coach");
|
||||
});
|
||||
|
||||
it("changes nothing for a session in its own record", () => {
|
||||
const own = visibleNavDestinations(ALL_MODULES, true).map((d) => d.href);
|
||||
expect(own).toEqual(NAV_DESTINATIONS.map((d) => d.href));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the utility tail under a switch", () => {
|
||||
it("disappears entirely", () => {
|
||||
// Settings holds credentials, integrations, notification channels and
|
||||
// grant management; Notifications is the delegate's own device business.
|
||||
expect(visibleUtilityDestinations({ sharedRecord: true })).toEqual([]);
|
||||
expect(visibleUtilityDestinations().length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the mobile bar under a switch", () => {
|
||||
it("drops the same entries from the More hub", () => {
|
||||
const hub = mobileMoreHubDestinations({
|
||||
modules: ALL_MODULES,
|
||||
sharedRecord: true,
|
||||
}).map((d) => d.href);
|
||||
expect(hub.length).toBeGreaterThan(0);
|
||||
expect(hub).not.toContain("/coach");
|
||||
expect(hub).toContain("/measurements");
|
||||
for (const slot of BOTTOM_NAV_PRIMARY_SLOT_HREFS) {
|
||||
expect(hub).not.toContain(slot);
|
||||
}
|
||||
});
|
||||
|
||||
it("answers for the fixed Insights slot from the same list", () => {
|
||||
// The bar carries its own literal for the fixed slots. Having it ASK the
|
||||
// model rather than carry a second answer is what stops the two surfaces
|
||||
// drifting — which is the whole reason this module exists.
|
||||
expect(isDestinationInSharedRecord("/insights")).toBe(false);
|
||||
expect(isDestinationInSharedRecord("/medications")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the deep-link guard reads the same list", () => {
|
||||
it("covers a destination and everything beneath it", () => {
|
||||
expect(isDestinationInSharedRecord("/measurements")).toBe(true);
|
||||
expect(isDestinationInSharedRecord("/measurements/abc123")).toBe(true);
|
||||
expect(isDestinationInSharedRecord("/labs/panel/7")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches the dashboard exactly, so it does not swallow the app", () => {
|
||||
// `"/"` is a destination like any other, and a prefix match on it would
|
||||
// report every path in the product as covered — including /settings.
|
||||
expect(isDestinationInSharedRecord("/")).toBe(true);
|
||||
expect(isDestinationInSharedRecord("/settings/account")).toBe(false);
|
||||
expect(isDestinationInSharedRecord("/notifications")).toBe(false);
|
||||
});
|
||||
|
||||
it("answers false for a path no destination claims", () => {
|
||||
// A surface nobody has classified is not one to open inside somebody
|
||||
// else's record.
|
||||
expect(isDestinationInSharedRecord("/some/new/surface")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* The chrome that tells somebody whose record they are in.
|
||||
*
|
||||
* These render the real components to markup rather than asserting on their
|
||||
* source, because the property that matters is what a person sees. The suite
|
||||
* runs SSR-only (`@testing-library/react` is not a dependency here), so what
|
||||
* is proven is the paint: the banner appears exactly when the server says the
|
||||
* session is inside a record, it names the person, and the switcher offers
|
||||
* exactly the accounts the server published. What is NOT proven here is the
|
||||
* click — the switch itself is driven end to end in
|
||||
* `e2e/account-sharing.spec.ts`.
|
||||
*
|
||||
* Mutation checks, run:
|
||||
* - make the banner render unconditionally (drop the `if (!active)` bail) →
|
||||
* "renders nothing in the caller's own record" goes red.
|
||||
* - make the switcher ignore `canSwitch` → "renders nothing when nothing is
|
||||
* shared" goes red.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { AccountAccess } from "@/lib/sharing/account-access-view";
|
||||
|
||||
const mockAccessRef: { value: AccountAccess } = {
|
||||
value: { accounts: [], active: null, canSwitch: false },
|
||||
};
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
id: "delegate",
|
||||
username: "delegate",
|
||||
email: null,
|
||||
role: "USER",
|
||||
avatarUrl: null,
|
||||
modules: {},
|
||||
accountAccess: mockAccessRef.value,
|
||||
},
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useLogout: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-account-switch", () => ({
|
||||
useAccountSwitch: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
import { AccountSwitcherMenuItems } from "../account-switcher-menu";
|
||||
import { SharedRecordBanner } from "../shared-record-banner";
|
||||
|
||||
const OWNER = {
|
||||
accountId: "acct-owner",
|
||||
username: "grandma",
|
||||
displayName: "Margarethe",
|
||||
access: "read" as const,
|
||||
canWrite: false,
|
||||
};
|
||||
|
||||
function render(access: AccountAccess, node: React.ReactNode): string {
|
||||
mockAccessRef.value = access;
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<SharedRecordBanner>", () => {
|
||||
it("renders nothing in the caller's own record", () => {
|
||||
const html = render(
|
||||
{ accounts: [OWNER], active: null, canSwitch: true },
|
||||
<SharedRecordBanner />,
|
||||
);
|
||||
// Holding a grant is not being inside the record. A banner that appeared
|
||||
// on the strength of the list would claim a context the session is not in.
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("names the person whose record is open", () => {
|
||||
const html = render(
|
||||
{ accounts: [OWNER], active: OWNER, canSwitch: true },
|
||||
<SharedRecordBanner />,
|
||||
);
|
||||
expect(html).toContain('data-slot="shared-record-banner"');
|
||||
expect(html).toContain('data-account-id="acct-owner"');
|
||||
// The display name, not the username: this is the line somebody reads
|
||||
// before they log a blood pressure.
|
||||
expect(html).toContain("Margarethe");
|
||||
});
|
||||
|
||||
it("falls back to the username when the account set no display name", () => {
|
||||
const html = render(
|
||||
{
|
||||
accounts: [{ ...OWNER, displayName: null }],
|
||||
active: { ...OWNER, displayName: null },
|
||||
canSwitch: true,
|
||||
},
|
||||
<SharedRecordBanner />,
|
||||
);
|
||||
// Never an unnamed banner. "You are in 's record" is worse than useless.
|
||||
expect(html).toContain("grandma");
|
||||
});
|
||||
|
||||
it("says the access is read-only, and carries the way out", () => {
|
||||
const html = render(
|
||||
{ accounts: [OWNER], active: OWNER, canSwitch: true },
|
||||
<SharedRecordBanner />,
|
||||
);
|
||||
expect(html).toContain('data-slot="shared-record-banner-exit"');
|
||||
expect(html).toContain("read it, not change it");
|
||||
});
|
||||
|
||||
it("drops the read-only line when the server says the grant can write", () => {
|
||||
// Resolved server-side. The banner never decides what a grant permits; the
|
||||
// day a WRITE grant exists this line stops claiming otherwise on its own.
|
||||
const writable = { ...OWNER, access: "write" as const, canWrite: true };
|
||||
const html = render(
|
||||
{ accounts: [writable], active: writable, canSwitch: true },
|
||||
<SharedRecordBanner />,
|
||||
);
|
||||
expect(html).toContain('data-slot="shared-record-banner"');
|
||||
expect(html).not.toContain("read it, not change it");
|
||||
});
|
||||
|
||||
it("uses the warning register, not a subtle tint", () => {
|
||||
// Somebody who stops noticing the banner logs their own reading into
|
||||
// another person's record. The visual register is part of the contract.
|
||||
const html = render(
|
||||
{ accounts: [OWNER], active: OWNER, canSwitch: true },
|
||||
<SharedRecordBanner />,
|
||||
);
|
||||
expect(html).toContain("bg-warning/15");
|
||||
expect(html).toContain("border-warning/40");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<AccountSwitcherMenuItems>", () => {
|
||||
it("renders nothing when nothing is shared", () => {
|
||||
const html = render(
|
||||
{ accounts: [], active: null, canSwitch: false },
|
||||
<AccountSwitcherMenuItems />,
|
||||
);
|
||||
// An account that has never used sharing sees the user menu it always saw.
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("binds canSwitch rather than the list length", () => {
|
||||
// The server publishes the boolean; the client renders it. A menu that
|
||||
// derived the affordance from `accounts.length` would be a second program
|
||||
// deciding whether this person may switch.
|
||||
const html = render(
|
||||
{ accounts: [OWNER], active: null, canSwitch: false },
|
||||
<AccountSwitcherMenuItems />,
|
||||
);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
// WHAT IS NOT PROVEN HERE. Nothing below renders the switcher's account
|
||||
// rows. Radix mounts `DropdownMenuSubContent` — and the `MenuContent` its
|
||||
// trigger requires — inside a portal, and portals do not render server-side
|
||||
// at all, so any assertion about the rows would be measuring the renderer
|
||||
// rather than the component. An earlier draft asserted on that markup and
|
||||
// passed for the wrong reason. The rows, their labels, the active tick and
|
||||
// the click are driven against a real browser in
|
||||
// `e2e/account-sharing.spec.ts`.
|
||||
//
|
||||
// What the two cases above DO prove is the gate, and they prove it sharply:
|
||||
// with the `canSwitch` bail removed they do not merely return different
|
||||
// markup, they throw, because the Radix sub-menu has no menu root here.
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { Check, Loader2, Users } from "lucide-react";
|
||||
|
||||
import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useAccountSwitch } from "@/hooks/use-account-switch";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { accountLabel } from "@/lib/sharing/account-access-view";
|
||||
|
||||
/**
|
||||
* v1.36.0 — the switcher, as a slice of the user menu.
|
||||
*
|
||||
* One component mounted in both user menus (the desktop sidebar's avatar
|
||||
* dropdown and the mobile top bar's) rather than the same block written twice.
|
||||
* The two menus have drifted before — that is the whole argument behind
|
||||
* `nav-model.ts` — and a switcher that exists on one surface and not the other
|
||||
* is a person who can enter a record on their laptop and cannot leave it on
|
||||
* their phone.
|
||||
*
|
||||
* Renders NOTHING unless the server says there is somewhere to go: `canSwitch`
|
||||
* arrives resolved on `accountAccess` and is bound directly. The list is
|
||||
* `accounts`, also resolved — the client never looks at grant rows and has none
|
||||
* to look at. An account that has never been shared with sees a user menu
|
||||
* identical to the one it saw before this feature existed.
|
||||
*
|
||||
* "Your own record" is a first-class entry rather than only a banner action:
|
||||
* the way out has to be reachable from the same menu as the way in, because
|
||||
* that is where somebody will look for it.
|
||||
*/
|
||||
export function AccountSwitcherMenuItems() {
|
||||
const { user } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const switchAccount = useAccountSwitch();
|
||||
|
||||
const access = user?.accountAccess;
|
||||
if (!access?.canSwitch) return null;
|
||||
|
||||
const activeId = access.active?.accountId ?? null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger
|
||||
data-slot="account-switcher-trigger"
|
||||
className="min-h-11 py-2 sm:min-h-9"
|
||||
>
|
||||
{switchAccount.isPending ? (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<Users className="size-4" aria-hidden="true" />
|
||||
)}
|
||||
<span className="ml-2">{t("recordSharing.switcher.label")}</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent
|
||||
data-slot="account-switcher-menu"
|
||||
className="w-60"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
data-slot="account-switcher-own"
|
||||
className="cursor-pointer"
|
||||
disabled={switchAccount.isPending}
|
||||
onClick={() => switchAccount.mutate(null)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{t("recordSharing.switcher.ownRecord")}
|
||||
</span>
|
||||
{activeId === null && (
|
||||
<span className="text-primary ml-auto text-xs">✓</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
{access.accounts.map((account) => (
|
||||
<DropdownMenuItem
|
||||
key={account.accountId}
|
||||
data-slot="account-switcher-entry"
|
||||
data-account-id={account.accountId}
|
||||
className="cursor-pointer"
|
||||
disabled={switchAccount.isPending}
|
||||
onClick={() => switchAccount.mutate(account.accountId)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate">{accountLabel(account)}</span>
|
||||
{/* Meta, so muted: what the grant lets this person do, in the
|
||||
words the server resolved rather than a level code. */}
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{account.canWrite
|
||||
? t("recordSharing.switcher.canWrite")
|
||||
: t("recordSharing.switcher.readOnly")}
|
||||
</span>
|
||||
</div>
|
||||
{activeId === account.accountId && (
|
||||
<Check className="text-primary ml-2 size-4 shrink-0" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -12,9 +12,12 @@ import { TourLauncher } from "@/components/onboarding/tour-launcher";
|
||||
import { clearCachesForSessionEnd, useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context";
|
||||
import { isDestinationInSharedRecord } from "./nav-model";
|
||||
import { BottomNav } from "./bottom-nav";
|
||||
import { DemoBanner } from "./demo-banner";
|
||||
import { OfflineBanner } from "./offline-banner";
|
||||
import { SharedRecordBanner } from "./shared-record-banner";
|
||||
import { SharedRecordUnavailable } from "./shared-record-unavailable";
|
||||
import { SidebarNav } from "./sidebar-nav";
|
||||
import { TopBar } from "./top-bar";
|
||||
|
||||
@@ -69,6 +72,14 @@ export function AuthShell({
|
||||
pathname.startsWith("/about") ||
|
||||
pathname.startsWith("/c/");
|
||||
const isAdminPage = pathname.startsWith("/admin");
|
||||
// v1.36.0 — a page reached while acting on somebody else's record that
|
||||
// sharing does not cover. The nav already drops these entries, so arriving
|
||||
// here means a bookmark or a browser back button; the panel below explains
|
||||
// it instead of letting the page paint a row of 403 error cards. Paint only
|
||||
// — every route behind these pages refuses on its own.
|
||||
const outsideSharedRecord =
|
||||
user?.accountAccess?.active != null &&
|
||||
!isDestinationInSharedRecord(pathname);
|
||||
const isOnboardingPage = pathname === "/onboarding";
|
||||
const showUnlockNotifier = isAuthenticated && !isPublicPage && !!user?.id;
|
||||
|
||||
@@ -301,6 +312,13 @@ export function AuthShell({
|
||||
the first chrome line a user sees in the offline branch.
|
||||
*/}
|
||||
<OfflineBanner />
|
||||
{/* v1.36.0 — whose record is open. Sits directly under the
|
||||
connection strip and above everything else: a person who has
|
||||
forgotten they are switched is about to write a reading into
|
||||
somebody else's record, and no other line of chrome is worth
|
||||
seeing first. Renders nothing when the session is in its own
|
||||
account, which is every session that has never used sharing. */}
|
||||
<SharedRecordBanner />
|
||||
{/* Demo instances surface a persistent "changes are not
|
||||
saved" strip — the proxy blocks every mutation, and
|
||||
without the banner that block only surfaced as a raw API
|
||||
@@ -357,7 +375,7 @@ export function AuthShell({
|
||||
data-slot="main-content-wrapper"
|
||||
className="mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6"
|
||||
>
|
||||
{children}
|
||||
{outsideSharedRecord ? <SharedRecordUnavailable /> : children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { medicationsPrefetchIntentProps } from "@/lib/queries/prefetch-medications";
|
||||
import {
|
||||
isNavDestinationActive,
|
||||
isDestinationInSharedRecord,
|
||||
mobileMoreHubDestinations,
|
||||
} from "@/components/layout/nav-model";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
@@ -92,6 +93,10 @@ export function BottomNav() {
|
||||
// module from flickering into the hub / primary slot before the query
|
||||
// resolves (the #418-class divergence); once mounted the real map applies.
|
||||
const mounted = useMounted();
|
||||
// v1.36.0 — acting on somebody else's record. Same gate as the sidebar, from
|
||||
// the same resolved payload, so the two bars cannot disagree about what a
|
||||
// delegate is offered.
|
||||
const sharedRecord = user?.accountAccess?.active != null;
|
||||
|
||||
// v1.17.1 (F-1) — the More hub is the model-computed hub: every visible
|
||||
// feature destination that isn't a primary slot, plus the shared utility
|
||||
@@ -102,8 +107,9 @@ export function BottomNav() {
|
||||
mobileMoreHubDestinations({
|
||||
modules: user?.modules,
|
||||
mounted,
|
||||
sharedRecord,
|
||||
}),
|
||||
[user?.modules, mounted],
|
||||
[user?.modules, mounted, sharedRecord],
|
||||
);
|
||||
|
||||
// v1.18.0 — drop a module-gated primary slot (Insights) when the account
|
||||
@@ -115,10 +121,14 @@ export function BottomNav() {
|
||||
() =>
|
||||
PRIMARY_RIGHT.filter(
|
||||
(item) =>
|
||||
!item.requiresModule ||
|
||||
(mounted && user?.modules?.[item.requiresModule] !== false),
|
||||
// v1.36.0 — the fixed slot asks the shared destination model
|
||||
// whether sharing covers it, rather than carrying its own answer:
|
||||
// Insights is an AI surface and drops out under a switch.
|
||||
(!sharedRecord || isDestinationInSharedRecord(item.href)) &&
|
||||
(!item.requiresModule ||
|
||||
(mounted && user?.modules?.[item.requiresModule] !== false)),
|
||||
),
|
||||
[user?.modules, mounted],
|
||||
[user?.modules, mounted, sharedRecord],
|
||||
);
|
||||
|
||||
function isActiveLink(href: string) {
|
||||
|
||||
@@ -57,6 +57,24 @@ export interface NavDestination {
|
||||
* not a re-derivation.
|
||||
*/
|
||||
requiresModule?: ModuleKey;
|
||||
/**
|
||||
* v1.36.0 — is this destination part of what account sharing covers.
|
||||
*
|
||||
* `false` (the default, so the omission is the safe answer) means the entry
|
||||
* disappears while the browser is acting on somebody else's record. It is
|
||||
* PAINT and nothing else: the server refuses every non-delegable route under
|
||||
* a switch on its own, from a frozen allowlist that this file cannot reach
|
||||
* and does not mirror. Hiding the entry only spares a delegate a click that
|
||||
* ends in a refusal — both ends, always, and this is the cosmetic end.
|
||||
*
|
||||
* The line the flag draws is the design's (§4, §6): a delegate reads the
|
||||
* health RECORD, and never the account around it. So the tracking surfaces
|
||||
* are marked, and everything that configures, connects, exports, or asks an
|
||||
* AI about the account is not. AI is the one that looks arbitrary and is
|
||||
* not: server-managed LLM egress of the owner's data rides a consent the
|
||||
* owner gave for their own use, so those surfaces stay owner-only in v1.
|
||||
*/
|
||||
sharedRecord?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,15 +87,23 @@ export interface NavDestination {
|
||||
// previous: Medications → Vorsorge → Labs → Illness → Insights → Coach →
|
||||
// Achievements. A disabled module simply drops out; the rest keep this order.
|
||||
export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
|
||||
{ href: "/", tKey: "nav.dashboard", icon: Home, tourId: "nav-dashboard" },
|
||||
{
|
||||
href: "/",
|
||||
sharedRecord: true,
|
||||
tKey: "nav.dashboard",
|
||||
icon: Home,
|
||||
tourId: "nav-dashboard",
|
||||
},
|
||||
{
|
||||
href: "/measurements",
|
||||
sharedRecord: true,
|
||||
tKey: "nav.measurements",
|
||||
icon: Activity,
|
||||
tourId: "nav-measurements",
|
||||
},
|
||||
{
|
||||
href: "/mood",
|
||||
sharedRecord: true,
|
||||
tKey: "nav.mood",
|
||||
icon: Waves,
|
||||
tourId: "nav-mood",
|
||||
@@ -91,6 +117,7 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
|
||||
// no longer borrows the Insights tab strip / layout shell).
|
||||
{
|
||||
href: "/mental-wellbeing",
|
||||
sharedRecord: true,
|
||||
tKey: "nav.mentalWellbeing",
|
||||
icon: Brain,
|
||||
tourId: "nav-mental-wellbeing",
|
||||
@@ -98,6 +125,7 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
|
||||
},
|
||||
{
|
||||
href: "/cycle",
|
||||
sharedRecord: true,
|
||||
tKey: "nav.cycle",
|
||||
icon: Droplets,
|
||||
tourId: "nav-cycle",
|
||||
@@ -105,6 +133,7 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
|
||||
},
|
||||
{
|
||||
href: "/medications",
|
||||
sharedRecord: true,
|
||||
tKey: "nav.medications",
|
||||
icon: Pill,
|
||||
tourId: "nav-medications",
|
||||
@@ -119,6 +148,7 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
|
||||
// Reminders hub still links to it; this is the direct front door.
|
||||
{
|
||||
href: "/checkups",
|
||||
sharedRecord: true,
|
||||
tKey: "nav.vorsorge",
|
||||
icon: Stethoscope,
|
||||
tourId: "nav-vorsorge",
|
||||
@@ -132,6 +162,7 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
|
||||
},
|
||||
{
|
||||
href: "/labs",
|
||||
sharedRecord: true,
|
||||
tKey: "nav.labs",
|
||||
icon: FlaskConical,
|
||||
tourId: "nav-labs",
|
||||
@@ -143,6 +174,7 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
|
||||
// absent until the account turns the module on from the Modules hub.
|
||||
{
|
||||
href: "/illness",
|
||||
sharedRecord: true,
|
||||
tKey: "nav.illness",
|
||||
icon: Thermometer,
|
||||
tourId: "nav-illness",
|
||||
@@ -158,11 +190,17 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
|
||||
// until the account turns the module on from the Modules hub.
|
||||
{
|
||||
href: "/documents",
|
||||
sharedRecord: true,
|
||||
tKey: "nav.documents",
|
||||
icon: FileScan,
|
||||
tourId: "nav-documents",
|
||||
requiresModule: "inboundDocuments",
|
||||
},
|
||||
// v1.36.0 — Insights and the Coach carry no `sharedRecord` flag, so they
|
||||
// drop out under a switch. Both are AI surfaces, and AI egress of a
|
||||
// person's health data rides the consent THAT person gave for their own
|
||||
// use; a delegate triggering it would create a consent-shaped act the owner
|
||||
// never made. Non-delegable in v1, server-side and here (design §4).
|
||||
{
|
||||
href: "/insights",
|
||||
tKey: "nav.insights",
|
||||
@@ -183,6 +221,7 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
|
||||
},
|
||||
{
|
||||
href: "/achievements",
|
||||
sharedRecord: true,
|
||||
tKey: "nav.achievements",
|
||||
icon: Trophy,
|
||||
tourId: "nav-achievements",
|
||||
@@ -224,7 +263,15 @@ export const NAV_UTILITY_DESTINATIONS: ReadonlyArray<NavUtilityDestination> = [
|
||||
* tail (the sidebar footer + avatar menu, the bottom-nav More hub) so the two
|
||||
* surfaces share one definition of the utility links.
|
||||
*/
|
||||
export function visibleUtilityDestinations(): NavUtilityDestination[] {
|
||||
export function visibleUtilityDestinations(
|
||||
opts: { sharedRecord?: boolean } = {},
|
||||
): NavUtilityDestination[] {
|
||||
// v1.36.0 — the utility tail is account configuration by definition:
|
||||
// Settings holds credentials, integrations, notification channels and grant
|
||||
// management, and Notifications is the delegate's own device business. Every
|
||||
// route behind both refuses under a switch; the tail simply stops being
|
||||
// offered so nobody walks into the refusal.
|
||||
if (opts.sharedRecord) return [];
|
||||
return [...NAV_UTILITY_DESTINATIONS];
|
||||
}
|
||||
|
||||
@@ -257,25 +304,53 @@ function isNavDestinationVisible(
|
||||
d: NavDestination,
|
||||
modules: ModuleVisibilityMap | undefined,
|
||||
mounted = true,
|
||||
sharedRecord = false,
|
||||
): boolean {
|
||||
if (sharedRecord && d.sharedRecord !== true) return false;
|
||||
if (!d.requiresModule) return true;
|
||||
if (!mounted) return false;
|
||||
return modules?.[d.requiresModule] !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.36.0 — is this path part of what account sharing covers?
|
||||
*
|
||||
* Answers from the one destination list rather than from a second literal, so
|
||||
* the mobile bar's fixed slots, the sidebar and the shell's deep-link guard
|
||||
* cannot end up disagreeing about which surfaces a delegate is offered.
|
||||
*
|
||||
* Matches a destination and everything beneath it (`/measurements/123` rides
|
||||
* `/measurements`), with the dashboard matching exactly — otherwise `"/"`
|
||||
* would swallow every path in the app. A path no destination claims answers
|
||||
* `false`: a surface nobody has classified is not one to open inside somebody
|
||||
* else's record.
|
||||
*/
|
||||
export function isDestinationInSharedRecord(pathname: string): boolean {
|
||||
return NAV_DESTINATIONS.some((d) => {
|
||||
if (!d.sharedRecord) return false;
|
||||
if (d.href === "/") return pathname === "/";
|
||||
return pathname === d.href || pathname.startsWith(`${d.href}/`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The ordered destinations visible to this account — drops a module-gated
|
||||
* entry (mood, cycle, labs, coach, achievements …) when its module is
|
||||
* disabled in the account's resolved module map. Both bars start from this.
|
||||
* v1.18.0 — cycle is no longer a bespoke boolean: it is `requiresModule:
|
||||
* "cycle"` and reads the delegated `cycle` key from the same map.
|
||||
*
|
||||
* v1.36.0 — `sharedRecord` drops every entry that is not part of what sharing
|
||||
* covers, so a delegate is not offered a door the server will shut. Paint
|
||||
* only; see the `sharedRecord` field's docblock.
|
||||
*/
|
||||
export function visibleNavDestinations(
|
||||
modules: ModuleVisibilityMap | undefined,
|
||||
mounted = true,
|
||||
sharedRecord = false,
|
||||
): NavDestination[] {
|
||||
return NAV_DESTINATIONS.filter((d) =>
|
||||
isNavDestinationVisible(d, modules, mounted),
|
||||
isNavDestinationVisible(d, modules, mounted, sharedRecord),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -312,8 +387,14 @@ export function mobileMoreHubDestinations(opts: {
|
||||
modules: ModuleVisibilityMap | undefined;
|
||||
/** Hydration gate — see `isNavDestinationVisible`. Defaults to mounted. */
|
||||
mounted?: boolean;
|
||||
/** v1.36.0 — acting on somebody else's record. Defaults to own. */
|
||||
sharedRecord?: boolean;
|
||||
}): MobileMoreHubEntry[] {
|
||||
return visibleNavDestinations(opts.modules, opts.mounted ?? true)
|
||||
return visibleNavDestinations(
|
||||
opts.modules,
|
||||
opts.mounted ?? true,
|
||||
opts.sharedRecord ?? false,
|
||||
)
|
||||
.filter((d) => !BOTTOM_NAV_PRIMARY_SLOT_HREFS.includes(d.href))
|
||||
.map((d) => ({ href: d.href, tKey: d.tKey, icon: d.icon }));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { Eye, Loader2, LogOut } from "lucide-react";
|
||||
|
||||
import { useAccountSwitch } from "@/hooks/use-account-switch";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { accountLabel } from "@/lib/sharing/account-access-view";
|
||||
|
||||
/**
|
||||
* v1.36.0 — the strip that says whose record is open.
|
||||
*
|
||||
* This is the feature, not decoration around it. A caregiver who forgets they
|
||||
* are switched will log their own blood pressure into somebody else's record,
|
||||
* and there is no undo for a reading that was never theirs. So the banner
|
||||
* states three things in one line — that this is not your record, whose it is,
|
||||
* and that you can only read it — and carries the way out beside them.
|
||||
*
|
||||
* ## Why it looks like this
|
||||
*
|
||||
* Loud on purpose, within the token system. `bg-warning/15` + `border-warning/40`
|
||||
* puts it in the one visual register the app reserves for "something about this
|
||||
* screen is not normal", above the offline strip and the demo strip in the
|
||||
* shell so it is the first line of chrome under any circumstance. Not
|
||||
* `destructive` — nothing is broken and nothing is at risk of being lost;
|
||||
* warning is the honest register for a context the user has to hold in mind.
|
||||
* Not a tint on the page background either: a subtle wash is exactly what
|
||||
* somebody stops seeing after the third switch, and the failure mode here is
|
||||
* a person who has stopped noticing.
|
||||
*
|
||||
* The name is `text-foreground` and the qualifier `text-muted-foreground`, per
|
||||
* the two-tier text rule — the person's name is content, the read-only note is
|
||||
* meta. Mounted for every authenticated route inside `<AuthShell>` (the
|
||||
* `<DemoBanner>` pattern) so no page can forget it, and rendered from
|
||||
* `accountAccess.active`, which the server resolves: the banner appears exactly
|
||||
* when the server says the session is inside a record, never on a client guess.
|
||||
*
|
||||
* `role="status"` rather than `alert`: assistive tech announces the context
|
||||
* once when it appears, without the interrupt an alert implies. `aria-live` is
|
||||
* left to the role's implicit `polite`.
|
||||
*/
|
||||
export function SharedRecordBanner() {
|
||||
const { user } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const switchAccount = useAccountSwitch();
|
||||
|
||||
const active = user?.accountAccess?.active ?? null;
|
||||
if (!active) return null;
|
||||
|
||||
const name = accountLabel(active);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
data-slot="shared-record-banner"
|
||||
data-account-id={active.accountId}
|
||||
className="bg-warning/15 border-warning/40 flex flex-wrap items-center justify-center gap-x-3 gap-y-1 border-b px-3 py-2 text-xs"
|
||||
>
|
||||
<Eye className="text-foreground size-3.5 shrink-0" aria-hidden="true" />
|
||||
<span className="text-foreground text-center font-medium">
|
||||
{t("recordSharing.banner.viewing", { name })}
|
||||
</span>
|
||||
{/* Resolved server-side. `canWrite` is false for every grant in v1, and
|
||||
the day it is not, this line stops claiming otherwise on its own. */}
|
||||
{!active.canWrite && (
|
||||
<span className="text-muted-foreground text-center">
|
||||
{t("recordSharing.banner.readOnly")}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
data-slot="shared-record-banner-exit"
|
||||
onClick={() => switchAccount.mutate(null)}
|
||||
disabled={switchAccount.isPending}
|
||||
className="text-foreground hover:bg-warning/25 focus-visible:ring-ring inline-flex min-h-8 shrink-0 items-center gap-1.5 rounded-md px-2 py-1 font-medium underline underline-offset-2 focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
|
||||
>
|
||||
{switchAccount.isPending ? (
|
||||
<Loader2
|
||||
className="size-3.5 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<LogOut className="size-3.5" aria-hidden="true" />
|
||||
)}
|
||||
{t("recordSharing.banner.leave")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import {
|
||||
subscribeToGrantLoss,
|
||||
useLeaveSharedRecordOnGrantLoss,
|
||||
} from "@/hooks/use-account-switch";
|
||||
|
||||
/**
|
||||
* v1.36.0 — leave a record the server has stopped honouring.
|
||||
*
|
||||
* The switcher reset the contract asks for. A grant can end at any moment: the
|
||||
* owner presses revoke, or an expiry passes. Revocation clears the delegate's
|
||||
* acting sessions in the same transaction, so that path lands them back in
|
||||
* their own account by itself. **Expiry does not** — nothing sweeps, the grant
|
||||
* simply stops being live on the next request, and the session keeps its stamp.
|
||||
* A browser in that state gets a 403 on every read while the banner still says
|
||||
* it is inside somebody's record. Correct refusals under a screen that lies.
|
||||
*
|
||||
* So one subscriber watches the query cache for the stable
|
||||
* `sharing.access.denied` code and, on the first one, clears the switch and
|
||||
* reloads. Watching the cache rather than asking ~200 call sites to handle a
|
||||
* code is the point: a per-call-site handler is a rule the next feature
|
||||
* forgets, and forgetting it here means somebody is stuck.
|
||||
*
|
||||
* Fires once — `subscribeToGrantLoss` owns that, because `landOnRecord` never
|
||||
* resolves (the document is being replaced) and a second trigger would queue a
|
||||
* second reload behind a navigation already in flight.
|
||||
*/
|
||||
export function SharedRecordGrantLossBridge() {
|
||||
const queryClient = useQueryClient();
|
||||
const leave = useLeaveSharedRecordOnGrantLoss();
|
||||
|
||||
useEffect(
|
||||
() => subscribeToGrantLoss(queryClient, () => void leave()),
|
||||
[queryClient, leave],
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { Lock } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { useAccountSwitch } from "@/hooks/use-account-switch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.36.0 — the page a delegate reached that sharing does not cover.
|
||||
*
|
||||
* The nav drops these destinations while a switch is on, so the way here is a
|
||||
* bookmark, a browser back button, or a link somebody kept open. Without this
|
||||
* panel the page would mount, fire its reads, collect a row of 403s, and paint
|
||||
* a screen full of error cards that each say something went wrong — when
|
||||
* nothing did.
|
||||
*
|
||||
* It says the honest thing instead: this surface is not part of what sharing
|
||||
* covers, and here is the way back to your own record. Settings, credentials,
|
||||
* integrations, notification channels and the AI surfaces are the account
|
||||
* around a health record rather than the record itself; a delegate reads the
|
||||
* record.
|
||||
*
|
||||
* Paint, not enforcement. Every route behind these pages refuses on its own,
|
||||
* from a frozen allowlist this component cannot reach — if this file were
|
||||
* deleted the delegate would see 403s, not data.
|
||||
*/
|
||||
export function SharedRecordUnavailable() {
|
||||
const { t } = useTranslations();
|
||||
const switchAccount = useAccountSwitch();
|
||||
|
||||
return (
|
||||
<div data-slot="shared-record-unavailable" className="py-10">
|
||||
<EmptyState
|
||||
icon={<Lock className="size-6" />}
|
||||
title={t("recordSharing.unavailable.title")}
|
||||
description={t("recordSharing.unavailable.description")}
|
||||
ctaSize="lg"
|
||||
action={
|
||||
<Button
|
||||
data-slot="shared-record-unavailable-leave"
|
||||
disabled={switchAccount.isPending}
|
||||
onClick={() => switchAccount.mutate(null)}
|
||||
>
|
||||
{t("recordSharing.unavailable.leave")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { AccountSwitcherMenuItems } from "@/components/layout/account-switcher-menu";
|
||||
import { medicationsPrefetchIntentProps } from "@/lib/queries/prefetch-medications";
|
||||
import {
|
||||
isNavDestinationActive,
|
||||
@@ -58,12 +59,51 @@ function getInitials(name: string): string {
|
||||
.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* The sidebar footer's name-and-avatar block.
|
||||
*
|
||||
* A link into the account settings normally, and a plain block while this
|
||||
* browser is acting on somebody else's record — the settings routes refuse
|
||||
* under a switch, so the link would lead only to an explanation of why it did
|
||||
* not work. It keeps naming the person at the keyboard either way, which is
|
||||
* what a switched session needs it to do.
|
||||
*/
|
||||
function FooterIdentity({
|
||||
sharedRecord,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
sharedRecord: boolean;
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const className =
|
||||
"flex min-w-0 flex-1 items-center gap-3 rounded-md transition-colors";
|
||||
if (sharedRecord) {
|
||||
return <div className={className}>{children}</div>;
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
href="/settings/account"
|
||||
aria-label={label}
|
||||
className={`hover:bg-accent ${className}`}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
|
||||
const { user } = useAuth();
|
||||
const logout = useLogout();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { t } = useTranslations();
|
||||
const avatarUrl = user?.avatarUrl ?? null;
|
||||
// v1.36.0 — while this browser is acting on somebody else's record the
|
||||
// account surfaces are unreachable, so the avatar stops being a door into
|
||||
// them. It still names the person at the keyboard, which is exactly what a
|
||||
// switched session needs it to do.
|
||||
const sharedRecord = user?.accountAccess?.active != null;
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
@@ -76,6 +116,23 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
|
||||
<Sun className="h-4 w-4" />
|
||||
);
|
||||
|
||||
const identityBlock = (
|
||||
<>
|
||||
<Avatar className="h-9 w-9 shrink-0">
|
||||
{avatarUrl && <AvatarImage src={avatarUrl} alt={user.username} />}
|
||||
<AvatarFallback className="bg-primary/15 text-primary text-xs font-medium">
|
||||
{getInitials(user.username)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold">{user.username}</p>
|
||||
{user.email && (
|
||||
<p className="text-muted-foreground truncate text-xs">{user.email}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const dropdownContent = (
|
||||
<DropdownMenuContent
|
||||
side="right"
|
||||
@@ -87,34 +144,27 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
|
||||
settings. Clicking the avatar / name jumps straight to
|
||||
`/settings/account` rather than making the user hunt for a
|
||||
separate "Settings" entry. */}
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href="/settings/account"
|
||||
className="flex cursor-pointer items-center gap-3 px-2 py-2"
|
||||
>
|
||||
<Avatar className="h-9 w-9 shrink-0">
|
||||
{avatarUrl && <AvatarImage src={avatarUrl} alt={user.username} />}
|
||||
<AvatarFallback className="bg-primary/15 text-primary text-xs font-medium">
|
||||
{getInitials(user.username)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold">{user.username}</p>
|
||||
{user.email && (
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{user.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{sharedRecord ? (
|
||||
<div className="flex items-center gap-3 px-2 py-2">{identityBlock}</div>
|
||||
) : (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href="/settings/account"
|
||||
className="flex cursor-pointer items-center gap-3 px-2 py-2"
|
||||
>
|
||||
{identityBlock}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/notifications" className="cursor-pointer">
|
||||
<Bell className="mr-2 h-4 w-4" />
|
||||
{t("nav.notifications")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{!sharedRecord && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/notifications" className="cursor-pointer">
|
||||
<Bell className="mr-2 h-4 w-4" />
|
||||
{t("nav.notifications")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* The about section lives at the end of the settings shell nav;
|
||||
the avatar menu stays focused on account-level actions. */}
|
||||
<DropdownMenuSub>
|
||||
@@ -146,6 +196,10 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
{/* v1.36.0 — the records this account may open. Renders nothing when
|
||||
nobody has shared one, so the menu is unchanged for every account
|
||||
that does not use sharing. */}
|
||||
<AccountSwitcherMenuItems />
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => logout.mutate()}
|
||||
@@ -188,10 +242,9 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
|
||||
{/* R20 — the avatar + name in the expanded footer routes straight
|
||||
into the account settings; the kebab to the right still opens
|
||||
the rest of the user menu. */}
|
||||
<Link
|
||||
href="/settings/account"
|
||||
aria-label={t("nav.accountSettings")}
|
||||
className="hover:bg-accent flex min-w-0 flex-1 items-center gap-3 rounded-md transition-colors"
|
||||
<FooterIdentity
|
||||
sharedRecord={sharedRecord}
|
||||
label={t("nav.accountSettings")}
|
||||
>
|
||||
<Avatar className="h-8 w-8 shrink-0">
|
||||
{avatarUrl && <AvatarImage src={avatarUrl} alt={user.username} />}
|
||||
@@ -207,7 +260,7 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</FooterIdentity>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={t("nav.userMenu")}
|
||||
@@ -250,9 +303,13 @@ export function SidebarNav() {
|
||||
// only) and stops a disabled module's entry from flickering in for one
|
||||
// frame before the query resolves; once mounted the real map applies.
|
||||
const mounted = useMounted();
|
||||
// v1.36.0 — while this browser is acting on somebody else's record, the nav
|
||||
// shows only what sharing covers. The server refuses the rest regardless;
|
||||
// dropping the entries spares a delegate a click that ends in a 403.
|
||||
const sharedRecord = user?.accountAccess?.active != null;
|
||||
const visibleNavItems = useMemo(
|
||||
() => visibleNavDestinations(user?.modules, mounted),
|
||||
[user?.modules, mounted],
|
||||
() => visibleNavDestinations(user?.modules, mounted, sharedRecord),
|
||||
[user?.modules, mounted, sharedRecord],
|
||||
);
|
||||
// Collapsed = the user's stored choice, or — with no stored choice — a
|
||||
// viewport default: tablet widths (md–lg, e.g. an iPad held upright)
|
||||
@@ -295,8 +352,10 @@ export function SidebarNav() {
|
||||
// role-gated, sidebar-only surface and is inserted separately below.
|
||||
const footerUtilityItems = useMemo(
|
||||
() =>
|
||||
visibleUtilityDestinations().filter((d) => d.href !== "/notifications"),
|
||||
[],
|
||||
visibleUtilityDestinations({ sharedRecord }).filter(
|
||||
(d) => d.href !== "/notifications",
|
||||
),
|
||||
[sharedRecord],
|
||||
);
|
||||
|
||||
function isUtilityActive(href: string) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Shield,
|
||||
Sun,
|
||||
} from "lucide-react";
|
||||
import { AccountSwitcherMenuItems } from "@/components/layout/account-switcher-menu";
|
||||
import { SHELL_HEADER_BAND } from "@/components/layout/shell-metrics";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Logo } from "@/components/ui/logo";
|
||||
@@ -41,6 +42,8 @@ function getInitials(name: string): string {
|
||||
|
||||
export function TopBar() {
|
||||
const { user, isLoading } = useAuth();
|
||||
// v1.36.0 — acting on somebody else's record; see the menu below.
|
||||
const sharedRecord = user?.accountAccess?.active != null;
|
||||
const logout = useLogout();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { t } = useTranslations();
|
||||
@@ -112,25 +115,34 @@ export function TopBar() {
|
||||
<ChevronDown className="h-3 w-3 opacity-60" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-60">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/settings/account" className="cursor-pointer">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
{t("nav.settings")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/notifications" className="cursor-pointer">
|
||||
<Bell className="mr-2 h-4 w-4" />
|
||||
{t("nav.notifications")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/admin" className="cursor-pointer">
|
||||
<Shield className="mr-2 h-4 w-4" />
|
||||
{t("nav.admin")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{/* v1.36.0 — the account utilities drop out while this browser is
|
||||
acting on somebody else's record. Every route behind them
|
||||
refuses under a switch, and a control that leads only to an
|
||||
explanation of why it does not work is worse than no control.
|
||||
The switcher below is how the person gets back. */}
|
||||
{!sharedRecord && (
|
||||
<>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/settings/account" className="cursor-pointer">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
{t("nav.settings")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/notifications" className="cursor-pointer">
|
||||
<Bell className="mr-2 h-4 w-4" />
|
||||
{t("nav.notifications")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/admin" className="cursor-pointer">
|
||||
<Shield className="mr-2 h-4 w-4" />
|
||||
{t("nav.admin")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* v1.4.36 W4e — About moved into the Admin Console
|
||||
(`/admin/about`). The dropdown entry was redundant
|
||||
@@ -177,6 +189,11 @@ export function TopBar() {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
{/* v1.36.0 — the same switcher slice the desktop sidebar menu
|
||||
mounts. One component in both menus: a switcher a person can
|
||||
reach on their laptop and not on their phone is a record they
|
||||
can enter and cannot leave. */}
|
||||
<AccountSwitcherMenuItems />
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => logout.mutate()}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { toast } from "sonner";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { VersionPoller } from "@/components/version-poller";
|
||||
import { ServiceWorkerRegistrar } from "@/components/service-worker-registrar";
|
||||
import { SharedRecordGrantLossBridge } from "@/components/layout/shared-record-grant-loss-bridge";
|
||||
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
|
||||
import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
|
||||
import { prefetchMedicationsList } from "@/lib/queries/prefetch-medications";
|
||||
@@ -281,6 +282,11 @@ export function Providers({
|
||||
<QueryVisibilityRefreshBridge />
|
||||
<DashboardSnapshotPreloader />
|
||||
<OfflineMutationToaster />
|
||||
{/* v1.36.0 — a grant that ends while a browser is inside the record
|
||||
leaves the session stamped and every read refused. One cache
|
||||
subscriber notices the stable errorCode and puts the browser back
|
||||
in its own account, rather than asking every call site to. */}
|
||||
<SharedRecordGrantLossBridge />
|
||||
{children}
|
||||
<Toaster position="bottom-right" richColors />
|
||||
<VersionPoller />
|
||||
|
||||
@@ -105,6 +105,10 @@ describe("SETTINGS_SECTION_SLUGS", () => {
|
||||
expect([...SETTINGS_SECTION_SLUGS]).toEqual([
|
||||
"account",
|
||||
"security",
|
||||
// v1.36.0 — account sharing: who may open this record, and whose this
|
||||
// account may open. Beside `security`, and distinct from the `sharing`
|
||||
// slug further down (the clinician share LINK).
|
||||
"access",
|
||||
"modules",
|
||||
"integrations",
|
||||
"sources",
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { GrantInviteCard } from "@/components/settings/access/grant-invite-card";
|
||||
import { GrantsGivenCard } from "@/components/settings/access/grants-given-card";
|
||||
import { GrantsReceivedCard } from "@/components/settings/access/grants-received-card";
|
||||
import { RecordActivityCard } from "@/components/settings/access/record-activity-card";
|
||||
|
||||
/**
|
||||
* v1.36.0 — Settings → "Shared access".
|
||||
*
|
||||
* One section for one concept: who else can open this health record, and whose
|
||||
* this account may open. Split across two pages it would be two half-answers —
|
||||
* the settings IA rule this repository keeps rediscovering — so both directions
|
||||
* and the activity that follows from them live in one column, in the order
|
||||
* somebody reads them:
|
||||
*
|
||||
* 1. Invite — the act.
|
||||
* 2. Who has access — the standing state, and the way to end it.
|
||||
* 3. What I can open — the same, from the other side.
|
||||
* 4. When it was opened — what actually happened.
|
||||
*
|
||||
* Distinct from Settings → "Health record" → sharing links, which mints a
|
||||
* time-boxed read-only link to a REPORT for a clinician. This section is about
|
||||
* another ACCOUNT on this instance being able to open the live record, which is
|
||||
* a standing relationship rather than a document handed over once.
|
||||
*
|
||||
* Every card here reads and writes routes that refuse while the browser is
|
||||
* acting on somebody else's record, and the section is unreachable in that
|
||||
* state because the whole Settings entry drops out of the nav. Grant management
|
||||
* is the one surface a delegate must have no reach into at all: a delegate who
|
||||
* could invite, widen or transfer would be re-delegating access nobody granted
|
||||
* them.
|
||||
*/
|
||||
export function AccessSection() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<GrantInviteCard />
|
||||
<GrantsGivenCard />
|
||||
<GrantsReceivedCard />
|
||||
<RecordActivityCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* The shared-access panel: what each row says, and what the activity view
|
||||
* admits about itself.
|
||||
*
|
||||
* SSR renders of the real cards with the reads stubbed at the hook boundary,
|
||||
* so what is asserted is the paint. The mutations (revoke, renounce, accept)
|
||||
* are proven where they can be: their invalidation contract in
|
||||
* `src/lib/queries/__tests__/account-grants-invalidation.test.ts`, their
|
||||
* confirmation gates in `src/__tests__/destructive-control-guard.test.ts`, and
|
||||
* the end-to-end click in `e2e/account-sharing.spec.ts`.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type {
|
||||
GrantList,
|
||||
GrantRow,
|
||||
RecordActivity,
|
||||
} from "@/lib/queries/use-account-grants";
|
||||
|
||||
const grantsRef: { value: GrantList } = { value: { given: [], received: [] } };
|
||||
const activityRef: { value: RecordActivity | undefined } = { value: undefined };
|
||||
|
||||
vi.mock("@/lib/queries/use-account-grants", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/queries/use-account-grants")>();
|
||||
return {
|
||||
...actual,
|
||||
useAccountGrants: () => ({
|
||||
data: grantsRef.value,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useRecordActivity: () => ({
|
||||
data: activityRef.value,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useRevokeGrant: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useRenounceGrant: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useAcceptGrant: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/hooks/use-account-switch", () => ({
|
||||
useAccountSwitch: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
import { GrantsGivenCard } from "../grants-given-card";
|
||||
import { GrantsReceivedCard } from "../grants-received-card";
|
||||
import { RecordActivityCard } from "../record-activity-card";
|
||||
|
||||
function render(node: React.ReactNode): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const BASE: GrantRow = {
|
||||
id: "g1",
|
||||
account: { id: "acct-1", username: "housemate", displayName: "Jo" },
|
||||
access: "READ",
|
||||
state: "ACTIVE",
|
||||
invitedAt: "2026-01-02T10:00:00.000Z",
|
||||
acceptedAt: "2026-01-02T11:00:00.000Z",
|
||||
expiresAt: null,
|
||||
lastUsedAt: null,
|
||||
revokedAt: null,
|
||||
revokedBy: null,
|
||||
};
|
||||
|
||||
describe("who can open my record", () => {
|
||||
it("says so honestly when nobody can", () => {
|
||||
grantsRef.value = { given: [], received: [] };
|
||||
const html = render(<GrantsGivenCard />);
|
||||
expect(html).toContain("Nobody else can open your record");
|
||||
expect(html).not.toContain('data-slot="grants-given-list"');
|
||||
});
|
||||
|
||||
it("renders the state the server resolved, not one it worked out", () => {
|
||||
// Every row carries a `state` the request resolver's own predicate
|
||||
// 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 = {
|
||||
given: [
|
||||
{ ...BASE, id: "a", state: "ACTIVE" },
|
||||
{ ...BASE, id: "p", state: "PENDING", acceptedAt: null },
|
||||
{
|
||||
...BASE,
|
||||
id: "e",
|
||||
state: "EXPIRED",
|
||||
expiresAt: "2026-01-03T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
received: [],
|
||||
};
|
||||
const html = render(<GrantsGivenCard />);
|
||||
expect(html).toContain('data-grant-state="ACTIVE"');
|
||||
expect(html).toContain('data-grant-state="PENDING"');
|
||||
expect(html).toContain('data-grant-state="EXPIRED"');
|
||||
});
|
||||
|
||||
it("keeps an ended grant in the list, with who ended it", () => {
|
||||
// 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 = {
|
||||
given: [
|
||||
{
|
||||
...BASE,
|
||||
state: "REVOKED",
|
||||
revokedAt: "2026-02-01T09:00:00.000Z",
|
||||
revokedBy: "GRANTOR",
|
||||
},
|
||||
],
|
||||
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 = {
|
||||
given: [
|
||||
{
|
||||
...BASE,
|
||||
state: "REVOKED",
|
||||
revokedAt: "2026-02-01T09:00:00.000Z",
|
||||
revokedBy: "GRANTEE",
|
||||
},
|
||||
],
|
||||
received: [],
|
||||
};
|
||||
const html = render(<GrantsGivenCard />);
|
||||
// Two facts, one column. A trail that conflated them would misread an
|
||||
// ordinary handover as a falling-out.
|
||||
expect(html).toContain("Handed back");
|
||||
expect(html).not.toContain("You ended it");
|
||||
});
|
||||
|
||||
it("offers no way to end a grant that has already ended", () => {
|
||||
grantsRef.value = {
|
||||
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: [] };
|
||||
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: [] };
|
||||
const html = render(<GrantsGivenCard />);
|
||||
expect(html).toContain("never");
|
||||
});
|
||||
});
|
||||
|
||||
describe("records I can open", () => {
|
||||
it("offers acceptance on an invitation and nothing else", () => {
|
||||
grantsRef.value = {
|
||||
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.
|
||||
expect(html).not.toContain('data-slot="grant-open"');
|
||||
expect(html).not.toContain('data-slot="grant-renounce"');
|
||||
});
|
||||
|
||||
it("offers opening and handing back once it is active", () => {
|
||||
grantsRef.value = { given: [], received: [BASE] };
|
||||
const html = render(<GrantsReceivedCard />);
|
||||
expect(html).toContain('data-slot="grant-open"');
|
||||
expect(html).toContain('data-account-id="acct-1"');
|
||||
expect(html).toContain('data-slot="grant-renounce"');
|
||||
expect(html).not.toContain('data-slot="grant-accept"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("when my record was opened", () => {
|
||||
it("states its own window, from the number the server resolved", () => {
|
||||
// A view that showed "the activity" without saying how far back it can
|
||||
// see would imply a completeness it does not have: somebody checking last
|
||||
// spring would read an empty list as "no" when the honest answer is
|
||||
// "further back than this reaches".
|
||||
activityRef.value = { entries: [], retentionDays: 90 };
|
||||
const html = render(<RecordActivityCard />);
|
||||
expect(html).toContain('data-slot="record-activity-retention"');
|
||||
expect(html).toContain("last 90 days");
|
||||
});
|
||||
|
||||
it("prints the operator's window, never a compiled-in 365", () => {
|
||||
activityRef.value = { entries: [], retentionDays: 30 };
|
||||
const html = render(<RecordActivityCard />);
|
||||
expect(html).toContain("last 30 days");
|
||||
expect(html).not.toContain("365");
|
||||
});
|
||||
|
||||
it("says nothing about a window it has not been told", () => {
|
||||
// A placeholder here would be exactly the invented value the sentence
|
||||
// exists to avoid.
|
||||
activityRef.value = undefined;
|
||||
const html = render(<RecordActivityCard />);
|
||||
expect(html).not.toContain('data-slot="record-activity-retention"');
|
||||
});
|
||||
|
||||
it("counts the openings rather than listing them one by one", () => {
|
||||
activityRef.value = {
|
||||
retentionDays: 365,
|
||||
entries: [
|
||||
{
|
||||
id: "a1",
|
||||
actor: { id: "acct-1", username: "housemate", displayName: "Jo" },
|
||||
action: "sharing.record.accessed",
|
||||
accesses: 40,
|
||||
createdAt: "2026-02-03T08:00:00.000Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
const html = render(<RecordActivityCard />);
|
||||
expect(html).toContain('data-slot="record-activity-row"');
|
||||
expect(html).toContain("Jo");
|
||||
expect(html).toContain("40");
|
||||
});
|
||||
|
||||
it("names a deleted account as one", () => {
|
||||
// The actor column is deliberately not a foreign key, so the id outlives
|
||||
// the person. Rendering nothing would read as though the owner had done
|
||||
// it themselves.
|
||||
activityRef.value = {
|
||||
retentionDays: 365,
|
||||
entries: [
|
||||
{
|
||||
id: "a2",
|
||||
actor: null,
|
||||
action: "sharing.record.accessed",
|
||||
accesses: 3,
|
||||
createdAt: "2026-02-03T08:00:00.000Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
const html = render(<RecordActivityCard />);
|
||||
expect(html).toContain("A deleted account");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { UserPlus } from "lucide-react";
|
||||
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useInviteGrant } from "@/lib/queries/use-account-grants";
|
||||
|
||||
/**
|
||||
* v1.36.0 — offering somebody read access to this record.
|
||||
*
|
||||
* One field, because there is one decision: who. The access level is not
|
||||
* offered — v1 grants read and only read, and a control whose only setting is
|
||||
* its default is a promise about a future release rather than a choice about
|
||||
* this one.
|
||||
*
|
||||
* The invitation confers nothing until the other person accepts it. The card
|
||||
* says so above the field rather than after the submit, because that is the
|
||||
* part somebody needs to know BEFORE they type a name: nothing has been shared
|
||||
* yet, and nothing will be until the other side agrees.
|
||||
*
|
||||
* Errors are named, not generic. "No account with that name" and "that person
|
||||
* already has access" are different situations with different next steps, and
|
||||
* the server publishes a stable `meta.errorCode` for each precisely so this
|
||||
* card does not have to read prose to tell them apart.
|
||||
*/
|
||||
export function GrantInviteCard() {
|
||||
const { t } = useTranslations();
|
||||
const invite = useInviteGrant();
|
||||
const [identifier, setIdentifier] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [invited, setInvited] = useState<string | null>(null);
|
||||
|
||||
const submit = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
const value = identifier.trim();
|
||||
if (value.length === 0) return;
|
||||
setError(null);
|
||||
setInvited(null);
|
||||
invite.mutate(
|
||||
{ identifier: value, expiresAt: null },
|
||||
{
|
||||
onSuccess: (grant) => {
|
||||
setIdentifier("");
|
||||
setInvited(grant.account.username);
|
||||
},
|
||||
onError: (err) => setError(t(inviteErrorKey(err))),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsCard data-slot="grant-invite-card">
|
||||
<SettingsCardHeader
|
||||
icon={UserPlus}
|
||||
title={t("recordSharing.invite.title")}
|
||||
description={t("recordSharing.invite.description")}
|
||||
/>
|
||||
<form onSubmit={submit} className="mt-4 space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="grant-invite-identifier">
|
||||
{t("recordSharing.invite.identifierLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="grant-invite-identifier"
|
||||
data-slot="grant-invite-identifier"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
placeholder={t("recordSharing.invite.identifierPlaceholder")}
|
||||
autoComplete="off"
|
||||
maxLength={255}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("recordSharing.invite.identifierHint")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
data-slot="grant-invite-submit"
|
||||
disabled={invite.isPending || identifier.trim().length === 0}
|
||||
>
|
||||
{invite.isPending
|
||||
? t("recordSharing.invite.submitting")
|
||||
: t("recordSharing.invite.submit")}
|
||||
</Button>
|
||||
{error && (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{invited && (
|
||||
<p role="status" className="text-sm">
|
||||
{t("recordSharing.invite.sent", { name: invited })}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The message for a refused invitation.
|
||||
*
|
||||
* Branches on the stable `meta.errorCode` rather than the status alone: a 422
|
||||
* covers both a malformed identifier and an invitation somebody addressed to
|
||||
* themselves, and telling those two apart is the difference between "fix the
|
||||
* field" and "you already have your own record".
|
||||
*/
|
||||
function inviteErrorKey(err: unknown): string {
|
||||
const code =
|
||||
err instanceof ApiError && typeof err.meta?.errorCode === "string"
|
||||
? err.meta.errorCode
|
||||
: null;
|
||||
switch (code) {
|
||||
case "sharing.invite.self":
|
||||
return "recordSharing.invite.errorSelf";
|
||||
case "sharing.invite.duplicate":
|
||||
return "recordSharing.invite.errorDuplicate";
|
||||
case "sharing.invite.invalid":
|
||||
return "recordSharing.invite.errorInvalid";
|
||||
default:
|
||||
break;
|
||||
}
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
return "recordSharing.invite.errorNoAccount";
|
||||
}
|
||||
if (err instanceof ApiError && err.status === 429) {
|
||||
return "recordSharing.invite.errorRateLimited";
|
||||
}
|
||||
return "recordSharing.invite.errorFailed";
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { accountLabel } from "@/lib/sharing/account-access-view";
|
||||
import type { GrantRow, GrantState } from "@/lib/queries/use-account-grants";
|
||||
|
||||
/**
|
||||
* v1.36.0 — one grant, from either side.
|
||||
*
|
||||
* The row's whole job is to say what a person can see and since when, without
|
||||
* anybody having to read a timestamp and work out the answer. Two rules shape
|
||||
* it:
|
||||
*
|
||||
* * **The state comes from the server.** `state` is resolved by the same
|
||||
* function the request resolver's predicate is built on. The row renders
|
||||
* it; it never compares `expiresAt` to the clock, because a panel that
|
||||
* said ACTIVE about a grant the server had stopped honouring would be a
|
||||
* second opinion about somebody's access to a health record.
|
||||
* * **Ended rows stay.** A revoked grant is not gone from the list — the row
|
||||
* IS the consent record, and "who had access, from when to when, and who
|
||||
* ended it" is the question the panel exists to answer. It renders muted
|
||||
* with its end named, rather than disappearing as though it never was.
|
||||
*
|
||||
* The person's name is `text-foreground` (it is the content); every date,
|
||||
* level and last-used note is `text-muted-foreground` (they are meta). The
|
||||
* status badge carries meaning rather than decoration, so it uses the semantic
|
||||
* tokens: active is `success`, an unanswered invitation is `warning`, an ended
|
||||
* one is neutral.
|
||||
*/
|
||||
|
||||
const STATE_VARIANT: Record<
|
||||
GrantState,
|
||||
"default" | "secondary" | "destructive" | "outline"
|
||||
> = {
|
||||
ACTIVE: "default",
|
||||
PENDING: "secondary",
|
||||
EXPIRED: "outline",
|
||||
REVOKED: "outline",
|
||||
};
|
||||
|
||||
export function GrantRowItem({
|
||||
grant,
|
||||
/** What ended it reads differently depending on who is looking. */
|
||||
side,
|
||||
actions,
|
||||
}: {
|
||||
grant: GrantRow;
|
||||
side: "given" | "received";
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
const ended = grant.state === "REVOKED" || grant.state === "EXPIRED";
|
||||
|
||||
return (
|
||||
<li
|
||||
data-slot="grant-row"
|
||||
data-grant-id={grant.id}
|
||||
data-grant-state={grant.state}
|
||||
className="flex flex-wrap items-start justify-between gap-3 py-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-foreground truncate text-sm font-medium">
|
||||
{accountLabel(grant.account)}
|
||||
</span>
|
||||
<Badge variant={STATE_VARIANT[grant.state]}>
|
||||
{t(`recordSharing.state.${stateKey(grant.state)}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{grant.access === "WRITE"
|
||||
? t("recordSharing.row.canWrite")
|
||||
: t("recordSharing.row.readOnly")}
|
||||
</p>
|
||||
<dl className="text-muted-foreground space-y-0.5 text-xs">
|
||||
<div className="flex flex-wrap gap-x-1">
|
||||
<dt>{t("recordSharing.row.invited")}</dt>
|
||||
<dd>{fmt.dateTime(new Date(grant.invitedAt))}</dd>
|
||||
</div>
|
||||
{grant.acceptedAt && (
|
||||
<div className="flex flex-wrap gap-x-1">
|
||||
<dt>{t("recordSharing.row.accepted")}</dt>
|
||||
<dd>{fmt.dateTime(new Date(grant.acceptedAt))}</dd>
|
||||
</div>
|
||||
)}
|
||||
{grant.expiresAt && !ended && (
|
||||
<div className="flex flex-wrap gap-x-1">
|
||||
<dt>{t("recordSharing.row.expires")}</dt>
|
||||
<dd>{fmt.dateTime(new Date(grant.expiresAt))}</dd>
|
||||
</div>
|
||||
)}
|
||||
{grant.state === "ACTIVE" && (
|
||||
<div className="flex flex-wrap gap-x-1">
|
||||
<dt>{t("recordSharing.row.lastUsed")}</dt>
|
||||
{/* Absence reads as absence: a grant that has never been
|
||||
exercised says so, rather than showing a blank. */}
|
||||
<dd>
|
||||
{grant.lastUsedAt
|
||||
? fmt.dateTime(new Date(grant.lastUsedAt))
|
||||
: t("recordSharing.row.neverUsed")}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{grant.revokedAt && (
|
||||
<div className="flex flex-wrap gap-x-1">
|
||||
<dt>{endedByKey(grant.revokedBy, side, t)}</dt>
|
||||
<dd>{fmt.dateTime(new Date(grant.revokedAt))}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
{actions && <div className="flex shrink-0 items-center">{actions}</div>}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function stateKey(state: GrantState): string {
|
||||
return state.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* "You withdrew it" versus "she handed it back" — the same column, read from
|
||||
* two sides. `revokedBy` is what makes the distinction possible; conflating
|
||||
* them would misreport an ordinary handover as a falling-out.
|
||||
*/
|
||||
function endedByKey(
|
||||
revokedBy: GrantRow["revokedBy"],
|
||||
side: "given" | "received",
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
if (revokedBy === "GRANTOR") {
|
||||
return side === "given"
|
||||
? t("recordSharing.row.endedByYou")
|
||||
: t("recordSharing.row.endedByOwner");
|
||||
}
|
||||
if (revokedBy === "GRANTEE") {
|
||||
return side === "given"
|
||||
? t("recordSharing.row.handedBack")
|
||||
: t("recordSharing.row.endedByYou");
|
||||
}
|
||||
return t("recordSharing.row.ended");
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, Users } from "lucide-react";
|
||||
|
||||
import { GrantRowItem } from "@/components/settings/access/grant-row";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { accountLabel } from "@/lib/sharing/account-access-view";
|
||||
import {
|
||||
useAccountGrants,
|
||||
useRevokeGrant,
|
||||
type GrantRow,
|
||||
} from "@/lib/queries/use-account-grants";
|
||||
|
||||
/**
|
||||
* v1.36.0 — who can open this record.
|
||||
*
|
||||
* The compliance surface: at any moment the owner can see who has access, at
|
||||
* what level, since when, and whether it is still being used. Ended grants stay
|
||||
* in the list because the row is the consent record.
|
||||
*
|
||||
* Revoking asks once and then does it. No step-up, no typed confirmation —
|
||||
* reducing access must never be harder than granting it was, and the one party
|
||||
* here whose intent needs no verification is the person closing their own
|
||||
* health record to somebody. The confirm dialog exists only because the click
|
||||
* target sits in a list and a mis-tap should not end an access silently; it
|
||||
* names the person and the act.
|
||||
*/
|
||||
export function GrantsGivenCard() {
|
||||
const { t } = useTranslations();
|
||||
const { data, isLoading, isError, refetch } = useAccountGrants();
|
||||
const revoke = useRevokeGrant();
|
||||
|
||||
const given = data?.given ?? [];
|
||||
|
||||
return (
|
||||
<SettingsCard data-slot="grants-given-card">
|
||||
<SettingsCardHeader
|
||||
icon={Users}
|
||||
title={t("recordSharing.given.title")}
|
||||
description={t("recordSharing.given.description")}
|
||||
/>
|
||||
<div className="mt-4">
|
||||
{isLoading && (
|
||||
<Loader2
|
||||
className="text-muted-foreground size-5 animate-spin motion-reduce:animate-none"
|
||||
aria-label={t("common.loading")}
|
||||
/>
|
||||
)}
|
||||
{isError && (
|
||||
<QueryErrorCard
|
||||
title={t("recordSharing.given.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
)}
|
||||
{!isLoading && !isError && given.length === 0 && (
|
||||
<EmptyState
|
||||
icon={<Users className="size-6" />}
|
||||
title={t("recordSharing.given.emptyTitle")}
|
||||
description={t("recordSharing.given.emptyDescription")}
|
||||
/>
|
||||
)}
|
||||
{given.length > 0 && (
|
||||
<ul data-slot="grants-given-list" className="divide-y">
|
||||
{given.map((grant) => (
|
||||
<GrantRowItem
|
||||
key={grant.id}
|
||||
grant={grant}
|
||||
side="given"
|
||||
actions={renderRevoke(grant)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
|
||||
function renderRevoke(grant: GrantRow) {
|
||||
// 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;
|
||||
const name = accountLabel(grant.account);
|
||||
return (
|
||||
<ConfirmButton
|
||||
slot="grant-revoke"
|
||||
size="sm"
|
||||
label={
|
||||
grant.state === "PENDING"
|
||||
? t("recordSharing.given.withdrawInvite")
|
||||
: t("recordSharing.given.revoke")
|
||||
}
|
||||
title={t("recordSharing.given.revokeTitle", { name })}
|
||||
body={t("recordSharing.given.revokeBody", { name })}
|
||||
confirmLabel={t("recordSharing.given.revokeConfirm")}
|
||||
pending={revoke.isPending}
|
||||
onConfirm={() => revoke.mutate(grant.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { FolderOpen, Loader2 } from "lucide-react";
|
||||
|
||||
import { GrantRowItem } from "@/components/settings/access/grant-row";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { useAccountSwitch } from "@/hooks/use-account-switch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { accountLabel } from "@/lib/sharing/account-access-view";
|
||||
import {
|
||||
useAcceptGrant,
|
||||
useAccountGrants,
|
||||
useRenounceGrant,
|
||||
type GrantRow,
|
||||
} from "@/lib/queries/use-account-grants";
|
||||
|
||||
/**
|
||||
* v1.36.0 — the records this account has been offered, and the ones it holds.
|
||||
*
|
||||
* The delegate's side. Three affordances, and the order they appear in is the
|
||||
* order somebody meets them:
|
||||
*
|
||||
* * **Accept.** An invitation confers nothing until this button is pressed.
|
||||
* That is the consent handshake, not a formality: being handed read access
|
||||
* to another person's health record is not something to impose silently,
|
||||
* and the acceptance is what the grant row records.
|
||||
* * **Open.** Once accepted, the record can be opened from here as well as
|
||||
* from the user menu — somebody who has just accepted is already looking
|
||||
* at the row and should not have to go hunting for the switcher.
|
||||
* * **Hand back.** The delegate's own way out, attributed as theirs so the
|
||||
* owner's record can tell "I withdrew it" from "he gave it back".
|
||||
*/
|
||||
export function GrantsReceivedCard() {
|
||||
const { t } = useTranslations();
|
||||
const { data, isLoading, isError, refetch } = useAccountGrants();
|
||||
const accept = useAcceptGrant();
|
||||
const renounce = useRenounceGrant();
|
||||
const switchAccount = useAccountSwitch();
|
||||
|
||||
const received = data?.received ?? [];
|
||||
|
||||
return (
|
||||
<SettingsCard data-slot="grants-received-card">
|
||||
<SettingsCardHeader
|
||||
icon={FolderOpen}
|
||||
title={t("recordSharing.received.title")}
|
||||
description={t("recordSharing.received.description")}
|
||||
/>
|
||||
<div className="mt-4">
|
||||
{isLoading && (
|
||||
<Loader2
|
||||
className="text-muted-foreground size-5 animate-spin motion-reduce:animate-none"
|
||||
aria-label={t("common.loading")}
|
||||
/>
|
||||
)}
|
||||
{isError && (
|
||||
<QueryErrorCard
|
||||
title={t("recordSharing.received.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
)}
|
||||
{!isLoading && !isError && received.length === 0 && (
|
||||
<EmptyState
|
||||
icon={<FolderOpen className="size-6" />}
|
||||
title={t("recordSharing.received.emptyTitle")}
|
||||
description={t("recordSharing.received.emptyDescription")}
|
||||
/>
|
||||
)}
|
||||
{received.length > 0 && (
|
||||
<ul data-slot="grants-received-list" className="divide-y">
|
||||
{received.map((grant) => (
|
||||
<GrantRowItem
|
||||
key={grant.id}
|
||||
grant={grant}
|
||||
side="received"
|
||||
actions={renderActions(grant)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
|
||||
function renderActions(grant: GrantRow) {
|
||||
if (grant.state === "PENDING") {
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
data-slot="grant-accept"
|
||||
disabled={accept.isPending}
|
||||
onClick={() => accept.mutate(grant.id)}
|
||||
>
|
||||
{accept.isPending
|
||||
? t("recordSharing.received.accepting")
|
||||
: t("recordSharing.received.accept")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
if (grant.state !== "ACTIVE") return null;
|
||||
const name = accountLabel(grant.account);
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
data-slot="grant-open"
|
||||
data-account-id={grant.account.id}
|
||||
disabled={switchAccount.isPending}
|
||||
onClick={() => switchAccount.mutate(grant.account.id)}
|
||||
>
|
||||
{t("recordSharing.received.open")}
|
||||
</Button>
|
||||
<ConfirmButton
|
||||
slot="grant-renounce"
|
||||
size="sm"
|
||||
label={t("recordSharing.received.renounce")}
|
||||
title={t("recordSharing.received.renounceTitle", { name })}
|
||||
body={t("recordSharing.received.renounceBody", { name })}
|
||||
confirmLabel={t("recordSharing.received.renounceConfirm")}
|
||||
pending={renounce.isPending}
|
||||
onConfirm={() => renounce.mutate(grant.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { History, Loader2 } from "lucide-react";
|
||||
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { accountLabel } from "@/lib/sharing/account-access-view";
|
||||
import {
|
||||
useRecordActivity,
|
||||
type RecordActivityEntry,
|
||||
} from "@/lib/queries/use-account-grants";
|
||||
|
||||
/**
|
||||
* v1.36.0 — when this record was opened, and by whom.
|
||||
*
|
||||
* ## The card says how far back it can see
|
||||
*
|
||||
* Access rows are purged on a retention schedule (365 days by default, and the
|
||||
* operator can shorten it). A view titled "activity" with no window stated
|
||||
* would imply a completeness it does not have — somebody checking whether
|
||||
* their record was opened last spring would read an empty list as "no" when
|
||||
* the honest answer is "that is further back than this can see". So the bound
|
||||
* is printed under the heading, and it is the number the SERVER resolved from
|
||||
* the same setting the purge job reads, never a constant compiled in here. An
|
||||
* instance running a 90-day window says 90.
|
||||
*
|
||||
* ## What a row is
|
||||
*
|
||||
* One row per delegate per day, with a count — not one row per request. A
|
||||
* hundred identical lines saying "she read your record" is a way of answering
|
||||
* nothing; "she was in your record on Tuesday, 40 times" is the answer somebody
|
||||
* actually wants. Rows the owner performed themselves never appear here: this
|
||||
* is the record of what SOMEBODY ELSE did, and the account's own security
|
||||
* activity has its own surface.
|
||||
*/
|
||||
export function RecordActivityCard() {
|
||||
const { t } = useTranslations();
|
||||
const { data, isLoading, isError, refetch } = useRecordActivity();
|
||||
|
||||
const entries = data?.entries ?? [];
|
||||
|
||||
return (
|
||||
<SettingsCard data-slot="record-activity-card">
|
||||
<SettingsCardHeader
|
||||
icon={History}
|
||||
title={t("recordSharing.activity.title")}
|
||||
description={t("recordSharing.activity.description")}
|
||||
/>
|
||||
<div className="mt-4 space-y-3">
|
||||
{/* The window, stated. Rendered only once the server has said what it
|
||||
is — a placeholder number here would be exactly the invented value
|
||||
the sentence exists to avoid. */}
|
||||
{data && (
|
||||
<p
|
||||
data-slot="record-activity-retention"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("recordSharing.activity.retention", {
|
||||
days: data.retentionDays,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<Loader2
|
||||
className="text-muted-foreground size-5 animate-spin motion-reduce:animate-none"
|
||||
aria-label={t("common.loading")}
|
||||
/>
|
||||
)}
|
||||
{isError && (
|
||||
<QueryErrorCard
|
||||
title={t("recordSharing.activity.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
)}
|
||||
{!isLoading && !isError && entries.length === 0 && (
|
||||
<EmptyState
|
||||
icon={<History className="size-6" />}
|
||||
title={t("recordSharing.activity.emptyTitle")}
|
||||
description={t("recordSharing.activity.emptyDescription")}
|
||||
/>
|
||||
)}
|
||||
{entries.length > 0 && (
|
||||
<ul data-slot="record-activity-list" className="divide-y">
|
||||
{entries.map((entry) => (
|
||||
<ActivityRow key={entry.id} entry={entry} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityRow({ entry }: { entry: RecordActivityEntry }) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
// A deleted account renders as one. The audit column is deliberately not a
|
||||
// foreign key, so the id outlives the person; showing nothing would read as
|
||||
// though the owner had done it themselves.
|
||||
const who = entry.actor
|
||||
? accountLabel(entry.actor)
|
||||
: t("recordSharing.activity.deletedAccount");
|
||||
|
||||
return (
|
||||
<li
|
||||
data-slot="record-activity-row"
|
||||
className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1 py-2"
|
||||
>
|
||||
<span className="text-foreground text-sm">
|
||||
{entry.accesses === null
|
||||
? t("recordSharing.activity.acted", { name: who })
|
||||
: t("recordSharing.activity.opened", {
|
||||
name: who,
|
||||
count: entry.accesses,
|
||||
})}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{fmt.dateTime(new Date(entry.createdAt))}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
Thermometer,
|
||||
TrendingUp,
|
||||
User,
|
||||
UsersRound,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -49,6 +50,7 @@ import type { SettingsSectionSlug } from "./section-slugs";
|
||||
const SLUG_ICON: Record<SettingsSectionSlug, LucideIcon> = {
|
||||
account: User,
|
||||
security: ShieldCheck,
|
||||
access: UsersRound,
|
||||
modules: Blocks,
|
||||
integrations: Link2,
|
||||
sources: Layers,
|
||||
|
||||
@@ -53,6 +53,14 @@ export const SETTINGS_SECTION_SLUGS = [
|
||||
// setup (TOTP + security keys), recovery codes, and passkey management. Sits
|
||||
// right after `account` so the two identity surfaces read as neighbours.
|
||||
"security",
|
||||
// v1.36.0 — `access` ("Shared access"): which other accounts on this
|
||||
// instance may open this health record, which records this one may open, and
|
||||
// when they were opened. Sits beside `security` because it answers the same
|
||||
// question those surfaces do — who can get at my account, and what have they
|
||||
// been doing. Distinct from the `sharing` slug below, which is the clinician
|
||||
// share LINK (a time-boxed read-only report handed to somebody outside the
|
||||
// instance); this one is a standing relationship between two accounts on it.
|
||||
"access",
|
||||
// v1.18.0 — `modules` ("Was du trackst") sits right after account: the
|
||||
// single front door for enabling/disabling secondary tracking domains.
|
||||
"modules",
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
User,
|
||||
UsersRound,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -150,6 +151,16 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
||||
icon: ShieldCheck,
|
||||
group: "account",
|
||||
},
|
||||
// v1.36.0 — who else may open this record, and whose this account may open.
|
||||
// In the Account group beside Security: both answer "who can get at my
|
||||
// account and what have they done", and a person looking for one will look
|
||||
// where the other is.
|
||||
{
|
||||
slug: "access",
|
||||
titleKey: "settings.sections.access.title",
|
||||
icon: UsersRound,
|
||||
group: "account",
|
||||
},
|
||||
// v1.23 — "Data & Privacy" assembles the export / deletion / retention /
|
||||
// encryption / session / activity pieces into one coherent privacy pane.
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user