mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge commit '38214e472' into work/v137-record-session-fence
This commit is contained in:
commit
a08e82d0be
81 files changed
+3505
-386
No files matched your search
+2
-4
@@ -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/
|
||||
/.worktrees/
|
||||
+234
-1
@@ -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<string> {
|
||||
return hash(password, {
|
||||
memoryCost: 19456,
|
||||
@@ -201,6 +266,163 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
],
|
||||
);
|
||||
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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";
|
||||
@@ -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<void>((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<typeof deferred>[] = [];
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -341,6 +341,7 @@
|
||||
},
|
||||
"cycle": "Zyklus",
|
||||
"labs": "Laborwerte",
|
||||
"profile": "Profil",
|
||||
"vorsorge": "Vorsorge",
|
||||
"illness": "Krankheit",
|
||||
"documents": "Dokumente",
|
||||
|
||||
@@ -341,6 +341,7 @@
|
||||
},
|
||||
"cycle": "Cycle",
|
||||
"labs": "Labs",
|
||||
"profile": "Profile",
|
||||
"vorsorge": "Checkups",
|
||||
"illness": "Illness",
|
||||
"documents": "Documents",
|
||||
|
||||
@@ -341,6 +341,7 @@
|
||||
},
|
||||
"cycle": "Ciclo",
|
||||
"labs": "Análisis",
|
||||
"profile": "Perfil",
|
||||
"vorsorge": "Revisiones",
|
||||
"illness": "Enfermedad",
|
||||
"documents": "Documentos",
|
||||
|
||||
@@ -341,6 +341,7 @@
|
||||
},
|
||||
"cycle": "Cycle",
|
||||
"labs": "Analyses",
|
||||
"profile": "Profil",
|
||||
"vorsorge": "Examens",
|
||||
"illness": "Maladie",
|
||||
"documents": "Documents",
|
||||
|
||||
@@ -341,6 +341,7 @@
|
||||
},
|
||||
"cycle": "Ciclo",
|
||||
"labs": "Esami",
|
||||
"profile": "Profilo",
|
||||
"vorsorge": "Controlli",
|
||||
"illness": "Malattia",
|
||||
"documents": "Documenti",
|
||||
|
||||
@@ -341,6 +341,7 @@
|
||||
},
|
||||
"cycle": "Cykl",
|
||||
"labs": "Wyniki badań",
|
||||
"profile": "Profil",
|
||||
"vorsorge": "Badania",
|
||||
"illness": "Choroba",
|
||||
"documents": "Dokumenty",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
<I18nProvider initialLocale="en">
|
||||
<SettingsShell>
|
||||
<div>managed settings content</div>
|
||||
</SettingsShell>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
<I18nProvider initialLocale="en">
|
||||
<SharedRecordBanner />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-account-id="managed-record"');
|
||||
expect(html).toContain('data-slot="shared-record-banner-exit"');
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -879,6 +879,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
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<string, DelegableEntry> = {
|
||||
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<string, string> = {
|
||||
* 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` —
|
||||
|
||||
@@ -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/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 }),
|
||||
}));
|
||||
|
||||
@@ -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 `<AuthShell>`.
|
||||
void launch;
|
||||
|
||||
if (coachUnavailable) return null;
|
||||
if (!canRenderOwnCoach || coachUnavailable) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Suspense, 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 { CycleView } from "@/components/cycle/cycle-view";
|
||||
|
||||
@@ -22,9 +23,10 @@ import { CycleView } from "@/components/cycle/cycle-view";
|
||||
*/
|
||||
export default function CyclePage() {
|
||||
const { user, isLoading, isAuthenticated } = useAuth();
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
const router = useRouter();
|
||||
|
||||
const enabled = user?.modules?.cycle !== false;
|
||||
const enabled = inSharedRecord || user?.modules?.cycle !== false;
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+15
-4
@@ -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" }),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ProfileSummaryPage } from "@/components/records/profile-summary";
|
||||
|
||||
export default function ProfilePage() {
|
||||
return <ProfileSummaryPage />;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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("<LookingAfterCard>", () => {
|
||||
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],
|
||||
|
||||
@@ -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")}
|
||||
</p>
|
||||
<ul className="divide-y">
|
||||
{accounts.map((entry) => (
|
||||
<li key={entry.accountId}>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="looking-after-row"
|
||||
data-account-id={entry.accountId}
|
||||
disabled={switchAccount.isPending}
|
||||
onClick={() => switchAccount.mutate(entry.accountId)}
|
||||
aria-label={t("recordSharing.lookingAfter.open", {
|
||||
name: accountLabel(entry),
|
||||
})}
|
||||
className="hover:bg-accent/40 focus-visible:ring-ring/50 flex min-h-11 w-full items-center gap-3 rounded-md px-2 py-2 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="text-foreground block truncate text-sm font-medium">
|
||||
{accountLabel(entry)}
|
||||
{accounts.map((entry) => {
|
||||
const presentation = resolveRecordPresentation(entry);
|
||||
const level = levelLabel(entry, presentation, t);
|
||||
const kind = recordKindLabel(entry, t);
|
||||
const scope = scopeLabel(entry, t);
|
||||
|
||||
return (
|
||||
<li key={entry.accountId}>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="looking-after-row"
|
||||
data-account-id={entry.accountId}
|
||||
data-access-level={presentation.access}
|
||||
data-record-kind={presentation.recordKind}
|
||||
disabled={switchAccount.isPending}
|
||||
onClick={() =>
|
||||
switchAccount.switchTo(
|
||||
entry.accountId,
|
||||
sharedRecordLandingHref(entry.sections),
|
||||
)
|
||||
}
|
||||
aria-label={[
|
||||
t("recordSharing.lookingAfter.open", {
|
||||
name: accountLabel(entry),
|
||||
}),
|
||||
level,
|
||||
kind,
|
||||
scope,
|
||||
].join("; ")}
|
||||
className="hover:bg-accent/40 focus-visible:ring-ring/50 flex min-h-11 w-full items-center gap-3 rounded-md px-2 py-2 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="text-foreground block truncate text-sm font-medium">
|
||||
{accountLabel(entry)}
|
||||
</span>
|
||||
<span className="text-muted-foreground block truncate text-xs">
|
||||
{level}
|
||||
</span>
|
||||
<span className="text-muted-foreground block truncate text-xs">
|
||||
{kind}
|
||||
</span>
|
||||
<span className="text-muted-foreground block truncate text-xs">
|
||||
{scope}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground block truncate text-xs">
|
||||
{levelLabel(entry, t)}
|
||||
</span>
|
||||
<span className="text-muted-foreground block truncate text-xs">
|
||||
{recordKindLabel(entry, t)}
|
||||
</span>
|
||||
<span className="text-muted-foreground block truncate text-xs">
|
||||
{scopeLabel(entry, t)}
|
||||
</span>
|
||||
</span>
|
||||
{switchAccount.isPending && (
|
||||
<Loader2
|
||||
className="text-muted-foreground size-4 shrink-0 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{switchAccount.isPending && (
|
||||
<Loader2
|
||||
className="text-muted-foreground size-4 shrink-0 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<IllnessEpisodeDTO[]>(
|
||||
`/api/illness/episodes?includeResolved=${includeResolved}`,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<OcrCapabilityDto>({
|
||||
|
||||
@@ -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(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<I18nProvider initialLocale="en">
|
||||
@@ -169,6 +190,46 @@ const DOORS = [
|
||||
] as const;
|
||||
|
||||
describe("<AuthShell> — 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("<AuthShell> — 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],
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -134,6 +134,31 @@ describe("<SharedRecordBanner>", () => {
|
||||
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,
|
||||
},
|
||||
<SharedRecordBanner />,
|
||||
);
|
||||
|
||||
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("<SharedRecordBanner>", () => {
|
||||
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 },
|
||||
<SharedRecordBanner />,
|
||||
);
|
||||
const context = html.match(
|
||||
/<span[^>]*data-slot="shared-record-banner-context"[^>]*>/,
|
||||
)?.[0];
|
||||
|
||||
expect(context).toContain("text-foreground");
|
||||
expect(context).not.toContain("text-muted-foreground");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<AccountSwitcherMenuItems>", () => {
|
||||
|
||||
@@ -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() {
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent
|
||||
data-slot="account-switcher-menu"
|
||||
className="w-60"
|
||||
className="max-h-(--radix-dropdown-menu-content-available-height) w-60 overflow-x-hidden overflow-y-auto"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
data-slot="account-switcher-own"
|
||||
@@ -79,32 +81,62 @@ export function AccountSwitcherMenuItems() {
|
||||
<span className="text-primary ml-auto text-xs">✓</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
{access.accounts.map((account) => (
|
||||
<DropdownMenuItem
|
||||
key={account.accountId}
|
||||
data-slot="account-switcher-entry"
|
||||
data-account-id={account.accountId}
|
||||
className="cursor-pointer"
|
||||
disabled={switchAccount.isPending}
|
||||
onClick={() => switchAccount.mutate(account.accountId)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate">{accountLabel(account)}</span>
|
||||
{/* Meta, so muted: what the grant lets this person do, in the
|
||||
words the server resolved rather than a level code. */}
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{account.canWrite
|
||||
? t("recordSharing.switcher.canWrite")
|
||||
: t("recordSharing.switcher.readOnly")}
|
||||
</span>
|
||||
</div>
|
||||
{activeId === account.accountId && (
|
||||
<Check className="text-primary ml-2 size-4 shrink-0" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{access.accounts.map((account) => {
|
||||
const presentation = resolveRecordPresentation(account);
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={account.accountId}
|
||||
data-slot="account-switcher-entry"
|
||||
data-account-id={account.accountId}
|
||||
data-account-username={account.username}
|
||||
data-access-level={presentation.access}
|
||||
data-record-kind={presentation.recordKind}
|
||||
className="cursor-pointer"
|
||||
disabled={switchAccount.isPending}
|
||||
onClick={() =>
|
||||
switchAccount.switchTo(
|
||||
account.accountId,
|
||||
sharedRecordLandingHref(account.sections),
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate">{accountLabel(account)}</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{accessLabel(presentation.access, t)}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{recordKindLabel(presentation.recordKind, t)}
|
||||
</span>
|
||||
</div>
|
||||
{activeId === account.accountId && (
|
||||
<Check className="text-primary ml-2 size-4 shrink-0" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function accessLabel(
|
||||
access: ReturnType<typeof resolveRecordPresentation>["access"],
|
||||
t: ReturnType<typeof useTranslations>["t"],
|
||||
): string {
|
||||
if (access === "manage") return t("recordSharing.row.canManage");
|
||||
return access === "view-and-add"
|
||||
? t("recordSharing.switcher.canWrite")
|
||||
: t("recordSharing.switcher.readOnly");
|
||||
}
|
||||
|
||||
function recordKindLabel(
|
||||
recordKind: ReturnType<typeof resolveRecordPresentation>["recordKind"],
|
||||
t: ReturnType<typeof useTranslations>["t"],
|
||||
): string {
|
||||
return recordKind === "managed"
|
||||
? t("recordSharing.lookingAfter.kindManaged")
|
||||
: t("recordSharing.lookingAfter.kindShared");
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex h-dvh items-center justify-center" role="status">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
<span className="sr-only">{t("nav.loadingScreen")}</span>
|
||||
</div>
|
||||
);
|
||||
// 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 <RecordScopeHydrationGate label={t("nav.loadingScreen")} />;
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div
|
||||
data-slot="invalid-record-access-refusal"
|
||||
className="flex min-h-dvh flex-col"
|
||||
>
|
||||
<MaintainershipBanner />
|
||||
<main className="mx-auto w-full max-w-screen-xl px-4 py-10 md:px-6">
|
||||
<SharedRecordUnavailable />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Onboarding page — minimal shell, no sidebar/nav
|
||||
if (isOnboardingPage) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<NavDestination> = [
|
||||
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 }));
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
data-slot="record-scope-hydration-gate"
|
||||
className="flex h-dvh items-center justify-center"
|
||||
role="status"
|
||||
>
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
<span className="sr-only">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
role="status"
|
||||
data-slot="shared-record-banner"
|
||||
data-account-id={active.accountId}
|
||||
data-access-level={presentation.access}
|
||||
data-record-kind={presentation.recordKind}
|
||||
className="bg-warning/15 border-warning/40 flex items-start gap-3 border-b px-3 py-2 text-xs sm:items-center"
|
||||
>
|
||||
<Eye
|
||||
@@ -75,18 +79,12 @@ export function SharedRecordBanner() {
|
||||
<span className="text-foreground font-medium">
|
||||
{t("recordSharing.banner.viewing", { name })}
|
||||
</span>{" "}
|
||||
{/* Resolved server-side, and it says something at BOTH levels now.
|
||||
v1.36.1 is the release that made `canWrite` true, and until this
|
||||
line changed the write case rendered nothing at all: a delegate who
|
||||
could add to somebody's health record was told only whose record it
|
||||
was. That matters more than the read case, not less, and the rest of
|
||||
the product leans on it — a control the record refuses is absent
|
||||
rather than disabled, on the stated grounds that the banner names
|
||||
the mode globally. It has to actually do that. */}
|
||||
<span className="text-muted-foreground">
|
||||
{active.canWrite
|
||||
? t("recordSharing.banner.canAdd")
|
||||
: t("recordSharing.banner.readOnly")}
|
||||
<span
|
||||
data-slot="shared-record-banner-context"
|
||||
className="text-foreground"
|
||||
>
|
||||
{recordKindLabel(presentation.recordKind, t)}.{" "}
|
||||
{accessLabel(presentation.access, t)}
|
||||
</span>
|
||||
</p>
|
||||
<button
|
||||
@@ -109,3 +107,22 @@ export function SharedRecordBanner() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function accessLabel(
|
||||
access: ReturnType<typeof resolveRecordPresentation>["access"],
|
||||
t: ReturnType<typeof useTranslations>["t"],
|
||||
): string {
|
||||
if (access === "manage") return t("recordSharing.row.canManage");
|
||||
return access === "view-and-add"
|
||||
? t("recordSharing.banner.canAdd")
|
||||
: t("recordSharing.banner.readOnly");
|
||||
}
|
||||
|
||||
function recordKindLabel(
|
||||
recordKind: ReturnType<typeof resolveRecordPresentation>["recordKind"],
|
||||
t: ReturnType<typeof useTranslations>["t"],
|
||||
): string {
|
||||
return recordKind === "managed"
|
||||
? t("recordSharing.lookingAfter.kindManaged")
|
||||
: t("recordSharing.lookingAfter.kindShared");
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { Logo } from "@/components/ui/logo";
|
||||
import { useAuth, useLogout } from "@/hooks/use-auth";
|
||||
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useTheme } from "@/components/providers";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
@@ -306,10 +307,11 @@ export function SidebarNav() {
|
||||
// v1.36.0 — while this browser is acting on somebody else's record, the nav
|
||||
// shows only what sharing covers. The server refuses the rest regardless;
|
||||
// dropping the entries spares a delegate a click that ends in a 403.
|
||||
const sharedRecord = user?.accountAccess?.active != null;
|
||||
const { inSharedRecord: sharedRecord, sections } = useRecordCapabilities();
|
||||
const visibleNavItems = useMemo(
|
||||
() => visibleNavDestinations(user?.modules, mounted, sharedRecord),
|
||||
[user?.modules, mounted, sharedRecord],
|
||||
() =>
|
||||
visibleNavDestinations(user?.modules, mounted, sharedRecord, sections),
|
||||
[user?.modules, mounted, sharedRecord, sections],
|
||||
);
|
||||
// Collapsed = the user's stored choice, or — with no stored choice — a
|
||||
// viewport default: tablet widths (md–lg, e.g. an iPad held upright)
|
||||
|
||||
+117
-10
@@ -22,9 +22,22 @@ import { Toaster } from "@/components/ui/sonner";
|
||||
import { VersionPoller } from "@/components/version-poller";
|
||||
import { ServiceWorkerRegistrar } from "@/components/service-worker-registrar";
|
||||
import { SharedRecordGrantLossBridge } from "@/components/layout/shared-record-grant-loss-bridge";
|
||||
import { useAuth, type AccountAccessStatus } from "@/hooks/use-auth";
|
||||
import { useRecordSessionTransition } from "@/hooks/use-record-session-transition";
|
||||
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
|
||||
import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
|
||||
import {
|
||||
discardDashboardSnapshotPreload,
|
||||
prefetchDashboardSnapshot,
|
||||
} from "@/lib/queries/use-dashboard-snapshot";
|
||||
import {
|
||||
getRecordSessionTransition,
|
||||
subscribeToRecordSessionTransition,
|
||||
} from "@/lib/query-keys/record-session-transition";
|
||||
import { prefetchMedicationsList } from "@/lib/queries/prefetch-medications";
|
||||
import {
|
||||
getRecordScope,
|
||||
subscribeToRecordScope,
|
||||
} from "@/lib/query-keys/record-scope";
|
||||
import {
|
||||
restorePersistedQueryCache,
|
||||
startPersistingQueryCache,
|
||||
@@ -168,15 +181,60 @@ function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
// the router commits to "/" instead of waiting for the dashboard page
|
||||
// chunk to download + mount (~450 ms later on a 4G / 4x-CPU profile).
|
||||
// The proxy has already enforced auth + onboarding for "/" before any
|
||||
// client code runs here, so the prefetch cannot fire for an
|
||||
// unauthenticated visitor; a racing edge case just yields a swallowed
|
||||
// 401 prefetch and the mounted cell re-resolves normally.
|
||||
// client code runs here. It does not, however, resolve the active shared
|
||||
// record: that verdict arrives from `/api/auth/me`. Do not begin a record read
|
||||
// until that answer and the local cache scope agree.
|
||||
export function isRecordPreloadReady({
|
||||
isLoading,
|
||||
accountAccessStatus,
|
||||
activeAccountId = null,
|
||||
}: {
|
||||
isLoading: boolean;
|
||||
accountAccessStatus: AccountAccessStatus | undefined;
|
||||
activeAccountId?: string | null;
|
||||
}): boolean {
|
||||
return (
|
||||
!isLoading &&
|
||||
(accountAccessStatus === "absent" || accountAccessStatus === "valid") &&
|
||||
getRecordScope() === activeAccountId
|
||||
);
|
||||
}
|
||||
|
||||
function subscribeToClientRecordScope(onStoreChange: () => void): () => void {
|
||||
return subscribeToRecordScope(onStoreChange);
|
||||
}
|
||||
|
||||
function getClientRecordScope(): string | null {
|
||||
return getRecordScope();
|
||||
}
|
||||
|
||||
function getServerRecordScope(): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function DashboardSnapshotPreloader() {
|
||||
const queryClient = useQueryClient();
|
||||
const pathname = usePathname();
|
||||
const { user, isLoading } = useAuth();
|
||||
const transition = useRecordSessionTransition();
|
||||
const recordScope = useSyncExternalStore(
|
||||
subscribeToClientRecordScope,
|
||||
getClientRecordScope,
|
||||
getServerRecordScope,
|
||||
);
|
||||
const ready =
|
||||
transition.phase === "ready" &&
|
||||
isRecordPreloadReady({
|
||||
isLoading,
|
||||
accountAccessStatus: user?.accountAccessStatus,
|
||||
activeAccountId: user?.accountAccess?.active?.accountId ?? null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
const controller = new AbortController();
|
||||
if (pathname === "/" && isDashboardSnapshotEnabled()) {
|
||||
prefetchDashboardSnapshot(queryClient);
|
||||
prefetchDashboardSnapshot(queryClient, controller.signal);
|
||||
}
|
||||
// v1.16.7 — same waterfall cut for the medications page: its list
|
||||
// query (which carries the per-medication `nextDueAt` the due cells
|
||||
@@ -185,9 +243,12 @@ function DashboardSnapshotPreloader() {
|
||||
// download; the nav links additionally prefetch on hover/touch
|
||||
// intent, so this is the fallback for direct loads + reloads.
|
||||
if (pathname === "/medications") {
|
||||
prefetchMedicationsList(queryClient);
|
||||
prefetchMedicationsList(queryClient, controller.signal);
|
||||
}
|
||||
}, [pathname, queryClient]);
|
||||
// A transition to an unresolved, refused, or different record scope must
|
||||
// abort a preloader before its response can be adopted by the next route.
|
||||
return () => controller.abort();
|
||||
}, [pathname, queryClient, ready, recordScope, transition.phase]);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -195,9 +256,13 @@ function DashboardSnapshotPreloader() {
|
||||
|
||||
function QueryVisibilityRefreshBridge() {
|
||||
const queryClient = useQueryClient();
|
||||
const transition = useRecordSessionTransition();
|
||||
useEffect(
|
||||
() => subscribeToMeaningfulVisibilityRefresh(queryClient),
|
||||
[queryClient],
|
||||
() =>
|
||||
transition.phase === "ready"
|
||||
? subscribeToMeaningfulVisibilityRefresh(queryClient)
|
||||
: undefined,
|
||||
[queryClient, transition.phase],
|
||||
);
|
||||
return null;
|
||||
}
|
||||
@@ -211,10 +276,16 @@ function QueryVisibilityRefreshBridge() {
|
||||
// empty skeletons. Build-version + age gated; cleared on logout.
|
||||
function QueryPersistenceBridge() {
|
||||
const queryClient = useQueryClient();
|
||||
const transition = useRecordSessionTransition();
|
||||
useEffect(() => {
|
||||
if (transition.phase !== "ready") return;
|
||||
let stop: (() => void) | undefined;
|
||||
let cancelled = false;
|
||||
void restorePersistedQueryCache(queryClient, SHELL_VERSION).finally(() => {
|
||||
void restorePersistedQueryCache(
|
||||
queryClient,
|
||||
SHELL_VERSION,
|
||||
() => !cancelled,
|
||||
).finally(() => {
|
||||
if (!cancelled) {
|
||||
stop = startPersistingQueryCache(queryClient, SHELL_VERSION);
|
||||
}
|
||||
@@ -223,7 +294,42 @@ function QueryPersistenceBridge() {
|
||||
cancelled = true;
|
||||
stop?.();
|
||||
};
|
||||
}, [queryClient, transition.phase]);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear tab-local reads immediately whenever the browser-wide transition
|
||||
* store enters its hold. The AuthShell consumes the same store synchronously,
|
||||
* so protected children unmount while this bridge cancels their observers.
|
||||
*/
|
||||
function RecordSessionTransitionBridge() {
|
||||
const queryClient = useQueryClient();
|
||||
const { refetch } = useAuth();
|
||||
const transition = useRecordSessionTransition();
|
||||
|
||||
useEffect(() => {
|
||||
const clearForTransition = () => {
|
||||
if (getRecordSessionTransition().phase === "ready") return;
|
||||
discardDashboardSnapshotPreload();
|
||||
void queryClient.cancelQueries();
|
||||
queryClient.clear();
|
||||
};
|
||||
// A tab opened while another tab is already switching needs the same
|
||||
// cache purge even though it has no future broadcast to receive.
|
||||
clearForTransition();
|
||||
return subscribeToRecordSessionTransition(clearForTransition);
|
||||
}, [queryClient]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
transition.phase === "resolving" ||
|
||||
(transition.phase === "ready" && transition.id !== null)
|
||||
) {
|
||||
void refetch();
|
||||
}
|
||||
}, [refetch, transition.id, transition.phase]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -279,6 +385,7 @@ export function Providers({
|
||||
<ThemeProvider>
|
||||
<I18nProvider initialLocale={initialLocale}>
|
||||
<QueryPersistenceBridge />
|
||||
<RecordSessionTransitionBridge />
|
||||
<QueryVisibilityRefreshBridge />
|
||||
<DashboardSnapshotPreloader />
|
||||
<OfflineMutationToaster />
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { ProfileSummary } from "../profile-summary";
|
||||
|
||||
describe("ProfileSummary", () => {
|
||||
it("renders bounded profile information without mutation controls", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<ProfileSummary
|
||||
summary={{
|
||||
allergies: [{ substance: "Pollen", category: "OTHER" }],
|
||||
familyHistory: [
|
||||
{ condition: "Hypertension", relationship: "PARENT" },
|
||||
],
|
||||
facts: [{ label: "Smoking", value: "Never" }],
|
||||
}}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-slot="profile-summary"');
|
||||
expect(html).toContain("Pollen");
|
||||
expect(html).toContain("Hypertension");
|
||||
expect(html).toContain("Smoking");
|
||||
expect(html).not.toContain("<button");
|
||||
expect(html).not.toContain("<form");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { ProfileSummaryPayload } from "@/lib/records/profile-summary";
|
||||
|
||||
function profileFactLabel(label: string, t: (key: string) => string): string {
|
||||
const keys: Record<string, string> = {
|
||||
SMOKING_STATUS: "records.profileFacts.smokingLabel",
|
||||
ALCOHOL_PATTERN: "records.profileFacts.alcoholLabel",
|
||||
SHIFT_SCHEDULE: "records.profileFacts.shiftLabel",
|
||||
};
|
||||
return keys[label] ? t(keys[label]) : label;
|
||||
}
|
||||
|
||||
function profileFactValue(
|
||||
label: string,
|
||||
value: string,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
const supported = new Set([
|
||||
"SMOKING_STATUS",
|
||||
"ALCOHOL_PATTERN",
|
||||
"SHIFT_SCHEDULE",
|
||||
]);
|
||||
return supported.has(label)
|
||||
? t(`records.profileFacts.values.${label}.${value}`)
|
||||
: value;
|
||||
}
|
||||
|
||||
/** Bounded profile facts, intentionally free of mutation controls. */
|
||||
export function ProfileSummary({
|
||||
summary,
|
||||
}: {
|
||||
summary: ProfileSummaryPayload;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-slot="profile-summary">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("records.allergies.cardTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{summary.allergies.length ? (
|
||||
<ul className="space-y-2" data-slot="profile-summary-allergies">
|
||||
{summary.allergies.map((allergy) => (
|
||||
<li key={`${allergy.substance}-${allergy.category}`}>
|
||||
<span className="font-medium">{allergy.substance}</span>{" "}
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t(`records.allergies.category.${allergy.category}`)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("records.allergies.emptyTitle")}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("records.family.cardTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{summary.familyHistory.length ? (
|
||||
<ul
|
||||
className="space-y-2"
|
||||
data-slot="profile-summary-family-history"
|
||||
>
|
||||
{summary.familyHistory.map((entry) => (
|
||||
<li key={`${entry.condition}-${entry.relationship}`}>
|
||||
<span className="font-medium">{entry.condition}</span>{" "}
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t(`records.family.relationship.${entry.relationship}`)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("records.family.emptyTitle")}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("records.profileFacts.cardTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{summary.facts.length ? (
|
||||
<dl className="space-y-2" data-slot="profile-summary-facts">
|
||||
{summary.facts.map((fact) => (
|
||||
<div key={fact.label} className="flex justify-between gap-4">
|
||||
<dt>{profileFactLabel(fact.label, t)}</dt>
|
||||
<dd className="text-muted-foreground text-right">
|
||||
{profileFactValue(fact.label, fact.value, t)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("records.profileFacts.notRecorded")}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProfileSummaryPage() {
|
||||
const { t } = useTranslations();
|
||||
const summary = useQuery({
|
||||
queryKey: queryKeys.profileSummary(),
|
||||
queryFn: () => apiGet<ProfileSummaryPayload>("/api/profile/summary"),
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="container mx-auto max-w-3xl space-y-6 px-4 py-6">
|
||||
<PageHeader title={t("settings.sections.anamnesis.title")} />
|
||||
{summary.isPending ? (
|
||||
<p className="text-muted-foreground text-sm" aria-live="polite">
|
||||
{t("common.loading")}
|
||||
</p>
|
||||
) : null}
|
||||
{summary.isError ? (
|
||||
<QueryErrorCard onRetry={() => void summary.refetch()} />
|
||||
) : null}
|
||||
{summary.data ? <ProfileSummary summary={summary.data} /> : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -84,8 +84,10 @@ describe("managed record settings forms", () => {
|
||||
),
|
||||
});
|
||||
|
||||
for (const module of Object.keys(MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS)) {
|
||||
expect(modules).toContain(`name="${module}"`);
|
||||
for (const moduleKey of Object.keys(
|
||||
MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS,
|
||||
)) {
|
||||
expect(modules).toContain(`name="${moduleKey}"`);
|
||||
}
|
||||
for (const metric of ALL_METRICS) {
|
||||
expect(thresholds).toContain(`value="${metric}"`);
|
||||
|
||||
@@ -80,7 +80,12 @@ export function ManagedIntegrationStatus() {
|
||||
|
||||
if (statusQuery.isLoading || !statusQuery.data) {
|
||||
return (
|
||||
<SettingsCard aria-busy="true" aria-live="polite">
|
||||
<SettingsCard
|
||||
aria-busy="true"
|
||||
aria-live="polite"
|
||||
data-record-id={recordId ?? undefined}
|
||||
data-record-settings-family="integrations"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
icon={Plug}
|
||||
title={t("settings.sections.integrations.title")}
|
||||
@@ -91,7 +96,11 @@ export function ManagedIntegrationStatus() {
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard aria-labelledby="managed-integration-status-title">
|
||||
<SettingsCard
|
||||
aria-labelledby="managed-integration-status-title"
|
||||
data-record-id={recordId ?? undefined}
|
||||
data-record-settings-family="integrations"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
icon={Plug}
|
||||
title={t("settings.sections.integrations.title")}
|
||||
|
||||
@@ -984,7 +984,11 @@ export function ManagedRecordSettingsSection({
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard aria-busy={query.isLoading}>
|
||||
<SettingsCard
|
||||
aria-busy={query.isLoading}
|
||||
data-record-id={recordId ?? undefined}
|
||||
data-record-settings-family={family}
|
||||
>
|
||||
<SettingsCardHeader
|
||||
icon={Settings}
|
||||
title={t(`settings.sharedRecord.managedSettings.${family}.title`)}
|
||||
|
||||
@@ -48,6 +48,7 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import { classifySettingsDestination } from "@/lib/record-settings";
|
||||
import {
|
||||
SETTINGS_SECTION_SLUGS,
|
||||
isSettingsSectionSlug,
|
||||
@@ -430,13 +431,28 @@ export function SettingsShell({
|
||||
// the two passes disagreeing.
|
||||
const hydrated = useMounted();
|
||||
const modules = user?.modules;
|
||||
const visibleSections = SETTINGS_SECTIONS.filter(
|
||||
const activeRecord = user?.accountAccess?.active ?? null;
|
||||
const allVisibleSections = SETTINGS_SECTIONS.filter(
|
||||
(section) =>
|
||||
!hydrated ||
|
||||
!section.moduleGate ||
|
||||
modules?.[section.moduleGate] !== false,
|
||||
);
|
||||
const activeSection = visibleSections.find(
|
||||
|
||||
// The server has already resolved which record is active. In a managed
|
||||
// profile, only guardian settings are a navigation destination; the section
|
||||
// gate remains responsible for refusing any direct URL that is not here.
|
||||
// Keep `allVisibleSections` for the active heading so a direct unavailable
|
||||
// link identifies itself before the gate explains why it cannot open.
|
||||
const visibleSections =
|
||||
activeRecord?.recordKind === "managed"
|
||||
? allVisibleSections.filter(
|
||||
(section) =>
|
||||
classifySettingsDestination(section.slug).kind ===
|
||||
"managed-guardian",
|
||||
)
|
||||
: allVisibleSections;
|
||||
const activeSection = allVisibleSections.find(
|
||||
(section) => section.slug === activeSlug,
|
||||
);
|
||||
|
||||
@@ -539,7 +555,11 @@ export function SettingsShell({
|
||||
// producing visibly more top/bottom whitespace on Settings/Admin
|
||||
// pages than on Dashboard/Insights/Measurements.
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-xl">
|
||||
<div
|
||||
className="mx-auto w-full max-w-screen-xl"
|
||||
data-active-record-id={activeRecord?.accountId}
|
||||
data-record-kind={activeRecord?.recordKind}
|
||||
>
|
||||
{/* v1.18.6.1 — on mobile the heading sits above the chip strip; on
|
||||
desktop it is rendered inside the grid (row 1 / content column) so
|
||||
it does not paint twice. */}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
__resetRecordScopeForTests,
|
||||
getRecordScope,
|
||||
isReadingSharedRecord,
|
||||
} from "@/lib/query-keys/record-scope";
|
||||
|
||||
const apiGet = vi.fn();
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", () => ({
|
||||
apiGet: (...args: unknown[]) => apiGet(...args),
|
||||
apiFetchRaw: vi.fn(),
|
||||
ApiError: class ApiError extends Error {},
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { fetchMe, NO_ACCOUNT_ACCESS } from "../use-auth";
|
||||
|
||||
const response = {
|
||||
id: "delegate",
|
||||
username: "delegate",
|
||||
role: "USER",
|
||||
timezone: "Europe/Berlin",
|
||||
};
|
||||
|
||||
const canonicalEntry = {
|
||||
accountId: "record-a",
|
||||
username: "record-a",
|
||||
displayName: "Record A",
|
||||
access: "read",
|
||||
level: "read",
|
||||
recordKind: "shared",
|
||||
sections: ["labs"],
|
||||
canWrite: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
apiGet.mockReset();
|
||||
__resetRecordScopeForTests();
|
||||
});
|
||||
|
||||
describe("fetchMe account-access resolution", () => {
|
||||
it("keeps a genuinely absent block compatible with an older server", async () => {
|
||||
apiGet.mockResolvedValue(response);
|
||||
|
||||
const user = await fetchMe();
|
||||
|
||||
expect(user.accountAccessStatus).toBe("absent");
|
||||
expect(user.accountAccess).toEqual(NO_ACCOUNT_ACCESS);
|
||||
expect(getRecordScope()).toBeNull();
|
||||
expect(isReadingSharedRecord()).toBe(false);
|
||||
});
|
||||
|
||||
it("uses a valid published block as the active record scope", async () => {
|
||||
apiGet.mockResolvedValue({
|
||||
...response,
|
||||
accountAccess: {
|
||||
accounts: [canonicalEntry],
|
||||
active: canonicalEntry,
|
||||
recordKind: "shared",
|
||||
canSwitch: true,
|
||||
},
|
||||
});
|
||||
|
||||
const user = await fetchMe();
|
||||
|
||||
expect(user.accountAccessStatus).toBe("valid");
|
||||
expect(user.accountAccess?.active).toEqual(canonicalEntry);
|
||||
expect(getRecordScope()).toBe("record-a");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an explicitly undefined block", undefined],
|
||||
["a null block", null],
|
||||
[
|
||||
"a divergent active entry",
|
||||
{
|
||||
accounts: [canonicalEntry],
|
||||
active: {
|
||||
...canonicalEntry,
|
||||
access: "write",
|
||||
level: "write",
|
||||
canWrite: true,
|
||||
},
|
||||
recordKind: "shared",
|
||||
canSwitch: true,
|
||||
},
|
||||
],
|
||||
])(
|
||||
"refuses %s without restoring an owner cache scope",
|
||||
async (_name, block) => {
|
||||
apiGet.mockResolvedValue({ ...response, accountAccess: block });
|
||||
|
||||
const user = await fetchMe();
|
||||
|
||||
expect(user.accountAccessStatus).toBe("invalid");
|
||||
expect(user.accountAccess).toEqual(NO_ACCOUNT_ACCESS);
|
||||
expect(getRecordScope()).not.toBeNull();
|
||||
expect(isReadingSharedRecord()).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -72,6 +72,19 @@ describe("resolveRecordCapabilities", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a malformed published block instead of treating it as own record", () => {
|
||||
expect(resolveRecordCapabilities(null, true)).toEqual({
|
||||
inSharedRecord: true,
|
||||
canWrite: false,
|
||||
canAdd: false,
|
||||
canManage: false,
|
||||
level: null,
|
||||
sections: [],
|
||||
recordKind: "shared",
|
||||
accessRefused: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("a read-only delegate adds nothing", () => {
|
||||
expect(resolveRecordCapabilities(READ_ONLY)).toEqual({
|
||||
inSharedRecord: true,
|
||||
|
||||
@@ -3,20 +3,22 @@
|
||||
/**
|
||||
* v1.36.0 — entering and leaving somebody else's record, from the browser.
|
||||
*
|
||||
* The switch is three acts in a fixed order, and the order is the whole
|
||||
* safety argument:
|
||||
* The switch is four acts in a fixed browser-coordination order:
|
||||
*
|
||||
* 1. **Ask the server.** `POST /api/account/switch` validates the grant and
|
||||
* stamps the session. Nothing local changes until it answers, so a switch
|
||||
* the server refuses leaves the browser exactly where it was.
|
||||
* 2. **Empty every cache that survives a reload.** The IndexedDB query
|
||||
* 1. **Hold every tab.** Publish a cross-tab `blocking` transition and clear
|
||||
* local reads before the POST can change the shared server session.
|
||||
* 2. **Ask the server.** `POST /api/account/switch` validates the grant and
|
||||
* stamps the session. Any client-visible failure stays held until a
|
||||
* fresh `/api/auth/me` resolves the record the server actually kept; it
|
||||
* never restores stale caches.
|
||||
* 3. **Empty every cache that survives a reload.** The IndexedDB query
|
||||
* snapshot and the service worker's `healthlog-data-*` cache both answer
|
||||
* reads from disk. Left in place, the first paint on the other side can
|
||||
* be the PREVIOUS record's numbers under a banner naming the new one —
|
||||
* and nothing in the page's own code would be wrong, because the answer
|
||||
* would not have come from the page. Awaited, not fired: a wipe racing
|
||||
* the navigation is a wipe that sometimes does not happen.
|
||||
* 3. **Reload the document.** Not `router.refresh()`, not a query
|
||||
* 4. **Reload the document.** Not `router.refresh()`, not a query
|
||||
* invalidation. A hard navigation is what discards the in-memory cache,
|
||||
* every React state tree holding a rendered value, and every in-flight
|
||||
* request that was issued against the old record. The reload IS the cache
|
||||
@@ -38,7 +40,11 @@ import { ApiError, apiPost } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { clearOfflineCachesForRecordSwitch } from "@/lib/pwa/query-persister";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { setRecordScope } from "@/lib/query-keys/record-scope";
|
||||
import {
|
||||
beginRecordSessionTransition,
|
||||
commitRecordSessionTransition,
|
||||
resolveUnknownRecordSessionTransition,
|
||||
} from "@/lib/query-keys/record-session-transition";
|
||||
|
||||
/**
|
||||
* The two stable sharing error codes, as the envelope publishes them
|
||||
@@ -96,16 +102,15 @@ async function postSwitch(accountId: string | null): Promise<SwitchResponse> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete the switch: stamp the cache scope, empty the disk layers, reload.
|
||||
* Complete a server-confirmed switch: empty the disk layers, reload.
|
||||
*
|
||||
* Extracted from the mutation so the grant-loss reset can reuse the exact same
|
||||
* sequence. Two copies of "wipe, then reload" is one copy too many for a step
|
||||
* whose order is the safety property.
|
||||
*/
|
||||
async function landOnRecord(accountId: string | null): Promise<never> {
|
||||
setRecordScope(accountId);
|
||||
async function landOnRecord(destination = "/"): Promise<never> {
|
||||
await clearOfflineCachesForRecordSwitch();
|
||||
window.location.assign("/");
|
||||
window.location.assign(destination);
|
||||
// The document is being replaced. Nothing after this runs, and the promise
|
||||
// deliberately never settles so no caller paints a "done" state over a page
|
||||
// that is on its way out.
|
||||
@@ -124,15 +129,35 @@ export function useAccountSwitch() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
const mutation = useMutation({
|
||||
mutationKey: queryKeys.accountSwitchMutation(),
|
||||
mutationFn: async (accountId: string | null) => {
|
||||
await postSwitch(accountId);
|
||||
mutationFn: async ({
|
||||
accountId,
|
||||
destination,
|
||||
}: {
|
||||
accountId: string | null;
|
||||
destination: string;
|
||||
}) => {
|
||||
// Publish the browser-wide hold before the server sees the session
|
||||
// change. Every recipient blocks its shell and clears local reads before
|
||||
// it reconciles the committed scope through a fresh `/me`.
|
||||
const transitionId = beginRecordSessionTransition(accountId);
|
||||
// The in-memory cache holds the record we are leaving. Clearing it here
|
||||
// as well as reloading costs nothing and closes the window in which a
|
||||
// failed navigation would leave the old entries reachable.
|
||||
void queryClient.cancelQueries();
|
||||
queryClient.clear();
|
||||
return landOnRecord(accountId);
|
||||
try {
|
||||
await postSwitch(accountId);
|
||||
} catch (error) {
|
||||
// A client error cannot prove that the server left the session alone:
|
||||
// its response may have been lost after the server committed. Keep all
|
||||
// tabs held until a fresh `/me` resolves the record the server kept.
|
||||
resolveUnknownRecordSessionTransition(transitionId);
|
||||
throw error;
|
||||
}
|
||||
commitRecordSessionTransition(transitionId, accountId);
|
||||
return landOnRecord(destination);
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast.error(
|
||||
@@ -142,6 +167,16 @@ export function useAccountSwitch() {
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...mutation,
|
||||
/** Preserve the established own-record and exit call sites. */
|
||||
mutate: (accountId: string | null) =>
|
||||
mutation.mutate({ accountId, destination: "/" }),
|
||||
/** Enter a scoped record directly at a server-presentable destination. */
|
||||
switchTo: (accountId: string, destination: string) =>
|
||||
mutation.mutate({ accountId, destination }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,14 +184,15 @@ export function useAccountSwitch() {
|
||||
*
|
||||
* The switcher-reset the contract asks for: on `sharing.access.denied` the
|
||||
* session is still stamped with an account it may no longer act on, and every
|
||||
* delegable read from here on is a 403. Clearing the stamp puts the browser
|
||||
* back in its own account rather than leaving somebody staring at a wall of
|
||||
* refusals under a banner that says they are somewhere they are not.
|
||||
* delegable read from here on is a 403. When the server confirms the reset,
|
||||
* its own-record scope replaces the stamp rather than leaving somebody
|
||||
* staring at refusals under a banner that says they are somewhere they are
|
||||
* not.
|
||||
*
|
||||
* `POST /api/account/switch` with `null` is an actor surface and always
|
||||
* succeeds — it is the way out, by design. If even that fails (offline, server
|
||||
* down) the reload still happens: the stamp survives, but so does the honest
|
||||
* refusal, and the alternative is a page that neither recovers nor says why.
|
||||
* down), the browser keeps the transition unresolved and reloads. A fresh
|
||||
* `/api/auth/me`, not a local scope guess, decides what may render next.
|
||||
*/
|
||||
/**
|
||||
* Watch the cache for the moment the server stops honouring the switch.
|
||||
@@ -185,12 +221,20 @@ export function subscribeToGrantLoss(
|
||||
}
|
||||
|
||||
export function useLeaveSharedRecordOnGrantLoss() {
|
||||
const queryClient = useQueryClient();
|
||||
return useCallback(async () => {
|
||||
const transitionId = beginRecordSessionTransition(null);
|
||||
void queryClient.cancelQueries();
|
||||
queryClient.clear();
|
||||
try {
|
||||
await postSwitch(null);
|
||||
commitRecordSessionTransition(transitionId, null);
|
||||
} catch {
|
||||
/* the reload below is the part that matters */
|
||||
// The grant-loss path deliberately still reloads, but it cannot claim
|
||||
// an own-record scope when the POST outcome is unknown. The next `/me`
|
||||
// is the only authority that may release this resolving hold.
|
||||
resolveUnknownRecordSessionTransition(transitionId);
|
||||
}
|
||||
await landOnRecord(null);
|
||||
}, []);
|
||||
await landOnRecord();
|
||||
}, [queryClient]);
|
||||
}
|
||||
+55
-25
@@ -23,8 +23,16 @@ import { storeTimezone } from "@/lib/timezone-mirror";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import type { TourProgress } from "@/lib/onboarding/tour-progress";
|
||||
import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister";
|
||||
import { setRecordScope } from "@/lib/query-keys/record-scope";
|
||||
import {
|
||||
setRecordScope,
|
||||
setRefusedRecordScope,
|
||||
} from "@/lib/query-keys/record-scope";
|
||||
import {
|
||||
settleRecordSessionTransition,
|
||||
settleRefusedRecordSessionTransition,
|
||||
} from "@/lib/query-keys/record-session-transition";
|
||||
import type { AccountAccess } from "@/lib/sharing/account-access-view";
|
||||
import { parseAccountAccess } from "@/lib/sharing/account-access-schema";
|
||||
|
||||
/**
|
||||
* v1.36.0 — an account with no sharing at all. The shape `fetchMe` falls back
|
||||
@@ -38,6 +46,9 @@ export const NO_ACCOUNT_ACCESS: AccountAccess = {
|
||||
canSwitch: false,
|
||||
};
|
||||
|
||||
/** Whether the account-access block was absent, valid, or refused as malformed. */
|
||||
export type AccountAccessStatus = "absent" | "valid" | "invalid";
|
||||
|
||||
/**
|
||||
* v1.18.6 — resume point for the module tour as the client sees it on
|
||||
* `/api/auth/me`. Mirrors the server-side `TourProgress` shape.
|
||||
@@ -192,6 +203,13 @@ export interface AuthUser {
|
||||
* the empty one — no accounts, no active record, no switcher.
|
||||
*/
|
||||
accountAccess?: AccountAccess;
|
||||
/**
|
||||
* The parser outcome for `accountAccess`. An absent block is the only
|
||||
* owner-compatible fallback: it is how older server images answered before
|
||||
* sharing existed. A present malformed block stays explicitly refused so a
|
||||
* server-side switch cannot paint owner controls in the target record.
|
||||
*/
|
||||
accountAccessStatus?: AccountAccessStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -252,7 +270,7 @@ export function isAuthVerdictUnknown(error: unknown): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function fetchMe(): Promise<AuthUser> {
|
||||
export async function fetchMe(): Promise<AuthUser> {
|
||||
// v1.16.4 — routed through the typed wrapper; a non-OK /me (401)
|
||||
// throws `ApiError`, which `useAuth` treats as "not authenticated"
|
||||
// exactly like the old hand-rolled throw.
|
||||
@@ -299,8 +317,22 @@ async function fetchMe(): Promise<AuthUser> {
|
||||
// whatever the switch flow stamped before its reload, the block is what the
|
||||
// resolver will actually honour on the next request, so the mirror tracks it
|
||||
// rather than the client's memory of what it asked for.
|
||||
const accountAccess = coerceAccountAccess(data.accountAccess);
|
||||
setRecordScope(accountAccess.active?.accountId ?? null);
|
||||
const { accountAccess, status: accountAccessStatus } =
|
||||
resolveAccountAccess(data);
|
||||
if (accountAccessStatus === "invalid") {
|
||||
setRefusedRecordScope();
|
||||
} else {
|
||||
setRecordScope(accountAccess.active?.accountId ?? null);
|
||||
}
|
||||
// A cross-tab switch holds the shell until this server-resolved payload
|
||||
// confirms the record the transition committed. The malformed branch has
|
||||
// already installed its refusal sentinel, so it may release only into the
|
||||
// refusal door rather than being mistaken for the caller's own record.
|
||||
if (accountAccessStatus === "invalid") {
|
||||
settleRefusedRecordSessionTransition();
|
||||
} else {
|
||||
settleRecordSessionTransition(accountAccess.active?.accountId ?? null);
|
||||
}
|
||||
return {
|
||||
...(data as AuthUser),
|
||||
disableCoach: data.disableCoach ?? false,
|
||||
@@ -328,34 +360,32 @@ async function fetchMe(): Promise<AuthUser> {
|
||||
? data.moduleAvailability
|
||||
: {},
|
||||
accountAccess,
|
||||
accountAccessStatus,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the sharing block off a payload that may not carry one.
|
||||
*
|
||||
* Fails CLOSED, unlike the module maps above: an older server image (or a
|
||||
* malformed block) resolves to no accounts, no active record, and no switcher.
|
||||
* The module gates fail open because a missing map must never blank the nav;
|
||||
* this one fails closed because the affordance it drives asks the server to
|
||||
* open somebody else's health record, and an affordance offered on a server
|
||||
* that cannot honour it is worse than one that is missing.
|
||||
* A genuinely missing field is the pre-sharing server contract and remains an
|
||||
* own-record fallback. Once a server publishes the field, every other value —
|
||||
* including `undefined` as an own property and `null` — is a malformed block,
|
||||
* not permission to fall back to the caller's own presentation.
|
||||
*/
|
||||
function coerceAccountAccess(value: unknown): AccountAccess {
|
||||
if (value === null || typeof value !== "object") return NO_ACCOUNT_ACCESS;
|
||||
const block = value as Partial<AccountAccess>;
|
||||
if (!Array.isArray(block.accounts)) return NO_ACCOUNT_ACCESS;
|
||||
return {
|
||||
accounts: block.accounts,
|
||||
active: block.active ?? null,
|
||||
recordKind:
|
||||
block.recordKind === "self" ||
|
||||
block.recordKind === "shared" ||
|
||||
block.recordKind === "managed"
|
||||
? block.recordKind
|
||||
: "self",
|
||||
canSwitch: block.canSwitch === true,
|
||||
};
|
||||
function resolveAccountAccess(value: object): {
|
||||
accountAccess: AccountAccess;
|
||||
status: AccountAccessStatus;
|
||||
} {
|
||||
if (!Object.hasOwn(value, "accountAccess")) {
|
||||
return { accountAccess: NO_ACCOUNT_ACCESS, status: "absent" };
|
||||
}
|
||||
|
||||
const accountAccess = parseAccountAccess(
|
||||
(value as { accountAccess?: unknown }).accountAccess,
|
||||
);
|
||||
return accountAccess
|
||||
? { accountAccess, status: "valid" }
|
||||
: { accountAccess: NO_ACCOUNT_ACCESS, status: "invalid" };
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useRecordSessionTransition } from "@/hooks/use-record-session-transition";
|
||||
import {
|
||||
accountLabel,
|
||||
type AccountAccessEntry,
|
||||
@@ -71,6 +72,10 @@ import {
|
||||
* search a button in gets a sentence.
|
||||
*/
|
||||
export interface RecordCapabilities {
|
||||
/** A tab is awaiting a fresh `/me` after another tab changed the session. */
|
||||
recordSessionPending?: boolean;
|
||||
/** A present account-access block was malformed and must not mount a record. */
|
||||
accessRefused?: boolean;
|
||||
/** Is this browser acting on somebody else's record right now. */
|
||||
inSharedRecord: boolean;
|
||||
/** The grant's resolved level for the record on screen. */
|
||||
@@ -111,7 +116,22 @@ export interface RecordCapabilities {
|
||||
*/
|
||||
export function resolveRecordCapabilities(
|
||||
active: AccountAccessEntry | null | undefined,
|
||||
accessRefused = false,
|
||||
recordSessionPending = false,
|
||||
): RecordCapabilities {
|
||||
if (accessRefused || recordSessionPending) {
|
||||
return {
|
||||
accessRefused: accessRefused || undefined,
|
||||
recordSessionPending: recordSessionPending || undefined,
|
||||
inSharedRecord: true,
|
||||
canWrite: false,
|
||||
canAdd: false,
|
||||
canManage: false,
|
||||
level: null,
|
||||
sections: [],
|
||||
recordKind: "shared",
|
||||
};
|
||||
}
|
||||
if (!active) {
|
||||
return {
|
||||
inSharedRecord: false,
|
||||
@@ -156,7 +176,12 @@ export function resolveRecordCapabilities(
|
||||
*/
|
||||
export function useRecordCapabilities(): RecordCapabilities {
|
||||
const { user } = useAuth();
|
||||
return resolveRecordCapabilities(user?.accountAccess?.active);
|
||||
const transition = useRecordSessionTransition();
|
||||
return resolveRecordCapabilities(
|
||||
user?.accountAccess?.active,
|
||||
user?.accountAccessStatus === "invalid",
|
||||
transition.phase !== "ready",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import {
|
||||
getRecordSessionTransition,
|
||||
subscribeToRecordSessionTransition,
|
||||
type RecordSessionTransition,
|
||||
} from "@/lib/query-keys/record-session-transition";
|
||||
|
||||
const SERVER_TRANSITION: RecordSessionTransition = {
|
||||
id: null,
|
||||
phase: "ready",
|
||||
expectedScope: undefined,
|
||||
};
|
||||
|
||||
function getServerRecordSessionTransition(): RecordSessionTransition {
|
||||
return SERVER_TRANSITION;
|
||||
}
|
||||
|
||||
/** The tab-local view of a browser-wide record-session transition. */
|
||||
export function useRecordSessionTransition(): RecordSessionTransition {
|
||||
return useSyncExternalStore(
|
||||
subscribeToRecordSessionTransition,
|
||||
getRecordSessionTransition,
|
||||
getServerRecordSessionTransition,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type {
|
||||
AccountAccessEntry,
|
||||
AccountRecordKind,
|
||||
} from "@/lib/sharing/account-access-view";
|
||||
|
||||
export type RecordAccessPresentation = "view" | "view-and-add" | "manage";
|
||||
|
||||
export interface RecordPresentation {
|
||||
access: RecordAccessPresentation;
|
||||
recordKind: AccountRecordKind;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server publishes both a resolved `canWrite` decision and the canonical
|
||||
* three-level label. Chrome binds those values here without treating either as
|
||||
* an authorization input: a handler still resolves the active grant afresh.
|
||||
*/
|
||||
export function resolveRecordPresentation(
|
||||
entry: Pick<AccountAccessEntry, "canWrite" | "level" | "recordKind">,
|
||||
): RecordPresentation {
|
||||
const validRecordKind =
|
||||
entry.recordKind === "shared" || entry.recordKind === "managed";
|
||||
const validAccess =
|
||||
(entry.level === "read" && entry.canWrite === false) ||
|
||||
((entry.level === "write" || entry.level === "manage") &&
|
||||
entry.canWrite === true);
|
||||
|
||||
// `parseAccountAccess` normally makes this branch unreachable. Keep the
|
||||
// presentation boundary defensive anyway: a stale cache or an untyped
|
||||
// consumer must never turn contradictory data into a stronger affordance or
|
||||
// claim that the browser is inside somebody else's record.
|
||||
if (!validRecordKind || !validAccess) {
|
||||
return { access: "view", recordKind: "self" };
|
||||
}
|
||||
|
||||
return {
|
||||
access:
|
||||
entry.level === "manage"
|
||||
? "manage"
|
||||
: entry.canWrite
|
||||
? "view-and-add"
|
||||
: "view",
|
||||
recordKind: entry.recordKind,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
ENTIRE_RECORD,
|
||||
SHARE_DOMAINS,
|
||||
type ShareDomain,
|
||||
type ShareScope,
|
||||
} from "@/lib/sharing/scope";
|
||||
|
||||
/**
|
||||
* The browser route families a scoped grant can present.
|
||||
*
|
||||
* This is deliberately a presentation inventory, not an authorization table.
|
||||
* `sections` arrives here as the server-resolved value from `accountAccess`;
|
||||
* the request handler still evaluates the active grant for every read and
|
||||
* mutation. Keeping the browser's route families closed lets a new domain
|
||||
* fail closed in chrome until it has been intentionally classified.
|
||||
*/
|
||||
export const SHARED_RECORD_DOMAIN_ROUTE_FAMILIES = {
|
||||
measurements: ["/measurements"],
|
||||
medications: ["/medications"],
|
||||
labs: ["/labs"],
|
||||
profile: ["/profile"],
|
||||
illness: ["/illness"],
|
||||
mind: ["/mood", "/mental-wellbeing"],
|
||||
cycle: ["/cycle"],
|
||||
documents: ["/documents"],
|
||||
} as const satisfies Record<ShareDomain, readonly `/${string}`[]>;
|
||||
|
||||
/**
|
||||
* Routes whose reads span multiple domains. A selected-domain grant never
|
||||
* opens these, even when it happens to name every currently known domain.
|
||||
*/
|
||||
const WHOLE_RECORD_ROUTE_FAMILIES = ["/", "/achievements"] as const;
|
||||
|
||||
interface RouteFamily {
|
||||
href: string;
|
||||
scope: ShareScope;
|
||||
}
|
||||
|
||||
const SHARED_RECORD_ROUTE_FAMILIES: readonly RouteFamily[] = [
|
||||
...WHOLE_RECORD_ROUTE_FAMILIES.map((href): RouteFamily => ({
|
||||
href,
|
||||
scope: ENTIRE_RECORD,
|
||||
})),
|
||||
...SHARE_DOMAINS.flatMap((domain): RouteFamily[] =>
|
||||
SHARED_RECORD_DOMAIN_ROUTE_FAMILIES[domain].map((href): RouteFamily => ({
|
||||
href,
|
||||
scope: domain,
|
||||
})),
|
||||
),
|
||||
];
|
||||
|
||||
function matchesRouteFamily(pathname: string, href: string): boolean {
|
||||
return href === "/"
|
||||
? pathname === href
|
||||
: pathname === href || pathname.startsWith(`${href}/`);
|
||||
}
|
||||
|
||||
function includesScope(
|
||||
sections: readonly ShareDomain[] | null,
|
||||
scope: ShareScope,
|
||||
): boolean {
|
||||
if (sections === null) return true;
|
||||
return scope !== ENTIRE_RECORD && sections.includes(scope);
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-resolved navigation paint for the active shared record.
|
||||
*
|
||||
* The return value never makes an authorization decision. It only prevents a
|
||||
* known-unavailable page from mounting its reads after the server has already
|
||||
* said which record sections the browser is inside.
|
||||
*/
|
||||
export function resolveSharedRecordNavigation(
|
||||
sections: readonly ShareDomain[] | null,
|
||||
) {
|
||||
const routeFamilies = SHARED_RECORD_ROUTE_FAMILIES.filter((family) =>
|
||||
includesScope(sections, family.scope),
|
||||
);
|
||||
|
||||
return {
|
||||
destinationHrefs: routeFamilies.map((family) => family.href),
|
||||
allowsPath(pathname: string): boolean {
|
||||
return routeFamilies.some((family) =>
|
||||
matchesRouteFamily(pathname, family.href),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The first scope-presentable location to load after a record switch. */
|
||||
export function sharedRecordLandingHref(
|
||||
sections: readonly ShareDomain[] | null,
|
||||
): string {
|
||||
return resolveSharedRecordNavigation(sections).destinationHrefs[0] ?? "/";
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the server-resolved scope make this browser route presentable?
|
||||
*
|
||||
* This helper is intentionally named for presentation. It must never be used
|
||||
* by a route handler or to choose an API request's target record.
|
||||
*/
|
||||
export function isSharedRecordPathPresentable(
|
||||
pathname: string,
|
||||
sections: readonly ShareDomain[] | null,
|
||||
): boolean {
|
||||
return resolveSharedRecordNavigation(sections).allowsPath(pathname);
|
||||
}
|
||||
@@ -23,6 +23,7 @@
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { accountAccessBlockSchema } from "@/lib/sharing/account-access-schema";
|
||||
import { SHARE_DOMAINS } from "@/lib/sharing/scope";
|
||||
import {
|
||||
inviteGrantSchema,
|
||||
@@ -210,72 +211,11 @@ export const accountSelectorParameter: NonNullable<
|
||||
|
||||
// ── What the client is told it may do ────────────────────────────────────────
|
||||
|
||||
const accountAccessEntry = z
|
||||
.object({
|
||||
accountId: z
|
||||
.string()
|
||||
.describe(
|
||||
"The account whose record it is — the value POST /api/account/switch takes, and the value the X-HealthLog-Account header carries.",
|
||||
),
|
||||
username: z.string(),
|
||||
displayName: z.string().nullable(),
|
||||
access: z
|
||||
.enum(["read", "write"])
|
||||
.describe(
|
||||
"The legacy grant level that shipped clients decode. MANAGE serializes as `write` here; new code reads canonical `level`.",
|
||||
),
|
||||
level: z
|
||||
.enum(["read", "write", "manage"])
|
||||
.describe(
|
||||
"The grant's resolved level. `manage` additionally admits changing and removing entries on the delegable surfaces; it never reaches the identity surfaces (settings, connections, tokens, consent, grant management), which stay refused on an invited record at every level. Render it; never derive it from `canWrite`.",
|
||||
),
|
||||
recordKind: z
|
||||
.enum(["self", "shared", "managed"])
|
||||
.describe(
|
||||
"Server-resolved presentation metadata for the record. It is never accepted as an authorization input.",
|
||||
),
|
||||
sections: z
|
||||
.array(shareSection)
|
||||
.nullable()
|
||||
.describe(
|
||||
"The sections of that record this caller may open, or null for the entire record. Null is first-class and is what every pre-v1.37.0 grant carries; a MANAGE grant always carries it too. A non-null array names the open sections and NOTHING else answers — a client that switches into a scoped record without honouring this gets the standard 403 on every out-of-scope route, fail-closed and byte-identical to every other sharing refusal, which is safe but reads as a wall of errors. An empty array means the grant opens nothing. Ordered by the consent screen's reading order.",
|
||||
),
|
||||
canWrite: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"Whether this caller may ADD to that record: true for an accepted WRITE or MANAGE grant, false for a READ one. On its own it never means edit or delete — a WRITE grant adds and does nothing more. What MANAGE additionally admits is said by `level`. Resolved server-side. Render it; never derive it.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "AccountAccessEntry",
|
||||
description:
|
||||
"One account this caller may act on. No avatar is published: the avatar bytes are owner-scoped, so a URL here would resolve to a refusal. Clients paint their initials fallback.",
|
||||
});
|
||||
|
||||
export const accountAccessBlock = z
|
||||
.object({
|
||||
accounts: z
|
||||
.array(accountAccessEntry)
|
||||
.describe("Every record this caller may open, newest grant first."),
|
||||
active: accountAccessEntry
|
||||
.nullable()
|
||||
.describe(
|
||||
"The record this session is inside right now, resolved to a full entry rather than an id to look up — so a banner can name the person without joining two fields. Null when the caller is in their own record, and ALWAYS null on the Bearer transport: a token carries its selector per request and this endpoint refuses one.",
|
||||
),
|
||||
recordKind: z
|
||||
.enum(["self", "shared", "managed"])
|
||||
.describe(
|
||||
"The server-resolved kind of the record currently in view. `self` applies when `active` is null.",
|
||||
),
|
||||
canSwitch: z
|
||||
.boolean()
|
||||
.describe("Whether there is anywhere to switch to. Bind it directly."),
|
||||
})
|
||||
.meta({
|
||||
id: "AccountAccess",
|
||||
description:
|
||||
"Account sharing, resolved. This block is the ONLY source of switchability and writability: the server publishes what the caller may do and the client renders it. A client that computed either from grant data would be a second program deciding one person's access to a health record, and the two answer differently the first time an expiry or a revocation appears. Always present — an account nobody has shared with gets an empty list, not a missing field.",
|
||||
});
|
||||
export const accountAccessBlock = accountAccessBlockSchema.meta({
|
||||
id: "AccountAccess",
|
||||
description:
|
||||
"Account sharing, resolved. This block is the ONLY source of switchability and writability: the server publishes what the caller may do and the client renders it. A client that computed either from grant data would be a second program deciding one person's access to a health record, and the two answer differently the first time an expiry or a revocation appears. Always present — an account nobody has shared with gets an empty list, not a missing field.",
|
||||
});
|
||||
|
||||
/**
|
||||
* The account payload, documented for the one field this release adds.
|
||||
|
||||
@@ -172,6 +172,7 @@ function currentUserId(queryClient: QueryClient): string | null {
|
||||
export async function restorePersistedQueryCache(
|
||||
queryClient: QueryClient,
|
||||
buildVersion: string,
|
||||
canHydrate: () => boolean = () => true,
|
||||
): Promise<void> {
|
||||
if (typeof indexedDB === "undefined") return;
|
||||
// v1.36.0 — a browser inside somebody else's record restores nothing. The
|
||||
@@ -219,7 +220,7 @@ export async function restorePersistedQueryCache(
|
||||
(q) => cache.get(q.queryHash) === undefined,
|
||||
),
|
||||
};
|
||||
if (filtered.queries.length === 0) return;
|
||||
if (filtered.queries.length === 0 || !canHydrate()) return;
|
||||
|
||||
hydrate(queryClient, filtered);
|
||||
// Restored queries carry their original `dataUpdatedAt`, so with the
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { QUERY_CLIENT_DEFAULT_OPTIONS } from "@/lib/pwa/query-client-options";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
__resetRecordScopeForTests,
|
||||
setRecordScope,
|
||||
} from "@/lib/query-keys/record-scope";
|
||||
import { prefetchMedicationsList } from "../prefetch-medications";
|
||||
|
||||
function envelopeResponse(data: unknown): Response {
|
||||
return new Response(JSON.stringify({ data, error: null }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
__resetRecordScopeForTests();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("prefetchMedicationsList", () => {
|
||||
it("does not park an aborted shared-record preload in the own-record cache", async () => {
|
||||
setRecordScope("shared-record");
|
||||
const controller = new AbortController();
|
||||
const fetchMock = vi.fn(
|
||||
(_path: string, init?: RequestInit) =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener("abort", () => {
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
});
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const client = new QueryClient({
|
||||
defaultOptions: QUERY_CLIENT_DEFAULT_OPTIONS,
|
||||
});
|
||||
|
||||
prefetchMedicationsList(client, controller.signal);
|
||||
controller.abort();
|
||||
await vi.waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
setRecordScope(null);
|
||||
|
||||
expect(client.getQueryData(queryKeys.medications())).toBeUndefined();
|
||||
expect(
|
||||
client.getQueryData(queryKeys.medicationComplianceSummary()),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("passes a lifecycle signal to both record reads", async () => {
|
||||
const controller = new AbortController();
|
||||
const fetchMock = vi.fn().mockResolvedValue(envelopeResponse([]));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const client = new QueryClient({
|
||||
defaultOptions: QUERY_CLIENT_DEFAULT_OPTIONS,
|
||||
});
|
||||
|
||||
prefetchMedicationsList(client, controller.signal);
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
signal: controller.signal,
|
||||
});
|
||||
expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({
|
||||
signal: controller.signal,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,10 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { useAchievementsQuery } from "../use-achievements-query";
|
||||
import {
|
||||
canFetchAchievements,
|
||||
useAchievementsQuery,
|
||||
} from "../use-achievements-query";
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ isAuthenticated: true }),
|
||||
@@ -90,6 +93,13 @@ function withQueryClient(node: ReactNode): string {
|
||||
}
|
||||
|
||||
describe("useAchievementsQuery — consumer collapse", () => {
|
||||
it("does not start the whole-record poll in a narrowed shared record", () => {
|
||||
expect(canFetchAchievements({ sections: ["profile"] })).toBe(false);
|
||||
expect(canFetchAchievements({ sections: [] })).toBe(false);
|
||||
expect(canFetchAchievements({ sections: null })).toBe(true);
|
||||
expect(canFetchAchievements(null)).toBe(true);
|
||||
});
|
||||
|
||||
it("fires a single network call when three consumers mount together", async () => {
|
||||
withQueryClient(
|
||||
<>
|
||||
|
||||
@@ -43,6 +43,10 @@ import {
|
||||
} from "../use-dashboard-snapshot";
|
||||
import { retryOnceOnTransientError } from "../retry-transient";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
__resetRecordScopeForTests,
|
||||
setRecordScope,
|
||||
} from "@/lib/query-keys/record-scope";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
/** Envelope-shaped Response — the snapshot fetch rides `apiGet` now. */
|
||||
@@ -58,6 +62,7 @@ afterEach(() => {
|
||||
getQueryDataMock.mockReset();
|
||||
setQueryDataMock.mockClear();
|
||||
_resetDashboardSnapshotPreloadForTests();
|
||||
__resetRecordScopeForTests();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
@@ -257,6 +262,32 @@ describe("prefetchDashboardSnapshot — hydration-safe promise handoff", () => {
|
||||
expect(snap.layout).toEqual(layout);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("drops a parked shared-record response after the record scope changes", async () => {
|
||||
setRecordScope("shared-record");
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
envelopeResponse({ layout: { owner: true }, tiles: {} }),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
envelopeResponse({ layout: { owner: false }, tiles: {} }),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
getQueryDataMock.mockReturnValue({ widgets: [{ id: "existing" }] });
|
||||
|
||||
prefetchDashboardSnapshot(fakeQueryClient());
|
||||
setRecordScope(null);
|
||||
|
||||
useDashboardSnapshot();
|
||||
const opts = lastOptsOf(useQueryMock);
|
||||
const snapshot = await (
|
||||
opts.queryFn as () => Promise<{ layout: unknown }>
|
||||
)();
|
||||
|
||||
expect(snapshot.layout).toEqual({ owner: false });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
function lastOptsOf(mock: typeof useQueryMock): Record<string, unknown> {
|
||||
|
||||
@@ -29,15 +29,18 @@ import { apiGet } from "@/lib/api/api-fetch";
|
||||
*/
|
||||
export const MEDICATIONS_LIST_STALE_TIME_MS = 15_000;
|
||||
|
||||
export function prefetchMedicationsList(queryClient: QueryClient): void {
|
||||
export function prefetchMedicationsList(
|
||||
queryClient: QueryClient,
|
||||
signal?: AbortSignal,
|
||||
): void {
|
||||
void queryClient.prefetchQuery({
|
||||
queryKey: queryKeys.medications(),
|
||||
queryFn: () => apiGet("/api/medications"),
|
||||
queryFn: () => apiGet("/api/medications", { signal }),
|
||||
staleTime: MEDICATIONS_LIST_STALE_TIME_MS,
|
||||
});
|
||||
void queryClient.prefetchQuery({
|
||||
queryKey: queryKeys.medicationComplianceSummary(),
|
||||
queryFn: () => apiGet("/api/medications/compliance"),
|
||||
queryFn: () => apiGet("/api/medications/compliance", { signal }),
|
||||
staleTime: MEDICATIONS_LIST_STALE_TIME_MS,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { AccountAccessEntry } from "@/lib/sharing/account-access-view";
|
||||
import type {
|
||||
AchievementMetrics,
|
||||
AchievementProgress,
|
||||
@@ -70,6 +71,17 @@ function fetchAchievements(): Promise<AchievementsPayload> {
|
||||
return apiGet<AchievementsPayload>("/api/gamification/achievements");
|
||||
}
|
||||
|
||||
/**
|
||||
* Achievements aggregate more than one record section, so a narrowed grant
|
||||
* never reaches the endpoint. Keep its shell-level poll dormant instead of
|
||||
* turning a correctly refused background read into an apparent grant loss.
|
||||
*/
|
||||
export function canFetchAchievements(
|
||||
active: Pick<AccountAccessEntry, "sections"> | null | undefined,
|
||||
): boolean {
|
||||
return active === null || active === undefined || active.sections === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared hook. Returns the unwrapped `data` payload typed as
|
||||
* `AchievementsPayload`. Consumers narrow at the call site.
|
||||
@@ -77,9 +89,10 @@ function fetchAchievements(): Promise<AchievementsPayload> {
|
||||
export function useAchievementsQuery(
|
||||
options: UseAchievementsQueryOptions = {},
|
||||
): UseQueryResult<AchievementsPayload, Error> {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const enabled =
|
||||
options.enabled !== undefined ? options.enabled : isAuthenticated;
|
||||
(options.enabled !== undefined ? options.enabled : isAuthenticated) &&
|
||||
canFetchAchievements(user?.accountAccess?.active);
|
||||
const refetchInterval = options.refetchInterval ?? false;
|
||||
|
||||
return useQuery({
|
||||
|
||||
@@ -40,16 +40,19 @@ import {
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { getRecordScope } from "@/lib/query-keys/record-scope";
|
||||
import { DASHBOARD_REFETCH_INTERVAL_MS } from "@/lib/queries/refetch-interval";
|
||||
import { retryOnceOnTransientError } from "@/lib/queries/retry-transient";
|
||||
import type { DashboardSnapshot } from "@/lib/dashboard/snapshot";
|
||||
|
||||
async function fetchDashboardSnapshot(): Promise<DashboardSnapshot> {
|
||||
async function fetchDashboardSnapshot(
|
||||
signal?: AbortSignal,
|
||||
): Promise<DashboardSnapshot> {
|
||||
// Routed through the typed wrapper so a failure carries its HTTP
|
||||
// status (`ApiError`) for the transient-retry predicate, and the
|
||||
// default 15 s timeout keeps a stalled response from pinning the
|
||||
// whole dashboard on its skeleton.
|
||||
return apiGet<DashboardSnapshot>("/api/dashboard/snapshot");
|
||||
return apiGet<DashboardSnapshot>("/api/dashboard/snapshot", { signal });
|
||||
}
|
||||
|
||||
/** Handoff freshness window — mirrors the hook's `staleTime`. */
|
||||
@@ -68,12 +71,18 @@ const PRELOAD_MAX_AGE_MS = 60_000;
|
||||
let preloadedSnapshot: {
|
||||
promise: Promise<DashboardSnapshot>;
|
||||
startedAt: number;
|
||||
recordScope: string | null;
|
||||
} | null = null;
|
||||
|
||||
export function _resetDashboardSnapshotPreloadForTests(): void {
|
||||
preloadedSnapshot = null;
|
||||
}
|
||||
|
||||
/** Drop a handoff when a tab begins or receives a record-session switch. */
|
||||
export function discardDashboardSnapshotPreload(): void {
|
||||
preloadedSnapshot = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kick the snapshot fetch off ahead of the dashboard page chunk.
|
||||
*
|
||||
@@ -88,7 +97,10 @@ export function _resetDashboardSnapshotPreloadForTests(): void {
|
||||
* `queryFn` consumes it; preload errors are swallowed by design — the
|
||||
* mounted cell owns error surfacing with its own fresh attempt.
|
||||
*/
|
||||
export function prefetchDashboardSnapshot(queryClient: QueryClient) {
|
||||
export function prefetchDashboardSnapshot(
|
||||
queryClient: QueryClient,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
// Expired handoff from a previous visit — drop it so a long-idle slot
|
||||
// can never serve stale data.
|
||||
if (
|
||||
@@ -128,22 +140,30 @@ export function prefetchDashboardSnapshot(queryClient: QueryClient) {
|
||||
if (freshest > 0) {
|
||||
return;
|
||||
}
|
||||
const promise = fetchDashboardSnapshot();
|
||||
const recordScope = getRecordScope();
|
||||
const promise = fetchDashboardSnapshot(signal);
|
||||
promise.catch(() => {
|
||||
// Swallow + clear so an unconsumed failed preload (racing 401 on
|
||||
// login, transient network) never becomes an unhandled rejection and
|
||||
// never gets handed to a later mount.
|
||||
if (preloadedSnapshot?.promise === promise) preloadedSnapshot = null;
|
||||
});
|
||||
preloadedSnapshot = { promise, startedAt: Date.now() };
|
||||
preloadedSnapshot = { promise, startedAt: Date.now(), recordScope };
|
||||
}
|
||||
|
||||
/** Single-use consume of the preload handoff; `null` when absent/expired. */
|
||||
function takePreloadedSnapshot(): Promise<DashboardSnapshot> | null {
|
||||
if (!preloadedSnapshot) return null;
|
||||
const { promise, startedAt } = preloadedSnapshot;
|
||||
const { promise, startedAt, recordScope } = preloadedSnapshot;
|
||||
preloadedSnapshot = null;
|
||||
return Date.now() - startedAt < PRELOAD_MAX_AGE_MS ? promise : null;
|
||||
// A handoff is not query-cached, so it needs the same record boundary as a
|
||||
// query cache entry. A scope move while it is in flight must make the next
|
||||
// mounted dashboard fetch afresh instead of adopting a response from the
|
||||
// record that just ended.
|
||||
return Date.now() - startedAt < PRELOAD_MAX_AGE_MS &&
|
||||
recordScope === getRecordScope()
|
||||
? promise
|
||||
: null;
|
||||
}
|
||||
|
||||
function makeSnapshotQueryFn(queryClient: QueryClient) {
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
getRecordScope,
|
||||
isReadingSharedRecord,
|
||||
recordScopedQueryKeyHashFn,
|
||||
setRefusedRecordScope,
|
||||
setRecordScope,
|
||||
} from "@/lib/query-keys/record-scope";
|
||||
|
||||
@@ -101,6 +102,24 @@ describe("the cache cannot serve one record's data while reading another", () =>
|
||||
expect(client.getQueryData(queryKeys.dashboardSnapshot())).toBeUndefined();
|
||||
});
|
||||
|
||||
it("never puts data from a refused record into the caller's own cache slot", () => {
|
||||
const client = appClient();
|
||||
client.setQueryData(queryKeys.dashboardSnapshot(), { weightKg: 71 });
|
||||
|
||||
setRefusedRecordScope();
|
||||
|
||||
expect(isReadingSharedRecord()).toBe(true);
|
||||
expect(client.getQueryData(queryKeys.dashboardSnapshot())).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the auth identity reachable when a refused record changes scope", () => {
|
||||
const ownAuthHash = recordScopedQueryKeyHashFn(queryKeys.authMe());
|
||||
|
||||
setRefusedRecordScope();
|
||||
|
||||
expect(recordScopedQueryKeyHashFn(queryKeys.authMe())).toBe(ownAuthHash);
|
||||
});
|
||||
|
||||
it("keeps two records' entries apart at the same time", () => {
|
||||
const client = appClient();
|
||||
setRecordScope("acct-a");
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
__resetRecordSessionTransitionForTests,
|
||||
beginRecordSessionTransition,
|
||||
commitRecordSessionTransition,
|
||||
getRecordSessionTransition,
|
||||
resolveUnknownRecordSessionTransition,
|
||||
settleRefusedRecordSessionTransition,
|
||||
settleRecordSessionTransition,
|
||||
} from "@/lib/query-keys/record-session-transition";
|
||||
import {
|
||||
__resetRecordScopeForTests,
|
||||
getRecordScope,
|
||||
setRecordScope,
|
||||
} from "@/lib/query-keys/record-scope";
|
||||
|
||||
beforeEach(() => {
|
||||
__resetRecordScopeForTests();
|
||||
__resetRecordSessionTransitionForTests();
|
||||
});
|
||||
|
||||
describe("record session transition", () => {
|
||||
it("blocks every tab before the server switch starts", () => {
|
||||
setRecordScope(null);
|
||||
|
||||
const transitionId = beginRecordSessionTransition("shared-record");
|
||||
|
||||
expect(getRecordSessionTransition()).toMatchObject({
|
||||
id: transitionId,
|
||||
phase: "blocking",
|
||||
expectedScope: "shared-record",
|
||||
});
|
||||
expect(getRecordScope()).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the hold through the committed scope until /me confirms it", () => {
|
||||
const transitionId = beginRecordSessionTransition("shared-record");
|
||||
|
||||
commitRecordSessionTransition(transitionId, "shared-record");
|
||||
|
||||
expect(getRecordScope()).toBe("shared-record");
|
||||
expect(getRecordSessionTransition()).toMatchObject({
|
||||
id: transitionId,
|
||||
phase: "resolving",
|
||||
expectedScope: "shared-record",
|
||||
});
|
||||
|
||||
settleRecordSessionTransition("shared-record");
|
||||
|
||||
expect(getRecordSessionTransition().phase).toBe("ready");
|
||||
});
|
||||
|
||||
it("keeps a failed switch held until /me reconciles the prior scope", () => {
|
||||
setRecordScope(null);
|
||||
const transitionId = beginRecordSessionTransition("shared-record");
|
||||
|
||||
resolveUnknownRecordSessionTransition(transitionId);
|
||||
|
||||
expect(getRecordSessionTransition()).toMatchObject({
|
||||
id: transitionId,
|
||||
phase: "resolving",
|
||||
expectedScope: undefined,
|
||||
});
|
||||
expect(getRecordScope()).toBeNull();
|
||||
|
||||
settleRecordSessionTransition(null);
|
||||
|
||||
expect(getRecordSessionTransition().phase).toBe("ready");
|
||||
});
|
||||
|
||||
it("does not release a committed transition for a divergent /me response", () => {
|
||||
const transitionId = beginRecordSessionTransition("shared-record");
|
||||
commitRecordSessionTransition(transitionId, "shared-record");
|
||||
|
||||
settleRecordSessionTransition("different-record");
|
||||
|
||||
expect(getRecordSessionTransition()).toMatchObject({
|
||||
phase: "resolving",
|
||||
expectedScope: "shared-record",
|
||||
});
|
||||
});
|
||||
|
||||
it("releases an explicitly refused /me response without trusting a scope", () => {
|
||||
const transitionId = beginRecordSessionTransition("shared-record");
|
||||
commitRecordSessionTransition(transitionId, "shared-record");
|
||||
|
||||
settleRefusedRecordSessionTransition();
|
||||
|
||||
expect(getRecordSessionTransition()).toEqual({
|
||||
id: transitionId,
|
||||
phase: "ready",
|
||||
expectedScope: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,7 @@ import { mentalHealthKeys } from "./mental-health";
|
||||
import { moodKeys } from "./mood";
|
||||
import { nutrientKeys } from "./nutrients";
|
||||
import { recordSettingsKeys } from "./record-settings";
|
||||
import { profileKeys } from "./profile";
|
||||
import { settingsKeys } from "./settings";
|
||||
import { sharingKeys } from "./sharing";
|
||||
import { workoutKeys } from "./workouts";
|
||||
@@ -65,6 +66,7 @@ export const queryKeys = {
|
||||
...customMetrics,
|
||||
...nutrientKeys,
|
||||
...recordSettingsKeys,
|
||||
...profileKeys,
|
||||
...sharingKeys,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Query keys for the bounded, read-only profile summary surface. */
|
||||
export const profileKeys = {
|
||||
profileSummary: () => ["profile", "summary"] as const,
|
||||
};
|
||||
@@ -51,7 +51,8 @@
|
||||
*/
|
||||
import type { QueryKey } from "@tanstack/react-query";
|
||||
|
||||
const STORAGE_KEY = "healthlog-record-scope";
|
||||
export const RECORD_SCOPE_STORAGE_KEY = "healthlog-record-scope";
|
||||
const REFUSED_RECORD_SCOPE = "__healthlog_refused_record__";
|
||||
|
||||
/**
|
||||
* The in-process copy. Seeded from storage on first read so the hash function
|
||||
@@ -59,10 +60,15 @@ const STORAGE_KEY = "healthlog-record-scope";
|
||||
*/
|
||||
let scope: string | null = null;
|
||||
let seeded = false;
|
||||
const scopeListeners = new Set<() => void>();
|
||||
|
||||
function notifyScopeListeners(): void {
|
||||
for (const listener of scopeListeners) listener();
|
||||
}
|
||||
|
||||
function readMirror(): string | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
const raw = localStorage.getItem(RECORD_SCOPE_STORAGE_KEY);
|
||||
return raw === null || raw.length === 0 ? null : raw;
|
||||
} catch {
|
||||
// Private mode, disabled storage, or SSR. "Own record" is the safe
|
||||
@@ -104,13 +110,53 @@ export function setRecordScope(accountId: string | null): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
if (accountId === null) {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
localStorage.removeItem(RECORD_SCOPE_STORAGE_KEY);
|
||||
} else {
|
||||
localStorage.setItem(STORAGE_KEY, accountId);
|
||||
localStorage.setItem(RECORD_SCOPE_STORAGE_KEY, accountId);
|
||||
}
|
||||
} catch {
|
||||
/* storage unavailable — the in-process copy still partitions this tab */
|
||||
}
|
||||
notifyScopeListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* React bridge for consumers that must stop work as soon as another tab moves
|
||||
* the session to a different record. `storage` never fires in the tab that
|
||||
* wrote the mirror, so local writes notify directly above; other tabs adopt
|
||||
* the new value before notifying their subscribers.
|
||||
*/
|
||||
export function subscribeToRecordScope(listener: () => void): () => void {
|
||||
scopeListeners.add(listener);
|
||||
if (typeof window === "undefined") {
|
||||
return () => scopeListeners.delete(listener);
|
||||
}
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key !== RECORD_SCOPE_STORAGE_KEY) return;
|
||||
scope =
|
||||
event.newValue === null || event.newValue.length === 0
|
||||
? null
|
||||
: event.newValue;
|
||||
seeded = true;
|
||||
notifyScopeListeners();
|
||||
};
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => {
|
||||
scopeListeners.delete(listener);
|
||||
window.removeEventListener("storage", onStorage);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep a malformed active-record response out of the caller's own cache slot.
|
||||
*
|
||||
* No record id from an invalid payload is trusted, but `null` would make
|
||||
* record-scoped reads share the caller's own cache identity. This sentinel is
|
||||
* a cache partition only; the shell refuses the presentation until the caller
|
||||
* leaves the server-side switch.
|
||||
*/
|
||||
export function setRefusedRecordScope(): void {
|
||||
setRecordScope(REFUSED_RECORD_SCOPE);
|
||||
}
|
||||
|
||||
/** True while this browser is reading somebody else's record. */
|
||||
@@ -138,6 +184,10 @@ export function recordScopedQueryKeyHashFn(queryKey: QueryKey): string {
|
||||
}, {})
|
||||
: val,
|
||||
);
|
||||
// `/api/auth/me` identifies the caller, never the record they are reading.
|
||||
// Its query begins before `fetchMe` can resolve or refuse the record scope,
|
||||
// so it must remain reachable across that transition.
|
||||
if (queryKey[0] === "auth") return key;
|
||||
const record = getRecordScope();
|
||||
return record === null ? key : `record:${record}|${key}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RECORD_SCOPE_STORAGE_KEY,
|
||||
setRecordScope,
|
||||
} from "@/lib/query-keys/record-scope";
|
||||
|
||||
/**
|
||||
* A browser-wide hold around a server-side record-session switch.
|
||||
*
|
||||
* The session stamp is shared by every tab, but React Query and `/api/auth/me`
|
||||
* are tab-local. A peer with a five-minute fresh `/me` cache can otherwise
|
||||
* keep owner chrome mounted after another tab changes the server session. The
|
||||
* switcher therefore publishes `blocking` before its POST. Recipients clear
|
||||
* their local reads and render only the hydration gate. A committed scope then
|
||||
* moves every tab to `resolving`; only a fresh `/me` whose active record
|
||||
* exactly matches the committed scope releases the hold.
|
||||
*
|
||||
* This is a browser-side coordination point, not a distributed-security
|
||||
* fence: BroadcastChannel and storage delivery cannot pre-empt a request that
|
||||
* a suspended peer already sent. The server remains authoritative for every
|
||||
* such request. After a tab processes the hold, this protocol removes local
|
||||
* results and prevents its protected shell from issuing new reads until `/me`
|
||||
* resolves the scope.
|
||||
*/
|
||||
export type RecordSessionPhase = "ready" | "blocking" | "resolving";
|
||||
|
||||
export interface RecordSessionTransition {
|
||||
id: string | null;
|
||||
phase: RecordSessionPhase;
|
||||
/** Undefined only for a recovery reload whose server outcome is unknown. */
|
||||
expectedScope: string | null | undefined;
|
||||
}
|
||||
|
||||
interface WireTransition {
|
||||
id: string;
|
||||
phase: Exclude<RecordSessionPhase, "ready"> | "ready";
|
||||
expectedScope?: string | null;
|
||||
at: number;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "healthlog-record-session-transition";
|
||||
const CHANNEL_NAME = "healthlog-record-session-transition";
|
||||
const TRANSITION_MAX_AGE_MS = 30_000;
|
||||
const READY: RecordSessionTransition = {
|
||||
id: null,
|
||||
phase: "ready",
|
||||
expectedScope: undefined,
|
||||
};
|
||||
|
||||
let transition = READY;
|
||||
let seeded = false;
|
||||
const listeners = new Set<() => void>();
|
||||
let channel: BroadcastChannel | null = null;
|
||||
let storageListenerAttached = false;
|
||||
|
||||
function notify(): void {
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function makeId(): string {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
function parseWire(value: string | null): WireTransition | null {
|
||||
if (value === null) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(value) as Partial<WireTransition>;
|
||||
if (
|
||||
typeof parsed.id !== "string" ||
|
||||
(parsed.phase !== "blocking" &&
|
||||
parsed.phase !== "resolving" &&
|
||||
parsed.phase !== "ready") ||
|
||||
typeof parsed.at !== "number" ||
|
||||
Date.now() - parsed.at > TRANSITION_MAX_AGE_MS
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
parsed.expectedScope !== undefined &&
|
||||
parsed.expectedScope !== null &&
|
||||
typeof parsed.expectedScope !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return parsed as WireTransition;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function toSnapshot(next: WireTransition): RecordSessionTransition {
|
||||
return {
|
||||
id: next.id,
|
||||
// A new tab cannot know whether an inherited blocking POST is still in
|
||||
// flight. It resolves `/me` rather than leaving the browser wedged if the
|
||||
// initiating tab crashed between its POST and terminal message.
|
||||
phase: next.phase === "blocking" && !seeded ? "resolving" : next.phase,
|
||||
expectedScope: next.expectedScope,
|
||||
};
|
||||
}
|
||||
|
||||
function applyWire(next: WireTransition): void {
|
||||
if (
|
||||
transition.id === next.id &&
|
||||
transition.phase === next.phase &&
|
||||
transition.expectedScope === next.expectedScope
|
||||
) {
|
||||
return;
|
||||
}
|
||||
transition = toSnapshot(next);
|
||||
seeded = true;
|
||||
notify();
|
||||
}
|
||||
|
||||
function writeWire(next: WireTransition): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* storage unavailable — BroadcastChannel can still notify peers */
|
||||
}
|
||||
}
|
||||
|
||||
function postWire(next: WireTransition): void {
|
||||
try {
|
||||
channel?.postMessage(next);
|
||||
} catch {
|
||||
/* a closed channel is only an optimisation loss; storage remains */
|
||||
}
|
||||
}
|
||||
|
||||
function publish(next: WireTransition): void {
|
||||
applyWire(next);
|
||||
writeWire(next);
|
||||
postWire(next);
|
||||
}
|
||||
|
||||
function ensureTransport(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
if (!storageListenerAttached) {
|
||||
window.addEventListener("storage", (event: StorageEvent) => {
|
||||
if (event.key === STORAGE_KEY) {
|
||||
const next = parseWire(event.newValue);
|
||||
if (next) applyWire(next);
|
||||
return;
|
||||
}
|
||||
// A scope write without a transition marker is an external/legacy
|
||||
// switch. Fail closed and force `/me` to reconcile it before any stale
|
||||
// shell can issue a new record read.
|
||||
if (event.key === RECORD_SCOPE_STORAGE_KEY) {
|
||||
const expectedScope =
|
||||
event.newValue === null || event.newValue.length === 0
|
||||
? null
|
||||
: event.newValue;
|
||||
// A current protocol transition already names this scope. The scope
|
||||
// mirror is its second storage write, not a separate legacy switch.
|
||||
if (
|
||||
transition.phase !== "ready" &&
|
||||
transition.expectedScope === expectedScope
|
||||
) {
|
||||
return;
|
||||
}
|
||||
publish({
|
||||
id: makeId(),
|
||||
phase: "resolving",
|
||||
expectedScope,
|
||||
at: Date.now(),
|
||||
});
|
||||
}
|
||||
});
|
||||
storageListenerAttached = true;
|
||||
}
|
||||
if (channel === null && typeof BroadcastChannel !== "undefined") {
|
||||
channel = new BroadcastChannel(CHANNEL_NAME);
|
||||
channel.onmessage = (event: MessageEvent<unknown>) => {
|
||||
const value = event.data;
|
||||
if (value === null || typeof value !== "object") return;
|
||||
const next = parseWire(JSON.stringify(value));
|
||||
if (next) applyWire(next);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function seed(): void {
|
||||
if (seeded) return;
|
||||
if (typeof window === "undefined") {
|
||||
seeded = true;
|
||||
return;
|
||||
}
|
||||
const next = parseWire(localStorage.getItem(STORAGE_KEY));
|
||||
if (next) {
|
||||
transition = toSnapshot(next);
|
||||
}
|
||||
seeded = true;
|
||||
}
|
||||
|
||||
export function getRecordSessionTransition(): RecordSessionTransition {
|
||||
seed();
|
||||
return transition;
|
||||
}
|
||||
|
||||
export function subscribeToRecordSessionTransition(
|
||||
listener: () => void,
|
||||
): () => void {
|
||||
seed();
|
||||
ensureTransport();
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
/** Publish the safe hold before issuing `POST /api/account/switch`. */
|
||||
export function beginRecordSessionTransition(
|
||||
expectedScope: string | null,
|
||||
): string {
|
||||
seed();
|
||||
ensureTransport();
|
||||
const id = makeId();
|
||||
publish({ id, phase: "blocking", expectedScope, at: Date.now() });
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Record the server-confirmed scope, while every tab still waits for `/me`. */
|
||||
export function commitRecordSessionTransition(
|
||||
id: string,
|
||||
expectedScope: string | null,
|
||||
): void {
|
||||
if (transition.id !== id) return;
|
||||
publish({ id, phase: "resolving", expectedScope, at: Date.now() });
|
||||
// The terminal transition marker is deliberately published before the
|
||||
// scope mirror. A peer that receives the two storage events separately is
|
||||
// already held when it learns the new record hash.
|
||||
setRecordScope(expectedScope);
|
||||
}
|
||||
|
||||
/**
|
||||
* A switch response was lost after the request was made. Keep all tabs held
|
||||
* and let `/me` decide which record the server actually kept.
|
||||
*/
|
||||
export function resolveUnknownRecordSessionTransition(id: string): void {
|
||||
if (transition.id !== id) return;
|
||||
publish({ id, phase: "resolving", at: Date.now() });
|
||||
}
|
||||
|
||||
/** Release only the `/me` response that confirms the committed record. */
|
||||
export function settleRecordSessionTransition(
|
||||
resolvedScope: string | null,
|
||||
): void {
|
||||
if (transition.phase !== "resolving") return;
|
||||
if (
|
||||
transition.expectedScope !== undefined &&
|
||||
transition.expectedScope !== resolvedScope
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const id = transition.id ?? makeId();
|
||||
publish({ id, phase: "ready", at: Date.now() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the browser hold after a server response has explicitly refused its
|
||||
* access block. The caller has already installed the refused scope sentinel,
|
||||
* so returning to `ready` can only render the access-refusal door; it never
|
||||
* restores a prior record scope.
|
||||
*/
|
||||
export function settleRefusedRecordSessionTransition(): void {
|
||||
if (transition.phase !== "resolving") return;
|
||||
const id = transition.id ?? makeId();
|
||||
publish({ id, phase: "ready", at: Date.now() });
|
||||
}
|
||||
|
||||
/** @internal — deterministic transition state for unit tests. */
|
||||
export function __resetRecordSessionTransitionForTests(): void {
|
||||
transition = READY;
|
||||
seeded = false;
|
||||
listeners.clear();
|
||||
channel?.close();
|
||||
channel = null;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { readHealthProfileFacts } from "@/lib/profile/health-facts";
|
||||
|
||||
export interface ProfileSummaryPayload {
|
||||
allergies: Array<{ substance: string; category: string }>;
|
||||
familyHistory: Array<{ condition: string; relationship: string }>;
|
||||
facts: Array<{ label: string; value: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared profile surface deliberately carries only the bounded, structured
|
||||
* facts a record viewer needs. It does not load settings, AI configuration, or
|
||||
* any encrypted free-text field.
|
||||
*/
|
||||
export async function readProfileSummary(
|
||||
userId: string,
|
||||
): Promise<ProfileSummaryPayload> {
|
||||
const [allergies, familyHistory, healthFacts] = await Promise.all([
|
||||
prisma.allergy.findMany({
|
||||
where: { userId, deletedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 25,
|
||||
select: { substance: true, category: true },
|
||||
}),
|
||||
prisma.familyHistoryEntry.findMany({
|
||||
where: { userId, deletedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 25,
|
||||
select: { condition: true, relationship: true },
|
||||
}),
|
||||
readHealthProfileFacts(userId),
|
||||
]);
|
||||
|
||||
return {
|
||||
allergies,
|
||||
familyHistory,
|
||||
facts: Object.values(healthFacts.current).flatMap((fact) =>
|
||||
fact?.value && !fact.unreadable
|
||||
? [{ label: fact.kind, value: fact.value }]
|
||||
: [],
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseAccountAccess } from "../account-access-schema";
|
||||
|
||||
const sharedEntry = {
|
||||
accountId: "record-a",
|
||||
username: "record-a",
|
||||
displayName: "Record A",
|
||||
access: "read",
|
||||
level: "read",
|
||||
recordKind: "shared",
|
||||
sections: ["labs"],
|
||||
canWrite: false,
|
||||
};
|
||||
|
||||
function accessBlock(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
accounts: [sharedEntry],
|
||||
active: sharedEntry,
|
||||
recordKind: "shared",
|
||||
canSwitch: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("parseAccountAccess", () => {
|
||||
it("preserves canonical shared payloads, including an empty scope", () => {
|
||||
const scoped = parseAccountAccess(accessBlock());
|
||||
const empty = parseAccountAccess(
|
||||
accessBlock({
|
||||
accounts: [{ ...sharedEntry, sections: [] }],
|
||||
active: { ...sharedEntry, sections: [] },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(scoped?.active?.sections).toEqual(["labs"]);
|
||||
expect(empty?.active?.sections).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves the canonical manage compatibility fields", () => {
|
||||
const manager = {
|
||||
...sharedEntry,
|
||||
access: "write",
|
||||
level: "manage",
|
||||
sections: null,
|
||||
canWrite: true,
|
||||
};
|
||||
|
||||
const parsed = parseAccountAccess(
|
||||
accessBlock({ accounts: [manager], active: manager }),
|
||||
);
|
||||
|
||||
expect(parsed?.active).toMatchObject({
|
||||
level: "manage",
|
||||
access: "write",
|
||||
sections: null,
|
||||
canWrite: true,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"legacy access and resolved level",
|
||||
{ access: "write", level: "write", canWrite: true },
|
||||
{},
|
||||
],
|
||||
["record kind", { recordKind: "managed" }, { recordKind: "managed" }],
|
||||
["scope", { sections: ["measurements"] }, {}],
|
||||
[
|
||||
"resolved write capability",
|
||||
{ access: "write", level: "write", canWrite: true },
|
||||
{},
|
||||
],
|
||||
["username", { username: "different-record" }, {}],
|
||||
["display name", { displayName: "Different record" }, {}],
|
||||
])(
|
||||
"fails closed when active disagrees with its canonical entry on %s",
|
||||
(_field, activeOverrides, blockOverrides) => {
|
||||
const active = { ...sharedEntry, ...activeOverrides };
|
||||
const block = accessBlock({ active, ...blockOverrides });
|
||||
|
||||
expect(parseAccountAccess(block)).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["unknown level", { level: "owner" }],
|
||||
["unknown record kind", { recordKind: "other" }],
|
||||
["unknown section", { sections: ["other"] }],
|
||||
["duplicated section", { sections: ["labs", "labs"] }],
|
||||
["non-array section", { sections: "labs" }],
|
||||
["read marked writable", { canWrite: true }],
|
||||
["write marked read-only", { access: "write", level: "write" }],
|
||||
[
|
||||
"scoped manage",
|
||||
{ access: "write", level: "manage", sections: ["labs"], canWrite: true },
|
||||
],
|
||||
[
|
||||
"manage with read legacy field",
|
||||
{ access: "read", level: "manage", sections: null, canWrite: true },
|
||||
],
|
||||
])("fails closed for %s entries", (_name, entryOverrides) => {
|
||||
const entry = { ...sharedEntry, ...entryOverrides };
|
||||
|
||||
expect(
|
||||
parseAccountAccess(accessBlock({ accounts: [entry], active: entry })),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("fails closed for a stale active entry or a contradictory switch block", () => {
|
||||
expect(
|
||||
parseAccountAccess(
|
||||
accessBlock({ active: { ...sharedEntry, accountId: "stale-record" } }),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(parseAccountAccess(accessBlock({ canSwitch: false }))).toBeNull();
|
||||
expect(
|
||||
parseAccountAccess({
|
||||
accounts: [],
|
||||
active: null,
|
||||
recordKind: "self",
|
||||
canSwitch: true,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { AccountAccess } from "./account-access-view";
|
||||
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();
|
||||
|
||||
/** The browser's resolved account-access payload, shared with OpenAPI. */
|
||||
export const accountAccessEntrySchema = z
|
||||
.object({
|
||||
accountId: z.string().min(1),
|
||||
username: z.string().min(1),
|
||||
displayName: z.string().nullable(),
|
||||
access: z.enum(["read", "write"]),
|
||||
level: accountAccessLevelSchema,
|
||||
recordKind: accountRecordKindSchema,
|
||||
sections: shareSectionsSchema,
|
||||
canWrite: z.boolean(),
|
||||
})
|
||||
.superRefine((entry, ctx) => {
|
||||
const uniqueSections = new Set(entry.sections ?? []);
|
||||
if (uniqueSections.size !== (entry.sections?.length ?? 0)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Account access sections must not repeat a domain",
|
||||
path: ["sections"],
|
||||
});
|
||||
}
|
||||
|
||||
const isManage = entry.level === "manage";
|
||||
if (isManage && (entry.access !== "write" || entry.sections !== null)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Manage access must use legacy write access and whole scope",
|
||||
});
|
||||
}
|
||||
|
||||
if (entry.canWrite !== (entry.level !== "read")) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Account access write flag disagrees with its level",
|
||||
path: ["canWrite"],
|
||||
});
|
||||
}
|
||||
|
||||
if (!isManage && entry.access !== entry.level) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Legacy access must agree with the resolved level",
|
||||
path: ["access"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const accountAccessBlockSchema = z.object({
|
||||
accounts: z.array(accountAccessEntrySchema),
|
||||
active: accountAccessEntrySchema.nullable(),
|
||||
recordKind: z.enum(["self", "shared", "managed"]),
|
||||
canSwitch: z.boolean(),
|
||||
});
|
||||
|
||||
/** Both published views of the active record must name the same resolved grant. */
|
||||
function matchesCanonicalAccountAccessEntry(
|
||||
active: z.infer<typeof accountAccessEntrySchema>,
|
||||
canonical: z.infer<typeof accountAccessEntrySchema>,
|
||||
): boolean {
|
||||
const activeSections = active.sections;
|
||||
const canonicalSections = canonical.sections;
|
||||
const sameSections =
|
||||
activeSections === null || canonicalSections === null
|
||||
? activeSections === canonicalSections
|
||||
: activeSections.length === canonicalSections.length &&
|
||||
activeSections.every(
|
||||
(section, index) => section === canonicalSections[index],
|
||||
);
|
||||
|
||||
return (
|
||||
active.accountId === canonical.accountId &&
|
||||
active.username === canonical.username &&
|
||||
active.displayName === canonical.displayName &&
|
||||
active.access === canonical.access &&
|
||||
active.level === canonical.level &&
|
||||
active.recordKind === canonical.recordKind &&
|
||||
active.canWrite === canonical.canWrite &&
|
||||
sameSections
|
||||
);
|
||||
}
|
||||
|
||||
/** Parse the server-resolved access block as a closed presentation contract. */
|
||||
export function parseAccountAccess(value: unknown): AccountAccess | null {
|
||||
const parsed = accountAccessBlockSchema.safeParse(value);
|
||||
if (!parsed.success) return null;
|
||||
|
||||
const { accounts, active, recordKind, canSwitch } = parsed.data;
|
||||
const accountIds = new Set(accounts.map((entry) => entry.accountId));
|
||||
if (
|
||||
accountIds.size !== accounts.length ||
|
||||
canSwitch !== accounts.length > 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (active === null) {
|
||||
return recordKind === "self"
|
||||
? { accounts, active: null, recordKind, canSwitch }
|
||||
: null;
|
||||
}
|
||||
|
||||
if (!accountIds.has(active.accountId) || recordKind !== active.recordKind) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const canonicalActive = accounts.find(
|
||||
(entry) => entry.accountId === active.accountId,
|
||||
);
|
||||
if (
|
||||
!canonicalActive ||
|
||||
!matchesCanonicalAccountAccessEntry(active, canonicalActive)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { accounts, active: canonicalActive, recordKind, canSwitch };
|
||||
}
|
||||
@@ -28,6 +28,7 @@ export type LegacyAccountAccessLevel = "read" | "write";
|
||||
|
||||
/** The server-resolved kind of record a presentation is naming. */
|
||||
export type AccountRecordKind = "self" | "shared" | "managed";
|
||||
export type SharedAccountRecordKind = Exclude<AccountRecordKind, "self">;
|
||||
|
||||
/**
|
||||
* Read the portion of an account-access entry that an older whole-record
|
||||
@@ -68,7 +69,7 @@ export interface AccountAccessEntry {
|
||||
* What kind of record this is. This is presentation metadata only; it is
|
||||
* resolved by the server and never accepted as an authorization input.
|
||||
*/
|
||||
recordKind: AccountRecordKind;
|
||||
recordKind: SharedAccountRecordKind;
|
||||
/**
|
||||
* v1.37.0 — which sections of the record this grant opens, or `null` for
|
||||
* the entire record.
|
||||
|
||||
Vendored
-42
@@ -1,42 +0,0 @@
|
||||
export const RELEASE_JOURNEYS = [
|
||||
{
|
||||
name: "adult-full-read-scoped-read-write-and-manage",
|
||||
contract: "inventory-only",
|
||||
},
|
||||
{
|
||||
name: "manage-mutation-activity-and-fenced-settings",
|
||||
contract: "inventory-only",
|
||||
},
|
||||
{
|
||||
name: "managed-profile-two-guardian-lifecycle",
|
||||
contract: "inventory-only",
|
||||
},
|
||||
{
|
||||
name: "two-guardian-two-record-notification-delivery",
|
||||
contract: "inventory-only",
|
||||
},
|
||||
{
|
||||
name: "revoked-manage-idempotency-replay",
|
||||
contract: "inventory-only",
|
||||
},
|
||||
{
|
||||
name: "delegate-provider-no-egress-owner-positive",
|
||||
contract: "inventory-only",
|
||||
},
|
||||
{
|
||||
name: "durable-import-actor-attribution",
|
||||
contract: "inventory-only",
|
||||
},
|
||||
{
|
||||
name: "legacy-client-compatibility",
|
||||
contract: "inventory-only",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const ACCESSIBILITY_STATES = [
|
||||
{ name: "success", contract: "inventory-only" },
|
||||
{ name: "empty", contract: "inventory-only" },
|
||||
{ name: "loading", contract: "inventory-only" },
|
||||
{ name: "error", contract: "inventory-only" },
|
||||
{ name: "refusal", contract: "inventory-only" },
|
||||
] as const;
|
||||
Reference in new issue
Block a user