mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
fix(settings): list record content only where the grant manages it
The anamnesis page writes through requireRecordAuth("manage",
"profile"), which refuses a grant whose sections do not reach profile.
The Settings list and the navigation entry checked only the level and
the record kind, so a MANAGE grant scoped away from profile would be
offered a page whose saves are refused.
Record-content destinations now name the section they write, and are
listed only when the active record's manageable sections include it.
A destination without a named section is never listed. The shell and
the navigation read the same predicate. MANAGE grants are whole-record
today, so managed profiles and adult MANAGE shares list what they did
before.
Refs #939
This commit is contained in:
1 parent
4a49120fcf
commit
45ee70acb7
7 files changed
+224
-27
No files matched your search
@@ -6,6 +6,7 @@ import type {
|
||||
AccountAccessLevel,
|
||||
AccountRecordKind,
|
||||
} from "@/lib/sharing/account-access-view";
|
||||
import type { ShareDomain } from "@/lib/sharing/scope";
|
||||
|
||||
export type SettingsDestinationKind =
|
||||
| "personal"
|
||||
@@ -146,14 +147,44 @@ export function isManageDelegateSettingsDestination(
|
||||
return classifySettingsDestination(destination).kind === "manage-writable";
|
||||
}
|
||||
|
||||
/**
|
||||
* The section each record-content destination writes to.
|
||||
*
|
||||
* A `manage-writable` destination is a page of forms whose routes resolve
|
||||
* `requireRecordAuth("manage", <section>)`, and that call refuses a grant
|
||||
* whose sections do not reach the section (`grantCoversDomain`). Level alone
|
||||
* therefore does not say the page works: the grant must manage that section.
|
||||
* The anamnesis forms post to `/api/allergies` and `/api/family-history`, both
|
||||
* declared on `profile`.
|
||||
*
|
||||
* A `manage-writable` destination missing here is never listed, so a new one
|
||||
* cannot appear in a shared record until somebody names what it writes.
|
||||
*/
|
||||
export const RECORD_CONTENT_WRITE_DOMAINS = {
|
||||
anamnesis: "profile",
|
||||
} as const satisfies Partial<Record<SettingsSectionSlug, ShareDomain>>;
|
||||
|
||||
/**
|
||||
* The record a Settings listing is drawn for: the server-resolved kind of the
|
||||
* record on screen and the level of the grant that opened it. `level` is null
|
||||
* in one's own record and in a context the client could not prove.
|
||||
* record on screen, the level of the grant that opened it, and the sections
|
||||
* that grant may manage. `level` is null in one's own record and in a context
|
||||
* the client could not prove. `manageableDomains` is bound from
|
||||
* `accountAccess.active.manageableDomains`, never derived here.
|
||||
*/
|
||||
export interface SettingsRecordContext {
|
||||
recordKind: AccountRecordKind;
|
||||
level: AccountAccessLevel | null;
|
||||
manageableDomains: readonly ShareDomain[];
|
||||
}
|
||||
|
||||
function managesRecordContentSection(
|
||||
destination: string,
|
||||
record: SettingsRecordContext,
|
||||
): boolean {
|
||||
const section = (
|
||||
RECORD_CONTENT_WRITE_DOMAINS as Partial<Record<string, ShareDomain>>
|
||||
)[destination];
|
||||
return section !== undefined && record.manageableDomains.includes(section);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -171,6 +202,15 @@ export interface SettingsRecordContext {
|
||||
* thresholds and notification routing stay with the owner.
|
||||
* * Every other context lists nothing.
|
||||
*
|
||||
* A record-content destination is listed only when the grant manages the
|
||||
* section it writes ({@link RECORD_CONTENT_WRITE_DOMAINS}). Today a MANAGE
|
||||
* grant is whole-record at every layer (`inviteGrant` refuses a scope on a
|
||||
* MANAGE invitation, no endpoint raises a live grant, and the account-access
|
||||
* schema rejects a manage entry with sections), so this changes nothing for a
|
||||
* managed profile or an adult MANAGE share as they exist. It keeps the list
|
||||
* true if a scoped MANAGE grant ever reaches the client, rather than offering a
|
||||
* page whose writes answer 403.
|
||||
*
|
||||
* Every destination on either list needs MANAGE, so the level is checked once
|
||||
* for both record kinds. A guardian grant is always MANAGE today, which is
|
||||
* exactly why the check was easy to leave out, and a managed entry that ever
|
||||
@@ -184,11 +224,13 @@ export function isSettingsDestinationListedForRecord(
|
||||
): boolean {
|
||||
if (record.level !== "manage") return false;
|
||||
const kind = classifySettingsDestination(destination).kind;
|
||||
if (record.recordKind === "managed") {
|
||||
return kind === "managed-guardian" || kind === "manage-writable";
|
||||
if (kind === "manage-writable") {
|
||||
return (
|
||||
(record.recordKind === "managed" || record.recordKind === "shared") &&
|
||||
managesRecordContentSection(destination, record)
|
||||
);
|
||||
}
|
||||
if (record.recordKind === "shared") return kind === "manage-writable";
|
||||
return false;
|
||||
return record.recordKind === "managed" && kind === "managed-guardian";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user