mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
feat: distinguish managed record access
This commit is contained in:
1 parent
908897cc6b
commit
ba3be7ba9b
5 files changed
+157
-78
No files matched your search
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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">✓</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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
|
||||
Reference in new issue
Block a user