feat: distinguish managed record access

This commit is contained in:
Marc-André Bombeck committed 2026-08-06 04:18:05 +02:00
1 parent 908897cc6b
commit ba3be7ba9b
5 files changed
+157 -78

No files matched your search

+50 -40
View File
@@ -7,6 +7,10 @@ import { TileHeader } from "@/components/insights/tile-header";
import { useAccountSwitch } from "@/hooks/use-account-switch";
import { useAccountOnceMounted } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import {
resolveRecordPresentation,
type RecordPresentation,
} from "@/lib/navigation/record-presentation";
import {
accountLabel,
type AccountAccessEntry,
@@ -77,42 +81,48 @@ export function LookingAfterCard() {
{t("recordSharing.lookingAfter.description")}
</p>
<ul className="divide-y">
{accounts.map((entry) => (
<li key={entry.accountId}>
<button
type="button"
data-slot="looking-after-row"
data-account-id={entry.accountId}
disabled={switchAccount.isPending}
onClick={() => switchAccount.mutate(entry.accountId)}
aria-label={t("recordSharing.lookingAfter.open", {
name: accountLabel(entry),
})}
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">
<span className="text-foreground block truncate text-sm font-medium">
{accountLabel(entry)}
{accounts.map((entry) => {
const presentation = resolveRecordPresentation(entry);
return (
<li key={entry.accountId}>
<button
type="button"
data-slot="looking-after-row"
data-account-id={entry.accountId}
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),
})}
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">
<span className="text-foreground block truncate text-sm font-medium">
{accountLabel(entry)}
</span>
<span className="text-muted-foreground block truncate text-xs">
{levelLabel(presentation, t)}
</span>
<span className="text-muted-foreground block truncate text-xs">
{recordKindLabel(presentation, t)}
</span>
<span className="text-muted-foreground block truncate text-xs">
{scopeLabel(entry, t)}
</span>
</span>
<span className="text-muted-foreground block truncate text-xs">
{levelLabel(entry, t)}
</span>
<span className="text-muted-foreground block truncate text-xs">
{recordKindLabel(entry, t)}
</span>
<span className="text-muted-foreground block truncate text-xs">
{scopeLabel(entry, t)}
</span>
</span>
{switchAccount.isPending && (
<Loader2
className="text-muted-foreground size-4 shrink-0 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
)}
</button>
</li>
))}
{switchAccount.isPending && (
<Loader2
className="text-muted-foreground size-4 shrink-0 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
)}
</button>
</li>
);
})}
</ul>
</CardContent>
</Card>
@@ -131,22 +141,22 @@ export function LookingAfterCard() {
* clothes.
*/
function levelLabel(
entry: AccountAccessEntry,
presentation: RecordPresentation,
t: (key: string) => string,
): string {
if (entry.level === "manage") {
if (presentation.access === "manage") {
return t("recordSharing.row.canManage");
}
return entry.canWrite
return presentation.access === "view-and-add"
? t("recordSharing.lookingAfter.levelWrite")
: t("recordSharing.lookingAfter.levelRead");
}
function recordKindLabel(
entry: AccountAccessEntry,
presentation: RecordPresentation,
t: (key: string) => string,
): string {
return entry.recordKind === "managed"
return presentation.recordKind === "managed"
? t("recordSharing.lookingAfter.kindManaged")
: t("recordSharing.lookingAfter.kindShared");
}
+49 -24
View File
@@ -12,6 +12,7 @@ import {
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 { accountLabel } from "@/lib/sharing/account-access-view";
/**
@@ -79,32 +80,56 @@ export function AccountSwitcherMenuItems() {
<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>
))}
{access.accounts.map((account) => {
const presentation = resolveRecordPresentation(account);
return (
<DropdownMenuItem
key={account.accountId}
data-slot="account-switcher-entry"
data-account-id={account.accountId}
data-access-level={presentation.access}
data-record-kind={presentation.recordKind}
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>
<span className="text-muted-foreground truncate text-xs">
{accessLabel(presentation.access, t)}
</span>
<span className="text-muted-foreground truncate text-xs">
{recordKindLabel(presentation.recordKind, t)}
</span>
</div>
{activeId === account.accountId && (
<Check className="text-primary ml-2 size-4 shrink-0" />
)}
</DropdownMenuItem>
);
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
</>
);
}
function accessLabel(
access: ReturnType<typeof resolveRecordPresentation>["access"],
t: ReturnType<typeof useTranslations>["t"],
): string {
if (access === "manage") return t("recordSharing.row.canManage");
return access === "view-and-add"
? t("recordSharing.switcher.canWrite")
: t("recordSharing.switcher.readOnly");
}
function recordKindLabel(
recordKind: ReturnType<typeof resolveRecordPresentation>["recordKind"],
t: ReturnType<typeof useTranslations>["t"],
): string {
return recordKind === "managed"
? t("recordSharing.lookingAfter.kindManaged")
: t("recordSharing.lookingAfter.kindShared");
}
+25 -11
View File
@@ -5,6 +5,7 @@ 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 { resolveRecordPresentation } from "@/lib/navigation/record-presentation";
import { accountLabel } from "@/lib/sharing/account-access-view";
/**
@@ -59,12 +60,15 @@ export function SharedRecordBanner() {
if (!active) return null;
const name = accountLabel(active);
const presentation = resolveRecordPresentation(active);
return (
<div
role="status"
data-slot="shared-record-banner"
data-account-id={active.accountId}
data-access-level={presentation.access}
data-record-kind={presentation.recordKind}
className="bg-warning/15 border-warning/40 flex items-start gap-3 border-b px-3 py-2 text-xs sm:items-center"
>
<Eye
@@ -75,18 +79,9 @@ export function SharedRecordBanner() {
<span className="text-foreground font-medium">
{t("recordSharing.banner.viewing", { name })}
</span>{" "}
{/* Resolved server-side, and it says something at BOTH levels now.
v1.36.1 is the release that made `canWrite` true, and until this
line changed the write case rendered nothing at all: a delegate who
could add to somebody's health record was told only whose record it
was. That matters more than the read case, not less, and the rest of
the product leans on it — a control the record refuses is absent
rather than disabled, on the stated grounds that the banner names
the mode globally. It has to actually do that. */}
<span className="text-muted-foreground">
{active.canWrite
? t("recordSharing.banner.canAdd")
: t("recordSharing.banner.readOnly")}
{recordKindLabel(presentation.recordKind, t)}.{" "}
{accessLabel(presentation.access, t)}
</span>
</p>
<button
@@ -109,3 +104,22 @@ export function SharedRecordBanner() {
</div>
);
}
function accessLabel(
access: ReturnType<typeof resolveRecordPresentation>["access"],
t: ReturnType<typeof useTranslations>["t"],
): string {
if (access === "manage") return t("recordSharing.row.canManage");
return access === "view-and-add"
? t("recordSharing.banner.canAdd")
: t("recordSharing.banner.readOnly");
}
function recordKindLabel(
recordKind: ReturnType<typeof resolveRecordPresentation>["recordKind"],
t: ReturnType<typeof useTranslations>["t"],
): string {
return recordKind === "managed"
? t("recordSharing.lookingAfter.kindManaged")
: t("recordSharing.lookingAfter.kindShared");
}
+30
View File
@@ -0,0 +1,30 @@
import type {
AccountAccessEntry,
AccountRecordKind,
} from "@/lib/sharing/account-access-view";
export type RecordAccessPresentation = "view" | "view-and-add" | "manage";
export interface RecordPresentation {
access: RecordAccessPresentation;
recordKind: AccountRecordKind;
}
/**
* The server publishes both a resolved `canWrite` decision and the canonical
* three-level label. Chrome binds those values here without treating either as
* an authorization input: a handler still resolves the active grant afresh.
*/
export function resolveRecordPresentation(
entry: Pick<AccountAccessEntry, "canWrite" | "level" | "recordKind">,
): RecordPresentation {
return {
access:
entry.level === "manage"
? "manage"
: entry.canWrite
? "view-and-add"
: "view",
recordKind: entry.recordKind,
};
}
+3 -3
View File
@@ -37,12 +37,12 @@ interface RouteFamily {
}
const SHARED_RECORD_ROUTE_FAMILIES: readonly RouteFamily[] = [
...WHOLE_RECORD_ROUTE_FAMILIES.map((href) => ({
...WHOLE_RECORD_ROUTE_FAMILIES.map((href): RouteFamily => ({
href,
scope: ENTIRE_RECORD,
})),
...SHARE_DOMAINS.flatMap((domain) =>
SHARED_RECORD_DOMAIN_ROUTE_FAMILIES[domain].map((href) => ({
...SHARE_DOMAINS.flatMap((domain): RouteFamily[] =>
SHARED_RECORD_DOMAIN_ROUTE_FAMILIES[domain].map((href): RouteFamily => ({
href,
scope: domain,
})),