Merge pull request #690 from MBombeck/work/sharing-client

Account sharing, the part people touch
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-02 17:13:30 +02:00
commit 210380f46d
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">&#10003;</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>
</>
);
}
+19 -1
View File
@@ -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 -4
View File
@@ -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) {
+85 -4
View File
@@ -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>
);
}
+95 -36
View File
@@ -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) {
+36 -19
View File
@@ -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()}
+6
View File
@@ -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,
+8
View File
@@ -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.
{