feat(02-12): route scoped records to granted domains

- keep shared navigation independent of actor module preferences\n- open scoped records at their first allowed destination\n- announce record access and kind in doorway names
This commit is contained in:
Marc-André Bombeck committed 2026-08-06 05:02:37 +02:00
1 parent 34073cf8dc
commit 6c4dd2d96b
6 files changed
+77 -15

No files matched your search

@@ -161,7 +161,7 @@ describe("<LookingAfterCard>", () => {
});
expect(html).toMatch(
/aria-label="Open Managed profile&#x27;s record; Can read the record, add to it, and change or remove what is in it; Managed profile"/,
/aria-label="Open Managed profile&#x27;s record; Can read the record, add to it, and change or remove what is in it; Managed profile;/,
);
});
@@ -11,6 +11,7 @@ import {
resolveRecordPresentation,
type RecordPresentation,
} from "@/lib/navigation/record-presentation";
import { sharedRecordLandingHref } from "@/lib/navigation/shared-record";
import {
accountLabel,
type AccountAccessEntry,
@@ -83,6 +84,9 @@ export function LookingAfterCard() {
<ul className="divide-y">
{accounts.map((entry) => {
const presentation = resolveRecordPresentation(entry);
const level = levelLabel(entry, presentation, t);
const kind = recordKindLabel(entry, t);
const scope = scopeLabel(entry, t);
return (
<li key={entry.accountId}>
@@ -93,10 +97,20 @@ export function LookingAfterCard() {
data-access-level={presentation.access}
data-record-kind={presentation.recordKind}
disabled={switchAccount.isPending}
onClick={() => switchAccount.mutate(entry.accountId)}
aria-label={t("recordSharing.lookingAfter.open", {
name: accountLabel(entry),
})}
onClick={() =>
switchAccount.switchTo(
entry.accountId,
sharedRecordLandingHref(entry.sections),
)
}
aria-label={[
t("recordSharing.lookingAfter.open", {
name: accountLabel(entry),
}),
level,
kind,
scope,
].join("; ")}
className="hover:bg-accent/40 focus-visible:ring-ring/50 flex min-h-11 w-full items-center gap-3 rounded-md px-2 py-2 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
>
<span className="min-w-0 flex-1">
@@ -104,13 +118,13 @@ export function LookingAfterCard() {
{accountLabel(entry)}
</span>
<span className="text-muted-foreground block truncate text-xs">
{levelLabel(entry, presentation, t)}
{level}
</span>
<span className="text-muted-foreground block truncate text-xs">
{recordKindLabel(entry, t)}
{kind}
</span>
<span className="text-muted-foreground block truncate text-xs">
{scopeLabel(entry, t)}
{scope}
</span>
</span>
{switchAccount.isPending && (
@@ -13,6 +13,7 @@ import { useAccountSwitch } from "@/hooks/use-account-switch";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { resolveRecordPresentation } from "@/lib/navigation/record-presentation";
import { sharedRecordLandingHref } from "@/lib/navigation/shared-record";
import { accountLabel } from "@/lib/sharing/account-access-view";
/**
@@ -92,7 +93,12 @@ export function AccountSwitcherMenuItems() {
data-record-kind={presentation.recordKind}
className="cursor-pointer"
disabled={switchAccount.isPending}
onClick={() => switchAccount.mutate(account.accountId)}
onClick={() =>
switchAccount.switchTo(
account.accountId,
sharedRecordLandingHref(account.sections),
)
}
>
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate">{accountLabel(account)}</span>
+16
View File
@@ -2,6 +2,7 @@ import {
Activity,
Bell,
Brain,
ClipboardList,
Droplets,
FileScan,
FlaskConical,
@@ -77,6 +78,8 @@ export interface NavDestination {
* owner gave for their own use, so those surfaces stay owner-only in v1.
*/
sharedRecord?: boolean;
/** A record-only view, intentionally absent from the actor's own nav. */
sharedRecordOnly?: boolean;
}
/**
@@ -170,6 +173,14 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
tourId: "nav-labs",
requiresModule: "labs",
},
{
href: "/profile",
sharedRecord: true,
sharedRecordOnly: true,
tKey: "settings.sections.anamnesis.title",
icon: ClipboardList,
tourId: "nav-profile",
},
// v1.18.1 — the illness/condition journal sits in the clinical spine
// next to Labs. Born-gated: `requiresModule: "illness"` reads the
// opt-in `illness` key from the resolved module map, so the entry is
@@ -316,6 +327,11 @@ function isNavDestinationVisible(
) {
return false;
}
// A shared record's server-resolved scope decides which health-domain doors
// exist. The actor's module preferences describe their own dashboard and
// must not hide a domain the target record explicitly granted.
if (sharedRecord) return true;
if (d.sharedRecordOnly) return false;
if (!d.requiresModule) return true;
if (!mounted) return false;
return modules?.[d.requiresModule] !== false;
+24 -5
View File
@@ -102,10 +102,13 @@ async function postSwitch(accountId: string | null): Promise<SwitchResponse> {
* sequence. Two copies of "wipe, then reload" is one copy too many for a step
* whose order is the safety property.
*/
async function landOnRecord(accountId: string | null): Promise<never> {
async function landOnRecord(
accountId: string | null,
destination = "/",
): Promise<never> {
setRecordScope(accountId);
await clearOfflineCachesForRecordSwitch();
window.location.assign("/");
window.location.assign(destination);
// The document is being replaced. Nothing after this runs, and the promise
// deliberately never settles so no caller paints a "done" state over a page
// that is on its way out.
@@ -124,15 +127,21 @@ export function useAccountSwitch() {
const { t } = useTranslations();
const queryClient = useQueryClient();
return useMutation({
const mutation = useMutation({
mutationKey: queryKeys.accountSwitchMutation(),
mutationFn: async (accountId: string | null) => {
mutationFn: async ({
accountId,
destination,
}: {
accountId: string | null;
destination: string;
}) => {
await postSwitch(accountId);
// The in-memory cache holds the record we are leaving. Clearing it here
// as well as reloading costs nothing and closes the window in which a
// failed navigation would leave the old entries reachable.
queryClient.clear();
return landOnRecord(accountId);
return landOnRecord(accountId, destination);
},
onError: (error: unknown) => {
toast.error(
@@ -142,6 +151,16 @@ export function useAccountSwitch() {
);
},
});
return {
...mutation,
/** Preserve the established own-record and exit call sites. */
mutate: (accountId: string | null) =>
mutation.mutate({ accountId, destination: "/" }),
/** Enter a scoped record directly at a server-presentable destination. */
switchTo: (accountId: string, destination: string) =>
mutation.mutate({ accountId, destination }),
};
}
/**
+8 -1
View File
@@ -18,7 +18,7 @@ export const SHARED_RECORD_DOMAIN_ROUTE_FAMILIES = {
measurements: ["/measurements"],
medications: ["/medications"],
labs: ["/labs"],
profile: ["/settings/anamnesis"],
profile: ["/profile"],
illness: ["/illness"],
mind: ["/mood", "/mental-wellbeing"],
cycle: ["/cycle"],
@@ -87,6 +87,13 @@ export function resolveSharedRecordNavigation(
};
}
/** The first scope-presentable location to load after a record switch. */
export function sharedRecordLandingHref(
sections: readonly ShareDomain[] | null,
): string {
return resolveSharedRecordNavigation(sections).destinationHrefs[0] ?? "/";
}
/**
* Does the server-resolved scope make this browser route presentable?
*