mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Merge pull request #942 from MBombeck/fix/managed-record-capabilities
v1.38.13 — a guardian sees the controls the routes accept
This commit is contained in:
91 files changed
+1724
-317
No files matched your search
@@ -1,5 +1,30 @@
|
||||
# Changelog
|
||||
|
||||
## [1.38.13] — 2026-09-09
|
||||
|
||||
A guardian acting on a managed profile gets the add, edit and delete
|
||||
controls the server has accepted since v1.37, decided per domain and
|
||||
published by the server. Reported by @sreeramachandramurthy in #939.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Managed profiles: the controls the routes accept are shown again.** A
|
||||
person acting on a managed profile, or on a record shared with them at the
|
||||
manage level, saw no way to add or change anything on mood, wellbeing
|
||||
check-ins, visits, allergies, family history, labs and more, although the
|
||||
server accepted those writes. The client hard-wired `canManage` to false for
|
||||
every shared record, a hold-back from the release that introduced managed
|
||||
profiles that was never lifted. It is lifted per domain: a table records
|
||||
which share domains accept delegated writes at `write` and at `manage`,
|
||||
frozen by a structural test that reads every API route and fails on drift;
|
||||
every entry in the account access list on `/api/auth/me` now carries
|
||||
`writableDomains` and `manageableDomains` (additive, existing clients
|
||||
ignore them); and every surface asks for its own domain. Documents cannot
|
||||
be delegated at any level, so the documents page tells a guardian that only
|
||||
the record owner can add documents instead of showing an empty slot. Module
|
||||
switches per managed profile, editing a profile after creation and asking
|
||||
for sex at creation are still open in #939 and follow as one piece.
|
||||
|
||||
## [1.38.12] — 2026-09-09
|
||||
|
||||
A dependency release: the image and the lockfile stop carrying twelve
|
||||
|
||||
+57
-1
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.38.12
|
||||
version: 1.38.13
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
@@ -65685,6 +65685,32 @@ components:
|
||||
- type: "null"
|
||||
canWrite:
|
||||
type: boolean
|
||||
writableDomains:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- measurements
|
||||
- medications
|
||||
- labs
|
||||
- profile
|
||||
- illness
|
||||
- mind
|
||||
- cycle
|
||||
- documents
|
||||
manageableDomains:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- measurements
|
||||
- medications
|
||||
- labs
|
||||
- profile
|
||||
- illness
|
||||
- mind
|
||||
- cycle
|
||||
- documents
|
||||
required:
|
||||
- accountId
|
||||
- username
|
||||
@@ -65695,6 +65721,8 @@ components:
|
||||
- recordKind
|
||||
- sections
|
||||
- canWrite
|
||||
- writableDomains
|
||||
- manageableDomains
|
||||
additionalProperties: false
|
||||
active:
|
||||
anyOf:
|
||||
@@ -65747,6 +65775,32 @@ components:
|
||||
- type: "null"
|
||||
canWrite:
|
||||
type: boolean
|
||||
writableDomains:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- measurements
|
||||
- medications
|
||||
- labs
|
||||
- profile
|
||||
- illness
|
||||
- mind
|
||||
- cycle
|
||||
- documents
|
||||
manageableDomains:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- measurements
|
||||
- medications
|
||||
- labs
|
||||
- profile
|
||||
- illness
|
||||
- mind
|
||||
- cycle
|
||||
- documents
|
||||
required:
|
||||
- accountId
|
||||
- username
|
||||
@@ -65757,6 +65811,8 @@ components:
|
||||
- recordKind
|
||||
- sections
|
||||
- canWrite
|
||||
- writableDomains
|
||||
- manageableDomains
|
||||
additionalProperties: false
|
||||
- type: "null"
|
||||
recordKind:
|
||||
|
||||
@@ -198,6 +198,17 @@ export const SCOPE_A11Y_STORAGE_STATE_PATH = resolve(
|
||||
"e2e/setup/storageStateScopeA11y.json",
|
||||
);
|
||||
|
||||
/**
|
||||
* v1.38.12 — the managed-record capabilities journey's own jar. Same scope
|
||||
* delegate, its own session row, for the reason every switching spec has
|
||||
* one: it moves the record selector, and two specs driving one row from two
|
||||
* workers would test their interference rather than the controls.
|
||||
*/
|
||||
export const SCOPE_CAPABILITIES_STORAGE_STATE_PATH = resolve(
|
||||
process.cwd(),
|
||||
"e2e/setup/storageStateScopeCapabilities.json",
|
||||
);
|
||||
|
||||
/**
|
||||
* v1.37.0 — one jar per SWITCHING spec, for the reason spelled out at
|
||||
* `DELEGATE_STORAGE_STATE_PATH` above and now applied to the scope delegate as
|
||||
@@ -739,6 +750,7 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
|
||||
await capture(E2E_SCOPE_DELEGATE, SCOPE_DELEGATE_STORAGE_STATE_PATH);
|
||||
await capture(E2E_SCOPE_DELEGATE, SCOPE_A11Y_STORAGE_STATE_PATH);
|
||||
await capture(E2E_SCOPE_DELEGATE, SCOPE_CAPABILITIES_STORAGE_STATE_PATH);
|
||||
|
||||
// v1.37.0 — one jar per switching spec. See the block comment on
|
||||
// FENCE_STORAGE_STATE_PATH: these three used to share the scope delegate's
|
||||
|
||||
@@ -21,6 +21,7 @@ export {
|
||||
DELEGATE_STORAGE_STATE_PATH,
|
||||
SCOPE_DELEGATE_STORAGE_STATE_PATH,
|
||||
SCOPE_A11Y_STORAGE_STATE_PATH,
|
||||
SCOPE_CAPABILITIES_STORAGE_STATE_PATH,
|
||||
FENCE_STORAGE_STATE_PATH,
|
||||
FENCE_OFFLINE_STORAGE_STATE_PATH,
|
||||
CROSS_TAB_STORAGE_STATE_PATH,
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* v1.38.12 — the controls a guardian sees inside a managed profile (#939).
|
||||
*
|
||||
* A person acting on a managed profile holds a MANAGE grant, and the server
|
||||
* has accepted their mood entries, screeners, visits, allergies and lab edits
|
||||
* since v1.37.0. The interface withheld every one of those controls anyway,
|
||||
* because the client answered `canManage: false` for every shared record. It
|
||||
* answers per section now, from the two lists `GET /api/auth/me` publishes.
|
||||
*
|
||||
* Two pages, one each way:
|
||||
*
|
||||
* - Mood is a section whose create answers at MANAGE, so the add control
|
||||
* is there and an entry can be added — asserted on the POST's status, not
|
||||
* on the paint, because the paint is what lied before.
|
||||
* - Documents is the section with no delegated write route, so the upload
|
||||
* control is absent and the page says why, in a sentence, where the
|
||||
* control would have been.
|
||||
*
|
||||
* Modelled on `v137-sharing-managed-profiles.spec.ts`; the switch helpers are
|
||||
* the same ones, for the same reasons written there.
|
||||
*/
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import { expect, test } from "./setup/test";
|
||||
import {
|
||||
E2E_LEVEL_RECORDS,
|
||||
SCOPE_CAPABILITIES_STORAGE_STATE_PATH,
|
||||
} from "./setup/test-helpers";
|
||||
|
||||
async function openSwitcher(page: Page) {
|
||||
await page.getByRole("button", { name: "User menu" }).first().click();
|
||||
await page.locator('[data-slot="account-switcher-trigger"]').click();
|
||||
await expect(
|
||||
page.locator('[data-slot="account-switcher-menu"]'),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `act` and wait for the document it replaces to be gone. See the
|
||||
* v1.37.0 spec for why waiting on the banner alone settles nothing.
|
||||
*/
|
||||
async function withDocumentReplacement(
|
||||
page: Page,
|
||||
act: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
(window as Window & { __hlNavToken?: true }).__hlNavToken = true;
|
||||
});
|
||||
await act();
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
(window as Window & { __hlNavToken?: true }).__hlNavToken === undefined,
|
||||
undefined,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
}
|
||||
|
||||
async function openRecord(page: Page, username: string) {
|
||||
await page.goto("/");
|
||||
await openSwitcher(page);
|
||||
const entry = page.locator(
|
||||
`[data-slot="account-switcher-entry"][data-account-username="${username}"]`,
|
||||
);
|
||||
await expect(entry).toHaveCount(1);
|
||||
await withDocumentReplacement(page, () => entry.click());
|
||||
await expect(page.locator('[data-slot="shared-record-banner"]')).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function leaveRecord(page: Page) {
|
||||
await withDocumentReplacement(page, () =>
|
||||
page.locator('[data-slot="shared-record-banner-exit"]').click(),
|
||||
);
|
||||
await expect(page.locator('[data-slot="shared-record-banner"]')).toHaveCount(
|
||||
0,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
}
|
||||
|
||||
test.describe.serial("a guardian's controls inside a managed profile", () => {
|
||||
// Its own session row: this journey moves the record selector, and the
|
||||
// scoped-sharing journey drives the same account from another worker.
|
||||
test.use({ storageState: SCOPE_CAPABILITIES_STORAGE_STATE_PATH });
|
||||
|
||||
const managed = E2E_LEVEL_RECORDS.find(
|
||||
(record) => record.recordKind === "managed",
|
||||
);
|
||||
if (!managed) throw new Error("managed record fixture is missing");
|
||||
|
||||
test("offers the mood add control and lands the entry", async ({ page }) => {
|
||||
await openRecord(page, managed.username);
|
||||
const banner = page.locator('[data-slot="shared-record-banner"]');
|
||||
await expect(banner).toHaveAttribute("data-record-kind", "managed");
|
||||
await expect(banner).toHaveAttribute("data-access-level", "manage");
|
||||
|
||||
await page.goto("/mood");
|
||||
const add = page.locator('[data-slot="mood-add-entry"]');
|
||||
await expect(add).toBeVisible({ timeout: 30_000 });
|
||||
await add.click();
|
||||
|
||||
const face = page.locator('[data-slot="mood-face"][data-mood="GUT"]');
|
||||
await expect(face).toBeVisible();
|
||||
await face.click();
|
||||
|
||||
const posted = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "POST" &&
|
||||
/\/api\/mood-entries(\?|$)/.test(response.url()),
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await page.getByRole("button", { name: "Save", exact: true }).click();
|
||||
const response = await posted;
|
||||
// The route, not the paint: a control that renders and 403s is the exact
|
||||
// failure this release ends.
|
||||
expect(response.status()).toBe(201);
|
||||
await expect(page.locator('[data-slot="mood-rows"]').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await leaveRecord(page);
|
||||
});
|
||||
|
||||
test("withholds the vault's upload control and says why", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openRecord(page, managed.username);
|
||||
|
||||
await page.goto("/documents");
|
||||
const note = page.locator('[data-slot="documents-owner-only"]');
|
||||
await expect(note).toBeVisible({ timeout: 30_000 });
|
||||
await expect(note).toHaveText(
|
||||
"Documents can only be added by the record owner.",
|
||||
);
|
||||
await expect(page.getByRole("button", { name: "Upload" })).toHaveCount(0);
|
||||
await expect(page.locator('input[type="file"]')).toHaveCount(0);
|
||||
|
||||
await leaveRecord(page);
|
||||
});
|
||||
});
|
||||
@@ -9548,6 +9548,7 @@
|
||||
"title": "Dokumente",
|
||||
"subtitle": "Jeder Brief, Befund und Scan an einem durchsuchbaren Ort — verschlüsselt gespeichert.",
|
||||
"pageUpload": "Hochladen",
|
||||
"ownerOnlyUpload": "Dokumente kann nur die Inhaberin oder der Inhaber der Akte hinzufügen.",
|
||||
"kind": {
|
||||
"DOCTOR_REPORT": "Arztbericht",
|
||||
"DISCHARGE_LETTER": "Entlassungsbrief",
|
||||
|
||||
@@ -9548,6 +9548,7 @@
|
||||
"title": "Documents",
|
||||
"subtitle": "Every letter, report, and scan in one searchable place — encrypted at rest.",
|
||||
"pageUpload": "Upload",
|
||||
"ownerOnlyUpload": "Documents can only be added by the record owner.",
|
||||
"kind": {
|
||||
"DOCTOR_REPORT": "Doctor report",
|
||||
"DISCHARGE_LETTER": "Discharge letter",
|
||||
|
||||
@@ -9548,6 +9548,7 @@
|
||||
"title": "Documentos",
|
||||
"subtitle": "Cada carta, informe y escaneo en un solo lugar con búsqueda — cifrado en reposo.",
|
||||
"pageUpload": "Subir",
|
||||
"ownerOnlyUpload": "Solo el titular del historial puede añadir documentos.",
|
||||
"kind": {
|
||||
"DOCTOR_REPORT": "Informe médico",
|
||||
"DISCHARGE_LETTER": "Informe de alta",
|
||||
|
||||
@@ -9548,6 +9548,7 @@
|
||||
"title": "Documents",
|
||||
"subtitle": "Chaque courrier, compte rendu et scan au même endroit, consultable — chiffré au repos.",
|
||||
"pageUpload": "Téléverser",
|
||||
"ownerOnlyUpload": "Seul le titulaire du dossier peut ajouter des documents.",
|
||||
"kind": {
|
||||
"DOCTOR_REPORT": "Compte rendu médical",
|
||||
"DISCHARGE_LETTER": "Lettre de sortie",
|
||||
|
||||
@@ -9548,6 +9548,7 @@
|
||||
"title": "Documenti",
|
||||
"subtitle": "Ogni lettera, referto e scansione in un unico posto ricercabile — cifrato a riposo.",
|
||||
"pageUpload": "Carica",
|
||||
"ownerOnlyUpload": "Solo il titolare della cartella può aggiungere documenti.",
|
||||
"kind": {
|
||||
"DOCTOR_REPORT": "Referto medico",
|
||||
"DISCHARGE_LETTER": "Lettera di dimissione",
|
||||
|
||||
@@ -9548,6 +9548,7 @@
|
||||
"title": "문서",
|
||||
"subtitle": "모든 소견서·리포트·영상을 한곳에서 검색해요 — 저장할 때 암호화돼요.",
|
||||
"pageUpload": "업로드",
|
||||
"ownerOnlyUpload": "문서는 기록 소유자만 추가할 수 있습니다.",
|
||||
"kind": {
|
||||
"DOCTOR_REPORT": "진료용 리포트",
|
||||
"DISCHARGE_LETTER": "퇴원 소견서",
|
||||
|
||||
@@ -9548,6 +9548,7 @@
|
||||
"title": "Dokumenty",
|
||||
"subtitle": "Każde pismo, wynik i skan w jednym przeszukiwalnym miejscu — zaszyfrowane w spoczynku.",
|
||||
"pageUpload": "Prześlij",
|
||||
"ownerOnlyUpload": "Dokumenty może dodawać tylko właściciel kartoteki.",
|
||||
"kind": {
|
||||
"DOCTOR_REPORT": "Raport lekarski",
|
||||
"DISCHARGE_LETTER": "Wypis ze szpitala",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.38.12",
|
||||
"version": "1.38.13",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
@@ -117,6 +117,7 @@ export default defineConfig({
|
||||
testIgnore: [
|
||||
"v137-sharing-managed-profiles.spec.ts",
|
||||
"v137-sharing-managed-profiles-a11y.spec.ts",
|
||||
"v138-managed-record-capabilities.spec.ts",
|
||||
// v1.37.0 — every spec that MOVES a session's record selector runs in
|
||||
// exactly one project.
|
||||
//
|
||||
|
||||
Generated
+17
-19
@@ -13,13 +13,11 @@ overrides:
|
||||
postcss@<8.5.23: ^8.5.23
|
||||
nanoid@<3.3.18: ^3.3.18
|
||||
'@hono/node-server@<1.19.15': ^1.19.15
|
||||
hono@<4.12.25: ^4.12.25
|
||||
picomatch@<4.0.4: ^4.0.4
|
||||
body-parser@<2.3.0: ^2.3.0
|
||||
qs@<6.16.0: ^6.16.0
|
||||
fast-uri@<3.1.6: ^3.1.6
|
||||
flatted@<3.4.2: ^3.4.2
|
||||
js-yaml@>=4.0.0 <4.3.1: ^4.3.1
|
||||
mysql2@<3.22.0: ^3.22.0
|
||||
minimatch@<3.1.4: ^3.1.4
|
||||
ip-address@<10.3.1: ^10.3.1
|
||||
@@ -699,7 +697,7 @@ packages:
|
||||
resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==}
|
||||
engines: {node: '>=18.14.1'}
|
||||
peerDependencies:
|
||||
hono: ^4.12.25
|
||||
hono: ^4.13.5
|
||||
|
||||
'@hookform/resolvers@5.9.1':
|
||||
resolution: {integrity: sha512-7b7vsbraJxKgjVSA1Nur9tLwj539WGJUBLA7QNvXnFoT2pM5Z7G+6rlukk4B2/QrTZy6huRtH6wKeESPKuIr6w==}
|
||||
@@ -4464,8 +4462,8 @@ packages:
|
||||
hermes-parser@0.25.1:
|
||||
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
|
||||
|
||||
hono@4.13.3:
|
||||
resolution: {integrity: sha512-r8AO2mYHoLxSHkgafNeC/BXyb2vWRxD3jem4Ts+ptav8oTG5FIRifAjuJEmZI4bSvvc2ns0GxmIYiZnHqN3mMw==}
|
||||
hono@4.13.7:
|
||||
resolution: {integrity: sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==}
|
||||
engines: {node: '>=16.9.0'}
|
||||
|
||||
html-escaper@2.0.2:
|
||||
@@ -4790,8 +4788,8 @@ packages:
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
js-yaml@4.3.1:
|
||||
resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==}
|
||||
js-yaml@4.3.2:
|
||||
resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==}
|
||||
hasBin: true
|
||||
|
||||
jsesc@3.1.0:
|
||||
@@ -7411,7 +7409,7 @@ snapshots:
|
||||
globals: 14.0.0
|
||||
ignore: 5.3.2
|
||||
import-fresh: 3.3.1
|
||||
js-yaml: 4.3.1
|
||||
js-yaml: 4.3.2
|
||||
minimatch: 3.1.5
|
||||
strip-json-comments: 3.1.1
|
||||
transitivePeerDependencies:
|
||||
@@ -7464,9 +7462,9 @@ snapshots:
|
||||
|
||||
'@hexagon/base64@1.1.28': {}
|
||||
|
||||
'@hono/node-server@1.19.17(hono@4.13.3)':
|
||||
'@hono/node-server@1.19.17(hono@4.13.7)':
|
||||
dependencies:
|
||||
hono: 4.13.3
|
||||
hono: 4.13.7
|
||||
|
||||
'@hookform/resolvers@5.9.1(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(effect@3.20.0)(react-hook-form@7.83.0(react@19.2.7))(valibot@1.4.2(typescript@6.0.3))(zod@4.4.3)':
|
||||
dependencies:
|
||||
@@ -7677,7 +7675,7 @@ snapshots:
|
||||
|
||||
'@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@hono/node-server': 1.19.17(hono@4.13.3)
|
||||
'@hono/node-server': 1.19.17(hono@4.13.7)
|
||||
ajv: 8.20.0
|
||||
ajv-formats: 3.0.1(ajv@8.20.0)
|
||||
content-type: 1.0.5
|
||||
@@ -7687,7 +7685,7 @@ snapshots:
|
||||
eventsource-parser: 3.1.1
|
||||
express: 5.2.1(supports-color@7.2.0)
|
||||
express-rate-limit: 8.6.2(express@5.2.1(supports-color@7.2.0))(supports-color@7.2.0)
|
||||
hono: 4.13.3
|
||||
hono: 4.13.7
|
||||
jose: 6.2.10
|
||||
json-schema-typed: 8.0.2
|
||||
pkce-challenge: 5.0.1
|
||||
@@ -7701,7 +7699,7 @@ snapshots:
|
||||
|
||||
'@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@4.4.3)':
|
||||
dependencies:
|
||||
'@hono/node-server': 1.19.17(hono@4.13.3)
|
||||
'@hono/node-server': 1.19.17(hono@4.13.7)
|
||||
ajv: 8.20.0
|
||||
ajv-formats: 3.0.1(ajv@8.20.0)
|
||||
content-type: 1.0.5
|
||||
@@ -7711,7 +7709,7 @@ snapshots:
|
||||
eventsource-parser: 3.1.1
|
||||
express: 5.2.1(supports-color@7.2.0)
|
||||
express-rate-limit: 8.6.2(express@5.2.1(supports-color@7.2.0))(supports-color@7.2.0)
|
||||
hono: 4.13.3
|
||||
hono: 4.13.7
|
||||
jose: 6.2.10
|
||||
json-schema-typed: 8.0.2
|
||||
pkce-challenge: 5.0.1
|
||||
@@ -8245,13 +8243,13 @@ snapshots:
|
||||
'@electric-sql/pglite': 0.4.1
|
||||
'@electric-sql/pglite-socket': 0.1.1(@electric-sql/pglite@0.4.1)
|
||||
'@electric-sql/pglite-tools': 0.3.1(@electric-sql/pglite@0.4.1)
|
||||
'@hono/node-server': 1.19.17(hono@4.13.3)
|
||||
'@hono/node-server': 1.19.17(hono@4.13.7)
|
||||
'@prisma/get-platform': 7.2.0
|
||||
'@prisma/query-plan-executor': 7.2.0
|
||||
'@prisma/streams-local': 0.1.2
|
||||
foreground-child: 3.3.1
|
||||
get-port-please: 3.2.0
|
||||
hono: 4.13.3
|
||||
hono: 4.13.7
|
||||
http-status-codes: 2.3.0
|
||||
pathe: 2.0.3
|
||||
proper-lockfile: 4.1.2
|
||||
@@ -10239,7 +10237,7 @@ snapshots:
|
||||
dependencies:
|
||||
env-paths: 2.2.1
|
||||
import-fresh: 3.3.1
|
||||
js-yaml: 4.3.1
|
||||
js-yaml: 4.3.2
|
||||
parse-json: 5.2.0
|
||||
optionalDependencies:
|
||||
typescript: 6.0.3
|
||||
@@ -11215,7 +11213,7 @@ snapshots:
|
||||
dependencies:
|
||||
hermes-estree: 0.25.1
|
||||
|
||||
hono@4.13.3: {}
|
||||
hono@4.13.7: {}
|
||||
|
||||
html-escaper@2.0.2: {}
|
||||
|
||||
@@ -11498,7 +11496,7 @@ snapshots:
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
js-yaml@4.3.1:
|
||||
js-yaml@4.3.2:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ overrides:
|
||||
# because the advisory is fixed there too and 2.x is a major nothing asked
|
||||
# for.
|
||||
"@hono/node-server@<1.19.15": "^1.19.15"
|
||||
"hono@<4.12.25": "^4.12.25"
|
||||
"picomatch@<4.0.4": "^4.0.4"
|
||||
"body-parser@<2.3.0": "^2.3.0"
|
||||
# Array-limit bypass through bracket-key comma parsing, and a denial of
|
||||
@@ -36,9 +35,6 @@ overrides:
|
||||
# copy, so it sits on a path this app exposes.
|
||||
"fast-uri@<3.1.6": "^3.1.6"
|
||||
"flatted@<3.4.2": "^3.4.2"
|
||||
# The advisory covers 4.x only; the bound keeps a hypothetical 3.x consumer
|
||||
# off a major it never asked for.
|
||||
"js-yaml@>=4.0.0 <4.3.1": "^4.3.1"
|
||||
# GHSA-3f6p-5ww8-9rcr: auth-plugin downgrade leaking plaintext credentials.
|
||||
# Reached only through prisma's MySQL driver, which this app never opens;
|
||||
# pinned so the audit reads clean, and mirrored for /opt/prisma-cli in the
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.38.12";
|
||||
/* @sw-version-fallback */ "v1.38.13";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
|
||||
@@ -106,6 +106,19 @@ const ACCOUNT_ACCESS_ADDITIVE_FIELDS = [
|
||||
"recordKind",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* v1.38.12 — fields that answer "what may I do in the record I am inside",
|
||||
* which only the active-record consumer has a use for. The switcher and the
|
||||
* navigation presentation say what was granted (`level`, `sections`); the
|
||||
* controls read these two lists. Requiring an entry-site reader for them
|
||||
* would be an artificial read written to satisfy this guard, so they are
|
||||
* checked against the active-record consumer only.
|
||||
*/
|
||||
const ACTIVE_RECORD_ONLY_FIELDS = [
|
||||
"writableDomains",
|
||||
"manageableDomains",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Fields with no client reader, each with the reason it is nonetheless
|
||||
* correct for the payload to carry it. An entry here is a claim; write one
|
||||
@@ -273,5 +286,12 @@ describe("account payload consumer guard", () => {
|
||||
);
|
||||
|
||||
expect(readerCount).toBeGreaterThan(0);
|
||||
|
||||
for (const field of ACTIVE_RECORD_ONLY_FIELDS) {
|
||||
expect(
|
||||
activeSource,
|
||||
`no active-record reader for account access field \`${field}\``,
|
||||
).toMatch(new RegExp(`active\\.${field}(?![\\w$])`));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -12,16 +12,24 @@
|
||||
* it, so this states it. It is structural rather than rendered because these
|
||||
* pages early-return a loading gate under SSR — the markup a delegate would
|
||||
* see is not reachable without a browser, and the property is about the source
|
||||
* either way: the link is inside a `canManage` conditional, or it is not.
|
||||
* either way: the link is inside an `inSharedRecord` conditional, or it is
|
||||
* not.
|
||||
*
|
||||
* v1.38.12 — the guard used to name the manage capability, which was true
|
||||
* only in one's own record. It answers per section now and is true for a
|
||||
* guardian, so a wrench behind it would send a guardian into the wall. The
|
||||
* customise pages live under `/settings`, which is the CALLER's and never the
|
||||
* record's, so the link is withheld on `inSharedRecord` — the one answer no
|
||||
* grant level moves.
|
||||
*
|
||||
* ## What decides
|
||||
*
|
||||
* The AST. The `href` is found as a string literal on a JSX attribute and the
|
||||
* ancestor chain above it is walked for a `&&` whose left-hand side names
|
||||
* `canManage`. A comment about gating cannot satisfy that, and a `canManage`
|
||||
* used elsewhere in the same file cannot either — which a "does this file
|
||||
* mention the symbol" matcher would have accepted, and which is exactly what
|
||||
* `/mood` looked like the day it shipped ungated.
|
||||
* `inSharedRecord`. A comment about gating cannot satisfy that, and an
|
||||
* `inSharedRecord` used elsewhere in the same file cannot either — which a
|
||||
* "does this file mention the symbol" matcher would have accepted, and which
|
||||
* is exactly what `/mood` looked like the day it shipped ungated.
|
||||
*/
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join, sep } from "node:path";
|
||||
@@ -34,8 +42,8 @@ const SRC = join(process.cwd(), "src");
|
||||
/** The prefix every customise shortcut points at. */
|
||||
const LAYOUT_PREFIX = "/settings/layout/";
|
||||
|
||||
/** The capability a shared-record surface withholds these on. */
|
||||
const GUARD = "canManage";
|
||||
/** The answer a shared-record surface withholds these on: `!inSharedRecord`. */
|
||||
const GUARD = "inSharedRecord";
|
||||
|
||||
/**
|
||||
* The surfaces that carry a layout shortcut and need no gate.
|
||||
@@ -66,16 +74,16 @@ function allSourceFiles(): string[] {
|
||||
.sort();
|
||||
}
|
||||
|
||||
/** Does any ancestor of `node` gate it behind `<GUARD> && …`? */
|
||||
function guardedByCanManage(node: ts.Node): boolean {
|
||||
/** Does any ancestor of `node` gate it behind `!<GUARD> && …`? */
|
||||
function guardedByOwnRecord(node: ts.Node): boolean {
|
||||
for (let cur: ts.Node | undefined = node; cur; cur = cur.parent) {
|
||||
const parent: ts.Node | undefined = cur.parent;
|
||||
if (
|
||||
parent &&
|
||||
ts.isBinaryExpression(parent) &&
|
||||
parent.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken &&
|
||||
// Only the RIGHT side is the guarded branch. `canManage && x` guards `x`;
|
||||
// `x && canManage` is `canManage` being the guarded thing.
|
||||
// Only the RIGHT side is the guarded branch. `!inSharedRecord && x`
|
||||
// guards `x`; `x && !inSharedRecord` is the answer being the guarded thing.
|
||||
parent.right === cur
|
||||
) {
|
||||
const names = new Set<string>();
|
||||
@@ -87,7 +95,7 @@ function guardedByCanManage(node: ts.Node): boolean {
|
||||
if (names.has(GUARD)) return true;
|
||||
}
|
||||
// A conditional whose whenTrue branch holds the link, guarded on the
|
||||
// capability: `canManage ? <Link/> : null`, the shape `vorsorge-section`
|
||||
// answer: `!inSharedRecord ? <Link/> : null`, the shape `vorsorge-section`
|
||||
// uses.
|
||||
if (
|
||||
parent &&
|
||||
@@ -120,7 +128,7 @@ function unguardedShortcuts(source: string, fileName: string): string[] {
|
||||
if (
|
||||
ts.isStringLiteralLike(node) &&
|
||||
node.text.startsWith(LAYOUT_PREFIX) &&
|
||||
!guardedByCanManage(node)
|
||||
!guardedByOwnRecord(node)
|
||||
) {
|
||||
found.push(node.text);
|
||||
}
|
||||
@@ -160,20 +168,20 @@ describe("the customise shortcut is withheld inside somebody else's record", ()
|
||||
}
|
||||
expect(
|
||||
offenders,
|
||||
`a customise link outside a \`${GUARD}\` branch sends a delegate to /settings, which a switch closes`,
|
||||
`a customise link outside a \`!${GUARD}\` branch sends a delegate to /settings, which a switch closes`,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("the matcher reads the branch, not the file", () => {
|
||||
// The shape that shipped: the capability is resolved and used elsewhere in
|
||||
// The shape that shipped: the answer is resolved and used elsewhere in
|
||||
// the module while the link itself sits outside any branch. A matcher
|
||||
// asking "does this file name canManage" calls this clean.
|
||||
// asking "does this file name inSharedRecord" calls this clean.
|
||||
const UNGATED = `
|
||||
export function Page() {
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
return (
|
||||
<>
|
||||
{canManage && <Button data-slot="add" />}
|
||||
{!inSharedRecord && <Button data-slot="add" />}
|
||||
<Link href="/settings/layout/mood">Customise</Link>
|
||||
</>
|
||||
);
|
||||
@@ -186,16 +194,16 @@ describe("the customise shortcut is withheld inside somebody else's record", ()
|
||||
|
||||
const GATED = `
|
||||
export function Page() {
|
||||
const { canManage } = useRecordCapabilities();
|
||||
return canManage && <Link href="/settings/layout/mood">Customise</Link>;
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
return !inSharedRecord && <Link href="/settings/layout/mood">Customise</Link>;
|
||||
}
|
||||
`;
|
||||
expect(unguardedShortcuts(GATED, "g.tsx")).toEqual([]);
|
||||
|
||||
const TERNARY = `
|
||||
export function Page() {
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const wrench = canManage ? (
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
const wrench = !inSharedRecord ? (
|
||||
<Link href="/settings/layout/vorsorge">Customise</Link>
|
||||
) : null;
|
||||
return wrench;
|
||||
@@ -203,12 +211,12 @@ describe("the customise shortcut is withheld inside somebody else's record", ()
|
||||
`;
|
||||
expect(unguardedShortcuts(TERNARY, "t.tsx")).toEqual([]);
|
||||
|
||||
// And the inverted shape is not a gate: `href && canManage` puts the
|
||||
// capability in the guarded position, not the link.
|
||||
// And the inverted shape is not a gate: `href && !inSharedRecord` puts
|
||||
// the answer in the guarded position, not the link.
|
||||
const INVERTED = `
|
||||
export function Page() {
|
||||
const { canManage } = useRecordCapabilities();
|
||||
return <Link href={"/settings/layout/labs"}>{something && canManage}</Link>;
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
return <Link href={"/settings/layout/labs"}>{something && !inSharedRecord}</Link>;
|
||||
}
|
||||
`;
|
||||
expect(unguardedShortcuts(INVERTED, "i.tsx")).toEqual([
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* v1.38.12 — the delegated-write table is the routes, frozen.
|
||||
*
|
||||
* `src/lib/sharing/domain-write-support.ts` says, per section, whether any
|
||||
* route accepts a delegated `"write"` and whether any accepts a delegated
|
||||
* `"manage"`. The account payload publishes a grant's intersection with that
|
||||
* table, and every add / edit / delete control in a shared record reads the
|
||||
* result. So the table is an access-shaped fact about the route tree, and a
|
||||
* table that drifted from the tree would either paint a control that 403s
|
||||
* (table says yes, no route) or hide one the server accepts (route exists,
|
||||
* table says no) — the two failures #939 was made of.
|
||||
*
|
||||
* This guard re-derives the table from the route files and fails on a
|
||||
* disagreement in EITHER direction. It scans every `route.ts` under
|
||||
* `src/app/api` for `requireRecordAuth("write" | "manage", "<domain>")`,
|
||||
* whitespace-tolerant across the line break a formatter may put after the
|
||||
* opening parenthesis (the matcher lesson from
|
||||
* `bearer-scope-enforcement-guard.test.ts`: a literal that demanded one line
|
||||
* matched nothing and was green for it). An empty match set is a failure, not
|
||||
* a clean sweep.
|
||||
*
|
||||
* `record` is skipped on purpose: it is not a member of `SHARE_DOMAINS`, and a
|
||||
* route declaring it reads across sections rather than writing to one. A
|
||||
* domain outside the vocabulary other than `record` fails the run outright,
|
||||
* because the table is keyed on the vocabulary and would silently lack it.
|
||||
*
|
||||
* Mutation checks, run:
|
||||
* - set `mind.write` to `true` in the table → "agrees with the routes" red;
|
||||
* - change `requireRecordAuth("manage", "mind")` in `mood-entries/route.ts`
|
||||
* to `"write"` → red, in the other direction;
|
||||
* - point `API_ROOT` at an empty directory → the non-zero floor goes red.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
DOMAIN_WRITE_SUPPORT,
|
||||
delegatedDomains,
|
||||
} from "@/lib/sharing/domain-write-support";
|
||||
import { SHARE_DOMAINS, type ShareDomain } from "@/lib/sharing/scope";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const API_ROOT = join(process.cwd(), "src", "app", "api");
|
||||
|
||||
/**
|
||||
* Whitespace-tolerant on both sides of the comma and after the parenthesis.
|
||||
* `[a-z-]+` rather than the vocabulary, so a route naming a section this
|
||||
* file has never heard of is reported instead of ignored.
|
||||
*/
|
||||
const DELEGATED_WRITE =
|
||||
/requireRecordAuth\(\s*"(write|manage)"\s*,\s*"([a-z-]+)"/g;
|
||||
|
||||
interface Observed {
|
||||
write: Set<string>;
|
||||
manage: Set<string>;
|
||||
matches: number;
|
||||
unknown: string[];
|
||||
}
|
||||
|
||||
function observe(): Observed {
|
||||
const out: Observed = {
|
||||
write: new Set(),
|
||||
manage: new Set(),
|
||||
matches: 0,
|
||||
unknown: [],
|
||||
};
|
||||
const known = new Set<string>(SHARE_DOMAINS);
|
||||
for (const rel of walkSourceFiles(API_ROOT, { floor: 100 })) {
|
||||
if (!rel.endsWith("route.ts")) continue;
|
||||
const source = readFileSync(join(API_ROOT, rel), "utf8");
|
||||
for (const m of source.matchAll(DELEGATED_WRITE)) {
|
||||
out.matches += 1;
|
||||
const need = m[1] as "write" | "manage";
|
||||
const domain = m[2];
|
||||
if (domain === "record") continue;
|
||||
if (!known.has(domain)) {
|
||||
out.unknown.push(`${rel}: ${need} ${domain}`);
|
||||
continue;
|
||||
}
|
||||
out[need].add(domain);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("the delegated-write table agrees with the routes", () => {
|
||||
const observed = observe();
|
||||
|
||||
it("scanned a non-trivial route tree", () => {
|
||||
// Below this, the matcher is broken rather than the tree small: the
|
||||
// measurements, medications, labs and profile families alone carry more.
|
||||
expect(observed.matches).toBeGreaterThan(50);
|
||||
expect(observed.write.size).toBeGreaterThan(0);
|
||||
expect(observed.manage.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("names no section outside the vocabulary", () => {
|
||||
expect(observed.unknown).toEqual([]);
|
||||
});
|
||||
|
||||
it("agrees with the routes, section by section, in both directions", () => {
|
||||
const derived = Object.fromEntries(
|
||||
SHARE_DOMAINS.map((domain) => [
|
||||
domain,
|
||||
{
|
||||
write: observed.write.has(domain),
|
||||
manage: observed.manage.has(domain),
|
||||
},
|
||||
]),
|
||||
) as Record<ShareDomain, { write: boolean; manage: boolean }>;
|
||||
expect(DOMAIN_WRITE_SUPPORT).toEqual(derived);
|
||||
});
|
||||
|
||||
it("keys the table on the whole vocabulary and nothing else", () => {
|
||||
expect(Object.keys(DOMAIN_WRITE_SUPPORT).sort()).toEqual(
|
||||
[...SHARE_DOMAINS].sort(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("delegatedDomains", () => {
|
||||
it("answers nothing for a read grant", () => {
|
||||
expect(delegatedDomains("read", null, "write")).toEqual([]);
|
||||
expect(delegatedDomains("read", null, "manage")).toEqual([]);
|
||||
});
|
||||
|
||||
it("gives a write grant its write sections and no manage sections", () => {
|
||||
const writable = delegatedDomains("write", null, "write");
|
||||
for (const domain of writable) {
|
||||
expect(DOMAIN_WRITE_SUPPORT[domain].write, domain).toBe(true);
|
||||
}
|
||||
expect(writable).not.toContain("mind");
|
||||
expect(writable).not.toContain("documents");
|
||||
expect(delegatedDomains("write", null, "manage")).toEqual([]);
|
||||
});
|
||||
|
||||
it("gives a manage grant every section with a delegated route, and never the vault", () => {
|
||||
const writable = delegatedDomains("manage", null, "write");
|
||||
const manageable = delegatedDomains("manage", null, "manage");
|
||||
expect(manageable).toContain("mind");
|
||||
expect(manageable).toContain("profile");
|
||||
expect(manageable).not.toContain("documents");
|
||||
expect(writable).not.toContain("documents");
|
||||
// manageable ⊆ writable: a section a grant can manage is one it can add to.
|
||||
for (const domain of manageable) expect(writable).toContain(domain);
|
||||
});
|
||||
|
||||
it("narrows to the grant's sections, keeping consent order", () => {
|
||||
expect(
|
||||
delegatedDomains("write", ["labs", "measurements"], "write"),
|
||||
).toEqual(["measurements", "labs"]);
|
||||
expect(delegatedDomains("write", ["documents"], "write")).toEqual([]);
|
||||
expect(delegatedDomains("write", [], "write")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -162,12 +162,14 @@ describe("what the canonical readers make of the same payload", () => {
|
||||
expect(capabilities.recordKind).toBe(fixture.recordKind);
|
||||
expect(capabilities.inSharedRecord).toBe(true);
|
||||
// Resolved server-side and rendered, never re-derived: `canAdd` follows
|
||||
// `canWrite`, and MANAGE does not become `canManage` on the client — a
|
||||
// delegate's management reach is the server's answer, one request at a
|
||||
// time.
|
||||
// `canWrite`, and `canManage` follows the published `manageableDomains`
|
||||
// list rather than the level — a delegate's management reach is the
|
||||
// server's answer, section by section.
|
||||
expect(capabilities.canWrite).toBe(fixture.canWrite);
|
||||
expect(capabilities.canAdd).toBe(fixture.canWrite);
|
||||
expect(capabilities.canManage).toBe(false);
|
||||
const manageable = active?.manageableDomains ?? [];
|
||||
expect(capabilities.canManage).toBe(manageable.length > 0);
|
||||
expect(manageable.length > 0).toBe(fixture.level === "manage");
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ export default function LabsPage() {
|
||||
// Scanning a report is not: it files a document and commits through the OCR
|
||||
// route, and neither is delegated. So a WRITE delegate keeps the manual add
|
||||
// and loses the choice menu around it.
|
||||
const { canAdd, canManage, inSharedRecord } = useRecordCapabilities();
|
||||
const { canAdd, inSharedRecord } = useRecordCapabilities();
|
||||
const mounted = useMounted();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -69,7 +69,7 @@ export default function LabsPage() {
|
||||
isLoading,
|
||||
labsEnabled: enabled,
|
||||
mounted,
|
||||
canManage,
|
||||
ownRecord: !inSharedRecord,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -111,7 +111,7 @@ export default function LabsPage() {
|
||||
point, left of the primary Add and linking to the Labs settings
|
||||
page (view, sort order, biomarker CRUD + reorder). Mirrors the
|
||||
medication page header's wrench glyph + slot + 44px tap floor. */}
|
||||
{canManage && (
|
||||
{!inSharedRecord && (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
@@ -133,7 +133,7 @@ export default function LabsPage() {
|
||||
mode (vision, or local OCR opted-in for text-only providers), so
|
||||
the surface stays simple for everyone else. When scanning is
|
||||
unavailable, Add opens the manual form directly. */}
|
||||
{ocrCapability.data?.available && canManage ? (
|
||||
{ocrCapability.data?.available && !inSharedRecord ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="min-h-11 sm:min-h-9">
|
||||
|
||||
@@ -59,6 +59,8 @@ const OWNER = {
|
||||
sections: null,
|
||||
recordKind: "shared" as const,
|
||||
canWrite: false,
|
||||
writableDomains: [],
|
||||
manageableDomains: [],
|
||||
};
|
||||
|
||||
const OWN_RECORD: AccountAccess = {
|
||||
|
||||
@@ -169,7 +169,7 @@ export default function MedicationsPageClient() {
|
||||
// to tapping each card in turn — so withholding it made a caregiver with
|
||||
// five morning tablets tap five times for nothing. The customize page stays
|
||||
// withheld; sharing does not cover it at all.
|
||||
const { canAdd, canManage, inSharedRecord } = useRecordCapabilities();
|
||||
const { canAdd, inSharedRecord } = useRecordCapabilities();
|
||||
// v1.18.1 (D3) — medications is an opt-out module. When the account has it
|
||||
// turned off the whole page disappears (the nav entry is hidden by the same
|
||||
// gate) and the list query never fires (the API would 403 anyway).
|
||||
@@ -433,7 +433,7 @@ export default function MedicationsPageClient() {
|
||||
Same glyph, slot (left of the add button) and responsive
|
||||
44-px mobile tap floor as the dashboard and insights
|
||||
headers. */}
|
||||
{canManage && (
|
||||
{!inSharedRecord && (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
|
||||
@@ -21,7 +21,9 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export default function MoodPageClient() {
|
||||
const { user, isAuthenticated, isLoading } = useAuth();
|
||||
const { canManage, inSharedRecord } = useRecordCapabilities();
|
||||
const { canManageDomain, inSharedRecord } = useRecordCapabilities();
|
||||
// A mood entry is created at MANAGE under the mind section.
|
||||
const canAddMood = canManageDomain("mind");
|
||||
const mounted = useMounted();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -89,7 +91,7 @@ export default function MoodPageClient() {
|
||||
the "not part of what was shared" panel: `/settings` is not a
|
||||
shared-record destination and the shell answers before the
|
||||
page does. */}
|
||||
{canManage && (
|
||||
{!inSharedRecord && (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
@@ -105,13 +107,13 @@ export default function MoodPageClient() {
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{/* v1.36.x — a mood entry is not one of the verbs a grant
|
||||
admits, so it stays with the account whose record it is. A
|
||||
delegate at any level gets no add path here. */}
|
||||
{canManage && (
|
||||
{/* A mood entry is a MANAGE create: a WRITE delegate gets no add
|
||||
path here, a guardian or a MANAGE delegate does. */}
|
||||
{canAddMood && (
|
||||
<Button
|
||||
onClick={() => setDialogOpen(true)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="mood-add-entry"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("mood.addEntry")}
|
||||
|
||||
@@ -16,14 +16,15 @@
|
||||
* Mutation checks, run:
|
||||
* - `useRecordCapabilities` returning `canAdd: true` unconditionally → the
|
||||
* read-only legs for the intake row and the card menu go red.
|
||||
* - `DeleteButton` dropping its `canManage` bail → "no row delete inside
|
||||
* somebody else's record" goes red.
|
||||
* - `DeleteButton` dropping its `canManageDomain` bail → "no row delete inside
|
||||
* somebody else's record" goes red; answering it for every section → the
|
||||
* vault leg of "a guardian's row delete follows the section" goes red.
|
||||
* - `TodayHero` passing `onDismiss` / `onAction` unconditionally → the rail
|
||||
* legs go red, printing the dismiss control and the check-in buttons.
|
||||
* - `VorsorgeDashboardCard` dropping its `canManage` bail → the mark-done
|
||||
* leg goes red.
|
||||
* - `EpisodeDocumentsCard` dropping its `canManage` bail → the link +
|
||||
* upload leg goes red.
|
||||
* - `VorsorgeDashboardCard` dropping its `canManageDomain` bail → the
|
||||
* mark-done leg goes red.
|
||||
* - `EpisodeDocumentsCard` dropping its `canManageDomain` bail → the link +
|
||||
* upload leg goes red, on the MANAGE arm.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
@@ -31,6 +32,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { AccountAccess } from "@/lib/sharing/account-access-view";
|
||||
import { delegatedDomains } from "@/lib/sharing/domain-write-support";
|
||||
|
||||
const OWNER = {
|
||||
accountId: "acct-owner",
|
||||
@@ -42,6 +44,8 @@ const OWNER = {
|
||||
sections: null,
|
||||
recordKind: "shared" as const,
|
||||
canWrite: false,
|
||||
writableDomains: [],
|
||||
manageableDomains: [],
|
||||
};
|
||||
|
||||
const OWN_RECORD: AccountAccess = {
|
||||
@@ -56,7 +60,30 @@ const READ_ONLY: AccountAccess = {
|
||||
};
|
||||
const WRITABLE: AccountAccess = {
|
||||
accounts: [OWNER],
|
||||
active: { ...OWNER, access: "write", level: "write", canWrite: true },
|
||||
active: {
|
||||
...OWNER,
|
||||
access: "write",
|
||||
level: "write",
|
||||
canWrite: true,
|
||||
writableDomains: delegatedDomains("write", null, "write"),
|
||||
manageableDomains: [],
|
||||
},
|
||||
canSwitch: true,
|
||||
};
|
||||
/**
|
||||
* v1.38.12 — a MANAGE grant, published as the server publishes it: the
|
||||
* sections with a delegated route, and never the vault.
|
||||
*/
|
||||
const MANAGING: AccountAccess = {
|
||||
accounts: [OWNER],
|
||||
active: {
|
||||
...OWNER,
|
||||
access: "write",
|
||||
level: "manage",
|
||||
canWrite: true,
|
||||
writableDomains: delegatedDomains("manage", null, "write"),
|
||||
manageableDomains: delegatedDomains("manage", null, "manage"),
|
||||
},
|
||||
canSwitch: true,
|
||||
};
|
||||
|
||||
@@ -141,25 +168,60 @@ function render(
|
||||
|
||||
describe("row delete", () => {
|
||||
const node = (
|
||||
<DeleteButton onConfirm={() => {}} title="Delete?" description="Gone." />
|
||||
<DeleteButton
|
||||
domain="labs"
|
||||
onConfirm={() => {}}
|
||||
title="Delete?"
|
||||
description="Gone."
|
||||
/>
|
||||
);
|
||||
|
||||
it("renders in the caller's own record", () => {
|
||||
expect(render(OWN_RECORD, node)).toContain("<button");
|
||||
});
|
||||
|
||||
it("is absent inside somebody else's record, at both levels", () => {
|
||||
it("is absent inside somebody else's record below MANAGE", () => {
|
||||
// Not disabled. A greyed bin still claims the row is the delegate's to
|
||||
// remove, and it is not — at either grant level, including for a row the
|
||||
// delegate entered themselves.
|
||||
expect(render(READ_ONLY, node)).toBe("");
|
||||
expect(render(WRITABLE, node)).toBe("");
|
||||
});
|
||||
|
||||
it("follows the section for a guardian: labs yes, the vault and owner-only rows no", () => {
|
||||
// The v1.37.0 hold-back answered "no" for every section; #939 is what
|
||||
// that cost. The answer is per section now, and the server's.
|
||||
expect(render(MANAGING, node)).toContain("<button");
|
||||
expect(
|
||||
render(
|
||||
MANAGING,
|
||||
<DeleteButton
|
||||
domain="documents"
|
||||
onConfirm={() => {}}
|
||||
title="Delete?"
|
||||
description="Gone."
|
||||
/>,
|
||||
),
|
||||
).toBe("");
|
||||
// `null`: the row's delete route resolves the caller (custom metrics).
|
||||
expect(
|
||||
render(
|
||||
MANAGING,
|
||||
<DeleteButton
|
||||
domain={null}
|
||||
onConfirm={() => {}}
|
||||
title="Delete?"
|
||||
description="Gone."
|
||||
/>,
|
||||
),
|
||||
).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bulk selection bar", () => {
|
||||
const node = (
|
||||
<SelectionActionBar
|
||||
domain="mind"
|
||||
count={3}
|
||||
onClear={() => {}}
|
||||
onConfirmDelete={() => {}}
|
||||
@@ -175,9 +237,15 @@ describe("bulk selection bar", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("is absent inside somebody else's record", () => {
|
||||
it("is absent inside somebody else's record below MANAGE", () => {
|
||||
expect(render(WRITABLE, node)).toBe("");
|
||||
});
|
||||
|
||||
it("renders for a guardian in a section whose bulk route answers", () => {
|
||||
expect(render(MANAGING, node)).toContain(
|
||||
'data-slot="selection-action-bar"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("marking a dose", () => {
|
||||
@@ -273,12 +341,13 @@ describe("the Today rail's mutating affordances", () => {
|
||||
expect(html).toContain("Keep it");
|
||||
});
|
||||
|
||||
it("withholds both inside somebody else's record, at either level", () => {
|
||||
it("withholds both inside somebody else's record, at every level", () => {
|
||||
// Dismissing an observation and answering a coach check-in are neither of
|
||||
// them an admitted create, and both write through routes that resolve the
|
||||
// CALLER — so a delegate tapping either would file it against their own
|
||||
// record if the route allowed it, and gets a 403 because it does not.
|
||||
for (const access of [READ_ONLY, WRITABLE]) {
|
||||
// record if the route allowed it, and gets a 403 because it does not. A
|
||||
// MANAGE grant changes nothing here: no section reaches a caller route.
|
||||
for (const access of [READ_ONLY, WRITABLE, MANAGING]) {
|
||||
const html = render(access, node);
|
||||
expect(html).not.toContain('data-slot="priority-card-dismiss"');
|
||||
expect(html).not.toContain("Keep it");
|
||||
@@ -326,7 +395,7 @@ describe("the Vorsorge summary's mark-done", () => {
|
||||
expect(html).toContain("Done");
|
||||
});
|
||||
|
||||
it("is absent inside somebody else's record, at either level", () => {
|
||||
it("is absent inside somebody else's record below MANAGE", () => {
|
||||
// `/checkups` already withheld this exact action; the dashboard summary
|
||||
// offering it was the inconsistency that showed nobody had asked.
|
||||
for (const access of [READ_ONLY, WRITABLE]) {
|
||||
@@ -335,6 +404,12 @@ describe("the Vorsorge summary's mark-done", () => {
|
||||
expect(html).not.toContain("Done");
|
||||
}
|
||||
});
|
||||
|
||||
it("is offered to a guardian: the satisfy route answers at MANAGE under measurements", () => {
|
||||
const html = render(MANAGING, node, seed);
|
||||
expect(html).toContain("Dental check-up");
|
||||
expect(html).toContain("Done");
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
@@ -350,12 +425,13 @@ describe("linking a document to an illness episode", () => {
|
||||
expect(html).toContain("Upload");
|
||||
});
|
||||
|
||||
it("offers neither inside somebody else's record, at either level", () => {
|
||||
it("offers neither inside somebody else's record, at every level", () => {
|
||||
// Both write through `POST /api/documents/inbound/bulk`, which the vault
|
||||
// gates on the same answer throughout — the episode side of the same link
|
||||
// had no gate at all, and the upload deep-linked to a page whose own
|
||||
// upload control is already withheld.
|
||||
for (const access of [READ_ONLY, WRITABLE]) {
|
||||
// upload control is already withheld. The vault has no delegated write
|
||||
// route, so a MANAGE grant is refused the same way.
|
||||
for (const access of [READ_ONLY, WRITABLE, MANAGING]) {
|
||||
const html = render(access, node);
|
||||
expect(html).not.toContain(">Link<");
|
||||
expect(html).not.toContain("Upload");
|
||||
@@ -374,24 +450,45 @@ describe("linking a document to an illness episode", () => {
|
||||
describe("the capture picker's kinds", () => {
|
||||
const ALL = ["measurement", "medication", "mood"] as const;
|
||||
|
||||
const OWNER_CAPS = { canAdd: true, canManageDomain: () => true };
|
||||
const WRITER_CAPS = { canAdd: true, canManageDomain: () => false };
|
||||
const READER_CAPS = { canAdd: false, canManageDomain: () => false };
|
||||
// A guardian: every section with a delegated route answers at MANAGE.
|
||||
const GUARDIAN_CAPS = {
|
||||
canAdd: true,
|
||||
canManageDomain: (domain: string) =>
|
||||
delegatedDomains("manage", null, "manage").includes(
|
||||
domain as "measurements",
|
||||
),
|
||||
};
|
||||
|
||||
it("offers everything in the caller's own record", () => {
|
||||
expect(
|
||||
visibleCaptureKinds({ canAdd: true, canManage: true }, [...ALL]),
|
||||
).toEqual(["measurement", "medication", "mood"]);
|
||||
expect(visibleCaptureKinds(OWNER_CAPS, [...ALL])).toEqual([
|
||||
"measurement",
|
||||
"medication",
|
||||
"mood",
|
||||
]);
|
||||
});
|
||||
|
||||
it("offers a delegate only what the delegation admits", () => {
|
||||
// A reading and a dose are admitted verbs. A mood entry is not, and the
|
||||
// server refuses it under a switch.
|
||||
expect(
|
||||
visibleCaptureKinds({ canAdd: true, canManage: false }, [...ALL]),
|
||||
).toEqual(["measurement", "medication"]);
|
||||
// A reading and a dose are admitted verbs. A mood entry is a MANAGE
|
||||
// create, and the server refuses it under a WRITE grant.
|
||||
expect(visibleCaptureKinds(WRITER_CAPS, [...ALL])).toEqual([
|
||||
"measurement",
|
||||
"medication",
|
||||
]);
|
||||
});
|
||||
|
||||
it("offers a guardian the mood entry too, because its route answers", () => {
|
||||
expect(visibleCaptureKinds(GUARDIAN_CAPS, [...ALL])).toEqual([
|
||||
"measurement",
|
||||
"medication",
|
||||
"mood",
|
||||
]);
|
||||
});
|
||||
|
||||
it("offers a read-only delegate nothing", () => {
|
||||
expect(
|
||||
visibleCaptureKinds({ canAdd: false, canManage: false }, [...ALL]),
|
||||
).toEqual([]);
|
||||
expect(visibleCaptureKinds(READER_CAPS, [...ALL])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -480,7 +577,7 @@ describe("a form opened before the record answered", () => {
|
||||
});
|
||||
|
||||
it("withdraws a dashboard quick-entry sheet the delegation does not admit", () => {
|
||||
const DELEGATE = { canAdd: true, canManage: false };
|
||||
const DELEGATE = { canAdd: true, canManageDomain: () => false };
|
||||
expect(admittedQuickEntry("mood", DELEGATE)).toBe(null);
|
||||
expect(admittedQuickEntry("measurement", DELEGATE)).toBe("measurement");
|
||||
expect(admittedQuickEntry("medicationIntake", DELEGATE)).toBe(
|
||||
@@ -488,15 +585,23 @@ describe("a form opened before the record answered", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the mood sheet for a guardian, whose mind routes answer at MANAGE", () => {
|
||||
const GUARDIAN = {
|
||||
canAdd: true,
|
||||
canManageDomain: (domain: string) => domain === "mind",
|
||||
};
|
||||
expect(admittedQuickEntry("mood", GUARDIAN)).toBe("mood");
|
||||
});
|
||||
|
||||
it("withdraws every quick-entry sheet from a read-only delegate", () => {
|
||||
const READER = { canAdd: false, canManage: false };
|
||||
const READER = { canAdd: false, canManageDomain: () => false };
|
||||
for (const sheet of ["measurement", "mood", "medicationIntake"] as const) {
|
||||
expect(admittedQuickEntry(sheet, READER), sheet).toBe(null);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves the owner's own sheets alone", () => {
|
||||
const OWNER_CAPS = { canAdd: true, canManage: true };
|
||||
const OWNER_CAPS = { canAdd: true, canManageDomain: () => true };
|
||||
for (const sheet of ["measurement", "mood", "medicationIntake"] as const) {
|
||||
expect(admittedQuickEntry(sheet, OWNER_CAPS), sheet).toBe(sheet);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ const OWNER = {
|
||||
sections: null,
|
||||
recordKind: "shared" as const,
|
||||
canWrite: true,
|
||||
writableDomains: [],
|
||||
manageableDomains: [],
|
||||
};
|
||||
|
||||
const OWN_RECORD: AccountAccess = {
|
||||
|
||||
@@ -100,8 +100,8 @@ export function ChartOverlayControls({
|
||||
// reasoning that keeps the mood tag layout off the delegable list. So a
|
||||
// delegate reads the owner's chart in its stored shape and is not offered a
|
||||
// dial that would silently fail.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
if (!canManage) return null;
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
if (inSharedRecord) return null;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
|
||||
@@ -196,6 +196,7 @@ export function CustomMetricDetail({
|
||||
<>
|
||||
{/* Controls, left → right: Delete · Edit · Show-all-values · Add. */}
|
||||
<DeleteButton
|
||||
domain={null}
|
||||
onConfirm={() => deleteMetric.mutate()}
|
||||
title={t("customMetrics.deleteConfirmTitle")}
|
||||
description={t("customMetrics.deleteConfirmDescription")}
|
||||
|
||||
@@ -261,6 +261,7 @@ export function CustomMetricHistoryList({
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<DeleteButton
|
||||
domain={null}
|
||||
onConfirm={() => deleteMutation.mutate(r.id)}
|
||||
title={t("customMetrics.entry.deleteConfirmTitle")}
|
||||
description={t(
|
||||
|
||||
@@ -38,7 +38,9 @@ export function CustomMetricList() {
|
||||
// Logging a value against a custom metric was admitted for a release and is
|
||||
// not any more: this paragraph named the gap, the release shipped over it,
|
||||
// and the write withdrew rather than the sentence staying true.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
// Every custom-metric write resolves the caller (`requireAuth`); no grant
|
||||
// reaches it at any level.
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
const router = useRouter();
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [addFooterEl, setAddFooterEl] = useState<HTMLDivElement | null>(null);
|
||||
@@ -57,7 +59,7 @@ export function CustomMetricList() {
|
||||
router.push(`/custom-metrics/${saved.id}`);
|
||||
}
|
||||
|
||||
if (!canManage) return null;
|
||||
if (inSharedRecord) return null;
|
||||
|
||||
return (
|
||||
<section
|
||||
|
||||
@@ -12,7 +12,11 @@ vi.mock("next/navigation", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({ canManage: true, canAdd: true }),
|
||||
useRecordCapabilities: () => ({
|
||||
canManage: true,
|
||||
canAdd: true,
|
||||
canManageDomain: () => true,
|
||||
}),
|
||||
}));
|
||||
|
||||
const errorQuery = {
|
||||
|
||||
@@ -93,7 +93,8 @@ export function CycleView() {
|
||||
// v1.36.x — no grant level admits a cycle write. Inside somebody else's
|
||||
// record the page stays readable and every path into the log-day sheet and
|
||||
// the profile settings is absent, rather than opening onto a refusal.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { canManageDomain } = useRecordCapabilities();
|
||||
const canManageCycle = canManageDomain("cycle");
|
||||
|
||||
// Deep-link support: `/cycle?tab=insights` opens the insights tab. The value
|
||||
// is read ONCE for the initial `defaultValue` (uncontrolled tabs — so normal
|
||||
@@ -188,7 +189,7 @@ export function CycleView() {
|
||||
// One action. The settings glyph that used to sit beside it was a
|
||||
// second door to the tab directly below, and no other page in the
|
||||
// app carries a gear in that corner.
|
||||
canManage ? (
|
||||
canManageCycle ? (
|
||||
<Button
|
||||
onClick={() => openSheet(today)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
@@ -275,7 +276,7 @@ export function CycleView() {
|
||||
) : null}
|
||||
{/* Log CTA — first period when no cycle exists yet, next period
|
||||
when the count paused on an overdue cycle. */}
|
||||
{canManage &&
|
||||
{canManageCycle &&
|
||||
!loading &&
|
||||
!calendarError &&
|
||||
verdict?.dayOfCycle == null ? (
|
||||
@@ -333,7 +334,7 @@ export function CycleView() {
|
||||
>
|
||||
{t("cycle.tabs.insights")}
|
||||
</TabsTrigger>
|
||||
{canManage && (
|
||||
{canManageCycle && (
|
||||
<TabsTrigger
|
||||
value="settings"
|
||||
className="text-xs sm:flex-1 sm:text-sm"
|
||||
@@ -385,7 +386,7 @@ export function CycleView() {
|
||||
null)
|
||||
: null
|
||||
}
|
||||
onSelectDay={canManage ? openSheet : undefined}
|
||||
onSelectDay={canManageCycle ? openSheet : undefined}
|
||||
onVisibleMonthChange={setVisibleMonth}
|
||||
/>
|
||||
</>
|
||||
@@ -453,7 +454,7 @@ export function CycleView() {
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
) : profileQuery.data && canManage ? (
|
||||
) : profileQuery.data && canManageCycle ? (
|
||||
<CycleSettings
|
||||
key={profileQuery.data.updatedAt}
|
||||
profile={profileQuery.data}
|
||||
|
||||
@@ -114,12 +114,13 @@ export function TodayHero({
|
||||
const dismissItem = usePriorityItemDismiss();
|
||||
// The rail's two mutating affordances — dismissing an observation and
|
||||
// answering a coach check-in — are neither of them an admitted create, so
|
||||
// they ask `canManage` and are absent at both grant levels. Both write
|
||||
// they ask for the caller's own record and are absent under every grant. Both write
|
||||
// through routes that resolve the CALLER (`POST /api/daily/digest/dismiss`,
|
||||
// `PATCH /api/coach/plans/[id]`), which under a switch means a 403 rather
|
||||
// than a stray row; the control still goes, because an affordance the
|
||||
// server is on record refusing should not be on the page.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
const ownRecord = !inSharedRecord;
|
||||
|
||||
// The coach check-in card's keep / let-go intents carry the plan id after the
|
||||
// ":" (a closed two-intent allowlist); adjust is an href handled by the card
|
||||
@@ -237,9 +238,9 @@ export function TodayHero({
|
||||
<PriorityCard
|
||||
key={`${item.kind}-${i}`}
|
||||
item={item}
|
||||
onAction={canManage ? handleAction : undefined}
|
||||
onAction={ownRecord ? handleAction : undefined}
|
||||
onDismiss={
|
||||
canManage ? (itemKey) => dismissItem.mutate(itemKey) : undefined
|
||||
ownRecord ? (itemKey) => dismissItem.mutate(itemKey) : undefined
|
||||
}
|
||||
actionsPending={
|
||||
item.kind === "coach_checkin" &&
|
||||
|
||||
@@ -40,7 +40,9 @@ export function DashboardHeader({
|
||||
// two of them (a reading, a dose). Mood stays with the account whose record
|
||||
// it is, and the customize shortcut points at a settings page sharing does
|
||||
// not cover at all.
|
||||
const { canAdd, canManage } = useRecordCapabilities();
|
||||
const { canAdd, canManageDomain, inSharedRecord } = useRecordCapabilities();
|
||||
// A mood entry is created at MANAGE under the mind section.
|
||||
const canAddMood = canManageDomain("mind");
|
||||
|
||||
// The pre-hero greeting derivation, kept hydration-safe: `user` comes
|
||||
// from the auth query, which can resolve before this boundary
|
||||
@@ -98,7 +100,7 @@ export function DashboardHeader({
|
||||
a `min-h-11 min-w-11` mobile floor so it meets the 44 px
|
||||
touch-target contract the add button also honours, shrinking
|
||||
back to the 40 px icon footprint on sm+. */}
|
||||
{canManage && (
|
||||
{!inSharedRecord && (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
@@ -154,7 +156,7 @@ export function DashboardHeader({
|
||||
<Activity className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("dashboard.quickAddMeasurement")}
|
||||
</DropdownMenuItem>
|
||||
{canManage && (
|
||||
{canAddMood && (
|
||||
<DropdownMenuItem onClick={() => onQuickEntry("mood")}>
|
||||
<Waves className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("dashboard.quickAddMood")}
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
useRecordCapabilities,
|
||||
type RecordCapabilities,
|
||||
} from "@/hooks/use-record-capabilities";
|
||||
import type { ShareDomain } from "@/lib/sharing/scope";
|
||||
|
||||
export type QuickEntryDialog =
|
||||
"measurement" | "mood" | "medicationIntake" | null;
|
||||
@@ -43,6 +44,15 @@ export type QuickEntryDialog =
|
||||
const DELEGABLE_QUICK_ENTRIES: ReadonlySet<NonNullable<QuickEntryDialog>> =
|
||||
new Set(["measurement", "medicationIntake"]);
|
||||
|
||||
/** v1.38.12 — the section each sheet writes to; see the capture picker. */
|
||||
const QUICK_ENTRY_DOMAIN: Readonly<
|
||||
Record<NonNullable<QuickEntryDialog>, ShareDomain>
|
||||
> = {
|
||||
measurement: "measurements",
|
||||
medicationIntake: "medications",
|
||||
mood: "mind",
|
||||
};
|
||||
|
||||
/**
|
||||
* The sheet that may actually be on screen.
|
||||
*
|
||||
@@ -59,9 +69,10 @@ const DELEGABLE_QUICK_ENTRIES: ReadonlySet<NonNullable<QuickEntryDialog>> =
|
||||
*/
|
||||
export function admittedQuickEntry(
|
||||
open: QuickEntryDialog,
|
||||
caps: Pick<RecordCapabilities, "canAdd" | "canManage">,
|
||||
caps: Pick<RecordCapabilities, "canAdd" | "canManageDomain">,
|
||||
): QuickEntryDialog {
|
||||
if (open === null || caps.canManage) return open;
|
||||
if (open === null) return open;
|
||||
if (caps.canManageDomain(QUICK_ENTRY_DOMAIN[open])) return open;
|
||||
return caps.canAdd && DELEGABLE_QUICK_ENTRIES.has(open) ? open : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import type { ShareDomain } from "@/lib/sharing/scope";
|
||||
|
||||
/**
|
||||
* Shared single-row delete button + confirm dialog for the data-
|
||||
@@ -30,11 +31,14 @@ import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
* v1.36.x — this is the one place a row delete is spelled across the app
|
||||
* (measurements, lab results, biomarkers, allergies, family history, custom
|
||||
* metrics, medication inventory), so the delegation rule is enforced here
|
||||
* rather than at twelve call sites: deleting is the owner's alone, at every
|
||||
* grant level, so inside somebody else's record the trigger is absent. Not
|
||||
* disabled — a greyed bin still claims the row is the delegate's to remove.
|
||||
* A caller that ever needs a delete a delegate MAY perform would have to say
|
||||
* so explicitly, and today there is none.
|
||||
* rather than at twelve call sites. Not disabled when withheld — a greyed bin
|
||||
* still claims the row is the delegate's to remove.
|
||||
*
|
||||
* v1.38.12 — deleting is a MANAGE verb, and whether a MANAGE grant reaches it
|
||||
* depends on the section the row belongs to (`domain-write-support.ts`), so
|
||||
* the caller names the section and this component asks `canManageDomain` for
|
||||
* it. `null` says the row's delete route resolves the caller rather than the
|
||||
* record (custom metrics), which no grant reaches at any level.
|
||||
*/
|
||||
export function DeleteButton({
|
||||
onConfirm,
|
||||
@@ -45,7 +49,10 @@ export function DeleteButton({
|
||||
triggerTitle,
|
||||
className = "",
|
||||
iconClassName = "h-3.5 w-3.5",
|
||||
domain,
|
||||
}: {
|
||||
/** The section the delete route answers under, or `null` for owner-only. */
|
||||
domain: ShareDomain | null;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -57,8 +64,9 @@ export function DeleteButton({
|
||||
iconClassName?: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { canManage } = useRecordCapabilities();
|
||||
if (!canManage) return null;
|
||||
const { canManageDomain, inSharedRecord } = useRecordCapabilities();
|
||||
const allowed = domain === null ? !inSharedRecord : canManageDomain(domain);
|
||||
if (!allowed) return null;
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import type { ShareDomain } from "@/lib/sharing/scope";
|
||||
|
||||
/**
|
||||
* Selection action bar for the data-management lists (measurements +
|
||||
@@ -31,10 +32,13 @@ import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
* The confirm copy is passed in because the title/body differ per surface
|
||||
* (measurements vs mood) and the count is interpolated by the caller.
|
||||
*
|
||||
* v1.36.x — bulk delete is the owner's alone at every grant level, so the bar
|
||||
* is absent inside somebody else's record. The row checkboxes that feed it are
|
||||
* dropped at their lists for the same reason; this guard is the backstop, so a
|
||||
* list that forgets cannot leave a delete button floating over the page.
|
||||
* v1.36.x — bulk delete is a MANAGE verb, so the bar is absent inside somebody
|
||||
* else's record below that level. The row checkboxes that feed it are dropped
|
||||
* at their lists for the same reason; this guard is the backstop, so a list
|
||||
* that forgets cannot leave a delete button floating over the page.
|
||||
*
|
||||
* v1.38.12 — the caller names the section its bulk route answers under, and
|
||||
* the bar asks `canManageDomain` for it; `null` marks an owner-only route.
|
||||
*/
|
||||
export function SelectionActionBar({
|
||||
count,
|
||||
@@ -43,7 +47,10 @@ export function SelectionActionBar({
|
||||
isDeleting,
|
||||
confirmTitle,
|
||||
confirmBody,
|
||||
domain,
|
||||
}: {
|
||||
/** The section the bulk-delete route answers under, or `null` for owner-only. */
|
||||
domain: ShareDomain | null;
|
||||
count: number;
|
||||
onClear: () => void;
|
||||
onConfirmDelete: () => void;
|
||||
@@ -52,9 +59,10 @@ export function SelectionActionBar({
|
||||
confirmBody: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { canManageDomain, inSharedRecord } = useRecordCapabilities();
|
||||
const allowed = domain === null ? !inSharedRecord : canManageDomain(domain);
|
||||
|
||||
if (count <= 0 || !canManage) return null;
|
||||
if (count <= 0 || !allowed) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -373,7 +373,8 @@ export function DocumentDetailSheet({
|
||||
(fact) => fact.factType === "OBSERVATION" && fact.status === "APPROVED",
|
||||
).length ?? 0;
|
||||
const { user } = useAuth();
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { canManageDomain } = useRecordCapabilities();
|
||||
const canManageDocuments = canManageDomain("documents");
|
||||
const labsModuleEnabled = user?.modules?.labs !== false;
|
||||
|
||||
const episodes = useIllnessEpisodes(true);
|
||||
@@ -387,14 +388,14 @@ export function DocumentDetailSheet({
|
||||
// document reads behind this sheet are delegable; the AI verbs above them are
|
||||
// not, and both of these routes stay on the caller's own authentication. A
|
||||
// delegate would fire two queries that must 403 in order to compute an
|
||||
// affordance every branch below already withholds on `canManage`.
|
||||
// affordance every branch below already withholds on `canManageDocuments`.
|
||||
const capability = useDocumentAiCapability(
|
||||
canManage && open && documentId !== null,
|
||||
canManageDocuments && open && documentId !== null,
|
||||
);
|
||||
// When auto-read is ON, reading happens automatically on upload — the manual
|
||||
// per-document AI action row is redundant and collapses away.
|
||||
const autoRead = useDocumentsAutoAiRead(
|
||||
canManage && open && documentId !== null,
|
||||
canManageDocuments && open && documentId !== null,
|
||||
);
|
||||
const suggest = useSuggestDetails();
|
||||
const summary = useDocumentSummary();
|
||||
@@ -709,7 +710,7 @@ export function DocumentDetailSheet({
|
||||
owner's alone. Inside somebody else's record the sheet reads
|
||||
the document and offers the download, and the leading slot
|
||||
simply has nothing in it. */}
|
||||
{canManage ? (
|
||||
{canManageDocuments ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
@@ -751,7 +752,7 @@ export function DocumentDetailSheet({
|
||||
</span>
|
||||
</Button>
|
||||
) : null}
|
||||
{canManage && (
|
||||
{canManageDocuments && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShareOpen(true)}
|
||||
@@ -829,7 +830,7 @@ export function DocumentDetailSheet({
|
||||
// The stored summary is read; generating one writes to the
|
||||
// owner's record, so a delegate reads what is there and has no
|
||||
// generate control.
|
||||
canGenerate={canManage}
|
||||
canGenerate={canManageDocuments}
|
||||
summaryState={doc.summaryState}
|
||||
generatedAtLabel={
|
||||
doc.summaryGeneratedAt
|
||||
@@ -842,7 +843,7 @@ export function DocumentDetailSheet({
|
||||
onGenerate={generateStoredSummary}
|
||||
/>
|
||||
|
||||
{canManage && (
|
||||
{canManageDocuments && (
|
||||
<DocumentAiSection
|
||||
aiEnabled={aiEnabled}
|
||||
autoReadEnabled={autoReadEnabled}
|
||||
@@ -906,7 +907,7 @@ export function DocumentDetailSheet({
|
||||
maxLength={200}
|
||||
placeholder={t("documents.detail.titlePlaceholder")}
|
||||
/>
|
||||
) : !canManage ? (
|
||||
) : !canManageDocuments ? (
|
||||
<p
|
||||
id="document-title-input"
|
||||
className={cn(
|
||||
@@ -952,7 +953,7 @@ export function DocumentDetailSheet({
|
||||
without accepting a change is the honest rendering. */}
|
||||
<Select
|
||||
value={doc.kind}
|
||||
disabled={!canManage}
|
||||
disabled={!canManageDocuments}
|
||||
onValueChange={(value) =>
|
||||
patch.mutate({ kind: value as InboundDocumentKindValue })
|
||||
}
|
||||
@@ -975,7 +976,7 @@ export function DocumentDetailSheet({
|
||||
</Label>
|
||||
<DateField
|
||||
id="document-date-field"
|
||||
disabled={!canManage}
|
||||
disabled={!canManageDocuments}
|
||||
value={doc.documentDate ?? ""}
|
||||
onChange={(value) =>
|
||||
patch.mutate({
|
||||
@@ -1006,7 +1007,7 @@ export function DocumentDetailSheet({
|
||||
{t("documents.detail.noConditions")}
|
||||
</span>
|
||||
) : null}
|
||||
{canManage && (episodes.data?.length ?? 0) > 0 ? (
|
||||
{canManageDocuments && (episodes.data?.length ?? 0) > 0 ? (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
@@ -1058,7 +1059,7 @@ export function DocumentDetailSheet({
|
||||
alreadyLinked={
|
||||
doc.encounterLinks.length > 0 && pickedEncounterId === null
|
||||
}
|
||||
disabled={!canManage}
|
||||
disabled={!canManageDocuments}
|
||||
value={pickedEncounterId}
|
||||
onChange={setSuggestedEncounter}
|
||||
/>
|
||||
@@ -1069,7 +1070,7 @@ export function DocumentDetailSheet({
|
||||
<VaccinationDocumentSuggestion
|
||||
anchor={doc.reportDate ?? doc.documentDate}
|
||||
documentId={doc.id}
|
||||
disabled={!canManage}
|
||||
disabled={!canManageDocuments}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -1111,7 +1112,7 @@ export function DocumentDetailSheet({
|
||||
values" over its stored text — never a re-upload. */}
|
||||
<DocumentFactsSection
|
||||
doc={doc}
|
||||
canManage={canManage}
|
||||
canManage={canManageDocuments}
|
||||
aiEnabled={aiEnabled}
|
||||
labsModuleEnabled={labsModuleEnabled}
|
||||
/>
|
||||
|
||||
@@ -241,8 +241,10 @@ export function DocumentsView() {
|
||||
// v1.36.x — uploading a document, filing it, sharing it and asking the AI
|
||||
// about it are none of them delegated verbs. Inside somebody else's record
|
||||
// the vault reads and nothing more: no upload path, no selection, no bulk
|
||||
// bar, no corpus backfill.
|
||||
const { canManage, inSharedRecord, sections } = useRecordCapabilities();
|
||||
// bar, no corpus backfill. `canManageDomain("documents")` says so from the
|
||||
// server's table, and answers false under every grant today.
|
||||
const { canManageDomain, inSharedRecord, sections } = useRecordCapabilities();
|
||||
const canManageDocuments = canManageDomain("documents");
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -424,7 +426,9 @@ export function DocumentsView() {
|
||||
},
|
||||
[episodeIdFilter, t],
|
||||
);
|
||||
const { dropActive } = usePageFileDrop(canManage ? enqueueFiles : undefined);
|
||||
const { dropActive } = usePageFileDrop(
|
||||
canManageDocuments ? enqueueFiles : undefined,
|
||||
);
|
||||
|
||||
const episodes = useIllnessEpisodes(
|
||||
true,
|
||||
@@ -439,7 +443,7 @@ export function DocumentsView() {
|
||||
const contentIndex = usage.data?.contentIndex;
|
||||
const canIndexContent = contentIndex?.enabled ?? false;
|
||||
const showIndexAll =
|
||||
canManage &&
|
||||
canManageDocuments &&
|
||||
canIndexContent &&
|
||||
contentIndex !== undefined &&
|
||||
contentIndex.totalCount > 0 &&
|
||||
@@ -872,7 +876,7 @@ export function DocumentsView() {
|
||||
title={t("documents.title")}
|
||||
description={t("documents.subtitle")}
|
||||
actions={
|
||||
canManage ? (
|
||||
canManageDocuments ? (
|
||||
<Button
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => uploadInputRef.current?.click()}
|
||||
@@ -884,13 +888,23 @@ export function DocumentsView() {
|
||||
}
|
||||
/>
|
||||
|
||||
{canManage && (
|
||||
{canManageDocuments && (
|
||||
<UploadZone
|
||||
usage={usage.data}
|
||||
inputRef={uploadInputRef}
|
||||
onFiles={enqueueFiles}
|
||||
/>
|
||||
)}
|
||||
{inSharedRecord && !canManageDocuments && (
|
||||
// Where the upload zone would be: the one empty space a person acting
|
||||
// for somebody else would otherwise search a control in.
|
||||
<p
|
||||
className="text-muted-foreground text-sm"
|
||||
data-slot="documents-owner-only"
|
||||
>
|
||||
{t("documents.ownerOnlyUpload")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DocumentFilterBar
|
||||
searchValue={searchDraft}
|
||||
@@ -932,7 +946,7 @@ export function DocumentsView() {
|
||||
description={t("documents.empty.description")}
|
||||
ctaSize="lg"
|
||||
action={
|
||||
canManage ? (
|
||||
canManageDocuments ? (
|
||||
<Button
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => uploadInputRef.current?.click()}
|
||||
@@ -963,9 +977,9 @@ export function DocumentsView() {
|
||||
isFetchingNextPage={list.isFetchingNextPage}
|
||||
onLoadMore={() => void list.fetchNextPage()}
|
||||
selectedIds={selectedIds}
|
||||
onToggleSelected={canManage ? toggleSelected : undefined}
|
||||
onToggleSelected={canManageDocuments ? toggleSelected : undefined}
|
||||
onOpen={openDetail}
|
||||
onDelete={canManage ? (id) => deleteBulk([id]) : undefined}
|
||||
onDelete={canManageDocuments ? (id) => deleteBulk([id]) : undefined}
|
||||
highlightId={upload.highlightId}
|
||||
onPrefetch={prefetchDetail}
|
||||
/>
|
||||
@@ -979,7 +993,7 @@ export function DocumentsView() {
|
||||
contentIndexEnabled={usage.data?.contentIndex.enabled}
|
||||
/>
|
||||
|
||||
{canManage && selectedIds.size > 0 ? (
|
||||
{canManageDocuments && selectedIds.size > 0 ? (
|
||||
<DocumentBulkBar
|
||||
selectedCount={selectedIds.size}
|
||||
episodes={(episodes.data ?? []).map((e) => ({
|
||||
|
||||
@@ -49,10 +49,11 @@ export function EpisodeDocumentsCard({ episodeId }: { episodeId: string }) {
|
||||
const { user } = useAuth();
|
||||
// Linking a document to an episode writes through
|
||||
// `POST /api/documents/inbound/bulk`, the same endpoint the vault gates on
|
||||
// `canManage` throughout. The episode side of the same link had no gate at
|
||||
// `canManageDocuments` throughout. The episode side of the same link had no gate at
|
||||
// all, so the vault refused what this card offered. Reading the linked
|
||||
// documents stays: the list rows and "show all" are pure navigation.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { canManageDomain } = useRecordCapabilities();
|
||||
const canManageDocuments = canManageDomain("documents");
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
const moduleEnabled = user?.modules?.inboundDocuments === true;
|
||||
@@ -146,7 +147,7 @@ export function EpisodeDocumentsCard({ episodeId }: { episodeId: string }) {
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{canManage ? (
|
||||
{canManageDocuments ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -158,7 +159,7 @@ export function EpisodeDocumentsCard({ episodeId }: { episodeId: string }) {
|
||||
</Button>
|
||||
{/* The upload affordance is a deep link into the vault with
|
||||
this episode pre-filtered. The vault's own upload control
|
||||
is `canManage`-gated, so an ungated link here would send a
|
||||
is `canManageDocuments`-gated, so an ungated link here would send a
|
||||
delegate to a page with nothing to press. */}
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={vaultHref}>
|
||||
@@ -184,7 +185,7 @@ export function EpisodeDocumentsCard({ episodeId }: { episodeId: string }) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{canManage ? (
|
||||
{canManageDocuments ? (
|
||||
<DocumentLinkPicker
|
||||
episodeId={episodeId}
|
||||
open={pickerOpen}
|
||||
|
||||
@@ -24,7 +24,12 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({ canManage: true, canWrite: true }),
|
||||
useRecordCapabilities: () => ({
|
||||
canManage: true,
|
||||
canWrite: true,
|
||||
canWriteDomain: () => true,
|
||||
canManageDomain: () => true,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", async () => {
|
||||
|
||||
@@ -30,7 +30,9 @@ import { VisitCard } from "./visit-card";
|
||||
|
||||
export function VisitsSection({ enabled = true }: { enabled?: boolean }) {
|
||||
const { t } = useTranslations();
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { canWriteDomain } = useRecordCapabilities();
|
||||
// `POST /api/encounters` answers at WRITE under the profile section.
|
||||
const canAddVisit = canWriteDomain("profile");
|
||||
const list = useEncounters(enabled);
|
||||
|
||||
// `null` closed, `"new"` create, a row edits it.
|
||||
@@ -45,7 +47,7 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) {
|
||||
const past = list.data?.past ?? [];
|
||||
const empty = upcoming.length === 0 && past.length === 0;
|
||||
|
||||
const addButton = canManage ? (
|
||||
const addButton = canAddVisit ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="min-h-11"
|
||||
|
||||
@@ -36,7 +36,9 @@ export function EpisodeVisitsCard({ episodeId }: { episodeId: string }) {
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
const { user } = useAuth();
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { canWriteDomain } = useRecordCapabilities();
|
||||
// The pre-linked visit is a `POST /api/encounters`, which answers at WRITE.
|
||||
const canAddVisit = canWriteDomain("profile");
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [session, setSession] = useState(0);
|
||||
|
||||
@@ -114,7 +116,7 @@ export function EpisodeVisitsCard({ episodeId }: { episodeId: string }) {
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{canManage ? (
|
||||
{canAddVisit ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -135,7 +137,7 @@ export function EpisodeVisitsCard({ episodeId }: { episodeId: string }) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{canManage ? (
|
||||
{canAddVisit ? (
|
||||
<EncounterSheet
|
||||
key={session}
|
||||
open={sheetOpen}
|
||||
|
||||
@@ -94,7 +94,8 @@ export function IllnessDayTimeline({
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
// v1.36.x — logging a day is not a delegated verb.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { canManageDomain } = useRecordCapabilities();
|
||||
const canManageIllness = canManageDomain("illness");
|
||||
const { data, isLoading, isError } = useIllnessDayLogList(episodeId, "desc");
|
||||
|
||||
const dayLogs = data?.dayLogs ?? [];
|
||||
@@ -119,7 +120,7 @@ export function IllnessDayTimeline({
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("illness.timeline.empty")}
|
||||
</p>
|
||||
{canManage && (
|
||||
{canManageIllness && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
|
||||
@@ -41,7 +41,8 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
|
||||
const { t } = useTranslations();
|
||||
// v1.36.x — logging a day, editing and resolving an episode are the
|
||||
// owner's; a delegate reads the episode.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { canManageDomain } = useRecordCapabilities();
|
||||
const canManageIllness = canManageDomain("illness");
|
||||
const fmt = useFormatters();
|
||||
const {
|
||||
data: episode,
|
||||
@@ -98,7 +99,7 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
|
||||
) : undefined
|
||||
}
|
||||
actions={
|
||||
canManage ? (
|
||||
canManageIllness ? (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => setLogOpen(true)}
|
||||
|
||||
@@ -74,7 +74,8 @@ function EpisodeCard({
|
||||
view,
|
||||
}: EpisodeCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { canManageDomain } = useRecordCapabilities();
|
||||
const canManageIllness = canManageDomain("illness");
|
||||
const fmt = useFormatters();
|
||||
const active = episode.resolvedAt === null;
|
||||
const isChronic = episode.lifecycle === "CHRONIC_ONGOING";
|
||||
@@ -109,7 +110,7 @@ function EpisodeCard({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{canManage && (
|
||||
{canManageIllness && (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -145,7 +146,7 @@ function EpisodeCard({
|
||||
<span className="block truncate">{episode.label}</span>
|
||||
</Link>
|
||||
</CardTitle>
|
||||
{canManage && (
|
||||
{canManageIllness && (
|
||||
<CardAction>
|
||||
<EpisodeMenu
|
||||
episode={episode}
|
||||
@@ -211,7 +212,7 @@ function EpisodeCard({
|
||||
flow, promoted from a busy inline cluster. The card navigates to
|
||||
the detail surface on its title link; this is the one explicit
|
||||
button. */}
|
||||
{canManage && (
|
||||
{canManageIllness && (
|
||||
<div className="mt-auto pt-0">
|
||||
<Button
|
||||
className="min-h-11 w-full sm:min-h-9"
|
||||
@@ -319,7 +320,7 @@ export function IllnessView() {
|
||||
// v1.36.x — opening an illness entry is an admitted delegated write. Logging
|
||||
// a day against one, editing it, resolving it and deleting it are not, so a
|
||||
// delegate can start the record of an illness and nothing more.
|
||||
const { canAdd, canManage } = useRecordCapabilities();
|
||||
const { canAdd, inSharedRecord } = useRecordCapabilities();
|
||||
const {
|
||||
data: episodes,
|
||||
isLoading,
|
||||
@@ -391,8 +392,9 @@ export function IllnessView() {
|
||||
actions={
|
||||
<>
|
||||
{/* v1.18.6 (MOD-01) — wrench left of the primary Add, linking to the
|
||||
Illness settings page (view + reorder). */}
|
||||
{canManage && (
|
||||
Illness settings page (view + reorder). `/settings` is the
|
||||
caller's, not the record's, so no grant reaches it. */}
|
||||
{!inSharedRecord && (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
|
||||
@@ -9,7 +9,7 @@ describe("shouldProbeOcrCapability", () => {
|
||||
isLoading: false,
|
||||
labsEnabled: true,
|
||||
mounted: true,
|
||||
canManage: true,
|
||||
ownRecord: true,
|
||||
};
|
||||
|
||||
expect(shouldProbeOcrCapability(ownerControl)).toBe(true);
|
||||
@@ -17,7 +17,7 @@ describe("shouldProbeOcrCapability", () => {
|
||||
shouldProbeOcrCapability({ ...ownerControl, isAuthenticated: false }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldProbeOcrCapability({ ...ownerControl, canManage: false }),
|
||||
shouldProbeOcrCapability({ ...ownerControl, ownRecord: false }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldProbeOcrCapability({ ...ownerControl, labsEnabled: false }),
|
||||
|
||||
@@ -151,6 +151,7 @@ export function BiomarkerManager() {
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<DeleteButton
|
||||
domain="labs"
|
||||
onConfirm={() => deleteMutation.mutate(marker.id)}
|
||||
title={t("labs.biomarker.deleteConfirmTitle")}
|
||||
description={t("labs.biomarker.deleteConfirmDescription")}
|
||||
|
||||
@@ -95,7 +95,8 @@ const READINGS_PAGE_SIZE = 200;
|
||||
*/
|
||||
export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
const { user } = useAuth();
|
||||
const { canAdd, canManage } = useRecordCapabilities();
|
||||
const { canAdd, canManageDomain } = useRecordCapabilities();
|
||||
const canManageLabs = canManageDomain("labs");
|
||||
const { t, locale } = useTranslations();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -332,6 +333,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
full marker editor behind the pencil, so the separate range
|
||||
control was dropped). */}
|
||||
<DeleteButton
|
||||
domain="labs"
|
||||
onConfirm={() => deleteMarker.mutate()}
|
||||
title={t("labs.biomarker.deleteConfirmTitle")}
|
||||
description={t("labs.biomarker.deleteConfirmDescription")}
|
||||
@@ -340,7 +342,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
iconClassName="h-4 w-4"
|
||||
/>
|
||||
{canManage && (
|
||||
{canManageLabs && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
||||
@@ -77,7 +77,8 @@ function parseDecimal(raw: string): number | null {
|
||||
*/
|
||||
export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
|
||||
const { t } = useTranslations();
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { canManageDomain } = useRecordCapabilities();
|
||||
const canManageLabs = canManageDomain("labs");
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Client-side column sort over the in-memory reading feed.
|
||||
@@ -379,7 +380,7 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
|
||||
</TableCell>
|
||||
<TableCell className="pr-4 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{canManage && (
|
||||
{canManageLabs && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
@@ -393,6 +394,7 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
|
||||
</Button>
|
||||
)}
|
||||
<DeleteButton
|
||||
domain="labs"
|
||||
onConfirm={() => deleteMutation.mutate(r.id)}
|
||||
title={t("labs.deleteConfirmTitle")}
|
||||
description={t("labs.deleteConfirmDescription")}
|
||||
|
||||
@@ -47,15 +47,16 @@ export function shouldProbeOcrCapability({
|
||||
isLoading,
|
||||
labsEnabled,
|
||||
mounted,
|
||||
canManage,
|
||||
ownRecord,
|
||||
}: {
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
labsEnabled: boolean;
|
||||
mounted: boolean;
|
||||
canManage: boolean;
|
||||
/** In the caller's own record; the probe and the commit resolve the caller, never a grant. */
|
||||
ownRecord: boolean;
|
||||
}): boolean {
|
||||
return isAuthenticated && !isLoading && labsEnabled && mounted && canManage;
|
||||
return isAuthenticated && !isLoading && labsEnabled && mounted && ownRecord;
|
||||
}
|
||||
|
||||
/** Capability probe — refetched when the scan dialog opens. */
|
||||
|
||||
@@ -127,6 +127,7 @@ vi.mock("@/components/gamification/achievement-unlock-notifier", () => ({
|
||||
}));
|
||||
|
||||
import { AuthShell } from "../auth-shell";
|
||||
import { delegatedDomains } from "@/lib/sharing/domain-write-support";
|
||||
|
||||
const OWNER_READ: AccountAccessEntry = {
|
||||
accountId: "acct-owner",
|
||||
@@ -138,12 +139,16 @@ const OWNER_READ: AccountAccessEntry = {
|
||||
sections: null,
|
||||
recordKind: "shared" as const,
|
||||
canWrite: false,
|
||||
writableDomains: [],
|
||||
manageableDomains: [],
|
||||
};
|
||||
const OWNER_WRITE: AccountAccessEntry = {
|
||||
...OWNER_READ,
|
||||
access: "write",
|
||||
level: "write",
|
||||
canWrite: true,
|
||||
writableDomains: [],
|
||||
manageableDomains: [],
|
||||
};
|
||||
const MANAGED_GUARDIAN: AccountAccessEntry = {
|
||||
accountId: "managed-record",
|
||||
@@ -155,6 +160,8 @@ const MANAGED_GUARDIAN: AccountAccessEntry = {
|
||||
sections: null,
|
||||
recordKind: "managed" as const,
|
||||
canWrite: true,
|
||||
writableDomains: delegatedDomains("manage", null, "write"),
|
||||
manageableDomains: delegatedDomains("manage", null, "manage"),
|
||||
};
|
||||
|
||||
function render(
|
||||
|
||||
@@ -20,6 +20,8 @@ vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
canWrite: false,
|
||||
canAdd: true,
|
||||
canManage: true,
|
||||
canWriteDomain: () => true,
|
||||
canManageDomain: () => true,
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
canWrite: false,
|
||||
canAdd: true,
|
||||
canManage: true,
|
||||
canWriteDomain: () => true,
|
||||
canManageDomain: () => true,
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -62,6 +62,8 @@ const OWNER = {
|
||||
sections: null,
|
||||
recordKind: "shared" as const,
|
||||
canWrite: false,
|
||||
writableDomains: [],
|
||||
manageableDomains: [],
|
||||
};
|
||||
|
||||
function render(access: AccountAccess, node: React.ReactNode): string {
|
||||
|
||||
@@ -29,7 +29,11 @@ import {
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { CapturePicker } from "@/components/layout/capture-picker";
|
||||
import {
|
||||
CAPTURE_KIND_ORDER,
|
||||
CapturePicker,
|
||||
visibleCaptureKinds,
|
||||
} from "@/components/layout/capture-picker";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
@@ -105,13 +109,12 @@ export function BottomNav() {
|
||||
// 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 {
|
||||
inSharedRecord: sharedRecord,
|
||||
sections,
|
||||
canAdd,
|
||||
canManage,
|
||||
} = useRecordCapabilities();
|
||||
const canCapture = canAdd || canManage;
|
||||
const capabilities = useRecordCapabilities();
|
||||
const { inSharedRecord: sharedRecord, sections } = capabilities;
|
||||
// The capture button opens the picker, so it shows exactly when the picker
|
||||
// would offer at least one kind — the same per-section rule, asked once.
|
||||
const canCapture =
|
||||
visibleCaptureKinds(capabilities, CAPTURE_KIND_ORDER).length > 0;
|
||||
|
||||
// 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
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { sheetBodyHasUnsavedInput } from "@/components/dashboard/quick-entry-sheets";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { ShareDomain } from "@/lib/sharing/scope";
|
||||
import {
|
||||
useRecordCapabilities,
|
||||
type RecordCapabilities,
|
||||
@@ -63,8 +64,20 @@ const DELEGABLE_CAPTURE_KINDS: ReadonlySet<CaptureKind> = new Set([
|
||||
"medication",
|
||||
]);
|
||||
|
||||
/**
|
||||
* v1.38.12 — the section each capture surface writes to. A MANAGE grant that
|
||||
* reaches the section's routes is offered the surface; a mood entry is a
|
||||
* MANAGE create under `mind`, so a guardian gets it and a WRITE delegate does
|
||||
* not.
|
||||
*/
|
||||
const CAPTURE_KIND_DOMAIN: Readonly<Record<CaptureKind, ShareDomain>> = {
|
||||
measurement: "measurements",
|
||||
medication: "medications",
|
||||
mood: "mind",
|
||||
};
|
||||
|
||||
/** The order the chooser lists them in. */
|
||||
const CAPTURE_KIND_ORDER: ReadonlyArray<CaptureKind> = [
|
||||
export const CAPTURE_KIND_ORDER: ReadonlyArray<CaptureKind> = [
|
||||
"measurement",
|
||||
"medication",
|
||||
"mood",
|
||||
@@ -104,11 +117,11 @@ export function admittedCaptureKind(
|
||||
* cannot tap the button that opens it.
|
||||
*/
|
||||
export function visibleCaptureKinds(
|
||||
caps: Pick<RecordCapabilities, "canAdd" | "canManage">,
|
||||
caps: Pick<RecordCapabilities, "canAdd" | "canManageDomain">,
|
||||
kinds: ReadonlyArray<CaptureKind>,
|
||||
): CaptureKind[] {
|
||||
return kinds.filter((kind) => {
|
||||
if (caps.canManage) return true;
|
||||
if (caps.canManageDomain(CAPTURE_KIND_DOMAIN[kind])) return true;
|
||||
return caps.canAdd && DELEGABLE_CAPTURE_KINDS.has(kind);
|
||||
});
|
||||
}
|
||||
|
||||
+5
-1
@@ -27,7 +27,11 @@ vi.mock("@/hooks/use-encounters", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({ canManage: true, canAdd: true }),
|
||||
useRecordCapabilities: () => ({
|
||||
canManage: true,
|
||||
canAdd: true,
|
||||
canManageDomain: () => true,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
|
||||
@@ -79,7 +79,8 @@ export function VorsorgeDashboardCard() {
|
||||
// (`POST /api/measurement-reminders/[id]/satisfy` resolves the caller), and
|
||||
// the dashboard summary offering what the dedicated page withholds was the
|
||||
// tell that this row had never been asked.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { canManageDomain } = useRecordCapabilities();
|
||||
const canManageMeasurements = canManageDomain("measurements");
|
||||
const { data: reminders, isError, refetch } = useMeasurementReminders();
|
||||
|
||||
// A query's `isLoading` is not a hydration-safe branch: TanStack reports the
|
||||
@@ -197,7 +198,7 @@ export function VorsorgeDashboardCard() {
|
||||
})}
|
||||
</Badge>
|
||||
</div>
|
||||
{canManage ? (
|
||||
{canManageMeasurements ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="default"
|
||||
|
||||
@@ -223,7 +223,8 @@ export function VorsorgeSection({
|
||||
variant?: "settings" | "page";
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { canManageDomain, inSharedRecord } = useRecordCapabilities();
|
||||
const canManageMeasurements = canManageDomain("measurements");
|
||||
const router = useRouter();
|
||||
const {
|
||||
data: reminders,
|
||||
@@ -382,7 +383,7 @@ export function VorsorgeSection({
|
||||
// and no grant level admits creating, editing or satisfying one. Inside
|
||||
// somebody else's record the schedule stays readable and every control that
|
||||
// would change it is absent.
|
||||
const addButton = canManage ? (
|
||||
const addButton = canManageMeasurements ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="min-h-11 shrink-0 sm:min-h-9"
|
||||
@@ -396,8 +397,9 @@ export function VorsorgeSection({
|
||||
// v1.18.6 (MOD-01) — the wrench links to the Vorsorge settings page (view,
|
||||
// reorder, per-reminder enable toggles), left of the Add button — the
|
||||
// canonical medication-page pattern. The old kebab "Anpassen" sheet is
|
||||
// gone (MOD-07); its toggles moved to the settings page.
|
||||
const wrenchButton = canManage ? (
|
||||
// gone (MOD-07); its toggles moved to the settings page. `/settings` is the
|
||||
// caller's, not the record's, so no grant reaches it.
|
||||
const wrenchButton = !inSharedRecord ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
@@ -716,7 +718,7 @@ export function VorsorgeSection({
|
||||
title={t("measurementReminders.empty.title")}
|
||||
description={t("measurementReminders.empty.description")}
|
||||
action={
|
||||
canManage ? (
|
||||
canManageMeasurements ? (
|
||||
<Button type="button" onClick={openCreate}>
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
@@ -864,7 +866,8 @@ function VorsorgeCard({
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { canManageDomain } = useRecordCapabilities();
|
||||
const canManageMeasurements = canManageDomain("measurements");
|
||||
// Issue #490 — day-boundary zone for the relative "today / yesterday"
|
||||
// bucket must match the zone `fmt.date` renders in (mirror → Berlin).
|
||||
// The next-due phrase reads off the SAME zone as the last-done date below
|
||||
@@ -938,7 +941,7 @@ function VorsorgeCard({
|
||||
|
||||
// Single med-style kebab: Edit + Delete. The Delete item is the shared
|
||||
// confirm-on-delete control rendered as a full-width menu row.
|
||||
const headerActions = canManage ? (
|
||||
const headerActions = canManageMeasurements ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
@@ -1009,7 +1012,7 @@ function VorsorgeCard({
|
||||
// `resolve.ts` states the same boundary server-side: an appointment reminder
|
||||
// is free-text by construction.
|
||||
const fileVisitButton =
|
||||
canManage && !isLinked ? (
|
||||
canManageMeasurements && !isLinked ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -1028,7 +1031,7 @@ function VorsorgeCard({
|
||||
// on the card. Outline against the filled primary: the same pairing, and
|
||||
// the same rule as everything else in this bar — one constant look, never
|
||||
// tinted by how overdue the checkup is.
|
||||
const postponeButton = canManage ? (
|
||||
const postponeButton = canManageMeasurements ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -1042,7 +1045,7 @@ function VorsorgeCard({
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
const primaryButton = canManage ? (
|
||||
const primaryButton = canManageMeasurements ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="min-h-11 flex-1"
|
||||
@@ -1272,7 +1275,7 @@ function VorsorgeCard({
|
||||
ungated copy, and which branch a person sees is a per-browser
|
||||
preference that survives the switch — so the identical action
|
||||
was offered or withheld depending on a view toggle. */}
|
||||
{canManage ? (
|
||||
{canManageMeasurements ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
@@ -1292,7 +1295,7 @@ function VorsorgeCard({
|
||||
{/* Icon-only in the dense list, labelled in the cards branch —
|
||||
the same action either way, never one branch offering what
|
||||
the other withholds. */}
|
||||
{canManage ? (
|
||||
{canManageMeasurements ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -1309,7 +1312,7 @@ function VorsorgeCard({
|
||||
) : null}
|
||||
{/* Free-text reminders only: a typed / screening reminder has no
|
||||
practice visit behind it. */}
|
||||
{canManage && !isLinked ? (
|
||||
{canManageMeasurements && !isLinked ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
||||
@@ -1304,6 +1304,7 @@ export function MeasurementList({
|
||||
</Button>
|
||||
)}
|
||||
<DeleteButton
|
||||
domain="measurements"
|
||||
onConfirm={() =>
|
||||
deleteMutation.mutate(m.id)
|
||||
}
|
||||
@@ -1504,6 +1505,7 @@ export function MeasurementList({
|
||||
</Button>
|
||||
)}
|
||||
<DeleteButton
|
||||
domain="measurements"
|
||||
iconClassName="h-4 w-4"
|
||||
onConfirm={() => deleteMutation.mutate(m.id)}
|
||||
title={t("measurements.deleteConfirmTitle")}
|
||||
@@ -1535,6 +1537,7 @@ export function MeasurementList({
|
||||
|
||||
{/* v1.15.13 — page-scoped multi-select action bar. */}
|
||||
<SelectionActionBar
|
||||
domain="measurements"
|
||||
count={selectedOnPage}
|
||||
onClear={clearSelection}
|
||||
onConfirmDelete={onConfirmBulkDelete}
|
||||
|
||||
@@ -393,7 +393,9 @@ function RetargetControl({
|
||||
// owner chose — not an admitted create, and `PUT .../efficacy/target`
|
||||
// resolves the caller, so it is refused at both grant levels. The Wirkung
|
||||
// tab itself stays readable; only the dial goes.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
// `PUT /api/medications/[id]/efficacy/target` resolves the caller, not the
|
||||
// record: no grant reaches it, so the editor belongs to the owner alone.
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
const [value, setValue] = useState<string>("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
@@ -443,7 +445,7 @@ function RetargetControl({
|
||||
}
|
||||
};
|
||||
|
||||
if (!canManage || items.length === 0) return null;
|
||||
if (inSharedRecord || items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -185,12 +185,23 @@ type TabSlug = (typeof TAB_SLUGS)[number];
|
||||
* none of it at any level.
|
||||
*/
|
||||
const OWNER_ONLY_TABS: ReadonlySet<TabSlug> = new Set([
|
||||
// Schedule times are a MANAGE route, but the tab also carries the reminder
|
||||
// and notification settings, which resolve the caller.
|
||||
"zeitplan",
|
||||
"bestand",
|
||||
// Per-medication tokens: a credential surface, never delegated.
|
||||
"api",
|
||||
// Phase config, export and the token-adjacent rows resolve the caller.
|
||||
"erweitert",
|
||||
]);
|
||||
|
||||
/**
|
||||
* v1.38.12 — tabs whose every write is a MANAGE route under `medications`,
|
||||
* so a grant that reaches those routes is shown the tab. The supply ledger
|
||||
* is one; the three above are not, because each mixes in a route that
|
||||
* resolves the caller and would 403 under a switch.
|
||||
*/
|
||||
const MANAGE_TABS: ReadonlySet<TabSlug> = new Set(["bestand"]);
|
||||
|
||||
/**
|
||||
* Legacy slugs that point at a dissolved tab. The Erinnerung tab folded
|
||||
* into Zeitplan in v1.15.20; old deep-links keep landing on the surface
|
||||
@@ -242,7 +253,8 @@ export function MedicationDetailTabs({
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { canManageDomain, inSharedRecord } = useRecordCapabilities();
|
||||
const canManageMedications = canManageDomain("medications");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
@@ -308,9 +320,16 @@ export function MedicationDetailTabs({
|
||||
(slug !== "injektion" || isInjectable) &&
|
||||
(slug !== "zeitplan" || !asNeeded) &&
|
||||
(slug !== "wirkung" || hasEfficacyTarget) &&
|
||||
(canManage || !OWNER_ONLY_TABS.has(slug)),
|
||||
(!inSharedRecord || !OWNER_ONLY_TABS.has(slug)) &&
|
||||
(canManageMedications || !MANAGE_TABS.has(slug)),
|
||||
),
|
||||
[isInjectable, asNeeded, hasEfficacyTarget, canManage],
|
||||
[
|
||||
isInjectable,
|
||||
asNeeded,
|
||||
hasEfficacyTarget,
|
||||
inSharedRecord,
|
||||
canManageMedications,
|
||||
],
|
||||
);
|
||||
|
||||
const requestedRaw = searchParams?.get("tab");
|
||||
@@ -908,7 +927,7 @@ export function MedicationDetailTabs({
|
||||
(`PUT /api/medications/[id]` is not delegable), so the deep link is
|
||||
gated exactly like the hero button it stands in for. */}
|
||||
<MedicationWizardDialog
|
||||
open={editOpen && canManage}
|
||||
open={editOpen && canManageMedications}
|
||||
onOpenChange={setEditOpen}
|
||||
mode="edit"
|
||||
initial={payload}
|
||||
|
||||
@@ -598,7 +598,8 @@ export function LedgerRowItem({
|
||||
onUnpin: (eventId: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { canAdd, canManage } = useRecordCapabilities();
|
||||
const { canAdd, canManageDomain } = useRecordCapabilities();
|
||||
const canManageMedications = canManageDomain("medications");
|
||||
const fmt = useFormatters();
|
||||
|
||||
const actionable = isSlotActionable(row);
|
||||
@@ -733,7 +734,7 @@ export function LedgerRowItem({
|
||||
items in one menu, a delegate gets skip as its own control beside
|
||||
take, which is the shape the card already uses. The owner keeps it
|
||||
where it was. */}
|
||||
{showTakenButton && canAdd && !canManage && (
|
||||
{showTakenButton && canAdd && !canManageMedications && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -754,7 +755,7 @@ export function LedgerRowItem({
|
||||
{t("medications.detail.verlauf.markSkipped")}
|
||||
</Button>
|
||||
)}
|
||||
{canManage && (
|
||||
{canManageMedications && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -63,7 +63,8 @@ export function IntakeHistoryEditable({
|
||||
}: IntakeHistoryEditableProps) {
|
||||
const { t } = useTranslations();
|
||||
// v1.36.x — bulk-deleting recorded doses is the owner's alone.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { canManageDomain } = useRecordCapabilities();
|
||||
const canManageMedications = canManageDomain("medications");
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
@@ -135,7 +136,7 @@ export function IntakeHistoryEditable({
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-3" data-slot="intake-history-editable-body">
|
||||
{canManage && selected.size > 0 && (
|
||||
{canManageMedications && selected.size > 0 && (
|
||||
<div
|
||||
className="border-border bg-muted/40 flex flex-wrap items-center justify-between gap-2 rounded-md border p-2"
|
||||
role="region"
|
||||
|
||||
@@ -592,8 +592,9 @@ function IntakeRowActions({
|
||||
t: ReturnType<typeof useTranslations>["t"];
|
||||
}) {
|
||||
// v1.36.x — editing or deleting a recorded dose is the owner's.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
if (!canManage) return null;
|
||||
const { canManageDomain } = useRecordCapabilities();
|
||||
const canManageMedications = canManageDomain("medications");
|
||||
if (!canManageMedications) return null;
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
||||
@@ -62,9 +62,10 @@ export function MedicationCardMenu({
|
||||
// medication and opening its history editor are not. When nothing in the
|
||||
// menu survives, the trigger goes with it rather than opening onto an empty
|
||||
// sheet. The card itself is untouched: no wash, no badge, no colour change.
|
||||
const { canAdd, canManage } = useRecordCapabilities();
|
||||
const { canAdd, canManageDomain } = useRecordCapabilities();
|
||||
const canManageMedications = canManageDomain("medications");
|
||||
const showSideEffect = onLogSideEffect != null && canAdd;
|
||||
if (!canManage && !showSideEffect) return null;
|
||||
if (!canManageMedications && !showSideEffect) return null;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
@@ -87,7 +88,7 @@ export function MedicationCardMenu({
|
||||
className="w-56"
|
||||
data-slot="medication-card-menu"
|
||||
>
|
||||
{canManage && (
|
||||
{canManageMedications && (
|
||||
<>
|
||||
<DropdownMenuItem onClick={onEdit}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
@@ -101,7 +102,7 @@ export function MedicationCardMenu({
|
||||
)}
|
||||
{showSideEffect && onLogSideEffect && (
|
||||
<>
|
||||
{canManage && <DropdownMenuSeparator />}
|
||||
{canManageMedications && <DropdownMenuSeparator />}
|
||||
<DropdownMenuItem onClick={onLogSideEffect}>
|
||||
<Stethoscope className="mr-2 h-4 w-4" />
|
||||
{t("medications.glp1LogSideEffect")}
|
||||
|
||||
@@ -36,7 +36,8 @@ export function IntakeHistoryPreview({
|
||||
}: IntakeHistoryPreviewProps) {
|
||||
const { t } = useTranslations();
|
||||
// v1.36.x — importing dose history is not a delegated verb.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { canManageDomain } = useRecordCapabilities();
|
||||
const canManageMedications = canManageDomain("medications");
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -45,7 +46,7 @@ export function IntakeHistoryPreview({
|
||||
title={t("medications.detail.intake.title")}
|
||||
dataSlot="medication-detail-intake-history-section"
|
||||
headerExtras={
|
||||
canManage ? (
|
||||
canManageMedications ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
@@ -53,6 +53,7 @@ import { Loader2, PackageOpen, Plus } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DeleteButton } from "@/components/data-list/delete-button";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { SettingsGroup } from "@/components/medications/settings-group";
|
||||
import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context";
|
||||
@@ -112,6 +113,7 @@ export function InventorySection({
|
||||
deliveryForm?: string;
|
||||
}) {
|
||||
const { t, locale } = useTranslations();
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
const dateFormatPref = useDateFormatPreference();
|
||||
const queryClient = useQueryClient();
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
@@ -301,19 +303,24 @@ export function InventorySection({
|
||||
{/* v1.16.11 — cross-link to the low-stock alert setting: the
|
||||
threshold lives in Settings → Notifications, but the question
|
||||
"when will it warn me?" comes up here, where the stock lives. */}
|
||||
<div className="py-2">
|
||||
<Link
|
||||
href="/settings/notifications#low-stock"
|
||||
className="text-muted-foreground hover:text-foreground text-xs underline-offset-2 hover:underline"
|
||||
data-slot="inventory-low-stock-link"
|
||||
>
|
||||
{lowStockDays !== null
|
||||
? t("medications.detail.bestand.lowStockLinkOn", {
|
||||
days: lowStockDays,
|
||||
})
|
||||
: t("medications.detail.bestand.lowStockLinkOff")}
|
||||
</Link>
|
||||
</div>
|
||||
{/* `/settings` is not a shared-record destination, so the cross-link
|
||||
is withheld under a switch rather than opening onto the "not part
|
||||
of what was shared" panel. */}
|
||||
{!inSharedRecord && (
|
||||
<div className="py-2">
|
||||
<Link
|
||||
href="/settings/notifications#low-stock"
|
||||
className="text-muted-foreground hover:text-foreground text-xs underline-offset-2 hover:underline"
|
||||
data-slot="inventory-low-stock-link"
|
||||
>
|
||||
{lowStockDays !== null
|
||||
? t("medications.detail.bestand.lowStockLinkOn", {
|
||||
days: lowStockDays,
|
||||
})
|
||||
: t("medications.detail.bestand.lowStockLinkOff")}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
|
||||
{/* v1.16.11 — packaging economics surfaced where the stock lives:
|
||||
@@ -443,6 +450,7 @@ export function InventorySection({
|
||||
{t("medications.detail.bestand.adjustButton")}
|
||||
</Button>
|
||||
<DeleteButton
|
||||
domain="medications"
|
||||
onConfirm={() => void deleteItem(item)}
|
||||
title={t("medications.detail.bestand.deleteTitle")}
|
||||
description={t("medications.detail.bestand.deleteDescription")}
|
||||
|
||||
@@ -112,7 +112,8 @@ function entryI18nKey(entry: MedicationSideEffectEntry): string {
|
||||
|
||||
export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
|
||||
const { t } = useTranslations();
|
||||
const { canAdd, canManage } = useRecordCapabilities();
|
||||
const { canAdd, canManageDomain } = useRecordCapabilities();
|
||||
const canManageMedications = canManageDomain("medications");
|
||||
const fmt = useFormatters();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -346,7 +347,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{canManage && (
|
||||
{canManageMedications && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
||||
@@ -75,7 +75,8 @@ export function InstrumentCard({
|
||||
// v1.36.x — a screener is answered by the person it is about. No grant
|
||||
// level admits it, so inside somebody else's record the card reads as
|
||||
// history and carries no way to start one.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { canManageDomain } = useRecordCapabilities();
|
||||
const canManageMind = canManageDomain("mind");
|
||||
// Issue #490 — day-boundary zone for the relative "today / yesterday"
|
||||
// bucket must match the zone `formatDate` renders in (mirror → Berlin).
|
||||
const displayTz = useDisplayTimezone();
|
||||
@@ -178,7 +179,7 @@ export function InstrumentCard({
|
||||
{/* Bottom-pinned single primary action — the med-/Vorsorge card slot.
|
||||
Nothing sits below it now: the attribution moved to the header's
|
||||
info control, so the Start button stands alone with clean space. */}
|
||||
{canManage && (
|
||||
{canManageMind && (
|
||||
<div className="mt-auto pt-0">
|
||||
<Button type="button" className="min-h-11 w-full" onClick={onStart}>
|
||||
{t("mentalHealth.start")}
|
||||
|
||||
@@ -37,7 +37,8 @@ export function InstrumentDetail({
|
||||
const { t } = useTranslations();
|
||||
const { date: formatDate } = useFormatters();
|
||||
// v1.36.x — see the card: starting a screener is not a delegated verb.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { canManageDomain } = useRecordCapabilities();
|
||||
const canManageMind = canManageDomain("mind");
|
||||
// Issue #490 — day-boundary zone for the relative "today / yesterday"
|
||||
// bucket must match the zone `formatDate` renders in (mirror → Berlin).
|
||||
const displayTz = useDisplayTimezone();
|
||||
@@ -76,7 +77,7 @@ export function InstrumentDetail({
|
||||
|
||||
{/* The Start action stays reachable here too — the detail is a calm
|
||||
reading surface, not a dead end. */}
|
||||
{canManage && (
|
||||
{canManageMind && (
|
||||
<Button
|
||||
type="button"
|
||||
className="min-h-11 w-full"
|
||||
|
||||
@@ -177,7 +177,8 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
// v1.36.x — mood is not a delegated verb. Inside somebody else's record the
|
||||
// list stays readable and every control that would change it is absent.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { canManageDomain } = useRecordCapabilities();
|
||||
const canManageMind = canManageDomain("mind");
|
||||
const queryClient = useQueryClient();
|
||||
// URL-owned filter state (the documents-vault pattern): mood, source and
|
||||
// date range parse from `?mood&source&from&to` and every change writes back
|
||||
@@ -721,7 +722,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
>
|
||||
{t("mood.emptyResetFilter")}
|
||||
</Button>
|
||||
) : onAddFirst && canManage ? (
|
||||
) : onAddFirst && canManageMind ? (
|
||||
<Button size="sm" onClick={onAddFirst}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("mood.emptyAddFirst")}
|
||||
@@ -747,7 +748,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
<TableRow>
|
||||
<TableHead className="w-10 pl-4">
|
||||
{/* v1.15.13 — select-all-on-page header checkbox. */}
|
||||
{canManage && (
|
||||
{canManageMind && (
|
||||
<Checkbox
|
||||
checked={
|
||||
selectAll === "all"
|
||||
@@ -800,7 +801,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
data-state={isSelected ? "selected" : undefined}
|
||||
>
|
||||
<TableCell className="pl-4">
|
||||
{canManage && (
|
||||
{canManageMind && (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => onToggleRow(entry.id)}
|
||||
@@ -844,7 +845,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
</TableCell>
|
||||
<TableCell className="pr-4 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{canManage && (
|
||||
{canManageMind && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -856,6 +857,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
</Button>
|
||||
)}
|
||||
<DeleteButton
|
||||
domain="mind"
|
||||
onConfirm={() => deleteMutation.mutate(entry.id)}
|
||||
title={t("mood.deleteConfirmTitle")}
|
||||
description={t("mood.deleteConfirmDescription")}
|
||||
@@ -898,7 +900,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
stays the single control and owns the 44px hit area
|
||||
via an `after` hit-slop (clicks on a pseudo-element
|
||||
hit-test against its host button). */}
|
||||
{canManage && (
|
||||
{canManageMind && (
|
||||
<div className="flex size-11 shrink-0 items-center justify-center">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
@@ -945,7 +947,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
and delete actions meet WCAG 2.5.5 on touch
|
||||
devices. The desktop table keeps its denser
|
||||
h-8 w-8 since pointer targets allow it. */}
|
||||
{canManage && (
|
||||
{canManageMind && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -957,6 +959,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
</Button>
|
||||
)}
|
||||
<DeleteButton
|
||||
domain="mind"
|
||||
onConfirm={() => deleteMutation.mutate(entry.id)}
|
||||
iconClassName="h-4 w-4"
|
||||
title={t("mood.deleteConfirmTitle")}
|
||||
@@ -972,6 +975,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
|
||||
{/* v1.15.13 — page-scoped multi-select action bar. */}
|
||||
<SelectionActionBar
|
||||
domain="mind"
|
||||
count={selectedOnPage}
|
||||
onClear={clearSelection}
|
||||
onConfirmDelete={onConfirmBulkDelete}
|
||||
|
||||
@@ -21,7 +21,11 @@ vi.mock("@/hooks/use-practitioners", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({ canManage: true }),
|
||||
useRecordCapabilities: () => ({
|
||||
canManage: true,
|
||||
canWriteDomain: () => true,
|
||||
canManageDomain: () => true,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-debounced-value", () => ({
|
||||
|
||||
@@ -44,7 +44,10 @@ import { PractitionerSheet } from "./practitioner-sheet";
|
||||
|
||||
export function PractitionerList({ enabled = true }: { enabled?: boolean }) {
|
||||
const { t } = useTranslations();
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
|
||||
// `POST /api/practitioners` answers at WRITE; editing and deleting at MANAGE.
|
||||
const canAddPractitioner = canWriteDomain("profile");
|
||||
const canManageProfile = canManageDomain("profile");
|
||||
const [term, setTerm] = useState("");
|
||||
const debounced = useDebouncedValue(term, 250);
|
||||
const list = usePractitioners(debounced, enabled);
|
||||
@@ -87,7 +90,7 @@ export function PractitionerList({ enabled = true }: { enabled?: boolean }) {
|
||||
}, [rows]);
|
||||
const showGroupHeadings = groups.some(([specialty]) => specialty !== null);
|
||||
|
||||
const addButton = canManage ? (
|
||||
const addButton = canAddPractitioner ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="min-h-11"
|
||||
@@ -186,7 +189,7 @@ export function PractitionerList({ enabled = true }: { enabled?: boolean }) {
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{canManage ? (
|
||||
{canManageProfile ? (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -106,6 +106,7 @@ export function AllergyManager() {
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<DeleteButton
|
||||
domain="profile"
|
||||
onConfirm={() => deleteMutation.mutate(row.id)}
|
||||
title={t("records.allergies.deleteConfirmTitle")}
|
||||
description={t("records.allergies.deleteConfirmDescription")}
|
||||
|
||||
@@ -91,6 +91,7 @@ export function FamilyHistoryManager() {
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<DeleteButton
|
||||
domain="profile"
|
||||
onConfirm={() => deleteMutation.mutate(row.id)}
|
||||
title={t("records.family.deleteConfirmTitle")}
|
||||
description={t("records.family.deleteConfirmDescription")}
|
||||
|
||||
@@ -37,6 +37,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import type { AccountAccessEntry } from "@/lib/sharing/account-access-view";
|
||||
import { delegatedDomains } from "@/lib/sharing/domain-write-support";
|
||||
import type { GrantList } from "@/lib/queries/use-account-grants";
|
||||
import type { ManagedProfileGuardian } from "@/lib/queries/use-managed-profiles";
|
||||
|
||||
@@ -219,6 +220,8 @@ function entry(partial: Partial<AccountAccessEntry> = {}): AccountAccessEntry {
|
||||
recordKind: "managed",
|
||||
sections: null,
|
||||
canWrite: true,
|
||||
writableDomains: delegatedDomains("manage", null, "write"),
|
||||
manageableDomains: delegatedDomains("manage", null, "manage"),
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ const canonicalEntry = {
|
||||
recordKind: "shared",
|
||||
sections: ["labs"],
|
||||
canWrite: false,
|
||||
writableDomains: [],
|
||||
manageableDomains: [],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
* nothing" goes red.
|
||||
* - make the `!active` branch return `canAdd: false` → "the caller's own
|
||||
* record keeps everything" goes red.
|
||||
* - answer `canManageDomain` from `level === "manage"` instead of the list →
|
||||
* "the vault is never manageable" goes red.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
@@ -22,6 +24,8 @@ import {
|
||||
resolveRecordCapabilities,
|
||||
} from "@/hooks/use-record-capabilities";
|
||||
import type { AccountAccessEntry } from "@/lib/sharing/account-access-view";
|
||||
import { delegatedDomains } from "@/lib/sharing/domain-write-support";
|
||||
import { SHARE_DOMAINS } from "@/lib/sharing/scope";
|
||||
|
||||
const READ_ONLY: AccountAccessEntry = {
|
||||
accountId: "acct-owner",
|
||||
@@ -33,13 +37,20 @@ const READ_ONLY: AccountAccessEntry = {
|
||||
sections: null,
|
||||
recordKind: "shared",
|
||||
canWrite: false,
|
||||
writableDomains: [],
|
||||
manageableDomains: [],
|
||||
};
|
||||
|
||||
// The lists are what the server publishes for each level over the whole
|
||||
// record: the same helper `resolveAccountAccess` runs, so the fixture is the
|
||||
// payload and not a guess at it.
|
||||
const WRITABLE: AccountAccessEntry = {
|
||||
...READ_ONLY,
|
||||
access: "write",
|
||||
level: "write",
|
||||
canWrite: true,
|
||||
writableDomains: delegatedDomains("write", null, "write"),
|
||||
manageableDomains: [],
|
||||
};
|
||||
|
||||
const MANAGING: AccountAccessEntry = {
|
||||
@@ -47,6 +58,8 @@ const MANAGING: AccountAccessEntry = {
|
||||
access: "write",
|
||||
level: "manage",
|
||||
canWrite: true,
|
||||
writableDomains: delegatedDomains("manage", null, "write"),
|
||||
manageableDomains: delegatedDomains("manage", null, "manage"),
|
||||
};
|
||||
|
||||
const SCOPED: AccountAccessEntry = {
|
||||
@@ -59,7 +72,8 @@ describe("resolveRecordCapabilities", () => {
|
||||
// `null` is not "no access", it is "this is mine". Both an unswitched
|
||||
// session and a payload from a server that has never heard of sharing
|
||||
// land here, which is why the absent case must be the permissive one.
|
||||
expect(resolveRecordCapabilities(null)).toEqual({
|
||||
const own = resolveRecordCapabilities(null);
|
||||
expect(own).toMatchObject({
|
||||
inSharedRecord: false,
|
||||
canWrite: false,
|
||||
canAdd: true,
|
||||
@@ -71,13 +85,21 @@ describe("resolveRecordCapabilities", () => {
|
||||
sections: null,
|
||||
recordKind: "self",
|
||||
});
|
||||
expect(resolveRecordCapabilities(undefined)).toEqual(
|
||||
resolveRecordCapabilities(null),
|
||||
);
|
||||
for (const domain of SHARE_DOMAINS) {
|
||||
expect(own.canWriteDomain(domain), domain).toBe(true);
|
||||
expect(own.canManageDomain(domain), domain).toBe(true);
|
||||
}
|
||||
expect(resolveRecordCapabilities(undefined)).toMatchObject({
|
||||
inSharedRecord: false,
|
||||
canAdd: true,
|
||||
canManage: true,
|
||||
recordKind: "self",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a malformed published block instead of treating it as own record", () => {
|
||||
expect(resolveRecordCapabilities(null, true)).toEqual({
|
||||
const refused = resolveRecordCapabilities(null, true);
|
||||
expect(refused).toMatchObject({
|
||||
inSharedRecord: true,
|
||||
canWrite: false,
|
||||
canAdd: false,
|
||||
@@ -87,10 +109,15 @@ describe("resolveRecordCapabilities", () => {
|
||||
recordKind: "shared",
|
||||
accessRefused: true,
|
||||
});
|
||||
for (const domain of SHARE_DOMAINS) {
|
||||
expect(refused.canWriteDomain(domain), domain).toBe(false);
|
||||
expect(refused.canManageDomain(domain), domain).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("a read-only delegate adds nothing", () => {
|
||||
expect(resolveRecordCapabilities(READ_ONLY)).toEqual({
|
||||
const reader = resolveRecordCapabilities(READ_ONLY);
|
||||
expect(reader).toMatchObject({
|
||||
inSharedRecord: true,
|
||||
canWrite: false,
|
||||
canAdd: false,
|
||||
@@ -99,12 +126,17 @@ describe("resolveRecordCapabilities", () => {
|
||||
sections: null,
|
||||
recordKind: "shared",
|
||||
});
|
||||
for (const domain of SHARE_DOMAINS) {
|
||||
expect(reader.canWriteDomain(domain), domain).toBe(false);
|
||||
expect(reader.canManageDomain(domain), domain).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("a delegate may not change what is already there", () => {
|
||||
// The asymmetry the whole design rests on: a WRITE grant adds, and that
|
||||
// is all it does. Not even to an entry the delegate made a minute ago.
|
||||
expect(resolveRecordCapabilities(WRITABLE)).toEqual({
|
||||
const writer = resolveRecordCapabilities(WRITABLE);
|
||||
expect(writer).toMatchObject({
|
||||
inSharedRecord: true,
|
||||
canWrite: true,
|
||||
canAdd: true,
|
||||
@@ -113,6 +145,14 @@ describe("resolveRecordCapabilities", () => {
|
||||
sections: null,
|
||||
recordKind: "shared",
|
||||
});
|
||||
// Writable where a WRITE route exists, manageable nowhere.
|
||||
expect(writer.canWriteDomain("measurements")).toBe(true);
|
||||
expect(writer.canWriteDomain("labs")).toBe(true);
|
||||
expect(writer.canWriteDomain("mind")).toBe(false);
|
||||
expect(writer.canWriteDomain("documents")).toBe(false);
|
||||
for (const domain of SHARE_DOMAINS) {
|
||||
expect(writer.canManageDomain(domain), domain).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("carries the level and the sections through untouched", () => {
|
||||
@@ -128,22 +168,45 @@ describe("resolveRecordCapabilities", () => {
|
||||
expect(resolveRecordCapabilities(MANAGING).level).toBe("manage");
|
||||
});
|
||||
|
||||
it("does not hand a manage grant the affordances its routes cannot answer yet", () => {
|
||||
// The deliberate gap, pinned so it cannot close by accident. MANAGE will
|
||||
// bring the edit and delete controls back, and it does so in the release
|
||||
// where the routes behind them answer — not in the one that merely
|
||||
// publishes the level. A `canManage: true` here today would paint
|
||||
// controls that 403, which is the exact failure these booleans exist to
|
||||
// prevent.
|
||||
expect(resolveRecordCapabilities(MANAGING)).toEqual({
|
||||
it("hands a manage grant exactly the sections its routes answer for", () => {
|
||||
// The v1.37.0 hold-back answered `canManage: false` here until the routes
|
||||
// behind the edit and delete controls existed. They exist; the answer is
|
||||
// the server's per-section list, and the coarse boolean is "is that list
|
||||
// non-empty".
|
||||
const manager = resolveRecordCapabilities(MANAGING);
|
||||
expect(manager).toMatchObject({
|
||||
inSharedRecord: true,
|
||||
canWrite: true,
|
||||
canAdd: true,
|
||||
canManage: false,
|
||||
canManage: true,
|
||||
level: "manage",
|
||||
sections: null,
|
||||
recordKind: "shared",
|
||||
});
|
||||
expect(manager.canManageDomain("mind")).toBe(true);
|
||||
expect(manager.canManageDomain("profile")).toBe(true);
|
||||
expect(manager.canManageDomain("labs")).toBe(true);
|
||||
expect(manager.canWriteDomain("mind")).toBe(true);
|
||||
});
|
||||
|
||||
it("never lets a shared record manage the vault", () => {
|
||||
// The vault has no delegated write route, so the server's list never
|
||||
// names it — and the hook binds the list rather than the level. Answering
|
||||
// from `level === "manage"` would paint the upload control that 403s.
|
||||
expect(
|
||||
resolveRecordCapabilities(MANAGING).canManageDomain("documents"),
|
||||
).toBe(false);
|
||||
expect(
|
||||
resolveRecordCapabilities(MANAGING).canWriteDomain("documents"),
|
||||
).toBe(false);
|
||||
const claimsEverything: AccountAccessEntry = {
|
||||
...MANAGING,
|
||||
manageableDomains: [],
|
||||
};
|
||||
// An empty list from the server is an empty answer, whatever the level.
|
||||
const held = resolveRecordCapabilities(claimsEverything);
|
||||
expect(held.canManage).toBe(false);
|
||||
expect(held.canManageDomain("mind")).toBe(false);
|
||||
});
|
||||
|
||||
it("binds the server's boolean rather than the grant's label", () => {
|
||||
@@ -157,6 +220,19 @@ describe("resolveRecordCapabilities", () => {
|
||||
canWrite: false,
|
||||
};
|
||||
expect(resolveRecordCapabilities(disagreeing).canAdd).toBe(false);
|
||||
// The same posture for the lists: a scoped WRITE grant is writable only
|
||||
// where its list says so, not wherever its level would reach.
|
||||
const scopedWriter: AccountAccessEntry = {
|
||||
...WRITABLE,
|
||||
sections: ["labs"],
|
||||
writableDomains: ["labs"],
|
||||
};
|
||||
expect(resolveRecordCapabilities(scopedWriter).canWriteDomain("labs")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
resolveRecordCapabilities(scopedWriter).canWriteDomain("measurements"),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -242,8 +318,10 @@ describe("an unprovable record context withholds every control", () => {
|
||||
it("leaves every existing arm untouched when the two agree", () => {
|
||||
// The fourth argument defaults to false, so every case above this block
|
||||
// means exactly what it meant before the fence existed.
|
||||
expect(resolveRecordCapabilities(READ_ONLY)).toEqual(
|
||||
resolveRecordCapabilities(READ_ONLY, false, false, false),
|
||||
);
|
||||
expect(resolveRecordCapabilities(READ_ONLY)).toMatchObject({
|
||||
...resolveRecordCapabilities(READ_ONLY, false, false, false),
|
||||
canWriteDomain: expect.any(Function),
|
||||
canManageDomain: expect.any(Function),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
accountLabel,
|
||||
type AccountAccessEntry,
|
||||
} from "@/lib/sharing/account-access-view";
|
||||
import type { ShareDomain } from "@/lib/sharing/scope";
|
||||
|
||||
const NEVER = () => false;
|
||||
const ALWAYS = () => true;
|
||||
|
||||
/**
|
||||
* What the person at the keyboard may do to the record on screen.
|
||||
@@ -20,7 +24,7 @@ import {
|
||||
* still taught them the product was broken. This hook is what every mutation
|
||||
* affordance asks before it renders.
|
||||
*
|
||||
* ## The two answers, and why there are two
|
||||
* ## The answers, and why there are several
|
||||
*
|
||||
* A grant at WRITE admits a short, closed list of verbs: entering readings,
|
||||
* lab results, an illness entry, a biomarker, a side effect, a medication, and
|
||||
@@ -32,34 +36,44 @@ import {
|
||||
* reason one release later: its only form sits on `/custom-metrics/{id}`,
|
||||
* which a switch closes too.
|
||||
* `sharing-surface-guard.test.ts` carries that argument
|
||||
* in full and is the list that binds; this paragraph has to agree with it. Editing, deleting, restoring, purging, importing and
|
||||
* bulk-acting stay with the owner, including on an entry the delegate added
|
||||
* themselves a minute ago, and so does every create the list does not name
|
||||
* (a mood entry, a screener, a cycle day). Two booleans carry that:
|
||||
* in full and is the list that binds; this paragraph has to agree with it.
|
||||
* Editing, deleting, restoring, purging, importing and bulk-acting are MANAGE
|
||||
* verbs, and so is every create the list does not name (a mood entry, a
|
||||
* screener, a cycle day). Which sections answer at which level is the
|
||||
* server's table (`domain-write-support.ts`), published per grant as two
|
||||
* lists on `accountAccess.active`. Four answers carry that:
|
||||
*
|
||||
* - `canAdd` — may this person add one of the admitted kinds.
|
||||
* - `canManage` — may this person change what is already there, or add
|
||||
* something the delegation does not cover. True only in one's own record.
|
||||
* - `canWriteDomain(domain)` — is there a delegated write in this section
|
||||
* the grant satisfies.
|
||||
* - `canManageDomain(domain)` — may this person change what is already
|
||||
* there in this section, or add what a WRITE grant does not cover.
|
||||
* - `canManage` — the coarse switch: is there ANY section this person may
|
||||
* manage. The banner and the navigation ask it; a control never should,
|
||||
* because a control belongs to one section and the answer differs per
|
||||
* section (the vault is read-only under every grant).
|
||||
*
|
||||
* A surface that offers an admitted create asks `canAdd`. Everything else
|
||||
* asks `canManage`. Neither is a permission decision made here: `canWrite`
|
||||
* arrives resolved from the server on `accountAccess.active` and is bound,
|
||||
* never recomputed (`account-access-view.ts:11-15`). What this file decides is
|
||||
* only which class of affordance a resolved level covers, and that mapping is
|
||||
* a property of the release, not of the caller.
|
||||
* A surface that offers an admitted create asks `canAdd`. A control that
|
||||
* edits, deletes or creates what WRITE does not cover asks `canManageDomain`
|
||||
* for its own section. A control whose route resolves the caller rather than
|
||||
* the record (settings, credentials, AI budget, chart preferences) asks
|
||||
* `!inSharedRecord`: no grant reaches it at any level. None of these is a
|
||||
* permission decision made here: `canWrite` and the two lists arrive resolved
|
||||
* from the server on `accountAccess.active` and are bound, never recomputed
|
||||
* (`account-access-view.ts`). What this file decides is only which class of
|
||||
* affordance a resolved answer covers.
|
||||
*
|
||||
* ## The two facts this hook carries but does not yet act on
|
||||
* ## Why the lists are per section
|
||||
*
|
||||
* v1.37.0 gives a grant a section set and a third level, and both arrive here
|
||||
* (`sections`, `level`) as resolved values beside the booleans. Nothing in
|
||||
* this file turns either into a capability, and that is the state of the
|
||||
* release rather than an omission: the nav narrowing that reads `sections`
|
||||
* and the return of the edit and delete affordances that reads `level` are
|
||||
* the shared-session UX work, which lands after the routes that answer for a
|
||||
* MANAGE grant exist. Publishing the facts here first is what lets those be
|
||||
* a rendering change rather than a second access decision — and a control
|
||||
* that appeared before its route answered would be the failure the two
|
||||
* booleans above were written to end.
|
||||
* v1.37.0 published `level` and `sections` and answered `canManage: false`
|
||||
* for every shared record, a hold-back for the release where the level
|
||||
* arrived before the routes behind it. The routes landed and the hold-back
|
||||
* stayed, so a guardian of a managed profile saw no add, edit or delete
|
||||
* control on Mood, screeners, visits, allergies and labs while the server
|
||||
* accepted every one of those writes (#939). A single boolean could not have
|
||||
* closed that without opening the vault, whose routes accept none; the lists
|
||||
* close it section by section, and a section with no delegated route stays
|
||||
* closed by the same mechanism.
|
||||
*
|
||||
* ## Absent, not disabled
|
||||
*
|
||||
@@ -82,17 +96,35 @@ export interface RecordCapabilities {
|
||||
canWrite: boolean;
|
||||
/** May the caller add an entry of a kind the delegation admits. */
|
||||
canAdd: boolean;
|
||||
/** May the caller change what exists, or add what the delegation excludes. */
|
||||
/**
|
||||
* May the caller change what exists somewhere in this record, or add what
|
||||
* the delegation excludes. In one's own record, always. In a shared record,
|
||||
* true when `manageableDomains` is non-empty: the coarse switch the banner
|
||||
* and the navigation read. A control asks {@link canManageDomain} for its
|
||||
* own section instead, because the answer differs per section.
|
||||
*/
|
||||
canManage: boolean;
|
||||
/**
|
||||
* v1.38.12 — is there a delegated write in `domain` this grant satisfies.
|
||||
* Own record: always. Shared record: membership in
|
||||
* `accountAccess.active.writableDomains`. Refused, pending or unproven
|
||||
* context: never.
|
||||
*/
|
||||
canWriteDomain: (domain: ShareDomain) => boolean;
|
||||
/**
|
||||
* v1.38.12 — may the caller edit, delete or bulk-act in `domain`, or add
|
||||
* what a WRITE grant does not cover there. Own record: always. Shared
|
||||
* record: membership in `accountAccess.active.manageableDomains`. Refused,
|
||||
* pending or unproven context: never.
|
||||
*/
|
||||
canManageDomain: (domain: ShareDomain) => boolean;
|
||||
/**
|
||||
* v1.37.0 — the resolved level of the grant on screen, or `null` in one's
|
||||
* own record (which is not a grant and has no level).
|
||||
*
|
||||
* Bound from `accountAccess.active.level`, never derived. `canManage` is
|
||||
* deliberately NOT computed from it in this release: the edit and delete
|
||||
* affordances come back for `"manage"` when the routes that answer them
|
||||
* land, and a control that appears before its route answers is the exact
|
||||
* failure the two booleans above exist to prevent.
|
||||
* Bound from `accountAccess.active.level`, never derived, and not what the
|
||||
* controls read: the two domain lists are, because a level says what was
|
||||
* granted and the lists say which routes answer for it.
|
||||
*/
|
||||
level: AccountAccessEntry["level"] | null;
|
||||
/**
|
||||
@@ -136,6 +168,8 @@ export function resolveRecordCapabilities(
|
||||
canWrite: false,
|
||||
canAdd: false,
|
||||
canManage: false,
|
||||
canWriteDomain: NEVER,
|
||||
canManageDomain: NEVER,
|
||||
level: null,
|
||||
sections: [],
|
||||
recordKind: "shared",
|
||||
@@ -147,6 +181,8 @@ export function resolveRecordCapabilities(
|
||||
canWrite: false,
|
||||
canAdd: true,
|
||||
canManage: true,
|
||||
canWriteDomain: ALWAYS,
|
||||
canManageDomain: ALWAYS,
|
||||
// One's own record has no grant, so it has no level, and it is open in
|
||||
// full. Null on both counts rather than a fabricated "manage" over all
|
||||
// eight sections: the caller is not a delegate, and a consumer that had
|
||||
@@ -156,11 +192,19 @@ export function resolveRecordCapabilities(
|
||||
recordKind: "self",
|
||||
};
|
||||
}
|
||||
// Bound, not derived: the two lists are the server's intersection of the
|
||||
// grant with the routes that exist. Membership is the whole of the client's
|
||||
// part, and `canManage` is only the question "is either list non-empty"
|
||||
// asked of the manage one.
|
||||
const writable = new Set<ShareDomain>(active.writableDomains);
|
||||
const manageable = new Set<ShareDomain>(active.manageableDomains);
|
||||
return {
|
||||
inSharedRecord: true,
|
||||
canWrite: active.canWrite,
|
||||
canAdd: active.canWrite,
|
||||
canManage: false,
|
||||
canManage: manageable.size > 0,
|
||||
canWriteDomain: (domain) => writable.has(domain),
|
||||
canManageDomain: (domain) => manageable.has(domain),
|
||||
level: active.level,
|
||||
sections: active.sections,
|
||||
recordKind: active.recordKind,
|
||||
|
||||
@@ -35,6 +35,8 @@ const OWNER = {
|
||||
sections: null,
|
||||
recordKind: "shared" as const,
|
||||
canWrite: true,
|
||||
writableDomains: [],
|
||||
manageableDomains: [],
|
||||
};
|
||||
|
||||
const mockAccessRef: { value: AccountAccess } = {
|
||||
|
||||
@@ -12,6 +12,8 @@ const sharedEntry = {
|
||||
recordKind: "shared",
|
||||
sections: ["labs"],
|
||||
canWrite: false,
|
||||
writableDomains: [],
|
||||
manageableDomains: [],
|
||||
};
|
||||
|
||||
function accessBlock(overrides: Record<string, unknown> = {}) {
|
||||
@@ -45,6 +47,8 @@ describe("parseAccountAccess", () => {
|
||||
level: "manage",
|
||||
sections: null,
|
||||
canWrite: true,
|
||||
writableDomains: ["measurements", "mind"],
|
||||
manageableDomains: ["mind"],
|
||||
};
|
||||
|
||||
const parsed = parseAccountAccess(
|
||||
@@ -56,9 +60,28 @@ describe("parseAccountAccess", () => {
|
||||
access: "write",
|
||||
sections: null,
|
||||
canWrite: true,
|
||||
writableDomains: ["measurements", "mind"],
|
||||
manageableDomains: ["mind"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the delegated write lists when they are consistent with the grant", () => {
|
||||
const writer = {
|
||||
...sharedEntry,
|
||||
access: "write",
|
||||
level: "write",
|
||||
sections: ["labs", "measurements"],
|
||||
canWrite: true,
|
||||
writableDomains: ["measurements", "labs"],
|
||||
manageableDomains: [],
|
||||
};
|
||||
const parsed = parseAccountAccess(
|
||||
accessBlock({ accounts: [writer], active: writer }),
|
||||
);
|
||||
expect(parsed?.active?.writableDomains).toEqual(["measurements", "labs"]);
|
||||
expect(parsed?.active?.manageableDomains).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"legacy access and resolved level",
|
||||
@@ -79,6 +102,28 @@ describe("parseAccountAccess", () => {
|
||||
// field. A parity matcher blind to it would let the banner and the
|
||||
// switcher name the same owner differently.
|
||||
["full name", { fullName: "Different name" }, {}],
|
||||
// v1.38.12 — the two lists are read by every control, so the two views
|
||||
// must agree on them like on every other field.
|
||||
[
|
||||
"writable domains",
|
||||
{
|
||||
access: "write",
|
||||
level: "write",
|
||||
canWrite: true,
|
||||
writableDomains: ["labs"],
|
||||
},
|
||||
{
|
||||
accounts: [
|
||||
{
|
||||
...sharedEntry,
|
||||
access: "write",
|
||||
level: "write",
|
||||
canWrite: true,
|
||||
writableDomains: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
])(
|
||||
"fails closed when active disagrees with its canonical entry on %s",
|
||||
(_field, activeOverrides, blockOverrides) => {
|
||||
@@ -105,6 +150,51 @@ describe("parseAccountAccess", () => {
|
||||
"manage with read legacy field",
|
||||
{ access: "read", level: "manage", sections: null, canWrite: true },
|
||||
],
|
||||
// v1.38.12 — structural invariants of the two lists. Which sections
|
||||
// qualify is the server's table and is not re-decided here; what IS
|
||||
// checked is that the lists cannot claim more than the grant.
|
||||
["read grant with a writable domain", { writableDomains: ["labs"] }],
|
||||
[
|
||||
"write grant with a manageable domain",
|
||||
{
|
||||
access: "write",
|
||||
level: "write",
|
||||
canWrite: true,
|
||||
writableDomains: ["labs"],
|
||||
manageableDomains: ["labs"],
|
||||
},
|
||||
],
|
||||
[
|
||||
"manageable domain that is not writable",
|
||||
{
|
||||
access: "write",
|
||||
level: "manage",
|
||||
sections: null,
|
||||
canWrite: true,
|
||||
writableDomains: ["labs"],
|
||||
manageableDomains: ["mind"],
|
||||
},
|
||||
],
|
||||
[
|
||||
"writable domain outside the grant's sections",
|
||||
{
|
||||
access: "write",
|
||||
level: "write",
|
||||
canWrite: true,
|
||||
writableDomains: ["measurements"],
|
||||
},
|
||||
],
|
||||
[
|
||||
"duplicated writable domain",
|
||||
{
|
||||
access: "write",
|
||||
level: "write",
|
||||
canWrite: true,
|
||||
writableDomains: ["labs", "labs"],
|
||||
},
|
||||
],
|
||||
["unknown writable domain", { writableDomains: ["other"] }],
|
||||
["missing writable domains", { writableDomains: undefined }],
|
||||
])("fails closed for %s entries", (_name, entryOverrides) => {
|
||||
const entry = { ...sharedEntry, ...entryOverrides };
|
||||
|
||||
|
||||
@@ -10,20 +10,23 @@ vi.mock("@/lib/db", () => ({
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveAccountAccess } from "../account-access";
|
||||
import { delegatedDomains } from "@/lib/sharing/domain-write-support";
|
||||
import { decodeLegacyWholeRecordAccess } from "../account-access-view";
|
||||
import { LEGACY_ACCOUNT_PAYLOADS } from "../../../../tests/fixtures/v137/legacy-account-payloads";
|
||||
|
||||
function activeGrant({
|
||||
access,
|
||||
managedProfileAt = null,
|
||||
scopeJson = null,
|
||||
}: {
|
||||
access: "READ" | "WRITE" | "MANAGE";
|
||||
managedProfileAt?: Date | null;
|
||||
scopeJson?: string[] | null;
|
||||
}) {
|
||||
return {
|
||||
id: `grant-${access.toLowerCase()}`,
|
||||
access,
|
||||
scopeJson: null,
|
||||
scopeJson,
|
||||
acceptedAt: new Date("2026-08-01T00:00:00.000Z"),
|
||||
revokedAt: null,
|
||||
expiresAt: null,
|
||||
@@ -69,11 +72,47 @@ describe("resolveAccountAccess", () => {
|
||||
level: "manage",
|
||||
sections: null,
|
||||
recordKind: "managed",
|
||||
// v1.38.12 — the routes' answer for a whole-record MANAGE grant: every
|
||||
// section with a delegated write route, and never the vault.
|
||||
writableDomains: delegatedDomains("manage", null, "write"),
|
||||
manageableDomains: delegatedDomains("manage", null, "manage"),
|
||||
}),
|
||||
);
|
||||
expect(access.active?.manageableDomains).toContain("mind");
|
||||
expect(access.active?.manageableDomains).not.toContain("documents");
|
||||
expect(access.recordKind).toBe("managed");
|
||||
});
|
||||
|
||||
it("publishes a WRITE grant's writable sections within its scope and no manageable ones", async () => {
|
||||
vi.mocked(prisma.accountGrant.findMany).mockResolvedValue([
|
||||
activeGrant({ access: "WRITE", scopeJson: ["mind", "labs"] }),
|
||||
] as never);
|
||||
|
||||
const access = await resolveAccountAccess({
|
||||
user: { id: "delegate" },
|
||||
session: { actingAsUserId: "record-owner" },
|
||||
});
|
||||
|
||||
// `mind` is opened by the scope but has no WRITE route, so it is absent;
|
||||
// `labs` has one. Consent order, not scope order.
|
||||
expect(access.active?.writableDomains).toEqual(["labs"]);
|
||||
expect(access.active?.manageableDomains).toEqual([]);
|
||||
});
|
||||
|
||||
it("publishes two empty lists for a READ grant", async () => {
|
||||
vi.mocked(prisma.accountGrant.findMany).mockResolvedValue([
|
||||
activeGrant({ access: "READ" }),
|
||||
] as never);
|
||||
|
||||
const access = await resolveAccountAccess({
|
||||
user: { id: "delegate" },
|
||||
session: { actingAsUserId: "record-owner" },
|
||||
});
|
||||
|
||||
expect(access.active?.writableDomains).toEqual([]);
|
||||
expect(access.active?.manageableDomains).toEqual([]);
|
||||
});
|
||||
|
||||
it("resolves an ordinary shared record on the server", async () => {
|
||||
vi.mocked(prisma.accountGrant.findMany).mockResolvedValue([
|
||||
activeGrant({ access: "READ" }),
|
||||
|
||||
@@ -6,6 +6,20 @@ import { SHARE_DOMAINS } from "./scope";
|
||||
const accountAccessLevelSchema = z.enum(["read", "write", "manage"]);
|
||||
const accountRecordKindSchema = z.enum(["shared", "managed"]);
|
||||
const shareSectionsSchema = z.array(z.enum(SHARE_DOMAINS)).nullable();
|
||||
const shareDomainListSchema = z.array(z.enum(SHARE_DOMAINS));
|
||||
|
||||
function hasRepeat(domains: readonly string[]): boolean {
|
||||
return new Set(domains).size !== domains.length;
|
||||
}
|
||||
|
||||
function isSubset(
|
||||
inner: readonly string[],
|
||||
outer: readonly string[] | null,
|
||||
): boolean {
|
||||
if (outer === null) return true;
|
||||
const set = new Set(outer);
|
||||
return inner.every((domain) => set.has(domain));
|
||||
}
|
||||
|
||||
/** The browser's resolved account-access payload, shared with OpenAPI. */
|
||||
export const accountAccessEntrySchema = z
|
||||
@@ -22,6 +36,11 @@ export const accountAccessEntrySchema = z
|
||||
recordKind: accountRecordKindSchema,
|
||||
sections: shareSectionsSchema,
|
||||
canWrite: z.boolean(),
|
||||
// v1.38.12 — the sections the grant can add to / change, resolved
|
||||
// server-side. Structural invariants only are checked here; which
|
||||
// sections qualify is the server's table and is not re-decided.
|
||||
writableDomains: shareDomainListSchema,
|
||||
manageableDomains: shareDomainListSchema,
|
||||
})
|
||||
.superRefine((entry, ctx) => {
|
||||
const uniqueSections = new Set(entry.sections ?? []);
|
||||
@@ -56,6 +75,48 @@ export const accountAccessEntrySchema = z
|
||||
path: ["access"],
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
hasRepeat(entry.writableDomains) ||
|
||||
hasRepeat(entry.manageableDomains)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Delegated write domains must not repeat a domain",
|
||||
path: ["writableDomains"],
|
||||
});
|
||||
}
|
||||
if (
|
||||
!isSubset(entry.writableDomains, entry.sections) ||
|
||||
!isSubset(entry.manageableDomains, entry.sections)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Delegated write domains must lie within the grant's sections",
|
||||
path: ["writableDomains"],
|
||||
});
|
||||
}
|
||||
if (!isSubset(entry.manageableDomains, entry.writableDomains)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Manageable domains must be writable domains",
|
||||
path: ["manageableDomains"],
|
||||
});
|
||||
}
|
||||
if (entry.level === "read" && entry.writableDomains.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "A read grant has no writable domains",
|
||||
path: ["writableDomains"],
|
||||
});
|
||||
}
|
||||
if (!isManage && entry.manageableDomains.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Only a manage grant has manageable domains",
|
||||
path: ["manageableDomains"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const accountAccessBlockSchema = z.object({
|
||||
@@ -80,7 +141,12 @@ function matchesCanonicalAccountAccessEntry(
|
||||
(section, index) => section === canonicalSections[index],
|
||||
);
|
||||
|
||||
const sameList = (a: readonly string[], b: readonly string[]) =>
|
||||
a.length === b.length && a.every((domain, index) => domain === b[index]);
|
||||
|
||||
return (
|
||||
sameList(active.writableDomains, canonical.writableDomains) &&
|
||||
sameList(active.manageableDomains, canonical.manageableDomains) &&
|
||||
active.accountId === canonical.accountId &&
|
||||
active.username === canonical.username &&
|
||||
active.displayName === canonical.displayName &&
|
||||
|
||||
@@ -103,6 +103,24 @@ export interface AccountAccessEntry {
|
||||
* than smuggled into this boolean.
|
||||
*/
|
||||
canWrite: boolean;
|
||||
/**
|
||||
* v1.38.12 — the sections this grant can add to, resolved server-side from
|
||||
* the grant's level and scope against the delegated write routes that
|
||||
* exist (`domain-write-support.ts`). In the consent screen's order. Empty
|
||||
* for a READ grant, and empty for any section whose routes accept no
|
||||
* delegated write at all — the vault, today.
|
||||
*
|
||||
* A list rather than a boolean because the answer differs per section, and
|
||||
* the client's per-control question is "this section", never "any".
|
||||
*/
|
||||
writableDomains: ShareDomain[];
|
||||
/**
|
||||
* v1.38.12 — the sections this grant can change: edit, delete, restore,
|
||||
* bulk-act, and the creates a WRITE grant does not cover. A subset of
|
||||
* {@link AccountAccessEntry.writableDomains} by construction, and empty
|
||||
* below MANAGE.
|
||||
*/
|
||||
manageableDomains: ShareDomain[];
|
||||
}
|
||||
|
||||
/** The `accountAccess` block on `GET /api/auth/me`. */
|
||||
|
||||
@@ -42,6 +42,7 @@ import type {
|
||||
AccountAccessEntry,
|
||||
AccountAccessLevel,
|
||||
} from "@/lib/sharing/account-access-view";
|
||||
import { delegatedDomains } from "@/lib/sharing/domain-write-support";
|
||||
import { resolveGrantSections } from "@/lib/sharing/grant-view";
|
||||
import { grantAllows, isGrantActive } from "@/lib/sharing/grants";
|
||||
import type { AccountGrantAccess } from "@/generated/prisma/client";
|
||||
@@ -126,6 +127,7 @@ export async function resolveAccountAccess(auth: {
|
||||
// clients that have not learned the canonical level. `level` names the
|
||||
// complete three-level contract.
|
||||
const level = LEVEL[grant.access];
|
||||
const sections = resolveGrantSections(grant.scopeJson);
|
||||
return {
|
||||
accountId: grant.grantor.id,
|
||||
username: grant.grantor.username,
|
||||
@@ -135,8 +137,14 @@ export async function resolveAccountAccess(auth: {
|
||||
level,
|
||||
recordKind:
|
||||
grant.grantor.managedProfileAt === null ? "shared" : "managed",
|
||||
sections: resolveGrantSections(grant.scopeJson),
|
||||
sections,
|
||||
canWrite: grantAllows(grant, "write", now),
|
||||
// v1.38.12 — per-section write answers, from the same level and scope
|
||||
// the two fields above publish, intersected with the routes that
|
||||
// exist. The lists are what the controls read; the level and the
|
||||
// sections stay published so the chrome can say what was granted.
|
||||
writableDomains: delegatedDomains(level, sections, "write"),
|
||||
manageableDomains: delegatedDomains(level, sections, "manage"),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Which sections of a record accept a delegated write, and at which level.
|
||||
*
|
||||
* `requireRecordAuth(need, domain)` is declared per route, so the server has
|
||||
* always known which sections a WRITE or a MANAGE grant can reach. The client
|
||||
* did not: `resolveRecordCapabilities` answered `canManage: false` for every
|
||||
* shared record from v1.37.0 on, a hold-back written for the release where the
|
||||
* level was published before the routes behind it existed. The routes exist
|
||||
* now, and the hold-back kept hiding the add, edit and delete controls on a
|
||||
* managed profile whose every write the server accepts (#939).
|
||||
*
|
||||
* Flipping the boolean would have overshot in the other direction: the vault
|
||||
* has no delegated write route at all, so a manage grant that painted the
|
||||
* upload control there would paint a control that 403s. The answer is per
|
||||
* section and it is the server's: this file is the one table, the account
|
||||
* payload publishes the intersection of a grant with it, and the client reads
|
||||
* the lists.
|
||||
*
|
||||
* The values are a property of the routes and nothing else, which is why they
|
||||
* are frozen rather than trusted: `domain-write-support-guard.test.ts` scans
|
||||
* every `route.ts` under `src/app/api` for `requireRecordAuth("write" |
|
||||
* "manage", "<domain>")` and fails when this table and the routes disagree in
|
||||
* either direction. Editing a value here without a route to justify it, or
|
||||
* adding a delegated write route without editing here, is a red test.
|
||||
*
|
||||
* `record` is deliberately absent. It is not a section a grant can be narrowed
|
||||
* to (`scope.ts`), and a route declaring it reads across sections; nothing a
|
||||
* per-section control asks is answered by it.
|
||||
*/
|
||||
import type { AccountAccessLevel } from "@/lib/sharing/account-access-view";
|
||||
import { SHARE_DOMAINS, type ShareDomain } from "@/lib/sharing/scope";
|
||||
|
||||
/** Whether any route in the section declares the need. */
|
||||
export interface DomainWriteSupport {
|
||||
/** At least one route declares `requireRecordAuth("write", domain)`. */
|
||||
readonly write: boolean;
|
||||
/** At least one route declares `requireRecordAuth("manage", domain)`. */
|
||||
readonly manage: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The table. Derived from the route files on 2026-09-09 and frozen by the
|
||||
* structural guard; the reasons are in the routes, not here.
|
||||
*/
|
||||
export const DOMAIN_WRITE_SUPPORT: Readonly<
|
||||
Record<ShareDomain, DomainWriteSupport>
|
||||
> = {
|
||||
measurements: { write: true, manage: true },
|
||||
medications: { write: true, manage: true },
|
||||
labs: { write: true, manage: true },
|
||||
profile: { write: true, manage: true },
|
||||
illness: { write: true, manage: true },
|
||||
// A mood entry and a screener are created at MANAGE, not at WRITE.
|
||||
mind: { write: false, manage: true },
|
||||
// Every cycle write is a MANAGE route.
|
||||
cycle: { write: false, manage: true },
|
||||
// The vault is read-only under every grant; upload, filing, sharing and the
|
||||
// AI verbs stay with the owner.
|
||||
documents: { write: false, manage: false },
|
||||
};
|
||||
|
||||
/** The two needs a delegated write can declare. */
|
||||
export type DelegatedWriteNeed = "write" | "manage";
|
||||
|
||||
const LEVEL_RANK: Record<AccountAccessLevel, number> = {
|
||||
read: 0,
|
||||
write: 1,
|
||||
manage: 2,
|
||||
};
|
||||
|
||||
/**
|
||||
* The sections a grant can write to, or manage, given its level and scope.
|
||||
*
|
||||
* `sections === null` is the entire record. A MANAGE grant always carries it
|
||||
* (`grants.ts` refuses a scope on a MANAGE invitation), a WRITE grant may.
|
||||
*
|
||||
* For `need === "write"` the answer is "is there a non-read route in this
|
||||
* section the grant satisfies": a WRITE grant reaches the section's `"write"`
|
||||
* routes, a MANAGE grant reaches those and its `"manage"` routes as well.
|
||||
* That is the `write ⊂ manage` ordering `grantAllows` enforces, stated as
|
||||
* lists — a section a manage grant can manage is a section it can write to,
|
||||
* so `manageable ⊆ writable` holds for every grant.
|
||||
*
|
||||
* For `need === "manage"` only a MANAGE grant answers, and only where a route
|
||||
* declares the need. The result keeps the consent screen's order.
|
||||
*/
|
||||
export function delegatedDomains(
|
||||
level: AccountAccessLevel,
|
||||
sections: readonly ShareDomain[] | null,
|
||||
need: DelegatedWriteNeed,
|
||||
): ShareDomain[] {
|
||||
if (LEVEL_RANK[level] < LEVEL_RANK[need]) return [];
|
||||
const opened =
|
||||
sections === null ? new Set<ShareDomain>(SHARE_DOMAINS) : new Set(sections);
|
||||
return SHARE_DOMAINS.filter((domain) => {
|
||||
if (!opened.has(domain)) return false;
|
||||
const support = DOMAIN_WRITE_SUPPORT[domain];
|
||||
if (need === "manage") return support.manage;
|
||||
return support.write || (level === "manage" && support.manage);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* v1.38.12 — what a delegate is told they may do, and what the routes then do.
|
||||
*
|
||||
* `GET /api/auth/me` publishes two lists on the active entry,
|
||||
* `writableDomains` and `manageableDomains`, and every add / edit / delete
|
||||
* control in a shared record reads them. The lists are worth nothing unless
|
||||
* the routes agree with them, so this file drives both ends over the real
|
||||
* route handlers, the real grant table and real Postgres:
|
||||
*
|
||||
* 1. a guardian acting on a managed profile reads the payload and gets
|
||||
* every section with a delegated route as manageable, and never the
|
||||
* vault;
|
||||
* 2. the same guardian posts a mood entry and an allergy — the two writes
|
||||
* #939 was reported on — and both land on the PROFILE's record;
|
||||
* 3. the same guardian is refused a vault write, which is what the empty
|
||||
* answer for `documents` promised;
|
||||
* 4. a WRITE delegate on an adult's record reads writable sections and no
|
||||
* manageable ones, and is refused the mood entry the list withheld.
|
||||
*
|
||||
* The lists are compared against `delegatedDomains`, the helper the resolver
|
||||
* itself runs, so a change to the table shows up here as a change in what the
|
||||
* routes must then honour, not as a fixture to update.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
import { delegatedDomains } from "@/lib/sharing/domain-write-support";
|
||||
import type { ShareDomain } from "@/lib/sharing/scope";
|
||||
|
||||
let counter = 0;
|
||||
|
||||
async function makeUser(label: string) {
|
||||
const suffix = `${label}-${counter++}`;
|
||||
return getPrismaClient().user.create({
|
||||
data: {
|
||||
username: `caps-${suffix}`,
|
||||
email: `caps-${suffix}@example.test`,
|
||||
displayName: `Caps ${suffix}`,
|
||||
role: "USER",
|
||||
timezone: "Europe/Berlin",
|
||||
locale: "en",
|
||||
onboardingCompletedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function signIn(userId: string) {
|
||||
const session = await getPrismaClient().session.create({
|
||||
data: { userId, expiresAt: new Date(Date.now() + 60_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return session;
|
||||
}
|
||||
|
||||
interface ActiveEntry {
|
||||
accountId: string;
|
||||
level: "read" | "write" | "manage";
|
||||
recordKind: "shared" | "managed";
|
||||
writableDomains: ShareDomain[];
|
||||
manageableDomains: ShareDomain[];
|
||||
}
|
||||
|
||||
async function readActive(): Promise<ActiveEntry> {
|
||||
const { GET } = await import("@/app/api/auth/me/route");
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { accountAccess: { active: ActiveEntry | null } };
|
||||
};
|
||||
const active = body.data.accountAccess.active;
|
||||
if (active === null) throw new Error("expected a switched payload");
|
||||
return active;
|
||||
}
|
||||
|
||||
function jsonRequest(url: string, body: unknown): NextRequest {
|
||||
return new NextRequest(`http://localhost${url}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function postMood() {
|
||||
const { POST } = await import("@/app/api/mood-entries/route");
|
||||
return POST(
|
||||
jsonRequest("/api/mood-entries", {
|
||||
mood: "GUT",
|
||||
moodLoggedAt: new Date().toISOString(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function postAllergy() {
|
||||
const { POST } = await import("@/app/api/allergies/route");
|
||||
return POST(
|
||||
jsonRequest("/api/allergies", {
|
||||
substance: "Penicillin",
|
||||
category: "MEDICATION",
|
||||
severity: "SEVERE",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function postDocumentsBulk() {
|
||||
const { POST } = await import("@/app/api/documents/inbound/bulk/route");
|
||||
return POST(
|
||||
jsonRequest("/api/documents/inbound/bulk", {
|
||||
ids: [],
|
||||
action: "setKind",
|
||||
kind: "OTHER",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
counter = 0;
|
||||
});
|
||||
|
||||
describe("a guardian acting on a managed profile", () => {
|
||||
async function guardianInside() {
|
||||
const guardian = await makeUser("guardian");
|
||||
const { createManagedProfile } =
|
||||
await import("@/lib/managed-profiles/create");
|
||||
const { profile } = await createManagedProfile({
|
||||
creatorId: guardian.id,
|
||||
displayName: "Managed record",
|
||||
dateOfBirth: null,
|
||||
locale: "en",
|
||||
timezone: "UTC",
|
||||
});
|
||||
const session = await signIn(guardian.id);
|
||||
await switchSessionTo(session.id, profile.id);
|
||||
return { guardian, profile };
|
||||
}
|
||||
|
||||
it("is told every section with a delegated route is manageable, and the vault is not", async () => {
|
||||
const { profile } = await guardianInside();
|
||||
const active = await readActive();
|
||||
|
||||
expect(active.accountId).toBe(profile.id);
|
||||
expect(active.level).toBe("manage");
|
||||
expect(active.recordKind).toBe("managed");
|
||||
expect(active.writableDomains).toEqual(
|
||||
delegatedDomains("manage", null, "write"),
|
||||
);
|
||||
expect(active.manageableDomains).toEqual(
|
||||
delegatedDomains("manage", null, "manage"),
|
||||
);
|
||||
// The sections #939 named, by name, so the list is not only self-consistent.
|
||||
for (const domain of ["mind", "profile", "labs", "measurements"] as const) {
|
||||
expect(active.manageableDomains, domain).toContain(domain);
|
||||
}
|
||||
expect(active.manageableDomains).not.toContain("documents");
|
||||
expect(active.writableDomains).not.toContain("documents");
|
||||
});
|
||||
|
||||
it("lands a mood entry and an allergy on the profile's record", async () => {
|
||||
const { guardian, profile } = await guardianInside();
|
||||
|
||||
const mood = await postMood();
|
||||
expect(mood.status).toBe(201);
|
||||
const moodRows = await getPrismaClient().moodEntry.findMany({
|
||||
where: { userId: profile.id },
|
||||
});
|
||||
expect(moodRows).toHaveLength(1);
|
||||
expect(
|
||||
await getPrismaClient().moodEntry.count({
|
||||
where: { userId: guardian.id },
|
||||
}),
|
||||
).toBe(0);
|
||||
|
||||
const allergy = await postAllergy();
|
||||
expect(allergy.status).toBe(201);
|
||||
const allergyRows = await getPrismaClient().allergy.findMany({
|
||||
where: { userId: profile.id },
|
||||
});
|
||||
expect(allergyRows).toHaveLength(1);
|
||||
expect(allergyRows[0]?.substance).toBe("Penicillin");
|
||||
});
|
||||
|
||||
it("is refused a vault write, as the empty documents answer promised", async () => {
|
||||
await guardianInside();
|
||||
const res = await postDocumentsBulk();
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a WRITE delegate on an adult's record", () => {
|
||||
it("reads writable sections without manageable ones, and is refused the mood entry", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants");
|
||||
const invited = await inviteGrant({
|
||||
grantorId: owner.id,
|
||||
granteeId: delegate.id,
|
||||
access: "WRITE",
|
||||
scope: null,
|
||||
});
|
||||
await acceptGrant({ grantId: invited.id, granteeId: delegate.id });
|
||||
const session = await signIn(delegate.id);
|
||||
await switchSessionTo(session.id, owner.id);
|
||||
|
||||
const active = await readActive();
|
||||
expect(active.level).toBe("write");
|
||||
expect(active.writableDomains).toEqual(
|
||||
delegatedDomains("write", null, "write"),
|
||||
);
|
||||
expect(active.writableDomains).toContain("measurements");
|
||||
expect(active.writableDomains).not.toContain("mind");
|
||||
expect(active.manageableDomains).toEqual([]);
|
||||
|
||||
// The route agrees with the list: a mood entry is a MANAGE create.
|
||||
const mood = await postMood();
|
||||
expect(mood.status).toBe(403);
|
||||
expect(
|
||||
await getPrismaClient().moodEntry.count({ where: { userId: owner.id } }),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -81,6 +81,8 @@ interface AccountAccessEntry {
|
||||
recordKind: "shared" | "managed";
|
||||
sections: string[] | null;
|
||||
canWrite: boolean;
|
||||
writableDomains: string[];
|
||||
manageableDomains: string[];
|
||||
}
|
||||
|
||||
interface AccountAccessBlock {
|
||||
@@ -183,6 +185,8 @@ describe("accountAccess — what the payload publishes", () => {
|
||||
recordKind: "shared",
|
||||
sections: null,
|
||||
canWrite: false,
|
||||
writableDomains: [],
|
||||
manageableDomains: [],
|
||||
},
|
||||
]);
|
||||
expect(accountAccess.canSwitch).toBe(true);
|
||||
@@ -342,6 +346,8 @@ describe("accountAccess — the active record", () => {
|
||||
recordKind: "shared",
|
||||
sections: null,
|
||||
canWrite: false,
|
||||
writableDomains: [],
|
||||
manageableDomains: [],
|
||||
});
|
||||
// The active entry is one of the listed ones, by construction. A banner
|
||||
// that had to join the two could render unnamed when they disagreed.
|
||||
|
||||
Reference in new issue
Block a user