diff --git a/.gitignore b/.gitignore index e5c925bfc..e121cf65a 100644 --- a/.gitignore +++ b/.gitignore @@ -18,9 +18,7 @@ /test-results /playwright-report /playwright/.cache -/e2e/setup/storageState.json -/e2e/setup/storageStateOwner.json -/e2e/setup/storageStateDelegate.json +/e2e/setup/storageState*.json # next.js /.next/ @@ -95,4 +93,4 @@ next-env.d.ts # times over. The same two names are excluded from eslint and vitest, for the # same reason in a different tool. /.wt-*/ -/.worktrees/ \ No newline at end of file +/.worktrees/ diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 80d3765d3..697b373fb 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -65,6 +65,61 @@ export const E2E_OWNER = { role: "USER", } as const; +/** A separate delegate session for the eight scoped-record browser journeys. */ +export const E2E_SCOPE_DELEGATE = { + email: "e2e-scope-delegate@healthlog.test", + username: "e2e-scope-delegate", + password: "Qz8!Vp4rL2nX7mKs", + role: "USER", +} as const; + +/** One non-personal target record per closed sharing domain. */ +export const E2E_SCOPE_RECORDS = [ + { + domain: "measurements", + username: "e2e-scope-measurements", + href: "/measurements", + }, + { + domain: "medications", + username: "e2e-scope-medications", + href: "/medications", + }, + { domain: "labs", username: "e2e-scope-labs", href: "/labs" }, + { domain: "profile", username: "e2e-scope-profile", href: "/profile" }, + { domain: "illness", username: "e2e-scope-illness", href: "/illness" }, + { domain: "mind", username: "e2e-scope-mind", href: "/mood" }, + { domain: "cycle", username: "e2e-scope-cycle", href: "/cycle" }, + { domain: "documents", username: "e2e-scope-documents", href: "/documents" }, +] as const; + +/** + * Whole-record grants used to prove the three adult access levels and the + * separate managed-profile boundary in a real browser session. + */ +export const E2E_LEVEL_RECORDS = [ + { + username: "e2e-level-read", + access: "READ", + recordKind: "shared", + }, + { + username: "e2e-level-write", + access: "WRITE", + recordKind: "shared", + }, + { + username: "e2e-level-manage", + access: "MANAGE", + recordKind: "shared", + }, + { + username: "e2e-level-managed", + access: "MANAGE", + recordKind: "managed", + }, +] as const; + /** * Weights seeded one per account, chosen so neither can be mistaken for the * other in rendered markup and neither collides with a value any other spec @@ -105,6 +160,16 @@ export const DELEGATE_STORAGE_STATE_PATH = resolve( "e2e/setup/storageStateDelegate.json", ); +export const SCOPE_DELEGATE_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateScopeDelegate.json", +); + +export const SCOPE_A11Y_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateScopeA11y.json", +); + async function hashPassword(password: string): Promise { return hash(password, { memoryCost: 19456, @@ -201,6 +266,163 @@ export default async function globalSetup(config: FullConfig): Promise { ], ); + // The scoped-record journey uses a dedicated delegate so its preseeded + // grants cannot affect the invitation lifecycle exercised by the sharing + // journey above. Its own module preferences are explicitly off: a target + // record's resolved scope, rather than the actor's preferences, must keep + // each granted doorway visible. + await pool.query( + `INSERT INTO users + (id, username, email, password_hash, role, created_at, updated_at, + onboarding_completed_at, onboarding_tour_completed, + module_preferences_json) + VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true, $6::jsonb) + ON CONFLICT (username) DO UPDATE SET + email = EXCLUDED.email, + password_hash = EXCLUDED.password_hash, + updated_at = EXCLUDED.updated_at, + onboarding_completed_at = EXCLUDED.onboarding_completed_at, + onboarding_tour_completed = EXCLUDED.onboarding_tour_completed, + module_preferences_json = EXCLUDED.module_preferences_json`, + [ + cuid(), + E2E_SCOPE_DELEGATE.username, + E2E_SCOPE_DELEGATE.email, + await hashPassword(E2E_SCOPE_DELEGATE.password), + now, + JSON.stringify({ + medications: false, + labs: false, + illness: false, + mood: false, + mentalHealth: false, + inboundDocuments: false, + }), + ], + ); + + await pool.query( + `DELETE FROM account_grants + WHERE grantee_id = (SELECT id FROM users WHERE username = $1)`, + [E2E_SCOPE_DELEGATE.username], + ); + await pool.query( + `UPDATE sessions SET acting_as_user_id = NULL + WHERE user_id = (SELECT id FROM users WHERE username = $1)`, + [E2E_SCOPE_DELEGATE.username], + ); + + for (const record of E2E_SCOPE_RECORDS) { + await pool.query( + `INSERT INTO users + (id, username, email, password_hash, role, created_at, updated_at, + onboarding_completed_at, onboarding_tour_completed, + module_preferences_json) + VALUES ($1, $2, $3, NULL, 'USER', $4, $4, $4, true, $5::jsonb) + ON CONFLICT (username) DO UPDATE SET + email = EXCLUDED.email, + updated_at = EXCLUDED.updated_at, + onboarding_completed_at = EXCLUDED.onboarding_completed_at, + onboarding_tour_completed = EXCLUDED.onboarding_tour_completed, + module_preferences_json = EXCLUDED.module_preferences_json`, + [ + cuid(), + record.username, + `${record.username}@healthlog.test`, + now, + JSON.stringify({ + medications: true, + labs: true, + illness: true, + mood: true, + mentalHealth: true, + inboundDocuments: true, + }), + ], + ); + + await pool.query( + `INSERT INTO account_grants + (id, grantor_id, grantee_id, access, scope_json, invited_at, + accepted_at, created_at) + SELECT $1, owner.id, delegate.id, 'READ', $2::jsonb, $3, $3, $3 + FROM users owner, users delegate + WHERE owner.username = $4 AND delegate.username = $5`, + [ + cuid(), + JSON.stringify([record.domain]), + now, + record.username, + E2E_SCOPE_DELEGATE.username, + ], + ); + } + + // Whole-record fixtures intentionally sit beside the narrow records + // above. They make the browser prove the presentation and mutation + // boundaries of READ, WRITE, MANAGE, and a guardian-managed record + // without changing the invitation lifecycle fixture below. + for (const record of E2E_LEVEL_RECORDS) { + await pool.query( + `INSERT INTO users + (id, username, email, password_hash, role, created_at, updated_at, + managed_profile_at, onboarding_completed_at, + onboarding_tour_completed, module_preferences_json) + VALUES ($1, $2, $3, NULL, 'USER', $4, $4, $5, $4, true, $6::jsonb) + ON CONFLICT (username) DO UPDATE SET + email = EXCLUDED.email, + password_hash = NULL, + updated_at = EXCLUDED.updated_at, + managed_profile_at = EXCLUDED.managed_profile_at, + onboarding_completed_at = EXCLUDED.onboarding_completed_at, + onboarding_tour_completed = EXCLUDED.onboarding_tour_completed, + module_preferences_json = EXCLUDED.module_preferences_json`, + [ + cuid(), + record.username, + `${record.username}@healthlog.test`, + now, + record.recordKind === "managed" ? now : null, + JSON.stringify({ + medications: true, + labs: true, + illness: true, + mood: true, + mentalHealth: true, + inboundDocuments: true, + }), + ], + ); + + await pool.query( + `INSERT INTO account_grants + (id, grantor_id, grantee_id, access, scope_json, invited_at, + accepted_at, created_at) + SELECT $1, owner.id, delegate.id, $2, NULL, $3, $3, $3 + FROM users owner, users delegate + WHERE owner.username = $4 AND delegate.username = $5`, + [ + cuid(), + record.access, + now, + record.username, + E2E_SCOPE_DELEGATE.username, + ], + ); + } + + // Cycle is delegated to its profile gate rather than module preferences. + await pool.query( + `INSERT INTO cycle_profiles + (id, user_id, cycle_tracking_enabled, created_at, updated_at) + SELECT $1, u.id, true, $2, $2 + FROM users u WHERE u.username = $3 + ON CONFLICT (user_id) DO UPDATE SET + cycle_tracking_enabled = true, + updated_at = EXCLUDED.updated_at`, + [cuid(), now, "e2e-scope-cycle"], + ); + // Repeated local E2E runs reuse the same seeded account and database. // Its owner-scoped share-link bucket lasts an hour, so otherwise the third // run can start above the 20-operation ceiling and fail before exercising @@ -281,7 +503,7 @@ export default async function globalSetup(config: FullConfig): Promise { WHERE key LIKE 'sharing:%'`, ); - // The login bucket, for the same reason. This setup signs in THREE times + // The login bucket, for the same reason. This setup signs in FOUR times // now (the shared jar, the owner, and the sharing journey's own delegate // jar), and the ceiling is five attempts per IP per quarter-hour — so two // local runs in a row would otherwise end with a 429 from the fixture @@ -345,6 +567,17 @@ export default async function globalSetup(config: FullConfig): Promise { // deliberately a different session row — see DELEGATE_STORAGE_STATE_PATH for // what happens when the journey switches the shared row instead. await captureAuthState(baseURL, E2E_USER, DELEGATE_STORAGE_STATE_PATH); + + await captureAuthState( + baseURL, + E2E_SCOPE_DELEGATE, + SCOPE_DELEGATE_STORAGE_STATE_PATH, + ); + await captureAuthState( + baseURL, + E2E_SCOPE_DELEGATE, + SCOPE_A11Y_STORAGE_STATE_PATH, + ); } /** diff --git a/e2e/setup/test-helpers.ts b/e2e/setup/test-helpers.ts index 9ae2f7a18..0b97ecc4e 100644 --- a/e2e/setup/test-helpers.ts +++ b/e2e/setup/test-helpers.ts @@ -9,9 +9,14 @@ export { E2E_USER, E2E_OWNER, + E2E_SCOPE_DELEGATE, + E2E_SCOPE_RECORDS, + E2E_LEVEL_RECORDS, E2E_DELEGATE_MARKER_KG, E2E_OWNER_MARKER_KG, STORAGE_STATE_PATH, OWNER_STORAGE_STATE_PATH, DELEGATE_STORAGE_STATE_PATH, + SCOPE_DELEGATE_STORAGE_STATE_PATH, + SCOPE_A11Y_STORAGE_STATE_PATH, } from "./global-setup"; diff --git a/e2e/v137-sharing-cross-tab-session.spec.ts b/e2e/v137-sharing-cross-tab-session.spec.ts new file mode 100644 index 000000000..a483ad98e --- /dev/null +++ b/e2e/v137-sharing-cross-tab-session.spec.ts @@ -0,0 +1,116 @@ +import type { Page } from "@playwright/test"; + +import { expect, test } from "./setup/test"; +import { SCOPE_DELEGATE_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(); +} + +function deferred() { + let release!: () => void; + const promise = new Promise((resolve) => { + release = resolve; + }); + return { promise, release }; +} + +test.describe.serial("cross-tab shared-record session switches", () => { + test("holds a peer tab before both switch-in and switch-out change the shared session", async ({ + browser, + }) => { + const context = await browser.newContext({ + storageState: SCOPE_DELEGATE_STORAGE_STATE_PATH, + }); + const switchingPage = await context.newPage(); + const peerPage = await context.newPage(); + const pausedSwitches: ReturnType[] = []; + const peerRecordReads: string[] = []; + + await switchingPage.route("**/api/account/switch", async (route) => { + const pause = pausedSwitches.shift(); + if (pause) await pause.promise; + await route.continue(); + }); + peerPage.on("request", (request) => { + if (request.method() !== "GET") return; + const path = new URL(request.url()).pathname; + if ( + [ + "/api/dashboard/snapshot", + "/api/insights/targets", + "/api/medications", + "/api/medications/compliance", + "/api/labs", + "/api/labs/ocr/capability", + "/api/insights/coach/nudge-status", + ].includes(path) + ) { + peerRecordReads.push(path); + } + }); + + try { + await Promise.all([switchingPage.goto("/"), peerPage.goto("/")]); + await expect( + peerPage.getByRole("button", { name: "User menu" }), + ).toBeVisible(); + + await openSwitcher(switchingPage); + const entry = switchingPage.locator( + '[data-slot="account-switcher-entry"][data-account-username="e2e-scope-labs"]', + ); + await expect(entry).toBeVisible(); + const switchIn = deferred(); + pausedSwitches.push(switchIn); + peerRecordReads.length = 0; + const switchInClick = entry.click({ noWaitAfter: true }); + + await expect( + peerPage.locator('[data-slot="record-scope-hydration-gate"]'), + ).toBeVisible(); + await expect( + peerPage.getByRole("button", { name: "User menu" }), + ).toHaveCount(0); + await peerPage.waitForTimeout(200); + expect(peerRecordReads).toEqual([]); + + switchIn.release(); + await switchInClick; + await expect( + switchingPage.locator('[data-slot="shared-record-banner"]'), + ).toBeVisible(); + await expect( + peerPage.locator('[data-slot="record-scope-hydration-gate"]'), + ).toHaveCount(0); + + const switchOut = deferred(); + pausedSwitches.push(switchOut); + peerRecordReads.length = 0; + const switchOutClick = switchingPage + .locator('[data-slot="shared-record-banner-exit"]') + .click({ noWaitAfter: true }); + + await expect( + peerPage.locator('[data-slot="record-scope-hydration-gate"]'), + ).toBeVisible(); + await expect( + peerPage.locator('[data-slot="shared-record-banner"]'), + ).toHaveCount(0); + await peerPage.waitForTimeout(200); + expect(peerRecordReads).toEqual([]); + + switchOut.release(); + await switchOutClick; + await expect( + switchingPage.getByRole("button", { name: "User menu" }), + ).toBeVisible(); + await expect( + peerPage.getByRole("button", { name: "User menu" }), + ).toBeVisible(); + } finally { + await context.close(); + } + }); +}); diff --git a/e2e/v137-sharing-managed-profiles-a11y.spec.ts b/e2e/v137-sharing-managed-profiles-a11y.spec.ts index ed146f0f3..6396eba29 100644 --- a/e2e/v137-sharing-managed-profiles-a11y.spec.ts +++ b/e2e/v137-sharing-managed-profiles-a11y.spec.ts @@ -1,11 +1,83 @@ +import AxeBuilder from "@axe-core/playwright"; +import type { Page } from "@playwright/test"; + import { expect, test } from "./setup/test"; +import { SCOPE_A11Y_STORAGE_STATE_PATH } from "./setup/test-helpers"; -import { ACCESSIBILITY_STATES } from "../tests/fixtures/v137/e2e-journeys"; +async function scan(page: Page) { + const result = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze(); + expect(result.violations).toEqual([]); +} -test.describe("sharing accessibility anchors", () => { - for (const state of ACCESSIBILITY_STATES) { - test(`anchor: ${state.name}`, () => { - expect(state.contract).toBe("inventory-only"); +async function openProfileRecord(page: Page) { + await page.goto("/"); + await page.getByRole("button", { name: "User menu" }).first().click(); + await page.locator('[data-slot="account-switcher-trigger"]').click(); + const entry = page.locator( + '[data-slot="account-switcher-entry"][data-account-username="e2e-scope-profile"]', + ); + await expect(entry).toHaveCount(1); + await entry.click(); + await expect(page).toHaveURL(/\/profile$/); +} + +async function leaveRecord(page: Page) { + await page.locator('[data-slot="shared-record-banner-exit"]').click(); + await expect(page.locator('[data-slot="shared-record-banner"]')).toHaveCount( + 0, + ); +} + +test.describe("shared record accessibility states", () => { + test.describe.configure({ mode: "serial" }); + test.use({ storageState: SCOPE_A11Y_STORAGE_STATE_PATH }); + + test("success and empty shared profile states have no WCAG violations", async ({ + page, + }) => { + await openProfileRecord(page); + await expect(page.locator('[data-slot="profile-summary"]')).toBeVisible(); + await scan(page); + await leaveRecord(page); + }); + + test("the capability hydration loading state has no WCAG violations", async ({ + page, + }) => { + await page.route("**/api/auth/me", async (route) => { + await new Promise((resolve) => setTimeout(resolve, 250)); + await route.continue(); }); - } + await page.goto("/measurements"); + await expect( + page.locator('[data-slot="record-scope-hydration-gate"]'), + ).toBeVisible(); + await scan(page); + }); + + test("the shared profile read error state has no WCAG violations", async ({ + page, + }) => { + await page.route("**/api/profile/summary**", (route) => + route.fulfill({ status: 500, body: "{}" }), + ); + await openProfileRecord(page); + await expect(page.locator('[data-slot="query-error-card"]')).toBeVisible(); + await scan(page); + await leaveRecord(page); + }); + + test("the shared-record refusal state has no WCAG violations", async ({ + page, + }) => { + await openProfileRecord(page); + await page.goto("/settings/account"); + await expect( + page.locator('[data-slot="shared-record-unavailable"]'), + ).toBeVisible(); + await scan(page); + await page.locator('[data-slot="shared-record-unavailable-leave"]').click(); + }); }); diff --git a/e2e/v137-sharing-managed-profiles.spec.ts b/e2e/v137-sharing-managed-profiles.spec.ts index a97bc38ae..f21d6137c 100644 --- a/e2e/v137-sharing-managed-profiles.spec.ts +++ b/e2e/v137-sharing-managed-profiles.spec.ts @@ -1,11 +1,468 @@ +import type { Page } from "@playwright/test"; + import { expect, test } from "./setup/test"; +import { + E2E_LEVEL_RECORDS, + E2E_SCOPE_RECORDS, + SCOPE_DELEGATE_STORAGE_STATE_PATH, +} from "./setup/test-helpers"; -import { RELEASE_JOURNEYS } from "../tests/fixtures/v137/e2e-journeys"; +const DOMAIN_READS = { + measurements: "/api/measurements", + medications: "/api/medications", + labs: "/api/labs", + profile: "/api/profile/summary", + illness: "/api/illness/episodes", + mind: "/api/mood-entries", + cycle: "/api/cycle/calendar", + documents: "/api/documents/inbound", +} as const; -test.describe("sharing release journey anchors", () => { - for (const journey of RELEASE_JOURNEYS) { - test(`anchor: ${journey.name}`, () => { - expect(journey.contract).toBe("inventory-only"); +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(); +} + +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); + const accountId = await entry.getAttribute("data-account-id"); + if (accountId === null) { + throw new Error(`Account switcher entry for ${username} has no account id`); + } + const accessLevel = await entry.getAttribute("data-access-level"); + const recordKind = await entry.getAttribute("data-record-kind"); + await entry.click(); + return { accountId, accessLevel, recordKind }; +} + +async function leaveRecord(page: Page) { + await page.locator('[data-slot="shared-record-banner-exit"]').click(); + await expect(page.locator('[data-slot="shared-record-banner"]')).toHaveCount( + 0, + ); +} + +function withDivergentActiveAccountAccess(body: unknown): unknown { + if (body === null || typeof body !== "object") { + throw new Error("Expected an object from /api/auth/me"); + } + + const envelope = body as { data?: unknown }; + const payload = "data" in envelope ? envelope.data : envelope; + if (payload === null || typeof payload !== "object") { + throw new Error("Expected an auth payload from /api/auth/me"); + } + + const auth = payload as { + accountAccess?: { + active?: { + access?: unknown; + level?: unknown; + canWrite?: unknown; + } | null; + }; + }; + if (!auth.accountAccess?.active) { + throw new Error("Expected a switched account-access payload"); + } + + const corrupted = { + ...auth, + accountAccess: { + ...auth.accountAccess, + active: { + ...auth.accountAccess.active, + access: "write", + level: "write", + canWrite: true, + }, + }, + }; + return "data" in envelope ? { ...envelope, data: corrupted } : corrupted; +} + +test.describe.serial("scoped sharing browser journeys", () => { + test.use({ storageState: SCOPE_DELEGATE_STORAGE_STATE_PATH }); + + for (const record of E2E_SCOPE_RECORDS) { + test(`opens only the ${record.domain} record doorway`, async ({ page }) => { + await page.goto("/"); + await openSwitcher(page); + + const entry = page.locator( + `[data-slot="account-switcher-entry"][data-account-username="${record.username}"]`, + ); + await expect(entry).toHaveCount(1); + + const unexpectedOcrCapability = + record.domain === "labs" + ? page.waitForRequest( + (request) => + request.method() === "GET" && + new URL(request.url()).pathname === "/api/labs/ocr/capability", + { timeout: 500 }, + ) + : null; + const read = page.waitForResponse( + (response) => + response.request().method() === "GET" && + new URL(response.url()).pathname === DOMAIN_READS[record.domain], + ); + await entry.click(); + + await expect(page).toHaveURL(new RegExp(`${record.href}$`)); + await expect( + page.locator('[data-slot="shared-record-banner"]'), + ).toContainText(record.username); + await expect( + page.locator(`a[href="${record.href}"]`).first(), + ).toBeVisible(); + await expect( + page.locator('[data-slot="shared-record-unavailable"]'), + ).toHaveCount(0); + await read; + if (unexpectedOcrCapability) { + await expect(unexpectedOcrCapability).rejects.toThrow(/Timeout/); + } + + await leaveRecord(page); }); } + + test("refuses a malformed switched payload before target or owner reads mount", async ({ + page, + }) => { + await page.goto("/"); + await openSwitcher(page); + + const entry = page.locator( + '[data-slot="account-switcher-entry"][data-account-username="e2e-scope-labs"]', + ); + await expect(entry).toHaveCount(1); + + // The switch normally writes this mirror before its full reload. Remove it + // in the new document so the global preloaders cannot guess an own-record + // scope while `/api/auth/me` is still deciding whether the target is safe. + await page.addInitScript(() => { + localStorage.removeItem("healthlog-record-scope"); + }); + + let corruptAuthPayload = true; + await page.route("**/api/auth/me", async (route) => { + const response = await route.fetch(); + if (!corruptAuthPayload) { + await route.fulfill({ response }); + return; + } + await route.fulfill({ + response, + json: withDivergentActiveAccountAccess(await response.json()), + }); + }); + + const ownerOnlyReads: string[] = []; + const trackOwnerOnlyRead = (request: { + method(): string; + url(): string; + }) => { + if (request.method() !== "GET") return; + const path = new URL(request.url()).pathname; + if ( + [ + "/api/labs/ocr/capability", + "/api/insights/coach/nudge-status", + "/api/coach/about-me/questions", + "/api/auth/me/notification-prefs", + ].includes(path) + ) { + ownerOnlyReads.push(path); + } + }; + page.on("request", trackOwnerOnlyRead); + const targetPreloads: string[] = []; + const trackTargetPreload = (request: { + method(): string; + url(): string; + }) => { + if (request.method() !== "GET") return; + const path = new URL(request.url()).pathname; + if ( + [ + "/api/dashboard/snapshot", + "/api/medications", + "/api/medications/compliance", + ].includes(path) + ) { + targetPreloads.push(path); + } + }; + page.on("request", trackTargetPreload); + try { + await entry.click(); + + await expect(page).toHaveURL(/\/labs$/); + await expect( + page.locator('[data-slot="invalid-record-access-refusal"]'), + ).toBeVisible(); + await expect( + page.locator('[data-slot="shared-record-unavailable-leave"]'), + ).toBeVisible(); + await expect(page.locator('[data-tour-id="labs-hero"]')).toHaveCount(0); + await expect( + page.locator('[data-slot="shared-record-banner"]'), + ).toHaveCount(0); + + // Both global preloader routes must remain inert while the malformed + // response is refused, even though the scope mirror was intentionally + // cleared before each reload. + await page.goto("/"); + await expect( + page.locator('[data-slot="invalid-record-access-refusal"]'), + ).toBeVisible(); + await page.goto("/medications"); + await expect( + page.locator('[data-slot="invalid-record-access-refusal"]'), + ).toBeVisible(); + expect(ownerOnlyReads).toEqual([]); + expect(targetPreloads).toEqual([]); + + // Leave the refused record, then demand fresh own-record reads. This + // catches a response that was parked under the owner hash while the + // refusal was on screen and would otherwise paint without a new request. + corruptAuthPayload = false; + const freshDashboard = page.waitForResponse( + (response) => + response.request().method() === "GET" && + new URL(response.url()).pathname === "/api/dashboard/snapshot", + ); + await page + .locator('[data-slot="shared-record-unavailable-leave"]') + .click(); + await expect(page).toHaveURL(/\/$/); + await freshDashboard; + + const freshMedications = page.waitForResponse( + (response) => + response.request().method() === "GET" && + new URL(response.url()).pathname === "/api/medications", + ); + await page.goto("/medications"); + await freshMedications; + } finally { + page.off("request", trackOwnerOnlyRead); + page.off("request", trackTargetPreload); + await page.unroute("**/api/auth/me"); + } + }); + + test("keeps an adult READ grant read-only and outside Settings", async ({ + page, + }) => { + const record = E2E_LEVEL_RECORDS.find( + (candidate) => candidate.access === "READ", + ); + if (!record) throw new Error("READ fixture is missing"); + + const { accessLevel, recordKind } = await openRecord(page, record.username); + expect(accessLevel).toBe("view"); + expect(recordKind).toBe("shared"); + + const banner = page.locator('[data-slot="shared-record-banner"]'); + await expect(banner).toHaveAttribute("data-access-level", "view"); + await expect(banner).toHaveAttribute("data-record-kind", "shared"); + await expect(banner).toContainText(record.username); + + await page.goto("/measurements"); + await expect(page.locator('[data-slot="measurement-add"]')).toHaveCount(0); + + await page.goto("/settings/account"); + await expect( + page.locator('[data-slot="shared-record-unavailable"]'), + ).toBeVisible(); + await leaveRecord(page); + }); + + test("lets an adult WRITE grant add only to the selected record", async ({ + page, + }) => { + const record = E2E_LEVEL_RECORDS.find( + (candidate) => candidate.access === "WRITE", + ); + if (!record) throw new Error("WRITE fixture is missing"); + + const { accountId, accessLevel, recordKind } = await openRecord( + page, + record.username, + ); + expect(accessLevel).toBe("view-and-add"); + expect(recordKind).toBe("shared"); + const banner = page.locator('[data-slot="shared-record-banner"]'); + await expect(banner).toHaveAttribute("data-account-id", accountId); + await expect(banner).toHaveAttribute("data-access-level", "view-and-add"); + await expect(banner).toHaveAttribute("data-record-kind", "shared"); + + await page.goto("/measurements"); + await expect(page.locator('[data-slot="measurement-add"]')).toBeVisible(); + const post = page.waitForResponse( + (response) => + response.request().method() === "POST" && + new URL(response.url()).pathname === "/api/measurements", + ); + await page.locator('[data-slot="measurement-add"]').click(); + await page.locator("#sys").fill("124"); + await page.locator("#dia").fill("78"); + await page.getByRole("button", { name: /^save$/i }).click(); + expect((await post).status()).toBeLessThan(300); + + await page.reload(); + const payload = await page.evaluate(async () => { + const response = await fetch("/api/measurements"); + return { status: response.status, body: await response.json() }; + }); + expect(payload.status).toBeLessThan(300); + expect(JSON.stringify(payload.body)).toContain("124"); + await expect(banner).toHaveAttribute("data-account-id", accountId); + + await page.goto("/settings/account"); + await expect( + page.locator('[data-slot="shared-record-unavailable"]'), + ).toBeVisible(); + await leaveRecord(page); + }); + + test("opens MANAGE-only generated reads without opening adult Settings", async ({ + page, + }) => { + const record = E2E_LEVEL_RECORDS.find( + (candidate) => + candidate.access === "MANAGE" && candidate.recordKind === "shared", + ); + if (!record) throw new Error("adult MANAGE fixture is missing"); + + const { accessLevel, recordKind } = await openRecord(page, record.username); + expect(accessLevel).toBe("manage"); + expect(recordKind).toBe("shared"); + const banner = page.locator('[data-slot="shared-record-banner"]'); + await expect(banner).toHaveAttribute("data-access-level", "manage"); + await expect(banner).toHaveAttribute("data-record-kind", "shared"); + + await page.goto("/"); + await expect(banner).toHaveAttribute("data-access-level", "manage"); + const generatedRead = await page.evaluate(async () => { + const response = await fetch("/api/dashboard/summary"); + return response.status; + }); + expect(generatedRead).toBeLessThan(300); + await expect( + page.locator('[data-slot="shared-record-unavailable"]'), + ).toHaveCount(0); + + await page.goto("/settings/account"); + await expect( + page.locator('[data-slot="shared-record-unavailable"]'), + ).toBeVisible(); + await leaveRecord(page); + }); + + test("keeps managed Settings on the active record and integrations status-only", async ({ + page, + }) => { + const record = E2E_LEVEL_RECORDS.find( + (candidate) => candidate.recordKind === "managed", + ); + if (!record) throw new Error("managed record fixture is missing"); + + const { accountId, accessLevel, recordKind } = await openRecord( + page, + record.username, + ); + expect(accessLevel).toBe("manage"); + expect(recordKind).toBe("managed"); + const banner = page.locator('[data-slot="shared-record-banner"]'); + await expect(banner).toHaveAttribute("data-account-id", accountId); + await expect(banner).toHaveAttribute("data-access-level", "manage"); + await expect(banner).toHaveAttribute("data-record-kind", "managed"); + + await page.goto("/settings/account"); + const profile = page.locator('[data-record-settings-family="profile"]'); + await expect(profile).toBeVisible(); + await expect(profile).toHaveAttribute("data-record-id", accountId); + + const integrationRead = page.waitForResponse( + (response) => + response.request().method() === "GET" && + new URL(response.url()).pathname === + "/api/record-settings/integrations", + ); + await page.goto("/settings/integrations"); + const integrationResponse = await integrationRead; + expect(integrationResponse.status()).toBeLessThan(300); + const integrationPayload = (await integrationResponse.json()) as { + data?: { recordId?: string }; + }; + expect(integrationPayload.data?.recordId).toBe(accountId); + const integrations = page.locator( + '[data-record-settings-family="integrations"]', + ); + await expect(integrations).toBeVisible(); + await expect(integrations).toHaveAttribute("data-record-id", accountId); + await expect( + integrations.locator("button, input, select, textarea"), + ).toHaveCount(0); + + await page.goBack(); + await expect(profile).toHaveAttribute("data-record-id", accountId); + await page.goForward(); + await expect(integrations).toHaveAttribute("data-record-id", accountId); + await expect(banner).toHaveAttribute("data-account-id", accountId); + + await leaveRecord(page); + await page.goto("/measurements"); + await expect(banner).toHaveCount(0); + }); + + test("refuses direct ungranted routes before coach reads start", async ({ + page, + }) => { + await openRecord(page, "e2e-scope-measurements"); + const protectedCoachReads: string[] = []; + const trackCoachRead = (request: { url(): string }) => { + const path = new URL(request.url()).pathname; + if ( + [ + "/api/insights/coach/nudge-status", + "/api/coach/about-me/questions", + "/api/auth/me/notification-prefs", + ].includes(path) + ) { + protectedCoachReads.push(path); + } + }; + page.on("request", trackCoachRead); + try { + await page.goto("/coach"); + await expect( + page.locator('[data-slot="shared-record-unavailable"]'), + ).toBeVisible(); + expect(protectedCoachReads).toEqual([]); + + await page.goto("/settings/access"); + await expect( + page.locator('[data-slot="shared-record-unavailable"]'), + ).toBeVisible(); + expect(protectedCoachReads).toEqual([]); + } finally { + page.off("request", trackCoachRead); + } + await leaveRecord(page); + }); }); diff --git a/messages/de.json b/messages/de.json index 6d8c53f1e..2e876a92b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -341,6 +341,7 @@ }, "cycle": "Zyklus", "labs": "Laborwerte", + "profile": "Profil", "vorsorge": "Vorsorge", "illness": "Krankheit", "documents": "Dokumente", diff --git a/messages/en.json b/messages/en.json index a0e7bc356..704da23e3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -341,6 +341,7 @@ }, "cycle": "Cycle", "labs": "Labs", + "profile": "Profile", "vorsorge": "Checkups", "illness": "Illness", "documents": "Documents", diff --git a/messages/es.json b/messages/es.json index 625c3daff..13cb2d2cb 100644 --- a/messages/es.json +++ b/messages/es.json @@ -341,6 +341,7 @@ }, "cycle": "Ciclo", "labs": "Análisis", + "profile": "Perfil", "vorsorge": "Revisiones", "illness": "Enfermedad", "documents": "Documentos", diff --git a/messages/fr.json b/messages/fr.json index bc662c21a..0c97286b6 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -341,6 +341,7 @@ }, "cycle": "Cycle", "labs": "Analyses", + "profile": "Profil", "vorsorge": "Examens", "illness": "Maladie", "documents": "Documents", diff --git a/messages/it.json b/messages/it.json index 2b0202731..9bb80dbf5 100644 --- a/messages/it.json +++ b/messages/it.json @@ -341,6 +341,7 @@ }, "cycle": "Ciclo", "labs": "Esami", + "profile": "Profilo", "vorsorge": "Controlli", "illness": "Malattia", "documents": "Documenti", diff --git a/messages/pl.json b/messages/pl.json index 526608239..f01fa2c7f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -341,6 +341,7 @@ }, "cycle": "Cykl", "labs": "Wyniki badań", + "profile": "Profil", "vorsorge": "Badania", "illness": "Choroba", "documents": "Dokumenty", diff --git a/playwright.config.ts b/playwright.config.ts index 3a79e9e88..bb986a4ca 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -83,6 +83,14 @@ export default defineConfig({ }, { name: "chromium-mobile", + // The scoped-sharing journeys each carry a persistent server-side + // session stamp. They use isolated desktop jars and are not responsive + // layout checks, so running a second project against the same stamps + // would test session interference rather than the record journeys. + testIgnore: [ + "v137-sharing-managed-profiles.spec.ts", + "v137-sharing-managed-profiles-a11y.spec.ts", + ], use: { // Pixel 5 — Chromium-based mobile profile so CI only needs // `playwright install chromium` instead of also pulling diff --git a/src/__tests__/managed-record-navigation.test.tsx b/src/__tests__/managed-record-navigation.test.tsx new file mode 100644 index 000000000..09dec936a --- /dev/null +++ b/src/__tests__/managed-record-navigation.test.tsx @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { classifySettingsDestination } from "@/lib/record-settings"; + +const pathnameRef = { value: "/settings/security" }; +const activeRecordRef = { value: true }; + +vi.mock("next/navigation", () => ({ + usePathname: () => pathnameRef.value, +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: activeRecordRef.value + ? { + id: "guardian", + modules: undefined, + accountAccess: { + active: { + accountId: "managed-record", + username: "managed-record", + displayName: "Managed record", + access: "write", + level: "manage", + recordKind: "managed", + sections: null, + canWrite: true, + }, + accounts: [], + canSwitch: true, + }, + } + : null, + isAuthenticated: true, + isLoading: false, + refetch: vi.fn(), + }), +})); + +vi.mock("@/hooks/use-account-switch", () => ({ + useAccountSwitch: () => ({ + mutate: vi.fn(), + isPending: false, + }), +})); + +import { + SETTINGS_SECTIONS, + SettingsShell, +} from "@/components/settings/settings-shell"; +import { SharedRecordBanner } from "@/components/layout/shared-record-banner"; + +function renderSettings(pathname = "/settings/security") { + pathnameRef.value = pathname; + return renderToStaticMarkup( + + +
managed settings content
+
+
, + ); +} + +describe("managed record navigation", () => { + it("shows only managed settings destinations while retaining an unavailable deep-link heading", () => { + const html = renderSettings(); + const managedDestinations = SETTINGS_SECTIONS.filter( + (section) => + classifySettingsDestination(section.slug).kind === "managed-guardian", + ); + const unavailableDestinations = SETTINGS_SECTIONS.filter( + (section) => + classifySettingsDestination(section.slug).kind !== "managed-guardian", + ); + + expect(html).toContain('data-active-record-id="managed-record"'); + expect(html).toContain('id="settings-section-security-title"'); + expect(html).not.toMatch(/href="[^"]*managed-record/); + + for (const section of managedDestinations) { + expect( + html.match(new RegExp(`href="/settings/${section.slug}"`, "g")) + ?.length ?? 0, + ).toBe(2); + } + + for (const section of unavailableDestinations) { + expect(html).not.toContain(`href="/settings/${section.slug}"`); + } + }); + + it("keeps the exit control present while administering a managed record", () => { + const html = renderToStaticMarkup( + + + , + ); + + expect(html).toContain('data-account-id="managed-record"'); + expect(html).toContain('data-slot="shared-record-banner-exit"'); + }); +}); diff --git a/src/__tests__/shared-record-navigation.test.tsx b/src/__tests__/shared-record-navigation.test.tsx new file mode 100644 index 000000000..3413dc1a3 --- /dev/null +++ b/src/__tests__/shared-record-navigation.test.tsx @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; + +import { + SHARED_RECORD_DOMAIN_ROUTE_FAMILIES, + resolveSharedRecordNavigation, +} from "@/lib/navigation/shared-record"; +import { SHARE_DOMAINS } from "@/lib/sharing/scope"; +import { + isDestinationInSharedRecord, + visibleNavDestinations, +} from "@/components/layout/nav-model"; +import { resolveRecordPresentation } from "@/lib/navigation/record-presentation"; + +describe("shared-record navigation", () => { + it("keeps every granted route family in one closed eight-domain inventory", () => { + expect(Object.keys(SHARED_RECORD_DOMAIN_ROUTE_FAMILIES)).toEqual( + SHARE_DOMAINS, + ); + + for (const domain of SHARE_DOMAINS) { + expect( + SHARED_RECORD_DOMAIN_ROUTE_FAMILIES[domain].length, + `${domain} needs at least one route family`, + ).toBeGreaterThan(0); + } + }); + + it("offers a scoped grant only its matching doorway", () => { + const navigation = resolveSharedRecordNavigation(["labs"]); + + expect(navigation.destinationHrefs).toEqual(["/labs"]); + expect(navigation.allowsPath("/labs/panel/7")).toBe(true); + expect(navigation.allowsPath("/medications")).toBe(false); + + expect( + visibleNavDestinations(undefined, true, true, ["labs"]).map( + (destination) => destination.href, + ), + ).toEqual(["/labs"]); + expect(isDestinationInSharedRecord("/labs/panel/7", ["labs"])).toBe(true); + expect(isDestinationInSharedRecord("/medications", ["labs"])).toBe(false); + }); + + it("keeps profile facts outside Settings and makes profile the scoped doorway", () => { + const navigation = resolveSharedRecordNavigation(["profile"]); + + expect(SHARED_RECORD_DOMAIN_ROUTE_FAMILIES.profile).toEqual(["/profile"]); + expect(navigation.destinationHrefs).toEqual(["/profile"]); + expect(navigation.allowsPath("/profile")).toBe(true); + expect(navigation.allowsPath("/settings/anamnesis")).toBe(false); + }); + + it("uses the active record scope instead of the actor's module flags", () => { + const actorModules = { + medications: false, + labs: false, + mood: false, + mentalHealth: false, + cycle: false, + illness: false, + inboundDocuments: false, + }; + + expect( + visibleNavDestinations(actorModules, true, true, ["medications"]).map( + (destination) => destination.href, + ), + ).toEqual(["/medications"]); + }); + + it("keeps whole-record navigation distinct from an all-sections scope", () => { + const wholeRecord = resolveSharedRecordNavigation(null); + const allSections = resolveSharedRecordNavigation([...SHARE_DOMAINS]); + + expect(wholeRecord.allowsPath("/")).toBe(true); + expect(allSections.allowsPath("/")).toBe(false); + }); + + it("refuses unclassified direct URLs in presentation before pages start reads", () => { + const navigation = resolveSharedRecordNavigation(["documents"]); + + expect(navigation.allowsPath("/documents/record-1")).toBe(true); + expect(navigation.allowsPath("/settings/account")).toBe(false); + expect(navigation.allowsPath("/new-unclassified-route")).toBe(false); + }); + + it("keeps server-resolved access and record kind distinct in chrome", () => { + expect( + resolveRecordPresentation({ + level: "read", + canWrite: false, + recordKind: "shared", + }), + ).toEqual({ access: "view", recordKind: "shared" }); + expect( + resolveRecordPresentation({ + level: "write", + canWrite: true, + recordKind: "shared", + }), + ).toEqual({ access: "view-and-add", recordKind: "shared" }); + expect( + resolveRecordPresentation({ + level: "manage", + canWrite: true, + recordKind: "shared", + }), + ).toEqual({ access: "manage", recordKind: "shared" }); + expect( + resolveRecordPresentation({ + level: "manage", + canWrite: true, + recordKind: "managed", + }), + ).toEqual({ access: "manage", recordKind: "managed" }); + }); + + it("fails closed when a malformed entry reaches presentation", () => { + expect( + resolveRecordPresentation({ + level: "manage", + canWrite: false, + recordKind: "managed", + } as never), + ).toEqual({ access: "view", recordKind: "self" }); + + expect( + resolveRecordPresentation({ + level: "unknown", + canWrite: true, + recordKind: "shared", + } as never), + ).toEqual({ access: "view", recordKind: "self" }); + }); +}); diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 235964540..f51839ab9 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -879,6 +879,10 @@ const DELEGABLE_ROUTES: Record = { domain: "profile", why: "The record's health-profile facts, read through `readHealthProfileFacts(user.id)`. The module holds write helpers, and the GET calls none of them.", }, + "app/api/profile/summary/route.ts": { + domain: "profile", + why: "The bounded shared profile read combines only the record's allergies, family history, and current lifestyle facts. It carries no settings, AI configuration, mutations, or encrypted free text.", + }, "app/api/mood-entries/route.ts": { domain: "mind", why: "The record's mood entries. The nested include is the tag vocabulary — a key and a kind, no identifiers. Note that `GET /api/mood/tags` is refused and stays refused: it pulls the owner's tag LAYOUT, which is a presentation preference and belongs to the person, not the record.", @@ -939,6 +943,14 @@ const DELEGABLE_ROUTES: Record = { domain: "cycle", why: "The record's cycle history. Whether cycle tracking applies is a property of the record, and the gate gets there by re-loading the profile and gender for the resolved user id — note that the `gender` argument at the call site is dead (`requireCycleEnabled` names it `_gender`), so it is the id that carries the substitution, not that argument.", }, + "app/api/cycle/calendar/route.ts": { + domain: "cycle", + why: "The initial cycle calendar read. Its gate resolves tracking eligibility from the substituted record id, and the handler only reads that record's bounded calendar inputs.", + }, + "app/api/cycle/insights/route.ts": { + domain: "cycle", + why: "The initial cycle insights read. Its rate-limit key and every phase-correlation input use the resolved record id; it creates no provider egress or mutation.", + }, "app/api/cycle/day-logs/route.ts": { domain: "cycle", why: 'One day of the record\'s cycle log. The `auditLog("cycle.day-log.upsert")` in this file belongs to the PUT, which keeps `requireAuth()`.', @@ -1793,9 +1805,11 @@ const ACTOR_ROUTES: Record = { * find out from the merge rather than from production. Import-status polling * adds two record entries and two MANAGE entries: 190 → 194. Managed-record * settings add two guardian-only entries: 194 → 196. Field-specific - * managed-record configuration adds one more: 196 → 197. + * managed-record configuration adds one more: 196 → 197. The bounded + * read-only profile summary makes the next explicit admission: 197 → 198. + * The two initial Cycle reads follow as separate, scoped admissions: 198 → 200. */ -const FROZEN_ENTRY_COUNT = 197; +const FROZEN_ENTRY_COUNT = 200; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — diff --git a/src/__tests__/v137-wave-zero-fixtures.test.ts b/src/__tests__/v137-wave-zero-fixtures.test.ts index 05d086678..6575f2228 100644 --- a/src/__tests__/v137-wave-zero-fixtures.test.ts +++ b/src/__tests__/v137-wave-zero-fixtures.test.ts @@ -1,6 +1,3 @@ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; - import { describe, expect, it } from "vitest"; import { @@ -13,10 +10,6 @@ import { NOTIFICATION_CHANNELS, NOTIFICATION_DELIVERY_MATRIX, } from "../../tests/fixtures/v137/notification-matrix"; -import { - ACCESSIBILITY_STATES, - RELEASE_JOURNEYS, -} from "../../tests/fixtures/v137/e2e-journeys"; describe("sharing and handler inventories", () => { it("freezes the exact sharing-domain vocabulary", () => { @@ -104,28 +97,3 @@ describe("security principals and compatibility", () => { expect(LEGACY_ACCOUNT_PAYLOADS[3]?.legacyDecoder).toBe("deny"); }); }); - -describe("journey and accessibility inventory", () => { - it("names every release journey and accessibility state exactly once", () => { - expect(RELEASE_JOURNEYS).toHaveLength(8); - expect(ACCESSIBILITY_STATES).toHaveLength(5); - expect(new Set(RELEASE_JOURNEYS.map(({ name }) => name)).size).toBe( - RELEASE_JOURNEYS.length, - ); - expect(new Set(ACCESSIBILITY_STATES.map(({ name }) => name)).size).toBe( - ACCESSIBILITY_STATES.length, - ); - }); - - it("leaves the browser anchors discoverable without focused or skipped cases", () => { - const anchorFiles = [ - "v137-sharing-managed-profiles.spec.ts", - "v137-sharing-managed-profiles-a11y.spec.ts", - ]; - - for (const file of anchorFiles) { - const source = readFileSync(join(process.cwd(), "e2e", file), "utf8"); - expect(source).not.toMatch(/\b(?:test|describe)\.(?:only|skip)\b/); - } - }); -}); diff --git a/src/app/api/cycle/calendar/route.ts b/src/app/api/cycle/calendar/route.ts index 0a4c95aad..f5f4d1f51 100644 --- a/src/app/api/cycle/calendar/route.ts +++ b/src/app/api/cycle/calendar/route.ts @@ -16,7 +16,7 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiError, apiSuccess } from "@/lib/api-response"; import { requireCycleEnabled } from "@/lib/cycle/gate"; @@ -43,7 +43,7 @@ const DEFAULT_FORWARD_DAYS = 180; const MAX_SPAN_DAYS = 400; export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read", "cycle"); const gate = await requireCycleEnabled(user.id, user.gender); if (!gate.enabled) return gate.response; diff --git a/src/app/api/cycle/insights/route.ts b/src/app/api/cycle/insights/route.ts index 7a9d5fdfc..ba23e7992 100644 --- a/src/app/api/cycle/insights/route.ts +++ b/src/app/api/cycle/insights/route.ts @@ -15,7 +15,7 @@ * is causal. */ import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess, apiError } from "@/lib/api-response"; import { requireCycleEnabled } from "@/lib/cycle/gate"; @@ -50,7 +50,7 @@ const WINDOW_DAYS = 365; export const dynamic = "force-dynamic"; export const GET = apiHandler(async () => { - const { user } = await requireAuth(); + const { user } = await requireRecordAuth("read", "cycle"); const gate = await requireCycleEnabled(user.id, user.gender); if (!gate.enabled) return gate.response; diff --git a/src/app/api/profile/summary/route.ts b/src/app/api/profile/summary/route.ts new file mode 100644 index 000000000..485d79ff6 --- /dev/null +++ b/src/app/api/profile/summary/route.ts @@ -0,0 +1,26 @@ +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { readProfileSummary } from "@/lib/records/profile-summary"; + +export const dynamic = "force-dynamic"; + +/** + * Read-only shared-record profile summary. The underlying fields are bounded + * and structured so this endpoint never becomes a Settings or AI surface. + */ +export const GET = apiHandler(async () => { + const { user } = await requireRecordAuth("read", "profile"); + const summary = await readProfileSummary(user.id); + + annotate({ + action: { name: "profile.summary.get" }, + meta: { + allergy_count: summary.allergies.length, + family_history_count: summary.familyHistory.length, + fact_count: summary.facts.length, + }, + }); + + return apiSuccess(summary); +}); diff --git a/src/app/coach/__tests__/page-launch-params.test.tsx b/src/app/coach/__tests__/page-launch-params.test.tsx index 13c42e388..3f56c28c8 100644 --- a/src/app/coach/__tests__/page-launch-params.test.tsx +++ b/src/app/coach/__tests__/page-launch-params.test.tsx @@ -44,6 +44,12 @@ vi.mock("@/hooks/use-feature-flags", () => ({ vi.mock("@/hooks/use-disable-coach", () => ({ useDisableCoach: () => false, })); +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ user: {}, isLoading: false }), +})); +vi.mock("@/hooks/use-record-capabilities", () => ({ + useRecordCapabilities: () => ({ inSharedRecord: false }), +})); vi.mock("@tanstack/react-query", () => ({ useQuery: () => ({ data: undefined }), })); diff --git a/src/app/coach/page-client.tsx b/src/app/coach/page-client.tsx index db99c0c42..0b8bb28ce 100644 --- a/src/app/coach/page-client.tsx +++ b/src/app/coach/page-client.tsx @@ -10,6 +10,8 @@ import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context"; import { useCoachLaunch } from "@/lib/insights/coach-launch-context"; import { useFeatureFlags } from "@/hooks/use-feature-flags"; import { useDisableCoach } from "@/hooks/use-disable-coach"; +import { useAuth } from "@/hooks/use-auth"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; import { coachScopeSourceSchema } from "@/lib/ai/coach/types"; @@ -163,6 +165,8 @@ export default function CoachPageClient() { const launch = useCoachLaunch(); const flags = useFeatureFlags(); const disableCoach = useDisableCoach(); + const { user, isLoading } = useAuth(); + const { inSharedRecord } = useRecordCapabilities(); const coachUnavailable = !flags.coach || disableCoach; @@ -170,11 +174,13 @@ export default function CoachPageClient() { // operator master flag OR per-user opt-out hides the Coach entirely. // Send a direct navigator back to the Insights mother page so the // route is never a dead-end. + const canRenderOwnCoach = user !== null && !isLoading && !inSharedRecord; + useEffect(() => { - if (coachUnavailable) { + if (canRenderOwnCoach && coachUnavailable) { router.replace("/insights"); } - }, [coachUnavailable, router]); + }, [canRenderOwnCoach, coachUnavailable, router]); // Reopening the bottom-right FAB drawer is no longer wired to a page // control, but keep the launch context referenced so the lint rule does @@ -182,7 +188,7 @@ export default function CoachPageClient() { // the shared ``. void launch; - if (coachUnavailable) return null; + if (!canRenderOwnCoach || coachUnavailable) return null; return (
{ if (isLoading) return; diff --git a/src/app/illness/[id]/page.tsx b/src/app/illness/[id]/page.tsx index 98e4c174c..c0275553a 100644 --- a/src/app/illness/[id]/page.tsx +++ b/src/app/illness/[id]/page.tsx @@ -14,6 +14,7 @@ import { PageAuthGate } from "@/components/ui/page-auth-gate"; import { IllnessEpisodeDetail } from "@/components/illness/illness-episode-detail"; import { BackLink } from "@/components/ui/back-link"; import { useAuth } from "@/hooks/use-auth"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { useMounted } from "@/hooks/use-mounted"; import { useTranslations } from "@/lib/i18n/context"; @@ -24,11 +25,12 @@ export default function IllnessEpisodePage({ }) { const { id } = use(params); const { user, isAuthenticated, isLoading } = useAuth(); + const { inSharedRecord } = useRecordCapabilities(); const mounted = useMounted(); const router = useRouter(); const { t } = useTranslations(); - const enabled = user?.modules?.illness === true; + const enabled = inSharedRecord || user?.modules?.illness === true; useEffect(() => { if (isLoading) return; diff --git a/src/app/illness/page.tsx b/src/app/illness/page.tsx index 6fef680cc..9511acf3c 100644 --- a/src/app/illness/page.tsx +++ b/src/app/illness/page.tsx @@ -4,6 +4,7 @@ import { useEffect } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/hooks/use-auth"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { PageAuthGate } from "@/components/ui/page-auth-gate"; import { IllnessView } from "@/components/illness/illness-view"; @@ -20,10 +21,11 @@ import { IllnessView } from "@/components/illness/illness-view"; */ export default function IllnessPage() { const { user, isLoading, isAuthenticated } = useAuth(); + const { inSharedRecord } = useRecordCapabilities(); const router = useRouter(); // Born-gated: enabled ONLY when the resolved map says `true`. - const enabled = user?.modules?.illness === true; + const enabled = inSharedRecord || user?.modules?.illness === true; useEffect(() => { if (isLoading) return; diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx index 78adf9925..238e26f80 100644 --- a/src/app/labs/page.tsx +++ b/src/app/labs/page.tsx @@ -17,7 +17,10 @@ import { import { LabForm } from "@/components/labs/lab-form"; import { LabList } from "@/components/labs/lab-list"; import { OcrReviewDialog } from "@/components/labs/ocr-review-dialog"; -import { useOcrCapability } from "@/components/labs/use-ocr-extract"; +import { + shouldProbeOcrCapability, + useOcrCapability, +} from "@/components/labs/use-ocr-extract"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -41,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 } = useRecordCapabilities(); + const { canAdd, canManage, inSharedRecord } = useRecordCapabilities(); const mounted = useMounted(); const router = useRouter(); const queryClient = useQueryClient(); @@ -53,14 +56,22 @@ export default function LabsPage() { // v1.18.9 — Lab-OCR "Scan a report" dialog. The scan affordance only shows // when the user's configured AI provider can read images (capability probe). const [scanOpen, setScanOpen] = useState(false); - const ocrCapability = useOcrCapability(isAuthenticated); // Gated on the resolved `modules.labs` flag from `GET /api/auth/me` (the // per-user toggle AND the operator server-wide kill-switch). Default-on: an // absent key reads as enabled, so a direct URL hit only bounces on an // explicit `false`. Every `/api/labs/*` route also enforces the gate // server-side, so this is a UX redirect, not the security boundary. - const enabled = user?.modules?.labs !== false; + const enabled = inSharedRecord || user?.modules?.labs !== false; + const ocrCapability = useOcrCapability( + shouldProbeOcrCapability({ + isAuthenticated, + isLoading, + labsEnabled: enabled, + mounted, + canManage, + }), + ); const refreshVisible = useCallback( () => queryClient.invalidateQueries({ type: "active" }), diff --git a/src/app/medications/page-client.tsx b/src/app/medications/page-client.tsx index 5799fde99..d7dc51dd4 100644 --- a/src/app/medications/page-client.tsx +++ b/src/app/medications/page-client.tsx @@ -168,13 +168,14 @@ 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 } = useRecordCapabilities(); + const { canAdd, canManage, 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). // Default-on: an absent key reads as enabled, so the page only hides on an // explicit `false`. - const medicationsEnabled = user?.modules?.medications !== false; + const medicationsEnabled = + inSharedRecord || user?.modules?.medications !== false; const router = useRouter(); const queryClient = useQueryClient(); const searchParams = useSearchParams(); diff --git a/src/app/mental-wellbeing/page.tsx b/src/app/mental-wellbeing/page.tsx index 970860c68..c5ccc99f2 100644 --- a/src/app/mental-wellbeing/page.tsx +++ b/src/app/mental-wellbeing/page.tsx @@ -4,6 +4,7 @@ import { useEffect } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/hooks/use-auth"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { PageAuthGate } from "@/components/ui/page-auth-gate"; import { MentalWellbeing } from "@/components/mental-health/mental-wellbeing"; @@ -26,9 +27,10 @@ import { MentalWellbeing } from "@/components/mental-health/mental-wellbeing"; */ export default function MentalWellbeingPage() { const { user, isLoading, isAuthenticated } = useAuth(); + const { inSharedRecord } = useRecordCapabilities(); const router = useRouter(); - const enabled = user?.modules?.mentalHealth === true; + const enabled = inSharedRecord || user?.modules?.mentalHealth === true; useEffect(() => { if (isLoading) return; diff --git a/src/app/mood/page.tsx b/src/app/mood/page.tsx index 04e02a1c0..8668a82f7 100644 --- a/src/app/mood/page.tsx +++ b/src/app/mood/page.tsx @@ -21,7 +21,7 @@ import { useTranslations } from "@/lib/i18n/context"; export default function MoodPage() { const { user, isAuthenticated, isLoading } = useAuth(); - const { canManage } = useRecordCapabilities(); + const { canManage, inSharedRecord } = useRecordCapabilities(); const mounted = useMounted(); const router = useRouter(); const queryClient = useQueryClient(); @@ -37,7 +37,7 @@ export default function MoodPage() { // absent key reads as enabled, so a direct URL hit only bounces on an // explicit `false`. Every `/api/mood-entries/*` route also enforces the gate // server-side, so this is a UX redirect, not the security boundary. - const enabled = user?.modules?.mood !== false; + const enabled = inSharedRecord || user?.modules?.mood !== false; // v1.16.4 — PWA pull-to-refresh: a top-anchored touch pull refetches // whatever this page currently has mounted (`type: "active"` scopes the diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx new file mode 100644 index 000000000..c0132cbe7 --- /dev/null +++ b/src/app/profile/page.tsx @@ -0,0 +1,5 @@ +import { ProfileSummaryPage } from "@/components/records/profile-summary"; + +export default function ProfilePage() { + return ; +} diff --git a/src/components/__tests__/providers-record-preloader.test.ts b/src/components/__tests__/providers-record-preloader.test.ts new file mode 100644 index 000000000..f33ab9434 --- /dev/null +++ b/src/components/__tests__/providers-record-preloader.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { isRecordPreloadReady } from "@/components/providers"; +import { + __resetRecordScopeForTests, + setRecordScope, + setRefusedRecordScope, +} from "@/lib/query-keys/record-scope"; + +describe("isRecordPreloadReady", () => { + it("waits for the account response before warming record reads", () => { + __resetRecordScopeForTests(); + + expect( + isRecordPreloadReady({ isLoading: true, accountAccessStatus: "valid" }), + ).toBe(false); + expect( + isRecordPreloadReady({ + isLoading: false, + accountAccessStatus: undefined, + }), + ).toBe(false); + }); + + it("accepts only a resolved own-record response with an own-record scope", () => { + __resetRecordScopeForTests(); + setRecordScope(null); + + expect( + isRecordPreloadReady({ + isLoading: false, + accountAccessStatus: "valid", + activeAccountId: null, + }), + ).toBe(true); + + setRecordScope("shared-record"); + expect( + isRecordPreloadReady({ + isLoading: false, + accountAccessStatus: "valid", + activeAccountId: null, + }), + ).toBe(false); + }); + + it("accepts a shared-record response only after its exact scope is mirrored", () => { + __resetRecordScopeForTests(); + setRecordScope("shared-record"); + + expect( + isRecordPreloadReady({ + isLoading: false, + accountAccessStatus: "valid", + activeAccountId: "shared-record", + }), + ).toBe(true); + + setRecordScope(null); + expect( + isRecordPreloadReady({ + isLoading: false, + accountAccessStatus: "valid", + activeAccountId: "shared-record", + }), + ).toBe(false); + }); + + it("never opens a preload window for an explicitly refused record", () => { + __resetRecordScopeForTests(); + setRefusedRecordScope(); + + expect( + isRecordPreloadReady({ + isLoading: false, + accountAccessStatus: "invalid", + activeAccountId: null, + }), + ).toBe(false); + }); +}); diff --git a/src/components/dashboard/__tests__/looking-after-card.test.tsx b/src/components/dashboard/__tests__/looking-after-card.test.tsx index 9cfe7e1f2..d67408a89 100644 --- a/src/components/dashboard/__tests__/looking-after-card.test.tsx +++ b/src/components/dashboard/__tests__/looking-after-card.test.tsx @@ -40,6 +40,16 @@ const FATHER = { recordKind: "shared" as const, canWrite: true, }; +const MANAGED_PROFILE = { + accountId: "managed-profile", + username: "managed-profile", + displayName: "Managed profile", + access: "write" as const, + level: "manage" as const, + sections: null, + recordKind: "managed" as const, + canWrite: true, +}; const mockAccessRef: { value: AccountAccess } = { value: { accounts: [], active: null, canSwitch: false }, @@ -130,6 +140,31 @@ describe("", () => { expect(html).not.toContain("Can view and add"); }); + it("keeps MANAGE and managed profile labels separate", () => { + const html = render({ + accounts: [MANAGED_PROFILE], + active: null, + canSwitch: true, + }); + + expect(html).toContain('data-access-level="manage"'); + expect(html).toContain('data-record-kind="managed"'); + expect(html).toContain("Can read the record, add to it, and change"); + expect(html).toContain("Managed profile"); + }); + + it("names the record button with its action, access, and kind", () => { + const html = render({ + accounts: [MANAGED_PROFILE], + active: null, + canSwitch: true, + }); + + expect(html).toMatch( + /aria-label="Open Managed profile's record; Can read the record, add to it, and change or remove what is in it; Managed profile;/, + ); + }); + it("shows no health data, only a doorway", () => { const html = render({ accounts: [GRANDMOTHER], diff --git a/src/components/dashboard/looking-after-card.tsx b/src/components/dashboard/looking-after-card.tsx index c95d6f94b..cccb7f4d4 100644 --- a/src/components/dashboard/looking-after-card.tsx +++ b/src/components/dashboard/looking-after-card.tsx @@ -7,6 +7,11 @@ import { TileHeader } from "@/components/insights/tile-header"; import { useAccountSwitch } from "@/hooks/use-account-switch"; import { useAccountOnceMounted } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; +import { + resolveRecordPresentation, + type RecordPresentation, +} from "@/lib/navigation/record-presentation"; +import { sharedRecordLandingHref } from "@/lib/navigation/shared-record"; import { accountLabel, type AccountAccessEntry, @@ -77,42 +82,61 @@ export function LookingAfterCard() { {t("recordSharing.lookingAfter.description")}

    - {accounts.map((entry) => ( -
  • - -
  • - ))} + {switchAccount.isPending && ( +
@@ -132,12 +156,13 @@ export function LookingAfterCard() { */ function levelLabel( entry: AccountAccessEntry, + presentation: RecordPresentation, t: (key: string) => string, ): string { if (entry.level === "manage") { return t("recordSharing.row.canManage"); } - return entry.canWrite + return presentation.access === "view-and-add" ? t("recordSharing.lookingAfter.levelWrite") : t("recordSharing.lookingAfter.levelRead"); } diff --git a/src/components/documents/documents-view.tsx b/src/components/documents/documents-view.tsx index ca4aa87e0..ac220790c 100644 --- a/src/components/documents/documents-view.tsx +++ b/src/components/documents/documents-view.tsx @@ -184,14 +184,15 @@ export function DocumentsView() { // 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 } = useRecordCapabilities(); + const { canManage, inSharedRecord, sections } = useRecordCapabilities(); const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); const queryClient = useQueryClient(); const coachLaunch = useCoachLaunch(); - const moduleEnabled = user?.modules?.inboundDocuments === true; + const moduleEnabled = + inSharedRecord || user?.modules?.inboundDocuments === true; // UX redirect only — the API routes enforce the gate server-side. useEffect(() => { @@ -377,7 +378,10 @@ export function DocumentsView() { ); const { dropActive } = usePageFileDrop(canManage ? enqueueFiles : undefined); - const episodes = useIllnessEpisodes(true); + const episodes = useIllnessEpisodes( + true, + !inSharedRecord || sections === null || sections.includes("illness"), + ); // Content-search coverage: fire the corpus backfill when indexing is // available and some documents are not yet searchable. The list search diff --git a/src/components/illness/use-illness.ts b/src/components/illness/use-illness.ts index 70be6ef08..4bd5dd642 100644 --- a/src/components/illness/use-illness.ts +++ b/src/components/illness/use-illness.ts @@ -25,9 +25,10 @@ import type { } from "./types"; /** Newest-first episode list. */ -export function useIllnessEpisodes(includeResolved = true) { +export function useIllnessEpisodes(includeResolved = true, enabled = true) { return useQuery({ queryKey: queryKeys.illnessEpisodes(includeResolved), + enabled, queryFn: () => apiGet( `/api/illness/episodes?includeResolved=${includeResolved}`, diff --git a/src/components/labs/__tests__/ocr-capability-gate.test.ts b/src/components/labs/__tests__/ocr-capability-gate.test.ts new file mode 100644 index 000000000..4e3be2b55 --- /dev/null +++ b/src/components/labs/__tests__/ocr-capability-gate.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +import { shouldProbeOcrCapability } from "../use-ocr-extract"; + +describe("shouldProbeOcrCapability", () => { + it("runs the owner-only capability probe only when the scan control can render", () => { + const ownerControl = { + isAuthenticated: true, + isLoading: false, + labsEnabled: true, + mounted: true, + canManage: true, + }; + + expect(shouldProbeOcrCapability(ownerControl)).toBe(true); + expect( + shouldProbeOcrCapability({ ...ownerControl, isAuthenticated: false }), + ).toBe(false); + expect( + shouldProbeOcrCapability({ ...ownerControl, canManage: false }), + ).toBe(false); + expect( + shouldProbeOcrCapability({ ...ownerControl, labsEnabled: false }), + ).toBe(false); + expect(shouldProbeOcrCapability({ ...ownerControl, mounted: false })).toBe( + false, + ); + }); +}); diff --git a/src/components/labs/use-ocr-extract.ts b/src/components/labs/use-ocr-extract.ts index 8f05ebcb1..7f2bd30c7 100644 --- a/src/components/labs/use-ocr-extract.ts +++ b/src/components/labs/use-ocr-extract.ts @@ -40,6 +40,23 @@ export interface OcrCommitResult extends OcrCommitResponseDto { inserted: LabResultDto[]; } +/** The capability route resolves the caller's own provider configuration. */ +export function shouldProbeOcrCapability({ + isAuthenticated, + isLoading, + labsEnabled, + mounted, + canManage, +}: { + isAuthenticated: boolean; + isLoading: boolean; + labsEnabled: boolean; + mounted: boolean; + canManage: boolean; +}): boolean { + return isAuthenticated && !isLoading && labsEnabled && mounted && canManage; +} + /** Capability probe — refetched when the scan dialog opens. */ export function useOcrCapability(enabled: boolean) { return useQuery({ diff --git a/src/components/layout/__tests__/auth-shell-shared-record-doors.test.tsx b/src/components/layout/__tests__/auth-shell-shared-record-doors.test.tsx index 93d4c7d01..44cee5e3d 100644 --- a/src/components/layout/__tests__/auth-shell-shared-record-doors.test.tsx +++ b/src/components/layout/__tests__/auth-shell-shared-record-doors.test.tsx @@ -51,6 +51,13 @@ import type { const mockAccessRef: { value: AccountAccess } = { value: { accounts: [], active: null, canSwitch: false }, }; +const mockAuthLoadingRef = { value: false }; +const mockAccountAccessStatusRef = { + value: "valid" as "absent" | "valid" | "invalid", +}; +const mockRecordSessionPhaseRef = { + value: "ready" as "ready" | "blocking" | "resolving", +}; vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ @@ -62,15 +69,24 @@ vi.mock("@/hooks/use-auth", () => ({ avatarUrl: null, modules: {}, accountAccess: mockAccessRef.value, + accountAccessStatus: mockAccountAccessStatusRef.value, }, isAuthenticated: true, isAuthUnknown: false, - isLoading: false, + isLoading: mockAuthLoadingRef.value, refetch: vi.fn(), }), clearCachesForSessionEnd: vi.fn(), })); +vi.mock("@/hooks/use-record-session-transition", () => ({ + useRecordSessionTransition: () => ({ + id: "cross-tab-switch", + phase: mockRecordSessionPhaseRef.value, + expectedScope: "record-a", + }), +})); + const mockPathRef = { value: "/" }; vi.mock("next/navigation", () => ({ @@ -137,9 +153,14 @@ const MANAGED_GUARDIAN: AccountAccessEntry = { canWrite: true, }; -function render(access: AccountAccess, pathname = "/"): string { +function render( + access: AccountAccess, + pathname = "/", + accountAccessStatus: "absent" | "valid" | "invalid" = "valid", +): string { mockAccessRef.value = access; mockPathRef.value = pathname; + mockAccountAccessStatusRef.value = accountAccessStatus; return renderToStaticMarkup( @@ -169,6 +190,46 @@ const DOORS = [ ] as const; describe(" — the owner-only doors", () => { + it("holds protected children until record capabilities resolve", () => { + mockAuthLoadingRef.value = true; + try { + const html = render(OWN_RECORD, "/medications"); + + expect(html).toContain('data-slot="record-scope-hydration-gate"'); + expect(html).not.toContain("sentinel-page"); + expect(html).not.toContain("sentinel-coach-fab"); + } finally { + mockAuthLoadingRef.value = false; + } + }); + + it("holds protected children and owner-only doors while a peer tab changes the session", () => { + mockRecordSessionPhaseRef.value = "blocking"; + try { + const html = render(OWN_RECORD, "/labs"); + + expect(html).toContain('data-slot="record-scope-hydration-gate"'); + expect(html).not.toContain("sentinel-page"); + for (const slot of DOORS) { + expect(html).not.toContain(slot); + } + } finally { + mockRecordSessionPhaseRef.value = "ready"; + } + }); + + it("does not delay public routes for record capability resolution", () => { + mockAuthLoadingRef.value = true; + try { + const html = render(OWN_RECORD, "/auth/login"); + + expect(html).toContain("sentinel-page"); + expect(html).not.toContain('data-slot="record-scope-hydration-gate"'); + } finally { + mockAuthLoadingRef.value = false; + } + }); + it("mounts all three in the caller's own record", () => { // The half that makes the rest mean something. Without it, a shell that // had simply dropped the Coach and the tour for everybody would satisfy @@ -179,6 +240,18 @@ describe(" — the owner-only doors", () => { } }); + it("refuses an invalid record block without mounting owner-only doors or children", () => { + const html = render(OWN_RECORD, "/", "invalid"); + + expect(html).toContain("sentinel-unavailable"); + expect(html).not.toContain("sentinel-page"); + for (const slot of DOORS) { + expect(html, `${slot} must not paint for a refused record`).not.toContain( + slot, + ); + } + }); + it.each([ ["read", OWNER_READ], ["write", OWNER_WRITE], diff --git a/src/components/layout/__tests__/nav-model-shared-record.test.ts b/src/components/layout/__tests__/nav-model-shared-record.test.ts index 36061a96c..431106898 100644 --- a/src/components/layout/__tests__/nav-model-shared-record.test.ts +++ b/src/components/layout/__tests__/nav-model-shared-record.test.ts @@ -78,7 +78,14 @@ describe("the destination list under a switch", () => { it("changes nothing for a session in its own record", () => { const own = visibleNavDestinations(ALL_MODULES, true).map((d) => d.href); - expect(own).toEqual(NAV_DESTINATIONS.map((d) => d.href)); + expect(own).toEqual( + NAV_DESTINATIONS.filter((d) => !d.sharedRecordOnly).map((d) => d.href), + ); + expect(own).not.toContain("/profile"); + }); + + it("offers the read-only profile summary only inside a shared record", () => { + expect(hrefsInSharedRecord()).toContain("/profile"); }); }); diff --git a/src/components/layout/__tests__/shared-record-chrome.test.tsx b/src/components/layout/__tests__/shared-record-chrome.test.tsx index a6c8a9651..53a2049f2 100644 --- a/src/components/layout/__tests__/shared-record-chrome.test.tsx +++ b/src/components/layout/__tests__/shared-record-chrome.test.tsx @@ -134,6 +134,31 @@ describe("", () => { expect(html).not.toContain("read it, not change it"); }); + it("names MANAGE and a managed profile without reducing either to add access", () => { + const managedProfile = { + ...OWNER, + accountId: "managed-profile", + access: "write" as const, + level: "manage" as const, + recordKind: "managed" as const, + canWrite: true, + }; + const html = render( + { + accounts: [managedProfile], + active: managedProfile, + canSwitch: true, + }, + , + ); + + expect(html).toContain('data-access-level="manage"'); + expect(html).toContain('data-record-kind="managed"'); + expect(html).toContain("Managed profile"); + expect(html).toContain("change or remove what is in it"); + expect(html).not.toContain("add to it, but not change"); + }); + it("uses the warning register, not a subtle tint", () => { // Somebody who stops noticing the banner logs their own reading into // another person's record. The visual register is part of the contract. @@ -144,6 +169,19 @@ describe("", () => { expect(html).toContain("bg-warning/15"); expect(html).toContain("border-warning/40"); }); + + it("keeps the warning-banner context copy at the foreground contrast floor", () => { + const html = render( + { accounts: [OWNER], active: OWNER, canSwitch: true }, + , + ); + const context = html.match( + /]*data-slot="shared-record-banner-context"[^>]*>/, + )?.[0]; + + expect(context).toContain("text-foreground"); + expect(context).not.toContain("text-muted-foreground"); + }); }); describe("", () => { diff --git a/src/components/layout/account-switcher-menu.tsx b/src/components/layout/account-switcher-menu.tsx index e132d385c..8fac8a3b6 100644 --- a/src/components/layout/account-switcher-menu.tsx +++ b/src/components/layout/account-switcher-menu.tsx @@ -12,6 +12,8 @@ import { import { useAccountSwitch } from "@/hooks/use-account-switch"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; +import { resolveRecordPresentation } from "@/lib/navigation/record-presentation"; +import { sharedRecordLandingHref } from "@/lib/navigation/shared-record"; import { accountLabel } from "@/lib/sharing/account-access-view"; /** @@ -64,7 +66,7 @@ export function AccountSwitcherMenuItems() { ✓ )} - {access.accounts.map((account) => ( - switchAccount.mutate(account.accountId)} - > -
- {accountLabel(account)} - {/* Meta, so muted: what the grant lets this person do, in the - words the server resolved rather than a level code. */} - - {account.canWrite - ? t("recordSharing.switcher.canWrite") - : t("recordSharing.switcher.readOnly")} - -
- {activeId === account.accountId && ( - - )} -
- ))} + {access.accounts.map((account) => { + const presentation = resolveRecordPresentation(account); + + return ( + + switchAccount.switchTo( + account.accountId, + sharedRecordLandingHref(account.sections), + ) + } + > +
+ {accountLabel(account)} + + {accessLabel(presentation.access, t)} + + + {recordKindLabel(presentation.recordKind, t)} + +
+ {activeId === account.accountId && ( + + )} +
+ ); + })}
); } + +function accessLabel( + access: ReturnType["access"], + t: ReturnType["t"], +): string { + if (access === "manage") return t("recordSharing.row.canManage"); + return access === "view-and-add" + ? t("recordSharing.switcher.canWrite") + : t("recordSharing.switcher.readOnly"); +} + +function recordKindLabel( + recordKind: ReturnType["recordKind"], + t: ReturnType["t"], +): string { + return recordKind === "managed" + ? t("recordSharing.lookingAfter.kindManaged") + : t("recordSharing.lookingAfter.kindShared"); +} diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx index ae67180d7..968e7733c 100644 --- a/src/components/layout/auth-shell.tsx +++ b/src/components/layout/auth-shell.tsx @@ -11,6 +11,7 @@ import { LayoutCoachMount } from "@/components/insights/layout-coach-mount"; import { TourLauncher } from "@/components/onboarding/tour-launcher"; import { clearCachesForSessionEnd, useAuth } from "@/hooks/use-auth"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; +import { useRecordSessionTransition } from "@/hooks/use-record-session-transition"; import { useTranslations } from "@/lib/i18n/context"; import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context"; import { isDestinationInSharedRecord } from "./nav-model"; @@ -19,6 +20,7 @@ import { DemoBanner } from "./demo-banner"; import { OfflineBanner } from "./offline-banner"; import { SharedRecordBanner } from "./shared-record-banner"; import { SharedRecordUnavailable } from "./shared-record-unavailable"; +import { RecordScopeHydrationGate } from "./record-scope-hydration-gate"; import { SidebarNav } from "./sidebar-nav"; import { TopBar } from "./top-bar"; @@ -57,6 +59,7 @@ export function AuthShell({ const pathname = usePathname(); const router = useRouter(); const queryClient = useQueryClient(); + const recordSessionTransition = useRecordSessionTransition(); const isPublicPage = PUBLIC_PATHS.some((p) => pathname.startsWith(p)); // v1.4.26 — `/privacy` is a long-form legal page that brings its own @@ -78,7 +81,13 @@ export function AuthShell({ // decides what to mount and the surfaces beneath it decide what to offer, // and the two disagreeing is the whole class of defect this hook exists to // close. - const { inSharedRecord, recordKind } = useRecordCapabilities(); + const { + inSharedRecord, + recordKind, + sections, + accessRefused, + recordSessionPending, + } = useRecordCapabilities(); // A managed profile has a deliberately small Settings surface of its own. // Let that route family reach its record-aware gate; adult shared records // stay in the generic refusal branch even at MANAGE. @@ -91,7 +100,7 @@ export function AuthShell({ // — every route behind these pages refuses on its own. const outsideSharedRecord = inSharedRecord && - !isDestinationInSharedRecord(pathname) && + !isDestinationInSharedRecord(pathname, sections) && !isManagedRecordSettingsPath; const isOnboardingPage = pathname === "/onboarding"; const showUnlockNotifier = isAuthenticated && !isPublicPage && !!user?.id; @@ -225,17 +234,16 @@ export function AuthShell({ ); } - // Admin pages stay behind the auth gate while `/api/auth/me` is in - // flight: the role is unknown until the payload lands, and mounting - // `/admin/*` children early would fire admin queries that 403 for a - // non-admin before the redirect effect can move them away. - if (isLoading && (isAdminPage || pathname.startsWith("/settings"))) { - return ( -
- - {t("nav.loadingScreen")} -
- ); + // Every protected route waits for `/api/auth/me`, not only Settings and + // Admin. The payload resolves the active record as well as identity, so a + // child mounted before it could issue an actor-scoped read before the shell + // knows that a switched record must be refused or scoped differently. + if ( + isLoading || + recordSessionTransition.phase !== "ready" || + recordSessionPending + ) { + return ; } // Auth RESOLVED as unauthenticated — hold a spinner while the @@ -249,15 +257,22 @@ export function AuthShell({ ); } - // While `/api/auth/me` is still in flight the shell renders the full - // app chrome + children immediately (the chrome components own their - // null-user skeletons, pages own their data skeletons). This takes the - // auth round-trip off the first-paint critical path: page-level - // queries fire in parallel with `/api/auth/me` instead of behind it. - // `src/proxy.ts` has already refused cookie-less requests to - // protected routes, so the unauthenticated-flash window is limited to - // expired/invalid sessions — those resolve into the redirect branch - // above as soon as the 401 lands. + // A present but malformed access block is not the older-server case. The + // server may still be switched, so mounting children here could fetch target + // data under owner controls. Offer only the actor-scoped way out. + if (accessRefused) { + return ( +
+ +
+ +
+
+ ); + } // Onboarding page — minimal shell, no sidebar/nav if (isOnboardingPage) { diff --git a/src/components/layout/bottom-nav.tsx b/src/components/layout/bottom-nav.tsx index 384bab972..de0e12093 100644 --- a/src/components/layout/bottom-nav.tsx +++ b/src/components/layout/bottom-nav.tsx @@ -97,8 +97,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 sharedRecord = user?.accountAccess?.active != null; - const { canAdd, canManage } = useRecordCapabilities(); + const { + inSharedRecord: sharedRecord, + sections, + canAdd, + canManage, + } = useRecordCapabilities(); const canCapture = canAdd || canManage; // v1.17.1 (F-1) — the More hub is the model-computed hub: every visible @@ -111,8 +115,9 @@ export function BottomNav() { modules: user?.modules, mounted, sharedRecord, + sections, }), - [user?.modules, mounted, sharedRecord], + [user?.modules, mounted, sharedRecord, sections], ); // v1.18.0 — drop a module-gated primary slot (Insights) when the account @@ -127,11 +132,11 @@ export function BottomNav() { // v1.36.0 — the fixed slot asks the shared destination model // whether sharing covers it, rather than carrying its own answer: // Insights is an AI surface and drops out under a switch. - (!sharedRecord || isDestinationInSharedRecord(item.href)) && + (!sharedRecord || isDestinationInSharedRecord(item.href, sections)) && (!item.requiresModule || (mounted && user?.modules?.[item.requiresModule] !== false)), ), - [user?.modules, mounted, sharedRecord], + [user?.modules, mounted, sharedRecord, sections], ); function isActiveLink(href: string) { diff --git a/src/components/layout/nav-model.ts b/src/components/layout/nav-model.ts index f8e8ab0b7..4ab0b709a 100644 --- a/src/components/layout/nav-model.ts +++ b/src/components/layout/nav-model.ts @@ -2,6 +2,7 @@ import { Activity, Bell, Brain, + ClipboardList, Droplets, FileScan, FlaskConical, @@ -18,6 +19,8 @@ import { } from "lucide-react"; import type { ModuleKey } from "@/lib/modules/registry"; +import { isSharedRecordPathPresentable } from "@/lib/navigation/shared-record"; +import type { ShareDomain } from "@/lib/sharing/scope"; /** * v1.17.1 — the single navigation information-model. @@ -75,6 +78,8 @@ export interface NavDestination { * owner gave for their own use, so those surfaces stay owner-only in v1. */ sharedRecord?: boolean; + /** A record-only view, intentionally absent from the actor's own nav. */ + sharedRecordOnly?: boolean; } /** @@ -168,6 +173,14 @@ export const NAV_DESTINATIONS: ReadonlyArray = [ tourId: "nav-labs", requiresModule: "labs", }, + { + href: "/profile", + sharedRecord: true, + sharedRecordOnly: true, + tKey: "nav.profile", + icon: ClipboardList, + tourId: "nav-profile", + }, // v1.18.1 — the illness/condition journal sits in the clinical spine // next to Labs. Born-gated: `requiresModule: "illness"` reads the // opt-in `illness` key from the resolved module map, so the entry is @@ -305,8 +318,20 @@ function isNavDestinationVisible( modules: ModuleVisibilityMap | undefined, mounted = true, sharedRecord = false, + sections: readonly ShareDomain[] | null = null, ): boolean { - if (sharedRecord && d.sharedRecord !== true) return false; + if ( + sharedRecord && + (d.sharedRecord !== true || + !isSharedRecordPathPresentable(d.href, sections)) + ) { + return false; + } + // A shared record's server-resolved scope decides which health-domain doors + // exist. The actor's module preferences describe their own dashboard and + // must not hide a domain the target record explicitly granted. + if (sharedRecord) return true; + if (d.sharedRecordOnly) return false; if (!d.requiresModule) return true; if (!mounted) return false; return modules?.[d.requiresModule] !== false; @@ -325,12 +350,11 @@ function isNavDestinationVisible( * `false`: a surface nobody has classified is not one to open inside somebody * else's record. */ -export function isDestinationInSharedRecord(pathname: string): boolean { - return NAV_DESTINATIONS.some((d) => { - if (!d.sharedRecord) return false; - if (d.href === "/") return pathname === "/"; - return pathname === d.href || pathname.startsWith(`${d.href}/`); - }); +export function isDestinationInSharedRecord( + pathname: string, + sections: readonly ShareDomain[] | null = null, +): boolean { + return isSharedRecordPathPresentable(pathname, sections); } /** @@ -348,9 +372,10 @@ export function visibleNavDestinations( modules: ModuleVisibilityMap | undefined, mounted = true, sharedRecord = false, + sections: readonly ShareDomain[] | null = null, ): NavDestination[] { return NAV_DESTINATIONS.filter((d) => - isNavDestinationVisible(d, modules, mounted, sharedRecord), + isNavDestinationVisible(d, modules, mounted, sharedRecord, sections), ); } @@ -389,11 +414,14 @@ export function mobileMoreHubDestinations(opts: { mounted?: boolean; /** v1.36.0 — acting on somebody else's record. Defaults to own. */ sharedRecord?: boolean; + /** Server-resolved scope for the active shared record. */ + sections?: readonly ShareDomain[] | null; }): MobileMoreHubEntry[] { return visibleNavDestinations( opts.modules, opts.mounted ?? true, opts.sharedRecord ?? false, + opts.sections ?? null, ) .filter((d) => !BOTTOM_NAV_PRIMARY_SLOT_HREFS.includes(d.href)) .map((d) => ({ href: d.href, tKey: d.tKey, icon: d.icon })); diff --git a/src/components/layout/record-scope-hydration-gate.tsx b/src/components/layout/record-scope-hydration-gate.tsx new file mode 100644 index 000000000..d4595c9d0 --- /dev/null +++ b/src/components/layout/record-scope-hydration-gate.tsx @@ -0,0 +1,19 @@ +import { Loader2 } from "lucide-react"; + +/** + * Holds protected route trees until `/api/auth/me` has resolved the active + * record and its capabilities. Mounting a child before this point can issue a + * request for the actor's record before the shared-record scope is known. + */ +export function RecordScopeHydrationGate({ label }: { label: string }) { + return ( +
+ + {label} +
+ ); +} diff --git a/src/components/layout/shared-record-banner.tsx b/src/components/layout/shared-record-banner.tsx index 63651d3ef..9d30ce719 100644 --- a/src/components/layout/shared-record-banner.tsx +++ b/src/components/layout/shared-record-banner.tsx @@ -5,6 +5,7 @@ import { Eye, Loader2, LogOut } from "lucide-react"; import { useAccountSwitch } from "@/hooks/use-account-switch"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; +import { resolveRecordPresentation } from "@/lib/navigation/record-presentation"; import { accountLabel } from "@/lib/sharing/account-access-view"; /** @@ -14,7 +15,7 @@ import { accountLabel } from "@/lib/sharing/account-access-view"; * are switched will log their own blood pressure into somebody else's record, * and there is no undo for a reading that was never theirs. So the banner * states three things in one line — that this is not your record, whose it is, - * and that you can only read it — and carries the way out beside them. + * and what sharing level applies — and carries the way out beside them. * * ## Why it looks like this * @@ -28,9 +29,9 @@ import { accountLabel } from "@/lib/sharing/account-access-view"; * somebody stops seeing after the third switch, and the failure mode here is * a person who has stopped noticing. * - * The name is `text-foreground` and the qualifier `text-muted-foreground`, per - * the two-tier text rule — the person's name is content, the read-only note is - * meta. + * The name and qualifier both use `text-foreground`: the warning wash is too + * dark for the muted token at this text size, while the foreground token keeps + * the context legible at the same contrast floor as the name. * * The row is the inline-action shape from UI-STANDARDS §11 rather than a * centred wrapping stack: sentence `min-w-0 flex-1`, action `shrink-0`, so the @@ -59,12 +60,15 @@ export function SharedRecordBanner() { if (!active) return null; const name = accountLabel(active); + const presentation = resolveRecordPresentation(active); return (
{t("recordSharing.banner.viewing", { name })} {" "} - {/* Resolved server-side, and it says something at BOTH levels now. - v1.36.1 is the release that made `canWrite` true, and until this - line changed the write case rendered nothing at all: a delegate who - could add to somebody's health record was told only whose record it - was. That matters more than the read case, not less, and the rest of - the product leans on it — a control the record refuses is absent - rather than disabled, on the stated grounds that the banner names - the mode globally. It has to actually do that. */} - - {active.canWrite - ? t("recordSharing.banner.canAdd") - : t("recordSharing.banner.readOnly")} + + {recordKindLabel(presentation.recordKind, t)}.{" "} + {accessLabel(presentation.access, t)}